Compare commits

..

2 Commits

76 changed files with 12033 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

BIN
TrackingToolWeb/.DS_Store vendored Normal file

Binary file not shown.

2
TrackingToolWeb/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
path
__pycache__

View File

@ -0,0 +1,3 @@
Run client: npm run dev or npm run build && npm run preview
Run server uvicorn main:app --reload

2857
TrackingToolWeb/a.json Normal file

File diff suppressed because it is too large Load Diff

59
TrackingToolWeb/api.py Normal file
View File

@ -0,0 +1,59 @@
import os
import datetime
import requests
from fastapi import UploadFile
URL_API = "https://ms.prology.net/api/v1"
def send_image(id, file: UploadFile, student_name: str, status: str):
id = str(id)
# Tạo folder theo ngày
today = datetime.datetime.now().strftime("%Y_%m_%d")
folder_path = f"./images/{today}"
if not os.path.exists(folder_path):
os.makedirs(folder_path)
# Tạo file name chuẩn
file_name = (
f"{student_name}_"
f"{status}_at_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}.png"
)
file_path = os.path.join(folder_path, file_name)
# Lưu file UploadFile xuống
with open(file_path, "wb") as f:
f.write(file.file.read())
# Mở lại file để gửi API
with open(file_path, "rb") as image_file:
files = {"image": image_file}
data = {"id": id, "file_name": file_name}
try:
response = requests.post(
URL_API + "/admin/tracking/send-image",
data=data,
files=files
)
response.raise_for_status()
res = response.json()
except Exception as e:
return {"status": False, "message": str(e)}
return res
def create_history(data):
# Gửi yêu cầu POST với dữ liệu đã chỉ định
response = requests.post(URL_API+"/admin/tracking/scan-create", data=data)
res = response.json()
print(res)
return res

45
TrackingToolWeb/camera.py Normal file
View File

@ -0,0 +1,45 @@
import cv2
import requests
# source path/to/venv/bin/activate
API_URL = "http://localhost:8000/checkin" # Đổi lại nếu backend chạy ở địa chỉ khác
CAMERA_ID = "cam_pc_01"
def capture_and_checkin():
cap = cv2.VideoCapture(0) # Dùng camera mặc định (webcam)
if not cap.isOpened():
print("Không mở được camera.")
return
print("Đang mở camera. Nhấn phím 'c' để check-in, 'q' để thoát.")
while True:
ret, frame = cap.read()
if not ret:
print("Không đọc được frame.")
break
cv2.imshow("Camera", frame)
key = cv2.waitKey(1)
if key == ord("q"):
break
elif key == ord("c"):
# Ghi tạm ảnh ra file
filename = "frame.jpg"
cv2.imwrite(filename, frame)
# Gửi ảnh lên server
with open(filename, "rb") as f:
response = requests.post(
API_URL,
files={"file": ("frame.jpg", f, "image/jpeg")},
data={"camera_id": CAMERA_ID}
)
print("📡 Server:", response.json())
cap.release()
cv2.destroyAllWindows()
if __name__ == "__main__":
capture_and_checkin()

24
TrackingToolWeb/client/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -0,0 +1,75 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is enabled on this template. See [this documentation](https://react.dev/learn/react-compiler) for more information.
Note: This will impact Vite dev & build performances.
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@ -0,0 +1,26 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
"eslint-disable @typescript-eslint/no-explicit-any": "off",
},
},
]);

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5243
TrackingToolWeb/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,54 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@tailwindcss/vite": "^4.1.17",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.23.25",
"lucide-react": "^0.556.0",
"moment": "^2.30.1",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.68.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"zod": "^4.1.13",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.2",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,7 @@
import ax from "axios";
const axios = ax.create({
baseURL: "http://127.0.0.1:8000",
});
export default axios;

View File

@ -0,0 +1,68 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios from "./axios";
class CheckingApi {
async logs() {
return await axios({
method: "GET",
url: "logs",
});
}
async users() {
return await axios({
method: "GET",
url: "users",
});
}
async register({ file, user }: { user: IUser; file: any }) {
const formData = new FormData();
formData.append("file", file, "frame.jpg");
for (const [key, value] of Object.entries(user)) {
formData.append(key, value);
}
return await axios({
headers: {
"Content-Type": "multipart/form-data",
},
method: "POST",
url: "/register",
data: formData,
});
}
async registerSimple({ user }: { user: IUser }) {
const formData = new FormData();
for (const [key, value] of Object.entries(user)) {
formData.append(key, value);
}
return await axios({
headers: {
"Content-Type": "multipart/form-data",
},
method: "POST",
url: "/register-simple",
data: formData,
});
}
async checkin({ file }: { file: any }) {
const formData = new FormData();
formData.append("file", file, "frame.jpg");
return await axios({
headers: {
"Content-Type": "multipart/form-data",
},
method: "POST",
url: "/checkin",
data: formData,
});
}
}
export const checkingApi = new CheckingApi();

View File

