first commit

This commit is contained in:
2025-11-24 16:30:37 +00:00
commit 843e93a274
114 changed files with 25585 additions and 0 deletions

View File

@@ -0,0 +1,11 @@
"use client";
import { SessionProvider } from "next-auth/react";
export default function AuthProvider({
children,
}: {
children: React.ReactNode;
}) {
return <SessionProvider>{children}</SessionProvider>;
}

157
components/demo/DemoMap.tsx Normal file
View File

@@ -0,0 +1,157 @@
"use client";
import { useEffect, useState, useRef } from 'react';
import dynamic from 'next/dynamic';
import { DEMO_DEVICES, DEMO_MAP_CENTER, DEMO_MAP_ZOOM, DemoDevice } from '@/lib/demo-data';
// Dynamically import Leaflet components (client-side only)
const MapContainer = dynamic(
() => import('react-leaflet').then((mod) => mod.MapContainer),
{ ssr: false }
);
const TileLayer = dynamic(
() => import('react-leaflet').then((mod) => mod.TileLayer),
{ ssr: false }
);
const Marker = dynamic(
() => import('react-leaflet').then((mod) => mod.Marker),
{ ssr: false }
);
const Popup = dynamic(
() => import('react-leaflet').then((mod) => mod.Popup),
{ ssr: false }
);
const Polyline = dynamic(
() => import('react-leaflet').then((mod) => mod.Polyline),
{ ssr: false }
);
export default function DemoMap() {
const [devicePositions, setDevicePositions] = useState<Map<string, number>>(new Map());
const [isClient, setIsClient] = useState(false);
const iconCache = useRef<Map<string, any>>(new Map());
// Initialize on client side
useEffect(() => {
setIsClient(true);
// Initialize all devices at position 0
const initialPositions = new Map();
DEMO_DEVICES.forEach(device => {
initialPositions.set(device.id, 0);
});
setDevicePositions(initialPositions);
}, []);
// Animate device movements
useEffect(() => {
if (!isClient) return;
const interval = setInterval(() => {
setDevicePositions(prev => {
const next = new Map(prev);
DEMO_DEVICES.forEach(device => {
const currentPos = next.get(device.id) || 0;
const nextPos = (currentPos + 1) % device.route.length;
next.set(device.id, nextPos);
});
return next;
});
}, 3000); // Move every 3 seconds
return () => clearInterval(interval);
}, [isClient]);
// Create custom marker icons
const getDeviceIcon = (color: string) => {
if (typeof window === 'undefined') return null;
if (iconCache.current.has(color)) {
return iconCache.current.get(color);
}
const L = require('leaflet');
const icon = L.divIcon({
className: 'custom-marker',
html: `
<div style="
background-color: ${color};
width: 24px;
height: 24px;
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
"></div>
`,
iconSize: [24, 24],
iconAnchor: [12, 12],
});
iconCache.current.set(color, icon);
return icon;
};
if (!isClient) {
return (
<div className="w-full h-full bg-gray-200 rounded-lg flex items-center justify-center">
<p className="text-gray-600">Loading demo map...</p>
</div>
);
}
return (
<div className="w-full h-full rounded-lg overflow-hidden shadow-lg">
<MapContainer
center={DEMO_MAP_CENTER}
zoom={DEMO_MAP_ZOOM}
style={{ height: '100%', width: '100%' }}
scrollWheelZoom={true}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{DEMO_DEVICES.map(device => {
const currentPosIdx = devicePositions.get(device.id) || 0;
const currentPos = device.route[currentPosIdx];
const pathSoFar = device.route.slice(0, currentPosIdx + 1);
return (
<div key={device.id}>
{/* Movement trail */}
{pathSoFar.length > 1 && (
<Polyline
positions={pathSoFar.map(loc => [loc.lat, loc.lng])}
color={device.color}
weight={3}
opacity={0.6}
/>
)}
{/* Current position marker */}
<Marker
position={[currentPos.lat, currentPos.lng]}
icon={getDeviceIcon(device.color)}
>
<Popup>
<div className="text-sm">
<p className="font-semibold">{device.name}</p>
<p className="text-xs text-gray-600">
Position: {currentPosIdx + 1}/{device.route.length}
</p>
<p className="text-xs text-gray-600">
Lat: {currentPos.lat.toFixed(4)}
</p>
<p className="text-xs text-gray-600">
Lng: {currentPos.lng.toFixed(4)}
</p>
</div>
</Popup>
</Marker>
</div>
);
})}
</MapContainer>
</div>
);
}

394
components/map/MapView.tsx Normal file
View File

