Files
FlockRadar/js/app.js
T

932 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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'),
// 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 || {};
// 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 MAIN DIRECTORY ---
function renderDirectory() {
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', '');
}
}
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 icon = '️';
if (type === 'success') icon = '✓';
if (type === 'error') icon = '⚠️';
if (type === 'warning') icon = '⚡';
toast.innerHTML = `<span>${icon}</span><span>${escapeHtml(message)}</span>`;
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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();
}
})();