diff --git a/bun.lockb b/bun.lockb index dda70b11..35dde97a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 17085a93..56d07582 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "javascript-time-ago": "^2.5.11", "js-confetti": "^0.12.0", "js-cookie": "^3.0.5", + "leaflet": "^1.9.4", "lucide-react": "^0.446.0", "next": "14.2.15", "next-plausible": "^3.12.2", @@ -48,6 +49,7 @@ "react-fast-marquee": "^1.6.5", "react-fullstory": "^1.4.0", "react-infinite-scroll-component": "^6.1.0", + "react-leaflet": "^4.2.1", "react-markdown": "^9.0.1", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.0", @@ -58,6 +60,7 @@ "uuid": "^11.0.3" }, "devDependencies": { + "@types/leaflet": "^1.9.16", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/public/handraise.png b/public/handraise.png new file mode 100644 index 00000000..fce59477 Binary files /dev/null and b/public/handraise.png differ diff --git a/public/tavern.png b/public/tavern.png new file mode 100644 index 00000000..1fb7bb3b Binary files /dev/null and b/public/tavern.png differ diff --git a/src/app/harbor/map/map.tsx b/src/app/harbor/map/map.tsx deleted file mode 100644 index b0e62dba..00000000 --- a/src/app/harbor/map/map.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export default function Map() { - return ( - - ) -} diff --git a/src/app/harbor/tavern/map.tsx b/src/app/harbor/tavern/map.tsx new file mode 100644 index 00000000..b9960800 --- /dev/null +++ b/src/app/harbor/tavern/map.tsx @@ -0,0 +1,186 @@ +'use client' + +import { useEffect, useState } from 'react' +import { + getTavernPeople, + getTavernEvents, + type TavernPersonItem, + type TavernEventItem, +} from './tavern-utils' +import { type LatLngExpression, DivIcon, Icon } from 'leaflet' +import { MapContainer, TileLayer, Marker, useMap, Tooltip } from 'react-leaflet' +import 'leaflet/dist/leaflet.css' +import { Card } from '@/components/ui/card' + +const MAP_ZOOM = 11, + MAP_CENTRE: LatLngExpression = [0, 0] + +export default function Map() { + const [tavernPeople, setTavernPeople] = useState([]) + const [tavernEvents, setTavernEvents] = useState([]) + + useEffect(() => { + Promise.all([getTavernPeople(), getTavernEvents()]).then(([tp, te]) => { + setTavernPeople(tp) + setTavernEvents(te) + }) + }, []) + + return ( +
+ + + + + + +

Map Legend

+
+ a star representing a tavern +

Mystic Tavern

+
+
+ someone raising a hand +

Mystic Tavern without organizer

+
+
+
+

Someone unable to organize or attend

+
+
+
+

Someone able to organize

+
+
+
+

Someone able to attend

+
+
+
+

Someone who has not responded

