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.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 = `
+
+ Plate: ${anonymizePlate(report.plate)} · Reporter: ${report.reporter || "Anonymous"}
+ ${report.description}
+ Attachments: ${(report.attachments.photos?.length || 0)} photo(s)${report.attachments.video ? " · video link" : ""}
+
+ Approve
+ Edit text
+ Remove
+
+ `;
+ moderationQueue.appendChild(panel);
+ });
+}
+
+function renderUsers() {
+ userTableBody.innerHTML = "";
+ users.forEach(user => {
+ const row = document.createElement("tr");
+ row.innerHTML = `
+ ${user.name}
+ ${user.email}
+
+
+ Admin
+ Moderator
+ User
+
+
+
+ Save role
+ Remove
+
+ `;
+ 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.description}
+ Reporter: ${report.reporterComment || "No reporter comment"}
+ ${report.driverResponse ? `Your response: ${report.driverResponse}
` : ""}
+
+ `;
+ 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
+
-
-
+
+
+
+
+
+
+
+
+ Describe the situation *
+
+
+
+
+
+
+
+ Tap the map to set location. Coordinates are required for publishing.
+
+
+ Submit report
+ Reset
+
+
+
+
+
+
+
Fast categories
+
Use the simplified list to classify near-misses, accidents, irritants (high beams or no lights), and "other" cases.
+
+
+
+
Only the first three letters appear on the public map so reporters and drivers stay safe while collaborating.
+
+
+
+
Upload photos of registration papers or a clear plate shot to help drivers verify and reply quickly.
+
+
+
+
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.
-
+
-
+
+
+
No location selected yet. Choose a point on the map to set latitude and longitude.
+
+
+
Recent public reports
+
+
+
+
+
+
+
+ Submit at least one verification method to view reports linked to your plate.
+
+ Verify & view reports
+ Clear
+
+
+
-
+
-
-
+
+
-
+
-
- Submit Report
-
-
-
+
+
User management
+
Designate admins and moderators. Changes take effect immediately.
+
+
+
+ User
+ Email
+ Role
+ Actions
+
+
+
+
+
-
+