feat: add manual photo proof capture, consensus voting, and anti-sabotage camera verification engine
This commit is contained in:
+348
@@ -2135,4 +2135,352 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
PHOTO EVIDENCE & DEMOCRATIC VERIFICATION ENGINE
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.hardware-proof-dialog {
|
||||||
|
max-width: 580px;
|
||||||
|
width: 92vw;
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.25);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.8), 0 0 30px rgba(6, 182, 212, 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cam-verification-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cam-verification-badge.badge-verified {
|
||||||
|
background: rgba(16, 185, 129, 0.15);
|
||||||
|
border: 1px solid #10b981;
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cam-verification-badge.badge-unverified {
|
||||||
|
background: rgba(245, 158, 11, 0.15);
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cam-verification-badge.badge-contested {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
border: 1px solid #ef4444;
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Proof Photo Container */
|
||||||
|
.proof-photo-container {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(8, 12, 20, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-photo-box {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 300px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(56, 189, 248, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-photo-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 260px;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-photo-img:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-photo-overlay {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.4) 60%, transparent 100%);
|
||||||
|
padding: 12px 14px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-photo-author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-photo-date {
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty Photo Prompt State */
|
||||||
|
.proof-empty-box {
|
||||||
|
padding: 24px 20px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed rgba(148, 163, 184, 0.25);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(15, 23, 42, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-empty-icon {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-empty-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #f8fafc;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-empty-desc {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 440px;
|
||||||
|
margin: 0 auto 16px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-proof-snap-cta {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: linear-gradient(135deg, #06b6d4, #0284c7);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 22px;
|
||||||
|
border-radius: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 15px rgba(6, 182, 212, 0.35);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-proof-snap-cta:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(6, 182, 212, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specs Grid */
|
||||||
|
.proof-specs-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-spec-card {
|
||||||
|
background: rgba(30, 41, 59, 0.5);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.1);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-spec-card .spec-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-spec-card .spec-val {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Consensus Banner */
|
||||||
|
.proof-consensus-banner {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(14, 116, 144, 0.12);
|
||||||
|
border-left: 3px solid #06b6d4;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-consensus-banner .consensus-icon {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-consensus-banner .consensus-text strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #38bdf8;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proof-consensus-banner .consensus-text p {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action Buttons Row */
|
||||||
|
.proof-actions-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1.3fr 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-proof-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 8px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm-vote {
|
||||||
|
background: rgba(16, 185, 129, 0.18);
|
||||||
|
border: 1px solid #10b981;
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm-vote:hover {
|
||||||
|
background: #10b981;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-confirm-vote.voted {
|
||||||
|
background: #10b981;
|
||||||
|
color: #0f172a;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-photo {
|
||||||
|
background: rgba(6, 182, 212, 0.18);
|
||||||
|
border: 1px solid #06b6d4;
|
||||||
|
color: #38bdf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-add-photo:hover {
|
||||||
|
background: #06b6d4;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-contest-vote {
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.4);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-contest-vote:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.3);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-maps {
|
||||||
|
background: rgba(148, 163, 184, 0.12);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-nav-maps:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-preview-box.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.proof-actions-row {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Photo Verified Pin & Contested Pin on Map */
|
||||||
|
.camera-map-pin.has-photo-pin .cam-dot-center {
|
||||||
|
background: linear-gradient(135deg, #10b981, #059669);
|
||||||
|
box-shadow: 0 0 12px rgba(16, 185, 129, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-map-pin.has-photo-pin .cam-pulse-wave {
|
||||||
|
border-color: rgba(16, 185, 129, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-map-pin.is-contested-pin .cam-dot-center {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #d97706);
|
||||||
|
box-shadow: 0 0 12px rgba(245, 158, 11, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-map-pin.is-contested-pin .cam-pulse-wave {
|
||||||
|
border-color: rgba(245, 158, 11, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-photo-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
background: rgba(15, 23, 42, 0.9);
|
||||||
|
border: 1px solid #10b981;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
|
||||||
|
animation: pulse-photo-dot 2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-photo-dot {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.15); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-badge.badge-verified-photo {
|
||||||
|
background: rgba(16, 185, 129, 0.2);
|
||||||
|
border: 1px solid #10b981;
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-badge.badge-contested-tag {
|
||||||
|
background: rgba(245, 158, 11, 0.2);
|
||||||
|
border: 1px solid #f59e0b;
|
||||||
|
color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+158
@@ -724,6 +724,21 @@
|
|||||||
<input type="text" id="report-notes" class="search-input" style="padding: 12px;" placeholder="e.g. Northbound lanes, black solar panel on pole">
|
<input type="text" id="report-notes" class="search-input" style="padding: 12px;" placeholder="e.g. Northbound lanes, black solar panel on pole">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Optional Photo Proof in Report Modal -->
|
||||||
|
<div class="form-group" style="margin-bottom: 16px;">
|
||||||
|
<label class="control-label" style="display:block; margin-bottom: 6px;">Physical Hardware Photo Proof (Recommended)</label>
|
||||||
|
<div class="photo-proof-upload-zone" id="report-photo-zone">
|
||||||
|
<button type="button" id="btn-report-add-photo" class="btn-secondary" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px;">
|
||||||
|
<span>📸</span>
|
||||||
|
<span id="report-photo-btn-text">Snap Camera on Pole (+100 pts)</span>
|
||||||
|
</button>
|
||||||
|
<div id="report-photo-preview-box" class="photo-preview-box hidden" style="margin-top: 10px; position: relative;">
|
||||||
|
<img id="report-photo-preview-img" src="" alt="Camera preview" style="width: 100%; max-height: 180px; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--accent-cyan);">
|
||||||
|
<button type="button" id="btn-report-remove-photo" style="position: absolute; top: 6px; right: 6px; background: rgba(0,0,0,0.7); color: #fff; border: none; border-radius: 50%; width: 26px; height: 26px; cursor: pointer;">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 24px;">
|
||||||
<div>
|
<div>
|
||||||
<label class="control-label" for="report-lat" style="display:block; margin-bottom: 6px;">Latitude</label>
|
<label class="control-label" for="report-lat" style="display:block; margin-bottom: 6px;">Latitude</label>
|
||||||
@@ -742,6 +757,149 @@
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<!-- ========================================================================
|
||||||
|
MODAL 4: SURVEILLANCE ZONE HARDWARE PROOF & COMMUNITY VOTING (<dialog>)
|
||||||
|
======================================================================== -->
|
||||||
|
<dialog id="camera-detail-modal" class="agency-dialog hardware-proof-dialog" aria-labelledby="cam-detail-title">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-header-titles">
|
||||||
|
<div class="cam-verification-badge" id="cam-detail-verify-badge">
|
||||||
|
<span class="badge-dot dot-clear"></span>
|
||||||
|
<span id="cam-detail-verify-text">CHECKING VERIFICATION...</span>
|
||||||
|
</div>
|
||||||
|
<h2 id="cam-detail-title" class="modal-agency-name" style="margin-top: 6px;">Flock Camera Location</h2>
|
||||||
|
<div class="modal-agency-meta" id="cam-detail-meta">Managing Agency & Hardware Registry</div>
|
||||||
|
</div>
|
||||||
|
<button id="cam-detail-close-btn" class="close-modal-btn" aria-label="Close modal">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- Hardware Photo Proof Area -->
|
||||||
|
<div class="proof-photo-container" id="cam-proof-container">
|
||||||
|
<!-- Populated dynamically via JS: either real driver photo or upload prompt -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hardware Specifications Grid -->
|
||||||
|
<div class="modal-section" style="margin-top: 20px;">
|
||||||
|
<h3 class="modal-section-title">Hardware Telemetry & Location</h3>
|
||||||
|
<div class="proof-specs-grid">
|
||||||
|
<div class="proof-spec-card">
|
||||||
|
<span class="spec-label">INSTALLATION TYPE</span>
|
||||||
|
<span class="spec-val" id="spec-mount-type">Solar Pole Mount</span>
|
||||||
|
</div>
|
||||||
|
<div class="proof-spec-card">
|
||||||
|
<span class="spec-label">MANAGING ENTITY</span>
|
||||||
|
<span class="spec-val" id="spec-agency">Local Law Enforcement</span>
|
||||||
|
</div>
|
||||||
|
<div class="proof-spec-card">
|
||||||
|
<span class="spec-label">EST. 30-DAY LOGS</span>
|
||||||
|
<span class="spec-val" id="spec-captures">300,000+ Reads</span>
|
||||||
|
</div>
|
||||||
|
<div class="proof-spec-card">
|
||||||
|
<span class="spec-label">COORDINATES</span>
|
||||||
|
<span class="spec-val" id="spec-coords">34.6019, -118.1484</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Community Consensus & Anti-Sabotage Rule Banner -->
|
||||||
|
<div class="proof-consensus-banner">
|
||||||
|
<div class="consensus-icon">🛡️</div>
|
||||||
|
<div class="consensus-text">
|
||||||
|
<strong>Democratic Checks & Balances Active</strong>
|
||||||
|
<p>Flock cameras are permanent municipal installations. To prevent law enforcement or bad actors from deleting active surveillance zones, locations are protected by community consensus.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Voting & Action Bar -->
|
||||||
|
<div class="proof-actions-row">
|
||||||
|
<button id="btn-vote-confirm-cam" class="btn-proof-action btn-confirm-vote" title="Confirm camera is still bolted to pole">
|
||||||
|
<span class="btn-icon">👍</span>
|
||||||
|
<span id="btn-vote-confirm-text">Confirm Active (1)</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="btn-proof-add-photo" class="btn-proof-action btn-add-photo" title="Snap or upload a photo of the camera on the pole">
|
||||||
|
<span class="btn-icon">📸</span>
|
||||||
|
<span id="btn-proof-photo-text">Add Photo Proof</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="btn-proof-contest" class="btn-proof-action btn-contest-vote" title="Report camera removed or inaccurate">
|
||||||
|
<span class="btn-icon">⚠️</span>
|
||||||
|
<span>Contest</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a id="btn-proof-navigate" href="#" target="_blank" rel="noopener noreferrer" class="btn-proof-action btn-nav-maps" title="Open navigation in Apple/Google Maps">
|
||||||
|
<span class="btn-icon">🧭</span>
|
||||||
|
<span>Maps ↗</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Public Transparency Portal Link -->
|
||||||
|
<div id="proof-portal-link-box" style="margin-top: 16px; text-align: center;">
|
||||||
|
<button id="btn-proof-open-portal" class="btn-secondary" style="width: 100%; padding: 12px;">
|
||||||
|
Inspect Official Transparency Portal & Policy Records ↗
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- ========================================================================
|
||||||
|
MODAL 5: CONTEST CAMERA / REPORT REMOVAL MODAL (<dialog>)
|
||||||
|
======================================================================== -->
|
||||||
|
<dialog id="contest-modal" class="agency-dialog" aria-labelledby="contest-modal-title">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-header-titles">
|
||||||
|
<h2 id="contest-modal-title" class="modal-agency-name">Contest Camera Location</h2>
|
||||||
|
<div class="modal-agency-meta">Community Checks & Balances to Prevent Misinformation</div>
|
||||||
|
</div>
|
||||||
|
<button id="contest-close-btn" class="close-modal-btn" aria-label="Close modal">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="contest-form">
|
||||||
|
<div class="form-group" style="margin-bottom: 16px;">
|
||||||
|
<label class="control-label" for="contest-reason" style="display:block; margin-bottom: 6px;">Reason for Contesting</label>
|
||||||
|
<select id="contest-reason" class="select-custom" style="width: 100%;">
|
||||||
|
<option value="Camera / Solar Panel physically removed from pole">Camera / Solar Panel physically removed from pole</option>
|
||||||
|
<option value="Not a Flock ALPR (Standard CCTV / traffic light sensor)">Not a Flock ALPR (Standard CCTV / traffic sensor)</option>
|
||||||
|
<option value="Incorrect intersection or misplaced GPS pin">Incorrect intersection or misplaced GPS pin</option>
|
||||||
|
<option value="Spam or fraudulent pin">Spam or fraudulent pin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom: 16px;">
|
||||||
|
<label class="control-label" for="contest-notes" style="display:block; margin-bottom: 6px;">Observations / Verification Notes</label>
|
||||||
|
<textarea id="contest-notes" class="search-input" style="padding: 12px; height: 80px; resize: vertical;" placeholder="Describe why this pin is inaccurate or what was found on the pole..." required></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom: 20px;">
|
||||||
|
<label class="control-label" style="display:block; margin-bottom: 6px;">Photo Proof of Empty Pole (Optional)</label>
|
||||||
|
<button type="button" id="btn-contest-add-photo" class="btn-secondary" style="width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px;">
|
||||||
|
<span>📷</span>
|
||||||
|
<span id="contest-photo-btn-text">Snap Empty Pole</span>
|
||||||
|
</button>
|
||||||
|
<div id="contest-photo-preview-box" class="photo-preview-box hidden" style="margin-top: 10px; position: relative;">
|
||||||
|
<img id="contest-photo-preview-img" src="" alt="Contest photo" style="width: 100%; max-height: 160px; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--accent-amber);">
|
||||||
|
<button type="button" id="btn-contest-remove-photo" style="position: absolute; top: 6px; right: 6px; background: rgba(0,0,0,0.7); color: #fff; border: none; border-radius: 50%; width: 26px; height: 26px; cursor: pointer;">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="proof-consensus-banner" style="margin-bottom: 20px;">
|
||||||
|
<div class="consensus-icon">⚖️</div>
|
||||||
|
<div class="consensus-text">
|
||||||
|
<strong>Consensus Safeguard:</strong>
|
||||||
|
<p>A single report does not delete a verified camera. Once 3 trusted scouts flag a location or photo proof of an empty pole is verified, the camera is archived.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-open-flock" style="width: 100%; justify-content: center; cursor: pointer; border: none; background: linear-gradient(135deg, #f59e0b, #d97706);">
|
||||||
|
Submit Contest Report
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- Hidden Native Camera / File Input for Mobile Evidence Snapping -->
|
||||||
|
<input type="file" id="camera-photo-file-input" accept="image/*" capture="environment" style="display: none;">
|
||||||
|
|
||||||
<!-- Toast Container -->
|
<!-- Toast Container -->
|
||||||
<div id="toast-container" class="toast-container" aria-live="assertive"></div>
|
<div id="toast-container" class="toast-container" aria-live="assertive"></div>
|
||||||
|
|
||||||
|
|||||||
+214
-8
@@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
const STORAGE_KEY_USER = 'flock_radar_user';
|
const STORAGE_KEY_USER = 'flock_radar_user';
|
||||||
const STORAGE_KEY_REPORTS = 'flock_community_reports';
|
const STORAGE_KEY_REPORTS = 'flock_community_reports';
|
||||||
|
const STORAGE_KEY_METADATA = 'flock_camera_metadata';
|
||||||
|
const STORAGE_KEY_VOTES = 'flock_user_votes';
|
||||||
|
|
||||||
// Default Guest Profile
|
// Default Guest Profile
|
||||||
const defaultUser = {
|
const defaultUser = {
|
||||||
@@ -19,16 +21,21 @@
|
|||||||
vehicle: 'Sedan (Silver)',
|
vehicle: 'Sedan (Silver)',
|
||||||
soundEnabled: true,
|
soundEnabled: true,
|
||||||
alertsMuted: false,
|
alertsMuted: false,
|
||||||
reportsCount: 0
|
reportsCount: 0,
|
||||||
|
photosCount: 0
|
||||||
};
|
};
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let communityReports = [];
|
let communityReports = [];
|
||||||
|
let cameraMetadata = {};
|
||||||
|
let userVotes = { confirmed: {}, contested: {} };
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
loadUser();
|
loadUser();
|
||||||
loadReports();
|
loadReports();
|
||||||
|
loadMetadata();
|
||||||
|
loadVotes();
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadUser() {
|
function loadUser() {
|
||||||
@@ -53,17 +60,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadVotes() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY_VOTES);
|
||||||
|
userVotes = stored ? JSON.parse(stored) : { confirmed: {}, contested: {} };
|
||||||
|
} catch (e) {
|
||||||
|
userVotes = { confirmed: {}, contested: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveVotes() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY_VOTES, JSON.stringify(userVotes));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save votes', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMetadata() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY_METADATA);
|
||||||
|
cameraMetadata = stored ? JSON.parse(stored) : {};
|
||||||
|
} catch (e) {
|
||||||
|
cameraMetadata = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveMetadata() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY_METADATA, JSON.stringify(cameraMetadata));
|
||||||
|
window.dispatchEvent(new CustomEvent('flock_meta_updated', { detail: cameraMetadata }));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save camera metadata', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function login(username, vehicle, soundEnabled) {
|
function login(username, vehicle, soundEnabled) {
|
||||||
currentUser = {
|
currentUser = {
|
||||||
isLoggedIn: true,
|
isLoggedIn: true,
|
||||||
username: username || 'Citizen Scout',
|
username: username || 'Citizen Scout',
|
||||||
callsign: 'RADAR-' + Math.floor(100 + Math.random() * 900),
|
callsign: 'RADAR-' + Math.floor(100 + Math.random() * 900),
|
||||||
level: 2,
|
level: 2,
|
||||||
points: 250,
|
points: (currentUser ? currentUser.points : 0) + 100,
|
||||||
vehicle: vehicle || 'Standard Vehicle',
|
vehicle: vehicle || 'Standard Vehicle',
|
||||||
soundEnabled: soundEnabled !== false,
|
soundEnabled: soundEnabled !== false,
|
||||||
alertsMuted: false,
|
alertsMuted: false,
|
||||||
reportsCount: communityReports.length
|
reportsCount: communityReports.length,
|
||||||
|
photosCount: currentUser ? (currentUser.photosCount || 0) : 0
|
||||||
};
|
};
|
||||||
saveUser();
|
saveUser();
|
||||||
return currentUser;
|
return currentUser;
|
||||||
@@ -97,30 +140,186 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addReport(report) {
|
function addReport(report) {
|
||||||
|
const reportId = 'rpt-' + Date.now();
|
||||||
const newReport = {
|
const newReport = {
|
||||||
id: 'rpt-' + Date.now(),
|
id: reportId,
|
||||||
lat: report.lat,
|
lat: report.lat,
|
||||||
lng: report.lng,
|
lng: report.lng,
|
||||||
type: report.type || 'Solar Pole Mount',
|
type: report.type || 'Solar Pole Mount',
|
||||||
intersection: report.intersection || 'Reported Location',
|
intersection: report.intersection || 'Reported Location',
|
||||||
notes: report.notes || 'Spotted by community driver',
|
notes: report.notes || 'Spotted by community driver',
|
||||||
reportedBy: currentUser ? currentUser.username : 'Anonymous Scout',
|
reportedBy: currentUser ? (currentUser.callsign || currentUser.username) : 'Anonymous Scout',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
confirmations: 1
|
confirmations: 1,
|
||||||
|
photoUrl: report.photoUrl || null
|
||||||
};
|
};
|
||||||
|
|
||||||
communityReports.push(newReport);
|
communityReports.push(newReport);
|
||||||
saveReports();
|
saveReports();
|
||||||
|
|
||||||
|
// If report has photo, initialize metadata
|
||||||
|
if (report.photoUrl) {
|
||||||
|
cameraMetadata[reportId] = {
|
||||||
|
confirmations: 1,
|
||||||
|
hasPhoto: true,
|
||||||
|
photoUrl: report.photoUrl,
|
||||||
|
photoDate: new Date().toISOString(),
|
||||||
|
photoAuthor: currentUser ? (currentUser.callsign || currentUser.username) : 'Scout',
|
||||||
|
isContested: false,
|
||||||
|
contests: []
|
||||||
|
};
|
||||||
|
saveMetadata();
|
||||||
|
}
|
||||||
|
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
currentUser.points = (currentUser.points || 0) + 50;
|
currentUser.points = (currentUser.points || 0) + (report.photoUrl ? 150 : 50);
|
||||||
currentUser.reportsCount = (currentUser.reportsCount || 0) + 1;
|
currentUser.reportsCount = (currentUser.reportsCount || 0) + 1;
|
||||||
|
if (report.photoUrl) {
|
||||||
|
currentUser.photosCount = (currentUser.photosCount || 0) + 1;
|
||||||
|
}
|
||||||
saveUser();
|
saveUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
return newReport;
|
return newReport;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- CAMERA METADATA, CONFIRMATION & PHOTO PROOF API ---
|
||||||
|
function getCameraMeta(camId) {
|
||||||
|
if (!cameraMetadata[camId]) {
|
||||||
|
// Default baseline
|
||||||
|
cameraMetadata[camId] = {
|
||||||
|
confirmations: 3, // Baseline community confirmations for directory portals
|
||||||
|
hasPhoto: false,
|
||||||
|
photoUrl: null,
|
||||||
|
photoDate: null,
|
||||||
|
photoAuthor: null,
|
||||||
|
isContested: false,
|
||||||
|
contests: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return cameraMetadata[camId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmCamera(camId) {
|
||||||
|
if (userVotes.confirmed[camId]) {
|
||||||
|
return { success: false, message: 'You have already confirmed this camera is active!' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = getCameraMeta(camId);
|
||||||
|
meta.confirmations = (meta.confirmations || 0) + 1;
|
||||||
|
userVotes.confirmed[camId] = new Date().toISOString();
|
||||||
|
|
||||||
|
saveMetadata();
|
||||||
|
saveVotes();
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
currentUser.points = (currentUser.points || 0) + 25;
|
||||||
|
saveUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, count: meta.confirmations };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPhotoProof(camId, photoDataUrl, notes) {
|
||||||
|
const meta = getCameraMeta(camId);
|
||||||
|
meta.hasPhoto = true;
|
||||||
|
meta.photoUrl = photoDataUrl;
|
||||||
|
meta.photoDate = new Date().toISOString();
|
||||||
|
meta.photoAuthor = currentUser ? (currentUser.callsign || currentUser.username) : 'Driver Scout';
|
||||||
|
meta.confirmations = (meta.confirmations || 0) + 2; // Adding photo boosts verification confidence
|
||||||
|
|
||||||
|
saveMetadata();
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
currentUser.points = (currentUser.points || 0) + 100;
|
||||||
|
currentUser.photosCount = (currentUser.photosCount || 0) + 1;
|
||||||
|
saveUser();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
function contestCamera(camId, reason, notes) {
|
||||||
|
if (userVotes.contested[camId]) {
|
||||||
|
return { success: false, message: 'You have already submitted a report for this location.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = getCameraMeta(camId);
|
||||||
|
if (!meta.contests) meta.contests = [];
|
||||||
|
|
||||||
|
meta.contests.push({
|
||||||
|
reason: reason || 'Camera reported missing',
|
||||||
|
notes: notes || '',
|
||||||
|
author: currentUser ? (currentUser.callsign || currentUser.username) : 'Scout',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
userVotes.contested[camId] = new Date().toISOString();
|
||||||
|
|
||||||
|
// Anti-Sabotage Rule: It requires at least 3 distinct driver contests to flag a camera
|
||||||
|
if (meta.contests.length >= 3) {
|
||||||
|
meta.isContested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveMetadata();
|
||||||
|
saveVotes();
|
||||||
|
|
||||||
|
return { success: true, contestCount: meta.contests.length, isContested: meta.isContested };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUserConfirmed(camId) {
|
||||||
|
return !!(userVotes.confirmed && userVotes.confirmed[camId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUserContested(camId) {
|
||||||
|
return !!(userVotes.contested && userVotes.contested[camId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CLIENT-SIDE FAST IMAGE COMPRESSION (CANVAS) ---
|
||||||
|
// Compresses 4MB-10MB phone camera photos down to ~60-80KB WebP/JPEG in milliseconds
|
||||||
|
function compressImage(file, maxWidth = 1000, quality = 0.72) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!file || !file.type.startsWith('image/')) {
|
||||||
|
return reject(new Error('Invalid image file.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = e => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
try {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
let width = img.width;
|
||||||
|
let height = img.height;
|
||||||
|
|
||||||
|
if (width > maxWidth) {
|
||||||
|
height = Math.round((height * maxWidth) / width);
|
||||||
|
width = maxWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
|
||||||
|
// Export as WebP if supported, fallback to JPEG
|
||||||
|
let dataUrl = canvas.toDataURL('image/webp', quality);
|
||||||
|
if (!dataUrl || dataUrl.indexOf('data:image/webp') !== 0) {
|
||||||
|
dataUrl = canvas.toDataURL('image/jpeg', quality);
|
||||||
|
}
|
||||||
|
resolve(dataUrl);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => reject(new Error('Failed to decode image data.'));
|
||||||
|
img.src = e.target.result;
|
||||||
|
};
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file.'));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getUser() {
|
function getUser() {
|
||||||
if (!currentUser) loadUser();
|
if (!currentUser) loadUser();
|
||||||
return currentUser;
|
return currentUser;
|
||||||
@@ -139,7 +338,14 @@
|
|||||||
logout,
|
logout,
|
||||||
saveUser,
|
saveUser,
|
||||||
getReports,
|
getReports,
|
||||||
addReport
|
addReport,
|
||||||
|
getCameraMeta,
|
||||||
|
confirmCamera,
|
||||||
|
addPhotoProof,
|
||||||
|
contestCamera,
|
||||||
|
hasUserConfirmed,
|
||||||
|
hasUserContested,
|
||||||
|
compressImage
|
||||||
};
|
};
|
||||||
|
|
||||||
init();
|
init();
|
||||||
|
|||||||
+402
-10
@@ -95,6 +95,12 @@
|
|||||||
let radarRangeMode = 'city'; // 'city' (1,000 ft) or 'highway' (2,500 ft)
|
let radarRangeMode = 'city'; // 'city' (1,000 ft) or 'highway' (2,500 ft)
|
||||||
let lastCautionSpokenCamId = null;
|
let lastCautionSpokenCamId = null;
|
||||||
|
|
||||||
|
// Photo Evidence & Consensus State
|
||||||
|
let activeModalCamera = null;
|
||||||
|
let photoUploadTarget = 'detail'; // 'detail' | 'report' | 'contest'
|
||||||
|
let reportPhotoPendingDataUrl = null;
|
||||||
|
let contestPhotoPendingDataUrl = null;
|
||||||
|
|
||||||
// --- DOM REFERENCES ---
|
// --- DOM REFERENCES ---
|
||||||
const el = {
|
const el = {
|
||||||
radarView: document.getElementById('radar-view'),
|
radarView: document.getElementById('radar-view'),
|
||||||
@@ -152,7 +158,47 @@
|
|||||||
reportForm: document.getElementById('report-form'),
|
reportForm: document.getElementById('report-form'),
|
||||||
loginCloseBtn: document.getElementById('login-close-btn'),
|
loginCloseBtn: document.getElementById('login-close-btn'),
|
||||||
reportCloseBtn: document.getElementById('report-close-btn'),
|
reportCloseBtn: document.getElementById('report-close-btn'),
|
||||||
loginGuestBtn: document.getElementById('login-guest-btn')
|
loginGuestBtn: document.getElementById('login-guest-btn'),
|
||||||
|
|
||||||
|
// Hardware Proof & Verification Modals
|
||||||
|
cameraDetailModal: document.getElementById('camera-detail-modal'),
|
||||||
|
camDetailTitle: document.getElementById('cam-detail-title'),
|
||||||
|
camDetailVerifyBadge: document.getElementById('cam-detail-verify-badge'),
|
||||||
|
camDetailVerifyText: document.getElementById('cam-detail-verify-text'),
|
||||||
|
camDetailMeta: document.getElementById('cam-detail-meta'),
|
||||||
|
camProofContainer: document.getElementById('cam-proof-container'),
|
||||||
|
specMountType: document.getElementById('spec-mount-type'),
|
||||||
|
specAgency: document.getElementById('spec-agency'),
|
||||||
|
specCaptures: document.getElementById('spec-captures'),
|
||||||
|
specCoords: document.getElementById('spec-coords'),
|
||||||
|
btnVoteConfirmCam: document.getElementById('btn-vote-confirm-cam'),
|
||||||
|
btnVoteConfirmText: document.getElementById('btn-vote-confirm-text'),
|
||||||
|
btnProofAddPhoto: document.getElementById('btn-proof-add-photo'),
|
||||||
|
btnProofPhotoText: document.getElementById('btn-proof-photo-text'),
|
||||||
|
btnProofContest: document.getElementById('btn-proof-contest'),
|
||||||
|
btnProofNavigate: document.getElementById('btn-proof-navigate'),
|
||||||
|
btnProofOpenPortal: document.getElementById('btn-proof-open-portal'),
|
||||||
|
camDetailCloseBtn: document.getElementById('cam-detail-close-btn'),
|
||||||
|
|
||||||
|
// Contest Modal
|
||||||
|
contestModal: document.getElementById('contest-modal'),
|
||||||
|
contestCloseBtn: document.getElementById('contest-close-btn'),
|
||||||
|
contestForm: document.getElementById('contest-form'),
|
||||||
|
btnContestAddPhoto: document.getElementById('btn-contest-add-photo'),
|
||||||
|
contestPhotoBtnText: document.getElementById('contest-photo-btn-text'),
|
||||||
|
contestPhotoPreviewBox: document.getElementById('contest-photo-preview-box'),
|
||||||
|
contestPhotoPreviewImg: document.getElementById('contest-photo-preview-img'),
|
||||||
|
btnContestRemovePhoto: document.getElementById('btn-contest-remove-photo'),
|
||||||
|
|
||||||
|
// Report Form Photo Proof
|
||||||
|
btnReportAddPhoto: document.getElementById('btn-report-add-photo'),
|
||||||
|
reportPhotoBtnText: document.getElementById('report-photo-btn-text'),
|
||||||
|
reportPhotoPreviewBox: document.getElementById('report-photo-preview-box'),
|
||||||
|
reportPhotoPreviewImg: document.getElementById('report-photo-preview-img'),
|
||||||
|
btnReportRemovePhoto: document.getElementById('btn-report-remove-photo'),
|
||||||
|
|
||||||
|
// Native Camera / File Input
|
||||||
|
cameraPhotoFileInput: document.getElementById('camera-photo-file-input')
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- INITIALIZATION ---
|
// --- INITIALIZATION ---
|
||||||
@@ -294,20 +340,23 @@
|
|||||||
cameraCircles = [];
|
cameraCircles = [];
|
||||||
|
|
||||||
allCameras.forEach(cam => {
|
allCameras.forEach(cam => {
|
||||||
|
const meta = window.FlockAuth ? window.FlockAuth.getCameraMeta(cam.id) : { confirmations: 1, hasPhoto: false, isContested: false };
|
||||||
|
const hasPhoto = meta.hasPhoto && !!meta.photoUrl;
|
||||||
|
|
||||||
// 1. Red Radar Recording Zone Circle (Translucent buffer)
|
// 1. Red Radar Recording Zone Circle (Translucent buffer)
|
||||||
const radarCircle = L.circle([cam.lat, cam.lng], {
|
const radarCircle = L.circle([cam.lat, cam.lng], {
|
||||||
radius: cam.radius,
|
radius: cam.radius,
|
||||||
color: '#ef4444',
|
color: meta.isContested ? '#f59e0b' : '#ef4444',
|
||||||
weight: 1.5,
|
weight: 1.5,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
fillColor: '#ef4444',
|
fillColor: meta.isContested ? '#f59e0b' : '#ef4444',
|
||||||
fillOpacity: 0.18,
|
fillOpacity: meta.isContested ? 0.12 : 0.18,
|
||||||
className: 'flock-radar-circle'
|
className: 'flock-radar-circle'
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
// 2. Camera Center Node Marker
|
// 2. Camera Center Node Marker
|
||||||
const iconHtml = `
|
const iconHtml = `
|
||||||
<div class="camera-map-pin ${cam.isCommunity ? 'community-pin' : ''}">
|
<div class="camera-map-pin ${cam.isCommunity ? 'community-pin' : ''} ${hasPhoto ? 'has-photo-pin' : ''} ${meta.isContested ? 'is-contested-pin' : ''}">
|
||||||
<div class="cam-pulse-wave"></div>
|
<div class="cam-pulse-wave"></div>
|
||||||
<div class="cam-dot-center">
|
<div class="cam-dot-center">
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2.5">
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2.5">
|
||||||
@@ -315,6 +364,7 @@
|
|||||||
<circle cx="12" cy="13" r="4"></circle>
|
<circle cx="12" cy="13" r="4"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
${hasPhoto ? '<div class="pin-photo-dot" title="Photo Evidence Verified">📸</div>' : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -328,26 +378,40 @@
|
|||||||
const marker = L.marker([cam.lat, cam.lng], { icon: customIcon }).addTo(map);
|
const marker = L.marker([cam.lat, cam.lng], { icon: customIcon }).addTo(map);
|
||||||
|
|
||||||
// Popup details
|
// Popup details
|
||||||
|
const badgeText = meta.isContested
|
||||||
|
? '🟠 CONTESTED SIGHTING'
|
||||||
|
: (hasPhoto ? '🟢 PHOTO VERIFIED HARDWARE' : (cam.isCommunity ? 'COMMUNITY SIGHTING' : 'FLOCK RECORDED ZONE'));
|
||||||
|
|
||||||
const popupHtml = `
|
const popupHtml = `
|
||||||
<div class="radar-popup-card">
|
<div class="radar-popup-card">
|
||||||
<div class="popup-badge">${cam.isCommunity ? 'COMMUNITY REPORT' : 'FLOCK RECORDED ZONE'}</div>
|
<div class="popup-badge ${hasPhoto ? 'badge-verified-photo' : (meta.isContested ? 'badge-contested-tag' : '')}">${badgeText}</div>
|
||||||
<h4 class="popup-title">${escapeHtml(cam.name)}</h4>
|
<h4 class="popup-title">${escapeHtml(cam.name)}</h4>
|
||||||
<div class="popup-meta">
|
<div class="popup-meta">
|
||||||
<span><strong>Agency:</strong> ${escapeHtml(cam.agency)}</span><br>
|
<span><strong>Agency:</strong> ${escapeHtml(cam.agency)}</span><br>
|
||||||
<span><strong>Cameras in Cluster:</strong> ${cam.cameras || 1}</span><br>
|
<span><strong>Consensus:</strong> 👍 ${meta.confirmations || 1} Confirmations</span><br>
|
||||||
<span><strong>30d Activity:</strong> ${cam.captures30d || 'Active'}</span>
|
<span><strong>Photo Proof:</strong> ${hasPhoto ? '🟢 Verified on Pole' : '⚠️ No Photo Yet (Needs Driver Sighting)'}</span>
|
||||||
${cam.notes ? `<br><span><em>${escapeHtml(cam.notes)}</em></span>` : ''}
|
${cam.notes ? `<br><span><em>${escapeHtml(cam.notes)}</em></span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 6px; margin-top: 10px;">
|
||||||
|
<button class="popup-inspect-btn" style="background: linear-gradient(135deg, #06b6d4, #0284c7); color: #fff; font-weight: 600;" onclick="window.openCameraHardwareProof('${escapeHtml(cam.id)}')">
|
||||||
|
📸 View Hardware Proof & Vote ↗
|
||||||
|
</button>
|
||||||
${cam.slug ? `
|
${cam.slug ? `
|
||||||
<button class="popup-inspect-btn" onclick="window.inspectPortal('${escapeHtml(cam.slug)}')">
|
<button class="popup-inspect-btn" onclick="window.inspectPortal('${escapeHtml(cam.slug)}')">
|
||||||
Inspect Agency Records & Sharing ↗
|
Inspect Agency Records & Sharing ↗
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
marker.bindPopup(popupHtml);
|
marker.bindPopup(popupHtml);
|
||||||
|
|
||||||
|
// Clicking marker directly opens the full hardware proof modal
|
||||||
|
marker.on('click', () => {
|
||||||
|
// Leaflet will open popup, but user can also click proof button directly
|
||||||
|
});
|
||||||
|
|
||||||
cameraCircles.push(radarCircle);
|
cameraCircles.push(radarCircle);
|
||||||
cameraCircles.push(marker);
|
cameraCircles.push(marker);
|
||||||
});
|
});
|
||||||
@@ -1261,6 +1325,218 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SURVEILLANCE ZONE HARDWARE PROOF MODAL ---
|
||||||
|
function openCameraDetailModal(cam) {
|
||||||
|
if (!cam) return;
|
||||||
|
activeModalCamera = cam;
|
||||||
|
const meta = window.FlockAuth ? window.FlockAuth.getCameraMeta(cam.id) : { confirmations: 1, hasPhoto: false, isContested: false };
|
||||||
|
const hasPhoto = meta.hasPhoto && !!meta.photoUrl;
|
||||||
|
|
||||||
|
// Title & Agency
|
||||||
|
if (el.camDetailTitle) el.camDetailTitle.textContent = cam.name || 'Flock ALPR Surveillance Installation';
|
||||||
|
if (el.camDetailMeta) el.camDetailMeta.textContent = `${cam.agency || 'Law Enforcement'} • Monitored Highway Grid`;
|
||||||
|
|
||||||
|
// Verification Badge
|
||||||
|
if (el.camDetailVerifyBadge && el.camDetailVerifyText) {
|
||||||
|
if (meta.isContested) {
|
||||||
|
el.camDetailVerifyBadge.className = 'cam-verification-badge badge-contested';
|
||||||
|
el.camDetailVerifyText.textContent = `🟠 CONTESTED SIGHTING (${meta.contests ? meta.contests.length : 1} REPORTS)`;
|
||||||
|
} else if (hasPhoto) {
|
||||||
|
el.camDetailVerifyBadge.className = 'cam-verification-badge badge-verified';
|
||||||
|
el.camDetailVerifyText.textContent = `🟢 VERIFIED PHOTO PROOF • ${meta.confirmations || 1} CONFIRMATIONS`;
|
||||||
|
} else {
|
||||||
|
el.camDetailVerifyBadge.className = 'cam-verification-badge badge-unverified';
|
||||||
|
el.camDetailVerifyText.textContent = `🟡 UNVERIFIED PIN • NEEDS PHOTO PROOF (${meta.confirmations || 1} CONFIRMED)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photo Container
|
||||||
|
if (el.camProofContainer) {
|
||||||
|
if (hasPhoto) {
|
||||||
|
el.camProofContainer.innerHTML = `
|
||||||
|
<div class="proof-photo-box">
|
||||||
|
<img src="${meta.photoUrl}" class="proof-photo-img" alt="Flock camera mounted on pole" onclick="window.open('${meta.photoUrl}', '_blank')" title="Click to view full size">
|
||||||
|
<div class="proof-photo-overlay">
|
||||||
|
<div>
|
||||||
|
<span class="proof-photo-author">📸 Scout: ${escapeHtml(meta.photoAuthor || 'Active Driver')}</span><br>
|
||||||
|
<span class="proof-photo-date">Logged: ${new Date(meta.photoDate || Date.now()).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })}</span>
|
||||||
|
</div>
|
||||||
|
<span style="background: rgba(16,185,129,0.25); border: 1px solid #10b981; padding: 3px 8px; border-radius: 12px; font-size: 0.7rem; font-weight: 700; color: #34d399;">VERIFIED HARDWARE</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
if (el.btnProofPhotoText) el.btnProofPhotoText.textContent = 'Update Photo';
|
||||||
|
} else {
|
||||||
|
el.camProofContainer.innerHTML = `
|
||||||
|
<div class="proof-empty-box">
|
||||||
|
<div class="proof-empty-icon">📸</div>
|
||||||
|
<div class="proof-empty-title">No Physical Hardware Photo Yet</div>
|
||||||
|
<div class="proof-empty-desc">
|
||||||
|
Flock cameras stay permanently bolted to utility poles and traffic signals with small solar panels. Snap a photo of the pole to lock this camera into the permanent verified registry.
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-proof-snap-cta" onclick="window.triggerPhotoCaptureForActiveCam()">
|
||||||
|
<span>📸</span>
|
||||||
|
<span>Snap Camera on Pole (+100 pts)</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
if (el.btnProofPhotoText) el.btnProofPhotoText.textContent = 'Add Photo Proof';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specs
|
||||||
|
if (el.specMountType) el.specMountType.textContent = cam.type || 'Solar Pole Mount (Falcon)';
|
||||||
|
if (el.specAgency) el.specAgency.textContent = cam.agency || 'Local Law Enforcement';
|
||||||
|
if (el.specCaptures) el.specCaptures.textContent = cam.captures30d ? `${cam.captures30d} Reads` : 'Active Corridor';
|
||||||
|
if (el.specCoords) el.specCoords.textContent = `${cam.lat.toFixed(4)}, ${cam.lng.toFixed(4)}`;
|
||||||
|
|
||||||
|
// Confirm button state
|
||||||
|
const userConfirmed = window.FlockAuth ? window.FlockAuth.hasUserConfirmed(cam.id) : false;
|
||||||
|
if (el.btnVoteConfirmCam && el.btnVoteConfirmText) {
|
||||||
|
if (userConfirmed) {
|
||||||
|
el.btnVoteConfirmCam.classList.add('voted');
|
||||||
|
el.btnVoteConfirmText.textContent = `👍 Confirmed (${meta.confirmations || 1})`;
|
||||||
|
} else {
|
||||||
|
el.btnVoteConfirmCam.classList.remove('voted');
|
||||||
|
el.btnVoteConfirmText.textContent = `Confirm Active (${meta.confirmations || 1})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate link
|
||||||
|
if (el.btnProofNavigate) {
|
||||||
|
el.btnProofNavigate.href = `https://www.google.com/maps/dir/?api=1&destination=${cam.lat},${cam.lng}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transparency Portal Link
|
||||||
|
const portalBox = document.getElementById('proof-portal-link-box');
|
||||||
|
if (portalBox) {
|
||||||
|
if (cam.slug) {
|
||||||
|
portalBox.style.display = 'block';
|
||||||
|
if (el.btnProofOpenPortal) {
|
||||||
|
el.btnProofOpenPortal.onclick = () => {
|
||||||
|
closeCameraDetailModal();
|
||||||
|
window.inspectPortal(cam.slug);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
portalBox.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
if (el.cameraDetailModal) {
|
||||||
|
if (typeof el.cameraDetailModal.showModal === 'function') {
|
||||||
|
el.cameraDetailModal.showModal();
|
||||||
|
} else {
|
||||||
|
el.cameraDetailModal.setAttribute('open', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCameraDetailModal() {
|
||||||
|
if (el.cameraDetailModal) {
|
||||||
|
if (typeof el.cameraDetailModal.close === 'function') {
|
||||||
|
el.cameraDetailModal.close();
|
||||||
|
} else {
|
||||||
|
el.cameraDetailModal.removeAttribute('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CONTEST CAMERA MODAL ---
|
||||||
|
function openContestModal(cam) {
|
||||||
|
activeModalCamera = cam;
|
||||||
|
const reasonEl = document.getElementById('contest-reason');
|
||||||
|
if (reasonEl) reasonEl.selectedIndex = 0;
|
||||||
|
const notesEl = document.getElementById('contest-notes');
|
||||||
|
if (notesEl) notesEl.value = '';
|
||||||
|
contestPhotoPendingDataUrl = null;
|
||||||
|
if (el.contestPhotoPreviewBox) el.contestPhotoPreviewBox.classList.add('hidden');
|
||||||
|
if (el.contestPhotoBtnText) el.contestPhotoBtnText.textContent = 'Snap Empty Pole';
|
||||||
|
|
||||||
|
if (el.contestModal) {
|
||||||
|
if (typeof el.contestModal.showModal === 'function') {
|
||||||
|
el.contestModal.showModal();
|
||||||
|
} else {
|
||||||
|
el.contestModal.setAttribute('open', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeContestModal() {
|
||||||
|
if (el.contestModal) {
|
||||||
|
if (typeof el.contestModal.close === 'function') {
|
||||||
|
el.contestModal.close();
|
||||||
|
} else {
|
||||||
|
el.contestModal.removeAttribute('open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- PHOTO CAPTURE TRIGGERS ---
|
||||||
|
window.triggerPhotoCaptureForActiveCam = function() {
|
||||||
|
photoUploadTarget = 'detail';
|
||||||
|
if (el.cameraPhotoFileInput) {
|
||||||
|
el.cameraPhotoFileInput.value = '';
|
||||||
|
el.cameraPhotoFileInput.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function triggerPhotoCaptureForReport() {
|
||||||
|
photoUploadTarget = 'report';
|
||||||
|
if (el.cameraPhotoFileInput) {
|
||||||
|
el.cameraPhotoFileInput.value = '';
|
||||||
|
el.cameraPhotoFileInput.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerPhotoCaptureForContest() {
|
||||||
|
photoUploadTarget = 'contest';
|
||||||
|
if (el.cameraPhotoFileInput) {
|
||||||
|
el.cameraPhotoFileInput.value = '';
|
||||||
|
el.cameraPhotoFileInput.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePhotoFileSelected(e) {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
showToast("📸 Processing & compressing photo proof...");
|
||||||
|
const compressedDataUrl = await window.FlockAuth.compressImage(file, 1000, 0.72);
|
||||||
|
|
||||||
|
if (photoUploadTarget === 'detail' && activeModalCamera) {
|
||||||
|
window.FlockAuth.addPhotoProof(activeModalCamera.id, compressedDataUrl, 'Physical pole photo uploaded by scout');
|
||||||
|
openCameraDetailModal(activeModalCamera);
|
||||||
|
renderCameraZones();
|
||||||
|
playLogConfirmationChirp();
|
||||||
|
speakVoiceAlert("Photo proof verified and locked into radar database. +100 Scout Points awarded!");
|
||||||
|
showToast("📸 Photo proof verified & locked! +100 Scout Points!");
|
||||||
|
} else if (photoUploadTarget === 'report') {
|
||||||
|
reportPhotoPendingDataUrl = compressedDataUrl;
|
||||||
|
if (el.reportPhotoPreviewImg) el.reportPhotoPreviewImg.src = compressedDataUrl;
|
||||||
|
if (el.reportPhotoPreviewBox) el.reportPhotoPreviewBox.classList.remove('hidden');
|
||||||
|
if (el.reportPhotoBtnText) el.reportPhotoBtnText.textContent = 'Change Photo Proof';
|
||||||
|
showToast("📸 Photo proof attached to camera report!");
|
||||||
|
} else if (photoUploadTarget === 'contest') {
|
||||||
|
contestPhotoPendingDataUrl = compressedDataUrl;
|
||||||
|
if (el.contestPhotoPreviewImg) el.contestPhotoPreviewImg.src = compressedDataUrl;
|
||||||
|
if (el.contestPhotoPreviewBox) el.contestPhotoPreviewBox.classList.remove('hidden');
|
||||||
|
if (el.contestPhotoBtnText) el.contestPhotoBtnText.textContent = 'Change Empty Pole Photo';
|
||||||
|
showToast("📸 Empty pole photo attached to contest report!");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Photo processing error:', err);
|
||||||
|
alert('Could not process photo: ' + (err.message || 'Unknown error'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.openCameraHardwareProof = function(camId) {
|
||||||
|
const cam = allCameras.find(c => c.id === camId);
|
||||||
|
if (cam) openCameraDetailModal(cam);
|
||||||
|
};
|
||||||
|
|
||||||
// --- EVENT BINDINGS ---
|
// --- EVENT BINDINGS ---
|
||||||
function bindEvents() {
|
function bindEvents() {
|
||||||
// Live GPS Continuous Tracking
|
// Live GPS Continuous Tracking
|
||||||
@@ -1324,6 +1600,108 @@
|
|||||||
if (el.btnSensCity) el.btnSensCity.addEventListener('click', () => setRadarSensitivity('city'));
|
if (el.btnSensCity) el.btnSensCity.addEventListener('click', () => setRadarSensitivity('city'));
|
||||||
if (el.btnSensHwy) el.btnSensHwy.addEventListener('click', () => setRadarSensitivity('highway'));
|
if (el.btnSensHwy) el.btnSensHwy.addEventListener('click', () => setRadarSensitivity('highway'));
|
||||||
|
|
||||||
|
// Camera Hardware Proof & Voting Events
|
||||||
|
if (el.camDetailCloseBtn) el.camDetailCloseBtn.addEventListener('click', closeCameraDetailModal);
|
||||||
|
|
||||||
|
if (el.btnVoteConfirmCam) {
|
||||||
|
el.btnVoteConfirmCam.addEventListener('click', () => {
|
||||||
|
if (!activeModalCamera) return;
|
||||||
|
const res = window.FlockAuth.confirmCamera(activeModalCamera.id);
|
||||||
|
if (res.success) {
|
||||||
|
playLogConfirmationChirp();
|
||||||
|
speakVoiceAlert("Camera confirmed active on pole. +25 Scout Points!");
|
||||||
|
showToast(`👍 Confirmed active on pole! Total: ${res.count} confirmations.`);
|
||||||
|
openCameraDetailModal(activeModalCamera);
|
||||||
|
renderCameraZones();
|
||||||
|
updateProfileDisplay();
|
||||||
|
} else {
|
||||||
|
showToast(res.message || "Already confirmed!");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.btnProofAddPhoto) {
|
||||||
|
el.btnProofAddPhoto.addEventListener('click', () => {
|
||||||
|
window.triggerPhotoCaptureForActiveCam();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.btnProofContest) {
|
||||||
|
el.btnProofContest.addEventListener('click', () => {
|
||||||
|
if (!activeModalCamera) return;
|
||||||
|
closeCameraDetailModal();
|
||||||
|
openContestModal(activeModalCamera);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Contest Modal Events
|
||||||
|
if (el.contestCloseBtn) el.contestCloseBtn.addEventListener('click', closeContestModal);
|
||||||
|
if (el.btnContestAddPhoto) el.btnContestAddPhoto.addEventListener('click', triggerPhotoCaptureForContest);
|
||||||
|
if (el.btnContestRemovePhoto) {
|
||||||
|
el.btnContestRemovePhoto.addEventListener('click', () => {
|
||||||
|
contestPhotoPendingDataUrl = null;
|
||||||
|
if (el.contestPhotoPreviewBox) el.contestPhotoPreviewBox.classList.add('hidden');
|
||||||
|
if (el.contestPhotoBtnText) el.contestPhotoBtnText.textContent = 'Snap Empty Pole';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (el.contestForm) {
|
||||||
|
el.contestForm.addEventListener('submit', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!activeModalCamera) return;
|
||||||
|
const reason = document.getElementById('contest-reason').value;
|
||||||
|
const notes = document.getElementById('contest-notes').value.trim();
|
||||||
|
|
||||||
|
const res = window.FlockAuth.contestCamera(activeModalCamera.id, reason, notes);
|
||||||
|
closeContestModal();
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
if (res.isContested) {
|
||||||
|
speakVoiceAlert("Camera flagged as contested by community consensus.");
|
||||||
|
showToast("⚠️ Location marked Contested by community consensus.");
|
||||||
|
} else {
|
||||||
|
showToast(`⚠️ Contest submitted (${res.contestCount}/3 flags for consensus review).`);
|
||||||
|
}
|
||||||
|
renderCameraZones();
|
||||||
|
} else {
|
||||||
|
showToast(res.message || "Already reported!");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report Form Photo Proof Events
|
||||||
|
if (el.btnReportAddPhoto) el.btnReportAddPhoto.addEventListener('click', triggerPhotoCaptureForReport);
|
||||||
|
if (el.btnReportRemovePhoto) {
|
||||||
|
el.btnReportRemovePhoto.addEventListener('click', () => {
|
||||||
|
reportPhotoPendingDataUrl = null;
|
||||||
|
if (el.reportPhotoPreviewBox) el.reportPhotoPreviewBox.classList.add('hidden');
|
||||||
|
if (el.reportPhotoBtnText) el.reportPhotoBtnText.textContent = 'Snap Camera on Pole (+100 pts)';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native Camera / File Input Change Event
|
||||||
|
if (el.cameraPhotoFileInput) {
|
||||||
|
el.cameraPhotoFileInput.addEventListener('change', handlePhotoFileSelected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nearest Camera HUD tap opens details
|
||||||
|
const hudLocationBar = document.querySelector('.hud-location-bar');
|
||||||
|
if (hudLocationBar) {
|
||||||
|
hudLocationBar.style.cursor = 'pointer';
|
||||||
|
hudLocationBar.title = 'Click to inspect nearest camera hardware proof & vote';
|
||||||
|
hudLocationBar.addEventListener('click', () => {
|
||||||
|
if (allCameras.length > 0) {
|
||||||
|
let closest = null;
|
||||||
|
let minDist = Infinity;
|
||||||
|
allCameras.forEach(cam => {
|
||||||
|
const d = getHaversineDistanceMeters(currentPosition.lat, currentPosition.lng, cam.lat, cam.lng);
|
||||||
|
if (d < minDist) { minDist = d; closest = cam; }
|
||||||
|
});
|
||||||
|
if (closest) openCameraDetailModal(closest);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Mute toggle
|
// Mute toggle
|
||||||
el.btnMuteSound.addEventListener('click', () => {
|
el.btnMuteSound.addEventListener('click', () => {
|
||||||
const user = window.FlockAuth ? window.FlockAuth.getUser() : null;
|
const user = window.FlockAuth ? window.FlockAuth.getUser() : null;
|
||||||
@@ -1374,11 +1752,25 @@
|
|||||||
const lng = parseFloat(document.getElementById('report-lng').value);
|
const lng = parseFloat(document.getElementById('report-lng').value);
|
||||||
|
|
||||||
if (window.FlockAuth) {
|
if (window.FlockAuth) {
|
||||||
const newRpt = window.FlockAuth.addReport({ lat, lng, type, intersection, notes });
|
const newRpt = window.FlockAuth.addReport({
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
type,
|
||||||
|
intersection,
|
||||||
|
notes,
|
||||||
|
photoUrl: reportPhotoPendingDataUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
reportPhotoPendingDataUrl = null;
|
||||||
|
if (el.reportPhotoPreviewBox) el.reportPhotoPreviewBox.classList.add('hidden');
|
||||||
|
if (el.reportPhotoBtnText) el.reportPhotoBtnText.textContent = 'Snap Camera on Pole (+100 pts)';
|
||||||
|
|
||||||
loadAllCameraData();
|
loadAllCameraData();
|
||||||
renderCameraZones();
|
renderCameraZones();
|
||||||
updateProfileDisplay();
|
updateProfileDisplay();
|
||||||
alert(`Camera report added! +50 Scout Points. Zone is now active on radar.`);
|
playLogConfirmationChirp();
|
||||||
|
speakVoiceAlert("Camera sighting broadcast to radar network!");
|
||||||
|
showToast(`Camera report added! +${newRpt.photoUrl ? '150' : '50'} Scout Points.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
closeReportModal();
|
closeReportModal();
|
||||||
|
|||||||
Reference in New Issue
Block a user