dengdeyan's picture
visilog
ec062c4
import React, { useEffect, useMemo, useRef, useState } from "react";
import "./VisitLog.css";
import { getApiUrl } from "../utils/apiConfig";
const EMPTY_STATE = [];
function formatTimestamp(value) {
if (!value) return "Unknown time";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
}
function formatLocation(location) {
if (!location || typeof location.lat !== "number") {
return "Unavailable";
}
const lat = location.lat.toFixed(4);
const lon = location.lon.toFixed(4);
const accuracy = location.accuracyMeters
? `${Math.round(location.accuracyMeters)} m`
: "Unknown";
return `${lat}, ${lon} (+/-${accuracy})`;
}
function loadGoogleMaps(apiKey) {
if (typeof window === "undefined") {
return Promise.reject(new Error("Window not available"));
}
if (window.google && window.google.maps) {
return Promise.resolve(window.google);
}
return new Promise((resolve, reject) => {
const existing = document.querySelector("script[data-google-maps]");
if (existing) {
existing.addEventListener("load", () => resolve(window.google));
existing.addEventListener("error", () =>
reject(new Error("Failed to load Google Maps"))
);
return;
}
const script = document.createElement("script");
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}`;
script.async = true;
script.defer = true;
script.dataset.googleMaps = "true";
script.addEventListener("load", () => resolve(window.google));
script.addEventListener("error", () =>
reject(new Error("Failed to load Google Maps"))
);
document.head.appendChild(script);
});
}
export default function VisitLog() {
const [visitorId, setVisitorId] = useState("");
const [selectedVisitorId, setSelectedVisitorId] = useState("");
const [visits, setVisits] = useState(EMPTY_STATE);
const [visitors, setVisitors] = useState(EMPTY_STATE);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [mapError, setMapError] = useState("");
const mapContainerRef = useRef(null);
const mapRef = useRef(null);
const markersRef = useRef([]);
const pendingFocusRef = useRef("");
const visitCount = visits.length;
const lastSeen = useMemo(() => {
if (!visitCount) return "No visits yet";
// Handle both `ts` (from SQLite) and `timestamp` (from old dataset implementation) for robustness.
return formatTimestamp(visits[0].ts || visits[0].timestamp);
}, [visitCount, visits]);
const fetchVisits = async (targetName) => {
if (!targetName) return;
setLoading(true);
setError("");
try {
const response = await fetch(
getApiUrl(`/api/visits?username=${encodeURIComponent(targetName)}`)
);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.message || "Failed to load visits");
}
setVisits(Array.isArray(payload.visits) ? payload.visits : []);
} catch (err) {
setError(err.message || "Failed to load visits");
setVisits(EMPTY_STATE);
} finally {
setLoading(false);
}
};
const fetchVisitors = async () => {
try {
const response = await fetch(getApiUrl("/api/visitors"));
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.message || "Failed to load visitors");
}
setVisitors(Array.isArray(payload.visitors) ? payload.visitors : []);
} catch (err) {
setMapError(err.message || "Failed to load visitors");
}
};
const handleSelectVisitor = (targetName) => {
if (!targetName) return;
setVisitorId(targetName);
setSelectedVisitorId(targetName);
fetchVisits(targetName);
pendingFocusRef.current = targetName;
focusMapOnVisitor(targetName);
};
const focusMapOnVisitor = (targetName) => {
if (!mapRef.current || !window.google || !targetName) {
return;
}
const match = visitors.find(
(visitor) =>
visitor.username === targetName || visitor.visitorId === targetName
);
const location = match?.location;
if (!location || typeof location.lat !== "number") {
return;
}
const position = { lat: location.lat, lng: location.lon };
mapRef.current.panTo(position);
mapRef.current.setZoom(8);
};
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const fromQuery = params.get("username") || "";
if (fromQuery) {
handleSelectVisitor(fromQuery);
}
}, []);
const handleSubmit = (event) => {
event.preventDefault();
handleSelectVisitor(visitorId.trim());
};
useEffect(() => {
document.body.classList.add("visit-log-body");
return () => {
document.body.classList.remove("visit-log-body");
};
}, []);
useEffect(() => {
fetchVisitors();
}, []);
useEffect(() => {
let cancelled = false;
const initializeMap = async () => {
try {
const configResponse = await fetch(getApiUrl("/api/config"));
if (!configResponse.ok) throw new Error("Failed to fetch client configuration.");
const config = await configResponse.json();
const apiKey = config.googleMapsApiKey;
if (!apiKey) {
throw new Error("Missing Google Maps API key from server configuration.");
}
await loadGoogleMaps(apiKey);
if (cancelled) return;
if (!mapRef.current && mapContainerRef.current) {
mapRef.current = new window.google.maps.Map(mapContainerRef.current, {
center: { lat: 20, lng: 0 },
zoom: 2,
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: false,
});
}
if (pendingFocusRef.current) focusMapOnVisitor(pendingFocusRef.current);
} catch (err) {
if (!cancelled) setMapError(err.message || "Failed to initialize map.");
}
};
initializeMap();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!mapRef.current || !window.google) {
return;
}
markersRef.current.forEach((marker) => marker.setMap(null));
markersRef.current = [];
const bounds = new window.google.maps.LatLngBounds();
let hasMarker = false;
visitors.forEach((visitor) => {
const location = visitor.location;
if (!location || typeof location.lat !== "number") {
return;
}
const position = { lat: location.lat, lng: location.lon };
const marker = new window.google.maps.Marker({
position,
map: mapRef.current,
title: visitor.visitorId,
});
marker.addListener("click", () => handleSelectVisitor(visitor.visitorId));
markersRef.current.push(marker);
bounds.extend(position);
hasMarker = true;
});
if (hasMarker) {
mapRef.current.fitBounds(bounds);
} else {
mapRef.current.setCenter({ lat: 20, lng: 0 });
mapRef.current.setZoom(2);
}
if (pendingFocusRef.current) {
focusMapOnVisitor(pendingFocusRef.current);
}
}, [visitors]);
useEffect(() => {
if (!selectedVisitorId) {
return;
}
focusMapOnVisitor(selectedVisitorId);
}, [selectedVisitorId, visitors]);
return (
<div className="visit-log-page">
<header className="visit-log-header">
<div>
<p className="visit-log-eyebrow">Visitor Insights</p>
<h1>Visit Timeline</h1>
<p className="visit-log-subtitle">
Track visits for a specific visitor and review model selections with
coarse location context.
</p>
</div>
<a className="visit-log-back" href="/">
Back to 3D Viewer
</a>
</header>
<section className="visit-log-card">
<form className="visit-log-form" onSubmit={handleSubmit}>
<div>
<label htmlFor="visitorId">Visitor ID</label>
<input
id="visitorId"
value={visitorId}
onChange={(event) => setVisitorId(event.target.value)}
placeholder="v_..."
/>
</div>
<button type="submit" disabled={!visitorId || loading}>
{loading ? "Loading..." : "Load Visits"}
</button>
</form>
<div className="visit-log-stats">
<div>
<span>Total Visits</span>
<strong>{visitCount}</strong>
</div>
<div>
<span>Last Seen</span>
<strong>{lastSeen}</strong>
</div>
</div>
</section>
{error && <p className="visit-log-error">{error}</p>}
{mapError && <p className="visit-log-error">{mapError}</p>}
<section className="visit-log-layout">
<aside className="visit-log-sidebar">
<div className="visit-log-sidebar-header">
<h2>Visitors</h2>
<span>{visitors.length} total</span>
</div>
<div className="visit-log-visitor-list">
{visitors.length === 0 && (
<p className="visit-log-empty">
No visitors found. Make sure the visit log has data.
</p>
)}
{visitors.map((visitor) => (
<button
key={visitor.visitorId}
className={
visitor.username === selectedVisitorId
? "visit-log-visitor visit-log-visitor--active"
: "visit-log-visitor"
}
type="button"
onClick={() => handleSelectVisitor(visitor.username || visitor.visitorId)}
>
<strong>{visitor.username || visitor.visitorId}</strong>
<span>{formatTimestamp(visitor.lastSeen)}</span>
</button>
))}
</div>
</aside>
<div className="visit-log-map-panel">
<div className="visit-log-map" ref={mapContainerRef} />
<div className="visit-log-map-note">
Click a marker or visitor to view their history.
</div>
</div>
</section>
<section className="visit-log-grid">
{visitCount === 0 && !loading && !error && (
<div className="visit-log-empty">
No visits yet. Enter a visitor ID to view activity.
</div>
)}
{visits.map((visit, index) => (
<article
className="visit-log-entry"
key={`${visit.ts || visit.timestamp}-${index}`}
>
<div className="visit-log-entry-header">
{/* Handle both `ts` and `timestamp` for robustness */}
<h2>{formatTimestamp(visit.ts || visit.timestamp)}</h2>
<span className="visit-log-entry-path">{visit.path || "/"}</span>
</div>
<div className="visit-log-entry-body">
<div>
<span>User</span>
<strong>{visit.username || visit.visitorId || "Unknown"}</strong>
</div>
<div>
<span>Model</span>
<strong>{visit.model?.name || visit.model?.id || "Unknown"}</strong>
</div>
<div>
<span>Tileset</span>
<strong>{visit.model?.tileset || "Unknown"}</strong>
</div>
<div>
<span>Offset Height</span>
<strong>
{typeof visit.model?.offsetHeight === "number"
? visit.model.offsetHeight
: "Unknown"}
</strong>
</div>
<div>
<span>Coarse Location</span>
<strong>{formatLocation(visit.location)}</strong>
</div>
</div>
</article>
))}
</section>
</div>
);
}