diff --git a/css/styles.css b/css/styles.css index f89454a..ce2bc82 100644 --- a/css/styles.css +++ b/css/styles.css @@ -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; +} + + diff --git a/index.html b/index.html index 8c7bd82..15124a2 100644 --- a/index.html +++ b/index.html @@ -724,6 +724,21 @@ + +
+ +
+ + +
+
+
@@ -742,6 +757,149 @@
+ + + + + + + + + + + + + + +
diff --git a/js/auth.js b/js/auth.js index d7eded3..5cedaf8 100644 --- a/js/auth.js +++ b/js/auth.js @@ -8,6 +8,8 @@ const STORAGE_KEY_USER = 'flock_radar_user'; const STORAGE_KEY_REPORTS = 'flock_community_reports'; + const STORAGE_KEY_METADATA = 'flock_camera_metadata'; + const STORAGE_KEY_VOTES = 'flock_user_votes'; // Default Guest Profile const defaultUser = { @@ -19,16 +21,21 @@ vehicle: 'Sedan (Silver)', soundEnabled: true, alertsMuted: false, - reportsCount: 0 + reportsCount: 0, + photosCount: 0 }; // State let currentUser = null; let communityReports = []; + let cameraMetadata = {}; + let userVotes = { confirmed: {}, contested: {} }; function init() { loadUser(); loadReports(); + loadMetadata(); + loadVotes(); } 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) { currentUser = { isLoggedIn: true, username: username || 'Citizen Scout', callsign: 'RADAR-' + Math.floor(100 + Math.random() * 900), level: 2, - points: 250, + points: (currentUser ? currentUser.points : 0) + 100, vehicle: vehicle || 'Standard Vehicle', soundEnabled: soundEnabled !== false, alertsMuted: false, - reportsCount: communityReports.length + reportsCount: communityReports.length, + photosCount: currentUser ? (currentUser.photosCount || 0) : 0 }; saveUser(); return currentUser; @@ -97,30 +140,186 @@ } function addReport(report) { + const reportId = 'rpt-' + Date.now(); const newReport = { - id: 'rpt-' + Date.now(), + id: reportId, lat: report.lat, lng: report.lng, type: report.type || 'Solar Pole Mount', intersection: report.intersection || 'Reported Location', 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(), - confirmations: 1 + confirmations: 1, + photoUrl: report.photoUrl || null }; communityReports.push(newReport); 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) { - currentUser.points = (currentUser.points || 0) + 50; + currentUser.points = (currentUser.points || 0) + (report.photoUrl ? 150 : 50); currentUser.reportsCount = (currentUser.reportsCount || 0) + 1; + if (report.photoUrl) { + currentUser.photosCount = (currentUser.photosCount || 0) + 1; + } saveUser(); } 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() { if (!currentUser) loadUser(); return currentUser; @@ -139,7 +338,14 @@ logout, saveUser, getReports, - addReport + addReport, + getCameraMeta, + confirmCamera, + addPhotoProof, + contestCamera, + hasUserConfirmed, + hasUserContested, + compressImage }; init(); diff --git a/js/radar.js b/js/radar.js index 3ec5550..9cf238f 100644 --- a/js/radar.js +++ b/js/radar.js @@ -95,6 +95,12 @@ let radarRangeMode = 'city'; // 'city' (1,000 ft) or 'highway' (2,500 ft) let lastCautionSpokenCamId = null; + // Photo Evidence & Consensus State + let activeModalCamera = null; + let photoUploadTarget = 'detail'; // 'detail' | 'report' | 'contest' + let reportPhotoPendingDataUrl = null; + let contestPhotoPendingDataUrl = null; + // --- DOM REFERENCES --- const el = { radarView: document.getElementById('radar-view'), @@ -152,7 +158,47 @@ reportForm: document.getElementById('report-form'), loginCloseBtn: document.getElementById('login-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 --- @@ -294,20 +340,23 @@ cameraCircles = []; 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) const radarCircle = L.circle([cam.lat, cam.lng], { radius: cam.radius, - color: '#ef4444', + color: meta.isContested ? '#f59e0b' : '#ef4444', weight: 1.5, opacity: 0.8, - fillColor: '#ef4444', - fillOpacity: 0.18, + fillColor: meta.isContested ? '#f59e0b' : '#ef4444', + fillOpacity: meta.isContested ? 0.12 : 0.18, className: 'flock-radar-circle' }).addTo(map); // 2. Camera Center Node Marker const iconHtml = ` -
+
@@ -315,6 +364,7 @@
+ ${hasPhoto ? '
📸
' : ''}
`; @@ -328,26 +378,40 @@ const marker = L.marker([cam.lat, cam.lng], { icon: customIcon }).addTo(map); // Popup details + const badgeText = meta.isContested + ? '🟠 CONTESTED SIGHTING' + : (hasPhoto ? '🟢 PHOTO VERIFIED HARDWARE' : (cam.isCommunity ? 'COMMUNITY SIGHTING' : 'FLOCK RECORDED ZONE')); + const popupHtml = `
- + - ${cam.slug ? ` - - ` : ''} + ${cam.slug ? ` + + ` : ''} +
`; 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(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 = ` +
+ Flock camera mounted on pole +
+
+ 📸 Scout: ${escapeHtml(meta.photoAuthor || 'Active Driver')}
+ Logged: ${new Date(meta.photoDate || Date.now()).toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })} +
+ VERIFIED HARDWARE +
+
+ `; + if (el.btnProofPhotoText) el.btnProofPhotoText.textContent = 'Update Photo'; + } else { + el.camProofContainer.innerHTML = ` +
+
📸
+
No Physical Hardware Photo Yet
+
+ 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. +
+ +
+ `; + 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 --- function bindEvents() { // Live GPS Continuous Tracking @@ -1324,6 +1600,108 @@ if (el.btnSensCity) el.btnSensCity.addEventListener('click', () => setRadarSensitivity('city')); 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 el.btnMuteSound.addEventListener('click', () => { const user = window.FlockAuth ? window.FlockAuth.getUser() : null; @@ -1374,11 +1752,25 @@ const lng = parseFloat(document.getElementById('report-lng').value); 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(); renderCameraZones(); 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();