first commit

This commit is contained in:
fanferr 2026-05-20 15:53:27 +07:00
parent fe9ba69cd4
commit 59e5ee9cba
71 changed files with 15112 additions and 1927 deletions

24
frontend-old/.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?

16
frontend-old/README.md Normal file
View File

@ -0,0 +1,16 @@
# React + 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 not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
}
}

6289
frontend-old/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

48
frontend-old/package.json Normal file
View File

@ -0,0 +1,48 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/postcss": "^4.1.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"firebase": "^12.11.0",
"jiti": "^2.6.1",
"jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"recharts": "^3.8.1",
"socket.io-client": "^4.8.3",
"tailwind-merge": "^3.4.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

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 @@
<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,232 @@
import React, { useState, useEffect } from 'react';
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import { rtdb } from '../firebase';
import { ref, get } from 'firebase/database';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { TrendingUp, Calendar, Zap, Target } from 'lucide-react';
const HistoryModal = ({ athlete, isOpen, onClose }) => {
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
const [debugKeys, setDebugKeys] = useState([]);
useEffect(() => {
if (!isOpen || !athlete) return;
setLoading(true);
setDebugKeys(["Mencari..."]);
const possibleIds = new Set([
athlete.id,
athlete.id?.toLowerCase(),
athlete.id?.toUpperCase(),
athlete.rfid_tag,
athlete.rfid_tag?.toLowerCase(),
athlete.rfid_tag?.toUpperCase()
].filter(Boolean));
const paths = [];
possibleIds.forEach(id => {
paths.push(`test_history/${id}`);
paths.push(`attempts/${id}`);
});
const checkPaths = async () => {
let finalData = null;
for (const path of paths) {
try {
const historyRef = ref(rtdb, path);
const snapshot = await get(historyRef);
const data = snapshot.val();
if (data) {
finalData = data;
setDebugKeys(Object.keys(data));
break; // Berhenti jika sudah ketemu data yang valid
}
} catch (err) {
setDebugKeys(["Error: " + err.message]);
}
}
if (finalData) {
const attemptArray = [];
// Cek apakah data bersarang (Session -> Attempt) atau langsung Attempt
Object.keys(finalData).forEach(sessionKey => {
const sessionData = finalData[sessionKey];
if (sessionKey.startsWith('session_') && typeof sessionData === 'object') {
// Ambil timestamp dari nama folder session (format: session_177xxxxxxxxx)
const sessionTimestamp = parseInt(sessionKey.replace('session_', ''));
// Cari nilai tertinggi dalam sesi ini (dari attempt_1, 2, 3)
let sessionMax = 0;
let sessionMaxType = '';
Object.keys(sessionData).forEach(attemptKey => {
if (attemptKey.startsWith('attempt_')) {
const item = sessionData[attemptKey];
const score = item.peak_newton || item.force || 0;
if (Number(score) > sessionMax) {
sessionMax = Number(score);
sessionMaxType = item.type || 'pukulan';
}
}
});
if (sessionMax > 0) {
const ts = (sessionTimestamp > 1000000) ? sessionTimestamp : Date.now();
const typeLabel = sessionMaxType.charAt(0).toUpperCase() + sessionMaxType.slice(1);
attemptArray.push({
date: new Date(ts).toLocaleDateString('id-ID', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }),
force: Number(sessionMax.toFixed(1)),
timestamp: ts,
label: `${typeLabel} Peak - ${new Date(ts).toLocaleDateString('id-ID', { hour: '2-digit', minute: '2-digit' })}`
});
}
} else if (sessionKey.startsWith('attempt_')) {
// Jika struktur langsung (Legacy/Lama)
const item = sessionData;
const score = item.peak_newton || item.force || 0;
const ts = (item.timestamp > 1000000) ? item.timestamp : Date.now();
attemptArray.push({
date: new Date(ts).toLocaleDateString('id-ID', { day: '2-digit', month: 'short' }),
force: Number(Number(score).toFixed(1)),
timestamp: ts,
label: `Legacy Test #${sessionKey.replace('attempt_', '')}`
});
}
});
if (attemptArray.length > 0) {
const sorted = attemptArray.sort((a, b) => a.timestamp - b.timestamp);
setHistory(sorted);
} else {
setHistory([]);
}
} else {
setHistory([]);
setDebugKeys(["ID Tidak Ditemukan"]);
}
setLoading(false);
};
checkPaths();
}, [isOpen, athlete]);
const maxPower = history.length > 0 ? Math.max(...history.map(d => d.force)) : 0;
const avgPower = history.length > 0 ? (history.reduce((acc, curr) => acc + curr.force, 0) / history.length).toFixed(1) : 0;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-3xl bg-white/95 backdrop-blur-xl border-black/5">
<DialogHeader>
<DialogTitle className="flex items-center gap-3 text-2xl font-bold">
<TrendingUp className="text-primary w-6 h-6" />
Riwayat Performa: {athlete?.name}
</DialogTitle>
<DialogDescription>
Grafik perkembangan kekuatan atlet berdasarkan data latihan yang tercatat.
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-blue-50 p-4 rounded-2xl border border-blue-100">
<div className="flex items-center gap-2 text-blue-600 text-xs font-bold uppercase tracking-wider mb-1">
<Zap size={14} /> Max Power
</div>
<div className="text-2xl font-black text-slate-900">{maxPower} <span className="text-sm font-normal">N</span></div>
</div>
<div className="bg-indigo-50 p-4 rounded-2xl border border-indigo-100">
<div className="flex items-center gap-2 text-indigo-600 text-xs font-bold uppercase tracking-wider mb-1">
<Target size={14} /> Avg Power
</div>
<div className="text-2xl font-black text-slate-900">{avgPower} <span className="text-sm font-normal">N</span></div>
</div>
<div className="bg-slate-50 p-4 rounded-2xl border border-slate-100">
<div className="flex items-center gap-2 text-slate-500 text-xs font-bold uppercase tracking-wider mb-1">
<Calendar size={14} /> Total Sesi
</div>
<div className="text-2xl font-black text-slate-900">{history.length} <span className="text-sm font-normal">Kali</span></div>
</div>
</div>
<div className="h-[300px] w-full mt-2">
{loading ? (
<div className="w-full h-full flex items-center justify-center text-slate-400">
Memuat data grafik...
</div>
) : history.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={history}>
<defs>
<linearGradient id="colorForce" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#2563eb" stopOpacity={0.3}/>
<stop offset="95%" stopColor="#2563eb" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f1f5f9" />
<XAxis
dataKey="date"
fontSize={10}
tickLine={false}
axisLine={false}
tickFormatter={(val) => val.split(' ')[0]}
/>
<YAxis
fontSize={10}
tickLine={false}
axisLine={false}
tickFormatter={(val) => `${val}N`}
/>
<Tooltip
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-white p-4 rounded-2xl shadow-2xl border border-slate-50 flex flex-col gap-1">
<p className="text-[10px] font-black uppercase text-slate-400 tracking-widest">{data.label}</p>
<p className="text-xl font-black italic text-primary">{data.force} <span className="text-xs font-normal">Newton</span></p>
</div>
);
}
return null;
}}
/>
<Area
type="monotone"
dataKey="force"
stroke="#2563eb"
strokeWidth={3}
fillOpacity={1}
fill="url(#colorForce)"
animationDuration={1500}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="w-full h-full flex flex-col items-center justify-center text-slate-400 gap-2 border-2 border-dashed border-slate-100 rounded-2xl">
<Zap size={32} className="text-slate-200" />
Belum ada data riwayat untuk atlet ini.
<div className="mt-4 p-4 bg-slate-900 rounded-xl text-[10px] font-mono text-emerald-400 border border-white/10 w-full max-w-md overflow-hidden text-left">
<div className="text-white font-bold mb-2 uppercase border-b border-white/10 pb-1">Debug Info:</div>
<div> Target ID: <span className="text-white">{athlete?.id}</span></div>
<div> Items Found: <span className="text-white">{history.length}</span></div>
<div> Folder Keys: <span className="text-yellow-400">{debugKeys.join(', ')}</span></div>
<div> DB URL: <span className="text-white">{rtdb.app.options.databaseURL}</span></div>
<div className="mt-2 text-slate-500 italic">
Jika "Items Found" adalah 0 padahal di Firebase ada, kemungkinan struktur data di dalam folder ID tidak sesuai standar (attempt_N).
</div>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default HistoryModal;

View File

