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 ( +
+ +
+ ({ 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" + /> +
+ + {picked && ( +
+

+ Selected: {picked.lat.toFixed(4)}, {picked.lon.toFixed(4)} +

+ 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" + /> +
+ + setRadiusKm(Number(e.target.value))} + className="flex-1" + /> +
+ +
+ )} + + {isLoading &&

Loading…

} + {error &&

{error}

} +
+ {locations.map((loc) => ( + setActiveId(activeId === loc.id ? null : loc.id)} + onRemove={() => removeLocation(loc.id)} + /> + ))} + {!isLoading && locations.length === 0 && ( +

No locations saved yet. Click the map to add one.

+ )} +
+ +
+ ); + */ +} diff --git a/app/components/LocationMapView.tsx b/app/components/LocationMapView.tsx new file mode 100644 index 0000000..5976265 --- /dev/null +++ b/app/components/LocationMapView.tsx @@ -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 ( +
+ + +
+ ); +} + +export function LocationMapView({ + className = "", + markers = [], + center, + radiusKm, + onMapClick, + hint, +}: LocationMapViewProps) { + 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 ( +
+
+
+

Solid Locations Demo

+

Locations stored in a Solid pod as Turtle RDF

+
+ + Edit locations + +
+ + {/* Map showing all saved locations */} +
+ +
+ + {/* Location cards — read-only, no remove button */} +
+ {locations.length === 0 ? ( +

+ No locations yet.{" "} + Add one → +

+ ) : ( + locations.map((loc) => ( + + )) + )} +
+
+ ); +} diff --git a/data/.acr b/data/.acr index 588cbbc..461528f 100644 --- a/data/.acr +++ b/data/.acr @@ -1,32 +1,31 @@ -# WARNING: DO NOT USE UNMODIFIED UNLESS FOR TESTING PURPOSES. -# WHEN IN DOUBT, DELETE THIS DOCUMENT. -# -# This root ACR allows unrestricted public access to all documents and subcontainers. -# -# This document was automatically generated by the Community Solid Server -# because the "Expose a public root Pod" option was selected during setup, -# or because setup has been bypassed. -# -# We strongly suggest to edit this document such that it restricts permissions. +PREFIX acl: +PREFIX : -@prefix acl: . -@prefix acp: . +# 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 ; + ] ; + ] ; +] . -# Give all agents Read, Write, and Control permissions on everything -<#card> - a acp:AccessControlResource; - acp:resource <./>; - acp:accessControl <#publicReadAccess>; - acp:memberAccessControl <#publicReadAccess> . - -<#publicReadAccess> - a acp:AccessControl; - acp:apply [ - a acp:Policy; - acp:allow acl:Read, acl:Write, acl:Control; - acp:anyOf [ - a acp:Matcher; - acp:agent acp:PublicAgent - ] - ]. +_:public + :allow acl:Read, acl:Write, acl:Control ; + :anyOf [ + :agent :PublicAgent ; + ] ; +. +# TODO: describe, also describe robust policy design +_:me :agent . diff --git a/data/.meta b/data/.meta index 8d5a9bc..62e627b 100644 --- a/data/.meta +++ b/data/.meta @@ -1,7 +1 @@ -@prefix pim: . - -# It is imperative the root container is marked as a pim:Storage : -# Solid, §4.1: "Servers exposing the storage resource MUST advertise by including the HTTP Link header -# with rel="type" targeting http://www.w3.org/ns/pim/space#Storage when responding to storage’s request URI." -# https://solid.github.io/specification/protocol#storage -<> a pim:Storage. + a . diff --git a/package-lock.json b/package-lock.json index 6086532..86508f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,24 +8,44 @@ "name": "my-app", "version": "0.1.0", "dependencies": { + "@heroicons/react": "^2.2.0", "@inrupt/solid-client": "2.1.2", "@inrupt/solid-client-authn-browser": "^3.1.0", "@rdfjs/wrapper": "0.33.0", + "leaflet": "^1.9.4", + "leaflet-defaulticon-compatibility": "^0.1.2", "n3": "^2", "next": "15.5.4", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "react-leaflet": "^5.0.0" }, "devDependencies": { "@solid/community-server": "^8.0.0-alpha.1", + "@tailwindcss/postcss": "^4.2.4", + "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "concurrently": "^9", "nodemon": "^3", + "tailwindcss": "^4.2.4", "typescript": "^5" } }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -5879,6 +5899,15 @@ "tslib": "^2.4.0" } }, + "node_modules/@heroicons/react": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz", + "integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==", + "license": "MIT", + "peerDependencies": { + "react": ">= 16 || ^19.0.0-rc" + } + }, "node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -6485,6 +6514,56 @@ "event-emitter-promisify": "^1.1.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@koa/cors": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@koa/cors/-/cors-5.0.0.tgz", @@ -6960,6 +7039,17 @@ "node": ">=24.0.0" } }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/@rubensworks/saxes": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@rubensworks/saxes/-/saxes-6.0.1.tgz", @@ -8013,6 +8103,306 @@ "tslib": "^2.8.0" } }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz", + "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "postcss": "^8.5.6", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/postcss/node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/@types/accepts": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.7.tgz", @@ -8159,6 +8549,13 @@ "@types/node": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/http-assert": { "version": "1.5.6", "resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.6.tgz", @@ -8240,6 +8637,16 @@ "@types/koa": "*" } }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, "node_modules/@types/lodash": { "version": "4.17.20", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", @@ -8518,6 +8925,7 @@ "integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -10012,8 +10420,8 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.1.tgz", "integrity": "sha512-ecqj/sy1jcK1uWrwpR67UhYrIFQ+5WlGxth34WquCbamhFA6hkkwiu37o6J5xCHdo1oixJRfVRw+ywV+Hq/0Aw==", + "devOptional": true, "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -10147,6 +10555,20 @@ "once": "^1.4.0" } }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -10839,6 +11261,16 @@ "node": ">=10" } }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/jose": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", @@ -11101,6 +11533,280 @@ "dev": true, "license": "MIT" }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/leaflet-defaulticon-compatibility": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/leaflet-defaulticon-compatibility/-/leaflet-defaulticon-compatibility-0.1.2.tgz", + "integrity": "sha512-IrKagWxkTwzxUkFIumy/Zmo3ksjuAu3zEadtOuJcKzuXaD76Gwvg2Z1mLyx7y52ykOzM8rAH5ChBs4DnfdGa6Q==", + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -11140,6 +11846,16 @@ "node": ">= 12.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/marked": { "version": "16.3.0", "resolved": "https://registry.npmjs.org/marked/-/marked-16.3.0.tgz", @@ -12299,6 +13015,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -12308,6 +13025,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -12315,6 +13033,20 @@ "react": "^19.1.0" } }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, "node_modules/readable-error": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/readable-error/-/readable-error-1.0.0.tgz", @@ -13026,6 +13758,27 @@ "node": ">=4" } }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", diff --git a/package.json b/package.json index 862fba3..a1db61f 100644 --- a/package.json +++ b/package.json @@ -11,21 +11,28 @@ "start:next": "next dev --turbopack" }, "dependencies": { - "@inrupt/solid-client-authn-browser": "^3.1.0", + "@heroicons/react": "^2.2.0", "@inrupt/solid-client": "2.1.2", + "@inrupt/solid-client-authn-browser": "^3.1.0", "@rdfjs/wrapper": "0.33.0", + "leaflet": "^1.9.4", + "leaflet-defaulticon-compatibility": "^0.1.2", "n3": "^2", "next": "15.5.4", "react": "19.1.0", - "react-dom": "19.1.0" + "react-dom": "19.1.0", + "react-leaflet": "^5.0.0" }, "devDependencies": { "@solid/community-server": "^8.0.0-alpha.1", + "@tailwindcss/postcss": "^4.2.4", + "@types/leaflet": "^1.9.21", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "concurrently": "^9", "nodemon": "^3", + "tailwindcss": "^4.2.4", "typescript": "^5" } } diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..7475692 --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,8 @@ +// PostCSS config — enables Tailwind CSS v4 +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/src/Config.ts b/src/Config.ts deleted file mode 100644 index 003bba5..0000000 --- a/src/Config.ts +++ /dev/null @@ -1,44 +0,0 @@ -const errorTemplate = "Could not find required environment variable: "; - -/** - * Retrieve environment variables - */ -export class Config { - public static get baseUri(): string { - const value = process.env.NEXT_PUBLIC_BASE_URI; - if (value === undefined) { - throw new Error(`${errorTemplate}NEXT_PUBLIC_BASE_URI`); - } - - return value; - } - - public static get manifestResourceUri(): string { - const value = process.env.NEXT_PUBLIC_MANIFEST_RESOURCE_URI; - if (value === undefined) { - throw new Error( - `${errorTemplate}NEXT_PUBLIC_MANIFEST_RESOURCE_URI` - ); - } - - return value; - } - - public static get adminWebID(): string { - const value = process.env.NEXT_PUBLIC_ADMIN_WEBID; - if (value === undefined) { - throw new Error(`${errorTemplate}NEXT_PUBLIC_ADMIN_WEBID`); - } - - return value; - } - - public static get oidcIssuer(): string { - const value = process.env.NEXT_PUBLIC_OIDC_ISSUER; - if (value === undefined) { - throw new Error(`${errorTemplate}NEXT_PUBLIC_OIDC_ISSUER`); - } - - return value; - } -} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx deleted file mode 100644 index ece2db4..0000000 --- a/src/app/admin/page.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { ListEditor } from "../../components/ui/ListEditor"; - -/** - * This is the admin page. - * It is available (by default) at http://localhost:3000/admin - * It is used to create and delete list items. - * Actual functionality is in the ListEditor component. - */ -export default function () { - return ; -} diff --git a/src/app/boot/page.module.css b/src/app/boot/page.module.css deleted file mode 100644 index 0fc618b..0000000 --- a/src/app/boot/page.module.css +++ /dev/null @@ -1,32 +0,0 @@ -/* Boot Page Styles */ -.message_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); -} - -.success_title { - font-size: 1.75rem; - font-weight: 700; - margin-bottom: 1rem; - color: #000; -} - -.error_title { - font-size: 1.75rem; - font-weight: 700; - margin-bottom: 1rem; - color: #000; -} - -.message_text { - color: #000; - margin-bottom: 1rem; -} - -.message_text:last-child { - margin-bottom: 0; -} diff --git a/src/app/boot/page.tsx b/src/app/boot/page.tsx deleted file mode 100644 index d181776..0000000 --- a/src/app/boot/page.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { Config } from "../../Config"; -import { getResourceInfoWithAcl } from "@inrupt/solid-client"; -import { getLinkedAcrUrl } from "@inrupt/solid-client/acp/acp"; -import styles from "./page.module.css"; - -export const dynamic = "force-dynamic"; - -/** - * This is the boot page. - * It is available (by default) at http://localhost:3000/boot - * It is used to create the list manifest Solid resource and to configure the access control policy for the Solid container where this application stores its data. - * It is intended to be called once only after deploying the Solid server. - * 'Provisioning' functionality similar to this is likely to be needed in a production setup, - * but it might not be needed in every deployment. - */ -export default async function () { - try { - await createManifetsResource(); - await updateContainerAccessControl(); - - return ( -
-

- Bootstrap successful -

-

- 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 ( -
-

- List Items -

- -
- ); - } catch { - return ( -
-

- Could not load list -

-

Manifest resource probably does not exist.

-

- Did you run the bootstrap page? -

-
- ); - } -} diff --git a/src/components/ui/ListEditor.tsx b/src/components/ui/ListEditor.tsx deleted file mode 100644 index 5320f07..0000000 --- a/src/components/ui/ListEditor.tsx +++ /dev/null @@ -1,297 +0,0 @@ -"use client"; - -import type { FormEvent } from "react"; -import { Writer } from "n3"; -import { - login, - getDefaultSession, - handleIncomingRedirect, -} from "@inrupt/solid-client-authn-browser"; -import { useEffect, useState } from "react"; -import type { List } from "../../model/List"; -import { Item } from "../../model/Item"; -import { Config } from "../../Config"; -import { ListViewer } from "../../components/ui/ListViewer"; -import { fetchList } from "../../fetchList"; -import style from "../../styles/ListEditorStyle.module.css"; - -export function ListEditor() { - const [newName, setNewName] = useState(""); - const [newDescription, setNewDescription] = useState(""); - const [newFeatured, setNewFeatured] = useState(false); - const [newWebsite, setNewWebsite] = useState(""); - const [newThumbnail, setNewThumbnail] = useState(); - const [list, setList] = useState(); - const [, setMagic] = useState(""); - const [isFormOpen, setIsFormOpen] = useState(false); - - useEffect(() => { - authenticate().then(fetchList).then(setList); - }, []); - - // Check if list is empty - const isListEmpty = list ? (list.item ? Array.from(list.item).length === 0 : true) : false; - - useEffect(() => { - // Show form by default if list is empty, and keep it open - if (list && isListEmpty) { - setIsFormOpen(true); - } - }, [list, isListEmpty]); - - if (list) { - return ( -
-
-

List Items

- {!isListEmpty && ( - - )} -
- -
- {!isListEmpty && ( -
- -
- )} - - {(isFormOpen || isListEmpty) && ( -
-
- New Item -
- -
-
- -
-
- -
-
- -
-
- -
- -
-
- )} -
-
- ); - } else { - return ( -
-

- Could not load list -

-

Manifest resource probably does not exist.

-

- Did you run the bootstrap page? -

-
- ); - } - - async function authenticate() { - await handleIncomingRedirect(); - const session = getDefaultSession(); - - if (!session.info.isLoggedIn) { - console.log("unauthenticated"); - - await login({ - oidcIssuer: Config.oidcIssuer, - clientName: "My application", - }); - } - } - - async function save() { - if (!list) { - throw new Error("List state variable was not set"); - } - - const uri = new URL(Config.manifestResourceUri, Config.baseUri); - const response = await getDefaultSession().fetch(uri, { - method: "put", - headers: { - "Content-Type": "text/turtle", - Link: '; rel="type"', - }, - body: await toTurtle(list), - }); - - if (!response.ok) { - throw new Error("Could not save list manifest resource"); - } - - alert("List manifest resource saved"); - } - - async function removeItem(item: Item) { - if (!item.thumbnail) { - throw new Error("thumbnail is required"); - } - - // TODO: Why separate? Explain dependent resource - await deleteThumbnail(item.thumbnail); - - item.name = undefined; - item.description = undefined; - item.featured = undefined; - item.website = undefined; - item.thumbnail = undefined; - - // TODO: Why separate? Explain graph deletion of complex value - list?.item.delete(item); - - save(); - - // TODO: justify - setMagic(Math.random().toString()); - } - - async function addItem(e: FormEvent) { - e.preventDefault(); - - const item = new Item(list!.factory.blankNode(), list!.dataset, list!.factory); - list?.item.add(item); - - item.name = newName; - item.description = newDescription; - item.featured = newFeatured; - item.website = newWebsite; - item.thumbnail = await createNewThumbnail(); - - setNewName(""); - setNewDescription(""); - setNewFeatured(false); - setNewWebsite(""); - setNewThumbnail(undefined); - setIsFormOpen(false); - // TODO: How to reset new thumbnail input? - - save(); - } - - async function createNewThumbnail(): Promise { - const response = await getDefaultSession().fetch(Config.baseUri, { - method: "post", - headers: { - "Content-Type": - newThumbnail?.type ?? "application/octet-stream", - }, - body: newThumbnail, - }); - - if (!response.ok) { - throw new Error("Could not create new thumbnail resource"); - } - - const location = response.headers.get("Location"); - if (!location) { - throw new Error("Thumbnail create response lacks location header"); - } - - return location; - } - - async function deleteThumbnail(thumbnail: string) { - await getDefaultSession().fetch(thumbnail, { method: "delete" }); - } -} - -export function toTurtle(list: List): Promise { - return new Promise((resolve, reject) => { - const writer = new Writer(); - - writer.addQuads([...list.dataset]); - - writer.end((error, result) => { - if (error) { - reject(error); - } else { - resolve(result); - } - }); - }); -} diff --git a/src/components/ui/ListViewer.tsx b/src/components/ui/ListViewer.tsx deleted file mode 100644 index d9bfa02..0000000 --- a/src/components/ui/ListViewer.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import type { Item } from "../../model/Item"; -import type { List } from "../../model/List"; -import style from "../../styles/ListEditorStyle.module.css"; - -/** - * This is a React component for displaying a list of items. - * It is used both on the (server-rendered, public) homepage, and in the (client-rendered, authenticated) admin page. - */ -export function ListViewer({ - data, - deleteHandler, -}: ListViewerProps): React.ReactNode { - // Convert Set to array to use standard Array.map() with index support - const itemsArray = data.item ? Array.from(data.item) : []; - const items = itemsArray.map((item, index) => renderItem(item, deleteHandler, index)); - - return
    {items}
; -} - -function renderItem(item: Item, deleteHandler?: ItemHandler, index?: number): React.ReactNode { - // TODO: Describe why these are nullable - if (!item.website) { - throw new Error("website is required"); - } - if (!item.thumbnail) { - throw new Error("thumbnail is required"); - } - - // Fixed: Use unique key to prevent React duplicate key errors - // Prefer item's id, fallback to thumbnail (unique per item), then index as last resort - const uniqueKey = item.id || item.thumbnail || `item-${index}`; - - return ( -
  • -
    -
    -
    name
    -
    {item.name}
    -
    -
    -
    description
    -
    {item.description}
    -
    -
    -
    featured
    -
    - -
    -
    -
    -
    website
    -
    - {item.website} -
    -
    -
    -
    thumbnail
    -
    - {`Thumbnail -
    -
    -
    - {deleteHandler && ( - - )} -
  • - ); -} - -/** - * This structure defines the shape of the properties passed to the list viewer component. - */ -type ListViewerProps = Readonly<{ - data: List; - deleteHandler?: ItemHandler; -}>; - -/** - * This structure defines the shape of an event handler (callback) for processing a {@link Item}. - * Used here for the "remove" button in the admin interface. - */ -type ItemHandler = (item: Item) => Promise; diff --git a/src/fetchList.tsx b/src/fetchList.tsx deleted file mode 100644 index 3a06df5..0000000 --- a/src/fetchList.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as N3 from "n3"; -import type * as RdfJs from "@rdfjs/types"; -import { List } from "./model/List"; -import { Config } from "./Config"; - -/** - * This fetches the resource manifest - * The resource manifest holds links to all list items - */ -export async function fetchList(): Promise { - // Construct a URL for the Solid resource. - // Use a base URI (like http://example.com/some-path/) separate from the - // local part (like file.ext) - const uri = new URL(Config.manifestResourceUri, Config.baseUri); - - // We accept well known RDF syntax; turtle is a common default - const response = await fetch(uri, { - headers: { - Accept: "text/turtle, application/trig, application/n-triples, application/n-quads, text/n3", - }, - }); - - // In case we were not able to fetch the manifest resource, bail out. - // Serious exception handling and network resilience are out of scope for this demo. - if (!response.ok) { - throw new Error("Failed to fetch list"); - } - - const rdf = await response.text(); - - // Convert Raw RDF into a JavaScript object. - const dataset = parseRdf(rdf); - - return new List("urn:example:list", dataset, N3.DataFactory); -} - -function parseRdf(rdf: string): RdfJs.DatasetCore { - const store = new N3.Store(); - store.addQuads(new N3.Parser().parse(rdf)); - - return store; -} diff --git a/src/model/Item.ts b/src/model/Item.ts deleted file mode 100644 index 885807e..0000000 --- a/src/model/Item.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - LiteralAs, - LiteralFrom, - NamedNodeAs, - NamedNodeFrom, - OptionalAs, - OptionalFrom, - TermWrapper -} from "@rdfjs/wrapper"; - -export class Item extends TermWrapper { - get id(): string { - return this.value; - } - - get thumbnail(): string | undefined { - return OptionalFrom.subjectPredicate(this, "urn:example:/thumbnail", NamedNodeAs.string); - } - - set thumbnail(value: string | undefined) { - OptionalAs.object(this, "urn:example:/thumbnail", value, NamedNodeFrom.string); - } - - get name(): string | undefined { - return OptionalFrom.subjectPredicate(this, "urn:example:/name", LiteralAs.string); - } - - set name(value: string | undefined) { - OptionalAs.object(this, "urn:example:/name", value, LiteralFrom.string); - } - - get featured(): boolean | undefined { - return OptionalFrom.subjectPredicate(this, "urn:example:/featured", LiteralAs.boolean); - } - - set featured(value: boolean | undefined) { - OptionalAs.object(this, "urn:example:/featured", value, LiteralFrom.boolean); - } - - get description(): string | undefined { - return OptionalFrom.subjectPredicate(this, "urn:example:/description", LiteralAs.string); - } - - set description(value: string | undefined) { - OptionalAs.object(this, "urn:example:/description", value, LiteralFrom.string); - } - - get website(): string | undefined { - return OptionalFrom.subjectPredicate(this, "urn:example:/website", NamedNodeAs.string); - } - - set website(value: string | undefined) { - OptionalAs.object(this, "urn:example:/website", value, NamedNodeFrom.string); - } -} diff --git a/src/model/List.ts b/src/model/List.ts deleted file mode 100644 index e2d5f10..0000000 --- a/src/model/List.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Item } from "./Item"; -import { SetFrom, TermAs, TermFrom, TermWrapper } from "@rdfjs/wrapper"; - -export class List extends TermWrapper { - get item(): Set { - return SetFrom.subjectPredicate(this, "urn:example:/item", TermAs.instance(Item), TermFrom.instance); - } -} diff --git a/src/styles/ListEditorStyle.module.css b/src/styles/ListEditorStyle.module.css deleted file mode 100644 index 173566a..0000000 --- a/src/styles/ListEditorStyle.module.css +++ /dev/null @@ -1,505 +0,0 @@ -/* Container */ -.main_container { - max-width: 1200px; - margin: 0 auto; -} - -/* Error Message Styles */ -.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; -} - -/* Header Section */ -.header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 2.5rem; - padding-bottom: 1.5rem; - border-bottom: 2px solid rgba(0, 0, 0, 0.1); -} - -.title { - font-size: 2rem; - font-weight: 700; - color: #000; - margin: 0; - letter-spacing: -0.02em; -} - -.toggle_button { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.75rem 1.5rem; - font-size: 1rem; - font-weight: 600; - color: #000; - background: white; - border: 2px solid #000; - border-radius: 8px; - cursor: pointer; - transition: all 0.2s ease; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.toggle_button:hover { - background: #f5f5f5; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); -} - -.toggle_button:active { - background: #e5e5e5; -} - -.toggle_button:focus-visible { - outline: 2px solid #000; - outline-offset: 2px; -} - -.toggle_button span { - font-size: 1.5rem; - font-weight: 300; - line-height: 1; -} - -/* Content Wrapper */ -.content_wrapper { - display: grid; - grid-template-columns: 1fr; - gap: 2rem; - align-items: start; -} - -.content_wrapper_with_form { - grid-template-columns: 1fr 1fr; -} - -/* List Section */ -.list_section { - margin-bottom: 0; -} - -.list { - list-style: none; - padding: 0; - margin: 0; - display: grid; - gap: 1.5rem; -} - -.list_item { - background: white; - border: 1px solid rgba(0, 0, 0, 0.1); - border-radius: 16px; - padding: 1.75rem; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.06); - transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; - overflow: hidden; -} - -.list_item:hover { - transform: translateY(-4px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 4px 12px rgba(0, 0, 0, 0.08); - border-color: rgba(0, 0, 0, 0.2); -} - -.item_details { - margin: 0; - display: grid; - gap: 1rem; -} - -.detail_row { - display: grid; - grid-template-columns: 140px 1fr; - gap: 1rem; - align-items: start; - padding: 0.875rem 0; - border-bottom: 1px solid rgba(0, 0, 0, 0.08); -} - -.detail_row:first-child { - padding-top: 0; -} - -.detail_row:last-child { - border-bottom: none; - padding-bottom: 0; -} - -.detail_row dt { - font-weight: 600; - color: #000; - font-size: 0.875rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.detail_row dd { - margin: 0; - color: #000; - word-break: break-word; - font-size: 0.95rem; - line-height: 1.6; -} - -.thumbnail { - max-width: 140px; - max-height: 140px; - border-radius: 12px; - object-fit: cover; - border: 2px solid rgba(0, 0, 0, 0.1); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); - transition: transform 0.3s ease, box-shadow 0.3s ease; -} - -.thumbnail:hover { - transform: scale(1.08); - box-shadow: 0 6px 16px rgba(0, 0, 0, 0.16); -} - -.link { - color: #000; - text-decoration: underline; - word-break: break-all; - font-weight: 500; - transition: opacity 0.2s ease; -} - -.link:hover { - opacity: 0.7; -} - -.link:focus-visible { - outline: 2px solid #000; - outline-offset: 2px; - border-radius: 4px; -} - -.remove_button { - margin-top: 1.5rem; - padding: 0.625rem 1.25rem; - background: white; - color: #000; - border: 2px solid #000; - border-radius: 8px; - font-size: 0.875rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} - -.remove_button:hover { - background: #000; - color: white; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); -} - -.remove_button:focus-visible { - outline: 2px solid #000; - outline-offset: 2px; -} - -.remove_button:active { - background: #333; - color: white; -} - -/* Form Styles */ -.form_cont { - background: white; - padding: 2rem; - border-radius: 16px; - border: 1px solid rgba(0, 0, 0, 0.1); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1), 0 2px 6px rgba(0, 0, 0, 0.08); - max-width: 100%; - margin: 0; - animation: slideDown 0.3s cubic-bezier(0.4, 0, 0.2, 1); - position: sticky; - top: 2rem; - align-self: start; - max-height: calc(100vh - 4rem); - overflow-y: auto; -} - -@keyframes slideDown { - from { - opacity: 0; - transform: translateY(-20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.fieldset_cont { - max-width: 700px; - margin: 0 auto; - width: 100%; - display: flex; - flex-direction: column; - gap: 1.5rem; - border: none; - padding: 0; -} - -.form_legend { - font-size: 1.5rem; - font-weight: 700; - color: #000; - margin-bottom: 1rem; - padding: 0; -} - -.checkbox_label { - display: flex; - flex-direction: row !important; - align-items: center; - gap: 0.75rem; -} - -.fieldset_cont label { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.fieldset_cont label span { - font-weight: 600; - color: #000; - font-size: 0.95rem; - margin-bottom: 0.25rem; -} - -.fieldset_cont input[type="text"], -.fieldset_cont input[type="url"], -.fieldset_cont input[type="file"] { - padding: 0.875rem 1rem; - border: 2px solid rgba(0, 0, 0, 0.2); - border-radius: 8px; - font-size: 1rem; - transition: all 0.2s ease; - font-family: inherit; - background: white; - color: #000; - width: 100%; - box-sizing: border-box; -} - -.fieldset_cont input[type="text"]:focus, -.fieldset_cont input[type="url"]:focus { - outline: none; - border-color: #000; - background: white; - box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1); -} - -.fieldset_cont input[type="file"] { - padding: 0.75rem; - cursor: pointer; - background: white; - color: #000; - font-size: 1rem; -} - -.fieldset_cont input[type="file"]::-webkit-file-upload-button { - padding: 0.5rem 1rem; - margin-right: 1rem; - background: white; - border: 2px solid rgba(0, 0, 0, 0.2); - border-radius: 6px; - color: #000; - font-size: 0.875rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; -} - -.fieldset_cont input[type="file"]::-webkit-file-upload-button:hover { - background: #f5f5f5; - border-color: #000; -} - -.fieldset_cont input[type="file"]::file-selector-button { - padding: 0.5rem 1rem; - margin-right: 1rem; - background: white; - border: 2px solid rgba(0, 0, 0, 0.2); - border-radius: 6px; - color: #000; - font-size: 0.875rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; -} - -.fieldset_cont input[type="file"]::file-selector-button:hover { - background: #f5f5f5; - border-color: #000; -} - -.fieldset_cont input[type="file"]:focus { - outline: 2px solid #000; - outline-offset: 2px; - border-radius: 8px; -} - -.fieldset_cont input[type="checkbox"] { - width: 1.25rem; - height: 1.25rem; - cursor: pointer; - border-radius: 6px; - border: 2px solid rgba(0, 0, 0, 0.2); - background: white; - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - position: relative; - transition: all 0.2s ease; -} - -.fieldset_cont input[type="checkbox"]:checked { - background: #000; - border-color: #000; -} - -.fieldset_cont input[type="checkbox"]:checked::after { - content: ''; - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - width: 4px; - height: 8px; - border: solid white; - border-width: 0 2px 2px 0; - transform: translate(-50%, -60%) rotate(45deg); -} - -.fieldset_cont input[type="checkbox"]:hover { - border-color: #000; -} - -.fieldset_cont input[type="checkbox"]:focus { - outline: 2px solid #000; - outline-offset: 2px; -} - -.fieldset_cont input[type="checkbox"]:disabled { - cursor: not-allowed; - opacity: 0.5; - background: #f5f5f5; -} - -.submit_button { - padding: 1rem 2rem; - background: white; - color: #000; - border: 2px solid #000; - border-radius: 8px; - font-size: 1rem; - font-weight: 600; - cursor: pointer; - transition: all 0.2s ease; - margin-top: 1rem; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - width: 100%; -} - -.submit_button:hover { - background: #000; - color: white; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); -} - -.submit_button:focus-visible { - outline: 2px solid #000; - outline-offset: 2px; -} - -.submit_button:active { - background: #333; - color: white; -} - -/* Responsive Design */ -@media (max-width: 768px) { - .header { - flex-direction: column; - align-items: stretch; - gap: 1rem; - } - - .title { - font-size: 1.75rem; - } - - .toggle_button { - width: 100%; - justify-content: center; - } - - .content_wrapper_with_form { - grid-template-columns: 1fr; - } - - .form_cont { - position: static; - margin-bottom: 2rem; - max-height: none; - } - - .detail_row { - grid-template-columns: 1fr; - gap: 0.5rem; - } - - .detail_row dt { - margin-top: 0.5rem; - margin-bottom: 0.25rem; - } - - .list_item { - padding: 1.5rem; - } - - .form_cont { - padding: 1.5rem; - margin-bottom: 2rem; - } -} - -@media (max-width: 480px) { - .title { - font-size: 1.5rem; - } - - .list_item { - padding: 1.25rem; - } - - .form_cont { - padding: 1.25rem; - } -} diff --git a/tsconfig.json b/tsconfig.json index 4b674cf..d8b9323 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,10 @@ { "name": "next" } - ] + ], + "paths": { + "@/*": ["./*"] + } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"]