311 lines
9.4 KiB
TypeScript
311 lines
9.4 KiB
TypeScript
// frontend\src\pages\operations\milestone-cameras\MilestoneCamerasPage.tsx
|
||
|
||
import { useEffect, useState } from "react";
|
||
import {
|
||
MicrophoneIcon,
|
||
VideoCameraIcon,
|
||
} from "@heroicons/react/24/outline";
|
||
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||
import LoadingSpinner from "../../../components/LoadingSpinner";
|
||
import Tables, { type TableColumn } from "../../../components/Tables";
|
||
import { Badge } from "../../../components/Badge";
|
||
import EmptyState from "../../../components/EmptyState";
|
||
import { useNotifications } from "../../../components/Notifications";
|
||
|
||
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8080";
|
||
|
||
type MilestoneCameraListItem = {
|
||
id: string;
|
||
name: string;
|
||
displayName: string;
|
||
type: "camera" | "microphone";
|
||
enabled: boolean;
|
||
recordingEnabled: boolean;
|
||
recordingStorageId: string;
|
||
hardwareId: string;
|
||
};
|
||
|
||
type StorageSimple = {
|
||
id: string;
|
||
name: string;
|
||
displayName: string;
|
||
};
|
||
|
||
type Props = {
|
||
canWrite?: boolean;
|
||
};
|
||
|
||
export default function MilestoneCamerasPage({ canWrite }: Props) {
|
||
const { notify } = useNotifications();
|
||
const [devices, setDevices] = useState<MilestoneCameraListItem[]>([]);
|
||
const [storageMap, setStorageMap] = useState<Record<string, StorageSimple>>({});
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [search, setSearch] = useState("");
|
||
|
||
useEffect(() => {
|
||
void Promise.all([loadDevices(), loadStorages()]);
|
||
}, []);
|
||
|
||
async function loadDevices() {
|
||
setIsLoading(true);
|
||
setError(null);
|
||
try {
|
||
const response = await fetch(`${API_URL}/admin/milestone/cameras`, {
|
||
credentials: "include",
|
||
});
|
||
if (!response.ok) {
|
||
const data = (await response.json().catch(() => null)) as {
|
||
error?: string;
|
||
} | null;
|
||
setError(data?.error ?? "Geräte konnten nicht geladen werden");
|
||
return;
|
||
}
|
||
const data = (await response.json()) as {
|
||
devices: MilestoneCameraListItem[];
|
||
};
|
||
setDevices(data.devices ?? []);
|
||
} catch {
|
||
setError("Verbindung zum Server fehlgeschlagen");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
async function loadStorages() {
|
||
try {
|
||
const response = await fetch(`${API_URL}/admin/milestone/storages`, {
|
||
credentials: "include",
|
||
});
|
||
if (!response.ok) return;
|
||
const data = (await response.json()) as {
|
||
storages?: { id: string; name: string; displayName: string }[];
|
||
};
|
||
const map: Record<string, StorageSimple> = {};
|
||
for (const s of data.storages ?? []) {
|
||
map[s.id] = { id: s.id, name: s.name, displayName: s.displayName };
|
||
}
|
||
setStorageMap(map);
|
||
} catch {
|
||
// silent – storage names are cosmetic
|
||
}
|
||
}
|
||
|
||
async function handleToggle(
|
||
device: MilestoneCameraListItem,
|
||
field: "enabled" | "recordingEnabled",
|
||
) {
|
||
const newValue = !device[field];
|
||
|
||
setDevices((current) =>
|
||
current.map((d) =>
|
||
d.id === device.id ? { ...d, [field]: newValue } : d,
|
||
),
|
||
);
|
||
|
||
const collection = device.type === "camera" ? "cameras" : "microphones";
|
||
try {
|
||
const response = await fetch(
|
||
`${API_URL}/admin/milestone/devices/${collection}/${device.id}`,
|
||
{
|
||
method: "PATCH",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ [field]: newValue }),
|
||
},
|
||
);
|
||
if (!response.ok) {
|
||
const data = (await response.json().catch(() => null)) as {
|
||
error?: string;
|
||
} | null;
|
||
setDevices((current) =>
|
||
current.map((d) =>
|
||
d.id === device.id ? { ...d, [field]: !newValue } : d,
|
||
),
|
||
);
|
||
notify.error(data?.error ?? "Gerät konnte nicht aktualisiert werden");
|
||
}
|
||
} catch {
|
||
setDevices((current) =>
|
||
current.map((d) =>
|
||
d.id === device.id ? { ...d, [field]: !newValue } : d,
|
||
),
|
||
);
|
||
notify.error("Verbindung fehlgeschlagen");
|
||
}
|
||
}
|
||
|
||
const searchLower = search.trim().toLowerCase();
|
||
const filtered = searchLower
|
||
? devices.filter(
|
||
(d) =>
|
||
d.name.toLowerCase().includes(searchLower) ||
|
||
d.displayName.toLowerCase().includes(searchLower),
|
||
)
|
||
: devices;
|
||
|
||
const cameraCount = filtered.filter((d) => d.type === "camera").length;
|
||
const micCount = filtered.filter((d) => d.type === "microphone").length;
|
||
|
||
const columns: TableColumn<MilestoneCameraListItem>[] = [
|
||
{
|
||
key: "name",
|
||
header: "Name",
|
||
isPrimary: true,
|
||
render: (row) => (
|
||
<div className="flex items-center gap-2">
|
||
{row.type === "camera" ? (
|
||
<VideoCameraIcon className="size-4 shrink-0 text-gray-400" />
|
||
) : (
|
||
<MicrophoneIcon className="size-4 shrink-0 text-gray-400" />
|
||
)}
|
||
<div>
|
||
<div className="font-medium text-gray-900 dark:text-white">
|
||
{row.name}
|
||
</div>
|
||
{row.displayName && row.displayName !== row.name && (
|
||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||
{row.displayName}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: "enabled",
|
||
header: "Status",
|
||
hideOnMobile: "sm",
|
||
render: (row) =>
|
||
canWrite ? (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
void handleToggle(row, "enabled");
|
||
}}
|
||
className="cursor-pointer"
|
||
>
|
||
<Badge tone={row.enabled ? "green" : "gray"} hover>
|
||
{row.enabled ? "Aktiv" : "Inaktiv"}
|
||
</Badge>
|
||
</button>
|
||
) : (
|
||
<Badge tone={row.enabled ? "green" : "gray"}>
|
||
{row.enabled ? "Aktiv" : "Inaktiv"}
|
||
</Badge>
|
||
),
|
||
},
|
||
{
|
||
key: "recordingEnabled",
|
||
header: "Aufnahme",
|
||
hideOnMobile: "md",
|
||
render: (row) =>
|
||
canWrite ? (
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
void handleToggle(row, "recordingEnabled");
|
||
}}
|
||
className="cursor-pointer"
|
||
>
|
||
<Badge tone={row.recordingEnabled ? "indigo" : "gray"} hover>
|
||
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
|
||
</Badge>
|
||
</button>
|
||
) : (
|
||
<Badge tone={row.recordingEnabled ? "indigo" : "gray"}>
|
||
{row.recordingEnabled ? "Aktiv" : "Inaktiv"}
|
||
</Badge>
|
||
),
|
||
},
|
||
{
|
||
key: "recordingStorageId",
|
||
header: "Speicher",
|
||
hideOnMobile: "lg",
|
||
render: (row) => {
|
||
const storage = storageMap[row.recordingStorageId];
|
||
if (storage) {
|
||
return (
|
||
<span className="text-sm text-gray-700 dark:text-gray-300">
|
||
{storage.displayName || storage.name}
|
||
</span>
|
||
);
|
||
}
|
||
if (row.recordingStorageId) {
|
||
return (
|
||
<span className="font-mono text-xs text-gray-400">
|
||
{row.recordingStorageId}
|
||
</span>
|
||
);
|
||
}
|
||
return <span className="text-gray-400">—</span>;
|
||
},
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="space-y-6 p-4 sm:p-6 lg:p-8">
|
||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||
<div>
|
||
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
|
||
Milestone-Geräte
|
||
</h1>
|
||
{!isLoading && !error && (
|
||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||
{cameraCount} Kamera{cameraCount !== 1 ? "s" : ""},{" "}
|
||
{micCount} Mikrofon{micCount !== 1 ? "e" : ""}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="relative max-w-sm">
|
||
<MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-gray-400" />
|
||
<input
|
||
type="search"
|
||
placeholder="Suchen…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500"
|
||
/>
|
||
</div>
|
||
|
||
{isLoading && (
|
||
<div className="flex justify-center py-16">
|
||
<LoadingSpinner size="lg" label="Geräte werden geladen…" center />
|
||
</div>
|
||
)}
|
||
|
||
{!isLoading && error && (
|
||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{!isLoading && !error && filtered.length === 0 && (
|
||
<EmptyState
|
||
icon={VideoCameraIcon}
|
||
title={search ? "Keine Treffer" : "Keine Geräte gefunden"}
|
||
description={
|
||
search
|
||
? "Versuche einen anderen Suchbegriff."
|
||
: "Es wurden keine Kameras oder Mikrofone in Milestone gefunden."
|
||
}
|
||
className="py-16"
|
||
/>
|
||
)}
|
||
|
||
{!isLoading && !error && filtered.length > 0 && (
|
||
<Tables
|
||
rows={filtered}
|
||
columns={columns}
|
||
getRowId={(row) => row.id}
|
||
variant="border"
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|