diff --git a/apps/database/src/db.ts b/apps/database/src/db.ts index 7f7ab06..820c35a 100644 --- a/apps/database/src/db.ts +++ b/apps/database/src/db.ts @@ -7,8 +7,30 @@ if (!process.env.DATABASE_URL) { throw new Error("DATABASE_URL environment variable is not set"); } +/* + * Cached on globalThis so hot reloads reuse one pool. + * + * Next dev re-evaluates this module on every HMR pass. Without the cache each + * reload opened a brand-new pool and leaked the previous one, so a long editing + * session walked straight into Postgres's 100-connection ceiling and every + * query — including the auth session lookup — started failing with + * `53300: too many clients already`. + */ +const globalForDb = globalThis as unknown as { __forumDbClient?: ReturnType }; + // Disable prefetch as it is not supported for "Transaction" pool mode -const client = postgres(process.env.DATABASE_URL, { prepare: false }); +const client = + globalForDb.__forumDbClient ?? + postgres(process.env.DATABASE_URL, { + prepare: false, + // Bounded so one process can't monopolise the server's connection slots. + max: 10, + idle_timeout: 20, + }); + +if (process.env.NODE_ENV !== "production") { + globalForDb.__forumDbClient = client; +} export const db = drizzle(client, { schema }); diff --git a/apps/web/src/actions/events.ts b/apps/web/src/actions/events.ts index 241c098..bdb3e1a 100644 --- a/apps/web/src/actions/events.ts +++ b/apps/web/src/actions/events.ts @@ -36,15 +36,128 @@ export interface FeedEvent { orgId: string | null; orgName: string | null; datetime: string; + /** ISO timestamp, for building calendar links client-side. */ + rawDatetime?: string; location: string; tags: string[]; flyerUrl: string | null; rsvpCount: number; friendsAttending: { id: string; displayName: string; avatarUrl: string | null }[]; + /** + * Everyone who has RSVP'd — powers the "N attending" list on each card. + * + * Optional because only the Explore feed loads it; the saved/created/friends + * queries build the same shape without paying for the extra join. + */ + attendees?: { id: string; displayName: string; avatarUrl: string | null }[]; isRsvped: boolean; isSaved: boolean; } +/** Accepted friendships are stored one-directional, so both columns are read. */ +async function loadFriendIds(userId: string): Promise { + const [outgoing, incoming] = await Promise.all([ + db + .select({ friendId: friendships.friendId }) + .from(friendships) + .where(and(eq(friendships.userId, userId), eq(friendships.status, "accepted"))), + db + .select({ friendId: friendships.userId }) + .from(friendships) + .where(and(eq(friendships.friendId, userId), eq(friendships.status, "accepted"))), + ]); + return [...outgoing, ...incoming].map((r) => r.friendId); +} + +interface EventEnrichment { + tags: Map; + attendees: Map>; + friends: Map; + rsvpedByMe: Set; + savedByMe: Set; +} + +/** + * Tags, attendees, friend attendance and the viewer's own RSVP/save state for + * a page of events. + * + * Four call sites were each hard-coding `tags: []`, `rsvpCount: 0`, + * `friendsAttending: []` and `isRsvped/isSaved: false`, so those screens could + * never show a tag or the right button state no matter how they were styled. + * + * Everything is fetched as one query per field and grouped in memory. The + * per-event version issued several queries per row, all concurrent, each + * holding a connection — which is how the pool got exhausted. + */ +async function loadEventEnrichment( + eventIds: string[], + userId: string, + friendIds: string[], +): Promise { + const empty: EventEnrichment = { + tags: new Map(), + attendees: new Map(), + friends: new Map(), + rsvpedByMe: new Set(), + savedByMe: new Set(), + }; + if (eventIds.length === 0) return empty; + + const [tagRows, attendeeRows, myRsvps, mySaves] = await Promise.all([ + db + .select({ eventId: eventTags.eventId, tag: eventTags.tag }) + .from(eventTags) + .where(inArray(eventTags.eventId, eventIds)), + db + .select({ + eventId: rsvps.eventId, + id: users.id, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }) + .from(rsvps) + .innerJoin(users, eq(rsvps.userId, users.id)) + .where(inArray(rsvps.eventId, eventIds)), + db + .select({ eventId: rsvps.eventId }) + .from(rsvps) + .where(and(inArray(rsvps.eventId, eventIds), eq(rsvps.userId, userId))), + db + .select({ eventId: savedEvents.eventId }) + .from(savedEvents) + .where(and(inArray(savedEvents.eventId, eventIds), eq(savedEvents.userId, userId))), + ]); + + const friendIdSet = new Set(friendIds); + const result: EventEnrichment = { + tags: new Map(), + attendees: new Map(), + friends: new Map(), + rsvpedByMe: new Set(myRsvps.map((r) => r.eventId)), + savedByMe: new Set(mySaves.map((r) => r.eventId)), + }; + + for (const row of tagRows) { + const list = result.tags.get(row.eventId); + if (list) list.push(row.tag); + else result.tags.set(row.eventId, [row.tag]); + } + + for (const { eventId, ...person } of attendeeRows) { + const all = result.attendees.get(eventId); + if (all) all.push(person); + else result.attendees.set(eventId, [person]); + + if (friendIdSet.has(person.id)) { + const mine = result.friends.get(eventId); + if (mine) mine.push(person); + else result.friends.set(eventId, [person]); + } + } + + return result; +} + export async function getFeedEvents(params?: { search?: string; tags?: string[]; @@ -288,6 +401,35 @@ export async function getFeedEvents(params?: { }), ); + /* + * Attendees for the "N attending" control on each card. + * + * One query for the whole page rather than one per event — the enrichment + * above already issues several queries per event, and adding another to that + * loop is what pushes the connection pool over on a full feed. + */ + const feedIds = enriched.map((e) => e.id); + const attendeeRows = + feedIds.length === 0 + ? [] + : await db + .select({ + eventId: rsvps.eventId, + id: users.id, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }) + .from(rsvps) + .innerJoin(users, eq(rsvps.userId, users.id)) + .where(inArray(rsvps.eventId, feedIds)); + + const attendeesByEvent = new Map(); + for (const { eventId, ...person } of attendeeRows) { + const list = attendeesByEvent.get(eventId); + if (list) list.push(person); + else attendeesByEvent.set(eventId, [person]); + } + // Sort by score descending; ties break by soonest event first, so // refreshing Explore with no new data never reorders the feed. enriched.sort((a, b) => { @@ -296,12 +438,30 @@ export async function getFeedEvents(params?: { }); return { - events: enriched.map(({ score: _score, _rawDatetime, ...event }) => event), + events: enriched.map(({ score: _score, _rawDatetime, ...event }) => ({ + ...event, + // Carried through so the feed card can build its "+ Calendar" link; the + // sort key was being dropped here and the button never rendered. + rawDatetime: _rawDatetime.toISOString(), + attendees: attendeesByEvent.get(event.id) ?? [], + })), total, }; } -export async function toggleRsvp(eventId: string): Promise<{ rsvped: boolean; count: number }> { +/** The attendee shape shared by the feed, the detail page and `toggleRsvp`. */ +type Attendee = { id: string; displayName: string; avatarUrl: string | null }; + +export async function toggleRsvp(eventId: string): Promise<{ + rsvped: boolean; + count: number; + /* + * The full attendee list after the toggle. Callers render an avatar stack + * from this alongside the count, so returning only the count left the + * viewer's own face in the stack after they un-RSVP'd. + */ + attendees: Attendee[]; +}> { const session = await auth(); if (!session?.user?.id) throw new Error("Unauthorized"); @@ -311,7 +471,7 @@ export async function toggleRsvp(eventId: string): Promise<{ rsvped: boolean; co const [eventRow] = await db.select().from(events).where(eq(events.id, eventId)).limit(1); if (!eventRow) { // Return zero count and no-op rsvp change to avoid FK constraint errors - return { rsvped: false, count: 0 }; + return { rsvped: false, count: 0, attendees: [] }; } const [existing] = await db @@ -326,16 +486,24 @@ export async function toggleRsvp(eventId: string): Promise<{ rsvped: boolean; co await db.insert(rsvps).values({ userId, eventId }); } - const [countResult] = await db - .select({ count: sql`count(*)::int` }) + // Re-read the roster rather than counting: the count and the avatar stack are + // rendered from the same data, so they cannot drift out of step this way. + const attendees = await db + .select({ + id: users.id, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }) .from(rsvps) + .innerJoin(users, eq(rsvps.userId, users.id)) .where(eq(rsvps.eventId, eventId)); revalidatePath("/explore"); return { rsvped: !existing, - count: countResult?.count ?? 0, + count: attendees.length, + attendees, }; } @@ -508,6 +676,8 @@ export async function getSimilarEvents( const session = await auth(); if (!session?.user?.id) throw new Error("Unauthorized"); + const userId = session.user.id; + const conditions = [gt(events.datetime, new Date())]; // Events with matching tags or same org, excluding current event @@ -548,21 +718,33 @@ export async function getSimilarEvents( .orderBy(events.datetime) .limit(4); - return rawEvents.map((event) => ({ - id: event.id, - title: event.title, - description: event.description, - orgId: event.orgId, - orgName: event.orgName, - datetime: formatEventDateTime(event.datetime), - location: event.locationName ?? "TBD", - tags: [], - flyerUrl: event.flyerUrl, - rsvpCount: 0, - friendsAttending: [], - isRsvped: false, - isSaved: false, - })); + const friendIds = await loadFriendIds(userId); + const extra = await loadEventEnrichment( + rawEvents.map((e) => e.id), + userId, + friendIds, + ); + + return rawEvents.map((event) => { + const attendees = extra.attendees.get(event.id) ?? []; + return { + id: event.id, + title: event.title, + description: event.description, + orgId: event.orgId, + orgName: event.orgName, + datetime: formatEventDateTime(event.datetime), + rawDatetime: event.datetime.toISOString(), + location: event.locationName ?? "TBD", + tags: extra.tags.get(event.id) ?? [], + flyerUrl: event.flyerUrl, + rsvpCount: attendees.length, + attendees, + friendsAttending: extra.friends.get(event.id) ?? [], + isRsvped: extra.rsvpedByMe.has(event.id), + isSaved: extra.savedByMe.has(event.id), + }; + }); } type EventTagValue = typeof eventTags.$inferSelect.tag; @@ -803,21 +985,37 @@ export async function getMyEvents(): Promise<{ .where(eq(savedEvents.userId, userId)) .orderBy(events.datetime); - const mapEvent = (e: (typeof createdEvents)[0]): FeedEvent => ({ - id: e.id, - title: e.title, - description: e.description, - orgId: e.orgId, - orgName: e.orgName, - datetime: formatEventDateTime(e.datetime), - location: e.locationName ?? "TBD", - tags: [], - flyerUrl: e.flyerUrl, - rsvpCount: 0, - friendsAttending: [], - isRsvped: false, - isSaved: false, - }); + /* + * Enrich all three tabs at once. These fields used to be hard-coded, so My + * Events could never show a tag, a friend, or the correct RSVP state + * regardless of how the card was styled. + */ + const allIds = [ + ...new Set([...createdEvents, ...rsvpedEvents, ...savedEventsResult].map((e) => e.id)), + ]; + const friendIds = await loadFriendIds(userId); + const extra = await loadEventEnrichment(allIds, userId, friendIds); + + const mapEvent = (e: (typeof createdEvents)[0]): FeedEvent => { + const attendees = extra.attendees.get(e.id) ?? []; + return { + id: e.id, + title: e.title, + description: e.description, + orgId: e.orgId, + orgName: e.orgName, + datetime: formatEventDateTime(e.datetime), + rawDatetime: e.datetime.toISOString(), + location: e.locationName ?? "TBD", + tags: extra.tags.get(e.id) ?? [], + flyerUrl: e.flyerUrl, + rsvpCount: attendees.length, + attendees, + friendsAttending: extra.friends.get(e.id) ?? [], + isRsvped: extra.rsvpedByMe.has(e.id), + isSaved: extra.savedByMe.has(e.id), + }; + }; return { created: createdEvents.map(mapEvent), @@ -860,25 +1058,43 @@ export async function getSavedEvents(): Promise { .innerJoin(events, eq(savedEvents.eventId, events.id)) .leftJoin(campusLocations, eq(events.locationId, campusLocations.id)) .leftJoin(organizations, eq(events.orgId, organizations.id)) - .where(eq(savedEvents.userId, userId)) + /* + * Future events only. "Upcoming Events" reads from this list, and without + * the datetime bound a saved event from last week surfaced there — with + * `formatRelativeDay` cheerfully announcing it was happening "yesterday". + */ + .where(and(eq(savedEvents.userId, userId), gt(events.datetime, new Date()))) .orderBy(events.datetime) .limit(5); - return saved.map((event) => ({ - id: event.id, - title: event.title, - description: event.description, - orgId: event.orgId, - orgName: event.orgName, - datetime: formatEventDateTime(event.datetime), - location: event.locationName ?? "TBD", - tags: [], - flyerUrl: event.flyerUrl, - rsvpCount: 0, - friendsAttending: [], - isRsvped: false, - isSaved: true, - })); + const friendIds = await loadFriendIds(userId); + const extra = await loadEventEnrichment( + saved.map((e) => e.id), + userId, + friendIds, + ); + + return saved.map((event) => { + const attendees = extra.attendees.get(event.id) ?? []; + return { + id: event.id, + title: event.title, + description: event.description, + orgId: event.orgId, + orgName: event.orgName, + datetime: formatEventDateTime(event.datetime), + rawDatetime: event.datetime.toISOString(), + location: event.locationName ?? "TBD", + tags: extra.tags.get(event.id) ?? [], + flyerUrl: event.flyerUrl, + rsvpCount: attendees.length, + attendees, + friendsAttending: extra.friends.get(event.id) ?? [], + isRsvped: extra.rsvpedByMe.has(event.id), + // Everything in this list is saved by definition. + isSaved: true, + }; + }); } export interface FriendsEvent extends FeedEvent { @@ -937,20 +1153,31 @@ export async function getFriendsEvents(): Promise { .orderBy(desc(sql`friend_count`), events.datetime) .limit(20); - return friendsEvents.map((event) => ({ - id: event.id, - title: event.title, - description: event.description, - orgId: event.orgId, - orgName: event.orgName, - datetime: formatEventDateTime(event.datetime), - location: event.locationName ?? "TBD", - tags: [], - flyerUrl: event.flyerUrl, - rsvpCount: 0, - friendsAttending: [], - isRsvped: false, - isSaved: false, - friendCount: event.friendCount, - })); + const extra = await loadEventEnrichment( + friendsEvents.map((e) => e.id), + userId, + friendIds, + ); + + return friendsEvents.map((event) => { + const attendees = extra.attendees.get(event.id) ?? []; + return { + id: event.id, + title: event.title, + description: event.description, + orgId: event.orgId, + orgName: event.orgName, + datetime: formatEventDateTime(event.datetime), + rawDatetime: event.datetime.toISOString(), + location: event.locationName ?? "TBD", + tags: extra.tags.get(event.id) ?? [], + flyerUrl: event.flyerUrl, + rsvpCount: attendees.length, + attendees, + friendsAttending: extra.friends.get(event.id) ?? [], + isRsvped: extra.rsvpedByMe.has(event.id), + isSaved: extra.savedByMe.has(event.id), + friendCount: event.friendCount, + }; + }); } diff --git a/apps/web/src/actions/map.ts b/apps/web/src/actions/map.ts index deb4563..04044a2 100644 --- a/apps/web/src/actions/map.ts +++ b/apps/web/src/actions/map.ts @@ -7,9 +7,13 @@ import { db, eq, eventTags, + friendships, gte, + inArray, lt, organizations, + rsvps, + users, } from "@the-forum/database"; import { auth } from "~/auth"; @@ -25,6 +29,8 @@ export interface MapEvent { latitude: number; longitude: number; tags: string[]; + /** Friends of the viewer who have RSVP'd — the avatar stack on each card. */ + friendsAttending: { id: string; displayName: string; avatarUrl: string | null }[]; } /** Fetch events for a date range (defaults to next 7 days). */ @@ -35,6 +41,21 @@ export async function getMapEvents(opts?: { const session = await auth(); if (!session?.user?.id) throw new Error("Unauthorized"); + const userId = session.user.id; + + // Accepted friendships are stored one-directional, so both columns are read. + const [outgoing, incoming] = await Promise.all([ + db + .select({ friendId: friendships.friendId }) + .from(friendships) + .where(and(eq(friendships.userId, userId), eq(friendships.status, "accepted"))), + db + .select({ friendId: friendships.userId }) + .from(friendships) + .where(and(eq(friendships.friendId, userId), eq(friendships.status, "accepted"))), + ]); + const friendIds = [...outgoing, ...incoming].map((r) => r.friendId); + const startDate = opts?.from ? new Date(opts.from) : new Date(); startDate.setHours(0, 0, 0, 0); @@ -60,24 +81,58 @@ export async function getMapEvents(opts?: { .where(and(gte(events.datetime, startDate), lt(events.datetime, endDate))) .orderBy(events.datetime); - return Promise.all( - results.map(async (r) => { - const tags = await db - .select({ tag: eventTags.tag }) - .from(eventTags) - .where(eq(eventTags.eventId, r.id)); + /* + * Tags and friend RSVPs are fetched in one query each and grouped in memory, + * rather than two queries per event. The per-event version issued 2N+1 + * queries — all fired concurrently via Promise.all — which held a connection + * each and helped exhaust the pool. + */ + const eventIds = results.map((r) => r.id); + if (eventIds.length === 0) return []; + + const [tagRows, friendRsvpRows] = await Promise.all([ + db + .select({ eventId: eventTags.eventId, tag: eventTags.tag }) + .from(eventTags) + .where(inArray(eventTags.eventId, eventIds)), + friendIds.length === 0 + ? Promise.resolve([]) + : db + .select({ + eventId: rsvps.eventId, + id: users.id, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }) + .from(rsvps) + .innerJoin(users, eq(rsvps.userId, users.id)) + .where(and(inArray(rsvps.eventId, eventIds), inArray(rsvps.userId, friendIds))), + ]); + + const tagsByEvent = new Map(); + for (const row of tagRows) { + const list = tagsByEvent.get(row.eventId); + if (list) list.push(row.tag); + else tagsByEvent.set(row.eventId, [row.tag]); + } + + const friendsByEvent = new Map(); + for (const { eventId, ...friend } of friendRsvpRows) { + const list = friendsByEvent.get(eventId); + if (list) list.push(friend); + else friendsByEvent.set(eventId, [friend]); + } - return { - ...r, - locationId: r.locationId ?? "", - locationName: r.locationName ?? "TBD", - rawDatetime: r.datetime.toISOString(), - datetime: r.datetime.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - }), - tags: tags.map((t) => t.tag), - }; + return results.map((r) => ({ + ...r, + friendsAttending: friendsByEvent.get(r.id) ?? [], + locationId: r.locationId ?? "", + locationName: r.locationName ?? "TBD", + rawDatetime: r.datetime.toISOString(), + datetime: r.datetime.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", }), - ); + tags: tagsByEvent.get(r.id) ?? [], + })); } diff --git a/apps/web/src/app/(app)/events/[id]/edit/edit-event-form.tsx b/apps/web/src/app/(app)/events/[id]/edit/edit-event-form.tsx index ab61ea1..73f88b4 100644 --- a/apps/web/src/app/(app)/events/[id]/edit/edit-event-form.tsx +++ b/apps/web/src/app/(app)/events/[id]/edit/edit-event-form.tsx @@ -182,10 +182,10 @@ export function EditEventForm({ event, locations }: EditEventFormProps) { -
+
{/* Timeline sidebar */} -
-
+
+
{TIMELINE_SECTIONS.map(({ id, label, color }) => ( {/* Attendees */}
- {event.attendees.length > 0 && ( - - )} + {attendees.length > 0 && }
- - - {rsvpCount} attending - + + f.id))} + />
{event.friendsAttending.length > 0 && (

diff --git a/apps/web/src/app/(app)/events/create/create-event-form.tsx b/apps/web/src/app/(app)/events/create/create-event-form.tsx index 4f1ac3c..43e3321 100644 --- a/apps/web/src/app/(app)/events/create/create-event-form.tsx +++ b/apps/web/src/app/(app)/events/create/create-event-form.tsx @@ -239,10 +239,10 @@ export function CreateEventForm({ locations, userOrgs }: CreateEventFormProps) {

-
+
{/* Timeline sidebar */} -
-
+
+
{TIMELINE_SECTIONS.map(({ id, label, color }) => ( + + + + ); } diff --git a/apps/web/src/app/(app)/events/page.tsx b/apps/web/src/app/(app)/events/page.tsx index b393bd2..23c1a5f 100644 --- a/apps/web/src/app/(app)/events/page.tsx +++ b/apps/web/src/app/(app)/events/page.tsx @@ -11,8 +11,9 @@ export default async function MyEventsPage() { return ( + + + ); + })} + + )} + + {/* Upcoming Events */}
Upcoming Events - - {upcomingList.map((event) => ( - -
- {event.flyerUrl ? ( - - ) : ( -
- )} -
-

- {event.title} -

- - DETAILS - - - ))} - {upcomingList.length === 0 && ( -

- No upcoming events yet. -

- )} - + {upcomingList.length === 0 ? ( +

+ No upcoming events yet. +

+ ) : ( +
    + {upcomingList.map((event) => ( +
  • +
    + {event.flyerUrl && ( + + )} +
    + +

    + {event.title} is happening{" "} + {event.rawDatetime ? formatRelativeDay(new Date(event.rawDatetime)) : "soon"}! +

    + + +
  • + ))} +
+ )}
diff --git a/apps/web/src/app/(app)/friends/friends-client.tsx b/apps/web/src/app/(app)/friends/friends-client.tsx index d01b8da..b441bc2 100644 --- a/apps/web/src/app/(app)/friends/friends-client.tsx +++ b/apps/web/src/app/(app)/friends/friends-client.tsx @@ -20,7 +20,9 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; function Avatar({ name, avatarUrl, - size = 56, + // 44px reads fine on a phone row and still leaves room for the name and + // its actions; 56 crowded them. + size = 44, }: { name: string; avatarUrl?: string | null; @@ -150,7 +152,8 @@ export function FriendsClient({ initialFriends, initialPending }: FriendsClientP {label} {count > 0 && ( @@ -237,7 +240,7 @@ export function FriendsClient({ initialFriends, initialPending }: FriendsClientP
@@ -250,15 +253,17 @@ export function FriendsClient({ initialFriends, initialPending }: FriendsClientP

{/* - Kept reachable by keyboard: the control is always in the - tab order and reveals itself on focus, not just on hover. + Always visible rather than hover-revealed: a control you + cannot see is a control you cannot find, and hover doesn't + exist on touch at all. It stays low-contrast until hover, + where it turns coral to signal the destructive action. */} @@ -285,8 +290,13 @@ export function FriendsClient({ initialFriends, initialPending }: FriendsClientP {pending.incoming.length > 0 ? (
+ {/* Rows wrap on phones so Accept/Decline don't squeeze the name */} {pending.incoming.map((req) => ( - +

diff --git a/apps/web/src/app/(app)/map/_components/event-detail-modal.tsx b/apps/web/src/app/(app)/map/_components/event-detail-modal.tsx index 5ce4563..ffb20dc 100644 --- a/apps/web/src/app/(app)/map/_components/event-detail-modal.tsx +++ b/apps/web/src/app/(app)/map/_components/event-detail-modal.tsx @@ -1,137 +1,145 @@ "use client"; -import { Bookmark, MapPin, Share2, X } from "lucide-react"; +import { X } from "lucide-react"; import { useEffect, useState, useTransition } from "react"; -import { getEvent } from "~/actions/events"; +import { toast } from "sonner"; +import { type EventDetail, getEvent, toggleRsvp, toggleSave } from "~/actions/events"; +import { EventCard } from "~/components/events/event-card"; import { Button } from "~/components/ui/button"; -import { Dialog, DialogContent, DialogTitle } from "~/components/ui/dialog"; -import { getEventColor } from "../_lib/map-helpers"; +import { Dialog, DialogClose, DialogContent, DialogTitle } from "~/components/ui/dialog"; +import { buildGCalUrl } from "~/lib/calendar"; +import { formatEventDateTime } from "~/lib/date-format"; interface EventDetailModalProps { eventId: string | null; onClose: () => void; } -interface EventDetail { - id: string; - title: string; - description: string | null; - datetime: Date; - endDatetime: Date | null; - locationName: string | null; - orgName: string | null; - flyerUrl: string | null; - tags: string[]; - rsvpCount: number; - isRsvped: boolean; - isSaved: boolean; -} - +/** + * The map's expanded event view. + * + * Renders the same `EventCard` the Explore feed uses, so an event looks + * identical whether you found it in the feed or on the map. This previously + * had a bespoke two-column layout, which meant the same event had two + * different visual treatments depending on where you opened it. + */ export function EventDetailModal({ eventId, onClose }: EventDetailModalProps) { const [event, setEvent] = useState(null); - const [isPending, startTransition] = useTransition(); + const [isLoading, startLoading] = useTransition(); + const [, startMutating] = useTransition(); useEffect(() => { if (!eventId) { setEvent(null); return; } - startTransition(async () => { - const result = await getEvent(eventId); - if (result) { - setEvent(result as unknown as EventDetail); - } + startLoading(async () => { + setEvent(await getEvent(eventId)); }); }, [eventId]); - return ( -

!open && onClose()}> - - {event?.title ?? "Event Details"} - - {isPending || !event ? ( -
-
-
- Loading event -
-
- ) : ( -
- {/* Header with actions */} -
-
- {event.orgName && ( -

{event.orgName}

- )} -

{event.title}

-
-
- - - -
-
+ const handleRsvp = () => { + if (!event) return; + setEvent({ ...event, isRsvped: !event.isRsvped }); + startMutating(async () => { + const result = await toggleRsvp(event.id); + setEvent((prev) => + prev + ? { + ...prev, + isRsvped: result.rsvped, + rsvpCount: result.count, + attendees: result.attendees, + } + : prev, + ); + }); + }; - {/* Location + time */} -
- {event.locationName && ( - - - {event.locationName} - - )} - - - {new Date(event.datetime).toLocaleDateString("en-US", { - weekday: "short", - month: "short", - day: "numeric", - })}{" "} - at{" "} - {new Date(event.datetime).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} - -
+ const handleSave = () => { + if (!event) return; + setEvent({ ...event, isSaved: !event.isSaved }); + startMutating(async () => { + const result = await toggleSave(event.id); + setEvent((prev) => (prev ? { ...prev, isSaved: result.saved } : prev)); + }); + }; - {/* Tags */} - {event.tags.length > 0 && ( -
- {event.tags.map((tag) => { - const tagColor = getEventColor([tag]); - return ( - - {tag.replace(/-/g, " ")} - - ); - })} -
- )} + const handleShare = async () => { + if (!event) return; + await navigator.clipboard.writeText(`${window.location.origin}/events/${event.id}`); + toast.success("Link copied to clipboard"); + }; - {/* Description */} - {event.description && ( -

- {event.description} -

- )} + return ( + !open && onClose()}> + {/* + Transparent overlay and chrome: the card is the surface, floating over a + still-visible map so you keep track of which pin you opened. + */} + + {event?.title ?? "Event details"} - {/* RSVP button */} - -
+ +
+ + {isLoading || !event ? ( + + + + Loading event + + + ) : ( + )}
diff --git a/apps/web/src/app/(app)/map/_components/event-list-panel.tsx b/apps/web/src/app/(app)/map/_components/event-list-panel.tsx index 459dc15..04f0dd8 100644 --- a/apps/web/src/app/(app)/map/_components/event-list-panel.tsx +++ b/apps/web/src/app/(app)/map/_components/event-list-panel.tsx @@ -1,151 +1,98 @@ "use client"; -import { MapPin, Maximize2, X } from "lucide-react"; +import { MapPin, X } from "lucide-react"; import type { MapEvent } from "~/actions/map"; import { EmptyState } from "~/components/common/states"; +import { EventCard } from "~/components/events/event-card"; import { Button } from "~/components/ui/button"; import { cn } from "~/lib/utils"; -import { getEventColor } from "../_lib/map-helpers"; interface EventListPanelProps { + /** Only the events at the clicked pin, not the whole filtered set. */ events: MapEvent[]; - selectedLocation: string | null; + /** Name of the clicked location, shown in the header. */ + locationName: string; + /** + * The event whose detail card is currently open — the only card that gets + * the highlighted fill. + * + * This used to be the selected *location*, which meant clicking a pin holding + * four events highlighted all four cards at once. + */ + expandedEventId: string | null; onLocateEvent: (event: MapEvent) => void; onExpandEvent: (eventId: string) => void; onClose: () => void; } +/** + * The map's right-hand event list. + * + * Rendered as separate floating cards over the map rather than one opaque + * full-height panel, matching the design — the map stays visible in the gaps + * between cards. + */ export function EventListPanel({ events, - selectedLocation, + locationName, + expandedEventId, onLocateEvent, onExpandEvent, onClose, }: EventListPanelProps) { return ( -
- {/* Header */} -
- - {events.length} event{events.length !== 1 ? "s" : ""} - -
- {/* Event cards */} -
- {events.length === 0 && ( + {events.length === 0 ? ( +
- )} - - {events.map((event) => ( - + ) : ( + events.map((event, index) => ( + onLocateEvent(event)} - onExpand={() => onExpandEvent(event.id)} - /> - ))} -
-
- ); -} - -function PanelEventCard({ - event, - isActive, - onLocate, - onExpand, -}: { - event: MapEvent; - isActive: boolean; - onLocate: () => void; - onExpand: () => void; -}) { - const eventDate = new Date(event.rawDatetime); - - return ( - /* - * The card was a - - -
- - {/* Tags */} - {event.tags.length > 0 && ( -
- {event.tags.slice(0, 3).map((tag) => { - const tagColor = getEventColor([tag]); - return ( - - {tag.replace(/-/g, " ")} - - ); - })} -
- )} - - {/* Org */} - {event.orgName && ( -

- {event.orgName} -

+ location={event.locationName} + tags={event.tags} + friendsAttending={event.friendsAttending} + density="compact" + source="map" + position={index} + onLocate={() => onLocateEvent(event)} + onOpen={() => onExpandEvent(event.id)} + className={cn( + "shrink-0 border-0 shadow-md transition-colors", + // Tinted only while this card's own detail view is open. + expandedEventId === event.id ? "bg-[#ECFCFC]" : "bg-white", + )} + /> + )) )}
); diff --git a/apps/web/src/app/(app)/map/_components/map-filter-pills.tsx b/apps/web/src/app/(app)/map/_components/map-filter-pills.tsx index d052df1..b30f1af 100644 --- a/apps/web/src/app/(app)/map/_components/map-filter-pills.tsx +++ b/apps/web/src/app/(app)/map/_components/map-filter-pills.tsx @@ -16,8 +16,10 @@ const PILLS: { key: FilterKey; label: string; icon: typeof Users }[] = [ ]; export function MapFilterPills({ activeFilters, onToggle }: MapFilterPillsProps) { + // Scrolls sideways on phones rather than wrapping into stacked rows that + // eat the map. return ( -
+
Filter map events {PILLS.map(({ key, label, icon: Icon }) => ( void; -} - -export function MapPopupCarousel({ events, onExpand }: MapPopupCarouselProps) { - const [activeIndex, setActiveIndex] = useState(0); - const current = events[activeIndex]; - if (!current) return null; - - return ( -
- onExpand(current.id)} /> - - {events.length > 1 && ( - <> - {/* Left arrow */} - - - {/* Right arrow */} - - - {/* Pagination dots */} -
- {events.map((evt, i) => ( -
- - )} -
- ); -} diff --git a/apps/web/src/app/(app)/map/_components/map-popup.tsx b/apps/web/src/app/(app)/map/_components/map-popup.tsx deleted file mode 100644 index 56e3a65..0000000 --- a/apps/web/src/app/(app)/map/_components/map-popup.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { Maximize2 } from "lucide-react"; -import type { MapEvent } from "~/actions/map"; -import { Button } from "~/components/ui/button"; -import { cn } from "~/lib/utils"; -import { URGENCY_STYLES, getEventColor, getRelativeLabel } from "../_lib/map-helpers"; - -interface MapPopupProps { - event: MapEvent; - onExpand: () => void; -} - -export function MapPopup({ event, onExpand }: MapPopupProps) { - const color = getEventColor(event.tags); - const rel = getRelativeLabel(event.rawDatetime); - const urgency = URGENCY_STYLES[rel.urgency]; - const eventDate = new Date(event.rawDatetime); - - return ( -
-
-
-

{event.title}

-

- {eventDate.toLocaleDateString("en-US", { - weekday: "long", - month: "short", - day: "numeric", - })} -

-

- {eventDate.toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} - {" - "} - {new Date(eventDate.getTime() + 2 * 60 * 60 * 1000).toLocaleTimeString("en-US", { - hour: "numeric", - minute: "2-digit", - })} -

-
- -
- - {event.tags.length > 0 && ( -
- {event.tags.slice(0, 3).map((tag) => { - const tagColor = getEventColor([tag]); - return ( - - {tag.replace(/-/g, " ")} - - ); - })} -
- )} - - {event.orgName && ( -
- - {event.orgName} -
- )} -
- ); -} diff --git a/apps/web/src/app/(app)/map/_components/map-view.tsx b/apps/web/src/app/(app)/map/_components/map-view.tsx index 461a028..e3231e6 100644 --- a/apps/web/src/app/(app)/map/_components/map-view.tsx +++ b/apps/web/src/app/(app)/map/_components/map-view.tsx @@ -1,7 +1,8 @@ "use client"; -import { forwardRef, useCallback, useState } from "react"; -import { Map as MapGL, type MapRef, Marker, NavigationControl, Popup } from "react-map-gl/mapbox"; +import type { Map as MapboxMap } from "mapbox-gl"; +import { forwardRef, useCallback, useEffect, useRef } from "react"; +import { Map as MapGL, type MapRef, Marker, NavigationControl } from "react-map-gl/mapbox"; import type { MapEvent } from "~/actions/map"; import { env } from "~/env"; import { @@ -13,115 +14,118 @@ import { } from "../_lib/map-constants"; import { getTimeGroup } from "../_lib/map-helpers"; import { MapPin } from "./map-pin"; -import { MapPopup } from "./map-popup"; -import { MapPopupCarousel } from "./map-popup-carousel"; +import { YouAreHere } from "./you-are-here"; interface MapViewProps { locationGroups: Map; selectedLocation: string | null; onSelectLocation: (locId: string | null) => void; onExpandEvent: (eventId: string) => void; + /** Open the sidebar listing every event at this location. */ + onShowLocationList: (locId: string) => void; } export const MapView = forwardRef(function MapView( - { locationGroups, selectedLocation, onSelectLocation, onExpandEvent }, + { locationGroups, selectedLocation, onSelectLocation, onExpandEvent, onShowLocationList }, ref, ) { - const [popupLoc, setPopupLoc] = useState<{ - lng: number; - lat: number; - events: MapEvent[]; - } | null>(null); - + /* + * Clicking a pin goes straight to the designed surface — no intermediate + * popup. One event opens the full detail card; several open the right-hand + * sidebar listing them. + * + * The previous mini-popup stacked a carousel's prev/next arrows on top of the + * popup body (`absolute top-1/2` over the content), so the arrows covered the + * event's own start time. + */ const handleMarkerClick = useCallback( (locId: string, locEvents: MapEvent[]) => { const first = locEvents[0]; if (!first) return; onSelectLocation(locId); - setPopupLoc({ lng: first.longitude, lat: first.latitude, events: locEvents }); + if (locEvents.length === 1) { + onExpandEvent(first.id); + } else { + onShowLocationList(locId); + } }, - [onSelectLocation], + [onSelectLocation, onExpandEvent, onShowLocationList], ); const handleMapClick = useCallback(() => { onSelectLocation(null); - setPopupLoc(null); }, [onSelectLocation]); - return ( - - + /* + * Mapbox sizes its canvas once and does not track its container, so any + * layout change after mount — the shell switching to a flex column, the nav + * rail animating, a window resize — leaves the canvas at its old size with + * blank space where the map should be. Re-measure whenever the box changes. + */ + const containerRef = useRef(null); + const mapInstance = useRef(null); - {Array.from(locationGroups.entries()).map(([locId, locEvents]) => { - const first = locEvents[0]; - if (!first) return null; - const isNow = getTimeGroup(first.rawDatetime) === "now"; - const isSelected = selectedLocation === locId; + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const observer = new ResizeObserver(() => mapInstance.current?.resize()); + observer.observe(el); + return () => observer.disconnect(); + }, []); + + return ( +
+ { + mapInstance.current = e.target; + e.target.resize(); + }} + mapboxAccessToken={env.NEXT_PUBLIC_CAMPUS_MAP_TOKEN} + initialViewState={{ + longitude: PRINCETON_CENTER.lng, + latitude: PRINCETON_CENTER.lat, + zoom: DEFAULT_ZOOM, + pitch: 0, + bearing: 0, + }} + minZoom={MIN_ZOOM} + maxZoom={MAX_ZOOM} + maxBounds={CAMPUS_BOUNDS} + mapStyle={env.NEXT_PUBLIC_CAMPUS_MAP_STYLE} + style={{ width: "100%", height: "100%" }} + reuseMaps + onClick={handleMapClick} + > + + - return ( - { - e.originalEvent.stopPropagation(); - handleMarkerClick(locId, locEvents); - }} - > - 1 ? locEvents.length : undefined} - isSelected={isSelected} - /> - - ); - })} + {Array.from(locationGroups.entries()).map(([locId, locEvents]) => { + const first = locEvents[0]; + if (!first) return null; + const isNow = getTimeGroup(first.rawDatetime) === "now"; + const isSelected = selectedLocation === locId; - {popupLoc && ( - { - setPopupLoc(null); - onSelectLocation(null); - }} - maxWidth="340px" - className="map-event-popup" - > - {popupLoc.events.length === 1 && popupLoc.events[0] ? ( - onExpandEvent(popupLoc.events[0]?.id ?? "")} - /> - ) : ( - onExpandEvent(eventId)} - /> - )} - - )} - + return ( + { + e.originalEvent.stopPropagation(); + handleMarkerClick(locId, locEvents); + }} + > + 1 ? locEvents.length : undefined} + isSelected={isSelected} + /> + + ); + })} + +
); }); diff --git a/apps/web/src/app/(app)/map/_components/timeline-scrubber.tsx b/apps/web/src/app/(app)/map/_components/timeline-scrubber.tsx index 7146caa..c01cfce 100644 --- a/apps/web/src/app/(app)/map/_components/timeline-scrubber.tsx +++ b/apps/web/src/app/(app)/map/_components/timeline-scrubber.tsx @@ -21,9 +21,9 @@ export function TimelineScrubber({ const timelineDays = useMemo(() => getTimelineDays(days), [days]); return ( -
- {/* Legend */} -
+
+ {/* Legend — hidden on phones, where the horizontal room matters more */} +
NOW diff --git a/apps/web/src/app/(app)/map/_components/you-are-here.tsx b/apps/web/src/app/(app)/map/_components/you-are-here.tsx new file mode 100644 index 0000000..494f4e2 --- /dev/null +++ b/apps/web/src/app/(app)/map/_components/you-are-here.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Marker } from "react-map-gl/mapbox"; +import { CAMPUS_BOUNDS } from "../_lib/map-constants"; + +/** + * The viewer's own position, labelled "You Are Here". + * + * Geolocation is requested once and failures are swallowed — the browser + * prompt can be denied, dismissed, or unavailable over plain HTTP, and none of + * those should surface an error on a map that works fine without it. The + * marker is simply omitted when there is no fix, or when the fix falls outside + * the campus bounds (so a user across the country doesn't yank the layout). + */ +export function YouAreHere() { + const [position, setPosition] = useState<{ lng: number; lat: number } | null>(null); + + useEffect(() => { + if (!navigator.geolocation) return; + + let cancelled = false; + navigator.geolocation.getCurrentPosition( + (pos) => { + if (cancelled) return; + const { longitude: lng, latitude: lat } = pos.coords; + const [[west, south], [east, north]] = CAMPUS_BOUNDS; + if (lng < west || lng > east || lat < south || lat > north) return; + setPosition({ lng, lat }); + }, + () => { + /* denied or unavailable — the map is still fully usable */ + }, + { enableHighAccuracy: true, timeout: 10_000, maximumAge: 60_000 }, + ); + + return () => { + cancelled = true; + }; + }, []); + + if (!position) return null; + + return ( + +
+ + + You Are Here + +
+
+ ); +} diff --git a/apps/web/src/app/(app)/map/map-client.tsx b/apps/web/src/app/(app)/map/map-client.tsx index 10cbd4f..86b4922 100644 --- a/apps/web/src/app/(app)/map/map-client.tsx +++ b/apps/web/src/app/(app)/map/map-client.tsx @@ -106,6 +106,15 @@ export function MapClient({ initialEvents }: MapClientProps) { return groups; }, [filteredEvents]); + /* + * The sidebar shows the events at the pin you clicked — not the whole + * filtered set. Clicking a pin with four events shows those four. + */ + const panelEvents = useMemo( + () => (selectedLocation ? (locationGroups.get(selectedLocation) ?? []) : []), + [selectedLocation, locationGroups], + ); + const eventCountByDate = useMemo(() => { const counts = new Map(); for (const event of events) { @@ -130,8 +139,14 @@ export function MapClient({ initialEvents }: MapClientProps) { setSelectedLocation(null); }, []); + /** A single event at a pin → open the full detail card. */ const handleExpandEvent = useCallback((eventId: string) => { setDetailEventId(eventId); + }, []); + + /** Several events at a pin → open the sidebar listing them. */ + const handleShowLocationList = useCallback((locId: string) => { + setSelectedLocation(locId); setPanelOpen(true); }, []); @@ -152,47 +167,71 @@ export function MapClient({ initialEvents }: MapClientProps) { map painted over the docked Sidebar and the route had to opt out of the standard chrome. It now shares the same nav as every other page. */} -
- {/* Map fills everything */} -
+
+ {/* Map area — everything that floats is scoped to this box, so no overlay + can land on the timeline below it */} +
-
- {/* ═══ Search bar + filter pills (top center) ═══ */} - {/* The wider right inset on ≥sm clears the TopBar's bell + avatar. */} -
-
- - + {/* ═══ Search bar + filter pills (top center) ═══ */} + {/* + The rail floats over the map on this route, so the left inset clears + its *expanded* 212px width — the controls are never swallowed when it + opens. The wider right inset on ≥sm clears the TopBar's bell + avatar. + */} +
+
+ + +
-
- {/* ═══ Right-side event list panel (floating overlay) ═══ */} -
-
+ {/* ═══ Right-side floating event cards ═══ */} + {/* No panel chrome — the cards themselves are the surface, so the map + shows through the gaps between them. */} +
setPanelOpen(false)} + onClose={() => { + setPanelOpen(false); + setSelectedLocation(null); + }} />
+ + {/* ═══ Loading overlay ═══ */} + {isPending && ( + + + + + Loading events + + + + )}
- {/* ═══ Timeline scrubber (bottom, full width) ═══ */} -
+ {/* ═══ Timeline scrubber — a bar beneath the map, not an overlay on it ═══ */} +
- - {/* ═══ Loading overlay ═══ */} - {isPending && ( -
-
-
- Loading events -
-
- )}
{/* ═══ Event detail modal ═══ */} diff --git a/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx b/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx index cc8ccdc..efb66e6 100644 --- a/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx +++ b/apps/web/src/app/(app)/orgs/[id]/org-profile-client.tsx @@ -10,7 +10,7 @@ import { type OrgDetail, addOfficer, removeOfficer, toggleFollowOrg } from "~/ac import { Panel } from "~/components/common/panel"; import { SearchInput } from "~/components/common/search-input"; import { EmptyState } from "~/components/common/states"; -import { getCategoryColor } from "~/components/events/event-card"; +import { EventCard } from "~/components/events/event-card"; import { PageHeading, PageShell, SectionHeading } from "~/components/layout/page-shell"; import { Button } from "~/components/ui/button"; @@ -157,39 +157,22 @@ export function OrgProfileClient({ org }: OrgProfileClientProps) {
Upcoming Events {org.upcomingEvents.length > 0 ? ( -
- {org.upcomingEvents.map((event) => { - const color = getCategoryColor(event.tags); - return ( - - -
- -
-
-

- {event.title} -

-

- {event.datetime} - · - {" "} - {event.locationName} -

-
- -
- ); - })} +
+ {org.upcomingEvents.map((event, index) => ( + + ))}
) : ( diff --git a/apps/web/src/app/(app)/settings/settings-client.tsx b/apps/web/src/app/(app)/settings/settings-client.tsx index 2f02c1d..e3bbff2 100644 --- a/apps/web/src/app/(app)/settings/settings-client.tsx +++ b/apps/web/src/app/(app)/settings/settings-client.tsx @@ -11,8 +11,14 @@ import { Field } from "~/components/common/field"; import { FilterChip } from "~/components/common/filter-chip"; import { Panel } from "~/components/common/panel"; import { SearchInput } from "~/components/common/search-input"; -import { PageHeading, PageShell, SectionHeading } from "~/components/layout/page-shell"; +import { + PageHeading, + PageShell, + SectionHeading, + TOP_BAR_CLEARANCE, +} from "~/components/layout/page-shell"; import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; const INTEREST_TAGS = [ { id: "free food", label: "free food" }, @@ -150,8 +156,8 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) { return ( - {/* Top bar — pr reserves space so buttons don't overlap the TopBar notification/avatar */} -
+ {/* Reserves space so these buttons don't collide with the floating TopBar. */} +
+ + + + Who's attending + + {count} {count === 1 ? "person has" : "people have"} RSVP'd to this event. + + + + {attendees.length === 0 ? ( + + ) : ( +
    + {attendees.map((person) => ( +
  • + {person.avatarUrl ? ( + + ) : ( + + {person.displayName[0]?.toUpperCase()} + + )} + + {person.displayName} + + {friendIds?.has(person.id) && ( + + Friend + + )} +
  • + ))} +
+ )} + + {/* + The attendee list can be shorter than the RSVP count — the server + returns a capped sample — so say so instead of silently under-reporting. + */} + {count > attendees.length && ( +

+ and {count - attendees.length} more +

+ )} +
+ + ); +} diff --git a/apps/web/src/components/events/event-card.tsx b/apps/web/src/components/events/event-card.tsx index dde33eb..46de7cf 100644 --- a/apps/web/src/components/events/event-card.tsx +++ b/apps/web/src/components/events/event-card.tsx @@ -1,11 +1,27 @@ "use client"; -import { Bookmark, BookmarkCheck, Clock, MapPin, Maximize2, Share2 } from "lucide-react"; +import { + Bookmark, + BookmarkCheck, + Check, + Clock, + Edit3, + Eye, + EyeOff, + MapPin, + Maximize2, + Plus, + Share2, + Trash2, +} from "lucide-react"; import Link from "next/link"; import { useEffect, useRef } from "react"; +import { toast } from "sonner"; import { logInteraction } from "~/actions/interactions"; +import { AttendeesDialog } from "~/components/events/attendees-dialog"; import { AvatarStack } from "~/components/social/avatar-stack"; import { Button } from "~/components/ui/button"; +import { cn } from "~/lib/utils"; export const CATEGORY_COLORS: Record = { "visual arts": { bg: "rgba(255,156,133,0.1)", accent: "#fb923c", text: "#9a3412" }, @@ -22,6 +38,15 @@ export const CATEGORY_COLORS: Record void; - onRsvpToggle?: () => void; + /* + * Actions render only when a handler is supplied. + * + * These may be async. The card awaits the returned promise and only + * announces success once it resolves, so a rejected save/RSVP shows the + * owner's error toast alone instead of a success toast beside it. + */ + onSaveToggle?: () => void | Promise; + onRsvpToggle?: () => void | Promise; onShare?: () => void; onHide?: () => void; + /** When true the card collapses to a stub that can be restored. */ + isHidden?: boolean; + onUnhide?: () => void; + /** Extra action, e.g. the map's "Show on map". */ + onLocate?: () => void; + /** + * Open the event in place instead of navigating to its page. The map uses + * this so opening a card doesn't throw you off the map. + */ + onOpen?: () => void; + /** + * `default` is the full feed card. `compact` drops the description and the + * friends sentence for narrow columns — the map's 320px rail and an org + * profile's event list. `wide` is the full-width row used by My Events. + */ + density?: "default" | "compact" | "wide"; + /** Google Calendar link; renders the Calendar action when supplied. */ + calendarUrl?: string; + /** + * Owner controls. Supplied only for events the viewer created, so the + * card itself does no permission checking. + */ + editHref?: string; + onDelete?: () => void; /** Where this card is displayed — logged with interactions */ source?: "feed" | "search" | "map" | "similar" | "notification"; /** Position in the list — for position bias correction */ position?: number; + className?: string; } +/** + * The event card, used on Explore, My Events, the map rail and org profiles. + * + * Each of those surfaces previously had its own card component, so the same + * event rendered with a different title size, tag colour and metadata order + * depending on where you saw it. Density is the only thing that varies now. + */ export function EventCard({ id, title, @@ -65,30 +131,40 @@ export function EventCard({ location, description, tags, - flyerUrl, rsvpCount, - friendsAttending, + friendsAttending = [], + attendees = [], isSaved, isRsvped, onSaveToggle, onRsvpToggle, onShare, onHide, + isHidden = false, + onUnhide, + onLocate, + onOpen, + density = "default", + calendarUrl, + editHref, + onDelete, source = "feed", position, + className, }: EventCardProps) { const cardRef = useRef(null); + const compact = density === "compact"; + const wide = density === "wide"; + + /* + * Ingested events often have no location, and the server substitutes the + * string "TBD" for a missing one — rendering that verbatim next to a map pin + * reads as a bug. Treat it as absent and drop the row instead. + */ + const hasLocation = Boolean(location) && location !== "TBD"; const displayedFriendNames = friendsAttending.slice(0, 2).map((friend) => friend.displayName); const remainingFriends = friendsAttending.length - displayedFriendNames.length; - const friendsText = - displayedFriendNames.length === 0 - ? "" - : displayedFriendNames.length === 1 - ? `${displayedFriendNames[0]} is also going to this event.` - : displayedFriendNames.length === 2 && remainingFriends === 0 - ? `${displayedFriendNames[0]} and ${displayedFriendNames[1]} are also going to this event.` - : `${displayedFriendNames.join(", ")} + ${remainingFriends} more are also going to this event.`; // Track view — IntersectionObserver fires after 1s of visibility useEffect(() => { @@ -115,185 +191,584 @@ export function EventCard({ logInteraction({ itemId: id, interactionType: "click", metadata: { source, position } }); }; - return ( -
- {/* Expand, Save & Share */} -
-
- - -
- {/* Expand button */} - + )}
-
- {/* Content */} -
- {/* Org */} + ); + } + + /* + * Wide layout: a full-width row for My Events, where each list is a single + * column and there's horizontal room to put the details and the blurb side + * by side, with the actions gathered in the header. + */ + if (wide) { + return ( +
+ {/* Header: org · calendar/RSVP · utilities */} +
{orgName && ( -
-
- {orgLogoUrl ? ( - {orgName} - ) : ( -
- )} +
+
+ {orgLogoUrl && }
-

- from - {orgId ? ( - e.stopPropagation()} - className="font-bold hover:text-forum-cerulean transition-colors duration-300ms" - > - {orgName} - - ) : ( - {orgName} - )} -

+ + {orgName} +
)} - {/* Title */} - -

- {title} -

- + {/* Full-width action row on phones; pushed right once there's room */} +
+ {calendarUrl && ( + + )} + {onRsvpToggle && ( + + )} - {/* Location & Time */} -
-
- - {location} -
-
- - {datetime} +
+ {onSaveToggle && ( + + )} + {onShare && ( + + )} +
-
- {/* Bottom: Tags + RSVP */} -
- {/* Tags */} -
-
- {tags.slice(0, 3).map((tag) => ( - - {tag} + {/* Body: details left, social + blurb right */} +
+
+ +

+ {title} +

+ +
+ {hasLocation && ( + + + {location} + + )} + + + {datetime} - ))} +
+ {tags.length > 0 && ( +
+ {tags.slice(0, 3).map((tag, i) => ( + + {tag} + + ))} +
+ )}
-
- {/* Friends Attending */} -
- {friendsAttending.length > 0 && ( -
- -
- )} - {friendsText ? ( -

- {displayedFriendNames.length > 0 && ( - <> +

+ {friendsAttending.length > 0 && ( +
+ +

- {displayedFriendNames.join( - displayedFriendNames.length === 2 && remainingFriends === 0 ? " and " : ", ", - )} + {displayedFriendNames.join(", ")} {remainingFriends > 0 && ( - + {remainingFriends} more - )} - are also going to this event. - - )} -

- ) : null} -
- - {/* Description */} -
- {description && ( -
-

+ + {remainingFriends} other + )}{" "} + added this event to their calendar! +

+
+ )} + {description && ( +

{description}

-
- )} + )} + + See Details + +
- {/* RSVP */} -
+ {/* + Owner controls, pinned to the card's bottom-right. Only rendered for + events you created — the card does no permission checking of its own. + */} + {(editHref || onDelete) && ( +
+ {editHref && ( + + )} + {onDelete && ( + + )} +
+ )} +
+ ); + } + + return ( +
+ {/* + Save, Share, Hide & Expand. + + The icon buttons are 32px boxes around a 16px glyph, so they carry 8px + of internal padding. The negative margins cancel that, putting the + glyphs on the same left/right edges as the text below. + */} + {hasUtilityRow && ( +
+
+ {onSaveToggle && ( + + )} + {onShare && ( + + )} + {/* Hide was previously an unreachable prop — no control ever called it. */} + {onHide && ( + + )} +
+ )} + + {/* Org */} + {orgName && ( +
+
+ {orgLogoUrl && } +
+

+ {orgId ? ( + e.stopPropagation()} + className="font-bold transition-colors hover:text-forum-cerulean" + > + {orgName} + + ) : ( + {orgName} + )} +

+
+ )} + + {/* Title — opens in place when `onOpen` is given, otherwise navigates */} + {onOpen ? ( + + ) : ( + +

+ {title} +

+ + )} + + {/* Location & Time */} +
+ {hasLocation && ( +
+ + + {location} + +
+ )} +
+ + {datetime} +
+ + {/* + Tags. Compact cards stack them vertically and leave a right-hand gutter + so the corner avatars never sit on top of a label. + */} + {tags.length > 0 && ( +
0 && "pr-20") + : "flex-wrap", + )} + > + {tags.slice(0, compact ? 2 : 3).map((tag, i) => ( + 0) ? "bg-forum-turquoise-50" : "bg-forum-yellow-50", + )} + > + {tag} + + ))} +
+ )} + + {/* Friends attending — a corner cluster on compact cards, an inline row elsewhere */} + {friendsAttending.length > 0 && + (compact ? ( +
+ +
+ ) : ( +
+ +

+ + {displayedFriendNames.join(", ")} + {remainingFriends > 0 && ` + ${remainingFriends} other`} + {" "} + added this event to their calendar! +

+
+ ))} + + {/* Description — full card only. Clamped to the mock's three lines, with + "See Details" carrying the rest. */} + {!compact && description && ( + <> +

+ {description} +

+ + See Details + + + )} + + {/* Footer actions — right gutter keeps clear of the corner avatar cluster */} + {(onRsvpToggle || onLocate || calendarUrl) && ( +
0 && "pr-20", + )} + > + {onLocate ? ( + + ) : ( + /* Avatar stack + "N attending", clickable to see the full list. */ + (attendees.length > 0 || Boolean(rsvpCount)) && ( +
+ {attendees.length > 0 && } + {rsvpCount ? ( + f.id))} + /* Sized to the card's own metadata scale — 14px bold black + shouted over the title's own details — and kept on one + line, which is what wrapped to "4 / attending". */ + className="whitespace-nowrap text-[12px] font-medium text-forum-dark-gray" + /> + ) : null} +
+ ) + )} + + {/* Calendar + RSVP, gathered at the card's bottom-right as in the mock. */} +
+ {calendarUrl && ( + + )} + {onRsvpToggle && ( + + )} +
+
+ )}
); } diff --git a/apps/web/src/components/layout/app-chrome.tsx b/apps/web/src/components/layout/app-chrome.tsx index 11f77a3..ea98118 100644 --- a/apps/web/src/components/layout/app-chrome.tsx +++ b/apps/web/src/components/layout/app-chrome.tsx @@ -2,14 +2,18 @@ import { usePathname } from "next/navigation"; import { GeometricBackground } from "~/components/layout/geometric-background"; +import { MobileNav } from "~/components/layout/mobile-nav"; import { Sidebar } from "~/components/layout/sidebar"; import { TopBar } from "~/components/layout/top-bar"; +import { cn } from "~/lib/utils"; /** * Routes whose content fills the shell edge-to-edge and manages its own * scrolling — the map canvas, which must not sit in a scroll container. * - * These still get the Sidebar and TopBar; only the `
` box changes. + * These still get the same Sidebar and TopBar as every other route; the rail + * just floats over the content rather than reserving a column beside it, so + * full-width furniture like the map's timeline can span the whole screen. */ const EDGE_TO_EDGE_ROUTES = new Set(["/map"]); @@ -29,7 +33,7 @@ export function AppChrome({ children }: { children: React.ReactNode }) {
- +
@@ -38,15 +42,19 @@ export function AppChrome({ children }: { children: React.ReactNode }) { Edge-to-edge pages get a positioning context with no scroll of their own; ordinary pages scroll vertically inside the shell. */} + {/* `pb-16` on phones reserves room for the fixed bottom tab bar. */}
{children}
+ +
); } diff --git a/apps/web/src/components/layout/mobile-nav.tsx b/apps/web/src/components/layout/mobile-nav.tsx new file mode 100644 index 0000000..f39535a --- /dev/null +++ b/apps/web/src/components/layout/mobile-nav.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { LogOut } from "lucide-react"; +import { signOut } from "next-auth/react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { NAV_ITEMS, isNavItemActive } from "~/components/layout/nav-items"; +import { cn } from "~/lib/utils"; + +/** + * Bottom tab bar for phones. + * + * The docked rail expands on hover, which does not exist on touch — a + * touch-only user could never see the labels. Below `md` the rail is hidden + * entirely and this takes over: every destination visible at once, thumb-height, + * with labels always shown. + * + * `pb-[env(safe-area-inset-bottom)]` keeps the tabs clear of the iOS home + * indicator. + */ +export function MobileNav() { + const pathname = usePathname(); + + return ( + + ); +} diff --git a/apps/web/src/components/layout/notification-dropdown.tsx b/apps/web/src/components/layout/notification-dropdown.tsx index 9400962..83d860d 100644 --- a/apps/web/src/components/layout/notification-dropdown.tsx +++ b/apps/web/src/components/layout/notification-dropdown.tsx @@ -10,6 +10,7 @@ import { markAllNotificationsRead, markNotificationRead, } from "~/actions/notifications"; +import { ErrorState } from "~/components/common/states"; import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"; import { cn } from "~/lib/utils"; @@ -31,13 +32,21 @@ export function NotificationDropdown() { const [open, setOpen] = useState(false); const [limit, setLimit] = useState(20); + const [loadFailed, setLoadFailed] = useState(false); + const fetchNotifications = useCallback(async () => { try { const data = await getNotifications(); setItems(data.items); setUnreadCount(data.unreadCount); + setLoadFailed(false); } catch { - // silently fail + /* + * Recorded rather than swallowed. This polls on a 60s interval, so a + * toast per failure would be spam — the dropdown says so instead, and + * only when you open it. + */ + setLoadFailed(true); } }, []); @@ -90,7 +99,8 @@ export function NotificationDropdown() { {/* Header — italic serif title */}
@@ -113,6 +123,13 @@ export function NotificationDropdown() { }} /> )) + ) : loadFailed ? ( + /* An empty list and a failed fetch mean very different things. */ + ) : (
diff --git a/apps/web/src/components/layout/page-shell.tsx b/apps/web/src/components/layout/page-shell.tsx index 771f81b..a118013 100644 --- a/apps/web/src/components/layout/page-shell.tsx +++ b/apps/web/src/components/layout/page-shell.tsx @@ -9,22 +9,36 @@ import { cn } from "~/lib/utils"; * * Gutters step up with the viewport (20 → 32 → 40px) so the sidebar-adjacent * content never crowds the edge on tablet. + * + * Deliberately **left-aligned, not centred**. `max-w-*` differs per page, so + * centring with `mx-auto` produced a different left edge on every route: the + * wide Explore feed sat flush against the gutter while the narrower Events page + * was pushed inward by half the leftover width, and the two page titles did not + * line up. Left-aligning means the distance from the rail to the heading is the + * same on every page at every viewport, and `max-w-*` now only caps line length + * on the right. */ -const pageShellVariants = cva("mx-auto w-full px-5 py-6 sm:px-8 lg:px-10 lg:py-8", { +const pageShellVariants = cva("w-full px-5 py-6 sm:px-8 lg:px-10 lg:py-8", { variants: { width: { - /** Forms, settings, single-column reading. */ + /** Forms only — keeps inputs and prose at a readable measure. */ narrow: "max-w-3xl", - /** Default: list + detail pages. */ + /** Slightly tighter than the default; single-column reading. */ content: "max-w-5xl", - /** Multi-column dashboards (Explore). */ + /** Default: matches Explore, so list pages agree with the home screen. */ wide: "max-w-7xl", /** Opt out — the page manages its own width (e.g. full-bleed map). */ full: "max-w-none", }, }, + /* + * `wide` is the default so every list page spans the same width as the home + * screen. Previously the default was `content` (max-w-5xl), which left the + * Events and Friends tab bars 256px shorter than the Explore column and made + * them read as mis-centred against it. + */ defaultVariants: { - width: "content", + width: "wide", }, }); @@ -42,6 +56,20 @@ export function PageShell({ ); } +/** + * Keeps a page's top-right control clear of the floating TopBar. + * + * The TopBar (notification bell + avatar) is absolutely positioned over the + * content area on every route, so anything sharing that band — a page heading's + * trailing action — collides with it once the viewport is narrower than the + * shell's max width. + * + * From `sm` up it reserves ~140px on the right: 24px page padding + 36px bell + + * 12px gap + 40px avatar + 24px padding, rounded up. On phones that would eat + * over a third of the screen, so the content drops *below* the bar instead. + */ +export const TOP_BAR_CLEARANCE = "pt-14 sm:pt-0 sm:pr-[140px]"; + /** * Page-level `

`. One ramp for every page — previously these ranged from * 48px to 60px with no rhyme, and none of them scaled down on mobile. @@ -51,15 +79,28 @@ export function PageHeading({ children, description, action, + clearTopBar = false, ...props }: React.ComponentProps<"h1"> & { /** Optional supporting line rendered under the title. */ description?: React.ReactNode; /** Optional trailing control (button, link) aligned to the title baseline. */ action?: React.ReactNode; + /** + * Reserve room for the floating TopBar. Set this when the heading is the + * first thing on the page *and* carries an `action`, so the two don't + * overlap. Off by default — headings rendered further down the page (an org + * profile's title inside its panel) sit below the TopBar already. + */ + clearTopBar?: boolean; }) { return ( -
+

+