@ -0,0 +1,23 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import axios from "axios";
class MsApi {
async timekeepings() {
return await axios({
headers: {
Authorization:
"Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL21zLnByb2xvZ3kubmV0L2FwaS92MS9hZG1pbi9sb2dpbiIsImlhdCI6MTc1Njg2MDQ1OSwiZXhwIjoxNzg4Mzk2NDU5LCJuYmYiOjE3NTY4NjA0NTksImp0aSI6IkRrb0NLbHBKV1pkNnZCN0QiLCJzdWIiOiIxNSIsInBydiI6ImQyZmYyOTMzOWE4YTNlODJjMzU4MmE1YThlNzM5ZGYxNzg5YmIxMmYifQ.DoHqHeAGGxpvzlNQ9dAZjZf2Yl573XCgNBT8ZiSx5N4",
},
baseURL: "https://ms.prology.net/api/v1/admin",
method: "GET",
url: "timekeeping",
params: {
month: new Date().getMonth(),
year: new Date().getFullYear(),
},
});
}
}
export const msApi = new MsApi();

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,125 @@
/* eslint-disable react-refresh/only-export-components */
"use client";
import type React from "react";
import { createContext, useContext, useState, useCallback } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AlertTriangle, Info, CheckCircle } from "lucide-react";
interface ConfirmOptions {
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: "default" | "destructive" | "warning" | "success";
}
interface ConfirmContextType {
confirm: (options: ConfirmOptions) => Promise<boolean>;
}
const ConfirmContext = createContext<ConfirmContextType | undefined>(undefined);
export function ConfirmModalProvider({
children,
}: {
children: React.ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<ConfirmOptions>({
message: "",
variant: "default",
});
const [resolveCallback, setResolveCallback] = useState<
((value: boolean) => void) | null
>(null);
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
setOptions({
title: options.title || "Xác nhận",
confirmText: options.confirmText || "Xác nhận",
cancelText: options.cancelText || "Hủy",
variant: options.variant || "default",
...options,
});
setIsOpen(true);
return new Promise<boolean>((resolve) => {
setResolveCallback(() => resolve);
});
}, []);
const handleConfirm = () => {
setIsOpen(false);
resolveCallback?.(true);
setResolveCallback(null);
};
const handleCancel = () => {
setIsOpen(false);
resolveCallback?.(false);
setResolveCallback(null);
};
const getIcon = () => {
switch (options.variant) {
case "destructive":
case "warning":
return <AlertTriangle className="h-6 w-6 text-destructive" />;
case "success":
return <CheckCircle className="h-6 w-6 text-green-600" />;
default:
return <Info className="h-6 w-6 text-blue-600" />;
}
};
return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-3">
{getIcon()}
<DialogTitle className="text-lg">{options.title}</DialogTitle>
</div>
<DialogDescription className="pt-2 text-base">
{options.message}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0 flex ">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
{options.cancelText}
</Button>
<Button
variant={
options.variant === "destructive" ? "destructive" : "default"
}
onClick={handleConfirm}
>
{options.confirmText}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</ConfirmContext.Provider>
);
}
export function useConfirm() {
const context = useContext(ConfirmContext);
if (!context) {
throw new Error("useConfirm must be used within ConfirmModalProvider");
}
return context.confirm;
}

View File

@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View File

@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@ -0,0 +1,165 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View File

@ -0,0 +1,56 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@ -0,0 +1,64 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,120 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,15 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
interface ILog {
name: string;
time: string;
camera_id: string;
status: string;
}
interface IUser {
id: string;
name: string;
email: string;
avatar?: string | null;
checkpoints?: any[];
}

View File

@ -0,0 +1,27 @@
export function speak({
type,
str,
}: {
type?: "check out" | "check in";
str?: string;
}) {
// Ưu tiên str, nếu không có thì dùng type
const text =
str ||
(type === "check in"
? "Check-in successful"
: type === "check out"
? "Check-out successful"
: "");
if (!text) return; // không có gì để đọc
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = "en-US";
utterance.rate = 1;
utterance.pitch = 1;
utterance.volume = 1;
speechSynthesis.speak(utterance);
}

View File

@ -0,0 +1,43 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { clsx, type ClassValue } from "clsx";
import moment from "moment";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const formatTime = (timeString: string) => {
return moment(timeString).format("DD/MM/YYYY HH:mm:ss");
};
export function capture(videoRef: any, canvasRef: any) {
if (!videoRef.current || !canvasRef.current) return;
const canvas = canvasRef.current;
const video = videoRef.current;
const context = canvas.getContext("2d");
return new Promise((resolve, reject) => {
try {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
context.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob(
(blob: unknown) => {
if (!blob) {
reject("Không thể tạo blob từ canvas");
return;
}
resolve(blob);
},
"image/jpeg",
0.95 // chất lượng cao
);
} catch (error) {
reject(error);
}
});
}

View File

@ -0,0 +1,15 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { ConfirmModalProvider } from "./components/confirm-modal-provider";
import "./index.css";
import Main from "./pages/main";
import { Toaster } from "@/components/ui/sonner";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ConfirmModalProvider>
<Main />
<Toaster richColors position="top-right" />
</ConfirmModalProvider>
</StrictMode>
);

View File

