diff --git a/src/components/layout/EditorShell.tsx b/src/components/layout/EditorShell.tsx index 608080f..2bd55db 100644 --- a/src/components/layout/EditorShell.tsx +++ b/src/components/layout/EditorShell.tsx @@ -1,25 +1,68 @@ +"use client"; + import type { ReactNode } from "react"; +import { IconButton } from "@/components/ui/IconButton"; +import { MenuIcon } from "@/icons/MenuIcon"; type EditorShellProps = { header: ReactNode; sidebar: ReactNode; preview: ReactNode; + /** Whether the mobile controls drawer is open (<=900px only). */ + controlsOpen: boolean; + onCloseControls: () => void; }; -export function EditorShell({ header, sidebar, preview }: EditorShellProps) { +export function EditorShell({ + header, + sidebar, + preview, + controlsOpen, + onCloseControls, +}: EditorShellProps) { return (
{header}
-
+
+ {/* Backdrop behind the mobile drawer. */} + + ) : !open ? ( + + / + + ) : null} +
+ + {showDropdown ? ( +
event.preventDefault()} + className="absolute inset-x-0 top-full z-10 mt-2 overflow-hidden rounded-editor-sm border border-editor-border bg-editor-bg-base py-1 shadow-editor" + > + {status === "loading" ? ( +

Searching…

+ ) : showEmpty ? ( +

+ No matches for “{trimmed}”. +

+ ) : ( + <> + {!trimmed ? ( +

+ Recent +

+ ) : null} + {list.map((result, index) => ( + + ))} + + )} +
+ ) : null} +
+ ); +} diff --git a/src/components/map/MapSideControls.tsx b/src/components/map/MapSideControls.tsx new file mode 100644 index 0000000..af77e36 --- /dev/null +++ b/src/components/map/MapSideControls.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { IconButton } from "@/components/ui/IconButton"; +import { CrosshairIcon } from "@/icons/CrosshairIcon"; +import { GlobeIcon } from "@/icons/GlobeIcon"; +import { MapIcon } from "@/icons/MapIcon"; +import { PatchIcon } from "@/icons/PatchIcon"; +import type { MapViewMode } from "@/types/map"; + +type MapSideControlsProps = { + viewMode: MapViewMode; + onViewModeChange: (mode: MapViewMode) => void; + hasSelection: boolean; + onZoomToSelection: () => void; + showPatch: boolean; + onTogglePatch: () => void; +}; + +const ISLAND_CLASS = + "pointer-events-auto flex flex-col items-center gap-1.5 rounded-editor-sm " + + "border border-editor-border bg-editor-bg-base p-1.5 shadow-editor"; + +// Mobile-only counterpart to the header nav controls. On narrow layouts +// (<=900px, matching EditorShell's stacking breakpoint) the view toggle and the +// crosshair/patch controls move out of the header and float against the right +// edge of the map as two stacked "islands", mirroring the issue #49 mockup. The +// view toggle renders as icon buttons the same size as the other controls. +export function MapSideControls({ + viewMode, + onViewModeChange, + hasSelection, + onZoomToSelection, + showPatch, + onTogglePatch, +}: MapSideControlsProps) { + return ( +
+
+ onViewModeChange("2d")} + > + + + onViewModeChange("sphere")} + > + + +
+ {hasSelection ? ( +
+ + + + + + +
+ ) : null} +
+ ); +} diff --git a/src/components/ui/IconButton.tsx b/src/components/ui/IconButton.tsx index 00d5a54..8a484e4 100644 --- a/src/components/ui/IconButton.tsx +++ b/src/components/ui/IconButton.tsx @@ -10,6 +10,12 @@ type IconButtonProps = { "aria-pressed"?: boolean; /** When set, renders an anchor to this URL instead of a button. */ href?: string; + /** + * "plain" drops the border/background so the button reads as a bare icon — + * used inside the mobile floating islands, which already carry their own + * border and shadow. + */ + variant?: "default" | "plain"; }; // Every IconButton renders inside the editor nav, so the editor sizing/colour @@ -26,6 +32,16 @@ const ICON_BUTTON_PRESSED_CLASS = "hover:text-accent hover:border-[color-mix(in_srgb,var(--accent)_45%,transparent)] " + "hover:bg-[color-mix(in_srgb,var(--accent)_16%,transparent)]"; +// Borderless variant — no border or resting background. +const ICON_BUTTON_PLAIN_CLASS = + "peer grid size-8 place-items-center rounded-editor-sm border border-transparent " + + "bg-transparent text-editor-fg-secondary transition-all duration-200 " + + "hover:bg-editor-bg-secondary hover:text-editor-fg-primary"; + +const ICON_BUTTON_PLAIN_PRESSED_CLASS = + "bg-[color-mix(in_srgb,var(--accent)_16%,transparent)] text-accent " + + "hover:text-accent hover:bg-[color-mix(in_srgb,var(--accent)_16%,transparent)]"; + // Appears below the button on hover or keyboard focus. Decorative only — the // button keeps its aria-label so screen readers do not double-announce. const TOOLTIP_CLASS = @@ -47,11 +63,17 @@ export function IconButton({ "aria-controls": ariaControls, "aria-pressed": ariaPressed, href, + variant = "default", }: IconButtonProps) { const tooltipLabel = tooltip ?? ariaLabel; + const plain = variant === "plain"; const classes = [ - ICON_BUTTON_CLASS, - ariaPressed ? ICON_BUTTON_PRESSED_CLASS : null, + plain ? ICON_BUTTON_PLAIN_CLASS : ICON_BUTTON_CLASS, + ariaPressed + ? plain + ? ICON_BUTTON_PLAIN_PRESSED_CLASS + : ICON_BUTTON_PRESSED_CLASS + : null, className, ] .filter(Boolean) diff --git a/src/icons/GlobeIcon.tsx b/src/icons/GlobeIcon.tsx new file mode 100644 index 0000000..baee5c6 --- /dev/null +++ b/src/icons/GlobeIcon.tsx @@ -0,0 +1,19 @@ +export function GlobeIcon() { + return ( + + ); +} diff --git a/src/icons/MapIcon.tsx b/src/icons/MapIcon.tsx new file mode 100644 index 0000000..9fd8fc0 --- /dev/null +++ b/src/icons/MapIcon.tsx @@ -0,0 +1,19 @@ +export function MapIcon() { + return ( + + ); +} diff --git a/src/icons/SearchIcon.tsx b/src/icons/SearchIcon.tsx new file mode 100644 index 0000000..fa18ce4 --- /dev/null +++ b/src/icons/SearchIcon.tsx @@ -0,0 +1,18 @@ +export function SearchIcon() { + return ( + + ); +} diff --git a/src/lib/search/geocode.ts b/src/lib/search/geocode.ts new file mode 100644 index 0000000..571e065 --- /dev/null +++ b/src/lib/search/geocode.ts @@ -0,0 +1,84 @@ +// Geocoding helpers for the map search box. Address lookups use Nominatim +// (OpenStreetMap) — keyless and CORS-enabled, matching the keyless OpenFreeMap +// tiles the map already uses. Raw coordinate input is parsed locally. + +export type SearchResult = { + id: string; + /** Primary line, e.g. "Jena". */ + label: string; + /** Secondary greyed line, e.g. "Thuringia · Germany" or "Coordinates". */ + detail: string; + lon: number; + lat: number; +}; + +// Accepts "lat, lon", "lat lon", or "lat; lon" with an optional leading sign. +const COORD_PATTERN = + /^\s*(-?\d{1,2}(?:\.\d+)?)\s*[,;\s]\s*(-?\d{1,3}(?:\.\d+)?)\s*$/; + +export function parseCoordinates(query: string): SearchResult | null { + const match = query.match(COORD_PATTERN); + if (!match) return null; + + const lat = Number(match[1]); + const lon = Number(match[2]); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null; + if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; + + return { + id: `coord:${lat},${lon}`, + label: `${lat.toFixed(4)}, ${lon.toFixed(4)}`, + detail: "Coordinates", + lon, + lat, + }; +} + +type NominatimPlace = { + place_id: number; + lat: string; + lon: string; + name?: string; + display_name: string; +}; + +const NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"; + +function toResult(place: NominatimPlace): SearchResult { + const parts = place.display_name.split(", "); + const label = place.name?.trim() || parts[0] || place.display_name; + const rest = parts[0] === label ? parts.slice(1) : parts; + const detail = rest.slice(0, 3).join(" · "); + + return { + id: `osm:${place.place_id}`, + label, + detail, + lon: Number(place.lon), + lat: Number(place.lat), + }; +} + +export async function geocodeAddress( + query: string, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams({ + q: query, + format: "jsonv2", + limit: "6", + }); + + const response = await fetch(`${NOMINATIM_URL}?${params.toString()}`, { + signal, + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + throw new Error("Search request failed"); + } + + const data = (await response.json()) as NominatimPlace[]; + return data + .map(toResult) + .filter((result) => Number.isFinite(result.lon) && Number.isFinite(result.lat)); +} diff --git a/src/lib/search/history.ts b/src/lib/search/history.ts new file mode 100644 index 0000000..6182798 --- /dev/null +++ b/src/lib/search/history.ts @@ -0,0 +1,32 @@ +// Recent map searches, persisted to localStorage so they survive reloads. + +import type { SearchResult } from "@/lib/search/geocode"; + +const STORAGE_KEY = "earthprints:search-history"; +const MAX_ENTRIES = 5; + +export function loadSearchHistory(): SearchResult[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as SearchResult[]; + return Array.isArray(parsed) ? parsed.slice(0, MAX_ENTRIES) : []; + } catch { + return []; + } +} + +export function pushSearchHistory(result: SearchResult): SearchResult[] { + if (typeof window === "undefined") return []; + const next = [ + result, + ...loadSearchHistory().filter((entry) => entry.id !== result.id), + ].slice(0, MAX_ENTRIES); + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // Ignore quota/serialization failures — history is best-effort. + } + return next; +}