mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
feat: full CRUD for cameras, kiosks, and labels in admin UI
- Camera edit page: rename, update RTSP/ONVIF, enable/disable, attach/detach labels, view streams, delete - Kiosk edit page: rename, enable/disable, attach/detach labels with role (consume/operate), delete - Labels page: create with color picker, delete - Camera/kiosk names now link to edit pages - Repository: added updateCamera, deleteCamera, updateKiosk, deleteKiosk, detachCameraLabel, detachKioskLabel, deleteLabel, updateLabel, cameraLabelIds
This commit is contained in:
parent
6dfed8548c
commit
7b4a11c182
3 changed files with 508 additions and 14 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* Admin page routes — overview, cameras, kiosks, labels, etc.
|
* Admin page routes — overview, cameras, kiosks, labels, etc.
|
||||||
*/
|
*/
|
||||||
import { type H3, readBody } from "h3";
|
import { type H3, readBody, getRouterParam } from "h3";
|
||||||
import { htmlPage } from "./html-response.js";
|
import { htmlPage } from "./html-response.js";
|
||||||
import type { AdminDeps } from "./index.js";
|
import type { AdminDeps } from "./index.js";
|
||||||
import { confirmPairing } from "../../shared/pairing.js";
|
import { confirmPairing } from "../../shared/pairing.js";
|
||||||
|
|
@ -9,7 +9,10 @@ import {
|
||||||
OverviewPage,
|
OverviewPage,
|
||||||
CamerasPage,
|
CamerasPage,
|
||||||
CameraNewPage,
|
CameraNewPage,
|
||||||
|
CameraEditPage,
|
||||||
KiosksPage,
|
KiosksPage,
|
||||||
|
KioskEditPage,
|
||||||
|
LabelsPage,
|
||||||
SimpleListPage,
|
SimpleListPage,
|
||||||
} from "../../web-templates/admin-pages.js";
|
} from "../../web-templates/admin-pages.js";
|
||||||
|
|
||||||
|
|
@ -205,17 +208,149 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
|
|
||||||
app.get("/admin/labels", (event) => {
|
app.get("/admin/labels", (event) => {
|
||||||
const user = event.context.user!;
|
const user = event.context.user!;
|
||||||
const labels = deps.repo.listLabels();
|
return htmlPage(LabelsPage({ user: user.username, labels: deps.repo.listLabels() }));
|
||||||
return htmlPage(SimpleListPage({
|
});
|
||||||
|
|
||||||
|
app.post("/admin/labels/new", async (event) => {
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const name = (body?.["name"] ?? "").trim().toLowerCase();
|
||||||
|
const color = body?.["color"] ?? null;
|
||||||
|
if (!name || !/^[a-z0-9][a-z0-9_-]*$/.test(name)) {
|
||||||
|
return htmlPage(LabelsPage({
|
||||||
|
user: event.context.user!.username,
|
||||||
|
labels: deps.repo.listLabels(),
|
||||||
|
error: "Label name must start with letter/digit and contain only lowercase, digits, hyphens, underscores.",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
deps.repo.createLabel({ name, color });
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/labels" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/labels/:id/delete", (event) => {
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
deps.repo.deleteLabel(id);
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/labels" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Camera edit/delete/labels --------------------------------------------
|
||||||
|
|
||||||
|
app.get("/admin/cameras/:id", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
const camera = deps.repo.getCameraById(id);
|
||||||
|
if (!camera) return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
|
||||||
|
return htmlPage(CameraEditPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
pageTitle: "Labels",
|
camera,
|
||||||
description: "Labels route cameras, layouts, and kiosks to each other across sites.",
|
labels: deps.repo.cameraLabelIds(id),
|
||||||
activeNav: "labels",
|
allLabels: deps.repo.listLabels(),
|
||||||
items: labels.map((l) => ({
|
streams: deps.repo.listCameraStreams(id),
|
||||||
name: l.name,
|
|
||||||
detail: l.description ?? "",
|
|
||||||
badge: l.color ?? undefined,
|
|
||||||
})),
|
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post("/admin/cameras/:id", async (event) => {
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
deps.repo.updateCamera(id, {
|
||||||
|
name: body?.["name"],
|
||||||
|
rtsp_url: body?.["rtsp_url"] || null,
|
||||||
|
onvif_host: body?.["onvif_host"] || null,
|
||||||
|
onvif_port: body?.["onvif_port"] ? Number(body["onvif_port"]) : null,
|
||||||
|
onvif_username: body?.["onvif_username"] || null,
|
||||||
|
onvif_password: body?.["onvif_password"] || undefined,
|
||||||
|
enabled: body?.["enabled"] === "1",
|
||||||
|
} as any);
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/cameras/:id/labels", async (event) => {
|
||||||
|
const camId = Number(getRouterParam(event, "id"));
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase();
|
||||||
|
let labelId = body?.["label_id"] ? Number(body["label_id"]) : null;
|
||||||
|
|
||||||
|
if (newLabel) {
|
||||||
|
const label = deps.repo.ensureLabel(newLabel);
|
||||||
|
labelId = label.id;
|
||||||
|
}
|
||||||
|
if (labelId) {
|
||||||
|
deps.repo.attachCameraLabel(camId, labelId);
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${camId}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/cameras/:id/labels/remove", async (event) => {
|
||||||
|
const camId = Number(getRouterParam(event, "id"));
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const labelId = Number(body?.["label_id"]);
|
||||||
|
deps.repo.detachCameraLabel(camId, labelId);
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${camId}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/cameras/:id/delete", (event) => {
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
deps.repo.deleteCamera(id);
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Kiosk edit/delete/labels ---------------------------------------------
|
||||||
|
|
||||||
|
app.get("/admin/kiosks/:id", (event) => {
|
||||||
|
const user = event.context.user!;
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
const kiosk = deps.repo.getKioskById(id);
|
||||||
|
if (!kiosk) return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||||
|
const kioskLabels = deps.repo.listKioskLabels(id).map((kl) => ({
|
||||||
|
label_id: kl.label_id,
|
||||||
|
name: kl.name,
|
||||||
|
role: kl.role,
|
||||||
|
}));
|
||||||
|
return htmlPage(KioskEditPage({
|
||||||
|
user: user.username,
|
||||||
|
kiosk,
|
||||||
|
labels: kioskLabels,
|
||||||
|
allLabels: deps.repo.listLabels(),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/kiosks/:id", async (event) => {
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
deps.repo.updateKiosk(id, {
|
||||||
|
name: body?.["name"],
|
||||||
|
enabled: body?.["enabled"] === "1",
|
||||||
|
} as any);
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/kiosks/:id/labels", async (event) => {
|
||||||
|
const kioskId = Number(getRouterParam(event, "id"));
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const newLabel = (body?.["new_label"] ?? "").trim().toLowerCase();
|
||||||
|
const role = (body?.["role"] ?? "consume") as "consume" | "operate";
|
||||||
|
let labelId = body?.["label_id"] ? Number(body["label_id"]) : null;
|
||||||
|
|
||||||
|
if (newLabel) {
|
||||||
|
const label = deps.repo.ensureLabel(newLabel);
|
||||||
|
labelId = label.id;
|
||||||
|
}
|
||||||
|
if (labelId) {
|
||||||
|
deps.repo.attachKioskLabel(kioskId, labelId, role);
|
||||||
|
}
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/kiosks/:id/labels/remove", async (event) => {
|
||||||
|
const kioskId = Number(getRouterParam(event, "id"));
|
||||||
|
const body = await readBody<Record<string, string>>(event);
|
||||||
|
const labelId = Number(body?.["label_id"]);
|
||||||
|
deps.repo.detachKioskLabel(kioskId, labelId);
|
||||||
|
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/admin/kiosks/:id/delete", (event) => {
|
||||||
|
const id = Number(getRouterParam(event, "id"));
|
||||||
|
deps.repo.deleteKiosk(id);
|
||||||
|
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -800,4 +800,87 @@ export class Repository {
|
||||||
).all(cameraId);
|
).all(cameraId);
|
||||||
return rs.map((r) => String((r as Record<string, unknown>)["name"]));
|
return rs.map((r) => String((r as Record<string, unknown>)["name"]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cameraLabelIds(cameraId: number): Array<{ label_id: number; name: string }> {
|
||||||
|
const rs = this.prep(
|
||||||
|
`SELECT cl.label_id, l.name FROM camera_labels cl
|
||||||
|
JOIN labels l ON l.id = cl.label_id
|
||||||
|
WHERE cl.camera_id = ?`,
|
||||||
|
).all(cameraId);
|
||||||
|
return rs.map((r) => {
|
||||||
|
const row = r as Record<string, unknown>;
|
||||||
|
return { label_id: Number(row["label_id"]), name: String(row["name"]) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCamera(id: number, patch: Partial<Camera>): void {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (k === "id" || k === "created_at") continue;
|
||||||
|
sets.push(`${k} = ?`);
|
||||||
|
vals.push(v === undefined ? null : v);
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return;
|
||||||
|
vals.push(id);
|
||||||
|
this.db.prepare(`UPDATE cameras SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]);
|
||||||
|
void this.notify("cameras", "update", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteCamera(id: number): void {
|
||||||
|
this.db.prepare(`DELETE FROM camera_labels WHERE camera_id = ?`).run(id);
|
||||||
|
this.db.prepare(`DELETE FROM camera_streams WHERE camera_id = ?`).run(id);
|
||||||
|
this.db.prepare(`DELETE FROM layout_cells WHERE camera_id = ?`).run(id);
|
||||||
|
this.db.prepare(`DELETE FROM cameras WHERE id = ?`).run(id);
|
||||||
|
void this.notify("cameras", "delete", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateKiosk(id: number, patch: Partial<Kiosk>): void {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (k === "id" || k === "created_at" || k === "paired_at") continue;
|
||||||
|
sets.push(`${k} = ?`);
|
||||||
|
vals.push(v === undefined ? null : v);
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return;
|
||||||
|
vals.push(id);
|
||||||
|
this.db.prepare(`UPDATE kiosks SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]);
|
||||||
|
void this.notify("kiosks", "update", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteKiosk(id: number): void {
|
||||||
|
this.db.prepare(`DELETE FROM kiosk_labels WHERE kiosk_id = ?`).run(id);
|
||||||
|
this.db.prepare(`DELETE FROM kiosks WHERE id = ?`).run(id);
|
||||||
|
void this.notify("kiosks", "delete", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
detachCameraLabel(cameraId: number, labelId: number): void {
|
||||||
|
this.db.prepare(`DELETE FROM camera_labels WHERE camera_id = ? AND label_id = ?`).run(cameraId, labelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
detachKioskLabel(kioskId: number, labelId: number): void {
|
||||||
|
this.db.prepare(`DELETE FROM kiosk_labels WHERE kiosk_id = ? AND label_id = ?`).run(kioskId, labelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteLabel(id: number): void {
|
||||||
|
this.db.prepare(`DELETE FROM camera_labels WHERE label_id = ?`).run(id);
|
||||||
|
this.db.prepare(`DELETE FROM kiosk_labels WHERE label_id = ?`).run(id);
|
||||||
|
this.db.prepare(`DELETE FROM layout_labels WHERE label_id = ?`).run(id);
|
||||||
|
this.db.prepare(`DELETE FROM labels WHERE id = ?`).run(id);
|
||||||
|
void this.notify("labels", "delete", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLabel(id: number, patch: { name?: string; description?: string | null; color?: string | null }): void {
|
||||||
|
const sets: string[] = [];
|
||||||
|
const vals: unknown[] = [];
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
sets.push(`${k} = ?`);
|
||||||
|
vals.push(v === undefined ? null : v);
|
||||||
|
}
|
||||||
|
if (sets.length === 0) return;
|
||||||
|
vals.push(id);
|
||||||
|
this.db.prepare(`UPDATE labels SET ${sets.join(", ")} WHERE id = ?`).run(...vals as any[]);
|
||||||
|
void this.notify("labels", "update", id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
import { js } from "jsx-htmx";
|
import { js } from "jsx-htmx";
|
||||||
import { Layout } from "./layout.js";
|
import { Layout } from "./layout.js";
|
||||||
import type { Camera, Kiosk, PairingCode, EventLog } from "../shared/types.js";
|
import type { Camera, Kiosk, Label, PairingCode, EventLog } from "../shared/types.js";
|
||||||
|
|
||||||
// ---- Overview ---------------------------------------------------------------
|
// ---- Overview ---------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -122,7 +122,7 @@ export function CamerasPage(props: CamerasProps) {
|
||||||
) : (
|
) : (
|
||||||
props.cameras.map((cam) => (
|
props.cameras.map((cam) => (
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{cam.name}</strong></td>
|
<td><a href={`/admin/cameras/${cam.id}`}><strong>{cam.name}</strong></a></td>
|
||||||
<td><span class="badge badge-blue">{cam.type.toUpperCase()}</span></td>
|
<td><span class="badge badge-blue">{cam.type.toUpperCase()}</span></td>
|
||||||
<td>{String(props.streamCounts.get(cam.id) ?? 0)}</td>
|
<td>{String(props.streamCounts.get(cam.id) ?? 0)}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -273,7 +273,7 @@ export function KiosksPage(props: KiosksProps) {
|
||||||
) : (
|
) : (
|
||||||
props.kiosks.map((k) => (
|
props.kiosks.map((k) => (
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong>{k.name}</strong></td>
|
<td><a href={`/admin/kiosks/${k.id}`}><strong>{k.name}</strong></a></td>
|
||||||
<td style="font-size:0.85rem">{k.hardware_model ?? "—"}</td>
|
<td style="font-size:0.85rem">{k.hardware_model ?? "—"}</td>
|
||||||
<td style="font-size:0.85rem; white-space:nowrap">{k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</td>
|
<td style="font-size:0.85rem; white-space:nowrap">{k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -524,6 +524,282 @@ export function SimpleListPage(props: SimpleListProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Camera Edit ------------------------------------------------------------
|
||||||
|
|
||||||
|
interface CameraEditProps {
|
||||||
|
user: string;
|
||||||
|
camera: Camera;
|
||||||
|
labels: Array<{ label_id: number; name: string }>;
|
||||||
|
allLabels: Label[];
|
||||||
|
streams: Array<{ id: number; role: string; name: string; rtsp_uri: string }>;
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CameraEditPage(props: CameraEditProps) {
|
||||||
|
const cam = props.camera;
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title={`Camera: ${cam.name}`}
|
||||||
|
user={props.user}
|
||||||
|
activeNav="cameras"
|
||||||
|
flash={
|
||||||
|
props.error ? { type: "error", message: props.error }
|
||||||
|
: props.success ? { type: "success", message: props.success }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style="max-width:700px">
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Edit Camera</h2>
|
||||||
|
<form method="post" action={`/admin/cameras/${cam.id}`}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" type="text" class="form-input" value={cam.name} required maxlength="128" />
|
||||||
|
</div>
|
||||||
|
{cam.type === "rtsp" && (
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="rtsp_url">RTSP URL</label>
|
||||||
|
<input id="rtsp_url" name="rtsp_url" type="text" class="form-input" value={cam.rtsp_url ?? ""} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cam.type === "onvif" && (
|
||||||
|
<div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="onvif_host">ONVIF Host</label>
|
||||||
|
<input id="onvif_host" name="onvif_host" type="text" class="form-input" value={cam.onvif_host ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="onvif_port">Port</label>
|
||||||
|
<input id="onvif_port" name="onvif_port" type="number" class="form-input" value={String(cam.onvif_port ?? 80)} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="onvif_username">Username</label>
|
||||||
|
<input id="onvif_username" name="onvif_username" type="text" class="form-input" value={cam.onvif_username ?? ""} />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="onvif_password">Password (leave blank to keep)</label>
|
||||||
|
<input id="onvif_password" name="onvif_password" type="password" class="form-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="enabled" value="1" checked={cam.enabled} />
|
||||||
|
{" "}Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
||||||
|
{props.labels.length > 0 ? (
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:0.5rem; margin-bottom:1rem">
|
||||||
|
{props.labels.map((l) => (
|
||||||
|
<form method="post" action={`/admin/cameras/${cam.id}/labels/remove`} style="display:inline">
|
||||||
|
<input type="hidden" name="label_id" value={String(l.label_id)} />
|
||||||
|
<button type="submit" class="badge badge-blue" style="cursor:pointer; border:none" title="Click to remove">
|
||||||
|
{l.name} ×
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style="color:#999; margin-bottom:1rem">No labels attached</p>
|
||||||
|
)}
|
||||||
|
<form method="post" action={`/admin/cameras/${cam.id}/labels`} style="display:flex; gap:0.5rem">
|
||||||
|
<select name="label_id" class="form-input" style="flex:1">
|
||||||
|
{props.allLabels
|
||||||
|
.filter((al) => !props.labels.some((l) => l.label_id === al.id))
|
||||||
|
.map((al) => <option value={String(al.id)}>{al.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary">Add</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action={`/admin/cameras/${cam.id}/labels`} style="display:flex; gap:0.5rem; margin-top:0.5rem">
|
||||||
|
<input name="new_label" type="text" class="form-input" placeholder="Or create new label..." style="flex:1" />
|
||||||
|
<button type="submit" class="btn btn-ghost">Create & Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Streams</h2>
|
||||||
|
{props.streams.length > 0 ? (
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Role</th><th>Name</th><th>URI</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{props.streams.map((s) => (
|
||||||
|
<tr>
|
||||||
|
<td><span class="badge badge-gray">{s.role}</span></td>
|
||||||
|
<td>{s.name}</td>
|
||||||
|
<td style="font-size:0.8rem; word-break:break-all">{s.rtsp_uri}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style="color:#999">No streams configured</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action={`/admin/cameras/${cam.id}/delete`} style="margin-top:1rem">
|
||||||
|
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this camera?')"}}>Delete Camera</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Kiosk Edit -------------------------------------------------------------
|
||||||
|
|
||||||
|
interface KioskEditProps {
|
||||||
|
user: string;
|
||||||
|
kiosk: Kiosk;
|
||||||
|
labels: Array<{ label_id: number; name: string; role: string }>;
|
||||||
|
allLabels: Label[];
|
||||||
|
error?: string;
|
||||||
|
success?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KioskEditPage(props: KioskEditProps) {
|
||||||
|
const k = props.kiosk;
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title={`Kiosk: ${k.name}`}
|
||||||
|
user={props.user}
|
||||||
|
activeNav="kiosks"
|
||||||
|
flash={
|
||||||
|
props.error ? { type: "error", message: props.error }
|
||||||
|
: props.success ? { type: "success", message: props.success }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div style="max-width:700px">
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Edit Kiosk</h2>
|
||||||
|
<form method="post" action={`/admin/kiosks/${k.id}`}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" type="text" class="form-input" value={k.name} required />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="enabled" value="1" checked={k.enabled} />
|
||||||
|
{" "}Enabled
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary">Save</button>
|
||||||
|
<a href="/admin/kiosks" class="btn btn-ghost" style="margin-left:0.5rem">Back</a>
|
||||||
|
</form>
|
||||||
|
<div style="margin-top:1rem; color:#666; font-size:0.85rem">
|
||||||
|
<div>Hardware: {k.hardware_model ?? "—"}</div>
|
||||||
|
<div>Paired: {k.paired_at ? formatTime(k.paired_at) : "—"}</div>
|
||||||
|
<div>Last seen: {k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
|
||||||
|
{props.labels.length > 0 ? (
|
||||||
|
<div style="display:flex; flex-wrap:wrap; gap:0.5rem; margin-bottom:1rem">
|
||||||
|
{props.labels.map((l) => (
|
||||||
|
<form method="post" action={`/admin/kiosks/${k.id}/labels/remove`} style="display:inline">
|
||||||
|
<input type="hidden" name="label_id" value={String(l.label_id)} />
|
||||||
|
<button type="submit" class="badge badge-blue" style="cursor:pointer; border:none" title="Click to remove">
|
||||||
|
{l.name} ({l.role}) ×
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style="color:#999; margin-bottom:1rem">No labels attached</p>
|
||||||
|
)}
|
||||||
|
<form method="post" action={`/admin/kiosks/${k.id}/labels`} style="display:flex; gap:0.5rem">
|
||||||
|
<select name="label_id" class="form-input" style="flex:1">
|
||||||
|
{props.allLabels
|
||||||
|
.filter((al) => !props.labels.some((l) => l.label_id === al.id))
|
||||||
|
.map((al) => <option value={String(al.id)}>{al.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select name="role" class="form-input" style="width:120px">
|
||||||
|
<option value="consume">consume</option>
|
||||||
|
<option value="operate">operate</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary">Add</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action={`/admin/kiosks/${k.id}/labels`} style="display:flex; gap:0.5rem; margin-top:0.5rem">
|
||||||
|
<input name="new_label" type="text" class="form-input" placeholder="Or create new label..." style="flex:1" />
|
||||||
|
<select name="role" class="form-input" style="width:120px">
|
||||||
|
<option value="consume">consume</option>
|
||||||
|
<option value="operate">operate</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-ghost">Create & Add</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action={`/admin/kiosks/${k.id}/delete`} style="margin-top:1rem">
|
||||||
|
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this kiosk?')"}}>Delete Kiosk</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Labels Management ------------------------------------------------------
|
||||||
|
|
||||||
|
interface LabelsPageProps {
|
||||||
|
user: string;
|
||||||
|
labels: Label[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LabelsPage(props: LabelsPageProps) {
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
title="Labels"
|
||||||
|
user={props.user}
|
||||||
|
activeNav="labels"
|
||||||
|
flash={props.error ? { type: "error", message: props.error } : undefined}
|
||||||
|
>
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">All Labels</h2>
|
||||||
|
</div>
|
||||||
|
<div style="max-width:500px; margin-bottom:1.5rem">
|
||||||
|
<form method="post" action="/admin/labels/new" style="display:flex; gap:0.5rem">
|
||||||
|
<input name="name" type="text" class="form-input" placeholder="New label name" required pattern="[a-z0-9][a-z0-9_-]*" style="flex:1" />
|
||||||
|
<input name="color" type="color" value="#2563eb" style="width:40px; height:38px; border:1px solid #d0d0d0; border-radius:4px" />
|
||||||
|
<button type="submit" class="btn btn-primary">Create</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Name</th><th>Color</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{props.labels.length === 0 ? (
|
||||||
|
<tr><td colspan="3" style="text-align:center; color:#999; padding:2rem">No labels</td></tr>
|
||||||
|
) : (
|
||||||
|
props.labels.map((l) => (
|
||||||
|
<tr>
|
||||||
|
<td><strong>{l.name}</strong></td>
|
||||||
|
<td>{l.color ? <span class="badge" style={`background-color:${l.color}; color:#fff`}>{l.color}</span> : "—"}</td>
|
||||||
|
<td>
|
||||||
|
<form method="post" action={`/admin/labels/${l.id}/delete`} style="display:inline">
|
||||||
|
<button type="submit" class="btn btn-sm btn-danger" {...{"onclick": "return confirm('Delete label?')"}}>Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Helpers ----------------------------------------------------------------
|
// ---- Helpers ----------------------------------------------------------------
|
||||||
|
|
||||||
function formatTime(iso: string): string {
|
function formatTime(iso: string): string {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue