Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions app/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

/**
* AuthProvider.tsx
*
* A tiny client component whose only job is to call handleIncomingRedirect()
* once when the app first loads in the browser.
*
* WHY a separate component?
* Next.js App Router layouts are Server Components by default.
* Server Components cannot use useEffect or browser APIs.
* By extracting this into a "use client" component we keep layout.tsx
* as a Server Component while still running the OIDC redirect logic.
*
* handleIncomingRedirect() checks whether the URL contains an auth code
* from the identity provider and, if so, completes the login handshake.
* With restorePreviousSession: true it also silently re-logs in on refresh.
*/

import { useEffect } from "react";
import { handleIncomingRedirect } from "@inrupt/solid-client-authn-browser";

export function AuthProvider() {
useEffect(() => {
handleIncomingRedirect({ restorePreviousSession: true });
}, []);

// Renders nothing — purely a side-effect component
return null;
}
60 changes: 60 additions & 0 deletions app/components/LocationCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"use client";

/**
* LocationCard.tsx
*
* A simple card displaying one saved location.
* Shows a MapPin icon, the label, lat/lon, radius, and a remove button.
*
* Intentionally minimal — no dropdown menus, no edit mode.
* The volunteering demo has a fuller version if you want to see it extended.
*/

import { MapPinIcon, TrashIcon } from "@heroicons/react/24/outline";
import type { LocationData } from "@/app/lib/helpers/locations";

type Props = {
location: LocationData;
/** Called when the user clicks the card — e.g. to fly the map to this location. */
onClick?: () => void;
/** Called when the user clicks the remove button. */
onRemove?: () => void;
/** Highlights the card when it is the currently selected location. */
isActive?: boolean;
};

export function LocationCard({ location, onClick, onRemove, isActive = false }: Props) {
return (
<div
onClick={onClick}
className={`flex cursor-pointer items-center gap-3 rounded-lg border p-3 transition
${isActive
? "border-indigo-400 bg-indigo-50"
: "border-gray-200 bg-white hover:border-gray-300"
}`}
>
{/* Pin icon — filled when active */}
<MapPinIcon className={`h-5 w-5 shrink-0 ${isActive ? "text-indigo-500" : "text-gray-400"}`} />

{/* Location details */}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-gray-900">{location.label}</p>
<p className="text-xs text-gray-500">
{location.lat.toFixed(4)}, {location.lon.toFixed(4)} · {location.radiusKm} km radius
</p>
</div>

{/* Remove button — stopPropagation so it doesn't also trigger onClick */}
{onRemove && (
<button
type="button"
aria-label={`Remove ${location.label}`}
onClick={(e) => { e.stopPropagation(); onRemove(); }}
className="shrink-0 rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-500"
>
<TrashIcon className="h-4 w-4" />
</button>
)}
</div>
);
}
129 changes: 129 additions & 0 deletions app/components/LocationEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
"use client";

/**
* LocationEditor.tsx
*
* The authenticated editor — will show:
* - A map (click to pick a location)
* - A label input + radius slider
* - An "Add location" button
* - A list of saved LocationCards with remove buttons
*
* The full UI is commented out below so we can build it live during the tutorial.
* The hook and state are already wired up — we just need to write the JSX.
*/

import { useState } from "react";
import { useLocations } from "@/app/lib/hooks/useLocations";

// ── Imports we'll uncomment during the tutorial ──────────────────────────────
// import { Map } from "@/app/components/Map";
// import { LocationCard } from "@/app/components/LocationCard";

type Props = {
/** Authenticated fetch from useAuth — required for writing to the pod. */
authFetch: typeof fetch;
};

