Refine portfolio experience and integrate coffee map
This commit is contained in:
@@ -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: '© <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 © 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("Couldn’t 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 O’Connor</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 O’Connor</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 what’s 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user