@ -0,0 +1,71 @@
"use client";
import { Button } from "@/components/ui/button";
import { useEffect, useState, useRef } from "react";
export default function CountDown({
onCountdowned,
}: {
onCountdowned?: () => void;
}) {
const [count, setCount] = useState(3);
const [running, setRunning] = useState(true);
const calledRef = useRef(false); // tránh gọi callback nhiều lần
// Countdown logic
useEffect(() => {
if (!running) return;
if (count === 0) {
if (!calledRef.current) {
calledRef.current = true;
onCountdowned?.();
}
return;
}
const timer = setTimeout(() => {
setCount((prev) => prev - 1);
}, 1000);
return () => clearTimeout(timer);
}, [count, running, onCountdowned]);
return (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-transparent">
<div className="flex flex-col items-center gap-8">
{/* Instruction text */}
<div className="text-center space-y-3">
<p className="text-lg font-medium">Chuẩn bị sẵn sàng</p>
<p className="text-base">Vui lòng nhìn thẳng vào camera</p>
</div>
{/* Timer circle */}
<div className="relative">
<div className="w-40 h-40 bg-white rounded-full flex items-center justify-center shadow-2xl">
<span className="text-8xl font-bold">{count}</span>
</div>
</div>
{/* Countdown text */}
<div className="text-center">
<p className="text-sm">
{count > 0 ? `Còn ${count} giây...` : "Đã hoàn thành!"}
</p>
</div>
{/* Cancel button */}
{running && count > 0 && (
<Button
variant="outline"
onClick={() => setRunning(false)}
className="mt-4 px-8 py-2"
>
Hủy
</Button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,37 @@
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { Users } from "lucide-react";
import TabUsers from "./tab-users";
export default function LeftSlidebar({
isSidebarOpen,
}: {
isSidebarOpen: boolean;
}) {
return (
<div
className={cn(
"fixed left-0 top-0 h-screen w-96 bg-white border-r border-gray-200 shadow-xl transition-transform duration-300 ease-in-out z-10",
isSidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="h-full flex flex-col">
<Tabs defaultValue="users" className="flex-1 flex flex-col">
<div className="border-b p-4">
<TabsList className="grid w-full grid-cols-1">
<TabsTrigger
value="users"
className="flex items-center gap-1 text-xs"
>
<Users className="size-3.5" />
User
</TabsTrigger>
</TabsList>
</div>
<TabUsers value="users" />
</Tabs>
</div>
</div>
);
}

View File

@ -0,0 +1,72 @@
"use client";
import { Camera } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useState, type ReactNode } from "react";
import useAppStore from "@/stores/use-app-store";
interface CameraNotificationModalProps {
onClose?: () => void;
children?: ReactNode;
}
export function CameraNotificationModal({
children,
onClose,
}: CameraNotificationModalProps) {
const [open, setOpen] = useState(false);
const { setIsCountDown } = useAppStore();
const handleClose = () => {
setOpen(false);
onClose?.();
};
const handleContinue = () => {
setIsCountDown(true);
handleClose();
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center justify-center mb-4">
<div className="rounded-full border p-3">
<Camera className="w-6 h-6 " />
</div>
</div>
<DialogTitle className="text-center text-lg">
Thông báo quan trọng
</DialogTitle>
<DialogDescription className="text-center text-base pt-2">
Đ kết quả tốt nhất bạn hay nhìn thẳng vào camera nhé
</DialogDescription>
</DialogHeader>
<div className="flex justify-center gap-3 pt-4">
<Button
variant="outline"
onClick={handleClose}
className="min-w-32 bg-transparent"
>
Hủy
</Button>
<Button onClick={handleContinue} className="min-w-32">
Tiếp tục
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,257 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState, type ReactNode } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useConfirm } from "@/components/confirm-modal-provider";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import useAppStore from "@/stores/use-app-store";
import { Camera, Loader } from "lucide-react";
import { checkingApi } from "@/api/checking-api";
import { toast } from "sonner";
interface UserModalProps {
user?: IUser | null;
onSave?: (user: IUser) => void;
children?: ReactNode;
trackingOpen?: boolean;
}
const userSchema = z.object({
name: z.string().min(1, "Tên là bắt buộc"),
email: z.string().email("Email không hợp lệ"),
avatar: z.string().optional(),
});
type UserFormData = z.infer<typeof userSchema>;
export function UserModal({
user,
onSave,
children,
trackingOpen,
}: UserModalProps) {
const confirm = useConfirm();
const isEditMode = !!user;
const [open, setOpenChange] = useState(false);
const [loading, setLoading] = useState(false);
const { captureRegisterImage, setCaptureRegisterImage } = useAppStore();
const form = useForm<UserFormData>({
resolver: zodResolver(userSchema),
defaultValues: {
name: "",
email: "",
avatar: "",
},
});
useEffect(() => {
if (user) {
form.reset({
name: user.name,
email: user.email,
avatar: user.avatar ?? "",
});
} else {
form.reset({
name: "",
email: "",
avatar: "",
});
}
}, [user, open, form]);
const handleClose = async () => {
const result = await confirm({
title: "Thông báo",
message: "Bạn muốn hủy đăng ký. Mọi dữ liệu bạn nhập sẽ bị mất",
confirmText: "Discard",
cancelText: "Hủy",
variant: "warning",
});
if (!result) return;
setOpenChange(false);
setCaptureRegisterImage(null);
};
const onSubmit = async (values: UserFormData) => {
try {
setLoading(true);
const dataToSubmit: IUser = {
// eslint-disable-next-line react-hooks/purity
id: user?.id || Date.now().toString(),
...values,
avatar: values.avatar || null,
};
const { data } = await checkingApi.register({
user: dataToSubmit,
file: captureRegisterImage,
});
console.log({ data });
onSave?.(dataToSubmit);
setOpenChange(false);
setCaptureRegisterImage(null);
toast.success(data?.message || "Đăng ký thành công !");
} catch (error) {
console.log({ error });
toast.error((error as any)?.message || "Internal Server Error");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (trackingOpen === undefined) return;
setOpenChange(trackingOpen);
}, [trackingOpen]);
useEffect(() => {
if (!captureRegisterImage) return;
return () => {
URL.revokeObjectURL(captureRegisterImage);
};
}, [captureRegisterImage]);
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
handleClose();
return;
}
setOpenChange(true); // mở
}}
>
<DialogTrigger>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
{isEditMode ? "Cập nhật thông tin" : "Tạo người dùng mới"}
</DialogTitle>
<DialogDescription>
{isEditMode
? "Cập nhật thông tin người dùng của bạn"
: "Nhập thông tin để tạo người dùng mới"}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{/* Name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
Tên <span className="text-red-600">*</span>
</FormLabel>
<FormControl>
<Input placeholder="Nhập tên người dùng" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Email */}
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>
Email <span className="text-red-600">*</span>
</FormLabel>
<FormControl>
<Input type="email" placeholder="Nhập email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Avatar */}
<FormField
control={form.control}
name="avatar"
render={({ field }) => (
<FormItem>
<FormLabel>Avatar URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/avatar.jpg"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* File */}
<FormLabel>nh từ camera</FormLabel>
<a target="_blank" href={URL.createObjectURL(captureRegisterImage)}>
<Button
size="sm"
variant="outline"
className="w-full bg-transparent"
type="button"
>
<Camera className="w-4 h-4 mr-2" />
Xem nh
</Button>
</a>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={handleClose}>
Hủy
</Button>
<Button type="submit">
{!loading && isEditMode ? "Cập nhật" : "Tạo mới"}
{loading && <Loader className="animate-spin" />}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,41 @@
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { ClipboardList } from "lucide-react";
import TabLogs from "./tab-log";
export default function RightSlidebar({
isSidebarOpen,
}: {
isSidebarOpen: boolean;
}) {
return (
<div
className={cn(
"fixed right-0 top-0 h-screen w-96 bg-white border-l border-gray-200 shadow-xl transition-transform duration-300 ease-in-out",
isSidebarOpen ? "translate-x-0" : "translate-x-full"
)}
>
<div className="h-full flex flex-col">
<Tabs
value={"logs"}
defaultValue="features"
className="flex-1 flex flex-col"
>
<div className="border-b p-4">
<TabsList className="grid w-full grid-cols-1">
<TabsTrigger
value="logs"
className="flex items-center gap-1 text-xs"
>
<ClipboardList className="size-3.5" />
Log
</TabsTrigger>
</TabsList>
</div>
<TabLogs value="logs" />
</Tabs>
</div>
</div>
);
}

View File

@ -0,0 +1,190 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { checkingApi } from "@/api/checking-api";
import { Button } from "@/components/ui/button";
import { capture, cn, formatTime } from "@/lib/utils";
import useAppStore from "@/stores/use-app-store";
import useUserStore from "@/stores/use-user-store";
import type { AxiosError } from "axios";
import { Camera, Image, Loader, Play, Square } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import Register from "./register";
import { speak } from "@/lib/speak";
export default function TabFeatures() {
const timeoutRef = useRef<any>(null);
const { canvasRef, videoRef } = useAppStore();
const { currentUser, setCurrentUser } = useUserStore();
const { isAutoChecking, setIsAutoChecking, setRefreshLog } = useAppStore();
const autoCheckIntervalRef = useRef<any>(null);
const [loading, setLoading] = useState(false);
const toggleAutoCheck = () => {
if (isAutoChecking) {
if (autoCheckIntervalRef.current) {
clearInterval(autoCheckIntervalRef.current);
autoCheckIntervalRef.current = null;
}
setIsAutoChecking(false);
} else {
autoCheckIntervalRef.current = setInterval(() => {
captureAndCheck();
}, 3000);
setIsAutoChecking(true);
}
};
const createCheckpoint = async () => {
if (!currentUser) return;
try {
const file = await capture(videoRef, canvasRef);
const { data } = await checkingApi.register({ user: currentUser, file });
if (!data) {
toast.error(
(data as any)?.message ||
"Error In Checkpoint: " + JSON.stringify(data)
);
return;
}
toast.success(data?.message || "Tạo checkpoint thành công");
} catch (error) {
const data = error as AxiosError;
toast.error(
(data.response?.data as any)?.message ||
"Error In Checkpoint: " + JSON.stringify(data)
);
}
};
const captureAndCheck = useCallback(async () => {
try {
setLoading(true);
const file = await capture(videoRef, canvasRef);
const { data } = await checkingApi.checkin({ file });
if (!data || !data?.data) {
toast.error(
(data as any)?.message || "Error In Checking: " + JSON.stringify(data)
);
return;
}
if (data?.checking) {
setCurrentUser(data?.data || null);
// Set timeout mới
timeoutRef.current = setTimeout(() => {
setCurrentUser(null);
timeoutRef.current = null;
}, 2000);
}
const message =
(data as any)?.message ||
`Checking thành công lúc: ${formatTime(new Date().toLocaleString())}`;
toast.success(message);
if (!data?.status) return;
speak({ type: data?.status });
setRefreshLog(true);
} catch (error) {
const data = error as AxiosError;
toast.error(
(data.response?.data as any)?.message ||
"Error In Checking: " + JSON.stringify(data)
);
} finally {
setLoading(false);
}
}, [canvasRef, setCurrentUser, videoRef]);
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
useEffect(() => {
const down = (e: KeyboardEvent) => {
if (e.code === "Space") {
// ← cách đúng nhất để detect phím cách
e.preventDefault(); // nếu không muốn scroll
captureAndCheck();
}
};
window.addEventListener("keydown", down);
return () => {
window.removeEventListener("keydown", down);
};
}, [captureAndCheck]);
return (
<div className="absolute bottom-10 px-4 right-0 left-0 grid grid-cols-4 gap-4">
<Button
onClick={captureAndCheck}
disabled={isAutoChecking}
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold"
>
{!loading && (
<>
<Camera className="mr-2 size-4" />
Điểm Danh Ngay
</>
)}
{loading && <Loader className="size-4 animate-spin" />}
</Button>
<Button
onClick={toggleAutoCheck}
variant={isAutoChecking ? "destructive" : "outline"}
className={cn(
"w-full font-semibold",
isAutoChecking && "animate-pulse"
)}
>
{isAutoChecking ? (
<>
<Square className="mr-2 size-4" />
Dừng Tự Đng
</>
) : (
<>
<Play className="mr-2 size-4" />
Tự Đng Điểm Danh
</>
)}
</Button>
<Button
disabled={isAutoChecking}
onClick={createCheckpoint}
className={cn("w-full font-semibold")}
>
<Image />
Tạo Check Point
</Button>
{!currentUser && <Register />}
</div>
);
}

View File

@ -0,0 +1,29 @@
import { Button } from "@/components/ui/button";
import useAppStore from "@/stores/use-app-store";
import { User2 } from "lucide-react";
// import { UserModal } from "../modals/user-modal";
import { CameraNotificationModal } from "../modals/camera-notification-modal";
import { UserModal } from "../modals/user-modal";
export default function Register() {
const { isAutoChecking, captureRegisterImage, isCountDown } = useAppStore();
return (
<>
<CameraNotificationModal>
<Button
onClick={() => {}}
disabled={isAutoChecking}
className="w-full bg-green-600 hover:bg-green-700 text-white font-semibold"
>
<User2 className="mr-2 size-4" />
Tạo User Checking
</Button>
</CameraNotificationModal>
{captureRegisterImage && !isCountDown && (
<UserModal trackingOpen={true} />
)}
</>
);
}

View File

@ -0,0 +1,74 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { checkingApi } from "@/api/checking-api";
import { Badge } from "@/components/ui/badge";
import { TabsContent } from "@/components/ui/tabs";
import { cn, formatTime } from "@/lib/utils";
import useAppStore from "@/stores/use-app-store";
import { ClipboardList } from "lucide-react";
import { useEffect, useState } from "react";
export default function TabLogs({ value }: { value: string }) {
const [logs, setLogs] = useState<ILog[]>([]);
const { refreshLog, setRefreshLog } = useAppStore();
const loadLogs = async () => {
try {
const { data } = await checkingApi.logs();
setLogs(data);
setRefreshLog(false);
} catch (error) {
console.log(error);
}
};
useEffect(() => {
loadLogs();
}, []);
useEffect(() => {
if (!refreshLog) return;
loadLogs();
}, [refreshLog]);
return (
<TabsContent value={value} className="">
<div className="flex flex-col gap-2 flex-1 p-4 space-y-2 overflow-y-auto h-[90vh]">
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<ClipboardList className="size-16 mb-3" />
<p>Chưa dữ liệu điểm danh</p>
</div>
) : (
logs.map((log, index) => (
<div
key={index}
className={cn(
"p-3 rounded-lg border transition-all duration-200",
index === 0
? "bg-blue-50 border-blue-200"
: "bg-gray-50 border-gray-200"
)}
>
<div className="flex items-center justify-between mb-1">
<span className="font-medium text-gray-900">{log.name}</span>
<Badge
className="capitalize"
variant={
log.status === "check out" ? "destructive" : "secondary"
}
>
{log.status}
</Badge>
</div>
<p className="text-sm text-gray-600">{formatTime(log.time)}</p>
</div>
))
)}
</div>
</TabsContent>
);
}

View File

@ -0,0 +1,147 @@
/* eslint-disable no-constant-binary-expression */
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import type React from "react";
import { checkingApi } from "@/api/checking-api";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TabsContent } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import useUserStore from "@/stores/use-user-store";
import { Edit, MoreVertical, Trash2, UserCheck, Users } from "lucide-react";
import { useEffect, useState } from "react";
export default function TabUsers({ value }: { value: string }) {
const [users, setUsers] = useState<IUser[]>([]);
const { currentUser, setCurrentUser } = useUserStore();
const loadUsers = async () => {
try {
const { data } = await checkingApi.users();
setUsers(data);
} catch (error) {
console.log(error);
}
};
const toggle = (data: IUser) => {
if (currentUser) {
if (data.id === currentUser.id) {
setCurrentUser(null);
} else {
setCurrentUser(data);
}
} else {
setCurrentUser(data);
}
};
const handleEdit = (user: IUser, e: React.MouseEvent) => {
e.stopPropagation();
console.log("Edit user:", user);
// TODO: Implement edit functionality
};
const handleDelete = (user: IUser, e: React.MouseEvent) => {
e.stopPropagation();
console.log("Delete user:", user);
// TODO: Implement delete functionality
};
const handleViewDetails = (user: IUser, e: React.MouseEvent) => {
e.stopPropagation();
console.log("View details:", user);
// TODO: Implement view details functionality
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
loadUsers();
}, []);
return (
<TabsContent value={value} className="">
<div className="flex flex-col gap-2 flex-1 p-4 space-y-2 overflow-y-auto h-[90vh]">
{users.map((user) => (
<Card
key={user.id}
className={cn(
"p-4 cursor-pointer transition-all duration-200 hover:shadow-md hover:scale-[1.01] select-none",
currentUser?.id === user.id &&
"bg-blue-50 dark:bg-blue-950 border-blue-500 shadow-md"
)}
onClick={() => toggle(user)}
>
<div className="flex items-center gap-3">
<Avatar className="size-12">
<AvatarImage
src={
`https://ms.prology.net/image/storage/${user?.avatar}` || ""
}
/>
<AvatarFallback> {user.name.charAt(0)}</AvatarFallback>
</Avatar>
<div className="flex-1">
<h4 className="font-semibold text-gray-900 dark:text-gray-100">
{user.name}
</h4>
<p className="text-sm text-gray-600 dark:text-gray-400">
{user.email}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger
asChild
onClick={(e) => e.stopPropagation()}
>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Mở menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={(e) => handleViewDetails(user, e)}>
<UserCheck className="mr-2 h-4 w-4" />
<span>Xem chi tiết</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => handleEdit(user, e)}>
<Edit className="mr-2 h-4 w-4" />
<span>Chỉnh sửa</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDelete(user, e)}
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Xóa</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Card>
))}
</div>
{users.length <= 0 && (
<div className="flex flex-col items-center justify-center h-full text-gray-400">
<Users className="size-16 mb-3" />
<p>Chưa dữ liệu điểm danh</p>
</div>
)}
</TabsContent>
);
}

