Files
personal-website/src/components/CoffeeMirror.tsx
T

217 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import Image from "next/image";
import type { Map as LeafletMap, Marker, TileLayer } from "leaflet";
import {
ChevronLeft, Coffee, Heart, Layers, LocateFixed, MapPin,
Minus, PanelLeft, Plus, Search, X,
} from "lucide-react";
import { COFFEE_SHOPS, type CoffeeShop } from "~/lib/coffee-shops";
import "leaflet/dist/leaflet.css";
type MapStyle = "street" | "satellite";
const images = [
"amami-kitchen", "culture-coffee", "7th-st-cafe", "tastecraft-cafe",
"paris-bakery-caf", "cycleup-coffee", "cornerstone-kitchen", "gram-s-eatery",
"starbucks-coffee", "barnes-noble-caf", "starbucks-giant", "dunkin",
"alee-s-cafe", "all-star-bagels", "panera-bread", "street-of-shops",
];
function shopImage(shop: CoffeeShop) {
return `/coffee/shops/${images[shop.id - 1]}.jpg`;
}
const tiles: Record<MapStyle, { url: string; attribution: string }> = {
street: {
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
satellite: {
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
attribution: "Tiles &copy; Esri",
},
};
export function CoffeeMirror() {
const [selected, setSelected] = useState<CoffeeShop | null>(null);
const [drawerOpen, setDrawerOpen] = useState(true);
const [query, setQuery] = useState("");
const [favorites, setFavorites] = useState<number[]>([]);
const [style, setStyle] = useState<MapStyle>("street");
const [styleOpen, setStyleOpen] = useState(false);
const [welcomeOpen, setWelcomeOpen] = useState(false);
const [mapFailed, setMapFailed] = useState(false);
const [locationError, setLocationError] = useState("");
const mapElement = useRef<HTMLDivElement>(null);
const map = useRef<LeafletMap | null>(null);
const tileLayer = useRef<TileLayer | null>(null);
const markers = useRef(new Map<number, Marker>());
useEffect(() => {
const timeout = window.setTimeout(() => {
setDrawerOpen(window.innerWidth >= 640);
try {
const saved = JSON.parse(localStorage.getItem("lewisburg-coffee-favorites") ?? "[]") as unknown;
if (Array.isArray(saved)) setFavorites(saved.filter((id): id is number => typeof id === "number"));
setWelcomeOpen(!localStorage.getItem("hasSeenWelcome"));
} catch {
setFavorites([]);
}
}, 0);
return () => window.clearTimeout(timeout);
}, []);
useEffect(() => {
let cancelled = false;
let resizeObserver: ResizeObserver | undefined;
const savedMarkers = markers.current;
import("leaflet").then((L) => {
if (cancelled || !mapElement.current) return;
const instance = L.map(mapElement.current, {
center: [40.9645, -76.8845], zoom: 15, zoomControl: false,
});
map.current = instance;
tileLayer.current = L.tileLayer(tiles.street.url, {
attribution: tiles.street.attribution, maxZoom: 19,
}).addTo(instance);
COFFEE_SHOPS.forEach((shop) => {
const marker = L.marker([shop.lat, shop.lng], {
title: shop.name,
icon: L.divIcon({ className: "coffee-mirror-pin", html: "<span></span>", iconSize: [18, 18], iconAnchor: [9, 9] }),
}).addTo(instance);
marker.bindTooltip(shop.name);
marker.on("click", () => {
setSelected(shop);
setDrawerOpen(true);
});
markers.current.set(shop.id, marker);
});
resizeObserver = new ResizeObserver(() => instance.invalidateSize());
resizeObserver.observe(mapElement.current);
requestAnimationFrame(() => instance.invalidateSize());
}).catch(() => { if (!cancelled) setMapFailed(true); });
return () => {
cancelled = true;
resizeObserver?.disconnect();
map.current?.remove();
map.current = null;
tileLayer.current = null;
savedMarkers.clear();
};
}, []);
useEffect(() => {
if (!map.current || !tileLayer.current) return;
const L = tileLayer.current;
L.setUrl(tiles[style].url);
L.options.attribution = tiles[style].attribution;
}, [style]);
useEffect(() => {
if (!selected || !map.current) return;
const instance = map.current;
const target = instance.project([selected.lat, selected.lng], 16);
const offset = window.innerWidth >= 640 && drawerOpen ? 200 : 0;
instance.flyTo(instance.unproject(target.subtract([offset, 0]), 16), 16, { duration: 0.8 });
markers.current.forEach((marker, id) => marker.getElement()?.classList.toggle("is-selected", id === selected.id));
}, [selected, drawerOpen]);
const filtered = useMemo(() => COFFEE_SHOPS.filter((shop) =>
`${shop.name} ${shop.address}`.toLowerCase().includes(query.toLowerCase().trim()),
), [query]);
const favoriteShops = filtered.filter((shop) => favorites.includes(shop.id));
const otherShops = filtered.filter((shop) => !favorites.includes(shop.id));
function toggleFavorite(id: number) {
setFavorites((current) => {
const next = current.includes(id) ? current.filter((item) => item !== id) : [...current, id];
localStorage.setItem("lewisburg-coffee-favorites", JSON.stringify(next));
return next;
});
}
function closeWelcome() {
setWelcomeOpen(false);
localStorage.setItem("hasSeenWelcome", "true");
}
function locate() {
setLocationError("");
if (!navigator.geolocation) { setLocationError("Location is unavailable in this browser."); return; }
navigator.geolocation.getCurrentPosition(
({ coords }) => map.current?.flyTo([coords.latitude, coords.longitude], 16),
() => setLocationError("Couldnt get your location."),
);
}
return (
<div className={`coffee-mirror ${style === "satellite" ? "is-satellite" : ""}`}>
<div ref={mapElement} className="coffee-mirror-map" aria-label="Interactive map of Lewisburg coffee shops" />
{mapFailed && <p className="coffee-mirror-map-error">The map could not load. You can still browse the coffee shops.</p>}
<header className="coffee-mirror-header">
<button className={`coffee-mirror-icon-button ${drawerOpen ? "is-active" : ""}`} type="button" onClick={() => setDrawerOpen((open) => !open)} aria-label="Toggle coffee shops" aria-expanded={drawerOpen}><PanelLeft size={21} /></button>
<button className="coffee-mirror-brand" type="button" onClick={() => setWelcomeOpen(true)}>
<Coffee size={25} />
<span><strong>Lewisburg Coffee Map</strong><small>Find your perfect brew</small></span>
</button>
<span className="coffee-mirror-header-spacer" />
</header>
<aside className={`coffee-mirror-drawer ${drawerOpen || selected ? "is-open" : ""}`} aria-label="Discover coffee shops">
{selected ? (
<div className="coffee-mirror-detail">
<div className="coffee-mirror-photo"><Image src={shopImage(selected)} alt={selected.name} fill sizes="(max-width: 640px) 100vw, 400px" /></div>
<div className="coffee-mirror-detail-actions">
<button type="button" onClick={() => toggleFavorite(selected.id)} aria-label={favorites.includes(selected.id) ? "Remove favorite" : "Add favorite"}><Heart size={18} fill={favorites.includes(selected.id) ? "currentColor" : "none"} /></button>
<button type="button" onClick={() => setSelected(null)} aria-label="Back to coffee shops"><ChevronLeft size={20} /></button>
<button type="button" onClick={() => { setSelected(null); setDrawerOpen(false); }} aria-label="Close panel"><X size={19} /></button>
</div>
<div className="coffee-mirror-detail-copy">
<h2>{selected.name}</h2>
<p className="coffee-mirror-address"><MapPin size={17} /> {selected.address}</p>
<p>{selected.description}</p>
<a className="coffee-mirror-directions" href={`https://www.google.com/maps/dir/?api=1&destination=${selected.lat},${selected.lng}`} target="_blank" rel="noreferrer">Get Directions </a>
<div className="coffee-mirror-detail-links"><a href={selected.website} target="_blank" rel="noreferrer">Website </a><a href={`tel:${selected.phone.replace(/[^+\d]/g, "")}`}>Call</a></div>
</div>
</div>
) : (
<div className="coffee-mirror-discovery">
<div className="coffee-mirror-discovery-top">
<h2><Coffee size={23} /> Coffee Shops</h2>
<label className="coffee-mirror-search"><Search size={17} /><input type="search" placeholder="Search coffee shops..." value={query} onChange={(event) => setQuery(event.target.value)} aria-label="Search coffee shops" /></label>
</div>
<div className="coffee-mirror-list">
{favoriteShops.length > 0 && <><p className="coffee-mirror-list-label"> Favorites</p>{favoriteShops.map((shop) => <ShopRow key={shop.id} shop={shop} favorite onSelect={setSelected} onFavorite={toggleFavorite} />)}<hr /></>}
{favoriteShops.length > 0 && <p className="coffee-mirror-list-label">All Shops</p>}
{otherShops.map((shop) => <ShopRow key={shop.id} shop={shop} favorite={false} onSelect={setSelected} onFavorite={toggleFavorite} />)}
{filtered.length === 0 && <p className="coffee-mirror-empty">No shops found matching {query}</p>}
</div>
</div>
)}
</aside>
<div className="coffee-mirror-controls">
{locationError && <span className="coffee-mirror-location-error" role="status">{locationError}</span>}
<button type="button" onClick={locate} aria-label="Find my location"><LocateFixed size={20} /></button>
<button type="button" onClick={() => map.current?.zoomIn()} aria-label="Zoom in"><Plus size={20} /></button>
<button type="button" onClick={() => map.current?.zoomOut()} aria-label="Zoom out"><Minus size={20} /></button>
<div className="coffee-mirror-style-wrap">
<button type="button" onClick={() => setStyleOpen((open) => !open)} aria-label="Change map style" aria-expanded={styleOpen}><Layers size={20} /></button>
{styleOpen && <div className="coffee-mirror-style-menu">{(["street", "satellite"] as const).map((option) => <button key={option} type="button" aria-pressed={style === option} onClick={() => { setStyle(option); setStyleOpen(false); }}>{option.charAt(0).toUpperCase() + option.slice(1)}</button>)}</div>}
</div>
</div>
<footer className="coffee-mirror-footer"><span>© {new Date().getFullYear()} Sean OConnor</span><span>Map data © OpenStreetMap contributors</span></footer>
{welcomeOpen && <div className="coffee-mirror-modal-backdrop" onClick={closeWelcome}><div className="coffee-mirror-modal" role="dialog" aria-modal="true" aria-labelledby="coffee-welcome-title" onClick={(event) => event.stopPropagation()}><button className="coffee-mirror-modal-close" type="button" onClick={closeWelcome} aria-label="Close welcome"><X size={20} /></button><Coffee size={34} /><h2 id="coffee-welcome-title">Welcome to the Lewisburg Coffee Map</h2><p>Discover the best coffee spots in Lewisburg, PA.</p><small>Created by Sean OConnor</small><ul><li>Explore the map and select a coffee shop.</li><li>Search the list to find your next cup.</li><li>Use the location button to see whats nearby.</li></ul><button className="coffee-mirror-start" type="button" onClick={closeWelcome}>Start Exploring</button></div></div>}
</div>
);
}
function ShopRow({ shop, favorite, onSelect, onFavorite }: { shop: CoffeeShop; favorite: boolean; onSelect: (shop: CoffeeShop) => void; onFavorite: (id: number) => void }) {
return <div className="coffee-mirror-row"><button type="button" className="coffee-mirror-row-main" onClick={() => onSelect(shop)}><Image src={shopImage(shop)} alt="" width={63} height={63} /><span><strong>{shop.name}</strong><small>{shop.address}</small></span></button><button type="button" className="coffee-mirror-favorite" onClick={() => onFavorite(shop.id)} aria-label={favorite ? `Remove ${shop.name} from favorites` : `Add ${shop.name} to favorites`}><Heart size={17} fill={favorite ? "currentColor" : "none"} /></button></div>;
}