11"use client" ;
22
33import 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
149export 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 (เฉพาะที่ใช้งานจริง) ----------
5622const 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 (แยกสีไอคอนและพื้นหลังแบดจ์)
6928const 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-
10035const 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 ----------
14477export 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