View File

@ -0,0 +1,192 @@
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import { checkingApi } from "@/api/checking-api";
import { msApi } from "@/api/ms-api";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { capture, cn } from "@/lib/utils";
import useAppStore from "@/stores/use-app-store";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import CountDown from "./components/count-down";
import LeftSlidebar from "./components/left-slidebar";
import RightSlidebar from "./components/right-slidebar";
import TabFeatures from "./components/tab-features";
export default function Main() {
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const [isLeftSidebarOpen, setIsLeftSidebarOpen] = useState(true);
// const { currentUser, setCurrentUser } = useUserStore();
const { setCanvasRef, setVideoRef } = useAppStore();
const { isCountDown, setCaptureRegisterImage, setIsCountDown } =
useAppStore();
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const sync = async () => {
try {
const { data } = await msApi.timekeepings();
const users = (data?.data as any[]) || [];
const mappd = users.map((user) => {
return {
name: user?.user?.name,
email: user?.user?.email,
avatar: user?.user?.avatar,
} as IUser;
});
await Promise.all(
mappd.map((user) => {
return checkingApi.registerSimple({
user: user,
});
})
);
console.log("Sync hoàn tất!");
} catch (error) {
console.log("Sync error:", error);
}
};
// 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(() => {
sync();
}, []);
useEffect(() => {
setCanvasRef(canvasRef);
setVideoRef(videoRef);
}, [videoRef, canvasRef]);
return (
<div className="min-h-screen bg-white">
<div className="flex h-screen">
<LeftSlidebar isSidebarOpen={isLeftSidebarOpen} />
<div
className={cn(
"flex-1 transition-all duration-300 ease-in-out",
isLeftSidebarOpen && "ml-96",
isSidebarOpen && "mr-96"
)}
>
<div className="h-full flex flex-col p-6">
{/* Video Feed */}
<Card className="flex-1 overflow-hidden bg-black relative group">
<video
ref={videoRef}
autoPlay
playsInline
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 border-4 border-blue-500/30 pointer-events-none" />
{/* <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-4 bg-white/90 hover:bg-white shadow-lg"
>
{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-4 bg-white/90 hover:bg-white shadow-lg"
>
{isSidebarOpen ? (
<ChevronRight className="size-4" />
) : (
<ChevronLeft className="size-4" />
)}
</Button>
<TabFeatures />
</Card>
</div>
</div>
<RightSlidebar isSidebarOpen={isSidebarOpen} />
{/* Hidden Canvas for Capture */}
<canvas ref={canvasRef} className="hidden" />
</div>
</div>
);
}

