Spaces:
Running
Running
| 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> | |
| ); | |
| } | |