From 1b648ef38119c847d37283f363320a7f21eaeb10 Mon Sep 17 00:00:00 2001 From: Adam Jackson Date: Tue, 24 Jun 2025 15:37:18 -0700 Subject: [PATCH 1/6] Initial marker editing logic --- backend/routes/trips/[tripId]/markers.ts | 32 ++- frontend/modals/AddMarker.tsx | 103 ---------- frontend/modals/Marker.tsx | 246 +++++++++++++++++++++++ frontend/modals/ViewMarker.tsx | 99 --------- frontend/providers/modals.tsx | 7 +- shared/types.ts | 11 + 6 files changed, 291 insertions(+), 207 deletions(-) delete mode 100644 frontend/modals/AddMarker.tsx create mode 100644 frontend/modals/Marker.tsx delete mode 100644 frontend/modals/ViewMarker.tsx diff --git a/backend/routes/trips/[tripId]/markers.ts b/backend/routes/trips/[tripId]/markers.ts index da72134b..17321830 100644 --- a/backend/routes/trips/[tripId]/markers.ts +++ b/backend/routes/trips/[tripId]/markers.ts @@ -2,7 +2,7 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { authenticate } from "lib/utils.js"; import { connect, Trip } from "lib/db.js"; -import type { MarkerInput, MarkerNotesInput } from "@birdplan/shared"; +import type { MarkerInput, MarkerNotesInput, MarkerUpdateInput } from "@birdplan/shared"; const markers = new Hono(); @@ -62,4 +62,34 @@ markers.patch("/:markerId/notes", async (c) => { return c.json({}); }); +markers.patch("/:markerId", async (c) => { + const session = await authenticate(c); + + const tripId = c.req.param("tripId"); + const markerId = c.req.param("markerId"); + if (!tripId) throw new HTTPException(400, { message: "Trip ID is required" }); + if (!markerId) throw new HTTPException(400, { message: "Marker ID is required" }); + + const data = await c.req.json(); + + await connect(); + const trip = await Trip.findById(tripId).lean(); + if (!trip) throw new HTTPException(404, { message: "Trip not found" }); + if (!trip.userIds.includes(session.uid)) throw new HTTPException(403, { message: "Forbidden" }); + + await Trip.updateOne( + { _id: tripId, "markers.id": markerId }, + { + $set: { + "markers.$.name": data.name, + "markers.$.lat": data.lat, + "markers.$.lng": data.lng, + "markers.$.icon": data.icon, + }, + } + ); + + return c.json({}); +}); + export default markers; diff --git a/frontend/modals/AddMarker.tsx b/frontend/modals/AddMarker.tsx deleted file mode 100644 index 3929277f..00000000 --- a/frontend/modals/AddMarker.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React from "react"; -import { Header, Body } from "providers/modals"; -import Button from "components/Button"; -import Field from "components/Field"; -import Input from "components/Input"; -import { useModal } from "providers/modals"; -import { useTrip } from "providers/trip"; -import { nanoId } from "lib/helpers"; -import { MarkerIconT, markerIcons } from "lib/icons"; -import MarkerWithIcon from "components/MarkerWithIcon"; -import clsx from "clsx"; -import toast from "react-hot-toast"; -import useTripMutation from "hooks/useTripMutation"; -import { CustomMarker } from "lib/types"; - -type Props = { - lat?: number; - lng?: number; -}; - -export default function AddMarker({ lat: defaultLat, lng: defaultLng }: Props) { - const [icon, setIcon] = React.useState(); - const [name, setName] = React.useState(""); - const [lat, setLat] = React.useState(defaultLat || 0); - const [lng, setLng] = React.useState(defaultLng || 0); - const { close } = useModal(); - const { trip } = useTrip(); - - const addMarkerMutation = useTripMutation({ - url: `/trips/${trip?._id}/markers`, - method: "POST", - updateCache: (old, input) => ({ - ...old, - markers: [...(old.markers || []), input], - }), - }); - - const handleAddMarker = () => { - if (!icon) return toast.error("Please choose an icon"); - addMarkerMutation.mutate({ lat, lng, name, icon, id: nanoId(6) }); - close(); - }; - - return ( - <> -
Add Custom Marker
- -
-
- - setName(e.target.value)} /> - -
- - { - const value = e.target.value; - if (value.includes(",")) { - const [lat, lng] = value.split(","); - setLat(Number(lat)); - setLng(Number(lng)); - } else { - setLat(Number(e.target.value)); - } - }} - /> - - - setLng(Number(e.target.value))} - /> - -
-
- -
- {Object.keys(markerIcons).map((it) => ( - - ))} -
-
- -
-
- - - ); -} diff --git a/frontend/modals/Marker.tsx b/frontend/modals/Marker.tsx new file mode 100644 index 00000000..c9b1f8a8 --- /dev/null +++ b/frontend/modals/Marker.tsx @@ -0,0 +1,246 @@ +import React from "react"; +import { Header, Body } from "providers/modals"; +import Button from "components/Button"; +import Field from "components/Field"; +import Input from "components/Input"; +import { useModal } from "providers/modals"; +import { useTrip } from "providers/trip"; +import { nanoId } from "lib/helpers"; +import { MarkerIconT, markerIcons } from "lib/icons"; +import MarkerWithIcon from "components/MarkerWithIcon"; +import clsx from "clsx"; +import toast from "react-hot-toast"; +import useTripMutation from "hooks/useTripMutation"; +import { CustomMarker, MarkerInput, MarkerUpdateInput } from "@birdplan/shared"; +import DirectionsButton from "components/DirectionsButton"; +import InputNotes from "components/InputNotes"; +import { Menu } from "@headlessui/react"; +import Icon from "components/Icon"; +import { getGooglePlaceUrl } from "lib/helpers"; + +type Props = { + marker?: CustomMarker; + lat?: number; + lng?: number; +}; + +export default function Marker({ marker, lat: defaultLat, lng: defaultLng }: Props) { + const isEditing = !!marker; + const [isEditMode, setIsEditMode] = React.useState(!isEditing); + const [icon, setIcon] = React.useState((marker?.icon as MarkerIconT) || undefined); + const [name, setName] = React.useState(marker?.name || ""); + const [lat, setLat] = React.useState(marker?.lat || defaultLat || 0); + const [lng, setLng] = React.useState(marker?.lng || defaultLng || 0); + const { close } = useModal(); + const { trip, canEdit, setSelectedMarkerId } = useTrip(); + + const addMarkerMutation = useTripMutation({ + url: `/trips/${trip?._id}/markers`, + method: "POST", + updateCache: (old, input) => ({ + ...old, + markers: [...(old.markers || []), input], + }), + }); + + const updateMarkerMutation = useTripMutation({ + url: `/trips/${trip?._id}/markers/${marker?.id}`, + method: "PATCH", + updateCache: (old, input) => ({ + ...old, + markers: old.markers.map((it) => + it.id === marker?.id ? { ...it, name: input.name, lat: input.lat, lng: input.lng, icon: input.icon } : it + ), + }), + }); + + const removeMutation = useTripMutation({ + url: `/trips/${trip?._id}/markers/${marker?.id}`, + method: "DELETE", + updateCache: (old) => ({ + ...old, + markers: old.markers.filter((it) => it.id !== marker?.id), + }), + }); + + const saveNotesMutation = useTripMutation<{ notes: string }>({ + url: `/trips/${trip?._id}/markers/${marker?.id}/notes`, + method: "PATCH", + updateCache: (old, input) => ({ + ...old, + markers: old.markers.map((it) => (it.id === marker?.id ? { ...it, notes: input.notes } : it)), + }), + }); + + const handleSave = () => { + if (!icon) return toast.error("Please choose an icon"); + + if (isEditing) { + updateMarkerMutation.mutate({ name, lat, lng, icon }); + setIsEditMode(false); + } else { + addMarkerMutation.mutate({ lat, lng, name, icon, id: nanoId(6) }); + close(); + } + }; + + const handleCancel = () => { + if (isEditing) { + setIcon(marker.icon as MarkerIconT); + setName(marker.name); + setLat(marker.lat); + setLng(marker.lng); + setIsEditMode(false); + } else { + close(); + } + }; + + const handleRemoveMarker = () => { + if (!confirm("Are you sure you want to remove this marker?")) return; + removeMutation.mutate({}); + close(); + }; + + React.useEffect(() => { + if (marker) { + setSelectedMarkerId(marker.id); + return () => setSelectedMarkerId(undefined); + } + }, [marker?.id]); + + const googleUrl = marker && getGooglePlaceUrl(marker.lat, marker.lng, marker.placeId); + + if (isEditing && !isEditMode) { + return ( + <> +
+ + {marker.name} +
+ +
+
+ + + + + + + + + View on Google Maps + + + {canEdit && ( + <> + + + + + + + + )} + + +
+ saveNotesMutation.mutate({ notes: value })} + key={marker.id} + /> +
+ + + ); + } + + return ( + <> +
{isEditing ? "Edit Marker" : "Add Custom Marker"}
+ +
+
+ + ) => setName(e.target.value)} + /> + +
+ + ) => { + const value = e.target.value; + if (value.includes(",")) { + const [lat, lng] = value.split(","); + setLat(Number(lat)); + setLng(Number(lng)); + } else { + setLat(Number(e.target.value)); + } + }} + /> + + + ) => setLng(Number(e.target.value))} + /> + +
+
+ +
+ {Object.keys(markerIcons).map((it) => ( + + ))} +
+
+
+ + +
+
+
+ + + ); +} diff --git a/frontend/modals/ViewMarker.tsx b/frontend/modals/ViewMarker.tsx deleted file mode 100644 index c9f7d1d8..00000000 --- a/frontend/modals/ViewMarker.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import React from "react"; -import { Header, Body } from "providers/modals"; -import { CustomMarker } from "@birdplan/shared"; -import { useTrip } from "providers/trip"; -import { useModal } from "providers/modals"; -import MarkerWithIcon from "components/MarkerWithIcon"; -import DirectionsButton from "components/DirectionsButton"; -import InputNotes from "components/InputNotes"; -import { Menu } from "@headlessui/react"; -import Icon from "components/Icon"; -import { getGooglePlaceUrl } from "lib/helpers"; -import useTripMutation from "hooks/useTripMutation"; -import { MarkerIconT } from "lib/icons"; - -type Props = { - marker: CustomMarker; -}; - -export default function ViewMarker({ marker }: Props) { - const { close } = useModal(); - const { trip, canEdit, setSelectedMarkerId } = useTrip(); - const { id, placeId, name, lat, lng } = marker; - - const removeMutation = useTripMutation({ - url: `/trips/${trip?._id}/markers/${id}`, - method: "DELETE", - updateCache: (old) => ({ - ...old, - markers: old.markers.filter((it) => it.id !== id), - }), - }); - - const saveNotesMutation = useTripMutation<{ notes: string }>({ - url: `/trips/${trip?._id}/markers/${id}/notes`, - method: "PATCH", - updateCache: (old, input) => ({ - ...old, - markers: old.markers.map((it) => (it.id === id ? { ...it, notes: input.notes } : it)), - }), - }); - - const handleRemoveMarker = () => { - if (!confirm("Are you sure you want to remove this marker?")) return; - removeMutation.mutate({}); - close(); - }; - - React.useEffect(() => { - setSelectedMarkerId(id); - return () => setSelectedMarkerId(undefined); - }, [id]); - - const googleUrl = getGooglePlaceUrl(lat, lng, placeId); - - return ( - <> -
- - {name} -
- -
-
- - - - - - - - - View on Google Maps - - - {canEdit && ( - - - - )} - - -
- saveNotesMutation.mutate({ notes: value })} key={id} /> -
- - - ); -} diff --git a/frontend/providers/modals.tsx b/frontend/providers/modals.tsx index 76e63dc1..7385e539 100644 --- a/frontend/providers/modals.tsx +++ b/frontend/providers/modals.tsx @@ -10,8 +10,7 @@ import clsx from "clsx"; // modals import Hotspot from "modals/Hotspot"; import PersonalLocation from "modals/PersonalLocation"; -import AddMarker from "modals/AddMarker"; -import ViewMarker from "modals/ViewMarker"; +import Marker from "modals/Marker"; import Share from "modals/Share"; import AddItineraryLocation from "modals/AddItineraryLocation"; import AddHotspot from "modals/AddHotspot"; @@ -41,7 +40,7 @@ const modals: ModalConfig[] = [ }, { id: "addMarker", - Component: AddMarker, + Component: Marker, small: true, position: "right", }, @@ -57,7 +56,7 @@ const modals: ModalConfig[] = [ }, { id: "viewMarker", - Component: ViewMarker, + Component: Marker, small: true, position: "right", }, diff --git a/shared/types.ts b/shared/types.ts index c228a0d7..da3124d9 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -204,9 +204,20 @@ export type TargetNotesInput = { export type MarkerInput = { id: string; + name: string; lat: number; lng: number; + icon: string; notes?: string; + placeId?: string; + placeType?: string; +}; + +export type MarkerUpdateInput = { + name: string; + lat: number; + lng: number; + icon: string; }; export type MarkerNotesInput = { From b818a784d3f6563278326d47d28f102c11f153ea Mon Sep 17 00:00:00 2001 From: Adam Jackson Date: Tue, 24 Jun 2025 15:45:20 -0700 Subject: [PATCH 2/6] Marker edit refining --- frontend/components/ItineraryDay.tsx | 2 +- frontend/components/Mapbox.tsx | 2 +- frontend/modals/Marker.tsx | 30 ++++++++++++++++++++-------- frontend/providers/trip.tsx | 4 ++++ 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/frontend/components/ItineraryDay.tsx b/frontend/components/ItineraryDay.tsx index e2ce6a6c..717aeb34 100644 --- a/frontend/components/ItineraryDay.tsx +++ b/frontend/components/ItineraryDay.tsx @@ -132,7 +132,7 @@ export default function ItineraryDay({ day, isEditing }: PropsT) { ? () => type === "hotspot" ? open("hotspot", { hotspot: location }) - : open("viewMarker", { marker: location }) + : open("viewMarker", { markerId: location.id }) : undefined } disabled={!location} diff --git a/frontend/components/Mapbox.tsx b/frontend/components/Mapbox.tsx index 9e44ee03..b1ec5857 100644 --- a/frontend/components/Mapbox.tsx +++ b/frontend/components/Mapbox.tsx @@ -46,7 +46,7 @@ export default function Mapbox({ const handleMarkerClick = (marker: CustomMarker) => { isOpeningModal.current = true; - open("viewMarker", { marker }); + open("viewMarker", { markerId: marker.id }); setTimeout(() => { isOpeningModal.current = false; }, 500); diff --git a/frontend/modals/Marker.tsx b/frontend/modals/Marker.tsx index c9b1f8a8..47b101a7 100644 --- a/frontend/modals/Marker.tsx +++ b/frontend/modals/Marker.tsx @@ -17,22 +17,25 @@ import InputNotes from "components/InputNotes"; import { Menu } from "@headlessui/react"; import Icon from "components/Icon"; import { getGooglePlaceUrl } from "lib/helpers"; +import Error from "components/Error"; type Props = { - marker?: CustomMarker; + markerId?: string; lat?: number; lng?: number; }; -export default function Marker({ marker, lat: defaultLat, lng: defaultLng }: Props) { +export default function Marker({ markerId, lat: defaultLat, lng: defaultLng }: Props) { + const { close } = useModal(); + const { trip, canEdit, setSelectedMarkerId, refetch } = useTrip(); + + const marker = markerId ? trip?.markers?.find((m) => m.id === markerId) : undefined; const isEditing = !!marker; const [isEditMode, setIsEditMode] = React.useState(!isEditing); const [icon, setIcon] = React.useState((marker?.icon as MarkerIconT) || undefined); const [name, setName] = React.useState(marker?.name || ""); const [lat, setLat] = React.useState(marker?.lat || defaultLat || 0); const [lng, setLng] = React.useState(marker?.lng || defaultLng || 0); - const { close } = useModal(); - const { trip, canEdit, setSelectedMarkerId } = useTrip(); const addMarkerMutation = useTripMutation({ url: `/trips/${trip?._id}/markers`, @@ -85,7 +88,7 @@ export default function Marker({ marker, lat: defaultLat, lng: defaultLng }: Pro }; const handleCancel = () => { - if (isEditing) { + if (isEditing && marker) { setIcon(marker.icon as MarkerIconT); setName(marker.name); setLat(marker.lat); @@ -103,14 +106,25 @@ export default function Marker({ marker, lat: defaultLat, lng: defaultLng }: Pro }; React.useEffect(() => { - if (marker) { - setSelectedMarkerId(marker.id); + if (markerId) { + setSelectedMarkerId(markerId); return () => setSelectedMarkerId(undefined); } - }, [marker?.id]); + }, [markerId, setSelectedMarkerId]); const googleUrl = marker && getGooglePlaceUrl(marker.lat, marker.lng, marker.placeId); + if (markerId && !marker) { + return ( + <> +
Marker Not Found
+ + + + + ); + } + if (isEditing && !isEditMode) { return ( <> diff --git a/frontend/providers/trip.tsx b/frontend/providers/trip.tsx index 143acc44..8ad445bd 100644 --- a/frontend/providers/trip.tsx +++ b/frontend/providers/trip.tsx @@ -32,6 +32,7 @@ type ContextT = { setSelectedSpecies: (species?: SelectedSpecies) => void; setSelectedMarkerId: (id?: string) => void; setHalo: (data?: HaloT) => void; + refetch: () => void; }; const initialState = { @@ -50,6 +51,7 @@ export const TripContext = React.createContext({ setSelectedSpecies: () => {}, setSelectedMarkerId: () => {}, setHalo: () => {}, + refetch: () => {}, }); type Props = { @@ -64,6 +66,7 @@ const TripProvider = ({ children }: Props) => { data: trip, isFetching, isLoading, + refetch, } = useQuery({ queryKey: [`/trips/${id}`], enabled: !!id, @@ -109,6 +112,7 @@ const TripProvider = ({ children }: Props) => { setSelectedSpecies, setSelectedMarkerId, setHalo, + refetch, invites: invites || null, canEdit, isOwner, From 7a49079d60ca38104e5736d45969dc3736916f0f Mon Sep 17 00:00:00 2001 From: Adam Jackson Date: Wed, 25 Jun 2025 15:28:22 -0700 Subject: [PATCH 3/6] Simplify marker types --- backend/routes/trips/[tripId]/markers.ts | 4 ++-- frontend/modals/Marker.tsx | 4 ++-- shared/types.ts | 11 ----------- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/backend/routes/trips/[tripId]/markers.ts b/backend/routes/trips/[tripId]/markers.ts index 17321830..373c7b8e 100644 --- a/backend/routes/trips/[tripId]/markers.ts +++ b/backend/routes/trips/[tripId]/markers.ts @@ -2,12 +2,12 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { authenticate } from "lib/utils.js"; import { connect, Trip } from "lib/db.js"; -import type { MarkerInput, MarkerNotesInput, MarkerUpdateInput } from "@birdplan/shared"; +import type { CustomMarker, MarkerNotesInput, MarkerUpdateInput } from "@birdplan/shared"; const markers = new Hono(); markers.post("/", async (c) => { - const data = await c.req.json(); + const data = await c.req.json(); const session = await authenticate(c); const tripId = c.req.param("tripId"); diff --git a/frontend/modals/Marker.tsx b/frontend/modals/Marker.tsx index 47b101a7..dccd9a99 100644 --- a/frontend/modals/Marker.tsx +++ b/frontend/modals/Marker.tsx @@ -11,7 +11,7 @@ import MarkerWithIcon from "components/MarkerWithIcon"; import clsx from "clsx"; import toast from "react-hot-toast"; import useTripMutation from "hooks/useTripMutation"; -import { CustomMarker, MarkerInput, MarkerUpdateInput } from "@birdplan/shared"; +import { CustomMarker, MarkerUpdateInput } from "@birdplan/shared"; import DirectionsButton from "components/DirectionsButton"; import InputNotes from "components/InputNotes"; import { Menu } from "@headlessui/react"; @@ -37,7 +37,7 @@ export default function Marker({ markerId, lat: defaultLat, lng: defaultLng }: P const [lat, setLat] = React.useState(marker?.lat || defaultLat || 0); const [lng, setLng] = React.useState(marker?.lng || defaultLng || 0); - const addMarkerMutation = useTripMutation({ + const addMarkerMutation = useTripMutation({ url: `/trips/${trip?._id}/markers`, method: "POST", updateCache: (old, input) => ({ diff --git a/shared/types.ts b/shared/types.ts index da3124d9..ae379ceb 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -202,17 +202,6 @@ export type TargetNotesInput = { notes: string; }; -export type MarkerInput = { - id: string; - name: string; - lat: number; - lng: number; - icon: string; - notes?: string; - placeId?: string; - placeType?: string; -}; - export type MarkerUpdateInput = { name: string; lat: number; From 86bde098c5572ec65df15258dd1f68a9adf7bc0b Mon Sep 17 00:00:00 2001 From: Adam Jackson Date: Wed, 25 Jun 2025 15:32:44 -0700 Subject: [PATCH 4/6] Update whats-new.tsx --- frontend/pages/whats-new.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/pages/whats-new.tsx b/frontend/pages/whats-new.tsx index f5939925..8d8b91d1 100644 --- a/frontend/pages/whats-new.tsx +++ b/frontend/pages/whats-new.tsx @@ -15,6 +15,10 @@ export default function WhatsNew() {

What's New

+

June 25, 2025

+
    +
  • ✨ Added ability to edit custom markers.
  • +

June 17, 2025

  • 🛠️ Migrated to a new backend. If you experience any issues, please let us know.
  • From 733555f933246057b2ad992182adf7814efc4e4f Mon Sep 17 00:00:00 2001 From: Adam Jackson Date: Wed, 25 Jun 2025 15:36:41 -0700 Subject: [PATCH 5/6] Update Marker.tsx --- frontend/modals/Marker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/modals/Marker.tsx b/frontend/modals/Marker.tsx index dccd9a99..dfe9f953 100644 --- a/frontend/modals/Marker.tsx +++ b/frontend/modals/Marker.tsx @@ -119,7 +119,7 @@ export default function Marker({ markerId, lat: defaultLat, lng: defaultLng }: P <>
    Marker Not Found
    - + ); From 7e7890c35e58621ddb2682872a615b4a24a12b1a Mon Sep 17 00:00:00 2001 From: Adam Jackson Date: Wed, 25 Jun 2025 15:39:30 -0700 Subject: [PATCH 6/6] Adjust notes type --- backend/routes/trips/[tripId]/markers.ts | 4 ++-- shared/types.ts | 4 ---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/backend/routes/trips/[tripId]/markers.ts b/backend/routes/trips/[tripId]/markers.ts index 373c7b8e..5da14c6f 100644 --- a/backend/routes/trips/[tripId]/markers.ts +++ b/backend/routes/trips/[tripId]/markers.ts @@ -2,7 +2,7 @@ import { Hono } from "hono"; import { HTTPException } from "hono/http-exception"; import { authenticate } from "lib/utils.js"; import { connect, Trip } from "lib/db.js"; -import type { CustomMarker, MarkerNotesInput, MarkerUpdateInput } from "@birdplan/shared"; +import type { CustomMarker, MarkerUpdateInput } from "@birdplan/shared"; const markers = new Hono(); @@ -50,7 +50,7 @@ markers.patch("/:markerId/notes", async (c) => { if (!tripId) throw new HTTPException(400, { message: "Trip ID is required" }); if (!markerId) throw new HTTPException(400, { message: "Marker ID is required" }); - const data = await c.req.json(); + const data = await c.req.json<{ notes: string }>(); await connect(); const trip = await Trip.findById(tripId).lean(); diff --git a/shared/types.ts b/shared/types.ts index ae379ceb..8313b6b2 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -209,10 +209,6 @@ export type MarkerUpdateInput = { icon: string; }; -export type MarkerNotesInput = { - notes: string; -}; - export type HotspotInput = { id: string; name: string;