View File

@ -0,0 +1,38 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// src/stores/useUserStore.ts
import { create } from "zustand";
type AppState = {
isAutoChecking: boolean;
isCountDown: boolean;
refreshLog: boolean;
captureRegisterImage: any;
videoRef: any;
canvasRef: any;
// actions
setIsAutoChecking: (data: boolean) => void;
setIsCountDown: (data: boolean) => void;
setRefreshLog: (data: boolean) => void;
setVideoRef: (data: any) => void;
setCanvasRef: (data: any) => void;
setCaptureRegisterImage: (data: any) => void;
};
const useAppStore = create<AppState>((set) => ({
isAutoChecking: false,
isCountDown: false,
captureRegisterImage: null,
canvasRef: null,
videoRef: null,
refreshLog: false,
setIsAutoChecking: (data) => set({ isAutoChecking: data }),
setRefreshLog: (data) => set({ refreshLog: data }),
setIsCountDown: (data) => set({ isCountDown: data }),
setCaptureRegisterImage: (data) => set({ captureRegisterImage: data }),
setVideoRef: (data) => set({ videoRef: data }),
setCanvasRef: (data) => set({ canvasRef: data }),
}));
export default useAppStore;

View File

@ -0,0 +1,16 @@
// src/stores/useUserStore.ts
import { create } from "zustand";
type UserState = {
currentUser: IUser | null;
// actions
setCurrentUser: (data: IUser | null) => void;
};
const useUserStore = create<UserState>((set) => ({
currentUser: null,
setCurrentUser: (data) => set(() => ({ currentUser: data })),
}));
export default useUserStore;

