413 lines
12 KiB
JavaScript
413 lines
12 KiB
JavaScript
/**
|
|
* FLOCKRADAR - USER AUTHENTICATION & COMMUNITY STATE MANAGER
|
|
* Waze-style driver profile, preferences, and crowdsourced reporting store.
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
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 = {
|
|
isLoggedIn: false,
|
|
username: 'Guest Driver',
|
|
callsign: 'SCOUT-104',
|
|
level: 1,
|
|
points: 120,
|
|
vehicle: 'Sedan (Silver)',
|
|
soundEnabled: true,
|
|
alertsMuted: false,
|
|
reportsCount: 0,
|
|
photosCount: 0
|
|
};
|
|
|
|
// State
|
|
let currentUser = null;
|
|
let communityReports = [];
|
|
let cameraMetadata = {};
|
|
let userVotes = { confirmed: {}, contested: {} };
|
|
|
|
function init() {
|
|
loadUser();
|
|
loadReports();
|
|
loadMetadata();
|
|
loadVotes();
|
|
}
|
|
|
|
function loadUser() {
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEY_USER);
|
|
if (stored) {
|
|
currentUser = JSON.parse(stored);
|
|
} else {
|
|
currentUser = { ...defaultUser };
|
|
}
|
|
} catch (e) {
|
|
currentUser = { ...defaultUser };
|
|
}
|
|
}
|
|
|
|
function saveUser() {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY_USER, JSON.stringify(currentUser));
|
|
window.dispatchEvent(new CustomEvent('flock_user_updated', { detail: currentUser }));
|
|
} catch (e) {
|
|
console.error('Failed to save user session', e);
|
|
}
|
|
}
|
|
|
|
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: (currentUser ? currentUser.points : 0) + 100,
|
|
vehicle: vehicle || 'Standard Vehicle',
|
|
soundEnabled: soundEnabled !== false,
|
|
alertsMuted: false,
|
|
reportsCount: communityReports.length,
|
|
photosCount: currentUser ? (currentUser.photosCount || 0) : 0
|
|
};
|
|
saveUser();
|
|
return currentUser;
|
|
}
|
|
|
|
function logout() {
|
|
currentUser = { ...defaultUser };
|
|
saveUser();
|
|
}
|
|
|
|
function loadReports() {
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEY_REPORTS);
|
|
if (stored) {
|
|
communityReports = JSON.parse(stored);
|
|
// Automatically prune any unverified test marks or pins without photo proof
|
|
const beforeCount = communityReports.length;
|
|
communityReports = communityReports.filter(r =>
|
|
r &&
|
|
r.photoUrl &&
|
|
!r.id.startsWith('usr-pin-') &&
|
|
r.notes !== '1-Tap Mobile Driver Pin' &&
|
|
r.type !== 'Fixed ALPR Camera'
|
|
);
|
|
if (communityReports.length !== beforeCount) {
|
|
localStorage.setItem(STORAGE_KEY_REPORTS, JSON.stringify(communityReports));
|
|
}
|
|
} else {
|
|
communityReports = [];
|
|
}
|
|
} catch (e) {
|
|
communityReports = [];
|
|
}
|
|
}
|
|
|
|
function clearUnverifiedReports() {
|
|
try {
|
|
communityReports = communityReports.filter(r =>
|
|
r &&
|
|
r.photoUrl &&
|
|
!r.id.startsWith('usr-pin-') &&
|
|
r.notes !== '1-Tap Mobile Driver Pin' &&
|
|
r.type !== 'Fixed ALPR Camera'
|
|
);
|
|
localStorage.setItem(STORAGE_KEY_REPORTS, JSON.stringify(communityReports));
|
|
Object.keys(cameraMetadata).forEach(id => {
|
|
if (id.startsWith('usr-pin-') || !cameraMetadata[id].photoUrl) {
|
|
delete cameraMetadata[id];
|
|
}
|
|
});
|
|
localStorage.setItem(STORAGE_KEY_METADATA, JSON.stringify(cameraMetadata));
|
|
window.dispatchEvent(new CustomEvent('flock_reports_updated', { detail: communityReports }));
|
|
} catch (e) {
|
|
console.warn('Could not clear unverified reports', e);
|
|
}
|
|
}
|
|
|
|
function removeReport(reportId) {
|
|
communityReports = communityReports.filter(r => r.id !== reportId);
|
|
if (cameraMetadata[reportId]) delete cameraMetadata[reportId];
|
|
localStorage.setItem(STORAGE_KEY_REPORTS, JSON.stringify(communityReports));
|
|
localStorage.setItem(STORAGE_KEY_METADATA, JSON.stringify(cameraMetadata));
|
|
window.dispatchEvent(new CustomEvent('flock_reports_updated', { detail: communityReports }));
|
|
return true;
|
|
}
|
|
|
|
function clearAllPlacedReports() {
|
|
communityReports = [];
|
|
localStorage.setItem(STORAGE_KEY_REPORTS, JSON.stringify([]));
|
|
// Clean up metadata for custom placed pins
|
|
Object.keys(cameraMetadata).forEach(id => {
|
|
if (id.startsWith('rpt-') || id.startsWith('usr-pin-') || id.startsWith('test-') || id.startsWith('cam-test-')) {
|
|
delete cameraMetadata[id];
|
|
}
|
|
});
|
|
localStorage.setItem(STORAGE_KEY_METADATA, JSON.stringify(cameraMetadata));
|
|
window.dispatchEvent(new CustomEvent('flock_reports_updated', { detail: [] }));
|
|
return true;
|
|
}
|
|
|
|
function saveReports() {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY_REPORTS, JSON.stringify(communityReports));
|
|
window.dispatchEvent(new CustomEvent('flock_reports_updated', { detail: communityReports }));
|
|
} catch (e) {
|
|
console.error('Failed to save community reports', e);
|
|
}
|
|
}
|
|
|
|
function addReport(report) {
|
|
const reportId = 'rpt-' + Date.now();
|
|
const newReport = {
|
|
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.callsign || currentUser.username) : 'Anonymous Scout',
|
|
timestamp: new Date().toISOString(),
|
|
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) + (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;
|
|
}
|
|
|
|
function getReports() {
|
|
if (!communityReports) loadReports();
|
|
return communityReports;
|
|
}
|
|
|
|
// Export API to global scope
|
|
window.FlockAuth = {
|
|
init,
|
|
getUser,
|
|
login,
|
|
logout,
|
|
saveUser,
|
|
getReports,
|
|
addReport,
|
|
removeReport,
|
|
clearAllPlacedReports,
|
|
clearUnverifiedReports,
|
|
getCameraMeta,
|
|
confirmCamera,
|
|
addPhotoProof,
|
|
contestCamera,
|
|
hasUserConfirmed,
|
|
hasUserContested,
|
|
compressImage
|
|
};
|
|
|
|
init();
|
|
})();
|