export function LocationEditor({ authFetch }: Props) {
const { locations, isLoading, error, addLocation, removeLocation } = useLocations(authFetch);

// The lat/lon the user last clicked on the map
const [picked, setPicked] = useState<{ lat: number; lon: number } | null>(null);
// Which card is currently highlighted (flies the map to that location)
const [activeId, setActiveId] = useState<string | null>(null);
const [label, setLabel] = useState("");
const [radiusKm, setRadiusKm] = useState(10);

// The map center: if a card is active use that location, otherwise use the picked point
const activeLocation = locations.find((l) => l.id === activeId);
const mapCenter = activeLocation
? ([activeLocation.lat, activeLocation.lon] as [number, number])
: picked
? ([picked.lat, picked.lon] as [number, number])
: null;

async function handleAdd() {
if (!picked) return;
await addLocation({ label: label || "Unnamed", lat: picked.lat, lon: picked.lon, radiusKm });
// Reset the form after adding
setPicked(null);
setLabel("");
setRadiusKm(10);
}

// ── Placeholder — replace this return during the tutorial ───────────────────
return (
<div className="flex flex-col gap-4">
<div className="flex h-64 w-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50">
<p className="text-sm text-gray-400">[ UI goes here — we will build this during the tutorial ]</p>
</div>
</div>
);

// ── Full UI — uncomment this return during the tutorial ──────────────────────
/*
return (
<div className="flex flex-col gap-4">

<div className="h-64 w-full rounded-lg overflow-hidden border border-gray-200">
<Map
markers={locations.map((l) => ({ lat: l.lat, lon: l.lon, label: l.label }))}
center={mapCenter}
radiusKm={picked ? radiusKm : null}
onMapClick={(lat, lon) => { setPicked({ lat, lon }); setActiveId(null); }}
hint="Click the map to pick a location"
/>
</div>

{picked && (
<div className="flex flex-col gap-2 rounded-lg border border-indigo-200 bg-indigo-50 p-3">
<p className="text-sm font-medium text-indigo-700">
Selected: {picked.lat.toFixed(4)}, {picked.lon.toFixed(4)}
</p>
<input
type="text"
placeholder="Location label"
value={label}
onChange={(e) => setLabel(e.target.value)}
className="rounded border border-gray-300 px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
/>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600 w-24">Radius: {radiusKm} km</label>
<input
type="range" min={1} max={100} value={radiusKm}
onChange={(e) => setRadiusKm(Number(e.target.value))}
className="flex-1"
/>
</div>
<button
type="button"
onClick={handleAdd}
className="rounded bg-indigo-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-indigo-700"
>
Add location
</button>
</div>
)}

{isLoading && <p className="text-sm text-gray-400">Loading…</p>}
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex flex-col gap-2">
{locations.map((loc) => (
<LocationCard
key={loc.id}
location={loc}
isActive={activeId === loc.id}
onClick={() => setActiveId(activeId === loc.id ? null : loc.id)}
onRemove={() => removeLocation(loc.id)}
/>
))}
{!isLoading && locations.length === 0 && (
<p className="text-sm text-gray-400">No locations saved yet. Click the map to add one.</p>
)}
</div>

</div>
);
*/
}
135 changes: 135 additions & 0 deletions app/components/LocationMapView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"use client";

/**
* LocationMapView.tsx
*
* The actual Leaflet map — rendered client-side only (see Map.tsx for why).
*
* Key Leaflet + react-leaflet concepts used here:
* MapContainer — mounts the Leaflet map into the DOM
* TileLayer — loads the OpenStreetMap tile images
* Marker + Popup — a pin with an optional label tooltip
* Circle — a filled radius circle around the center point
* useMap() — gives access to the Leaflet map instance inside a component
* useMapEvents() — subscribes to Leaflet map events (e.g. click)
*
* leaflet-defaulticon-compatibility fixes broken marker icons in Next.js
* (webpack rewrites asset paths, which breaks Leaflet's default icon detection).
*/

import { useEffect } from "react";
import "leaflet/dist/leaflet.css";
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
import "leaflet-defaulticon-compatibility";

import {
MapContainer,
TileLayer,
Marker,
Popup,
Circle,
useMap,
useMapEvents,
} from "react-leaflet";

export type MapMarker = { lat: number; lon: number; label?: string };

export type LocationMapViewProps = {
className?: string;
/** Markers to show on the map. */
markers?: MapMarker[];
/** When set, map smoothly flies to this center. */
center?: [number, number] | null;
/** Radius in km drawn around center. */
radiusKm?: number | null;
/** Called when the user clicks the map — used to add a location. */
onMapClick?: (lat: number, lon: number) => void;
/** Small hint overlay text, e.g. "Click to add a location". */
hint?: string;
};