View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,20 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [
react({
babel: {
plugins: [["babel-plugin-react-compiler"]],
},
}),
tailwindcss(),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});

View File

@ -0,0 +1,8 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
DATABASE_URL = "mysql+pymysql://root:123@localhost/face_checkin?charset=utf8mb4"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine, autoflush=False)
Base = declarative_base()

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

369
TrackingToolWeb/main.py Normal file
View File

@ -0,0 +1,369 @@
from fastapi import FastAPI, UploadFile, File, Form, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
import face_recognition
import numpy as np
import os
import datetime
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from database import SessionLocal, engine
from models import Base, Student, CheckInLog, StudentEncoding
from sqlalchemy.exc import IntegrityError
from sqlalchemy import text
from fastapi.middleware.cors import CORSMiddleware
from api import create_history, send_image
app = FastAPI()
# --- CORS ---
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Base.metadata.create_all(bind=engine)
UPLOAD_DIR = "./uploads"
os.makedirs(UPLOAD_DIR, exist_ok=True)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
def root():
return FileResponse("static/index.html")
@app.post("/register")
async def register_face(
name: str = Form(...),
email: str = Form(...),
avatar: str = Form(None), # OPTIONAL
file: UploadFile = File(...)
):
db = SessionLocal()
# Check duplicate email
existing_email = db.execute(
text("SELECT id FROM students WHERE email = :email"),
{"email": email}
).fetchone()
# Save image
image_data = await file.read()
image_path = f"./uploads/{file.filename}"
with open(image_path, "wb") as f:
f.write(image_data)
# Encode face
image = face_recognition.load_image_file(image_path)
encodings = face_recognition.face_encodings(image)
if not encodings:
db.close()
return JSONResponse(
content={"message": "Không phát hiện khuôn mặt."},
status_code=400
)
encoding_bytes = encodings[0].tobytes()
try:
if existing_email:
# Email exists → just add new encoding
student_id = existing_email[0]
db.execute(
text("""
INSERT INTO student_encodings (student_id, encoding)
VALUES (:student_id, :encoding)
"""),
{"student_id": student_id, "encoding": encoding_bytes}
)
db.commit()
return {"message": "Đã thêm encoding mới."}
else:
# Insert new student (avatar nullable)
db.execute(
text("""
INSERT INTO students (name, email, avatar)
VALUES (:name, :email, :avatar)
"""),
{
"name": name,
"email": email,
"avatar": avatar,
}
)
db.commit()
student_id = db.execute(text("SELECT LAST_INSERT_ID()")).fetchone()[0]
# Insert encoding
db.execute(
text("""
INSERT INTO student_encodings (student_id, encoding)
VALUES (:student_id, :encoding)
"""),
{"student_id": student_id, "encoding": encoding_bytes}
)
db.commit()
return {"message": "Đăng ký thành công."}
except IntegrityError:
db.rollback()
raise HTTPException(status_code=400, detail="Email đã tồn tại.")
finally:
db.close()
@app.post("/register-simple")
async def register_student(
name: str = Form(...),
email: str = Form(...),
avatar: str = Form(None), # OPTIONAL
):
db = SessionLocal()
try:
# Kiểm tra xem student đã tồn tại chưa
existing = db.execute(
text("SELECT id FROM students WHERE email = :email"),
{"email": email}
).fetchone()
if existing:
# UPDATE
db.execute(
text("""
UPDATE students
SET name = :name,
avatar = :avatar
WHERE email = :email
"""),
{
"name": name,
"avatar": avatar,
"email": email
}
)
db.commit()
return JSONResponse({"message": "Cập nhật thành công."}, status_code=200)
else:
# INSERT
db.execute(
text("""
INSERT INTO students (name, email, avatar)
VALUES (:name, :email, :avatar)
"""),
{
"name": name,
"email": email,
"avatar": avatar
}
)
db.commit()
return JSONResponse({"message": "Đăng ký thành công."}, status_code=201)
except IntegrityError:
db.rollback()
return JSONResponse(
{"message": "Lỗi cơ sở dữ liệu."},
status_code=400
)
finally:
db.close()
@app.post("/checkin")
async def checkin(file: UploadFile = File(...), camera_id: str = Form("cam1"), db: Session = Depends(get_db)):
image_data = await file.read()
path = os.path.join(UPLOAD_DIR, "checkin.jpg")
with open(path, "wb") as f:
f.write(image_data)
unknown_img = face_recognition.load_image_file(path)
unknown_encodings = face_recognition.face_encodings(unknown_img)
if not unknown_encodings:
return {"message": "No face detected."}
unknown_encoding = unknown_encodings[0]
# Get all encodings with student info
encodings = db.execute(
text("""
SELECT s.id, s.name, s.email, s.avatar, se.encoding
FROM student_encodings se
JOIN students s ON s.id = se.student_id
""")
).fetchall()
for encoding in encodings:
known_encoding = np.frombuffer(encoding.encoding)
result = face_recognition.compare_faces([known_encoding], unknown_encoding, tolerance=0.5)
if result[0]:
now = datetime.datetime.now()
# Check recent checkin
recent_check = db.execute(
text("""
SELECT id FROM checkin_logs
WHERE student_id = :student_id
AND time > :time_threshold
"""),
{
"student_id": encoding.id,
"time_threshold": now - datetime.timedelta(minutes=5)
}
).fetchone()
if recent_check:
return {
"message": f"{encoding.name} already checked in recently.",
"checking": False,
"data": {
"id": encoding.id,
"name": encoding.name,
"email": encoding.email,
"avatar": encoding.avatar,
"camera_id": camera_id,
"time": now.isoformat()
}
}
# thêm dô đây
id_log = 0
ms_response = create_history({"name": encoding.name.split('\n')[0], "time_string": f"{datetime.datetime.now()}", "status": "check in"})
id_log = ms_response.get('data').get('id')
status = ms_response.get('data').get('status')
# reset pointer
file.file.seek(0)
send_image_res = send_image(
id=id_log,
file=file,
student_name=encoding.name,
status=status
)
print(id_log, send_image_res)
# Insert new checkin
db.execute(
text("""
INSERT INTO checkin_logs (student_id, time, camera_id, status)
VALUES (:student_id, :time, :camera_id, :status)
"""),
{
"student_id": encoding.id,
"time": now,
"camera_id": camera_id,
"status": status
}
)
db.commit()
return {
"message": f"Check-in successful for {encoding.name}",
"checking": True,
"status": status,
"data": {
"id": encoding.id,
"name": encoding.name,
"email": encoding.email,
"avatar": encoding.avatar,
"camera_id": camera_id,
"time": now.isoformat()
}
}
return {"message": "No match found."}
@app.get("/logs")
def get_logs(db: Session = Depends(get_db)):
logs = db.execute(
text("""
SELECT s.name, cl.time, cl.camera_id, cl.status
FROM checkin_logs cl
JOIN students s ON cl.student_id = s.id
ORDER BY cl.time DESC
LIMIT 20
""")
).fetchall()
result = []
for log in logs:
result.append({
"name": log.name,
"time": log.time.strftime("%Y-%m-%d %H:%M:%S"),
"camera_id": log.camera_id,
"status": log.status
})
return result
@app.get("/users")
def get_users(db: Session = Depends(get_db)):
# Lấy danh sách student
students = db.execute(
text("""
SELECT id, name, email, avatar
FROM students
ORDER BY name DESC
""")
).fetchall()
result = []
for stu in students:
student_id = stu.id
# Lấy tối đa 5 checkpoint mới nhất
checkpoints = db.execute(
text("""
SELECT id, time, camera_id
FROM checkin_logs
WHERE student_id = :sid
ORDER BY time DESC
LIMIT 5
"""),
{"sid": student_id}
).fetchall()
result.append({
"id": stu.id,
"name": stu.name,
"email": stu.email,
"avatar": stu.avatar,
"checkpoints": [
{
"id": c.id,
"time": c.time,
"camera_id": c.camera_id
}
for c in checkpoints
]
})
return result