@@ -0,0 +1,394 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { Location, LocationResponse } from "@/types/location";
import { getDevice, DEFAULT_DEVICE } from "@/lib/devices";
import L from "leaflet";
import {
MapContainer,
TileLayer,
Marker,
Popup,
Polyline,
LayersControl,
useMap,
} from "react-leaflet";
interface MapViewProps {
selectedDevice: string;
timeFilter: number; // in hours, 0 = all
isPaused: boolean;
filterMode: "quick" | "custom";
startTime: string; // datetime-local format
endTime: string; // datetime-local format
}
interface DeviceInfo {
id: string;
name: string;
color: string;
}
// Component to auto-center map to latest position and track zoom
function SetViewOnChange({
center,
zoom,
onZoomChange
}: {
center: [number, number] | null;
zoom: number;
onZoomChange: (zoom: number) => void;
}) {
const map = useMap();
useEffect(() => {
if (center) {
map.setView(center, zoom, { animate: true });
}
}, [center, zoom, map]);
useEffect(() => {
const handleZoom = () => {
onZoomChange(map.getZoom());
};
// Initial zoom
onZoomChange(map.getZoom());
// Listen to zoom changes
map.on('zoomend', handleZoom);
return () => {
map.off('zoomend', handleZoom);
};
}, [map, onZoomChange]);
return null;
}
export default function MapView({ selectedDevice, timeFilter, isPaused, filterMode, startTime, endTime }: MapViewProps) {
const [locations, setLocations] = useState<Location[]>([]);
const [devices, setDevices] = useState<Record<string, DeviceInfo>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [mapCenter, setMapCenter] = useState<[number, number] | null>(null);
const [currentZoom, setCurrentZoom] = useState(12);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
// Add animation styles for latest marker
useEffect(() => {
// Inject CSS animation for marker pulse effect
if (typeof document !== 'undefined') {
const styleId = 'marker-animation-styles';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
@keyframes marker-pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.15);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.latest-marker {
animation: marker-pulse 2s ease-in-out infinite;
}
`;
document.head.appendChild(style);
}
}
}, []);
// Fetch devices from API
useEffect(() => {
const fetchDevices = async () => {
try {
const response = await fetch("/api/devices/public");
if (response.ok) {
const data = await response.json();
const devicesMap = data.devices.reduce((acc: Record<string, DeviceInfo>, dev: DeviceInfo) => {
acc[dev.id] = dev;
return acc;
}, {});
setDevices(devicesMap);
}
} catch (err) {
console.error("Failed to fetch devices:", err);
// Fallback to hardcoded devices if API fails
}
};
fetchDevices();
// Refresh devices every 30 seconds (in case of updates)
const interval = setInterval(fetchDevices, 30000);
return () => clearInterval(interval);
}, []);
// Fetch locations
useEffect(() => {
const fetchLocations = async () => {
if (isPaused) return; // Skip fetching when paused
try {
// Build query params
const params = new URLSearchParams();
if (selectedDevice !== "all") {
params.set("username", selectedDevice);
}
// Apply time filter based on mode
if (filterMode === "custom" && startTime && endTime) {
// Convert datetime-local to ISO string
const startISO = new Date(startTime).toISOString();
const endISO = new Date(endTime).toISOString();
params.set("startTime", startISO);
params.set("endTime", endISO);
} else if (filterMode === "quick" && timeFilter > 0) {
params.set("timeRangeHours", timeFilter.toString());
}
params.set("limit", "5000"); // Fetch more data for better history
params.set("sync", "false"); // Disable n8n sync (using direct MQTT)
// Fetch from local SQLite API (MQTT subscriber writes directly)
const response = await fetch(`/api/locations?${params.toString()}`);
if (!response.ok) throw new Error("Failed to fetch locations");
const data: LocationResponse = await response.json();
// Debug: Log last 3 locations to see speed/battery values
if (data.history && data.history.length > 0) {
console.log('[MapView Debug] Last 3 locations:', data.history.slice(0, 3).map(loc => ({
username: loc.username,
timestamp: loc.timestamp,
speed: loc.speed,
speed_type: typeof loc.speed,
speed_is_null: loc.speed === null,
speed_is_undefined: loc.speed === undefined,
battery: loc.battery,
})));
// Auto-center to latest location
const latest = data.history[0];
if (latest && latest.latitude && latest.longitude) {
setMapCenter([Number(latest.latitude), Number(latest.longitude)]);
}
}
setLocations(data.history || []);
setError(null);
} catch (err) {
setError("Failed to load locations");
console.error(err);
} finally {
setLoading(false);
}
};
fetchLocations();
// Store interval reference for pause/resume control
if (!isPaused) {
intervalRef.current = setInterval(fetchLocations, 5000); // Refresh every 5s
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [selectedDevice, timeFilter, isPaused, filterMode, startTime, endTime]);
// No client-side filtering needed - API already filters by username and timeRangeHours
// Filter out locations without username (should not happen, but TypeScript safety)
const filteredLocations = locations.filter(loc => loc.username != null);
// Group by device
const deviceGroups = filteredLocations.reduce((acc, loc) => {
const deviceId = loc.username!; // Safe to use ! here because we filtered null above
if (!acc[deviceId]) acc[deviceId] = [];
acc[deviceId].push(loc);
return acc;
}, {} as Record<string, Location[]>);
if (loading) {
return (
<div className="h-full flex items-center justify-center bg-gray-100">
<p>Loading map...</p>
</div>
);
}
if (error) {
return (
<div className="h-full flex items-center justify-center bg-red-50">
<p className="text-red-600">{error}</p>
</div>
);
}
return (
<div className="h-full w-full">
<MapContainer
center={[48.1351, 11.582]}
zoom={12}
style={{ height: "100%", width: "100%" }}
>
{/* Auto-center to latest position and track zoom */}
<SetViewOnChange
center={mapCenter}
zoom={14}
onZoomChange={setCurrentZoom}
/>
<LayersControl position="topright">
<LayersControl.BaseLayer checked name="Standard">
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Satellite">
<TileLayer
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
attribution="Esri"
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Dark">
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://carto.com/">CartoDB</a>'
/>
</LayersControl.BaseLayer>
</LayersControl>
{Object.entries(deviceGroups).map(([deviceId, locs]) => {
// Use device from API if available, fallback to hardcoded
const device = devices[deviceId] || getDevice(deviceId);
// Sort DESC (newest first) - same as API
const sortedLocs = [...locs].sort(
(a, b) =>
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
);
return (
<div key={deviceId}>
{/* Polyline for path - reverse for chronological drawing (oldest to newest) */}
<Polyline
positions={[...sortedLocs].reverse().map((loc) => [
Number(loc.latitude),
Number(loc.longitude),
])}
color={device.color}
weight={2}
opacity={0.6}
/>
{/* Markers - render with explicit z-index (newest on top) */}
{sortedLocs.map((loc, idx) => {
const isLatest = idx === 0; // First in sorted array = newest (DESC order)
// Calculate z-index: newer locations get higher z-index
const zIndexOffset = sortedLocs.length - idx;
// Debug: Log for latest location only
if (isLatest) {
console.log('[Popup Debug] Latest location for', device.name, {
speed: loc.speed,
speed_type: typeof loc.speed,
speed_is_null: loc.speed === null,
speed_is_undefined: loc.speed === undefined,
condition_result: loc.speed != null,
display_time: loc.display_time
});
}
return (
<Marker
key={`${deviceId}-${loc.timestamp}-${idx}`}
position={[Number(loc.latitude), Number(loc.longitude)]}
icon={createCustomIcon(
device.color,
isLatest,
currentZoom
)}
zIndexOffset={zIndexOffset}
>
<Popup>
<div className="text-sm space-y-1">
<p className="font-bold text-base flex items-center gap-2">
<span className="text-lg">📱</span>
{device.name}
</p>
<p className="flex items-center gap-1">
<span>🕒</span> {loc.display_time}
</p>
{loc.battery != null && Number(loc.battery) > 0 && (
<p className="flex items-center gap-1">
<span>🔋</span> Battery: {loc.battery}%
</p>
)}
{loc.speed != null && (
<p className="flex items-center gap-1">
<span>🚗</span> Speed: {Number(loc.speed).toFixed(1)} km/h
</p>
)}
</div>
</Popup>
</Marker>
);
})}
</div>
);
})}
</MapContainer>
</div>
);
}
// Helper to create custom icon (similar to original)
function createCustomIcon(color: string, isLatest: boolean, zoom: number) {
// Base size - much bigger than before
const baseSize = isLatest ? 64 : 32;
// Zoom-based scaling: smaller at zoom 10, larger at zoom 18+
// zoom 10 = 0.6x, zoom 12 = 1.0x, zoom 15 = 1.45x, zoom 18 = 1.9x
const zoomScale = 0.6 + ((zoom - 10) * 0.15);
const clampedScale = Math.max(0.5, Math.min(2.5, zoomScale)); // Clamp between 0.5x and 2.5x
const size = Math.round(baseSize * clampedScale);
// Standard Location Pin Icon (wie Google Maps/Standard Marker)
const svg = `
<svg width="${size}" height="${size}" viewBox="0 0 24 36" xmlns="http://www.w3.org/2000/svg">
<!-- Outer pin shape -->
<path d="M12 0C5.4 0 0 5.4 0 12c0 7 12 24 12 24s12-17 12-24c0-6.6-5.4-12-12-12z"
fill="${color}"
stroke="white"
stroke-width="1.5"/>
<!-- Inner white circle -->
<circle cx="12" cy="12" r="5" fill="white" opacity="0.9"/>
<!-- Center dot -->
<circle cx="12" cy="12" r="2.5" fill="${color}"/>
</svg>
`;
return L.divIcon({
html: svg,
iconSize: [size, size * 1.5], // Height 1.5x width for pin shape
iconAnchor: [size / 2, size * 1.5], // Bottom center point
popupAnchor: [0, -size * 1.2], // Popup above the pin
className: isLatest ? "custom-marker-icon latest-marker" : "custom-marker-icon",
});
}