Skip to content

Commit d890745

Browse files
committed
feat: connect-camera
1 parent b3ef736 commit d890745

File tree

5 files changed

+384
-272
lines changed

5 files changed

+384
-272
lines changed

client/my-app/src/app/components/Cameras/CameraCard.tsx

Lines changed: 80 additions & 206 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,37 @@
11
"use client";
22

33
import Image from "next/image";
4-
import {
5-
MapPin,
6-
Camera as CameraIcon,
7-
Move,
8-
Scan,
9-
Thermometer,
10-
Activity,
11-
} from "lucide-react";
12-
import BottomCameraCard from "../Utilities/ButtonCameraCard";
4+
import { MapPin, Camera as CameraIcon, Move, Scan, Thermometer, Activity } from "lucide-react";
5+
import { useEffect, useState } from "react";
6+
import WhepPlayer from "../../components/WhepPlayer";
7+
import BottomCameraCard from "@/app/components/Utilities/ButtonCameraCard";
138

149
export type Camera = {
1510
id: number;
1611
name: string;
17-
address: string;
18-
type: string;
19-
status: boolean; // true => Active, false => Inactive
20-
health: number | string; // รองรับทั้งตัวเลข (%) หรือข้อความ (Good, Critical, ...)
21-
location: {
22-
id: number;
23-
name: string;
24-
};
12+
address: string; // rtsp://.../city-traffic
13+
type: string; // fixed | ptz | panoramic | thermal
14+
status: boolean; // true => Active, false => Inactive
15+
health: number | string;
16+
location: { id: number; name: string };
2517
last_maintenance_date: string;
2618
last_maintenance_time: string;
2719
};
2820

