- C1: Replace innerHTML template literals with DOM textContent in sites_map.ts - C2: Derive user_id from current_scope instead of client params in policy consent - C3: Fix broken halt() in GDPR data export (orphaned _ = ... halt()) - C4: Add owner/admin authorization check to MembersController update/delete - C5: Require HMAC signature on agent release webhook (was optional) - C6: Fix Repo.all_by/2 (not a real Ecto function) → Repo.all with query - C7: Move auth exemption to before_send callback in BruteForceProtection - C8: Block </style>/<script injection in status page custom_css validation - C9: Create secrets.example.yaml with placeholders; secrets.yaml already gitignored - C10: Load Stripe key from STRIPE_SECRET_KEY env var instead of hardcoded value - C13: Remove credentials from PubSub backup broadcast; channel resolves them C11 (no force_ssl): by design — Cloudflared terminates TLS C12 (device quota race): false positive — FOR UPDATE serializes correctly
158 lines
4.9 KiB
TypeScript
158 lines
4.9 KiB
TypeScript
// Lazy-loaded sites map hook (Leaflet). Leaflet itself is loaded on
|
|
// demand via ensureLeaflet (no bundling), so this chunk is small.
|
|
|
|
import { ensureLeaflet } from "../lib/leaflet"
|
|
|
|
declare const L: any
|
|
|
|
export const SitesMap = {
|
|
map: null as any,
|
|
markers: null as any,
|
|
|
|
mounted(this: any) {
|
|
ensureLeaflet().then(() => {
|
|
const sitesData = JSON.parse(this.el.dataset.sites || '[]')
|
|
this.initializeLeaflet(sitesData)
|
|
|
|
this.handleEvent("site_clicked", (payload: any) => {
|
|
this.pushEvent("site_clicked", payload)
|
|
})
|
|
}).catch((e: any) => {
|
|
console.error('SitesMap: Leaflet failed to load', e)
|
|
})
|
|
},
|
|
|
|
updated(this: any) {
|
|
if (this.map) {
|
|
const sitesData = JSON.parse(this.el.dataset.sites || '[]')
|
|
this.updateSites(sitesData)
|
|
}
|
|
},
|
|
|
|
destroyed(this: any) {
|
|
if (this.map) {
|
|
this.map.remove()
|
|
this.map = null
|
|
this.markers = null
|
|
}
|
|
},
|
|
|
|
initializeLeaflet(this: any, sites: any[]) {
|
|
this.map = L.map(this.el, {
|
|
zoomControl: true,
|
|
scrollWheelZoom: true
|
|
})
|
|
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 18
|
|
}).addTo(this.map)
|
|
|
|
this.updateSites(sites)
|
|
},
|
|
|
|
updateSites(this: any, sites: any[]) {
|
|
if (!this.map) return
|
|
|
|
if (this.markers) {
|
|
this.map.removeLayer(this.markers)
|
|
}
|
|
|
|
this.markers = L.layerGroup()
|
|
|
|
if (sites.length === 0) {
|
|
this.map.setView([39.8283, -98.5795], 4)
|
|
this.markers.addTo(this.map)
|
|
return
|
|
}
|
|
|
|
const bounds = L.latLngBounds([])
|
|
|
|
sites.forEach((site: any) => {
|
|
if (site.latitude && site.longitude) {
|
|
const marker = L.marker([site.latitude, site.longitude])
|
|
|
|
const popupEl = document.createElement('div')
|
|
popupEl.className = 'p-2'
|
|
|
|
const h3 = popupEl.appendChild(document.createElement('h3'))
|
|
h3.className = 'font-semibold text-lg mb-1'
|
|
h3.textContent = site.name
|
|
|
|
if (site.description) {
|
|
const descP = popupEl.appendChild(document.createElement('p'))
|
|
descP.className = 'text-sm text-gray-600 mb-1'
|
|
descP.textContent = site.description
|
|
}
|
|
if (site.address) {
|
|
const addrP = popupEl.appendChild(document.createElement('p'))
|
|
addrP.className = 'text-xs text-gray-500 mb-1'
|
|
addrP.textContent = site.address
|
|
}
|
|
|
|
const coordsP = popupEl.appendChild(document.createElement('p'))
|
|
coordsP.className = 'text-xs text-gray-500 mb-2'
|
|
coordsP.textContent = `${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}`
|
|
|
|
if (site.device_count > 0) {
|
|
const deviceP = popupEl.appendChild(document.createElement('p'))
|
|
deviceP.className = 'text-xs text-blue-600 mb-2'
|
|
deviceP.textContent = `${site.device_count} device${site.device_count !== 1 ? 's' : ''}`
|
|
}
|
|
|
|
const button = popupEl.appendChild(document.createElement('button'))
|
|
button.setAttribute('data-site-id', site.id)
|
|
button.setAttribute('data-action', 'view-site')
|
|
button.className = 'text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700'
|
|
button.textContent = 'View Site →'
|
|
|
|
marker.bindPopup(popupEl)
|
|
marker.on('popupopen', () => {
|
|
const button = marker.getPopup()?.getElement()?.querySelector('[data-action="view-site"]') as HTMLElement
|
|
if (button) {
|
|
button.addEventListener('click', () => {
|
|
this.pushEvent("site_clicked", { site_id: button.dataset.siteId })
|
|
})
|
|
}
|
|
})
|
|
this.markers.addLayer(marker)
|
|
bounds.extend([site.latitude, site.longitude])
|
|
}
|
|
})
|
|
|
|
this.markers.addTo(this.map)
|
|
|
|
if (bounds.isValid()) {
|
|
this.map.fitBounds(bounds, { padding: [20, 20] })
|
|
}
|
|
}
|
|
}
|
|
|
|
// Single-marker map for site detail page. Loads Leaflet via ensureLeaflet
|
|
// rather than relying on a global script polled with setTimeout.
|
|
export const LeafletMap = {
|
|
map: null as any,
|
|
mounted(this: any) {
|
|
const lat = parseFloat(this.el.dataset.lat)
|
|
const lng = parseFloat(this.el.dataset.lng)
|
|
const zoom = parseInt(this.el.dataset.zoom || '14')
|
|
const title = this.el.dataset.markerTitle || ''
|
|
if (isNaN(lat) || isNaN(lng)) return
|
|
|
|
ensureLeaflet().then(() => {
|
|
this.map = L.map(this.el, { scrollWheelZoom: false, zoomControl: true })
|
|
this.map.setView([lat, lng], zoom)
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
maxZoom: 19,
|
|
attribution: '© OpenStreetMap'
|
|
}).addTo(this.map)
|
|
L.marker([lat, lng]).addTo(this.map).bindPopup(title)
|
|
setTimeout(() => this.map.invalidateSize(), 200)
|
|
}).catch((e: any) => {
|
|
console.error('LeafletMap: Leaflet failed to load', e)
|
|
})
|
|
},
|
|
destroyed(this: any) {
|
|
if (this.map) { this.map.remove(); this.map = null }
|
|
}
|
|
}
|