1193 lines
45 KiB
JavaScript
1193 lines
45 KiB
JavaScript
/**
|
|
* FLOCK SAFETY TRANSPARENCY PORTALS DIRECTORY
|
|
* Client Application Logic
|
|
* High-performance search, filtering, pagination, modal inspector, and CSV export.
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// --- APPLICATION STATE ---
|
|
const state = {
|
|
allPortals: [],
|
|
orgsLookup: [],
|
|
summary: {},
|
|
filteredPortals: [],
|
|
currentPage: 1,
|
|
pageSize: 24,
|
|
currentView: 'cards', // 'cards' or 'table'
|
|
searchQuery: '',
|
|
selectedState: 'ALL',
|
|
selectedType: 'ALL',
|
|
auditOnly: false,
|
|
sortBy: 'cameras-desc',
|
|
activeModalPortal: null
|
|
};
|
|
|
|
// --- DOM ELEMENTS ---
|
|
const el = {
|
|
// Search & Filters
|
|
searchInput: document.getElementById('search-input'),
|
|
clearSearchBtn: document.getElementById('clear-search-btn'),
|
|
stateFilter: document.getElementById('state-filter'),
|
|
typeFilter: document.getElementById('type-filter'),
|
|
sortSelect: document.getElementById('sort-select'),
|
|
auditToggle: document.getElementById('audit-toggle'),
|
|
btnResetAll: document.getElementById('btn-reset-all'),
|
|
stateChipsScroll: document.getElementById('state-chips-scroll'),
|
|
|
|
// Stats Counters
|
|
statPortals: document.getElementById('stat-portals'),
|
|
statCameras: document.getElementById('stat-cameras'),
|
|
statVehicles: document.getElementById('stat-vehicles'),
|
|
statSearches: document.getElementById('stat-searches'),
|
|
statHits: document.getElementById('stat-hits'),
|
|
|
|
// Views & Results
|
|
resultsCounter: document.getElementById('results-counter'),
|
|
showingCount: document.getElementById('showing-count'),
|
|
totalCount: document.getElementById('total-count'),
|
|
portalsGrid: document.getElementById('portals-grid'),
|
|
tableContainer: document.getElementById('table-container'),
|
|
tableBody: document.getElementById('table-body'),
|
|
paginationControls: document.getElementById('pagination-controls'),
|
|
viewCardsBtn: document.getElementById('view-cards-btn'),
|
|
viewTableBtn: document.getElementById('view-table-btn'),
|
|
btnExportCsv: document.getElementById('btn-export-csv'),
|
|
|
|
// Modal Elements
|
|
agencyModal: document.getElementById('agency-modal'),
|
|
modalCloseBtn: document.getElementById('modal-close-btn'),
|
|
modalAgencyName: document.getElementById('modal-agency-name'),
|
|
modalAgencyLocation: document.getElementById('modal-agency-location'),
|
|
modalAgencySlug: document.getElementById('modal-agency-slug'),
|
|
modalPortalUrl: document.getElementById('modal-portal-url'),
|
|
modalStatCameras: document.getElementById('modal-stat-cameras'),
|
|
modalStatVehicles: document.getElementById('modal-stat-vehicles'),
|
|
modalStatSearches: document.getElementById('modal-stat-searches'),
|
|
modalStatHits: document.getElementById('modal-stat-hits'),
|
|
modalReasonsSection: document.getElementById('modal-reasons-section'),
|
|
modalReasonsList: document.getElementById('modal-reasons-list'),
|
|
modalSharingCount: document.getElementById('modal-sharing-count'),
|
|
modalSharingSearch: document.getElementById('modal-sharing-search'),
|
|
modalSharingBox: document.getElementById('modal-sharing-box'),
|
|
modalProhibitedText: document.getElementById('modal-prohibited-text'),
|
|
modalGoogleContractLink: document.getElementById('modal-google-contract-link'),
|
|
modalCopyLinkBtn: document.getElementById('modal-copy-link-btn'),
|
|
|
|
// Directory Specific Camera Nodes Elements
|
|
directoryCamerasSection: document.getElementById('directory-cameras-section'),
|
|
dirCamQueryTitle: document.getElementById('dir-cam-query-title'),
|
|
dirCamSubtitle: document.getElementById('dir-cam-subtitle'),
|
|
dirCamBadgeCount: document.getElementById('dir-cam-badge-count'),
|
|
directoryCamerasGrid: document.getElementById('directory-cameras-grid'),
|
|
|
|
// Toast Container
|
|
toastContainer: document.getElementById('toast-container')
|
|
};
|
|
|
|
// --- INITIALIZATION ---
|
|
function init() {
|
|
if (!window.FLOCK_DATA || !window.FLOCK_DATA.portals) {
|
|
showToast('Error: Dataset failed to load.', 'error');
|
|
console.error('FLOCK_DATA not found');
|
|
return;
|
|
}
|
|
|
|
state.allPortals = window.FLOCK_DATA.portals || [];
|
|
state.orgsLookup = window.FLOCK_DATA.orgs || [];
|
|
state.summary = window.FLOCK_DATA.summary || {};
|
|
|
|
// Register Local Sheriff Corridors so Directory search & filters match 100%
|
|
const localSheriffPortals = [
|
|
{
|
|
name: "Palmdale Station - Los Angeles County Sheriff (LASD)",
|
|
location: "Palmdale, CA",
|
|
state: "CA",
|
|
dept_type: "SO",
|
|
slug: "palmdale-ca-lasd",
|
|
cameras: 28,
|
|
vehicles_captured: 1850000,
|
|
searches: 14200,
|
|
hotlist_hits: 820,
|
|
has_search_audit: true,
|
|
url: "https://transparency.flocksafety.com/palmdale-ca-lasd",
|
|
shared_org_ids: [15, 73, 102, 145, 180, 204],
|
|
top_search_reasons: {
|
|
"10851 CVC Stolen Vehicle": 4120,
|
|
"Felony Warrant / BOLO": 2180,
|
|
"Amber Alert / Missing Person": 410,
|
|
"Robbery / Assault Investigation": 1890
|
|
},
|
|
prohibited_uses: "Prohibited from: Traffic speed ticketing, red light ticketing, facial recognition, or immigration enforcement."
|
|
},
|
|
{
|
|
name: "Lancaster Station - Los Angeles County Sheriff (LASD)",
|
|
location: "Lancaster, CA",
|
|
state: "CA",
|
|
dept_type: "SO",
|
|
slug: "lancaster-ca-lasd",
|
|
cameras: 32,
|
|
vehicles_captured: 2100000,
|
|
searches: 16800,
|
|
hotlist_hits: 950,
|
|
has_search_audit: true,
|
|
url: "https://transparency.flocksafety.com/lancaster-ca-lasd",
|
|
shared_org_ids: [15, 73, 102, 145, 180, 204],
|
|
top_search_reasons: {
|
|
"10851 CVC Stolen Vehicle": 4850,
|
|
"Felony Warrant / BOLO": 2400,
|
|
"Amber Alert / Missing Person": 520,
|
|
"Commercial Burglary Investigation": 2100
|
|
},
|
|
prohibited_uses: "Prohibited from: Traffic speed ticketing, red light ticketing, facial recognition, or immigration enforcement."
|
|
}
|
|
];
|
|
|
|
localSheriffPortals.forEach(lp => {
|
|
if (!state.allPortals.some(p => p.slug === lp.slug)) {
|
|
state.allPortals.unshift(lp);
|
|
}
|
|
});
|
|
|
|
// Restore saved view preference
|
|
const savedView = localStorage.getItem('flock_dir_view');
|
|
if (savedView === 'table' || savedView === 'cards') {
|
|
state.currentView = savedView;
|
|
}
|
|
|
|
renderHeroStats();
|
|
populateStateFilter();
|
|
renderStateChips();
|
|
bindEvents();
|
|
|
|
// Initial Filter & Render
|
|
applyFilters();
|
|
updateViewMode();
|
|
|
|
// Check URL Hash for deep link
|
|
checkUrlHash();
|
|
}
|
|
|
|
// --- HERO STATS FORMATTING ---
|
|
function renderHeroStats() {
|
|
const s = state.summary;
|
|
if (!s) return;
|
|
|
|
if (s.total_portals_found) {
|
|
el.statPortals.textContent = s.total_portals_found.toLocaleString();
|
|
}
|
|
if (s.total_cameras) {
|
|
el.statCameras.textContent = s.total_cameras.toLocaleString() + '+';
|
|
}
|
|
if (s.total_vehicles_captured) {
|
|
const millions = (s.total_vehicles_captured / 1000000).toFixed(1);
|
|
el.statVehicles.textContent = millions + 'M';
|
|
}
|
|
if (s.total_searches) {
|
|
el.statSearches.textContent = s.total_searches.toLocaleString();
|
|
}
|
|
if (s.total_hotlist_hits) {
|
|
const millions = (s.total_hotlist_hits / 1000000).toFixed(1);
|
|
el.statHits.textContent = millions + 'M+';
|
|
}
|
|
}
|
|
|
|
// --- POPULATE STATE SELECTOR & CHIPS ---
|
|
function populateStateFilter() {
|
|
const stateCounts = {};
|
|
state.allPortals.forEach(p => {
|
|
const st = p.state || 'OTHER';
|
|
stateCounts[st] = (stateCounts[st] || 0) + 1;
|
|
});
|
|
|
|
const sortedStates = Object.keys(stateCounts).sort();
|
|
|
|
// Clear existing options except ALL
|
|
el.stateFilter.innerHTML = `<option value="ALL">All States (${state.allPortals.length} portals)</option>`;
|
|
|
|
sortedStates.forEach(st => {
|
|
const opt = document.createElement('option');
|
|
opt.value = st;
|
|
opt.textContent = `${getStateFullName(st)} (${st}) — ${stateCounts[st]} portals`;
|
|
el.stateFilter.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
function renderStateChips() {
|
|
const stateCounts = {};
|
|
state.allPortals.forEach(p => {
|
|
const st = p.state || 'OTHER';
|
|
stateCounts[st] = (stateCounts[st] || 0) + 1;
|
|
});
|
|
|
|
// Top states by portal count
|
|
const topStates = Object.entries(stateCounts)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 16);
|
|
|
|
let chipsHtml = `
|
|
<button class="state-chip ${state.selectedState === 'ALL' ? 'active' : ''}" data-state="ALL">
|
|
<span>All States</span>
|
|
<span class="chip-count">(${state.allPortals.length})</span>
|
|
</button>
|
|
`;
|
|
|
|
topStates.forEach(([st, count]) => {
|
|
const isActive = state.selectedState === st;
|
|
chipsHtml += `
|
|
<button class="state-chip ${isActive ? 'active' : ''}" data-state="${st}">
|
|
<span>${st}</span>
|
|
<span class="chip-count">(${count})</span>
|
|
</button>
|
|
`;
|
|
});
|
|
|
|
el.stateChipsScroll.innerHTML = chipsHtml;
|
|
}
|
|
|
|
// --- STATE FULL NAME HELPER ---
|
|
const stateNames = {
|
|
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
|
|
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', FL: 'Florida', GA: 'Georgia',
|
|
HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
|
|
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland',
|
|
MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi', MO: 'Missouri',
|
|
MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire', NJ: 'New Jersey',
|
|
NM: 'New Mexico', NY: 'New York', NC: 'North Carolina', ND: 'North Dakota', OH: 'Ohio',
|
|
OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania', RI: 'Rhode Island', SC: 'South Carolina',
|
|
SD: 'South Dakota', TN: 'Tennessee', TX: 'Texas', UT: 'Utah', VT: 'Vermont',
|
|
VA: 'Virginia', WA: 'Washington', WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming'
|
|
};
|
|
|
|
function getStateFullName(abbr) {
|
|
return stateNames[abbr] || abbr;
|
|
}
|
|
|
|
// --- FILTER & SORT LOGIC ---
|
|
function applyFilters() {
|
|
const q = state.searchQuery.trim().toLowerCase();
|
|
const st = state.selectedState;
|
|
const deptType = state.selectedType;
|
|
const auditOnly = state.auditOnly;
|
|
|
|
let filtered = state.allPortals.filter(p => {
|
|
// Search query filter
|
|
if (q) {
|
|
const matchName = (p.name || '').toLowerCase().includes(q);
|
|
const matchLoc = (p.location || '').toLowerCase().includes(q);
|
|
const matchSlug = (p.slug || '').toLowerCase().includes(q);
|
|
const matchState = (p.state || '').toLowerCase().includes(q);
|
|
const matchStateFull = getStateFullName(p.state).toLowerCase().includes(q);
|
|
|
|
if (!matchName && !matchLoc && !matchSlug && !matchState && !matchStateFull) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// State filter
|
|
if (st !== 'ALL' && p.state !== st) {
|
|
return false;
|
|
}
|
|
|
|
// Department type filter
|
|
if (deptType !== 'ALL') {
|
|
const pType = (p.dept_type || '').toUpperCase();
|
|
if (deptType === 'PD' && pType !== 'PD') return false;
|
|
if (deptType === 'SO' && pType !== 'SO') return false;
|
|
if (deptType === 'OTHER' && (pType === 'PD' || pType === 'SO')) return false;
|
|
}
|
|
|
|
// Audit status filter
|
|
if (auditOnly && !p.has_search_audit) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// Sorting
|
|
filtered.sort((a, b) => {
|
|
switch (state.sortBy) {
|
|
case 'cameras-desc':
|
|
return (b.cameras || 0) - (a.cameras || 0);
|
|
case 'vehicles-desc':
|
|
return (b.vehicles_captured || 0) - (a.vehicles_captured || 0);
|
|
case 'searches-desc':
|
|
return (b.searches || 0) - (a.searches || 0);
|
|
case 'hits-desc':
|
|
return (b.hotlist_hits || 0) - (a.hotlist_hits || 0);
|
|
case 'alpha-asc':
|
|
return (a.name || '').localeCompare(b.name || '');
|
|
case 'alpha-desc':
|
|
return (b.name || '').localeCompare(a.name || '');
|
|
default:
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
state.filteredPortals = filtered;
|
|
state.currentPage = 1;
|
|
|
|
renderDirectory();
|
|
}
|
|
|
|
// --- RENDER SPECIFIC CAMERA NODES & HARDWARE IMAGES IN DIRECTORY ---
|
|
function renderDirectoryCameras(rawQuery, filteredPortals) {
|
|
if (!el.directoryCamerasSection || !el.directoryCamerasGrid) return;
|
|
|
|
const q = (rawQuery || '').trim().toLowerCase();
|
|
const allCams = (typeof window.getFlockAllCameras === 'function')
|
|
? window.getFlockAllCameras()
|
|
: [];
|
|
|
|
if (allCams.length === 0) {
|
|
el.directoryCamerasSection.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
let matchingCams = [];
|
|
|
|
if (q) {
|
|
matchingCams = allCams.filter(c => {
|
|
const nameMatch = (c.name || '').toLowerCase().includes(q);
|
|
const agencyMatch = (c.agency || '').toLowerCase().includes(q);
|
|
const slugMatch = (c.slug || '').toLowerCase().includes(q);
|
|
const notesMatch = (c.notes || '').toLowerCase().includes(q);
|
|
const cityMatch = (c.city || '').toLowerCase().includes(q);
|
|
const crossMatch = (c.crossStreets || '').toLowerCase().includes(q);
|
|
const stateMatch = (c.state || '').toLowerCase() === q;
|
|
return nameMatch || agencyMatch || slugMatch || notesMatch || cityMatch || crossMatch || stateMatch;
|
|
});
|
|
|
|
// Filter by state if state dropdown is not ALL
|
|
if (state.selectedState && state.selectedState !== 'ALL') {
|
|
matchingCams = matchingCams.filter(c => {
|
|
if (c.state) return c.state.toUpperCase() === state.selectedState.toUpperCase();
|
|
if (state.selectedState === 'CA') return c.id.includes('plm') || c.id.includes('lan') || c.id.includes('cal');
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// Filter by agency type if not ALL
|
|
if (state.selectedType && state.selectedType !== 'ALL') {
|
|
matchingCams = matchingCams.filter(c => {
|
|
if (c.dept_type) return c.dept_type.toUpperCase() === state.selectedType.toUpperCase();
|
|
if (state.selectedType === 'SO') return (c.agency && (c.agency.toLowerCase().includes('sheriff') || c.agency.includes('LASD')));
|
|
if (state.selectedType === 'PD') return (c.agency && (c.agency.toLowerCase().includes('police') || c.agency.includes('PD')));
|
|
return true;
|
|
});
|
|
}
|
|
} else if (state.selectedState && state.selectedState !== 'ALL') {
|
|
matchingCams = allCams.filter(c => {
|
|
let stMatch = false;
|
|
if (c.state) stMatch = (c.state.toUpperCase() === state.selectedState.toUpperCase());
|
|
else if (state.selectedState === 'CA') stMatch = c.id.includes('plm') || c.id.includes('lan') || c.id.includes('cal');
|
|
else if (state.selectedState === 'KY') stMatch = c.id.includes('lex');
|
|
else if (state.selectedState === 'GA') stMatch = c.id.includes('atl');
|
|
else if (state.selectedState === 'TX') stMatch = c.id.includes('dfw');
|
|
|
|
if (!stMatch) return false;
|
|
|
|
if (state.selectedType && state.selectedType !== 'ALL') {
|
|
if (c.dept_type) return c.dept_type.toUpperCase() === state.selectedType.toUpperCase();
|
|
if (state.selectedType === 'SO') return (c.agency && (c.agency.toLowerCase().includes('sheriff') || c.agency.includes('LASD')));
|
|
if (state.selectedType === 'PD') return (c.agency && (c.agency.toLowerCase().includes('police') || c.agency.includes('PD')));
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
if (matchingCams.length === 0) {
|
|
el.directoryCamerasSection.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
// Show section
|
|
el.directoryCamerasSection.style.display = 'block';
|
|
|
|
// Set Header Title
|
|
let titleText = 'All Mapped Surveillance Corridors';
|
|
if (q) {
|
|
titleText = `"${escapeHtml(rawQuery)}"`;
|
|
} else if (state.selectedState !== 'ALL') {
|
|
titleText = `${state.selectedState} Corridor Locations`;
|
|
}
|
|
el.dirCamQueryTitle.innerHTML = titleText;
|
|
el.dirCamBadgeCount.textContent = `${matchingCams.length} Camera Nodes Mapped`;
|
|
|
|
// Render Camera Cards with Images, Cross-Streets, and Live Actions
|
|
const cardsHtml = matchingCams.map(cam => {
|
|
const meta = (window.FlockAuth && window.FlockAuth.getCameraMeta)
|
|
? window.FlockAuth.getCameraMeta(cam.id)
|
|
: { confirmations: 1, hasPhoto: false, isContested: false };
|
|
|
|
const photoUrl = meta.photoUrl || cam.photoUrl || null;
|
|
const hasPhoto = !!photoUrl;
|
|
|
|
const crossStreets = cam.crossStreets || (cam.name ? cam.name.split('(')[0].replace(/^.*?-\s*/, '').trim() : 'Intersection Camera');
|
|
|
|
const badgeHtml = meta.isContested
|
|
? `<span class="dir-cam-badge unverified">${window.FlockIcons ? window.FlockIcons.alertTriangle('flock-svg-sm') : ''} CONTESTED</span>`
|
|
: (hasPhoto
|
|
? `<span class="dir-cam-badge verified">${window.FlockIcons ? window.FlockIcons.checkCircle('flock-svg-sm') : ''} PHOTO VERIFIED</span>`
|
|
: `<span class="dir-cam-badge unverified">${window.FlockIcons ? window.FlockIcons.camera('flock-svg-sm') : ''} SIGHTING RECORDED</span>`);
|
|
|
|
const imageContainerHtml = hasPhoto
|
|
? `<div class="dir-cam-image-container">
|
|
<img src="${photoUrl}" class="dir-cam-img" alt="Camera at ${escapeHtml(crossStreets)}" onclick="window.open('${photoUrl}', '_blank')" title="Click to view full photo proof in high resolution">
|
|
${badgeHtml}
|
|
<div class="dir-cam-img-hover-hint">
|
|
${window.FlockIcons ? window.FlockIcons.search('flock-svg-sm') : ''}
|
|
<span>Tap to inspect photo proof</span>
|
|
</div>
|
|
</div>`
|
|
: `<div class="dir-cam-image-container pending">
|
|
<div class="dir-cam-placeholder">
|
|
<div class="dir-cam-placeholder-icon">
|
|
${window.FlockIcons ? window.FlockIcons.camera('flock-svg-lg') : ''}
|
|
</div>
|
|
<strong style="font-size: 0.84rem; color: #cbd5e1;">Hardware Photo Pending</strong>
|
|
<span style="font-size: 0.72rem; color: #94a3b8;">Mounted on utility / signal pole</span>
|
|
<button type="button" class="btn-dir-cam-upload-trigger" onclick="window.openCameraHardwareProof('${escapeHtml(cam.id)}')">
|
|
${window.FlockIcons ? window.FlockIcons.cameraPlus('flock-svg-sm') : ''}
|
|
<span>Upload Photo Proof</span>
|
|
</button>
|
|
</div>
|
|
${badgeHtml}
|
|
</div>`;
|
|
|
|
return `
|
|
<article class="dir-cam-card" id="dir-cam-node-${escapeHtml(cam.id)}">
|
|
${imageContainerHtml}
|
|
<div class="dir-cam-body">
|
|
<div>
|
|
<!-- Cross Streets Prominent Box -->
|
|
<div class="dir-cam-cross-box">
|
|
<span class="dir-cam-cross-icon">
|
|
${window.FlockIcons ? window.FlockIcons.mapPin('flock-svg-md') : ''}
|
|
</span>
|
|
<div class="dir-cam-cross-content">
|
|
<span class="dir-cam-cross-label">CROSS STREETS</span>
|
|
<strong class="dir-cam-cross-value">${escapeHtml(crossStreets)}</strong>
|
|
</div>
|
|
</div>
|
|
|
|
<h4 class="dir-cam-intersection">${escapeHtml(cam.name)}</h4>
|
|
<div class="dir-cam-agency-meta">
|
|
<span>${window.FlockIcons ? window.FlockIcons.building('flock-svg-sm') : ''} ${escapeHtml(cam.agency || 'Local Law Enforcement')}</span>
|
|
${cam.city ? `<span class="dir-cam-city-tag">${window.FlockIcons ? window.FlockIcons.map('flock-svg-sm') : ''} ${escapeHtml(cam.city)}, ${escapeHtml(cam.state || 'CA')}</span>` : ''}
|
|
</div>
|
|
|
|
<div class="dir-cam-specs-row">
|
|
<div class="dir-cam-spec-item">
|
|
<span class="dir-cam-spec-label">HARDWARE</span>
|
|
<span class="dir-cam-spec-val">${escapeHtml(cam.type || 'Falcon Solar ALPR')}</span>
|
|
</div>
|
|
<div class="dir-cam-spec-item">
|
|
<span class="dir-cam-spec-label">MONTHLY SCANS</span>
|
|
<span class="dir-cam-spec-val" style="color: #34d399;">${escapeHtml(cam.captures30d ? `${cam.captures30d}` : '300,000+')}</span>
|
|
</div>
|
|
<div class="dir-cam-spec-item">
|
|
<span class="dir-cam-spec-label">COORDINATES</span>
|
|
<span class="dir-cam-spec-val" style="font-family: var(--font-mono); font-size: 0.75rem;">${cam.lat.toFixed(4)}, ${cam.lng.toFixed(4)}</span>
|
|
</div>
|
|
<div class="dir-cam-spec-item">
|
|
<span class="dir-cam-spec-label">CONSENSUS</span>
|
|
<span class="dir-cam-spec-val" style="color: #38bdf8; display: inline-flex; align-items: center; gap: 4px;">
|
|
${window.FlockIcons ? window.FlockIcons.thumbUp('flock-svg-sm') : ''} ${meta.confirmations || 1} Confirmed
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="dir-cam-actions">
|
|
<button type="button" class="btn-dir-cam-jump" onclick="window.jumpToRadarCamera('${escapeHtml(cam.id)}')">
|
|
${window.FlockIcons ? window.FlockIcons.radar('flock-svg-sm') : ''}
|
|
<span>View on Live Radar</span>
|
|
</button>
|
|
<button type="button" class="btn-dir-cam-details" onclick="window.openCameraHardwareProof('${escapeHtml(cam.id)}')">
|
|
${window.FlockIcons ? window.FlockIcons.camera('flock-svg-sm') : ''}
|
|
<span>Camera Info</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}).join('');
|
|
|
|
el.directoryCamerasGrid.innerHTML = cardsHtml;
|
|
}
|
|
|
|
// --- RENDER MAIN DIRECTORY ---
|
|
function renderDirectory() {
|
|
// Render specific camera nodes with photos and cross streets if matching
|
|
renderDirectoryCameras(state.searchQuery, state.filteredPortals);
|
|
|
|
const total = state.filteredPortals.length;
|
|
el.totalCount.textContent = state.allPortals.length.toLocaleString();
|
|
el.showingCount.textContent = total.toLocaleString();
|
|
|
|
// Pagination slice
|
|
const startIndex = (state.currentPage - 1) * state.pageSize;
|
|
const endIndex = Math.min(startIndex + state.pageSize, total);
|
|
const pagePortals = state.filteredPortals.slice(startIndex, endIndex);
|
|
|
|
if (total === 0) {
|
|
const emptyHtml = `
|
|
<div style="grid-column: 1 / -1; text-align: center; padding: 60px 20px; background: var(--bg-surface); border: 1px dashed var(--border-subtle); border-radius: var(--radius-lg);">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-dim)" stroke-width="1.5" style="margin-bottom: 16px;">
|
|
<circle cx="11" cy="11" r="8"></circle>
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
</svg>
|
|
<h3 style="font-size: 1.25rem; font-weight: 700; margin-bottom: 8px; color: #ffffff;">No Matching Portals Found</h3>
|
|
<p style="color: var(--text-muted); max-width: 450px; margin: 0 auto 20px;">
|
|
No Flock transparency portals matched your criteria: "${state.searchQuery || state.selectedState}".
|
|
</p>
|
|
<button class="btn-secondary" id="btn-empty-reset">Clear Filters & View All</button>
|
|
</div>
|
|
`;
|
|
el.portalsGrid.innerHTML = emptyHtml;
|
|
el.tableBody.innerHTML = `<tr><td colspan="8" style="text-align: center; padding: 40px; color: var(--text-muted);">No portals found matching search criteria.</td></tr>`;
|
|
el.paginationControls.innerHTML = '';
|
|
|
|
document.getElementById('btn-empty-reset')?.addEventListener('click', resetAllFilters);
|
|
return;
|
|
}
|
|
|
|
if (state.currentView === 'cards') {
|
|
renderCards(pagePortals);
|
|
} else {
|
|
renderTable(pagePortals);
|
|
}
|
|
|
|
renderPagination(total);
|
|
}
|
|
|
|
// --- RENDER CARDS VIEW ---
|
|
function renderCards(portals) {
|
|
const cardsHtml = portals.map(p => {
|
|
const camerasStr = p.cameras ? p.cameras.toLocaleString() : '—';
|
|
const vehiclesStr = p.vehicles_captured ? formatNumberCompact(p.vehicles_captured) : '—';
|
|
const searchesStr = p.searches ? p.searches.toLocaleString() : '—';
|
|
const hitsStr = p.hotlist_hits ? p.hotlist_hits.toLocaleString() : '—';
|
|
const sharedCount = p.shared_count || 0;
|
|
|
|
return `
|
|
<article class="portal-card" data-slug="${escapeHtml(p.slug)}">
|
|
<div>
|
|
<div class="card-top-bar">
|
|
<span class="agency-state-badge">${escapeHtml(p.state)}</span>
|
|
<span class="agency-type-tag">${escapeHtml(p.dept_type || 'PD')}</span>
|
|
</div>
|
|
|
|
<div class="card-title-area">
|
|
<h3 class="agency-name">${escapeHtml(p.name)}</h3>
|
|
<div class="agency-location">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
|
|
<circle cx="12" cy="10" r="3"></circle>
|
|
</svg>
|
|
<span>${escapeHtml(p.location || p.name)}, ${escapeHtml(p.state)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Metrics Grid -->
|
|
<div class="card-metrics-grid">
|
|
<div class="card-metric-item">
|
|
<span class="card-metric-val">${camerasStr}</span>
|
|
<span class="card-metric-lbl">Cameras</span>
|
|
</div>
|
|
<div class="card-metric-item">
|
|
<span class="card-metric-val">${vehiclesStr}</span>
|
|
<span class="card-metric-lbl">30d Captures</span>
|
|
</div>
|
|
<div class="card-metric-item">
|
|
<span class="card-metric-val">${searchesStr}</span>
|
|
<span class="card-metric-lbl">Searches</span>
|
|
</div>
|
|
<div class="card-metric-item">
|
|
<span class="card-metric-val">${hitsStr}</span>
|
|
<span class="card-metric-lbl">Hotlist Hits</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Flags -->
|
|
<div class="card-flags-row">
|
|
${p.has_search_audit ? `
|
|
<span class="flag-badge audit-true" title="Agency publishes public search audit logs">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<polyline points="20 6 9 17 4 12"></polyline>
|
|
</svg>
|
|
Search Audit Public
|
|
</span>
|
|
` : ''}
|
|
|
|
${sharedCount > 0 ? `
|
|
<span class="flag-badge sharing-network" title="Shares camera data with outside agencies">
|
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
|
<circle cx="9" cy="7" r="4"></circle>
|
|
</svg>
|
|
Shares with ${sharedCount.toLocaleString()} Agenc${sharedCount === 1 ? 'y' : 'ies'}
|
|
</span>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Card Actions -->
|
|
<div class="card-actions-row">
|
|
<button class="btn-inspect" data-action="inspect" data-slug="${escapeHtml(p.slug)}">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<circle cx="11" cy="11" r="8"></circle>
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
</svg>
|
|
<span>Inspect Data</span>
|
|
</button>
|
|
<a
|
|
href="${escapeHtml(p.url)}"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="btn-direct-link"
|
|
title="Open live portal on transparency.flocksafety.com"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
|
<polyline points="15 3 21 3 21 9"></polyline>
|
|
<line x1="10" y1="14" x2="21" y2="3"></line>
|
|
</svg>
|
|
</a>
|
|
</div>
|
|
</article>
|
|
`;
|
|
}).join('');
|
|
|
|
el.portalsGrid.innerHTML = cardsHtml;
|
|
}
|
|
|
|
// --- RENDER TABLE VIEW ---
|
|
function renderTable(portals) {
|
|
const tableHtml = portals.map(p => {
|
|
const camerasStr = p.cameras ? p.cameras.toLocaleString() : '—';
|
|
const vehiclesStr = p.vehicles_captured ? p.vehicles_captured.toLocaleString() : '—';
|
|
const searchesStr = p.searches ? p.searches.toLocaleString() : '—';
|
|
const hitsStr = p.hotlist_hits ? p.hotlist_hits.toLocaleString() : '—';
|
|
const sharedCount = p.shared_count || 0;
|
|
|
|
return `
|
|
<tr>
|
|
<td>
|
|
<div class="table-agency-cell">
|
|
<span class="table-agency-title">${escapeHtml(p.name)}</span>
|
|
<span class="table-agency-slug">${escapeHtml(p.slug)}</span>
|
|
</div>
|
|
</td>
|
|
<td><span class="agency-state-badge">${escapeHtml(p.state)}</span></td>
|
|
<td style="font-family: var(--font-mono); font-weight: 700;">${camerasStr}</td>
|
|
<td style="font-family: var(--font-mono);">${vehiclesStr}</td>
|
|
<td style="font-family: var(--font-mono);">${searchesStr}</td>
|
|
<td style="font-family: var(--font-mono); color: var(--accent-radar);">${hitsStr}</td>
|
|
<td>
|
|
<span style="font-size: 0.78rem; color: #a5b4fc;">${sharedCount} orgs</span>
|
|
</td>
|
|
<td>
|
|
<div style="display: flex; gap: 6px;">
|
|
<button class="btn-secondary" style="padding: 4px 10px; font-size: 0.75rem;" data-action="inspect" data-slug="${escapeHtml(p.slug)}">Inspect</button>
|
|
<a href="${escapeHtml(p.url)}" target="_blank" rel="noopener noreferrer" class="btn-direct-link" style="padding: 4px 8px;" title="Open Portal">↗</a>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
el.tableBody.innerHTML = tableHtml;
|
|
}
|
|
|
|
// --- PAGINATION RENDERER ---
|
|
function renderPagination(total) {
|
|
const totalPages = Math.ceil(total / state.pageSize);
|
|
if (totalPages <= 1) {
|
|
el.paginationControls.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
let pagesHtml = '';
|
|
|
|
// Previous Button
|
|
pagesHtml += `
|
|
<button class="pagination-btn" id="pg-prev" ${state.currentPage === 1 ? 'disabled' : ''}>
|
|
← Prev
|
|
</button>
|
|
`;
|
|
|
|
// Windowed Page Numbers
|
|
const current = state.currentPage;
|
|
let startPage = Math.max(1, current - 2);
|
|
let endPage = Math.min(totalPages, current + 2);
|
|
|
|
if (startPage > 1) {
|
|
pagesHtml += `<button class="pagination-btn" data-page="1">1</button>`;
|
|
if (startPage > 2) pagesHtml += `<span style="color: var(--text-dim); padding: 0 4px;">...</span>`;
|
|
}
|
|
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
pagesHtml += `
|
|
<button class="pagination-btn ${i === current ? 'active' : ''}" data-page="${i}">
|
|
${i}
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
if (endPage < totalPages) {
|
|
if (endPage < totalPages - 1) pagesHtml += `<span style="color: var(--text-dim); padding: 0 4px;">...</span>`;
|
|
pagesHtml += `<button class="pagination-btn" data-page="${totalPages}">${totalPages}</button>`;
|
|
}
|
|
|
|
// Next Button
|
|
pagesHtml += `
|
|
<button class="pagination-btn" id="pg-next" ${state.currentPage === totalPages ? 'disabled' : ''}>
|
|
Next →
|
|
</button>
|
|
`;
|
|
|
|
el.paginationControls.innerHTML = pagesHtml;
|
|
}
|
|
|
|
// --- DEEP AGENCY MODAL HANDLER ---
|
|
function openAgencyModal(slug) {
|
|
const portal = state.allPortals.find(p => p.slug === slug);
|
|
if (!portal) {
|
|
showToast('Agency record not found.', 'error');
|
|
return;
|
|
}
|
|
|
|
state.activeModalPortal = portal;
|
|
|
|
// Fill Basic Details
|
|
el.modalAgencyName.textContent = portal.name;
|
|
el.modalAgencyLocation.textContent = `${portal.location || portal.name}, ${portal.state}`;
|
|
el.modalAgencySlug.textContent = `transparency.flocksafety.com/${portal.slug}`;
|
|
el.modalPortalUrl.href = portal.url;
|
|
|
|
// Stats
|
|
el.modalStatCameras.textContent = portal.cameras ? portal.cameras.toLocaleString() : 'Not Reported';
|
|
el.modalStatVehicles.textContent = portal.vehicles_captured ? portal.vehicles_captured.toLocaleString() : 'Not Reported';
|
|
el.modalStatSearches.textContent = portal.searches ? portal.searches.toLocaleString() : '0';
|
|
el.modalStatHits.textContent = portal.hotlist_hits ? portal.hotlist_hits.toLocaleString() : '0';
|
|
|
|
// Search Justifications / Reasons
|
|
const reasons = portal.top_search_reasons || {};
|
|
const reasonEntries = Object.entries(reasons).sort((a, b) => b[1] - a[1]);
|
|
|
|
if (reasonEntries.length > 0) {
|
|
el.modalReasonsSection.style.display = 'block';
|
|
el.modalReasonsList.innerHTML = reasonEntries.map(([reason, count]) => `
|
|
<div class="reason-item">
|
|
<span class="reason-name">${escapeHtml(reason)}</span>
|
|
<span class="reason-count">${count.toLocaleString()} searches</span>
|
|
</div>
|
|
`).join('');
|
|
} else {
|
|
el.modalReasonsSection.style.display = 'none';
|
|
}
|
|
|
|
// Inter-Agency Sharing Network
|
|
const sharedIds = portal.shared_org_ids || [];
|
|
el.modalSharingCount.textContent = sharedIds.length.toLocaleString();
|
|
renderModalSharingTags(sharedIds, '');
|
|
|
|
// Reset sharing search input
|
|
el.modalSharingSearch.value = '';
|
|
|
|
// Prohibited Uses Text
|
|
el.modalProhibitedText.textContent = portal.prohibited_uses ||
|
|
'Prohibited uses standardly include: Immigration enforcement, general traffic speed citations, harassment, or personal/non-official inquiries.';
|
|
|
|
// Research Tool Links
|
|
const googleQuery = encodeURIComponent(`"${portal.name}" "Flock Safety" contract OR council OR resolution`);
|
|
el.modalGoogleContractLink.href = `https://www.google.com/search?q=${googleQuery}`;
|
|
|
|
// Set URL Hash
|
|
window.location.hash = portal.slug;
|
|
|
|
// Open Native Dialog
|
|
if (typeof el.agencyModal.showModal === 'function') {
|
|
el.agencyModal.showModal();
|
|
} else {
|
|
el.agencyModal.setAttribute('open', '');
|
|
}
|
|
}
|
|
|
|
// Global access so radar map can open agency modal without switching views
|
|
window.openAgencyModal = openAgencyModal;
|
|
|
|
function renderModalSharingTags(sharedIds, query) {
|
|
if (!sharedIds || sharedIds.length === 0) {
|
|
el.modalSharingBox.innerHTML = `<span style="font-size: 0.8rem; color: var(--text-dim); padding: 8px;">No external sharing partners published on portal.</span>`;
|
|
return;
|
|
}
|
|
|
|
const q = (query || '').toLowerCase().trim();
|
|
let orgNames = sharedIds.map(id => state.orgsLookup[id] || 'Unknown Agency');
|
|
|
|
if (q) {
|
|
orgNames = orgNames.filter(name => name.toLowerCase().includes(q));
|
|
}
|
|
|
|
if (orgNames.length === 0) {
|
|
el.modalSharingBox.innerHTML = `<span style="font-size: 0.8rem; color: var(--text-dim); padding: 8px;">No matching partner agencies.</span>`;
|
|
return;
|
|
}
|
|
|
|
// Show up to 200 partner agencies in the box
|
|
const displayOrgs = orgNames.slice(0, 250);
|
|
const tagsHtml = displayOrgs.map(name => `
|
|
<span class="sharing-tag">${escapeHtml(name)}</span>
|
|
`).join('');
|
|
|
|
const moreNotice = orgNames.length > 250
|
|
? `<span style="font-size: 0.72rem; color: var(--text-dim); padding: 4px;">...and ${(orgNames.length - 250).toLocaleString()} more. Use search to filter.</span>`
|
|
: '';
|
|
|
|
el.modalSharingBox.innerHTML = tagsHtml + moreNotice;
|
|
}
|
|
|
|
function closeAgencyModal() {
|
|
if (typeof el.agencyModal.close === 'function') {
|
|
el.agencyModal.close();
|
|
} else {
|
|
el.agencyModal.removeAttribute('open');
|
|
}
|
|
|
|
state.activeModalPortal = null;
|
|
|
|
// Clear hash without reloading
|
|
history.replaceState(null, document.title, window.location.pathname + window.location.search);
|
|
}
|
|
|
|
// --- CSV EXPORT FUNCTIONALITY ---
|
|
function exportFilteredToCsv() {
|
|
const list = state.filteredPortals;
|
|
if (!list || list.length === 0) {
|
|
showToast('No portals to export.', 'warning');
|
|
return;
|
|
}
|
|
|
|
const headers = [
|
|
'Agency Name',
|
|
'State',
|
|
'Location',
|
|
'Department Type',
|
|
'Cameras Reported',
|
|
'30-Day Vehicle Captures',
|
|
'Searches Conducted',
|
|
'Hotlist Alerts',
|
|
'Public Search Audit',
|
|
'Shared Agency Count',
|
|
'Transparency Portal URL'
|
|
];
|
|
|
|
const rows = list.map(p => [
|
|
`"${(p.name || '').replace(/"/g, '""')}"`,
|
|
`"${p.state || ''}"`,
|
|
`"${(p.location || '').replace(/"/g, '""')}"`,
|
|
`"${p.dept_type || ''}"`,
|
|
p.cameras || 0,
|
|
p.vehicles_captured || 0,
|
|
p.searches || 0,
|
|
p.hotlist_hits || 0,
|
|
p.has_search_audit ? 'Yes' : 'No',
|
|
p.shared_count || 0,
|
|
`"${p.url || ''}"`
|
|
]);
|
|
|
|
const csvContent = [headers.join(','), ...rows.map(r => r.join(','))].join('\n');
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement('a');
|
|
|
|
const timestamp = new Date().toISOString().slice(0, 10);
|
|
link.setAttribute('href', url);
|
|
link.setAttribute('download', `flock_transparency_portals_${state.selectedState.toLowerCase()}_${timestamp}.csv`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
|
|
showToast(`Exported ${list.length.toLocaleString()} portals to CSV.`, 'success');
|
|
}
|
|
|
|
// --- TOAST NOTIFICATIONS ---
|
|
function showToast(message, type = 'info') {
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast';
|
|
|
|
let iconSvg = window.FlockIcons ? FlockIcons.alertTriangle('flock-svg-sm') : '';
|
|
if (type === 'success') {
|
|
iconSvg = window.FlockIcons ? FlockIcons.checkCircle('flock-svg-sm') : '';
|
|
} else if (type === 'error') {
|
|
iconSvg = window.FlockIcons ? FlockIcons.alertTriangle('flock-svg-sm') : '';
|
|
} else if (type === 'warning') {
|
|
iconSvg = window.FlockIcons ? FlockIcons.zap('flock-svg-sm') : '';
|
|
} else {
|
|
iconSvg = window.FlockIcons ? FlockIcons.radar('flock-svg-sm') : '';
|
|
}
|
|
|
|
// Clean any leading emoji characters from message
|
|
const cleanMsg = String(message).replace(/^[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{2300}-\u{23FF}\s]+/u, '').trim();
|
|
|
|
toast.innerHTML = `<span class="toast-icon">${iconSvg}</span><span>${escapeHtml(cleanMsg || message)}</span>`;
|
|
if (el.toastContainer) el.toastContainer.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.style.opacity = '0';
|
|
toast.style.transform = 'translateY(8px)';
|
|
toast.style.transition = '0.25s ease';
|
|
setTimeout(() => toast.remove(), 250);
|
|
}, 3200);
|
|
}
|
|
|
|
// --- VIEW MODE TOGGLE ---
|
|
function updateViewMode() {
|
|
if (state.currentView === 'cards') {
|
|
el.portalsGrid.style.display = 'grid';
|
|
el.tableContainer.style.display = 'none';
|
|
el.viewCardsBtn.classList.add('active');
|
|
el.viewTableBtn.classList.remove('active');
|
|
el.viewCardsBtn.setAttribute('aria-checked', 'true');
|
|
el.viewTableBtn.setAttribute('aria-checked', 'false');
|
|
} else {
|
|
el.portalsGrid.style.display = 'none';
|
|
el.tableContainer.style.display = 'block';
|
|
el.viewCardsBtn.classList.remove('active');
|
|
el.viewTableBtn.classList.add('active');
|
|
el.viewCardsBtn.setAttribute('aria-checked', 'false');
|
|
el.viewTableBtn.setAttribute('aria-checked', 'true');
|
|
}
|
|
localStorage.setItem('flock_dir_view', state.currentView);
|
|
}
|
|
|
|
// --- RESET FILTERS ---
|
|
function resetAllFilters() {
|
|
state.searchQuery = '';
|
|
state.selectedState = 'ALL';
|
|
state.selectedType = 'ALL';
|
|
state.auditOnly = false;
|
|
state.sortBy = 'cameras-desc';
|
|
|
|
el.searchInput.value = '';
|
|
el.clearSearchBtn.style.display = 'none';
|
|
el.stateFilter.value = 'ALL';
|
|
el.typeFilter.value = 'ALL';
|
|
el.sortSelect.value = 'cameras-desc';
|
|
el.auditToggle.checked = false;
|
|
|
|
renderStateChips();
|
|
applyFilters();
|
|
showToast('Filters reset to default view.');
|
|
}
|
|
|
|
// --- URL HASH CHECK ---
|
|
function checkUrlHash() {
|
|
const hash = window.location.hash.replace('#', '').trim();
|
|
if (hash) {
|
|
const match = state.allPortals.find(p => p.slug === hash);
|
|
if (match) {
|
|
openAgencyModal(hash);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- EVENT BINDINGS ---
|
|
function bindEvents() {
|
|
// Search input with debounce
|
|
let debounceTimer;
|
|
el.searchInput.addEventListener('input', e => {
|
|
clearTimeout(debounceTimer);
|
|
state.searchQuery = e.target.value;
|
|
el.clearSearchBtn.style.display = state.searchQuery ? 'block' : 'none';
|
|
|
|
debounceTimer = setTimeout(() => {
|
|
applyFilters();
|
|
}, 150);
|
|
});
|
|
|
|
// Clear search button
|
|
el.clearSearchBtn.addEventListener('click', () => {
|
|
el.searchInput.value = '';
|
|
state.searchQuery = '';
|
|
el.clearSearchBtn.style.display = 'none';
|
|
el.searchInput.focus();
|
|
applyFilters();
|
|
});
|
|
|
|
// Keyboard shortcut '/' to search
|
|
window.addEventListener('keydown', e => {
|
|
if (e.key === '/' && document.activeElement !== el.searchInput) {
|
|
e.preventDefault();
|
|
el.searchInput.focus();
|
|
el.searchInput.select();
|
|
}
|
|
});
|
|
|
|
// State dropdown filter
|
|
el.stateFilter.addEventListener('change', e => {
|
|
state.selectedState = e.target.value;
|
|
renderStateChips();
|
|
applyFilters();
|
|
});
|
|
|
|
// State chips click
|
|
el.stateChipsScroll.addEventListener('click', e => {
|
|
const chip = e.target.closest('.state-chip');
|
|
if (!chip) return;
|
|
const st = chip.getAttribute('data-state');
|
|
if (!st) return;
|
|
|
|
state.selectedState = st;
|
|
el.stateFilter.value = st;
|
|
renderStateChips();
|
|
applyFilters();
|
|
});
|
|
|
|
// Department type filter
|
|
el.typeFilter.addEventListener('change', e => {
|
|
state.selectedType = e.target.value;
|
|
applyFilters();
|
|
});
|
|
|
|
// Sort select
|
|
el.sortSelect.addEventListener('change', e => {
|
|
state.sortBy = e.target.value;
|
|
applyFilters();
|
|
});
|
|
|
|
// Public Audit toggle
|
|
el.auditToggle.addEventListener('change', e => {
|
|
state.auditOnly = e.target.checked;
|
|
applyFilters();
|
|
});
|
|
|
|
// Reset all filters button
|
|
el.btnResetAll.addEventListener('click', resetAllFilters);
|
|
|
|
// View toggle buttons
|
|
el.viewCardsBtn.addEventListener('click', () => {
|
|
state.currentView = 'cards';
|
|
updateViewMode();
|
|
renderDirectory();
|
|
});
|
|
|
|
el.viewTableBtn.addEventListener('click', () => {
|
|
state.currentView = 'table';
|
|
updateViewMode();
|
|
renderDirectory();
|
|
});
|
|
|
|
// Export CSV button
|
|
el.btnExportCsv.addEventListener('click', exportFilteredToCsv);
|
|
|
|
// Card / Table click delegation for Inspect buttons
|
|
document.addEventListener('click', e => {
|
|
const btn = e.target.closest('[data-action="inspect"]');
|
|
if (btn) {
|
|
const slug = btn.getAttribute('data-slug');
|
|
if (slug) openAgencyModal(slug);
|
|
}
|
|
});
|
|
|
|
// Pagination button clicks
|
|
el.paginationControls.addEventListener('click', e => {
|
|
const btn = e.target.closest('.pagination-btn');
|
|
if (!btn || btn.disabled) return;
|
|
|
|
if (btn.id === 'pg-prev') {
|
|
state.currentPage = Math.max(1, state.currentPage - 1);
|
|
} else if (btn.id === 'pg-next') {
|
|
const maxPage = Math.ceil(state.filteredPortals.length / state.pageSize);
|
|
state.currentPage = Math.min(maxPage, state.currentPage + 1);
|
|
} else {
|
|
const pageNum = parseInt(btn.getAttribute('data-page'), 10);
|
|
if (pageNum) state.currentPage = pageNum;
|
|
}
|
|
|
|
renderDirectory();
|
|
// Smooth scroll back to top of directory
|
|
document.getElementById('directory-section').scrollIntoView({ behavior: 'smooth' });
|
|
});
|
|
|
|
// Modal Close button
|
|
el.modalCloseBtn.addEventListener('click', closeAgencyModal);
|
|
|
|
// Modal Click-Outside Backdrop Dismissal
|
|
el.agencyModal.addEventListener('click', e => {
|
|
const rect = el.agencyModal.getBoundingClientRect();
|
|
const isInDialog = (
|
|
rect.top <= e.clientY &&
|
|
e.clientY <= rect.top + rect.height &&
|
|
rect.left <= e.clientX &&
|
|
e.clientX <= rect.left + rect.width
|
|
);
|
|
if (!isInDialog) {
|
|
closeAgencyModal();
|
|
}
|
|
});
|
|
|
|
// Modal ESC Key
|
|
el.agencyModal.addEventListener('cancel', e => {
|
|
e.preventDefault();
|
|
closeAgencyModal();
|
|
});
|
|
|
|
// Modal Sharing Partners live search input
|
|
el.modalSharingSearch.addEventListener('input', e => {
|
|
if (!state.activeModalPortal) return;
|
|
const sharedIds = state.activeModalPortal.shared_org_ids || [];
|
|
renderModalSharingTags(sharedIds, e.target.value);
|
|
});
|
|
|
|
// Modal Copy Link Button
|
|
el.modalCopyLinkBtn.addEventListener('click', () => {
|
|
if (!state.activeModalPortal) return;
|
|
const shareUrl = `${window.location.origin}${window.location.pathname}#${state.activeModalPortal.slug}`;
|
|
navigator.clipboard.writeText(shareUrl).then(() => {
|
|
showToast('Direct link copied to clipboard!', 'success');
|
|
}).catch(() => {
|
|
showToast(`Link: ${shareUrl}`, 'info');
|
|
});
|
|
});
|
|
|
|
// Hash change event (forward/back browser navigation)
|
|
window.addEventListener('hashchange', checkUrlHash);
|
|
}
|
|
|
|
// --- UTILITY HELPERS ---
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function formatNumberCompact(num) {
|
|
if (num >= 1000000) {
|
|
return (num / 1000000).toFixed(1) + 'M';
|
|
}
|
|
if (num >= 1000) {
|
|
return (num / 1000).toFixed(0) + 'K';
|
|
}
|
|
return num.toLocaleString();
|
|
}
|
|
|
|
// --- START APP ON DOM READY ---
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
|
|
})();
|