@ -0,0 +1,31 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({ className, variant, ...props }) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,47 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
})
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,57 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,89 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }) => (
<div
className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({ className, ...props }) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
})
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,19 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@ -0,0 +1,137 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,25 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,52 @@
import { createContext, useContext, useState, useEffect } from "react";
const AuthContext = createContext();
export const useAuth = () => useContext(AuthContext);
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Check session storage for existing session
const token = sessionStorage.getItem("adminToken");
const username = sessionStorage.getItem("adminUsername");
if (token) {
setUser({ role: "admin", username: username });
}
setLoading(false);
}, []);
const login = async (username, password) => {
try {
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const response = await fetch(`${API_URL}/api/admin/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (data.success) {
sessionStorage.setItem("adminToken", data.token);
sessionStorage.setItem("adminUsername", data.username);
setUser({ role: "admin", username: data.username });
return { success: true };
}
return { success: false, message: data.message };
} catch (error) {
return { success: false, message: "Login failed" };
}
};
const logout = () => {
sessionStorage.removeItem("adminToken");
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{!loading && children}
</AuthContext.Provider>
);
};

View File

@ -0,0 +1,34 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
import { io } from 'socket.io-client';
const SocketContext = createContext();
export const useSocket = () => {
const context = useContext(SocketContext);
if (context === undefined) {
throw new Error('useSocket must be used within a SocketProvider');
}
return context;
};
export const SocketProvider = ({ children }) => {
const [socket, setSocket] = useState(null);
useEffect(() => {
const SOCKET_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const newSocket = io(SOCKET_URL);
setSocket(newSocket);
newSocket.on('connect', () => {
console.log('Connected to backend socket');
});
return () => newSocket.close();
}, []);
return (
<SocketContext.Provider value={socket}>
{children}
</SocketContext.Provider>
);
};

View File

@ -0,0 +1,17 @@
import { initializeApp } from "firebase/app";
import { getDatabase } from "firebase/database";
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID
};
const app = initializeApp(firebaseConfig);
const rtdb = getDatabase(app);
export { rtdb };

View File

@ -0,0 +1,6 @@
import { clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs))
}

55
frontend/.gitignore vendored
View File

@ -1,24 +1,41 @@
# Logs
logs
*.log
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.pnpm-debug.log*
node_modules
dist
dist-ssr
*.local
# env files (can opt-in for committing if needed)
.env*
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

5
frontend/AGENTS.md Normal file
View File

@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

1
frontend/CLAUDE.md Normal file
View File

@ -0,0 +1 @@
@AGENTS.md

View File

@ -1,16 +1,36 @@
# React + Vite
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Getting Started
Currently, two official plugins are available:
First, run the development server:
- [@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
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
## React Compiler
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
## Expanding the ESLint configuration
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@ -0,0 +1,16 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
const eslintConfig = defineConfig([
...nextVitals,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@ -1,10 +1,7 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
}

6
frontend/next.config.mjs Normal file
View File

@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
};
export default nextConfig;

File diff suppressed because it is too large Load Diff

View File

@ -1,13 +1,12 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.15",
@ -15,34 +14,24 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/postcss": "^4.1.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"firebase": "^12.11.0",
"jiti": "^2.6.1",
"jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"firebase": "^12.13.0",
"jspdf": "^4.2.1",
"jspdf-autotable": "^5.0.8",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.8.1",
"socket.io-client": "^4.8.3",
"tailwind-merge": "^3.4.0",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1"
"@tailwindcss/postcss": "^4",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"tailwindcss": "^4"
}
}

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
frontend/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
frontend/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,97 @@
"use client";
import React, { useEffect } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/context/AuthContext';
import { Activity, UserPlus, Trophy, LayoutDashboard, Monitor as MonitorIcon, LogOut, Settings as SettingsIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
const NavLink = ({ href, icon: Icon, label }) => {
const pathname = usePathname();
const isActive = pathname === href || (href !== '/' && pathname.startsWith(href));
return (
<Link
href={href}
className={cn(
"relative flex items-center gap-2 text-sm font-semibold transition-all px-4 py-2 rounded-xl group",
isActive
? "text-primary bg-primary/8 shadow-sm"
: "text-slate-500 hover:text-slate-900 hover:bg-slate-100"
)}
>
<Icon size={18} className={cn("transition-transform group-hover:scale-110", isActive && "text-primary")} />
<span>{label}</span>
{isActive && (
<span className="absolute bottom-0 left-1/2 -translate-x-1/2 w-1 h-1 bg-primary rounded-full" />
)}
</Link>
);
};
export default function AdminLayout({ children }) {
const { user, logout, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.push('/login');
}
}, [user, loading, router]);
const handleLogout = () => {
logout();
router.push('/login');
};
if (loading || !user) {
return null; // Or a loading spinner
}
return (
<div className="min-h-screen flex flex-col tech-grid">
<nav className="sticky top-0 z-50 w-full border-b border-black/5 bg-white/60 backdrop-blur-2xl">
<div className="container mx-auto px-4 h-20 flex justify-between items-center">
<Link href="/" className="flex items-center gap-3 group">
<img src="/logo.png" alt="IKSPI" className="h-12 w-auto object-contain group-hover:scale-105 transition-transform drop-shadow-sm" />
<div className="h-8 w-px bg-slate-200 mx-1 hidden sm:block" />
<div className="flex flex-col">
<span className="text-xl font-bold tracking-tighter leading-none text-slate-900">
IMPACT<span className="text-primary">MONITOR</span>
</span>
<span className="text-[10px] uppercase tracking-[0.2em] font-black text-slate-400">Pro Edition</span>
</div>
</Link>
<div className="hidden md:flex items-center gap-2 p-1.5 bg-slate-100/50 rounded-2xl border border-black/5">
<NavLink href="/" icon={LayoutDashboard} label="Dashboard" />
<NavLink href="/register" icon={UserPlus} label="Register" />
<NavLink href="/monitor" icon={MonitorIcon} label="Monitor" />
<NavLink href="/leaderboard" icon={Trophy} label="Leaderboard" />
<NavLink href="/settings" icon={SettingsIcon} label="Settings" />
</div>
<div className="flex items-center gap-4">
<div className="h-8 w-[1px] bg-slate-200 hidden sm:block" />
<Button
variant="ghost"
size="icon"
className="text-slate-500 hover:text-red-500 rounded-full"
title="Logout"
onClick={handleLogout}
>
<LogOut size={20} />
</Button>
</div>
</div>
</nav>
<main className="flex-1 container mx-auto p-6 md:p-10">
<div className="max-w-7xl mx-auto">
{children}
</div>
</main>
<footer className="py-8 border-t border-black/5 text-center text-sm text-slate-400">
<p>&copy; {new Date().getFullYear()} ImpactMonitor Pro · Dirancang untuk Atlet Profesional</p>
</footer>
</div>
);
}

View File

@ -0,0 +1,631 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Trophy, Medal, Download, Calendar, AlertCircle, Filter, FileText, Users } from 'lucide-react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { jsPDF } from 'jspdf';
import autoTable from 'jspdf-autotable';
import { cn } from "@/lib/utils";
import { rtdb } from '@/firebase';
import { ref, onValue } from 'firebase/database';
const HARI_LIST = ["Senin", "Selasa", "Rabu", "Kamis", "Jumat", "Sabtu", "Minggu"];
const BULAN_LIST = ["Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"];
const TANGGAL_LIST = Array.from({ length: 31 }, (_, i) => String(i + 1));
const CATEGORIES = ["Usia dini A", "Usia dini B", "Pra remaja", "Remaja", "Dewasa"];
const Leaderboard = () => {
const [leaderboard, setLeaderboard] = useState([]);
const [filteredLeaderboard, setFilteredLeaderboard] = useState([]);
// New Date Range State
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
const [strikeTypeFilter, setStrikeTypeFilter] = useState('all');
const [currentTime, setCurrentTime] = useState(new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }));
const isExportReady = true;
useEffect(() => {
const usersRef = ref(rtdb, 'users');
const historyRef = ref(rtdb, 'test_history');
// Listen to both nodes to ensure real-time ranking update
const unsubUsers = onValue(usersRef, (snapshot) => {
const userData = snapshot.val() || {};
onValue(historyRef, (historySnapshot) => {
const historyData = historySnapshot.val() || {};
const athletesArray = Object.keys(userData).map(key => {
const profile = userData[key].profile || {};
const athleteHistory = historyData[key] || {};
let bestPunch = 0;
let bestKick = 0;
const processAttempt = (data, parentKey) => {
const peak = Number(data?.peak_newton || 0);
let ts = data?.timestamp || 0;
const type = (data?.type || 'pukulan').toLowerCase();
// Perbaikan: Ambil timestamp dari nama session jika TS hardware rusak
if (ts < 1000000000000 && parentKey && parentKey.startsWith('session_')) {
const parsedTs = parseInt(parentKey.replace('session_', ''));
if (!isNaN(parsedTs) && parsedTs > 1000000000000) {
ts = parsedTs;
}
}
if (ts < 1000000000000) {
ts = new Date('2026-04-21T12:00:00').getTime();
}
const testDate = new Date(ts);
let includeDate = true;
if (startDate && endDate) {
const start = new Date(startDate);
start.setHours(0, 0, 0, 0);
const end = new Date(endDate);
end.setHours(23, 59, 59, 999);
includeDate = testDate >= start && testDate <= end;
}
if (includeDate) {
if (type === 'pukulan' && peak > bestPunch) bestPunch = peak;
if (type === 'tendangan' && peak > bestKick) bestKick = peak;
}
};
const traverse = (obj, parentKey = '') => {
if (!obj || typeof obj !== 'object') return;
Object.keys(obj).forEach(k => {
if (k.startsWith('attempt_')) {
processAttempt(obj[k], parentKey);
} else if (typeof obj[k] === 'object') {
traverse(obj[k], k);
}
});
};
traverse(athleteHistory);
// Logic for display score based on filter
let displayScore = 0;
if (strikeTypeFilter === 'pukulan') displayScore = bestPunch;
else if (strikeTypeFilter === 'tendangan') displayScore = bestKick;
else displayScore = Math.max(bestPunch, bestKick);
return {
id: key,
name: profile.name || 'Unknown',
category: profile.category || 'N/A',
best_punch: bestPunch,
best_kick: bestKick,
best_score: displayScore
};
});
setLeaderboard(athletesArray);
}, { onlyOnce: false });
});
const timeInterval = setInterval(() => {
setCurrentTime(new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }));
}, 30000);
return () => {
unsubUsers();
clearInterval(timeInterval);
};
}, [startDate, endDate, strikeTypeFilter]); // Trigger re-fetching when dates or strike type change
// Apply category filter and sorting
useEffect(() => {
let filtered = [...leaderboard];
if (categoryFilter !== 'all') {
filtered = filtered.filter(a => a.category === categoryFilter);
}
// Only show those with scores > 0
filtered = filtered.filter(a => a.best_score > 0);
// Sort descending
filtered.sort((a, b) => b.best_score - a.best_score);
setFilteredLeaderboard(filtered);
}, [leaderboard, categoryFilter]);
const getRankIcon = (index) => {
if (index === 0) return <div className="w-12 h-12 rounded-2xl bg-yellow-400 flex items-center justify-center shadow-lg shadow-yellow-200 ring-4 ring-yellow-50"><Trophy className="text-white w-6 h-6" /></div>;
if (index === 1) return <div className="w-10 h-10 rounded-xl bg-slate-300 flex items-center justify-center shadow-lg shadow-slate-200"><Medal className="text-white w-5 h-5" /></div>;
if (index === 2) return <div className="w-10 h-10 rounded-xl bg-amber-600 flex items-center justify-center shadow-lg shadow-amber-200"><Medal className="text-white w-5 h-5" /></div>;
return <div className="w-10 h-10 flex items-center justify-center font-black text-slate-300">#{index + 1}</div>;
};
const exportToPDF = () => {
const generatePDF = (ikspiLogo) => {
try {
const doc = new jsPDF();
const pageWidth = doc.internal.pageSize.width;
// --- BACKGROUND PUTIH ---
doc.setFillColor(255, 255, 255);
doc.rect(0, 0, pageWidth, doc.internal.pageSize.height, 'F');
// --- TOP KIRI: LOGO IKSPI + IMPACT ---
let impactStartX = 14;
// Tambahkan Logo IKSPI Paling Kiri (jika ada)
if (ikspiLogo) {
doc.addImage(ikspiLogo, 'PNG', 14, 5, 14, 14);
impactStartX = 32; // Geser teks Impact ke kanan logo
}
// Simulasi Logo "i" warna biru
doc.setTextColor(37, 99, 235); // blue-600
doc.setFontSize(22);
doc.setFont("helvetica", "bold");
doc.text("i", impactStartX, 15);
doc.setFontSize(9);
doc.setTextColor(15, 23, 42); // slate-900
doc.text("IMPACT", impactStartX + 6, 13);
doc.setFontSize(6);
doc.setTextColor(100, 116, 139);
doc.text("MONITOR PRO", impactStartX + 6, 17);
// --- TOP KANAN: INFO CETAK ---
doc.setFontSize(8);
doc.setTextColor(100, 116, 139);
doc.setFont("helvetica", "normal");
const realTimeJam = new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
doc.text(`Dicetak pada: ${new Date().toLocaleDateString('id-ID')} ${realTimeJam}`, pageWidth - 14, 12, { align: "right" });
doc.text(`Sistem: Impact Monitor Pro`, pageWidth - 14, 16, { align: "right" });
// --- JUDUL UTAMA ---
doc.setTextColor(15, 23, 42); // navy gelap
doc.setFontSize(26);
doc.setFont("helvetica", "bold");
doc.text("HALL OF FAME", 14, 33);
doc.setFontSize(10);
doc.setFont("helvetica", "normal");
doc.setTextColor(100, 116, 139);
doc.text("Laporan Perankingan Atlet Impact Monitor Pro", 14, 40);
// --- PERIODE DATA ---
const formattedRange = (startDate && endDate) ? `${startDate} s/d ${endDate}` : "Keseluruhan Waktu";
doc.setFontSize(9);
doc.setTextColor(71, 85, 105);
doc.text(`Periode Data: ${formattedRange}`, 14, 48);
// --- 📊 4 KOTAK SUMMARY ---
const totalAtlet = filteredLeaderboard.length;
let maxPower = 0;
let sumPower = 0;
const categoriesSet = new Set();
filteredLeaderboard.forEach(a => {
const p = a.best_score || 0;
if (p > maxPower) maxPower = p;
sumPower += p;
if (a.category && a.category !== 'N/A') categoriesSet.add(a.category);
});
const avgPower = totalAtlet > 0 ? (sumPower / totalAtlet).toFixed(2) : 0;
const totalCategories = categoriesSet.size;
const cardWidth = (pageWidth - 28 - (3 * 4)) / 4; // 4 kotak dengan margin
const summaryData = [
{ title: "TOTAL ATLET", value: totalAtlet, sub: "Atlet" },
{ title: "RATA-RATA POWER", value: avgPower, sub: "Newton (N)" },
{ title: "POWER TERTINGGI", value: maxPower > 0 ? maxPower.toFixed(2) : 0, sub: "Newton (N)" },
{ title: "KATEGORI", value: totalCategories, sub: "Kategori" }
];
for (let i = 0; i < 4; i++) {
const x = 14 + (i * (cardWidth + 4));
const y = 55;
doc.setDrawColor(226, 232, 240); // slate-200 border
doc.setFillColor(255, 255, 255); // putih
doc.roundedRect(x, y, cardWidth, 22, 2, 2, 'FD');
doc.setFontSize(6);
doc.setTextColor(148, 163, 184); // slate-400
doc.text(summaryData[i].title, x + cardWidth/2, y + 7, { align: "center" });
doc.setFontSize(14);
doc.setTextColor(15, 23, 42); // slate-900
doc.setFont("helvetica", "bold");
doc.text(`${summaryData[i].value}`, x + cardWidth/2, y + 14, { align: "center" });
doc.setFontSize(6);
doc.setTextColor(148, 163, 184);
doc.setFont("helvetica", "normal");
doc.text(summaryData[i].sub, x + cardWidth/2, y + 19, { align: "center" });
}
// --- 🏆 TOP 3 JUDUL ---
doc.setTextColor(250, 204, 21); // kuning emas
// Menggambar 3 titik/lingkaran emas sebagai pengganti bintang yang error
doc.setFillColor(250, 204, 21);
doc.circle(pageWidth/2 - 6, 86, 0.8, 'F');
doc.circle(pageWidth/2, 86, 1.2, 'F');
doc.circle(pageWidth/2 + 6, 86, 0.8, 'F');
doc.setTextColor(15, 23, 42);
doc.setFontSize(11);
doc.text("TOP 3 ATLET", pageWidth/2, 93, { align: "center" });
doc.setDrawColor(250, 204, 21);
doc.line(pageWidth/2 - 25, 91, pageWidth/2 - 15, 91);
doc.line(pageWidth/2 + 15, 91, pageWidth/2 + 25, 91);
// --- 🏆 TOP 3 PODIUM CARDS ---
const top3Width = 50;
const rank1X = (pageWidth / 2) - (top3Width / 2);
const rank2X = rank1X - top3Width - 8;
const rank3X = rank1X + top3Width + 8;
const drawTopCard = (x, y, width, height, rank, athlete, colorHex) => {
if (!athlete) return;
// Hex to RGB untuk drawColor
const r = parseInt(colorHex.substring(1,3), 16);
const g = parseInt(colorHex.substring(3,5), 16);
const b = parseInt(colorHex.substring(5,7), 16);
// Background Kartu
doc.setFillColor(255, 255, 255);
doc.setDrawColor(r, g, b); // Border sesuai medali
doc.roundedRect(x, y, width, height, 3, 3, 'FD');
// Lingkaran Medali
doc.setFillColor(r, g, b);
doc.circle(x + width/2, y + 12, 6, 'F');
doc.setTextColor(255, 255, 255);
doc.setFontSize(10);
doc.setFont("helvetica", "bold");
doc.text(`${rank}`, x + width/2, y + 15.5, { align: "center" });
// Nama Atlet
doc.setTextColor(15, 23, 42);
doc.setFontSize(10);
const name = athlete.name.length > 12 ? athlete.name.substring(0, 10) + '...' : athlete.name;
doc.text(name.toUpperCase(), x + width/2, y + 26, { align: "center" });
// Kategori
doc.setTextColor(100, 116, 139);
doc.setFontSize(7);
doc.setFont("helvetica", "normal");
doc.text(athlete.category, x + width/2, y + 31, { align: "center" });
// Power Score
doc.setTextColor(37, 99, 235); // Biru
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.text(`${athlete.best_score ? athlete.best_score.toFixed(2) : 0} N`, x + width/2, y + 37, { align: "center" });
};
// Rank 2 (Kiri)
drawTopCard(rank2X, 103, top3Width, 42, 2, filteredLeaderboard[1], "#94A3B8"); // Silver
// Rank 1 (Tengah - Lebih Tinggi & Lebar)
drawTopCard(rank1X, 98, top3Width + 4, 47, 1, filteredLeaderboard[0], "#FBBF24"); // Emas
// Rank 3 (Kanan)
drawTopCard(rank3X, 103, top3Width, 42, 3, filteredLeaderboard[2], "#D97706"); // Perunggu
// --- 📋 TABEL PERINGKAT LENGKAP ---
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.setTextColor(15, 23, 42);
doc.text("PERINGKAT LENGKAP", 14, 158);
// Dinamis Kolom berdasarkan Filter
let tableColumn = ["RANK", "NAMA ATLET", "KATEGORI"];
if (strikeTypeFilter === 'all') {
tableColumn.push("PUKULAN (N)", "TENDANGAN (N)", "BEST SCORE (N)");
} else if (strikeTypeFilter === 'pukulan') {
tableColumn.push("PUKULAN (N)");
} else {
tableColumn.push("TENDANGAN (N)");
}
const tableRows = filteredLeaderboard.map((athlete, index) => {
const row = [
`#${index + 1}`,
athlete.name.toUpperCase(),
athlete.category
];
if (strikeTypeFilter === 'all') {
row.push(
athlete.best_punch ? athlete.best_punch.toFixed(1) : '-',
athlete.best_kick ? athlete.best_kick.toFixed(1) : '-',
athlete.best_score ? athlete.best_score.toFixed(2) : '0.00'
);
} else if (strikeTypeFilter === 'pukulan') {
row.push(athlete.best_punch ? athlete.best_punch.toFixed(2) : '0.00');
} else {
row.push(athlete.best_kick ? athlete.best_kick.toFixed(2) : '0.00');
}
return row;
});
// Style Kolom Dinamis
const colStyles = {
0: { halign: 'center', fontStyle: 'bold', textColor: [37, 99, 235] },
1: { halign: 'left', fontStyle: 'bold', textColor: [15, 23, 42] },
2: { halign: 'center', textColor: [100, 116, 139] }
};
if (strikeTypeFilter === 'all') {
colStyles[3] = { halign: 'center', textColor: [15, 23, 42] };
colStyles[4] = { halign: 'center', textColor: [15, 23, 42] };
colStyles[5] = { halign: 'center', fontStyle: 'bold', textColor: [37, 99, 235] };
} else {
colStyles[3] = { halign: 'center', fontStyle: 'bold', textColor: [15, 23, 42] };
}
autoTable(doc, {
head: [tableColumn],
body: tableRows,
startY: 163,
theme: 'grid',
headStyles: {
fillColor: [15, 23, 42],
textColor: [255, 255, 255],
fontStyle: 'bold',
halign: 'center',
fontSize: 8,
cellPadding: 4
},
columnStyles: colStyles,
alternateRowStyles: { fillColor: [248, 250, 252] },
styles: { fontSize: 7, cellPadding: 4, lineColor: [226, 232, 240], lineWidth: 0.1 },
// --- FOOTER BAWAH ---
didDrawPage: function (data) {
let pageCount = doc.internal.getNumberOfPages();
doc.setFontSize(7);
doc.setTextColor(148, 163, 184); // slate-400
doc.text("Laporan ini digenerate secara otomatis oleh Impact Monitor Pro", 14, doc.internal.pageSize.height - 12);
doc.text("Sistem pemantauan dan analisis performa atlet berbasis teknologi.", 14, doc.internal.pageSize.height - 8);
doc.text(`Halaman ${pageCount} dari {total_pages}`, doc.internal.pageSize.width - 14, doc.internal.pageSize.height - 10, { align: 'right' });
}
});
// Kalkulasi Total Halaman untuk Footer
if (typeof doc.putTotalPages === 'function') {
doc.putTotalPages("{total_pages}");
}
doc.save(`Leaderboard_Silat_${startDate || 'All'}_to_${endDate || 'Time'}.pdf`);
} catch (error) {
console.error("Error PDF Generate:", error);
}
};
// --- PROSES MUAT LOGO ---
// Jika ada logo.png di dalam folder public, akan otomatis masuk ke pojok atas
const img = new Image();
img.src = '/logo.png';
img.onload = () => generatePDF(img);
img.onerror = () => generatePDF(null); // Terus lanjut tanpa logo jika gagal dimuat
};
return (
<div className="space-y-10 animate-in fade-in slide-in-from-bottom-8 duration-1000">
{/* Page Header */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<div className="flex items-center gap-3 mb-3">
<div className="p-2 bg-yellow-100 rounded-xl">
<Trophy className="text-yellow-600 w-6 h-6" />
</div>
<h2 className="text-sm font-black uppercase tracking-[0.3em] text-slate-400 leading-none">Hall of Fame</h2>
</div>
<h1 className="text-5xl font-black tracking-tighter text-slate-900">
Top <span className="text-primary italic">Performers</span>
</h1>
</div>
{/* Horizontal Filter Bar */}
<div className="flex flex-wrap items-center gap-4 p-6 bg-white/50 backdrop-blur-md rounded-[2rem] border border-white/20 shadow-xl">
<div className="flex flex-col gap-1">
<label className="text-[10px] font-black uppercase text-slate-400 ml-1">Kategori</label>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[180px] h-11 rounded-xl bg-white border-none shadow-sm font-bold">
<SelectValue placeholder="Semua Kategori" />
</SelectTrigger>
<SelectContent className="rounded-xl border-none shadow-2xl">
<SelectItem value="all">Semua Kategori</SelectItem>
{CATEGORIES.map(cat => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-black uppercase text-slate-400 ml-1">Jenis Serangan</label>
<Select value={strikeTypeFilter} onValueChange={setStrikeTypeFilter}>
<SelectTrigger className="w-[150px] h-11 rounded-xl bg-white border-none shadow-sm font-bold">
<SelectValue placeholder="Semua Serangan" />
</SelectTrigger>
<SelectContent className="rounded-xl border-none shadow-2xl">
<SelectItem value="all">Semua Serangan</SelectItem>
<SelectItem value="pukulan">Pukulan Saja</SelectItem>
<SelectItem value="tendangan">Tendangan Saja</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-black uppercase text-slate-400 ml-1">Dari</label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="h-11 px-4 rounded-xl bg-white border-none shadow-sm font-bold text-sm outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] font-black uppercase text-slate-400 ml-1">Sampai</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="h-11 px-4 rounded-xl bg-white border-none shadow-sm font-bold text-sm outline-none focus:ring-2 focus:ring-primary/20"
/>
</div>
<div className="flex items-end gap-2 mt-auto pb-0.5">
<Button
variant="outline"
className="h-11 px-6 rounded-xl border-blue-200 text-blue-600 hover:bg-blue-50 font-bold"
onClick={() => {
setStartDate('');
setEndDate('');
setCategoryFilter('all');
setStrikeTypeFilter('all');
}}
>
Reset
</Button>
<Button
variant="secondary"
className="h-11 px-6 rounded-xl bg-slate-200 text-slate-700 hover:bg-slate-300 font-bold ml-4"
onClick={exportToPDF}
>
<Download size={18} className="mr-2" /> Export PDF
</Button>
</div>
</div>
</div>
<div className="flex flex-col gap-8">
{/* Leaderboard Table */}
<Card className="glass-card border-none rounded-[3.5rem] shadow-2xl overflow-hidden p-0">
<div className="p-10 border-b border-black/5 bg-slate-900 text-white flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-primary/20 rounded-2xl flex items-center justify-center">
<Filter size={24} className="text-primary" />
</div>
<div>
<h3 className="text-xl font-black italic tracking-tight">Katalog Peringkat</h3>
<p className="text-xs font-black uppercase tracking-widest text-slate-500">{filteredLeaderboard.length} Rekod Atlet</p>
</div>
</div>
<div className="flex flex-col items-end">
<div className="flex items-center gap-2 mb-1">
<Badge variant="secondary" className="bg-primary/10 text-primary text-[9px] font-black uppercase px-2 py-0.5 rounded-md">
{(startDate && endDate) ? `${startDate}${endDate}` : "Semua Waktu"}
</Badge>
<p className="text-[10px] font-black uppercase text-slate-500">Update Terakhir</p>
</div>
<p className="text-sm font-bold">{currentTime} WIB</p>
</div>
</div>
<div className="h-[700px] overflow-auto custom-scrollbar">
<Table>
<TableHeader>
<TableRow className="border-none bg-slate-50 hover:bg-slate-50">
<TableHead className="w-[120px] h-16 px-10 text-[10px] font-black uppercase tracking-widest text-slate-400">Peringkat</TableHead>
<TableHead className="h-16 text-[10px] font-black uppercase tracking-widest text-slate-400">Data Atlet</TableHead>
<TableHead className="h-16 text-[10px] font-black uppercase tracking-widest text-slate-400">Kategori</TableHead>
{(strikeTypeFilter === 'all' || strikeTypeFilter === 'pukulan') && (
<TableHead className="h-16 text-[10px] font-black uppercase tracking-widest text-slate-400 text-center">Pukulan (N)</TableHead>
)}
{(strikeTypeFilter === 'all' || strikeTypeFilter === 'tendangan') && (
<TableHead className="h-16 text-[10px] font-black uppercase tracking-widest text-slate-400 text-center">Tendangan (N)</TableHead>
)}
<TableHead className="h-16 text-[10px] font-black uppercase tracking-widest text-slate-400 text-right pr-10">Power Score</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLeaderboard.map((athlete, index) => (
<TableRow key={`${athlete.id}-${index}`} className="border-b border-black/5 hover:bg-blue-50/30 transition-colors group">
<TableCell className="px-10 py-6">{getRankIcon(index)}</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="text-lg font-black tracking-tight text-slate-900 group-hover:text-primary transition-colors">{athlete.name}</span>
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">Athlete ID: {athlete.id || 'N/A'}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="rounded-lg bg-slate-100/50 border-black/5 font-bold px-3 py-1">
{athlete.category}
</Badge>
</TableCell>
{(strikeTypeFilter === 'all' || strikeTypeFilter === 'pukulan') && (
<TableCell className="text-center">
<span className={cn(
"text-lg font-bold tracking-tighter",
(strikeTypeFilter === 'pukulan' || (athlete.best_punch >= athlete.best_kick && athlete.best_punch > 0)) ? "text-primary" : "text-slate-400"
)}>
{athlete.best_punch ? athlete.best_punch.toFixed(1) : '-'}
</span>
</TableCell>
)}
{(strikeTypeFilter === 'all' || strikeTypeFilter === 'tendangan') && (
<TableCell className="text-center">
<span className={cn(
"text-lg font-bold tracking-tighter",
(strikeTypeFilter === 'tendangan' || (athlete.best_kick > athlete.best_punch)) ? "text-primary" : "text-slate-400"
)}>
{athlete.best_kick ? athlete.best_kick.toFixed(1) : '-'}
</span>
</TableCell>
)}
<TableCell className="text-right pr-10">
<div className="flex flex-col items-end">
<span className="text-2xl font-black italic tracking-tighter text-slate-950">
{athlete.best_score ? athlete.best_score.toFixed(2) : '0.00'}
</span>
<Badge variant="secondary" className="bg-slate-100 text-[8px] font-black uppercase px-2 py-0.5 rounded-md border-none">
{strikeTypeFilter === 'pukulan' ? 'Pukulan' : (strikeTypeFilter === 'tendangan' ? 'Tendangan' : (athlete.best_punch >= athlete.best_kick ? 'Pukulan' : 'Tendangan'))}
</Badge>
</div>
</TableCell>
</TableRow>
))}
{filteredLeaderboard.length === 0 && (
<TableRow>
<TableCell colSpan={strikeTypeFilter === 'all' ? 6 : 5} className="text-center py-40">
<div className="flex flex-col items-center">
<Users size={64} className="text-slate-100 mb-4" />
<span className="font-bold text-slate-300">Belum Ada Data Tersedia untuk filter ini</span>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Card>
</div>
</div>
);
};
export default Leaderboard;

View File

@ -0,0 +1,440 @@
"use client";
import React, { useEffect, useState, useRef } from 'react';
import { useParams } from 'next/navigation';
import { rtdb } from '@/firebase';
import { ref, onValue, get, set, off, remove } from 'firebase/database';
import { Play, Square, Activity, Target, Zap, Waves, ChevronUp, RotateCcw } from 'lucide-react';
import { cn } from "@/lib/utils";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
ResponsiveContainer,
ReferenceLine
} from 'recharts';
const GRAPH_BARS = 150;
const LiveTest = () => {
const { id } = useParams();
const [athlete, setAthlete] = useState(null);
const [currentForce, setCurrentForce] = useState(0);
const [sessionPeak, setSessionPeak] = useState(0);
const [attempts, setAttempts] = useState([]);
const [isTesting, setIsTesting] = useState(false);
const [bestScore, setBestScore] = useState(0);
const [forceHistory, setForceHistory] = useState(Array(GRAPH_BARS).fill({ value: 0 }).map((_, i) => ({ time: i, value: 0 })));
const [strikeType, setStrikeType] = useState('pukulan');
// 1. Fetch Athlete Details
useEffect(() => {
const fetchAthlete = async () => {
try {
const athleteRef = ref(rtdb, `users/${id}`);
const snapshot = await get(athleteRef);
if (snapshot.exists()) {
const data = snapshot.val();
setAthlete(data.profile ? { ...data.profile, id } : { id, ...data });
}
} catch (err) {
console.error("Error fetching athlete:", err);
}
};
fetchAthlete();
}, [id]);
// 2. Listen to Live Data and History
useEffect(() => {
if (!id) return;
const statusRef = ref(rtdb, 'system/test_status');
const liveRef = ref(rtdb, 'realtime/current_force');
const historyRef = ref(rtdb, `test_history/${id}`);
onValue(statusRef, (snapshot) => {
const status = snapshot.val();
const testing = status === 'measuring';
setIsTesting(testing);
if (testing) {
setSessionPeak(0);
setCurrentForce(0);
}
});
onValue(liveRef, (snapshot) => {
const force = Number(snapshot.val() || 0);
setCurrentForce(force);
if (force > sessionPeak) {
setSessionPeak(force);
}
// Rolling graph history (Muncul dari kanan ke kiri - sinkron dengan Monitor)
setForceHistory(prev => {
const newData = { time: 0, value: force };
// Masukkan data baru di awal [newData], buang yang terakhir di belakang
const updated = [newData, ...prev.slice(0, -1)];
// Maintain indices for re-rendering
return updated.map((d, idx) => ({ ...d, time: idx }));
});
});
onValue(historyRef, (snapshot) => {
const data = snapshot.val();
if (data) {
const attemptArray = [];
let max = 0;
Object.keys(data).forEach(key => {
if (key.startsWith('attempt_')) {
const score = data[key].peak_newton || 0;
attemptArray.push({
attemptNumber: key.split('_')[1],
force: score
});
if (score > max) max = score;
}
});
setAttempts(attemptArray.sort((a, b) => a.attemptNumber - b.attemptNumber));
setBestScore(max);
}
});
return () => {
off(statusRef);
off(liveRef);
off(historyRef);
};
}, [id, sessionPeak]);
const handleStart = async () => {
if (!id) return;
try {
const nextAttempt = attempts.length + 1;
// Format command: ID|Attempt|StrikeType
await set(ref(rtdb, 'test_command'), `${id}|${nextAttempt}|${strikeType}`);
} catch (err) {
console.error("Failed to start test:", err);
}
};
const resetAttempts = async () => {
if (!id || isTesting) return;
try {
await remove(ref(rtdb, `test_history/${id}`));
setAttempts([]);
setBestScore(0);
} catch (err) {
console.error("Failed to reset attempts:", err);
}
};
const handleStop = async () => {
try {
await set(ref(rtdb, 'system/test_status'), 'idle');
await set(ref(rtdb, 'test_command'), '');
} catch (err) {
console.error("Failed to stop test:", err);
}
};
if (!athlete) return <div className="text-center p-20 text-slate-400 font-bold italic">Memuat Data Atlet...</div>;
const forcePercentage = Math.min(100, (currentForce / 1000) * 100);
const peakPercentage = Math.min(100, (sessionPeak / 1000) * 100);
// Font size based on digit count (Sinkron dengan Monitor)
const gaugeFontSize = currentForce >= 10000
? 'text-[5rem] md:text-[7rem]'
: currentForce >= 1000
? 'text-[7rem] md:text-[10rem]'
: currentForce >= 100
? 'text-[9rem] md:text-[13rem]'
: 'text-[11rem] md:text-[16rem]';
return (
<div className="space-y-6 max-w-7xl mx-auto p-4 animate-in fade-in duration-700">
{/* Header Section */}
<header className="flex flex-col lg:row justify-between items-center bg-slate-900 text-white p-8 rounded-[3rem] shadow-2xl gap-8 border border-white/5 relative overflow-hidden group">
<div className="absolute top-0 right-0 p-10 opacity-5 group-hover:rotate-12 transition-transform duration-1000">
<Zap className="w-40 h-40" />
</div>
<div className="flex items-center gap-8 relative z-10 w-full lg:w-auto">
<div className="relative">
<div className="w-24 h-24 rounded-3xl bg-primary flex items-center justify-center border-4 border-white/10 shadow-inner group-hover:scale-110 transition-transform">
<span className="text-4xl font-black italic">{athlete.name?.charAt(0)}</span>
</div>
{isTesting && (
<div className="absolute -top-2 -right-2 bg-red-500 w-6 h-6 rounded-full animate-ping" />
)}
</div>
<div>
<h1 className="text-5xl font-black tracking-tighter italic uppercase leading-none mb-2">{athlete.name}</h1>
<div className="flex gap-4">
<span className="bg-white/10 px-3 py-1 rounded-full text-[10px] font-black tracking-[0.2em] uppercase">{athlete.category || 'Atlete'}</span>
<span className="bg-white/10 px-3 py-1 rounded-full text-[10px] font-black tracking-[0.2em] uppercase">{athlete.weight || '-'} KG</span>
</div>
</div>
</div>
<div className="flex gap-6 w-full lg:w-auto">
<div className="bg-white/5 p-6 rounded-[2rem] border border-white/10 flex-1 lg:min-w-[200px] text-center">
<span className="block text-slate-500 text-[10px] uppercase font-black tracking-widest mb-1">Impact Tertinggi</span>
<span className="text-5xl font-black text-primary italic tracking-tighter">
{bestScore.toFixed(0)} <span className="text-sm">N</span>
</span>
</div>
<div className="bg-white/5 p-6 rounded-[2rem] border border-white/10 flex-1 lg:min-w-[200px] text-center">
<span className="block text-slate-500 text-[10px] uppercase font-black tracking-widest mb-1">Status Sistem</span>
<span className={cn(
"text-xl font-black italic uppercase tracking-widest",
isTesting ? "text-red-500" : "text-emerald-500"
)}>
{isTesting ? "MEASURING" : "READY"}
</span>
</div>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Main Gauge Display */}
<div className="lg:col-span-3 space-y-8">
<div className="bg-white rounded-[4rem] shadow-2xl p-12 border border-black/5 flex flex-col md:flex-row gap-12 min-h-[650px] relative overflow-hidden">
{/* Background Deco */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-[20rem] font-black text-black/[0.02] select-none italic tracking-tighter">
FORCE
</div>
{/* Power Meter Vertical */}
<div className="relative w-full md:w-40 h-[450px] md:h-full bg-slate-100 rounded-[3rem] p-4 flex flex-col-reverse group">
{/* Scale Indicators */}
<div className="absolute -left-10 h-[80%] my-auto inset-y-0 flex flex-col justify-between text-[10px] font-black text-slate-400">
<span>1000</span>
<span>750</span>
<span>500</span>
<span>250</span>
<span>0</span>
</div>
{/* The Main Power Bar */}
<div
className={cn(
"w-full rounded-[2.5rem] transition-all duration-300 ease-out shadow-2xl relative",
currentForce > 800 ? "bg-gradient-to-t from-orange-500 to-red-600" :
currentForce > 400 ? "bg-gradient-to-t from-emerald-400 to-yellow-500" :
"bg-gradient-to-t from-blue-500 to-emerald-400"
)}
style={{ height: `${forcePercentage}%` }}
>
{/* Impact Glow */}
<div className="absolute top-0 left-0 w-full h-full bg-white opacity-20 blur-xl animate-pulse" />
</div>
{/* Ghost Peak Marker */}
<div
className="absolute left-1/2 -translate-x-1/2 w-[calc(100%+16px)] h-1 bg-slate-400 shadow-lg z-10 transition-all duration-700"
style={{ bottom: `calc(${peakPercentage}% + 16px)` }}
>
<div className="absolute right-0 -top-6 text-[10px] font-black bg-slate-400 text-white px-2 py-1 rounded-md">
PEAK: {sessionPeak.toFixed(0)}N
</div>
</div>
</div>
{/* Textual Feedback */}
<div className="flex-1 flex flex-col justify-between py-4 relative z-10">
<div>
<div className="flex items-center gap-3 mb-6">
<Waves className="text-primary w-6 h-6 animate-pulse" />
<h3 className="text-xl font-black tracking-tight uppercase">Live Impact Monitoring</h3>
</div>
<p className="text-slate-400 font-medium max-w-sm mb-8 uppercase text-xs tracking-widest leading-relaxed">
Sensor mendeteksi osilasi gaya pada 100Hz. Pukul target untuk melihat lonjakan energi real-time.
</p>
<div className="space-y-6">
{/* Strike Type Toggle */}
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-slate-400 tracking-widest pl-1">Jenis Serangan</label>
<div className="flex p-1.5 bg-slate-100 rounded-3xl gap-2 w-full max-w-xs">
<button
onClick={() => setStrikeType('pukulan')}
disabled={isTesting}
className={cn(
"flex-1 h-12 rounded-2xl font-black text-[10px] transition-all uppercase tracking-widest",
strikeType === 'pukulan'
? "bg-white text-primary shadow-lg"
: "text-slate-400 hover:bg-white/50"
)}
>
Pukulan
</button>
<button
onClick={() => setStrikeType('tendangan')}
disabled={isTesting}
className={cn(
"flex-1 h-12 rounded-2xl font-black text-[10px] transition-all uppercase tracking-widest",
strikeType === 'tendangan'
? "bg-white text-primary shadow-lg"
: "text-slate-400 hover:bg-white/50"
)}
>
Tendangan
</button>
</div>
</div>
<div className="bg-slate-50 p-6 rounded-[2.5rem] border border-black/5 flex flex-col items-center justify-center min-h-[300px]">
<span className="text-[10px] font-black uppercase text-slate-400 tracking-widest mb-1 block">Live Pressure</span>
<div className={cn(
"font-black italic tracking-tighter text-slate-900 leading-none transition-all duration-75",
gaugeFontSize
)}>
{currentForce.toFixed(0)}
</div>
<span className="text-2xl font-black text-primary italic tracking-tighter mt-2 uppercase">Newton</span>
</div>
{sessionPeak > 0 && (
<div className="bg-primary/5 p-6 rounded-[2.5rem] border border-primary/10 animate-in zoom-in-95">
<span className="text-[10px] font-black uppercase text-primary tracking-widest mb-1 block">Puncak Sesi Ini</span>
<div className="text-4xl font-black italic tracking-tighter text-primary">
{sessionPeak.toFixed(0)} <span className="text-lg uppercase">Newton</span>
</div>
</div>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-12">
<button
onClick={handleStart}
disabled={isTesting}
className={cn(
"h-24 rounded-[2.5rem] text-xl font-black italic tracking-tighter flex items-center justify-center gap-4 transition-all overflow-hidden relative",
isTesting
? "bg-slate-100 text-slate-400 cursor-not-allowed"
: "bg-primary text-white hover:bg-primary/90 hover:scale-[1.02] shadow-2xl shadow-primary/30"
)}
>
<Play fill="currentColor" size={28} /> MULAI SESI
</button>
<button
onClick={handleStop}
disabled={!isTesting}
className={cn(
"h-24 rounded-[2.5rem] text-xl font-black italic tracking-tighter flex items-center justify-center gap-4 transition-all border-4",
!isTesting
? "bg-slate-50 text-slate-300 border-slate-100 cursor-not-allowed"
: "bg-white border-slate-900 text-slate-900 hover:bg-slate-50 shadow-xl"
)}
>
<Square fill="currentColor" size={28} /> HENTIKAN
</button>
</div>
</div>
</div>
{/* Real-time Oscilloscope Waveform */}
<div className="bg-slate-50 p-10 rounded-[3.5rem] border border-black/5 mt-8">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 text-primary rounded-lg">
<Activity size={20} />
</div>
<h3 className="text-xl font-black italic uppercase tracking-tighter">Force Waveform</h3>
</div>
<span className="text-[10px] font-black uppercase text-slate-400 tracking-[0.3em]">Live Trace (5.0s)</span>
</div>
<div className="h-64 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={forceHistory} margin={{ top: 10, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorForceLive" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="rgba(0,0,0,0.03)" />
<XAxis hide dataKey="time" />
<YAxis
hide
domain={[0, (dataMax) => Math.max(dataMax, 1000)]}
/>
<Area
type="monotone"
dataKey="value"
stroke="#3b82f6"
strokeWidth={4}
fillOpacity={1}
fill="url(#colorForceLive)"
isAnimationActive={false}
/>
{sessionPeak > 0 && (
<ReferenceLine y={sessionPeak} stroke="#ef4444" strokeDasharray="5 5" label={{ position: 'right', value: `Peak`, fill: '#ef4444', fontSize: 10, fontWeight: 'bold' }} />
)}
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Sidebar Stats */}
<div className="space-y-8">
{/* History List */}
<div className="bg-slate-900 text-white p-10 rounded-[4rem] shadow-2xl min-h-[400px]">
<div className="flex items-center justify-between mb-10">
<h3 className="text-xs font-black uppercase tracking-widest text-slate-500">Log Hasil</h3>
<div className="flex gap-2">
{attempts.length > 0 && !isTesting && (
<button
onClick={resetAttempts}
className="p-2 bg-white/5 hover:bg-red-500/20 text-slate-400 hover:text-red-500 rounded-lg transition-all"
title="Reset Riwayat"
>
<RotateCcw size={16} />
</button>
)}
<Activity className="text-primary w-5 h-5" />
</div>
</div>
<div className="space-y-4">
{attempts.length === 0 ? (
<div className="text-slate-600 italic text-center py-20 bg-white/5 rounded-[3rem] border-2 border-dashed border-white/5">
<p className="text-[10px] font-black uppercase tracking-widest">Kosong</p>
</div>
) : (
attempts.map((attempt, idx) => (
<div key={idx} className="bg-white/5 group border border-white/5 flex justify-between items-center p-6 rounded-[2rem] transition-all hover:bg-white/10 hover:translate-x-2">
<div>
<span className="text-[10px] text-slate-500 font-black uppercase tracking-widest">Lari #{attempt.attemptNumber}</span>
<div className="text-2xl font-black italic tracking-tighter text-white">
{attempt.force} <span className="text-[10px] uppercase text-primary">N</span>
</div>
</div>
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<ChevronUp className="text-primary w-6 h-6" />
</div>
</div>
))
)}
</div>
</div>
<div className="bg-primary text-white p-10 rounded-[3.5rem] shadow-2xl shadow-primary/30 text-center relative overflow-hidden group">
<div className="relative z-10">
<span className="text-[10px] font-black uppercase tracking-widest opacity-80">Skor Legenda</span>
<div className="text-6xl font-black italic tracking-tighter mt-1">1000<span className="text-xl ml-1">N</span></div>
</div>
<Target className="absolute -left-4 -bottom-4 w-32 h-32 text-white/10 rotate-12 group-hover:rotate-0 transition-transform duration-1000" />
</div>
</div>
</div>
</div>
);
};
export default LiveTest;

View File

@ -0,0 +1,579 @@
"use client";
import React, { useState, useEffect, useRef } from 'react';
import { rtdb } from '@/firebase';
import { ref, onValue, set, off } from 'firebase/database';
import { Badge } from "@/components/ui/badge";
import {
Activity,
Play,
Square,
Target,
Waves,
Zap,
MessageSquare,
TrendingUp,
RotateCcw
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
ResponsiveContainer,
ReferenceLine
} from 'recharts';
const GRAPH_BARS = 50; // Jarak diperlebar (dari sebelumnya 150 titik menjadi 50 titik)
const Monitor = () => {
const [athletes, setAthletes] = useState([]);
const [selectedAthlete, setSelectedAthlete] = useState('');
const [isTesting, setIsTesting] = useState(false);
const [livePeak, setLivePeak] = useState(0);
const [liveForce, setLiveForce] = useState(0);
const [attempts, setAttempts] = useState([]);
const [forceHistory, setForceHistory] = useState(Array(GRAPH_BARS).fill({ value: 0 }).map((_, i) => ({ time: i, value: 0 })));
const [currentAttemptIndex, setCurrentAttemptIndex] = useState(0);
const [animatedValue, setAnimatedValue] = useState(0);
const [currentSessionId, setCurrentSessionId] = useState(null);
const [strikeType, setStrikeType] = useState('pukulan');
// Track live peak locally for immediate responsiveness
const localPeakRef = useRef(0);
const isTestingRef = useRef(false); // Ref untuk sinkronisasi dengan listener Firebase
// 1. Fetch Athletes
useEffect(() => {
const r = ref(rtdb, 'users');
const unsub = onValue(r, (snap) => {
const data = snap.val();
if (data) {
const arr = Object.keys(data).map(key => ({
id: String(key),
name: String(data[key]?.profile?.name || data[key]?.name || 'Unknown'),
}));
setAthletes(arr);
}
});
return () => off(r, 'value', unsub);
}, []);
// 2. Load History (Current Session)
useEffect(() => {
// Reset seketika saat atlet berubah agar tidak ada sisa data atlet sebelumnya
setLiveForce(0);
setLivePeak(0);
setAnimatedValue(0);
setForceHistory(Array(GRAPH_BARS).fill({ value: 0 }).map((_, i) => ({ time: i, value: 0 })));
if (!selectedAthlete) {
setAttempts([]);
setCurrentSessionId(null);
return;
}
const r = ref(rtdb, `test_history/${selectedAthlete}`);
const unsub = onValue(r, (snap) => {
const data = snap.val();
if (data && typeof data === 'object') {
// Find all sessions or legacy attempts
const sessionKeys = Object.keys(data).filter(k => k.startsWith('session_')).sort().reverse();
// If there are sessions, use the latest one by default (unless we just started a new one)
// If no sessions but there are legacy attempt_ keys, treat them as a "Legacy" session
let targetSessionData = null;
let activeSessionId = currentSessionId;
if (!activeSessionId) {
if (sessionKeys.length > 0) {
activeSessionId = sessionKeys[0];
} else if (Object.keys(data).some(k => k.startsWith('attempt_'))) {
activeSessionId = 'legacy';
}
}
if (activeSessionId === 'legacy') {
targetSessionData = data;
} else if (activeSessionId) {
targetSessionData = data[activeSessionId];
}
setCurrentSessionId(activeSessionId);
if (targetSessionData) {
const arr = [];
Object.keys(targetSessionData).forEach(k => {
if (k.startsWith('attempt_')) {
const attemptNum = parseInt(k.replace('attempt_', ''));
arr.push({
number: attemptNum,
peak: Number(targetSessionData[k]?.peak_newton || 0)
});
}
});
arr.sort((a, b) => a.number - b.number);
setAttempts(arr);
} else {
setAttempts([]);
}
} else {
setAttempts([]);
setCurrentSessionId(null);
}
});
return () => off(r, 'value', unsub);
}, [selectedAthlete, currentSessionId]);
// 3. Real-time Listeners
useEffect(() => {
const sRef = ref(rtdb, 'system/test_status');
const lRef = ref(rtdb, 'realtime/current_force');
const pRef = ref(rtdb, 'realtime/peak_force');
const unsubS = onValue(sRef, (s) => {
const status = s.val();
const testing = status === 'measuring' || status === 'waiting';
setIsTesting(testing);
isTestingRef.current = testing; // Update ref juga
if (!testing) {
// Test selesai, reset local peak
localPeakRef.current = 0;
}
});
const unsubL = onValue(lRef, (s) => {
const val = Number(s.val() || 0);
setLiveForce(val);
// Update local peak tracking
if (val > localPeakRef.current) {
localPeakRef.current = val;
}
// Rolling graph history (Muncul dari kiri ke kanan)
setForceHistory(prev => {
const newData = { time: 0, value: val };
// Masukkan data baru di awal [newData], buang yang terakhir di belakang
const updated = [newData, ...prev.slice(0, -1)];
// Maintain indices for re-rendering
return updated.map((d, idx) => ({ ...d, time: idx }));
});
});
const unsubP = onValue(pRef, (s) => setLivePeak(Number(s.val() || 0)));
return () => {
off(sRef, 'value', unsubS);
off(lRef, 'value', unsubL);
off(pRef, 'value', unsubP);
};
}, []);
// 4. Count-up Animation Logic
useEffect(() => {
if (!isTesting) {
// Jangan reset ke 0 agar angka tetap stay di layar setelah selesai
return;
}
let animationFrame;
const animate = () => {
setAnimatedValue(prev => {
if (prev < livePeak) {
// Cepat bertambah jika selisih besar, melambat jika dekat
const diff = livePeak - prev;
const step = Math.max(1, Math.ceil(diff / 5)); // "urut" atau sequential cepat
return Math.min(livePeak, prev + step);
}
return prev;
});
animationFrame = requestAnimationFrame(animate);
};
animationFrame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrame);
}, [livePeak, isTesting]);
const startTest = async () => {
if (!selectedAthlete) return;
// Cek apakah sesi saat ini sudah penuh (3 percobaan selesai)
const isSessionFull = attempts.length >= 3 && attempts.every(a => a.peak > 0);
let sessionId = currentSessionId;
// Mulai sesi baru jika sesi lama penuh atau belum ada sesi/Legacy
if (!sessionId || isSessionFull || sessionId === 'legacy') {
sessionId = `session_${new Date().getTime()}`;
setCurrentSessionId(sessionId);
setAttempts([]); // Reset UI lokal seketika
}
// Temukan slot pertama (1, 2, atau 3) yang masih kosong di sesi ini
const firstEmptySlot = [1, 2, 3].find(num => {
const found = attempts.find(a => a.number === num);
return !found || found.peak <= 0;
}) || 1;
const nextId = firstEmptySlot;
setCurrentAttemptIndex(nextId - 1);
console.log(`Starting test for ${selectedAthlete} in session ${sessionId} slot ${nextId}`);
// Reset semua nilai sebelum sesi baru
localPeakRef.current = 0;
await set(ref(rtdb, 'realtime/current_force'), 0);
await set(ref(rtdb, 'realtime/peak_force'), 0);
setLivePeak(0);
setLiveForce(0);
setAnimatedValue(0);
setForceHistory(Array(GRAPH_BARS).fill({ value: 0 }).map((_, i) => ({ time: i, value: 0 })));
// Trik: UID diisi path 'athleteId/sessionId' agar firmware menyimpan ke folder tersebut
await set(ref(rtdb, 'test_command'), `${selectedAthlete}/${sessionId}|${nextId}|${strikeType}`);
};
const stopTest = async () => {
await set(ref(rtdb, 'system/test_status'), 'idle');
await set(ref(rtdb, 'test_command'), '');
};
const resetAttempts = async () => {
if (!selectedAthlete || isTesting) return;
if (currentSessionId && currentSessionId !== 'legacy') {
// Hanya hapus sesi yang aktif saat ini, jangan hapus seluruh riwayat atlet
await set(ref(rtdb, `test_history/${selectedAthlete}/${currentSessionId}`), null);
} else {
await set(ref(rtdb, `test_history/${selectedAthlete}`), null);
}
setAttempts([]);
};
const currentAthlete = athletes.find(a => a.id === selectedAthlete);
// Display values
// PERBAIKAN: Jika atlet belum dipilih, paksa angka ke 0. Jika sudah, gunakan logika persistence.
const latestAttemptPeak = attempts.length > 0 ? attempts[attempts.length - 1].peak : 0;
const displayForce = !selectedAthlete ? 0 : (isTesting ? animatedValue : (liveForce > 0 ? liveForce : latestAttemptPeak));
const displayPeak = !selectedAthlete ? 0 : (isTesting ? livePeak : (livePeak > 0 ? livePeak : latestAttemptPeak));
// Dynamic scale for graph
const maxGraph = Math.max(...forceHistory.map(d => d.value), 100);
// Font size based on digit count
const gaugeFontSize = displayForce >= 10000
? 'text-[7rem]'
: displayForce >= 1000
? 'text-[10rem]'
: displayForce >= 100
? 'text-[13rem]'
: 'text-[16rem]';
return (
<div className="flex flex-col xl:flex-row gap-8 animate-in fade-in duration-500">
{/* ─── Sidebar Kiri: SESI LATIHAN ────────────────────────── */}
<div className="w-full xl:w-96 space-y-6">
<div className="glass-card rounded-[3rem] overflow-hidden border-none shadow-2xl relative">
<div className="h-2 w-full bg-slate-900 absolute top-0 left-0" />
<div className="p-8 pt-10">
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 rounded-2xl bg-slate-900 flex items-center justify-center text-primary shadow-lg">
<Activity size={24} />
</div>
<div>
<h2 className="text-xl font-black italic uppercase tracking-tighter">Sesi Latihan</h2>
<p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest leading-none">Konfigurasi audit performa</p>
</div>
</div>
<div className="space-y-4">
{/* Pilih Atlet */}
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-slate-400 tracking-widest pl-1">Pilih Subjek</label>
<select
value={selectedAthlete}
onChange={(e) => setSelectedAthlete(e.target.value)}
disabled={isTesting}
className="w-full h-14 bg-slate-50 border border-black/5 rounded-2xl px-4 font-bold text-slate-900 outline-none focus:border-primary transition-all appearance-none cursor-pointer"
style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'currentColor\'%3E%3Cpath stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M19 9l-7 7-7-7\' /%3E%3C/svg%3E")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 1rem center', backgroundSize: '1rem' }}
>
<option value="">Pilih Atlet dari Daftar</option>
{athletes.map(a => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</div>
{/* Pilih Jenis Serangan */}
<div className="space-y-2">
<label className="text-[10px] font-black uppercase text-slate-400 tracking-widest pl-1">Jenis Serangan</label>
<div className="flex p-1 bg-slate-100 rounded-2xl gap-1">
<button
onClick={() => setStrikeType('pukulan')}
disabled={isTesting}
className={cn(
"flex-1 h-12 rounded-xl font-bold text-xs transition-all uppercase tracking-widest",
strikeType === 'pukulan'
? "bg-white text-primary shadow-sm"
: "text-slate-400 hover:bg-white/50"
)}
>
Pukulan
</button>
<button
onClick={() => setStrikeType('tendangan')}
disabled={isTesting}
className={cn(
"flex-1 h-12 rounded-xl font-bold text-xs transition-all uppercase tracking-widest",
strikeType === 'tendangan'
? "bg-white text-primary shadow-sm"
: "text-slate-400 hover:bg-white/50"
)}
>
Tendangan
</button>
</div>
</div>
{/* Tombol Mulai / Hentikan */}
{!isTesting ? (
<button
onClick={startTest}
disabled={!selectedAthlete}
className="w-full h-20 bg-primary text-white rounded-[1.8rem] font-black text-lg italic shadow-2xl shadow-primary/30 active:scale-95 transition-all disabled:opacity-30 flex items-center justify-center gap-3"
>
<Play fill="currentColor" size={20} /> MULAI SESI
</button>
) : (
<button
onClick={stopTest}
className="w-full h-20 bg-red-500 text-white rounded-[1.8rem] font-black text-lg italic shadow-2xl shadow-red-500/30 animate-pulse active:scale-95 transition-all flex items-center justify-center gap-3"
>
<Square fill="currentColor" size={20} /> HENTIKAN
</button>
)}
{/* Logging Percobaan */}
<div className="pt-8 space-y-4">
<div className="flex items-center justify-between">
<p className="text-[10px] font-black uppercase text-slate-400 tracking-widest pl-1">Logging Percobaan</p>
{attempts.length > 0 && !isTesting && (
<button
onClick={resetAttempts}
className="flex items-center gap-1 text-[9px] font-black uppercase text-red-400 hover:text-red-600 tracking-widest transition-colors"
title="Reset semua percobaan"
>
<RotateCcw size={10} /> Reset
</button>
)}
</div>
{[0, 1, 2].map(i => {
const isActive = isTesting && currentAttemptIndex === i;
const attemptData = attempts[i];
const hasData = attemptData !== undefined;
// Nilai yang ditampilkan:
// - Jika sedang aktif (testing): tampilkan animatedValue (count-up)
// - Jika sudah selesai: tampilkan peak dari history
// - Jika belum: tampilkan 0
const displayVal = isActive
? animatedValue
: (hasData ? attemptData.peak : 0);
return (
<div key={i} className={cn(
"h-20 rounded-[1.8rem] flex items-center justify-between px-6 border border-black/5 bg-slate-50 transition-all",
isActive && "bg-white shadow-xl ring-2 ring-primary/20 scale-[1.02]"
)}>
<div className="flex items-center gap-4">
<div className={cn("w-10 h-10 rounded-full flex items-center justify-center font-black text-[10px]", isActive ? "bg-primary text-white" : "bg-white text-slate-300 shadow-inner")}>
{i + 1}
</div>
<div className="flex flex-col">
<span className="text-[8px] font-black uppercase text-slate-400 tracking-widest leading-none mb-1">Attempt {i + 1}</span>
<span className={cn("text-[10px] font-black uppercase italic", isActive ? "text-primary" : "text-slate-300")}>
{isActive ? "Recording..." : (hasData ? "Finished" : "Ready")}
</span>
</div>
</div>
<div className={cn(
"text-2xl font-black italic transition-all",
isActive ? "text-primary animate-pulse" : (hasData ? "text-slate-900" : "text-slate-200")
)}>
{displayVal > 0 ? Number(displayVal).toFixed(0) : "0"} <span className="text-[10px]">N</span>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
{/* ─── Main: Center + Right ──────────────────────────────── */}
<div className="flex-1 space-y-8">
<div className="flex flex-col lg:flex-row gap-8">
{/* CENTER GAUGE */}
<div className="flex-1 glass-card rounded-[4rem] p-12 shadow-2xl flex flex-col items-center justify-center text-center relative overflow-hidden min-h-[500px] border-none">
{/* Decorative circles */}
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[450px] h-[450px] rounded-full border-[1.5px] border-black/[0.03] flex items-center justify-center pointer-events-none">
<div className="w-[300px] h-[300px] rounded-full border border-primary/5" />
</div>
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 w-40 h-[1.5px] bg-slate-100" />
<Badge className="mb-6 px-6 py-2 rounded-full font-black text-[9px] tracking-[0.3em] uppercase bg-primary/5 text-primary border-primary/10">
{isTesting ? "Sensor Active Tracking" : "Sensor Standby Mode"}
</Badge>
<h2 className="text-slate-400 text-[11px] font-black uppercase tracking-[0.4em] mb-2">Gaya Saat Ini</h2>
{/* ANGKA NEWTON */}
<div className="flex flex-col items-center justify-center gap-0 relative z-10">
<span className={cn(
"font-black leading-none italic select-none tracking-tighter",
"transition-colors duration-75",
displayForce > 0 ? "text-slate-700" : "text-slate-100",
gaugeFontSize
)}>
{displayForce.toFixed(0)}
</span>
<span className="text-5xl font-black text-primary italic tracking-tighter -mt-8">NEWTON</span>
</div>
{/* Badge Peak */}
<div className={cn(
"mt-5 flex items-center gap-2 px-5 py-2 rounded-full transition-all duration-300",
isTesting ? "bg-emerald-50 opacity-100" : "opacity-0 pointer-events-none"
)}>
<TrendingUp size={13} className="text-emerald-500" />
<span className="text-[11px] font-black uppercase text-emerald-600 tracking-widest">
Peak: {displayPeak.toFixed(0)} N
</span>
</div>
<div className="flex items-center justify-center gap-12 mt-8 w-full max-w-sm">
<div className="flex items-center gap-2 text-[10px] font-black uppercase text-emerald-500 tracking-widest">
<Target size={14} /> Target: {">"}800N
</div>
<div className="flex items-center gap-2 text-[10px] font-black uppercase text-primary tracking-widest border-l border-slate-100 pl-12">
<Waves size={14} /> Athlete: {currentAthlete?.name || "Pilih Atlet"}
</div>
</div>
</div>
{/* RIGHT ANALYSIS CARD */}
<div className="w-full lg:w-[400px] space-y-6">
<div className="glass-card rounded-[3.5rem] p-8 border-none shadow-2xl h-full">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-2xl bg-emerald-50 text-emerald-500 flex items-center justify-center shadow-inner">
<Zap size={22} />
</div>
<h3 className="text-xs font-black uppercase tracking-widest italic">Analisis Dampak</h3>
</div>
<div className="space-y-8 mt-10">
{/* Progress bar */}
<div>
<div className="flex justify-between items-end mb-2">
<span className="text-[10px] font-black uppercase text-slate-400 tracking-widest">Efisiensi Serangan</span>
<span className="text-lg font-black italic text-emerald-500">
{(Math.min(100, displayPeak / 1000 * 100)).toFixed(1)}%
</span>
</div>
<div className="w-full h-2 bg-slate-50 rounded-full p-[2px] shadow-inner overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, (displayPeak / 1000) * 100)}%` }}
/>
</div>
<div className="flex justify-between mt-2 text-[9px] font-black uppercase text-slate-300 tracking-widest">
<span>0 N</span>
<span>1000 N</span>
</div>
</div>
<div className="bg-slate-900 rounded-[2.5rem] p-8 text-white relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:rotate-12 transition-transform">
<MessageSquare size={100} />
</div>
<span className="text-[9px] font-black uppercase text-primary tracking-widest border-b border-primary/20 pb-2 mb-4 block">Saran Pelatih</span>
<p className="text-sm font-bold italic leading-relaxed text-slate-300">
"Fokus pada kecepatan putaran pinggang untuk menambah gaya ledak."
</p>
</div>
</div>
</div>
</div>
</div>
{/* BOTTOM — Live Force Stream Graph */}
<div className="glass-card rounded-[3.5rem] p-10 border-none shadow-2xl min-h-[300px] relative overflow-hidden">
<div className="flex items-center justify-between mb-8 relative z-10">
<div>
<div className="flex items-center gap-3">
<div className="p-2 bg-red-50 text-red-500 rounded-lg">
<Waves size={20} />
</div>
<h3 className="text-2xl font-black italic uppercase tracking-tighter">Live Force Stream</h3>
</div>
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-1">Osiloskop gaya real-time dari sensor.</p>
</div>
<div className="flex items-center gap-2 px-4 py-2 bg-red-50 text-red-500 rounded-full">
<div className={cn("w-2 h-2 rounded-full bg-red-500", isTesting && "animate-pulse")} />
<span className="text-[10px] font-black uppercase tracking-widest">
{isTesting ? "Live" : "Idle"}
</span>
</div>
</div>
{/* Real-time Oscilloscope Waveform */}
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={forceHistory} margin={{ top: 10, right: 0, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorForce" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4}/>
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="rgba(0,0,0,0.03)" />
<XAxis hide dataKey="time" />
<YAxis
hide
domain={[0, maxGraph]}
/>
<Area
type="monotone"
dataKey="value"
stroke="#3b82f6"
strokeWidth={3}
fillOpacity={1}
fill="url(#colorForce)"
isAnimationActive={false}
/>
{isTesting && (
<ReferenceLine y={livePeak} stroke="#ef4444" strokeDasharray="3 3" label={{ position: 'right', value: `Peak: ${livePeak}N`, fill: '#ef4444', fontSize: 10, fontWeight: 'bold' }} />
)}
</AreaChart>
</ResponsiveContainer>
</div>
<div className="flex justify-between mt-6 text-[9px] font-black uppercase text-slate-300 tracking-widest px-2">
<span>Timeline (5s)</span>
<span>{(maxGraph / 2).toFixed(0)} N</span>
<span>Max Scale: {maxGraph.toFixed(0)} N</span>
</div>
<div className="absolute bottom-0 left-0 w-full h-[1.5px] bg-slate-100" />
</div>
</div>
</div>
);
};
export default Monitor;

View File

@ -0,0 +1,377 @@
"use client";
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { User, Activity, Trash2, UserPlus, Users, Zap, TrendingUp, Info, History } from 'lucide-react';
import HistoryModal from '@/components/HistoryModal';
import { Card, CardHeader, CardTitle, CardContent, CardFooter, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { rtdb } from "@/firebase";
import { ref, onValue, remove } from "firebase/database";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
} from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
const CATEGORIES = ["Usia dini A", "Usia dini B", "Pra remaja", "Remaja", "Dewasa"];
const StatCard = ({ title, value, icon: Icon, color }) => (
<Card className="glass-card overflow-hidden transition-all hover:scale-[1.02] shadow-xl border-none">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{title}</p>
<h3 className="text-3xl font-black mt-1 italic tracking-tighter">{value}</h3>
</div>
<div className={cn("p-4 rounded-[1.25rem]", color)}>
<Icon size={24} />
</div>
</div>
</CardContent>
</Card>
);
const Dashboard = () => {
const [athletes, setAthletes] = useState([]);
const [serverStatus, setServerStatus] = useState('Menghubungkan...');
const [sensorStatus, setSensorStatus] = useState('Memeriksa...');
const [serverColor, setServerColor] = useState('text-slate-400 bg-slate-50');
const [sensorColor, setSensorColor] = useState('text-slate-400 bg-slate-50');
const [selectedAthlete, setSelectedAthlete] = useState(null);
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
const [categoryFilter, setCategoryFilter] = useState('all');
// Cek status App Server (Node.js backend) via HTTP ping setiap 4 detik
// Lebih cepat dari Socket.IO ping timeout (20-30 detik)
useEffect(() => {
let pingInterval = null;
const checkServer = async () => {
try {
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const res = await fetch(`${API_URL}/api/athletes`, {
signal: AbortSignal.timeout(2000) // timeout 2 detik
});
if (res.ok || res.status < 500) {
setServerStatus('Online');
setServerColor('text-emerald-600 bg-emerald-50');
} else {
setServerStatus('Error');
setServerColor('text-orange-500 bg-orange-50');
}
} catch {
setServerStatus('Offline');
setServerColor('text-red-500 bg-red-50');
}
};
// Cek langsung saat komponen mount
checkServer();
// Lalu cek setiap 4 detik
pingInterval = setInterval(checkServer, 4000);
return () => clearInterval(pingInterval);
}, []);
// Cek status sensor IoT dari Firebase RTDB (timestamp heartbeat)
// Firmware menulis Unix timestamp ke /system/last_seen setiap 8 detik
// Dashboard cek apakah timestamp sudah >15 detik Offline
useEffect(() => {
const lastSeenRef = ref(rtdb, '/system/last_seen');
let lastSeenValue = 0;
let checkInterval = null;
const unsubscribe = onValue(lastSeenRef, (snapshot) => {
const ts = snapshot.val();
if (ts && ts > 0) {
lastSeenValue = ts; // Unix timestamp dari firmware
}
});
// Cek staleness setiap 5 detik
checkInterval = setInterval(() => {
if (lastSeenValue === 0) {
setSensorStatus('Tidak Terdeteksi');
setSensorColor('text-slate-400 bg-slate-50');
return;
}
const nowSec = Math.floor(Date.now() / 1000);
const ageSeconds = nowSec - lastSeenValue;
if (ageSeconds <= 15) {
// Heartbeat masih segar Online
setSensorStatus('Online');
setSensorColor('text-emerald-600 bg-emerald-50');
} else {
// Heartbeat sudah lama alat mati/putus
setSensorStatus('Offline');
setSensorColor('text-red-500 bg-red-50');
}
}, 5000);
return () => {
unsubscribe();
clearInterval(checkInterval);
};
}, []);
useEffect(() => {
const usersRef = ref(rtdb, 'users');
const unsubscribe = onValue(usersRef, (snapshot) => {
const data = snapshot.val();
if (data) {
const athletesArray = Object.keys(data).map(key => {
const u = data[key] || {};
const p = u.profile || {};
return {
id: String(key),
name: String(p.name || u.name || 'Unknown'),
category: String(p.category || u.category || 'N/A'),
rfid_tag: String(p.rfid_tag || u.rfid_tag || key),
age: Number(p.age || u.age || 0),
height: Number(p.height || u.height || 0),
weight: Number(p.weight || u.weight || 0)
};
});
setAthletes(athletesArray);
} else {
setAthletes([]);
}
});
return () => unsubscribe();
}, []);
const deleteAthlete = async (id) => {
if (!id) return;
try {
// 1. Hapus dari Firebase (Data Profil)
await remove(ref(rtdb, `users/${id}`));
// 2. Hapus dari Firebase (Data Riwayat) - Sangat Penting!
// Kita hapus di kedua path yang mungkin digunakan
await remove(ref(rtdb, `test_history/${id}`));
await remove(ref(rtdb, `attempts/${id}`));
// 3. Sync dengan Backend (SQLite)
// Ini untuk membersihkan data di Leaderboard lokal
try {
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
await fetch(`${API_URL}/api/athletes/${id}`, {
method: 'DELETE'
});
} catch (backendErr) {
console.warn("Backend sync failed, but Firebase data was removed.", backendErr);
}
console.log(`Athlete ${id} and all associated data deleted successfully.`);
} catch (err) {
console.error("Failed to delete athlete and data", err);
}
};
const getCategoryStyles = (category) => {
const cat = String(category || '').toLowerCase();
if (cat.includes('dini')) return "bg-blue-100 text-blue-700 border-blue-200";
if (cat.includes('remaja')) return "bg-emerald-100 text-emerald-700 border-emerald-200";
if (cat.includes('dewasa')) return "bg-purple-100 text-purple-700 border-purple-200";
return "bg-slate-100 text-slate-700";
};
const avgAge = athletes.length > 0 ? (athletes.reduce((acc, a) => acc + (Number(a.age) || 0), 0) / athletes.length).toFixed(1) : "0";
return (
<div className="space-y-10 animate-in fade-in duration-700">
{/* Header Section */}
<div className="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h1 className="text-4xl font-black tracking-tighter text-slate-900 md:text-6xl italic uppercase">
Dashboard <span className="text-primary">PRO</span>
</h1>
<p className="text-slate-500 mt-2 text-lg max-w-2xl font-bold uppercase tracking-widest text-[10px]">
Monitoring Impact & Manajemen Atlet Real-time
</p>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<Link href="/register">
<Button size="lg" className="rounded-[1.5rem] px-8 h-16 text-base font-black shadow-2xl shadow-primary/30 transition-all hover:scale-105 active:scale-95 italic">
<UserPlus className="mr-2 h-6 w-6" /> DAFTARKAN ATLET
</Button>
</Link>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<StatCard title="Total Atlet" value={athletes.length} icon={Users} color="text-blue-600 bg-blue-50" />
<StatCard title="Rata-rata Umur" value={`${avgAge} th`} icon={TrendingUp} color="text-emerald-600 bg-emerald-50" />
<StatCard title="App Server" value={serverStatus} icon={Activity} color={serverColor} />
<StatCard title="Sensor IoT" value={sensorStatus} icon={Zap} color={sensorColor} />
</div>
<Separator className="opacity-50" />
{/* Athlete List Section */}
<div className="space-y-8">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="flex items-center gap-4">
<h2 className="text-3xl font-black italic uppercase tracking-tighter">Katalog Atlet</h2>
<Badge variant="secondary" className="bg-slate-900 text-white rounded-lg px-3 py-1 text-xs">
{athletes.length} REKOD
</Badge>
</div>
<div className="flex items-center gap-3">
<span className="text-[10px] font-black uppercase text-slate-400 tracking-widest">Filter:</span>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[180px] h-11 rounded-xl bg-white border-black/5 shadow-sm font-bold text-xs uppercase tracking-wider">
<SelectValue placeholder="Pilih Kategori" />
</SelectTrigger>
<SelectContent className="rounded-xl border-none shadow-2xl">
<SelectItem value="all" className="text-xs font-bold uppercase">Semua Kategori</SelectItem>
{CATEGORIES.map(cat => (
<SelectItem key={cat} value={cat} className="text-xs font-bold uppercase">{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8 pb-20">
{athletes
.filter(a => categoryFilter === 'all' || a.category === categoryFilter)
.sort((a, b) => a.name.localeCompare(b.name))
.map((athlete) => (
<Card key={athlete.id} className="group glass-card border-none hover:ring-4 hover:ring-primary/10 transition-all duration-700 overflow-hidden shadow-2xl rounded-[3rem]">
<CardHeader className="p-0">
<div className="h-28 bg-slate-100 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-br from-primary/5 to-transparent" />
<div className="absolute -bottom-6 left-8 p-1.5 bg-white rounded-[1.5rem] shadow-2xl">
<div className="w-20 h-20 rounded-[1.2rem] bg-slate-950 flex items-center justify-center text-white text-3xl font-black italic">
{String(athlete.name || "?").charAt(0).toUpperCase()}
</div>
</div>
<div className="absolute top-6 right-8">
<Badge variant="outline" className="border-black/5 bg-white/50 backdrop-blur-md font-black text-[9px] tracking-[0.2em] px-3 py-1.5 uppercase rounded-full">
ID: {String(athlete.id || "N/A").slice(-6)}
</Badge>
</div>
</div>
<div className="pt-12 px-10 pb-4">
<CardTitle className="text-3xl font-black tracking-tighter italic uppercase group-hover:text-primary transition-colors leading-none">
{athlete.name}
</CardTitle>
<div className={cn("inline-block mt-3 px-4 py-1.5 rounded-full text-[9px] font-black uppercase tracking-widest border", getCategoryStyles(athlete.category))}>
{athlete.category}
</div>
</div>
</CardHeader>
<CardContent className="px-10 pb-10">
<div className="grid grid-cols-3 gap-6 py-6 border-y border-black/5 mb-8">
<div className="text-center">
<p className="text-[10px] uppercase font-black text-slate-400 mb-1 tracking-widest">Umur</p>
<p className="text-lg font-black italic">{athlete.age} <span className="text-[10px] opacity-40">TH</span></p>
</div>
<div className="text-center border-x border-black/5">
<p className="text-[10px] uppercase font-black text-slate-400 mb-1 tracking-widest">Tinggi</p>
<p className="text-lg font-black italic">{athlete.height} <span className="text-[10px] opacity-40">CM</span></p>
</div>
<div className="text-center">
<p className="text-[10px] uppercase font-black text-slate-400 mb-1 tracking-widest">Berat</p>
<p className="text-lg font-black italic">{athlete.weight} <span className="text-[10px] opacity-40">KG</span></p>
</div>
</div>
<div className="flex gap-4">
<Link href={`/live-test/${athlete.id}`} className="flex-1">
<Button className="w-full rounded-2xl font-black uppercase text-xs tracking-widest h-14 shadow-xl shadow-primary/20 bg-primary hover:bg-primary/90 hover:scale-[1.03] transition-transform italic">
<Activity className="w-4 h-4 mr-2" /> Live Test
</Button>
</Link>
<Button
variant="outline"
onClick={() => { setSelectedAthlete(athlete); setIsHistoryOpen(true); }}
className="h-14 w-14 rounded-2xl border-black/5 hover:border-primary/20 hover:text-primary transition-all"
title="Lihat Riwayat"
>
<TrendingUp size={20} />
</Button>
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="h-14 w-14 rounded-2xl text-slate-300 hover:text-red-500 hover:bg-red-50 hover:border-red-100 border border-black/5 transition-all">
<Trash2 size={20} />
</Button>
</DialogTrigger>
<DialogContent className="rounded-[3rem] border-none shadow-[0_50px_100px_rgba(0,0,0,0.3)] p-10 max-w-md">
<DialogHeader className="pt-4">
<div className="w-20 h-20 bg-red-100 text-red-600 rounded-[2rem] flex items-center justify-center mx-auto mb-6">
<Info size={40} />
</div>
<DialogTitle className="text-3xl font-black text-center italic tracking-tighter uppercase">Hapus Atlet</DialogTitle>
<DialogDescription className="text-center text-slate-500 font-bold mt-2">
Anda akan menghapus data <span className="text-slate-900">{athlete.name}</span> secara permanen. Tindakan ini tidak dapat dibatalkan.
</DialogDescription>
</DialogHeader>
<DialogFooter className="sm:justify-center gap-4 pt-10">
<DialogClose asChild>
<Button variant="ghost" className="rounded-2xl px-8 h-14 font-black uppercase text-xs tracking-widest">BATAL</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={() => deleteAthlete(athlete.id)} variant="destructive" className="rounded-2xl px-10 h-14 font-black uppercase text-xs tracking-widest shadow-2xl shadow-red-200 italic">
HAPUS DATA
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</CardContent>
</Card>
))}
{athletes.length === 0 && (
<div className="col-span-full py-40 flex flex-col items-center justify-center glass-card border-none rounded-[4rem] text-center">
<div className="w-32 h-32 bg-slate-50 rounded-[3rem] flex items-center justify-center mb-10 shadow-inner">
<UserPlus size={60} className="text-slate-200" />
</div>
<h3 className="text-4xl font-black tracking-tighter uppercase italic mb-2">Basis Data Kosong</h3>
<p className="text-slate-400 max-w-sm font-bold uppercase tracking-widest text-[10px] mb-12">
Daftarkan atlet pertama Anda untuk mulai memantau performa.
</p>
<Link href="/register">
<Button size="lg" className="rounded-[2rem] px-12 h-20 text-lg font-black shadow-2xl shadow-primary/30 rotate-[-2deg] hover:rotate-0 transition-transform italic">
<UserPlus className="mr-3 h-8 w-8" /> DAFTAR SEKARANG
</Button>
</Link>
</div>
)}
</div>
</div>
<HistoryModal
athlete={selectedAthlete}
isOpen={isHistoryOpen}
onClose={() => setIsHistoryOpen(false)}
/>
</div>
);
};
export default Dashboard;

View File

@ -0,0 +1,215 @@
"use client";
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { rtdb } from '@/firebase';
import { ref, onValue, set, get } from 'firebase/database';
import { Radio, Scan, Save, User as UserIcon, Calendar, Ruler, Weight, Tag, Activity } from 'lucide-react';
import { Card, CardContent, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
const CATEGORIES = ["Usia dini A", "Usia dini B", "Pra remaja", "Remaja", "Dewasa"];
const Register = () => {
const router = useRouter();
const [formData, setFormData] = useState({
name: '',
age: '',
height: '',
weight: '',
category: 'Usia dini A',
rfid_tag: ''
});
const [scanning, setScanning] = useState(true);
const [loading, setLoading] = useState(false);
const [rfidError, setRfidError] = useState('');
// ================= RFID LISTENER =================
useEffect(() => {
const scannedRef = ref(rtdb, 'system/last_scanned');
const unsubscribe = onValue(scannedRef, async (snapshot) => {
const val = snapshot.val();
if (val && typeof val === 'string') {
try {
const userRef = ref(rtdb, `users/${val}`);
const userSnap = await get(userRef);
if (userSnap.exists()) {
setRfidError("UID sudah terdaftar!");
setFormData(prev => ({ ...prev, rfid_tag: '' }));
setScanning(true);
await set(scannedRef, ""); // Reset system/last_scanned
// Clear error message after 3 seconds
setTimeout(() => setRfidError(''), 3000);
} else {
setRfidError('');
setFormData(prev => ({ ...prev, rfid_tag: val }));
setScanning(false);
}
} catch (error) {
console.error("Error checking UID:", error);
}
}
});
return () => unsubscribe();
}, []);
// ================= HANDLE INPUT =================
const handleChange = (e) => {
setFormData({ ...formData, [e.target.name]: e.target.value });
};
// ================= SUBMIT =================
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.rfid_tag) {
alert('Silakan scan kartu RFID terlebih dahulu.');
return;
}
setLoading(true);
try {
const uid = formData.rfid_tag;
const payload = {
...formData,
id: uid,
createdAt: new Date().toISOString()
};
const userRef = ref(rtdb, `users/${uid}`);
await set(userRef, {
profile: payload,
total_tests: 0,
best_power: 0
});
// 🔥 RESET RFID (PENTING)
await set(ref(rtdb, "system/last_scanned"), "");
alert('✅ Data berhasil disimpan');
// reset form
setFormData({
name: '',
age: '',
height: '',
weight: '',
category: 'Usia dini A',
rfid_tag: ''
});
setScanning(true);
router.push('/');
} catch (err) {
alert('❌ Gagal: ' + err.message);
}
setLoading(false);
};
return (
<div className="flex justify-center items-center py-10">
<Card className="w-full max-w-3xl rounded-[3rem] shadow-2xl overflow-hidden">
{/* HEADER */}
<div className="bg-slate-900 text-white p-10">
<CardTitle className="text-3xl font-black flex items-center gap-3">
<Scan /> PENDAFTARAN ATLET
</CardTitle>
<CardDescription className="text-slate-400 mt-2">
Integrasi RFID & Sistem ImpactMonitor
</CardDescription>
</div>
<form onSubmit={handleSubmit} className="p-10 space-y-10">
{/* IDENTITAS */}
<div>
<Label>Nama</Label>
<Input name="name" value={formData.name} onChange={handleChange} required />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Umur</Label>
<Input type="number" min="1" name="age" value={formData.age} onChange={handleChange} required />
</div>
<div>
<Label>Kategori</Label>
<Select
value={formData.category}
onValueChange={(val) => setFormData(prev => ({ ...prev, category: val }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORIES.map(cat => (
<SelectItem key={cat} value={cat}>{cat}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Separator />
{/* FISIK */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Tinggi</Label>
<Input type="number" min="1" name="height" value={formData.height} onChange={handleChange} required />
</div>
<div>
<Label>Berat</Label>
<Input type="number" min="1" name="weight" value={formData.weight} onChange={handleChange} required />
</div>
</div>
<Separator />
{/* RFID */}
<div className={`p-8 border-4 border-dashed text-center rounded-2xl transition-colors
${rfidError ? 'border-red-500 bg-red-100 text-red-700' :
(scanning ? 'border-blue-300 animate-pulse' : 'border-green-500 bg-green-100')}
`}>
<Radio className={`mx-auto mb-3 ${rfidError ? 'text-red-500' : ''}`} size={40} />
<p className={`font-bold text-lg ${rfidError ? 'text-red-700' : ''}`}>
{rfidError ? 'UID SUDAH TERDAFTAR' : (formData.rfid_tag || 'DEKATKAN KARTU')}
</p>
<p className={`text-sm ${rfidError ? 'text-red-600' : 'text-gray-500'}`}>
{rfidError ? 'Gunakan kartu lain' : (scanning ? 'Menunggu scan...' : 'RFID terdeteksi')}
</p>
</div>
{/* BUTTON */}
<Button
type="submit"
className="w-full"
disabled={loading || scanning || !formData.name || !formData.age || !formData.height || !formData.weight}
>
{loading ? 'Menyimpan...' : 'Simpan'}
</Button>
</form>
</Card>
</div>
);
};
export default Register;

View File

@ -0,0 +1,251 @@
"use client";
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Settings as SettingsIcon, ShieldCheck, KeyRound, AlertCircle, Users, UserPlus, Trash2 } from 'lucide-react';
import { useAuth } from '@/context/AuthContext';
const Settings = () => {
const { user } = useAuth();
const [passwords, setPasswords] = useState({
oldPassword: '',
newPassword: '',
confirmPassword: ''
});
const [newAdmin, setNewAdmin] = useState({ username: '', password: '' });
const [adminUsers, setAdminUsers] = useState([]);
const [status, setStatus] = useState({ type: '', message: '' });
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
fetchAdminUsers();
}, []);
const fetchAdminUsers = async () => {
try {
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const response = await fetch(`${API_URL}/api/admin/users`);
const data = await response.json();
setAdminUsers(data);
} catch (error) {
console.error('Gagal mengambil daftar admin');
}
};
const handleUpdatePassword = async (e) => {
e.preventDefault();
if (passwords.newPassword !== passwords.confirmPassword) {
setStatus({ type: 'error', message: 'Konfirmasi password tidak cocok' });
return;
}
setIsLoading(true);
setStatus({ type: '', message: '' });
try {
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const response = await fetch(`${API_URL}/api/admin/update-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: user?.username,
oldPassword: passwords.oldPassword,
newPassword: passwords.newPassword
})
});
const data = await response.json();
if (data.success) {
setStatus({ type: 'success', message: 'Password berhasil diperbarui!' });
setPasswords({ oldPassword: '', newPassword: '', confirmPassword: '' });
} else {
setStatus({ type: 'error', message: data.message || 'Gagal memperbarui password' });
}
} catch (error) {
setStatus({ type: 'error', message: 'Koneksi ke server gagal' });
} finally {
setIsLoading(false);
}
};
const handleAddAdmin = async (e) => {
e.preventDefault();
try {
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const response = await fetch(`${API_URL}/api/admin/users`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAdmin)
});
const data = await response.json();
if (data.success) {
setNewAdmin({ username: '', password: '' });
fetchAdminUsers();
}
} catch (error) {
console.error('Gagal menambah admin');
}
};
const handleDeleteAdmin = async (id) => {
if (adminUsers.length <= 1) return alert('Minimal harus ada 1 admin!');
if (!confirm('Hapus admin ini?')) return;
try {
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
await fetch(`${API_URL}/api/admin/users/${id}`, { method: 'DELETE' });
fetchAdminUsers();
} catch (error) {
console.error('Gagal menghapus admin');
}
};
return (
<div className="space-y-8 animate-in fade-in duration-500 pb-20">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-3">
<SettingsIcon className="text-primary w-8 h-8" />
Pengaturan Sistem
</h1>
<p className="text-slate-500">Kelola keamanan dan pengguna admin aplikasi Impact Monitor.</p>
</div>
<div className="grid gap-8 md:grid-cols-2">
{/* Bagian Ganti Password */}
<Card className="border-black/5 shadow-xl shadow-slate-200/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<KeyRound className="w-5 h-5 text-primary" />
Ganti Password Saya
</CardTitle>
<CardDescription>Login sebagai: <span className="font-bold text-slate-900">{user?.username}</span></CardDescription>
</CardHeader>
<form onSubmit={handleUpdatePassword}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="oldPassword">Password Lama</Label>
<Input
id="oldPassword"
type="password"
value={passwords.oldPassword}
onChange={(e) => setPasswords({...passwords, oldPassword: e.target.value})}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="newPassword">Password Baru</Label>
<Input
id="newPassword"
type="password"
value={passwords.newPassword}
onChange={(e) => setPasswords({...passwords, newPassword: e.target.value})}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Konfirmasi Password</Label>
<Input
id="confirmPassword"
type="password"
value={passwords.confirmPassword}
onChange={(e) => setPasswords({...passwords, confirmPassword: e.target.value})}
required
/>
</div>
{status.message && (
<div className={`p-3 rounded-lg text-sm flex items-center gap-2 ${
status.type === 'success' ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'
}`}>
{status.type === 'error' && <AlertCircle size={16} />}
{status.message}
</div>
)}
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? 'Menyimpan...' : 'Perbarui Password'}
</Button>
</CardFooter>
</form>
</Card>
{/* Manajemen User Admin */}
<div className="space-y-8">
<Card className="border-black/5 shadow-xl shadow-slate-200/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="w-5 h-5 text-primary" />
Daftar Admin
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{adminUsers.map(admin => (
<div key={admin.id} className="flex justify-between items-center p-3 bg-slate-50 rounded-xl border border-slate-100">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-slate-200 flex items-center justify-center text-xs font-bold text-slate-600">
{admin.username.charAt(0).toUpperCase()}
</div>
<span className="font-medium text-slate-700">{admin.username}</span>
</div>
{admin.username !== user?.username && (
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteAdmin(admin.id)}
className="text-slate-400 hover:text-red-500 rounded-lg"
>
<Trash2 size={16} />
</Button>
)}
</div>
))}
</div>
</CardContent>
</Card>
<Card className="border-black/5 shadow-xl shadow-slate-200/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<UserPlus className="w-5 h-5 text-primary" />
Tambah Admin Baru
</CardTitle>
</CardHeader>
<form onSubmit={handleAddAdmin}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Username Admin Baru</Label>
<Input
value={newAdmin.username}
onChange={(e) => setNewAdmin({...newAdmin, username: e.target.value})}
placeholder="Ketik username baru..."
required
/>
</div>
<div className="space-y-2">
<Label>Password</Label>
<Input
type="password"
value={newAdmin.password}
onChange={(e) => setNewAdmin({...newAdmin, password: e.target.value})}
required
/>
</div>
</CardContent>
<CardFooter>
<Button type="submit" variant="secondary" className="w-full">Tambah Admin</Button>
</CardFooter>
</form>
</Card>
</div>
</div>
</div>
);
};
export default Settings;

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,167 @@
@import "tailwindcss";
@plugin "tailwindcss-animate";
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--radius-lg: var(--radius);
--radius-md: calc(var(--radius) - 2px);
--radius-sm: calc(var(--radius) - 4px);
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--color-chart-1: hsl(var(--chart-1));
--color-chart-2: hsl(var(--chart-2));
--color-chart-3: hsl(var(--chart-3));
--color-chart-4: hsl(var(--chart-4));
--color-chart-5: hsl(var(--chart-5));
}
@layer base {
:root {
--background: 210 40% 98%;
--foreground: 222.2 47.4% 11.2%;
--card: 0 0% 100%;
--card-foreground: 222.2 47.4% 11.2%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 47.4% 11.2%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 1rem;
--chart-1: 221 83% 53%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans antialiased;
background-image:
radial-gradient(circle at top right, rgba(59, 130, 246, 0.08) 0%, transparent 40%),
radial-gradient(circle at bottom left, rgba(16, 185, 129, 0.05) 0%, transparent 40%),
linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.4));
background-attachment: fixed;
}
}
@layer utilities {
.glass-card {
@apply bg-white/70 backdrop-blur-xl border border-white/20 shadow-xl shadow-primary/5;
}
.glass-card-dark {
@apply bg-slate-950/40 backdrop-blur-xl border border-slate-800/50 shadow-2xl;
}
.text-gradient {
@apply bg-clip-text text-transparent bg-gradient-to-r from-primary to-blue-600;
}
.tech-grid {
background-size: 40px 40px;
background-image:
linear-gradient(to right, rgba(0, 0, 0, 0.02) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0, 0, 0, 0.02) 1px, transparent 1px);
}
}
/* Chrome, Safari, Edge, Opera */
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Firefox */
input[type=number] {
-moz-appearance: textfield;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply bg-slate-200 rounded-full hover:bg-slate-300 transition-colors;
}

View File

@ -0,0 +1,36 @@
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/context/AuthContext";
import { SocketProvider } from "@/context/SocketContext";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata = {
title: "ImpactMonitor Pro",
description: "Dirancang untuk Atlet Profesional",
};
export default function RootLayout({ children }) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<AuthProvider>
<SocketProvider>
{children}
</SocketProvider>
</AuthProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,117 @@
"use client";
import { useState, useEffect } from "react";
import { useAuth } from "@/context/AuthContext";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Activity, Lock } from "lucide-react";
const AdminLogin = () => {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { login, user } = useAuth();
const router = useRouter();
// Jika sudah login, langsung lempar ke dashboard
useEffect(() => {
if (user) {
router.push("/");
}
}, [user, router]);
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
const result = await login(username, password);
setIsLoading(false);
if (result.success) {
router.push("/");
} else {
setError(result.message);
}
};
return (
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-slate-100 via-blue-50 to-slate-100">
{/* Decorative background blobs */}
<div className="absolute top-0 right-0 w-96 h-96 bg-blue-400/10 rounded-full blur-3xl -translate-y-1/2 translate-x-1/2 pointer-events-none" />
<div className="absolute bottom-0 left-0 w-96 h-96 bg-indigo-400/10 rounded-full blur-3xl translate-y-1/2 -translate-x-1/2 pointer-events-none" />
<div className="relative z-10 w-full max-w-md px-6">
{/* Branding */}
<div className="text-center mb-10">
<div className="flex items-center justify-center mb-6">
<img src="/logo.png" alt="IKSPI" className="h-32 w-auto object-contain drop-shadow-2xl" />
</div>
<h1 className="text-4xl font-extrabold tracking-tight text-slate-900">
IMPACT<span className="text-primary">MONITOR</span>
</h1>
<p className="text-slate-500 text-base mt-2 font-medium">Sistem Monitoring Atlet Silat Pro Edition</p>
</div>
<Card className="border-black/5 shadow-2xl shadow-blue-100 bg-white/90 backdrop-blur-2xl py-2">
<CardHeader className="pb-6">
<CardTitle className="flex items-center gap-3 text-2xl font-bold">
<div className="p-2 bg-primary/10 rounded-lg">
<Lock className="w-6 h-6 text-primary" />
</div>
Login Admin
</CardTitle>
<CardDescription className="text-sm">Silakan masukkan kata sandi untuk mengelola data atlet.</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-6">
<div className="grid w-full items-center gap-2.5">
<Label htmlFor="username">Username Admin</Label>
<Input
id="username"
type="text"
placeholder="Masukkan username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="h-12 bg-white/50 border-slate-200 focus:ring-2 focus:ring-primary/20 transition-all text-lg"
/>
</div>
<div className="grid w-full items-center gap-2.5">
<Label htmlFor="password text-slate-700">Kata Sandi Sistem</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-12 bg-white/50 border-slate-200 focus:ring-2 focus:ring-primary/20 transition-all text-lg"
/>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-4 py-3 flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse" />
{error}
</p>
)}
</CardContent>
<CardFooter className="pt-2 pb-8">
<Button
type="submit"
className="w-full h-12 text-base font-semibold shadow-lg shadow-primary/25 hover:shadow-primary/40 transition-all active:scale-[0.98]"
disabled={isLoading}
>
{isLoading ? "Memverifikasi..." : "Masuk ke Dashboard"}
</Button>
</CardFooter>
</form>
</Card>
<p className="text-center text-sm text-slate-400 mt-10">
© {new Date().getFullYear()} ImpactMonitor Pro · Dirancang untuk IKSPI Kera Sakti
</p>
</div>
</div>
);
};
export default AdminLogin;

View File

@ -1,3 +1,4 @@
"use client";
import { createContext, useContext, useState, useEffect } from "react";
const AuthContext = createContext();
@ -20,7 +21,7 @@ export const AuthProvider = ({ children }) => {
const login = async (username, password) => {
try {
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const response = await fetch(`${API_URL}/api/admin/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },

View File

@ -1,3 +1,4 @@
"use client";
import React, { createContext, useContext, useEffect, useState } from 'react';
import { io } from 'socket.io-client';
@ -15,7 +16,7 @@ export const SocketProvider = ({ children }) => {
const [socket, setSocket] = useState(null);
useEffect(() => {
const SOCKET_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000';
const SOCKET_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
const newSocket = io(SOCKET_URL);
setSocket(newSocket);

View File

@ -2,13 +2,13 @@ import { initializeApp } from "firebase/app";
import { getDatabase } from "firebase/database";
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
databaseURL: import.meta.env.VITE_FIREBASE_DATABASE_URL,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
databaseURL: process.env.NEXT_PUBLIC_FIREBASE_DATABASE_URL,
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID
};
const app = initializeApp(firebaseConfig);

2
package-lock.json generated
View File

@ -1,5 +1,5 @@
{
"name": "Silat",
"name": "silat",
"lockfileVersion": 3,
"requires": true,
"packages": {