/** Listens for click events on the map and calls onMapClick. */
function MapClickHandler({ onMapClick }: { onMapClick?: (lat: number, lon: number) => void }) {
useMapEvents({
click(e) {
onMapClick?.(e.latlng.lat, e.latlng.lng);
},
});
return null;
}

/** Watches the `center` prop and smoothly flies the map there when it changes. */
function MapCenterUpdater({ center }: { center: [number, number] | null | undefined }) {
const map = useMap();
useEffect(() => {
if (center != null) map.flyTo(center, 12, { duration: 0.5 });
}, [map, center]);
return null;
}

/** +/− zoom buttons — replaces Leaflet's default control for consistent styling. */
function ZoomControl() {
const map = useMap();
return (
<div className="absolute left-2 top-2 z-[1000] flex flex-col gap-0.5 rounded border border-gray-200 bg-white p-0.5 shadow-sm">
<button type="button" aria-label="Zoom in"
className="flex h-8 w-8 items-center justify-center rounded text-lg hover:bg-gray-100"
onClick={() => map.zoomIn()}>+</button>
<button type="button" aria-label="Zoom out"
className="flex h-8 w-8 items-center justify-center rounded text-lg hover:bg-gray-100"
onClick={() => map.zoomOut()}>−</button>
</div>
);
}

export function LocationMapView({
className = "",
markers = [],
center,
radiusKm,
onMapClick,
hint,
}: LocationMapViewProps) {
return (
<div className={`relative h-full w-full overflow-hidden ${className}`.trim()}>
<MapContainer
center={[51.505, -0.09]} // default: London
zoom={6}
className="h-full w-full rounded-md"
style={{ minHeight: 300 }}
zoomControl={false} // we render our own ZoomControl below
scrollWheelZoom
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<ZoomControl />
<MapCenterUpdater center={center} />
<MapClickHandler onMapClick={onMapClick} />

{/* Render a pin + popup for each saved location */}
{markers.map((m, i) => (
<Marker key={i} position={[m.lat, m.lon]}>
{m.label && <Popup>{m.label}</Popup>}
</Marker>
))}

{/* Draw a radius circle when a center point is active */}
{center != null && radiusKm != null && radiusKm > 0 && (
<Circle
center={center}
radius={radiusKm * 1000} // Leaflet uses metres
pathOptions={{ color: "#6366f1", fillOpacity: 0.1 }}
/>
)}
</MapContainer>

{hint && (
<p className="absolute right-2 top-2 z-[1000] rounded bg-white px-2 py-1 text-xs text-gray-500 shadow">
{hint}
</p>
)}
</div>
);
}
41 changes: 41 additions & 0 deletions app/components/Map.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
"use client";

/**
* Map.tsx
*
* A Next.js dynamic import wrapper around LocationMapView.
*
* WHY this file exists:
* Leaflet accesses `window` and `document` on import — those don't exist
* during Next.js server-side rendering (SSR), which would crash the build.
*
* `dynamic(..., { ssr: false })` tells Next.js to skip this component
* during SSR and only load it in the browser.
*
* This is the standard pattern for any browser-only library in Next.js.
* The volunteering demo uses the same approach.
*/

import dynamic from "next/dynamic";
import type { LocationMapViewProps } from "./LocationMapView";

export type { LocationMapViewProps, MapMarker } from "./LocationMapView";

// Dynamically import the real map — ssr: false prevents the server from
// ever importing Leaflet, keeping the SSR build safe.
const LocationMapView = dynamic(
() => import("./LocationMapView").then((m) => m.LocationMapView),
{
ssr: false,
loading: () => (
<div className="flex h-full min-h-[300px] w-full items-center justify-center rounded-lg border border-gray-200 bg-gray-50">
<span className="text-sm text-gray-400">Loading map…</span>
</div>
),
},
);

/** Drop-in component — use this everywhere instead of importing LocationMapView directly. */
export function Map(props: LocationMapViewProps) {
return <LocationMapView {...props} />;
}
Loading