Permit-iframe hardening: - public/embed.php — replace the 302 redirect on unauthenticated visits with an in-iframe HTML "Sign in to view the map" card (HTTP 401) whose primary button uses target="_top" to break the iframe and send the parent window to the SSO portal. The 302 was broken UX inside an iframe because the LUSPA portal refuses to be framed. - public/embed.php + public/.htaccess — strip X-Frame-Options at the embed endpoint (defence in depth). Apache's <Files "embed.php"> Header always unset X-Frame-Options + PHP's header_remove() both ensure the only iframe-policy header on the response is our CSP frame-ancestors (which already allows the permits subdomain). Fixes Safari's "Refused to display ... because it set 'X-Frame-Options' to 'SAMEORIGIN'" when the container's reverse proxy injects it. Import UX refinements: - Spinner overlay (index.html #import-spinner-overlay + main.js showImportSpinner/hideImportSpinner) shown during the file-drop → mapping-modal gap. Wired at the top of each handle*Import and at every error / early-return path; hidden by stageImport() just before openImportMappingModal() so it spans both the JS parse and the SQLocal staging insert. - Per-feature client_uuid tagging — each imported OL feature now carries _externalImportId + _clientUuid set in stageImport(). These tags are the link that lets later edits find the matching staging row, and they are passed through to addExternalImportFeatures. - Geometry-edit persistence — new public callback registry MapView.onFeatureModified(cb) fired from a modifyend listener on _modifyInteraction. main.js handler writes the new WKT (EPSG:4326) back to external_import_features.geometry_wkt via new helper updateExternalImportFeatureGeometry(clientUuid, wkt). Non-imported features carry no tags, so the handler is a no-op for them. - Delete persistence — removefeature listener on each imported layer's source. New helper deleteExternalImportFeature(clientUuid) runs an atomic DELETE + decrement of external_imports.feature_count and broadcasts the changes so the LayerSwitcher badge can recount. - Field-mapping dropdown — sample values + bold field names. New helpers sampleSourceValues(fc) in import-detect.js (picks first non-empty value per attribute, JSON-stringifies objects, collapses whitespace, truncates to 35 chars) and toBoldUnicode(s) in import-modal.js (ASCII letters/digits → Mathematical Alphanumeric Symbols block). Options now read as "𝐮𝐩𝐧 — [12345-6789]"; HTML/CSS bold doesn't render inside <option> elements, so Unicode bold codepoints are the cross-browser way. Workshop deliverables: - LUPMIS2_Improvements_Mar_to_Jun_2026.docx — handout mirroring the slide deck one-to-one (160 paragraphs, branded styling). - LUPMIS2_Workshop_Mar_to_Jun_2026.pptx — 16-slide pptxgenjs deck (16:9 widescreen, brand palette, hero + content + closing masters, embedded staged-upload diagram on slide 9). - LUPMIS2_Staged_Upload_Flow.svg + .png — three swim-lane diagram of the staged-upload pipeline with a dedicated "Client QA Gate" callout. Hand-crafted SVG + 2400 px PNG. save_gps_trail.php diagnosis (no code change, on the database team): the reported "CORS" error is a missing endpoint — Apache returns 404 with no CORS headers and the browser surfaces it as access-control. Once the endpoint is deployed the API server's global CORS handling attaches the right headers and the GPS-trail sync will work without client changes. dist/ rebuilt. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2424 lines
84 KiB
HTML
2424 lines
84 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en" data-bs-theme="light">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||
<meta name="theme-color" content="#005eb8">
|
||
<meta name="description" content="LUPMIS2 Drawing Tools">
|
||
|
||
<!-- PWA Manifest -->
|
||
<link rel="manifest" href="manifest.json">
|
||
<link rel="apple-touch-icon" sizes="192x192" href="app-icons/luspa-192x192.png">
|
||
<link rel="icon" type="image/png" sizes="32x32" href="app-icons/luspa-32x32.png">
|
||
<link rel="icon" type="image/png" sizes="16x16" href="app-icons/luspa-16x16.png">
|
||
|
||
<!-- LUSPA Design System Fonts: Bebas Neue (display) + Exo (body) — self-hosted -->
|
||
<style>
|
||
/* Bebas Neue 400 — latin-ext */
|
||
@font-face {
|
||
font-family: 'Bebas Neue';
|
||
font-style: normal;
|
||
font-weight: 400;
|
||
font-display: swap;
|
||
src: url('/fonts/bebas-neue-latin-ext.woff2') format('woff2');
|
||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||
}
|
||
/* Bebas Neue 400 — latin */
|
||
@font-face {
|
||
font-family: 'Bebas Neue';
|
||
font-style: normal;
|
||
font-weight: 400;
|
||
font-display: swap;
|
||
src: url('/fonts/bebas-neue-latin.woff2') format('woff2');
|
||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||
}
|
||
/* Exo 300-800 — vietnamese */
|
||
@font-face {
|
||
font-family: 'Exo';
|
||
font-style: normal;
|
||
font-weight: 300 800;
|
||
font-display: swap;
|
||
src: url('/fonts/exo-vietnamese.woff2') format('woff2');
|
||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
|
||
}
|
||
/* Exo 300-800 — latin-ext */
|
||
@font-face {
|
||
font-family: 'Exo';
|
||
font-style: normal;
|
||
font-weight: 300 800;
|
||
font-display: swap;
|
||
src: url('/fonts/exo-latin-ext.woff2') format('woff2');
|
||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||
}
|
||
/* Exo 300-800 — latin */
|
||
@font-face {
|
||
font-family: 'Exo';
|
||
font-style: normal;
|
||
font-weight: 300 800;
|
||
font-display: swap;
|
||
src: url('/fonts/exo-latin.woff2') format('woff2');
|
||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||
}
|
||
</style>
|
||
|
||
<title>LUPMIS2 Drawing Tools</title>
|
||
|
||
<style>
|
||
/* LUSPA Design System Tokens
|
||
* Source: lupmis.ammonvictor.com design guidelines
|
||
*
|
||
* Brand Palette:
|
||
* --brand-navy: #1e1a4b (foreground text)
|
||
* --brand-blue-gray: #333f48 (charcoal)
|
||
* --brand-blue-strong: #005eb8 (primary action)
|
||
* --brand-blue-light: #b9d9eb (accent/highlight)
|
||
* --brand-gray-cool: #f2f4f7 (muted background)
|
||
* --brand-green-deep: #006b3f (success)
|
||
* --brand-green-bright: #41b6a6 (teal accent)
|
||
* --brand-brown-muted: #8b6f47 (earth tone)
|
||
* --brand-orange-warm: #ff9e1b (warning)
|
||
* --brand-gray-medium: #7a7a7a (muted text)
|
||
*/
|
||
:root {
|
||
/* Brand palette */
|
||
--brand-navy: #1e1a4b;
|
||
--brand-blue-gray: #333f48;
|
||
--brand-blue-strong: #005eb8;
|
||
--brand-blue-light: #b9d9eb;
|
||
--brand-gray-cool: #f2f4f7;
|
||
--brand-green-deep: #006b3f;
|
||
--brand-green-bright: #41b6a6;
|
||
--brand-brown-muted: #8b6f47;
|
||
--brand-orange-warm: #ff9e1b;
|
||
--brand-gray-medium: #7a7a7a;
|
||
|
||
/* Semantic tokens */
|
||
--primary: var(--brand-blue-strong);
|
||
--primary-foreground: #fff;
|
||
--primary-hover: #004a92;
|
||
--foreground: var(--brand-navy);
|
||
--background: #fff;
|
||
--card: #fff;
|
||
--card-foreground: var(--brand-navy);
|
||
--muted: var(--brand-gray-cool);
|
||
--muted-foreground: var(--brand-gray-medium);
|
||
--accent: var(--brand-blue-light);
|
||
--accent-foreground: var(--brand-navy);
|
||
--success: var(--brand-green-deep);
|
||
--success-foreground: #fff;
|
||
--warning: var(--brand-orange-warm);
|
||
--warning-foreground: var(--brand-navy);
|
||
--destructive: #d4183d;
|
||
--destructive-foreground: #fff;
|
||
--border: #1e1a4b1f;
|
||
--ring: var(--brand-blue-strong);
|
||
|
||
/* Typography */
|
||
--font-display: "Bebas Neue", sans-serif;
|
||
--font-body: "Exo", sans-serif;
|
||
|
||
/* Bootstrap overrides using semantic tokens */
|
||
--bs-primary: var(--brand-blue-strong);
|
||
--bs-primary-rgb: 0, 94, 184;
|
||
--bs-secondary: var(--brand-green-deep);
|
||
--bs-secondary-rgb: 0, 107, 63;
|
||
--bs-body-color: var(--brand-navy);
|
||
|
||
/* Shorthand overrides for component styling */
|
||
--bsOverwrite-primary: var(--brand-blue-strong);
|
||
--bsOverwrite-primary-light: var(--brand-blue-light);
|
||
--bsOverwrite-primary-dark: var(--primary-hover);
|
||
--bsOverwrite-secondary: var(--brand-green-deep);
|
||
--bsOverwrite-text: var(--brand-navy);
|
||
--bsOverwrite-on-primary: #fff;
|
||
|
||
/* Legacy named tokens (mapped to new palette) */
|
||
--luspa-charcoal: var(--brand-blue-gray);
|
||
--luspa-navy: var(--brand-navy);
|
||
--luspa-blue: var(--brand-blue-strong);
|
||
--luspa-blue-light: var(--brand-blue-light);
|
||
--luspa-lavender: var(--brand-gray-cool);
|
||
--luspa-green: var(--brand-green-deep);
|
||
--luspa-lime: var(--brand-green-bright);
|
||
--luspa-tan: var(--brand-brown-muted);
|
||
--luspa-gold: var(--brand-orange-warm);
|
||
|
||
/* Border radius tokens */
|
||
--radius-sm: 0.25rem;
|
||
--radius-md: 0.375rem;
|
||
--radius-lg: 0.5rem;
|
||
--radius-xl: 0.75rem;
|
||
--radius-2xl: 1rem;
|
||
}
|
||
|
||
/* ─── Fieldwork Mode ─── high-contrast + larger touch targets ─── */
|
||
.fieldwork-mode {
|
||
--foreground: #000;
|
||
--background: #fff;
|
||
--card: #fff;
|
||
--card-foreground: #000;
|
||
--primary: #0044aa;
|
||
--primary-foreground: #fff;
|
||
--primary-hover: #003080;
|
||
--muted: #e0e0e0;
|
||
--muted-foreground: #333;
|
||
--accent: #cce0ff;
|
||
--accent-foreground: #000;
|
||
--border: rgba(0,0,0,0.25);
|
||
--success: #005a00;
|
||
--success-foreground: #fff;
|
||
--warning: #b36b00;
|
||
--warning-foreground: #000;
|
||
--destructive: #b80000;
|
||
--destructive-foreground: #fff;
|
||
--ring: #0044aa;
|
||
--bs-body-color: #000;
|
||
}
|
||
|
||
/* Fieldwork: larger dock buttons */
|
||
.fieldwork-mode .dock-btn {
|
||
min-width: 72px;
|
||
min-height: 58px;
|
||
font-size: 1.6rem;
|
||
border-width: 2px;
|
||
}
|
||
.fieldwork-mode .dock-btn-label {
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Fieldwork: bolder navbar */
|
||
.fieldwork-mode .navbar {
|
||
border-bottom-width: 4px;
|
||
}
|
||
.fieldwork-mode .navbar .navbar-brand {
|
||
font-size: 1.6rem;
|
||
}
|
||
|
||
/* Fieldwork: larger offcanvas toggle buttons */
|
||
.fieldwork-mode .offcanvas-toggle {
|
||
width: 44px;
|
||
height: 44px;
|
||
font-size: 1.2rem;
|
||
}
|
||
|
||
/* Fieldwork: thicker bottom dock border */
|
||
.fieldwork-mode .bottom-dock {
|
||
border-top-width: 4px;
|
||
}
|
||
|
||
/* Fieldwork: larger text in cards / lists */
|
||
.fieldwork-mode .card-header h6 {
|
||
font-size: 1rem;
|
||
}
|
||
.fieldwork-mode .list-group-item {
|
||
font-size: 0.95rem;
|
||
padding: 0.65rem 1rem;
|
||
}
|
||
|
||
/* Fieldwork: larger buttons globally */
|
||
.fieldwork-mode .btn {
|
||
font-size: 0.95rem;
|
||
padding: 0.5rem 1rem;
|
||
font-weight: 600;
|
||
}
|
||
.fieldwork-mode .btn-sm {
|
||
font-size: 0.85rem;
|
||
padding: 0.4rem 0.75rem;
|
||
}
|
||
|
||
/* Fieldwork: stronger borders on inputs / form controls */
|
||
.fieldwork-mode .form-control,
|
||
.fieldwork-mode .form-select {
|
||
border-width: 2px;
|
||
border-color: #555;
|
||
font-size: 1rem;
|
||
}
|
||
|
||
/* Fieldwork: bolder map controls (ol-ext) */
|
||
.fieldwork-mode .ol-control button {
|
||
font-size: 1.3rem;
|
||
width: 2.2em;
|
||
height: 2.2em;
|
||
}
|
||
|
||
/* Fieldwork: scale bar text legibility */
|
||
.fieldwork-mode .ol-scale-bar .ol-scale-step-text,
|
||
.fieldwork-mode .ol-scale-bar .ol-scale-text {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
text-shadow: 0 0 4px #fff, 0 0 8px #fff;
|
||
}
|
||
|
||
/* ─── Dark Mode ─── reversed colour scheme ─── */
|
||
.dark-mode {
|
||
--foreground: #e0dff0;
|
||
--background: #131325;
|
||
--card: #1e1e38;
|
||
--card-foreground: #e0dff0;
|
||
--primary: #4d9de6;
|
||
--primary-foreground: #fff;
|
||
--primary-hover: #6fb3f0;
|
||
--muted: #272745;
|
||
--muted-foreground: #9594a8;
|
||
--accent: #1e3a5f;
|
||
--accent-foreground: #e0dff0;
|
||
--border: rgba(255,255,255,0.12);
|
||
--ring: #4d9de6;
|
||
--success: #2dd46a;
|
||
--success-foreground: #131325;
|
||
--warning: #ffb84d;
|
||
--warning-foreground: #131325;
|
||
--destructive: #f04040;
|
||
--destructive-foreground: #fff;
|
||
--bs-body-color: #e0dff0;
|
||
--bs-body-bg: #131325;
|
||
--bs-tertiary-bg: #1e1e38;
|
||
color-scheme: dark;
|
||
}
|
||
|
||
/* Dark: navbar */
|
||
.dark-mode .navbar {
|
||
background-color: #1a1a30 !important;
|
||
box-shadow: 0 1px 6px rgba(0,0,0,0.4);
|
||
}
|
||
|
||
/* Dark: bottom dock */
|
||
.dark-mode .bottom-dock {
|
||
background-color: #1a1a30;
|
||
box-shadow: 0 -2px 10px rgba(0,0,0,0.3);
|
||
}
|
||
.dark-mode .dock-btn {
|
||
border-color: var(--primary);
|
||
color: var(--foreground);
|
||
}
|
||
.dark-mode .dock-btn:hover {
|
||
background-color: var(--muted);
|
||
}
|
||
.dark-mode .dock-btn.active {
|
||
background-color: var(--primary);
|
||
color: var(--primary-foreground);
|
||
}
|
||
|
||
/* Dark: offcanvas panels */
|
||
.dark-mode .offcanvas {
|
||
background-color: var(--background) !important;
|
||
color: var(--foreground) !important;
|
||
}
|
||
.dark-mode .offcanvas-header {
|
||
border-bottom-color: var(--border) !important;
|
||
}
|
||
.dark-mode .btn-close {
|
||
filter: invert(1) grayscale(100%) brightness(200%);
|
||
}
|
||
|
||
/* Dark: cards */
|
||
.dark-mode .card {
|
||
background-color: var(--card) !important;
|
||
color: var(--card-foreground) !important;
|
||
border-color: var(--border) !important;
|
||
}
|
||
|
||
/* Dark: offcanvas toggle buttons */
|
||
.dark-mode .offcanvas-toggle {
|
||
background-color: var(--card);
|
||
color: var(--foreground);
|
||
border-color: var(--border);
|
||
}
|
||
.dark-mode .offcanvas-toggle:hover {
|
||
background-color: var(--primary);
|
||
color: var(--primary-foreground);
|
||
}
|
||
|
||
/* Dark: form controls */
|
||
.dark-mode .form-control,
|
||
.dark-mode .form-select {
|
||
background-color: var(--muted) !important;
|
||
color: var(--foreground) !important;
|
||
border-color: var(--border) !important;
|
||
}
|
||
.dark-mode .form-check-input {
|
||
background-color: var(--muted);
|
||
border-color: var(--muted-foreground);
|
||
}
|
||
.dark-mode .form-check-input:checked {
|
||
background-color: var(--primary);
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
/* Dark: list groups */
|
||
.dark-mode .list-group-item {
|
||
background-color: var(--card) !important;
|
||
color: var(--card-foreground) !important;
|
||
border-color: var(--border) !important;
|
||
}
|
||
|
||
/* Dark: buttons */
|
||
.dark-mode .btn-outline-primary {
|
||
color: var(--primary);
|
||
border-color: var(--primary);
|
||
}
|
||
.dark-mode .btn-outline-danger {
|
||
color: var(--destructive);
|
||
border-color: var(--destructive);
|
||
}
|
||
|
||
/* Dark: text utilities */
|
||
.dark-mode .text-muted {
|
||
color: var(--muted-foreground) !important;
|
||
}
|
||
|
||
/* Dark: measurement tooltips */
|
||
.dark-mode .measure-tooltip {
|
||
background: rgba(30, 30, 56, 0.95);
|
||
color: var(--foreground);
|
||
border-color: var(--primary);
|
||
}
|
||
.dark-mode .measure-tooltip::before {
|
||
border-right-color: var(--primary);
|
||
}
|
||
|
||
/* Dark: OL controls */
|
||
.dark-mode .ol-control button {
|
||
background-color: var(--card) !important;
|
||
color: var(--foreground) !important;
|
||
}
|
||
.dark-mode .ol-control button:hover {
|
||
background-color: var(--primary) !important;
|
||
color: var(--primary-foreground) !important;
|
||
}
|
||
.dark-mode .ol-attribution,
|
||
.dark-mode .ol-attribution a {
|
||
color: var(--muted-foreground) !important;
|
||
}
|
||
|
||
/* Dark: scale bar */
|
||
.dark-mode .ol-scale-bar .ol-scale-step-text,
|
||
.dark-mode .ol-scale-bar .ol-scale-text {
|
||
color: #fff !important;
|
||
text-shadow: 0 0 4px #000, 0 0 8px #000 !important;
|
||
}
|
||
.dark-mode .ol-scale-bar .ol-scale-singlebar-even {
|
||
background-color: #fff !important;
|
||
}
|
||
.dark-mode .ol-scale-bar .ol-scale-singlebar-odd {
|
||
background-color: #999 !important;
|
||
}
|
||
|
||
/* Dark: map drop overlay */
|
||
.dark-mode .map-drop-overlay {
|
||
background: rgba(19, 19, 37, 0.85);
|
||
border-color: var(--primary);
|
||
color: var(--foreground);
|
||
}
|
||
|
||
/* Dark: ol-ext LayerSwitcher */
|
||
.dark-mode .ol-layerswitcher {
|
||
background-color: var(--card) !important;
|
||
}
|
||
.dark-mode .ol-layerswitcher .panel {
|
||
background-color: var(--card) !important;
|
||
color: var(--foreground) !important;
|
||
}
|
||
.dark-mode .ol-layerswitcher .panel li {
|
||
color: var(--foreground);
|
||
}
|
||
.dark-mode .ol-layerswitcher .ol-switchertopdiv,
|
||
.dark-mode .ol-layerswitcher .ol-switcherbottomdiv {
|
||
background: var(--card) !important;
|
||
}
|
||
|
||
/* Dark: alert boxes */
|
||
.dark-mode .alert-danger {
|
||
background-color: rgba(240, 64, 64, 0.15) !important;
|
||
color: var(--destructive) !important;
|
||
border-color: var(--destructive) !important;
|
||
}
|
||
.dark-mode .alert-success {
|
||
background-color: rgba(45, 212, 106, 0.15) !important;
|
||
color: var(--success) !important;
|
||
border-color: var(--success) !important;
|
||
}
|
||
|
||
/* Full height layout */
|
||
html, body {
|
||
height: 100%;
|
||
overflow: hidden;
|
||
color: var(--foreground);
|
||
font-family: var(--font-body);
|
||
}
|
||
|
||
/* Override Bootstrap primary color */
|
||
.btn-primary {
|
||
--bs-btn-bg: var(--bsOverwrite-primary);
|
||
--bs-btn-border-color: var(--bsOverwrite-primary);
|
||
--bs-btn-hover-bg: var(--bsOverwrite-primary-dark);
|
||
--bs-btn-hover-border-color: var(--bsOverwrite-primary-dark);
|
||
--bs-btn-active-bg: var(--bsOverwrite-primary-dark);
|
||
--bs-btn-active-border-color: var(--bsOverwrite-primary-dark);
|
||
--bs-btn-color: var(--bsOverwrite-on-primary);
|
||
--bs-btn-hover-color: var(--bsOverwrite-on-primary);
|
||
--bs-btn-active-color: var(--bsOverwrite-on-primary);
|
||
}
|
||
|
||
.btn-outline-primary {
|
||
--bs-btn-color: var(--bsOverwrite-primary);
|
||
--bs-btn-border-color: var(--bsOverwrite-primary);
|
||
--bs-btn-hover-bg: var(--bsOverwrite-primary);
|
||
--bs-btn-hover-border-color: var(--bsOverwrite-primary);
|
||
--bs-btn-hover-color: var(--bsOverwrite-on-primary);
|
||
--bs-btn-active-bg: var(--bsOverwrite-primary);
|
||
--bs-btn-active-border-color: var(--bsOverwrite-primary);
|
||
--bs-btn-active-color: var(--bsOverwrite-on-primary);
|
||
}
|
||
|
||
.btn-secondary {
|
||
--bs-btn-bg: var(--bsOverwrite-secondary);
|
||
--bs-btn-border-color: var(--bsOverwrite-secondary);
|
||
--bs-btn-color: #fff;
|
||
}
|
||
|
||
.bg-primary {
|
||
background-color: var(--bsOverwrite-primary) !important;
|
||
color: var(--bsOverwrite-on-primary) !important;
|
||
}
|
||
|
||
.text-primary {
|
||
color: var(--bsOverwrite-primary) !important;
|
||
}
|
||
|
||
.border-primary {
|
||
border-color: var(--bsOverwrite-primary) !important;
|
||
}
|
||
|
||
/* Navbar styling — white background with navy text and blue-strong accent */
|
||
.navbar {
|
||
background-color: var(--background) !important;
|
||
border-bottom: 3px solid var(--primary);
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||
}
|
||
|
||
.navbar .navbar-brand {
|
||
color: var(--foreground) !important;
|
||
font-family: var(--font-display);
|
||
font-size: 1.4rem;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
/* Card headers */
|
||
.card-header.bg-primary {
|
||
background-color: var(--primary) !important;
|
||
color: var(--primary-foreground) !important;
|
||
}
|
||
|
||
.card-header.bg-primary h6 {
|
||
color: var(--primary-foreground) !important;
|
||
font-family: var(--font-body);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Modal header */
|
||
.modal-header.bg-primary {
|
||
background-color: var(--primary) !important;
|
||
color: var(--primary-foreground) !important;
|
||
}
|
||
|
||
.modal-header.bg-primary .modal-title {
|
||
color: var(--primary-foreground) !important;
|
||
font-family: var(--font-display);
|
||
font-weight: 400;
|
||
letter-spacing: 0.025em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.modal-header.bg-primary .btn-close-white {
|
||
filter: none;
|
||
}
|
||
|
||
/* Focus states */
|
||
.form-control:focus, .form-select:focus {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 0.25rem rgba(0, 94, 184, 0.2);
|
||
}
|
||
|
||
/* Form labels */
|
||
.form-label {
|
||
color: var(--foreground);
|
||
font-family: var(--font-body);
|
||
}
|
||
|
||
/* Main container - full height.
|
||
100svh = the "small" viewport height (browser toolbar shown). Because
|
||
this app disables scrolling, the toolbar never auto-hides, so svh is
|
||
always accurate AND guarantees the bottom dock stays clear of Safari's
|
||
bottom chrome (Safari resolves 100vh/100dvh taller than the visible
|
||
area, which pushed the dock behind the toolbar and clipped its labels).
|
||
Falls back to 100vh on browsers without svh support. */
|
||
.app-container {
|
||
height: 100vh;
|
||
height: 100svh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* Map and sidebar row */
|
||
.main-content {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* Map container - now takes full space */
|
||
.map-container {
|
||
flex: 1;
|
||
position: relative;
|
||
min-height: 250px;
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
#map {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
/* Drag-and-drop overlay shown when files are dragged over the map */
|
||
.map-drop-overlay {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 9999;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(0, 94, 184, 0.15);
|
||
border: 3px dashed var(--primary, #005eb8);
|
||
border-radius: 8px;
|
||
pointer-events: none;
|
||
opacity: 0;
|
||
transition: opacity 0.15s ease;
|
||
}
|
||
.map-container.drag-over .map-drop-overlay {
|
||
opacity: 1;
|
||
}
|
||
.map-drop-overlay span {
|
||
font-family: var(--font-body, 'Exo', sans-serif);
|
||
font-size: 1.15rem;
|
||
font-weight: 600;
|
||
color: var(--primary, #005eb8);
|
||
background: var(--card, #fff);
|
||
padding: 0.6rem 1.4rem;
|
||
border-radius: 6px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||
}
|
||
|
||
/* Offline indicator */
|
||
#offline-indicator {
|
||
display: none;
|
||
position: absolute;
|
||
top: 10px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
z-index: 1000;
|
||
}
|
||
|
||
body.is-offline #offline-indicator {
|
||
display: block;
|
||
}
|
||
|
||
/* Locations list */
|
||
.locations-list {
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.locations-list {
|
||
max-height: 300px;
|
||
}
|
||
}
|
||
|
||
.location-item {
|
||
cursor: pointer;
|
||
transition: background-color 0.15s;
|
||
color: var(--foreground);
|
||
}
|
||
|
||
.location-item:hover {
|
||
background-color: var(--muted);
|
||
}
|
||
|
||
.location-item h6 {
|
||
color: var(--foreground);
|
||
}
|
||
|
||
/* Category badges */
|
||
.badge-water { background-color: #3b82f6 !important; }
|
||
.badge-school { background-color: #f59e0b !important; }
|
||
.badge-health { background-color: #ef4444 !important; }
|
||
.badge-market { background-color: #8b5cf6 !important; }
|
||
.badge-default { background-color: var(--bsOverwrite-secondary) !important; }
|
||
.badge-other { background-color: #6b7280 !important; }
|
||
|
||
/* Install button */
|
||
#install-btn {
|
||
display: none;
|
||
position: fixed;
|
||
bottom: 20px;
|
||
right: 20px;
|
||
z-index: 1050;
|
||
}
|
||
|
||
/* OL controls stacking context fix — OpenLayers sets z-index:0 on
|
||
.ol-overlaycontainer-stopevent, trapping all controls below the
|
||
offcanvas-toggle buttons (z-index:500). Raising the container
|
||
to 501 lets the LayerSwitcher dropdown render above the toggles.
|
||
pointer-events:none on the container still lets clicks through
|
||
to the toggle buttons underneath. */
|
||
.ol-overlaycontainer-stopevent {
|
||
z-index: 501 !important;
|
||
}
|
||
|
||
/* Alert hint box */
|
||
.alert-light.border-primary {
|
||
border-color: var(--primary) !important;
|
||
color: var(--foreground);
|
||
}
|
||
|
||
/* Badge in navbar and cards */
|
||
.badge.bg-primary {
|
||
background-color: var(--primary) !important;
|
||
color: var(--primary-foreground) !important;
|
||
}
|
||
|
||
/* Headings and text — LUSPA Design System typography */
|
||
h1, h2 {
|
||
font-family: var(--font-display);
|
||
font-weight: 400;
|
||
line-height: 1.25;
|
||
letter-spacing: 0.025em;
|
||
text-transform: uppercase;
|
||
color: var(--foreground);
|
||
}
|
||
|
||
h3, h4, h5, h6 {
|
||
font-family: var(--font-body);
|
||
font-weight: 600;
|
||
line-height: 1.375;
|
||
color: var(--foreground);
|
||
}
|
||
|
||
/* Scrollbar styling */
|
||
.offcanvas-body::-webkit-scrollbar,
|
||
.locations-list::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.offcanvas-body::-webkit-scrollbar-track,
|
||
.locations-list::-webkit-scrollbar-track {
|
||
background: #f1f1f1;
|
||
}
|
||
|
||
.offcanvas-body::-webkit-scrollbar-thumb,
|
||
.locations-list::-webkit-scrollbar-thumb {
|
||
background: var(--primary);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.offcanvas-body::-webkit-scrollbar-thumb:hover,
|
||
.locations-list::-webkit-scrollbar-thumb:hover {
|
||
background: var(--primary-hover);
|
||
}
|
||
|
||
/* Offcanvas toggle buttons on map */
|
||
.offcanvas-toggle {
|
||
position: absolute;
|
||
z-index: 500;
|
||
background-color: var(--card);
|
||
border: 2px solid var(--primary);
|
||
color: var(--primary);
|
||
min-width: 44px;
|
||
min-height: 44px;
|
||
padding: 10px 14px;
|
||
border-radius: var(--radius-lg);
|
||
cursor: pointer;
|
||
font-size: 1.2rem;
|
||
box-shadow: 0 2px 6px rgba(0,0,0,0.15);
|
||
transition: all 0.15s ease;
|
||
/* Touch-friendly */
|
||
-webkit-tap-highlight-color: transparent;
|
||
touch-action: manipulation;
|
||
}
|
||
|
||
.offcanvas-toggle:hover {
|
||
background-color: var(--primary);
|
||
color: var(--primary-foreground);
|
||
transform: scale(1.05);
|
||
}
|
||
|
||
.offcanvas-toggle:active {
|
||
background-color: var(--primary-hover);
|
||
color: var(--primary-foreground);
|
||
transform: scale(0.95);
|
||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||
}
|
||
|
||
.offcanvas-toggle-left {
|
||
left: 10px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.offcanvas-toggle-left:hover {
|
||
transform: translateY(-50%) scale(1.05);
|
||
}
|
||
|
||
.offcanvas-toggle-left:active {
|
||
transform: translateY(-50%) scale(0.95);
|
||
}
|
||
|
||
.offcanvas-toggle-right {
|
||
right: 10px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
}
|
||
|
||
.offcanvas-toggle-right:hover {
|
||
transform: translateY(-50%) scale(1.05);
|
||
}
|
||
|
||
.offcanvas-toggle-right:active {
|
||
transform: translateY(-50%) scale(0.95);
|
||
}
|
||
|
||
.offcanvas-toggle-bottom {
|
||
bottom: calc(80px + env(safe-area-inset-bottom, 0px)); /* Above the dock */
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
}
|
||
|
||
.offcanvas-toggle-bottom:hover {
|
||
transform: translateX(-50%) scale(1.05);
|
||
}
|
||
|
||
.offcanvas-toggle-bottom:active {
|
||
transform: translateX(-50%) scale(0.95);
|
||
}
|
||
|
||
/* Bottom Dock — white card style with blue-strong accent.
|
||
env(safe-area-inset-bottom) adds padding on devices with a
|
||
home indicator / gesture bar (e.g. iPhone notch models).
|
||
The value is 0 on devices without an inset. */
|
||
.bottom-dock {
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 600;
|
||
background-color: var(--card);
|
||
border-top: 3px solid var(--primary);
|
||
padding: 8px 16px calc(8px + env(safe-area-inset-bottom, 0px));
|
||
display: flex;
|
||
justify-content: space-around;
|
||
align-items: center;
|
||
box-shadow: 0 -2px 10px rgba(0,0,0,0.08);
|
||
}
|
||
|
||
.dock-btn {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 60px;
|
||
min-height: 50px;
|
||
padding: 6px 12px;
|
||
background-color: transparent;
|
||
border: 1px solid var(--primary);
|
||
color: var(--foreground);
|
||
font-size: 1.4rem;
|
||
cursor: pointer;
|
||
border-radius: var(--radius-lg);
|
||
transition: all 0.15s ease;
|
||
/* Touch-friendly */
|
||
-webkit-tap-highlight-color: transparent;
|
||
touch-action: manipulation;
|
||
}
|
||
|
||
.dock-btn:hover {
|
||
background-color: var(--muted);
|
||
}
|
||
|
||
.dock-btn:active {
|
||
background-color: var(--accent);
|
||
transform: scale(0.92);
|
||
}
|
||
|
||
.dock-btn.active {
|
||
background-color: var(--primary);
|
||
color: var(--primary-foreground);
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.dock-btn.active:hover {
|
||
background-color: var(--primary-hover);
|
||
color: var(--primary-foreground);
|
||
}
|
||
|
||
.dock-btn-label {
|
||
font-size: 0.65rem;
|
||
margin-top: 2px;
|
||
font-weight: 500;
|
||
font-family: var(--font-body);
|
||
}
|
||
|
||
/* EditBar colour picker inline styling */
|
||
.ol-editbar .ol-colorpicker button {
|
||
padding: 2px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
/* Snap-guides toggle — highlighted when active */
|
||
.ol-snap-toggle.ol-active button {
|
||
background: var(--primary) !important;
|
||
color: var(--primary-foreground, #fff) !important;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/* Touch-friendly improvements for forms and buttons */
|
||
.form-control, .form-select {
|
||
min-height: 44px;
|
||
font-size: 16px; /* Prevents iOS zoom on focus */
|
||
}
|
||
|
||
.btn {
|
||
min-height: 44px;
|
||
-webkit-tap-highlight-color: transparent;
|
||
touch-action: manipulation;
|
||
}
|
||
|
||
.btn:active {
|
||
transform: scale(0.97);
|
||
}
|
||
|
||
/* Location list items - larger touch targets */
|
||
.location-item {
|
||
cursor: pointer;
|
||
transition: background-color 0.15s;
|
||
color: var(--foreground);
|
||
min-height: 60px;
|
||
-webkit-tap-highlight-color: transparent;
|
||
touch-action: manipulation;
|
||
}
|
||
|
||
.location-item:hover {
|
||
background-color: var(--muted);
|
||
}
|
||
|
||
.location-item:active {
|
||
background-color: var(--accent);
|
||
}
|
||
|
||
.location-item h6 {
|
||
color: var(--foreground);
|
||
}
|
||
|
||
/* Message log in the right panel */
|
||
.message-log {
|
||
max-height: 260px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.message-log-entry {
|
||
font-size: 0.82rem;
|
||
border-color: var(--border, #eee) !important;
|
||
background: transparent;
|
||
}
|
||
|
||
/* ol-ext GeolocationButton styling */
|
||
.ol-geobt {
|
||
top: auto !important;
|
||
bottom: 90px !important;
|
||
right: 10px !important;
|
||
left: auto !important;
|
||
}
|
||
|
||
.ol-geobt button {
|
||
background-color: var(--primary) !important;
|
||
color: var(--primary-foreground) !important;
|
||
min-width: 44px;
|
||
min-height: 44px;
|
||
}
|
||
|
||
.ol-geobt button:hover {
|
||
background-color: var(--primary-hover) !important;
|
||
}
|
||
|
||
/* ================================
|
||
Expandable "My Location" control
|
||
(replaces the ol-ext GeolocationButton — see MapView._createLocationControl)
|
||
================================ */
|
||
.ls-locate-toggle {
|
||
position: absolute;
|
||
right: 10px;
|
||
bottom: 90px;
|
||
width: 44px;
|
||
height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--primary);
|
||
color: var(--primary-foreground);
|
||
border: none;
|
||
border-radius: var(--radius-md);
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||
cursor: pointer;
|
||
z-index: 100;
|
||
font-size: 20px;
|
||
transition: background 0.15s;
|
||
}
|
||
.ls-locate-toggle:hover,
|
||
.ls-locate-toggle.active { background: var(--primary-hover); }
|
||
.ls-locate-toggle.recording {
|
||
background: #d32f2f;
|
||
animation: ls-locate-pulse 1.4s ease-out infinite;
|
||
}
|
||
|
||
/* Sub-button cluster — expands to the LEFT of the main button */
|
||
.ls-locate-actions {
|
||
position: absolute;
|
||
right: 62px; /* 10 + 44 + 8 gap */
|
||
bottom: 90px;
|
||
display: flex;
|
||
gap: 8px;
|
||
opacity: 0;
|
||
transform: translateX(8px);
|
||
pointer-events: none;
|
||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||
z-index: 100;
|
||
}
|
||
.ls-locate-actions.open {
|
||
opacity: 1;
|
||
transform: none;
|
||
pointer-events: auto;
|
||
}
|
||
.ls-locate-btn {
|
||
width: 44px;
|
||
height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: var(--card);
|
||
color: var(--primary);
|
||
border: 2px solid var(--primary);
|
||
border-radius: var(--radius-md);
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
transition: all 0.12s;
|
||
}
|
||
.ls-locate-btn:hover { background: var(--muted); }
|
||
.ls-locate-record.recording {
|
||
background: #d32f2f;
|
||
color: #fff;
|
||
border-color: #d32f2f;
|
||
}
|
||
@keyframes ls-locate-pulse {
|
||
0% { box-shadow: 0 0 0 0 rgba(211,47,47,0.55); }
|
||
70% { box-shadow: 0 0 0 10px rgba(211,47,47,0); }
|
||
100% { box-shadow: 0 0 0 0 rgba(211,47,47,0); }
|
||
}
|
||
|
||
/* ================================
|
||
Navbar live GPS readout
|
||
================================ */
|
||
.gps-readout {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex: 0 1 auto;
|
||
min-width: 0;
|
||
padding: 3px 10px;
|
||
border-radius: 999px;
|
||
background: var(--muted, #f1f3f5);
|
||
color: var(--muted-foreground, #6b7280);
|
||
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||
font-size: 0.72rem;
|
||
line-height: 1.15;
|
||
white-space: nowrap;
|
||
transition: color 0.2s, background 0.2s;
|
||
}
|
||
.gps-readout .bi-broadcast { font-size: 0.85rem; opacity: 0.7; }
|
||
.gps-readout-body { display: flex; flex-direction: column; min-width: 0; }
|
||
.gps-coords { font-weight: 600; }
|
||
.gps-meta { display: flex; gap: 5px; opacity: 0.85; }
|
||
/* Active fix: tint green-ish; colour-coded further from JS via quality class */
|
||
.gps-readout.active { background: rgba(16,185,129,0.12); color: var(--foreground, #1f2937); }
|
||
.gps-readout.recording { background: rgba(211,47,47,0.12); }
|
||
.gps-readout.quality-good .bi-broadcast { color: #10b981; opacity: 1; }
|
||
.gps-readout.quality-fair .bi-broadcast { color: #f59e0b; opacity: 1; }
|
||
.gps-readout.quality-poor .bi-broadcast { color: #ef4444; opacity: 1; }
|
||
|
||
/* Tight navbars: drop the brand text and the secondary meta line first */
|
||
@media (max-width: 600px) {
|
||
.brand-text { display: none; }
|
||
.gps-readout { font-size: 0.68rem; padding: 3px 8px; }
|
||
}
|
||
@media (max-width: 380px) {
|
||
.gps-readout .gps-meta { display: none; }
|
||
}
|
||
|
||
/* ol-ext SearchNominatim styling */
|
||
.ol-search {
|
||
top: 10px !important;
|
||
left: 40px !important;
|
||
right: auto !important;
|
||
}
|
||
|
||
.ol-search button {
|
||
background-color: var(--primary) !important;
|
||
color: var(--primary-foreground) !important;
|
||
min-width: 24px;
|
||
min-height: 24px;
|
||
border: none;
|
||
}
|
||
|
||
.ol-search button:hover {
|
||
background-color: var(--primary-hover) !important;
|
||
}
|
||
|
||
.ol-search input {
|
||
border: 2px solid var(--primary) !important;
|
||
border-radius: var(--radius-md);
|
||
padding: 8px 12px;
|
||
font-size: 14px;
|
||
font-family: var(--font-body);
|
||
min-height: 40px;
|
||
color: var(--foreground);
|
||
width: 200px;
|
||
}
|
||
|
||
@media (min-width: 500px) {
|
||
.ol-search input {
|
||
width: 280px;
|
||
}
|
||
}
|
||
|
||
.ol-search input:focus {
|
||
outline: none;
|
||
border-color: var(--primary) !important;
|
||
box-shadow: 0 0 0 2px rgba(0, 94, 184, 0.2);
|
||
}
|
||
|
||
.ol-search input::placeholder {
|
||
color: var(--muted-foreground);
|
||
}
|
||
|
||
.ol-search ul.autocomplete {
|
||
background: var(--card);
|
||
border: 2px solid var(--primary);
|
||
border-top: none;
|
||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||
}
|
||
|
||
.ol-search ul.autocomplete li {
|
||
padding: 10px 14px;
|
||
cursor: pointer;
|
||
border-bottom: 1px solid var(--border);
|
||
color: var(--foreground);
|
||
font-size: 13px;
|
||
font-family: var(--font-body);
|
||
min-height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.ol-search ul.autocomplete li:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.ol-search ul.autocomplete li:hover,
|
||
.ol-search ul.autocomplete li.select {
|
||
background-color: var(--muted) !important;
|
||
}
|
||
|
||
.ol-search ul.autocomplete li b {
|
||
color: var(--foreground);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Add Location Popup Form on Map */
|
||
.map-add-location-popup {
|
||
background: var(--card);
|
||
border-radius: var(--radius-xl);
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||
min-width: 280px;
|
||
max-width: 320px;
|
||
font-family: var(--font-body);
|
||
font-size: 14px;
|
||
z-index: 1000;
|
||
border: 2px solid var(--primary);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.add-location-popup-header {
|
||
background-color: var(--primary);
|
||
color: var(--primary-foreground);
|
||
padding: 10px 14px;
|
||
font-weight: 600;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
|
||
.add-location-popup-close {
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.5rem;
|
||
line-height: 1;
|
||
color: var(--primary-foreground);
|
||
cursor: pointer;
|
||
padding: 0 4px;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.add-location-popup-close:hover {
|
||
opacity: 1;
|
||
}
|
||
|
||
#map-add-location-form {
|
||
padding: 14px;
|
||
}
|
||
|
||
.add-location-popup-field {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.add-location-popup-field label {
|
||
display: block;
|
||
margin-bottom: 4px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--foreground);
|
||
}
|
||
|
||
.add-location-popup-field input,
|
||
.add-location-popup-field select,
|
||
.add-location-popup-field textarea {
|
||
width: 100%;
|
||
padding: 8px 10px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-md);
|
||
font-size: 14px;
|
||
font-family: var(--font-body);
|
||
min-height: 40px;
|
||
color: var(--foreground);
|
||
}
|
||
|
||
.add-location-popup-field input:focus,
|
||
.add-location-popup-field select:focus,
|
||
.add-location-popup-field textarea:focus {
|
||
outline: none;
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 2px rgba(0, 94, 184, 0.2);
|
||
}
|
||
|
||
.add-location-popup-field textarea {
|
||
resize: vertical;
|
||
min-height: 60px;
|
||
}
|
||
|
||
.add-location-popup-coords {
|
||
margin-bottom: 12px;
|
||
padding: 8px 10px;
|
||
background: var(--muted);
|
||
border-radius: var(--radius-md);
|
||
color: var(--muted-foreground);
|
||
font-family: monospace;
|
||
}
|
||
|
||
.add-location-popup-submit {
|
||
width: 100%;
|
||
padding: 10px 16px;
|
||
background-color: var(--primary);
|
||
color: var(--primary-foreground);
|
||
border: none;
|
||
border-radius: var(--radius-md);
|
||
font-size: 14px;
|
||
font-family: var(--font-body);
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
min-height: 44px;
|
||
transition: background-color 0.15s;
|
||
}
|
||
|
||
.add-location-popup-submit:hover {
|
||
background-color: var(--primary-hover);
|
||
}
|
||
|
||
.add-location-popup-submit:active {
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
/* Navbar location count as clickable button */
|
||
.location-count-btn {
|
||
background: none;
|
||
border: none;
|
||
padding: 0;
|
||
cursor: pointer;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
|
||
.location-count-btn .badge {
|
||
transition: transform 0.15s;
|
||
}
|
||
|
||
.location-count-btn:hover .badge {
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.location-count-btn:active .badge {
|
||
transform: scale(0.95);
|
||
}
|
||
|
||
/* Navbar Menu button — opens the right-side account menu */
|
||
.navbar-menu-btn {
|
||
background: var(--primary, #005eb8);
|
||
color: var(--primary-foreground, #fff);
|
||
border: none;
|
||
border-radius: 8px;
|
||
width: 40px;
|
||
height: 40px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 1.4rem;
|
||
cursor: pointer;
|
||
transition: background .15s, transform .12s;
|
||
-webkit-tap-highlight-color: transparent;
|
||
}
|
||
.navbar-menu-btn:hover {
|
||
background: var(--primary-hover, #004a92);
|
||
}
|
||
.navbar-menu-btn:active {
|
||
transform: scale(0.95);
|
||
}
|
||
/* Subtle red dot when no session — visual cue without being intrusive */
|
||
.navbar-menu-btn[data-state="no-session"]::after,
|
||
.navbar-menu-btn[data-state="unauthenticated"]::after {
|
||
content: '';
|
||
position: absolute;
|
||
width: 9px;
|
||
height: 9px;
|
||
background: var(--brand-orange-warm, #ff9e1b);
|
||
border: 2px solid var(--primary, #005eb8);
|
||
border-radius: 50%;
|
||
transform: translate(14px, -14px);
|
||
}
|
||
.navbar-menu-btn { position: relative; }
|
||
|
||
/* Offcanvas styling — white card with blue-strong header */
|
||
.offcanvas {
|
||
background-color: var(--background) !important;
|
||
color: var(--foreground);
|
||
}
|
||
|
||
.offcanvas-header {
|
||
background-color: var(--primary);
|
||
border-bottom: none;
|
||
}
|
||
|
||
.offcanvas-title {
|
||
color: var(--primary-foreground);
|
||
font-family: var(--font-display);
|
||
font-weight: 400;
|
||
letter-spacing: 0.025em;
|
||
text-transform: uppercase;
|
||
font-size: 1.25rem;
|
||
}
|
||
|
||
.offcanvas-header .btn-close {
|
||
filter: invert(1) grayscale(100%) brightness(200%);
|
||
}
|
||
|
||
.offcanvas-body {
|
||
color: var(--foreground);
|
||
}
|
||
|
||
/* Right offcanvas - Data Entry panel */
|
||
.offcanvas-end {
|
||
width: 320px !important;
|
||
background-color: var(--bs-body-bg) !important;
|
||
}
|
||
|
||
@media (min-width: 400px) {
|
||
.offcanvas-end {
|
||
width: 380px !important;
|
||
}
|
||
}
|
||
|
||
.offcanvas-end .offcanvas-header {
|
||
background-color: var(--primary);
|
||
color: var(--primary-foreground);
|
||
}
|
||
|
||
.offcanvas-end .offcanvas-body {
|
||
background-color: var(--background);
|
||
color: var(--foreground);
|
||
padding: 1rem;
|
||
}
|
||
|
||
/* Locations list in offcanvas - can be taller now without form */
|
||
.offcanvas-end .locations-list {
|
||
max-height: calc(100vh - 280px);
|
||
max-height: calc(100dvh - 280px);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
/* Bottom offcanvas height */
|
||
.offcanvas-bottom {
|
||
height: 40vh;
|
||
max-height: 400px;
|
||
}
|
||
|
||
/* ================================
|
||
Map Tools - Measurement Tooltips
|
||
================================ */
|
||
.measure-tooltip {
|
||
position: relative;
|
||
background: rgba(255, 255, 255, 0.95);
|
||
border-radius: var(--radius-md);
|
||
padding: 6px 10px;
|
||
white-space: nowrap;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
font-family: var(--font-body);
|
||
color: var(--foreground);
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||
border: 2px solid var(--primary);
|
||
}
|
||
|
||
.measure-tooltip-static {
|
||
background-color: var(--primary);
|
||
color: var(--primary-foreground);
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.measure-tooltip-static::before {
|
||
border-top-color: var(--primary);
|
||
}
|
||
|
||
/* Arrow pointing to measurement point */
|
||
.measure-tooltip::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: -10px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
border: 5px solid transparent;
|
||
border-right-color: var(--primary);
|
||
}
|
||
|
||
/* ================================
|
||
Map Tools - Control Bar Styling
|
||
================================ */
|
||
.map-tools-bar {
|
||
position: absolute;
|
||
top: 70px;
|
||
left: 10px;
|
||
z-index: 100;
|
||
}
|
||
|
||
.map-tools-bar .ol-bar {
|
||
background: var(--card);
|
||
border-radius: var(--radius-lg);
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
|
||
border: 2px solid var(--primary);
|
||
padding: 4px;
|
||
}
|
||
|
||
.map-tools-bar button {
|
||
background: var(--card);
|
||
border: none;
|
||
min-width: 40px;
|
||
min-height: 40px;
|
||
border-radius: var(--radius-md);
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.map-tools-bar button:hover {
|
||
background: var(--muted);
|
||
}
|
||
|
||
.map-tools-bar button.ol-active,
|
||
.map-tools-bar button:active {
|
||
background: var(--primary);
|
||
}
|
||
|
||
.map-tools-bar .tool-icon {
|
||
font-size: 18px;
|
||
}
|
||
|
||
/* ScaleBar - position above the bottom dock with 4px gap */
|
||
.ol-scale-bar {
|
||
bottom: calc(85px + env(safe-area-inset-bottom, 0px)) !important;
|
||
left: 10px !important;
|
||
}
|
||
|
||
.ol-scale-bar .ol-scale-step-text {
|
||
color: var(--foreground) !important;
|
||
font-family: var(--font-body) !important;
|
||
font-size: 11px !important;
|
||
text-shadow: 0 0 3px var(--background), 0 0 6px var(--background) !important;
|
||
}
|
||
|
||
.ol-scale-bar .ol-scale-text {
|
||
color: var(--foreground) !important;
|
||
font-family: var(--font-body) !important;
|
||
font-size: 11px !important;
|
||
text-shadow: 0 0 3px var(--background), 0 0 6px var(--background) !important;
|
||
}
|
||
|
||
.ol-scale-bar .ol-scale-singlebar-even {
|
||
background-color: var(--foreground) !important;
|
||
}
|
||
|
||
.ol-scale-bar .ol-scale-singlebar-odd {
|
||
background-color: var(--muted-foreground) !important;
|
||
}
|
||
|
||
/* ol-ext Bar overrides */
|
||
.ol-control.ol-bar {
|
||
padding: 0;
|
||
background: transparent;
|
||
}
|
||
|
||
.ol-control.ol-bar .ol-bar {
|
||
display: flex;
|
||
gap: 2px;
|
||
}
|
||
|
||
.ol-bar.ol-group {
|
||
display: flex;
|
||
gap: 2px;
|
||
}
|
||
|
||
/* ----------------------------------------------------------------
|
||
Small-screen drawing toolbar: the EditBar is a single nowrap row
|
||
by default and overflows narrow phones. Below the sm breakpoint we
|
||
let it wrap and push the action group (undo / redo / save / snap)
|
||
onto its own second row so every tool stays reachable.
|
||
---------------------------------------------------------------- */
|
||
/* Flex line-break used to start the toolbar's second row. Inert (removed
|
||
from flow) on wider screens; activated inside the sm media query. */
|
||
.ol-editbar-break {
|
||
display: none;
|
||
}
|
||
|
||
/* ----------------------------------------------------------------
|
||
Iframe embed mode (see public/embed.php + src/embed-bridge.js).
|
||
The body class is set by the inline script injected by embed.php
|
||
(`embed embed-mode-permit`). In permit mode the iframe is hosted
|
||
inside the permitting app, so all LUPMIS2 chrome is hidden —
|
||
only the map and its floating controls remain visible. The map
|
||
container expands to fill the viewport.
|
||
---------------------------------------------------------------- */
|
||
body.embed-mode-permit .navbar,
|
||
body.embed-mode-permit .bottom-dock,
|
||
body.embed-mode-permit .offcanvas,
|
||
body.embed-mode-permit .offcanvas-toggle,
|
||
body.embed-mode-permit #install-btn,
|
||
body.embed-mode-permit #offline-indicator,
|
||
body.embed-mode-permit .map-tools-bar {
|
||
display: none !important;
|
||
}
|
||
body.embed-mode-permit .main-content,
|
||
body.embed-mode-permit .map-container {
|
||
flex: 1 1 auto;
|
||
height: 100%;
|
||
}
|
||
body.embed-mode-permit #map {
|
||
height: 100svh;
|
||
height: 100dvh;
|
||
}
|
||
|
||
@media (max-width: 576px) {
|
||
.ol-editbar.ol-bar {
|
||
/* NOTE: display must NOT be !important — ol-ext toggles the bar via an
|
||
inline `display:none`/`''` (setVisible). A `!important` here would beat
|
||
that inline style and keep the toolbar permanently visible even when
|
||
Draw mode is off. Plain `display:flex` only applies when visible. */
|
||
display: flex;
|
||
flex-wrap: wrap !important;
|
||
justify-content: center;
|
||
align-items: center;
|
||
row-gap: 4px;
|
||
white-space: normal !important;
|
||
max-width: calc(100vw - 12px);
|
||
}
|
||
|
||
/* Full-width, zero-height break → forces everything after it (the
|
||
action group + Split + Merge) onto a shared second row. */
|
||
.ol-editbar.ol-bar > .ol-editbar-break {
|
||
display: block;
|
||
flex-basis: 100%;
|
||
width: 100%;
|
||
height: 0;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
/* Right-align the second row (action group + Split + Merge). The auto
|
||
left-margin pushes them to the right end of the line, clearing the
|
||
far-left zone where an active tool's option bar (e.g. Select's
|
||
Delete/Info) drops down from row 1 — which otherwise overlaps them. */
|
||
.ol-editbar.ol-bar > .ol-editbar-actions {
|
||
/* !important: ol-ext's `.ol-control.ol-bar .ol-control { margin:0 }`
|
||
has equal specificity and loads later, so it would otherwise cancel
|
||
this auto-margin and the group would stay glued to the left. */
|
||
margin-left: auto !important;
|
||
}
|
||
|
||
/* Pull the second row (action group + Split + Merge — everything after
|
||
the line-break) up ~10px. The zero-height break sits on its own flex
|
||
line, stacking two row-gaps above the row; this negative top margin
|
||
closes that extra space so row 2 aligns nicely under row 1. */
|
||
.ol-editbar.ol-bar > .ol-editbar-break ~ .ol-control {
|
||
margin-top: -8px !important;
|
||
}
|
||
}
|
||
</style>
|
||
<script type="module" crossorigin src="/assets/index-DRlPLJxg.js"></script>
|
||
<link rel="modulepreload" crossorigin href="/assets/openlayers-D8ReJJOp.js">
|
||
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
|
||
<link rel="modulepreload" crossorigin href="/assets/ol-ext-P1ircg-B.js">
|
||
<link rel="modulepreload" crossorigin href="/assets/shpjs-iyObTF9J.js">
|
||
<link rel="stylesheet" crossorigin href="/assets/openlayers-BtPuoxOl.css">
|
||
<link rel="stylesheet" crossorigin href="/assets/bootstrap-BtmJYOxZ.css">
|
||
<link rel="stylesheet" crossorigin href="/assets/ol-ext-BgKrOIxx.css">
|
||
<link rel="stylesheet" crossorigin href="/assets/index-Dp-9_Fz_.css">
|
||
</head>
|
||
<body>
|
||
<div class="app-container">
|
||
<!-- Navbar - visible on all screens -->
|
||
<nav class="navbar py-2">
|
||
<div class="container-fluid">
|
||
<span class="navbar-brand mb-0 h1">
|
||
<span class="me-2">🌍</span><span class="brand-text">LUPMIS2 Drawing Tools</span>
|
||
</span>
|
||
|
||
<!-- Live GPS status: lon/lat, accuracy (precision) and satellites.
|
||
Satellites show "—" on the web (the Geolocation API does not expose
|
||
them); a native build can populate the field. -->
|
||
<div class="gps-readout" id="gps-readout" title="Live GPS status">
|
||
<i class="bi bi-broadcast" aria-hidden="true"></i>
|
||
<span class="gps-readout-body">
|
||
<span class="gps-coords" id="gps-coords">GPS off</span>
|
||
<span class="gps-meta">
|
||
<span id="gps-accuracy">—</span>
|
||
<span class="gps-sep">·</span>
|
||
<span id="gps-sats">— sat</span>
|
||
</span>
|
||
</span>
|
||
</div>
|
||
|
||
<div class="d-flex align-items-center gap-2">
|
||
<button type="button"
|
||
class="location-count-btn"
|
||
data-bs-toggle="offcanvas"
|
||
data-bs-target="#offcanvasRight"
|
||
aria-controls="offcanvasRight"
|
||
title="View saved locations">
|
||
<span class="badge" style="background-color: var(--primary); color: var(--primary-foreground);" id="location-count-mobile">0</span>
|
||
</button>
|
||
|
||
<!-- Menu button — opens the right-side account menu -->
|
||
<button type="button"
|
||
class="navbar-menu-btn"
|
||
id="menu-btn"
|
||
data-bs-toggle="offcanvas"
|
||
data-bs-target="#menuOffcanvas"
|
||
aria-controls="menuOffcanvas"
|
||
title="Open menu">
|
||
<i class="bi bi-list"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<!-- Main content area -->
|
||
<div class="main-content">
|
||
<!-- Map -->
|
||
<div class="map-container">
|
||
<div id="offline-indicator">
|
||
<span class="badge bg-danger fs-6">
|
||
📴 OFFLINE
|
||
</span>
|
||
</div>
|
||
<div id="map"></div>
|
||
<div class="map-drop-overlay"><span><i class="bi bi-file-earmark-arrow-up me-2"></i>Drop file to import (.shp .geojson .kml)</span></div>
|
||
|
||
<!-- Offcanvas toggle buttons -->
|
||
<button class="offcanvas-toggle offcanvas-toggle-left"
|
||
type="button"
|
||
data-bs-toggle="offcanvas"
|
||
data-bs-target="#offcanvasLeft"
|
||
aria-controls="offcanvasLeft"
|
||
title="Open left panel">
|
||
<i class="bi bi-chevron-left"></i>
|
||
</button>
|
||
|
||
<button class="offcanvas-toggle offcanvas-toggle-right"
|
||
type="button"
|
||
data-bs-toggle="offcanvas"
|
||
data-bs-target="#offcanvasRight"
|
||
aria-controls="offcanvasRight"
|
||
title="Open right panel">
|
||
<i class="bi bi-chevron-right"></i>
|
||
</button>
|
||
|
||
<button class="offcanvas-toggle offcanvas-toggle-bottom"
|
||
type="button"
|
||
data-bs-toggle="offcanvas"
|
||
data-bs-target="#offcanvasBottom"
|
||
aria-controls="offcanvasBottom"
|
||
title="Open bottom panel">
|
||
<i class="bi bi-chevron-down"></i>
|
||
</button>
|
||
|
||
<!-- Bottom Dock -->
|
||
<div class="bottom-dock">
|
||
<button class="dock-btn active" type="button" id="dock-btn-add-location" title="Add Location Mode">
|
||
<span>📍</span>
|
||
<span class="dock-btn-label">Add</span>
|
||
</button>
|
||
<button class="dock-btn" type="button" id="dock-btn-measure-circle" title="Measure Circle">
|
||
<span>⭕</span>
|
||
<span class="dock-btn-label">Circle</span>
|
||
</button>
|
||
<button class="dock-btn" type="button" id="dock-btn-measure-line" title="Measure Distance">
|
||
<span>📏</span>
|
||
<span class="dock-btn-label">Distance</span>
|
||
</button>
|
||
<button class="dock-btn" type="button" id="dock-btn-measure-area" title="Measure Area">
|
||
<span>⬛</span>
|
||
<span class="dock-btn-label">Area</span>
|
||
</button>
|
||
<button class="dock-btn" type="button" id="dock-btn-draw" title="Draw / Edit Features">
|
||
<span>✏️</span>
|
||
<span class="dock-btn-label">Digitise</span>
|
||
</button>
|
||
<button class="dock-btn" type="button" id="dock-btn-clear" title="Clear Measurements">
|
||
<span>🗑️</span>
|
||
<span class="dock-btn-label">Clear</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Install PWA button -->
|
||
<button id="install-btn" class="btn btn-primary btn-lg shadow">
|
||
📲 Install App
|
||
</button>
|
||
|
||
<!-- Status Modal -->
|
||
<div class="modal fade" id="statusModal" tabindex="-1" aria-labelledby="statusModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-primary">
|
||
<h5 class="modal-title" id="statusModalLabel">ℹ️ Database Status</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div id="status-content">
|
||
<div class="text-center py-4">
|
||
<div class="spinner-border text-primary" role="status">
|
||
<span class="visually-hidden">Loading...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Table Content Modal -->
|
||
<div class="modal fade" id="tableContentModal" tabindex="-1" aria-labelledby="tableContentModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-primary">
|
||
<h5 class="modal-title" id="tableContentModalLabel"><i class="bi bi-table me-2"></i>Table</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body p-0">
|
||
<div id="table-content-body">
|
||
<div class="text-center py-4">
|
||
<div class="spinner-border text-primary" role="status">
|
||
<span class="visually-hidden">Loading...</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<small class="text-muted me-auto" id="table-content-info"></small>
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- GIS export modal — opened from the Export GIS button on the Area /
|
||
Circle Analysis popup. Lets the user pick a format and rename the
|
||
output fields before downloading. Wired up in src/export-gis-modal.js. -->
|
||
<div class="modal fade" id="exportGisModal" tabindex="-1"
|
||
aria-labelledby="exportGisModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-primary">
|
||
<h5 class="modal-title" id="exportGisModalLabel">
|
||
<i class="bi bi-globe me-2"></i>Export intersecting features
|
||
</h5>
|
||
<button type="button" class="btn-close btn-close-white"
|
||
data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<!-- Summary -->
|
||
<div class="mb-3">
|
||
<strong id="export-gis-summary"></strong>
|
||
</div>
|
||
|
||
<!-- Format -->
|
||
<div class="mb-3">
|
||
<label class="form-label fw-bold d-block">Format</label>
|
||
<div class="btn-group" role="group" aria-label="Export format">
|
||
<input type="radio" class="btn-check" name="export-gis-format"
|
||
id="export-gis-fmt-geojson" value="geojson" checked>
|
||
<label class="btn btn-outline-primary" for="export-gis-fmt-geojson">
|
||
GeoJSON
|
||
</label>
|
||
<input type="radio" class="btn-check" name="export-gis-format"
|
||
id="export-gis-fmt-shp" value="shp">
|
||
<label class="btn btn-outline-primary" for="export-gis-fmt-shp">
|
||
Shapefile
|
||
</label>
|
||
<input type="radio" class="btn-check" name="export-gis-format"
|
||
id="export-gis-fmt-kml" value="kml">
|
||
<label class="btn btn-outline-primary" for="export-gis-fmt-kml">
|
||
KML
|
||
</label>
|
||
</div>
|
||
<div class="form-text" id="export-gis-format-hint">
|
||
GeoJSON keeps all attributes as-is and is the safest default.
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Filename -->
|
||
<div class="mb-3">
|
||
<label for="export-gis-filename" class="form-label fw-bold">
|
||
Filename (without extension)
|
||
</label>
|
||
<input type="text" class="form-control" id="export-gis-filename"
|
||
value="area_analysis">
|
||
</div>
|
||
|
||
<!-- Field rename table -->
|
||
<div>
|
||
<label class="form-label fw-bold mb-1">Field names</label>
|
||
<div class="form-text mb-2">
|
||
Each source attribute on the left; rename it on the right
|
||
(or clear the input to drop the field from the export).
|
||
Shapefiles cap field names at 10 characters — the table flags
|
||
any over-length names when SHP is selected.
|
||
</div>
|
||
<div class="table-responsive" style="max-height:340px;">
|
||
<table class="table table-sm table-hover align-middle mb-0">
|
||
<thead class="table-light" style="position:sticky;top:0;z-index:1;">
|
||
<tr>
|
||
<th style="width:42%">Source field</th>
|
||
<th>Export as</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="export-gis-fields-tbody">
|
||
<!-- Populated at runtime -->
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-link text-muted me-auto"
|
||
data-bs-dismiss="modal">Cancel</button>
|
||
<button type="button" class="btn btn-primary" id="export-gis-go">
|
||
<i class="bi bi-download me-1"></i>Export
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Import spinner — shown while a dropped file is being parsed and staged
|
||
(Shapefile decompression in particular can take several seconds).
|
||
Hidden once the mapping modal opens or an error occurs. -->
|
||
<div id="import-spinner-overlay" class="d-none position-fixed top-0 start-0 w-100 h-100"
|
||
style="z-index: 1080; background: rgba(255,255,255,0.85);
|
||
align-items: center; justify-content: center;"
|
||
role="status" aria-live="polite">
|
||
<div class="text-center"
|
||
style="background:#fff;border:1px solid #e5e7eb;border-radius:12px;
|
||
padding:28px 36px;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
|
||
<div class="spinner-border text-primary mb-3"
|
||
style="width:3rem;height:3rem;" aria-hidden="true"></div>
|
||
<div class="fw-bold" style="color:var(--primary,#1e1a4b);font-size:1rem;">
|
||
Parsing imported file…
|
||
</div>
|
||
<div class="text-muted mt-1" style="font-size:0.85rem;">
|
||
<span id="import-spinner-filename"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Import-mapping modal — shown after a file is dropped on the map.
|
||
Wired up in src/import-modal.js. Lets the user pick a target type
|
||
and map source fields to LUPMIS2 columns; on save the staging row
|
||
is updated (status: 'other' | 'mapped'). See
|
||
LUPMIS2_Import_Upload_Design.docx §3. -->
|
||
<div class="modal fade" id="importMappingModal" tabindex="-1"
|
||
aria-labelledby="importMappingModalLabel" aria-hidden="true">
|
||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||
<div class="modal-content">
|
||
<div class="modal-header bg-primary">
|
||
<h5 class="modal-title" id="importMappingModalLabel">
|
||
<i class="bi bi-file-earmark-arrow-up me-2"></i>Imported dataset
|
||
</h5>
|
||
<button type="button" class="btn-close btn-close-white"
|
||
data-bs-dismiss="modal" aria-label="Close"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<!-- Summary -->
|
||
<div class="mb-3">
|
||
<div class="d-flex flex-wrap gap-2 align-items-baseline">
|
||
<strong id="import-modal-filename" class="text-truncate"></strong>
|
||
<span class="text-muted" id="import-modal-summary"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Target-type dropdown -->
|
||
<div class="mb-3">
|
||
<label for="import-modal-target" class="form-label fw-bold">
|
||
Target type
|
||
</label>
|
||
<select class="form-select" id="import-modal-target">
|
||
<!-- Populated from TARGET_TYPES at runtime -->
|
||
</select>
|
||
<div class="form-text" id="import-modal-target-hint">
|
||
Choose <em>Other (view only)</em> if this dataset is for display
|
||
only and should not be uploaded to the database.
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Field-mapping table — only shown when a real target type is chosen -->
|
||
<div id="import-modal-fields-wrap">
|
||
<label class="form-label fw-bold mb-1">Field mapping</label>
|
||
<div class="form-text mb-2">
|
||
Each LUPMIS2 column is matched to a source field where possible.
|
||
Choose <em>(none)</em> to leave a column unfilled.
|
||
</div>
|
||
<div class="table-responsive" style="max-height:340px;">
|
||
<table class="table table-sm table-hover align-middle mb-0">
|
||
<thead class="table-light" style="position:sticky;top:0;z-index:1;">
|
||
<tr>
|
||
<th style="width:42%">LUPMIS2 column</th>
|
||
<th>Source field</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="import-modal-fields-tbody">
|
||
<!-- Populated at runtime -->
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-link text-muted me-auto"
|
||
id="import-modal-cancel" data-bs-dismiss="modal">
|
||
Cancel (keep as view-only)
|
||
</button>
|
||
<button type="button" class="btn btn-outline-primary"
|
||
id="import-modal-save">
|
||
<i class="bi bi-check2 me-1"></i>Save mapping
|
||
</button>
|
||
<button type="button" class="btn btn-primary"
|
||
id="import-modal-save-upload">
|
||
<i class="bi bi-cloud-arrow-up me-1"></i>Save + Upload now
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Left Offcanvas -->
|
||
<div class="offcanvas offcanvas-start" tabindex="-1" id="offcanvasLeft" aria-labelledby="offcanvasLeftLabel">
|
||
<div class="offcanvas-header">
|
||
<h5 class="offcanvas-title" id="offcanvasLeftLabel"><i class="bi bi-chevron-left me-2"></i>Tools</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||
</div>
|
||
<div class="offcanvas-body">
|
||
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="local-data-btn">
|
||
<i class="bi bi-database me-2"></i>Local Data
|
||
</button>
|
||
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-shp-btn">
|
||
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import .shp
|
||
</button>
|
||
<input type="file" id="shp-file-input" accept=".zip,.shp,.dbf,.shx,.prj" multiple class="d-none">
|
||
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-geojson-btn">
|
||
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import GeoJSON
|
||
</button>
|
||
<input type="file" id="geojson-file-input" accept=".geojson,.json" class="d-none">
|
||
<button type="button" class="btn btn-outline-primary w-100 mb-3" id="import-kml-btn">
|
||
<i class="bi bi-file-earmark-arrow-up me-2"></i>Import KML
|
||
</button>
|
||
<input type="file" id="kml-file-input" accept=".kml,.kmz" class="d-none">
|
||
<div id="file-import-alert" class="alert alert-danger alert-dismissible fade show d-none mb-3" role="alert">
|
||
<small class="message-text"></small>
|
||
<button type="button" class="btn-close btn-close-sm" data-bs-dismiss="alert" aria-label="Close"></button>
|
||
</div>
|
||
<div id="imported-layers-info" class="d-none mb-3"></div>
|
||
<div id="local-data-stats" class="d-none">
|
||
<div class="card">
|
||
<div class="card-header bg-primary py-2 d-flex justify-content-between align-items-center">
|
||
<h6 class="mb-0"><i class="bi bi-database me-2"></i>Local Database Tables</h6>
|
||
<button type="button" class="btn btn-sm btn-outline-light"
|
||
id="clear-all-cached-btn"
|
||
title="Delete all cached map layers. They will be re-downloaded on next app start.">
|
||
<i class="bi bi-arrow-clockwise me-1"></i>Refresh cached layers
|
||
</button>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<table class="table table-sm table-striped mb-0">
|
||
<thead>
|
||
<tr>
|
||
<th class="ps-3">Table</th>
|
||
<th class="text-end">Records</th>
|
||
<th class="text-end pe-3" style="width:3rem;"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="local-data-tbody">
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Right Offcanvas - Saved Locations Panel -->
|
||
<!-- Right Offcanvas — Account Menu -->
|
||
<div class="offcanvas offcanvas-end" tabindex="-1" id="menuOffcanvas" aria-labelledby="menuOffcanvasLabel"
|
||
style="max-width:90vw;width:340px;">
|
||
<div class="offcanvas-header" style="background:var(--primary);color:#fff;">
|
||
<h5 class="offcanvas-title" id="menuOffcanvasLabel">Menu</h5>
|
||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||
</div>
|
||
<div class="offcanvas-body">
|
||
<!-- User section -->
|
||
<div class="mb-3">
|
||
<h6 class="text-muted text-uppercase mb-2" style="font-size:0.75rem;letter-spacing:0.06em;font-weight:700;">User</h6>
|
||
<div class="d-flex align-items-center gap-3">
|
||
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0"
|
||
id="menu-user-avatar"
|
||
style="width:44px;height:44px;background:var(--brand-navy);color:#fff;font-weight:700;font-size:17px;font-family:var(--font-body);">
|
||
<i class="bi bi-person-fill"></i>
|
||
</div>
|
||
<div style="flex:1;min-width:0;">
|
||
<div id="menu-user-name"
|
||
style="font-family:var(--font-body);font-weight:700;color:var(--brand-navy);font-size:1rem;line-height:1.2;">
|
||
--
|
||
</div>
|
||
<small id="menu-user-email" class="text-muted d-block" style="line-height:1.3;"></small>
|
||
</div>
|
||
</div>
|
||
<div class="mt-2 small text-muted" id="menu-user-detail">District: --</div>
|
||
</div>
|
||
|
||
<hr>
|
||
|
||
<!-- Sign-out (only visible when authenticated) -->
|
||
<button type="button" id="menu-signout-btn"
|
||
class="btn btn-outline-danger w-100 d-none"
|
||
style="font-weight:600;">
|
||
<i class="bi bi-box-arrow-right me-2"></i>Return to Landing Page
|
||
</button>
|
||
|
||
<!-- Sign-in prompt (only visible when NOT authenticated) -->
|
||
<a id="menu-signin-link"
|
||
href="https://lupmis4luspa.org/"
|
||
target="_blank" rel="noopener"
|
||
class="btn btn-outline-primary w-100 d-none"
|
||
style="font-weight:600;">
|
||
<i class="bi bi-box-arrow-in-right me-2"></i>Sign in at lupmis4luspa.org
|
||
</a>
|
||
|
||
<!-- Dev-mode info (only visible when window.LUPMIS_SESSION is undefined) -->
|
||
<div id="menu-no-session-note" class="alert alert-warning small mt-2 d-none mb-0" role="alert" style="font-size:0.8em;">
|
||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||
<strong>No session injected.</strong> Page not served via <code>index.php</code>
|
||
— running in dev mode or Apache is bypassing PHP.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="offcanvas offcanvas-end" tabindex="-1" id="offcanvasRight" aria-labelledby="offcanvasRightLabel">
|
||
<div class="offcanvas-header">
|
||
<h5 class="offcanvas-title" id="offcanvasRightLabel"><i class="bi bi-geo-alt me-2"></i>Saved Locations</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||
</div>
|
||
<div class="offcanvas-body">
|
||
<!-- Alert messages -->
|
||
<div id="error-message" class="alert alert-danger alert-dismissible fade show d-none" role="alert">
|
||
<span class="message-text"></span>
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||
</div>
|
||
<div id="success-message" class="alert alert-success alert-dismissible fade show d-none" role="alert">
|
||
<span class="message-text"></span>
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||
</div>
|
||
<div id="warning-message" class="alert alert-warning alert-dismissible fade show d-none" role="alert">
|
||
<span class="message-text"></span>
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||
</div>
|
||
|
||
<!-- Tip -->
|
||
<div class="alert alert-light border-start border-4 border-primary py-2 mb-3" role="alert">
|
||
<small>💡 Tap on the map to add a new location.</small>
|
||
</div>
|
||
|
||
<!-- Saved Locations Card -->
|
||
<div class="card">
|
||
<div class="card-header bg-primary py-2 d-flex justify-content-between align-items-center">
|
||
<h6 class="mb-0">📍 Locations</h6>
|
||
<span class="badge bg-light text-dark" id="location-count">0</span>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div id="locations-list" class="locations-list list-group list-group-flush">
|
||
<div class="text-center text-muted py-4">
|
||
<div class="spinner-border spinner-border-sm me-2" role="status">
|
||
<span class="visually-hidden">Loading...</span>
|
||
</div>
|
||
Loading...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card-footer bg-transparent">
|
||
<div class="btn-group btn-group-sm w-100" role="group">
|
||
<button type="button" class="btn btn-outline-secondary" id="fit-btn" title="Fit map to all markers">
|
||
🔍 Fit All
|
||
</button>
|
||
<button type="button" class="btn btn-outline-secondary" id="export-btn" title="Export database">
|
||
📥 Export
|
||
</button>
|
||
<button type="button" class="btn btn-outline-secondary" id="exportGeoJSON-btn" title="Export GeoJSON">
|
||
📥 Export
|
||
</button>
|
||
<button type="button" class="btn btn-outline-secondary" id="status-btn" title="Show database status">
|
||
ℹ️ Status
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Message Log -->
|
||
<div class="card mt-3" id="message-log-card">
|
||
<div class="card-header bg-transparent py-2 d-flex justify-content-between align-items-center">
|
||
<h6 class="mb-0" style="font-family:var(--font-body);font-weight:700;"><i class="bi bi-journal-text me-1"></i> Messages</h6>
|
||
<button class="btn btn-sm btn-link text-muted p-0" id="clear-message-log" title="Clear messages">
|
||
<i class="bi bi-trash3"></i>
|
||
</button>
|
||
</div>
|
||
<div class="card-body p-0">
|
||
<div id="message-log" class="message-log list-group list-group-flush">
|
||
<div class="text-center text-muted py-3">
|
||
<small>No messages yet.</small>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bottom Offcanvas -->
|
||
<div class="offcanvas offcanvas-bottom" tabindex="-1" id="offcanvasBottom" aria-labelledby="offcanvasBottomLabel">
|
||
<div class="offcanvas-header">
|
||
<h5 class="offcanvas-title" id="offcanvasBottomLabel"><i class="bi bi-gear me-2"></i>Settings</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
|
||
</div>
|
||
<div class="offcanvas-body">
|
||
<div class="row g-3">
|
||
<!-- Account info lives in a click-popover on the map chip
|
||
(id="account-chip"), not inside the Settings panel. -->
|
||
|
||
<!-- Fieldwork Mode -->
|
||
<div class="col-12 col-md-6 col-lg-4">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<div class="d-flex align-items-center justify-content-between">
|
||
<div>
|
||
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Fieldwork Mode</h6>
|
||
<small class="text-muted">High-contrast colours and larger touch targets for bright sunlight and field conditions.</small>
|
||
</div>
|
||
<div class="form-check form-switch ms-3">
|
||
<input class="form-check-input" type="checkbox" role="switch" id="fieldwork-mode-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Dark Mode -->
|
||
<div class="col-12 col-md-6 col-lg-4">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<div class="d-flex align-items-center justify-content-between">
|
||
<div>
|
||
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Dark Mode</h6>
|
||
<small class="text-muted">Reduce glare and save battery with a dark colour scheme.</small>
|
||
</div>
|
||
<div class="form-check form-switch ms-3">
|
||
<input class="form-check-input" type="checkbox" role="switch" id="dark-mode-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Measurement System -->
|
||
<div class="col-12 col-md-6 col-lg-4">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<div class="d-flex align-items-center justify-content-between">
|
||
<div>
|
||
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Measurement System</h6>
|
||
<small class="text-muted">Switch between Metric (m, km) and Imperial (ft, mi, acres) units.</small>
|
||
</div>
|
||
<div class="form-check form-switch ms-3">
|
||
<input class="form-check-input" type="checkbox" role="switch" id="measurement-system-toggle" style="width:3rem;height:1.5rem;cursor:pointer;">
|
||
<label class="form-check-label ms-1" id="measurement-system-label" for="measurement-system-toggle" style="font-size:0.8rem;font-weight:600;min-width:55px;">Metric</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Default Base Map -->
|
||
<div class="col-12 col-md-6 col-lg-4">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<div class="d-flex align-items-center justify-content-between">
|
||
<div style="flex:1;min-width:0;">
|
||
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">Default Base Map</h6>
|
||
<small class="text-muted">Base map shown on app start. Saved on this device.</small>
|
||
</div>
|
||
<div class="ms-3" style="min-width:140px;">
|
||
<select class="form-select form-select-sm" id="default-basemap-select" aria-label="Default base map">
|
||
<option value="topo">Topographic</option>
|
||
<option value="osm">OpenStreetMap</option>
|
||
<option value="satellite">Satellite</option>
|
||
<option value="googlesat">Google Sat</option>
|
||
<option value="carto-light">Carto Light</option>
|
||
<option value="carto-dark">Carto Dark</option>
|
||
<option value="none">None (no base map)</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Offline Map Tiles -->
|
||
<div class="col-12 col-md-6 col-lg-8">
|
||
<div class="card">
|
||
<div class="card-body">
|
||
<div class="d-flex align-items-start justify-content-between mb-2">
|
||
<div style="flex:1;min-width:0;">
|
||
<h6 class="mb-1" style="font-family:var(--font-body);font-weight:700;">
|
||
<i class="bi bi-map me-1"></i>Offline Map Tiles
|
||
</h6>
|
||
<small class="text-muted">
|
||
Map tiles you've already viewed are cached on this device so they work offline.
|
||
Tiles are cached automatically as you browse, or you can pre-download a region.
|
||
</small>
|
||
</div>
|
||
<div class="ms-3 d-flex gap-2 flex-shrink-0">
|
||
<button type="button" class="btn btn-sm btn-primary"
|
||
id="download-tiles-btn" style="white-space:nowrap;">
|
||
<i class="bi bi-cloud-download me-1"></i>Download offline map
|
||
</button>
|
||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||
id="clear-tiles-btn" style="white-space:nowrap;">
|
||
<i class="bi bi-trash3 me-1"></i>Clear cached tiles
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div id="tile-cache-stats" class="small">
|
||
<div class="text-muted fst-italic">Loading…</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Download Offline Map modal -->
|
||
<div class="modal fade" id="offline-download-modal" tabindex="-1" aria-labelledby="offline-download-title" aria-hidden="true">
|
||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h5 class="modal-title" id="offline-download-title">
|
||
<i class="bi bi-cloud-download me-2"></i>Download Offline Map
|
||
</h5>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" id="offline-download-close-btn"></button>
|
||
</div>
|
||
|
||
<!-- Form view (shown until Start is clicked) -->
|
||
<div class="modal-body" id="offline-download-form-view">
|
||
<p class="text-muted small mb-3">
|
||
Pre-fetch map tiles so they're available when you're offline.
|
||
Only the OpenStreetMap and Topographic base maps can be downloaded;
|
||
other providers don't permit bulk caching.
|
||
</p>
|
||
|
||
<!-- Base map -->
|
||
<div class="mb-3">
|
||
<label for="offline-basemap-select" class="form-label fw-bold">Base map</label>
|
||
<select class="form-select form-select-sm" id="offline-basemap-select">
|
||
<option value="topo">Topographic</option>
|
||
<option value="osm">OpenStreetMap</option>
|
||
</select>
|
||
</div>
|
||
|
||
<!-- Area -->
|
||
<div class="mb-3">
|
||
<label class="form-label fw-bold mb-1">Area to download</label>
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-view" value="view" checked>
|
||
<label class="form-check-label" for="offline-area-view">
|
||
Current map view
|
||
<span class="text-muted small" id="offline-area-view-info"></span>
|
||
</label>
|
||
</div>
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-district" value="district">
|
||
<label class="form-check-label" for="offline-area-district">
|
||
District boundary
|
||
<span class="text-muted small" id="offline-area-district-info"></span>
|
||
</label>
|
||
</div>
|
||
<div class="form-check">
|
||
<input class="form-check-input" type="radio" name="offline-area" id="offline-area-ghana" value="ghana">
|
||
<label class="form-check-label" for="offline-area-ghana">
|
||
Entire Ghana <span class="text-muted small">(very large — only attempt over fast Wi-Fi)</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Zoom range -->
|
||
<div class="mb-3">
|
||
<label class="form-label fw-bold mb-1">Zoom levels</label>
|
||
<div class="row g-2 align-items-center">
|
||
<div class="col-auto"><label for="offline-min-zoom" class="form-label small mb-0">Min</label></div>
|
||
<div class="col-auto">
|
||
<input type="number" class="form-control form-control-sm" id="offline-min-zoom" min="6" max="19" value="10" style="width:5em;">
|
||
</div>
|
||
<div class="col-auto"><label for="offline-max-zoom" class="form-label small mb-0">Max</label></div>
|
||
<div class="col-auto">
|
||
<input type="number" class="form-control form-control-sm" id="offline-max-zoom" min="6" max="19" value="15" style="width:5em;">
|
||
</div>
|
||
<div class="col text-muted small">10 = regional · 13 = neighbourhood · 16 = building</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Estimate -->
|
||
<div class="alert alert-info py-2 px-3 mb-3" id="offline-estimate" style="font-size:0.9em;">
|
||
<strong>Estimated download:</strong>
|
||
<span id="offline-estimate-detail">Calculating…</span>
|
||
</div>
|
||
|
||
<!-- Acknowledgement -->
|
||
<div class="form-check mb-2">
|
||
<input class="form-check-input" type="checkbox" id="offline-ack-check">
|
||
<label class="form-check-label small" for="offline-ack-check">
|
||
I understand this counts against the tile provider's usage quota
|
||
and will use mobile data if I'm not on Wi-Fi.
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Progress view (shown during download) -->
|
||
<div class="modal-body d-none" id="offline-download-progress-view">
|
||
<div class="text-center mb-3">
|
||
<div class="fs-5 fw-bold" id="offline-progress-percent">0%</div>
|
||
<div class="small text-muted" id="offline-progress-counts">0 of 0 tiles</div>
|
||
</div>
|
||
<div class="progress mb-3" style="height:1.25em;">
|
||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
||
role="progressbar" id="offline-progress-bar"
|
||
aria-valuemin="0" aria-valuemax="100" style="width:0%;"></div>
|
||
</div>
|
||
<div class="row text-center small text-muted">
|
||
<div class="col"><div class="fw-bold text-body" id="offline-progress-ok">0</div>fetched</div>
|
||
<div class="col"><div class="fw-bold text-body" id="offline-progress-failed">0</div>failed</div>
|
||
<div class="col"><div class="fw-bold text-body" id="offline-progress-eta">—</div>remaining</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Done view (shown after completion) -->
|
||
<div class="modal-body d-none" id="offline-download-done-view">
|
||
<div class="text-center py-3">
|
||
<i class="bi bi-check-circle-fill text-success" style="font-size:3rem;"></i>
|
||
<div class="fs-5 fw-bold mt-2" id="offline-done-title">Download complete</div>
|
||
<div class="text-muted" id="offline-done-detail"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-footer">
|
||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" id="offline-download-cancel-btn">
|
||
Cancel
|
||
</button>
|
||
<button type="button" class="btn btn-primary" id="offline-download-start-btn" disabled>
|
||
<i class="bi bi-cloud-download me-1"></i>Start download
|
||
</button>
|
||
<button type="button" class="btn btn-primary d-none" id="offline-download-close-done-btn" data-bs-dismiss="modal">
|
||
Close
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Polyfill crypto.randomUUID for non-secure contexts (HTTP) -->
|
||
<!-- Must run before module imports (SQLocal/coincident require it) -->
|
||
<script>
|
||
if (typeof crypto !== 'undefined' && !crypto.randomUUID) {
|
||
crypto.randomUUID = function () {
|
||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, function (c) {
|
||
return (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16);
|
||
});
|
||
};
|
||
}
|
||
</script>
|
||
|
||
<!-- Main application script -->
|
||
</body>
|
||
</html>
|