diff --git a/.env.example b/.env.example index 663ee9ef..99c6ea7c 100644 --- a/.env.example +++ b/.env.example @@ -1,7 +1,23 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/opensensemap" SHADOW_DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" + SESSION_SECRET="super-duper-s3cret" + MAPBOX_ACCESS_TOKEN="" MAPBOX_GEOCODING_API="https://api.mapbox.com/geocoding/v5/mapbox.places/" + OSEM_API_URL="https://api.opensensemap.org/" DIRECTUS_URL="https://coelho.opensensemap.org" + +MYBADGES_API_URL = "https://api.v2.mybadges.org/" +MYBADGES_URL = "https://mybadges.org/" +MYBADGES_SERVERADMIN_USERNAME = "" +MYBADGES_SERVERADMIN_PASSWORD = "" +MYBADGES_ISSUERID_OSEM = "" +MYBADGES_CLIENT_ID = "" +MYBADGES_CLIENT_SECRET = "" + +NOVU_API_KEY= "" +NOVU_API_URL = "" +NOVU_WEBSOCKET_URL = "" +NOVU_APPLICATION_IDENTIFIER = "" diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 00000000..8150d9fd --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,15 @@ +version: 2 + +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "npm" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index effd793e..15096b7b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,6 +9,7 @@ on: permissions: actions: write contents: read + packages: write jobs: lint: @@ -140,13 +141,6 @@ jobs: - name: ⬇️ Checkout repo uses: actions/checkout@v3 - - name: 👀 Read app name - uses: SebRollen/toml-action@v1.0.2 - id: app_name - with: - file: "fly.toml" - field: "app" - - name: 🐳 Set up Docker Buildx uses: docker/setup-buildx-action@v2 with: @@ -161,67 +155,37 @@ jobs: restore-keys: | ${{ runner.os }}-buildx- - # - name: 🔑 Fly Registry Auth - # uses: docker/login-action@v2 - # with: - # registry: registry.fly.io - # username: x - # password: ${{ secrets.FLY_API_TOKEN }} - - # - name: 🐳 Docker build - # uses: docker/build-push-action@v4 - # with: - # context: . - # push: true - # tags: registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }} - # build-args: | - # COMMIT_SHA=${{ github.sha }} - # cache-from: type=local,src=/tmp/.buildx-cache - # cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new + - name: Docker meta + id: meta + uses: docker/metadata-action@v4 + with: + images: ghcr.io/opensensemap/frontend + + - name: 🔑 GitHub Registry Auth + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: 🐳 Docker build + uses: docker/build-push-action@v4 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + COMMIT_SHA=${{ github.sha }} + cache-from: type=local,src=/tmp/.buildx-cache + cache-to: type=local,mode=max,dest=/tmp/.buildx-cache-new # This ugly bit is necessary if you don't want your cache to grow forever # till it hits GitHub's limit of 5GB. # Temp fix # https://github.com/docker/build-push-action/issues/252 # https://github.com/moby/buildkit/issues/1896 - # - name: 🚚 Move cache - # run: | - # rm -rf /tmp/.buildx-cache - # mv /tmp/.buildx-cache-new /tmp/.buildx-cache - - # deploy: - # name: 🚀 Deploy - # runs-on: ubuntu-latest - # needs: [lint, typecheck, vitest, cypress, build] - # # only build/deploy main branch on pushes - # if: ${{ (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev') && github.event_name == 'push' }} - - # steps: - # - name: 🛑 Cancel Previous Runs - # uses: styfle/cancel-workflow-action@0.11.0 - - # - name: ⬇️ Checkout repo - # uses: actions/checkout@v3 - - # - name: 👀 Read app name - # uses: SebRollen/toml-action@v1.0.2 - # id: app_name - # with: - # file: "fly.toml" - # field: "app" - - # - name: 🚀 Deploy Staging - # if: ${{ github.ref == 'refs/heads/dev' }} - # uses: superfly/flyctl-actions@1.3 - # with: - # args: "deploy --app ${{ steps.app_name.outputs.value }}-staging --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" - # env: - # FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} - - # - name: 🚀 Deploy Production - # if: ${{ github.ref == 'refs/heads/main' }} - # uses: superfly/flyctl-actions@1.3 - # with: - # args: "deploy --image registry.fly.io/${{ steps.app_name.outputs.value }}:${{ github.ref_name }}-${{ github.sha }}" - # env: - # FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + - name: 🚚 Move cache + run: | + rm -rf /tmp/.buildx-cache + mv /tmp/.buildx-cache-new /tmp/.buildx-cache diff --git a/ERD.svg b/ERD.svg new file mode 100644 index 00000000..819cd2e6 --- /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 00000000..3ee872a7 --- /dev/null +++ b/app/components/Map/Map.tsx @@ -0,0 +1,44 @@ +import type { MapProps, MapRef } from "react-map-gl"; +import { NavigationControl, Map as ReactMap } from "react-map-gl"; +import { forwardRef } from "react"; + +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/cluster/index.ts b/app/components/Map/cluster/index.ts deleted file mode 100644 index e4f9dd28..00000000 --- a/app/components/Map/cluster/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { Device } from "@prisma/client"; -import type { GeoJsonProperties } from "geojson"; - -// colors to use for the categories -const colors = ["#4EAF47", "#666", "#666"]; - -export type ClusterPropertiesType = GeoJsonProperties & - Device & { - active: number; - inactive: number; - old: number; - cluster: boolean; - cluster_id: number; - point_count: number; - point_count_abbreviated: number | string; - }; - -// code for creating an SVG donut chart from feature properties -const createDonutChart = (props: any) => { - const offsets = []; - const counts = [props.active, props.inactive, props.old]; - let total = 0; - for (const count of counts) { - offsets.push(total); - total += count; - } - const fontSize = - total >= 1000 ? 14 : total >= 100 ? 10 : total >= 10 ? 5 : 14; - const r = total >= 1000 ? 36 : total >= 100 ? 20 : total >= 10 ? 10 : 18; - const r0 = Math.round(r * 0.6); - const w = r * 2; - - let html = `
-`; - - for (let i = 0; i < counts.length; i++) { - html += donutSegment( - offsets[i] / total, - (offsets[i] + counts[i]) / total, - r, - r0, - colors[i] - ); - } - html += ` - -${total.toLocaleString()} - - -
`; - - const el = document.createElement("div"); - el.innerHTML = html; - - return el.firstChild; -}; - -function donutSegment( - start: number, - end: number, - r: number, - r0: number, - color: string -) { - if (end - start === 1) end -= 0.00001; - const a0 = 2 * Math.PI * (start - 0.25); - const a1 = 2 * Math.PI * (end - 0.25); - const x0 = Math.cos(a0), - y0 = Math.sin(a0); - const x1 = Math.cos(a1), - y1 = Math.sin(a1); - const largeArc = end - start > 0.5 ? 1 : 0; - - // draw an SVG path - return ``; -} - -export default createDonutChart; diff --git a/app/components/Map/draw-control.tsx b/app/components/Map/draw-control.tsx new file mode 100644 index 00000000..d6dd5c22 --- /dev/null +++ b/app/components/Map/draw-control.tsx @@ -0,0 +1,125 @@ +import MapboxDraw, { modes } from "@mapbox/mapbox-gl-draw"; +import { useControl } 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"; +import type { MapRef, ControlPosition } from "react-map-gl"; + +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 00000000..c7accd4c --- /dev/null +++ b/app/components/Map/geocoder-control.tsx @@ -0,0 +1,89 @@ +import type { ControlPosition } from "react-map-gl"; +import { useControl } from "react-map-gl"; +import maplibregl from "maplibre-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 00000000..6a64dcd8 --- /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 numeral from "numeral"; +import lineDistance from "@turf/line-distance"; + +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; // eslint-disable-line + 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/bottom-bar/bottom-bar.tsx b/app/components/bottom-bar/bottom-bar.tsx index 80a9b1be..6c93881a 100644 --- a/app/components/bottom-bar/bottom-bar.tsx +++ b/app/components/bottom-bar/bottom-bar.tsx @@ -14,8 +14,9 @@ import { import Graph from "./graph"; import * as ToastPrimitive from "@radix-ui/react-toast"; import { clsx } from "clsx"; -import type { Prisma, Sensor } from "@prisma/client"; -import type { DeviceWithSensors } from "types"; +// import type { Prisma, Sensor } from "@prisma/client"; +import type { Sensor } from "~/schema"; +// import type { DeviceWithSensors } from "types"; import Spinner from "../spinner"; import { Card, CardContent } from "../ui/card"; @@ -25,7 +26,7 @@ export interface LastMeasurementProps { } export interface DeviceAndSelectedSensors { - device: DeviceWithSensors; + device: any; selectedSensors: Sensor[]; } @@ -110,7 +111,7 @@ export default function BottomBar(data: DeviceAndSelectedSensors) { {data.device.sensors.map((sensor: Sensor) => { // dont really know why this is necessary - some kind of TypeScript/i18n bug? const lastMeasurement = - sensor.lastMeasurement as Prisma.JsonObject; + sensor.lastMeasurement as any; const value = lastMeasurement.value as string; return (
+ createPopup( + "twitter", + getTwitterLink(message, window.location.href, ["OpenStreetMap"]) + ); + + const facebookPopup = (message: string) => + createPopup("facebook", getFacebookLink(message, window.location.href)); + + const linkedInPopup = (message: string) => + createPopup("linkedin", getLinkedInLink(window.location.href)); + + const whatsappPopup = (message: string) => + createPopup("whatsapp", getWhatsAppLink(message, window.location.href)); + + const instagramPopup = (caption: string) => + createPopup("instagram", getInstagramLink(caption, window.location.href)); + + const telegramPopup = (text: string) => + createPopup("telegram", getTelegramLink(text, window.location.href)); + return (
{/* */} -
+
facebookPopup("Trage zu dieser Kampagne bei")} + >
{/* */} -
+
twitterPopup("Trage zu diese Kampagne bei")} + >
{/* */} -
+
instagramPopup("")} + >
{/* */} -
+
whatsappPopup("")} + >
{/* */} -
+
telegramPopup("")} + >
+ {/* LINKEDIN ICON */} +
linkedInPopup("")} + > + + + +
{/* */}
diff --git a/app/components/campaigns/area/map.tsx b/app/components/campaigns/area/map.tsx new file mode 100644 index 00000000..f17debe7 --- /dev/null +++ b/app/components/campaigns/area/map.tsx @@ -0,0 +1,215 @@ +import { TrashIcon } from "lucide-react"; +import type { Dispatch, SetStateAction } from "react"; +import { useCallback, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { MapLayerMouseEvent, PopupProps } from "react-map-gl"; +import { MapProvider, Source, Layer, Popup } from "react-map-gl"; +import { Map } from "~/components/map"; +import DrawControl from "~/components/Map/draw-control"; +import GeocoderControl from "~/components/Map/geocoder-control"; +import { Button } from "~/components/ui/button"; +import { + Popover, + PopoverAnchor, + PopoverArrow, + PopoverContent, +} from "~/components/ui/popover"; +import normalize from "@mapbox/geojson-normalize"; +import flatten from "geojson-flatten"; +import type { FeatureCollection, GeoJsonProperties, Geometry } from "geojson"; + +type MapProps = { + mapRef: any; + // handleMapClick: (e: MapLayerMouseEvent) => void + drawPopoverOpen: boolean; + setDrawPopoverOpen: Dispatch>; + // onUpdate: (e: any) => void + // onDelete: (e: any) => void + geojsonUploadData: FeatureCollection | null; + setGeojsonUploadData: Dispatch< + SetStateAction | null> + >; + // popup: PopupProps | false + // setPopup: Dispatch> + setFeatures: (features: any) => void; +}; + +export default function DefineAreaMap({ + setFeatures, + drawPopoverOpen, + setDrawPopoverOpen, + mapRef, + geojsonUploadData, + setGeojsonUploadData, +}: MapProps) { + const { t } = useTranslation("campaign-area"); + const [popup, setPopup] = useState(); + + const onUpdate = useCallback( + (e: any) => { + setGeojsonUploadData(null); + // if (e.features[0].properties.radius) { + // const coordinates = [ + // e.features[0].geometry.coordinates[0], + // e.features[0].geometry.coordinates[1], + // ]; //[lon, lat] + // const radius = parseInt(e.features[0].properties.radius); // in meters + // const options = { numberOfEdges: 32 }; //optional, defaults to { numberOfEdges: 32 } + + // const polygon = circleToPolygon(coordinates, radius, options); + // const updatedFeatures = { + // type: "Feature", + // geometry: { + // type: "Polygon", + // coordinates: polygon.coordinates[0].map((c) => { + // return [c[0], c[1]]; + // }), + // }, + // properties: { + // radius: radius, + // centerpoint: e.features[0].geometry.coordinates, + // }, + // }; + // console.log(updatedFeatures); + // setFeatures(updatedFeatures); + // } else { + setFeatures((currFeatures: any) => { + const updatedFeatures = e.features.map((f: any) => { + return { ...f }; + }); + const normalizedFeatures = normalize(updatedFeatures[0]); + const flattenedFeatures = flatten(normalizedFeatures); + return flattenedFeatures; + }); + }, + // }, + [setFeatures, setGeojsonUploadData] + ); + + const onDelete = useCallback( + (e: any) => { + setFeatures((currFeatures: any) => { + const newFeatures = { ...currFeatures }; + + for (const feature of e.features) { + if (feature.id) { + // Filter out the feature with the matching 'id' + newFeatures.features = newFeatures.features.filter( + (f: any) => f.id !== feature.id + ); + } + } + return newFeatures; + }); + }, + [setFeatures] + ); + + const handleMapClick = useCallback( + (e: MapLayerMouseEvent) => { + if (geojsonUploadData != null) { + const { lngLat } = e; + setPopup({ + latitude: lngLat.lat, + longitude: lngLat.lng, + className: "p-4", + children: ( +
+ {geojsonUploadData.features.map((f: any, index: number) => ( +
+ {Object.entries(f.properties).map(([key, value]) => ( +
+ {key}: {value as string} +
+ ))} +
+ ))} + +
+ ), + }); + } + }, + [geojsonUploadData, setFeatures, setGeojsonUploadData] + ); + + return ( + + (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 00000000..4d0423fa --- /dev/null +++ b/app/components/campaigns/campaignId/comment-tab/comment-cards.tsx @@ -0,0 +1,145 @@ +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Form } from "@remix-run/react"; +import { Button } from "~/components/ui/button"; +import { TrashIcon, EditIcon } from "lucide-react"; +import { ClientOnly } from "remix-utils"; +import { MarkdownEditor } from "~/markdown.client"; +import Markdown from "markdown-to-jsx"; +// import type { Comment } from "@prisma/client"; +import type { Comment } from "~/schema"; + +type CommentCardsProps = { + comments: any; + userId: string; + setCommentEditMode: (e: boolean) => void; + setEditCommentId: (e: string | undefined) => void; + setEditComment: (e: string | undefined) => void; + commentEditMode: boolean; + textAreaRef: any; + editComment: string; +}; + +export default function CommentCards({ + comments, + userId, + setCommentEditMode, + setEditComment, + setEditCommentId, + textAreaRef, + commentEditMode, + editComment, +}: CommentCardsProps) { + return ( +
+ {comments.map((c: Comment, i: number) => { + return ( +
+ + + +
+ + + CN + + {/* @ts-ignore */} + {c.owner.name} +
+ {userId === c.userId && ( +
+ +
+ + +
+
+ )} +
+
+ + {commentEditMode ? ( + + {() => ( +
+ +
+ + Bild hinzufügen + + + Markdown unterstützt + +
+
+ + + +
+
+ )} +
+ ) : ( + {c.content} + )} +
+
+
+ ); + })} +
+ ); +} diff --git a/app/components/campaigns/campaignId/comment-tab/comment-input.tsx b/app/components/campaigns/campaignId/comment-tab/comment-input.tsx new file mode 100644 index 00000000..61d11b6b --- /dev/null +++ b/app/components/campaigns/campaignId/comment-tab/comment-input.tsx @@ -0,0 +1,93 @@ +import { Form } from "@remix-run/react"; +import { ClientOnly } from "remix-utils"; +import { Button } from "~/components/ui/button"; +import { MarkdownEditor } from "~/markdown.client"; +import { useNavigate } from "@remix-run/react"; +// import Tribute from "tributejs"; +// import tributeStyles from "tributejs/tribute.css"; +// import type { LinksFunction } from "@remix-run/node"; +// import { useEffect } from "react"; + +// export const links: LinksFunction = () => { +// return [{ rel: "stylesheet", href: tributeStyles }]; +// }; + +type CommentInputProps = { + textAreaRef: any; + comment: string | undefined; + setComment: any; + setCommentEditMode: (editMode: boolean) => void; + mentions?: string[]; +}; + +export default function CommentInput({ + textAreaRef, + comment, + setComment, + setCommentEditMode, + mentions, +}: CommentInputProps) { + const navigate = useNavigate(); + + // useEffect(() => { + // if (textAreaRef.current) { + // var tribute = new Tribute({ + // trigger: "@", + // values: [ + // { key: "Phil Heartman", value: "pheartman" }, + // { key: "Gordon Ramsey", value: "gramsey" }, + // ], + // itemClass: "bg-blue-700 text-black", + // }); + // tribute.attach(textAreaRef.current.textarea); + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [textAreaRef.current]); + + return ( + + {() => ( +
+ +
+ Bild hinzufügen + + Markdown unterstützt + +
+
+ + + +
+
+ )} +
+ ); +} diff --git a/app/components/campaigns/campaignId/comment-tab/comment-replies.tsx b/app/components/campaigns/campaignId/comment-tab/comment-replies.tsx new file mode 100644 index 00000000..622cc88a --- /dev/null +++ b/app/components/campaigns/campaignId/comment-tab/comment-replies.tsx @@ -0,0 +1,87 @@ +import { Form } from "@remix-run/react"; +import { ClientOnly } from "remix-utils"; +import { Button } from "~/components/ui/button"; +import { MarkdownEditor } from "~/markdown.client"; +import { useNavigate } from "@remix-run/react"; +import { useState } from "react"; + +type CommentInputProps = { + textAreaRef: any; + comment: string | undefined; + setComment: any; + setCommentEditMode: (editMode: boolean) => void; + mentions?: string[]; +}; + +export default function Reply({ + textAreaRef, + comment, + setComment, + setCommentEditMode, + mentions, +}: CommentInputProps) { + const navigate = useNavigate(); + const [reply, setReply] = useState("") + + // useEffect(() => { + // if (textAreaRef.current) { + // var tribute = new Tribute({ + // trigger: "@", + // values: [ + // { key: "Phil Heartman", value: "pheartman" }, + // { key: "Gordon Ramsey", value: "gramsey" }, + // ], + // itemClass: "bg-blue-700 text-black", + // }); + // tribute.attach(textAreaRef.current.textarea); + // } + // // eslint-disable-next-line react-hooks/exhaustive-deps + // }, [textAreaRef.current]); + + return ( + + {() => ( +
+ +
+ Bild hinzufügen + + Markdown unterstützt + +
+
+ + + +
+
+ )} +
+ ); +} diff --git a/app/components/campaigns/campaignId/event-tab/create-form.tsx b/app/components/campaigns/campaignId/event-tab/create-form.tsx new file mode 100644 index 00000000..a239b5aa --- /dev/null +++ b/app/components/campaigns/campaignId/event-tab/create-form.tsx @@ -0,0 +1,138 @@ +import { Form } from "@remix-run/react"; +import { ClientOnly } from "remix-utils"; +import { MarkdownEditor } from "~/markdown.client"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +type EventFormProps = { + eventDescription: string; + setEventDescription: any; + eventTextAreaRef: any; +}; + +export default function EventForm({ + eventDescription, + setEventDescription, + eventTextAreaRef, +}: EventFormProps) { + return ( +
+ + Noch keine Events für diese Kampagne.{" "} + {" "} + + +

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 00000000..1e94d672 --- /dev/null +++ b/app/components/campaigns/campaignId/event-tab/event-cards.tsx @@ -0,0 +1,205 @@ +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "~/components/ui/button"; +import { EditIcon, TrashIcon } from "lucide-react"; +import { Form } from "@remix-run/react"; +import { ClientOnly } from "remix-utils"; +import { MarkdownEditor } from "~/markdown.client"; +import Markdown from "markdown-to-jsx"; + +type EventCardsProps = { + events: any[]; + eventEditMode: boolean; + setEventEditMode: any; + setEditEventTitle: any; + userId: string; + eventTextAreaRef: any; + editEventDescription: string; + setEditEventDescription: any; + editEventTitle: string; + setEditEventStartDate: any; +}; + +export default function EventCards({ + events, + eventEditMode, + editEventTitle, + setEditEventStartDate, + editEventDescription, + setEditEventDescription, + eventTextAreaRef, + setEventEditMode, + setEditEventTitle, + userId, +}: EventCardsProps) { + return ( +
+ {events.map((e: any, i: number) => ( +
+ + + +
+ {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 00000000..c162f418 --- /dev/null +++ b/app/components/campaigns/campaignId/overview-tab/edit-table.tsx @@ -0,0 +1,317 @@ +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { Campaign } from "~/schema"; +// import type { Campaign } from "@prisma/client"; +import { Form } from "@remix-run/react"; +import { Switch } from "@/components/ui/switch"; +import { + ChevronDown, + EditIcon, + SaveIcon, + TrashIcon, + XIcon, +} from "lucide-react"; +import Markdown from "markdown-to-jsx"; +import { Button } from "~/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { priorityEnum, exposureEnum } from "~/schema"; +// import { Priority, Exposure } from "@prisma/client"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useState, useRef } from "react"; +import { MarkdownEditor } from "~/markdown.client"; +import { ClientOnly } from "remix-utils"; +import { useTranslation } from "react-i18next"; +import { CountryDropdown } from "../../overview/country-dropdown"; + +type EditTableProps = { + setEditMode: any; + campaign: any; + phenomena: string[]; +}; + +export default function EditTable({ + setEditMode, + campaign, + phenomena, +}: EditTableProps) { + const descriptionRef = useRef(); + const [title, setTitle] = useState(campaign.title); + const [editDescription, setEditDescription] = useState( + campaign.description + ); + const [priority, setPriority] = useState(campaign.priority); + const [startDate, setStartDate] = useState(campaign.startDate); + const [endDate, setEndDate] = useState(campaign.endDate); + const [minimumParticipants, setMinimumParticipants] = useState( + campaign.minimumParticipants + ); + const [openDropdown, setDropdownOpen] = useState(false); + const [phenomenaState, setPhenomenaState] = useState( + Object.fromEntries(phenomena.map((p: string) => [p, false])) + ); + const [exposure, setExposure] = useState(campaign.exposure); + const [country, setCountry] = useState(campaign.country); + const { t } = useTranslation("edit-campaign-table"); + + return ( +
+ +
+ + +
+ + + + {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 00000000..4b50696b --- /dev/null +++ b/app/components/campaigns/campaignId/overview-tab/overview-table.tsx @@ -0,0 +1,151 @@ +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { Campaign } from "~/schema"; +// import type { Campaign } from "@prisma/client"; +import { Form } from "@remix-run/react"; +import { EditIcon, SaveIcon, TrashIcon, XIcon } from "lucide-react"; +import Markdown from "markdown-to-jsx"; +import { Button } from "~/components/ui/button"; +import { useState, useRef } from "react"; +import EditTable from "./edit-table"; +import { CountryFlagIcon } from "~/components/ui/country-flag"; +import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; +import { HoverCard } from "~/components/ui/hover-card"; +import { HoverCardContent, HoverCardTrigger } from "@radix-ui/react-hover-card"; + +type OverviewTableProps = { + campaign: Campaign; + userId: string; + phenomena: string[]; +}; + +export default function OverviewTable({ + campaign, + userId, + phenomena, +}: OverviewTableProps) { + const [editMode, setEditMode] = useState(false); + const [editDescription, setEditDescription] = useState( + "" + ); + const descriptionRef = useRef(); + const instructions = campaign.instructions + ? campaign.instructions.toString() + : ""; + return ( +
+ {userId === campaign.ownerId && !editMode && ( + + )} + {!editMode ? ( + <> +
+

Contributors

+
+ + + + + JR + + + +
+

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 00000000..2dd1c3a8 --- /dev/null +++ b/app/components/campaigns/campaignId/posts/create.tsx @@ -0,0 +1,31 @@ +import { Form } from "@remix-run/react"; +import { Button } from "~/components/ui/button"; + +type Props = { + loggedIn: boolean; +}; + +export default function CreateThread({ loggedIn }: Props) { + if (loggedIn) { + return ( +
+ 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 00000000..8ab6dc4f --- /dev/null +++ b/app/components/campaigns/campaignId/posts/index.tsx @@ -0,0 +1,92 @@ +import { Form, useActionData } from "@remix-run/react"; +import { useState } from "react"; +import { Button } from "~/components/ui/button"; +import { action } from "~/routes/campaigns/$slug"; +import { Comment, Post } from "~/schema"; + +type Props = { + posts: Post[]; +}; + +interface ShowReplyFields { + [postId: string]: boolean; +} + +export default function ListPosts({ posts }: Props) { + const comments = useActionData(); + console.log(comments); + const initialState: ShowReplyFields = posts.reduce( + (acc: ShowReplyFields, post) => { + acc[post.id] = false; + return acc; + }, + {} + ); + + const [showReplyFields, setShowReplyFields] = + useState(initialState); + + const handleReplyClick = (postId: string) => { + setShowReplyFields((prevShowReplyFields) => ({ + ...prevShowReplyFields, + [postId]: !prevShowReplyFields[postId], + })); + }; + return ( +
    + {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 00000000..0108cab4 --- /dev/null +++ b/app/components/campaigns/campaignId/table/buttons.tsx @@ -0,0 +1,42 @@ +import { EditIcon, SaveIcon, XIcon } from "lucide-react"; +import { Button } from "~/components/ui/button"; + +type Props = { + setEditMode?: any; + t: any; +}; + +export function EditButton({ setEditMode, t }: Props) { + return ( + + ); +} + +export function CancelButton({ setEditMode, t }: Props) { + return ( + + ); +} + +export function SaveButton({ t }: Props) { + return ( + + ); +} diff --git a/app/components/campaigns/campaignId/table/edit-components/description.tsx b/app/components/campaigns/campaignId/table/edit-components/description.tsx new file mode 100644 index 00000000..2a901b3b --- /dev/null +++ b/app/components/campaigns/campaignId/table/edit-components/description.tsx @@ -0,0 +1,45 @@ +import { ClientOnly } from "remix-utils"; +import { MarkdownEditor } from "~/markdown.client"; + +type Props = { + editDescription: any; + descriptionRef: any; + setEditDescription: any; + t: any; +}; +export function EditDescription({ + editDescription, + setEditDescription, + descriptionRef, + t, +}: Props) { + return ( + <> + + + {() => ( + <> + +
+ + {t("add image")} + + + {t("markdown supported")} + +
+ + )} +
+ + ); +} diff --git a/app/components/campaigns/campaignId/table/edit-components/phenomena.tsx b/app/components/campaigns/campaignId/table/edit-components/phenomena.tsx new file mode 100644 index 00000000..333d445a --- /dev/null +++ b/app/components/campaigns/campaignId/table/edit-components/phenomena.tsx @@ -0,0 +1,68 @@ +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { t } from "i18next"; +import { ChevronDown } from "lucide-react"; +import { Button } from "~/components/ui/button"; + +type Props = { + phenomenaState: any; + setPhenomenaState: any; + openDropdown: any; + setDropdownOpen: any; + phenomena: any; +}; + +export default function PhenomenaDropdown({ + phenomenaState, + setPhenomenaState, + openDropdown, + setDropdownOpen, + phenomena, +}: Props) { + return ( + + + + + + {phenomena.map((p: any) => { + return ( + { + setPhenomenaState({ + ...phenomenaState, + [p]: !phenomenaState[p], + }); + }} + onSelect={(event) => event.preventDefault()} + > + {p} + + ); + })} + + + ); +} diff --git a/app/components/campaigns/campaignId/table/index.tsx b/app/components/campaigns/campaignId/table/index.tsx new file mode 100644 index 00000000..4ed46195 --- /dev/null +++ b/app/components/campaigns/campaignId/table/index.tsx @@ -0,0 +1,280 @@ +import { + Table, + TableBody, + TableCaption, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { Campaign } from "~/schema"; +// import type { Campaign } from "@prisma/client"; +import { Form } from "@remix-run/react"; +import { Switch } from "@/components/ui/switch"; +import Markdown from "markdown-to-jsx"; +import { priorityEnum, exposureEnum } from "~/schema"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useState, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { CountryDropdown } from "../../overview/country-dropdown"; +import PhenomenaDropdown from "./edit-components/phenomena"; +import { EditButton, CancelButton, SaveButton } from "./buttons"; +import { EditDescription } from "./edit-components/description"; + +type EditTableProps = { + owner: boolean; + campaign: any; + phenomena: string[]; +}; + +export default function CampaignTable({ + owner, + campaign, + phenomena, +}: EditTableProps) { + const descriptionRef = useRef(); + const [editMode, setEditMode] = useState(false); + const [title, setTitle] = useState(campaign.title); + const [editDescription, setEditDescription] = useState( + campaign.description + ); + const [priority, setPriority] = useState(campaign.priority); + const [startDate, setStartDate] = useState(campaign.startDate); + const [endDate, setEndDate] = useState(campaign.endDate); + const [minimumParticipants, setMinimumParticipants] = useState( + campaign.minimumParticipants + ); + const [openDropdown, setDropdownOpen] = useState(false); + const [phenomenaState, setPhenomenaState] = useState( + Object.fromEntries(phenomena.map((p: string) => [p, false])) + ); + const [exposure, setExposure] = useState(campaign.exposure); + const [country, setCountry] = useState(campaign.country); + const { t } = useTranslation("edit-campaign-table"); + + return ( +
+ + + + + {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 00000000..6aa6c496 --- /dev/null +++ b/app/components/campaigns/overview/all-countries-object.ts @@ -0,0 +1,251 @@ +export const countryListAlpha2 = { + AF: "Afghanistan", + AL: "Albania", + DZ: "Algeria", + AS: "American Samoa", + AD: "Andorra", + AO: "Angola", + AI: "Anguilla", + AQ: "Antarctica", + AG: "Antigua and Barbuda", + AR: "Argentina", + AM: "Armenia", + AW: "Aruba", + AU: "Australia", + AT: "Austria", + AZ: "Azerbaijan", + BS: "Bahamas (the)", + BH: "Bahrain", + BD: "Bangladesh", + BB: "Barbados", + BY: "Belarus", + BE: "Belgium", + BZ: "Belize", + BJ: "Benin", + BM: "Bermuda", + BT: "Bhutan", + BO: "Bolivia (Plurinational State of)", + BQ: "Bonaire, Sint Eustatius and Saba", + BA: "Bosnia and Herzegovina", + BW: "Botswana", + BV: "Bouvet Island", + BR: "Brazil", + IO: "British Indian Ocean Territory (the)", + BN: "Brunei Darussalam", + BG: "Bulgaria", + BF: "Burkina Faso", + BI: "Burundi", + CV: "Cabo Verde", + KH: "Cambodia", + CM: "Cameroon", + CA: "Canada", + KY: "Cayman Islands (the)", + CF: "Central African Republic (the)", + TD: "Chad", + CL: "Chile", + CN: "China", + CX: "Christmas Island", + CC: "Cocos (Keeling) Islands (the)", + CO: "Colombia", + KM: "Comoros (the)", + CD: "Congo (the Democratic Republic of the)", + CG: "Congo (the)", + CK: "Cook Islands (the)", + CR: "Costa Rica", + HR: "Croatia", + CU: "Cuba", + CW: "Curaçao", + CY: "Cyprus", + CZ: "Czechia", + CI: "Côte d'Ivoire", + DK: "Denmark", + DJ: "Djibouti", + DM: "Dominica", + DO: "Dominican Republic (the)", + EC: "Ecuador", + EG: "Egypt", + SV: "El Salvador", + GQ: "Equatorial Guinea", + ER: "Eritrea", + EE: "Estonia", + SZ: "Eswatini", + ET: "Ethiopia", + FK: "Falkland Islands (the) [Malvinas]", + FO: "Faroe Islands (the)", + FJ: "Fiji", + FI: "Finland", + FR: "France", + GF: "French Guiana", + PF: "French Polynesia", + TF: "French Southern Territories (the)", + GA: "Gabon", + GM: "Gambia (the)", + GE: "Georgia", + DE: "Germany", + GH: "Ghana", + GI: "Gibraltar", + GR: "Greece", + GL: "Greenland", + GD: "Grenada", + GP: "Guadeloupe", + GU: "Guam", + GT: "Guatemala", + GG: "Guernsey", + GN: "Guinea", + GW: "Guinea-Bissau", + GY: "Guyana", + HT: "Haiti", + HM: "Heard Island and McDonald Islands", + VA: "Holy See (the)", + HN: "Honduras", + HK: "Hong Kong", + HU: "Hungary", + IS: "Iceland", + IN: "India", + ID: "Indonesia", + IR: "Iran (Islamic Republic of)", + IQ: "Iraq", + IE: "Ireland", + IM: "Isle of Man", + IL: "Israel", + IT: "Italy", + JM: "Jamaica", + JP: "Japan", + JE: "Jersey", + JO: "Jordan", + KZ: "Kazakhstan", + KE: "Kenya", + KI: "Kiribati", + KP: "Korea (the Democratic People's Republic of)", + KR: "Korea (the Republic of)", + KW: "Kuwait", + KG: "Kyrgyzstan", + LA: "Lao People's Democratic Republic (the)", + LV: "Latvia", + LB: "Lebanon", + LS: "Lesotho", + LR: "Liberia", + LY: "Libya", + LI: "Liechtenstein", + LT: "Lithuania", + LU: "Luxembourg", + MO: "Macao", + MG: "Madagascar", + MW: "Malawi", + MY: "Malaysia", + MV: "Maldives", + ML: "Mali", + MT: "Malta", + MH: "Marshall Islands (the)", + MQ: "Martinique", + MR: "Mauritania", + MU: "Mauritius", + YT: "Mayotte", + MX: "Mexico", + FM: "Micronesia (Federated States of)", + MD: "Moldova (the Republic of)", + MC: "Monaco", + MN: "Mongolia", + ME: "Montenegro", + MS: "Montserrat", + MA: "Morocco", + MZ: "Mozambique", + MM: "Myanmar", + NA: "Namibia", + NR: "Nauru", + NP: "Nepal", + NL: "Netherlands (the)", + NC: "New Caledonia", + NZ: "New Zealand", + NI: "Nicaragua", + NE: "Niger (the)", + NG: "Nigeria", + NU: "Niue", + NF: "Norfolk Island", + MP: "Northern Mariana Islands (the)", + NO: "Norway", + OM: "Oman", + PK: "Pakistan", + PW: "Palau", + PS: "Palestine, State of", + PA: "Panama", + PG: "Papua New Guinea", + PY: "Paraguay", + PE: "Peru", + PH: "Philippines (the)", + PN: "Pitcairn", + PL: "Poland", + PT: "Portugal", + PR: "Puerto Rico", + QA: "Qatar", + MK: "Republic of North Macedonia", + RO: "Romania", + RU: "Russian Federation (the)", + RW: "Rwanda", + RE: "Réunion", + BL: "Saint Barthélemy", + SH: "Saint Helena, Ascension and Tristan da Cunha", + KN: "Saint Kitts and Nevis", + LC: "Saint Lucia", + MF: "Saint Martin (French part)", + PM: "Saint Pierre and Miquelon", + VC: "Saint Vincent and the Grenadines", + WS: "Samoa", + SM: "San Marino", + ST: "Sao Tome and Principe", + SA: "Saudi Arabia", + SN: "Senegal", + RS: "Serbia", + SC: "Seychelles", + SL: "Sierra Leone", + SG: "Singapore", + SX: "Sint Maarten (Dutch part)", + SK: "Slovakia", + SI: "Slovenia", + SB: "Solomon Islands", + SO: "Somalia", + ZA: "South Africa", + GS: "South Georgia and the South Sandwich Islands", + SS: "South Sudan", + ES: "Spain", + LK: "Sri Lanka", + SD: "Sudan (the)", + SR: "Suriname", + SJ: "Svalbard and Jan Mayen", + SE: "Sweden", + CH: "Switzerland", + SY: "Syrian Arab Republic", + TW: "Taiwan", + TJ: "Tajikistan", + TZ: "Tanzania, United Republic of", + TH: "Thailand", + TL: "Timor-Leste", + TG: "Togo", + TK: "Tokelau", + TO: "Tonga", + TT: "Trinidad and Tobago", + TN: "Tunisia", + TR: "Turkey", + TM: "Turkmenistan", + TC: "Turks and Caicos Islands (the)", + TV: "Tuvalu", + UG: "Uganda", + UA: "Ukraine", + AE: "United Arab Emirates (the)", + GB: "United Kingdom of Great Britain and Northern Ireland (the)", + UM: "United States Minor Outlying Islands (the)", + US: "United States of America (the)", + UY: "Uruguay", + UZ: "Uzbekistan", + VU: "Vanuatu", + VE: "Venezuela (Bolivarian Republic of)", + VN: "Viet Nam", + VG: "Virgin Islands (British)", + VI: "Virgin Islands (U.S.)", + WF: "Wallis and Futuna", + EH: "Western Sahara", + YE: "Yemen", + ZM: "Zambia", + ZW: "Zimbabwe", + AX: "Åland Islands", +}; diff --git a/app/components/campaigns/overview/campaign-badges.tsx b/app/components/campaigns/overview/campaign-badges.tsx new file mode 100644 index 00000000..ec81813d --- /dev/null +++ b/app/components/campaigns/overview/campaign-badges.tsx @@ -0,0 +1,51 @@ +// import type { Exposure, Priority } from "@prisma/client"; +import { priorityEnum, exposureEnum } from "~/schema"; +import clsx from "clsx"; +import { ClockIcon } from "lucide-react"; +import { Badge } from "~/components/ui/badge"; + +type PriorityBadgeProps = { + priority: keyof typeof priorityEnum; +}; + +type ExposureBadgeProps = { + exposure: keyof typeof exposureEnum; +}; + +export function PriorityBadge({ priority }: PriorityBadgeProps) { + const prio = priority.toString().toLowerCase(); + return ( + + {" "} + {prio} + + ); +} + +export function ExposureBadge({ exposure }: ExposureBadgeProps) { + const exposed = exposure.toString().toLowerCase(); + if (exposed === "unknown") { + return null; + } + return ( + + {exposed} + + ); +} diff --git a/app/components/campaigns/overview/campaign-filter.tsx b/app/components/campaigns/overview/campaign-filter.tsx new file mode 100644 index 00000000..9ee22469 --- /dev/null +++ b/app/components/campaigns/overview/campaign-filter.tsx @@ -0,0 +1,288 @@ +import { Form, useSearchParams } from "@remix-run/react"; +import { Button } from "~/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Switch } from "~/components/ui/switch"; +import { + AlertCircleIcon, + ArrowDownAZIcon, + ChevronDown, + ChevronUp, + FilterXIcon, +} from "lucide-react"; +// import { Priority } from "@prisma/client"; +import { priorityEnum } from "~/schema"; +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import clsx from "clsx"; +import FiltersModal from "./filters-modal"; +import { Label } from "~/components/ui/label"; + +type FilterProps = { + switchDisabled: boolean; + showMap: boolean; + setShowMap: (e: boolean) => void; + phenomena: string[]; +}; + +export default function Filter({ + switchDisabled, + showMap, + setShowMap, + phenomena, +}: FilterProps) { + const { t } = useTranslation("explore-campaigns"); + const [searchParams] = useSearchParams(); + const [phenomenaState, setPhenomenaState] = useState( + Object.fromEntries(phenomena.map((p: string) => [p, false])) + ); + const [filterObject, setFilterObject] = useState({ + searchTerm: "", + priority: "", + country: "", + exposure: "", + phenomena: [] as string[], + time_range: { + startDate: "", + endDate: "", + }, + }); + + const [sortBy, setSortBy] = useState("updatedAt"); + const [showMobileFilters, setShowMobileFilters] = useState(false); + return ( + <> +
+ + setShowMap(!showMap)} + /> + + +
+ {!showMap && ( +
+
+
+ + {/* 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 00000000..9854676e --- /dev/null +++ b/app/components/campaigns/overview/country-dropdown.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { countryListAlpha2 } from "./all-countries-object"; +import { CountryFlagIcon } from "~/components/ui/country-flag"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ScrollArea } from "~/components/ui/scroll-area"; + +type CountryDropdownProps = { + setCountry?: (country: string) => void; +}; + +export function CountryDropdown({ setCountry }: CountryDropdownProps) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(""); + + const countries = Object.values(countryListAlpha2); + + return ( + + + + + + + + No country found. + + + {Object.entries(countryListAlpha2).map( + ([countryCode, countryName], index: number) => { + const flagIcon = CountryFlagIcon({ + country: String(countryCode).toUpperCase(), + }); + + return ( + { + setValue(countryName); + if (setCountry) { + setCountry(countryCode); + } + setOpen(false); + }} + > + {flagIcon !== undefined ? ( + <> + {flagIcon} + {countryName} + + ) : ( + <>Flag not available for {countryName} + )} + + ); + } + )} + + + + + + ); +} diff --git a/app/components/campaigns/overview/filters-bar.tsx b/app/components/campaigns/overview/filters-bar.tsx new file mode 100644 index 00000000..df91f7b9 --- /dev/null +++ b/app/components/campaigns/overview/filters-bar.tsx @@ -0,0 +1,169 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "~/components/ui/button"; +import FiltersModal from "./filters-modal"; +import { Switch } from "~/components/ui/switch"; +import { + AlertCircleIcon, + ArrowDownAZIcon, + ChevronDown, + FilterXIcon, +} from "lucide-react"; +import type { Dispatch, SetStateAction } from "react"; +import { useTranslation } from "react-i18next"; +// import { Priority } from "@prisma/client"; +import { priorityEnum, zodPriorityEnum } from "~/schema"; +import clsx from "clsx"; + +type FiltersBarProps = { + phenomena: string[]; + phenomenaState: { + [k: string]: any; + }; + setPhenomenaState: Dispatch< + SetStateAction<{ + [k: string]: any; + }> + >; + filterObject: { + searchTerm: string; + priority: string; + country: string; + exposure: string; + phenomena: string[]; + time_range: { + startDate: string; + endDate: string; + }; + }; + setFilterObject: Dispatch< + SetStateAction<{ + searchTerm: string; + priority: string; + country: string; + exposure: string; + phenomena: string[]; + time_range: { + startDate: string; + endDate: string; + }; + }> + >; + sortBy: string; + setSortBy: Dispatch>; + switchDisabled: boolean; + showMap: boolean; + setShowMap: Dispatch>; + resetFilters: () => void; +}; + +export default function FiltersBar({ + phenomena, + phenomenaState, + setPhenomenaState, + filterObject, + setFilterObject, + sortBy, + setSortBy, + switchDisabled, + showMap, + setShowMap, + resetFilters, +}: FiltersBarProps) { + const { t } = useTranslation("explore-campaigns"); + return ( +
+ + + + + + + setFilterObject({ ...filterObject, priority: e }) + } + > + {Object.values(priorityEnum.enumValues).map((priority: zodPriorityEnum, index: number) => { + return ( + + {priority} + + ); + })} + + + + + + + + + + + + {t("priority")} + + + {t("creation date")} + + + + + + +
+ {t("show map")} + setShowMap(!showMap)} + /> +
+
+ ); +} diff --git a/app/components/campaigns/overview/filters-modal.tsx b/app/components/campaigns/overview/filters-modal.tsx new file mode 100644 index 00000000..7f1b20b3 --- /dev/null +++ b/app/components/campaigns/overview/filters-modal.tsx @@ -0,0 +1,292 @@ +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Button } from "~/components/ui/button"; +import { CountryDropdown } from "./country-dropdown"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Popover, + PopoverAnchor, + PopoverArrow, + PopoverContent, + PopoverTrigger, +} from "~/components/ui/popover"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ChevronDown, FilterIcon } from "lucide-react"; +import { ScrollArea } from "~/components/ui/scroll-area"; +import type { Dispatch, SetStateAction } from "react"; +import { useState } from "react"; +import { exposureEnum } from "~/schema"; +// import { Exposure } from "@prisma/client"; +import { useTranslation } from "react-i18next"; +import PhenomenaSelect from "../phenomena-select"; + +type FiltersModalProps = { + phenomena: string[]; + phenomenaState: { + [k: string]: any; + }; + setPhenomenaState: Dispatch< + SetStateAction<{ + [k: string]: any; + }> + >; + filterObject: { + searchTerm: string; + priority: string; + country: string; + exposure: string; + phenomena: string[]; + time_range: { + startDate: string; + endDate: string; + }; + }; + setFilterObject: Dispatch< + SetStateAction<{ + searchTerm: string; + priority: string; + country: string; + exposure: string; + phenomena: string[]; + time_range: { + startDate: string; + endDate: string; + }; + }> + >; +}; + +export default function FiltersModal({ + phenomena, + phenomenaState, + setPhenomenaState, + filterObject, + setFilterObject, +}: FiltersModalProps) { + const [moreFiltersOpen, setMoreFiltersOpen] = useState(false); + const [popoverOpen, setPopoverOpen] = useState(false); + const [phenomenaDropdown, setPhenomenaDropdownOpen] = useState(false); + const [localFilterObject, setLocalFilterObject] = useState({ + country: "", + exposure: "", + phenomena: [] as string[], + time_range: { + startDate: "", + endDate: "", + }, + }); + const { t } = useTranslation("campaign-filters-modal"); + + return ( + + + + + {/* */} + + + {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 00000000..1e5f1a72 --- /dev/null +++ b/app/components/campaigns/overview/grid.tsx @@ -0,0 +1,186 @@ +// import type { Campaign, CampaignBookmark, User } from "@prisma/client"; +import type { Campaign } from "~/schema"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Link, Form } from "@remix-run/react"; +import { ExposureBadge, PriorityBadge } from "./campaign-badges"; +import { PlusIcon, StarIcon } from "lucide-react"; +import { Progress } from "~/components/ui/progress"; +import Markdown from "markdown-to-jsx"; +import { CountryFlagIcon } from "~/components/ui/country-flag"; +import { useTranslation } from "react-i18next"; +import Pagination from "./pagination"; + +type CampaignGridProps = { + campaigns: any[]; + showMap: boolean; + userId: string; + campaignCount: number; + totalPages: number; + // bookmarks: CampaignBookmark[]; +}; + +export default function CampaignGrid({ + campaigns, + showMap, + userId, + campaignCount, + totalPages, + // bookmarks, +}: CampaignGridProps) { + const { t } = useTranslation("explore-campaigns"); + + const CampaignInfo = () => ( + + {campaigns.length} {t("of")} {campaignCount} {t("campaigns are shown")} + + ); + + if (campaigns.length === 0) { + return ( +
+ {t("no campaigns yet")}. {" "} +
+ {t("click")}{" "} + + {t("here")} + {" "} + {t("to create a campaign")} +
+
+ ); + } + return ( +
+ + {campaigns.map((item: Campaign, index: number) => { + // const isBookmarked = + // userId && + // bookmarks.find( + // (bookmark: CampaignBookmark) => + // bookmark.userId === userId && bookmark.campaignId === item.id + // ); + return ( + + + + +
+
+
+ + +
+
+
+ {/* */} + {/* */} +
+
+
+
+ {item.title} +
+ {item.countries && item.countries.map( + (country: string, index: number) => { + if (index === 2) { + return ( + + ); + } + const flagIcon = CountryFlagIcon({ + country: String(country).toUpperCase(), + }); + if (!flagIcon) return null; + return ( +
+
{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 00000000..76438f6e --- /dev/null +++ b/app/components/campaigns/overview/list-page-options.ts @@ -0,0 +1,41 @@ +//original function here: https://github.com/hotosm/tasking-manager/blob/5136d12ede6c06d87d764085353efbcbd2fe5d2f/frontend/src/components/paginator/index.js#L70 +export function listPageOptions(page: number, lastPage: number) { + let pageOptions: (string | number)[] = [1]; + if (lastPage === 0) { + return pageOptions; + } + if (page === 0 || page > lastPage) { + return pageOptions.concat([2, "...", lastPage]); + } + if (lastPage > 5) { + if (page < 3) { + return pageOptions.concat([2, 3, "...", lastPage]); + } + if (page === 3) { + return pageOptions.concat([2, 3, 4, "...", lastPage]); + } + if (page === lastPage) { + return pageOptions.concat(["...", page - 2, page - 1, lastPage]); + } + if (page === lastPage - 1) { + return pageOptions.concat(["...", page - 1, page, lastPage]); + } + if (page === lastPage - 2) { + return pageOptions.concat(["...", page - 1, page, page + 1, lastPage]); + } + return pageOptions.concat([ + "...", + page - 1, + page, + page + 1, + "...", + lastPage, + ]); + } else { + let range = []; + for (let i = 1; i <= lastPage; i++) { + range.push(i); + } + return range; + } +} diff --git a/app/components/campaigns/overview/map/index.tsx b/app/components/campaigns/overview/map/index.tsx new file mode 100644 index 00000000..52e73ea6 --- /dev/null +++ b/app/components/campaigns/overview/map/index.tsx @@ -0,0 +1,489 @@ +import { Input } from "~/components/ui/input"; +import { + Layer, + LngLatBounds, + LngLatLike, + MapLayerMouseEvent, + MapProvider, + MapRef, + MapboxEvent, + Marker, + Source, +} from "react-map-gl"; +import { Map } from "~/components/map"; +import { + ChangeEvent, + Dispatch, + SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import type { BBox } from "geojson"; +import PointLayer from "~/components/campaigns/overview/map/point-layer"; +// import { Campaign, Exposure, Priority, Prisma } from "@prisma/client"; +import { Campaign, exposureEnum, priorityEnum } from "~/schema"; +import { Link } from "@remix-run/react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Label } from "~/components/ui/label"; +import { CountryDropdown } from "../country-dropdown"; +import PhenomenaSelect from "../../phenomena-select"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; +import { CalendarIcon } from "lucide-react"; +import { addDays, format } from "date-fns"; +import { DateRange } from "react-day-picker"; +import { DataItem } from "~/components/ui/multi-select"; + +export default function CampaignMap({ + campaigns, + phenomena, +}: { + campaigns: Campaign[]; + // setDisplayedCampaigns: Dispatch>; + phenomena: string[]; +}) { + type PriorityType = keyof typeof priorityEnum; + type ExposureType = keyof typeof exposureEnum; + + const mapRef = useRef(null); + const [mapBounds, setMapBounds] = useState(); + const [zoom, setZoom] = useState(1); + const [searchTerm, setSearchTerm] = useState(""); + const [filterObject, setFilterObject] = useState<{ + priority: PriorityType | ""; + country: string; + exposure: ExposureType | ""; + phenomena: string[]; + time_range: DateRange | undefined; + }>({ + priority: "", + country: "", + exposure: "", + phenomena: [], + time_range: { + from: undefined, + to: undefined, + }, + }); + const [filteredCampaigns, setFilteredCampaigns] = + useState(campaigns); + + const [selectedPhenomena, setSelectedPhenomena] = useState([]); + + const [visibleCampaigns, setVisibleCampaigns] = useState([]); + + const handleMapLoad = useCallback(() => { + const map = mapRef?.current?.getMap(); + if (map) { + setMapBounds(map.getBounds().toArray().flat() as BBox); + } + }, []); + + //show only campaigns in sidebar that are within map view + const handleMapMouseMove = useCallback( + (event: MapLayerMouseEvent) => { + const map = mapRef?.current?.getMap(); + if (map) { + const bounds = map.getBounds(); + const visibleCampaigns: Campaign[] = campaigns.filter( + (campaign: Campaign) => { + const centerObject = campaign.centerpoint as any; + const geometryObject = centerObject.geometry as any; + const coordinates = geometryObject.coordinates; + if (coordinates && Array.isArray(coordinates)) + return bounds.contains([ + coordinates[0] as number, + coordinates[1] as number, + ]); + } + ); + console.log(filteredCampaigns); + const visibleAndFiltered = filteredCampaigns.filter( + (filtered_campaign) => + visibleCampaigns.some( + (visible_campaign) => visible_campaign.id === filtered_campaign.id + ) + ); + setVisibleCampaigns(visibleAndFiltered); + // setFilteredCampaigns(visibleAndFiltered); + } + }, + [campaigns, filteredCampaigns] + ); + + const handleInputChange = (event: ChangeEvent) => { + setSearchTerm(event.target.value); + }; + + useEffect(() => { + setFilteredCampaigns( + campaigns.filter((campaign: Campaign) => + campaign.title.includes(searchTerm.toLocaleLowerCase()) + ) + ); + }, [campaigns, searchTerm]); + + useEffect(() => { + setFilterObject({ + ...filterObject, + phenomena: selectedPhenomena.map((p) => p.label), + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedPhenomena]); + + const checkPriorityMatch = useCallback( + (priority: string) => { + return ( + !filterObject.priority || + priority.toLowerCase() === filterObject.priority.toLowerCase() + ); + }, + [filterObject.priority] + ); + + const checkCountryMatch = useCallback( + (countries: string[] | null) => { + if (!countries || countries.length === 0) { + return true; + } + return ( + !filterObject.country || + countries.some( + (country) => + country.toLowerCase() === filterObject.country.toLowerCase() + ) + ); + }, + [filterObject.country] + ); + + const checkExposureMatch = useCallback( + (exposure: string) => { + return ( + !filterObject.exposure || + exposure.toLowerCase() === filterObject.exposure.toLowerCase() + ); + }, + [filterObject.exposure] + ); + + const checkTimeRangeMatches = useCallback( + (startDate: Date | null, endDate: Date | null) => { + if ( + !filterObject.time_range || + !filterObject.time_range.from || + !filterObject.time_range.to + ) + return true; + + const dateRange = [ + filterObject.time_range.from, + filterObject.time_range.to, + ]; + + function inRange(element: Date, index: number, array: any) { + if (!startDate || !endDate) { + return false; + } + const campaignStartTimestamp = new Date(startDate).getTime(); + const campaignEndTimestamp = new Date(endDate).getTime(); + const filterTimeStamp = new Date(element).getTime(); + + return ( + filterTimeStamp >= campaignStartTimestamp && + filterTimeStamp <= campaignEndTimestamp + ); + } + + return dateRange.some(inRange); + }, + [filterObject.time_range] + ); + + const checkPhenomenaMatch = useCallback( + (phenomena: string[]) => { + const filterPhenomena: string[] = filterObject.phenomena; + + if (filterPhenomena.length === 0) { + return true; + } + + const hasMatchingPhenomena = phenomena.some((phenomenon) => + filterPhenomena.includes(phenomenon) + ); + + return hasMatchingPhenomena; + }, + [filterObject.phenomena] + ); + + useEffect(() => { + console.log(filterObject); + const filteredCampaigns = campaigns.slice().filter((campaign: Campaign) => { + const priorityMatches = checkPriorityMatch(campaign.priority ?? ''); + const countryMatches = checkCountryMatch(campaign.countries); + const exposureMatches = checkExposureMatch(campaign.exposure ?? ''); + const timeRangeMatches = checkTimeRangeMatches( + campaign.startDate, + campaign.endDate + ); + const phenomenaMatches = checkPhenomenaMatch(campaign.phenomena ?? []); + return ( + priorityMatches && + countryMatches && + exposureMatches && + timeRangeMatches && + phenomenaMatches + ); + }); + setFilteredCampaigns(filteredCampaigns); + }, [ + campaigns, + checkCountryMatch, + checkExposureMatch, + checkPriorityMatch, + checkTimeRangeMatches, + checkPhenomenaMatch, + filterObject, + ]); + + return ( + <> +
+
+ +
+ + + + More Filters + + + + Filter by Priority + + { + setFilterObject({ ...filterObject, priority: e }); + }} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + setFilterObject({ ...filterObject, country: e }) + } + /> + + + Filter by Exposure + + + setFilterObject({ ...filterObject, exposure: e }) + } + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+ + + + + + { + setFilterObject({ + ...filterObject, + time_range: { + from: e?.from, + to: e?.to, + }, + }); + }} + numberOfMonths={2} + /> + + +
+
+
+
+ +
+ {visibleCampaigns.map((campaign: Campaign) => { + return ( + + {campaign.title} + + ); + })} +
+
+ + 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 00000000..3037d3ca --- /dev/null +++ b/app/components/campaigns/overview/map/point-layer.tsx @@ -0,0 +1,321 @@ +import type { + BBox, + Feature, + GeoJsonProperties, + Geometry, + GeometryCollection, +} from "geojson"; +import type { Dispatch, SetStateAction } from "react"; +import { useMemo, useCallback, useState, useEffect } from "react"; +import { Layer, Marker, Popup, Source, useMap } from "react-map-gl"; +import type { PointFeature } from "supercluster"; +import useSupercluster from "use-supercluster"; +import debounce from "lodash.debounce"; +// import type { Campaign, Prisma } from "@prisma/client"; +import type { Campaign } from "~/schema"; +import type { DeviceClusterProperties } from "~/routes/explore"; +import { useSearchParams } from "@remix-run/react"; +import { FeatureCollection, Properties } from "@turf/helpers"; +import { + Table, + TableBody, + TableCaption, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +type PointProperties = { + title: string; + cluster: boolean; + point_count: number; + id: string; + // color: string; + // selected: boolean; +}; + +const DEBOUNCE_VALUE = 50; + +const options = { + radius: 50, + maxZoom: 14, +}; + +export default function PointLayer({ + campaigns, +}: // setDisplayedCampaigns, +{ + campaigns: Campaign[]; + // setDisplayedCampaigns: Dispatch>; +}) { + const { osem: mapRef } = useMap(); + const [bounds, setBounds] = useState( + mapRef?.getMap().getBounds().toArray().flat() as BBox + ); + const [zoom, setZoom] = useState(mapRef?.getZoom() || 0); + const [selectedMarker, setSelectedMarker] = useState(""); + const [selectedCampaign, setSelectedCampaign] = useState(); + + const centerpoints = campaigns + .map((campaign: Campaign) => { + if ( + typeof campaign.centerpoint === "object" && + campaign.centerpoint !== null && + "geometry" in campaign.centerpoint + ) { + const centerObject = campaign.centerpoint as any; + const geometryObject = centerObject.geometry as any; + if (centerObject && geometryObject) { + return { + coordinates: geometryObject.coordinates, + title: campaign.title, + id: campaign.id, + }; + } + } else { + return null; + } + }) + .filter((coords) => coords !== null); + + const points: PointFeature[] = + useMemo(() => { + return centerpoints.map( + (point: PointFeature) => ({ + type: "Feature", + properties: { + cluster: false, + point_count: 1, + color: "blue", + selected: false, + title: point?.title ?? "", + id: point?.id?.toString() ?? "", + }, + geometry: { + type: "Point", + // @ts-ignore + coordinates: point.coordinates, + }, + }) + ); + }, [centerpoints]); + + const debouncedChangeHandler = debounce(() => { + if (!mapRef) return; + setBounds(mapRef.getMap().getBounds().toArray().flat() as BBox); + setZoom(mapRef.getZoom()); + }, DEBOUNCE_VALUE); + + // register the debounced change handler to map events + useEffect(() => { + if (!mapRef) return; + + mapRef?.getMap().on("load", debouncedChangeHandler); + mapRef?.getMap().on("zoom", debouncedChangeHandler); + mapRef?.getMap().on("move", debouncedChangeHandler); + mapRef?.getMap().on("resize", debouncedChangeHandler); + }, [debouncedChangeHandler, mapRef]); + + function createGeoJson(clusters: any) { + const filteredClusters = clusters.filter( + (cluster: any) => cluster.properties.cluster + ); + const features: Feature[] = filteredClusters.map((cluster: any) => ({ + type: "Feature", + geometry: { + type: "Point", + coordinates: [ + cluster.geometry.coordinates.longitude, + cluster.geometry.coordinates.latitude, + ], + }, + properties: { + id: cluster.id, + }, + })); + return { + type: "FeatureCollection", + features: features, + }; + } + + const { clusters, supercluster } = useSupercluster({ + points, + bounds, + zoom, + options, + }); + + const geojsonData = useMemo(() => createGeoJson(clusters), [clusters]); + console.log(geojsonData); + + const handleClusterClick = useCallback( + (cluster: DeviceClusterProperties) => { + // supercluster from hook can be null or undefined + if (!supercluster) return; + + const [longitude, latitude] = cluster.geometry.coordinates; + + const expansionZoom = Math.min( + supercluster.getClusterExpansionZoom(cluster.id as number), + 20 + ); + + mapRef?.getMap().flyTo({ + center: [longitude, latitude], + animate: true, + speed: 1.6, + zoom: expansionZoom, + essential: true, + }); + }, + [mapRef, supercluster] + ); + + const handleMarkerClick = useCallback( + (markerId: string, latitude: number, longitude: number) => { + const clickedCampaign = campaigns.filter( + (campaign: Campaign) => campaign.id === markerId + ); + // const url = new URL(window.location.href); + // const query = url.searchParams; + // query.set("search", selectedCampaign[0].title); + // query.set("showMap", "true"); + // window.location.href = url.toString(); + // searchParams.append("search", selectedCampaign[0].title); + + setSelectedMarker(markerId); + // setDisplayedCampaigns(selectedCampaign); + setSelectedCampaign(clickedCampaign[0]); + mapRef?.flyTo({ + center: [longitude, latitude], + duration: 1000, + zoom: 6, + }); + }, + [ + campaigns, + mapRef, + // setDisplayedCampaigns, + setSelectedCampaign, + setSelectedMarker, + ] + ); + + const clusterMarker = useMemo(() => { + return clusters.map((cluster) => { + // every cluster point has coordinates + const [longitude, latitude] = cluster.geometry.coordinates; + // the point may be either a cluster or a crime point + const { cluster: isCluster, point_count: pointCount } = + cluster.properties; + + // we have a cluster to render + if (isCluster) { + return ( + +
handleClusterClick(cluster)} + > + {pointCount} +
+
+ ); + } + + // we have a single device to render + return ( + <> + + handleMarkerClick(cluster.properties.id, latitude, longitude) + } + > + {selectedMarker === cluster.properties.id && ( + setSelectedMarker("")} + anchor="bottom" + maxWidth="400px" + style={{ + maxHeight: "208px", + overflowY: "scroll", + }} + > + + + {selectedCampaign?.title} + + + + Description + {selectedCampaign?.description} + + + Priority + {selectedCampaign?.priority} + + + Exposure + {selectedCampaign?.exposure} + {" "} + + StartDate + + {selectedCampaign?.startDate && + new Date(selectedCampaign?.startDate) + .toISOString() + .split("T")[0]} + + + + Phenomena + + {selectedCampaign?.phenomena.map((p, i) => ( + {p} + ))} + + + +
+
+ )} + + {cluster.properties.title} + + + ); + }); + }, [ + clusters, + handleClusterClick, + handleMarkerClick, + points.length, + selectedMarker, + ]); + + return <>{clusterMarker}; +} diff --git a/app/components/campaigns/overview/map/sidebar.tsx b/app/components/campaigns/overview/map/sidebar.tsx new file mode 100644 index 00000000..e69de29b diff --git a/app/components/campaigns/overview/pagination.tsx b/app/components/campaigns/overview/pagination.tsx new file mode 100644 index 00000000..4a8a09ec --- /dev/null +++ b/app/components/campaigns/overview/pagination.tsx @@ -0,0 +1,67 @@ +// code from https://github.com/AustinGil/npm/blob/main/app/components/Pagination.jsx + +import React from "react"; +import { Link, useSearchParams } from "@remix-run/react"; +import { Button } from "~/components/ui/button"; +import { listPageOptions } from "./list-page-options"; + +const Pagination = ({ + totalPages = Number.MAX_SAFE_INTEGER, + pageParam = "page", + className = "", + ...attrs +}) => { + const [queryParams] = useSearchParams(); + const currentPage = Number(queryParams.get(pageParam) || 1); + totalPages = Number(totalPages); + + const previousQuery = new URLSearchParams(queryParams); + previousQuery.set(pageParam, (currentPage - 1).toString()); + const nextQuery = new URLSearchParams(queryParams); + nextQuery.set(pageParam, (currentPage + 1).toString()); + + const pageOptions = listPageOptions(currentPage, totalPages); + + return ( + + ); +}; + +export default Pagination; diff --git a/app/components/campaigns/overview/where-query.ts b/app/components/campaigns/overview/where-query.ts new file mode 100644 index 00000000..1c7e9ab1 --- /dev/null +++ b/app/components/campaigns/overview/where-query.ts @@ -0,0 +1,75 @@ +export const generateWhereObject = (query: URLSearchParams) => { + const where: { + title?: { + contains: string; + mode: "insensitive"; + }; + priority?: string; + country?: { + contains: string; + mode: "insensitive"; + }; + exposure?: string; + startDate?: { + gte: Date; + }; + endDate?: { + lte: Date; + }; + phenomena?: any; + } = {}; + + if (query.get("search")) { + where.title = { + contains: query.get("search") || "", + mode: "insensitive", + }; + } + + if (query.get("priority")) { + const priority = query.get("priority") || ""; + where.priority = priority; + } + + if (query.get("country")) { + where.country = { + contains: query.get("country") || "", + mode: "insensitive", + }; + } + + if (query.get("exposure")) { + const exposure = query.get("exposure") || "UNKNOWN"; + where.exposure = exposure; + } + if (query.get("phenomena")) { + const phenomenaString = query.get("phenomena") || ""; + try { + const phenomena = JSON.parse(phenomenaString); + + if (Array.isArray(phenomena) && phenomena.length > 0) { + where.phenomena = { + hasSome: phenomena, + }; + } + } catch (error) { + console.error("Error parsing JSON:", error); + } + } + + if (query.get("startDate")) { + const startDate = new Date(query.get("startDate") || ""); + where.startDate = { + gte: startDate, + }; + } + + if (query.get("endDate")) { + const endDate = new Date(query.get("endDate") || ""); + where.endDate = { + lte: endDate, + }; + } + + return where; +}; diff --git a/app/components/campaigns/phenomena-select.tsx b/app/components/campaigns/phenomena-select.tsx new file mode 100644 index 00000000..70167fc0 --- /dev/null +++ b/app/components/campaigns/phenomena-select.tsx @@ -0,0 +1,49 @@ +import type { Dispatch, SetStateAction } from "react"; +import type { DataItem } from "../ui/multi-select"; +import { MultiSelect } from "../ui/multi-select"; + +type PhenomenaSelectProps = { + phenomena: string[]; + setSelected: React.Dispatch>; + localFilterObject?: { + country: string; + exposure: string; + phenomena: string[]; + time_range: { + startDate: string; + endDate: string; + }; + }; + setLocalFilterObject?: Dispatch< + SetStateAction<{ + country: string; + exposure: string; + phenomena: string[]; + time_range: { + startDate: string; + endDate: string; + }; + }> + >; + setSelectedPhenomena?: any; +}; + +export default function PhenomenaSelect({ + phenomena, + setSelected, +}: PhenomenaSelectProps) { + const data = phenomena.map((str) => { + return { + value: str, + label: str, + }; + }); + return ( + + ); +} diff --git a/app/components/campaigns/select-countries.tsx b/app/components/campaigns/select-countries.tsx new file mode 100644 index 00000000..ce156761 --- /dev/null +++ b/app/components/campaigns/select-countries.tsx @@ -0,0 +1,28 @@ +import { MultiSelect } from "../ui/multi-select"; +import { countryListAlpha2 } from "./overview/all-countries-object"; +import type { DataItem } from "../ui/multi-select"; + +type Props = { + selectedCountry?: DataItem; + setSelected: React.Dispatch>; +}; +export default function SelectCountries({ + selectedCountry, + setSelected, +}: Props) { + const data = Object.entries(countryListAlpha2).map((entry) => { + return { + value: entry[0], + label: entry[1], + }; + }); + const preselected = selectedCountry; + return ( + + ); +} diff --git a/app/components/campaigns/tutorial/contribute/steps.tsx b/app/components/campaigns/tutorial/contribute/steps.tsx new file mode 100644 index 00000000..e69de29b diff --git a/app/components/campaigns/tutorial/create/steps.tsx b/app/components/campaigns/tutorial/create/steps.tsx new file mode 100644 index 00000000..71fe74c6 --- /dev/null +++ b/app/components/campaigns/tutorial/create/steps.tsx @@ -0,0 +1,8 @@ +// export default function CreateSteps(){ +// const steps = [ +// { +// message: '', +// img: string } + +// ] +// } \ No newline at end of file diff --git a/app/components/device-card.tsx b/app/components/device-card.tsx new file mode 100644 index 00000000..68344276 --- /dev/null +++ b/app/components/device-card.tsx @@ -0,0 +1,35 @@ +import { Circle } from "lucide-react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "./ui/card"; +import type { Device } from "~/schema"; + +interface DeviceCardProps { + device: Device; +} + +export default function DeviceCard({ device }: DeviceCardProps) { + return ( + + +
+ {device.name} + {device.description} +
+
+ +
+
+ + {device.model} +
+
Updated {device.updatedAt.toString()}
+
+
+
+ ); +} diff --git a/app/components/error-boundary.tsx b/app/components/error-boundary.tsx new file mode 100644 index 00000000..21d4a5eb --- /dev/null +++ b/app/components/error-boundary.tsx @@ -0,0 +1,44 @@ +import { + isRouteErrorResponse, + useParams, + useRouteError, +} from "@remix-run/react"; +import { type ErrorResponse } from "@remix-run/router"; +import { getErrorMessage } from "~/utils/misc"; + +type StatusHandler = (info: { + error: ErrorResponse; + params: Record; +}) => JSX.Element | null; + +export function GeneralErrorBoundary({ + defaultStatusHandler = ({ error }) => ( +

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

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

{getErrorMessage(error)}

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

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

+// +//

+// Add some info here. +//

+//
+//
+// ); +// } \ No newline at end of file diff --git a/app/components/header/menu/index.tsx b/app/components/header/menu/index.tsx index f5f6550d..c8bfaf34 100644 --- a/app/components/header/menu/index.tsx +++ b/app/components/header/menu/index.tsx @@ -145,16 +145,17 @@ export default function Menu() { )} {data.profile && ( - + + Profile )} - {t("settings_label")} + {t("settings_label")} - {t("my_devices_label")} + {t("my_devices_label")} diff --git a/app/components/header/nav-bar/nav-bar.tsx b/app/components/header/nav-bar/nav-bar.tsx index e0e3970a..4430df74 100644 --- a/app/components/header/nav-bar/nav-bar.tsx +++ b/app/components/header/nav-bar/nav-bar.tsx @@ -1,204 +1,204 @@ -import React, { useEffect, useRef } from "react"; -import Search from "~/components/search"; -import { SunIcon, CalendarDaysIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; -import { TimeFilter } from "~/components/header/navBar/time-filter/time-filter"; -import type { DateRange } from "react-day-picker"; -import getUserLocale from "get-user-locale"; -import { format } from "date-fns"; -import { useTranslation } from "react-i18next"; -import type { Device } from "@prisma/client"; - -interface NavBarProps { - devices: Device[]; -} - -type ValuePiece = Date | string | null; - -type Value = ValuePiece - - -export default function NavBar(props: NavBarProps) { - let { t } = useTranslation("navbar"); - - const [timeState, setTimeState] = React.useState("live"); - const [isDialogOpen, setIsDialogOpen] = React.useState(false); - const [isHovered, setIsHovered] = React.useState(false); - const [showSearch, setShowSearch] = React.useState(false); - const searchRef = useRef(null); - - const [value, onChange] = React.useState(null); - const [dateRange, setDateRange] = React.useState(undefined); - const [singleDate, setSingleDate] = React.useState(undefined) - const userLocaleString = getUserLocale(); - - /** - * Focus the search input - */ - const focusSearchInput = () => { - searchRef.current?.focus(); - }; - - /** - * Display the search - */ - const displaySearch = () => { - setShowSearch(true); - setTimeout(() => { - focusSearchInput(); - }, 100); - }; - - /** - * Close the search when the escape key is pressed - * - * @param event event object - */ - const closeSearch = (event: any) => { - if (event.key === "Escape") { - setShowSearch(false); - } - }; - - /** - * useEffect hook to attach and remove the event listener - */ - useEffect(() => { - // attach the event listener - document.addEventListener("keydown", closeSearch); - - // remove the event listener - return () => { - document.removeEventListener("keydown", closeSearch); - }; - }); - - // useEffect(() => { - // console.log("dateRange", dateRange); - // console.log("time", value); - // console.log("singleDate", singleDate); - // }, [dateRange, value, singleDate]); - - return ( -
- {!isHovered && !showSearch ? ( -
{ - setIsHovered(true); - }} - > -
- -
- {t("temperature_label")} -
-
-
- - Suche - - Ctrl + K - -
-
- -
- {timeState === "live" ? ( - {t("live_label")} - ) : timeState === "pointintime" ? ( - singleDate ? ( - <> - {format( - singleDate, - userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy" - )} - - ) : ( - t("date_picker_label") - ) - ) : timeState === "timeperiod" ? ( - dateRange?.from ? ( - dateRange.to ? ( - <> - {format( - dateRange.from, - userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy" - )}{" "} - -{" "} - {format( - dateRange.to, - userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy" - )} - - ) : ( - format( - dateRange.from, - userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy" - ) - ) - ) : ( - t("date_range_picker_label") - ) - ) : null} -
-
-
- ) : isHovered && !showSearch ? ( -
{ - if (!isDialogOpen) { - setIsHovered(false); - } - }} - > - -
-
- -
-
- ) : ( -
{ - setIsHovered(false); - }} - onMouseEnter={() => { - setIsHovered(true); - }} - > - { - setShowSearch(false); - setIsHovered(false); - }} - /> -
- )} -
- ); -} +import React, { useEffect, useRef } from "react"; +import Search from "~/components/search"; +import { SunIcon, CalendarDaysIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline"; +import { TimeFilter } from "~/components/header/navBar/time-filter/time-filter"; +import type { DateRange } from "react-day-picker"; +import getUserLocale from "get-user-locale"; +import { format } from "date-fns"; +import { useTranslation } from "react-i18next"; +import type { Device } from "~/schema"; + +interface NavBarProps { + devices: Device[]; +} + +type ValuePiece = Date | string | null; + +type Value = ValuePiece + + +export default function NavBar(props: NavBarProps) { + let { t } = useTranslation("navbar"); + + const [timeState, setTimeState] = React.useState("live"); + const [isDialogOpen, setIsDialogOpen] = React.useState(false); + const [isHovered, setIsHovered] = React.useState(false); + const [showSearch, setShowSearch] = React.useState(false); + const searchRef = useRef(null); + + const [value, onChange] = React.useState(null); + const [dateRange, setDateRange] = React.useState(undefined); + const [singleDate, setSingleDate] = React.useState(undefined) + const userLocaleString = getUserLocale(); + + /** + * Focus the search input + */ + const focusSearchInput = () => { + searchRef.current?.focus(); + }; + + /** + * Display the search + */ + const displaySearch = () => { + setShowSearch(true); + setTimeout(() => { + focusSearchInput(); + }, 100); + }; + + /** + * Close the search when the escape key is pressed + * + * @param event event object + */ + const closeSearch = (event: any) => { + if (event.key === "Escape") { + setShowSearch(false); + } + }; + + /** + * useEffect hook to attach and remove the event listener + */ + useEffect(() => { + // attach the event listener + document.addEventListener("keydown", closeSearch); + + // remove the event listener + return () => { + document.removeEventListener("keydown", closeSearch); + }; + }); + + // useEffect(() => { + // console.log("dateRange", dateRange); + // console.log("time", value); + // console.log("singleDate", singleDate); + // }, [dateRange, value, singleDate]); + + return ( +
+ {!isHovered && !showSearch ? ( +
{ + setIsHovered(true); + }} + > +
+ +
+ {t("temperature_label")} +
+
+
+ + Suche + + Ctrl + K + +
+
+ +
+ {timeState === "live" ? ( + {t("live_label")} + ) : timeState === "pointintime" ? ( + singleDate ? ( + <> + {format( + singleDate, + userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy" + )} + + ) : ( + t("date_picker_label") + ) + ) : timeState === "timeperiod" ? ( + dateRange?.from ? ( + dateRange.to ? ( + <> + {format( + dateRange.from, + userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy" + )}{" "} + -{" "} + {format( + dateRange.to, + userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy" + )} + + ) : ( + format( + dateRange.from, + userLocaleString === "de" ? "dd/MM/yyyy" : "MM/dd/yyyy" + ) + ) + ) : ( + t("date_range_picker_label") + ) + ) : null} +
+
+
+ ) : isHovered && !showSearch ? ( +
{ + if (!isDialogOpen) { + setIsHovered(false); + } + }} + > + +
+
+ +
+
+ ) : ( +
{ + setIsHovered(false); + }} + onMouseEnter={() => { + setIsHovered(true); + }} + > + { + setShowSearch(false); + setIsHovered(false); + }} + /> +
+ )} +
+ ); +} diff --git a/app/components/header/notification/index.tsx b/app/components/header/notification/index.tsx index 447137b9..8dda7cdb 100644 --- a/app/components/header/notification/index.tsx +++ b/app/components/header/notification/index.tsx @@ -1,47 +1,86 @@ -import { - NovuProvider, - PopoverNotificationCenter, - NotificationBell, -} from "@novu/notification-center"; -import type { IMessage } from "@novu/notification-center"; -import { useLoaderData } from "@remix-run/react"; -import type { loader } from "~/root"; - -function onNotificationClick(message: IMessage) { - if (message?.cta?.data?.url) { - //window.location.href = message.cta.data.url; - window.open(message.cta.data.url, "_blank"); - } -} - -export default function Notification() { - const data = useLoaderData(); - return ( -
- - { - //header content here - return
; - }} - footer={() => { - //footer content here - return
; - }} - > - {({ unseenCount }) => } -
-
-
- ); -} +import { + NovuProvider, + PopoverNotificationCenter, + NotificationBell, + useUpdateAction, + MessageActionStatusEnum, + useRemoveNotification, +} from "@novu/notification-center"; +import type { ButtonTypeEnum, IMessage } from "@novu/notification-center"; +import { useLoaderData } from "@remix-run/react"; +import type { loader } from "~/root"; +import { styles } from "./styles"; +import { useToast } from "~/components/ui/use-toast"; +import { useNavigate } from "@remix-run/react"; + +function PopoverWrapper() { + const { updateAction } = useUpdateAction(); + const { removeNotification } = useRemoveNotification(); + const { toast } = useToast(); + const navigate = useNavigate(); + + function handlerOnNotificationClick(message: IMessage) { + if (message?.cta?.data?.url) { + window.location.href = message.cta.data.url; + } + } + + async function handlerOnActionClick( + templateIdentifier: string, + type: ButtonTypeEnum, + message: IMessage + ) { + if (templateIdentifier === "new-participant") { + await updateAction({ + messageId: message._id, + actionButtonType: type, + status: MessageActionStatusEnum.DONE, + }); + + await removeNotification({ + messageId: message._id, + }); + if (type === "primary") { + toast({ title: "Participant accepted successfully!" }); + // navigate("../create/area"); + } + if (type === "secondary") { + toast({ title: "Participant rejected" }); + } + } + } + + return ( + + {({ unseenCount }) => { + return ; + }} + + ); +} + +export default function Notification() { + const data = useLoaderData(); + if (!data.user) { + return null; + } + + return ( +
+ + + +
+ ); +} diff --git a/app/components/header/notification/styles.ts b/app/components/header/notification/styles.ts new file mode 100644 index 00000000..38b37f3b --- /dev/null +++ b/app/components/header/notification/styles.ts @@ -0,0 +1,174 @@ +const primaryColor = "#709f61"; +const secondaryColor = "#AFE1AF"; +const primaryTextColor = "#0C0404"; +const secondaryTextColor = "#494F55"; +const unreadBackGroundColor = "#869F9F"; +const primaryButtonBackGroundColor = unreadBackGroundColor; +const secondaryButtonBackGroundColor = "#C6DFCD"; +const dropdownBorderStyle = "2px solid #AFE1AF"; +const tabLabelAfterStyle = "#AFE1AF !important"; +const ncWidth = "350px !important"; + +export const styles = { + bellButton: { + root: { + marginTop: "5px", + svg: { + color: secondaryColor, + fill: primaryColor, + minWidth: "75px", + minHeight: "80px", + }, + }, + dot: { + marginRight: "-25px", + marginTop: "-20px", + rect: { + fill: "red", + strokeWidth: "0", + width: "3px", + height: "3px", + x: 10, + y: 2, + }, + }, + }, + unseenBadge: { + root: { color: primaryTextColor, background: secondaryColor }, + }, + popover: { + arrow: { + backgroundColor: primaryColor, + borderLeftColor: secondaryColor, + borderTopColor: secondaryColor, + }, + dropdown: { + border: dropdownBorderStyle, + borderRadius: "10px", + marginTop: "25px", + maxWidth: ncWidth, + }, + }, + header: { + root: { + backgroundColor: primaryColor, + "&:hover": { backgroundColor: primaryColor }, + cursor: "pointer", + color: primaryTextColor, + }, + cog: { opacity: 1 }, + markAsRead: { + color: primaryTextColor, + fontSize: "14px", + }, + title: { color: primaryTextColor }, + backButton: { + color: primaryTextColor, + }, + }, + layout: { + root: { + background: primaryColor, + maxWidth: ncWidth, + }, + }, + loader: { + root: { + stroke: primaryTextColor, + }, + }, + accordion: { + item: { + backgroundColor: secondaryColor, + ":hover": { + backgroundColor: secondaryColor, + }, + }, + content: { + backgroundColor: secondaryColor, + borderBottomLeftRadius: "7px", + borderBottomRightRadius: "7px", + }, + control: { + ":hover": { + backgroundColor: secondaryColor, + }, + color: primaryTextColor, + title: { + color: primaryTextColor, + }, + }, + chevron: { + color: primaryTextColor, + }, + }, + notifications: { + root: { + ".nc-notifications-list-item": { + backgroundColor: secondaryColor, + }, + }, + listItem: { + layout: { + borderRadius: "7px", + color: primaryTextColor, + }, + timestamp: { color: secondaryTextColor, fontWeight: "bold" }, + dotsButton: { + path: { + fill: primaryTextColor, + }, + }, + unread: { + "::before": { background: unreadBackGroundColor }, + }, + buttons: { + primary: { + background: primaryButtonBackGroundColor, + color: primaryTextColor, + "&:hover": { + background: primaryButtonBackGroundColor, + color: secondaryTextColor, + }, + }, + secondary: { + background: secondaryButtonBackGroundColor, + color: secondaryTextColor, + "&:hover": { + background: secondaryButtonBackGroundColor, + color: secondaryTextColor, + }, + }, + }, + }, + }, + actionsMenu: { + item: { "&:hover": { backgroundColor: secondaryColor } }, + dropdown: { + backgroundColor: primaryColor, + }, + arrow: { + backgroundColor: primaryColor, + borderTop: "0", + borderLeft: "0", + }, + }, + preferences: { + item: { + title: { color: primaryTextColor }, + divider: { borderTopColor: primaryColor }, + channels: { color: secondaryTextColor }, + content: { + icon: { color: primaryTextColor }, + channelLabel: { color: primaryTextColor }, + success: { color: primaryTextColor }, + }, + }, + }, + tabs: { + tabLabel: { + "::after": { background: tabLabelAfterStyle }, + }, + tabsList: { borderBottomColor: primaryColor }, + }, +}; diff --git a/app/components/label-button.tsx b/app/components/label-button.tsx new file mode 100644 index 00000000..4a77d3ad --- /dev/null +++ b/app/components/label-button.tsx @@ -0,0 +1,5 @@ +export function LabelButton({ + ...props +}: Omit, "className">) { + return