Refine portfolio experience and integrate coffee map

This commit is contained in:
2026-09-22 16:41:12 -04:00
parent b72108e103
commit 844819731d
52 changed files with 4589 additions and 1582 deletions
+216
View File
@@ -0,0 +1,216 @@
"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>;
}