+
{event.tags.slice(0, 3).map((tag) => {
const tagColor = getEventColor([tag]);
return (
{tag.replace(/-/g, " ")}
@@ -140,7 +142,11 @@ function PanelEventCard({
)}
{/* Org */}
- {event.orgName && {event.orgName}
}
-
+ {event.orgName && (
+
+ {event.orgName}
+
+ )}
+
);
}
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 f247303..d052df1 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
@@ -1,7 +1,7 @@
"use client";
import { Heart, Users, Zap } from "lucide-react";
-import { cn } from "~/lib/utils";
+import { FilterChip } from "~/components/common/filter-chip";
import type { FilterKey } from "../map-client";
interface MapFilterPillsProps {
@@ -17,26 +17,20 @@ const PILLS: { key: FilterKey; label: string; icon: typeof Users }[] = [
export function MapFilterPills({ activeFilters, onToggle }: MapFilterPillsProps) {
return (
-
- {PILLS.map(({ key, label, icon: Icon }) => {
- const isActive = activeFilters.has(key);
- return (
- onToggle(key)}
- className={cn(
- "flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all border shadow-sm",
- isActive
- ? "bg-sky-50 border-sky-200 text-sky-700 shadow-sky-100"
- : "bg-white/95 backdrop-blur-xl border-gray-200/60 text-gray-600 hover:bg-gray-50 shadow-black/5",
- )}
- >
-
- {label}
-
- );
- })}
-
+
+ Filter map events
+ {PILLS.map(({ key, label, icon: Icon }) => (
+ onToggle(key)}
+ // Sits over the map, so the inactive state needs a backdrop of its own.
+ className="shadow-sm aria-[pressed=false]:bg-white/95 aria-[pressed=false]:backdrop-blur-sm"
+ >
+
+ {label}
+
+ ))}
+
);
}
diff --git a/apps/web/src/app/(app)/map/_components/map-legend.tsx b/apps/web/src/app/(app)/map/_components/map-legend.tsx
index 49fad34..0902829 100644
--- a/apps/web/src/app/(app)/map/_components/map-legend.tsx
+++ b/apps/web/src/app/(app)/map/_components/map-legend.tsx
@@ -1,22 +1,30 @@
+import { Panel } from "~/components/common/panel";
import { FUTURE_COLOR, NOW_COLOR } from "../_lib/map-constants";
+const ENTRIES = [
+ { label: "Now", color: NOW_COLOR },
+ { label: "Future", color: FUTURE_COLOR },
+];
+
export function MapLegend() {
return (
-
-
-
- NOW
-
-
-
- FUTURE
-
-
+
+ {ENTRIES.map(({ label, color }) => (
+
+
+
+ {label}
+
+
+ ))}
+
);
}
diff --git a/apps/web/src/app/(app)/map/_components/map-popup-carousel.tsx b/apps/web/src/app/(app)/map/_components/map-popup-carousel.tsx
index fb99970..8a9cea8 100644
--- a/apps/web/src/app/(app)/map/_components/map-popup-carousel.tsx
+++ b/apps/web/src/app/(app)/map/_components/map-popup-carousel.tsx
@@ -28,7 +28,7 @@ export function MapPopupCarousel({ events, onExpand }: MapPopupCarouselProps) {
e.stopPropagation();
setActiveIndex((i) => (i - 1 + events.length) % events.length);
}}
- className="absolute left-1 top-1/2 -translate-y-1/2 w-6 h-6 rounded-full bg-white/90 shadow-sm border border-gray-200 flex items-center justify-center text-gray-500 hover:text-gray-800 hover:bg-white transition-colors"
+ className="absolute left-1 top-1/2 -translate-y-1/2 w-6 h-6 rounded-full bg-white/90 shadow-sm border border-forum-border flex items-center justify-center text-forum-dark-gray hover:text-black hover:bg-white transition-colors"
>
@@ -40,7 +40,7 @@ export function MapPopupCarousel({ events, onExpand }: MapPopupCarouselProps) {
e.stopPropagation();
setActiveIndex((i) => (i + 1) % events.length);
}}
- className="absolute right-1 top-1/2 -translate-y-1/2 w-6 h-6 rounded-full bg-white/90 shadow-sm border border-gray-200 flex items-center justify-center text-gray-500 hover:text-gray-800 hover:bg-white transition-colors"
+ className="absolute right-1 top-1/2 -translate-y-1/2 w-6 h-6 rounded-full bg-white/90 shadow-sm border border-forum-border flex items-center justify-center text-forum-dark-gray hover:text-black hover:bg-white transition-colors"
>
@@ -56,7 +56,7 @@ export function MapPopupCarousel({ events, onExpand }: MapPopupCarouselProps) {
setActiveIndex(i);
}}
className={`w-1.5 h-1.5 rounded-full transition-colors ${
- i === activeIndex ? "bg-gray-800" : "bg-gray-300"
+ i === activeIndex ? "bg-gray-800" : "bg-forum-medium-gray"
}`}
/>
))}
diff --git a/apps/web/src/app/(app)/map/_components/map-popup.tsx b/apps/web/src/app/(app)/map/_components/map-popup.tsx
index 3f787f4..56e3a65 100644
--- a/apps/web/src/app/(app)/map/_components/map-popup.tsx
+++ b/apps/web/src/app/(app)/map/_components/map-popup.tsx
@@ -1,5 +1,6 @@
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";
@@ -18,15 +19,15 @@ export function MapPopup({ event, onExpand }: MapPopupProps) {
-
{event.title}
-
+
{event.title}
+
{eventDate.toLocaleDateString("en-US", {
weekday: "long",
month: "short",
day: "numeric",
})}
-
+
{eventDate.toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
@@ -38,13 +39,15 @@ export function MapPopup({ event, onExpand }: MapPopupProps) {
})}
-
-
-
+
+
{event.tags.length > 0 && (
@@ -65,9 +68,9 @@ export function MapPopup({ event, onExpand }: MapPopupProps) {
)}
{event.orgName && (
-
+
- {event.orgName}
+ {event.orgName}
)}
diff --git a/apps/web/src/app/(app)/map/_components/map-search-bar.tsx b/apps/web/src/app/(app)/map/_components/map-search-bar.tsx
index fd9e45e..2dc0a0f 100644
--- a/apps/web/src/app/(app)/map/_components/map-search-bar.tsx
+++ b/apps/web/src/app/(app)/map/_components/map-search-bar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Search } from "lucide-react";
+import { SearchInput } from "~/components/common/search-input";
interface MapSearchBarProps {
value: string;
@@ -9,15 +9,13 @@ interface MapSearchBarProps {
export function MapSearchBar({ value, onChange }: MapSearchBarProps) {
return (
-
-
- onChange(e.target.value)}
- className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-white/95 backdrop-blur-xl border border-gray-200/60 shadow-lg shadow-black/5 text-sm text-gray-800 placeholder:text-gray-400 placeholder:text-xs placeholder:tracking-wider placeholder:font-medium focus:outline-none focus:ring-2 focus:ring-sky-300/40"
- />
-
+
onChange(e.target.value)}
+ // Floats over the map — needs the translucent backdrop the docked field doesn't.
+ className="bg-white/95 shadow-lg shadow-black/5 backdrop-blur-sm"
+ />
);
}
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 83cc140..7146caa 100644
--- a/apps/web/src/app/(app)/map/_components/timeline-scrubber.tsx
+++ b/apps/web/src/app/(app)/map/_components/timeline-scrubber.tsx
@@ -21,16 +21,16 @@ export function TimelineScrubber({
const timelineDays = useMemo(() => getTimelineDays(days), [days]);
return (
-
+
{/* Legend */}
- NOW
+ NOW
- FUTURE
+ FUTURE
@@ -38,7 +38,7 @@ export function TimelineScrubber({
{/* Background track line */}
-
+
{/* Filled track segments between dates with events */}
{timelineDays.map((day, i) => {
@@ -54,7 +54,7 @@ export function TimelineScrubber({
return (
@@ -94,12 +94,12 @@ export function TimelineScrubber({
className={cn(
"text-[9px] font-bold mt-1.5 uppercase tracking-wider leading-none",
isSelected
- ? "text-sky-600"
+ ? "text-forum-cerulean"
: day.isToday
- ? "text-sky-500"
+ ? "text-forum-cerulean"
: hasEvents
- ? "text-gray-600"
- : "text-gray-400",
+ ? "text-forum-dark-gray"
+ : "text-forum-light-gray",
)}
>
{day.isToday ? "TODAY" : day.dayName}
@@ -108,10 +108,10 @@ export function TimelineScrubber({
className={cn(
"text-[9px] leading-none mt-px",
isSelected
- ? "text-sky-600 font-bold"
+ ? "text-forum-cerulean font-bold"
: hasEvents
- ? "text-gray-500"
- : "text-gray-400",
+ ? "text-forum-dark-gray"
+ : "text-forum-light-gray",
)}
>
{day.monthShort} {String(day.dayNum).padStart(2, "0")}
diff --git a/apps/web/src/app/(app)/map/_lib/map-helpers.ts b/apps/web/src/app/(app)/map/_lib/map-helpers.ts
index b287aaa..d3bdcff 100644
--- a/apps/web/src/app/(app)/map/_lib/map-helpers.ts
+++ b/apps/web/src/app/(app)/map/_lib/map-helpers.ts
@@ -111,8 +111,8 @@ export const URGENCY_STYLES: Record
now: { badge: "bg-[rgba(255,156,133,0.25)] text-[#FF7151]", text: "text-[#FF7151]" },
soon: { badge: "bg-[rgba(254,232,130,0.5)] text-[#854d0e]", text: "text-[#854d0e]" },
"later-today": { badge: "bg-[rgba(162,239,240,0.2)] text-[#0A9CD5]", text: "text-[#0A9CD5]" },
- tomorrow: { badge: "bg-gray-100 text-[#585858]", text: "text-[#585858]" },
- "this-week": { badge: "bg-gray-50 text-[#767676]", text: "text-[#767676]" },
+ tomorrow: { badge: "bg-forum-medium-gray text-forum-dark-gray", text: "text-[#585858]" },
+ "this-week": { badge: "bg-forum-medium-gray text-forum-light-gray", text: "text-[#767676]" },
};
/* ═══ Date helpers ═══ */
diff --git a/apps/web/src/app/(app)/map/map-client.tsx b/apps/web/src/app/(app)/map/map-client.tsx
index 4770c9b..10cbd4f 100644
--- a/apps/web/src/app/(app)/map/map-client.tsx
+++ b/apps/web/src/app/(app)/map/map-client.tsx
@@ -1,13 +1,9 @@
"use client";
-import { Building2, CalendarDays, Home, LogOut, Map as MapIcon, Users } from "lucide-react";
-import { signOut } from "next-auth/react";
import dynamic from "next/dynamic";
-import Link from "next/link";
-import { usePathname } from "next/navigation";
import { useCallback, useMemo, useRef, useState, useTransition } from "react";
import type { MapRef } from "react-map-gl/mapbox";
-import { type MapEvent, getMapEvents } from "~/actions/map";
+import type { MapEvent } from "~/actions/map";
import { cn } from "~/lib/utils";
import { getTimeGroup } from "./_lib/map-helpers";
@@ -17,10 +13,12 @@ const MapView = dynamic(
{
ssr: false,
loading: () => (
-
+
-
-
Loading map
+
+
+ Loading map
+
),
@@ -52,14 +50,6 @@ const EventDetailModal = dynamic(
/* ═══ Filter types ═══ */
export type FilterKey = "friends" | "now" | "attending";
-/* ═══ Floating mini-nav items ═══ */
-const NAV_ITEMS = [
- { href: "/explore", icon: Home },
- { href: "/events", icon: CalendarDays },
- { href: "/map", icon: MapIcon },
- { href: "/friends", icon: Users },
-];
-
/* ═══ Component ═══ */
interface MapClientProps {
initialEvents: MapEvent[];
@@ -67,7 +57,6 @@ interface MapClientProps {
export function MapClient({ initialEvents }: MapClientProps) {
const mapRef = useRef
(null);
- const pathname = usePathname();
const [isPending, startTransition] = useTransition();
/* Data */
@@ -157,8 +146,13 @@ export function MapClient({ initialEvents }: MapClientProps) {
return (
<>
- {/* ═══ FULL-BLEED MAP — covers entire viewport ═══ */}
-
+ {/*
+ Fills the app shell's content area rather than the viewport. It used to
+ be `fixed inset-0 z-40` with its own floating mini-nav, which meant the
+ 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 */}
- {/* ═══ Floating mini-nav (left side) ═══ */}
-
-
- {NAV_ITEMS.map(({ href, icon: Icon }) => {
- const isActive = pathname.startsWith(href);
- return (
-
-
-
- );
- })}
-
- {/* Log out button */}
-
signOut({ callbackUrl: "/" })}
- className="mt-2 flex items-center justify-center w-11 h-11 bg-white/95 backdrop-blur-xl rounded-xl shadow-lg shadow-black/10 border border-gray-200/60 text-gray-400 hover:text-gray-600 transition-colors"
- >
-
-
-
-
{/* ═══ Search bar + filter pills (top center) ═══ */}
-
-
+ {/* The wider right inset on ≥sm clears the TopBar's bell + avatar. */}
+
+
@@ -238,9 +204,9 @@ export function MapClient({ initialEvents }: MapClientProps) {
{/* ═══ Loading overlay ═══ */}
{isPending && (
-
)}
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 8b18f98..cc8ccdc 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
@@ -1,14 +1,18 @@
"use client";
-import { CalendarDays, ChevronLeft, Heart, MapPin, Search, Shield, Users, X } from "lucide-react";
+import { CalendarDays, ChevronLeft, Heart, MapPin, Shield, Users, X } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useState, useTransition } from "react";
import { toast } from "sonner";
import { type FriendProfile, searchUsers } from "~/actions/friends";
import { type OrgDetail, addOfficer, removeOfficer, toggleFollowOrg } from "~/actions/orgs";
+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 { cn } from "~/lib/utils";
+import { PageHeading, PageShell, SectionHeading } from "~/components/layout/page-shell";
+import { Button } from "~/components/ui/button";
function colorFromString(str: string) {
const colors = ["#6366f1", "#ec4899", "#14b8a6", "#f97316", "#8b5cf6", "#22c55e", "#3b82f6"];
@@ -85,31 +89,24 @@ export function OrgProfileClient({ org }: OrgProfileClientProps) {
};
return (
-
+
{/* Back */}
- router.back()}
- className="flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors mb-6 group"
- >
-
+ router.back()} className="mb-6">
+
Back
-
+
{/* Header */}
-
+
{org.logoUrl ? (
-
+
) : (
{org.name[0]?.toUpperCase()}
@@ -117,188 +114,192 @@ export function OrgProfileClient({ org }: OrgProfileClientProps) {
)}
-
-
-
{org.name}
-
{org.category}
-
-
-
- {isFollowing ? "Following" : "Follow"}
-
-
+
{org.category}}
+ action={
+
+
+ {isFollowing ? "Following" : "Follow"}
+
+ }
+ >
+ {org.name}
+
-
-
-
+
+
+
{followerCount} follower{followerCount !== 1 ? "s" : ""}
-
-
+
+
{org.upcomingEvents.length} upcoming event{org.upcomingEvents.length !== 1 ? "s" : ""}
{org.description && (
-
+
{org.description}
)}
-
+
-
+
{/* Left: Events */}
-
-
- Upcoming Events
-
+
+ Upcoming Events
{org.upcomingEvents.length > 0 ? (
-
+
{org.upcomingEvents.map((event) => {
const color = getCategoryColor(event.tags);
return (
-
-
+
+
-
-
+
+
{event.title}
-
+
{event.datetime}
·
- {event.locationName}
+ {" "}
+ {event.locationName}
-
-
+
+
);
})}
) : (
-
+
)}
-
+
{/* Right: Members */}
-
-
Team
-
+
+ Team
+
{[...owners, ...officers].map((member) => (
{member.avatarUrl ? (
) : (
member.displayName[0]?.toUpperCase()
)}
-
-
{member.displayName}
+
+
+ {member.displayName}
+
-
- {member.role === "owner" && }
+
+ {member.role === "owner" && (
+
+ )}
{member.role}
{org.isOwner && member.role === "officer" && (
- handleRemoveOfficer(member.id, member.displayName)}
+ handleRemoveOfficer(member.id, member.displayName)}
+ className="text-forum-light-gray hover:text-forum-coral"
>
-
-
+
+
)}
))}
{owners.length === 0 && officers.length === 0 && (
-
-
No team members listed
-
+
+ No team members listed
+
)}
-
+
{/* Add officer search — owner only */}
{org.isOwner && (
-
-
- handleSearch(e.target.value)}
- className="w-full pl-9 pr-3 py-2 text-sm rounded-lg border border-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-200 focus:border-indigo-300"
- />
-
+
handleSearch(e.target.value)}
+ />
{searchResults.length > 0 && (
-
+
{searchResults.map((user) => (
{user.avatarUrl ? (
) : (
user.displayName[0]?.toUpperCase()
)}
-
+
{user.displayName}
- handleAddOfficer(user)}
+ handleAddOfficer(user)}
+ className="text-forum-cerulean"
>
Add
-
+
))}
-
+
+ )}
+ {isSearching && (
+ Searching…
)}
- {isSearching && Searching...
}
)}
-
+
-
+
);
}
diff --git a/apps/web/src/app/(app)/orgs/create/page.tsx b/apps/web/src/app/(app)/orgs/create/page.tsx
index 22cccc5..b45f82c 100644
--- a/apps/web/src/app/(app)/orgs/create/page.tsx
+++ b/apps/web/src/app/(app)/orgs/create/page.tsx
@@ -5,6 +5,10 @@ import { useRouter } from "next/navigation";
import { useCallback, useRef, useState, useTransition } from "react";
import { createOrg } from "~/actions/orgs";
import { getPresignedUploadUrl } from "~/actions/upload";
+import { Field, fieldControlProps } from "~/components/common/field";
+import { FilterChip } from "~/components/common/filter-chip";
+import { Panel } from "~/components/common/panel";
+import { PageHeading, PageShell } from "~/components/layout/page-shell";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
@@ -101,49 +105,55 @@ export default function CreateOrgPage() {
};
return (
-
-
-
Create Organization
-
Set up your student group on The Forum.
-
+
+
+ Create Organization
+
-
+
{/* Logo */}
{logoPreview ? (
-
-
+
{
setLogoPreview(null);
setLogoUrl(null);
}}
- className="absolute -top-1.5 -right-1.5 p-1 rounded-full bg-black/60 text-white hover:bg-black/80 transition-colors"
+ className="absolute -right-1.5 -top-1.5 rounded-full"
>
-
-
+
+
{isUploading && (
-
-
-
+
+ Uploading logo…
+
+
)}
) : (
fileInputRef.current?.click()}
- className="w-20 h-20 rounded-2xl border-2 border-dashed border-gray-200 hover:border-indigo-300 bg-gray-50/50 flex items-center justify-center group cursor-pointer transition-colors"
+ className="group flex size-20 cursor-pointer items-center justify-center rounded-xl border-2 border-dashed border-forum-border transition-colors hover:border-forum-cerulean focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-forum-cerulean"
>
+ Upload organization logo
)}
-
Organization Logo
-
Optional. JPEG, PNG, or WebP.
+
Organization Logo
+
+ Optional. JPEG, PNG, or WebP.
+
- {/* Name */}
-
-
- Organization Name
-
+
{
setName(e.target.value);
if (errors.name) setErrors((prev) => ({ ...prev, name: "" }));
}}
placeholder="e.g. Princeton Entrepreneurship Club"
- className={cn(
- "h-12 text-base border-gray-200 bg-gray-50/50 placeholder:text-gray-300",
- errors.name && "border-red-300",
- )}
+ className={cn("h-12 text-base", errors.name && "border-forum-coral")}
/>
- {errors.name && {errors.name}
}
-
+
- {/* Description */}
-
+
{/* Category */}
-
-
Category
+
+
+
+ Category*
+
+
{ORG_CATEGORIES.map(({ id, label }) => (
- {
setCategory(id);
if (errors.category) setErrors((prev) => ({ ...prev, category: "" }));
}}
- className={cn(
- "px-4 py-2 rounded-lg text-sm font-medium border transition-colors",
- category === id
- ? "border-indigo-500 bg-indigo-50 text-indigo-700"
- : "border-gray-200 text-gray-600 hover:border-gray-300",
- )}
>
{label}
-
+
))}
- {errors.category && {errors.category}
}
-
-
+ {errors.category && (
+ {errors.category}
+ )}
+
+
-
-
router.back()} className="text-gray-500">
+
+ router.back()}>
Cancel
-
- {isPending ? "Creating..." : "Create Organization"}
+
+ {isPending ? "Creating…" : "Create Organization"}
-
+
);
}
diff --git a/apps/web/src/app/(app)/orgs/orgs-client.tsx b/apps/web/src/app/(app)/orgs/orgs-client.tsx
index a1fd50a..ac9a234 100644
--- a/apps/web/src/app/(app)/orgs/orgs-client.tsx
+++ b/apps/web/src/app/(app)/orgs/orgs-client.tsx
@@ -1,11 +1,15 @@
"use client";
-import { Heart, Plus, Search, Users } from "lucide-react";
+import { Heart, Plus, Users } from "lucide-react";
import Link from "next/link";
import { useCallback, useRef, useState, useTransition } from "react";
import { type OrgListItem, getOrgs, toggleFollowOrg } from "~/actions/orgs";
+import { FilterChip, FilterChipGroup } from "~/components/common/filter-chip";
+import { Panel } from "~/components/common/panel";
+import { SearchInput } from "~/components/common/search-input";
+import { EmptyState } from "~/components/common/states";
+import { SectionHeading } from "~/components/layout/page-shell";
import { Button } from "~/components/ui/button";
-import { Input } from "~/components/ui/input";
import { cn } from "~/lib/utils";
const ORG_CATEGORIES = [
@@ -97,105 +101,101 @@ export function OrgsClient({ initialOrgs, recommendedOrgs }: OrgsClientProps) {
{/* Search + Create */}
-
-
- handleSearch(e.target.value)}
- placeholder="Search organizations..."
- className="h-11 pl-11 border-gray-200 bg-white placeholder:text-gray-300 rounded-xl"
- />
-
-
-
-
+ handleSearch(e.target.value)}
+ className="flex-1"
+ />
+
+
+
Create
-
-
+
+
{/* Category filters */}
-
+
{ORG_CATEGORIES.map(({ id, label }) => (
- handleCategoryChange(id)}
- className={cn(
- "px-3.5 py-1.5 rounded-full text-xs font-medium whitespace-nowrap transition-colors",
- activeCategory === id
- ? "bg-indigo-500 text-white"
- : "bg-gray-100 text-gray-500 hover:bg-gray-200",
- )}
>
{label}
-
+
))}
-
+
{/* Recommended for You */}
{recommendedOrgs.length > 0 && (
-
-
- Recommended for You
-
-
+
+ Recommended for You
+
{recommendedOrgs.map((org) => {
const color = orgColor(org.name);
return (
-
-
- {org.logoUrl ? (
-
- ) : (
-
- {org.name[0]?.toUpperCase()}
-
- )}
-
-
-
{org.name}
-
{org.category}
-
-
+
+
+ {org.logoUrl ? (
+
+ ) : (
+
+ {org.name[0]?.toUpperCase()}
+
+ )}
+
+
+
+ {org.name}
+
+
+ {org.category}
+
+
+
+
);
})}
-
+
)}
{/* Orgs grid */}
{orgs.length > 0 ? (
-
+
{orgs.map((org) => {
const color = orgColor(org.name);
return (
-
-
+
{org.logoUrl ? (
-
+
) : (
{org.name[0]?.toUpperCase()}
@@ -205,49 +205,53 @@ export function OrgsClient({ initialOrgs, recommendedOrgs }: OrgsClientProps) {
-
+
{org.name}
- {org.category}
+
+ {org.category}
+
{org.description && (
-
{org.description}
+
+ {org.description}
+
)}
-
-
-
+
+
+
{org.followerCount} follower{org.followerCount !== 1 ? "s" : ""}
- handleToggleFollow(org.id)}
+ handleToggleFollow(org.id)}
className={cn(
- "text-xs font-medium px-3 py-1 rounded-full transition-colors flex items-center gap-1",
- org.isFollowing
- ? "bg-indigo-50 text-indigo-600"
- : "bg-gray-100 text-gray-500 hover:bg-indigo-50 hover:text-indigo-600",
+ "rounded-full",
+ org.isFollowing ? "text-forum-cerulean" : "text-forum-light-gray",
)}
>
-
+
{org.isFollowing ? "Following" : "Follow"}
-
+
-
+
);
})}
) : (
-
-
-
No organizations found
-
- {searchQuery || activeCategory !== "all"
+
-
+ : "Be the first to create one!"
+ }
+ />
)}
);
diff --git a/apps/web/src/app/(app)/orgs/page.tsx b/apps/web/src/app/(app)/orgs/page.tsx
index cf1eb8a..79848ce 100644
--- a/apps/web/src/app/(app)/orgs/page.tsx
+++ b/apps/web/src/app/(app)/orgs/page.tsx
@@ -1,13 +1,14 @@
import { getOrgs, getRecommendedOrgs } from "~/actions/orgs";
+import { PageHeading, PageShell } from "~/components/layout/page-shell";
import { OrgsClient } from "./orgs-client";
export default async function OrgsPage() {
const [orgs, recommended] = await Promise.all([getOrgs(), getRecommendedOrgs()]);
return (
-
-
Organizations
+
+ Organizations
-
+
);
}
diff --git a/apps/web/src/app/(app)/settings/settings-client.tsx b/apps/web/src/app/(app)/settings/settings-client.tsx
index e542e88..2f02c1d 100644
--- a/apps/web/src/app/(app)/settings/settings-client.tsx
+++ b/apps/web/src/app/(app)/settings/settings-client.tsx
@@ -7,7 +7,12 @@ import { useRef, useState, useTransition } from "react";
import type { FriendProfile } from "~/actions/friends";
import { getPresignedUploadUrl } from "~/actions/upload";
import { type UserProfile, updateAvatar, updateProfile } from "~/actions/users";
-import { cn } from "~/lib/utils";
+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 { Button } from "~/components/ui/button";
const INTEREST_TAGS = [
{ id: "free food", label: "free food" },
@@ -52,6 +57,37 @@ interface SettingsClientProps {
friends: FriendProfile[];
}
+/** Single friend row — was duplicated verbatim in both Friends sections. */
+function FriendRow({ friend }: { friend: FriendProfile }) {
+ return (
+
+
+ {friend.avatarUrl ? (
+
+ ) : (
+
+ {friend.displayName[0]?.toUpperCase()}
+
+ )}
+
+
+
+ {friend.displayName}
+
+ @{friend.netId}
+
+ {friend.classYear && (
+
+ '{friend.classYear.slice(-2)}
+
+ )}
+
+ );
+}
+
export function SettingsClient({ profile, friends }: SettingsClientProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
@@ -113,70 +149,52 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) {
);
return (
-
- {/* Top bar — pr-[100px] reserves space so buttons don't overlap the TopBar notification/avatar */}
-
-
router.back()}
- className="flex items-center gap-[6px] text-[13px] font-dm-sans text-forum-dark-gray hover:text-black transition-colors"
- >
-
- BACK
-
-
-
router.push("/explore")}
- className="text-[13px] font-dm-sans text-forum-light-gray hover:text-forum-dark-gray transition-colors"
- >
- EXIT
-
-
- {isPending ? "SAVING..." : "SAVE CHANGES"}
-
+
+ {/* Top bar — pr reserves space so buttons don't overlap the TopBar notification/avatar */}
+
+
router.back()}>
+
+ Back
+
+
+ router.push("/explore")}>
+ Exit
+
+
+ {isPending ? "Saving…" : "Save changes"}
+
- {/* Title */}
- My Account
+ My Account
{/* ═══ Personal Info ═══ */}
-
-
-
-
+
+ Personal Info
+
{/* Avatar */}
-
-
+
+
{avatarPreview ? (
-
+
) : (
-
+
{profile.displayName[0]?.toUpperCase()}
)}
-
avatarInputRef.current?.click()}
- className="flex items-center gap-[4px] px-[10px] py-[4px] rounded-[6px] border border-forum-cerulean text-forum-cerulean text-[11px] font-bold font-dm-sans hover:bg-forum-cerulean/5 transition-colors"
>
-
+
Edit Photo
-
+
{/* Name + Class Year inline */}
-
-
-
-
- Name *
+
+
+
+
+ {profile.displayName}
-
-
{profile.displayName}
-
-
+
-
-
- Class Year *
-
- setClassYear(e.target.value)}
- className="w-full border-b border-forum-medium-gray pb-[6px] text-[15px] font-dm-sans text-black bg-transparent outline-none appearance-none"
- >
- Select
- {CLASS_YEARS.map((y) => (
-
- {y}
-
- ))}
-
-
-
+
+
+ setClassYear(e.target.value)}
+ className="w-full appearance-none border-b border-forum-medium-gray bg-transparent pb-1.5 font-dm-sans text-[15px] text-black outline-none focus:border-forum-cerulean"
+ >
+ Select
+ {CLASS_YEARS.map((y) => (
+
+ {y}
+
+ ))}
+
+
-
-
+
+
{/* ═══ Friends + Organizations — two columns ═══ */}
-
+
{/* Friends column */}
-
-
-
- {/* Search */}
-
-
-
+
Friends
+
+ setFriendSearch(e.target.value)}
- placeholder="Search"
- className="w-full h-[32px] pl-[30px] pr-[10px] border border-forum-medium-gray rounded-[6px] text-[12px] font-dm-sans outline-none focus:border-forum-cerulean"
/>
-
- {/* Friend list */}
-
- {filteredFriends.map((friend) => (
-
-
- {friend.avatarUrl ? (
-
- ) : (
-
- {friend.displayName[0]?.toUpperCase()}
-
- )}
-
-
-
- {friend.displayName}
-
-
- @{friend.netId}
-
-
- {friend.classYear && (
-
- '{friend.classYear.slice(-2)}
-
- )}
-
- ))}
-
+
+ {filteredFriends.map((friend) => (
+
+
+
+ ))}
+
- {/* Edit friends link */}
-
- ADD / EDIT MY FRIENDS LIST
-
-
-
+
+
+ Add / edit my friends list
+
+
+
+
+
{/* Organizations column */}
-
-
-
- {/* Search */}
-
-
-
+
Organizations
+
+ setOrgSearch(e.target.value)}
- placeholder="Search"
- className="w-full h-[32px] pl-[30px] pr-[10px] border border-forum-medium-gray rounded-[6px] text-[12px] font-dm-sans outline-none focus:border-forum-cerulean"
/>
-
- {/* Org list placeholder */}
-
-
+
No organizations yet. Follow orgs from the Orgs page.
-
-
- ADD / EDIT MY ORGANIZATIONS
-
-
-
+
+
+ Add / edit my organizations
+
+
+
+
+
{/* ═══ Interest Tags ═══ */}
-
-
-
-
- {/* Selected topics */}
-
-
- Topics
-
-
- {interests.map((tagId) => {
- const tag = INTEREST_TAGS.find((t) => t.id === tagId);
- return (
- toggleInterest(tagId)}
- className="flex items-center gap-[4px] h-[28px] px-[12px] rounded-[14px] bg-forum-cerulean text-white text-[11px] font-bold font-dm-sans"
- >
- {tag?.label ?? tagId}
-
-
- );
- })}
-
+
+ Interest Tags
+
+
+ {/* Selected topics */}
+
+
+ Topics
+
+
+ {interests.map((tagId) => {
+ const tag = INTEREST_TAGS.find((t) => t.id === tagId);
+ const label = tag?.label ?? tagId;
+ return (
+
toggleInterest(tagId)}
+ >
+ {label}
+
+
+ );
+ })}
+ {interests.length === 0 && (
+
+ No topics selected yet.
+
+ )}
+
+
+
+ {/* Search for new tags */}
+
+
+
+ Suggested tags
+
+
+ {SUGGESTION_TAGS.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
- {/* Search for new tags */}
-
-
-
-
- Search for new tags
-
-
-
- {/* Active filter tags (coral) */}
-
- {interests.slice(0, 1).map((tagId) => {
- const tag = INTEREST_TAGS.find((t) => t.id === tagId);
- return (
-
- {tag?.label ?? tagId}
-
- );
- })}
-
-
- {/* Suggestion tags */}
-
- {SUGGESTION_TAGS.map((tag) => (
-
- {tag}
-
- ))}
-
+ {/* Organizations — link to orgs page */}
+
+
+ Organizations
+
+
+ Manage your organization memberships from the{" "}
+
+ Orgs page
+
+ .
+
-
-
- {/* Organizations — link to orgs page */}
-
-
- Organizations
-
-
- Manage your organization memberships from the{" "}
-
- Orgs page
-
- .
-
-
-
+
+
{/* ═══ Friends + Organizations (bottom expanded view) ═══ */}
@@ -544,6 +487,6 @@ export function SettingsClient({ profile, friends }: SettingsClientProps) {
-
+
);
}
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 3cd13e3..19cae1b 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -76,6 +76,7 @@
--color-forum-yellow-50: rgba(254, 232, 130, 0.5);
--color-forum-yellow-10: rgba(254, 232, 130, 0.1);
--color-forum-cerulean: #0a9cd5;
+ --color-forum-cerulean-light: rgba(10, 156, 213, 0.25);
--color-forum-black: #000000;
--color-forum-dark-gray: #585858;
--color-forum-medium-gray: #ececec;
@@ -172,6 +173,25 @@
background-color: var(--color-forum-coral);
}
+ /* cerulean-tinted thin scrollbar, kept clear of content above it */
+ .scrollbar-cerulean {
+ scrollbar-width: thin;
+ scrollbar-color: var(--color-forum-cerulean) transparent;
+ }
+
+ .scrollbar-cerulean::-webkit-scrollbar {
+ height: 6px;
+ }
+
+ .scrollbar-cerulean::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ .scrollbar-cerulean::-webkit-scrollbar-thumb {
+ background-color: var(--color-forum-cerulean);
+ border-radius: 9999px;
+ }
+
/* event cards */
.card {
border-radius: var(--radius);
diff --git a/apps/web/src/components/common/field.tsx b/apps/web/src/components/common/field.tsx
new file mode 100644
index 0000000..e0d4c9f
--- /dev/null
+++ b/apps/web/src/components/common/field.tsx
@@ -0,0 +1,62 @@
+import type * as React from "react";
+
+import { Label } from "~/components/ui/label";
+import { cn } from "~/lib/utils";
+
+/**
+ * Labelled form field with hint and error slots.
+ *
+ * The create-org, create-event and edit-event forms each repeated this
+ * label/control/error stack with their own type scale and error colour
+ * (`text-red-400` in one, `text-forum-coral` in another). One field now.
+ *
+ * The error is wired to the control with `aria-describedby`, so pass the same
+ * `id` you give the input — screen readers announce the message with the field
+ * rather than leaving it as loose text.
+ */
+export function Field({
+ id,
+ label,
+ hint,
+ error,
+ required,
+ className,
+ children,
+ ...props
+}: Omit
, "id"> & {
+ /** Must match the `id` of the control rendered as `children`. */
+ id: string;
+ label: React.ReactNode;
+ hint?: React.ReactNode;
+ error?: string;
+ required?: boolean;
+}) {
+ return (
+
+
+ {label}
+ {required ? (
+
+ *
+
+ ) : null}
+
+ {hint ?
{hint}
: null}
+ {children}
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ );
+}
+
+/** Props to spread onto the control inside a `Field` so errors are announced. */
+export function fieldControlProps(id: string, error?: string) {
+ return {
+ id,
+ "aria-invalid": error ? true : undefined,
+ "aria-describedby": error ? `${id}-error` : undefined,
+ } as const;
+}
diff --git a/apps/web/src/components/common/filter-chip.tsx b/apps/web/src/components/common/filter-chip.tsx
new file mode 100644
index 0000000..9ccf266
--- /dev/null
+++ b/apps/web/src/components/common/filter-chip.tsx
@@ -0,0 +1,83 @@
+"use client";
+
+import { type VariantProps, cva } from "class-variance-authority";
+import type * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+/**
+ * Toggleable filter chip.
+ *
+ * Every filtered surface previously rolled its own pill: Explore used
+ * `rounded-full px-[14px] h-[30px]` with a cerulean fill, the map's filter
+ * pills used `rounded-[20px] h-[32px]` with a turquoise fill, My Events used
+ * square-ish tabs, and Orgs used a bordered pill. They now share one shape,
+ * one type scale, and one selected treatment.
+ *
+ * Selection is communicated with `aria-pressed`, not colour alone.
+ */
+const filterChipVariants = cva(
+ cn(
+ "inline-flex h-[32px] shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-full",
+ "px-[14px] font-dm-sans text-[13px] font-medium transition-colors",
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-forum-cerulean focus-visible:ring-offset-1",
+ "disabled:pointer-events-none disabled:opacity-50",
+ "[&_svg]:pointer-events-none [&_svg]:size-3.5 [&_svg]:shrink-0",
+ ),
+ {
+ variants: {
+ active: {
+ true: "bg-forum-cerulean text-white",
+ false:
+ "border border-forum-border bg-white text-forum-dark-gray hover:border-forum-cerulean hover:text-black",
+ },
+ },
+ defaultVariants: {
+ active: false,
+ },
+ },
+);
+
+export function FilterChip({
+ className,
+ active = false,
+ type = "button",
+ ...props
+}: Omit, "aria-pressed"> & VariantProps) {
+ return (
+
+ );
+}
+
+/**
+ * Horizontal chip row. Scrolls rather than wrapping on narrow viewports.
+ *
+ * A `` + visually-hidden `` rather than `role="group"` +
+ * `aria-label` — same semantics, but it survives Biome's `useSemanticElements`
+ * rule and needs no ARIA to carry the group name.
+ */
+export function FilterChipGroup({
+ className,
+ label,
+ children,
+ ...props
+}: React.ComponentProps<"fieldset"> & { label: string }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+export { filterChipVariants };
diff --git a/apps/web/src/components/common/panel.tsx b/apps/web/src/components/common/panel.tsx
new file mode 100644
index 0000000..461f235
--- /dev/null
+++ b/apps/web/src/components/common/panel.tsx
@@ -0,0 +1,95 @@
+import { type VariantProps, cva } from "class-variance-authority";
+import { Slot } from "radix-ui";
+import type * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+/**
+ * Standard content surface.
+ *
+ * Distinct from `components/ui/card.tsx` (the shadcn card, which owns its own
+ * 24px header/content/footer grid) and from the `.card` utility in globals.css
+ * (reserved for the event card silhouette). `Panel` is the plain sectioned
+ * container the app pages actually reach for — settings blocks, the Explore
+ * right rail, the map's floating overlays.
+ *
+ * Padding steps with `size` rather than being respecified per page; the border
+ * and radius are fixed so panels never disagree corner-to-corner.
+ */
+const panelVariants = cva("rounded-xl border border-forum-border bg-white", {
+ variants: {
+ size: {
+ sm: "p-4",
+ md: "p-5",
+ lg: "p-6",
+ /** No padding — the panel's child manages it (e.g. a full-bleed map). */
+ none: "p-0",
+ },
+ elevation: {
+ flat: "",
+ raised: "shadow-sm",
+ /** Overlay sitting on top of the map. */
+ floating: "border-white/60 bg-white/95 shadow-lg backdrop-blur-sm",
+ },
+ },
+ defaultVariants: {
+ size: "md",
+ elevation: "raised",
+ },
+});
+
+export function Panel({
+ className,
+ size,
+ elevation,
+ asChild = false,
+ ...props
+}: React.ComponentProps<"div"> &
+ VariantProps & {
+ /** Render the panel styling onto the child (e.g. a whole-card ` `). */
+ asChild?: boolean;
+ }) {
+ const Comp = asChild ? Slot.Root : "div";
+
+ return (
+
+ );
+}
+
+/**
+ * Heading row inside a `Panel` — title on the left, optional control on the
+ * right, consistent 12px gap to the panel body.
+ */
+export function PanelHeader({
+ className,
+ title,
+ description,
+ action,
+ ...props
+}: Omit, "title"> & {
+ title: React.ReactNode;
+ description?: React.ReactNode;
+ action?: React.ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {description ? (
+
{description}
+ ) : null}
+
+ {action ?
{action}
: null}
+
+ );
+}
+
+export { panelVariants };
diff --git a/apps/web/src/components/common/search-input.tsx b/apps/web/src/components/common/search-input.tsx
new file mode 100644
index 0000000..0649daa
--- /dev/null
+++ b/apps/web/src/components/common/search-input.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { Search } from "lucide-react";
+import type * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+/**
+ * Standard search field.
+ *
+ * Explore used a 36px bordered box with a sentence-case placeholder; Friends
+ * used a 42px tinted box with a heavy drop shadow and an ALL-CAPS placeholder.
+ * One field now, with the icon inside the control and the border reacting to
+ * focus rather than a shadow.
+ *
+ * `label` is required and rendered visually hidden — a placeholder is not an
+ * accessible name.
+ */
+export function SearchInput({
+ className,
+ label,
+ ...props
+}: Omit, "type"> & { label: string }) {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/common/states.tsx b/apps/web/src/components/common/states.tsx
new file mode 100644
index 0000000..83179b4
--- /dev/null
+++ b/apps/web/src/components/common/states.tsx
@@ -0,0 +1,136 @@
+import type { LucideIcon } from "lucide-react";
+import { AlertTriangle, Inbox } from "lucide-react";
+import type * as React from "react";
+
+import { Button } from "~/components/ui/button";
+import { Skeleton } from "~/components/ui/skeleton";
+import { cn } from "~/lib/utils";
+
+/**
+ * Shared empty / loading / error states.
+ *
+ * Every page previously hand-rolled these with slightly different padding
+ * (`py-[60px]` vs `py-20`), type scale (22 / 24 / base) and colour. These three
+ * components are the only ones that should be used going forward.
+ */
+
+const SHELL = "flex flex-col items-center justify-center px-6 py-16 text-center";
+
+export function EmptyState({
+ icon: Icon = Inbox,
+ title,
+ description,
+ action,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ icon?: LucideIcon;
+ title: React.ReactNode;
+ description?: React.ReactNode;
+ action?: React.ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {description ? (
+
+ {description}
+
+ ) : null}
+ {action ?
{action}
: null}
+
+ );
+}
+
+export function ErrorState({
+ title = "Something went wrong",
+ description,
+ onRetry,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ title?: React.ReactNode;
+ description?: React.ReactNode;
+ /** When provided, renders a Try again button. */
+ onRetry?: () => void;
+}) {
+ return (
+
+
+
{title}
+ {description ? (
+
+ {description}
+
+ ) : null}
+ {onRetry ? (
+
+ Try again
+
+ ) : null}
+
+ );
+}
+
+/** Text-only loading state, for regions too small to justify a skeleton. */
+export function LoadingState({
+ label = "Loading…",
+ className,
+ ...props
+}: React.ComponentProps<"output"> & { label?: string }) {
+ // carries an implicit role="status" + polite live region.
+ return (
+
+ {label}
+
+ );
+}
+
+/** Matches the EventCard silhouette so the feed doesn't jump when data lands. */
+export function EventCardSkeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function EventCardSkeletonList({ count = 3 }: { count?: number }) {
+ return (
+
+ Loading events…
+ {Array.from({ length: count }, (_, i) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey: fixed-length placeholder list
+
+ ))}
+
+ );
+}
diff --git a/apps/web/src/components/events/event-card.tsx b/apps/web/src/components/events/event-card.tsx
index d2ee8dc..dde33eb 100644
--- a/apps/web/src/components/events/event-card.tsx
+++ b/apps/web/src/components/events/event-card.tsx
@@ -4,8 +4,8 @@ import { Bookmark, BookmarkCheck, Clock, MapPin, Maximize2, Share2 } from "lucid
import Link from "next/link";
import { useEffect, useRef } from "react";
import { logInteraction } from "~/actions/interactions";
-import { EventCoverArt } from "~/components/events/event-cover-art";
import { AvatarStack } from "~/components/social/avatar-stack";
+import { Button } from "~/components/ui/button";
export const CATEGORY_COLORS: Record = {
"visual arts": { bg: "rgba(255,156,133,0.1)", accent: "#fb923c", text: "#9a3412" },
@@ -77,7 +77,6 @@ export function EventCard({
source = "feed",
position,
}: EventCardProps) {
- const color = getCategoryColor(tags);
const cardRef = useRef(null);
const displayedFriendNames = friendsAttending.slice(0, 2).map((friend) => friend.displayName);
@@ -119,13 +118,18 @@ export function EventCard({
return (
{/* Expand, Save & Share */}
-
-
+ {
e.preventDefault();
logInteraction({
@@ -135,16 +139,17 @@ export function EventCard({
});
onSaveToggle?.();
}}
- className="icon p-0.5 hover:text-forum-dark-gray transition-color"
>
{isSaved ? (
-
+
) : (
-
+
)}
-
-
+ {
e.preventDefault();
logInteraction({
@@ -154,17 +159,16 @@ export function EventCard({
});
onShare?.();
}}
- className="icon"
>
-
-
+
+
{/* Expand button */}
-
-
-
+
+
+
-
+
{/* Content */}
@@ -225,8 +229,7 @@ export function EventCard({
{tags.slice(0, 3).map((tag) => (
{tag}
@@ -273,8 +276,10 @@ export function EventCard({
{/* RSVP */}
- {
e.preventDefault();
logInteraction({
@@ -284,14 +289,9 @@ export function EventCard({
});
onRsvpToggle?.();
}}
- className={`self-end w-fit button-coral ${
- isRsvped
- ? "bg-forum-dark-gray text-white"
- : "bg-forum-coral/90 text-white transition-colors duration-400 hover:bg-forum-coral"
- }`}
>
- {isRsvped ? "RSVP'D" : "RSVP"}
-
+ {isRsvped ? "RSVP'd" : "RSVP"}
+
diff --git a/apps/web/src/components/events/event-filters.tsx b/apps/web/src/components/events/event-filters.tsx
index 457c97a..a5a27b8 100644
--- a/apps/web/src/components/events/event-filters.tsx
+++ b/apps/web/src/components/events/event-filters.tsx
@@ -2,7 +2,8 @@
import { Pencil } from "lucide-react";
import { useState } from "react";
-import { cn } from "~/lib/utils";
+import { FilterChip } from "~/components/common/filter-chip";
+import { Button } from "~/components/ui/button";
const QUICK_FILTERS = [
{ id: "free food", label: "free food" },
@@ -43,33 +44,22 @@ export function EventFilters({ activeFilters, onFilterToggle }: EventFiltersProp
const filters = expanded ? ALL_FILTERS : QUICK_FILTERS;
return (
-
- {filters.map(({ id, label }) => {
- const isActive = activeFilters.includes(id);
- return (
-
onFilterToggle(id)}
- className={cn(
- "px-[10px] py-[3px] rounded-[12px] text-[13px] font-dm-sans transition-colors border",
- isActive
- ? "bg-forum-coral-light border-forum-coral text-forum-coral"
- : "border-forum-medium-gray text-forum-light-gray hover:border-forum-dark-gray hover:text-forum-dark-gray",
- )}
- >
- {label}
-
- );
- })}
-
+ Filter events by tag
+ {filters.map(({ id, label }) => (
+ onFilterToggle(id)}>
+ {label}
+
+ ))}
+ setExpanded(!expanded)}
- className="flex items-center gap-[4px] text-[11px] text-forum-light-gray hover:text-forum-dark-gray transition-colors ml-1"
>
-
- {expanded ? "Less" : "Edit Filters"}
-
-
+
+ {expanded ? "Less" : "Edit filters"}
+
+
);
}
diff --git a/apps/web/src/components/layout/app-chrome.tsx b/apps/web/src/components/layout/app-chrome.tsx
new file mode 100644
index 0000000..11f77a3
--- /dev/null
+++ b/apps/web/src/components/layout/app-chrome.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import { usePathname } from "next/navigation";
+import { GeometricBackground } from "~/components/layout/geometric-background";
+import { Sidebar } from "~/components/layout/sidebar";
+import { TopBar } from "~/components/layout/top-bar";
+
+/**
+ * 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.
+ */
+const EDGE_TO_EDGE_ROUTES = new Set(["/map"]);
+
+/**
+ * The app shell: docked nav rail, top bar, and page content.
+ *
+ * This must be a client component driven by `usePathname()`. An earlier version
+ * branched inside the server layout on an `x-pathname` request header, but a
+ * shared layout is not re-executed on client-side navigation — so soft
+ * navigating kept the stale branch and rendered two navs at once.
+ */
+export function AppChrome({ children }: { children: React.ReactNode }) {
+ const pathname = usePathname();
+ const isEdgeToEdge = EDGE_TO_EDGE_ROUTES.has(pathname);
+
+ return (
+
+
+
+
+
+
+
+
+ {/*
+ Edge-to-edge pages get a positioning context with no scroll of their
+ own; ordinary pages scroll vertically inside the shell.
+ */}
+
+ {children}
+
+
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/geometric-background.tsx b/apps/web/src/components/layout/geometric-background.tsx
index 218774b..2030c3d 100644
--- a/apps/web/src/components/layout/geometric-background.tsx
+++ b/apps/web/src/components/layout/geometric-background.tsx
@@ -6,7 +6,7 @@
export function GeometricBackground() {
return (
{/* Large turquoise polygon — bottom left */}
diff --git a/apps/web/src/components/layout/nav-items.ts b/apps/web/src/components/layout/nav-items.ts
new file mode 100644
index 0000000..c1e011b
--- /dev/null
+++ b/apps/web/src/components/layout/nav-items.ts
@@ -0,0 +1,39 @@
+import {
+ Building2,
+ CalendarDays,
+ Home,
+ type LucideIcon,
+ Map as MapIcon,
+ Users,
+} from "lucide-react";
+
+export interface NavItem {
+ href: string;
+ icon: LucideIcon;
+ label: string;
+}
+
+/**
+ * Single source of truth for primary navigation.
+ *
+ * Both the docked `Sidebar` and the map route's floating mini-nav read from
+ * this list — previously they were separate arrays and had already drifted
+ * (the map nav was missing Orgs entirely).
+ */
+export const NAV_ITEMS: NavItem[] = [
+ { href: "/explore", icon: Home, label: "Home" },
+ { href: "/events", icon: CalendarDays, label: "My Events" },
+ { href: "/map", icon: MapIcon, label: "Map" },
+ { href: "/friends", icon: Users, label: "My Friends" },
+ { href: "/orgs", icon: Building2, label: "Orgs" },
+];
+
+/**
+ * Whether `href` is the active nav entry for `pathname`.
+ *
+ * Exact match or a `/`-delimited descendant, so `/events` does not light up for
+ * a hypothetical `/events-archive` route the way a bare `startsWith` would.
+ */
+export function isNavItemActive(pathname: string, href: string): boolean {
+ return pathname === href || pathname.startsWith(`${href}/`);
+}
diff --git a/apps/web/src/components/layout/page-shell.tsx b/apps/web/src/components/layout/page-shell.tsx
new file mode 100644
index 0000000..771f81b
--- /dev/null
+++ b/apps/web/src/components/layout/page-shell.tsx
@@ -0,0 +1,99 @@
+import { type VariantProps, cva } from "class-variance-authority";
+import type * as React from "react";
+
+import { cn } from "~/lib/utils";
+
+/**
+ * Standard page container. Owns the horizontal gutter and max width for every
+ * routed page so individual pages stop hand-rolling `px-[40px] max-w-4xl`.
+ *
+ * Gutters step up with the viewport (20 → 32 → 40px) so the sidebar-adjacent
+ * content never crowds the edge on tablet.
+ */
+const pageShellVariants = cva("mx-auto w-full px-5 py-6 sm:px-8 lg:px-10 lg:py-8", {
+ variants: {
+ width: {
+ /** Forms, settings, single-column reading. */
+ narrow: "max-w-3xl",
+ /** Default: list + detail pages. */
+ content: "max-w-5xl",
+ /** Multi-column dashboards (Explore). */
+ wide: "max-w-7xl",
+ /** Opt out — the page manages its own width (e.g. full-bleed map). */
+ full: "max-w-none",
+ },
+ },
+ defaultVariants: {
+ width: "content",
+ },
+});
+
+export function PageShell({
+ className,
+ width,
+ ...props
+}: React.ComponentProps<"div"> & VariantProps
) {
+ return (
+
+ );
+}
+
+/**
+ * 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.
+ */
+export function PageHeading({
+ className,
+ children,
+ description,
+ action,
+ ...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;
+}) {
+ return (
+
+
+
+ {children}
+
+ {description ? (
+
{description}
+ ) : null}
+
+ {action ?
{action}
: null}
+
+ );
+}
+
+/** Section heading with the cerulean dot used across Explore and the org pages. */
+export function SectionHeading({ className, children, ...props }: React.ComponentProps<"h2">) {
+ return (
+
+
+
+ {children}
+
+
+ );
+}
+
+export { pageShellVariants };
diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx
index 4dee2ae..5009060 100644
--- a/apps/web/src/components/layout/sidebar.tsx
+++ b/apps/web/src/components/layout/sidebar.tsx
@@ -1,61 +1,150 @@
"use client";
-import { Building2, CalendarDays, Home, LogOut, Map as MapIcon, Plus, Users } from "lucide-react";
+import type { LucideIcon } from "lucide-react";
+import { LogOut, Plus } 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";
-const NAV_ITEMS = [
- { href: "/explore", icon: Home, label: "Home" },
- { href: "/events", icon: CalendarDays, label: "My Events" },
- { href: "/map", icon: MapIcon, label: "Map" },
- { href: "/friends", icon: Users, label: "My Friends" },
- { href: "/orgs", icon: Building2, label: "Orgs" },
-];
+/**
+ * Space reserved in the page flow: the 12px inset plus the *expanded* 200px
+ * width, not the collapsed 64px.
+ *
+ * The panel overlays rather than pushes, so if we only reserved the collapsed
+ * width the expanded panel would land on top of the page heading and the first
+ * card. Reserving the open width means content always begins to the right of
+ * where the panel can ever reach — nothing is covered, and nothing reflows on
+ * hover. The cost is a fixed gutter beside the collapsed rail.
+ *
+ * The widths themselves are literal Tailwind classes below, because the JIT
+ * scans source text and cannot read them out of a template string.
+ */
+const FOOTPRINT = 212;
+
+/**
+ * Row inside the nav.
+ *
+ * The icon lives in a fixed 20px slot and every row uses the same padding, so
+ * icons sit on one vertical line at exactly the same x whether the rail is
+ * collapsed or expanded — the labels grow to the right of a stationary icon
+ * rather than the whole row sliding.
+ */
+function NavRow({
+ icon: Icon,
+ label,
+ className,
+ iconSize = 20,
+}: {
+ icon: LucideIcon;
+ label: string;
+ className?: string;
+ iconSize?: number;
+}) {
+ return (
+ <>
+
+
+
+
+ {label}
+
+ >
+ );
+}
export function Sidebar() {
const pathname = usePathname();
return (
-
-
- {NAV_ITEMS.map(({ href, icon: Icon, label }) => {
- const isActive = pathname.startsWith(href);
- return (
-
-
- {label}
-
- );
- })}
-
-
-
- CREATE AN EVENT
-
-
-
- signOut({ callbackUrl: "/" })}
- className="flex items-center gap-[10px] px-[22px] py-[8px] text-[14px] font-semibold font-inter text-forum-light-gray hover:text-forum-dark-gray transition-colors w-full mb-3"
+ /*
+ * The outer element reserves a fixed slice of page flow and never resizes;
+ * the panel inside is absolutely positioned and grows on hover. Animating
+ * the flow width instead would re-wrap the feed on every pointer pass.
+ */
+
+
+ {/*
+ Two backdrop layers. Collapsed, the white one is transparent so the
+ geometric background still reads through the turquoise tint exactly as
+ before. Expanded, it turns opaque so the panel can sit over page
+ content without the feed showing through.
+ */}
+
+
+
+
+ {NAV_ITEMS.map(({ href, icon, label }) => {
+ const isActive = isNavItemActive(pathname, href);
+ return (
+
+
+
+ );
+ })}
+
+ {/*
+ Event creation is a secondary action, not the product's headline
+ flow — a quiet ghost item in the nav rather than a filled CTA.
+ */}
+
+
+
+
+
+
+ signOut({ callbackUrl: "/" })}
+ className={cn(
+ "flex w-full items-center gap-[10px] rounded-[8px] px-[10px] py-[8px] font-inter text-[14px] font-semibold transition-colors",
+ "text-forum-light-gray hover:bg-forum-turquoise/20 hover:text-forum-dark-gray",
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-forum-cerulean",
+ )}
+ >
+
+
+
+
+
);
}
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index 21a11fa..6b9db4a 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -17,9 +17,28 @@ const buttonVariants = cva(
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
+
+ /*
+ * Forum variants. The app's real button languages, which previously
+ * lived as ~90 hand-rolled ``
+ * elements across the (app) routes. Radius is `rounded-lg` (10px, the
+ * --radius token) so every button agrees with the card corner.
+ */
+ /** Primary action: RSVP, Save, Create. */
+ cerulean: "rounded-lg bg-forum-cerulean text-white shadow-sm hover:bg-forum-cerulean/90",
+ /** High-contrast action on tinted surfaces. */
+ solid: "rounded-lg bg-black text-white hover:bg-forum-dark-gray",
+ /** Destructive / leave / remove. */
+ coral: "rounded-lg bg-forum-coral text-white hover:bg-forum-coral/90",
+ /** Secondary action sitting on white. */
+ soft: "rounded-lg bg-forum-turquoise-20 text-black hover:bg-forum-turquoise/40",
+ /** Tertiary, de-emphasized action. */
+ quiet: "rounded-lg text-forum-light-gray hover:bg-forum-turquoise/20 hover:text-black",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ /** Forum call-to-action: uppercase, tracked, 42px tall. */
+ cta: "h-[42px] rounded-lg px-8 font-dm-sans text-[13px] font-bold tracking-[0.08em] uppercase has-[>svg]:px-6",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",