fix:history, loading
continuous-integration/drone/push Build is passing Details

This commit is contained in:
Rama Priyanto 2026-04-08 16:49:00 +07:00
parent 1e163e3e29
commit 70e71acf3b
6 changed files with 199 additions and 76 deletions

View File

@ -2,48 +2,47 @@
import { ArrowLeftIcon, ChevronLeft } from "lucide-react" import { ArrowLeftIcon, ChevronLeft } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import Image from "next/image" import Image from "next/image"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { socket } from "@/components/socket" import { socket } from "@/components/socket"
import { usePlateStore } from "@/components/zustand/plate-history"
import { Spinner } from "@/components/ui/spinner"
interface historyType {
img: string
plate: string
}
const stream_dummy = [ const stream_dummy = [
"/app/static/uploads/ds1.mp4", { id: "asssd", url: "/app/static/uploads/ds1.mp4" },
"/app/static/uploads/ds2.mp4", { id: "asaggssd", url: "/app/static/uploads/ds2.mp4" },
"/app/static/uploads/ds3.mp4", { id: "asss112d", url: "/app/static/uploads/ds3.mp4" },
"/app/static/uploads/ds4.mp4", { id: "asgsasssd", url: "/app/static/uploads/ds4.mp4" },
] ]
export default function Etle() { export default function Etle() {
const router = useRouter()
const [sessionId, setSessionId] = useState<string | null>(null) const [sessionId, setSessionId] = useState<string | null>(null)
const [imageSrc, setImageSrc] = useState<string>("") const [imageSrc, setImageSrc] = useState<string>("")
const [plate, setPlate] = useState<string>("") const [plate, setPlate] = useState<string>("")
const [confidence, setConfidence] = useState<number | null>(null) const [confidence, setConfidence] = useState<number | null>(null)
const [history, setHistory] = useState<historyType[]>([]) const activeStream = usePlateStore((s) => s.activeStreamId)
const setActiveStream = usePlateStore((s) => s.setActiveStream)
const history = usePlateStore((s) => s.history)
const addPlate = usePlateStore((s) => s.addPlate)
const [loading, setLoading] = useState(true)
const currentStream = usePlateStore.getState().activeStreamId
const startDetection = (index: number) => { const startDetection = (index: number) => {
const stream = stream_dummy[index] setLoading(true)
if (history[activeStream]) setPlate("")
setConfidence(null)
const stream = stream_dummy[index].url
socket.emit("stop_detection") socket.emit("stop_detection")
socket.emit("start_detection", { socket.emit("start_detection", {
stream_url: stream, stream_url: stream,
}) })
setTimeout(() => {
setLoading(false)
}, 2000)
} }
useEffect(() => { useEffect(() => {
@ -53,15 +52,19 @@ export default function Etle() {
socket.on("connected", (data) => { socket.on("connected", (data) => {
setSessionId(data.session_id) setSessionId(data.session_id)
const stream = stream_dummy.find((a) => a.id == currentStream)
setActiveStream(currentStream)
socket.emit("start_detection", { socket.emit("start_detection", {
stream_url: "/app/static/uploads/ds1.mp4", stream_url: stream?.url ?? stream_dummy[0].url,
}) })
setTimeout(() => {
setLoading(false)
}, 2000)
}) })
socket.on("video_frame", (data) => { socket.on("video_frame", (data) => {
const thumb = `data:image/jpeg;base64,${data.frame}` const thumb = `data:image/jpeg;base64,${data.frame}`
setImageSrc(thumb) setImageSrc(thumb)
// setHistory((prev) => [thumb, ...prev].slice(0, 20))
}) })
socket.on("plate_detected", (data) => { socket.on("plate_detected", (data) => {
@ -69,24 +72,24 @@ export default function Etle() {
setConfidence(data.confidence ?? null) setConfidence(data.confidence ?? null)
const thumb = `data:image/jpeg;base64,${data.frame_thumb}` const thumb = `data:image/jpeg;base64,${data.frame_thumb}`
setHistory((prev) => {
// cek apakah plate sudah pernah ada
const exists = prev.some((item) => item.plate === data.plate_text)
if (exists) return prev // skip kalau duplicate if (!currentStream) return
return [{ img: thumb, plate: data.plate_text }, ...prev] addPlate(currentStream, {
imgThumb: thumb,
plate: data.plate_text,
confidence: data.confidence,
}) })
}) })
// socket.on("detection_stopped", (data) => {
// console.log("Total:", data.total)
// })
socket.on("error", (data) => { socket.on("error", (data) => {
console.error(data.message) console.error(data.message)
}) })
socket.on("detection_stopped", (data) => {
console.log("Selesai. Total:", data.total)
})
return () => { return () => {
socket.emit("stop_detection") socket.emit("stop_detection")
socket.removeAllListeners() socket.removeAllListeners()
@ -94,11 +97,16 @@ export default function Etle() {
} }
}, []) }, [])
// const startDetection = () => { const [hasMounted, setHasMounted] = useState(false)
// socket.emit("start_detection", {
// stream_url: "/app/static/uploads/ds1", useEffect(() => {
// }) setHasMounted(true)
// } }, [])
if (!hasMounted) {
return null
}
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex h-[8vh] flex-row items-center gap-10 bg-[#0057B3] px-8 py-5"> <div className="flex h-[8vh] flex-row items-center gap-10 bg-[#0057B3] px-8 py-5">
@ -109,20 +117,33 @@ export default function Etle() {
</div> </div>
<div className="flex h-[92vh] flex-col items-start gap-5 bg-gray-200 p-8 text-black"> <div className="flex h-[92vh] flex-col items-start gap-5 bg-gray-200 p-8 text-black">
<p className="text-2xl font-semibold">TRAFFIC VIOLATIONS</p> <p className="text-2xl font-semibold">TRAFFIC VIOLATIONS</p>
{/* <Button onClick={startDetection}>START DETECTION</Button> */}
<div className="flex gap-2"> <div className="flex gap-2">
<p>now {currentStream}</p>
{stream_dummy.map((stream, index) => ( {stream_dummy.map((stream, index) => (
<Button key={index} onClick={() => startDetection(index)}> <Button
key={stream.id}
onClick={() => {
setActiveStream(stream.id)
startDetection(index)
}}
className={`cursor-pointer ${currentStream == stream.id ? "border-2 bg-gray-50 p-2" : ""}`}
>
DS{index + 1} DS{index + 1}
</Button> </Button>
))} ))}
</div> </div>
{imageSrc && ( {loading ? (
<img <div className="w-screen rounded-lg md:w-full xl:w-180">
src={imageSrc} <Spinner className="h-50 w-50" />
alt="Realtime" </div>
className="w-screen rounded-lg md:w-full xl:w-180" ) : (
/> imageSrc && (
<img
src={imageSrc}
alt="Realtime"
className="w-screen rounded-lg md:w-full xl:w-180"
/>
)
)} )}
<Button <Button
variant="ghost" variant="ghost"
@ -146,44 +167,24 @@ export default function Etle() {
<DrawerContent> <DrawerContent>
<div className="no-scrollbar space-y-3 overflow-y-auto bg-[#CBCBCBCC] px-4 py-4 text-black"> <div className="no-scrollbar space-y-3 overflow-y-auto bg-[#CBCBCBCC] px-4 py-4 text-black">
<div className="rounded-lg bg-white p-3 text-lg"> <div className="rounded-lg bg-white p-3 text-lg">
Total Image: {history.length} Total Image: {history[activeStream]?.length}
</div>{" "} </div>{" "}
{history.map((item, index) => ( {history[currentStream]?.map((item, index) => (
<div key={index} className="rounded-lg bg-white p-3 text-lg"> <div key={index} className="rounded-lg bg-white p-3 text-lg">
<Image <Image
src={item.img} src={item.imgThumb}
width={1280} width={1280}
height={960} height={960}
alt={"image" + index} alt={"image" + index}
className="rounded-lg" className="rounded-lg"
/> />
<p>{item.plate}</p> <p>Plate : {item.plate}</p>
<p>Confidence: {item.confidence}</p>
</div> </div>
))} ))}
{/* {history.map((img, index) => (
<div key={index} className="rounded-lg bg-white p-3">
<img src={img.img} className="rounded-lg" />
</div>
))} */}
</div> </div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
{/* <Sheet>
<SheetTrigger asChild>
<Button className="fixed top-1/2 right-0 h-20 w-6 -translate-y-1/2 rounded-l-md">
<ChevronLeft />
</Button>
</SheetTrigger>
<SheetContent>
<div className="grid flex-1 auto-rows-min gap-6 bg-[#CBCBCBCC] px-4 py-10 text-black">
<div className="flex flex-col gap-4">
<div className="rounded-lg bg-white p-3 text-lg">
Total Image: 10
</div>{" "}
</div>
</div>
</SheetContent>
</Sheet> */}
</div> </div>
) )
} }

View File

@ -55,13 +55,14 @@ function DrawerContent({
<DrawerOverlay /> <DrawerOverlay />
<DrawerPrimitive.Content <DrawerPrimitive.Content
data-slot="drawer-content" data-slot="drawer-content"
title=""
className={cn( className={cn(
"group/drawer-content fixed z-50 flex h-auto flex-col bg-background text-sm data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm", "group/drawer-content fixed z-50 flex h-auto flex-col bg-background text-sm data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-xl data-[vaul-drawer-direction=bottom]:border-t data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:rounded-r-xl data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:rounded-l-xl data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-xl data-[vaul-drawer-direction=top]:border-b data-[vaul-drawer-direction=left]:sm:max-w-sm data-[vaul-drawer-direction=right]:sm:max-w-sm",
className className
)} )}
{...props} {...props}
> >
<div className="mx-auto mt-4 hidden h-1 w-[100px] shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" /> <div className="mx-auto mt-4 hidden h-1 w-25 shrink-0 rounded-full bg-muted group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children} {children}
</DrawerPrimitive.Content> </DrawerPrimitive.Content>
</DrawerPortal> </DrawerPortal>

12
components/ui/spinner.tsx Normal file
View File

@ -0,0 +1,12 @@
import { LoaderIcon } from "lucide-react"
import { cn } from "@/lib/utils"
export function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<LoaderIcon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}

View File

@ -0,0 +1,80 @@
import { create } from "zustand"
import { persist } from "zustand/middleware"
type PlateItem = {
imgThumb: string
plate: string
confidence?: number | null
}
type PlateState = {
activeStreamId: string | ""
history: Record<string, PlateItem[]>
setActiveStream: (id: string) => void
addPlate: (streamId: string, newData: PlateItem) => void
clearHistory: (streamId: string) => void
}
export const usePlateStore = create<PlateState>()(
persist(
(set, get) => ({
// active stream
activeStreamId: "",
setActiveStream: (id: string) => set({ activeStreamId: id }),
// history per stream
history: {},
addPlate: (streamId: string, newData: PlateItem) => {
const state = get()
const currentHistory = state.history[streamId] || []
const index = currentHistory.findIndex(
(item) => item.plate === newData.plate
)
let updatedHistory
// kalau belum ada → tambah
if (index === -1) {
updatedHistory = [newData, ...currentHistory]
} else {
const existing = currentHistory[index]
// kalau confidence lebih tinggi → replace
if ((newData.confidence ?? 0) > (existing.confidence ?? 0)) {
updatedHistory = [
newData,
...currentHistory.filter((_, i) => i !== index),
]
} else {
updatedHistory = currentHistory
}
}
updatedHistory = updatedHistory
set({
history: {
...state.history,
[streamId]: updatedHistory,
},
})
},
// optional: clear history per stream
clearHistory: (streamId) => {
const state = get()
const newHistory = { ...state.history }
delete newHistory[streamId]
set({ history: newHistory })
},
}),
{
name: "plate-storage", // key localStorage
}
)
)

34
package-lock.json generated
View File

@ -38,9 +38,9 @@
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vaul": "^1.1.2" "vaul": "^1.1.2",
}, "zustand": "^5.0.12"
"devDependencies": {} }
}, },
"node_modules/@alloc/quick-lru": { "node_modules/@alloc/quick-lru": {
"version": "5.2.0", "version": "5.2.0",
@ -10639,6 +10639,34 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
},
"node_modules/zustand": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }

View File

@ -42,6 +42,7 @@
"tailwindcss": "^4.2.1", "tailwindcss": "^4.2.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vaul": "^1.1.2" "vaul": "^1.1.2",
"zustand": "^5.0.12"
} }
} }