teg/frontend/src/pages/operations/milestone-cameras/MilestoneDevicesPage.tsx
2026-06-11 15:02:19 +02:00

311 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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