Add beginnings of status page

This commit is contained in:
Eli Ribble 2026-04-08 22:54:20 +00:00
parent 2c0bfb9904
commit 37ce3183ca
No known key found for this signature in database
6 changed files with 413 additions and 10 deletions

View file

@ -2,8 +2,11 @@ package resource
import (
"context"
"time"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform"
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
"net/http"
//"github.com/rs/zerolog/log"
"github.com/gorilla/mux"
@ -14,9 +17,15 @@ type publicreportR struct {
}
type publicreport struct {
ID string `json:"id"`
District string `json:"district"`
URI string `json:"uri"`
Address string `json:"address"`
Created time.Time `json:"created"`
District string `json:"district"`
ID string `json:"id"`
ImageCount int `json:"image_count"`
Location types.Location `json:"location"`
Status string `json:"status"`
Type string `json:"type"`
URI string `json:"uri"`
}
func Publicreport(r *router) *publicreportR {
@ -39,8 +48,23 @@ func (res *publicreportR) ByID(ctx context.Context, r *http.Request, query Query
if err != nil {
return nil, nhttp.NewError("district uri: %w", err)
}
uri, err := res.router.IDStrToURI("publicreport.ByIDGet", report.PublicID)
if err != nil {
return nil, nhttp.NewError("uri: %w", err)
}
location := types.Location{
Latitude: report.LocationLatitude.GetOr(0.0),
Longitude: report.LocationLongitude.GetOr(0.0),
}
return &publicreport{
District: district_uri,
ID: report.PublicID,
District: district_uri,
ID: report.PublicID,
Address: report.AddressRaw,
Created: report.Created,
ImageCount: len(report.R.Images),
Location: location,
Status: report.Status.String(),
Type: report.ReportType.String(),
URI: uri,
}, nil
}

View file

@ -0,0 +1,178 @@
<style scoped>
@import url("https://unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css");
.map {
width: 100%;
height: 100%;
transition: filter 0.2s ease;
}
/* Ensure map fills container on all devices */
:deep(.maplibregl-map) {
width: 100%;
height: 100%;
}
:deep(.maplibregl-canvas) {
width: 100%;
height: 100%;
}
</style>
<template>
<!-- Map container -->
<div ref="mapContainer" class="map-container"></div>
</template>
<script setup lang="ts">
import maplibregl from "maplibre-gl";
import {
onMounted,
onUnmounted,
ref,
type Ref,
shallowRef,
useTemplateRef,
watch,
} from "vue";
import { boundsMarkers, boundsDefault } from "@/map-utils";
import type { Marker } from "@/types";
import type { Location } from "@/type/api";
import type { Camera, MoveEndEventInternal } from "@/type/map";
// Emits interface
interface Emits {}
// Props
interface Props {
markers?: Marker[];
}
const props = withDefaults(defineProps<Props>(), {
markers: () => [],
});
// Refs
const map: Ref<maplibregl.Map | null> = shallowRef(null);
const mapContainer = ref<HTMLDivElement | null>(null);
const mapMarkers: Ref<Map<string, maplibregl.Marker>> = shallowRef<
Map<string, maplibregl.Marker>
>(new Map());
// Initialize map
function initializeMap() {
if (!mapContainer.value) return;
let bounds = boundsDefault();
if (props.markers.length > 0) {
bounds = boundsMarkers(props.markers);
}
const _map = new maplibregl.Map({
bounds: bounds,
container: mapContainer.value,
style: "https://tiles.stadiamaps.com/styles/alidade_smooth.json",
// Disable interactions by default
doubleClickZoom: false,
dragPan: false,
scrollZoom: false,
touchZoomRotate: false,
});
_map.addControl(new maplibregl.NavigationControl(), "top-left");
map.value = _map;
}
// Update markers on the map
const updateMarkers = () => {
if (!map.value) return;
// Remove markers that no longer exist
const currentMarkerIds = new Set(props.markers.map((m) => m.id));
for (const [id, marker] of mapMarkers.value) {
if (!currentMarkerIds.has(id)) {
marker.remove();
mapMarkers.value.delete(id);
}
}
// Add or update markers
props.markers.forEach((markerData) => {
let marker = mapMarkers.value.get(markerData.id);
if (marker) {
// Update existing marker
marker.setLngLat([
markerData.location.longitude,
markerData.location.latitude,
]);
marker.setDraggable(markerData.draggable ?? false);
} else {
marker = new maplibregl.Marker({
draggable: markerData.draggable ?? false,
})
.setLngLat([
markerData.location.longitude,
markerData.location.latitude,
])
.addTo(map.value!);
mapMarkers.value.set(markerData.id, marker);
}
});
};
// Frame all markers in view
const frameMarkers = () => {
if (!map.value || props.markers.length === 0) return;
if (props.markers.length === 1) {
// Single marker: pan to it
map.value.panTo(
{
lat: props.markers[0].location.latitude,
lng: props.markers[0].location.longitude,
},
{ duration: 1000 },
{ isInternalUpdate: true },
);
} else {
// Multiple markers: fit bounds
const bounds = new maplibregl.LngLatBounds();
props.markers.forEach((marker) => {
bounds.extend([marker.location.longitude, marker.location.latitude]);
});
map.value.fitBounds(
bounds,
{ padding: 10, duration: 1000 },
{ isInternalUpdate: true },
);
}
};
// Watch for markers changes
watch(
() => props.markers,
() => {
updateMarkers();
},
{ deep: true },
);
// Lifecycle hooks
onMounted(() => {
setTimeout(() => {
initializeMap();
updateMarkers();
}, 0);
});
onUnmounted(() => {
// Remove all markers
mapMarkers.value.forEach((marker) => marker.remove());
mapMarkers.value.clear();
// Remove map
if (map.value) {
map.value.remove();
map.value = null;
}
});
</script>

View file

@ -16,6 +16,7 @@ import NuisanceBase from "@/rmo/view/Nuisance.vue";
import NuisanceDistrict from "@/rmo/view/district/Nuisance.vue";
import ReportSubmitted from "@/rmo/view/ReportSubmitted.vue";
import StatusBase from "@/rmo/view/Status.vue";
import StatusByID from "@/rmo/view/StatusByID.vue";
import StatusDistrict from "@/rmo/view/district/Status.vue";
import Water from "@/rmo/view/Water.vue";
import WaterDistrict from "@/rmo/view/district/Water.vue";
@ -118,6 +119,12 @@ const routes: RouteRecordRaw[] = [
name: "StatusBase",
component: StatusBase,
},
{
component: StatusByID,
name: "StatusbyID",
path: "/status/:id",
props: true,
},
{
path: "/water",
name: "Water",

161
ts/rmo/view/StatusByID.vue Normal file
View file

@ -0,0 +1,161 @@
<style scoped>
.timeline {
border-left: 3px solid #dee2e6;
padding-left: 20px;
margin-left: 10px;
}
.timeline-item {
position: relative;
margin-bottom: 25px;
}
.timeline-item:before {
content: "";
position: absolute;
left: -29px;
top: 0;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: #0d6efd;
}
.timeline-date {
font-size: 0.85rem;
color: #6c757d;
}
.status-badge {
font-size: 1rem;
}
</style>
<template>
<div class="container my-4" v-if="report">
<!-- Report ID and Status Section -->
<div class="card mb-4">
<div
class="card-header bg-primary text-white d-flex justify-content-between align-items-center"
>
<h5 class="mb-0">Report {{ id }}</h5>
<span class="badge bg-warning text-dark status-badge">
{{ report.status }}
</span>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3">
<strong><i class="bi bi-tag me-2"></i>Type:</strong>
<span>{{ report.type }}</span>
</div>
<div class="col-md-4 mb-3">
<strong><i class="bi bi-calendar me-2"></i>Created:</strong>
<span>{{ formatTimeRelative(report.created) }}</span>
</div>
<div class="col-md-4 mb-3" v-if="district">
<strong><i class="bi bi-crosshair me-2"></i>District:</strong>
<span>
{{ district.name }}
</span>
</div>
</div>
<div class="row">
<div class="col-md-12">
<strong><i class="bi bi-pin-map me-2"></i>Location:</strong>
<span>{{ report.address }}</span>
</div>
</div>
<div class="row">
<div class="col-md-12">
<strong><i class="bi bi-images me-2"></i>Images:</strong>
<span>
{{
report.image_count > 0 ? report.image_count : "None provided"
}}
</span>
</div>
</div>
</div>
</div>
<!-- Map Section -->
<div class="card mb-4">
<div class="card-header bg-info text-white">
<h5 class="mb-0">
<i class="bi bi-pin-map-fill me-2"></i>Location Map
</h5>
</div>
<div class="card-body p-0">
<div class="map-container">
<MapLocatorDisplay id="map" :markers="markers"></MapLocatorDisplay>
</div>
</div>
</div>
</div>
<div class="container my-4" v-else>
<p>loading...</p>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { computedAsync } from "@vueuse/core";
import MapLocatorDisplay from "@/components/MapLocatorDisplay.vue";
import { useStoreDistrict } from "@/rmo/store/district";
import { useStorePublicreport } from "@/store/publicreport";
import type { Marker } from "@/types";
import type { District, Publicreport } from "@/type/api";
import { formatTimeRelative } from "@/format";
// Props
interface Props {
id: string;
}
const props = defineProps<Props>();
const storeDistrict = useStoreDistrict();
const storePublicreport = useStorePublicreport();
// Computed
const report = computedAsync(async (): Promise<Publicreport | undefined> => {
return await storePublicreport.byID(props.id);
});
const district = computedAsync(async (): Promise<District | undefined> => {
if (!(report.value && report.value.district)) {
return undefined;
}
return await storeDistrict.byURI(report.value.district);
});
const markers = computed((): Marker[] => {
if (!(report.value && report.value.location)) {
return [];
}
return [
{
id: report.value.id,
location: report.value.location,
},
];
});
// Lifecycle
onMounted(() => {
// Load map scripts if needed
loadMapScripts();
});
const loadMapScripts = () => {
// Load MapLibre GL if not already loaded
if (!document.querySelector('script[src*="maplibre-gl"]')) {
const script1 = document.createElement("script");
script1.src = "//unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.js";
document.head.appendChild(script1);
}
// Load Mapbox GL if not already loaded
if (!document.querySelector('script[src*="mapbox-gl"]')) {
const script2 = document.createElement("script");
script2.src =
"https://api.mapbox.com/mapbox-gl-js/v3.17.0-beta.1/mapbox-gl.js";
document.head.appendChild(script2);
}
};
</script>

View file

@ -1,6 +1,6 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import type { Publicreport } from "@/type/api";
import { Publicreport, type PublicreportDTO } from "@/type/api";
export const useStorePublicreport = defineStore("publicreport", () => {
// State
@ -23,9 +23,10 @@ export const useStorePublicreport = defineStore("publicreport", () => {
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const body = await response.json();
_byID.value.set(id, body);
return body;
const body: PublicreportDTO = await response.json();
const report = Publicreport.fromJSON(body);
_byID.value.set(id, report);
return report;
} catch (err) {
console.error("Error loading users:", err);
throw err;

View file

@ -32,8 +32,40 @@ export interface Geocode {
cell: number;
location: Location;
}
export interface Publicreport {
export interface PublicreportDTO {
address: string;
created: string;
district: string;
id: string;
image_count: number;
location: Location;
status: string;
type: string;
uri: string;
}
export class Publicreport {
constructor(
public address: string,
public created: Date,
public district: string,
public id: string,
public image_count: number,
public location: Location,
public status: string,
public type: string,
public uri: string,
) {}
static fromJSON(json: PublicreportDTO): Publicreport {
return new Publicreport(
json.address,
new Date(json.created),
json.district,
json.id,
json.image_count,
json.location,
json.status,
json.type,
json.uri,
);
}
}