first commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
|
||||
export default function Home() {
|
||||
return <Navigate to="/live" replace />;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import CameraTile from "@/components/CameraTile";
|
||||
import { usePathsStatus } from "@/hooks/usePathsStatus";
|
||||
import { useConfigStore } from "@/stores/configStore";
|
||||
import { getMediamtxWebrtcBaseUrl } from "@/utils/api";
|
||||
|
||||
type Grid = 1 | 4 | 9;
|
||||
|
||||
export default function Live() {
|
||||
const { config, isLoading, error, load } = useConfigStore();
|
||||
const { readyByName } = usePathsStatus(5000);
|
||||
const [grid, setGrid] = useState<Grid>(4);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) void load();
|
||||
}, [config, load]);
|
||||
|
||||
const cams = useMemo(() => {
|
||||
const list = config?.cameras ?? [];
|
||||
return list.slice(0, grid);
|
||||
}, [config, grid]);
|
||||
|
||||
const webrtcBaseUrl = useMemo(() => {
|
||||
return getMediamtxWebrtcBaseUrl(config?.mediamtx_webrtc_url);
|
||||
}, [config?.mediamtx_webrtc_url]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Live View</div>
|
||||
<div className="text-xs text-zinc-400">WebRTC grid (auto reconnect + lazy load)</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-zinc-400">Grid</label>
|
||||
<select
|
||||
value={grid}
|
||||
onChange={(e) => setGrid(Number(e.target.value) as Grid)}
|
||||
className="h-9 rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100"
|
||||
>
|
||||
<option value={1}>1</option>
|
||||
<option value={4}>4</option>
|
||||
<option value={9}>9</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-rose-900/60 bg-rose-950/30 px-4 py-3 text-sm text-rose-200">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isLoading && !config ? (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 px-4 py-3 text-sm text-zinc-300">
|
||||
Đang tải cấu hình...
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{config && config.cameras.length === 0 ? (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/30 px-4 py-3 text-sm text-zinc-300">
|
||||
Chưa có camera. Thêm camera trong Settings.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={[
|
||||
"grid gap-3",
|
||||
grid === 1 ? "grid-cols-1" : "grid-cols-1 md:grid-cols-2",
|
||||
grid === 9 ? "lg:grid-cols-3" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{cams.map((c) => (
|
||||
<CameraTile
|
||||
key={c.name}
|
||||
name={c.name}
|
||||
webrtcBaseUrl={webrtcBaseUrl}
|
||||
isOnline={readyByName.get(c.name) ?? false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { Download, Play } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useConfigStore } from "@/stores/configStore";
|
||||
import { apiJson } from "@/utils/api";
|
||||
import type { RecordingItem } from "@/types/api";
|
||||
|
||||
function toLocalDateInputValue(d: Date) {
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
export default function Playback() {
|
||||
const { config, isLoading, error, load } = useConfigStore();
|
||||
const [camera, setCamera] = useState<string>("");
|
||||
const [date, setDate] = useState<string>(() => toLocalDateInputValue(new Date()));
|
||||
const [items, setItems] = useState<RecordingItem[]>([]);
|
||||
const [selected, setSelected] = useState<RecordingItem | null>(null);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [listLoading, setListLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) void load();
|
||||
}, [config, load]);
|
||||
|
||||
useEffect(() => {
|
||||
const first = config?.cameras?.[0]?.name;
|
||||
if (first && !camera) setCamera(first);
|
||||
}, [config?.cameras, camera]);
|
||||
|
||||
const canQuery = useMemo(() => Boolean(camera), [camera]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const run = async () => {
|
||||
if (!canQuery) return;
|
||||
setListLoading(true);
|
||||
setListError(null);
|
||||
try {
|
||||
const q = new URLSearchParams();
|
||||
q.set("camera", camera);
|
||||
if (date) q.set("date", date);
|
||||
const res = await apiJson<RecordingItem[]>(`/recordings?${q.toString()}`, {
|
||||
method: "GET",
|
||||
});
|
||||
if (!alive) return;
|
||||
setItems(res);
|
||||
setSelected((prev) => {
|
||||
if (prev && res.some((x) => x.filename === prev.filename)) return prev;
|
||||
return res[0] ?? null;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!alive) return;
|
||||
setItems([]);
|
||||
setSelected(null);
|
||||
if (typeof e === "object" && e && "status" in e) {
|
||||
setListError(`http_${(e as { status: number }).status}`);
|
||||
} else {
|
||||
setListError("failed_to_load_recordings");
|
||||
}
|
||||
} finally {
|
||||
if (alive) setListLoading(false);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [camera, date, canQuery]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Playback</div>
|
||||
<div className="text-xs text-zinc-400">Chọn camera + ngày, phát file fMP4</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-rose-900/60 bg-rose-950/30 px-4 py-3 text-sm text-rose-200">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-[320px_1fr]">
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/20 p-3">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400">Camera</label>
|
||||
<select
|
||||
value={camera}
|
||||
onChange={(e) => setCamera(e.target.value)}
|
||||
disabled={isLoading && !config}
|
||||
className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100"
|
||||
>
|
||||
{(config?.cameras ?? []).map((c) => (
|
||||
<option key={c.name} value={c.name}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400">Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-zinc-800 pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium text-zinc-200">Files</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{listLoading ? "loading" : `${items.length}`}
|
||||
</div>
|
||||
</div>
|
||||
{listError ? (
|
||||
<div className="mt-2 text-xs text-rose-300">{listError}</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 max-h-[420px] space-y-1 overflow-auto pr-1">
|
||||
{items.map((it) => {
|
||||
const dt = new Date(it.timestamp);
|
||||
const t = dt.toLocaleTimeString();
|
||||
const active = selected?.filename === it.filename;
|
||||
return (
|
||||
<div
|
||||
key={it.filename}
|
||||
className={[
|
||||
"flex items-center justify-between gap-2 rounded-md border px-2 py-2",
|
||||
active
|
||||
? "border-zinc-700 bg-zinc-950/40"
|
||||
: "border-zinc-800 bg-zinc-950/10 hover:bg-zinc-950/30",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-xs text-zinc-100">{t}</div>
|
||||
<div className="truncate text-[11px] text-zinc-500">{it.filename}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(it)}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-950/40 text-zinc-200 transition hover:bg-zinc-900"
|
||||
title="Play"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
</button>
|
||||
<a
|
||||
href={it.url}
|
||||
download
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-950/40 text-zinc-200 transition hover:bg-zinc-900"
|
||||
title="Download"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!listLoading && items.length === 0 ? (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/10 px-3 py-2 text-xs text-zinc-400">
|
||||
Không có file.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/20 p-3">
|
||||
<div className="text-xs font-medium text-zinc-200">Player</div>
|
||||
<div className="mt-2 aspect-video w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
|
||||
{selected ? (
|
||||
<video
|
||||
key={selected.filename}
|
||||
className="h-full w-full"
|
||||
controls
|
||||
playsInline
|
||||
src={selected.url}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm text-zinc-400">
|
||||
Chọn 1 file để phát
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useConfigStore } from "@/stores/configStore";
|
||||
|
||||
export default function Settings() {
|
||||
const {
|
||||
config,
|
||||
isLoading,
|
||||
error,
|
||||
load,
|
||||
addCamera,
|
||||
deleteCamera,
|
||||
setSchedulerEnabled,
|
||||
updateSchedule,
|
||||
} = useConfigStore();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [rtspUrl, setRtspUrl] = useState("");
|
||||
const schedule = config?.schedule;
|
||||
const [weekdaysFrom, setWeekdaysFrom] = useState("18:00");
|
||||
const [weekdaysTo, setWeekdaysTo] = useState("08:00");
|
||||
const [weekendAllDay, setWeekendAllDay] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config) void load();
|
||||
}, [config, load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!schedule) return;
|
||||
setWeekdaysFrom(schedule.weekdays_from);
|
||||
setWeekdaysTo(schedule.weekdays_to);
|
||||
setWeekendAllDay(schedule.weekend_all_day);
|
||||
}, [schedule]);
|
||||
|
||||
const canAdd = useMemo(
|
||||
() => name.trim().length > 0 && rtspUrl.trim().length > 0,
|
||||
[name, rtspUrl]
|
||||
);
|
||||
|
||||
const onAdd = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canAdd) return;
|
||||
await addCamera({ name: name.trim(), rtsp_url: rtspUrl.trim() });
|
||||
setName("");
|
||||
setRtspUrl("");
|
||||
};
|
||||
|
||||
const onSaveSchedule = async () => {
|
||||
await updateSchedule({
|
||||
weekdays_from: weekdaysFrom,
|
||||
weekdays_to: weekdaysTo,
|
||||
weekend_all_day: weekendAllDay,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Settings</div>
|
||||
<div className="text-xs text-zinc-400">Camera management + recording schedule</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-rose-900/60 bg-rose-950/30 px-4 py-3 text-sm text-rose-200">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/20 p-4">
|
||||
<div className="text-xs font-semibold text-zinc-200">Cameras</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">
|
||||
Đồng bộ paths lên MediaMTX thông qua Control API
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{(config?.cameras ?? []).map((c) => (
|
||||
<div
|
||||
key={c.name}
|
||||
className="flex items-center justify-between gap-3 rounded-md border border-zinc-800 bg-zinc-950/10 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm text-zinc-100">{c.name}</div>
|
||||
<div className="truncate text-xs text-zinc-500">{c.rtsp_url}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void deleteCamera(c.name)}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-zinc-700 bg-zinc-950/40 text-zinc-200 transition hover:bg-zinc-900"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{(config?.cameras ?? []).length === 0 ? (
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/10 px-3 py-2 text-xs text-zinc-400">
|
||||
Chưa có camera.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => void onAdd(e)} className="mt-4 space-y-2">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="cam1"
|
||||
className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400">RTSP URL</label>
|
||||
<input
|
||||
value={rtspUrl}
|
||||
onChange={(e) => setRtspUrl(e.target.value)}
|
||||
placeholder="rtsp://user:pass@ip:554/stream"
|
||||
className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canAdd || isLoading}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-950/40 px-3 py-2 text-sm text-zinc-200 transition hover:bg-zinc-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Camera
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/20 p-4">
|
||||
<div className="text-xs font-semibold text-zinc-200">Recording Schedule</div>
|
||||
<div className="mt-1 text-xs text-zinc-400">Backend sẽ bật/tắt record mỗi 60 giây</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(schedule?.enabled)}
|
||||
onChange={(e) => void setSchedulerEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-zinc-700 bg-zinc-950"
|
||||
/>
|
||||
Enable scheduler
|
||||
</label>
|
||||
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400">Weekdays from</label>
|
||||
<input
|
||||
type="time"
|
||||
value={weekdaysFrom}
|
||||
onChange={(e) => setWeekdaysFrom(e.target.value)}
|
||||
className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-zinc-400">Weekdays to</label>
|
||||
<input
|
||||
type="time"
|
||||
value={weekdaysTo}
|
||||
onChange={(e) => setWeekdaysTo(e.target.value)}
|
||||
className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={weekendAllDay}
|
||||
onChange={(e) => setWeekendAllDay(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-zinc-700 bg-zinc-950"
|
||||
/>
|
||||
Weekend: record all day
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onSaveSchedule()}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-950/40 px-3 py-2 text-sm text-zinc-200 transition hover:bg-zinc-900 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/10 px-3 py-2 text-xs text-zinc-400">
|
||||
MediaMTX API: <span className="text-zinc-200">{config?.mediamtx_api_url ?? "-"}</span>
|
||||
<br />
|
||||
WebRTC: <span className="text-zinc-200">{config?.mediamtx_webrtc_url ?? "-"}</span>
|
||||
<br />
|
||||
Recordings dir: <span className="text-zinc-200">{config?.recordings_dir ?? "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user