removed extra app folder - integrated commands
This commit is contained in:
110
app/page.tsx
110
app/page.tsx
@ -1,110 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { MapContainer, TileLayer, Marker, useMapEvents } from "react-leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import L from "leaflet";
|
||||
|
||||
// Delete default Leaflet marker icon to avoid errors
|
||||
delete (L.Icon.Default as any)._getIconUrl;
|
||||
|
||||
// Helpers and types from lib/api.ts
|
||||
|
||||
const IMEI = "your_device_imei_here"; // Replace with dynamic if multiple devices
|
||||
|
||||
export default function Dashboard() {
|
||||
const [telemetry, setTelemetry] = useState<TelemetryPost | null>(null);
|
||||
const [commands, setCommands] = useState<DeviceCommand[]>([]);
|
||||
const [fence, setFence] = useState<{ lat: number; lng: number }[]>([]); // Geo-fence points
|
||||
const [commandType, setCommandType] = useState<DeviceCommandType>("sleep");
|
||||
const [payload, setPayload] = useState<string>(""); // JSON string for form
|
||||
|
||||
// Poll for latest telemetry and commands
|
||||
useEffect(() => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const latestGPS = await getLatestGPS(IMEI);
|
||||
setTelemetry(latestGPS);
|
||||
const cmds = await getCommands(IMEI);
|
||||
setCommands(cmds);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, 5000); // Poll every 5s; adjust
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Send command from form
|
||||
const sendCommand = async () => {
|
||||
try {
|
||||
const parsedPayload = JSON.parse(payload); // Validate in real app
|
||||
await postReceipt(IMEI, { command_id: 0, result: "ok" }); // Stub receipt (adjust)
|
||||
// Send actual command via your API if needed
|
||||
setPayload("");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Map component (geo-fence drawing)
|
||||
function FenceMap() {
|
||||
const map = useMapEvents({
|
||||
click: (e) => setFence([...fence, { lat: e.latlng.lat, lng: e.latlng.lng }]),
|
||||
});
|
||||
// Draw fence as polyline
|
||||
if (fence.length > 1) L.polyline(fence, { color: "red" }).addTo(map);
|
||||
// Marker for latest GPS
|
||||
if (telemetry) L.marker([telemetry.lat, telemetry.lng]).addTo(map);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="p-4">
|
||||
<h1>Sams Home Network Device Dashboard</h1>
|
||||
|
||||
<section className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h2>Latest Telemetry</h2>
|
||||
{telemetry ? (
|
||||
<pre>{JSON.stringify(telemetry, null, 2)}</pre>
|
||||
) : "Loading telemetry..."}
|
||||
<p>WiFi: {telemetry?.raw?.wifiStatus ?? 'Unknown'}</p>
|
||||
<p>Battery: {telemetry?.battery_percent ?? 'Unknown'}%</p>
|
||||
<p>Car On: {telemetry?.is_car_on ? 'Yes' : 'No'}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2>Map & Geo-Fence</h2>
|
||||
<MapContainer center={[0, 0]} zoom={3} style={{ height: "300px" }}>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<FenceMap />
|
||||
</MapContainer>
|
||||
<p>Click map to add fence points: {fence.length}</p>
|
||||
<button onClick={() => postTelemetry(IMEI, fence[0])}>Set Fence</button> {/* Example */}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Queued Commands</h2>
|
||||
<pre>{JSON.stringify(commands, null, 2)}</pre>
|
||||
<button onClick={() => postReceipt(IMEI, { command_id: commands[0]?.id, result: "ok" })}>Acknowledge First</button>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Send Command</h2>
|
||||
<select value={commandType} onChange={(e) => setCommandType(e.target.value as DeviceCommandType)}>
|
||||
<option value="lights">Lights On/Off</option>
|
||||
<option value="camera">Camera On/Off</option>
|
||||
<option value="sleep">Sleep</option>
|
||||
<option value="telemetry_sec">Posting Frequency</option>
|
||||
<option value="poll_sec">Poll Frequency</option>
|
||||
|
||||
<option value="ota">OTA Update</option>
|
||||
<option value="ring_fence">Geo Fence</option>
|
||||
</select>
|
||||
<textarea value={payload} onChange={(e) => setPayload(e.target.value)} placeholder='{ "on": true }' />
|
||||
<button onClick={sendCommand}>Send</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
9
package-lock.json
generated
9
package-lock.json
generated
@ -16,6 +16,7 @@
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/react-query": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"geist": "^1.5.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^15.2.3",
|
||||
"react": "^19.0.0",
|
||||
@ -3531,6 +3532,14 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/geist": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/geist/-/geist-1.5.1.tgz",
|
||||
"integrity": "sha512-mAHZxIsL2o3ZITFaBVFBnwyDOw+zNLYum6A6nIjpzCGIO8QtC3V76XF2RnZTyLx1wlDTmMDy8jg3Ib52MIjGvQ==",
|
||||
"peerDependencies": {
|
||||
"next": ">=13.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/react-query": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"geist": "^1.5.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"next": "^15.2.3",
|
||||
"react": "^19.0.0",
|
||||
|
||||
155
src/app/page.tsx
155
src/app/page.tsx
@ -1,53 +1,118 @@
|
||||
import Link from "next/link";
|
||||
// In file: src/app/page.tsx
|
||||
"use client";
|
||||
|
||||
import { LatestPost } from "~/app/_components/post";
|
||||
import { api, HydrateClient } from "~/trpc/server";
|
||||
import { useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { getCommands, getLatestGPS, DeviceCommand, TelemetryPost, DeviceCommandType, postReceipt } from "~/lib/api"; // Assuming postCommand will be added later
|
||||
|
||||
export default async function Home() {
|
||||
const hello = await api.post.hello({ text: "from tRPC" });
|
||||
// Dynamically import the map component with SSR turned OFF to prevent "window is not defined" errors
|
||||
const DeviceMap = dynamic(
|
||||
() => import("~/components/DeviceMapClient").then((mod) => mod.DeviceMap),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => <p>Loading map...</p>,
|
||||
}
|
||||
);
|
||||
|
||||
void api.post.getLatest.prefetch();
|
||||
const DEFAULT_IMEI = "sim7080g-01"; // Or make this dynamic later
|
||||
|
||||
export default function Dashboard() {
|
||||
const [selectedImei, setSelectedImei] = useState<string>(DEFAULT_IMEI);
|
||||
const [telemetry, setTelemetry] = useState<TelemetryPost | null>(null);
|
||||
const [commands, setCommands] = useState<DeviceCommand[]>([]);
|
||||
const [fence, setFence] = useState<Array<{ lat: number; lng: number }>>([]);
|
||||
const [commandType, setCommandType] = useState<DeviceCommandType>("sleep");
|
||||
const [payload, setPayload] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const pollData = async () => {
|
||||
try {
|
||||
const latest = await getLatestGPS(selectedImei);
|
||||
setTelemetry(latest);
|
||||
const cmds = await getCommands(selectedImei);
|
||||
setCommands(cmds);
|
||||
} catch (e) {
|
||||
console.error("Poll error:", e);
|
||||
}
|
||||
};
|
||||
|
||||
pollData(); // Initial fetch
|
||||
const interval = setInterval(pollData, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedImei]);
|
||||
|
||||
const sendCommand = async () => {
|
||||
// This is a placeholder until you provide the Laravel routes.
|
||||
try {
|
||||
const parsedPayload = payload ? JSON.parse(payload) : {};
|
||||
console.log("WAITING FOR LARAVEL ROUTE: Would send command:", { type: commandType, payload: parsedPayload });
|
||||
alert("Command sending is not implemented yet. See console for details.");
|
||||
} catch (e) {
|
||||
alert("Invalid JSON in payload!");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<HydrateClient>
|
||||
<main className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c] text-white">
|
||||
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16">
|
||||
<h1 className="text-5xl font-extrabold tracking-tight sm:text-[5rem]">
|
||||
Create <span className="text-[hsl(280,100%,70%)]">T3</span> App
|
||||
</h1>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-8">
|
||||
<Link
|
||||
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 hover:bg-white/20"
|
||||
href="https://create.t3.gg/en/usage/first-steps"
|
||||
target="_blank"
|
||||
>
|
||||
<h3 className="text-2xl font-bold">First Steps →</h3>
|
||||
<div className="text-lg">
|
||||
Just the basics - Everything you need to know to set up your
|
||||
database and authentication.
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 hover:bg-white/20"
|
||||
href="https://create.t3.gg/en/introduction"
|
||||
target="_blank"
|
||||
>
|
||||
<h3 className="text-2xl font-bold">Documentation →</h3>
|
||||
<div className="text-lg">
|
||||
Learn more about Create T3 App, the libraries it uses, and how
|
||||
to deploy it.
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-2xl text-white">
|
||||
{hello ? hello.greeting : "Loading tRPC query..."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LatestPost />
|
||||
<main className="p-4">
|
||||
<h1 className="text-2xl font-bold">Device Dashboard ({selectedImei})</h1>
|
||||
<section className="grid grid-cols-1 gap-4 mt-4 md:grid-cols-2">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Latest Telemetry</h2>
|
||||
{telemetry ? (
|
||||
<pre className="p-2 mt-2 text-sm text-white rounded-md bg-white/10">{JSON.stringify(telemetry, null, 2)}</pre>
|
||||
) : (
|
||||
<p>Loading telemetry...</p>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</HydrateClient>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">Map & Geo-Fence</h2>
|
||||
<div className="mt-2 rounded-md overflow-hidden">
|
||||
<DeviceMap latestGPS={telemetry} onFenceChange={setFence} />
|
||||
</div>
|
||||
<p>Geo-fence points: {fence.length}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="text-xl font-bold">Queued Commands</h2>
|
||||
<pre className="p-2 mt-2 text-sm text-white rounded-md bg-white/10">{commands.length > 0 ? JSON.stringify(commands, null, 2) : "No commands queued."}</pre>
|
||||
{commands.length > 0 && (
|
||||
<button
|
||||
className="px-4 py-2 mt-2 text-white bg-green-600 rounded-md hover:bg-green-700"
|
||||
onClick={() => postReceipt(selectedImei, { command_id: commands[0]?.id ?? 0, result: "ok" })}
|
||||
>
|
||||
Acknowledge First Command
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2 className="text-xl font-bold">Send Command</h2>
|
||||
<div className="flex flex-col gap-2 mt-2">
|
||||
<select
|
||||
className="p-2 text-white rounded-md bg-white/10"
|
||||
value={commandType}
|
||||
onChange={(e) => setCommandType(e.target.value as DeviceCommandType)}
|
||||
>
|
||||
<option value="reboot">Reboot Device</option>
|
||||
<option value="lights">Lights On/Off</option>
|
||||
<option value="camera">Camera On/Off</option>
|
||||
<option value="sleep">Set Sleep Interval</option>
|
||||
<option value="telemetry_sec">Set Telemetry Interval</option>
|
||||
</select>
|
||||
<textarea
|
||||
className="p-2 text-white rounded-md bg-white/10"
|
||||
value={payload}
|
||||
onChange={(e) => setPayload(e.target.value)}
|
||||
placeholder='Enter JSON payload, e.g., {"on": true}'
|
||||
/>
|
||||
<button
|
||||
className="px-4 py-2 text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
||||
onClick={sendCommand}>
|
||||
Send Command
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
52
src/components/DynamicDeviceMap.tsx
Normal file
52
src/components/DynamicDeviceMap.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
// In file: src/components/DeviceMapClient.tsx
|
||||
"use client";
|
||||
|
||||
// This file now contains your DeviceMap component, ensuring it's always a client component.
|
||||
import { useState } from "react";
|
||||
import { MapContainer, TileLayer, Marker, Polyline, useMapEvents } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Fix default Leaflet icon issue in React
|
||||
delete (L.Icon.Default as any)._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'leaflet/marker-icon-2x.png',
|
||||
iconUrl: 'leaflet/marker-icon.png',
|
||||
shadowUrl: 'leaflet/marker-shadow.png',
|
||||
});
|
||||
|
||||
|
||||
interface MapProps {
|
||||
latestGPS?: { lat: number; lng: number };
|
||||
onFenceChange: (fence: Array<{ lat: number; lng: number }>) => void;
|
||||
}
|
||||
|
||||
const FenceMap = ({ latestGPS, onFenceChange }: MapProps) => {
|
||||
const [fence, setFence] = useState<Array<{ lat: number; lng: number }>>([]);
|
||||
|
||||
useMapEvents({
|
||||
click: (e) => {
|
||||
const newFence = [...fence, { lat: e.latlng.lat, lng: e.latlng.lng }];
|
||||
setFence(newFence);
|
||||
onFenceChange(newFence);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{fence.length > 1 && <Polyline pathOptions={{ color: "red" }} positions={fence} />}
|
||||
{latestGPS && <Marker position={[latestGPS.lat, latestGPS.lng]} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeviceMap = ({ latestGPS, onFenceChange }: { latestGPS?: { lat: number; lng: number }; onFenceChange: (fence: Array<{ lat: number; lng: number }>) => void; }) => {
|
||||
const center = latestGPS ? [latestGPS.lat, latestGPS.lng] : [-37.76, 145.04]; // Default center
|
||||
|
||||
return (
|
||||
<MapContainer center={center} zoom={13} style={{ height: "300px", width: "100%" }}>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<FenceMap latestGPS={latestGPS} onFenceChange={onFenceChange} />
|
||||
</MapContainer>
|
||||
);
|
||||
};
|
||||
@ -105,4 +105,15 @@ export async function postReceipt(imei: string, body: CommandReceiptPost) {
|
||||
// Optional: Get latest GPS (adjust if your endpoint is /api/gps/latest-any)
|
||||
export async function getLatestGPS(imei: string) {
|
||||
return http<{ lat: number; lng: number; /* other fields */ }>(`/api/gps/latest-any?imei=${imei}`);
|
||||
}
|
||||
|
||||
export async function postCommand(
|
||||
imei: string,
|
||||
command: { type: DeviceCommandType; payload: Record<string, unknown> }
|
||||
) {
|
||||
return http<{ ok: true; command_id: number }>(`/api/device/${imei}/commands`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(command),
|
||||
});
|
||||
}
|
||||
@ -1,45 +1,49 @@
|
||||
// lib/map.tsx - Custom叶 map component with Leaflet for GPS location and geo-fence drawing
|
||||
// lib/map.tsx - Map component for displaying GPS location and geo-fence drawing
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MapContainer, TileLayer, Marker, Polyline, useMapEvents } from "react-leaflet";
|
||||
import L from "leaflet";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Fix default Leaflet icon issue in React
|
||||
// Fix Leaflet default icon path issue in React (common fix to avoid broken markers).
|
||||
delete (L.Icon.Default as any)._getIconUrl;
|
||||
|
||||
// Props for the map: latest GPS coords and callback to update fence in parent.
|
||||
interface MapProps {
|
||||
latestGPS?: { lat: number; lng: number };
|
||||
onFenceChange: (fence: Array<{ lat: number; lng: number }>) => void;
|
||||
latestGPS?: { lat: number; lng: number }; // Optional latest telemetry position
|
||||
onFenceChange: (fence: Array<{ lat: number; lng: number }>) => void; // Callback to parent for fence points
|
||||
}
|
||||
|
||||
const FenceMap = ({ latestGPS, onFenceChange }: MapProps) => {
|
||||
const [fence, setFence] = useState<Array<{ lat: number; lng: number }>>([]);
|
||||
const [fence, setFence] = useState<Array<{ lat: number; lng: number }>>([]); // Local state for fence points
|
||||
|
||||
const map = useMapEvents({
|
||||
click: (e) => {
|
||||
useMapEvents({
|
||||
click: (e) => { // On map click, add point to fence and notify parent
|
||||
const newFence = [...fence, { lat: e.latlng.lat, lng: e.latlng.lng }];
|
||||
setFence(newFence);
|
||||
onFenceChange(newFence); // Update parent
|
||||
onFenceChange(newFence);
|
||||
},
|
||||
});
|
||||
|
||||
const center = latestGPS ? [latestGPS.lat, latestGPS.lng] : [0, 0]; // Default center
|
||||
const center = latestGPS ? [latestGPS.lat, latestGPS.lng] : [0, 0]; // Center on latest GPS or default
|
||||
|
||||
return (
|
||||
<>
|
||||
{fence.length > 1 && <Polyline pathOptions={{ color: "red" }} positions={fence} />}
|
||||
{latestGPS && <Marker position={[latestGPS.lat, latestGPS.lng]} />}
|
||||
{fence.length > 1 && <Polyline pathOptions={{ color: "red" }} positions={fence} />} // Draw red line if 2+ points
|
||||
{latestGPS && <Marker position={[latestGPS.lat, latestGPS.lng]} />} // Show marker for latest position
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// Main exportable map component
|
||||
export const DeviceMap = ({ latestGPS }: { latestGPS?: { lat: number; lng: number } }) => {
|
||||
const [fence, setFence] = useState<Array<{ lat: number; lng: number }>>([]);
|
||||
const [fence, setFence] = useState<Array<{ lat: number; lng: number }>>([]); // Parent state for fence
|
||||
|
||||
return (
|
||||
<MapContainer center={[0, 0]} zoom={2} style={{ height: "300px", width: "100%" }}>
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
||||
<FenceMap latestGPS={latestGPS} onFenceChange={setFence} />
|
||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> // OpenStreetMap tiles (free, no key)
|
||||
<FenceMap latestGPS={latestGPS} onFenceChange={setFence} /> // Inner event handler component
|
||||
</MapContainer>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user