ManagementSystem/TrackingToolWebAU/client/src/pages/main/index.tsx

319 lines
11 KiB
TypeScript

/* eslint-disable react-hooks/exhaustive-deps */
"use client";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { useFaceZoom } from "@/lib/use-face-zoom";
import { capture, cn } from "@/lib/utils";
import useAppStore from "@/stores/use-app-store";
import { AnimatePresence, motion } from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import CountDown from "./components/count-down";
import FaceBracket from "./components/face-bracket";
import LeftSlidebar from "./components/left-slidebar";
import RightSlidebar from "./components/right-slidebar";
import TabFeatures from "./components/tab-features";
import { LogList } from "./components/tab-log";
const DESKTOP_MQ = "(min-width: 1024px)";
const matchDesktop = () =>
typeof window !== "undefined"
? window.matchMedia(DESKTOP_MQ).matches
: true;
export default function Main() {
const [isDesktop, setIsDesktop] = useState(matchDesktop);
const [isSidebarOpen, setIsSidebarOpen] = useState(matchDesktop);
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(false);
useEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia(DESKTOP_MQ);
const handler = (e: MediaQueryListEvent) => {
setIsDesktop(e.matches);
// Resize xuống tablet/mobile thì đóng luôn drawer logs (tắt auto-open).
if (!e.matches) setIsSidebarOpen(false);
};
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
// const { currentUser, setCurrentUser } = useUserStore();
const {
setCanvasRef,
setVideoRef,
setFaceZoomTransform,
isAutoChecking,
bumpAutoCheckinTick,
} = useAppStore();
const { isCountDown, setCaptureRegisterImage, setIsCountDown } =
useAppStore();
const [flashKey, setFlashKey] = useState(0);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
// Kích thước thực của khung hiển thị video bên trong Card sau khi
// letterbox theo aspect-ratio nguồn (1280x720 = 16:9). FaceBracket dùng %
// của container này nên bám đúng vùng video, không lệch ra vùng đen.
const [videoFrame, setVideoFrame] = useState<{ w: number; h: number }>({
w: 0,
h: 0,
});
useEffect(() => {
const el = cardRef.current;
if (!el) return;
const VIDEO_ASPECT = 16 / 9;
const ro = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect;
if (!width || !height) return;
const cardAspect = width / height;
let w: number, h: number;
if (cardAspect >= VIDEO_ASPECT) {
h = height;
w = height * VIDEO_ASPECT;
} else {
w = width;
h = width / VIDEO_ASPECT;
}
setVideoFrame({ w: Math.round(w), h: Math.round(h) });
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const onStableFace = useCallback(() => {
// Fire the same path as the "Điểm Danh Ngay" button via the store trigger,
// and play a camera-shutter flash to acknowledge the capture.
setFlashKey((k) => k + 1);
bumpAutoCheckinTick();
}, [bumpAutoCheckinTick]);
const {
transform: faceZoom,
box: faceBox,
stabilityProgress: faceStability,
} = useFaceZoom(videoRef, {
enabled: true,
intervalMs: 110,
targetFaceRatio: 0.6,
minScale: 1.35,
maxScale: 4,
followSpeed: 0.11,
detectionSmoothing: 0.3,
detectorInputSize: 416,
detectorScoreThreshold: 0.3,
stabilityEnabled: isAutoChecking,
stableMs: 2000,
onStableFace,
});
// Initialize camera
useEffect(() => {
const initCamera = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 1280, height: 720, facingMode: "user" },
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
}
} catch (err) {
console.error("Không thể truy cập camera:", err);
}
};
initCamera();
return () => {
if (videoRef.current?.srcObject) {
const stream = videoRef.current?.srcObject as MediaStream;
stream.getTracks().forEach((track) => track.stop());
}
};
}, []);
useEffect(() => {
setCanvasRef(canvasRef);
setVideoRef(videoRef);
}, [videoRef, canvasRef]);
// Keep store in sync with the live zoom so capture() can crop to the
// visible focused region instead of the full frame.
useEffect(() => {
setFaceZoomTransform(faceZoom);
}, [faceZoom, setFaceZoomTransform]);
return (
<div className="min-h-screen bg-white">
<div className="flex h-screen relative">
<LeftSlidebar
isSidebarOpen={isLeftSidebarOpen}
onClose={() => setIsLeftSidebarOpen(false)}
/>
{/* Mobile/tablet backdrop */}
{(isLeftSidebarOpen || isSidebarOpen) && (
<div
className="fixed inset-0 bg-black/40 z-20 lg:hidden"
onClick={() => {
setIsLeftSidebarOpen(false);
setIsSidebarOpen(false);
}}
/>
)}
<div
className={cn(
"flex-1 transition-all duration-300 ease-in-out min-w-0",
isLeftSidebarOpen && "lg:ml-96",
isSidebarOpen && "lg:mr-96"
)}
>
<div className="h-full flex flex-col p-2 sm:p-4 lg:p-6 gap-2 lg:gap-0">
{/* Mobile/tablet: Buttons at top — conditional render để tránh
mount 2 instance TabFeatures cùng lúc (duplicate auto-check). */}
{!isDesktop && <TabFeatures inline />}
{/* Video Feed */}
<Card className="flex-1 min-h-0 overflow-hidden bg-black relative group">
<div ref={cardRef} className="absolute inset-0">
{/* Khung video centered theo aspect nguồn — đặt video + bracket
chung trong inner container để toạ độ % của bracket bám
đúng vùng video, không tràn ra letterbox đen. */}
<div
className="absolute inset-0 m-auto"
style={{
width: videoFrame.w || "100%",
height: videoFrame.h || "100%",
}}
>
<video
ref={videoRef}
autoPlay
playsInline
className="w-full h-full object-cover will-change-transform"
style={{
transformOrigin: "0 0",
transform: `translate3d(${faceZoom.translateX}%, ${faceZoom.translateY}%, 0) scale(${faceZoom.scale})`,
backfaceVisibility: "hidden",
}}
/>
<FaceBracket
box={faceBox}
transform={faceZoom}
progress={isAutoChecking ? faceStability : 0}
/>
</div>
<div className="absolute inset-0 border-4 border-blue-500/30 pointer-events-none" />
{/* Camera shutter flash — keyed so each fire replays the animation. */}
<AnimatePresence>
{flashKey > 0 && (
<motion.div
key={flashKey}
initial={{ opacity: 0 }}
animate={{ opacity: [0, 0.85, 0] }}
transition={{ duration: 0.45, times: [0, 0.18, 1] }}
className="absolute inset-0 bg-white pointer-events-none z-40"
/>
)}
</AnimatePresence>
{/* <AnimatePresence>
{currentUser && (
<motion.div
onClick={() => setCurrentUser(null)}
key="user-card"
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.25 }}
className="absolute top-4 left-4 right-4"
>
<Card className="p-4 bg-white/95 backdrop-blur-sm border-blue-200 shadow-lg">
<div className="flex items-center gap-3">
<div className="size-12 rounded-full bg-blue-600 text-white flex items-center justify-center font-semibold text-lg">
{currentUser.name.charAt(0)}
</div>
<div>
<h3 className="font-semibold text-gray-900">
{currentUser.name}
</h3>
<p className="text-sm text-gray-600">
{currentUser.email}
</p>
</div>
</div>
</Card>
</motion.div>
)}
</AnimatePresence> */}
{isCountDown && (
<CountDown
onCountdowned={async () => {
const data = await capture(videoRef, canvasRef);
setCaptureRegisterImage(data);
setIsCountDown(false);
}}
/>
)}
<Button
onClick={() => setIsLeftSidebarOpen(!isLeftSidebarOpen)}
variant="outline"
size="icon"
className="absolute top-1/2 -translate-y-1/2 left-2 sm:left-4 bg-white/90 hover:bg-white shadow-lg z-30 size-8 sm:size-10"
>
{isLeftSidebarOpen ? (
<ChevronLeft className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
</Button>
<Button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
variant="outline"
size="icon"
className="absolute top-1/2 -translate-y-1/2 right-2 sm:right-4 bg-white/90 hover:bg-white shadow-lg z-30 size-8 sm:size-10"
>
{isSidebarOpen ? (
<ChevronRight className="size-4" />
) : (
<ChevronLeft className="size-4" />
)}
</Button>
{/* Desktop only: buttons overlaid at bottom of video */}
{isDesktop && <TabFeatures />}
</div>
</Card>
{/* Mobile/tablet: Log at bottom */}
{!isDesktop && (
<div className="h-44 border rounded-lg bg-white overflow-hidden">
<LogList />
</div>
)}
</div>
</div>
<RightSlidebar
isSidebarOpen={isSidebarOpen}
onClose={() => setIsSidebarOpen(false)}
/>
{/* Hidden Canvas for Capture */}
<canvas ref={canvasRef} className="hidden" />
</div>
</div>
);
}