feat(sprint-3): refresh responsive UlHub portal
This commit is contained in:
@@ -1,66 +1,134 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
import { ReactNode } from 'react';
|
||||
import { useAuth } from '../lib/auth-context';
|
||||
import Head from "next/head";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { ReactNode } from "react";
|
||||
import { useAuth } from "../lib/auth-context";
|
||||
|
||||
export default function Layout({ children, title }: { children: ReactNode; title?: string }) {
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/", label: "Jobs" },
|
||||
{ href: "/settings/devices", label: "Devices" },
|
||||
{ href: "/settings/members", label: "Members" },
|
||||
{ href: "/settings/api-keys", label: "API keys" },
|
||||
{ href: "/settings/mqtt-certs", label: "MQTT certs" },
|
||||
];
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
}) {
|
||||
const { user, memberships, activeOrg, setActiveOrgId, logout } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const isActive = (href: string) =>
|
||||
href === "/"
|
||||
? router.pathname === "/" || router.pathname.startsWith("/jobs")
|
||||
: router.pathname.startsWith(href);
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
const navLinks = NAV_ITEMS.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="ul-shell__nav-link"
|
||||
aria-current={isActive(item.href) ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{title ? `${title} · UlHub` : 'UlHub'}</title>
|
||||
<title>{title ? `${title} · UlHub` : "UlHub"}</title>
|
||||
</Head>
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1.5rem',
|
||||
padding: '0.75rem 1.5rem',
|
||||
borderBottom: '1px solid #ddd',
|
||||
fontFamily: 'sans-serif',
|
||||
}}
|
||||
>
|
||||
<Link href="/" style={{ fontWeight: 700, fontSize: '1.1rem', textDecoration: 'none', color: '#111' }}>
|
||||
UlHub
|
||||
<a className="ul-skip-link" href="#main-content">
|
||||
Skip to main content
|
||||
</a>
|
||||
<header className="ul-shell__header">
|
||||
<Link href="/" className="ul-shell__brand" aria-label="UlHub jobs home">
|
||||
<span className="ul-shell__brand-mark" aria-hidden="true">
|
||||
UL
|
||||
</span>
|
||||
<span>UlHub</span>
|
||||
</Link>
|
||||
{user && (
|
||||
<>
|
||||
<nav style={{ display: 'flex', gap: '1rem' }}>
|
||||
<Link href="/">Jobs</Link>
|
||||
<Link href="/settings/devices">Devices</Link>
|
||||
<Link href="/settings/members">Members</Link>
|
||||
<Link href="/settings/api-keys">API Keys</Link>
|
||||
<Link href="/settings/mqtt-certs">MQTT Certs</Link>
|
||||
<nav
|
||||
className="ul-shell__nav ul-shell__nav--desktop"
|
||||
aria-label="Primary navigation"
|
||||
>
|
||||
{navLinks}
|
||||
</nav>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
{memberships.length > 1 ? (
|
||||
<select value={activeOrg?.org.id ?? ''} onChange={(e) => setActiveOrgId(e.target.value)}>
|
||||
{memberships.map((m) => (
|
||||
<option key={m.org.id} value={m.org.id}>
|
||||
{m.org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<span style={{ color: '#555' }}>{activeOrg?.org.name}</span>
|
||||
)}
|
||||
<span style={{ color: '#888', fontSize: '0.9rem' }}>{user.email}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await logout();
|
||||
router.push('/login');
|
||||
}}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
<div className="ul-shell__context">
|
||||
<div className="ul-org-context">
|
||||
<span className="ul-org-context__label" id="active-org-label">
|
||||
Active organization
|
||||
</span>
|
||||
{memberships.length > 1 ? (
|
||||
<select
|
||||
className="ul-org-context__select"
|
||||
aria-labelledby="active-org-label"
|
||||
value={activeOrg?.org.id ?? ""}
|
||||
onChange={(event) => setActiveOrgId(event.target.value)}
|
||||
>
|
||||
{memberships.map((membership) => (
|
||||
<option key={membership.org.id} value={membership.org.id}>
|
||||
{membership.org.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<strong className="ul-org-context__name">
|
||||
{activeOrg?.org.name ?? "No organization"}
|
||||
</strong>
|
||||
)}
|
||||
{activeOrg && (
|
||||
<span className="ul-org-context__role">
|
||||
{activeOrg.role.replace("_", " ")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<details className="ul-shell__menu">
|
||||
<summary>Menu</summary>
|
||||
<nav
|
||||
className="ul-shell__nav ul-shell__nav--mobile"
|
||||
aria-label="Mobile navigation"
|
||||
>
|
||||
{navLinks}
|
||||
<div className="ul-shell__mobile-account">
|
||||
<span>{user.email}</span>
|
||||
<button
|
||||
className="ul-button ul-button--quiet"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</details>
|
||||
<div className="ul-shell__account">
|
||||
<span className="ul-shell__email">{user.email}</span>
|
||||
<button
|
||||
className="ul-button ul-button--quiet"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
<main style={{ fontFamily: 'sans-serif', padding: '1.5rem', maxWidth: 1100, margin: '0 auto' }}>{children}</main>
|
||||
<main id="main-content" className="ul-shell__main" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
96
web/components/PortalUi.tsx
Normal file
96
web/components/PortalUi.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type StatusTone = "info" | "success" | "warning" | "danger" | "neutral";
|
||||
|
||||
const STATUS_PRESENTATION: Record<
|
||||
string,
|
||||
{ icon: string; label: string; tone: StatusTone }
|
||||
> = {
|
||||
OPEN: { icon: "○", label: "Open", tone: "info" },
|
||||
IN_PROGRESS: { icon: "◐", label: "In progress", tone: "warning" },
|
||||
COMPLETED: { icon: "✓", label: "Completed", tone: "success" },
|
||||
CANCELLED: { icon: "—", label: "Cancelled", tone: "neutral" },
|
||||
LIVE: { icon: "●", label: "Live", tone: "success" },
|
||||
CONNECTING: { icon: "↻", label: "Connecting", tone: "info" },
|
||||
OFFLINE: { icon: "○", label: "Offline · reconnecting", tone: "warning" },
|
||||
ERROR: { icon: "!", label: "Connection error · retrying", tone: "danger" },
|
||||
};
|
||||
|
||||
export function humanizeStatus(status: string): string {
|
||||
return (
|
||||
STATUS_PRESENTATION[status]?.label ??
|
||||
status
|
||||
.toLowerCase()
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({
|
||||
status,
|
||||
label,
|
||||
}: {
|
||||
status: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const presentation = STATUS_PRESENTATION[status] ?? {
|
||||
icon: "•",
|
||||
label: humanizeStatus(status),
|
||||
tone: "neutral" as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`ul-status-badge ul-status-badge--${presentation.tone}`}>
|
||||
<span className="ul-status-badge__icon" aria-hidden="true">
|
||||
{presentation.icon}
|
||||
</span>
|
||||
<span>{label ?? presentation.label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageState({
|
||||
kind,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
kind: "loading" | "empty" | "error";
|
||||
title: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const icon = kind === "error" ? "!" : kind === "empty" ? "□" : "…";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ul-page-state ul-page-state--${kind}`}
|
||||
role={kind === "error" ? "alert" : "status"}
|
||||
>
|
||||
<span className="ul-page-state__icon" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
{children && <div className="ul-page-state__detail">{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Metric({
|
||||
label,
|
||||
value,
|
||||
detail,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
detail?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="ul-metric">
|
||||
<span className="ul-metric__label">{label}</span>
|
||||
<strong className="ul-metric__value">{value}</strong>
|
||||
{detail && <span className="ul-metric__detail">{detail}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import type MapViewConstructor from '@arcgis/core/views/MapView.js';
|
||||
import { JobMapProps, LiveStatus, liveStatusToMapPoint, MapPoint, pointDetailRows, UTILITY_COLORS } from '../types';
|
||||
|
||||
const API_KEY = process.env.NEXT_PUBLIC_ARCGIS_API_KEY || '';
|
||||
const LIVE_COLOR = '#1a73e8';
|
||||
|
||||
const DEFAULT_CENTER: [number, number] = [-98.35, 39.5]; // continental US, [lng, lat]
|
||||
const DEFAULT_ZOOM = 4;
|
||||
@@ -63,6 +62,10 @@ function colorFor(utilityType: string): string {
|
||||
return UTILITY_COLORS[utilityType] ?? UTILITY_COLORS.UNKNOWN;
|
||||
}
|
||||
|
||||
function semanticColor(name: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
function orderKey(p: MapPoint): number {
|
||||
return p.sequence ?? new Date(p.recordedAt).getTime();
|
||||
}
|
||||
@@ -78,10 +81,10 @@ function popupHtml(point: MapPoint): string {
|
||||
const rows = pointDetailRows(point)
|
||||
.map(
|
||||
([label, value]) =>
|
||||
`<tr><td style="color:#666;padding-right:0.75rem;white-space:nowrap;">${label}</td><td style="font-weight:600;">${value}</td></tr>`,
|
||||
`<tr><td class="ul-map-popup-table__label">${label}</td><td class="ul-map-popup-table__value">${value}</td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
return `<table style="border-collapse:collapse;font-family:sans-serif;">${rows}</table>`;
|
||||
return `<table class="ul-map-popup-table">${rows}</table>`;
|
||||
}
|
||||
|
||||
// Esri opens/closes/dismisses-on-outside-click popups natively via each
|
||||
@@ -94,7 +97,7 @@ function pointGraphic(mods: EsriModules, point: MapPoint, color: string) {
|
||||
style: 'circle',
|
||||
color,
|
||||
size: 10,
|
||||
outline: { color: '#ffffff', width: 1.5 },
|
||||
outline: { color: semanticColor('--ul-color-on-primary'), width: 1.5 },
|
||||
},
|
||||
popupTemplate: {
|
||||
title: `${point.utilityType} · ${point.fixType}`,
|
||||
@@ -119,15 +122,16 @@ function lineGraphic(mods: EsriModules, points: MapPoint[], color: string) {
|
||||
}
|
||||
|
||||
function liveMarkerGraphics(mods: EsriModules, status: LiveStatus) {
|
||||
const liveColor = semanticColor('--ul-color-primary');
|
||||
const position = { type: 'point' as const, longitude: status.lng, latitude: status.lat };
|
||||
const marker = new mods.Graphic({
|
||||
geometry: position,
|
||||
symbol: {
|
||||
type: 'simple-marker',
|
||||
style: 'circle',
|
||||
color: LIVE_COLOR,
|
||||
color: liveColor,
|
||||
size: 16,
|
||||
outline: { color: '#ffffff', width: 2 },
|
||||
outline: { color: semanticColor('--ul-color-on-primary'), width: 2 },
|
||||
},
|
||||
popupTemplate: {
|
||||
title: `Live · ${status.serial}`,
|
||||
@@ -144,15 +148,15 @@ function liveMarkerGraphics(mods: EsriModules, status: LiveStatus) {
|
||||
}),
|
||||
symbol: {
|
||||
type: 'simple-fill',
|
||||
color: [...hexToRgb(LIVE_COLOR), 0.15],
|
||||
outline: { color: [...hexToRgb(LIVE_COLOR), 0.3], width: 1 },
|
||||
color: [...hexChannels(liveColor), 0.15],
|
||||
outline: { color: [...hexChannels(liveColor), 0.3], width: 1 },
|
||||
},
|
||||
} as ConstructorParameters<typeof GraphicConstructor>[0]);
|
||||
|
||||
return [halo, marker];
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): [number, number, number] {
|
||||
function hexChannels(hex: string): [number, number, number] {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
||||
}
|
||||
@@ -165,6 +169,9 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true
|
||||
const modsRef = useRef<EsriModules | null>(null);
|
||||
const fittedSignatureRef = useRef('');
|
||||
const [ready, setReady] = useState(false);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const sizeClass = heightPx <= 360 ? 'ul-map--compact' : heightPx >= 500 ? 'ul-map--detail' : '';
|
||||
const mapClassName = ['ul-map', sizeClass].filter(Boolean).join(' ');
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !API_KEY) {
|
||||
@@ -173,26 +180,33 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true
|
||||
let cancelled = false;
|
||||
let view: InstanceType<typeof MapViewConstructor> | undefined;
|
||||
|
||||
loadEsriModules().then((mods) => {
|
||||
if (cancelled || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
const pointsLayer = new mods.GraphicsLayer();
|
||||
const liveLayer = new mods.GraphicsLayer();
|
||||
const map = new mods.EsriMap({ basemap: 'hybrid', layers: [pointsLayer, liveLayer] });
|
||||
view = new mods.MapView({
|
||||
container: containerRef.current,
|
||||
map,
|
||||
center: DEFAULT_CENTER,
|
||||
zoom: DEFAULT_ZOOM,
|
||||
});
|
||||
loadEsriModules()
|
||||
.then((mods) => {
|
||||
if (cancelled || !containerRef.current) {
|
||||
return;
|
||||
}
|
||||
const pointsLayer = new mods.GraphicsLayer();
|
||||
const liveLayer = new mods.GraphicsLayer();
|
||||
const map = new mods.EsriMap({ basemap: 'hybrid', layers: [pointsLayer, liveLayer] });
|
||||
view = new mods.MapView({
|
||||
container: containerRef.current,
|
||||
map,
|
||||
center: DEFAULT_CENTER,
|
||||
zoom: DEFAULT_ZOOM,
|
||||
});
|
||||
|
||||
modsRef.current = mods;
|
||||
pointsLayerRef.current = pointsLayer;
|
||||
liveLayerRef.current = liveLayer;
|
||||
viewRef.current = view;
|
||||
setReady(true);
|
||||
});
|
||||
modsRef.current = mods;
|
||||
pointsLayerRef.current = pointsLayer;
|
||||
liveLayerRef.current = liveLayer;
|
||||
viewRef.current = view;
|
||||
setReady(true);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
if (!cancelled) {
|
||||
console.error('EsriJobMap: map failed to load', error);
|
||||
setLoadError(true);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -267,25 +281,23 @@ export default function EsriJobMap({ points, liveStatus = null, fitBounds = true
|
||||
|
||||
if (!API_KEY) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: heightPx,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: '1px dashed #999',
|
||||
borderRadius: 8,
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
<div className={`${mapClassName} ul-map__fallback`} role="status">
|
||||
Set NEXT_PUBLIC_ARCGIS_API_KEY in .env to enable the map.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className={`${mapClassName} ul-map__fallback`} role="alert">
|
||||
The locate map could not be loaded. Check the network connection and ArcGIS configuration.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: heightPx, borderRadius: 8, overflow: 'hidden' }}>
|
||||
<div ref={containerRef} style={{ height: '100%' }} />
|
||||
<div className={mapClassName} role="region" aria-label="Interactive locate map" aria-busy={!ready}>
|
||||
<div ref={containerRef} className="ul-map__canvas" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user