diff --git a/ERD.svg b/ERD.svg new file mode 100644 index 000000000..819cd2e62 --- /dev/null +++ b/ERD.svg @@ -0,0 +1 @@ +ExposureINDOORINDOOROUTDOOROUTDOORMOBILEMOBILEUNKNOWNUNKNOWNStatusACTIVEACTIVEINACTIVEINACTIVEOLDOLDModelHOME_V2_LORAHOME_V2_LORAPriorityURGENTURGENTHIGHHIGHMEDIUMMEDIUMLOWLOWFileStringid🗝️BytesblobDateTimecreatedAtDateTimeupdatedAtImageStringcontentTypeStringaltTextDateTimecreatedAtDateTimeupdatedAtUserStringid🗝️StringnameStringemailStringroleStringlanguageStringboxesBooleanemailIsConfirmedDateTimecreatedAtDateTimeupdatedAtProfileStringid🗝️StringusernameBooleanpublicPasswordStringhashNoteStringid🗝️StringtitleStringbodyDateTimecreatedAtDateTimeupdatedAtDeviceStringid🗝️StringnameStringdescriptionExposureexposureBooleanuseAuthStringmodelBooleanpublicStatusstatusDateTimecreatedAtDateTimeupdatedAtFloatlatitudeFloatlongitudeSensorStringid🗝️StringtitleStringunitStringsensorTypeJsonlastMeasurementJsondataDateTimecreatedAtDateTimeupdatedAtMeasurementStringsensorIdDateTimetimeFloatvalueCampaignStringid🗝️StringtitleStringslugJsonfeatureStringinstructionsStringdescriptionPrioritypriorityStringcountryIntminimumParticipantsDateTimecreatedAtDateTimeupdatedAtDateTimestartDateDateTimeendDateStringphenomenaJsoncenterpointExposureexposureBooleanhardwareAvailableCommentStringid🗝️StringcontentDateTimecreatedAtDateTimeupdatedAtCampaignEventStringid🗝️StringtitleStringdescriptionDateTimecreatedAtDateTimeupdatedAtDateTimestartDateDateTimeendDateimagefileprofilepasswordnotesdevicesownedCampaignsparticipatingCampaignsbookmarkedCampaignscommentseventsprofileimageuseruseruserenum:exposurecampaignenum:statussensorsuserdeviceownerenum:priorityparticipantseventsenum:exposurecommentsdevicesbookmarkedByUserscampaignownercampaignowner \ No newline at end of file diff --git a/app/components/Map/Map.tsx b/app/components/Map/Map.tsx new file mode 100644 index 000000000..3ee3915dc --- /dev/null +++ b/app/components/Map/Map.tsx @@ -0,0 +1,43 @@ +import { forwardRef } from "react"; +import { type MapProps, type MapRef, NavigationControl, Map as ReactMap } from "react-map-gl"; + +const Map = forwardRef( + ( + // take fog and terrain out of props to resolve error + { children, mapStyle, fog = null, terrain = null, ...props }, + ref + ) => { + return ( + + {children} + + + ); + } +); + +Map.displayName = "Map"; + +export default Map; diff --git a/app/components/Map/draw-control.tsx b/app/components/Map/draw-control.tsx new file mode 100644 index 000000000..32abd81e7 --- /dev/null +++ b/app/components/Map/draw-control.tsx @@ -0,0 +1,124 @@ +import MapboxDraw, { modes } from "@mapbox/mapbox-gl-draw"; +import { useControl, type MapRef, type ControlPosition } from "react-map-gl"; +import "@mapbox/mapbox-gl-draw/dist/mapbox-gl-draw.css"; +import { CircleMode, DragCircleMode } from "maplibre-gl-draw-circle"; +import DrawRectangle from "mapbox-gl-draw-rectangle-mode"; + +type DrawControlProps = ConstructorParameters[0] & { + position?: ControlPosition; + + onCreate?: (evt: { features: object[] }) => void; + onUpdate?: (evt: { features: object[]; action: string }) => void; + onDelete?: (evt: { features: object[] }) => void; +}; + +let draw: MapboxDraw | null = null; + +class MyCustomControl { + containerCir: HTMLButtonElement | undefined; + containerRec: HTMLButtonElement | undefined; + map: any; + containerImgCir: HTMLImageElement | undefined; + containerImgRec: HTMLImageElement | undefined; + mainContainer: HTMLDivElement | undefined; + container: any; + + onAdd(map: any) { + if (this.mainContainer) { + // If the buttons have already been created, return the existing container + return this.mainContainer; + } + this.containerCir = document.createElement("button"); + this.containerRec = document.createElement("button"); + + this.map = map; + + this.containerCir.onclick = () => { + const zoom = this.map.getZoom(); + draw?.changeMode("drag_circle", { + initialRadiusInKm: 1 / Math.pow(2, zoom - 11), + }); + draw?.delete("-96.5801808656236544.76489866786821"); + }; + this.containerCir.className = + "mapbox-gl-draw_ctrl-draw-btn my-custom-control-cir"; + + this.containerCir.className = + "h-[29px] w-[29px] flex items-center justify-center"; + // this.containerCir.innerHTML = "◯"; + this.containerImgCir = document.createElement("img"); + this.containerImgCir.src = + " https://cdn-icons-png.flaticon.com/16/808/808569.png"; + this.containerImgCir.className = "mx-auto"; + + this.containerCir.appendChild(this.containerImgCir); + + this.containerRec.onclick = () => { + draw?.changeMode("draw_rectangle"); + }; + this.containerRec.className = + "mapbox-gl-draw_ctrl-draw-btn my-custom-control-rec"; + // this.containerRec.innerHTML = "▭"; + this.containerRec.className = + "h-[29px] w-[29px] flex items-center justify-center"; + + this.containerImgRec = document.createElement("img"); + this.containerImgRec.src = + " https://cdn-icons-png.flaticon.com/16/7367/7367908.png"; + this.containerImgRec.className = "mx-auto"; + this.containerRec.appendChild(this.containerImgRec); + + this.mainContainer = document.createElement("div"); + + this.mainContainer.className = "mapboxgl-ctrl-group mapboxgl-ctrl"; + this.mainContainer.appendChild(this.containerCir); + this.mainContainer.appendChild(this.containerRec); + + return this.mainContainer; + } + onRemove() { + // this.container.parentNode.removeChild(this.container); + this.map = undefined; + } +} +export default function DrawControl(props: DrawControlProps) { + const myCustomControl = new MyCustomControl(); + useControl( + () => { + draw = new MapboxDraw({ + ...props, + modes: { + ...modes, + draw_circle: CircleMode, + drag_circle: DragCircleMode, + draw_rectangle: DrawRectangle, + }, + // defaultMode: "drag_circle", + }); + return draw; + }, + ({ map }: { map: MapRef }) => { + props.onCreate && map.on("draw.create", props.onCreate); + props.onUpdate && map.on("draw.update", props.onUpdate); + props.onDelete && map.on("draw.delete", props.onDelete); + + map.addControl(myCustomControl, "top-left"); + }, + ({ map }: { map: MapRef }) => { + props.onCreate && map.off("draw.create", props.onCreate); + props.onUpdate && map.off("draw.update", props.onUpdate); + props.onDelete && map.off("draw.delete", props.onDelete); + }, + { + position: props.position, + } + ); + + return null; +} + +DrawControl.defaultProps = { + onCreate: () => {}, + onUpdate: () => {}, + onDelete: () => {}, +}; diff --git a/app/components/Map/geocoder-control.tsx b/app/components/Map/geocoder-control.tsx new file mode 100644 index 000000000..095d937fe --- /dev/null +++ b/app/components/Map/geocoder-control.tsx @@ -0,0 +1,89 @@ +import maplibregl from "maplibre-gl"; +import { type ControlPosition, useControl } from "react-map-gl"; + +// import "@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css"; +// resolve import error +const MaplibreGeocoder = require("@maplibre/maplibre-gl-geocoder"); + +const geocoder_api = { + forwardGeocode: async (config: any) => { + const features = []; + try { + console.log("Starting request"); + const request = `https://nominatim.openstreetmap.org/search?q=${config.query}&format=geojson&polygon_geojson=1&addressdetails=1`; + const response = await fetch(request); + const geojson = await response.json(); + for (const feature of geojson.features) { + const center = [ + feature.bbox[0] + (feature.bbox[2] - feature.bbox[0]) / 2, + feature.bbox[1] + (feature.bbox[3] - feature.bbox[1]) / 2, + ]; + const point = { + type: "Feature", + geometry: { + type: "Point", + coordinates: center, + }, + place_name: feature.properties.display_name, + properties: feature.properties, + text: feature.properties.display_name, + place_type: ["place"], + center: center, + }; + features.push(point); + } + } catch (e) { + console.error(`Failed to forwardGeocode with error: ${e}`); + } + + return { + features: features, + }; + }, + reverseGeocode: async (config: any) => { + const { latitude, longitude } = config; + + try { + const request = `https://nominatim.openstreetmap.org/reverse?lat=${latitude}&lon=${longitude}&format=jsonv2`; + const response = await fetch(request); + const result = await response.json(); + + const country_code = result.address.country_code; + const country = result.address.country; + + return { country_code, country }; + } catch (e) { + console.error(`Failed to reverseGeocode with error: ${e}`); + return null; + } + }, +}; + +type GeocoderControlProps = { + position?: ControlPosition; + language?: string; + onResult?: (e: any) => void; +}; + +export default function GeocoderControl(props: GeocoderControlProps) { + useControl( + () => { + const control = new MaplibreGeocoder(geocoder_api, { + mapboxgl: maplibregl, + showResultsWhileTyping: true, + }); + + control.on("result", props.onResult); + return control; + }, + { + position: props.position ?? "top-right", + } + ); + + return null; +} + +export const reverseGeocode = async (latitude: number, longitude: number) => { + return await geocoder_api.reverseGeocode({ latitude, longitude }); +}; diff --git a/app/components/Map/radius-mode.tsx b/app/components/Map/radius-mode.tsx new file mode 100644 index 000000000..452402c42 --- /dev/null +++ b/app/components/Map/radius-mode.tsx @@ -0,0 +1,258 @@ +//@ts-nocheck +// custom mapbopx-gl-draw mode that modifies draw_line_string +// shows a center point, radius line, and circle polygon while drawing +// forces draw.create on creation of second vertex + +import MapboxDraw from "@mapbox/mapbox-gl-draw"; +import lineDistance from "@turf/line-distance"; +import numeral from "numeral"; + +const RadiusMode = MapboxDraw.modes.draw_line_string; + +function createVertex( + parentId: any, + coordinates: any, + path: any, + selected: any +) { + return { + type: "Feature", + properties: { + meta: "vertex", + parent: parentId, + coord_path: path, + active: selected ? "true" : "false", + }, + geometry: { + type: "Point", + coordinates, + }, + }; +} + +// create a circle-like polygon given a center point and radius +// https://stackoverflow.com/questions/37599561/drawing-a-circle-with-the-radius-in-miles-meters-with-mapbox-gl-js/39006388#39006388 +function createGeoJSONCircle( + center: any, + radiusInKm: any, + parentId: any, + points = 64 +) { + const coords = { + latitude: center[1], + longitude: center[0], + }; + + const km = radiusInKm; + + const ret = []; + const distanceX = km / (111.32 * Math.cos((coords.latitude * Math.PI) / 180)); + const distanceY = km / 110.574; + + let theta; + let x; + let y; + for (let i = 0; i < points; i += 1) { + theta = (i / points) * (2 * Math.PI); + x = distanceX * Math.cos(theta); + y = distanceY * Math.sin(theta); + + ret.push([coords.longitude + x, coords.latitude + y]); + } + ret.push(ret[0]); + + return { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [ret], + }, + properties: { + parent: parentId, + active: "true", + }, + }; +} + +function getDisplayMeasurements(feature: any) { + // should log both metric and standard display strings for the current drawn feature + + // metric calculation + const drawnLength = lineDistance(feature) * 1000; // meters + + let metricUnits = "m"; + let metricFormat = "0,0"; + let metricMeasurement; + + let standardUnits = "feet"; + let standardFormat = "0,0"; + let standardMeasurement; + + metricMeasurement = drawnLength; + if (drawnLength >= 1000) { + // if over 1000 meters, upgrade metric + metricMeasurement = drawnLength / 1000; + metricUnits = "km"; + metricFormat = "0.00"; + } + + standardMeasurement = drawnLength * 3.28084; + if (standardMeasurement >= 5280) { + // if over 5280 feet, upgrade standard + standardMeasurement /= 5280; + standardUnits = "mi"; + standardFormat = "0.00"; + } + + const displayMeasurements = { + metric: `${numeral(metricMeasurement).format(metricFormat)} ${metricUnits}`, + standard: `${numeral(standardMeasurement).format( + standardFormat + )} ${standardUnits}`, + }; + + return displayMeasurements; +} + +// const doubleClickZoom = { +// enable: (ctx) => { +// setTimeout(() => { +// // First check we've got a map and some context. +// if ( +// !ctx.map || +// !ctx.map.doubleClickZoom || +// !ctx._ctx || +// !ctx._ctx.store || +// !ctx._ctx.store.getInitialConfigValue +// ) +// return; +// // Now check initial state wasn't false (we leave it disabled if so) +// if (!ctx._ctx.store.getInitialConfigValue("doubleClickZoom")) return; +// ctx.map.doubleClickZoom.enable(); +// }, 0); +// }, +// }; + +RadiusMode.clickAnywhere = function (state: any, e: any) { + // this ends the drawing after the user creates a second point, triggering this.onStop + if (state.currentVertexPosition === 1) { + state.line.addCoordinate(0, e.lngLat.lng, e.lngLat.lat); + return this.changeMode("simple_select", { featureIds: [state.line.id] }); + } + this.updateUIClasses({ mouse: "add" }); + state.line.updateCoordinate( + state.currentVertexPosition, + e.lngLat.lng, + e.lngLat.lat + ); + if (state.direction === "forward") { + state.currentVertexPosition += 1; + state.line.updateCoordinate( + state.currentVertexPosition, + e.lngLat.lng, + e.lngLat.lat + ); + } else { + state.line.addCoordinate(0, e.lngLat.lng, e.lngLat.lat); + } + + return null; +}; + +// creates the final geojson point feature with a radius property +// triggers draw.create +RadiusMode.onStop = function (state) { + // doubleClickZoom.enable(this); + + this.activateUIButton(); + + // check to see if we've deleted this feature + if (this.getFeature(state.line.id) === undefined) return; + + // remove last added coordinate + state.line.removeCoordinate("0"); + if (state.line.isValid()) { + const lineGeoJson = state.line.toGeoJSON(); + // reconfigure the geojson line into a geojson point with a radius property + const pointWithRadius = { + type: "Feature", + geometry: { + type: "Point", + coordinates: lineGeoJson.geometry.coordinates[0], + }, + properties: { + radius: (lineDistance(lineGeoJson) * 1000).toFixed(1), + }, + }; + + this.map.fire("draw.create", { + features: [pointWithRadius], + }); + } else { + this.deleteFeature([state.line.id], { silent: true }); + this.changeMode("simple_select", {}, { silent: true }); + } +}; + +RadiusMode.toDisplayFeatures = function (state, geojson, display) { + const isActiveLine = geojson.properties.id === state.line.id; + geojson.properties.active = isActiveLine ? "true" : "false"; + if (!isActiveLine) return display(geojson); + + // Only render the line if it has at least one real coordinate + if (geojson.geometry.coordinates.length < 2) return null; + geojson.properties.meta = "feature"; + + // displays center vertex as a point feature + display( + createVertex( + state.line.id, + geojson.geometry.coordinates[ + state.direction === "forward" + ? geojson.geometry.coordinates.length - 2 + : 1 + ], + `${ + state.direction === "forward" + ? geojson.geometry.coordinates.length - 2 + : 1 + }`, + false + ) + ); + + // displays the line as it is drawn + display(geojson); + + const displayMeasurements = getDisplayMeasurements(geojson); + + // create custom feature for the current pointer position + const currentVertex = { + active: "true", + type: "Feature", + properties: { + meta: "currentPosition", + radiusMetric: displayMeasurements.metric, + radiusStandard: displayMeasurements.standard, + parent: state.line.id, + }, + geometry: { + type: "Point", + coordinates: geojson.geometry.coordinates[1], + }, + }; + display(currentVertex); + + // create custom feature for radius circlemarker + const center = geojson.geometry.coordinates[0]; + const radiusInKm = lineDistance(geojson, "kilometers"); + console.log(radiusInKm); + const circleFeature = createGeoJSONCircle(center, radiusInKm, state.line.id); + circleFeature.properties.meta = "radius"; + + display(circleFeature); + + return null; +}; + +export default RadiusMode; diff --git a/app/components/campaigns/area/map.tsx b/app/components/campaigns/area/map.tsx new file mode 100644 index 000000000..e110a1c9d --- /dev/null +++ b/app/components/campaigns/area/map.tsx @@ -0,0 +1,215 @@ +import normalize from "@mapbox/geojson-normalize"; +import { type FeatureCollection, type GeoJsonProperties, type Geometry } from "geojson"; +import flatten from "geojson-flatten"; +import { TrashIcon } from "lucide-react"; +import { type Dispatch, type SetStateAction, useCallback, useState } from "react"; + +import { useTranslation } from "react-i18next"; +import { type MapLayerMouseEvent, type PopupProps, 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"; + +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 ( + + (mapRef.current = ref && ref.getMap())} + initialViewState={{ latitude: 7, longitude: 52, zoom: 2 }} + style={{ + width: "100%", + height: "100%", + position: "relative", + top: 0, + right: 0, + }} + // onLoad={onLoad} + onClick={handleMapClick} + > + console.log(e)} + position="top-left" + /> + + + + + {t("use these symbols to draw different geometries on the map")} + + + + {geojsonUploadData && ( + + + + )} + {popup && ( + setPopup(false)} /> + )} + + + ); +} 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 000000000..b70165c40 --- /dev/null +++ b/app/components/campaigns/campaignId/comment-tab/comment-cards.tsx @@ -0,0 +1,144 @@ +// import { +// Card, +// CardContent, +// CardDescription, +// CardFooter, +// CardHeader, +// CardTitle, +// } from "@/components/ui/card"; +// import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +// import { Form } from "react-router"; +// import { Button } from "~/components/ui/button"; +// import { TrashIcon, EditIcon } from "lucide-react"; +// 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 000000000..e361d2e5f --- /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 000000000..2bc53517a --- /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 000000000..ad6a2092f --- /dev/null +++ b/app/components/campaigns/campaignId/event-tab/create-form.tsx @@ -0,0 +1,144 @@ +import { useEffect, useState } from "react"; +import { Form } from "react-router"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { MarkdownEditor } from "~/markdown.client"; + + +function ClientOnly({ children }: { children: () => React.ReactNode }) { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return mounted ? <>{children()} : null; +} + +type EventFormProps = { + eventDescription: string; + setEventDescription: any; + eventTextAreaRef: any; +}; + +export default function EventForm({ + eventDescription, + setEventDescription, + eventTextAreaRef, +}: EventFormProps) { + return ( +
+ + Noch keine Events für diese Kampagne.{" "} + {" "} + + +

Erstelle hier ein Event

+
+ + + Erstelle ein Event + + Erstelle ein Event für diese Kampagne + + +
+
+
+ +
+ +
+
+
+ +
+ + + {() => ( + <> + +
+ + Bild hinzufügen + + + Markdown unterstützt + +
+ + )} +
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+
+ ); +} 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 000000000..6ce434127 --- /dev/null +++ b/app/components/campaigns/campaignId/event-tab/event-cards.tsx @@ -0,0 +1,208 @@ +import { EditIcon, TrashIcon } from "lucide-react"; +import Markdown from "markdown-to-jsx"; +import { useEffect, useState } from "react"; +import { Form } from "react-router"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "~/components/ui/button"; +import { MarkdownEditor } from "~/markdown.client"; + +function ClientOnly({ children }: { children: () => React.ReactNode }) { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return mounted ? <>{children()} : null; +} + +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) => ( +
+ + + +
+ {eventEditMode ? ( + setEditEventTitle(e.target.value)} + placeholder="Enter new title" + /> + ) : ( +

{e.title}

+ )} +
+ {userId === e.ownerId && ( +
+ + + + + + + + + Sind Sie sicher dass Sie dieses Event löschen + möchten? + + +
+ + +
+
+
+
+ )} +
+
+ + Beschreibung: + {eventEditMode ? ( + + {() => ( + <> + +
+ + Bild hinzufügen + + + Markdown unterstützt + +
+ + )} +
+ ) : ( + {e.description} + )} + Beginn: + {eventEditMode ? ( + setEditEventStartDate} + /> + ) : ( +

{e.startDate}

+ )} + Abschluss: +

