1237 lines
35 KiB
HTML
1237 lines
35 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="id">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>WebGIS — Point of Interest Pontianak</title>
|
|
|
|
<!-- Leaflet CSS -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
|
|
|
<!-- Google Fonts -->
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link
|
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
|
|
rel="stylesheet">
|
|
|
|
<style>
|
|
/* ── Reset & Base ─────────────────────────────── */
|
|
*,
|
|
*::before,
|
|
*::after {
|
|
box-sizing: border-box;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
:root {
|
|
--c-bg: #fafaf9;
|
|
--c-surface: #ede8e2;
|
|
--c-surface2: #e5dfd8;
|
|
--c-border: #ddd8d2;
|
|
--c-text: #201515;
|
|
--c-muted: #7a7067;
|
|
--c-accent: #0d7490;
|
|
--c-accent-h: #0a5f7a;
|
|
--c-danger: #ef4444;
|
|
--c-warn: #f59e0b;
|
|
--c-wa: #25d366;
|
|
--c-wa-h: #1fad53;
|
|
--radius: 12px;
|
|
--shadow: 0 4px 12px rgba(32, 21, 21, .08);
|
|
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
--font-mono: var(--font-body);
|
|
}
|
|
|
|
html,
|
|
body {
|
|
height: 100%;
|
|
font-family: var(--font-body);
|
|
background: var(--c-bg);
|
|
color: var(--c-text);
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* ── Layout ──────────────────────────────────── */
|
|
.app-wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
}
|
|
|
|
/* ── Header ──────────────────────────────────── */
|
|
.app-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 20px;
|
|
height: 56px;
|
|
background: var(--c-surface);
|
|
border-bottom: 1px solid var(--c-border);
|
|
flex-shrink: 0;
|
|
gap: 16px;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.app-logo {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.app-logo svg {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.app-logo-title {
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
letter-spacing: -.3px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.app-logo-sub {
|
|
font-size: 11px;
|
|
color: var(--c-muted);
|
|
font-family: var(--font-mono);
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.header-badge {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 5px 12px;
|
|
background: rgba(46, 160, 67, .12);
|
|
border: 1px solid rgba(46, 160, 67, .3);
|
|
border-radius: 20px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--c-accent-h);
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.header-badge .dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
background: var(--c-accent);
|
|
animation: pulse 2s infinite;
|
|
}
|
|
|
|
@keyframes pulse {
|
|
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
transform: scale(1);
|
|
}
|
|
|
|
50% {
|
|
opacity: .5;
|
|
transform: scale(.8);
|
|
}
|
|
}
|
|
|
|
.header-hint {
|
|
font-size: 12px;
|
|
color: var(--c-muted);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
}
|
|
|
|
.header-hint kbd {
|
|
background: var(--c-border);
|
|
border-radius: 4px;
|
|
padding: 2px 6px;
|
|
font-family: var(--font-mono);
|
|
font-size: 11px;
|
|
}
|
|
|
|
/* ── Map Container ───────────────────────────── */
|
|
#map {
|
|
flex: 1;
|
|
width: 100%;
|
|
cursor: crosshair;
|
|
}
|
|
|
|
/* ── Counter Badge ───────────────────────────── */
|
|
.poi-counter {
|
|
position: fixed;
|
|
bottom: 28px;
|
|
left: 20px;
|
|
z-index: 999;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 16px;
|
|
background: var(--c-surface);
|
|
border: 1px solid var(--c-border);
|
|
border-radius: 20px;
|
|
font-size: 13px;
|
|
font-family: var(--font-mono);
|
|
color: var(--c-muted);
|
|
box-shadow: var(--shadow);
|
|
transition: all .3s ease;
|
|
}
|
|
|
|
.poi-counter strong {
|
|
color: var(--c-accent-h);
|
|
font-size: 15px;
|
|
}
|
|
|
|
/* ── Leaflet Popup Override ───────────────────── */
|
|
.leaflet-popup-content-wrapper {
|
|
background: var(--c-surface) !important;
|
|
border: 1px solid var(--c-border) !important;
|
|
border-radius: var(--radius) !important;
|
|
box-shadow: var(--shadow) !important;
|
|
padding: 0 !important;
|
|
color: var(--c-text) !important;
|
|
min-width: 280px;
|
|
}
|
|
|
|
.leaflet-popup-content {
|
|
margin: 0 !important;
|
|
width: 100% !important;
|
|
}
|
|
|
|
.leaflet-popup-tip-container {
|
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, .4));
|
|
}
|
|
|
|
.leaflet-popup-tip {
|
|
background: var(--c-border) !important;
|
|
}
|
|
|
|
.leaflet-popup-close-button {
|
|
color: var(--c-muted) !important;
|
|
font-size: 20px !important;
|
|
top: 10px !important;
|
|
right: 10px !important;
|
|
width: 24px !important;
|
|
height: 24px !important;
|
|
display: flex !important;
|
|
align-items: center !important;
|
|
justify-content: center !important;
|
|
line-height: 1 !important;
|
|
}
|
|
|
|
/* ── Form Popup ───────────────────────────────── */
|
|
.form-popup {
|
|
padding: 20px;
|
|
}
|
|
|
|
.form-popup-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-bottom: 18px;
|
|
padding-bottom: 14px;
|
|
border-bottom: 1px solid var(--c-border);
|
|
}
|
|
|
|
.form-popup-icon {
|
|
width: 36px;
|
|
height: 36px;
|
|
border-radius: 8px;
|
|
background: rgba(46, 160, 67, .15);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.form-popup-title {
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.form-popup-coords {
|
|
font-size: 10px;
|
|
color: var(--c-muted);
|
|
font-family: var(--font-mono);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
color: var(--c-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: .6px;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.form-group input[type="text"],
|
|
.form-group input[type="tel"],
|
|
.form-group select {
|
|
width: 100%;
|
|
padding: 8px 12px;
|
|
background: var(--c-bg);
|
|
border: 1px solid var(--c-border);
|
|
border-radius: 7px;
|
|
color: var(--c-text);
|
|
font-family: var(--font-body);
|
|
font-size: 13px;
|
|
transition: border-color .2s;
|
|
outline: none;
|
|
}
|
|
|
|
.form-group input:focus,
|
|
.form-group select:focus {
|
|
border-color: var(--c-accent);
|
|
}
|
|
|
|
.form-group input::placeholder {
|
|
color: var(--c-muted);
|
|
}
|
|
|
|
/* Radio group */
|
|
.radio-group {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.radio-option {
|
|
flex: 1;
|
|
position: relative;
|
|
}
|
|
|
|
.radio-option input[type="radio"] {
|
|
position: absolute;
|
|
opacity: 0;
|
|
width: 0;
|
|
height: 0;
|
|
}
|
|
|
|
.radio-option label {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding: 8px;
|
|
background: var(--c-bg);
|
|
border: 1px solid var(--c-border);
|
|
border-radius: 7px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: var(--c-muted);
|
|
text-transform: none;
|
|
letter-spacing: 0;
|
|
transition: all .2s;
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.radio-option input:checked+label {
|
|
border-color: var(--c-accent);
|
|
color: var(--c-accent-h);
|
|
background: rgba(46, 160, 67, .1);
|
|
}
|
|
|
|
.btn-save {
|
|
width: 100%;
|
|
padding: 10px;
|
|
background: var(--c-accent);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 7px;
|
|
font-family: var(--font-body);
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
transition: background .2s, transform .1s;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.btn-save:hover {
|
|
background: var(--c-accent-h);
|
|
}
|
|
|
|
.btn-save:active {
|
|
transform: scale(.98);
|
|
}
|
|
|
|
.btn-save:disabled {
|
|
opacity: .5;
|
|
cursor: not-allowed;
|
|
transform: none;
|
|
}
|
|
|
|
.form-status {
|
|
font-size: 12px;
|
|
text-align: center;
|
|
margin-top: 8px;
|
|
min-height: 18px;
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.form-status.error {
|
|
color: var(--c-danger);
|
|
}
|
|
|
|
.form-status.success {
|
|
color: var(--c-accent);
|
|
}
|
|
|
|
/* ── Info Popup ───────────────────────────────── */
|
|
.info-popup {
|
|
padding: 18px 20px 16px;
|
|
}
|
|
|
|
.info-popup-header {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
margin-bottom: 14px;
|
|
}
|
|
|
|
.info-popup-icon {
|
|
width: 38px;
|
|
height: 38px;
|
|
border-radius: 8px;
|
|
background: rgba(46, 160, 67, .15);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
font-size: 18px;
|
|
}
|
|
|
|
.info-popup-name {
|
|
font-size: 15px;
|
|
font-weight: 700;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.info-popup-id {
|
|
font-size: 10px;
|
|
color: var(--c-muted);
|
|
font-family: var(--font-mono);
|
|
margin-top: 2px;
|
|
}
|
|
|
|
.info-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 8px 0;
|
|
border-top: 1px solid var(--c-border);
|
|
font-size: 13px;
|
|
}
|
|
|
|
.info-row-icon {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 6px;
|
|
background: rgba(255, 255, 255, .05);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 14px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.info-row-label {
|
|
font-size: 10px;
|
|
color: var(--c-muted);
|
|
text-transform: uppercase;
|
|
letter-spacing: .5px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.info-row-value {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.badge-open {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 5px;
|
|
padding: 3px 10px;
|
|
border-radius: 12px;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
}
|
|
|
|
.badge-open.yes {
|
|
background: rgba(46, 160, 67, .2);
|
|
color: var(--c-accent-h);
|
|
border: 1px solid rgba(46, 160, 67, .35);
|
|
}
|
|
|
|
.badge-open.no {
|
|
background: rgba(248, 81, 73, .12);
|
|
color: var(--c-danger);
|
|
border: 1px solid rgba(248, 81, 73, .3);
|
|
}
|
|
|
|
.btn-wa {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
width: 100%;
|
|
margin-top: 14px;
|
|
padding: 9px;
|
|
background: var(--c-wa);
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: 7px;
|
|
font-family: var(--font-body);
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
text-decoration: none;
|
|
transition: background .2s;
|
|
}
|
|
|
|
.btn-wa:hover {
|
|
background: var(--c-wa-h);
|
|
}
|
|
|
|
.btn-hapus {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
width: 100%;
|
|
margin-top: 8px;
|
|
padding: 9px;
|
|
background: transparent;
|
|
color: var(--c-danger);
|
|
border: 1px solid rgba(248, 81, 73, .35);
|
|
border-radius: 7px;
|
|
font-family: var(--font-body);
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
transition: all .2s;
|
|
}
|
|
|
|
.btn-hapus:hover {
|
|
background: rgba(248, 81, 73, .12);
|
|
border-color: var(--c-danger);
|
|
}
|
|
|
|
/* ── Notification Toast ───────────────────────── */
|
|
.toast {
|
|
position: fixed;
|
|
top: 70px;
|
|
right: 20px;
|
|
z-index: 9999;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 12px 18px;
|
|
background: var(--c-surface);
|
|
border: 1px solid var(--c-border);
|
|
border-left: 3px solid var(--c-accent);
|
|
border-radius: var(--radius);
|
|
box-shadow: var(--shadow);
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
max-width: 320px;
|
|
transform: translateX(360px);
|
|
opacity: 0;
|
|
transition: all .35s cubic-bezier(.34, 1.56, .64, 1);
|
|
}
|
|
|
|
.toast.show {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
|
|
.toast.error {
|
|
border-left-color: var(--c-danger);
|
|
}
|
|
|
|
/* ── Custom Confirm Modal ─────────────────────── */
|
|
.confirm-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: rgba(0, 0, 0, .55);
|
|
backdrop-filter: blur(2px);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
z-index: 10000;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity .2s ease;
|
|
}
|
|
|
|
.confirm-overlay.show {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.confirm-dialog {
|
|
width: min(420px, 100%);
|
|
background: var(--c-surface);
|
|
border: 1px solid var(--c-border);
|
|
border-radius: 12px;
|
|
box-shadow: var(--shadow);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.confirm-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 14px 16px;
|
|
border-bottom: 1px solid var(--c-border);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.confirm-icon {
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 8px;
|
|
background: rgba(248, 81, 73, .15);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: var(--c-danger);
|
|
}
|
|
|
|
.confirm-body {
|
|
padding: 16px;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
color: var(--c-text);
|
|
}
|
|
|
|
.confirm-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
justify-content: flex-end;
|
|
padding: 14px 16px;
|
|
border-top: 1px solid var(--c-border);
|
|
}
|
|
|
|
.btn-confirm-cancel,
|
|
.btn-confirm-delete {
|
|
padding: 8px 14px;
|
|
border-radius: 8px;
|
|
font-family: var(--font-body);
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
transition: all .2s;
|
|
}
|
|
|
|
.btn-confirm-cancel {
|
|
border: 1px solid var(--c-border);
|
|
background: transparent;
|
|
color: var(--c-text);
|
|
}
|
|
|
|
.btn-confirm-cancel:hover {
|
|
background: rgba(255, 255, 255, .05);
|
|
}
|
|
|
|
.btn-confirm-delete {
|
|
border: 1px solid var(--c-danger);
|
|
background: rgba(248, 81, 73, .15);
|
|
color: #ffb3ad;
|
|
}
|
|
|
|
.btn-confirm-delete:hover {
|
|
background: rgba(248, 81, 73, .28);
|
|
}
|
|
|
|
/* ── Leaflet Dark Attribution ─────────────────── */
|
|
.leaflet-control-attribution {
|
|
background: rgba(13, 17, 23, .8) !important;
|
|
color: var(--c-muted) !important;
|
|
font-size: 10px !important;
|
|
}
|
|
|
|
.leaflet-control-attribution a {
|
|
color: var(--c-accent) !important;
|
|
}
|
|
|
|
.leaflet-control-zoom a {
|
|
background: var(--c-surface) !important;
|
|
color: var(--c-text) !important;
|
|
border-color: var(--c-border) !important;
|
|
}
|
|
|
|
.leaflet-control-zoom a:hover {
|
|
background: var(--c-border) !important;
|
|
}
|
|
|
|
/* ── Loading Overlay ──────────────────────────── */
|
|
.loading-overlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
background: var(--c-bg);
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 16px;
|
|
z-index: 99999;
|
|
transition: opacity .4s ease;
|
|
}
|
|
|
|
.loading-overlay.hide {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.loader-ring {
|
|
width: 48px;
|
|
height: 48px;
|
|
border: 3px solid var(--c-border);
|
|
border-top-color: var(--c-accent);
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.loading-text {
|
|
font-family: var(--font-mono);
|
|
font-size: 13px;
|
|
color: var(--c-muted);
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
|
|
<!-- Loading Overlay -->
|
|
<div class="loading-overlay" id="loadingOverlay">
|
|
<div class="loader-ring"></div>
|
|
<div class="loading-text">Memuat peta...</div>
|
|
</div>
|
|
|
|
<!-- Toast Notification -->
|
|
<div class="toast" id="toast">
|
|
<span id="toastIcon">✓</span>
|
|
<span id="toastMsg"></span>
|
|
</div>
|
|
|
|
<!-- Delete Confirm Modal -->
|
|
<div class="confirm-overlay" id="deleteConfirmOverlay" aria-hidden="true">
|
|
<div class="confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="deleteConfirmTitle">
|
|
<div class="confirm-header">
|
|
<div class="confirm-icon">🗑</div>
|
|
<div id="deleteConfirmTitle">Hapus Lokasi</div>
|
|
</div>
|
|
<div class="confirm-body" id="deleteConfirmMessage">
|
|
Yakin ingin menghapus lokasi ini?
|
|
</div>
|
|
<div class="confirm-actions">
|
|
<button type="button" class="btn-confirm-cancel" id="btnDeleteCancel">Batal</button>
|
|
<button type="button" class="btn-confirm-delete" id="btnDeleteConfirm">Ya, Hapus</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- App Wrapper -->
|
|
<div class="app-wrapper">
|
|
|
|
<!-- Header -->
|
|
<header class="app-header">
|
|
<div class="app-logo">
|
|
<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<rect width="30" height="30" rx="7" fill="rgba(46,160,67,.15)" />
|
|
<path
|
|
d="M15 5C11.134 5 8 8.134 8 12C8 17.25 15 25 15 25C15 25 22 17.25 22 12C22 8.134 18.866 5 15 5ZM15 15C13.343 15 12 13.657 12 12C12 10.343 13.343 9 15 9C16.657 9 18 10.343 18 12C18 13.657 16.657 15 15 15Z"
|
|
fill="#3fb950" />
|
|
</svg>
|
|
<div>
|
|
<div class="app-logo-title">WebGIS POI</div>
|
|
<div class="app-logo-sub">Pontianak · Leaflet.js</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="header-hint">
|
|
<kbd>Klik</kbd> pada peta untuk menambah titik lokasi
|
|
</div>
|
|
|
|
<div class="header-badge">
|
|
<div class="dot"></div>
|
|
<span id="headerCount">0</span> POI
|
|
</div>
|
|
</header>
|
|
|
|
<!-- Map -->
|
|
<div id="map"></div>
|
|
|
|
</div>
|
|
|
|
<!-- POI Counter -->
|
|
<div class="poi-counter" id="poiCounter">
|
|
📍 Total: <strong id="totalPoi">0</strong> lokasi
|
|
</div>
|
|
|
|
|
|
<!-- Leaflet JS -->
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
|
|
|
<script>
|
|
// ══════════════════════════════════════════════════
|
|
// INISIALISASI PETA
|
|
// ══════════════════════════════════════════════════
|
|
const CENTER_LAT = -0.0557;
|
|
const CENTER_LNG = 109.3487;
|
|
const ZOOM_LEVEL = 15;
|
|
|
|
const map = L.map('map', {
|
|
center: [CENTER_LAT, CENTER_LNG],
|
|
zoom: ZOOM_LEVEL,
|
|
zoomControl: true
|
|
});
|
|
|
|
// Base Layer: OpenStreetMap
|
|
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
}).addTo(map);
|
|
|
|
|
|
// ══════════════════════════════════════════════════
|
|
// CUSTOM MARKER ICON
|
|
// ══════════════════════════════════════════════════
|
|
function createCustomIcon(color = '#2ea043') {
|
|
return L.divIcon({
|
|
className: '',
|
|
html: `
|
|
<svg width="28" height="38" viewBox="0 0 28 38" xmlns="http://www.w3.org/2000/svg">
|
|
<filter id="shadow" x="-30%" y="-10%" width="160%" height="160%">
|
|
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.4"/>
|
|
</filter>
|
|
<path d="M14 0C6.268 0 0 6.268 0 14C0 24.5 14 38 14 38C14 38 28 24.5 28 14C28 6.268 21.732 0 14 0Z"
|
|
fill="${color}" filter="url(#shadow)"/>
|
|
<circle cx="14" cy="14" r="6" fill="white" opacity="0.9"/>
|
|
</svg>`,
|
|
iconSize: [28, 38],
|
|
iconAnchor: [14, 38],
|
|
popupAnchor: [0, -40]
|
|
});
|
|
}
|
|
|
|
const defaultIcon = createCustomIcon('#2ea043');
|
|
const tempIcon = createCustomIcon('#e3b341');
|
|
|
|
|
|
// ══════════════════════════════════════════════════
|
|
// UTILITY: TOAST NOTIFICATION
|
|
// ══════════════════════════════════════════════════
|
|
let toastTimer;
|
|
function showToast(msg, type = 'success') {
|
|
const toast = document.getElementById('toast');
|
|
const toastMsg = document.getElementById('toastMsg');
|
|
const toastIcon = document.getElementById('toastIcon');
|
|
toastMsg.textContent = msg;
|
|
toastIcon.textContent = type === 'success' ? '✓' : '✕';
|
|
toast.className = 'toast ' + type + ' show';
|
|
clearTimeout(toastTimer);
|
|
toastTimer = setTimeout(() => { toast.className = 'toast ' + type; }, 3500);
|
|
}
|
|
|
|
|
|
// ══════════════════════════════════════════════════
|
|
// UPDATE COUNTER
|
|
// ══════════════════════════════════════════════════
|
|
let markerCount = 0;
|
|
const allMarkers = {}; // registry semua marker: { id: markerObject }
|
|
let deleteConfirmResolver = null;
|
|
|
|
function updateCount(n) {
|
|
markerCount = n;
|
|
document.getElementById('totalPoi').textContent = n;
|
|
document.getElementById('headerCount').textContent = n;
|
|
}
|
|
|
|
function setupDeleteConfirmModal() {
|
|
const overlay = document.getElementById('deleteConfirmOverlay');
|
|
const messageEl = document.getElementById('deleteConfirmMessage');
|
|
const btnCancel = document.getElementById('btnDeleteCancel');
|
|
const btnConfirm = document.getElementById('btnDeleteConfirm');
|
|
|
|
function closeModal(result) {
|
|
overlay.classList.remove('show');
|
|
overlay.setAttribute('aria-hidden', 'true');
|
|
if (deleteConfirmResolver) {
|
|
deleteConfirmResolver(result);
|
|
deleteConfirmResolver = null;
|
|
}
|
|
}
|
|
|
|
btnCancel.addEventListener('click', () => closeModal(false));
|
|
btnConfirm.addEventListener('click', () => closeModal(true));
|
|
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) closeModal(false);
|
|
});
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if (!overlay.classList.contains('show')) return;
|
|
if (e.key === 'Escape') closeModal(false);
|
|
});
|
|
|
|
window.showDeleteConfirm = (text) => {
|
|
messageEl.textContent = text;
|
|
overlay.classList.add('show');
|
|
overlay.setAttribute('aria-hidden', 'false');
|
|
setTimeout(() => btnConfirm.focus(), 0);
|
|
return new Promise((resolve) => {
|
|
deleteConfirmResolver = resolve;
|
|
});
|
|
};
|
|
}
|
|
|
|
|
|
// ══════════════════════════════════════════════════
|
|
// FUNGSI: LOAD MARKERS dari database
|
|
// ══════════════════════════════════════════════════
|
|
function loadMarkers() {
|
|
fetch('ambil_data.php?_=' + Date.now(), { cache: 'no-store' })
|
|
.then(res => {
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
return res.json();
|
|
})
|
|
.then(json => {
|
|
if (json.status !== 'success') throw new Error(json.message);
|
|
|
|
updateCount(json.total);
|
|
json.data.forEach(poi => addMarkerToMap(poi));
|
|
|
|
// Sembunyikan loading overlay
|
|
const overlay = document.getElementById('loadingOverlay');
|
|
overlay.classList.add('hide');
|
|
})
|
|
.catch(err => {
|
|
console.error('Gagal memuat marker:', err);
|
|
document.getElementById('loadingOverlay').classList.add('hide');
|
|
showToast('Gagal memuat data dari server.', 'error');
|
|
});
|
|
}
|
|
|
|
|
|
// ══════════════════════════════════════════════════
|
|
// FUNGSI: Tambah Satu Marker ke Peta
|
|
// ══════════════════════════════════════════════════
|
|
function addMarkerToMap(poi) {
|
|
const isOpen24h = Number(poi.buka_24jam) === 1;
|
|
const icon = isOpen24h ? createCustomIcon('#2ea043') : createCustomIcon('#f85149');
|
|
const marker = L.marker([poi.latitude, poi.longitude], { icon, draggable: true }).addTo(map);
|
|
marker._poiId = poi.id; // simpan ID di objek marker
|
|
allMarkers[poi.id] = marker; // daftarkan ke registry global
|
|
marker.on('dragend', function (e) {
|
|
const { lat, lng } = e.target.getLatLng();
|
|
|
|
const formData = new FormData();
|
|
formData.append('id', poi.id);
|
|
formData.append('latitude', lat.toFixed(8));
|
|
formData.append('longitude', lng.toFixed(8));
|
|
|
|
fetch('update_posisi.php', { method: 'POST', body: formData })
|
|
.then(async res => {
|
|
const text = await res.text();
|
|
let json;
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch {
|
|
throw new Error('Respons server tidak valid: ' + text.slice(0, 120));
|
|
}
|
|
|
|
if (!res.ok) {
|
|
throw new Error(json.message || ('HTTP ' + res.status));
|
|
}
|
|
|
|
return json;
|
|
})
|
|
.then(json => {
|
|
if (json.status === 'success') {
|
|
showToast(`Posisi "${poi.nama_tempat}" diperbarui!`);
|
|
} else {
|
|
throw new Error(json.message || 'Gagal memperbarui posisi.');
|
|
}
|
|
})
|
|
.catch((err) => showToast(err.message || 'Gagal terhubung ke server.', 'error'));
|
|
});
|
|
// Siapkan link WhatsApp
|
|
const noWaBersih = poi.no_wa ? poi.no_wa.replace(/\D/g, '') : '';
|
|
const waLink = noWaBersih
|
|
? `<a class="btn-wa" href="https://wa.me/${noWaBersih}" target="_blank" rel="noopener">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
|
|
Chat WhatsApp
|
|
</a>`
|
|
: `<div style="font-size:12px;color:var(--c-muted);text-align:center;padding:8px 0;">Tidak ada nomor WA</div>`;
|
|
|
|
const buka24Badge = isOpen24h
|
|
? `<span class="badge-open yes">🟢 Buka 24 Jam</span>`
|
|
: `<span class="badge-open no">🔴 Tidak 24 Jam</span>`;
|
|
|
|
const popupHTML = `
|
|
<div class="info-popup">
|
|
<div class="info-popup-header">
|
|
<div class="info-popup-icon">📍</div>
|
|
<div>
|
|
<div class="info-popup-name">${escapeHTML(poi.nama_tempat)}</div>
|
|
<div class="info-popup-id">#${poi.id}</div>
|
|
</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-row-icon">📞</div>
|
|
<div>
|
|
<div class="info-row-label">No. WhatsApp</div>
|
|
<div class="info-row-value">${poi.no_wa ? escapeHTML(poi.no_wa) : '—'}</div>
|
|
</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-row-icon">🕐</div>
|
|
<div>
|
|
<div class="info-row-label">Jam Operasional</div>
|
|
<div class="info-row-value">${buka24Badge}</div>
|
|
</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-row-icon">🌐</div>
|
|
<div>
|
|
<div class="info-row-label">Koordinat</div>
|
|
<div class="info-row-value" style="font-family:var(--font-mono);font-size:11px;">
|
|
${poi.latitude.toFixed(6)}, ${poi.longitude.toFixed(6)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
${waLink}
|
|
<button class="btn-hapus" onclick="hapusMarker(${poi.id}, this)">
|
|
🗑 Hapus Lokasi
|
|
</button>
|
|
</div>`;
|
|
|
|
marker.bindPopup(popupHTML, { maxWidth: 300 });
|
|
}
|
|
|
|
|
|
// ══════════════════════════════════════════════════
|
|
// FUNGSI: Klik Peta → Popup Form Input
|
|
// ══════════════════════════════════════════════════
|
|
let tempMarker = null;
|
|
|
|
map.on('click', function (e) {
|
|
const lat = e.latlng.lat.toFixed(8);
|
|
const lng = e.latlng.lng.toFixed(8);
|
|
|
|
// Hapus marker sementara sebelumnya jika ada
|
|
if (tempMarker) map.removeLayer(tempMarker);
|
|
tempMarker = L.marker([lat, lng], { icon: tempIcon }).addTo(map);
|
|
|
|
const formHTML = `
|
|
<div class="form-popup">
|
|
<div class="form-popup-header">
|
|
<div class="form-popup-icon">
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#3fb950" stroke-width="2.5">
|
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="16"/>
|
|
<line x1="8" y1="12" x2="16" y2="12"/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div class="form-popup-title">Tambah Lokasi Baru</div>
|
|
<div class="form-popup-coords">${lat}, ${lng}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Nama Tempat *</label>
|
|
<input type="text" id="f_nama" placeholder="cth. Warung Kopi Aroma" maxlength="100" autocomplete="off">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>No. WhatsApp</label>
|
|
<input type="tel" id="f_wa" placeholder="628xxxxxxxxxx" maxlength="15" autocomplete="off">
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label>Buka 24 Jam?</label>
|
|
<div class="radio-group">
|
|
<div class="radio-option">
|
|
<input type="radio" name="buka24" id="r_ya" value="1">
|
|
<label for="r_ya">✅ Ya</label>
|
|
</div>
|
|
<div class="radio-option">
|
|
<input type="radio" name="buka24" id="r_tidak" value="0" checked>
|
|
<label for="r_tidak">❌ Tidak</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<input type="hidden" id="f_lat" value="${lat}">
|
|
<input type="hidden" id="f_lng" value="${lng}">
|
|
|
|
<button class="btn-save" id="btnSimpan" onclick="simpanData()">
|
|
💾 Simpan Lokasi
|
|
</button>
|
|
<div class="form-status" id="formStatus"></div>
|
|
</div>`;
|
|
|
|
L.popup({ maxWidth: 320, closeOnClick: false })
|
|
.setLatLng(e.latlng)
|
|
.setContent(formHTML)
|
|
.openOn(map);
|
|
|
|
// Focus pada input nama
|
|
setTimeout(() => {
|
|
const el = document.getElementById('f_nama');
|
|
if (el) el.focus();
|
|
}, 200);
|
|
});
|
|
|
|
|
|
// ══════════════════════════════════════════════════
|
|
// FUNGSI: Simpan Data via Fetch API
|
|
// ══════════════════════════════════════════════════
|
|
function simpanData() {
|
|
const nama = document.getElementById('f_nama').value.trim();
|
|
const wa = document.getElementById('f_wa').value.trim();
|
|
const buka24 = document.querySelector('input[name="buka24"]:checked')?.value ?? '0';
|
|
const lat = document.getElementById('f_lat').value;
|
|
const lng = document.getElementById('f_lng').value;
|
|
const status = document.getElementById('formStatus');
|
|
const btn = document.getElementById('btnSimpan');
|
|
|
|
// Validasi sisi klien
|
|
if (!nama) {
|
|
status.textContent = '⚠ Nama tempat wajib diisi.';
|
|
status.className = 'form-status error';
|
|
document.getElementById('f_nama').focus();
|
|
return;
|
|
}
|
|
|
|
btn.disabled = true;
|
|
btn.textContent = '⏳ Menyimpan...';
|
|
status.textContent = '';
|
|
|
|
const formData = new FormData();
|
|
formData.append('nama_tempat', nama);
|
|
formData.append('no_wa', wa);
|
|
formData.append('buka_24jam', buka24);
|
|
formData.append('latitude', lat);
|
|
formData.append('longitude', lng);
|
|
|
|
fetch('simpan.php', { method: 'POST', body: formData })
|
|
.then(res => {
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
return res.json();
|
|
})
|
|
.then(json => {
|
|
if (json.status === 'success') {
|
|
// Tutup popup & hapus marker sementara
|
|
map.closePopup();
|
|
if (tempMarker) { map.removeLayer(tempMarker); tempMarker = null; }
|
|
|
|
// Tambahkan marker permanen ke peta
|
|
addMarkerToMap(json.data);
|
|
updateCount(markerCount + 1);
|
|
|
|
showToast(`"${nama}" berhasil disimpan!`);
|
|
} else {
|
|
throw new Error(json.message || 'Gagal menyimpan.');
|
|
}
|
|
})
|
|
.catch(err => {
|
|
status.textContent = '✕ ' + err.message;
|
|
status.className = 'form-status error';
|
|
btn.disabled = false;
|
|
btn.textContent = '💾 Simpan Lokasi';
|
|
});
|
|
}
|
|
|
|
function hapusMarker(id, btnEl) {
|
|
showDeleteConfirm('Yakin ingin menghapus lokasi ini?')
|
|
.then((confirmed) => {
|
|
if (!confirmed) return;
|
|
|
|
btnEl.disabled = true;
|
|
btnEl.textContent = '⏳ Menghapus...';
|
|
|
|
const formData = new FormData();
|
|
formData.append('id', id);
|
|
|
|
return fetch('hapus.php', { method: 'POST', body: formData })
|
|
.then(async res => {
|
|
const text = await res.text();
|
|
let json;
|
|
try {
|
|
json = JSON.parse(text);
|
|
} catch {
|
|
throw new Error('Respons server tidak valid: ' + text.slice(0, 120));
|
|
}
|
|
|
|
if (!res.ok) {
|
|
throw new Error(json.message || ('HTTP ' + res.status));
|
|
}
|
|
|
|
return json;
|
|
})
|
|
.then(json => {
|
|
if (json.status === 'success') {
|
|
map.closePopup();
|
|
if (allMarkers[id]) {
|
|
map.removeLayer(allMarkers[id]);
|
|
delete allMarkers[id];
|
|
}
|
|
updateCount(Math.max(0, markerCount - 1));
|
|
showToast('Lokasi berhasil dihapus.');
|
|
} else {
|
|
throw new Error(json.message || 'Gagal menghapus lokasi.');
|
|
}
|
|
});
|
|
})
|
|
.catch((err) => {
|
|
showToast(err.message || 'Gagal terhubung ke server.', 'error');
|
|
btnEl.disabled = false;
|
|
btnEl.textContent = '🗑 Hapus Lokasi';
|
|
});
|
|
}
|
|
|
|
|
|
// ══════════════════════════════════════════════════
|
|
// UTILITY: Escape HTML
|
|
// ══════════════════════════════════════════════════
|
|
function escapeHTML(str) {
|
|
const div = document.createElement('div');
|
|
div.appendChild(document.createTextNode(str));
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Izinkan Enter untuk submit form popup
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter' && document.getElementById('btnSimpan')) {
|
|
simpanData();
|
|
}
|
|
});
|
|
|
|
|
|
// ══════════════════════════════════════════════════
|
|
// BOOT: Muat semua marker saat halaman siap
|
|
// ══════════════════════════════════════════════════
|
|
setupDeleteConfirmModal();
|
|
loadMarkers();
|
|
|
|
</script>
|
|
</body>
|
|
|
|
</html> |