diff --git a/apps/web/index.html b/apps/web/index.html index 09e9354..c39a0b1 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -4,10 +4,15 @@ TigerMap — Princeton Campus Map + + + + +
diff --git a/apps/web/public/tiger.svg b/apps/web/public/tiger.svg new file mode 100644 index 0000000..c66c5e1 --- /dev/null +++ b/apps/web/public/tiger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 5fe8d45..82f9d63 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,3 +1,4 @@ +import { ArrowUpRight, ChevronDown, MapPin, SlidersHorizontal, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { CampusMap } from "./components/CampusMap"; import { CategorySidebar } from "./components/CategorySidebar"; @@ -6,18 +7,14 @@ import { DirectionsSheet, type DirectionsState } from "./components/DirectionsSh import { EatingClubDetail } from "./components/EatingClubDetail"; import { FreefoodDetail } from "./components/FreefoodDetail"; import { POIDetail } from "./components/POIDetail"; +import { PlaceCarousel } from "./components/PlaceCarousel"; import { SearchBar } from "./components/SearchBar"; -import type { DiningHallMenu, EatingClub, FreefoodPost, POI } from "./types"; +import type { DiningHallMenu, EatingClub, FreefoodPost, POI, Selection } from "./types"; +import { getJSON } from "./utils/api"; +import { QUICK_CATEGORIES, getCategoryIcon } from "./utils/categories"; import { type LatLng, type TravelProfile, fetchRoutes } from "./utils/directions"; import { distanceM, getRecentFix, locate } from "./utils/geolocation"; -type DetailPanel = - | { kind: "poi"; data: POI } - | { kind: "freefood"; data: FreefoodPost } - | { kind: "club"; data: EatingClub } - | { kind: "dining"; data: DiningHallMenu } - | null; - type Destination = DirectionsState["dest"]; // Map furniture that shouldn't appear as search destinations @@ -25,8 +22,13 @@ const EXCLUDED_SEARCH_CATS = new Set(["Steps", "steps", "Ramp", "Entrance"]); export function App() { const [pois, setPois] = useState([]); + const [placesError, setPlacesError] = useState(false); + const [feedError, setFeedError] = useState(false); + const [foodFeedError, setFoodFeedError] = useState(false); const [searchQuery, setSearchQuery] = useState(""); - const [activeCategories, setActiveCategories] = useState>(new Set(["Restaurant"])); + const [activeCategories, setActiveCategories] = useState>( + new Set(["@dining", "@freefood", "@clubs"]), + ); const [sidebarOpen, setSidebarOpen] = useState(false); const [freefoodPosts, setFreefoodPosts] = useState([]); const [eatingClubs, setEatingClubs] = useState([]); @@ -34,19 +36,14 @@ export function App() { const [diningCurrentMeal, setDiningCurrentMeal] = useState("Lunch"); // Single active detail panel — only one can be open at a time - const [detail, setDetail] = useState(null); + const [detail, setDetail] = useState(null); - const selectPOI = useCallback((poi: POI | null) => { - setDetail(poi ? { kind: "poi", data: poi } : null); - }, []); - const selectFreefood = useCallback((post: FreefoodPost | null) => { - setDetail(post ? { kind: "freefood", data: post } : null); - }, []); - const selectClub = useCallback((club: EatingClub | null) => { - setDetail(club ? { kind: "club", data: club } : null); - }, []); - const selectDining = useCallback((menu: DiningHallMenu | null) => { - setDetail(menu ? { kind: "dining", data: menu } : null); + const select = useCallback((next: Selection) => { + setDetail(next); + if (next) { + setSidebarOpen(false); + setDirections(null); + } }, []); const closeDetail = useCallback(() => setDetail(null), []); @@ -118,16 +115,20 @@ export function App() { const pickSearchResult = useCallback( (poi: POI) => { - setSearchQuery(poi.name); - startDirections({ name: poi.name, lat: poi.lat, lng: poi.lng, cat: poi.cat }); + setSearchQuery(""); + select({ kind: "poi", data: poi }); }, - [startDirections], + [select], ); - const directionsFromPOI = useCallback( - (poi: POI) => startDirections({ name: poi.name, lat: poi.lat, lng: poi.lng, cat: poi.cat }), - [startDirections], - ); + const directionsToDetail = () => { + if (detail?.kind === "freefood") { + const { location_name: name, lat, lng } = detail.data; + startDirections({ name, lat, lng }); + } else if (detail) { + startDirections(detail.kind === "dining" ? detail.data.hall : detail.data); + } + }; const searchResults = useMemo(() => { const q = searchQuery.trim().toLowerCase(); @@ -147,35 +148,50 @@ export function App() { return scored.slice(0, 7).map((s) => s.poi); }, [pois, searchQuery]); + const loadPlaces = useCallback(() => { + setPlacesError(false); + getJSON("/data/pois.json") + .then(setPois) + .catch(() => setPlacesError(true)); + }, []); + useEffect(() => { + loadPlaces(); + }, [loadPlaces]); useEffect(() => { - fetch("/data/pois.json") - .then((r) => r.json()) - .then((data: POI[]) => setPois(data)); + const dismiss = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setSidebarOpen(false); + setDetail(null); + setDirections(null); + } + }; + window.addEventListener("keydown", dismiss); + return () => window.removeEventListener("keydown", dismiss); }, []); useEffect(() => { - fetch("/api/eating-clubs") - .then((r) => r.json()) + getJSON<{ clubs?: EatingClub[] }>("/api/eating-clubs") .then((data) => setEatingClubs(data.clubs ?? [])) - .catch(() => {}); + .catch(() => setFeedError(true)); }, []); useEffect(() => { - fetch("/api/dining/today") - .then((r) => r.json()) + getJSON<{ menus?: DiningHallMenu[]; currentMeal?: string }>("/api/dining/today") .then((data) => { setDiningMenus(data.menus ?? []); setDiningCurrentMeal(data.currentMeal ?? "Lunch"); }) - .catch(() => {}); + .catch(() => setFeedError(true)); }, []); useEffect(() => { const load = () => - fetch("/api/freefood/feed?hours=9") - .then((r) => r.json()) - .then((data) => setFreefoodPosts(data.emails ?? [])) - .catch(() => {}); + getJSON<{ emails?: FreefoodPost[] }>("/api/freefood/feed?hours=9") + .then((data) => { + setFreefoodPosts(data.emails ?? []); + setFoodFeedError(false); + }) + .catch(() => setFoodFeedError(true)); load(); const id = setInterval(load, 2 * 60 * 1000); return () => clearInterval(id); @@ -184,34 +200,39 @@ export function App() { const categories = useMemo(() => { const cats = new Map(); for (const poi of pois) { + if (!poi.name || EXCLUDED_SEARCH_CATS.has(poi.cat || "")) continue; const cat = poi.cat || "Other"; cats.set(cat, (cats.get(cat) ?? 0) + 1); } - return Array.from(cats.entries()) + const placeCategories = Array.from(cats.entries()) .sort((a, b) => a[0].localeCompare(b[0])) .map(([name, count]) => ({ name, count })); - }, [pois]); + return [ + { name: "@dining", count: diningMenus.length }, + { name: "@freefood", count: freefoodPosts.length }, + { name: "@clubs", count: eatingClubs.length }, + ...placeCategories, + ]; + }, [pois, diningMenus, freefoodPosts, eatingClubs]); - const filteredPOIs = useMemo(() => { - if (activeCategories.size === 0 && !searchQuery.trim()) return []; - let result = pois; - if (activeCategories.size > 0) { - result = result.filter((p) => activeCategories.has(p.cat || "Other")); - } - if (searchQuery.trim()) { - const q = searchQuery.toLowerCase(); - result = result.filter( - (p) => - p.name?.toLowerCase().includes(q) || - p.alt?.toLowerCase().includes(q) || - p.desc?.toLowerCase().includes(q) || - p.cat?.toLowerCase().includes(q), - ); - } - return result; - }, [pois, activeCategories, searchQuery]); + const filteredPOIs = useMemo( + () => + pois.filter( + (poi) => + activeCategories.has(poi.cat || "Other") && + poi.name && + !EXCLUDED_SEARCH_CATS.has(poi.cat || ""), + ), + [pois, activeCategories], + ); + const popularPlaces = [193, 1126, 347, 359].flatMap((id) => { + const poi = pois.find((p) => p.id === id); + return poi ? [poi] : []; + }); const toggleCategory = useCallback((name: string) => { + setDetail(null); + setDirections(null); setActiveCategories((prev) => { const next = new Set(prev); if (next.has(name)) next.delete(name); @@ -221,80 +242,181 @@ export function App() { }, []); return ( -
+
- - {/* Header */} -
-
- ☤ PRINCETON UNIVERSITY -
-
- - {/* Search + categories */} -
- -
- + by TigerApps + + +
+ +
+
+ {QUICK_CATEGORIES.map(({ name, label }) => { + const Icon = getCategoryIcon(name); + return ( + + ); + })} +
+ +
+ {sidebarOpen ? ( + setActiveCategories(new Set())} + onSelectAll={() => setActiveCategories(new Set(categories.map(({ name }) => name)))} + onClose={() => setSidebarOpen(false)} + /> + ) : ( +
+ +

+ + Find your next stop. + + + +

+
+ + {!pois.length && !placesError && ( +
+ + + +
+ )} + {placesError && ( +
+ Campus places couldn’t load.{" "} + +
+ )} + {(feedError || foodFeedError) && ( +
Some live updates are unavailable
+ )} +
+ )}
- - {sidebarOpen && ( - setSidebarOpen(false)} + {detail?.kind === "poi" && ( + )} - - {/* Single detail panel — only one renders at a time */} - {detail?.kind === "poi" && ( - + {detail?.kind === "freefood" && ( + + )} + {detail?.kind === "club" && ( + )} - {detail?.kind === "freefood" && } - {detail?.kind === "club" && } {detail?.kind === "dining" && ( - + )} - {directions && ( )} -
+
); } diff --git a/apps/web/src/components/CampusMap.tsx b/apps/web/src/components/CampusMap.tsx index f452457..3d0d2f0 100644 --- a/apps/web/src/components/CampusMap.tsx +++ b/apps/web/src/components/CampusMap.tsx @@ -1,4 +1,13 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { Coffee, LocateFixed, MapPin, Pizza, RotateCcw, Users, Utensils } from "lucide-react"; +import type { Marker as MapboxMarker, StyleSpecification } from "mapbox-gl"; +import { + type CSSProperties, + type ReactNode, + useCallback, + useEffect, + useRef, + useState, +} from "react"; import { GeolocateControl, Layer, @@ -6,31 +15,89 @@ import { type MapRef, Marker, NavigationControl, - Popup, Source, } from "react-map-gl/mapbox"; -import type { DiningHallMenu, EatingClub, FreefoodPost, POI } from "../types"; +import type { DiningHallMenu, EatingClub, FreefoodPost, POI, Selection } from "../types"; import { getCategoryColor, getCategoryIcon } from "../utils/categories"; import type { LatLng, RouteInfo } from "../utils/directions"; import { rememberFix } from "../utils/geolocation"; +import { polishMapStyle } from "../utils/mapStyle"; const CAMPUS_MAP_TOKEN = import.meta.env.VITE_CAMPUS_MAP_TOKEN; const CAMPUS_MAP_STYLE = import.meta.env.VITE_CAMPUS_MAP_STYLE; const TIGERAPPS_TOKEN = import.meta.env.VITE_TIGERAPPS_MAPBOX_TOKEN; +const CAMPUS_CENTER: [number, number] = [-74.6554, 40.3473]; +const motionDuration = (duration: number) => + window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : duration; + +// keeps the camera target clear of the panels laid out in index.css +function cameraPadding(routing: boolean) { + const { innerWidth: width, innerHeight: height } = window; + if (width >= 768) return { top: routing ? 240 : 180, bottom: 100, left: 80, right: 460 }; + // short screens put the panel on the right and the search tools top left + if (height <= 550) return { top: 196, bottom: 64, left: 24, right: width * 0.46 + 24 }; + return routing + ? { top: 190, bottom: 334, left: 40, right: 70 } + : { top: 210, bottom: Math.min(height * 0.65, height - 220) + 16, left: 36, right: 36 }; +} + +function presentMarker(marker: MapboxMarker | null) { + const element = marker?.getElement(); + if (!element) return; + element.setAttribute("role", "presentation"); + element.removeAttribute("tabindex"); + element.removeAttribute("aria-label"); +} + +function PlaceMarker({ + at, + label, + tooltip, + anchor = "center", + className = "", + color, + selected, + onSelect, + children, +}: { + at: LatLng; + label: string; + tooltip: string; + anchor?: "center" | "bottom"; + className?: string; + color?: string; + selected: boolean; + onSelect: () => void; + children: ReactNode; +}) { + return ( + + + + ); +} interface CampusMapProps { pois: POI[]; - selectedPOI: POI | null; - onSelectPOI: (poi: POI | null) => void; + allPOIs: POI[]; freefoodPosts: FreefoodPost[]; - selectedFreefood: FreefoodPost | null; - onSelectFreefood: (post: FreefoodPost | null) => void; eatingClubs: EatingClub[]; - selectedClub: EatingClub | null; - onSelectClub: (club: EatingClub | null) => void; diningMenus: DiningHallMenu[]; - selectedDining: DiningHallMenu | null; - onSelectDining: (menu: DiningHallMenu | null) => void; + selected: Selection; + onSelect: (selection: Selection) => void; route: RouteInfo | null; routeOrigin: LatLng | null; routeDest: { name: string; lat: number; lng: number } | null; @@ -38,469 +105,329 @@ interface CampusMapProps { export function CampusMap({ pois, - selectedPOI, - onSelectPOI, + allPOIs, freefoodPosts, - selectedFreefood, - onSelectFreefood, eatingClubs, - selectedClub, - onSelectClub, diningMenus, - selectedDining, - onSelectDining, + selected, + onSelect, route, routeOrigin, routeDest, }: CampusMapProps) { const mapRef = useRef(null); + const [mapStyle, setMapStyle] = useState(); + const [mapFailed, setMapFailed] = useState(false); + const [loaded, setLoaded] = useState(false); - // Frame the route (or fly to the destination while routes are loading) - useEffect(() => { - const map = mapRef.current; - if (!map || !routeDest) return; - if (route) { - const coords: [number, number][] = [...route.geometry.coordinates]; - if (routeOrigin) coords.push([routeOrigin.lng, routeOrigin.lat]); - coords.push([routeDest.lng, routeDest.lat]); - let minLng = Number.POSITIVE_INFINITY; - let minLat = Number.POSITIVE_INFINITY; - let maxLng = Number.NEGATIVE_INFINITY; - let maxLat = Number.NEGATIVE_INFINITY; - for (const [lng, lat] of coords) { - if (lng < minLng) minLng = lng; - if (lat < minLat) minLat = lat; - if (lng > maxLng) maxLng = lng; - if (lat > maxLat) maxLat = lat; - } - const mobile = window.innerWidth < 640; - map.fitBounds( - [ - [minLng, minLat], - [maxLng, maxLat], - ], - { - padding: mobile - ? { top: 130, bottom: 250, left: 45, right: 70 } - : { top: 120, bottom: 240, left: 90, right: 100 }, - duration: 700, - maxZoom: 17.5, - }, + const loadStyle = useCallback(async (signal?: AbortSignal) => { + setMapFailed(false); + setMapStyle(undefined); + setLoaded(false); + try { + const styleId = ( + CAMPUS_MAP_STYLE || "mapbox://styles/applied-information-group/cld1kdenc001001m89xs12zvl" + ).replace("mapbox://styles/", ""); + const response = await fetch( + `https://api.mapbox.com/styles/v1/${styleId}?access_token=${CAMPUS_MAP_TOKEN}`, + { signal }, ); - } else { - map.flyTo({ center: [routeDest.lng, routeDest.lat], zoom: 16.5, duration: 500 }); - } - }, [route, routeOrigin, routeDest]); - - // Each handler only calls its own setter — App's single `detail` state - // handles mutual exclusion, so clearing others would overwrite with null. - const handleMarkerClick = useCallback( - (poi: POI) => { - onSelectPOI(poi); - mapRef.current?.flyTo({ center: [poi.lng, poi.lat], zoom: 17, duration: 500 }); - }, - [onSelectPOI], - ); - - const handleFoodClick = useCallback( - (post: FreefoodPost) => { - onSelectFreefood(post); - mapRef.current?.flyTo({ center: [post.lng, post.lat], zoom: 17, duration: 500 }); - }, - [onSelectFreefood], - ); - - const handleClubClick = useCallback( - (club: EatingClub) => { - onSelectClub(club); - mapRef.current?.flyTo({ center: [club.lng, club.lat], zoom: 17, duration: 500 }); - }, - [onSelectClub], - ); - - const handleDiningClick = useCallback( - (menu: DiningHallMenu) => { - onSelectDining(menu); - mapRef.current?.flyTo({ center: [menu.hall.lng, menu.hall.lat], zoom: 17, duration: 500 }); - }, - [onSelectDining], - ); - - // Fetch Princeton's style, inject TigerApps tileset sources + custom layers - const [mapStyle, setMapStyle] = useState(undefined); - - useEffect(() => { - const styleUrl = `https://api.mapbox.com/styles/v1/applied-information-group/cld1kdenc001001m89xs12zvl?access_token=${CAMPUS_MAP_TOKEN}`; - fetch(styleUrl) - .then((r) => r.json()) - .then((style) => { - // Add TigerApps tilesets as a separate source (different token) - style.sources["tigerapps"] = { + if (!response.ok) throw new Error("Map unavailable"); + const style: StyleSpecification = await response.json(); + if (TIGERAPPS_TOKEN) { + style.sources.tigerapps = { url: "mapbox://tigerapps.downtown-princeton-buildings,tigerapps.8mfuncwk", type: "vector", }; - - // Find insertion point: before first symbol layer - const firstSymIdx = style.layers.findIndex((l: { type: string }) => l.type === "symbol"); - const insertAt = firstSymIdx >= 0 ? firstSymIdx : style.layers.length; - - // Downtown buildings (campus-green style, before labels) - const overlayLayers = [ - { - id: "downtown-buildings-shadow", - type: "fill", - source: "tigerapps", - "source-layer": "downtown-princeton-buildings", - paint: { "fill-color": "rgba(0,0,0,0.08)", "fill-translate": [2, 2] }, - }, + const beforeLabels = style.layers.findIndex((layer) => layer.type === "symbol"); + style.layers.splice( + beforeLabels < 0 ? style.layers.length : beforeLabels, + 0, { id: "downtown-buildings", type: "fill", source: "tigerapps", - "source-layer": "downtown-princeton-buildings", - paint: { "fill-color": "#b8c8b0", "fill-opacity": 0.85 }, - }, - { - id: "downtown-buildings-outline", - type: "line", - source: "tigerapps", - "source-layer": "downtown-princeton-buildings", - paint: { "line-color": "#8fa087", "line-width": 0.5, "line-opacity": 0.6 }, + "source-layer": "Downtown Princeton Buildings", + paint: { "fill-color": "#ddd5c7", "fill-outline-color": "#b4aa99" }, }, - // Highlighted restaurants (light red) { - id: "highlighted-restaurants-fill", + id: "highlighted-restaurants", type: "fill", source: "tigerapps", "source-layer": "highlighted-restaurants-3jo06s", - paint: { "fill-color": "#e8b0b0", "fill-opacity": 0.85 }, + paint: { "fill-color": "#e5cbb2", "fill-outline-color": "#c7a98e" }, }, - { - id: "highlighted-restaurants-outline", - type: "line", - source: "tigerapps", - "source-layer": "highlighted-restaurants-3jo06s", - paint: { "line-color": "#c49090", "line-width": 1 }, - }, - ]; + ); + } + setMapStyle(polishMapStyle(style)); + } catch { + if (!signal?.aborted) setMapFailed(true); + } + }, []); - style.layers.splice(insertAt, 0, ...overlayLayers); + useEffect(() => { + const controller = new AbortController(); + loadStyle(controller.signal); + return () => controller.abort(); + }, [loadStyle]); - // Labels (at the top) - style.layers.push( - { - id: "downtown-buildings-labels", - type: "symbol", - source: "tigerapps", - "source-layer": "downtown-princeton-buildings", - filter: ["has", "name"], - layout: { - "text-field": ["get", "name"], - "text-size": 11, - "text-font": ["Open Sans SemiBold", "Arial Unicode MS Regular"], - "text-max-width": 8, - "text-anchor": "center", - }, - paint: { - "text-color": "#3d3d3d", - "text-halo-color": "rgba(255,255,255,0.8)", - "text-halo-width": 1, - }, - }, - { - id: "highlighted-restaurants-labels", - type: "symbol", - source: "tigerapps", - "source-layer": "highlighted-restaurants-3jo06s", - filter: ["has", "name"], - layout: { - "text-field": ["get", "name"], - "text-size": 12, - "text-font": ["Open Sans Bold", "Arial Unicode MS Regular"], - "text-max-width": 7, - "text-anchor": "center", - "text-allow-overlap": true, - }, - paint: { - "text-color": "#002e2c", - "text-halo-color": "#ffffff", - "text-halo-width": 2, - }, - }, - ); + const focus = selected?.kind === "dining" ? selected.data.hall : selected?.data; + const selectedPOI = selected?.kind === "poi" ? selected.data : null; + useEffect(() => { + if (!loaded || routeDest) return; + if (!focus) { + mapRef.current?.easeTo({ + padding: { top: 0, bottom: 0, left: 0, right: 0 }, + duration: motionDuration(300), + }); + return; + } + mapRef.current?.flyTo({ + center: [focus.lng, focus.lat], + zoom: 17, + padding: cameraPadding(false), + duration: motionDuration(650), + }); + }, [focus, loaded, routeDest]); - setMapStyle(style); - }) - .catch(() => { - // Fallback to the original Princeton style URL if fetch fails - setMapStyle(CAMPUS_MAP_STYLE); + useEffect(() => { + const map = mapRef.current; + if (!map || !routeDest || !loaded) return; + if (route) { + const coords = [...route.geometry.coordinates, [routeDest.lng, routeDest.lat]]; + if (routeOrigin) coords.push([routeOrigin.lng, routeOrigin.lat]); + const lngs = coords.map((point) => point[0]); + const lats = coords.map((point) => point[1]); + map.fitBounds( + [ + [Math.min(...lngs), Math.min(...lats)], + [Math.max(...lngs), Math.max(...lats)], + ], + { + padding: cameraPadding(true), + duration: motionDuration(700), + maxZoom: 17.5, + }, + ); + } else { + map.flyTo({ + center: [routeDest.lng, routeDest.lat], + zoom: 16.5, + duration: motionDuration(500), }); - }, []); + } + }, [route, routeOrigin, routeDest, loaded]); - if (!mapStyle) return
; + const visiblePOIs = + selectedPOI && !pois.some((poi) => poi.id === selectedPOI.id) ? [...pois, selectedPOI] : pois; return ( - { - // Route TigerApps tileset requests through the TigerApps token - if (url.includes("tigerapps.") && TIGERAPPS_TOKEN) { - return { - url: url.replace(/access_token=[^&]+/, `access_token=${TIGERAPPS_TOKEN}`), - } as any; - } - return { url } as any; - }} - initialViewState={{ longitude: -74.6554, latitude: 40.3473, zoom: 16, pitch: 0, bearing: 0 }} - minZoom={14} - maxZoom={19} - maxBounds={[ - [-74.675, 40.335], - [-74.645, 40.358], - ]} - mapStyle={mapStyle} - style={{ width: "100%", height: "100%" }} - reuseMaps - onClick={() => onSelectPOI(null)} - > - - - rememberFix({ - lat: e.coords.latitude, - lng: e.coords.longitude, - accuracy: e.coords.accuracy, - at: Date.now(), - }) - } - /> - - {/* Active route (orange over a white casing for contrast) */} - {route && ( - - - - - )} - {routeOrigin && ( - -
- +
+ {(!loaded || mapFailed) && ( +
+
+ +
+ {mapFailed ? ( + <> +

The campus map couldn’t load.

+ + + ) : ( + Loading campus map + )} +
)} - {routeDest && } - - {pois.map((poi) => ( - { - e.originalEvent.stopPropagation(); - handleMarkerClick(poi); + {mapStyle && ( + ({ + url: + url.includes("tigerapps.") && TIGERAPPS_TOKEN + ? url.replace(/access_token=[^&]+/, `access_token=${TIGERAPPS_TOKEN}`) + : url, + })} + initialViewState={{ + longitude: CAMPUS_CENTER[0], + latitude: CAMPUS_CENTER[1], + zoom: 15.9, + pitch: 0, + bearing: 0, }} - > -
- {getCategoryIcon(poi.cat)} -
-
- ))} - - {/* Free food markers */} - {freefoodPosts.map((post) => ( - { - e.originalEvent.stopPropagation(); - handleFoodClick(post); + minZoom={14} + maxZoom={19} + maxBounds={[ + [-74.675, 40.335], + [-74.645, 40.358], + ]} + mapStyle={mapStyle} + style={{ width: "100%", height: "100%" }} + interactiveLayerIds={["poi-label"]} + onLoad={() => { + setLoaded(true); + setMapFailed(false); }} - > -
-
- 🍕 -
-
-
- - ))} - - {/* Eating club markers */} - {eatingClubs.map((club) => ( - { - e.originalEvent.stopPropagation(); - handleClubClick(club); + onError={() => { + if (!loaded) setMapFailed(true); + }} + onClick={(event) => { + if ( + event.originalEvent.target instanceof Element && + event.originalEvent.target.closest(".map-marker") + ) + return; + const id = event.features?.[0]?.properties?.id; + const poi = allPOIs.find((point) => point.id === Number(id)); + onSelect(poi ? { kind: "poi", data: poi } : null); }} > -
- {club.name} - {club.eventCount > 0 && {club.eventCount}} + + + rememberFix({ + lat: event.coords.latitude, + lng: event.coords.longitude, + accuracy: event.coords.accuracy, + at: Date.now(), + }) + } + /> +
+
- - ))} - - {/* Eating club popup */} - {selectedClub && ( - onSelectClub(null)} - maxWidth="320px" - > -
-
- {selectedClub.name} + -
-

{selectedClub.name}

- Eating Club -
-
- {selectedClub.recentEvents.length > 0 ? ( -
-

- Recent Events -

-
- {selectedClub.recentEvents.map((event) => ( -
-

- {event.subject} -

-

- {new Date(event.date).toLocaleDateString()} · {event.type || "Event"} -

-
- ))} -
-
- ) : ( -

No recent events

- )} -
-
- )} - - {/* Dining hall markers — residential (large) and retail (small) */} - {diningMenus.map((menu) => { - const isResidential = menu.hall.category === "residential"; - const isSelected = selectedDining?.hall.id === menu.hall.id; - return ( - { - e.originalEvent.stopPropagation(); - handleDiningClick(menu); - }} - > -
+ + )} + {routeOrigin && ( + +
+ + )} + {routeDest && ( + - - {isResidential ? "🍽" : "☕"} + + -
-
- ); - })} - - {selectedPOI && ( - onSelectPOI(null)} - maxWidth="320px" - > -
- {selectedPOI.img && ( - {selectedPOI.name} - )} -

{selectedPOI.name}

- {selectedPOI.sub &&

{selectedPOI.sub}

} - {selectedPOI.cat && ( - + )} + {visiblePOIs.map((poi) => { + const Icon = getCategoryIcon(poi.cat); + return ( + onSelect({ kind: "poi", data: poi })} > - {selectedPOI.cat} - - )} - {selectedPOI.hours && ( -

- Hours: {selectedPOI.hours} -

- )} - {selectedPOI.desc && ( -

{selectedPOI.desc}

- )} -
-
+ + + ); + })} + {freefoodPosts.map((post) => ( + onSelect({ kind: "freefood", data: post })} + > + + + + ))} + {eatingClubs.map((club) => ( + onSelect({ kind: "club", data: club })} + > + + {club.eventCount > 0 && } + + ))} + {diningMenus.map((menu) => { + const Icon = menu.hall.category === "residential" ? Utensils : Coffee; + return ( + onSelect({ kind: "dining", data: menu })} + > + + + ); + })} + )} - + + Princeton, New Jersey + +
); } diff --git a/apps/web/src/components/CategorySidebar.tsx b/apps/web/src/components/CategorySidebar.tsx index b2a25eb..51d6f7b 100644 --- a/apps/web/src/components/CategorySidebar.tsx +++ b/apps/web/src/components/CategorySidebar.tsx @@ -1,69 +1,73 @@ -import { X } from "lucide-react"; -import { getCategoryColor } from "../utils/categories"; +import { Check, X } from "lucide-react"; +import { getCategoryIcon, getCategoryLabel } from "../utils/categories"; interface CategorySidebarProps { categories: { name: string; count: number }[]; activeCategories: Set; onToggle: (name: string) => void; + onClear: () => void; + onSelectAll: () => void; onClose: () => void; } -const HIDDEN = new Set(["Steps", "steps", "Entrance", "Ramp"]); - export function CategorySidebar({ categories, activeCategories, onToggle, + onClear, + onSelectAll, onClose, }: CategorySidebarProps) { - const display = categories.filter((c) => !HIDDEN.has(c.name)); - + const allSelected = categories.every((category) => activeCategories.has(category.name)); return ( -
-
-

Categories

-
-
- {display.map(({ name, count }) => { - const isActive = activeCategories.has(name); - const color = getCategoryColor(name); +
+ {categories.map(({ name, count }) => { + const active = activeCategories.has(name); + const Icon = getCategoryIcon(name); return ( ); })}
- {activeCategories.size > 0 && ( -
+
+ {activeCategories.size} selected +
+
- )} -
+
+ ); } diff --git a/apps/web/src/components/DetailPanel.tsx b/apps/web/src/components/DetailPanel.tsx new file mode 100644 index 0000000..06a2d1a --- /dev/null +++ b/apps/web/src/components/DetailPanel.tsx @@ -0,0 +1,204 @@ +import { ArrowLeft, type LucideIcon, Navigation, X } from "lucide-react"; +import { + type ReactNode, + type PointerEvent as ReactPointerEvent, + useEffect, + useRef, + useState, +} from "react"; + +const isMobile = () => window.matchMedia("(width < 768px)").matches; +const reducedMotion = () => window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +export function DetailPanel({ + title, + category, + icon: Icon, + onClose, + onBack, + onDirections, + children, + className = "", + expanded: controlledExpanded, + onExpandedChange, +}: { + title: string; + category: string; + icon: LucideIcon; + onClose: () => void; + onBack?: () => void; + onDirections?: () => void; + children: ReactNode; + className?: string; + expanded?: boolean; + onExpandedChange?: (expanded: boolean) => void; +}) { + const panelRef = useRef(null); + const [localExpanded, setLocalExpanded] = useState(false); + const [offset, setOffset] = useState(0); + const [entering, setEntering] = useState(true); + const expanded = controlledExpanded ?? localExpanded; + const drag = useRef<{ + y: number; + delta: number; + origin: number; + target: Element; + id: number; + } | null>(null); + const skipHandleClick = useRef(false); + const closing = useRef(null); + const setExpanded = (value: boolean) => { + setLocalExpanded(value); + onExpandedChange?.(value); + }; + const close = () => { + const panel = panelRef.current; + if (!panel || closing.current) return; + if (reducedMotion()) { + onClose(); + return; + } + closing.current = panel.animate( + [ + { transform: getComputedStyle(panel).transform, opacity: 1 }, + isMobile() + ? { transform: "translateY(100%)", opacity: 1 } + : { transform: "translateY(16px)", opacity: 0 }, + ], + { duration: 220, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" }, + ); + closing.current.finished.then(onClose, () => {}); + }; + const startDrag = (event: ReactPointerEvent) => { + if (!isMobile() || !event.isPrimary || event.button !== 0 || drag.current || closing.current) + return; + const target = event.target as Element; + const button = target.closest("button, a"); + if (button && !button.matches(".sheet-handle-button")) return; + const capture = button ?? event.currentTarget; + capture.setPointerCapture(event.pointerId); + skipHandleClick.current = false; + const transform = getComputedStyle(panelRef.current as HTMLElement).transform; + const origin = transform === "none" ? 0 : new DOMMatrixReadOnly(transform).m42; + setEntering(false); + setOffset(origin); + drag.current = { y: event.clientY, delta: 0, origin, target: capture, id: event.pointerId }; + }; + const moveDrag = (event: ReactPointerEvent) => { + if (!drag.current || drag.current.id !== event.pointerId) return; + drag.current.delta = event.clientY - drag.current.y; + setOffset( + drag.current.origin + + (drag.current.delta > 0 ? drag.current.delta : drag.current.delta * 0.2), + ); + }; + const endDrag = (event: ReactPointerEvent, cancelled = false) => { + const gesture = drag.current; + if (!gesture || gesture.id !== event.pointerId) return; + drag.current = null; + if (gesture.target.hasPointerCapture(gesture.id)) + gesture.target.releasePointerCapture(gesture.id); + skipHandleClick.current = Math.abs(gesture.delta) > 4; + if (!cancelled && gesture.delta > 56) { + if (expanded && gesture.delta < (panelRef.current?.offsetHeight ?? 0) * 0.35) + setExpanded(false); + else close(); + } else if (!cancelled && gesture.delta < -40) setExpanded(true); + setOffset(0); + }; + useEffect(() => { + const panel = panelRef.current; + if (!panel) return; + const previous = document.activeElement as HTMLElement | null; + panel.focus({ preventScroll: true }); + let height = panel.getBoundingClientRect().height; + let resize: Animation | undefined; + const observer = new ResizeObserver(() => { + if (resize?.playState === "running") return; + const nextHeight = panel.getBoundingClientRect().height; + if (Math.abs(nextHeight - height) < 1) return; + if (!reducedMotion()) { + resize = panel.animate( + { + height: [`${height}px`, `${nextHeight}px`], + maxHeight: [`${height}px`, `${nextHeight}px`], + }, + { duration: 280, easing: "cubic-bezier(0.22, 1, 0.36, 1)" }, + ); + } + height = nextHeight; + }); + observer.observe(panel); + return () => { + observer.disconnect(); + closing.current?.cancel(); + resize?.cancel(); + if (previous?.isConnected) previous.focus({ preventScroll: true }); + }; + }, []); + return ( +
{ + if (event.target === event.currentTarget) setEntering(false); + }} + aria-label={title} + style={offset ? { transform: `translateY(${offset}px)` } : undefined} + > +
endDrag(event, true)} + onLostPointerCapture={(event) => endDrag(event, true)} + > + +
+ {onBack ? ( + + ) : ( + + + + )} +
+ {category} +

{title}

+
+ +
+
+ {children} + {onDirections && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/components/DiningDetail.tsx b/apps/web/src/components/DiningDetail.tsx index 86cfaeb..5f7709c 100644 --- a/apps/web/src/components/DiningDetail.tsx +++ b/apps/web/src/components/DiningDetail.tsx @@ -1,79 +1,70 @@ -import { X } from "lucide-react"; +import { Coffee, Utensils } from "lucide-react"; +import { useState } from "react"; import type { DiningHallMenu } from "../types"; +import { DetailPanel } from "./DetailPanel"; interface DiningDetailProps { menu: DiningHallMenu; currentMeal: string; onClose: () => void; + onDirections: () => void; } -export function DiningDetail({ menu, currentMeal, onClose }: DiningDetailProps) { - const sortedMeals = [...menu.meals].sort((a, b) => { - if (a.meal === currentMeal) return -1; - if (b.meal === currentMeal) return 1; - return 0; - }); - +export function DiningDetail({ menu, currentMeal, onClose, onDirections }: DiningDetailProps) { + const [selectedMeal, setSelectedMeal] = useState( + menu.meals.find((meal) => meal.meal === currentMeal)?.meal ?? menu.meals[0]?.meal, + ); + const active = menu.meals.find((meal) => meal.meal === selectedMeal); const isRetail = menu.hall.category === "retail"; - return ( -
- {/* Header */} -
- 🍽 -
- {menu.hall.name} - {isRetail ? "Retail" : currentMeal} + + {menu.meals.length > 1 && ( +
+ {menu.meals.map((meal) => ( + + ))} +
+ )} + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard access to scrollable content */} +
+
+ Today’s menu +
- -
- - {/* Content */} -
- {sortedMeals.length === 0 && ( -

No menu available for today.

+ {active ? ( + Object.entries(active.stations).map(([station, items]) => ( +
+

{station}

+
    + {items.map((item, i) => ( +
  • {item}
  • + ))} +
+
+ )) + ) : ( +

No menu is available today.

)} - - {sortedMeals.map((meal) => ( -
- {!(isRetail && sortedMeals.length === 1) && ( -
- {meal.meal} - {meal.meal === currentMeal && !isRetail && ( - — now serving - )} -
- )} - -
- {Object.entries(meal.stations).map(([station, items]) => ( -
-

{station}

-
    - {items.map((item, i) => ( -
  • - {item} -
  • - ))} -
-
- ))} -
-
- ))}
-
+ ); } diff --git a/apps/web/src/components/DirectionsSheet.tsx b/apps/web/src/components/DirectionsSheet.tsx index 499c15b..64360d5 100644 --- a/apps/web/src/components/DirectionsSheet.tsx +++ b/apps/web/src/components/DirectionsSheet.tsx @@ -1,14 +1,9 @@ -/** - * Directions bottom sheet — slides up from the bottom when a destination is - * chosen. Dark panel, walking/cycling toggle with live ETAs, orange accent - * matching the route line. - */ - -import { Bike, Footprints, LocateFixed, MapPin, RefreshCw, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { Bike, ChevronDown, Footprints, LoaderCircle, MapPin, RefreshCw } from "lucide-react"; +import { useState } from "react"; import type { LatLng, RouteInfo, TravelProfile } from "../utils/directions"; import { formatArrival, formatDistance, formatMinutes } from "../utils/directions"; import type { LocateError } from "../utils/geolocation"; +import { DetailPanel } from "./DetailPanel"; export interface DirectionsState { dest: { name: string; lat: number; lng: number; cat?: string }; @@ -22,7 +17,7 @@ export interface DirectionsState { const LOCATE_MESSAGES: Record = { denied: "Location is blocked for this site", - unavailable: "Couldn't get a fix on your location", + unavailable: "Your location couldn’t be found", unsupported: "This browser can't share your location", }; @@ -33,8 +28,6 @@ interface DirectionsSheetProps { onClose: () => void; } -const ORANGE = "#e77500"; - function ModeButton({ profile, route, @@ -55,22 +48,21 @@ function ModeButton({ type="button" onClick={onClick} disabled={loading || !route} - aria-label={route ? `${label}, ${formatMinutes(route.durationS)}` : label} - className={`flex items-center justify-center gap-2.5 rounded-xl py-3 transition-colors ${ - active && route - ? "bg-[#e77500] text-white" - : "bg-[#2c2c2e] text-gray-200 hover:bg-[#3a3a3c] disabled:hover:bg-[#2c2c2e]" - }`} + aria-pressed={active} + className={`travel-mode ${active && route ? "is-active" : ""}`} > - - - {loading ? ( - … - ) : route ? ( - formatMinutes(route.durationS) - ) : ( - — - )} + + + {label} + + {loading ? ( + + ) : route ? ( + formatMinutes(route.durationS) + ) : ( + "Unavailable" + )} + ); @@ -85,176 +77,89 @@ export function DirectionsSheet({ const { dest, routes, profile, status } = state; const active = routes?.[profile]; const loading = status === "locating" || status === "routing"; - - // Expanded state reveals turn-by-turn steps (mobile only; desktop always - // shows them). Swipe up on the sheet header expands, swipe down collapses - // or dismisses; tapping the handle toggles. const [expanded, setExpanded] = useState(false); - const [dragY, setDragY] = useState(0); - const touchStartY = useRef(null); - // Ref mirror of dragY: touchend must read the final value even if React - // hasn't committed the last touchmove's state update yet. - const lastDy = useRef(0); - - // New destination — start collapsed again - // biome-ignore lint/correctness/useExhaustiveDependencies: reset is keyed on dest - useEffect(() => setExpanded(false), [dest]); - - const onTouchStart = (e: React.TouchEvent) => { - touchStartY.current = e.touches[0].clientY; - }; - const onTouchMove = (e: React.TouchEvent) => { - if (touchStartY.current === null) return; - const dy = e.touches[0].clientY - touchStartY.current; - // Elastic feedback while dragging, capped so the sheet never flies away - const capped = Math.max(-28, Math.min(64, dy * 0.5)); - lastDy.current = capped; - setDragY(capped); - }; - const onTouchEnd = () => { - const dy = lastDy.current; - touchStartY.current = null; - lastDy.current = 0; - setDragY(0); - if (dy < -10) setExpanded(true); - else if (dy > 16) { - if (expanded) setExpanded(false); - else onClose(); - } - }; - - const subline = - status === "locating" + const subline = loading + ? status === "locating" ? "Finding your location…" - : status === "routing" - ? "Finding the fastest paths…" - : status === "ready" && active - ? `${formatDistance(active.distanceM)} · arrive ${formatArrival(active.durationS)}` - : status === "no-location" - ? LOCATE_MESSAGES[state.locateError ?? "unavailable"] - : "No route found"; + : "Planning your route…" + : status === "ready" && active + ? `${formatDistance(active.distanceM)} · Arrive at ${formatArrival(active.durationS)}` + : status === "no-location" + ? LOCATE_MESSAGES[state.locateError ?? "unavailable"] + : "A route couldn’t be found"; return ( -
-
- {/* Drag/tap header: handle + destination + close */} -
-
setExpanded((v) => !v)} - > -
- + +
+

+ {loading && } + {subline} +

+ {status === "no-location" || status === "error" ? ( +
+ {state.locateError !== "unsupported" && ( + + )} + {status === "no-location" && state.locateError === "denied" && ( +

Allow location in your browser’s site settings, then try again.

+ )} + {status === "error" &&

Check your connection or try another campus destination.

}
- - {/* Destination */} -
- -
-

- {dest.name} -

-

- {subline} -

-
+ ) : ( +
+ {(["walking", "cycling"] as const).map((mode) => ( + onSelectProfile(mode)} + /> + ))}
-
- - {/* Actions */} -
- {status === "no-location" && state.locateError === "denied" ? ( - <> - -

- Allow location for this site in your browser's site settings. On a Mac, your browser - also needs Location Services on under System Settings → Privacy & Security. -

- - ) : status === "no-location" && state.locateError === "unsupported" ? null : status === - "no-location" ? ( - - ) : status === "error" ? ( + )} + {status === "ready" && active && active.steps.length > 0 && ( + <> - ) : ( -
- onSelectProfile("walking")} - /> - onSelectProfile("cycling")} - /> +
+
+ {/* biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard access to scrollable route steps */} +
    + {active.steps.map((step, i) => ( +
  1. + {i + 1} + {step.instruction} + {step.distanceM > 0 && ( + {formatDistance(step.distanceM)} + )} +
  2. + ))} +
+
- )} -
- - {/* Turn-by-turn steps */} - {status === "ready" && active && active.steps.length > 0 && ( -
-
-
    - {active.steps.map((step, i) => ( -
  1. - {step.instruction} - {step.distanceM > 0 && ( - - {formatDistance(step.distanceM)} - - )} -
  2. - ))} -
-
-
+ )}
-
+
); } diff --git a/apps/web/src/components/EatingClubDetail.tsx b/apps/web/src/components/EatingClubDetail.tsx index 93952b6..9392d30 100644 --- a/apps/web/src/components/EatingClubDetail.tsx +++ b/apps/web/src/components/EatingClubDetail.tsx @@ -1,18 +1,19 @@ -import { ChevronLeft, ExternalLink, X } from "lucide-react"; +import { ArrowUpRight, ExternalLink, Users } from "lucide-react"; import { useEffect, useState } from "react"; import type { EatingClub } from "../types"; +import { getJSON, parseList } from "../utils/api"; +import { DetailPanel } from "./DetailPanel"; interface EatingClubDetailProps { club: EatingClub; onClose: () => void; + onDirections: () => void; } interface FullEvent { id: number; - message_id: string; subject: string; author_name: string; - author_email: string; date: string; body_text: string; images: string; @@ -25,151 +26,120 @@ function formatDate(dateStr: string): string { return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } -function parseImages(event: FullEvent): string[] { - try { - return JSON.parse(event.images || "[]"); - } catch { - return []; - } -} - /** LISTSERV-hosted attachments need the API's login; other hosts load directly. */ function imageSrc(event: FullEvent, url: string, index: number): string { return url.includes("lists.princeton.edu") ? `/api/eating-clubs/image/${event.id}/${index}` : url; } -export function EatingClubDetail({ club, onClose }: EatingClubDetailProps) { +export function EatingClubDetail({ club, onClose, onDirections }: EatingClubDetailProps) { const [events, setEvents] = useState([]); const [selectedEvent, setSelectedEvent] = useState(null); - + const [status, setStatus] = useState<"loading" | "ready" | "error">("loading"); + const [retry, setRetry] = useState(0); useEffect(() => { - fetch(`/api/eating-clubs/${encodeURIComponent(club.name)}/events?limit=20`) - .then((r) => r.json()) - .then((data) => setEvents(data.events ?? [])) - .catch(() => {}); - }, [club.name]); + const controller = new AbortController(); + setStatus("loading"); + getJSON<{ events?: FullEvent[] }>( + `/api/eating-clubs/${encodeURIComponent(club.name)}/events?limit=20`, + { signal: controller.signal, cache: retry ? "reload" : "default" }, + ) + .then((data) => { + setEvents(data.events ?? []); + setStatus("ready"); + }) + .catch(() => { + if (!controller.signal.aborted) setStatus("error"); + }); + return () => controller.abort(); + }, [club.name, retry]); return ( -
- {/* Header */} -
- {selectedEvent ? ( - - ) : ( - - )} - - {selectedEvent ? "Event Detail" : club.name} - - -
- - {/* Content */} -
+ setSelectedEvent(null) : undefined} + > + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard access to scrollable content */} +
{selectedEvent ? ( - // ── Full event view ── -
-

- {selectedEvent.subject} -

-
- {selectedEvent.event_type && ( - - {selectedEvent.event_type} - - )} - {formatDate(selectedEvent.date)} + <> +

{selectedEvent.subject}

+
+ {selectedEvent.event_type || "Club event"} +
- - {selectedEvent.body_text && ( -

- {selectedEvent.body_text} -

- )} - - {parseImages(selectedEvent).length > 0 && ( -
- {parseImages(selectedEvent).map((url, i) => ( - - ))} -
- )} - -
-

- From: {selectedEvent.author_name} -

+ {selectedEvent.body_text &&

{selectedEvent.body_text}

} + {parseList(selectedEvent.images).map((url, i) => ( + {`Event + ))} +
+

From {selectedEvent.author_name}

{selectedEvent.listserv_url && ( - - - View on LISTSERV + + + Original post )}
-
+ ) : ( - // ── Event list view ── -
-

- Recent Events ({events.length}) -

- {events.length > 0 ? ( -
- {events.map((event) => ( - - ))} + <> +
+ Recent events + {status === "ready" ? events.length : ""} +
+ {status === "loading" && ( +
+ + +
- ) : ( -

No recent events found

)} -
+ {status === "error" && ( +

+ Events couldn’t load.{" "} + +

+ )} + {status === "ready" && !events.length && ( +

No recent events have been posted.

+ )} + {status === "ready" && + events.map((event) => ( + + ))} + )}
-
+ ); } diff --git a/apps/web/src/components/FreefoodDetail.tsx b/apps/web/src/components/FreefoodDetail.tsx index f137842..2038e4d 100644 --- a/apps/web/src/components/FreefoodDetail.tsx +++ b/apps/web/src/components/FreefoodDetail.tsx @@ -1,95 +1,60 @@ -import { ExternalLink, MapPin, X } from "lucide-react"; +import { ExternalLink, MapPin, Pizza } from "lucide-react"; import type { FreefoodPost } from "../types"; +import { parseList } from "../utils/api"; +import { DetailPanel } from "./DetailPanel"; interface FreefoodDetailProps { post: FreefoodPost; onClose: () => void; + onDirections: () => void; } function timeAgo(dateStr: string): string { - const diff = Date.now() - new Date(dateStr).getTime(); - const mins = Math.floor(diff / 60_000); - if (mins < 1) return "just now"; - if (mins < 60) return `${mins}m ago`; + const mins = Math.max(0, Math.floor((Date.now() - new Date(dateStr).getTime()) / 60_000)); + if (mins < 1) return "Just now"; + if (mins < 60) return `${mins} min ago`; const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h ago`; - const days = Math.floor(hrs / 24); - return `${days}d ago`; + return hrs < 24 ? `${hrs} hr ago` : `${Math.floor(hrs / 24)} days ago`; } -export function FreefoodDetail({ post, onClose }: FreefoodDetailProps) { - const images: string[] = (() => { - try { - return JSON.parse(post.images); - } catch { - return []; - } - })(); - +export function FreefoodDetail({ post, onClose, onDirections }: FreefoodDetailProps) { return ( -
- {/* Header */} -
- 🍕 - FREE FOOD - -
- - {/* Content */} -
-

{post.subject}

- -
- - + + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard access to scrollable content */} +
+
+ + {post.location_name} - {timeAgo(post.date)} +
- - {post.body_text && ( -

- {post.body_text.split("-----")[0].trim()} -

- )} - - {images.length > 0 && ( -
- {images.map((_, i) => ( - - ))} -
- )} - -
-

- From: {post.author_name} -

+ {post.body_text &&

{post.body_text.split("-----")[0].trim()}

} + {parseList(post.images).map((_, i) => ( + {`Food + ))} +
+

From {post.author_name}

{post.listserv_url && ( - - - View on LISTSERV + + + Original post )}
-
+
); } diff --git a/apps/web/src/components/POIDetail.tsx b/apps/web/src/components/POIDetail.tsx index 2368741..500b1d1 100644 --- a/apps/web/src/components/POIDetail.tsx +++ b/apps/web/src/components/POIDetail.tsx @@ -1,101 +1,71 @@ -import { ExternalLink, MapPin, Navigation, Phone, X } from "lucide-react"; +import { Accessibility, ArrowUpRight, Clock, ExternalLink, MapPin, Phone } from "lucide-react"; import type { POI } from "../types"; -import { getCategoryColor } from "../utils/categories"; +import { getCategoryIcon } from "../utils/categories"; +import { DetailPanel } from "./DetailPanel"; interface POIDetailProps { poi: POI; onClose: () => void; - onDirections: (poi: POI) => void; + onDirections: () => void; } export function POIDetail({ poi, onClose, onDirections }: POIDetailProps) { - const color = getCategoryColor(poi.cat); - return ( -
- {poi.img && ( -
- {poi.name} - -
- )} -
- {!poi.img && ( -
- -
- )} -

{poi.name}

- {poi.sub &&

{poi.sub}

} - {poi.cat && ( - - {poi.cat} - + + {/* biome-ignore lint/a11y/noNoninteractiveTabindex: keyboard access to scrollable content */} +
+ {poi.img && ( + {poi.name} { + e.currentTarget.hidden = true; + }} + /> )} - - {poi.desc &&

{poi.desc}

} -
+ {poi.sub &&

{poi.sub}

} + {poi.desc &&

{poi.desc}

} +
{poi.hours && ( -
- Hours: - {poi.hours} +
+ + {poi.hours}
)} {poi.addr && ( -
- - {poi.addr} +
+ + {poi.addr}
)} {poi.phone && ( - - - {poi.phone} + + + {poi.phone} )} {poi.web && ( - - - Visit website + + + Visit website + )} + {poi.access === "true" && ( +
+ + Wheelchair accessible +
+ )}
- {poi.access === "true" && ( -
- Wheelchair Accessible -
- )}
-
+ ); } diff --git a/apps/web/src/components/PlaceCarousel.tsx b/apps/web/src/components/PlaceCarousel.tsx new file mode 100644 index 0000000..b51d212 --- /dev/null +++ b/apps/web/src/components/PlaceCarousel.tsx @@ -0,0 +1,22 @@ +import type { POI } from "../types"; +import { getCategoryIcon } from "../utils/categories"; + +export function PlaceCarousel({ places, onPick }: { places: POI[]; onPick: (poi: POI) => void }) { + return ( +
+ {places.map((poi) => { + const Icon = getCategoryIcon(poi.cat); + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/src/components/SearchBar.tsx b/apps/web/src/components/SearchBar.tsx index d3db8ba..c6bec65 100644 --- a/apps/web/src/components/SearchBar.tsx +++ b/apps/web/src/components/SearchBar.tsx @@ -1,7 +1,7 @@ -import { Search } from "lucide-react"; +import { ArrowUpRight, Search, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import type { POI } from "../types"; -import { getCategoryColor } from "../utils/categories"; +import { getCategoryIcon } from "../utils/categories"; interface SearchBarProps { value: string; @@ -15,82 +15,120 @@ export function SearchBar({ value, onChange, results, onPick }: SearchBarProps) const [activeIdx, setActiveIdx] = useState(0); const inputRef = useRef(null); - // Reset highlight whenever the result set changes - useEffect(() => setActiveIdx(0), [results]); + useEffect(() => { + const shortcut = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key === "k") { + event.preventDefault(); + inputRef.current?.focus(); + } + }; + window.addEventListener("keydown", shortcut); + return () => window.removeEventListener("keydown", shortcut); + }, []); const pick = (poi: POI) => { setOpen(false); - inputRef.current?.blur(); onPick(poi); }; - - const showDropdown = open && value.trim().length >= 2 && results.length > 0; + const showDropdown = open && value.trim().length >= 2; return ( -
- { - onChange(e.target.value); - setOpen(true); - }} - onFocus={() => setOpen(true)} - onBlur={() => setOpen(false)} - onKeyDown={(e) => { - if (!showDropdown) return; - if (e.key === "ArrowDown") { - e.preventDefault(); - setActiveIdx((i) => Math.min(i + 1, results.length - 1)); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - setActiveIdx((i) => Math.max(i - 1, 0)); - } else if (e.key === "Enter") { - e.preventDefault(); - pick(results[activeIdx] ?? results[0]); - } else if (e.key === "Escape") { - setOpen(false); +
+
+
{showDropdown && ( -
    - {results.map((poi, i) => ( -
  • - -
  • - ))} -
+
+ Places on campus + {/* biome-ignore lint/a11y/useSemanticElements: custom combobox uses aria-activedescendant */} +
+ {results.map((poi, i) => { + const Icon = getCategoryIcon(poi.cat); + return ( + + ); + })} +
+ {!results.length && ( +

+ No places match “{value.trim()}”. Try a building or library name. +

+ )} +
)}
); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 4fb05c1..a8b178e 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1,328 +1,333 @@ @import "tailwindcss"; @import "mapbox-gl/dist/mapbox-gl.css"; -html, -body, -#root { - height: 100%; - margin: 0; - font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; -} - -/* Lock the page: the app is a fixed full-screen map — no document scrolling, - no iOS rubber-band bounce (which showed white past the page edges), and no - browser pinch-zoom of the UI. The Mapbox canvas manages its own gestures. */ -html { - background: #1c1c1e; - overflow: hidden; - overscroll-behavior: none; -} -body { - position: fixed; - inset: 0; - width: 100%; - overflow: hidden; - overscroll-behavior: none; - touch-action: pan-x pan-y; - -webkit-text-size-adjust: 100%; -} - -/* ── Shared detail panel ────────────────────────── */ -.detail-panel { - position: absolute; - bottom: 1rem; - right: 1rem; - z-index: 20; - width: 380px; - max-height: 70vh; - background: white; - border-radius: 0.75rem; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.08); - border: 1px solid #e5e7eb; - overflow: hidden; - display: flex; - flex-direction: column; -} - -/* Mobile: full-width bottom sheet */ -@media (max-width: 640px) { - .detail-panel { - left: 0; - right: 0; - bottom: 0; - width: 100%; - max-height: 55vh; - border-radius: 1rem 1rem 0 0; - border-bottom: none; - } -} - -/* ── Directions sheet ──────────────────────────────── */ -.directions-sheet-wrap { - position: absolute; - left: 0; - right: 0; - bottom: 0; - z-index: 30; - display: flex; - justify-content: center; - pointer-events: none; -} - -.directions-sheet { - pointer-events: auto; - touch-action: none; - width: 100%; - background: #1c1c1e; - border-radius: 1rem 1rem 0 0; - padding: 0 1rem calc(1rem + env(safe-area-inset-bottom)); - box-shadow: 0 -12px 40px rgba(0, 0, 0, 0.35); - animation: sheet-up 300ms cubic-bezier(0.32, 0.72, 0.3, 1); - transition: transform 220ms cubic-bezier(0.32, 0.72, 0.3, 1); -} - -/* Turn-by-turn steps: collapsed on mobile until the sheet is expanded - (swipe up or tap the handle); always open on desktop. */ -.steps-wrap { - display: grid; - grid-template-rows: 0fr; - transition: grid-template-rows 320ms cubic-bezier(0.32, 0.72, 0.3, 1); -} -.steps-wrap.steps-open { - grid-template-rows: 1fr; -} -.steps-inner { - overflow: hidden; - min-height: 0; -} -.steps-list { - max-height: 40dvh; - overflow-y: auto; - overscroll-behavior: contain; - touch-action: pan-y; -} -@media (min-width: 640px) { - .steps-wrap { - grid-template-rows: 1fr; - } - .steps-list { - max-height: 13rem; - } -} -@media (prefers-reduced-motion: reduce) { - .steps-wrap, - .directions-sheet { - transition: none; - } -} +:root { + --surface: oklch(98.5% 0.009 85); + --surface-soft: oklch(96% 0.012 85); + --ink: oklch(28% 0.018 70); + --muted: oklch(49% 0.022 75); + --faint: oklch(65% 0.022 75); + --line: oklch(89% 0.018 85); + --accent: oklch(51% 0.133 44); + --accent-soft: oklch(94% 0.036 65); + --green: oklch(46% 0.051 145); + --shadow: 0 8px 32px oklch(28% 0.03 70 / 9%), 0 2px 6px oklch(28% 0.03 70 / 4%); + --ease: cubic-bezier(0.22, 1, 0.36, 1); + --space-sm: 8px; + color: var(--ink); + font-family: "Manrope", sans-serif; + font-synthesis: none; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} +html, body, #root { height: 100%; margin: 0; } +html { background: var(--surface-soft); overflow: hidden; overscroll-behavior: none; } +body { position: fixed; inset: 0; overflow: hidden; overscroll-behavior: none; -webkit-text-size-adjust: 100%; } +button, input { font: inherit; } +button, a, input { -webkit-tap-highlight-color: transparent; } +button { cursor: pointer; } +button:disabled { cursor: default; opacity: 0.55; } +button, a { transition: color 160ms var(--ease), background-color 160ms var(--ease), border-color 160ms var(--ease), transform 160ms var(--ease); } +button:active:not(:disabled) { transform: scale(0.97); } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } +.detail-panel:focus { outline: none; } +h1, h2, h3, p { margin: 0; } +h1, h2, .brand-name, .event-title { font-family: "Bricolage Grotesque", sans-serif; } +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +svg { flex-shrink: 0; } +::selection { background: var(--accent-soft); } +* { scrollbar-width: thin; scrollbar-color: var(--line) transparent; } +.map-app, .campus-map { position: relative; width: 100%; height: 100%; } +.map-app { overflow: hidden; } +.campus-map { background: #eae9df; } +.campus-map > .mapboxgl-map { opacity: 0; transition: opacity 500ms var(--ease); } +.campus-map.is-loaded > .mapboxgl-map { opacity: 1; } +.map-loading { position: absolute; inset: 0; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 20px; color: var(--muted); font-size: 14px; } +.map-loading .primary-button { width: auto; min-width: 120px; } +.map-loading-symbol { color: var(--accent); animation: loading-breathe 1.6s ease-in-out infinite; } +.map-context { position: absolute; top: 28px; right: 28px; display: flex; align-items: center; gap: 8px; border: 1px solid oklch(89% 0.018 85 / 75%); border-radius: 24px; background: var(--surface); padding: 10px 16px; color: var(--muted); font-size: 11px; font-weight: 600; pointer-events: none; } +.map-workspace { position: absolute; top: 24px; left: 24px; width: 360px; z-index: 10; display: flex; flex-direction: column; gap: 16px; max-height: calc(100dvh - 68px); pointer-events: none; animation: arrive 550ms var(--ease) both; } +.map-workspace > * { pointer-events: auto; } +.is-searching .map-workspace { z-index: 40; } +.app-brand { display: flex; align-items: center; gap: 10px; padding: 12px 16px; background: var(--surface); border: 1px solid var(--line); border-radius: 16px; box-shadow: var(--shadow); } +.brand-mark { width: 42px; height: 42px; display: grid; place-items: center; background: var(--accent); color: var(--surface); border-radius: 13px; } +.brand-name { display: block; font-size: 25px; font-weight: 700; line-height: 1.1; letter-spacing: -1px; } +.brand-period { color: var(--accent); } +.brand-subtitle { display: block; font-size: 10px; color: var(--muted); margin-top: 4px; letter-spacing: 0.03em; } +.brand-byline { margin-left: auto; display: inline-flex; align-items: center; justify-content: center; gap: 3px; min-height: 44px; font-size: 10px; color: var(--muted); white-space: nowrap; } +.brand-byline:hover { color: var(--accent); } +.map-search-tools { position: relative; display: flex; flex-direction: column; gap: 10px; } +.search-bar { position: relative; z-index: 3; } +.search-field { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 4px 8px 4px 18px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); color: var(--accent); transition: border-color 180ms var(--ease), box-shadow 180ms var(--ease); } +.search-field:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in oklch, var(--accent) 12%, transparent), var(--shadow); } +.search-field input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--ink); font-size: 15px; font-weight: 500; } +.search-field input::placeholder { color: var(--muted); } +.search-field input::-webkit-search-cancel-button { display: none; } +kbd { display: inline-flex; gap: 4px; padding: 5px 7px; margin-right: 8px; color: var(--muted); font-family: inherit; font-size: 10px; border: 1px solid var(--line); border-radius: 5px; white-space: nowrap; } +.icon-button { display: inline-flex; align-items: center; justify-content: center; width: 44px; height: 44px; flex-shrink: 0; border: 0; border-radius: 10px; background: transparent; color: var(--muted); } +.icon-button:hover { background: var(--surface-soft); color: var(--ink); } +.icon-button.is-active { color: var(--accent); background: var(--accent-soft); } +.search-dropdown { position: absolute; left: 0; right: 0; top: calc(100% + 8px); background: var(--surface); border: 1px solid var(--line); border-radius: 16px; padding: 16px 8px 8px; box-shadow: var(--shadow); max-height: min(480px, 65dvh); overflow-y: auto; animation: arrive 220ms var(--ease); } +.search-dropdown > .eyebrow { padding: 0 12px; } +.search-dropdown [role="listbox"] { padding-top: 8px; } +.eyebrow { display: flex; align-items: center; gap: 6px; font-size: 9px; line-height: 1.5; font-weight: 700; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); } +.filter-strip { display: flex; align-items: center; gap: 6px; } +.quick-filters { display: flex; align-items: center; gap: 6px; flex: 1; overflow-x: auto; padding: 4px 1px; scrollbar-width: none; } +.quick-filters::-webkit-scrollbar { display: none; } +.filter-chip { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; min-height: 38px; padding: 0 12px; border-radius: 20px; border: 1px solid var(--line); background: var(--surface); color: var(--muted); font-size: 11px; font-weight: 600; white-space: nowrap; } +.filter-chip:hover { border-color: var(--faint); color: var(--ink); } +.filter-chip.is-active { background: var(--ink); border-color: var(--ink); color: var(--surface); } +.chip-count { display: grid; place-items: center; min-width: 16px; height: 16px; padding: 0 3px; background: var(--accent); color: var(--surface); border-radius: 8px; font-size: 9px; } +.filter-more { width: 40px; background: var(--surface); border: 1px solid var(--line); box-shadow: 0 2px 5px oklch(28% 0.03 70 / 5%); } +.discovery-panel, .category-panel { overflow: hidden; min-height: 0; background: var(--surface); border: 1px solid var(--line); border-radius: 18px; box-shadow: var(--shadow); animation: arrive 350ms var(--ease) both; } +.discovery-panel { overflow-y: auto; } +.app-brand, .map-search-tools { flex-shrink: 0; } +.discovery-intro { display: flex; align-items: center; min-height: 56px; padding: 12px 20px; cursor: pointer; list-style: none; } +.discovery-intro::-webkit-details-marker { display: none; } +.discovery-intro:hover { background: var(--surface-soft); } +.discovery-chevron { color: var(--muted); transition: transform 240ms var(--ease); } +.discovery-panel:not([open]) .discovery-chevron { transform: rotate(-90deg); } +.discovery-title { display: grid; flex: 1; align-items: center; text-align: left; } +.discovery-title > span { grid-area: 1 / 1; transition: opacity 150ms var(--ease), transform 150ms var(--ease), visibility 150ms; } +.discovery-title-compact { display: inline-flex; align-items: center; gap: 8px; font-family: "Manrope", sans-serif; font-size: 14px; font-weight: 650; line-height: 24px; letter-spacing: 0; } +.discovery-panel[open] .discovery-title-compact, .discovery-panel:not([open]) .discovery-title-expanded { opacity: 0; visibility: hidden; transform: translateY(-4px); } +.discovery-panel[open] .discovery-title-expanded, .discovery-panel:not([open]) .discovery-title-compact { opacity: 1; visibility: visible; transform: translateY(0); } +.popular-places .place-row { min-height: 52px; padding-top: 8px; padding-bottom: 8px; } +.has-detail .discovery-panel, .is-searching .discovery-panel { display: none; } +@supports (interpolate-size: allow-keywords) { + .discovery-panel::details-content { interpolate-size: allow-keywords; overflow: hidden; block-size: 0; transition: block-size 260ms var(--ease), content-visibility 260ms; transition-behavior: allow-discrete; } + .discovery-panel[open]::details-content { block-size: auto; } +} +.discovery-intro h1 { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 12px; font-size: 26px; font-weight: 600; letter-spacing: -1px; line-height: 1.15; } +.popular-places { padding: 8px 12px 12px; } +.place-row { width: 100%; display: flex; align-items: center; gap: 12px; padding: 12px; border: 0; border-radius: 10px; background: transparent; text-align: left; } +.place-row:hover, .place-row.is-highlighted { background: var(--surface-soft); } +.place-icon { display: grid; place-items: center; flex-shrink: 0; width: 36px; height: 40px; color: var(--muted); } +.place-row-copy { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 4px; } +.place-row-copy strong { font-size: 12px; line-height: 1.4; font-weight: 650; color: var(--ink); } +.place-row-copy > span { font-size: 10px; color: var(--muted); } +.row-arrow { color: var(--faint); transition: transform 200ms var(--ease), color 200ms var(--ease); } +.place-row:hover .row-arrow { transform: translate(2px, -2px); color: var(--accent); } +.discovery-footer { display: flex; align-items: center; gap: 8px; border-top: 1px solid var(--line); padding: 16px 24px; color: var(--muted); font-size: 9px; } +.section-heading { display: flex; align-items: center; justify-content: space-between; padding: 20px 16px 16px 24px; gap: 10px; border-bottom: 1px solid var(--line); } +.section-heading h2 { font-size: 21px; margin-top: 0; letter-spacing: -0.5px; } +.category-panel { display: flex; flex-direction: column; } +.category-list { display: flex; flex-direction: column; gap: var(--space-sm); overflow-y: auto; padding: 12px; overscroll-behavior: contain; } +.category-row { display: flex; align-items: center; gap: 12px; width: 100%; min-height: 46px; padding: 8px 12px; border: 0; border-radius: 9px; background: transparent; color: var(--muted); font-size: 12px; text-align: left; } +.category-row:hover { background: var(--surface-soft); } +.category-row.is-active { background: var(--accent-soft); color: var(--accent); } +.category-row > span:nth-child(2) { flex: 1; } +.category-count { font-size: 10px; font-variant-numeric: tabular-nums; } +.category-check { display: grid; place-items: center; width: 18px; height: 18px; border: 1px solid var(--line); border-radius: 5px; } +.category-check svg { animation: check-arrive 160ms var(--ease); } +.category-actions { display: flex; align-items: center; gap: 16px; } +.category-actions .text-button { min-height: 44px; } +.is-active .category-check { background: var(--accent); border-color: var(--accent); color: var(--surface); } +.category-footer { display: flex; align-items: center; justify-content: space-between; padding: 10px 24px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; } +.text-button { background: transparent; border: 0; color: var(--accent); font-size: inherit; font-weight: 700; padding: 12px 0; } +.text-button:hover { text-decoration: underline; } +.detail-panel { position: absolute; right: 88px; bottom: 32px; z-index: 20; display: flex; flex-direction: column; width: 380px; max-height: calc(100dvh - 112px); background: var(--surface); border: 1px solid var(--line); border-radius: 20px; overflow: hidden; box-shadow: var(--shadow); transition: transform 250ms var(--ease); } +.detail-panel.is-entering { animation: panel-arrive 350ms var(--ease) backwards; } +.detail-header, .detail-actions { flex-shrink: 0; } +.detail-header { display: flex; align-items: flex-start; gap: 12px; padding: 24px 16px 20px 24px; } +.detail-header > .icon-button { margin-top: -7px; margin-right: -4px; } +.detail-category-icon { color: var(--accent); padding-top: 6px; } +.detail-heading { flex: 1; min-width: 0; } +.detail-heading h2 { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; overflow: hidden; font-size: 23px; font-weight: 600; line-height: 1.2; letter-spacing: -0.5px; margin-top: 5px; overflow-wrap: anywhere; } +.detail-content { overflow-y: auto; min-height: 0; flex: 1; padding: 0 24px 24px; overscroll-behavior: contain; animation: arrive 250ms var(--ease); } +.detail-subtitle { font-size: 13px; color: var(--muted); margin-bottom: 16px; } +.detail-description, .event-body { font-size: 13px; line-height: 1.8; color: var(--muted); overflow-wrap: anywhere; } +.place-photo { display: block; width: 100%; aspect-ratio: 16/10; object-fit: cover; border-radius: 12px; margin-bottom: 20px; } +.place-photo[hidden] { display: none; } +.place-facts { display: flex; flex-direction: column; gap: 16px; margin-top: 20px; font-size: 12px; } +.place-facts > div, .place-facts > a { display: flex; align-items: flex-start; gap: 12px; color: var(--muted); line-height: 1.6; overflow-wrap: anywhere; } +.place-facts > a { color: var(--accent); min-height: 28px; } +.place-facts svg { margin-top: 2px; } +.accessible-note { color: var(--green) !important; } +.detail-actions { padding: 16px 24px 20px; border-top: 1px solid var(--line); background: var(--surface); } +.primary-button { width: 100%; min-height: 46px; display: flex; align-items: center; justify-content: center; gap: 9px; padding: 12px 20px; border: 1px solid transparent; border-radius: 11px; background: var(--accent); color: var(--surface); font-size: 13px; font-weight: 700; } +.primary-button:hover { background: color-mix(in oklch, var(--accent) 85%, var(--ink)); } +.meal-tabs { display: flex; gap: 4px; margin: 0 24px 20px; padding: 4px; border: 0; border-radius: 10px; background: var(--surface-soft); flex-shrink: 0; overflow-x: auto; } +.meal-tabs button { flex: 1; padding: 10px 12px; min-height: 40px; border: 0; background: transparent; border-radius: 7px; color: var(--muted); font-size: 11px; font-weight: 600; } +.meal-tabs button.is-active { background: var(--surface); color: var(--ink); box-shadow: 0 1px 4px oklch(28% 0.03 70 / 9%); } +.menu-date { display: flex; align-items: center; justify-content: space-between; color: var(--muted); font-size: 11px; padding: 2px 0 18px; } +.menu-station { padding: 16px 0; border-top: 1px solid var(--line); } +.menu-station h3 { color: var(--green); font-size: 12px; font-weight: 700; margin-bottom: 10px; } +.menu-station ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 8px; } +.menu-station li { font-size: 13px; line-height: 1.5; color: var(--muted); } +.event-title { margin-bottom: 12px; font-size: 19px; font-weight: 600; line-height: 1.25; letter-spacing: -0.3px; overflow-wrap: anywhere; } +.event-meta { display: flex; align-items: flex-start; flex-wrap: wrap; gap: 8px 16px; color: var(--muted); font-size: 11px; margin-bottom: 20px; } +.event-meta > span { display: inline-flex; align-items: center; gap: 6px; color: var(--accent); } +.event-body { white-space: pre-line; } +.event-photo { display: block; width: 100%; height: auto; border-radius: 10px; margin-top: 16px; } +.event-source { border-top: 1px solid var(--line); margin-top: 24px; padding-top: 16px; font-size: 11px; color: var(--muted); } +.event-source a { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; } +.event-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; width: 100%; padding: 16px 8px; text-align: left; border: 0; border-top: 1px solid var(--line); border-radius: 0; background: transparent; } +.event-row:hover { background: var(--surface-soft); } +.event-row > span { display: flex; flex-direction: column; gap: 8px; } +.event-row strong { color: var(--ink); font-size: 13px; font-weight: 600; line-height: 1.5; } +.event-row span span { color: var(--muted); font-size: 11px; } +.event-row svg { color: var(--accent); } +.empty-state { font-size: 13px; color: var(--muted); line-height: 1.7; padding: 16px 12px; } +.detail-content > .empty-state { padding: 8px 0 16px; } +.places-skeleton { padding: 16px 24px; display: flex; flex-direction: column; gap: 16px; } +.places-skeleton span { height: 28px; border-radius: 6px; background: var(--surface-soft); animation: loading-breathe 1.6s ease-in-out infinite; } +.places-skeleton span:nth-child(2) { width: 80%; animation-delay: 100ms; } +.places-skeleton span:nth-child(3) { width: 65%; animation-delay: 200ms; } +.sheet-handle, .sheet-handle-button { display: none; } +.detail-panel.is-dragging { transition: none; } +.directions-sheet { z-index: 30; } +.directions-content { padding: 0 24px 24px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; } +.route-summary { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; line-height: 1.6; margin-bottom: 20px; } +.travel-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.travel-mode { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); color: var(--muted); text-align: left; } +.travel-mode > span { display: flex; flex-direction: column; gap: 4px; font-size: 10px; } +.travel-mode strong { font-size: 16px; font-weight: 700; font-variant-numeric: tabular-nums; } +.travel-mode.is-active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); } +.travel-mode:hover:not(:disabled) { border-color: var(--accent); } +.route-error > p { margin-top: 12px; font-size: 12px; line-height: 1.7; color: var(--muted); } +.steps-toggle { display: none; } +.steps-wrap { display: grid; grid-template-rows: 1fr; } +.steps-inner { min-height: 0; overflow: hidden; } +.steps-list { padding: 0; margin: 20px 0 0; list-style: none; max-height: 32dvh; overflow-y: auto; overscroll-behavior: contain; touch-action: pan-y; } +.steps-list li { display: flex; align-items: baseline; gap: 10px; padding: 14px 0; border-top: 1px solid var(--line); font-size: 12px; line-height: 1.6; color: var(--muted); } +.step-number { width: 20px; flex-shrink: 0; font-size: 10px; color: var(--accent); font-variant-numeric: tabular-nums; } +.step-distance { margin-left: auto; flex-shrink: 0; color: var(--muted); font-size: 10px; font-variant-numeric: tabular-nums; } +.loading-spinner { animation: spin 900ms linear infinite; } +.map-marker { position: relative; display: grid; place-items: center; width: 44px; height: 44px; padding: 0; border: 0; background: transparent; color: var(--marker-color, var(--accent)); } +.marker-disc { position: relative; display: grid; place-items: center; width: 29px; height: 29px; background: var(--surface); border: 1.5px solid color-mix(in oklch, currentColor 35%, var(--surface)); border-radius: 50%; box-shadow: 0 2px 5px oklch(28% 0.03 70 / 12%); transition: transform 200ms var(--ease), background-color 200ms var(--ease), box-shadow 200ms var(--ease); } +.map-marker:hover .marker-disc, .map-marker:focus-visible .marker-disc { transform: translateY(-2px); box-shadow: 0 4px 12px oklch(28% 0.03 70 / 16%); } +.map-marker[aria-pressed="true"] .marker-disc { background: currentColor; outline: 4px solid color-mix(in oklch, currentColor 18%, transparent); transform: scale(1.1); } +.map-marker[aria-pressed="true"] svg { color: var(--surface); } +.food-marker { color: var(--accent); } +.food-marker .marker-disc { width: 34px; height: 34px; border-color: currentColor; } +.dining-marker { color: var(--green); } +.club-marker { color: #796249; } +.marker-activity { position: absolute; top: -2px; right: -1px; width: 7px; height: 7px; border: 1.5px solid var(--surface); background: var(--accent); border-radius: 50%; } +.marker-tooltip { position: absolute; bottom: 100%; left: 50%; transform: translate(-50%, 4px); opacity: 0; pointer-events: none; padding: 7px 10px; background: var(--ink); color: var(--surface); border-radius: 7px; font: 600 10px "Manrope", sans-serif; white-space: nowrap; transition: transform 180ms var(--ease), opacity 180ms var(--ease); } +.map-marker:hover .marker-tooltip, .map-marker:focus-visible .marker-tooltip { opacity: 1; transform: translate(-50%, 0); } +.mapboxgl-marker:has(.food-marker) { z-index: 1; } +.mapboxgl-marker:has(.map-marker:hover), .mapboxgl-marker:has(.map-marker[aria-pressed="true"]) { z-index: 2; } +.route-origin-dot { width: 16px; height: 16px; background: #4e6679; border: 3px solid var(--surface); border-radius: 50%; box-shadow: 0 0 0 5px #4e667925; } +.route-destination { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 50%; background: var(--accent); color: var(--surface); border: 3px solid var(--surface); box-shadow: var(--shadow); } +.mapboxgl-ctrl-bottom-right { right: 24px; bottom: 32px; } +.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl { margin: 0 0 8px; } +.mapboxgl-ctrl-group { border: 1px solid var(--line); background: var(--surface); box-shadow: var(--shadow) !important; border-radius: 12px; overflow: hidden; } +.mapboxgl-ctrl-group button { width: 42px; height: 44px; } +.mapboxgl-ctrl-group button + button { border-top: 1px solid var(--line); } +.mapboxgl-ctrl-group button:hover { background: var(--surface-soft); } +.mapboxgl-ctrl-group button:focus-visible { outline-offset: -4px; } +.mapboxgl-ctrl-icon { opacity: 0.68; } +.reset-map-control { position: absolute; right: 24px; bottom: 194px; background: var(--surface); border: 1px solid var(--line); border-radius: 12px; box-shadow: var(--shadow); } +.reset-map-control .icon-button { width: 42px; } +.mapboxgl-ctrl-attrib { background: var(--surface) !important; color: var(--muted); font-size: 9px; } +.mapboxgl-ctrl-logo { opacity: 0.65; } +.t-icon-swap { display: inline-grid; } +.t-icon-swap .t-icon { grid-area: 1 / 1; display: grid; place-items: center; transition: opacity 250ms ease-in-out, filter 250ms ease-in-out, transform 250ms ease-in-out; } +.t-icon-swap[data-state="a"] .t-icon[data-icon="b"], .t-icon-swap[data-state="b"] .t-icon[data-icon="a"] { opacity: 0; filter: blur(2px); transform: scale(0.25); } +@keyframes check-arrive { from { opacity: 0; transform: scale(0.65); } to { opacity: 1; transform: scale(1); } } +@keyframes arrive { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } +@keyframes sheet-arrive { from { transform: translateY(100%); } to { transform: translateY(0); } } +@keyframes panel-arrive { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } +@keyframes loading-breathe { 50% { opacity: 0.4; } } +@keyframes spin { to { transform: rotate(360deg); } } -@media (min-width: 640px) { - .directions-sheet { - width: 420px; - margin-bottom: 1.5rem; - border-radius: 1rem; - box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(0, 0, 0, 0.25); - } -} +@media (max-height: 720px) and (min-width: 768px) { + .map-workspace { gap: 12px; top: 16px; } -@keyframes sheet-up { - from { - transform: translateY(110%); - } - to { - transform: translateY(0); - } + .place-row { padding-top: 8px; padding-bottom: 8px; } + .discovery-footer { padding-top: 12px; padding-bottom: 12px; } +} +@media (min-width: 768px) and (max-width: 1050px) { + .map-workspace { width: 320px; left: 16px; } + .detail-panel { width: 340px; right: 76px; } + .filter-chip { padding: 0 8px; } + .map-context { right: 16px; } + .mapboxgl-ctrl-bottom-right, .reset-map-control { right: 16px; } +} +@media (width < 768px) { + .map-workspace { top: max(12px, env(safe-area-inset-top)); left: 12px; right: 12px; width: auto; height: calc(100dvh - max(12px, env(safe-area-inset-top)) - 12px); max-height: none; gap: 12px; } + .app-brand { padding: 9px 12px; border-radius: 14px; } + .brand-mark { width: 36px; height: 36px; border-radius: 11px; } + .brand-mark svg { width: 21px; } + .brand-name { font-size: 23px; } + .brand-subtitle { font-size: 9px; margin-top: 2px; } + .search-field { min-height: 54px; } + .search-field input { font-size: 16px; } + kbd { display: none; } + .map-search-tools { gap: 6px; } + .filter-chip { min-height: 40px; font-size: 12px; } + .filter-more { width: 44px; } + .map-context { display: none; } + .discovery-panel { position: absolute; left: 0; right: 0; bottom: calc(12px + env(safe-area-inset-bottom)); } + .discovery-intro { padding: 12px 18px; } + .discovery-intro h1 { font-size: 23px; } + .discovery-footer { display: none; } + .popular-places { display: flex; gap: var(--space-sm); overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; scroll-padding-inline: 12px; padding: 0 12px 12px; scrollbar-width: none; } + .popular-places .place-row { flex: 0 0 auto; width: auto; scroll-snap-align: start; gap: 8px; padding: 8px 10px; } + .popular-places .place-icon { height: 32px; } + .place-row-copy strong { font-size: 13px; } + .place-row-copy > span { font-size: 11px; } + .place-icon { width: 32px; } + .category-panel { max-height: calc(100dvh - 228px); } + .category-row { min-height: 48px; font-size: 14px; } + .category-count { font-size: 12px; } + .category-footer { font-size: 13px; } + .section-heading h2 { font-size: 22px; } + .detail-panel { left: 0; right: 0; bottom: 0; width: 100%; max-height: min(65dvh, calc(100dvh - 220px)); border-radius: 22px 22px 0 0; border-bottom: 0; padding-bottom: env(safe-area-inset-bottom); } + .detail-panel.is-entering { animation: sheet-arrive 350ms var(--ease) backwards; } + .detail-panel.is-expanded { max-height: min(86dvh, calc(100dvh - 100px)); } + .detail-header { padding: 20px 16px 20px 24px; } + .detail-heading h2 { font-size: 25px; } + .detail-heading .eyebrow { font-size: 10px; } + .detail-content { font-size: 14px; } + .detail-description, .event-body, .menu-station li, .event-row strong { font-size: 14px; } + .place-facts, .detail-subtitle { font-size: 14px; } + .place-facts > a { min-height: 44px; } + .primary-button { min-height: 48px; font-size: 14px; } + .menu-date, .event-meta { font-size: 12px; } + .menu-station h3 { font-size: 14px; } + .meal-tabs button { font-size: 12px; min-height: 44px; } + .sheet-drag-area { touch-action: none; flex-shrink: 0; cursor: grab; user-select: none; } + .is-dragging .sheet-drag-area { cursor: grabbing; } + .sheet-handle-button { display: grid; place-items: center; width: 100%; height: 44px; border: 0; background: transparent; } + .sheet-handle-button .sheet-handle { display: block; width: 32px; height: 4px; background: var(--line); border-radius: 4px; } + .sheet-drag-area .detail-header { padding-top: 4px; } + .route-summary { font-size: 13px; } + .travel-mode > span { font-size: 12px; } + .travel-mode strong { font-size: 17px; } + .route-error > p { font-size: 13px; } + .steps-wrap { grid-template-rows: 0fr; visibility: hidden; } + .steps-wrap.steps-open { grid-template-rows: 1fr; visibility: visible; } + .steps-toggle { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 44px; border: 0; background: transparent; color: var(--muted); font-size: 13px; margin-top: 8px; } + .steps-toggle svg { transition: transform 200ms var(--ease); } + .steps-toggle svg.is-expanded { transform: rotate(180deg); } + .steps-list { max-height: 24dvh; margin-top: 4px; } + .steps-list li { font-size: 13px; } + .mapboxgl-ctrl-bottom-right { right: 12px; bottom: calc(176px + env(safe-area-inset-bottom)); } + .reset-map-control { right: 12px; bottom: calc(338px + env(safe-area-inset-bottom)); } + .has-detail .mapboxgl-ctrl-bottom-right { bottom: auto; top: 204px; display: flex; align-items: center; gap: 8px; } + .has-detail .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-group { display: flex; } + .has-detail .mapboxgl-ctrl-group button + button { border-top: 0; border-left: 1px solid var(--line); } + .has-detail .reset-map-control { display: none; } + .mapboxgl-ctrl-attrib { bottom: 0; } +} +@media (width < 768px) and (max-height: 550px) { + .detail-heading h2 { font-size: 21px; -webkit-line-clamp: 2; line-clamp: 2; } + .app-brand { padding: 4px 8px; } + .brand-name { font-size: 20px; } + .brand-subtitle { display: none; } + .brand-mark { width: 28px; height: 28px; } + .brand-mark svg { width: 18px; } + .detail-panel { left: auto; right: 12px; bottom: 12px; width: 46%; max-height: calc(100dvh - 32px); border-radius: 16px; } + .detail-panel.is-expanded { max-height: calc(100dvh - 32px); } + .map-workspace { right: auto; width: 46%; } + .category-panel { max-height: calc(100dvh - 130px); } + .mapboxgl-ctrl-bottom-right { bottom: 16px; } + .reset-map-control { bottom: 178px; } + .has-detail .mapboxgl-ctrl-bottom-right { display: flex; left: 12px; right: auto; top: auto; bottom: 12px; } } - @media (prefers-reduced-motion: reduce) { - .directions-sheet { - animation: none; - } -} - -/* User's position at the start of a route */ -.route-origin-dot { - width: 15px; - height: 15px; - border-radius: 50%; - background: #2563eb; - border: 3px solid white; - box-shadow: 0 1px 6px rgba(0, 0, 0, 0.35); -} - -/* Clean up MapBox popup defaults */ -.mapboxgl-popup-content { - padding: 0 !important; - border-radius: 12px !important; - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.06) !important; - overflow: hidden; -} -.mapboxgl-popup-close-button { - font-size: 18px; - padding: 4px 8px; - color: #94a3b8; -} -.mapboxgl-popup-close-button:hover { - color: #1e293b; - background: transparent; -} -.mapboxgl-popup-tip { - border-top-color: white !important; -} - -/* ── Free food markers ────────────────────────────── */ -.freefood-marker-wrapper { - display: flex; - flex-direction: column; - align-items: center; - cursor: pointer; - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.25)); -} - -.freefood-marker { - width: 36px; - height: 36px; - border-radius: 50%; - background: linear-gradient(135deg, #f97316, #f59e0b); - border: 2.5px solid white; - display: flex; - align-items: center; - justify-content: center; - animation: food-pulse 2.5s ease-in-out infinite; - transition: transform 0.15s ease; -} - -.freefood-marker-wrapper:hover .freefood-marker { - transform: scale(1.2); -} - -.freefood-marker-selected { - transform: scale(1.2); - box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.4); - animation: none; -} - -.freefood-marker-stem { - width: 2px; - height: 6px; - background: white; - border-radius: 0 0 1px 1px; -} - -@keyframes food-pulse { - 0%, - 100% { - box-shadow: 0 0 0 0 rgba(249, 115, 22, 0.45); - } - 50% { - box-shadow: 0 0 0 10px rgba(249, 115, 22, 0); - } -} - -/* ── Eating club markers ─────────────────────────── */ -.eating-club-marker { - position: relative; - width: 28px; - height: 28px; - border-radius: 50%; - background: white; - border: 2px solid #d4a574; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); - transition: transform 0.15s ease, border-color 0.15s ease; -} - -.eating-club-marker:hover { - transform: scale(1.15); - border-color: #c4844a; -} - -.eating-club-marker-selected { - transform: scale(1.2); - border-color: #c4844a; - box-shadow: 0 0 0 3px rgba(212, 165, 116, 0.4); -} - -.eating-club-badge { - position: absolute; - top: -4px; - right: -4px; - min-width: 16px; - height: 16px; - border-radius: 8px; - background: #ef4444; - color: white; - font-size: 10px; - font-weight: 700; - display: flex; - align-items: center; - justify-content: center; - padding: 0 4px; - line-height: 1; -} - -/* ── Dining hall markers — residential (main dhalls) ── */ -.dining-marker-residential { - width: 38px; - height: 38px; - border-radius: 10px; - background: linear-gradient(135deg, #059669, #047857); - border: 2.5px solid white; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.25)); - transition: transform 0.15s ease; - position: relative; -} - -.dining-marker-residential::after { - content: ""; - position: absolute; - bottom: -5px; - left: 50%; - transform: translateX(-50%); - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 5px solid white; -} - -.dining-marker-residential:hover { - transform: scale(1.15); -} - -.dining-marker-residential.dining-marker-selected { - transform: scale(1.15); - box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.4); -} - -/* ── Dining hall markers — retail (cafés) ──────────── */ -.dining-marker-retail { - width: 26px; - height: 26px; - border-radius: 50%; - background: white; - border: 2px solid #059669; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.15)); - transition: transform 0.15s ease; -} - -.dining-marker-retail:hover { - transform: scale(1.15); -} - -.dining-marker-retail.dining-marker-selected { - transform: scale(1.15); - border-color: #047857; - box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.3); + *, *::before, *::after, *::details-content { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } } diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index af8fc69..03914fe 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -74,3 +74,10 @@ export interface FreefoodPost { lat: number; lng: number; } + +export type Selection = + | { kind: "poi"; data: POI } + | { kind: "freefood"; data: FreefoodPost } + | { kind: "club"; data: EatingClub } + | { kind: "dining"; data: DiningHallMenu } + | null; diff --git a/apps/web/src/utils/api.ts b/apps/web/src/utils/api.ts new file mode 100644 index 0000000..77ab587 --- /dev/null +++ b/apps/web/src/utils/api.ts @@ -0,0 +1,15 @@ +export async function getJSON(url: string, init?: RequestInit): Promise { + const response = await fetch(url, init); + if (!response.ok) throw new Error(`${url} returned ${response.status}`); + return response.json(); +} + +// image lists arrive as JSON strings from the mail feeds +export function parseList(json: string | null | undefined): string[] { + try { + const parsed = JSON.parse(json || "[]"); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} diff --git a/apps/web/src/utils/categories.ts b/apps/web/src/utils/categories.ts index 563f820..559b426 100644 --- a/apps/web/src/utils/categories.ts +++ b/apps/web/src/utils/categories.ts @@ -1,67 +1,77 @@ -const CATEGORY_COLORS: Record = { - Building: "#4a7c59", - University: "#4a7c59", - "Academic Department": "#2563eb", - "Administrative Department": "#6366f1", - College: "#b91c1c", - Dormitory: "#9333ea", - Library: "#0891b2", - "Dining Hall": "#ea580c", - Cafe: "#d97706", - Restaurant: "#dc2626", - "Food Court": "#ea580c", - "Convenience Store": "#65a30d", - "Parking Lot": "#3b82f6", - "Bus Stop": "#0ea5e9", - "Train Station": "#0284c7", - "Emergency Phone": "#dc2626", - "Public Art": "#ec4899", - "Events Venue": "#8b5cf6", - Theater: "#a855f7", - Gallery: "#d946ef", - Museum: "#c026d3", - "Sports Center": "#16a34a", - "Fitness Center": "#22c55e", - "Swimming Pool": "#06b6d4", - Park: "#15803d", - Entrance: "#64748b", - Ramp: "#64748b", - Steps: "#94a3b8", - Information: "#0ea5e9", - Shop: "#8b5cf6", -}; +import { + BookOpen, + Building2, + Bus, + Coffee, + Dumbbell, + GraduationCap, + House, + Info, + Landmark, + type LucideIcon, + MapPin, + Palette, + ParkingCircle, + Phone, + Pizza, + Shield, + ShoppingBag, + TrainFront, + Trees, + Users, + Utensils, + Waves, +} from "lucide-react"; -const CATEGORY_ICONS: Record = { - Building: "🏛", - University: "🏛", - "Academic Department": "📚", - "Administrative Department": "🏢", - College: "🛡", - Dormitory: "🏠", - Library: "📖", - "Dining Hall": "🍽", - Cafe: "☕", - Restaurant: "🍴", - "Parking Lot": "P", - "Bus Stop": "🚌", - "Train Station": "🚂", - "Emergency Phone": "📞", - "Public Art": "🎨", - "Events Venue": "🎭", - Theater: "🎭", - Gallery: "🖼", - Museum: "🏛", - "Sports Center": "⚽", - "Fitness Center": "💪", - Park: "🌳", - Entrance: "🚪", - Information: "ℹ", +const CATEGORY_ICONS: Record = { + Building: Building2, + University: Landmark, + "Academic Department": GraduationCap, + "Administrative Department": Building2, + College: Shield, + Dormitory: House, + Library: BookOpen, + "Dining Hall": Utensils, + Cafe: Coffee, + Restaurant: Utensils, + "Parking Lot": ParkingCircle, + "Bus Stop": Bus, + "Train Station": TrainFront, + "Emergency Phone": Phone, + "Public Art": Palette, + Museum: Landmark, + "Events Venue": Users, + Theater: Users, + Athletics: Dumbbell, + "Sports Center": Dumbbell, + "Fitness Center": Dumbbell, + "Swimming Pool": Waves, + Park: Trees, + Shop: ShoppingBag, + Information: Info, + "@dining": Utensils, + "@freefood": Pizza, + "@clubs": Users, }; +export const QUICK_CATEGORIES = [ + { name: "@dining", label: "Dining" }, + { name: "@freefood", label: "Free food" }, + { name: "@clubs", label: "Clubs" }, +]; + +export function getCategoryLabel(category: string): string { + return QUICK_CATEGORIES.find((c) => c.name === category)?.label ?? category; +} + export function getCategoryColor(category: string | null | undefined): string { - return CATEGORY_COLORS[category ?? ""] ?? "#64748b"; + if (["@dining", "Dining Hall", "Cafe"].includes(category ?? "")) return "#47644b"; + if (["@freefood", "Restaurant"].includes(category ?? "")) return "#ad4d20"; + if (["@clubs", "College", "Dormitory"].includes(category ?? "")) return "#796249"; + if (category === "Library") return "#4e6679"; + return "#62695b"; } -export function getCategoryIcon(category: string | null | undefined): string { - return CATEGORY_ICONS[category ?? ""] ?? "•"; +export function getCategoryIcon(category: string | null | undefined): LucideIcon { + return CATEGORY_ICONS[category ?? ""] ?? MapPin; } diff --git a/apps/web/src/utils/mapStyle.ts b/apps/web/src/utils/mapStyle.ts new file mode 100644 index 0000000..a19eacc --- /dev/null +++ b/apps/web/src/utils/mapStyle.ts @@ -0,0 +1,100 @@ +import type { StyleSpecification } from "mapbox-gl"; + +const FILL_COLORS: Record = { + water: "#b9d2d1", + "road-polygon": "#faf7ef", + "detail-polygon": "#d4decb", + "buildings-shadow": "rgba(70, 61, 48, 0.08)", + underpass: "#ddd5c7", +}; + +export function polishMapStyle(style: StyleSpecification): StyleSpecification { + for (const layer of style.layers) { + if (layer.type === "background") layer.paint = { "background-color": "#eae9df" }; + if (layer.type === "fill") { + if (layer.id === "Constuction Fill") { + layer.paint = { "fill-color": "#e2dccb", "fill-opacity": 0.7 }; + } else if (FILL_COLORS[layer.id]) { + layer.paint = { ...layer.paint, "fill-color": FILL_COLORS[layer.id] }; + } else if (layer.id === "landuse copy") { + layer.paint = { + ...layer.paint, + "fill-color": [ + "match", + ["get", "class"], + "grass", + "#dce5d0", + "pitch", + "#d1dec5", + "sand", + "#e8dfcb", + "#e6eadb", + ], + }; + } else if (layer.id === "buildings") { + layer.paint = { + ...layer.paint, + "fill-color": [ + "match", + ["get", "style_rank"], + [1], + "#e5cbb2", + [2], + "#d6cfc3", + [3], + "#e2ddd3", + "rgba(0,0,0,0)", + ], + }; + } + } + if (layer.type === "line") { + const color = + layer.id.includes("buildings") || layer.id.includes("underpass") + ? "#b4aa99" + : layer.id.includes("road") + ? "#f7f3e9" + : "#b9c3ac"; + layer.paint = { ...layer.paint, "line-color": color }; + if (layer.id === "Inclines") layer.paint["line-opacity"] = 0.25; + } + if (layer.type === "symbol") { + if ( + ["public-art", "princeton_line", "bus_stops", "place-label", "quarter-label"].includes( + layer.id, + ) + ) { + layer.layout = { ...layer.layout, visibility: "none" }; + continue; + } + layer.paint = { + ...layer.paint, + "text-color": layer.id === "greenspace_label" ? "#647459" : "#5c6055", + "text-halo-color": "#f5f3e9", + "text-halo-width": 1.2, + }; + if (layer.id === "poi-label") { + layer.layout = { + ...layer.layout, + "icon-image": "", + "text-font": ["Open Sans Regular", "Arial Unicode MS Regular"], + "text-size": ["interpolate", ["linear"], ["zoom"], 14, 9, 16, 11, 19, 15], + "text-transform": "none", + "text-anchor": "center", + "text-justify": "center", + "text-field": ["get", "name"], + "text-padding": 4, + "text-max-width": 8, + }; + } else if (["road-label", "greenspace_label", "waterway-label"].includes(layer.id)) { + layer.layout = { + ...layer.layout, + "text-size": ["interpolate", ["linear"], ["zoom"], 14, 8, 19, 13], + "text-font": ["Open Sans Regular", "Arial Unicode MS Regular"], + "text-letter-spacing": 0.06, + }; + } + } + } + return style; +} diff --git a/apps/web/tests/mapStyle.test.ts b/apps/web/tests/mapStyle.test.ts new file mode 100644 index 0000000..b2eb84a --- /dev/null +++ b/apps/web/tests/mapStyle.test.ts @@ -0,0 +1,43 @@ +import { expect, test } from "bun:test"; +import type { StyleSpecification } from "mapbox-gl"; +import spec from "mapbox-gl/dist/style-spec/index.cjs"; +import { polishMapStyle } from "../src/utils/mapStyle"; + +test("campus restyling stays valid and preserves map data and label visibility rules", () => { + const style: StyleSpecification = { + version: 8, + glyphs: "mapbox://fonts/mapbox/{fontstack}/{range}.pbf", + sources: { campus: { type: "geojson", data: { type: "FeatureCollection", features: [] } } }, + layers: [ + { id: "background", type: "background" }, + { id: "buildings", type: "fill", source: "campus" }, + { id: "road-polygon", type: "fill", source: "campus" }, + { + id: "Constuction Fill", + type: "fill", + source: "campus", + paint: { "fill-pattern": "construction" }, + }, + { + id: "poi-label", + type: "symbol", + source: "campus", + minzoom: 14, + filter: ["==", ["get", "style_rank"], 1], + paint: { "text-opacity": ["step", ["zoom"], 0, 16, 1] }, + layout: { "icon-image": "landmark", "text-field": ["get", "name"] }, + }, + { id: "public-art", type: "symbol", source: "campus" }, + ], + }; + const sources = structuredClone(style.sources); + const filter = structuredClone(style.layers[4].filter); + const result = polishMapStyle(style); + expect(spec.validate(result).map((error: { message: string }) => error.message)).toEqual([]); + expect(result.sources).toEqual(sources); + expect(result.layers[4].filter).toEqual(filter); + expect(result.layers[4].minzoom).toBe(14); + expect(result.layers[4].paint?.["text-opacity"]).toEqual(["step", ["zoom"], 0, 16, 1]); + expect(result.layers[3].paint).not.toHaveProperty("fill-pattern"); + expect(result.layers[5].layout?.visibility).toBe("none"); +}); diff --git a/apps/web/tests/mobileSheets.browser.js b/apps/web/tests/mobileSheets.browser.js new file mode 100644 index 0000000..ed93231 --- /dev/null +++ b/apps/web/tests/mobileSheets.browser.js @@ -0,0 +1,91 @@ +// Run with agent-browser eval --stdin in an open mobile detail or directions sheet +(async () => { + const panel = document.querySelector(".detail-panel"); + const handle = panel?.querySelector(".sheet-handle-button"); + if (!panel || !handle || !matchMedia("(width < 768px)").matches) + throw new Error("Open a sheet at a mobile viewport first"); + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const frame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + const assert = (condition, message) => { + if (!condition) throw new Error(message); + }; + const offset = () => { + const transform = getComputedStyle(panel).transform; + return transform === "none" ? 0 : new DOMMatrixReadOnly(transform).m42; + }; + const expanded = () => handle.getAttribute("aria-expanded") === "true"; + const pointer = (type, y) => + handle.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + pointerId: 12, + pointerType: "touch", + isPrimary: true, + button: 0, + clientY: y, + }), + ); + // Synthetic pointers cannot acquire native capture + const capture = ["setPointerCapture", "hasPointerCapture", "releasePointerCapture"]; + const original = capture.map((name) => Element.prototype[name]); + for (const name of capture) Element.prototype[name] = () => false; + const swipe = async (delta, cancel = false) => { + pointer("pointerdown", 400); + pointer("pointermove", 400 + delta); + await frame(); + assert( + Math.abs(offset() - (delta > 0 ? delta : delta * 0.2)) < 2, + "Sheet must follow the finger", + ); + pointer(cancel ? "pointercancel" : "pointerup", 400 + delta); + handle.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 1 })); + await frame(); + }; + try { + await wait(400); + if (expanded()) { + handle.click(); + await wait(320); + } + const collapsedHeight = panel.getBoundingClientRect().height; + await swipe(-80, true); + assert(!expanded(), "Cancelled gesture must preserve expansion"); + await wait(320); + assert(Math.abs(offset()) < 1, "Cancelled gesture must return to rest"); + await swipe(-80); + assert(expanded(), "Swipe up must expand without a duplicate click"); + await wait(320); + assert( + panel.getBoundingClientRect().height >= collapsedHeight - 1, + "Expansion must not shrink the sheet", + ); + if (panel.matches(".directions-sheet") && panel.querySelector(".steps-wrap")) + assert( + panel.querySelector(".steps-wrap.steps-open"), + "Directions expansion must reveal route steps", + ); + await swipe(80); + assert( + !expanded() && panel.isConnected, + "Short downward swipe must collapse an expanded sheet", + ); + await wait(320); + handle.click(); + await frame(); + assert(expanded(), "Handle must support keyboard activation"); + handle.click(); + await wait(320); + await swipe(24); + assert(!expanded() && panel.isConnected, "Short drag must not dismiss or toggle the sheet"); + await wait(320); + assert(Math.abs(offset()) < 1, "Short drag must snap back without replaying entrance"); + await swipe(80); + await wait(280); + assert(!panel.isConnected, "Downward swipe must dismiss a collapsed sheet"); + return { passed: true, directions: panel.matches(".directions-sheet") }; + } finally { + capture.forEach((name, index) => { + Element.prototype[name] = original[index]; + }); + } +})(); diff --git a/apps/web/tests/panelResize.browser.js b/apps/web/tests/panelResize.browser.js new file mode 100644 index 0000000..b223430 --- /dev/null +++ b/apps/web/tests/panelResize.browser.js @@ -0,0 +1,24 @@ +// Run with agent-browser eval --stdin after opening a place detail panel +(async () => { + const panel = document.querySelector(".detail-panel"); + const content = panel?.querySelector(".detail-content"); + if (!panel || !content) throw new Error("Open a place detail panel first"); + const originalMaxHeight = content.style.maxHeight; + const before = panel.getBoundingClientRect().height; + const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; + try { + content.style.maxHeight = "24px"; + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + const resize = panel + .getAnimations() + .find((animation) => animation.effect.getKeyframes().some((frame) => frame.height)); + if (reduced && resize) throw new Error("Panel resize ignores reduced motion"); + if (!reduced && !resize) throw new Error("Panel content resized without a transition"); + if (resize) await resize.finished; + const after = panel.getBoundingClientRect().height; + if (after >= before) throw new Error("Panel did not adapt to the shorter content"); + return { passed: true, reducedMotion: reduced, before, after }; + } finally { + content.style.maxHeight = originalMaxHeight; + } +})(); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 3d71ef6..a5924c1 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -7,7 +7,10 @@ export default defineConfig({ server: { port: 5173, proxy: { - "/api": "http://localhost:3001", + "/api": { + target: process.env.TIGERMAP_API_PROXY || "http://localhost:3001", + changeOrigin: true, + }, }, }, });