38
TrackingToolWeb/models.py Normal file
View File

@ -0,0 +1,38 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, LargeBinary, UniqueConstraint
from sqlalchemy.orm import relationship
from database import Base
import datetime
class Student(Base):
__tablename__ = "students"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
email = Column(String(100), nullable=False, unique=True, index=True)
avatar = Column(String(500), nullable=True, unique=True, index=True)
__table_args__ = (UniqueConstraint('email', name='uq_student_email'),)
checkins = relationship("CheckInLog", back_populates="student")
encodings = relationship("StudentEncoding", back_populates="student")
class StudentEncoding(Base):
__tablename__ = "student_encodings"
id = Column(Integer, primary_key=True, index=True)
student_id = Column(Integer, ForeignKey("students.id"))
encoding = Column(LargeBinary, nullable=False)
created_at = Column(DateTime, default=datetime.datetime.utcnow)
student = relationship("Student", back_populates="encodings")
class CheckInLog(Base):
__tablename__ = "checkin_logs"
id = Column(Integer, primary_key=True, index=True)
student_id = Column(Integer, ForeignKey("students.id"))
time = Column(DateTime, default=datetime.datetime.utcnow)
status = Column(String(100), nullable=True, unique=True)
camera_id = Column(String(100))
student = relationship("Student", back_populates="checkins")

6
TrackingToolWeb/package-lock.json generated Normal file
View File