{e.endDate}

+
+ {userId === e.ownerId && eventEditMode && ( + +
+ + + + + + + +
+
+ )} +
+
+ ))} +
+ ); +} 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 000000000..4ae7ef23d --- /dev/null +++ b/app/components/campaigns/campaignId/overview-tab/edit-table.tsx @@ -0,0 +1,312 @@ +import { + ChevronDown, + SaveIcon, + XIcon, +} from "lucide-react"; +// import { priorityEnum, exposureEnum } from "~/schema"; +import { useState, useRef, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Form } from "react-router"; +import { CountryDropdown } from "../../overview/country-dropdown"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectGroup, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Button } from "~/components/ui/button"; +import { MarkdownEditor } from "~/markdown.client"; + +function ClientOnly({ children }: { children: () => React.ReactNode }) { + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + return mounted ? <>{children()} : null; +} + +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 ( +
+ +
+ + +
+ + + + {t("attribute")} + {t("value")} + + + + + {t("title")} + + setTitle(e.target.value)} + /> + + + + {t("description")} + + + + {() => ( + <> + +
+ + {t("add image")} + + + {t("markdown supported")} + +
+ + )} +
+
+
+ + {t("priority")} + + + + + + + {t("start date")} + + + + + + {t("end date")} + + + + + + {t("location")} + + + + + + + + {t("phenomena")} + + + + + + + + {phenomena.map((p: any) => { + return ( + { + setPhenomenaState({ + ...phenomenaState, + [p]: !phenomenaState[p], + }); + }} + onSelect={(event) => event.preventDefault()} + > + {p} + + ); + })} + + + + + + {t("exposure")} + + + + + + + {t("hardware available")} + +
+ {t("no")} + + {t("yes")} +
+
+
+
+
+
+ ); +} 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 000000000..85f6ee3d3 --- /dev/null +++ b/app/components/campaigns/campaignId/overview-tab/overview-table.tsx @@ -0,0 +1,149 @@ +import { HoverCardContent, HoverCardTrigger } from "@radix-ui/react-hover-card"; +import { EditIcon } from "lucide-react"; +import Markdown from "markdown-to-jsx"; +import { useState, useRef } from "react"; +import EditTable from "./edit-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; +import { Button } from "~/components/ui/button"; +import { CountryFlagIcon } from "~/components/ui/country-flag"; +import { HoverCard } from "~/components/ui/hover-card"; +import { type Campaign } from "~/schema"; + +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 + + + +
+

Jona159

+
+
+
+
+
+
+

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 000000000..d17ea52bd --- /dev/null +++ b/app/components/campaigns/campaignId/posts/create.tsx @@ -0,0 +1,31 @@ +import { Form } from "react-router"; +import { Button } from "~/components/ui/button"; + +type Props = { + loggedIn: boolean; +}; + +export default function CreateThread({ loggedIn }: Props) { + if (loggedIn) { + return ( +
+ Create new Thread +
+ + + +
+
+ ); + } 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 000000000..39746224c --- /dev/null +++ b/app/components/campaigns/campaignId/posts/index.tsx @@ -0,0 +1,92 @@ +// import { Form, useActionData } from "react-router"; +// 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 ( +//
    +// {posts.map((p) => { +// return ( +// <> +//
  • +// {p.title} +//
    +// +// +//
    +//
  • +// {comments && ( +// {comments.map(c => { +// {c.id} +// })} +// )} +// {/* {p.comment.length > 0 &&
    {p.comment.length} Replies
    } */} +// {showReplyFields[p.id] && ( +//
    +// +// +// +//
    +// )} +// +// ); +// })} +//
+// ); +// } diff --git a/app/components/campaigns/campaignId/table/buttons.tsx b/app/components/campaigns/campaignId/table/buttons.tsx new file mode 100644 index 000000000..0108cab40 --- /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 000000000..2a901b3bb --- /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 000000000..1fd0b9e0e --- /dev/null +++ b/app/components/campaigns/campaignId/table/edit-components/phenomena.tsx @@ -0,0 +1,68 @@ +import { t } from "i18next"; +import { ChevronDown } from "lucide-react"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +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 000000000..ed1fbc9eb --- /dev/null +++ b/app/components/campaigns/campaignId/table/index.tsx @@ -0,0 +1,278 @@ +import Markdown from "markdown-to-jsx"; +// import { priorityEnum, exposureEnum } from "~/schema"; +import { useState, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Form } from "react-router"; +import { CountryDropdown } from "../../overview/country-dropdown"; +import { EditButton, CancelButton, SaveButton } from "./buttons"; +import { EditDescription } from "./edit-components/description"; +import PhenomenaDropdown from "./edit-components/phenomena"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +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 ( +
+ + + + + {t("attribute")} + {t("value")} + {owner && !editMode ? ( + + ) : owner && editMode ? ( + <> + + + + ) : null} + + + + + {t("title")} + + {editMode ? ( + setTitle(e.target.value)} + /> + ) : ( + {campaign.title} + )} + + + + {t("description")} + + {editMode ? ( + + ) : ( + {campaign.description} + )} + + + + {t("priority")} + + + {editMode ? ( + + ) : ( + {campaign.priority} + )} + + + + {t("start date")} + + {editMode ? ( + + ) : ( + <>{campaign.startDate} + )} + + + + {t("end date")} + + {editMode ? ( + + ) : ( + <>{campaign.endDate} + )} + + + + {t("location")} + + + {editMode ? ( + + ) : ( + <>{campaign.countries} + )} + + + + + {t("phenomena")} + + + {editMode ? ( + + ) : ( + <>{campaign.phenomena} + )} + + + + {t("exposure")} + + + {editMode ? ( + + ) : ( + <>{campaign.exposure} + )} + + + + {t("hardware available")} + + {editMode ? ( +
+ {t("no")} + + {t("yes")} +
+ ) : ( + <>{campaign.hardwareAvailable} + )} +
+
+
+
+
+ ); +} 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 000000000..6aa6c4966 --- /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 000000000..57d944084 --- /dev/null +++ b/app/components/campaigns/overview/campaign-badges.tsx @@ -0,0 +1,51 @@ +// import type { Exposure, Priority } from "@prisma/client"; +import clsx from "clsx"; +import { ClockIcon } from "lucide-react"; +import { Badge } from "~/components/ui/badge"; +import { type priorityEnum, type exposureEnum } from "~/schema"; + +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 000000000..908813551 --- /dev/null +++ b/app/components/campaigns/overview/campaign-filter.tsx @@ -0,0 +1,288 @@ +import clsx from "clsx"; +import { + AlertCircleIcon, + ArrowDownAZIcon, + ChevronDown, + ChevronUp, + FilterXIcon, +} from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Form, useSearchParams } from "react-router"; +import FiltersModal from "./filters-modal"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "~/components/ui/button"; +import { Label } from "~/components/ui/label"; +import { Switch } from "~/components/ui/switch"; +// import { Priority } from "@prisma/client"; +// import { priorityEnum } from "~/schema"; + +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 && ( +
+
+
+ + {/* THIS IS FOR CLIENT SIDE FILTERING ONLY // + +value={filterObject.searchTerm} + onChange={(event) => +// setFilterObject({ +// ...filterObject, +// searchTerm: event.target.value, +// }) +// } */} + +
+
+ +
+ +
+ +
+ + + + + + + + setFilterObject({ ...filterObject, priority: e }) + } + > + {/* {Object.keys(priorityEnum).map( + (priority: string, index: number) => { + return ( + + {priority} + + ); + } + )} */} + + + + + + + + + + + + {t("priority")} + + + {t("created At")} + + + {t("updated At")} + + + + + + + + + + {showMobileFilters ? ( + yo + ) : ( + + )} + +
+
+ )} + + ); +} diff --git a/app/components/campaigns/overview/country-dropdown.tsx b/app/components/campaigns/overview/country-dropdown.tsx new file mode 100644 index 000000000..4e0440e44 --- /dev/null +++ b/app/components/campaigns/overview/country-dropdown.tsx @@ -0,0 +1,88 @@ +import { Check, ChevronsUpDown } from "lucide-react"; +import { useState } from "react"; +import { countryListAlpha2 } from "./all-countries-object"; + +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 { CountryFlagIcon } from "~/components/ui/country-flag"; +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 000000000..a0341fd6b --- /dev/null +++ b/app/components/campaigns/overview/filters-bar.tsx @@ -0,0 +1,169 @@ +import clsx from "clsx"; +import { + AlertCircleIcon, + ArrowDownAZIcon, + ChevronDown, + FilterXIcon, +} from "lucide-react"; +import { type Dispatch, type SetStateAction } from "react"; +import { useTranslation } from "react-i18next"; +import FiltersModal from "./filters-modal"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "~/components/ui/button"; +import { Switch } from "~/components/ui/switch"; +// import { Priority } from "@prisma/client"; +import { priorityEnum, type zodPriorityEnum } from "~/schema"; + +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 000000000..ac1942233 --- /dev/null +++ b/app/components/campaigns/overview/filters-modal.tsx @@ -0,0 +1,292 @@ +import { ChevronDown, FilterIcon } from "lucide-react"; +import { type Dispatch, type SetStateAction, useState } from "react"; +import { useTranslation } from "react-i18next"; +import PhenomenaSelect from "../phenomena-select"; +import { CountryDropdown } from "./country-dropdown"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "~/components/ui/button"; +import { + Popover, + PopoverAnchor, + PopoverArrow, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; +import { ScrollArea } from "~/components/ui/scroll-area"; + +import { exposureEnum } from "~/schema"; +// import { Exposure } from "@prisma/client"; + +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 ( + + + + + {/* */} + + + {t("more filters")} + + + setLocalFilterObject({ ...localFilterObject, country: e }) + } + /> + + + {/* + + + + + + {phenomena.map((p: any) => { + return ( + { + setPhenomenaState({ + ...phenomenaState, + [p]: !phenomenaState[p], + }); + }} + onSelect={(event) => event.preventDefault()} + > + {p} + + ); + })} + + + + + + + + +

TODO: Organizations here

+
+
*/} + + + + + +
+
+ + + setLocalFilterObject({ + ...localFilterObject, + time_range: { + ...localFilterObject.time_range, + startDate: e.target.value, + }, + }) + } + /> +
+
+ + + setLocalFilterObject({ + ...localFilterObject, + time_range: { + ...localFilterObject.time_range, + endDate: e.target.value, + }, + }) + } + /> +
+ +
+ +
+
+ + + + + +
+ {/*
*/} +
+ ); +} diff --git a/app/components/campaigns/overview/grid.tsx b/app/components/campaigns/overview/grid.tsx new file mode 100644 index 000000000..7119c98e7 --- /dev/null +++ b/app/components/campaigns/overview/grid.tsx @@ -0,0 +1,183 @@ +import { PlusIcon, StarIcon } from "lucide-react"; +import Markdown from "markdown-to-jsx"; +import { useTranslation } from "react-i18next"; +import { Link, Form } from "react-router"; +import Pagination from "./pagination"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { CountryFlagIcon } from "~/components/ui/country-flag"; +import { Progress } from "~/components/ui/progress"; +import { type Campaign } from "~/schema"; + +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 ( +
+
{flagIcon}
+
+ ); + } + )} +
+
+
+
+
+ + + + {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 000000000..76438f6e6 --- /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 000000000..a989ef8e9 --- /dev/null +++ b/app/components/campaigns/overview/map/index.tsx @@ -0,0 +1,489 @@ +// import { Campaign, Exposure, Priority, Prisma } from "@prisma/client"; +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 type { BBox } from "geojson"; +import { + type ChangeEvent, + Dispatch, + SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { type DateRange } from "react-day-picker"; +import { + Layer, + LngLatBounds, + LngLatLike, + type MapLayerMouseEvent, + MapProvider, + type MapRef, + MapboxEvent, + Marker, + Source, +} from "react-map-gl"; +import { Link } from "react-router"; +import { CountryDropdown } from "../country-dropdown"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import PointLayer from "~/components/campaigns/overview/map/point-layer"; +import { Map } from "~/components/map"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { type DataItem } from "~/components/ui/multi-select"; +import { type Campaign} from "~/schema"; + +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} + + ); + })} +
+
+ + setZoom(Math.floor(e.viewState.zoom))} + ref={mapRef} + style={{ + height: "100vh", + width: "75%", + left: "25%", + // position: "absolute", + // top: 0, + bottom: 0, + // margin: "auto", + }} + > + + + + + ); +} 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 000000000..1d9c2b7f3 --- /dev/null +++ b/app/components/campaigns/overview/map/point-layer.tsx @@ -0,0 +1,313 @@ +// import { +// type BBox, +// type Feature, +// type GeoJsonProperties, +// } from "geojson"; +// import debounce from "lodash.debounce"; +// import { useMemo, useCallback, useState, useEffect } from "react"; + +// import { Marker, Popup, useMap } from "react-map-gl"; +// import { type PointFeature } from "supercluster"; +// import useSupercluster from "use-supercluster"; +// import { +// Table, +// TableBody, +// TableCaption, +// TableCell, +// TableRow, +// } from "@/components/ui/table"; +// import { type DeviceClusterProperties } from "~/routes/explore"; +// import { type Campaign } from "~/schema"; + +// 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 000000000..e69de29bb diff --git a/app/components/campaigns/overview/pagination.tsx b/app/components/campaigns/overview/pagination.tsx new file mode 100644 index 000000000..f0a37942c --- /dev/null +++ b/app/components/campaigns/overview/pagination.tsx @@ -0,0 +1,66 @@ +// code from https://github.com/AustinGil/npm/blob/main/app/components/Pagination.jsx + +import { Link, useSearchParams } from "react-router"; +import { listPageOptions } from "./list-page-options"; +import { Button } from "~/components/ui/button"; + +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 000000000..1c7e9ab12 --- /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 000000000..339c3f60b --- /dev/null +++ b/app/components/campaigns/phenomena-select.tsx @@ -0,0 +1,49 @@ +import { type Dispatch, type SetStateAction } from "react"; +import { type DataItem, 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 000000000..0f03e4586 --- /dev/null +++ b/app/components/campaigns/select-countries.tsx @@ -0,0 +1,27 @@ +import { MultiSelect, type DataItem } from "../ui/multi-select"; +import { countryListAlpha2 } from "./overview/all-countries-object"; + +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 000000000..e69de29bb diff --git a/app/components/campaigns/tutorial/create/steps.tsx b/app/components/campaigns/tutorial/create/steps.tsx new file mode 100644 index 000000000..71fe74c64 --- /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/header/index.tsx b/app/components/header/index.tsx index d3ad676bc..1ffdd4105 100644 --- a/app/components/header/index.tsx +++ b/app/components/header/index.tsx @@ -3,7 +3,6 @@ import Download from './download' import Home from './home' import Menu from './menu' import NavBar from './nav-bar' -// import { useLoaderData } from "@remix-run/react"; // import Notification from "./notification"; // import type { loader } from "~/routes/explore.$deviceId._index"; diff --git a/app/components/header/nav-bar/time-filter/index.tsx b/app/components/header/nav-bar/time-filter/index.tsx index 15a5918b0..2cf70289e 100644 --- a/app/components/header/nav-bar/time-filter/index.tsx +++ b/app/components/header/nav-bar/time-filter/index.tsx @@ -6,7 +6,6 @@ import { de, enGB } from 'date-fns/locale' import { getUserLocale } from 'get-user-locale' import { Clock, CalendarSearch, CalendarClock } from 'lucide-react' import * as React from 'react' -// import { useSearchParams, useSubmit } from "@remix-run/react"; import { type DateRange } from 'react-day-picker' import { useTranslation } from 'react-i18next' diff --git a/app/components/header/nav-bar/time-filter/time-filter.tsx b/app/components/header/nav-bar/time-filter/time-filter.tsx index d2b3fd7f6..c7f07ad09 100644 --- a/app/components/header/nav-bar/time-filter/time-filter.tsx +++ b/app/components/header/nav-bar/time-filter/time-filter.tsx @@ -4,7 +4,6 @@ import { de, enGB } from 'date-fns/locale' import { getUserLocale } from 'get-user-locale' import { Clock, CalendarSearch, CalendarClock } from 'lucide-react' import * as React from 'react' -// import { useSearchParams, useSubmit } from "@remix-run/react"; import { type DateRange } from 'react-day-picker' import { useTranslation } from 'react-i18next' diff --git a/app/components/header/notification/index.tsx b/app/components/header/notification/index.tsx index 2ff3b2fe9..bd376f21f 100644 --- a/app/components/header/notification/index.tsx +++ b/app/components/header/notification/index.tsx @@ -1,49 +1,49 @@ -import { - NovuProvider, - PopoverNotificationCenter, - NotificationBell, - type IMessage, -} from '@novu/notification-center' -import { useLoaderData } from 'react-router' -import { type loader } from '~/root' +// import { +// NovuProvider, +// PopoverNotificationCenter, +// NotificationBell, +// type IMessage, +// } from '@novu/notification-center' +// import { useLoaderData } from 'react-router' +// 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') - } -} +// function onNotificationClick(message: IMessage) { +// if (message?.cta?.data?.url) { +// //window.location.href = message.cta.data.url; +// window.open(message.cta.data.url, '_blank') +// } +// } -export default function Notification() { - const data = useLoaderData() - // get theme from tailwind - const [theme] = 'light' // useTheme(); - return ( -
- - { - //header content here - return
- }} - footer={() => { - //footer content here - return
- }} - > - {({ unseenCount }) => } -
-
-
- ) -} +// export default function Notification() { +// const data = useLoaderData() +// // get theme from tailwind +// const [theme] = 'light' // useTheme(); +// return ( +//
+// +// { +// //header content here +// return
+// }} +// footer={() => { +// //footer content here +// return
+// }} +// > +// {({ unseenCount }) => } +//
+//
+//
+// ) +// } diff --git a/app/components/header/notification/styles.ts b/app/components/header/notification/styles.ts new file mode 100644 index 000000000..38b37f3bd --- /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/landing/header/header.tsx b/app/components/landing/header/header.tsx index 79efc9cdc..dd97b47fd 100644 --- a/app/components/landing/header/header.tsx +++ b/app/components/landing/header/header.tsx @@ -1,8 +1,8 @@ import { useState } from 'react' +import { useTranslation } from 'react-i18next' import { Link } from 'react-router' // import { ModeToggle } from "../../mode-toggle"; import LanguageSelector from './language-selector' -import { useTranslation } from 'react-i18next' const links = [ { diff --git a/app/components/landing/sections/tools.tsx b/app/components/landing/sections/tools.tsx index 2a84ece09..469adfdef 100644 --- a/app/components/landing/sections/tools.tsx +++ b/app/components/landing/sections/tools.tsx @@ -1,6 +1,6 @@ import { motion } from 'framer-motion' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { useTranslation } from 'react-i18next' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' export default function Tools() { const { t } = useTranslation('tools') diff --git a/app/components/map/Markers.tsx b/app/components/map/Markers.tsx new file mode 100644 index 000000000..10a3f06c3 --- /dev/null +++ b/app/components/map/Markers.tsx @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; +import { type CircleLayer, type MarkerProps, 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/ui/button.tsx b/app/components/ui/button.tsx index efc496a2b..80c39d736 100644 --- a/app/components/ui/button.tsx +++ b/app/components/ui/button.tsx @@ -55,4 +55,4 @@ const Button = React.forwardRef( ) Button.displayName = 'Button' -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/app/components/ui/country-flag.tsx b/app/components/ui/country-flag.tsx new file mode 100644 index 000000000..217871377 --- /dev/null +++ b/app/components/ui/country-flag.tsx @@ -0,0 +1,14 @@ +import Flags from "country-flag-icons/react/3x2"; + +type Props = { + country: string | undefined; +}; + +export const CountryFlagIcon = ({ country }: Props) => { + if (!country) { + return; + } + const Flag = Flags[country as keyof typeof Flags]; + + return ; +}; diff --git a/app/components/ui/dialog.tsx b/app/components/ui/dialog.tsx index 72262a8b9..42e3e2cae 100644 --- a/app/components/ui/dialog.tsx +++ b/app/components/ui/dialog.tsx @@ -6,9 +6,9 @@ import * as React from 'react' import { cn } from '@/lib/utils' -const Dialog = DialogPrimitive.Root +const Dialog = DialogPrimitive.Root; -const DialogTrigger = DialogPrimitive.Trigger +const DialogTrigger = DialogPrimitive.Trigger; const DialogPortal = ({ children, diff --git a/app/components/ui/multi-select.tsx b/app/components/ui/multi-select.tsx new file mode 100644 index 000000000..03370f445 --- /dev/null +++ b/app/components/ui/multi-select.tsx @@ -0,0 +1,157 @@ +//code from https://craft.mxkaske.dev/post/fancy-multi-select + +import clsx from "clsx"; +import { Command as CommandPrimitive } from "cmdk"; +import { X } from "lucide-react"; +import * as React from "react"; + +import { useEffect } from "react"; +import { Badge } from "./badge"; +import { Command, CommandGroup, CommandItem } from "./command"; +import { Label } from "./label"; +import { ScrollArea } from "./scroll-area"; + +export type DataItem = Record<"value" | "label", string>; + +export function MultiSelect({ + label, + placeholder = "Select an item", + parentClassName, + data, + preselected, + setSelectedItems, +}: { + label?: string; + placeholder?: string; + parentClassName?: string; + data: DataItem[]; + preselected?: DataItem; + setSelectedItems: React.Dispatch>; +}) { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [selected, setSelected] = React.useState([]); + + useEffect(() => { + if (preselected) setSelected([preselected]); + }, [preselected]); + const [inputValue, setInputValue] = React.useState(""); + + useEffect(() => { + if (setSelectedItems) { + setSelectedItems(selected); + } + }, [selected, setSelectedItems]); + + const handleUnselect = React.useCallback((item: DataItem) => { + setSelected((prev) => prev.filter((s) => s.value !== item.value)); + }, []); + + const handleKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + const input = inputRef.current; + if (input) { + if (e.key === "Delete" || e.key === "Backspace") { + if (input.value === "") { + setSelected((prev) => { + const newSelected = [...prev]; + newSelected.pop(); + return newSelected; + }); + } + } + // This is not a default behaviour of the field + if (e.key === "Escape") { + input.blur(); + } + } + }, + [] + ); + + const selectables = data.filter((item) => !selected.includes(item)); + + return ( +
+ {label && ( + + )} + +
+
+ {selected.map((item, index) => { + if (index > 1) return null; + return ( + + {item.label} + + + ); + })} + {selected.length > 2 &&

{`+${selected.length - 2} more`}

} + {/* Avoid having the "Search" Icon */} + setOpen(false)} + onFocus={() => setOpen(true)} + placeholder={placeholder} + className="ml-2 flex-1 border-none bg-transparent outline-none placeholder:text-muted-foreground" + /> +
+
+
+ {open && selectables.length > 0 ? ( +
+ + + {selectables.map((framework) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={(value) => { + setInputValue(""); + setSelected((prev) => [...prev, framework]); + }} + > + {framework.label} + + ); + })} + + +
+ ) : null} +
+
+
+ ); +} diff --git a/app/components/ui/popover.tsx b/app/components/ui/popover.tsx index e688d2056..8a9af041a 100644 --- a/app/components/ui/popover.tsx +++ b/app/components/ui/popover.tsx @@ -5,9 +5,9 @@ import * as React from 'react' import { cn } from '@/lib/utils' -const Popover = PopoverPrimitive.Root +const Popover = PopoverPrimitive.Root; -const PopoverTrigger = PopoverPrimitive.Trigger +const PopoverTrigger = PopoverPrimitive.Trigger; const PopoverContent = React.forwardRef< React.ElementRef, @@ -28,4 +28,4 @@ const PopoverContent = React.forwardRef< )) PopoverContent.displayName = PopoverPrimitive.Content.displayName -export { Popover, PopoverTrigger, PopoverContent } +export { Popover, PopoverTrigger, PopoverContent, PopoverArrow, PopoverAnchor }; diff --git a/app/components/ui/progress.tsx b/app/components/ui/progress.tsx new file mode 100644 index 000000000..150bf73de --- /dev/null +++ b/app/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as ProgressPrimitive from "@radix-ui/react-progress"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/app/components/ui/use-toast.tsx b/app/components/ui/use-toast.tsx new file mode 100644 index 000000000..2245b9399 --- /dev/null +++ b/app/components/ui/use-toast.tsx @@ -0,0 +1,190 @@ +// Inspired by react-hot-toast library +import * as React from "react"; + +import type { ToastActionElement } from "@/components/ui/toast"; +import { type ToastProps } from "@/components/ui/toast"; + +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1000000; + +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/editor-icon-config.js b/app/editor-icon-config.js new file mode 100644 index 000000000..383a08947 --- /dev/null +++ b/app/editor-icon-config.js @@ -0,0 +1,195 @@ +import { commands, selectWord } from "@uiw/react-md-editor"; + +const ICON_SIZE = 14; + +export const iconConfig = { + bold: { + ...commands.bold, + icon: ( + + + + ), + }, + italic: { + ...commands.italic, + icon: ( + + + + ), + }, + quote: { + ...commands.quote, + icon: ( + + + + ), + }, + link: { + ...commands.link, + icon: ( + + + + ), + }, + unorderedListCommand: { + ...commands.unorderedListCommand, + icon: ( + + + + ), + }, + orderedListCommand: { + ...commands.orderedListCommand, + icon: ( + + + + ), + }, + mention: { + name: "mention", + keyCommand: "mention", + value: "@", + buttonProps: { "aria-label": "Mention user", title: "Mention user" }, + icon: ( + + + + + ), + execute: (state, api) => { + const newSelectionRange = selectWord({ + text: state.text, + selection: state.selection, + }); + const state1 = api.setSelectionRange(newSelectionRange); + const state2 = api.replaceSelection(`@${state1.selectedText}`); + api.setSelectionRange({ + start: state2.selection.end - state1.selectedText.length, + end: state2.selection.end, + }); + }, + }, + upload: { + name: "upload", + keyCommand: "upload", + buttonProps: { "aria-label": "Upload user", title: "Upload image" }, + icon: ( + + + + + + ), + execute: () => { + document.getElementById("image_picker").click(); + }, + }, + // The backend converts markdown into HTML, so youtube embed iframe works on the preview mode, + // uncomment this when backend has support for converting our custom youtube markdown into iframes + // youtube: { + // name: 'youtube', + // keyCommand: 'youtube', + // value: '::youtube[]', + // buttonProps: { 'aria-label': 'Add YouTube video', title: 'Add YouTube video' }, + // icon: ( + // + // + // + // + // ), + // execute: (state, api) => { + // const newSelectionRange = selectWord({ text: state.text, selection: state.selection }); + // const state1 = api.setSelectionRange(newSelectionRange); + // const state2 = api.replaceSelection(`::youtube[${state1.selectedText}]`); + // api.setSelectionRange({ + // start: state2.selection.end - 1, + // end: state2.selection.end - 1, + // }); + // }, + // }, +}; diff --git a/app/lib/actions.ts b/app/lib/actions.ts new file mode 100644 index 000000000..b64666cc8 --- /dev/null +++ b/app/lib/actions.ts @@ -0,0 +1,35 @@ +import { + participate, + deleteCampaignAction, + updateCampaignAction, + bookmark, + messageAllUsers, +} from "./actions/campaign"; +import { + publishCommentAction, + deleteCommentAction, + updateCommentAction, + publishPostAction, + getCommentsAction +} from "./actions/comments"; +import { + updateCampaignEvent, + createCampaignEvent, + deleteCampaignEvent, +} from "./actions/events"; + +export { + participate, + bookmark, + deleteCampaignAction, + updateCampaignAction, + updateCampaignEvent, + createCampaignEvent, + deleteCampaignEvent, + messageAllUsers, + publishCommentAction, + deleteCommentAction, + updateCommentAction, + getCommentsAction, + publishPostAction +}; diff --git a/app/lib/actions/campaign.ts b/app/lib/actions/campaign.ts new file mode 100644 index 000000000..459fa5c3e --- /dev/null +++ b/app/lib/actions/campaign.ts @@ -0,0 +1,215 @@ +// // import { type Exposure, type Priority } from "@prisma/client"; +// import { type ActionFunctionArgs, redirect } from "react-router"; + +// import { campaignUpdateSchema } from "../validations/campaign"; +// import { +// // updateCampaign, +// deleteCampaign, +// update, +// // bookmarkCampaign, +// } from "~/models/campaign.server"; +// import { +// campaignCancelled, +// triggerNotificationNewParticipant, +// } from "~/novu.server"; +// import { requireUser, requireUserId } from "~/utils/session.server"; + +// export async function participate({ request }: ActionFunctionArgs) { +// const ownerId = await requireUserId(request); +// const user = await requireUser(request); +// const formData = await request.formData(); +// const campaignId = formData.get("campaignId"); +// if (typeof campaignId !== "string" || campaignId.length === 0) { +// return new Response(JSON.stringify({ errors: { campaignId: "campaignId is required" } }), { status: 400 }); +// } +// const campaignTitle = formData.get("title"); +// if (typeof campaignTitle !== "string" || campaignTitle.length === 0) { +// return new Response(JSON.stringify( +// { errors: { campaignTitle: "campaignTitle is required", body: null }}), +// { status: 400 } +// ); +// } +// const campaignOwner = formData.get("owner"); +// if (typeof campaignOwner !== "string" || campaignOwner.length === 0) { +// return new Response(JSON.stringify( +// { errors: { campaignOwner: "campaignOwner is required", body: null }}), +// { status: 400 } +// ); +// } +// // const email = formData.get("email"); +// // const hardware = formData.get("hardware"); +// // if (typeof email !== "string" || email.length === 0) { +// // return json( +// // { errors: { email: "email is required", body: null } }, +// // { status: 400 } +// // ); +// // } +// try { +// // const updated = await updateCampaign(campaignId, ownerId); +// console.log(campaignOwner); +// await triggerNotificationNewParticipant( +// campaignOwner, +// user.email, +// user.name, +// campaignTitle +// ); +// return new Response({ ok: true }); +// } catch (error) { +// console.error(`form not submitted ${error}`); +// return json({ error }); +// } +// } + +// interface PhenomenaState { +// [phenomena: string]: boolean; +// } + +// type PriorityType = keyof typeof Priority; +// type ExposureType = keyof typeof Exposure; + +// export async function messageAllUsers({ request }: ActionFunctionArgs) { +// const formData = await request.formData(); +// const message = formData.get("messageForAll"); +// console.log(message); +// return json({ message }); +// } + +// export async function updateCampaignAction({ request }: ActionFunctionArgs) { +// const formData = await request.formData(); +// const campaignId = formData.get("campaignId"); +// if (typeof campaignId !== "string" || campaignId.length === 0) { +// return json( +// { errors: { campaignId: "campaignId is required", body: null } }, +// { status: 400 } +// ); +// } +// const title = formData.get("title"); +// if (typeof title !== "string" || title.length === 0) { +// return json( +// { errors: { title: "title is required", body: null } }, +// { status: 400 } +// ); +// } +// const description = formData.get("description"); +// if (typeof description !== "string" || description.length === 0) { +// return json( +// { errors: { description: "description is required", body: null } }, +// { status: 400 } +// ); +// } +// const phenomenaString = formData.get("phenomena"); +// let phenomenaState: PhenomenaState = {}; +// if (typeof phenomenaString === "string") { +// phenomenaState = JSON.parse(phenomenaString); +// } +// const phenomena = Object.keys(phenomenaState).filter( +// (key) => phenomenaState[key] +// ); + +// const priority = formData.get("priority") as PriorityType; +// const begin = formData.get("startDate"); +// const startDate = +// begin && typeof begin === "string" ? new Date(begin) : new Date(); +// const end = formData.get("endDate"); +// const endDate = end && typeof end === "string" ? new Date(end) : new Date(); +// const location = formData.get("countries"); +// let countries: string[] = ['dz'] +// const minimumParticipants = formData.get("minimumParticipants"); +// const minParticipants = parseInt(minimumParticipants); +// const updatedAt = new Date(); +// const exposure = formData.get("exposure") as ExposureType; +// const hardwareAvailable = +// formData.get("hardware_available") === "on" ? true : false; +// console.log( +// campaignId, +// title, +// // description, +// phenomena, +// priority, +// startDate, +// endDate, +// countries, +// exposure, +// hardwareAvailable +// ); +// try { +// const updatedCampaign = campaignUpdateSchema.parse({ +// title: title, +// description: description, +// priority: priority, +// country: countries, +// phenomena: phenomena, +// startDate: startDate, +// endDate: endDate, +// minimumParticipants: minParticipants, +// updatedAt: updatedAt, +// exposure: exposure, +// hardwareAvailable: hardwareAvailable, +// }); +// const updated = await update(campaignId, updatedCampaign); +// // console.log(updated); +// return redirect("../explore"); +// // return json({ ok: true }); +// } catch (error) { +// console.error(`form not submitted ${error}`); +// return json({ error }); +// } +// } + +// export async function deleteCampaignAction({ request }: ActionFunctionArgs) { +// const formData = await request.formData(); +// const ownerId = await requireUserId(request); +// const campaignId = formData.get("campaignId"); +// if (typeof campaignId !== "string" || campaignId.length === 0) { +// return json( +// { errors: { campaignId: "campaignId is required", body: null } }, +// { status: 400 } +// ); +// } +// const campaignTitle = formData.get("title"); +// if (typeof campaignTitle !== "string" || campaignTitle.length === 0) { +// return json( +// { errors: { campaignTitle: "campaignTitle is required", body: null } }, +// { status: 400 } +// ); +// } +// let participants = formData.get("participants"); +// if (typeof participants !== "string" || participants.length === 0) { +// return json( +// { errors: { participants: "participants is required", body: null } }, +// { status: 400 } +// ); +// } +// participants = JSON.parse(participants); +// try { +// const deleted = await deleteCampaign({ id: campaignId, ownerId }); +// if (Array.isArray(participants)) { +// participants.map((p) => campaignCancelled(p.id, campaignTitle)); +// } +// return redirect("../explore"); +// // return json({ ok: true }); +// } catch (error) { +// console.error(`form not submitted ${error}`); +// return json({ error }); +// } +// } + +// export async function bookmark({ request }: ActionFunctionArgs) { +// const formData = await request.formData(); +// const userId = await requireUserId(request); +// const campaignId = formData.get("campaignId"); +// if (typeof campaignId !== "string" || campaignId.length === 0) { +// return json( +// { errors: { campaignId: "campaignId is required", body: null } }, +// { status: 400 } +// ); +// } +// try { +// // const bookmarked = await bookmarkCampaign({ id: campaignId, userId }); +// // return bookmarked; +// return null +// } catch (error) { +// console.error(`form not submitted ${error}`); +// return json({ error }); +// } +// } diff --git a/app/lib/actions/comments.ts b/app/lib/actions/comments.ts new file mode 100644 index 000000000..1e6d62e20 --- /dev/null +++ b/app/lib/actions/comments.ts @@ -0,0 +1,143 @@ + +// import { +// createComment, +// deleteComment, +// getComments, +// updateComment, +// } from "~/models/comment.server"; +// import { createPost } from "~/models/post.server"; +// // import { getUserByName } from "~/models/user.server"; +// import { mentionedUser } from "~/novu.server"; +// import { User } from "~/schema"; +// import { requireUser, requireUserId } from "~/session.server"; + +// export async function updateCommentAction({ request }: ActionArgs) { +// const formData = await request.formData(); +// const content = formData.get("editComment"); +// if (typeof content !== "string" || content.length === 0) { +// return json( +// { errors: { content: "content is required", body: null } }, +// { status: 400 } +// ); +// } +// const commentId = formData.get("commentId"); +// if (typeof commentId !== "string" || commentId.length === 0) { +// return json( +// { errors: { commentId: "commentId is required", body: null } }, +// { status: 400 } +// ); +// } +// try { +// const comment = await updateComment(commentId, content); +// return json({ ok: true }); +// } catch (error) { +// console.error(`form not submitted ${error}`); +// return json({ error }); +// } +// } + +// export async function publishCommentAction({ request, params }: ActionArgs) { +// const ownerId = await requireUserId(request); +// const username = (await requireUser(request)).name; +// const formData = await request.formData(); +// const content = formData.get("content"); +// if (typeof content !== "string" || content.length === 0) { +// return json( +// { errors: { content: "content is required", body: null } }, +// { status: 400 } +// ); +// } +// const postId = formData.get("postId") +// if (typeof postId !== "string" || postId.length === 0) { +// return json( +// { errors: { postId: "postId is required", body: null } }, +// { status: 400 } +// ); +// } +// // let mentions = formData.get("mentions"); +// // if (typeof mentions !== "string" || mentions.length === 0) { +// // return json( +// // { errors: { mentions: "mentions is required", body: null } }, +// // { status: 400 } +// // ); +// // } +// // mentions = JSON.parse(mentions); +// const campaignSlug = params.slug; +// if (typeof campaignSlug !== "string" || campaignSlug.length === 0) { +// return json( +// { errors: { campaignSlug: "campaignSlug is required", body: null } }, +// { status: 400 } +// ); +// } +// try { +// const comment = await createComment({ content, campaignSlug, ownerId, postId }); +// // if (mentions) { +// // // const user = await getUserByName(mentions); +// // const user = {} as User +// // if (user?.id) mentionedUser(user?.id, username, campaignSlug); +// // } +// return json({ ok: true }); +// } catch (error) { +// console.error(`form not submitted ${error}`); +// return json({ error }); +// } +// } + +// export async function getCommentsAction({request, params}: ActionArgs){ +// const formData = await request.formData() +// const postId = formData.get("postId") +// if (typeof postId !== "string" || postId.length === 0) { +// return json( +// { errors: { postId: "postId is required", body: null } }, +// { status: 400 } +// ); +// } +// const comments = await getComments(postId) +// return comments +// } + +// export async function publishPostAction({ request, params }: ActionArgs) { +// const ownerId = await requireUserId(request); +// const formData = await request.formData(); +// const title = formData.get("title") +// if (typeof title !== "string" || title.length === 0) { +// return json( +// { errors: { title: "title is required", body: null } }, +// { status: 400 } +// ); +// } +// const content = formData.get("content"); +// if (typeof content !== "string" || content.length === 0) { +// return json( +// { errors: { content: "content is required", body: null } }, +// { status: 400 } +// ); +// } +// const campaignSlug = params.slug; +// if (typeof campaignSlug !== "string" || campaignSlug.length === 0) { +// return json( +// { errors: { campaignSlug: "campaignSlug is required", body: null } }, +// { status: 400 } +// ); +// } +// const post = await createPost({ campaignSlug, title, content, ownerId }); +// return post +// } + +// export async function deleteCommentAction({ request }: ActionArgs) { +// const formData = await request.formData(); +// const commentId = formData.get("deleteComment"); +// if (typeof commentId !== "string" || commentId.length === 0) { +// return json( +// { errors: { commentId: "commentId is required", body: null } }, +// { status: 400 } +// ); +// } +// try { +// const commentToDelete = await deleteComment({ id: commentId }); +// return json({ ok: true }); +// } catch (error) { +// console.error(`form not submitted ${error}`); +// return json({ error }); +// } +// } diff --git a/app/lib/actions/events.ts b/app/lib/actions/events.ts new file mode 100644 index 000000000..7f1616ca0 --- /dev/null +++ b/app/lib/actions/events.ts @@ -0,0 +1,108 @@ +// import { +// createEvent, +// deleteEvent, +// updateEvent, +// } from "~/models/campaign-events.server"; +// import { requireUserId } from "~/session.server"; + +// export async function createCampaignEvent({ request, params }: ActionArgs) { +// const ownerId = await requireUserId(request); +// const formData = await request.formData(); +// const title = formData.get("title"); +// const description = formData.get("description"); +// const startDate = new Date(); +// const endDate = new Date(); + +// if (typeof title !== "string" || title.length === 0) { +// return json( +// { errors: { title: "title is required", body: null } }, +// { status: 400 } +// ); +// } +// if (typeof description !== "string" || description.length === 0) { +// return json( +// { errors: { description: "description is required", body: null } }, +// { status: 400 } +// ); +// } +// const campaignSlug = params.slug; +// if (typeof campaignSlug !== "string" || campaignSlug.length === 0) { +// return json( +// { errors: { campaignSlug: "campaignSlug is required", body: null } }, +// { status: 400 } +// ); +// } +// try { +// const event = await createEvent({ +// title, +// description, +// startDate, +// endDate, +// campaignSlug, +// ownerId, +// }); +// return json({ ok: true }); +// } catch (error) { +// console.error(`form not submitted ${error}`); +// return json({ error }); +// } +// } + +// export async function deleteCampaignEvent({ request }: ActionArgs) { +// const formData = await request.formData(); +// const eventId = formData.get("eventId"); +// if (typeof eventId !== "string" || eventId.length === 0) { +// return json( +// { errors: { eventId: "eventId is required", body: null } }, +// { status: 400 } +// ); +// } +// try { +// const eventToDelete = await deleteEvent({ id: eventId }); +// return json({ ok: true }); +// } catch (error) { +// console.error(`form not submitted ${error}`); +// return json({ error }); +// } +// } + +// export async function updateCampaignEvent({ request }: ActionArgs) { +// const formData = await request.formData(); +// const eventId = formData.get("eventId"); +// if (typeof eventId !== "string" || eventId.length === 0) { +// return json( +// { errors: { eventId: "eventId is required", body: null } }, +// { status: 400 } +// ); +// } + +// const title = formData.get("title"); +// if (typeof title !== "string" || title.length === 0) { +// return json( +// { errors: { title: "title is required", body: null } }, +// { status: 400 } +// ); +// } +// const description = formData.get("description"); +// if (typeof description !== "string" || description.length === 0) { +// return json( +// { errors: { description: "description is required", body: null } }, +// { status: 400 } +// ); +// } +// const startDate = new Date(); +// const endDate = new Date(); +// try { +// const event = await updateEvent( +// eventId, +// title, +// description, +// startDate, +// endDate +// ); +// return json({ ok: true }); +// } catch (error) { +// console.error(`form not submitted ${error}`); +// return json({ error }); +// } +// } diff --git a/app/lib/create-popup.ts b/app/lib/create-popup.ts new file mode 100644 index 000000000..12c8445e8 --- /dev/null +++ b/app/lib/create-popup.ts @@ -0,0 +1,17 @@ +export function createPopup(title: string, location: string) { + const width = 500; + const height = 630; + const settings = [ + ["width", width], + ["height", height], + ["left", window.innerWidth / 2 - width / 2], + ["top", window.innerHeight / 2 - height / 2], + ] + .map((x) => x.join("=")) + .join(","); + + const popup = window.open(location, "_blank", settings); + if (!popup) return; + + return popup; +} diff --git a/app/lib/download-geojson.ts b/app/lib/download-geojson.ts new file mode 100644 index 000000000..3f2c0ee3a --- /dev/null +++ b/app/lib/download-geojson.ts @@ -0,0 +1,18 @@ +import { valid } from "geojson-validation"; + +export function downloadGeojSON(data: any) { + //@ts-ignore + const geojson = JSON.parse(JSON.stringify(data)); + if (valid(geojson)) { + const geojsonString = JSON.stringify(geojson); + const blob = new Blob([geojsonString], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.href = url; + link.download = "geojson_data.json"; + link.click(); + + URL.revokeObjectURL(url); + } +} diff --git a/app/lib/jwt.ts b/app/lib/jwt.ts index 4058e138e..e9ae53a53 100644 --- a/app/lib/jwt.ts +++ b/app/lib/jwt.ts @@ -5,7 +5,7 @@ import invariant from 'tiny-invariant' import { v4 as uuidv4 } from 'uuid' import { drizzleClient } from '~/db.server' import { getUserByEmail } from '~/models/user.server' -import { device, Device, type User } from '~/schema' +import { device, type Device, type User } from '~/schema' import { refreshToken, tokenRevocation } from '~/schema/refreshToken' const { sign, verify } = jsonwebtoken diff --git a/app/lib/read-file-async.ts b/app/lib/read-file-async.ts new file mode 100644 index 000000000..db626c990 --- /dev/null +++ b/app/lib/read-file-async.ts @@ -0,0 +1,13 @@ +export default function readFileAsync(file: File) { + return new Promise((resolve, reject) => { + let reader = new FileReader(); + + reader.onload = () => { + resolve(reader.result); + }; + + reader.onerror = reject; + + reader.readAsText(file); + }); +} diff --git a/app/lib/share-functions.ts b/app/lib/share-functions.ts new file mode 100644 index 000000000..ddb0a6361 --- /dev/null +++ b/app/lib/share-functions.ts @@ -0,0 +1,35 @@ +export function getTwitterLink( + message: string, + url: string, + hashtags: string[] +) { + const baseUrl = "https://twitter.com/intent/tweet"; + return `${baseUrl}?text=${encode(message)}&url=${encode( + url + )}&hashtags=${hashtags.join(",")}`; +} + +export function getLinkedInLink(url: string) { + return `https://www.linkedin.com/sharing/share-offsite/?url=${encode(url)}`; +} + +export function getFacebookLink(message: string, url: string) { + const baseUrl = "https://web.facebook.com/sharer/sharer.php"; + return `${baseUrl}?display=popup&u=${encode(url)}"e=${encode(message)}`; +} + +export function getWhatsAppLink(text: string, url: string) { + return `https://api.whatsapp.com/send?text=${encode(text)}%20${encode(url)}`; +} + +export function getInstagramLink(caption: string, url: string) { + return `https://www.instagram.com/share?url=${encode(url)}&caption=${encode( + caption + )}`; +} + +export function getTelegramLink(text: string, url: string) { + return `https://t.me/share/url?text=${encode(text)}&url=${encode(url)}`; +} + +const encode = (value: string) => encodeURIComponent(value); diff --git a/app/lib/slug.ts b/app/lib/slug.ts new file mode 100644 index 000000000..0152e9a43 --- /dev/null +++ b/app/lib/slug.ts @@ -0,0 +1,47 @@ +import slugify from "slugify"; +// import { prisma } from "~/db.server"; +import { drizzleClient } from "~/db.server"; + +/** + * Generates a slug for a campaign title. Throws error if no slug was found + * @param title title to slugify + * @returns Promise with new slug + */ +export async function generateSlug(title: string) { + return new Promise(async (resolve, reject) => { + const slug = slugify(title, { + lower: true, + strict: true, + }); + const unique = await uniqueSlug(slug); + if (!unique) { + return reject("Slug is not unique"); + } + resolve(unique); + }); +} + +/** + * Get a unique slug for a campaign + * @param slug the slug to check + * @param maxSuffix optional max suffix to check + * @returns unique campaign slug, undefined if no unique slug was found + * @example + * Simple example + * ```ts + * const slug = "my-campaign" + * const unique = await uniqueSlug(slug) + * ``` + */ +const uniqueSlug = async (slug: string, maxSuffix = 1000) => { + for (let suffix = 0; suffix < maxSuffix; suffix++) { + const slugToCheck = suffix === 0 ? slug : `${slug}-${suffix}`; + // Check if the current slug with the current suffix exists + const existingSlug = await drizzleClient.query.campaign.findFirst({ + where: (campaign, {eq}) => eq(campaign.slug, slug) + }); + if (!existingSlug) { + return slugToCheck; + } + } +}; diff --git a/app/lib/validations/campaign-event.ts b/app/lib/validations/campaign-event.ts new file mode 100644 index 000000000..77be1132f --- /dev/null +++ b/app/lib/validations/campaign-event.ts @@ -0,0 +1,31 @@ +import * as z from "zod"; + +function checkValidDates(startDate: Date, endDate: Date | undefined) { + if (startDate && endDate) { + return startDate <= endDate; + } + return true; +} + +export const campaignEventSchema = z + .object({ + title: z + .string() + .min(3, "Der Titel muss mindestens 5 Zeichen lang sein!") + .max(52), + description: z + .string() + .min(5, "Die Beschreibung muss mindestens 5 Zeichen lang sein!"), + createdAt: z.date(), + updatedAt: z.date(), + startDate: z + .date() + .refine((value) => value !== undefined && value !== null, { + message: "Dies ist ein Pflichtfeld!", + }), + endDate: z.date().optional(), + }) + .refine( + (data) => checkValidDates(data.startDate, data.endDate), + "Der Beginn muss früher sein als der Abschluss der Kampagne!" + ); diff --git a/app/lib/validations/campaign.ts b/app/lib/validations/campaign.ts new file mode 100644 index 000000000..43fe5f433 --- /dev/null +++ b/app/lib/validations/campaign.ts @@ -0,0 +1,81 @@ +import * as z from "zod"; + +function checkValidDates(startDate: Date, endDate: Date | undefined) { + if (startDate && endDate) { + return startDate <= endDate; + } + return true; +} + +export const campaignSchema = z + .object({ + title: z + .string() + .min(3, "Der Titel muss mindestens 3 Zeichen lang sein!") + .max(52), + description: z + .string() + .min(5, "Die Beschreibung muss mindestens 5 Zeichen lang sein!"), + instructions: z.string(), + feature: z.any(), + priority: z.enum(["low", "medium", "high", "urgent"]), + countries: z.array(z.string()).optional(), + createdAt: z.date(), + updatedAt: z.date(), + startDate: z + .date() + .refine((value) => value !== undefined && value !== null, { + message: "Dies ist ein Pflichtfeld!", + }), + endDate: z.date().optional(), + phenomena: z.array(z.string()), + exposure: z.enum(["unknown", "indoor", "mobile", "outdoor"]), + hardwareAvailable: z.boolean(), + centerpoint: z.any(), + minimumParticipants: z + .number() + .int("Bitte geben Sie eine Zahl ein") + .nonnegative("Bitte geben Sie nur positive Zahlen ein") + .refine((value) => typeof value === "number" && value >= 1, { + message: "Bitte geben Sie nur positive Zahlen ein!", + }), + }) + .refine( + (data) => checkValidDates(data.startDate, data.endDate), + "Der Beginn muss früher sein als der Abschluss der Kampagne!" + ); + +export const campaignUpdateSchema = z + .object({ + title: z + .string() + .min(3, "Der Titel muss mindestens 3 Zeichen lang sein!") + .max(52), + description: z + .string() + .min(5, "Die Beschreibung muss mindestens 5 Zeichen lang sein!"), + instructions: z.string().optional(), + feature: z.any().optional(), + priority: z.enum(["low", "medium", "high", "urgent"]), + countries: z.array(z.string()).optional(), + createdAt: z.date().optional(), + updatedAt: z.date(), + startDate: z.date(), + endDate: z.date(), + phenomena: z.array(z.string()), + exposure: z.enum(["unknown", "indoor", "mobile", "outdoor"]), + hardwareAvailable: z.boolean(), + centerpoint: z.any().optional(), + minimumParticipants: z + .number() + .int("Bitte geben Sie eine Zahl ein") + .nonnegative("Bitte geben Sie nur positive Zahlen ein") + .optional() + .refine((value) => typeof value === "number" && value >= 1, { + message: "Bitte geben Sie nur positive Zahlen ein!", + }), + }) + .refine( + (data) => checkValidDates(data.startDate, data.endDate), + "Start date must be earlier than End date." + ); diff --git a/app/lib/validations/support.ts b/app/lib/validations/support.ts new file mode 100644 index 000000000..a02c15e9c --- /dev/null +++ b/app/lib/validations/support.ts @@ -0,0 +1,3 @@ +import * as z from "zod"; + +export const campaignSupportSchema = z.object({}); diff --git a/app/lib/zoom-to-extent.ts b/app/lib/zoom-to-extent.ts new file mode 100644 index 000000000..7b5b3acbe --- /dev/null +++ b/app/lib/zoom-to-extent.ts @@ -0,0 +1,19 @@ +//inspired by geojson.io +import bbox from "@turf/bbox"; + +export default function zoomToExtent(map: any, feature: any) { + // if the data is a single point, flyTo() + if (feature.geometry.length === 1 && feature.geometry.type === "Point") { + map.flyTo({ + center: feature.geometry.coordinates, + zoom: 6, + duration: 1000, + }); + } else { + const bounds = bbox(feature); + map.fitBounds(bounds, { + padding: 50, + duration: 1000, + }); + } +} diff --git a/app/markdown.client.tsx b/app/markdown.client.tsx new file mode 100644 index 000000000..543fec0c8 --- /dev/null +++ b/app/markdown.client.tsx @@ -0,0 +1,35 @@ +import MDEditor from "@uiw/react-md-editor"; +import rehypeSanitize from "rehype-sanitize"; +import rehypeVideo from "rehype-video"; +import { iconConfig } from "./editor-icon-config"; + +type MDEditorProps = { + comment: string | undefined; + setComment: (comment: string | undefined) => void; + textAreaRef: any; +}; + +export const MarkdownEditor = ({ + comment, + setComment, + textAreaRef, +}: MDEditorProps) => { + return ( + iconConfig[key])} + preview="live" + value={comment} + onChange={setComment} + previewOptions={{ + rehypePlugins: [[rehypeVideo]], + }} + textareaProps={{ + spellCheck: "true", + placeholder: "Leave a comment...", + }} + /> + ); +}; diff --git a/app/models/campaign-events.server.ts b/app/models/campaign-events.server.ts new file mode 100644 index 000000000..151c350a5 --- /dev/null +++ b/app/models/campaign-events.server.ts @@ -0,0 +1,65 @@ +// import type { User, CampaignEvent } from "@prisma/client"; +// // import { prisma } from "~/db.server"; +// import { drizzleClient } from "~/db.server"; + +// export function createEvent({ +// title, +// description, +// campaignSlug, +// startDate, +// endDate, +// ownerId, +// }: Pick< +// CampaignEvent, +// "title" | "description" | "campaignSlug" | "startDate" | "endDate" +// > & { +// ownerId: User["id"]; +// }) { +// return prisma.campaignEvent.create({ +// data: { +// title, +// description, +// startDate, +// endDate, +// createdAt: new Date(), +// updatedAt: new Date(), +// owner: { +// connect: { +// id: ownerId, +// }, +// }, +// campaign: { +// connect: { +// slug: campaignSlug, +// }, +// }, +// }, +// }); +// } + +// export function deleteEvent({ id }: Pick) { +// return prisma.campaignEvent.deleteMany({ +// where: { id }, +// }); +// } + +// export async function updateEvent( +// eventId: string, +// title?: string, +// description?: string, +// startDate?: Date, +// endDate?: Date +// ) { +// return prisma.campaignEvent.update({ +// where: { +// id: eventId, +// }, +// data: { +// title, +// description, +// startDate, +// endDate, +// updatedAt: new Date(), +// }, +// }); +// } diff --git a/app/models/campaign.server.ts b/app/models/campaign.server.ts new file mode 100644 index 000000000..d36c6a636 --- /dev/null +++ b/app/models/campaign.server.ts @@ -0,0 +1,303 @@ +import { count, eq } from "drizzle-orm"; +import { drizzleClient } from "~/db.server"; +import { generateSlug } from "~/lib/slug"; +import { type User } from "~/schema"; +import { campaign, type Campaign } from "~/schema/campaign"; + +export function getCampaign({ slug }: Pick, userId: string) { + return drizzleClient.query.campaign.findFirst({ + // where: { slug }, + // include: { + // comments: { + // include: { + // owner: true, + // }, + // }, + // events: true, + // participants: true, + // bookmarks: { + // where: { userId: userId }, + // }, + // }, + where: (campaign, {eq}) => eq(campaign.slug, slug), + with: { + posts: true + } + }); +} + +export async function getOwnCampaigns(userId: string) { + return drizzleClient.query.campaign.findMany({ + where: (campaign, {eq}) => eq(campaign.ownerId, userId) + }); +} + +const priorityOrder = { + URGENT: 0, + HIGH: 1, + MEDIUM: 2, + LOW: 3, +}; + +export async function getCampaigns( + options = {}, + userId?: string, + sortBy?: string +) { + const campaigns = await drizzleClient.query.campaign.findMany({ + // include: { + // participants: { + // select: { + // id: true, + // }, + // }, + // bookmarks: { + // where: { + // userId: userId, + // }, + // }, + // }, + // orderBy: [ + // { + // bookmarkedByUsers: { + // _count: "desc", + // }, + // }, + // { + // updatedAt: "desc", + // }, + // ], + // ...options, + }); + if (sortBy === "priority") { + return campaigns + .slice() + .sort((campaignA: Campaign, campaignB: Campaign) => { + const priorityA = + priorityOrder[campaignA.priority as keyof typeof priorityOrder]; + const priorityB = + priorityOrder[campaignB.priority as keyof typeof priorityOrder]; + + return priorityA - priorityB; + }); + } + return campaigns; +} + +export async function getCampaignCount() { + return await drizzleClient.select({value: count()}).from(campaign); +} + +export async function getFilteredCampaigns(title: string) { + return drizzleClient.query.campaign.findMany({ + where: (campaign, {eq}) => eq(campaign.title, title) + }); +} + +// export async function getBookmark({ +// id, +// userId, +// }: Pick & { userId: User["id"] }) { +// const bookmark = await prisma.campaignBookmark.findUnique({ +// where: { +// userId_campaignId: { userId, campaignId: id }, +// }, +// }); +// return bookmark; +// } + +// export async function getBookmarks({ userId }: { userId: User["id"] }) { +// const bookmarks = await prisma.campaignBookmark.findMany({ +// where: { +// userId: userId, +// }, +// }); +// return bookmarks; +// } + +// export async function bookmarkCampaign({ +// id, +// userId, +// }: Pick & { userId: User["id"] }) { +// const user = await prisma.user.findUnique({ +// where: { id: userId }, +// }); + +// const campaign = await prisma.campaign.findUnique({ +// where: { id: id }, +// include: { +// bookmarks: true, +// }, +// }); + +// if (!user || !campaign) { +// return; +// } + +// const isBookmarked = campaign.bookmarks.some((b) => b.userId === userId); + +// if (isBookmarked) { +// const unbookmarked = await deleteCampaignBookmark({ id, userId }); +// if (unbookmarked) return json({ unbookmarked: true }); +// return unbookmarked; +// } else { +// const bookmark = await prisma.campaignBookmark.create({ +// data: { +// userId: userId, +// campaignId: id, +// }, +// }); +// if (bookmark) { +// return json({ bookmarked: true }); +// } +// return bookmark; +// } + + // const isBookmarked = user.bookmarkedCampaigns.some( + // (bookmark) => bookmark.id === id + // ); + + // if (isBookmarked) { + // const unbookmark = await prisma.user.update({ + // where: { id: ownerId }, + // data: { bookmarkedCampaigns: { disconnect: { id: id } } }, + // }); + // if (unbookmark) { + // return json({ unbookmarked: true }); + // } + // return unbookmark; + // } else { + // const bookmark = await prisma.user.update({ + // where: { id: ownerId }, + // data: { bookmarkedCampaigns: { connect: { id: id } } }, + // }); + // if (bookmark) { + // return json({ bookmarked: true }); + // } + // return bookmark; + // } +// } + +export async function createCampaign({ + title, + feature, + ownerId, + description, + instructions, + priority, + countries, + minimumParticipants, + startDate, + endDate, + createdAt, + updatedAt, + phenomena, + exposure, + hardwareAvailable, + centerpoint, +}: Pick< + Campaign, + | "title" + | "feature" + | "description" + | "instructions" + | "priority" + | "countries" + | "minimumParticipants" + | "startDate" + | "endDate" + | "createdAt" + | "updatedAt" + | "phenomena" + | "exposure" + | "hardwareAvailable" + | "centerpoint" +> & { + ownerId: User["id"]; +}) { + const slug = await generateSlug(title); + return drizzleClient.insert(campaign).values({ + title: title, + slug: slug, + feature: feature === null ? {} : feature, + description: description, + instructions: instructions, + priority: priority, + countries: countries, + minimumParticipants: minimumParticipants, + startDate: startDate, + endDate: endDate, + createdAt: createdAt, + updatedAt: updatedAt, + phenomena: phenomena, + exposure: exposure, + hardwareAvailable: hardwareAvailable, + centerpoint: centerpoint === null ? {} : centerpoint, + ownerId: ownerId + }); +} + +// export async function updateCampaign( +// id: string, +// options: Prisma.CampaignUpdateInput +// ) { +// return prisma.campaign.update({ +// where: { id }, +// data: options, +// }); +// } + +export async function update( + id: string, + update: Pick< + Campaign, + | "title" + | "description" + | "priority" + | "startDate" + | "endDate" + | "countries" + | "updatedAt" + | "phenomena" + | "exposure" + | "hardwareAvailable" + > +) { + return drizzleClient.update(campaign).set({ + title: update.title, + + }).where(eq(campaign.id, id)); +} + +// export async function updateCampaign( +// campaignId: string, +// participantId: string +// ) { +// return prisma.campaign.update({ +// where: { +// id: campaignId, +// }, +// data: { +// participants: { +// connect: { id: participantId }, +// }, +// updatedAt: new Date(), +// }, +// }); +// } + +// export function deleteCampaignBookmark({ +// id, +// userId, +// }: Pick & { userId: User["id"] }) { +// return prisma.campaignBookmark.delete({ +// where: { userId_campaignId: { userId, campaignId: id } }, +// }); +// } + +export function deleteCampaign({ + id, + ownerId, +}: Pick & { ownerId: User["id"] }) { + return drizzleClient.delete(campaign).where(eq(campaign.id, id)) +} diff --git a/app/models/comment.server.ts b/app/models/comment.server.ts new file mode 100644 index 000000000..947ff786d --- /dev/null +++ b/app/models/comment.server.ts @@ -0,0 +1,42 @@ +import { eq } from "drizzle-orm"; +import { drizzleClient } from "~/db.server"; +import { type Comment, type User, comment } from "~/schema"; + +export function createComment({ + content, + campaignSlug, + ownerId, + postId +}: Pick & { + ownerId: User["id"]; +}) { + return drizzleClient.insert(comment).values({ + content: content, + createdAt: new Date(), + updatedAt: new Date(), + userId: ownerId, + campaignSlug: campaignSlug, + postId: postId + // campaign: { + // connect: { + // slug: campaignSlug, + // }, + // }, + }).returning() +} + +export function deleteComment({ id }: Pick) { + return drizzleClient.delete(comment).where(eq(comment.id, id)) +} + +export async function updateComment(commentId: string, content: string) { + return drizzleClient.update(comment).set({ + content: content + }).where(eq(comment.id, commentId)); +} + +export async function getComments(postId: string){ + return drizzleClient.query.comment.findMany({ + where: (eq(comment.postId, postId)) + }) +} diff --git a/app/models/device.server.ts b/app/models/device.server.ts index 42039ed79..ecfd1baae 100644 --- a/app/models/device.server.ts +++ b/app/models/device.server.ts @@ -906,3 +906,127 @@ export async function addOrReplaceDeviceApiKey( return { apiKey: result[0].apiKey } } + +// export async function createDevice( +// deviceData: any, +// userId: string | undefined, +// ) { +// // hack to register to OSEM API +// const authData = await fetch( +// `${process.env.OSEM_API_TESTING_URL}/users/sign-in`, +// { +// method: "POST", +// headers: { +// "Content-Type": "application/json", +// }, +// body: JSON.stringify({ +// email: `${process.env.TESTING_ACCOUNT}`, +// password: `${process.env.TESTING_PW}`, +// }), +// }, +// ).then((res) => res.json()); + +// let sensorArray: any = []; +// Object.values(deviceData.sensors).forEach((sensorsOfPhenomenon: any) => { +// sensorsOfPhenomenon.forEach((sensor: any) => { +// sensorArray.push({ +// name: sensor[0], +// title: sensor[2], +// sensorType: sensor[1], +// unit: sensor[3], +// }); +// }); +// }); +// const registeredDevice = await createDeviceOsemAPI( +// { ...deviceData, sensors: sensorArray }, +// authData.token, +// ); +// const newDevicePostgres = await createDevicePostgres( +// registeredDevice.data, +// userId, +// sensorArray, +// deviceData, +// ); +// return newDevicePostgres; +// } + +export async function createDevicePostgres( + deviceData: any, + userId: string | undefined, + sensorArray: any[], + formDeviceData: any, +) { + const newDevice = await drizzleClient.insert(device).values({ + id: deviceData._id, + sensorWikiModel: formDeviceData.type, + userId: userId ?? "unknown", + name: deviceData.name, + exposure: deviceData.exposure, + useAuth: deviceData.useAuth, + latitude: Number(deviceData.currentLocation.coordinates[1]), + longitude: Number(deviceData.currentLocation.coordinates[0]), + }).returning(); + + for await (let [i, sensor] of deviceData.sensors.entries()) { + await drizzleClient.insert(sensor).values({ + id: sensor._id, + deviceId: newDevice[0].id, + title: sensorArray[i].name, + sensorType: sensor.sensorType, + unit: sensor.unit, + sensorWikiType: sensor.sensorType, + sensorWikiUnit: sensor.unit, + sensorWikiPhenomenon: sensor.title, + }); + } + + return newDevice; +} + +export async function createDeviceOsemAPI(deviceData: any, token: string) { + const registerDevice = await fetch( + `${process.env.OSEM_API_TESTING_URL}/boxes`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: deviceData.name, + grouptag: deviceData.groupId, + exposure: deviceData.exposure.toLowerCase(), + // model: deviceData.type, + sensors: deviceData.sensors, + location: { + lat: deviceData.latitude, + lng: deviceData.longitude, + ...(deviceData.height && { height: deviceData.height }), + }, + ...(deviceData.ttnEnabled && { + ttn: { + dev_id: deviceData["ttn.devId"], + app_id: deviceData["ttn.appId"], + profile: deviceData["ttn.decodeProfile"], + ...(deviceData["ttn.decodeOptions"] && { + decodeOptions: deviceData["ttn.decodeOptions"], + }), + ...(deviceData["ttn.port"] && { port: deviceData["ttn.port"] }), + }, + }), + ...(deviceData.mqttEnabled && { + mqtt: { + enabled: true, + url: deviceData["mqtt.url"], + topic: deviceData["mqtt.topic"], + messageFormat: deviceData["mqtt.messageFormat"], + decodeOptions: deviceData["mqtt.decodeOptions"], + connectionOptions: deviceData["mqtt.connectOptions"], + }, + }), + }), + }, + ).then((res) => res.json()); + + return registerDevice; +} \ No newline at end of file diff --git a/app/models/post.server.ts b/app/models/post.server.ts new file mode 100644 index 000000000..aea03992e --- /dev/null +++ b/app/models/post.server.ts @@ -0,0 +1,30 @@ +import { eq } from "drizzle-orm"; +import { drizzleClient } from "~/db.server"; +import { type Post, type User, post } from "~/schema"; + +export function createPost({ + content, + campaignSlug, + ownerId, + title, + }: Pick & { + ownerId: User["id"]; + }) { + return drizzleClient.insert(post).values({ + content: content, + userId: ownerId, + campaignSlug: campaignSlug, + title: title + }).returning() + } + + +export function deletePost({ id }: Pick) { + return drizzleClient.delete(post).where(eq(post.id, id)) +} + +export async function updatePost(postId: string, content: string) { + return drizzleClient.update(post).set({ + content: content + }).where(eq(post.id, postId)); +} diff --git a/app/novu.server.ts b/app/novu.server.ts new file mode 100644 index 000000000..2481c1e27 --- /dev/null +++ b/app/novu.server.ts @@ -0,0 +1,140 @@ +// import * as fs from "fs"; +// import path from "path"; +// import { Novu } from "@novu/node"; +// import { getEnv } from "./env.server"; + +// const novu = new Novu(getEnv().NOVU_API_KEY ?? ""); + +// export const deleteMessageById = async function deleteMessageById( +// messageId: string +// ) { +// const messageToDelete = await novu.messages.deleteById(messageId); +// console.log(messageToDelete); +// }; + +// export const triggerNotificationNewParticipant = +// async function triggerNotificationNewParticipant( +// subscriberId: string, +// email: string, +// name: string, +// campaignTitle?: string +// ) { +// const notif = await novu.trigger("new-participant", { +// to: { +// subscriberId: subscriberId, +// }, +// payload: { +// campaign: { title: campaignTitle }, +// participant: { email: email, name: name }, +// }, +// }); +// }; + +// export const campaignCancelled = async function campaignCancelled( +// subscriberId: string, +// campaignTitle: string +// ) { +// const notif = await novu.trigger("campaign-cancelled", { +// to: { +// subscriberId: subscriberId, +// }, +// payload: { +// campaign: { title: campaignTitle }, +// }, +// }); +// }; + +// export const requestReceived = async function requestReceived( +// subscriberId: string +// ) { +// const notif = await novu.trigger("request-received", { +// to: { +// subscriberId: subscriberId, +// }, +// payload: {}, +// }); +// }; + +// export const supportRequested = async function supportRequested( +// subscriberId: string, +// username: string, +// description: string, +// detailed_description: string, +// browsers: any[] +// ) { +// const notif = await novu.trigger("support-requested", { +// to: { +// subscriberId: subscriberId, +// }, +// payload: { +// attachments: [ +// { +// file: fs.readFileSync( +// path.join(__dirname, "../public/problem_screenshot.png") +// ), +// name: "problem_screenshot.jpeg", +// mime: "image/jpg", +// }, +// ], +// username: username, +// form: { +// description: description, +// detailed_description: detailed_description, +// browsers: browsers, +// }, +// }, +// }); +// // const notif = novu.trigger("support-requested", { +// // to: { +// // subscriberId: subscriberId, +// // }, +// // payload: { +// // // attachments: [ +// // // { +// // // file: fs.readFileSync(__dirname + '/data/test.jpeg'), +// // // name: 'test.jpeg', +// // // mime: 'image/jpg', +// // // }, +// // // ], +// // }, +// // overrides: { +// // email: { +// // to: ["gerspammer@gmail.com"], +// // from: "from@novu.co", +// // senderName: "Novu Team", +// // text: "text version of email", +// // replyTo: "no-reply@novu.co", +// // // cc: ["1@novu.co"], +// // // bcc: ["2@novu.co"], +// // integrationIdentifier: "send", +// // }, +// // }, +// // }); +// }; + +// export const mentionedUser = async function mentionedUser( +// subscriberId: string, +// mentionedBy: string, +// slug: string +// ) { +// const notif = await novu.trigger("mentioned-user", { +// to: { +// subscriberId: subscriberId, +// }, +// payload: { +// message: { mentionedBy: mentionedBy }, +// campaign: { slug: slug }, +// }, +// }); +// }; + +// export const createNewSubscriber = async function createNewSubscriber( +// id: string, +// email?: string, +// name?: string +// ) { +// await novu.subscribers.identify(id, { +// email: email, +// firstName: name, +// }); +// }; diff --git a/app/routes/api.boxes.ts b/app/routes/api.boxes.ts index 781222fa1..770574e2d 100644 --- a/app/routes/api.boxes.ts +++ b/app/routes/api.boxes.ts @@ -1,5 +1,5 @@ import { - LoaderFunctionArgs, + type LoaderFunctionArgs, type ActionFunction, type ActionFunctionArgs, } from 'react-router' @@ -9,9 +9,9 @@ import { getUserFromJwt } from '~/lib/jwt' import { createDevice, findDevices, - FindDevicesOptions, + type FindDevicesOptions, } from '~/models/device.server' -import { Device, type User } from '~/schema' +import { type Device, type User } from '~/schema' import { StandardResponse } from '~/utils/response-utils' /** diff --git a/app/routes/campaigns.tsx b/app/routes/campaigns.tsx new file mode 100644 index 000000000..8c4cd767d --- /dev/null +++ b/app/routes/campaigns.tsx @@ -0,0 +1,157 @@ +import { MenuIcon, PlusIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, NavLink, Outlet, useLoaderData, type LoaderFunctionArgs } from "react-router"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +// import Notification from "~/components/header/notification"; +import { Button } from "~/components/ui/button"; +import { getUser } from "~/utils/session.server"; + +export async function loader({ request }: LoaderFunctionArgs) { + return { + user: await getUser(request), + }; +} + +export default function CampaignsPage() { + const data = useLoaderData(); + const { t } = useTranslation("campaigns"); + + // function HamburgerMenu({ links }) { + // const [showMenu, setShowMenu] = useState(false); + + // const toggleMenu = () => { + // setShowMenu(!showMenu); + // }; + + // return ( + //
+ // + // {showMenu && ( + // + // {links.map((item, index) => ( + // + // {item.name} + // + // ))} + // + // )} + //
+ // ); + // } + const [isMobileScreen, setIsMobileScreen] = useState(false); + + useEffect(() => { + const handleResize = () => { + setIsMobileScreen(window.innerWidth <= 768); // Adjust the breakpoint as needed + }; + + handleResize(); // Check on initial render + + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + const links = [ + { + name: "Info", + link: "./info", + }, + { + name: t("explore"), + link: "./explore", + }, + { + name: "Tutorial", + link: "./tutorial", + }, + + { + name: "Support", + link: "./support", + }, + ]; + return ( +
+
+
+
+ + osem Logo + + {/*
*/} + + openSenseMap + + {t("campaigns")} Manager + {/*
*/} +
+ {!isMobileScreen ? ( +
    + {links.map((item, index) => { + return ( +
  • + + isActive + ? "dark:md:hover:text-green-200 block rounded py-2 pl-3 pr-4 underline md:p-0 md:font-thin md:hover:text-green-100" + : "dark:md:hover:text-green-200 block rounded py-2 pl-3 pr-4 md:p-0 md:font-thin md:hover:text-green-100" + } + > + {item.name} + +
  • + ); + })} +
+ ) : ( + + + + + + {links.map((item, index) => { + return ( + + {item.name} + + ); + })} + + + )} + +
+ {/* {data?.user?.email ? : null} */} + + +
+
+ +
+ +
+
+
+ ); +} diff --git a/app/routes/campaigns/$slug.tsx b/app/routes/campaigns/$slug.tsx new file mode 100644 index 000000000..a6f3eed84 --- /dev/null +++ b/app/routes/campaigns/$slug.tsx @@ -0,0 +1,566 @@ +// import clsx from "clsx"; +// import { type FeatureCollection, type Geometry, type GeoJsonProperties } from "geojson"; +// import { +// ClockIcon, +// UsersIcon, +// Share2Icon, +// DownloadIcon, +// TrashIcon, +// StarIcon, +// MailIcon, +// EditIcon, +// } from "lucide-react"; +// import maplibregl from "maplibre-gl/dist/maplibre-gl.css"; +// import Markdown from "markdown-to-jsx"; +// import { useEffect, useRef, useState } from "react"; +// import { useTranslation } from "react-i18next"; +// import { type LayerProps, MapProvider, Source, Layer } from "react-map-gl"; +// import Tribute from "tributejs"; +// import { +// Dialog, +// DialogClose, +// DialogContent, +// DialogDescription, +// DialogFooter, +// DialogHeader, +// DialogTitle, +// DialogTrigger, +// } from "@/components/ui/dialog"; +// import { Button } from "~/components/ui/button"; +// import { getCampaign } from "~/models/campaign.server"; +// import { getUserId, requireUserId } from "~/session.server"; +// import { useToast } from "~/components/ui/use-toast"; +// import { Map } from "~/components/map"; + +// import ShareLink from "~/components/bottom-bar/share-link"; +// // import { updateCampaign } from "~/models/campaign.server"; +// import { Switch } from "~/components/ui/switch"; +// import { downloadGeojSON } from "~/lib/download-geojson"; +// import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +// import { +// publishCommentAction, +// publishPostAction, +// createCampaignEvent, +// deleteCampaignEvent, +// deleteCommentAction, +// updateCampaignEvent, +// deleteCampaignAction, +// updateCommentAction, +// messageAllUsers, +// participate, +// bookmark, +// updateCampaignAction, +// } from "~/lib/actions"; +// import OverviewTable from "~/components/campaigns/campaignId/overview-tab/overview-table"; +// import EventForm from "~/components/campaigns/campaignId/event-tab/create-form"; +// import EventCards from "~/components/campaigns/campaignId/event-tab/event-cards"; +// import CommentInput from "~/components/campaigns/campaignId/comment-tab/comment-input"; +// import CommentCards from "~/components/campaigns/campaignId/comment-tab/comment-cards"; +// import tributeStyles from "tributejs/tribute.css"; +// import { getPhenomena } from "~/models/phenomena.server"; +// // import type { Campaign } from "@prisma/client"; +// import { type Campaign } from "~/schema"; +// import { +// ExposureBadge, +// PriorityBadge, +// } from "~/components/campaigns/overview/campaign-badges"; +// import { ScrollArea } from "~/components/ui/scroll-area"; +// import CampaignTable from "~/components/campaigns/campaignId/table"; +// import CreateThread from "~/components/campaigns/campaignId/posts/create"; +// import ListPosts from "~/components/campaigns/campaignId/posts"; + +// export const links: LinksFunction = () => { +// return [ +// { +// rel: "stylesheet", +// href: maplibregl, +// }, +// { +// rel: "stylesheet", +// href: tributeStyles, +// }, +// ]; +// }; + +// export async function action(args: ActionArgs) { +// const formData = await args.request.clone().formData(); +// const _action = formData.get("_action"); + +// switch (_action) { +// case "PUBLISH": +// return publishCommentAction(args); +// case "CREATE_POST": +// return publishPostAction(args); +// case "DELETE": +// return deleteCommentAction(args); +// case "EDIT": +// return updateCommentAction(args); +// case "CREATE_EVENT": +// return createCampaignEvent(args); +// case "DELETE_EVENT": +// return deleteCampaignEvent(args); +// case "UPDATE_EVENT": +// return updateCampaignEvent(args); +// case "PARTICIPATE": +// return participate(args); +// case "UPDATE_CAMPAIGN": +// return updateCampaignAction(args); +// case "DELETE_CAMPAIGN": +// return deleteCampaignAction(args); +// case "BOOKMARK": +// return bookmark(args); +// case "MESSAGE_ALL": +// return messageAllUsers(args); + +// default: +// throw new Error(`Unknown action: ${_action}`); +// } +// } + +// export const meta: MetaFunction = ({ params }) => ({ +// charset: "utf-8", +// title: "openSenseMap", +// description: `Trage zu dieser Kampagne bei: ${params.slug}`, +// viewport: "width=device-width,initial-scale=1", +// "og:title": "openSenseMap", +// "og:description": `Trage zu dieser Kampagne bei: ${params.slug}`, +// // "og:image": "URL_TO_IMAGE", +// "og:url": `https://magellan.testing.opensensemap.org/`, +// "og:type": "website", +// }); + +// export async function loader({ request, params }: LoaderArgs) { +// // const userId = await requireUserId(request); +// const userId = await getUserId(request); + +// const slug = params.slug ?? ""; + +// const campaign = await getCampaign({ slug }, userId ?? ""); +// if (!campaign) { +// throw new Response("Campaign not found", { status: 502 }); +// } +// // const isBookmarked = !!campaign?.bookmarkedByUsers.length; +// const response = await getPhenomena(); +// if (response.code === "UnprocessableEntity") { +// throw new Response("Phenomena not found", { status: 502 }); +// } +// const phenomena = response.map((p: { slug: string }) => p.slug); +// return json({ campaign, userId, phenomena }); +// } + +// const layer: LayerProps = { +// id: "polygon-data", +// type: "fill", +// source: "polygon", +// paint: { +// "fill-color": "#5394d0", +// "fill-opacity": 0.7, +// }, +// }; + +// export default function CampaignId() { +// const data = useLoaderData(); +// const actionData = useActionData(); +// const { t } = useTranslation("campaign-slug"); +// const campaign = data.campaign; +// // const participants = campaign.participants.map(function (participant) { +// // return { key: participant.name, value: participant.name }; +// // }); +// const participants = [{ key: "Joe", value: "Joe" }]; +// const userId = data.userId; +// // const bookmarked = data.isBookmarked; +// const [editMode, setEditMode] = useState(false); +// const [commentEditMode, setCommentEditMode] = useState(false); +// const [eventEditMode, setEventEditMode] = useState(false); +// const [editEventTitle, setEditEventTitle] = useState(""); +// const [editEventDescription, setEditEventDescription] = useState< +// string | undefined +// >(""); +// const [editEventStartDate, setEditEventStartDate] = useState< +// Date | undefined +// >(); +// const [editEventEndDate, setEditEventEndDate] = useState(); +// const [comment, setComment] = useState(""); +// const [mentions, setMentions] = useState(); +// const [editComment, setEditComment] = useState(""); +// const [editCommentId, setEditCommentId] = useState(""); +// const [eventDescription, setEventDescription] = useState( +// "" +// ); +// const [messageForParticipants, setMessageForParticipants] = useState< +// string | undefined +// >(""); + +// const [showMap, setShowMap] = useState(false); +// const [tabView, setTabView] = useState<"overview" | "calendar" | "comments">( +// "overview" +// ); +// const textAreaRef = useRef(); +// const isBundle = useRef(false); +// const eventTextAreaRef = useRef(); +// const { toast } = useToast(); + +// const tribute = new Tribute({ +// trigger: "@", +// values: participants, +// itemClass: "bg-blue-700 text-black", +// selectTemplate(participant) { +// return `@${participant.original.value}`; +// }, +// }); + +// useEffect(() => { +// if (actionData) { +// if (actionData.bookmarked) { +// toast({ +// description: {t("campaign successfully bookmarked")}, +// }); +// } +// if (actionData.unbookmarked) { +// toast({ +// description: {t("campaign successfully un-bookmarked")}, +// }); +// } +// } +// }, [actionData, t, toast]); + +// useEffect(() => { +// if ( +// textAreaRef.current && +// !isBundle.current && +// Array.isArray(participants) +// ) { +// isBundle.current = true; +// //@ts-ignore +// tribute.attach(textAreaRef.current.textarea); +// //@ts-ignore +// textAreaRef.current.textarea.addEventListener("tribute-replaced", (e) => { +// setComment(e.target.value); +// setMentions(e.detail.item.original.value); +// }); +// } +// // eslint-disable-next-line react-hooks/exhaustive-deps +// }, [textAreaRef.current]); + +// return ( +//
+//
+//
+//

+// {campaign.title} +//

+//
+// {/* */} +// {/* */} +//
+//
+//
+//
+// +// +//
+// {/* +// +// +// + +// +// +// {t("contributors")} +// +//
+// Message all Participants +// +// +//
+//
+//
+// {participants.map((p, i) => { +// return ( +//
+// {p.value}; +// +//
+// ); +// })} +//
+//
*/} +// +// +// +// +// +// +// {t("contribute")} +// +//

+// {t( +// "by clicking on Contribute you agree to be reached out to by the organizer via the email you provided!" +// )} +//

+//

+// {t( +// "please state if you have the required hardware available" +// )} +//

+//
+//
+//
+// +// +// +//
+//
+// +// +//
+//
+// +// +//
+//
+// +// +// +// +// +//
+//
+//
+// +// +// +// +// +// +// {t("share")} +// +// +// +// +// +// +// +//
+// {t("show map")} +// setShowMap(!showMap)} +// /> +//
+// +// +// +// +// +// +// +// {t("Are you sure that you want to delete this campaign")} +// +// +// {t( +// "This action cannot be reversed. Participants will be notified that this campaign was deleted. You can leave a message for the participants in the field below." +// )} +// +// +//
+// +// +// +//
+// +// +// +// +//
+//
+//
+//
+//
+//
+ +//
+// + +//
+// {showMap && ( +// +// +// {campaign.feature && ( +// +// } +// > +// +// +// )} +// +// +// )} +//
+// {/* */} +//
+// + +// +//
+// ); +// } + +// export function CatchBoundary() { +// const caught = useCatch(); +// if (caught.status === 502) { +// return ( +//
+//
+//
+// Oh no, we could not find this Campaign ID. Are you sure it exists? +//
+//
+//
+// ); +// } +// throw new Error(`Unsupported thrown response status code: ${caught.status}`); +// } diff --git a/app/routes/campaigns/explore.tsx b/app/routes/campaigns/explore.tsx new file mode 100644 index 000000000..c9f72fac5 --- /dev/null +++ b/app/routes/campaigns/explore.tsx @@ -0,0 +1,456 @@ +// import { XMarkIcon, ClockIcon } from "@heroicons/react/24/outline"; +// import { +// Form, +// Link, +// useLoaderData, +// useSearchParams, +// useActionData, +// import { type BBox } from "geojson"; +// import maplibregl from "maplibre-gl/dist/maplibre-gl.css"; +// import { useCallback, useEffect, useRef, useState } from "react"; +// import { useTranslation } from "react-i18next"; +// import { +// Layer, +// LngLatBounds, +// MapLayerMouseEvent, +// MapProvider, +// type MapRef, +// MapboxEvent, +// Marker, +// Source, +// } from "react-map-gl"; +// import Filter from "~/components/campaigns/overview/campaign-filter"; +// import CampaignGrid from "~/components/campaigns/overview/grid"; +// import CampaignMap from "~/components/campaigns/overview/map"; +// import PointLayer from "~/components/campaigns/overview/map/point-layer"; +// import Pagination from "~/components/campaigns/overview/pagination"; +// import { +// // getBookmarks, +// getCampaignCount, +// getCampaigns, +// } from "~/models/campaign.server"; +// import { getPhenomena } from "~/models/phenomena.server"; +// // import FiltersBar from "~/components/campaigns/overview/filters-bar"; +// // import type { Campaign, CampaignBookmark, Prisma } from "@prisma/client"; +// import { triggerNotificationNewParticipant } from "~/novu.server"; +// import { type Campaign } from "~/schema"; +// import { Button } from "~/components/ui/button"; +// import { getUserId, requireUserId } from "~/session.server"; +// import { bookmark } from "~/lib/actions"; +// import { generateWhereObject } from "~/components/campaigns/overview/where-query"; +// import { useToast } from "~/components/ui/use-toast"; +// import { Input } from "~/components/ui/input"; + +// const PER_PAGE = 9; + +// export async function action(args: ActionArgs) { +// return bookmark(args); +// } + +// export async function loader({ params, request }: LoaderArgs) { +// const userId = await getUserId(request); +// let bookmarks: any[] = []; +// if (userId) { +// // bookmarks = await getBookmarks({ userId }); +// } +// const allCampaigns = await getCampaigns({}); +// const url = new URL(request.url); +// const query = url.searchParams; +// const currentPage = Math.max(Number(query.get("page") || 1), 1); +// const options: { +// take: number; +// skip: number; +// orderBy: [{}, {}]; +// where?: {}; +// } = { +// take: PER_PAGE, +// skip: (currentPage - 1) * PER_PAGE, +// orderBy: [ +// { +// bookmarks: { +// _count: "desc", +// }, +// }, +// { +// updatedAt: "desc", +// }, +// ], +// where: generateWhereObject(query), +// }; + +// const countOptions = options.where; +// let sort = ""; + +// if (query.get("sortBy")) { +// const sortBy = query.get("sortBy") || "updatedAt"; +// if (sortBy === "priority") { +// sort = "priority"; +// } +// options.orderBy.push({ +// [sortBy]: "desc", +// }); +// } + +// const campaignsOnPage = await getCampaigns(options, userId, sort); +// const campaignCount = await getCampaignCount(); +// const phenos = await getPhenomena(); +// if (phenos.code === "UnprocessableEntity") { +// throw new Response("Phenomena not found", { status: 502 }); +// } +// const phenomena = phenos.map((p: { slug: string }) => p.slug); +// // const data = await campaigns.json(); +// // if (data.code === "UnprocessableEntity") { +// // throw new Response("Campaigns not found", { status: 502 }); +// // } +// return json({ +// allCampaigns, +// bookmarks, +// campaignsOnPage, +// campaignCount, +// phenomena, +// userId, +// }); +// } +// export const links: LinksFunction = () => { +// return [ +// { +// rel: "stylesheet", +// href: maplibregl, +// }, +// ]; +// }; + +// export default function Campaigns() { +// const data = useLoaderData(); +// const actionData = useActionData(); +// const { toast } = useToast(); +// const { t } = useTranslation("explore-campaigns"); +// const allCampaigns = data.allCampaigns as unknown as Campaign[]; +// const bookmarks = data.bookmarks; +// const campaigns = data.campaignsOnPage; +// const phenomena = data.phenomena; +// const campaignCount = data.campaignCount; +// const userId = data.userId; +// // const totalPages = Math.ceil(campaignCount / PER_PAGE); +// const totalPages = 0 +// // const [mapLoaded, setMapLoaded] = useState(false); +// // const [markers, setMarkers] = useState>>( +// // [] +// // ); + +// const [searchParams] = useSearchParams(); +// const mapShown = searchParams.get("showMap"); + +// const [moreFiltersOpen, setMoreFiltersOpen] = useState(false); +// const [phenomenaDropdown, setPhenomenaDropdownOpen] = useState(false); +// const [exposure, setExposure] = useState(""); +// const [showMap, setShowMap] = useState(mapShown === "true" ? true : false); +// // const [searchTerm, setSearchTerm] = useState(""); +// const [sortBy, setSortBy] = useState(""); +// const [priority, setpriority] = useState(""); +// // const [displayedCampaigns, setDisplayedCampaigns] = useState([]); +// const mapRef = useRef(null); +// const [mapBounds, setMapBounds] = useState(); +// const [zoom, setZoom] = useState(1); +// const [filterObject, setFilterObject] = useState({ +// searchTerm: "", +// priority: "", +// country: "", +// exposure: "", +// phenomena: [] as string[], +// time_range: { +// startDate: "", +// endDate: "", +// }, +// }); + +// useEffect(() => { +// // Access the map instance when the component mounts +// const map = mapRef?.current?.getMap(); +// if (map) { +// const bounds = map.getBounds().toArray().flat(); +// setMapBounds(bounds as BBox); +// } +// }, [mapRef]); + +// // useEffect(() => { +// // if (selectedCampaign) { +// // setShowMap(true); +// // } +// // }, [selectedCampaign]); + +// useEffect(() => { +// if (actionData) { +// if (actionData.bookmarked) { +// toast({ +// description: {t("campaign successfully bookmarked")}, +// }); +// } +// if (actionData.unbookmarked) { +// toast({ +// description: {t("campaign successfully un-bookmarked")}, +// }); +// } +// } +// }, [actionData, t, toast]); + +// // const [campaigns, setCampaigns] = useState([]) +// const resetFilters = () => { +// setFilterObject({ +// searchTerm: "", +// priority: "", +// country: "", +// exposure: "", +// phenomena: [], +// time_range: { +// startDate: "", +// endDate: "", +// }, +// }); +// // const allCampaigns = campaigns.map((campaign: Campaign) => { +// // return campaign; +// // }); +// // setDisplayedCampaigns(allCampaigns); +// // setSelectedCampaign(""); +// if (mapRef.current) { +// mapRef.current.flyTo({ +// center: [0, 0], +// duration: 1000, +// zoom: 1, +// }); +// } +// }; + +// // CLIENT-SIDE FILTERING IS REPLACED BY SERVER-SIDE FILTER FOR NOW + +// // const checkTitleMatch = useCallback( +// // (title: string) => { +// // return title +// // .toLowerCase() +// // .includes(filterObject.searchTerm.toLowerCase()); +// // }, +// // [filterObject.searchTerm] +// // ); + +// // const checkPriorityMatch = useCallback( +// // (priority: string) => { +// // return ( +// // !filterObject.priority || +// // priority.toLowerCase() === filterObject.priority.toLowerCase() +// // ); +// // }, +// // [filterObject.priority] +// // ); + +// // const checkCountryMatch = useCallback( +// // (country: string | null) => { +// // if (!country) { +// // return true; +// // } +// // return ( +// // !filterObject.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) => { +// // const filterStartDate = filterObject.time_range.startDate; +// // const filterEndDate = filterObject.time_range.endDate; + +// // if (!filterStartDate || !filterEndDate) { +// // return true; +// // } + +// // if (!startDate) { +// // return false; +// // } + +// // if (!endDate) { +// // return false; +// // } + +// // const campaignStartTimestamp = new Date(startDate).getTime(); +// // const campaignEndTimestamp = new Date(endDate).getTime(); +// // const filterStartTimestamp = new Date(filterStartDate).getTime(); +// // const filterEndTimestamp = new Date(filterEndDate).getTime(); + +// // const isStartDateWithinRange = +// // campaignStartTimestamp >= filterStartTimestamp && +// // campaignStartTimestamp <= filterEndTimestamp; + +// // const isEndDateWithinRange = +// // campaignEndTimestamp >= filterStartTimestamp && +// // campaignEndTimestamp <= filterEndTimestamp; + +// // return isStartDateWithinRange && isEndDateWithinRange; +// // }, +// // [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 titleMatches = checkTitleMatch(campaign.title); +// // const priorityMatches = checkPriorityMatch(campaign.priority); +// // const countryMatches = checkCountryMatch(campaign.country); +// // const exposureMatches = checkExposureMatch(campaign.exposure); +// // const timeRangeMatches = checkTimeRangeMatches( +// // campaign.startDate, +// // campaign.endDate +// // ); +// // // const phenomenaMatches = checkPhenomenaMatch(campaign.phenomena); +// // return ( +// // titleMatches && +// // priorityMatches && +// // countryMatches && +// // exposureMatches && +// // timeRangeMatches +// // // phenomenaMatches +// // ); +// // }); +// // setDisplayedCampaigns(filteredCampaigns); +// // }, [ +// // campaigns, +// // checkCountryMatch, +// // checkExposureMatch, +// // checkPriorityMatch, +// // checkTitleMatch, +// // checkTimeRangeMatches, +// // // checkPhenomenaMatch, +// // filterObject, +// // ]); + +// // useEffect(() => { +// // let sortedCampaigns; + +// // switch (sortBy) { +// // case "erstelldatum": +// // sortedCampaigns = campaigns +// // .slice() +// // .sort((campaignA: Campaign, campaignB: Campaign) => { +// // const createdAtA = new Date(campaignA.createdAt).getTime(); +// // const createdAtB = new Date(campaignB.createdAt).getTime(); +// // return createdAtA - createdAtB; +// // }); +// // break; + +// // case "dringlichkeit": +// // const priorityOrder = { +// // URGENT: 0, +// // HIGH: 1, +// // MEDIUM: 2, +// // LOW: 3, +// // }; + +// // sortedCampaigns = campaigns +// // .slice() +// // .sort((campaignA: Campaign, campaignB: Campaign) => { +// // const priorityA = +// // priorityOrder[campaignA.priority as keyof typeof priorityOrder]; +// // const priorityB = +// // priorityOrder[campaignB.priority as keyof typeof priorityOrder]; + +// // return priorityA - priorityB; +// // }); +// // break; + +// // default: +// // sortedCampaigns = campaigns.slice(); +// // } + +// // setDisplayedCampaigns(sortedCampaigns); +// // }, [campaigns, sortBy]); + +// const handleMapLoad = useCallback(() => { +// const map = mapRef?.current?.getMap(); +// if (map) { +// setMapBounds(map.getBounds().toArray().flat() as BBox); +// } +// }, []); + +// return ( +//
+// +// {/* */} +// {/* {selectedCampaign && ( +//
+// +// { +// setSelectedCampaign(""); +// resetFilters(); +// mapRef.current?.flyTo({ +// center: [0, 0], +// duration: 1000, +// zoom: 1, +// }); +// }} +// className="absolute right-2 top-2 ml-auto h-5 w-5" +// /> +//
+// )} */} +//
+//
+// {!showMap ? ( +// +// ) : ( +// +// )} +//
+//
+// ); +// } diff --git a/app/routes/campaigns/info.tsx b/app/routes/campaigns/info.tsx new file mode 100644 index 000000000..c4fac3582 --- /dev/null +++ b/app/routes/campaigns/info.tsx @@ -0,0 +1,25 @@ +export default function Info() { + return ( +
+

+ Welcome +

+

+ to the +

+

+ OpenSenseMap Campaign Manager +

+

+ Create or contribute to campaigns on the openSenseMap and connect with + fellow citizen scientists to unite efforts for a shared goal! +

+ + Explore Campaigns + +
+ ); +} diff --git a/app/routes/campaigns/support.tsx b/app/routes/campaigns/support.tsx new file mode 100644 index 000000000..efee6b8e8 --- /dev/null +++ b/app/routes/campaigns/support.tsx @@ -0,0 +1,264 @@ +// import { +// InformationCircleIcon, +// ArrowUpTrayIcon, +// } from "@heroicons/react/24/solid"; +// import { useState, useCallback } from "react"; +// import { FileWithPath, useDropzone, DropzoneOptions } from "react-dropzone"; +// import { useTranslation } from "react-i18next"; +// import { +// Card, +// CardContent, +// CardDescription, +// CardHeader, +// CardTitle, +// } from "@/components/ui/card"; +// import { getUserById } from "~/models/user.server"; +// import { requestReceived, supportRequested } from "~/novu.server"; +// import { requireUserId } from "~/session.server"; + +// type FileTypes = { +// [key: string]: string[]; +// }; + +// export async function action({ request }: ActionArgs) { +// const ownerId = await requireUserId(request); +// const user = await getUserById(ownerId); +// const username = user?.name; +// const formData = await request.formData(); +// console.log(formData); +// const description = formData.get("description"); + +// const detailed_description = formData.get("detailed_description"); +// const email = formData.get("email"); +// const files = formData.get("files"); +// const campaignId = formData.get("campaignId"); + +// const browserFieldNames = [ +// "edge", +// "explorer", +// "chrome", +// "firefox", +// "safari", +// "opera", +// "other", +// ]; + +// const browsers = browserFieldNames.filter( +// (browser) => formData.get(browser) === "on" +// ); + +// const request_Received = await requestReceived(ownerId); + +// const requestSupport = await supportRequested( +// "64ac170290b5785d47096d3c", +// username as string, +// description as string, +// "put detailed description here", +// browsers +// ); + +// // console.log(requestSupport); + +// return redirect("/campaigns/explore"); +// } + +// export default function Support() { +// const [files, setFiles] = useState([]); +// const { t } = useTranslation("support"); + +// const onDrop = useCallback((acceptedFiles: File[]) => { +// setFiles((prevFiles) => [...prevFiles, ...acceptedFiles]); +// console.log("Dropped files:", acceptedFiles); +// }, []); + +// const { getRootProps, getInputProps, isDragActive } = useDropzone({ +// onDrop, +// accept: { +// image: [".jpeg", ".png", ".gif", "image/jpeg", "image/png", "image/gif"], +// } as FileTypes, +// }); + +// const filesList = () => +// files.map((file) => ( +//
  • +// {file.name} - {file.size} bytes +//
  • +// )); + +// return ( +//
    +//
    +//

    +// {t("use this form to receive technical support")}
    {" "} +// {t("for issues that arise while creating or managing campaigns.")} +//

    +//
    +//
    +//
    +//
    +//
    +// +//

    +// {t( +// "we are sorry that you have encountered an issue! Please provide as much information as possible about how this problem occurred. This will assist us in efficiently resolving the issue." +// )} +//

    +//
    +// +//
    +// +// {/* {actionData?.errors?.description && ( +//
    +// {actionData.errors.email} +//
    +// )} */} +//
    +//
    +// +//
    +// +// +// {() => ( +// <> +// {/* */} +// + +//
    +// +// Bild hinzufügen +// +// +// Markdown unterstützt +// +//
    +// +// )} +//
    +// {/*