29-
// ---------- helpers ----------
30-
function toBool(raw: unknown): boolean {
31-
if (typeof raw === "boolean") return raw;
32-
if (typeof raw === "number") return raw === 1;
33-
if (typeof raw === "string") {
34-
const v = raw.trim().toLowerCase();
35-
return v === "true" || v === "1" || v === "active";
36-
}
37-
return false;
38-
}
39-
40-
function getStatusBool(cam: any): boolean {
41-
// ใช้ cam.status (boolean) เป็นหลัก แต่เผื่อมี cam_status จาก API เก่า ๆ
42-
const raw = cam?.status ?? cam?.cam_status ?? false;
43-
return toBool(raw);
44-
}
45-
46-
function getLocationName(cam: any): string {
47-
return (
48-
cam?.location?.name ??
49-
cam?.cam_location ??
50-
(typeof cam?.location === "string" ? cam.location : "") ??
51-
"-"
52-
);
53-
}
54-
55-
// 1) ไอคอนของแต่ละ type
21+
// ---------- helpers (เฉพาะที่ใช้งานจริง) ----------
5622
const TYPE_ICON: Record<string, React.ComponentType<{ className?: string }>> = {
5723
fixed: CameraIcon,
5824
ptz: Move,
5925
panoramic: Scan,
6026
thermal: Thermometer,
6127
};
62-
63-
function getTypeIcon(typeKey?: string | null) {
64-
const key = (typeKey ?? "").toLowerCase();
65-
return TYPE_ICON[key] ?? CameraIcon;
66-
}
67-
68-
// 2) สีของ type (แยกสีไอคอนและพื้นหลังแบดจ์)
6928
const TYPE_STYLES: Record<string, { badge: string; icon: string }> = {
70-
fixed: {
71-
badge: "border-blue-200 bg-blue-50 text-blue-700",
72-
icon: "text-blue-600",
73-
},
74-
ptz: {
75-
badge: "border-amber-200 bg-amber-50 text-amber-700",
76-
icon: "text-amber-600",
77-
},
78-
panoramic: {
79-
badge: "border-violet-200 bg-violet-50 text-violet-700",
80-
icon: "text-violet-600",
81-
},
82-
thermal: {
83-
badge: "border-orange-200 bg-orange-50 text-orange-700",
84-
icon: "text-orange-600",
85-
},
86-
default: {
87-
badge: "border-slate-200 bg-slate-50 text-slate-700",
88-
icon: "text-slate-600",
89-
},
29+
fixed: { badge: "border-blue-200 bg-blue-50 text-blue-700", icon: "text-blue-600" },
30+
ptz: { badge: "border-amber-200 bg-amber-50 text-amber-700", icon: "text-amber-600" },
31+
panoramic: { badge: "border-violet-200 bg-violet-50 text-violet-700", icon: "text-violet-600" },
32+
thermal: { badge: "border-orange-200 bg-orange-50 text-orange-700", icon: "text-orange-600" },
33+
default: { badge: "border-slate-200 bg-slate-50 text-slate-700", icon: "text-slate-600" },
9034
};
91-
92-
function getTypeStyle(typeKey?: string | null) {
93-
const k = (typeKey ?? "").toLowerCase();
94-
return TYPE_STYLES[k] ?? TYPE_STYLES.default;
95-
}
96-
97-
// 3) สีของ health (รองรับทั้งข้อความและตัวเลขเปอร์เซ็นต์)
98-
type HealthText = string | number | null | undefined;
99-
10035
const HEALTH_STYLES = {
10136
excellent: { badge: "border-emerald-200 bg-emerald-50 text-emerald-700" },
10237
good: { badge: "border-green-200 bg-green-50 text-green-700" },
@@ -108,13 +43,17 @@ const HEALTH_STYLES = {
10843
default: { badge: "border-slate-200 bg-slate-50 text-slate-700" },
10944
} as const;
11045

111-
function getHealthStyle(health: HealthText) {
112-
if (health === null || health === undefined || health === "") {
113-
return HEALTH_STYLES.default;
114-
}
115-
116-
// ถ้าเป็นตัวเลข (0-100) map ตามช่วง
117-
const n = Number(health);
46+
function getTypeIcon(typeKey?: string | null) {
47+
const k = (typeKey ?? "").toLowerCase();
48+
return TYPE_ICON[k] ?? CameraIcon;
49+
}
50+
function getTypeStyle(typeKey?: string | null) {
51+
const k = (typeKey ?? "").toLowerCase();
52+
return TYPE_STYLES[k] ?? TYPE_STYLES.default;
53+
}
54+
function getHealthStyle(h: number | string | null | undefined) {
55+
if (h === null || h === undefined || h === "") return HEALTH_STYLES.default;
56+
const n = Number(h);
11857
if (!Number.isNaN(n)) {
11958
if (n >= 90) return HEALTH_STYLES.excellent;
12059
if (n >= 80) return HEALTH_STYLES.good;
@@ -123,130 +62,76 @@ function getHealthStyle(health: HealthText) {
12362
if (n > 0) return HEALTH_STYLES.poor;
12463
return HEALTH_STYLES.offline;
12564
}
126-
127-
// ถ้าเป็นข้อความ รองรับหลายคำพ้อง
128-
const key = String(health).toLowerCase();
129-
if (["excellent", "very good", "ยอดเยี่ยม"].includes(key))
130-
return HEALTH_STYLES.excellent;
65+
const key = String(h).toLowerCase();
66+
if (["excellent", "very good", "ยอดเยี่ยม"].includes(key)) return HEALTH_STYLES.excellent;
13167
if (["good", "healthy", "ดี"].includes(key)) return HEALTH_STYLES.good;
13268
if (["fair", "moderate", "พอใช้"].includes(key)) return HEALTH_STYLES.fair;
133-
if (["degraded", "warning", "เตือน"].includes(key))
134-
return HEALTH_STYLES.degraded;
69+
if (["degraded", "warning", "เตือน"].includes(key)) return HEALTH_STYLES.degraded;
13570
if (["poor", "bad", "แย่"].includes(key)) return HEALTH_STYLES.poor;
13671
if (["critical", "วิกฤติ"].includes(key)) return HEALTH_STYLES.critical;
137-
if (["offline", "down", "ออฟไลน์"].includes(key))
138-
return HEALTH_STYLES.offline;
139-
72+
if (["offline", "down", "ออฟไลน์"].includes(key)) return HEALTH_STYLES.offline;
14073
return HEALTH_STYLES.default;
14174
}
14275

14376
// ---------- component ----------
14477
export default function CameraCard({ cam }: { cam: Camera }) {
145-
const rawImg =
146-
(cam as any).cam_image ??
147-
(cam as any).image_url ??
148-
(cam as any).thumbnail ??
149-
null;
150-
151-
const rawVideo =
152-
(cam as any).cam_video ??
153-
(cam as any).video_url ??
154-
(cam as any).footage_url ??
155-
null;
156-
157-
const statusBool = getStatusBool(cam);
158-
const statusLabel = statusBool ? "Active" : "Inactive";
159-
160-
const camBorder = statusBool
161-
? "border-[var(--color-primary)]"
162-
: "border-[var(--color-danger)]";
163-
164-
const camHeaderBG = statusBool
165-
? "bg-[var(--color-primary-bg)] border border-[var(--color-primary)]"
166-
: "bg-[var(--color-danger-bg)] border border-[var(--color-danger)]";
167-
168-
const camIconRing = statusBool
169-
? "border-[var(--color-primary)]"
170-
: "border-[var(--color-danger)]";
171-
172-
const camIconBG = statusBool
173-
? "bg-[var(--color-primary)]"
174-
: "bg-[var(--color-danger)]";
175-
176-
// ถ้าไม่มีวิดีโอ ใช้ภาพแทน
177-
const imageSrc = rawImg || "/library-room.jpg";
178-
const videoSrc = rawVideo || "/footage-library-room.mp4";
78+
const isOnline = !!cam.status;
79+
const imageSrc = (cam as any).cam_image ?? (cam as any).image_url ?? (cam as any).thumbnail ?? "/library-room.jpg";
17980

18081
const camCode = `CAM${String(cam.id).padStart(3, "0")}`;
181-
const locationName = getLocationName(cam);
82+
const locationName = cam.location?.name ?? "-";
83+
const TypeIcon = getTypeIcon(cam.type);
84+
const typeStyle = getTypeStyle(cam.type);
85+
const healthStyle = getHealthStyle(cam.health);
86+
const healthText = typeof cam.health === "number" ? `${cam.health}%` : String(cam.health ?? "-");
87+
88+
const WHEP_BASE = process.env.NEXT_PUBLIC_WHEP_BASE ?? "http://localhost:8889";
89+
const isRtsp = typeof cam.address === "string" && cam.address.startsWith("rtsp://");
18290

183-
// Type
184-
const typeKey = cam.type ?? "";
185-
const typeLabel = cam.type || "Fixed";
186-
const TypeIcon = getTypeIcon(typeKey);
187-
const typeStyle = getTypeStyle(typeKey);
91+
const [webrtcFailed, setWebrtcFailed] = useState(false);
92+
useEffect(() => { setWebrtcFailed(false); }, []);
93+
useEffect(() => setWebrtcFailed(false), [cam.id, cam.address]);
18894

189-
// Health
190-
const healthStyle = getHealthStyle(cam.health as any);
191-
const healthText =
192-
typeof cam.health === "number"
193-
? `${cam.health}%`
194-
: String(cam.health ?? "-");
95+
const camBorder = isOnline ? "border-[var(--color-primary)]" : "border-[var(--color-danger)]";
96+
const camHeaderBG = isOnline ? "bg-[var(--color-primary-bg)] border border-[var(--color-primary)]"
97+
: "bg-[var(--color-danger-bg)] border border-[var(--color-danger)]";
98+
const camIconRing = isOnline ? "border-[var(--color-primary)]" : "border-[var(--color-danger)]";
99+
const camIconBG = isOnline ? "bg-[var(--color-primary)]" : "bg-[var(--color-danger)]";
195100

196101
return (
197102
<div className="relative mt-12">
198-
{/* Top background (แถบสีหลังการ์ด) */}
199-
<div
200-
aria-hidden
201-
className={`
202-
pointer-events-none absolute inset-x-0 -top-7 h-16
203-
rounded-2xl ${camHeaderBG} shadow-sm z-0
204-
`}
205-
/>
103+
{/* Top background */}
104+
<div aria-hidden className={`pointer-events-none absolute inset-x-0 -top-7 h-16 rounded-2xl ${camHeaderBG} shadow-sm z-0`} />
206105

207-
{/* Floating icon (กล้องตรงกลางด้านบน) */}
106+
{/* Floating icon */}
208107
<div className="absolute left-1/2 -top-4 -translate-x-1/2 z-20">
209-
<div
210-
className={`grid place-items-center h-10 w-10 rounded-full bg-white ${camIconRing} shadow`}
211-
>
212-
<div
213-
className={`grid place-items-center h-8 w-8 rounded-full ${camIconBG} ring-2 ring-white`}
214-
>
108+
<div className={`grid place-items-center h-10 w-10 rounded-full bg-white ${camIconRing} shadow`}>
109+
<div className={`grid place-items-center h-8 w-8 rounded-full ${camIconBG} ring-2 ring-white`}>
215110
<CameraIcon className="h-5 w-5 text-white" />
216111
</div>
217112
</div>
218113
</div>
219114

220-
{/* การ์ดหลัก */}
221-
<div
222-
className={`relative z-10 rounded-xl border ${camBorder} bg-[var(--color-white)] shadow-sm p-4 overflow-hidden`}
223-
>
224-
{/* Media block */}
115+
{/* Card */}
116+
<div className={`relative z-10 rounded-xl border ${camBorder} bg-[var(--color-white)] shadow-sm p-4 overflow-hidden`}>
117+
{/* Media */}
225118
<div className="relative overflow-hidden rounded-md">
226119
<div className="relative aspect-video">
227-
{cam.status ? (
228-
videoSrc ? (
229-
<video
230-
src={videoSrc}
231-
autoPlay
232-
muted
233-
loop
234-
playsInline
235-
controls={false}
236-
preload="metadata"
237-
poster={imageSrc}
238-
className="absolute inset-0 h-full w-full object-cover"
239-
/>
240-
) : (
241-
<Image
242-
src={imageSrc}
243-
alt={cam.name}
244-
fill
245-
className="object-cover"
246-
sizes="(min-width: 1024px) 400px, 100vw"
247-
priority={false}
248-
/>
249-
)
120+
{isOnline && isRtsp && !webrtcFailed ? (
121+
<WhepPlayer
122+
key={cam.id}
123+
camAddressRtsp={cam.address}
124+
webrtcBase={WHEP_BASE}
125+
onFailure={() => setWebrtcFailed(true)} // เงียบ ๆ แล้วเป็นรูปแทน
126+
/>
127+
) : isOnline ? (
128+
<Image
129+
src={imageSrc}
130+
alt={cam.name}
131+
fill
132+
className="absolute inset-0 h-full w-full object-cover rounded-md"
133+
sizes="(min-width: 1024px) 400px, 100vw"
134+
/>
250135
) : (
251136
<Image
252137
src="/blind.svg"
@@ -263,13 +148,10 @@ export default function CameraCard({ cam }: { cam: Camera }) {
263148
{camCode}
264149
</span>
265150

266-
<span
267-
className={`absolute right-3 top-3 rounded-full px-3 py-0.5 text-[11px] font-semibold shadow-sm border ${statusBool
268-
? "bg-emerald-50 text-emerald-700 border-emerald-700"
269-
: "bg-red-50 text-red-700 border-red-700"
270-
}`}
271-
>
272-
{statusLabel}
151+
<span className={`absolute right-3 top-3 rounded-full px-3 py-0.5 text-[11px] font-semibold shadow-sm border ${
152+
isOnline ? "bg-emerald-50 text-emerald-700 border-emerald-700" : "bg-red-50 text-red-700 border-red-700"
153+
}`}>
154+
{isOnline ? "Active" : "Inactive"}
273155
</span>
274156

275157
<span className="absolute left-3 bottom-3 inline-flex max-w-[75%] items-center gap-2 truncate rounded-full border bg-white/90 px-3 py-1 text-sm text-gray-700 shadow">
@@ -278,25 +160,17 @@ export default function CameraCard({ cam }: { cam: Camera }) {
278160
</span>
279161
</div>
280162

281-
{/* Info under media */}
163+
{/* Info */}
282164
<div className="mt-4">
283-
<h3 className="text-base font-semibold text-[var(--color-primary)]">
284-
{cam.name}
285-
</h3>
165+
<h3 className="text-base font-semibold text-[var(--color-primary)]">{cam.name}</h3>
286166

287167
<div className="mt-3 flex items-center justify-between">
288-
{/* Type badge + icon สีตามประเภท */}
289-
<span
290-
className={`inline-flex items-center gap-1 rounded-full border px-3 py-1 text-sm font-medium ${typeStyle.badge}`}
291-
>
168+
<span className={`inline-flex items-center gap-1 rounded-full border px-3 py-1 text-sm font-medium ${typeStyle.badge}`}>
292169
<TypeIcon className={`h-4 w-4 ${typeStyle.icon}`} />
293-
{typeLabel}
170+
{cam.type || "Fixed"}
294171
</span>
295172

296-
{/* Health badge + icon สีตามสถานะ */}
297-
<span
298-
className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-sm font-medium ${healthStyle.badge}`}
299-
>
173+
<span className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-sm font-medium ${healthStyle.badge}`}>
300174
<Activity className="h-4 w-4" />
301175
Health: {healthText}
302176
</span>

0 commit comments

Comments
 (0)