From ca8352f52ec494ee76bb63dd6f0b6719642c1a6a Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Tue, 14 Jul 2026 11:34:08 +0300 Subject: [PATCH 1/3] Move map controls into mobile drawer and floating islands (#49) On narrow layouts (<=900px) the header controls move onto the map: the view toggle and crosshair/patch controls become plain icon buttons in floating right-edge islands, and the control panel opens as a full-screen drawer via a menu button beside the logo. --- src/components/layout/EditorShell.tsx | 51 ++++++++++++++-- src/components/layout/Nav.tsx | 74 +++++++++++++++-------- src/components/map/EarthMap.tsx | 14 +++++ src/components/map/MapSideControls.tsx | 83 ++++++++++++++++++++++++++ src/components/ui/IconButton.tsx | 26 +++++++- src/icons/GlobeIcon.tsx | 19 ++++++ src/icons/MapIcon.tsx | 19 ++++++ 7 files changed, 255 insertions(+), 31 deletions(-) create mode 100644 src/components/map/MapSideControls.tsx create mode 100644 src/icons/GlobeIcon.tsx create mode 100644 src/icons/MapIcon.tsx 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. */} + + ))} + + )} +
+ ) : null} +
+ ); +} 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; +} From 82a78f2615f2342f8a87c8f690658360a72f2444 Mon Sep 17 00:00:00 2001 From: Anastasiia Ivanchenko Date: Tue, 14 Jul 2026 11:58:18 +0300 Subject: [PATCH 3/3] Add a clear button to the map search input (#49) --- src/components/map/MapSearch.tsx | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/components/map/MapSearch.tsx b/src/components/map/MapSearch.tsx index 8c81488..c3d68aa 100644 --- a/src/components/map/MapSearch.tsx +++ b/src/components/map/MapSearch.tsx @@ -156,7 +156,32 @@ export function MapSearch({ onSelect, className }: MapSearchProps) { aria-label="Search address or coordinates" className="min-w-0 flex-1 bg-transparent text-sm text-editor-fg-primary placeholder:text-editor-fg-tertiary focus:outline-none" /> - {!query && !open ? ( + {query ? ( + + ) : !open ? ( /