319 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|