removed extra app folder - integrated commands

This commit is contained in:
2025-09-10 11:16:55 +10:00
parent 27f8b1da20
commit 5a21768b70
8 changed files with 201 additions and 169 deletions

View File

@@ -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>
);
}

View 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>
);
};

119
src/lib/api.ts Normal file
View File

@@ -0,0 +1,119 @@
// lib/api.ts - API helpers with Zod validation for your Laravel endpoints
import { z } from "zod";
export type DeviceCommandType =
| "wifi"
| "sleep"
| "telemetry_sec"
| "poll_sec"
| "ota"
| "ring_fence"
| "lights"
| "camera";
export interface DeviceCommand {
id: number;
type: DeviceCommandType;
payload: Record<string, unknown>;
}
export interface TelemetryPost {
recorded_at?: string;
lat: number;
lng: number;
altitude_m?: number;
speed_kmh?: number;
heading_deg?: number;
accuracy_m?: number;
battery_percent?: number;
is_car_on?: boolean;
raw?: Record<string, unknown>;
}
export interface CommandReceiptPost {
command_id: number;
acked_at?: string;
executed_at?: string;
result?: "ok" | "error";
result_detail?: string;
}
// Zod schemas for validation
const telemetrySchema = z.object({
recorded_at: z.string().optional(),
lat: z.number(),
lng: z.number(),
altitude_m: z.number().optional(),
speed_kmh: z.number().optional(),
heading_deg: z.number().optional(),
accuracy_m: z.number().optional(),
battery_percent: z.number().optional(),
is_car_on: z.boolean().optional(),
raw: z.record(z.unknown()).optional(),
});
const receiptSchema = z.object({
command_id: z.number(),
acked_at: z.string().optional(),
executed_at: z.string().optional(),
result: z.enum(["ok", "error"]).optional(),
result_detail: z.string().optional(),
});
// API base from हुए env (add to .env: NEXT_PUBLIC_API_BASE=https://laravel-server.lab.audasmedia.com.au)
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "";
async function http<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: "GET",
headers: { "Accept": "application/json", ...(init?.headers ?? {}) },
cache: "no-store",
...init,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
}
const text = await res.text();
return (text ? JSON.parse(text) : null) as T;
}
export async function postTelemetry(imei: string, body: TelemetryPost) {
const validatedBody = telemetrySchema.parse(body);
return http<{ ok: true }>(`/api/device/${imei}/telemetry`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(validatedBody),
});
}
export async function getCommands(imei: string, since?: string) {
const q = since ? `?since=${encodeURIComponent(since)}` : "";
return http<DeviceCommand[]>(`/api/device/${imei}/commands${q}`);
}
export async function postReceipt(imei: string, body: CommandReceiptPost) {
const validatedBody = receiptSchema.parse(body);
return http<{ ok: true }>(`/api/device/${imei}/command-receipts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(validatedBody),
});
}
// 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),
});
}

49
src/lib/map.tsx Normal file
View File

@@ -0,0 +1,49 @@
// 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 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 }; // 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 }>>([]); // Local state for fence points
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);
},
});
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} />} // 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 }>>([]); // 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" /> // OpenStreetMap tiles (free, no key)
<FenceMap latestGPS={latestGPS} onFenceChange={setFence} /> // Inner event handler component
</MapContainer>
);
};

36
src/lib/types.ts Normal file
View File

@@ -0,0 +1,36 @@
export type DeviceCommandType =
| "wifi"
| "sleep"
| "telemetry_sec"
| "poll_sec"
| "ota"
| "ring_fence"
| "lights"
| "camera";
export interface DeviceCommand {
id: number;
type: DeviceCommandType;
payload: Record<string, unknown>;
}
export interface TelemetryPost {
recorded_at?: string;
lat: number;
lng: number;
altitude_m?: number;
speed_kmh?: number;
heading_deg?: number;
accuracy_m?: number;
battery_percent?: number;
is_car_on?: boolean;
raw?: Record<string, unknown>;
}
export interface CommandReceiptPost {
command_id: number;
acked_at?: string;
executed_at?: string;
result?: "ok" | "error";
result_detail?: string;
}