+
+
+
+ ) +} + +function UserLocation() { + const map = useMap() + + useEffect(() => { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition((loc) => { + if (map !== null) { + map.setView([loc.coords.latitude, loc.coords.longitude], 11) + } + }) + } + }, [map]) + + return null +} + +function TavernMarkers(props: MapProps) { + const map = useMap() + + if (!map) return null + + const peopleMarkers = props.people.map((t) => { + let iconClass = `rounded-full border-2 border-white w-full h-full ` + + switch (t.status) { + case 'none': { + iconClass += 'bg-[#cfdfff]' + break + } + case 'organizer': { + iconClass += 'bg-[#ffd66e]' + break + } + case 'participant': { + iconClass += 'bg-[#f82b60]' + break + } + default: { + iconClass += 'bg-[#666666]' + break + } + } + + const icon = new DivIcon({ + className: iconClass, + iconSize: [25, 25], + }) + + return ( + Number(c)) as LatLngExpression + } + icon={icon} + /> + ) + }) + const eventMarkers = props.events + .map((e) => { + if (!e.geocode) { + return null + } + + const geocodeObj = JSON.parse(atob(e.geocode.slice(2).trim())) + + if (geocodeObj.o.status !== 'OK') { + return null + } + + let iconUrl + if (e.organizers.length === 0) { + iconUrl = '/handraise.png' + } else { + iconUrl = '/tavern.png' + } + + const icon = new Icon({ + iconUrl, + iconSize: [50, 50], + }) + + return ( + + {e.city} + + ) + }) + .filter((e) => e !== null) + + return [...peopleMarkers, ...eventMarkers] +} + +type MapProps = { + people: TavernPersonItem[] + events: TavernEventItem[] +} diff --git a/src/app/harbor/tavern/tavern-utils.ts b/src/app/harbor/tavern/tavern-utils.ts new file mode 100644 index 00000000..2628a41a --- /dev/null +++ b/src/app/harbor/tavern/tavern-utils.ts @@ -0,0 +1,75 @@ +'use server' + +import Airtable from 'airtable' + +Airtable.configure({ + apiKey: process.env.AIRTABLE_API_KEY, + endpointUrl: process.env.AIRTABLE_ENDPOINT_URL, +}) + +type RsvpStatus = 'none' | 'organizer' | 'participant' +export type TavernPersonItem = { + id: string + status: RsvpStatus + coordinates: string +} +export type TavernEventItem = { + id: string + city: string + geocode: string + organizers: string[] +} + +let cachedPeople: TavernPersonItem[] | null, + cachedEvents: TavernEventItem[] | null +let lastPeopleFetch = 0, + lastEventsFetch = 0 +const TTL = 30 * 60 * 1000 + +export const getTavernPeople = async () => { + if (Date.now() - lastPeopleFetch < TTL) return cachedPeople + + console.log('Fetching tavern people') + const base = Airtable.base(process.env.BASE_ID!) + const records = await base('people') + .select({ + fields: ['tavern_rsvp_status', 'tavern_map_coordinates'], + filterByFormula: + 'AND({tavern_map_coordinates} != "", OR(tavern_rsvp_status != "", shipped_ship_count >= 1))', + }) + .all() + + const items = records.map((r) => ({ + id: r.id, + status: r.get('tavern_rsvp_status'), + coordinates: r.get('tavern_map_coordinates'), + })) as TavernPersonItem[] + + cachedPeople = items + lastPeopleFetch = Date.now() + + return items +} + +export const getTavernEvents = async () => { + if (Date.now() - lastEventsFetch < TTL) return cachedEvents + + console.log('Fetching tavern events') + const base = Airtable.base(process.env.BASE_ID!) + const records = await base('taverns') + .select({ + fields: ['city', 'map_geocode', 'organizers'], + }) + .all() + + const items = records.map((r) => ({ + id: r.id, + city: r.get('city'), + geocode: r.get('map_geocode'), + organizers: r.get('organizers') ?? [], + })) as TavernEventItem[] + + cachedEvents = items + lastEventsFetch = Date.now() + return items +} diff --git a/src/app/harbor/tavern/tavern.tsx b/src/app/harbor/tavern/tavern.tsx index a03cf1cc..d7a780c2 100644 --- a/src/app/harbor/tavern/tavern.tsx +++ b/src/app/harbor/tavern/tavern.tsx @@ -4,6 +4,11 @@ import { useEffect } from 'react' import useLocalStorageState from '../../../../lib/useLocalStorageState' import { setTavernRsvpStatus, getTavernRsvpStatus } from '@/app/utils/tavern' import { Card } from '@/components/ui/card' +import dynamic from 'next/dynamic' + +const Map = dynamic(() => import('./map'), { + ssr: false, +}) const RsvpStatusSwitcher = () => { const [rsvpStatus, setRsvpStatus] = useLocalStorageState( @@ -45,40 +50,7 @@ export default function Tavern() {

Mystic Tavern

- {/*
-