diff --git a/app/AuthProvider.tsx b/app/AuthProvider.tsx
new file mode 100644
index 0000000..bbf9ea6
--- /dev/null
+++ b/app/AuthProvider.tsx
@@ -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;
+}
diff --git a/app/components/LocationCard.tsx b/app/components/LocationCard.tsx
new file mode 100644
index 0000000..25f9b71
--- /dev/null
+++ b/app/components/LocationCard.tsx
@@ -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 (
+
+ {/* Pin icon — filled when active */}
+
+
+ {/* Location details */}
+
+
{location.label}
+
+ {location.lat.toFixed(4)}, {location.lon.toFixed(4)} · {location.radiusKm} km radius
+
+
+
+ {/* Remove button — stopPropagation so it doesn't also trigger onClick */}
+ {onRemove && (
+
+ )}
+
+ );
+}
diff --git a/app/components/LocationEditor.tsx b/app/components/LocationEditor.tsx
new file mode 100644
index 0000000..1952502
--- /dev/null
+++ b/app/components/LocationEditor.tsx
@@ -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(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 (
+
+
+
[ UI goes here — we will build this during the tutorial ]
+
+
+ );
+
+ // ── Full UI — uncomment this return during the tutorial ──────────────────────
+ /*
+ return (
+
+
+
+
+
+
+
+ {/* Render a pin + popup for each saved location */}
+ {markers.map((m, i) => (
+
+ {m.label && {m.label}}
+
+ ))}
+
+ {/* Draw a radius circle when a center point is active */}
+ {center != null && radiusKm != null && radiusKm > 0 && (
+
+ )}
+
+
+ {hint && (
+
+ {hint}
+
+ )}
+
+ );
+}
diff --git a/app/components/Map.tsx b/app/components/Map.tsx
new file mode 100644
index 0000000..4e7113e
--- /dev/null
+++ b/app/components/Map.tsx
@@ -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: () => (
+
+ Loading map…
+
+ ),
+ },
+);
+
+/** Drop-in component — use this everywhere instead of importing LocationMapView directly. */
+export function Map(props: LocationMapViewProps) {
+ return ;
+}
diff --git a/app/edit/page.tsx b/app/edit/page.tsx
new file mode 100644
index 0000000..6e7daef
--- /dev/null
+++ b/app/edit/page.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+/**
+ * edit/page.tsx — Authenticated edit page (Client Component)
+ *
+ * Uses useAuth() to:
+ * - Check if the user is logged in
+ * - Get authFetch (the DPoP-authenticated fetch)
+ * - Trigger login if not authenticated
+ *
+ * Passes authFetch down to LocationEditor which uses it for all pod writes.
+ *
+ * This page must be "use client" because it uses hooks (useAuth).
+ */
+
+import { useAuth } from "@/app/lib/hooks/useAuth";
+import { LocationEditor } from "@/app/components/LocationEditor";
+import Link from "next/link";
+
+export default function EditPage() {
+ const { isLoggedIn, webId, authFetch, login } = useAuth();
+
+ // Not yet logged in — show a login prompt
+ if (!isLoggedIn) {
+ return (
+
+
Edit Locations
+
You need to log in to edit locations.
+
+
+ );
+ }
+
+ return (
+
+
+
+
Edit Locations
+ {/* Show the logged-in WebID so users can see whose pod is being written to */}
+
Logged in as {webId}
+
+
+ ← Back
+
+
+
+ {/* The editor — receives authFetch so every pod write is authenticated */}
+
+
+ );
+}
diff --git a/src/app/global.css b/app/globals.css
similarity index 59%
rename from src/app/global.css
rename to app/globals.css
index ed72c6b..f1f56ca 100644
--- a/src/app/global.css
+++ b/app/globals.css
@@ -1,3 +1,12 @@
+/* Tailwind CSS v4 — imports all utilities, components, and base styles */
+@import "tailwindcss";
+
+/* ─────────────────────────────────────────────
+ Plain CSS below — kept so you can use either
+ Tailwind utility classes OR regular CSS.
+ Both approaches work side-by-side.
+ ───────────────────────────────────────────── */
+
/* Minimal Global Styles - Black and White */
* {
@@ -7,7 +16,8 @@
body {
margin: 0;
padding: 2rem;
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+ 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
line-height: 1.6;
color: #000;
background: white;
diff --git a/app/layout.tsx b/app/layout.tsx
new file mode 100644
index 0000000..d0cd679
--- /dev/null
+++ b/app/layout.tsx
@@ -0,0 +1,32 @@
+/**
+ * layout.tsx — Root layout (wraps every page)
+ *
+ * Two jobs:
+ * 1. Import globals.css so Tailwind + plain CSS apply everywhere
+ * 2. Call handleIncomingRedirect() on the client after the OIDC provider
+ * redirects back — this completes the login flow and restores the session.
+ *
+ * We do the redirect handling in a tiny "use client" sub-component so that
+ * the layout itself can remain a Server Component (required by Next.js App Router).
+ */
+
+import type { Metadata } from "next";
+import "./globals.css";
+import { AuthProvider } from "./AuthProvider";
+
+export const metadata: Metadata = {
+ title: "Solid Locations Demo",
+ description: "A minimal demo of @rdfjs/wrapper + Solid + Next.js",
+};
+
+export default function RootLayout({ children }: { children: React.ReactNode }) {
+ return (
+
+
+ {/* AuthProvider handles the OIDC redirect on the client */}
+
+ {children}
+
+
+ );
+}
diff --git a/app/lib/class/Location.ts b/app/lib/class/Location.ts
new file mode 100644
index 0000000..a0c8bfc
--- /dev/null
+++ b/app/lib/class/Location.ts
@@ -0,0 +1,61 @@
+/**
+ * Location.ts
+ *
+ * A TermWrapper wraps a single RDF subject and gives it typed getters/setters.
+ *
+ * In @rdfjs/wrapper v0.33, we use two static helpers instead of instance methods:
+ *
+ * OptionalFrom.subjectPredicate(this, predicate, coercion)
+ * → looks up: ?value (returns T | undefined)
+ *
+ * OptionalAs.object(this, predicate, value, coercion)
+ * → writes: (replaces any existing triple)
+ */
+
+import { TermWrapper, LiteralAs, LiteralFrom, OptionalFrom, OptionalAs } from "@rdfjs/wrapper";
+import { DEMO } from "./Vocabulary";
+
+export class Location extends TermWrapper {
+ // ── Getters ──────────────────────────────────────────────
+
+ get label(): string | undefined {
+ // Read the urn:demo/label triple as a plain string
+ return OptionalFrom.subjectPredicate(this, DEMO.label, LiteralAs.string);
+ }
+
+ get lat(): number | undefined {
+ const v = OptionalFrom.subjectPredicate(this, DEMO.lat, LiteralAs.string);
+ // Stored as a string literal in Turtle; parse to float for JS use
+ return v != null ? parseFloat(v) : undefined;
+ }
+
+ get lon(): number | undefined {
+ const v = OptionalFrom.subjectPredicate(this, DEMO.lon, LiteralAs.string);
+ return v != null ? parseFloat(v) : undefined;
+ }
+
+ get radiusKm(): number | undefined {
+ const v = OptionalFrom.subjectPredicate(this, DEMO.radiusKm, LiteralAs.string);
+ return v != null ? parseFloat(v) : undefined;
+ }
+
+ // ── Setters ──────────────────────────────────────────────
+
+ set label(value: string) {
+ // Write the urn:demo/label triple — LiteralFrom.string creates an xsd:string literal
+ OptionalAs.object(this, DEMO.label, value, LiteralFrom.string);
+ }
+
+ set lat(value: number) {
+ // Store as string literal so Turtle output is human-readable ("51.5")
+ OptionalAs.object(this, DEMO.lat, String(value), LiteralFrom.string);
+ }
+
+ set lon(value: number) {
+ OptionalAs.object(this, DEMO.lon, String(value), LiteralFrom.string);
+ }
+
+ set radiusKm(value: number) {
+ OptionalAs.object(this, DEMO.radiusKm, String(value), LiteralFrom.string);
+ }
+}
diff --git a/app/lib/class/LocationDataset.ts b/app/lib/class/LocationDataset.ts
new file mode 100644
index 0000000..82cfeb5
--- /dev/null
+++ b/app/lib/class/LocationDataset.ts
@@ -0,0 +1,29 @@
+/**
+ * LocationDataset.ts
+ *
+ * A DatasetWrapper wraps an entire RDF dataset and lets you query it.
+ *
+ * subjectsOf(predicate, Constructor) finds every subject that has
+ * the given predicate, then wraps each one in the given TermWrapper class.
+ *
+ * Example: given the Turtle:
+ * _:loc1 urn:demo/label "London Bridge" .
+ * _:loc2 urn:demo/label "Tower Hill" .
+ *
+ * subjectsOf(DEMO.label, Location) returns two Location instances —
+ * one for _:loc1 and one for _:loc2.
+ */
+
+import { DatasetWrapper } from "@rdfjs/wrapper";
+import { Location } from "./Location";
+import { DEMO } from "./Vocabulary";
+
+export class LocationDataset extends DatasetWrapper {
+ /**
+ * Returns all Location subjects in the dataset —
+ * i.e. every blank node / named node that has a urn:demo/label triple.
+ */
+ get locations(): Iterable {
+ return this.subjectsOf(DEMO.label, Location);
+ }
+}
diff --git a/app/lib/class/Vocabulary.ts b/app/lib/class/Vocabulary.ts
new file mode 100644
index 0000000..e338802
--- /dev/null
+++ b/app/lib/class/Vocabulary.ts
@@ -0,0 +1,24 @@
+/**
+ * Vocabulary.ts
+ *
+ * Defines the RDF predicate IRIs used in this demo.
+ * Using a short "urn:demo/" namespace keeps the Turtle readable
+ * during a live demo — no long URLs to explain.
+ *
+ * In a real app you would use an established vocabulary
+ * like schema.org or geo: instead.
+ */
+
+export const DEMO = {
+ /** The human-readable name of a location, e.g. "London Bridge" */
+ label: "urn:demo/label",
+
+ /** WGS84 latitude as a decimal string */
+ lat: "urn:demo/lat",
+
+ /** WGS84 longitude as a decimal string */
+ lon: "urn:demo/lon",
+
+ /** Deployment radius in kilometres */
+ radiusKm: "urn:demo/radiusKm",
+} as const;
diff --git a/app/lib/helpers/locations.ts b/app/lib/helpers/locations.ts
new file mode 100644
index 0000000..e9e7d77
--- /dev/null
+++ b/app/lib/helpers/locations.ts
@@ -0,0 +1,113 @@
+/**
+ * locations.ts
+ *
+ * Two public functions:
+ * fetchLocations(fetchFn?) — GET locations.ttl, parse, wrap, return Location[]
+ * saveLocations(locations, fetchFn) — build quads, serialize to Turtle, PUT
+ *
+ * The optional fetchFn parameter is how Solid auth works:
+ * - Public reads: pass nothing → uses browser fetch (no auth)
+ * - Authed writes: pass getDefaultSession().fetch → adds DPoP auth headers
+ */
+
+import * as N3 from "n3";
+import { LocationDataset } from "../class/LocationDataset";
+import { DEMO } from "../class/Vocabulary";
+
+// ── Helpers ───────────────────────────────────────────────────────────────
+
+/** Builds the full URL to locations.ttl from env vars. */
+function locationsUrl(): string {
+ const base = process.env.NEXT_PUBLIC_BASE_URI!;
+ const path = process.env.NEXT_PUBLIC_MANIFEST_RESOURCE_URI!;
+ return new URL(path, base).toString();
+}
+
+/** Parses a Turtle string into an N3 Store (the RDF/JS DatasetCore). */
+function parseTurtle(turtle: string): N3.Store {
+ const store = new N3.Store();
+ store.addQuads(new N3.Parser().parse(turtle));
+ return store;
+}
+
+/** Serializes every quad in a Store back to a Turtle string. */
+function serializeToTurtle(store: N3.Store): Promise {
+ // N3 Writer outputs valid Turtle with a urn:demo/ prefix for readability
+ const writer = new N3.Writer({ prefixes: { demo: "urn:demo/" } });
+ for (const q of store.getQuads(null, null, null, null)) writer.addQuad(q);
+ return new Promise((resolve, reject) => {
+ writer.end((err, result) => (err ? reject(err) : resolve(result ?? "")));
+ });
+}
+
+// ── Public API ────────────────────────────────────────────────────────────
+
+export type LocationData = {
+ id: string; // blank node ID — used as a stable React key
+ label: string;
+ lat: number;
+ lon: number;
+ radiusKm: number;
+};
+
+/**
+ * Fetches locations.ttl from the CSS pod, parses it, and returns a plain array.
+ * Pass an authenticated fetch (getDefaultSession().fetch) for private pods.
+ */
+export async function fetchLocations(fetchFn: typeof fetch = fetch): Promise {
+ const res = await fetchFn(locationsUrl(), {
+ headers: { Accept: "text/turtle" },
+ });
+
+ if (res.status === 404) return []; // empty pod — not an error
+ if (!res.ok) throw new Error(`Failed to fetch locations: ${res.status}`);
+
+ const turtle = await res.text();
+ if (!turtle.trim()) return [];
+
+ // Wrap the parsed store with our LocationDataset — gives us the .locations getter
+ const store = parseTurtle(turtle);
+ const dataset = new LocationDataset(store, N3.DataFactory);
+
+ return [...dataset.locations]
+ .filter((loc) => loc.lat != null && loc.lon != null)
+ .map((loc) => ({
+ id: loc.value, // the blank node's internal value
+ label: loc.label ?? "",
+ lat: loc.lat!,
+ lon: loc.lon!,
+ radiusKm: loc.radiusKm ?? 10,
+ }));
+}
+
+/**
+ * Writes the full locations array to locations.ttl via authenticated PUT.
+ * Replaces the entire file — simpler than diffing for a demo.
+ */
+export async function saveLocations(
+ locations: LocationData[],
+ fetchFn: typeof fetch,
+): Promise {
+ const store = new N3.Store();
+ const { blankNode, namedNode, literal } = N3.DataFactory;
+
+ for (const loc of locations) {
+ // Each location becomes a blank node with four predicates:
+ // _:b0 urn:demo/label "London Bridge" .
+ // _:b0 urn:demo/lat "51.5" . etc.
+ const bnode = blankNode();
+ store.addQuad(bnode, namedNode(DEMO.label), literal(loc.label));
+ store.addQuad(bnode, namedNode(DEMO.lat), literal(String(loc.lat)));
+ store.addQuad(bnode, namedNode(DEMO.lon), literal(String(loc.lon)));
+ store.addQuad(bnode, namedNode(DEMO.radiusKm), literal(String(loc.radiusKm)));
+ }
+
+ const turtle = await serializeToTurtle(store);
+ const res = await fetchFn(locationsUrl(), {
+ method: "PUT",
+ headers: { "Content-Type": "text/turtle" },
+ body: turtle,
+ });
+
+ if (!res.ok) throw new Error(`Failed to save locations: ${res.status}`);
+}
diff --git a/app/lib/hooks/useAuth.ts b/app/lib/hooks/useAuth.ts
new file mode 100644
index 0000000..977c097
--- /dev/null
+++ b/app/lib/hooks/useAuth.ts
@@ -0,0 +1,81 @@
+"use client";
+
+/**
+ * useAuth.ts
+ *
+ * Wraps @inrupt/solid-client-authn-browser into a simple React hook.
+ *
+ * The three functions we use from the library:
+ * handleIncomingRedirect() — must run on page load to complete the OIDC flow
+ * (the identity provider redirects back with a code)
+ * getDefaultSession() — returns the singleton session object
+ * login() — redirects the user to the OIDC provider login page
+ *
+ * Returns:
+ * isLoggedIn — whether the user has an active session
+ * webId — the user's WebID IRI (e.g. https://id.inrupt.com/alice)
+ * authFetch — a drop-in replacement for fetch() that adds DPoP auth headers
+ * login() — call this to start the login flow
+ */
+
+import { useEffect, useState } from "react";
+import {
+ getDefaultSession,
+ login as solidLogin,
+} from "@inrupt/solid-client-authn-browser";
+
+export type AuthState = {
+ isLoggedIn: boolean;
+ webId: string | undefined;
+ authFetch: typeof fetch;
+ login: () => Promise;
+};
+
+export function useAuth(): AuthState {
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
+ const [webId, setWebId] = useState();
+
+ useEffect(() => {
+ const session = getDefaultSession();
+
+ // Read current state — AuthProvider in layout.tsx may have already
+ // completed handleIncomingRedirect by the time this hook runs.
+ setIsLoggedIn(session.info.isLoggedIn);
+ setWebId(session.info.webId);
+
+ // Listen for session changes so the UI updates reactively:
+ // "login" — OIDC redirect completed successfully
+ // "sessionRestore" — silent re-login on page refresh
+ // "logout" — user logged out
+ const onLogin = () => { setIsLoggedIn(true); setWebId(getDefaultSession().info.webId); };
+ const onLogout = () => { setIsLoggedIn(false); setWebId(undefined); };
+
+ session.events.on("login", onLogin);
+ session.events.on("sessionRestore", onLogin);
+ session.events.on("logout", onLogout);
+
+ return () => {
+ session.events.off("login", onLogin);
+ session.events.off("sessionRestore", onLogin);
+ session.events.off("logout", onLogout);
+ };
+ }, []);
+
+ async function login() {
+ await solidLogin({
+ oidcIssuer: process.env.NEXT_PUBLIC_OIDC_ISSUER!,
+ // After login, the provider redirects back to wherever we currently are
+ redirectUrl: window.location.href,
+ clientName: "Solid Locations Demo",
+ });
+ }
+
+ return {
+ isLoggedIn,
+ webId,
+ // getDefaultSession().fetch is an authenticated fetch — same interface as
+ // the browser's built-in fetch but with DPoP proof headers attached
+ authFetch: getDefaultSession().fetch as typeof fetch,
+ login,
+ };
+}
diff --git a/app/lib/hooks/useLocations.ts b/app/lib/hooks/useLocations.ts
new file mode 100644
index 0000000..0298388
--- /dev/null
+++ b/app/lib/hooks/useLocations.ts
@@ -0,0 +1,67 @@
+"use client";
+
+/**
+ * useLocations.ts
+ *
+ * Manages the locations array in React state and wires up save/delete.
+ *
+ * Pattern:
+ * 1. On mount, call fetchLocations() to load from the pod
+ * 2. addLocation / removeLocation update local state immediately (optimistic)
+ * then call saveLocations() to persist the full array to the pod via PUT
+ *
+ * Why PUT the whole file instead of patching?
+ * Simpler to reason about — the pod file always matches the React state exactly.
+ * For a demo this is fine; a production app might use PATCH instead.
+ */
+
+import { useState, useEffect, useCallback } from "react";
+import {
+ fetchLocations,
+ saveLocations,
+ type LocationData,
+} from "@/app/lib/helpers/locations";
+
+export type UseLocationsResult = {
+ locations: LocationData[];
+ isLoading: boolean;
+ error: string | null;
+ addLocation: (loc: Omit) => Promise;
+ removeLocation: (id: string) => Promise;
+};
+
+export function useLocations(authFetch: typeof fetch): UseLocationsResult {
+ const [locations, setLocations] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ // Load locations from the pod on mount
+ useEffect(() => {
+ fetchLocations(authFetch)
+ .then(setLocations)
+ .catch((e) => setError(e.message))
+ .finally(() => setIsLoading(false));
+ }, [authFetch]);
+
+ const addLocation = useCallback(
+ async (loc: Omit) => {
+ // Generate a stable id from coordinates so the list key is meaningful
+ const id = `${loc.lat.toFixed(5)},${loc.lon.toFixed(5)}`;
+ const next = [...locations, { ...loc, id }];
+ setLocations(next); // optimistic update
+ await saveLocations(next, authFetch); // persist to pod
+ },
+ [locations, authFetch],
+ );
+
+ const removeLocation = useCallback(
+ async (id: string) => {
+ const next = locations.filter((l) => l.id !== id);
+ setLocations(next); // optimistic update
+ await saveLocations(next, authFetch); // persist to pod
+ },
+ [locations, authFetch],
+ );
+
+ return { locations, isLoading, error, addLocation, removeLocation };
+}
diff --git a/app/page.tsx b/app/page.tsx
new file mode 100644
index 0000000..85e2a89
--- /dev/null
+++ b/app/page.tsx
@@ -0,0 +1,61 @@
+/**
+ * page.tsx — Public home page (Server Component)
+ *
+ * Fetches locations from the pod at request time (no auth — public read).
+ * Renders a static map with pins + a list of location cards.
+ *
+ * The "Edit locations" link goes to /edit which requires login.
+ *
+ * export const dynamic = "force-dynamic" tells Next.js not to cache this
+ * page at build time — so it always shows the latest pod data.
+ */
+
+import { fetchLocations } from "@/app/lib/helpers/locations";
+import { Map } from "@/app/components/Map";
+import { LocationCard } from "@/app/components/LocationCard";
+import Link from "next/link";
+
+export const dynamic = "force-dynamic";
+
+export default async function HomePage() {
+ // Public fetch — no auth needed, CSS pod allows public reads
+ const locations = await fetchLocations().catch(() => []);
+
+ const markers = locations.map((l) => ({ lat: l.lat, lon: l.lon, label: l.label }));
+
+ return (
+
+
- Created manifest resource and modified container access
- control.
-
-
- Try editing the manifest resource on the{" "}
- admin page
-
-
- );
- } catch {
- return (
-
-
- Bootstrap failed
-
-
Could not create manifest resource / modify container access control.
-
- );
- }
-}
-
-/**
- * Creates the manifest Solid resource used by this application.
- * Assumes that the resource is publically writable.
- */
-async function createManifetsResource() {
- // This is the address of the manifest resource to create.
- // In a production environment there could be a name clash.
- // An alternative approach could be to generate a unique name for the resource,
- // or to use a POST request to the container instead. This would generate a unique resource name
- // that would be returned in the Location header of the response.
- const uri = new URL(Config.manifestResourceUri, Config.baseUri);
-
- // Send an unauthenticated PUT request to create the manifest resource.
- // It is unlikely that unauthenticated requests are allowed in a production environment,
- // so one would need to authenticate first and use the appropriate authentication headers here,
- // for example using the @inrupt/solid-client-authn-browser or @inrupt/solid-client-authn-node libraries.
- // Alternatively, the resource could be created manually or by an automated process outside of this application.
- const response = await fetch(uri, {
- method: "put",
- headers: {
- // Solid required a content-type header for requests that change resources, even ones that do not have a body, like ours.
- // See https://solidproject.org/TR/protocol#client-content-type-includes
- // Solid guarantees support for Turtle and JSON-LD, but other RDF serializations may also be supported by some servers.
- "Content-Type": "text/turtle",
- },
- });
-
- // Robust network resilience and error handling are out of scope for this simple example.
- if (!response.ok) {
- throw new Error("Could not create manifest resource");
- }
-}
-
-async function updateContainerAccessControl() {
- const appContainer = await getResourceInfoWithAcl(Config.baseUri);
- const acrUri = getLinkedAcrUrl(appContainer);
- if (!acrUri) {
- throw new Error("Could not find container access control resource");
- }
-
- const response = await fetch(acrUri, {
- method: "put",
- body: defaultAcrAcpRdf,
- headers: {
- "Content-Type": "text/turtle",
- },
- });
-
- // Robust network resilience and error handling are out of scope for this simple example.
- if (!response.ok) {
- throw new Error("Could not modify container access control");
- }
-}
-
-const defaultAcrAcpRdf = `PREFIX acl:
-PREFIX :
-
-# This gives full access to everyone but denies Write and control for anyone but the admin
-[
- :resource <.> ;
- :accessControl [
- :apply _:public ;
- :apply [
- :deny acl:Write, acl:Control ;
- :noneOf _:me ;
- ] ;
- ] ;
- :memberAccessControl [
- :apply _:public ;
- :apply [
- :deny acl:Write ;
- :noneOf _:me ;
- ] ;
- ] ;
-] .
-
-_:public
- :allow acl:Read, acl:Write, acl:Control ;
- :anyOf [
- :agent :PublicAgent ;
- ] ;
-.
-
-# TODO: describe, also describe robust policy design
-_:me :agent <${Config.adminWebID}> .
-`;
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
deleted file mode 100644
index fd20465..0000000
--- a/src/app/layout.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import type { Metadata } from "next";
-import "./global.css";
-
-/**
- * This is the default metadata of the Next.js application.
- * In this application it is used by all pages.
- * @see {@link https://nextjs.org/docs/app/getting-started/metadata-and-og-images#static-metadata}
- * @see {@link https://nextjs.org/docs/app/api-reference/file-conventions/metadata}
- */
-export const metadata: Metadata = {
- title: "Solid List Items",
- description: "Authn browser & RDF/JS Wrapper & Next.js",
-};
-
-/**
- * This is the root layout of the Next.js application.
- * In this application it is used by all pages.
- * @see {@link https://nextjs.org/docs/app/api-reference/file-conventions/layout#root-layout}
- * @see {@link https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#the-root-layout}
- */
-export default function ({ children }: LayoutProps) {
- return (
-
- {children}
-
- );
-}
-
-/**
- * This structure defines the shape of the properties passed to the root layout component.
- */
-type LayoutProps = Readonly<{
- children: React.ReactNode;
-}>;
diff --git a/src/app/page.module.css b/src/app/page.module.css
deleted file mode 100644
index 7979fcf..0000000
--- a/src/app/page.module.css
+++ /dev/null
@@ -1,34 +0,0 @@
-/* Page Styles */
-.container {
- max-width: 1200px;
- margin: 0 auto;
-}
-
-.page_title {
- font-size: 2rem;
- font-weight: 700;
- margin-bottom: 2.5rem;
- color: #000;
- letter-spacing: -0.02em;
-}
-
-.error_container {
- padding: 3rem;
- max-width: 600px;
- margin: 0 auto;
- background: white;
- border-radius: 20px;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.08);
-}
-
-.error_title {
- font-size: 1.75rem;
- font-weight: 700;
- margin-bottom: 1rem;
- color: #000;
-}
-
-.error_text {
- color: #000;
- margin-bottom: 1rem;
-}
diff --git a/src/app/page.tsx b/src/app/page.tsx
deleted file mode 100644
index 0cc9fce..0000000
--- a/src/app/page.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { ListViewer } from "../components/ui/ListViewer";
-import { fetchList } from "../fetchList";
-import styles from "./page.module.css";
-
-export const dynamic = "force-dynamic";
-
-/**
- * This is the home page.
- * It is available (by default) at http://localhost:3000/
- */
-export default async function () {
- try {
- return (
-