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 Link from "next/link"
import { useRouter } from "next/navigation"
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer"
import { Button } from "@/components/ui/button"
import Image from "next/image"
import { useEffect, useState } from "react"
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 = [
"/app/static/uploads/ds1.mp4",
"/app/static/uploads/ds2.mp4",
"/app/static/uploads/ds3.mp4",
"/app/static/uploads/ds4.mp4",
{ id: "asssd", url: "/app/static/uploads/ds1.mp4" },
{ id: "asaggssd", url: "/app/static/uploads/ds2.mp4" },
{ id: "asss112d", url: "/app/static/uploads/ds3.mp4" },
{ id: "asgsasssd", url: "/app/static/uploads/ds4.mp4" },
]
export default function Etle() {
const router = useRouter()
const [sessionId, setSessionId] = useState<string | null>(null)
const [imageSrc, setImageSrc] = useState<string>("")
const [plate, setPlate] = useState<string>("")
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 stream = stream_dummy[index]
setLoading(true)
if (history[activeStream]) setPlate("")
setConfidence(null)
const stream = stream_dummy[index].url
socket.emit("stop_detection")
socket.emit("start_detection", {
stream_url: stream,
})
setTimeout(() => {
setLoading(false)
}, 2000)
}
useEffect(() => {
@ -53,15 +52,19 @@ export default function Etle() {
socket.on("connected", (data) => {
setSessionId(data.session_id)
const stream = stream_dummy.find((a) => a.id == currentStream)
setActiveStream(currentStream)
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) => {
const thumb = `data:image/jpeg;base64,${data.frame}`
setImageSrc(thumb)
// setHistory((prev) => [thumb, ...prev].slice(0, 20))
})
socket.on("plate_detected", (data) => {
@ -69,24 +72,24 @@ export default function Etle() {
setConfidence(data.confidence ?? null)
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) => {
console.error(data.message)
})
socket.on("detection_stopped", (data) => {
console.log("Selesai. Total:", data.total)
})
return () => {
socket.emit("stop_detection")
socket.removeAllListeners()
@ -94,11 +97,16 @@ export default function Etle() {
}
}, [])
// const startDetection = () => {
// socket.emit("start_detection", {
// stream_url: "/app/static/uploads/ds1",
// })
// }
const [hasMounted, setHasMounted] = useState(false)
useEffect(() => {
setHasMounted(true)
}, [])
if (!hasMounted) {
return null
}
return (
<div className="flex flex-col">
<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 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>
{/* <Button onClick={startDetection}>START DETECTION</Button> */}
<div className="flex gap-2">
<p>now {currentStream}</p>
{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}
</Button>
))}
</div>
{imageSrc && (
{loading ? (
<div className="w-screen rounded-lg md:w-full xl:w-180">
<Spinner className="h-50 w-50" />
</div>
) : (
imageSrc && (
<img
src={imageSrc}
alt="Realtime"
className="w-screen rounded-lg md:w-full xl:w-180"
/>
)
)}
<Button
variant="ghost"
@ -146,44 +167,24 @@ export default function Etle() {
<DrawerContent>
<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">
Total Image: {history.length}
Total Image: {history[activeStream]?.length}
</div>{" "}
{history.map((item, index) => (
{history[currentStream]?.map((item, index) => (
<div key={index} className="rounded-lg bg-white p-3 text-lg">
<Image
src={item.img}
src={item.imgThumb}
width={1280}
height={960}
alt={"image" + index}
className="rounded-lg"
/>
<p>{item.plate}</p>
<p>Plate : {item.plate}</p>
<p>Confidence: {item.confidence}</p>
</div>
))}
{/* {history.map((img, index) => (
<div key={index} className="rounded-lg bg-white p-3">
<img src={img.img} className="rounded-lg" />
</div>
))} */}
</div>
</DrawerContent>
</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>
)
}

View File

@ -55,13 +55,14 @@ function DrawerContent({
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
title=""
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",
className
)}
{...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}
</DrawerPrimitive.Content>
</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",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vaul": "^1.1.2"
},
"devDependencies": {}
"vaul": "^1.1.2",
"zustand": "^5.0.12"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
@ -10639,6 +10639,34 @@
"peerDependencies": {
"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",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vaul": "^1.1.2"
"vaul": "^1.1.2",
"zustand": "^5.0.12"
}
}