diff --git a/assets/css/style.css b/assets/css/style.css index b743f63..edb2c10 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -2,6 +2,17 @@ * { box-sizing: border-box; } +:root { + --surface: #ffffff; + --primary: #1a73e8; + --primary-strong: #0f5ac1; + --muted: #4a5568; + --border: #d9e2ef; + --success: #2a9d8f; + --danger: #d62828; + --warning: #ffb703; + --card: #f8fafc; +} body { font-family: Arial, Helvetica, sans-serif; background: #f4f7f9; @@ -21,12 +32,12 @@ a:hover { .container { max-width: 1200px; margin: 20px auto; - background: #fff; + background: var(--surface); padding: 20px; border-radius: 10px; box-shadow: 0 2px 6px rgba(0,0,0,0.1); } -h1, h2 { +h1, h2, h3 { margin-top: 0; } h1 { @@ -43,6 +54,32 @@ h2 { outline-offset: 2px; } +/* Layout helpers */ +.section { + margin-bottom: 40px; +} +.section-header { + margin-bottom: 6px; +} +.page-header { + margin-bottom: 10px; +} +.page-intro { + margin: 4px 0 12px; + color: var(--muted); + font-size: 1.05rem; +} +.page-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; +} +.stack { + display: flex; + flex-direction: column; + gap: 12px; +} + /* Forms */ form { display: block; @@ -55,14 +92,15 @@ label { } input, select, textarea, button { width: 100%; - padding: 8px; + padding: 10px; margin-top: 4px; border: 1px solid #ccc; - border-radius: 5px; + border-radius: 8px; font-family: inherit; } textarea { - min-height: 80px; + min-height: 96px; + resize: vertical; } button { background: #0077cc; @@ -70,18 +108,26 @@ button { border: none; cursor: pointer; margin-top: 10px; + border-radius: 8px; + padding: 10px 12px; + font-weight: 600; } button:hover { background: #005fa3; } -button.approve { background: #2a9d8f; } +button.approve { background: var(--success); } button.reject { background: #e63946; } -button.dismiss { background: #ffb703; color: black; } -button.remove { background: #d62828; } +button.dismiss { background: var(--warning); color: black; } +button.remove { background: var(--danger); } .primary { - background: #1a73e8; - border: 1px solid #0f5ac1; + background: var(--primary); + border: 1px solid var(--primary-strong); +} +.secondary { + background: #e2e8f0; + color: #1f2937; + border: 1px solid #cbd5e0; } .form-surface { @@ -103,6 +149,8 @@ button.remove { background: #d62828; } .form-actions { display: flex; + flex-wrap: wrap; + gap: 10px; justify-content: flex-start; margin-top: 8px; } @@ -115,35 +163,49 @@ button.remove { background: #d62828; } .alert { padding: 10px; - border-radius: 5px; + border-radius: 8px; margin: 10px 0; border: 1px solid transparent; } - .alert-success { background: #d4edda; border-color: #c3e6cb; color: #155724; } - .alert-error { background: #f8d7da; border-color: #f5c6cb; color: #721c24; } +.alert-info { + background: #ebf5ff; + border-color: #bfdcff; + color: #1a4f8b; +} /* Cards */ .card { border: 1px solid #ddd; - border-radius: 8px; - padding: 12px; + border-radius: 10px; + padding: 14px; margin-bottom: 12px; - background: #fafafa; + background: var(--card); } -.card img { - max-width: 150px; +.card img, .card video { + max-width: 180px; border-radius: 5px; margin-top: 5px; + display: block; +} +.card-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} +.card-meta { + color: var(--muted); + font-size: 0.95rem; } /* Tables */ @@ -169,24 +231,26 @@ tr:hover { .status-approved { color: green; font-weight: bold; } .status-rejected { color: red; font-weight: bold; } .status-pending { color: orange; font-weight: bold; } +.status-response { color: #1a4f8b; font-weight: bold; } -/* Layout Helpers */ -.section { - margin-bottom: 40px; -} -.filters { - margin: 10px 0; - padding: 10px; - background: #f7f7f7; - border: 1px solid #ddd; - border-radius: 5px; -} +.badge { + display: inline-block; + padding: 4px 8px; + border-radius: 999px; + font-size: 0.85rem; + background: #e2e8f0; + color: #1f2937; + border: 1px solid #cbd5e0; +} +.badge-primary { background: #e8f1ff; color: #1a4f8b; border-color: #cfe0ff; } +.badge-danger { background: #ffe6e6; color: #9b1c1c; border-color: #ffc9c9; } +.badge-success { background: #e6f4ea; color: #116530; border-color: #c1e2cc; } +.badge-warning { background: #fff2d6; color: #8a4b00; border-color: #ffdf9b; } /* Map */ .map-section { margin: 24px 0; } - .map-frame { height: 500px; margin: 10px 0 8px; @@ -196,20 +260,64 @@ tr:hover { border: 1px solid #d9e2ef; } -.section-header { - margin-bottom: 6px; +/* Navigation */ +.top-nav { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 10px 0 18px; +} +.top-nav a { + padding: 8px 12px; + background: #eef2f7; + border-radius: 8px; + border: 1px solid #d8dee9; + font-weight: 600; + color: #1f2937; } -.page-header { - margin-bottom: 10px; +/* Panels */ +.panel { + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 16px; + box-shadow: 0 2px 4px rgba(15, 23, 42, 0.04); +} +.panel-title { + margin: 0 0 6px; +} +.panel-description { + margin: 0 0 10px; + color: var(--muted); } -.page-intro { - margin: 4px 0 12px; - color: #4a5568; - font-size: 1.05rem; +/* Feed */ +.feed { display: grid; gap: 12px; } +.feed-item { + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 12px; + background: #fff; +} +.feed-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; } +/* Admin */ +.role-select { + min-width: 150px; +} +.action-buttons { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +/* Responsive */ @media (max-width: 640px) { .container { margin: 12px; @@ -219,5 +327,9 @@ tr:hover { .map-frame { height: 360px; } -} + .card-header { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/assets/js/web_ui.js b/assets/js/web_ui.js new file mode 100644 index 0000000..7790681 --- /dev/null +++ b/assets/js/web_ui.js @@ -0,0 +1,432 @@ +const map = L.map("map").setView([53.5461, -113.4938], 12); +L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { + attribution: "© OpenStreetMap contributors", +}).addTo(map); + +const markerCluster = L.markerClusterGroup(); +map.addLayer(markerCluster); + +const locationStatus = document.getElementById("locationStatus"); +const latitudeInput = document.getElementById("latitude"); +const longitudeInput = document.getElementById("longitude"); +let selectionMarker; + +const reportFeedback = document.getElementById("formFeedback"); +const driverFeedback = document.getElementById("driverFeedback"); +const driverReportsContainer = document.getElementById("driverReports"); +const publicFeed = document.getElementById("publicFeed"); +const moderationQueue = document.getElementById("moderationQueue"); +const userTableBody = document.getElementById("userTableBody"); + +const visibilityFilter = document.getElementById("visibilityFilter"); +const categoryFilter = document.getElementById("categoryFilter"); + +const reportForm = document.getElementById("reportForm"); +const driverForm = document.getElementById("driverVerificationForm"); + +const users = [ + { id: 1, name: "Pat Jordan", email: "pat@example.com", role: "Admin" }, + { id: 2, name: "Lee Chen", email: "lee@example.com", role: "Moderator" }, + { id: 3, name: "Alex Garcia", email: "alex@example.com", role: "User" }, +]; + +const reports = [ + { + id: 1, + plate: "ABC1234", + category: "Accident", + incidentType: "Collision", + description: "Rear-end collision at low speed. Minor bumper damage and no injuries reported.", + latitude: 53.543, + longitude: -113.49, + status: "published", + reporter: "Taylor", + reporterContact: "taylor@example.com", + reporterComment: "Vehicle failed to stop at light.", + driverResponse: "We contacted insurance and have booked repairs. Sorry for the delay.", + responseNotified: true, + attachments: { + photos: ["/assets/sample/plate-abc1234.jpg"], + video: "https://example.com/dashcam/abc1234" + } + }, + { + id: 2, + plate: "XYZ5678", + category: "Near-miss", + incidentType: "Close pass / near-miss", + description: "Cyclist was passed within 30cm on a narrow street. No collision but very close call.", + latitude: 53.547, + longitude: -113.52, + status: "published", + reporter: "Jordan", + reporterContact: "", + reporterComment: "Driver crossed solid line to pass.", + driverResponse: "Reviewed dashcam and will give more space going forward.", + responseNotified: true, + attachments: { + photos: [], + video: "" + } + }, + { + id: 3, + plate: "LMN9001", + category: "Irritant", + incidentType: "High beams or no lights", + description: "Pickup truck drove several blocks with high beams in heavy fog.", + latitude: 53.55, + longitude: -113.47, + status: "pending", + reporter: "Casey", + reporterContact: "casey@example.com", + reporterComment: "Blinding oncoming traffic overnight.", + driverResponse: "", + responseNotified: false, + attachments: { + photos: ["/assets/sample/fog.jpg"], + video: "" + } + } +]; + +function anonymizePlate(plate) { + if (!plate) return ""; + return plate.substring(0, 3).toUpperCase() + "****"; +} + +function setLocation(lat, lng) { + latitudeInput.value = lat; + longitudeInput.value = lng; + locationStatus.textContent = `Location selected: ${lat.toFixed(5)}, ${lng.toFixed(5)}. You can submit the form now.`; + locationStatus.classList.remove("alert-error"); +} + +function showFeedback(element, message, tone = "alert-info") { + element.textContent = message; + element.className = `alert ${tone}`; +} + +function renderMap() { + markerCluster.clearLayers(); + const includePending = visibilityFilter.value === "pending"; + const category = categoryFilter.value; + + reports + .filter(report => includePending || report.status === "published") + .filter(report => category === "All" || report.category === category) + .forEach(report => { + const marker = L.marker([report.latitude, report.longitude]); + const popup = ` + ${report.category} · ${report.incidentType}
+ Plate: ${anonymizePlate(report.plate)}
+ Reporter: ${report.reporterComment || "No comment"}
+ ${report.driverResponse ? `Driver: ${report.driverResponse}
` : ""} + Status: ${report.status} + `; + marker.bindPopup(popup); + markerCluster.addLayer(marker); + }); +} + +function createAttachmentList(attachments) { + const list = document.createElement("ul"); + list.className = "helper-text"; + + if (attachments.photos && attachments.photos.length) { + const photos = document.createElement("li"); + photos.textContent = `Photos: ${attachments.photos.map(p => p.split("/").pop()).join(", ")}`; + list.appendChild(photos); + } + + if (attachments.video) { + const video = document.createElement("li"); + video.textContent = `Video: ${attachments.video}`; + list.appendChild(video); + } + + if (!list.children.length) { + const empty = document.createElement("li"); + empty.textContent = "No evidence attached"; + list.appendChild(empty); + } + + return list; +} + +function renderPublicFeed() { + publicFeed.innerHTML = ""; + const includePending = visibilityFilter.value === "pending"; + const category = categoryFilter.value; + + reports + .filter(report => includePending || report.status === "published") + .filter(report => category === "All" || report.category === category) + .forEach(report => { + const item = document.createElement("article"); + item.className = "feed-item"; + item.innerHTML = ` +
+
${report.category} · ${report.incidentType}
+ ${anonymizePlate(report.plate)} +
+

${report.description}

+

Reporter: ${report.reporterComment || "No reporter comment"}

+ ${report.driverResponse ? `

Driver: ${report.driverResponse}

` : "

Driver has not responded yet.

"} +

Status: ${report.status === "published" ? "Visible to public" : "Pending moderation"}${report.responseNotified ? " · Reporter notified" : ""}

+ `; + item.appendChild(createAttachmentList(report.attachments)); + publicFeed.appendChild(item); + }); +} + +function renderModerationQueue() { + moderationQueue.innerHTML = ""; + reports.forEach(report => { + const panel = document.createElement("div"); + panel.className = "card"; + panel.innerHTML = ` +
+
${report.category} · ${report.incidentType}
+ ${report.status} +
+

Plate: ${anonymizePlate(report.plate)} · Reporter: ${report.reporter || "Anonymous"}

+

${report.description}

+

Attachments: ${(report.attachments.photos?.length || 0)} photo(s)${report.attachments.video ? " · video link" : ""}

+
+ + + +
+ `; + moderationQueue.appendChild(panel); + }); +} + +function renderUsers() { + userTableBody.innerHTML = ""; + users.forEach(user => { + const row = document.createElement("tr"); + row.innerHTML = ` + ${user.name} + ${user.email} + + + + + + + + `; + userTableBody.appendChild(row); + }); +} + +function renderDriverReports(plate) { + driverReportsContainer.innerHTML = ""; + const matches = reports.filter(report => report.plate.toUpperCase() === plate.toUpperCase()); + + if (!matches.length) { + const empty = document.createElement("p"); + empty.className = "card-meta"; + empty.textContent = "No reports linked to this plate yet."; + driverReportsContainer.appendChild(empty); + return; + } + + matches.forEach(report => { + const card = document.createElement("div"); + card.className = "card"; + card.innerHTML = ` +
+
${report.category} · ${report.incidentType}
+ ${anonymizePlate(report.plate)} +
+

${report.description}

+

Reporter: ${report.reporterComment || "No reporter comment"}

+ ${report.driverResponse ? `

Your response: ${report.driverResponse}

` : ""} +
+ + +
+ +
+

Reporter will be notified after you submit.

+
+ `; + driverReportsContainer.appendChild(card); + }); +} + +function captureFiles(input) { + if (!input || !input.files || !input.files.length) return []; + return Array.from(input.files).map(file => file.name); +} + +map.on("click", event => { + const { lat, lng } = event.latlng; + if (selectionMarker) { + map.removeLayer(selectionMarker); + } + selectionMarker = L.marker([lat, lng], { + keyboard: true, + title: "Selected report location", + }).addTo(map); + setLocation(lat, lng); +}); + +if (reportForm) { + reportForm.addEventListener("submit", event => { + event.preventDefault(); + if (!latitudeInput.value || !longitudeInput.value) { + locationStatus.textContent = "Select a location on the map before submitting."; + locationStatus.classList.add("alert-error"); + locationStatus.scrollIntoView({ behavior: "smooth", block: "center" }); + return; + } + + const newReport = { + id: reports.length + 1, + plate: document.getElementById("plate").value.trim(), + category: document.getElementById("incidentCategory").value, + incidentType: document.getElementById("incidentType").value, + description: document.getElementById("description").value.trim(), + latitude: Number(latitudeInput.value), + longitude: Number(longitudeInput.value), + status: "pending", + reporter: document.getElementById("reporterName").value.trim() || "Anonymous", + reporterContact: document.getElementById("reporterContact").value.trim(), + reporterComment: document.getElementById("description").value.trim(), + driverResponse: "", + responseNotified: false, + attachments: { + photos: captureFiles(document.getElementById("photoEvidence")), + video: document.getElementById("videoEvidence").value.trim() + } + }; + + reports.unshift(newReport); + renderMap(); + renderPublicFeed(); + renderModerationQueue(); + reportForm.reset(); + if (selectionMarker) { + map.removeLayer(selectionMarker); + selectionMarker = null; + } + latitudeInput.value = ""; + longitudeInput.value = ""; + showFeedback(reportFeedback, "Report submitted. It will be moderated before public display.", "alert-success"); + }); +} + +if (driverForm) { + driverForm.addEventListener("submit", event => { + event.preventDefault(); + const plate = document.getElementById("driverPlate").value.trim(); + const registrationNumber = document.getElementById("registrationNumber").value.trim(); + const platePhoto = document.getElementById("platePhoto").files?.length || 0; + + if (!plate) { + showFeedback(driverFeedback, "Enter a plate number to continue.", "alert-error"); + return; + } + + if (!registrationNumber && !platePhoto) { + showFeedback(driverFeedback, "Add a registration number or plate photo to verify.", "alert-error"); + return; + } + + renderDriverReports(plate); + showFeedback(driverFeedback, "Verified. Reports for this plate are ready and responses will notify reporters.", "alert-success"); + }); +} + +function handleDriverResponse(event) { + if (!event.target.matches("form.driver-response")) return; + event.preventDefault(); + const form = event.target; + const id = Number(form.getAttribute("data-report")); + const textArea = form.querySelector("textarea"); + const message = textArea.value.trim(); + if (!message) { + alert("Please add a response before sending."); + return; + } + const report = reports.find(r => r.id === id); + if (report) { + report.driverResponse = message; + report.responseNotified = true; + renderPublicFeed(); + renderModerationQueue(); + renderDriverReports(report.plate); + } + textArea.value = ""; +} + +driverReportsContainer?.addEventListener("submit", handleDriverResponse); + +moderationQueue?.addEventListener("click", event => { + const button = event.target.closest("button[data-id]"); + if (!button) return; + const id = Number(button.getAttribute("data-id")); + const action = button.getAttribute("data-action"); + const report = reports.find(r => r.id === id); + + if (!report) return; + if (action === "approve") { + report.status = "published"; + } + if (action === "remove") { + const index = reports.findIndex(r => r.id === id); + reports.splice(index, 1); + } + if (action === "edit") { + const updated = prompt("Edit report text", report.description); + if (updated !== null) { + report.description = updated; + report.reporterComment = updated; + } + } + renderMap(); + renderPublicFeed(); + renderModerationQueue(); +}); + +userTableBody?.addEventListener("click", event => { + const button = event.target.closest("button[data-id]"); + if (!button) return; + const id = Number(button.getAttribute("data-id")); + const action = button.getAttribute("data-action"); + const user = users.find(u => u.id === id); + if (!user) return; + if (action === "promote") { + const roleSelect = userTableBody.querySelector(`select[data-id='${id}']`); + user.role = roleSelect.value; + alert(`${user.name} is now ${user.role}`); + } + if (action === "remove") { + const index = users.findIndex(u => u.id === id); + users.splice(index, 1); + } + renderUsers(); +}); + +visibilityFilter?.addEventListener("change", () => { + renderMap(); + renderPublicFeed(); +}); + +categoryFilter?.addEventListener("change", () => { + renderMap(); + renderPublicFeed(); +}); + +renderMap(); +renderPublicFeed(); +renderModerationQueue(); +renderUsers(); diff --git a/index.html b/index.html index d9d4a0d..f0779da 100644 --- a/index.html +++ b/index.html @@ -3,89 +3,227 @@ - Accident Reports - Edmonton + Open Safety Map + -
-
+
-

Incident map

-

Tap or click to explore existing reports. Select a spot to fill in its coordinates before submitting your own.

+

Report a near-miss or incident

+

Share the location, describe what happened, and attach photos or video. Categories are simplified to keep reporting fast.

+
+
+
+
+
+ + +
+
+ + +

We'll notify you when a driver responds or an admin updates your report.

+
+
+ +
+
+ + +

Only the first three letters will appear publicly.

+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +

You can add registration photos, scene photos, or plate images.

+
+
+ + +

Paste a link to dashcam footage or upload a clip.

+
+
+ + + +

Tap the map to set location. Coordinates are required for publishing.

+ +
+ + +
+
+
+ +
+
+

Fast categories

+

Use the simplified list to classify near-misses, accidents, irritants (high beams or no lights), and "other" cases.

+
+
+
Need to include plates?Privacy-safe
+

Only the first three letters appear on the public map so reporters and drivers stay safe while collaborating.

+
+
+
EvidencePhotos & video
+

Upload photos of registration papers or a clear plate shot to help drivers verify and reply quickly.

+
+
+
NotificationReporter updates
+

When a driver responds, the reporter gets a notification summary.

+
+
-
-

No location selected yet. Choose a point on the map to set latitude and longitude.

-
+
-

Submit a Report

-

Complete each field. Required inputs are marked with an asterisk (*).

+

Public map & anonymized feed

+

All public markers show only the first three letters of a plate. Both reporter and driver comments appear after moderation.

-
+
- - + + + + +
-
- - + +
+
+
+

No location selected yet. Choose a point on the map to set latitude and longitude.

+
+
+

Recent public reports

+
+
+
+
+
+

Driver verification & responses

+

Drivers can verify with a registration number or a clear plate photo, then respond. Reporters are notified automatically.

+
+
+
- - + + +

Full plate required for verification. Public view shows only the first three letters.

-
+
+
+ + +
+
+ + +
+
+

Submit at least one verification method to view reports linked to your plate.

+
+ + +
+
+ -
- - +
+

Respond to reports

+

Verified drivers can reply with context, proof of corrections, or to contest a report. Reporters are notified after each response.

+
+
+
- - +
+
+

Admin moderation & user roles

+

Admins and moderators can edit, remove, and approve reports or responses. User management assigns Admin and Moderator roles separately.

+
-
- - -

Share a direct link to an image. Files are optional.

-
+
+

Moderate reports

+
+
-
- -
-
- +
+

User management

+

Designate admins and moderators. Changes take effect immediately.

+ + + + + + + + + + +
UserEmailRoleActions
+
- +