@ -0,0 +1,6 @@
{
"name": "school-checkin",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@ -0,0 +1,12 @@
fastapi
uvicorn
sqlalchemy
face_recognition
numpy
opencv-python
requests
pymysql
# pip install -r requirements.txt
# sudo apt-get install cmake or brew install cmake
# pip install dlib
# pip install setuptools

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
<script
type="module"
crossorigin
src="/static/assets/index-Cbkb3kfK.js"
></script>
<link
rel="stylesheet"
crossorigin
href="/static/assets/index-CvR5W1c8.css"
/>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,432 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Face Check-In / Register</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background-color: #f5f7fa;
color: #333;
}
h2 {
color: #2c3e50;
text-align: center;
margin-bottom: 20px;
border-bottom: 2px solid #3498db;
padding-bottom: 10px;
}
video {
width: 100%;
max-width: 1200px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
display: block;
margin: 0 auto;
}
input[type="text"], input[type="email"] {
width: 100%;
padding: 12px;
margin: 8px 0;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
font-size: 16px;
transition: border-color 0.3s;
}
input[type="text"]:focus, input[type="email"]:focus {
border-color: #3498db;
outline: none;
box-shadow: 0 0 5px rgba(52, 152, 219, 0.5);
}
button {
background-color: #3498db;
color: white;
border: none;
padding: 12px 20px;
margin: 10px 5px;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
button:hover {
background-color: #2980b9;
}
#register {
background-color: #2ecc71;
}
#register:hover {
background-color: #27ae60;
}
.container {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.form-group {
margin-bottom: 15px;
}
.button-group {
display: flex;
justify-content: center;
margin-top: 20px;
gap: 10px;
}
#auto-checkin {
background-color: #f39c12;
}
#auto-checkin:hover {
background-color: #d35400;
}
#auto-checkin.active {
background-color: #e74c3c;
}
.logs-container {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
th, td {
padding: 12px;
text-align: left;
border-bottom: 1px solid #ddd;
}
th {
background-color: #f2f2f2;
color: #333;
font-weight: bold;
}
tr:hover {
background-color: #f5f5f5;
}
.refresh-btn {
background-color: #9b59b6;
margin-bottom: 15px;
}
.refresh-btn:hover {
background-color: #8e44ad;
}
.empty-logs {
text-align: center;
color: #7f8c8d;
padding: 20px;
font-style: italic;
}
.main-layout {
display: flex;
gap: 20px;
max-width: 1600px;
margin: 0 auto;
}
.left-panel {
flex: 6;
}
.right-panel {
flex: 5;
}
@media (max-width: 768px) {
.main-layout {
flex-direction: column;
}
}
.newest-log {
background-color: #e8f8f5;
font-weight: bold;
animation: highlight 2s ease-in-out;
}
@keyframes highlight {
0% { background-color: #d4efdf; }
50% { background-color: #a9dfbf; }
100% { background-color: #e8f8f5; }
}
.shortcut-hint {
text-align: center;
margin-top: 10px;
color: #7f8c8d;
font-style: italic;
font-size: 14px;
}
.custom-alert {
position: fixed;
top: 20px;
right: 20px;
color: white;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
font-family: sans-serif;
font-size: 16px;
z-index: 9999;
animation: slideIn 0.3s ease-out;
}
.alert-success {
background-color: #34d399; /* green-400 */
}
.alert-error {
background-color: #f87171; /* red-400 */
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
</head>
<body>
<div class="main-layout">
<div class="left-panel">
<div class="container">
<h2>📸 Face Camera</h2>
<div class="video-container" style="position: relative; display: flex; justify-content: center; align-items: center;">
<video id="video" autoplay style="width: 100%;"></video>
<img src="http://127.0.0.1:8000/static/face-removebg-preview.png" style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 60%; height: auto; pointer-events: none; opacity: 0.5;" alt="Hướng dẫn nhận diện khuôn mặt">
</div>
<br>
<div class="form-group">
<input type="text" id="name" placeholder="Tên học sinh (khi đăng ký)">
</div>
<div class="form-group">
<input type="email" id="email" placeholder="Email học sinh (bắt buộc)">
</div>
<div class="button-group">
<button id="register">📥 Đăng ký khuôn mặt</button>
<button id="checkin">✅ Điểm danh</button>
<button id="auto-checkin">🔄 Tự động điểm danh</button>
</div>
<div class="shortcut-hint">
Nhấn phím Space để điểm danh nhanh
</div>
</div>
</div>
<div class="right-panel">
<div class="logs-container">
<h2>📋 Lịch sử điểm danh</h2>
<button id="refresh-logs" class="refresh-btn">🔄 Làm mới dữ liệu</button>
<div id="logs-table-container">
<table id="logs-table">
<thead>
<tr>
<th>Tên học sinh</th>
<th>Thời gian</th>
<th>Camera ID</th>
</tr>
</thead>
<tbody id="logs-body">
<!-- Dữ liệu logs sẽ được thêm vào đây -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<canvas id="canvas" width="1200" height="900" style="display:none;"></canvas>
<script>
const video = document.getElementById('video');
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
// Mở camera
navigator.mediaDevices.getUserMedia({ video: true })
.then((stream) => {
video.srcObject = stream;
})
.catch((err) => {
showAlert("Không mở được camera: " + err);
});
function showAlert(text, status = 'error') {
const alertDiv = document.createElement('div');
alertDiv.className = `custom-alert alert-${status}`;
alertDiv.innerText = text;
document.body.appendChild(alertDiv);
setTimeout(() => {
alertDiv.style.opacity = '0';
alertDiv.style.transition = 'opacity 0.5s ease-out';
setTimeout(() => {
alertDiv.remove();
}, 500);
}, 2000);
}
// Hàm chụp ảnh từ video và gửi đến API
function sendImage(url, extraData = {}) {
context.drawImage(video, 0, 0, 1200, 900);
canvas.toBlob((blob) => {
const formData = new FormData();
formData.append("file", blob, "frame.jpg");
for (const [key, value] of Object.entries(extraData)) {
formData.append(key, value);
}
fetch(url, {
method: "POST",
body: formData
})
.then(res => res.json())
.then(data => {
const status = data.message && data.message.includes("successful") ? 'success' : 'error';
showAlert(data.message || JSON.stringify(data), status);
if (url === "/checkin" && data.message && data.message.includes("successful")) {
loadLogs(); // Tải lại logs sau khi check-in thành công
}
})
.catch(err => showAlert("Lỗi gửi ảnh: " + err, 'error'));
}, "image/jpeg");
}
// Sự kiện: Đăng ký
document.getElementById('register').addEventListener('click', () => {
const name = document.getElementById('name').value.trim();
const email = document.getElementById('email').value.trim();
if (!name || !email) {
showAlert("Vui lòng nhập cả tên và email.", 'error');
return;
}
sendImage("/register", { name, email });
});
let autoCheckinInterval = null;
const autoCheckinButton = document.getElementById('auto-checkin');
// Hàm bật/tắt tự động điểm danh
function toggleAutoCheckin() {
if (autoCheckinInterval) {
// Tắt tự động
clearInterval(autoCheckinInterval);
autoCheckinInterval = null;
autoCheckinButton.classList.remove('active');
autoCheckinButton.textContent = '🔄 Tự động điểm danh';
} else {
// Bật tự động
autoCheckinInterval = setInterval(() => {
sendImage("/checkin", { camera_id: "webcam" });
}, 1000); // Gửi mỗi giây
autoCheckinButton.classList.add('active');
autoCheckinButton.textContent = '⏹️ Dừng tự động';
}
}
// Sự kiện: Bật/tắt tự động điểm danh
autoCheckinButton.addEventListener('click', toggleAutoCheckin);
// Sự kiện: Check-in
document.getElementById('checkin').addEventListener('click', () => {
if (autoCheckinInterval) {
toggleAutoCheckin(); // Tắt tự động nếu đang bật
}
sendImage("/checkin", { camera_id: "webcam" });
});
// Sự kiện: Nhấn phím Space để điểm danh
document.addEventListener('keydown', (event) => {
if (event.code === 'Space' || event.keyCode === 32) {
event.preventDefault();
if (autoCheckinInterval) {
toggleAutoCheckin(); // Tắt tự động nếu đang bật
}
sendImage("/checkin", { camera_id: "webcam" });
}
});
// Hàm tải logs từ server
function loadLogs() {
fetch("/logs")
.then(res => res.json())
.then(logs => {
const logsBody = document.getElementById('logs-body');
logsBody.innerHTML = '';
if (logs.length === 0) {
logsBody.innerHTML = '<tr><td colspan="3" class="empty-logs">Chưa có dữ liệu điểm danh</td></tr>';
return;
}
// Sắp xếp logs từ mới đến cũ
logs.sort((a, b) => new Date(b.time) - new Date(a.time));
logs.forEach((log, index) => {
// Highlight log mới nhất
const isNewest = index === 0;
const row = document.createElement('tr');
if (isNewest) {
row.classList.add('newest-log');
}
row.innerHTML = `
<td>${log.name}</td>
<td>${log.time}</td>
<td>${log.camera_id}</td>
`;
logsBody.appendChild(row);
});
})
.catch(err => {
console.error("Lỗi tải logs:", err);
document.getElementById('logs-body').innerHTML =
'<tr><td colspan="3" class="empty-logs">Lỗi khi tải dữ liệu</td></tr>';
});
}
// Sự kiện: Làm mới logs
document.getElementById('refresh-logs').addEventListener('click', loadLogs);
// Tải logs khi trang được tải
document.addEventListener('DOMContentLoaded', loadLogs);
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB