diff --git a/components/event/EventCard.js b/components/event/EventCard.js index 5b9eabb1b6c..33a64469d1f 100644 --- a/components/event/EventCard.js +++ b/components/event/EventCard.js @@ -52,7 +52,9 @@ export default function EventCard({ manage, event, usernames }) { {event.isVirtual && ( )} - {event.isInPerson && } + {event.location?.country && ( + + )} {event.date.cfpOpen && } {event.price?.startingFrom > 0 && } {event.price?.startingFrom === 0 && } @@ -97,17 +99,11 @@ export default function EventCard({ manage, event, usernames }) { {event.description}

- {(event.isVirtual || (event.isInPerson && event.location)) && ( - - )} + {(event.isVirtual || event.location?.country) && } {event.isVirtual && "Remote"} - {event.isVirtual && - event.isInPerson && - event.location && - " AND in "} - {event.isInPerson && - event.location && + {event.isVirtual && event.location?.country && " AND in "} + {event.location?.country && Object.values(event.location).join(", ")}

diff --git a/components/map/Clusters.js b/components/map/Clusters.js index 1d64cfc2fa8..4f02bf347ca 100644 --- a/components/map/Clusters.js +++ b/components/map/Clusters.js @@ -4,8 +4,9 @@ import { Marker, useMap } from "react-leaflet"; import useSupercluster from "use-supercluster"; import UserMarker from "./UserMarker"; import styles from "./Clusters.module.css"; +import EventMarker from "./EventMarker"; -export default function Clusters({users}) { +export default function Clusters({points}) { const map = useMap(); const mapB = map.getBounds(); const [bounds, setBounds] = useState([ @@ -32,7 +33,7 @@ export default function Clusters({users}) { }) const { clusters, supercluster } = useSupercluster({ - points: users, + points: points, bounds, zoom, options: { @@ -40,7 +41,6 @@ export default function Clusters({users}) { maxZoom: 18 } }); - const icons = {}; const fetchIcon = (count) => { const size = @@ -65,7 +65,8 @@ export default function Clusters({users}) { const { cluster: isCluster, point_count: pointCount, - username + username, + name } = cluster.properties; // we have a cluster to render @@ -91,7 +92,9 @@ export default function Clusters({users}) { } // we have a single point to render - return ( + return cluster.properties.isEvent ? ( + + ) : ( ); })} diff --git a/components/map/EventMarker.js b/components/map/EventMarker.js new file mode 100644 index 00000000000..84cdc935383 --- /dev/null +++ b/components/map/EventMarker.js @@ -0,0 +1,58 @@ +import L from "leaflet"; +import { Marker, Popup } from "react-leaflet"; +import { ReactMarkdown } from "react-markdown/lib/react-markdown"; +import Link from "@components/Link"; + +export default function EventMarker({event}) { + // Custom component for rendering links within ReactMarkdown + const LinkRenderer = ({ href, children }) => ( + + {children} + + ); + + return ( + + + + + `, + popupAnchor: [0, -10], + iconSize: [40, 40], + iconAnchor: [20, 20], + })} + position={[event.geometry.coordinates[1], event.geometry.coordinates[0]]} + > + +
+

+ + {event.properties.name} + +

+ + {[ + event.properties.location.city, + event.properties.location.state, + event.properties.location.country, + ] + .filter((x) => x) + .join(", ")} + + + + {event.properties.description} + + + + {`${new Date(event.properties.date.start).toLocaleDateString()} - + ${new Date(event.properties.date.end).toLocaleDateString()}`} + +
+
+
+ ) +} diff --git a/components/map/Map.js b/components/map/Map.js index 34b05ae0121..5e7c5885211 100644 --- a/components/map/Map.js +++ b/components/map/Map.js @@ -2,7 +2,7 @@ import { MapContainer, TileLayer } from "react-leaflet"; import Clusters from "./Clusters"; import "leaflet/dist/leaflet.css"; -export default function Map({ users }) { +export default function Map({ points }) { const boundsMap = [ [-90, -180], // Southwest coordinates [90, 180], // Northeast coordinates @@ -23,7 +23,7 @@ export default function Map({ users }) { attribution='© OpenStreetMap contributors' url="https://b.tile.openstreetmap.org/{z}/{x}/{y}.png" /> - + ); } diff --git a/components/user/UserEvents.js b/components/user/UserEvents.js index 81674f8de41..3113e56617b 100644 --- a/components/user/UserEvents.js +++ b/components/user/UserEvents.js @@ -3,8 +3,12 @@ import EventCard from "@components/event/EventCard"; import Alert from "@components/Alert"; import DropdownMenu from "@components/form/DropDown"; -export default function UserEvents({ manage = false, events }) { - const [eventType, setEventType] = useState("future"); +export default function UserEvents({ + manage = false, + events, + filter = "future", +}) { + const [eventType, setEventType] = useState(filter); const eventOptions = [ { value: "all", name: "All Events" }, @@ -31,7 +35,7 @@ export default function UserEvents({ manage = false, events }) { case "virtual": return event.date.future && event.isVirtual; case "inPerson": - return event.date.future && event.isInPerson; + return event.date.future && event.location?.country; case "cfpOpen": return event.date.cfpOpen; case "free": diff --git a/models/Profile/Event.js b/models/Profile/Event.js index df9449779b0..08a06cc7dc4 100644 --- a/models/Profile/Event.js +++ b/models/Profile/Event.js @@ -34,6 +34,30 @@ const EventSchema = new Schema({ price: { startingFrom: Number, }, + location: { + road: { + type: String, + min: 2, + max: 128, + }, + city: { + type: String, + min: 2, + max: 128, + }, + state: { + type: String, + min: 2, + max: 128, + }, + country: { + type: String, + min: 2, + max: 128, + }, + lat: Number, + lon: Number, + }, color: { type: String, min: 2, diff --git a/pages/account/manage/event/[[...data]].js b/pages/account/manage/event/[[...data]].js index 59bb6e66382..9304c7d0c8e 100644 --- a/pages/account/manage/event/[[...data]].js +++ b/pages/account/manage/event/[[...data]].js @@ -70,7 +70,11 @@ export default function ManageEvent({ BASE_URL, event }) { event.date?.end && formatDate(event.date?.end) ); const [price, setPrice] = useState(event.price?.startingFrom || 0); - const [color, setColor] = useState(event.color || "" ); + const [color, setColor] = useState(event.color || ""); + const [road, setRoad] = useState(event.location.road || ""); + const [city, setCity] = useState(event.location.city || ""); + const [state, setState] = useState(event.location.state || ""); + const [country, setCountry] = useState(event.location.country || ""); const handleSubmit = async (e) => { e.preventDefault(); @@ -83,6 +87,7 @@ export default function ManageEvent({ BASE_URL, event }) { isVirtual, price: { startingFrom: price }, color, + location: { road, city, state, country }, }; let apiUrl = `${BASE_URL}/api/account/manage/event/`; if (event._id) { @@ -156,11 +161,11 @@ export default function ManageEvent({ BASE_URL, event }) { additionalMessage={showNotification.additionalMessage} /> -
-
+ +
@@ -278,21 +283,67 @@ export default function ManageEvent({ BASE_URL, event }) {
- -
- + +
+ +
+

+ Does the event have a location? +

+
+
+ setRoad(e.target.value)} + value={road} + minLength="2" + maxLength="64" + /> +
+
+ setCity(e.target.value)} + value={city} + minLength="2" + maxLength="64" + /> +
+
+ setState(e.target.value)} + value={state} + minLength="2" + maxLength="64" + /> +
+
+ setCountry(e.target.value)} + value={country} + minLength="2" + maxLength="64" + /> +
+
-
+ - + ); diff --git a/pages/api/events.js b/pages/api/events.js index d85a204f9a6..21db480f589 100644 --- a/pages/api/events.js +++ b/pages/api/events.js @@ -8,38 +8,55 @@ export default async function handler(req, res) { .json({ error: "Invalid request: GET request required" }); } - const events = await getEvents(); + const { withLocation } = req.query; + + const events = await getEvents(withLocation); return res.status(200).json(events); } -export async function getEvents() { +export async function getEvents(withLocation = false) { let events = []; - try { - events = await Profile.aggregate([ - { $project: { username: 1, events: 1, isEnabled: 1 } }, - { $match: { "events.date.start": { $gt: new Date() }, isEnabled: true } }, - { $unwind: "$events" }, - { $match: { "events.date.end": { $gt: new Date() } } }, - { - $group: { - _id: "$events.url", - usernames: { $addToSet: "$username" }, - isVirtual: { $first: "$events.isVirtual" }, - color: { $first: "$events.color" }, - date: { $first: "$events.date" }, - url: { $first: "$events.url" }, - name: { $first: "$events.name" }, - description: { $first: "$events.description" }, - isEnabled: { $first: "$isEnabled" }, - }, - }, - - { - $sort: { "date.start": 1 }, + let aggregate = [ + { $project: { username: 1, events: 1, isEnabled: 1 } }, + { $match: { "events.date.start": { $gt: new Date() }, isEnabled: true } }, + { $unwind: "$events" }, + { $match: { "events.date.end": { $gt: new Date() } } }, + ]; + + if (withLocation) { + aggregate.push({ + $match: { + $and: [ + { "events.location": { $exists: true } }, + { "events.location.lat": { $exists: true } }, + { "events.location.lon": { $exists: true } }, + { "events.location.lat": { $ne: null } }, + { "events.location.lon": { $ne: null } }, + ], }, + }); + } - ]).exec(); + aggregate.push( + { + $group: { + _id: "$events.url", + usernames: { $addToSet: "$username" }, + isVirtual: { $first: "$events.isVirtual" }, + color: { $first: "$events.color" }, + date: { $first: "$events.date" }, + url: { $first: "$events.url" }, + name: { $first: "$events.name" }, + description: { $first: "$events.description" }, + location: { $mergeObjects: "$events.location" }, + isEnabled: { $first: "$isEnabled" }, + }, + }, + { $sort: { "date.start": 1 } } + ); + try { + events = await Profile.aggregate(aggregate).exec(); let dateEvents = []; const today = new Date(); for (const event of events) { diff --git a/pages/api/system/reload.js b/pages/api/system/reload.js index 9d1ecad91d6..6a2c48eca92 100644 --- a/pages/api/system/reload.js +++ b/pages/api/system/reload.js @@ -246,28 +246,68 @@ export default async function handler(req, res) { } // - events - try { - if (profile.events) { + async function getCoordinates(city, state, country) { + let locationDb = {}; + const provided = [city, state, country].filter((x) => x).join(","); + if (locationDb[provided]) { + return locationDb[provided]; + } + try { + const location = await fetch( + `https://nominatim.openstreetmap.org/?addressdetails=1&q= + ${encodeURIComponent(provided)}&format=json&limit=1` + ); + const coordinates = await location.json(); + if (coordinates) { + const point = { + lat: coordinates[0].lat, + lon: coordinates[0].lon, + }; + locationDb[provided] = point; + return point; + } + } catch (e) { + return null; + } + return null; + } + + if (profile.events) { + try { + const events = await Promise.all( + profile.events.map(async (event, position) => { + let location = {}; + if (event.location) { + location = { + location: { ...event.location }, + }; + if (new Date(event.date.start) > Date.now() || new Date(event.date.end) > Date.now()) { + const coordinates = await getCoordinates( + event.location.city, + event.location.state, + event.location.country + ); + if (coordinates) { + location.location.lat = coordinates.lat; + location.location.lon = coordinates.lon; + } + } + } + return { + order: position, + ...event, + ...location, + }; + }) + ); + await Profile.findOneAndUpdate( { username: profile.username }, - { - events: profile.events.map((event) => ({ - isVirtual: event.isVirtual, - color: event.color, - name: event.name, - description: event.description, - date: { - start: event.date.start, - end: event.date.end, - }, - url: event.url, - price: event.price, - })), - } + { events } ); + } catch (e) { + logger.error(e,`failed to update events for ${profile.username}`); } - } catch (e) { - logger.error(e, `failed to update events for ${profile.username}`); } }) ); diff --git a/pages/docs/how-to-guides/events-json.mdx b/pages/docs/how-to-guides/events-json.mdx index ff486500399..237c06937e5 100644 --- a/pages/docs/how-to-guides/events-json.mdx +++ b/pages/docs/how-to-guides/events-json.mdx @@ -24,14 +24,13 @@ All future events also appear on the Events page for the app. _If you need help on how to edit this file, please see the Editing Guide_ -3. This `json` file will contain one object for the event and must have these six fields: `isVirtual` and/or `isInPerson`, `name`, `description`, `date`, `url`, and `location` (this field will be required only when `isInPerson` is `true`). +3. This `json` file will contain one object for the event and must have these six fields: `isVirtual`, `name`, `description`, `date`, `url`, and `location`. It can also optionally include other fields. For instance: `userStatus` & `speakingTopic`. It will look like this: ```js { "isVirtual": true, - "isInPerson": true, "name": "Open Source GitHub reviews", "description": "In this livestream I will be going reviewing your **Open Source projects** and profiles! I will be joined by **Amanda**, a Developer Advocate.", "date": { @@ -55,18 +54,18 @@ _If you need help on how to edit this file, please see the { - switch (index % 4 ) { + switch (index % 4) { case 0: return [coords[0] + offset, coords[1] + offset2]; case 1: @@ -49,7 +52,7 @@ export async function getStaticProps() { default: return [coords[0] + offset, coords[1] - offset2]; } - } + }; data.users = data.users.map((user, index) => { const offset = Math.random() * 0.02; // ~2.2km @@ -62,21 +65,18 @@ export async function getStaticProps() { username: user.username, name: user.name, location: user.location.provided, - bio: user.bio || '' + bio: user.bio || "", }, geometry: { type: "Point", - coordinates:adjustCoords( - [ - parseFloat(user.location.lon), - parseFloat(user.location.lat) - ], + coordinates: adjustCoords( + [parseFloat(user.location.lon), parseFloat(user.location.lat)], offset, offset2, index - ) - } - } + ), + }, + }; }); try { @@ -85,6 +85,38 @@ export async function getStaticProps() { logger.error(e, "ERROR loading tags"); } + try { + data.events = await getEvents(true); + } catch (e) { + logger.error(e, "ERROR loading Events"); + } + + data.events = data.events.map((event, index) => { + const offset = Math.random() * 0.02; // ~2.2km + const offset2 = Math.random() * 0.02; // ~2.2km + return { + type: "Feature", + properties: { + cluster: false, + isEvent: true, + description: event.description, + name: event.name, + location: event.location, + date: event.date, + url: event.url || "", + }, + geometry: { + type: "Point", + coordinates: adjustCoords( + [parseFloat(event.location.lon), parseFloat(event.location.lat)], + offset, + offset2, + index + ), + }, + }; + }); + data.points = [...data.users, ...data.events]; return { props: { data }, revalidate: pageConfig.revalidateSeconds, @@ -92,8 +124,8 @@ export async function getStaticProps() { } export default function Map({ data }) { - let { users, tags } = data; - const [filteredUsers, setFilteredUsers] = useState([]); + let { tags, points } = data; + const [filteredPoints, setFilteredPoints] = useState([]); const [selectedTags, setSelectedTags] = useState(new Set()); let results = []; @@ -115,12 +147,12 @@ export default function Map({ data }) { const valueLower = value.toLowerCase(); const terms = [...updateSelectedTagsFilter(value)]; - results = users.filter((user) => { - if (user.properties.name.toLowerCase().includes(valueLower)) { + results = points.filter((point) => { + if (point.properties.name.toLowerCase().includes(valueLower)) { return true; } - let userTags = user.properties.tags?.map((tag) => tag.toLowerCase()); + let userTags = point.properties.tags?.map((tag) => tag.toLowerCase()); if (terms.every((keyword) => userTags?.includes(keyword.toLowerCase()))) { return true; @@ -129,11 +161,11 @@ export default function Map({ data }) { return false; }); - setFilteredUsers(results); + setFilteredPoints(results); }; const resetFilter = () => { - setFilteredUsers([]); + setFilteredPoints([]); setSelectedTags(new Set()); }; @@ -172,14 +204,16 @@ export default function Map({ data }) { 0 ? filteredUsers.length : users.length + filteredPoints.length > 0 ? filteredPoints.length : points.length } > + > + Clear/Reset Filters + {tags && tags @@ -196,7 +230,7 @@ export default function Map({ data }) {
0 ? filteredUsers : users} + points={filteredPoints.length > 0 ? filteredPoints : points} />