Compare commits

...

2 Commits

5 changed files with 94 additions and 45 deletions

View File

@@ -32,7 +32,7 @@ const WORLD_BOUNDS: LatLngTuple[] = [
const CURRENT_LOCATION_ICON = L.divIcon({ const CURRENT_LOCATION_ICON = L.divIcon({
className: 'brewery-map-current-location', className: 'brewery-map-current-location',
html: '<span class="block h-3.5 w-3.5 rounded-full bg-blue-500 border-2 border-white shadow-md"></span>', html: '<span class="block h-3.5 w-3.5 rounded-full bg-primary border-2 border-base-100 shadow-md"></span>',
iconSize: [14, 14], iconSize: [14, 14],
iconAnchor: [7, 7], iconAnchor: [7, 7],
}); });

View File

@@ -10,10 +10,10 @@ import type { NearbyMapPin } from './NearbyBreweryMap';
const NearbyBreweryMap = lazy(() => import('./NearbyBreweryMap')); const NearbyBreweryMap = lazy(() => import('./NearbyBreweryMap'));
const MAP_PLACEHOLDER_CLASS = 'min-h-[30rem] h-full w-full rounded-box bg-base-300'; const MAP_PLACEHOLDER_CLASS = 'skeleton min-h-[30rem] h-full w-full rounded-box';
const DEFAULT_RADIUS_KM = 120; const DEFAULT_RADIUS_KM = 120;
const MIN_RADIUS_KM = 10; const MIN_RADIUS_KM = 10;
const MAX_RADIUS_KM = 150; const MAX_RADIUS_KM = 300;
const RADIUS_STEP_KM = 10; const RADIUS_STEP_KM = 10;
const RADIUS_DEBOUNCE_MS = 300; const RADIUS_DEBOUNCE_MS = 300;
@@ -41,8 +41,8 @@ interface NearbyBreweriesSectionProps {
const FILLER_NOTE = 'A stop worth the detour.'; const FILLER_NOTE = 'A stop worth the detour.';
const cardClassName = (selected: boolean) => const cardClassName = (selected: boolean) =>
`text-left rounded-box bg-base-100 shadow p-4 cursor-pointer transition-shadow hover:shadow-lg border-l-4 ${ `card card-sm card-border bg-base-100 text-left shadow transition-shadow hover:shadow-lg ${
selected ? 'border-l-primary' : 'border-l-transparent' selected ? 'border-2 border-primary' : ''
}`; }`;
interface BreweriesNearByHeaderParams { interface BreweriesNearByHeaderParams {
@@ -288,21 +288,23 @@ const NearbyBreweriesSection = ({
onClick={() => onSelect(brewery.breweryPostId)} onClick={() => onSelect(brewery.breweryPostId)}
className={cardClassName(selectedId === brewery.breweryPostId)} className={cardClassName(selectedId === brewery.breweryPostId)}
> >
<div className="flex justify-between items-baseline gap-3"> <div className="card-body">
<h3 className="font-serif text-lg m-0"> <div className="flex justify-between items-baseline gap-3">
{brewery.breweryName} <h3 className="card-title font-serif text-lg">
</h3> {brewery.breweryName}
<span className="text-sm font-semibold text-primary whitespace-nowrap"> </h3>
{formatDistance(distanceMetres, unit)} <span className="text-sm font-semibold text-primary whitespace-nowrap">
</span> {formatDistance(distanceMetres, unit)}
</div> </span>
{brewery.location && (
<div className="text-sm text-base-content/60 mt-1">
{brewery.location.cityName},{' '}
{brewery.location.stateProvinceCode}
</div> </div>
)} {brewery.location && (
<div className="text-sm mt-2">{FILLER_NOTE}</div> <div className="text-sm text-base-content/60">
{brewery.location.cityName},{' '}
{brewery.location.stateProvinceCode}
</div>
)}
<div className="text-sm mt-2">{FILLER_NOTE}</div>
</div>
</button> </button>
)} )}
/> />

View File

@@ -3,6 +3,9 @@ import { MapContainer, TileLayer, Marker, Popup, CircleMarker, useMap } from 're
import L, { type LatLngTuple } from 'leaflet'; import L, { type LatLngTuple } from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
const DEFAULT_ZOOM = 11;
const MIN_ZOOM = 8;
export interface NearbyMapPin { export interface NearbyMapPin {
id: string; id: string;
name: string; name: string;
@@ -19,6 +22,14 @@ interface NearbyBreweryMapProps {
userLocationLabel: string; userLocationLabel: string;
} }
// Leaflet paints markers as SVG attributes, which can't reference CSS custom
// properties the way class names can - resolve the current theme's colors instead.
const resolveThemeColor = (variable: string, fallback: string) => {
if (typeof document === 'undefined') return fallback;
const value = getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
return value || fallback;
};
const pinIcon = (active: boolean) => const pinIcon = (active: boolean) =>
L.divIcon({ L.divIcon({
className: '', className: '',
@@ -43,10 +54,10 @@ const FitAndFocus = ({
useEffect(() => { useEffect(() => {
if (didInitialFit.current) return; if (didInitialFit.current) return;
const points: LatLngTuple[] = pins.map((pin) => [pin.latitude, pin.longitude]); const focus: LatLngTuple | null =
if (userLocation) points.push(userLocation); userLocation ?? (pins[0] ? [pins[0].latitude, pins[0].longitude] : null);
if (points.length > 0) { if (focus) {
map.fitBounds(points, { padding: [40, 40], animate: false }); map.setView(focus, DEFAULT_ZOOM, { animate: false });
didInitialFit.current = true; didInitialFit.current = true;
} }
const timeout = setTimeout(() => map.invalidateSize(), 200); const timeout = setTimeout(() => map.invalidateSize(), 200);
@@ -63,6 +74,33 @@ const FitAndFocus = ({
return null; return null;
}; };
const RecenterButton = ({ userLocation }: { userLocation: LatLngTuple | null }) => {
const map = useMap();
if (!userLocation) return null;
return (
<button
type="button"
onClick={() => map.setView(userLocation, DEFAULT_ZOOM, { animate: true })}
className="btn btn-circle btn-sm bg-base-100 absolute top-2.5 right-2.5 z-[1000] shadow"
aria-label="Recenter on your location"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
>
<circle cx="12" cy="12" r="3" />
<path d="M12 2v3M12 19v3M2 12h3M19 12h3" />
</svg>
</button>
);
};
const NearbyBreweryMap = ({ const NearbyBreweryMap = ({
pins, pins,
selectedId, selectedId,
@@ -72,7 +110,8 @@ const NearbyBreweryMap = ({
}: NearbyBreweryMapProps) => ( }: NearbyBreweryMapProps) => (
<MapContainer <MapContainer
center={userLocation ?? [pins[0]?.latitude ?? 20, pins[0]?.longitude ?? 0]} center={userLocation ?? [pins[0]?.latitude ?? 20, pins[0]?.longitude ?? 0]}
zoom={11} zoom={DEFAULT_ZOOM}
minZoom={MIN_ZOOM}
scrollWheelZoom={false} scrollWheelZoom={false}
className="min-h-[30rem] h-full w-full rounded-box relative z-0" className="min-h-[30rem] h-full w-full rounded-box relative z-0"
> >
@@ -81,11 +120,17 @@ const NearbyBreweryMap = ({
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/> />
<FitAndFocus pins={pins} userLocation={userLocation} selectedId={selectedId} /> <FitAndFocus pins={pins} userLocation={userLocation} selectedId={selectedId} />
<RecenterButton userLocation={userLocation} />
{userLocation && ( {userLocation && (
<CircleMarker <CircleMarker
center={userLocation} center={userLocation}
radius={6} radius={6}
pathOptions={{ color: '#fff', weight: 2, fillColor: '#2b6cb0', fillOpacity: 1 }} pathOptions={{
color: resolveThemeColor('--color-base-100', '#fff'),
weight: 2,
fillColor: resolveThemeColor('--color-primary', '#2b6cb0'),
fillOpacity: 1,
}}
> >
<Popup>You are here &mdash; {userLocationLabel}</Popup> <Popup>You are here &mdash; {userLocationLabel}</Popup>
</CircleMarker> </CircleMarker>

View File

@@ -13,25 +13,27 @@ const RecentBreweries = ({ breweries }: RecentBreweriesProps) => {
<h2 className="font-serif text-2xl mb-3.5">Recently added</h2> <h2 className="font-serif text-2xl mb-3.5">Recently added</h2>
<div className="grid gap-5 grid-cols-[repeat(auto-fit,minmax(15rem,1fr))]"> <div className="grid gap-5 grid-cols-[repeat(auto-fit,minmax(15rem,1fr))]">
{breweries.map((brewery) => ( {breweries.map((brewery) => (
<div <div key={brewery.breweryPostId} className="card bg-base-100 shadow">
key={brewery.breweryPostId} <div className="card-body gap-1.5">
className="card bg-base-100 shadow p-6 flex flex-col gap-1.5" <h3 className="card-title font-serif text-xl">
> {brewery.breweryName}
<h3 className="font-serif text-xl m-0">{brewery.breweryName}</h3> </h3>
{brewery.location && ( {brewery.location && (
<div className="text-sm text-base-content/60"> <div className="text-sm text-base-content/60">
{brewery.location.cityName}, {brewery.location.stateProvinceCode} {brewery.location.cityName},{' '}
</div> {brewery.location.stateProvinceCode}
)} </div>
<p className="text-sm leading-snug mt-1 line-clamp-2"> )}
{brewery.description} <p className="text-sm leading-snug mt-1 line-clamp-2">
</p> {brewery.description}
<Link </p>
to={`/breweries/${brewery.breweryPostId}`} <Link
className="link link-hover text-sm font-semibold text-secondary hover:text-primary mt-2" to={`/breweries/${brewery.breweryPostId}`}
> className="link link-hover text-sm font-semibold text-secondary hover:text-primary mt-2"
View brewery &rarr; >
</Link> View brewery &rarr;
</Link>
</div>
</div> </div>
))} ))}
</div> </div>

View File

@@ -9,7 +9,7 @@ import { useFetcher } from 'react-router';
import type { BreweryMapPin } from '../components/BreweryMap'; import type { BreweryMapPin } from '../components/BreweryMap';
const PAGE_SIZE = 12; const PAGE_SIZE = 12;
const MAP_PLACEHOLDER_CLASS = 'h-[28rem] md:h-[34rem] w-full rounded-box bg-base-300'; const MAP_PLACEHOLDER_CLASS = 'skeleton h-[28rem] md:h-[34rem] w-full rounded-box';
export const meta = ({}: Route.MetaArgs) => { export const meta = ({}: Route.MetaArgs) => {
return [{ title: 'Brewery Directory | The Biergarten App' }]; return [{ title: 'Brewery Directory | The Biergarten App' }];