Compare commits
10 Commits
59e5ee9cba
...
943a99340f
| Author | SHA1 | Date |
|---|---|---|
|
|
943a99340f | |
|
|
e0e64110cd | |
|
|
0d3be84153 | |
|
|
e7b422bc3b | |
|
|
b96eab67f3 | |
|
|
cbab5166d8 | |
|
|
29382d6067 | |
|
|
dc2c722e57 | |
|
|
fee981d9be | |
|
|
97856369d4 |
|
|
@ -1,7 +1,4 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
backend/node_modules/
|
.env
|
||||||
frontend/node_modules/
|
dist/
|
||||||
frontend/dist/
|
*.log
|
||||||
backend/silat_monitor.db
|
|
||||||
.env
|
|
||||||
.DS_Store
|
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const dbPath = path.resolve(__dirname, 'silat_monitor.db');
|
|
||||||
const db = new sqlite3.Database(dbPath, (err) => {
|
|
||||||
if (err) console.error('Error opening database:', err);
|
|
||||||
else console.log('Database initialized at', dbPath);
|
|
||||||
});
|
|
||||||
|
|
||||||
db.serialize(() => {
|
|
||||||
db.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS athletes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
age INTEGER,
|
|
||||||
height REAL,
|
|
||||||
weight REAL,
|
|
||||||
category TEXT,
|
|
||||||
rfid_tag TEXT UNIQUE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
db.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS attempts (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
athlete_id INTEGER,
|
|
||||||
force REAL,
|
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY(athlete_id) REFERENCES athletes(id)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
db.run(`
|
|
||||||
CREATE TABLE IF NOT EXISTS admins (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT UNIQUE NOT NULL,
|
|
||||||
password TEXT NOT NULL
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Inisialisasi admin default jika belum ada
|
|
||||||
db.run(`INSERT OR IGNORE INTO admins (username, password) VALUES ('admin', 'admin123')`);
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = db;
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"name": "backend",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "",
|
|
||||||
"main": "server.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node server.js",
|
|
||||||
"dev": "nodemon server.js"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"sqlite3": "^5.1.7",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"express": "^4.21.2",
|
|
||||||
"socket.io": "^4.8.1"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"nodemon": "^3.1.9"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,265 +0,0 @@
|
||||||
const express = require('express');
|
|
||||||
const http = require('http');
|
|
||||||
const { Server } = require("socket.io");
|
|
||||||
const cors = require('cors');
|
|
||||||
const db = require('./database');
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
const server = http.createServer(app);
|
|
||||||
const io = new Server(server, {
|
|
||||||
cors: {
|
|
||||||
origin: "*",
|
|
||||||
methods: ["GET", "POST"]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentTestSession = {
|
|
||||||
athleteId: null,
|
|
||||||
attempts: []
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper for Promisified DB queries
|
|
||||||
const dbAll = (sql, params = []) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
db.all(sql, params, (err, rows) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(rows);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const dbGet = (sql, params = []) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
db.get(sql, params, (err, row) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(row);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const dbRun = (sql, params = []) => {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
db.run(sql, params, function (err) {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve({ id: this.lastID, changes: this.changes });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// API Routes
|
|
||||||
app.get('/api/athletes', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const athletes = await dbAll('SELECT * FROM athletes ORDER BY id DESC');
|
|
||||||
res.json(athletes);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/athletes/:id', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const athlete = await dbGet('SELECT * FROM athletes WHERE id = ?', [req.params.id]);
|
|
||||||
if (athlete) res.json(athlete);
|
|
||||||
else res.status(404).json({ error: 'Athlete not found' });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/athletes', async (req, res) => {
|
|
||||||
const { name, age, height, weight, category, rfid_tag } = req.body;
|
|
||||||
try {
|
|
||||||
const result = await dbRun(
|
|
||||||
'INSERT INTO athletes (name, age, height, weight, category, rfid_tag) VALUES (?, ?, ?, ?, ?, ?)',
|
|
||||||
[name, age, height, weight, category, rfid_tag]
|
|
||||||
);
|
|
||||||
res.json({ id: result.id, success: true });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete('/api/athletes/:id', async (req, res) => {
|
|
||||||
try {
|
|
||||||
await dbRun('DELETE FROM attempts WHERE athlete_id = ?', [req.params.id]);
|
|
||||||
await dbRun('DELETE FROM athletes WHERE id = ?', [req.params.id]);
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/leaderboard', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const query = `
|
|
||||||
SELECT a.name, a.category, MAX(at.force) as best_score
|
|
||||||
FROM athletes a
|
|
||||||
JOIN attempts at ON a.id = at.athlete_id
|
|
||||||
GROUP BY a.id, a.name, a.category
|
|
||||||
ORDER BY best_score DESC
|
|
||||||
`;
|
|
||||||
const leaderboard = await dbAll(query);
|
|
||||||
res.json(leaderboard);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/api/attempts/:athleteId', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const attempts = await dbAll(
|
|
||||||
'SELECT * FROM attempts WHERE athlete_id = ? ORDER BY timestamp ASC',
|
|
||||||
[req.params.athleteId]
|
|
||||||
);
|
|
||||||
res.json(attempts);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get History for Chart (Option 3)
|
|
||||||
app.get('/api/history/:athleteId', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const history = await dbAll(`
|
|
||||||
SELECT
|
|
||||||
strftime('%Y-%m-%d %H:%M', timestamp) as date,
|
|
||||||
force
|
|
||||||
FROM attempts
|
|
||||||
WHERE athlete_id = ?
|
|
||||||
ORDER BY timestamp ASC
|
|
||||||
`, [req.params.athleteId]);
|
|
||||||
res.json(history);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start/Stop Test Session logic
|
|
||||||
app.post('/api/start-test', (req, res) => {
|
|
||||||
const { athleteId } = req.body;
|
|
||||||
// Reset session for this athlete
|
|
||||||
currentTestSession = { athleteId: parseInt(athleteId), attempts: [] };
|
|
||||||
io.emit('session_started', { athleteId: currentTestSession.athleteId });
|
|
||||||
console.log(`Session started for athlete ${athleteId}`);
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/stop-test', (req, res) => {
|
|
||||||
currentTestSession = { athleteId: null, attempts: [] };
|
|
||||||
io.emit('session_ended');
|
|
||||||
console.log(`Session ended`);
|
|
||||||
res.json({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/admin/login', async (req, res) => {
|
|
||||||
const { username, password } = req.body;
|
|
||||||
try {
|
|
||||||
const admin = await dbGet('SELECT * FROM admins WHERE username = ?', [username]);
|
|
||||||
if (admin && password === admin.password) {
|
|
||||||
res.json({ success: true, token: 'mock-admin-token', username: admin.username });
|
|
||||||
} else {
|
|
||||||
res.status(401).json({ success: false, message: 'Username atau Password salah' });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: 'Database error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Admin Management APIs
|
|
||||||
app.get('/api/admin/users', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const admins = await dbAll('SELECT id, username FROM admins');
|
|
||||||
res.json(admins);
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/admin/users', async (req, res) => {
|
|
||||||
const { username, password } = req.body;
|
|
||||||
try {
|
|
||||||
await dbRun('INSERT INTO admins (username, password) VALUES (?, ?)', [username, password]);
|
|
||||||
res.json({ success: true, message: 'Admin baru ditambahkan' });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: 'Username sudah digunakan atau gagal simpan' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.delete('/api/admin/users/:id', async (req, res) => {
|
|
||||||
try {
|
|
||||||
await dbRun('DELETE FROM admins WHERE id = ?', [req.params.id]);
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/api/admin/update-password', async (req, res) => {
|
|
||||||
const { username, oldPassword, newPassword } = req.body;
|
|
||||||
try {
|
|
||||||
const admin = await dbGet('SELECT * FROM admins WHERE username = ?', [username]);
|
|
||||||
if (!admin || oldPassword !== admin.password) {
|
|
||||||
return res.status(400).json({ success: false, message: 'Password lama salah' });
|
|
||||||
}
|
|
||||||
await dbRun('UPDATE admins SET password = ? WHERE username = ?', [newPassword, username]);
|
|
||||||
res.json({ success: true, message: 'Password berhasil diubah' });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ error: 'Gagal mengubah password' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Socket.io
|
|
||||||
io.on('connection', (socket) => {
|
|
||||||
console.log('Client connected', socket.id);
|
|
||||||
|
|
||||||
// Received from RFID reader (simulation)
|
|
||||||
socket.on('rfid_scan', (tagId) => {
|
|
||||||
console.log('RFID Scanned:', tagId);
|
|
||||||
io.emit('rfid_scanned', tagId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Received from Impact Sensor (simulation)
|
|
||||||
// Stream for graph visualization
|
|
||||||
socket.on('sensor_stream', (value) => {
|
|
||||||
io.emit('sensor_live_data', value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Impact detected (Peak value or significant hit)
|
|
||||||
socket.on('impact_detected', async (forceValue) => {
|
|
||||||
console.log('Impact detected:', forceValue);
|
|
||||||
|
|
||||||
if (currentTestSession.athleteId) {
|
|
||||||
const athleteId = currentTestSession.athleteId;
|
|
||||||
|
|
||||||
// Save to DB
|
|
||||||
try {
|
|
||||||
await dbRun('INSERT INTO attempts (athlete_id, force) VALUES (?, ?)', [athleteId, forceValue]);
|
|
||||||
|
|
||||||
currentTestSession.attempts.push(forceValue);
|
|
||||||
const attemptCount = currentTestSession.attempts.length;
|
|
||||||
|
|
||||||
io.emit('new_attempt_result', {
|
|
||||||
force: forceValue,
|
|
||||||
attemptNumber: attemptCount,
|
|
||||||
athleteId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (attemptCount >= 3) {
|
|
||||||
io.emit('test_milestone_reached', { athleteId, count: 3 });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error saving attempt:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const PORT = 3000;
|
|
||||||
server.listen(PORT, () => {
|
|
||||||
console.log(`Server running on port ${PORT}`);
|
|
||||||
});
|
|
||||||
Binary file not shown.
|
|
@ -1,15 +0,0 @@
|
||||||
const io = require('socket.io-client');
|
|
||||||
|
|
||||||
const socket = io('http://localhost:3000');
|
|
||||||
|
|
||||||
const tagId = process.argv[2] || 'TAG-' + Math.floor(Math.random() * 100000);
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
console.log('Connected to server as RFID Reader');
|
|
||||||
console.log(`Scanning tag: ${tagId}`);
|
|
||||||
socket.emit('rfid_scan', tagId);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
socket.disconnect();
|
|
||||||
}, 1000);
|
|
||||||
});
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
const io = require('socket.io-client');
|
|
||||||
|
|
||||||
const socket = io('http://localhost:3000');
|
|
||||||
|
|
||||||
socket.on('connect', () => {
|
|
||||||
console.log('Connected to server as Impact Sensor');
|
|
||||||
simulatePunch();
|
|
||||||
});
|
|
||||||
|
|
||||||
function simulatePunch() {
|
|
||||||
console.log('Simulating punch...');
|
|
||||||
|
|
||||||
// 1. Send low noise (idle)
|
|
||||||
let steps = 0;
|
|
||||||
const idleInterval = setInterval(() => {
|
|
||||||
const noise = Math.random() * 5;
|
|
||||||
socket.emit('sensor_stream', noise);
|
|
||||||
steps++;
|
|
||||||
|
|
||||||
if (steps > 20) {
|
|
||||||
clearInterval(idleInterval);
|
|
||||||
sendImpact();
|
|
||||||
}
|
|
||||||
}, 50);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendImpact() {
|
|
||||||
// 2. Send spike (Impact)
|
|
||||||
let force = 0;
|
|
||||||
const peak = 500 + Math.random() * 500; // Random peak between 500 and 1000
|
|
||||||
let up = true;
|
|
||||||
|
|
||||||
const impactInterval = setInterval(() => {
|
|
||||||
if (up) {
|
|
||||||
force += 100;
|
|
||||||
if (force >= peak) {
|
|
||||||
force = peak;
|
|
||||||
up = false;
|
|
||||||
// Emit event at peak
|
|
||||||
socket.emit('impact_detected', Math.round(peak));
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
force -= 100;
|
|
||||||
if (force <= 0) {
|
|
||||||
force = 0;
|
|
||||||
clearInterval(impactInterval);
|
|
||||||
socket.emit('sensor_stream', 0);
|
|
||||||
console.log(`Punch finished. Peak: ${Math.round(peak)}`);
|
|
||||||
socket.disconnect(); // Or keep running for next punch
|
|
||||||
}
|
|
||||||
}
|
|
||||||
socket.emit('sensor_stream', force);
|
|
||||||
}, 20); // Fast update for impact
|
|
||||||
}
|
|
||||||
|
|
@ -458,7 +458,7 @@ const Monitor = () => {
|
||||||
|
|
||||||
<div className="flex items-center justify-center gap-12 mt-8 w-full max-w-sm">
|
<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">
|
<div className="flex items-center gap-2 text-[10px] font-black uppercase text-emerald-500 tracking-widest">
|
||||||
<Target size={14} /> Target: {">"}800N
|
<Target size={14} /> Target: {strikeType === 'pukulan' ? '>150N' : '>250N'}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-[10px] font-black uppercase text-primary tracking-widest border-l border-slate-100 pl-12">
|
<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"}
|
<Waves size={14} /> Athlete: {currentAthlete?.name || "Pilih Atlet"}
|
||||||
|
|
@ -477,33 +477,56 @@ const Monitor = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8 mt-10">
|
<div className="space-y-8 mt-10">
|
||||||
{/* Progress bar */}
|
{/* Progress bar - max dinamis sesuai jenis serangan */}
|
||||||
<div>
|
{(() => {
|
||||||
<div className="flex justify-between items-end mb-2">
|
const maxN = strikeType === 'pukulan' ? 150 : 300;
|
||||||
<span className="text-[10px] font-black uppercase text-slate-400 tracking-widest">Efisiensi Serangan</span>
|
const pct = Math.min(100, (displayPeak / maxN) * 100);
|
||||||
<span className="text-lg font-black italic text-emerald-500">
|
return (
|
||||||
{(Math.min(100, displayPeak / 1000 * 100)).toFixed(1)}%
|
<div>
|
||||||
</span>
|
<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">
|
||||||
|
{pct.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: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between mt-2 text-[9px] font-black uppercase text-slate-300 tracking-widest">
|
||||||
|
<span>0 N</span>
|
||||||
|
<span>{maxN} N</span>
|
||||||
|
</div>
|
||||||
</div>
|
</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="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">
|
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:rotate-12 transition-transform">
|
||||||
<MessageSquare size={100} />
|
<MessageSquare size={100} />
|
||||||
</div>
|
</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>
|
<span className="text-[9px] font-black uppercase text-primary tracking-widest border-b border-primary/20 pb-2 mb-4 block">
|
||||||
|
Saran Pelatih — {strikeType === 'pukulan' ? '🥊 Pukulan' : '🦵 Tendangan'}
|
||||||
|
</span>
|
||||||
<p className="text-sm font-bold italic leading-relaxed text-slate-300">
|
<p className="text-sm font-bold italic leading-relaxed text-slate-300">
|
||||||
"Fokus pada kecepatan putaran pinggang untuk menambah gaya ledak."
|
{strikeType === 'pukulan'
|
||||||
|
? displayPeak >= 130
|
||||||
|
? '"Pukulan sangat kuat! Rotasi bahu dan dorongan pinggul sudah optimal. Pertahankan posisi siku tetap terkunci saat impact dan jaga konsistensi kecepatan di setiap percobaan."'
|
||||||
|
: displayPeak >= 80
|
||||||
|
? '"Pukulan cukup baik. Fokus pada koordinasi antara dorongan bahu dan ekstensi siku. Kunci pergelangan tangan sesaat sebelum mengenai target untuk memaksimalkan transfer gaya."'
|
||||||
|
: displayPeak > 0
|
||||||
|
? '"Gaya pukulan masih perlu ditingkatkan. Latih kecepatan ekstensi siku dan perkuat otot bahu. Pastikan seluruh berat badan ikut mendorong ke depan saat memukul."'
|
||||||
|
: '"Pilih atlet dan mulai sesi untuk melihat analisis pukulan."'
|
||||||
|
: displayPeak >= 250
|
||||||
|
? '"Tendangan sangat powerful! Pivot kaki tumpu dan rotasi pinggul sudah sangat baik. Jaga keseimbangan setelah impact dan pertahankan snap pergelangan kaki untuk akurasi maksimal."'
|
||||||
|
: displayPeak >= 150
|
||||||
|
? '"Tendangan cukup baik. Tingkatkan kecepatan rotasi pinggul dan pastikan lutut terangkat cukup tinggi sebelum eksekusi. Snap pergelangan kaki saat impact akan menambah gaya secara signifikan."'
|
||||||
|
: displayPeak > 0
|
||||||
|
? '"Gaya tendangan perlu ditingkatkan. Fokus pada kecepatan putaran pinggang dan angkat lutut lebih tinggi sebelum menendang. Latih fleksibilitas pinggul untuk menambah jangkauan dan gaya ledak."'
|
||||||
|
: '"Pilih atlet dan mulai sesi untuk melihat analisis tendangan."'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -43,46 +43,14 @@ const StatCard = ({ title, value, icon: Icon, color }) => (
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const [athletes, setAthletes] = useState([]);
|
const [athletes, setAthletes] = useState([]);
|
||||||
const [serverStatus, setServerStatus] = useState('Menghubungkan...');
|
|
||||||
const [sensorStatus, setSensorStatus] = useState('Memeriksa...');
|
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 [sensorColor, setSensorColor] = useState('text-slate-400 bg-slate-50');
|
||||||
|
|
||||||
const [selectedAthlete, setSelectedAthlete] = useState(null);
|
const [selectedAthlete, setSelectedAthlete] = useState(null);
|
||||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
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)
|
// Cek status sensor IoT dari Firebase RTDB (timestamp heartbeat)
|
||||||
|
|
@ -169,17 +137,6 @@ const Dashboard = () => {
|
||||||
await remove(ref(rtdb, `test_history/${id}`));
|
await remove(ref(rtdb, `test_history/${id}`));
|
||||||
await remove(ref(rtdb, `attempts/${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.`);
|
console.log(`Athlete ${id} and all associated data deleted successfully.`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete athlete and data", err);
|
console.error("Failed to delete athlete and data", err);
|
||||||
|
|
@ -218,10 +175,9 @@ const Dashboard = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<StatCard title="Total Atlet" value={athletes.length} icon={Users} color="text-blue-600 bg-blue-50" />
|
<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="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} />
|
<StatCard title="Sensor IoT" value={sensorStatus} icon={Zap} color={sensorColor} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,7 @@ const Settings = () => {
|
||||||
|
|
||||||
const fetchAdminUsers = async () => {
|
const fetchAdminUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const response = await fetch(`/api/admin/users`);
|
||||||
const response = await fetch(`${API_URL}/api/admin/users`);
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setAdminUsers(data);
|
setAdminUsers(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -46,8 +45,7 @@ const Settings = () => {
|
||||||
setStatus({ type: '', message: '' });
|
setStatus({ type: '', message: '' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const response = await fetch(`/api/admin/update-password`, {
|
||||||
const response = await fetch(`${API_URL}/api/admin/update-password`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|
@ -74,8 +72,7 @@ const Settings = () => {
|
||||||
const handleAddAdmin = async (e) => {
|
const handleAddAdmin = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const response = await fetch(`/api/admin/users`, {
|
||||||
const response = await fetch(`${API_URL}/api/admin/users`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(newAdmin)
|
body: JSON.stringify(newAdmin)
|
||||||
|
|
@ -95,8 +92,7 @@ const Settings = () => {
|
||||||
if (!confirm('Hapus admin ini?')) return;
|
if (!confirm('Hapus admin ini?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
await fetch(`/api/admin/users/${id}`, { method: 'DELETE' });
|
||||||
await fetch(`${API_URL}/api/admin/users/${id}`, { method: 'DELETE' });
|
|
||||||
fetchAdminUsers();
|
fetchAdminUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Gagal menghapus admin');
|
console.error('Gagal menghapus admin');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { rtdb } from '@/firebase';
|
||||||
|
import { ref, get } from 'firebase/database';
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { username, password } = body;
|
||||||
|
|
||||||
|
const adminsRef = ref(rtdb, 'admins');
|
||||||
|
const snapshot = await get(adminsRef);
|
||||||
|
|
||||||
|
if (snapshot.exists()) {
|
||||||
|
const admins = snapshot.val();
|
||||||
|
let isAuthenticated = false;
|
||||||
|
let loggedInUser = null;
|
||||||
|
|
||||||
|
for (const key in admins) {
|
||||||
|
if (admins[key].username === username && admins[key].password === password) {
|
||||||
|
isAuthenticated = true;
|
||||||
|
loggedInUser = admins[key].username;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
return NextResponse.json({ success: true, token: 'mock-admin-token-firebase', username: loggedInUser });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback default admin if no admins exist in Firebase yet
|
||||||
|
if (!snapshot.exists() && username === 'admin' && password === 'admin123') {
|
||||||
|
return NextResponse.json({ success: true, token: 'mock-admin-token-fallback', username: 'admin' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: false, message: 'Username atau Password salah' }, { status: 401 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Login Error:", error);
|
||||||
|
return NextResponse.json({ success: false, message: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { rtdb } from '@/firebase';
|
||||||
|
import { ref, get, update } from 'firebase/database';
|
||||||
|
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { username, oldPassword, newPassword } = body;
|
||||||
|
|
||||||
|
const adminsRef = ref(rtdb, 'admins');
|
||||||
|
const snapshot = await get(adminsRef);
|
||||||
|
|
||||||
|
if (snapshot.exists()) {
|
||||||
|
const admins = snapshot.val();
|
||||||
|
let targetAdminId = null;
|
||||||
|
let isValid = false;
|
||||||
|
|
||||||
|
for (const key in admins) {
|
||||||
|
if (admins[key].username === username && admins[key].password === oldPassword) {
|
||||||
|
targetAdminId = key;
|
||||||
|
isValid = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isValid && targetAdminId) {
|
||||||
|
const adminRef = ref(rtdb, `admins/${targetAdminId}`);
|
||||||
|
await update(adminRef, { password: newPassword });
|
||||||
|
return NextResponse.json({ success: true, message: 'Password berhasil diubah' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock default admin check
|
||||||
|
if (!snapshot.exists() && username === 'admin' && oldPassword === 'admin123') {
|
||||||
|
return NextResponse.json({ success: false, message: 'Harap buat akun admin baru terlebih dahulu di menu Admin' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: false, message: 'Password lama salah atau user tidak ditemukan' }, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update Password Error:", error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { rtdb } from '@/firebase';
|
||||||
|
import { ref, remove } from 'firebase/database';
|
||||||
|
|
||||||
|
export async function DELETE(request, { params }) {
|
||||||
|
try {
|
||||||
|
const { id } = params;
|
||||||
|
if (!id || id === 'default') {
|
||||||
|
return NextResponse.json({ error: 'Cannot delete this admin' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminRef = ref(rtdb, `admins/${id}`);
|
||||||
|
await remove(adminRef);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Admin deleted' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("DELETE Admin Error:", error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { rtdb } from '@/firebase';
|
||||||
|
import { ref, get, push, set } from 'firebase/database';
|
||||||
|
|
||||||
|
// GET all admins
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const adminsRef = ref(rtdb, 'admins');
|
||||||
|
const snapshot = await get(adminsRef);
|
||||||
|
|
||||||
|
let adminsArray = [];
|
||||||
|
if (snapshot.exists()) {
|
||||||
|
const data = snapshot.val();
|
||||||
|
adminsArray = Object.keys(data).map(key => ({
|
||||||
|
id: key,
|
||||||
|
username: data[key].username
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Default admin if empty
|
||||||
|
adminsArray = [{ id: 'default', username: 'admin' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(adminsArray);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("GET Admins Error:", error);
|
||||||
|
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST new admin
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { username, password } = body;
|
||||||
|
|
||||||
|
const adminsRef = ref(rtdb, 'admins');
|
||||||
|
const snapshot = await get(adminsRef);
|
||||||
|
|
||||||
|
// Check if username exists
|
||||||
|
if (snapshot.exists()) {
|
||||||
|
const admins = snapshot.val();
|
||||||
|
const exists = Object.values(admins).some(admin => admin.username === username);
|
||||||
|
if (exists) {
|
||||||
|
return NextResponse.json({ error: 'Username sudah digunakan' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newAdminRef = push(adminsRef);
|
||||||
|
await set(newAdminRef, {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Admin baru ditambahkan', id: newAdminRef.key });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("POST Admin Error:", error);
|
||||||
|
return NextResponse.json({ error: 'Gagal menyimpan admin' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,8 +21,7 @@ export const AuthProvider = ({ children }) => {
|
||||||
|
|
||||||
const login = async (username, password) => {
|
const login = async (username, password) => {
|
||||||
try {
|
try {
|
||||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
const response = await fetch(`/api/admin/login`, {
|
||||||
const response = await fetch(`${API_URL}/api/admin/login`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,5 @@
|
||||||
"firebase": "^12.11.0",
|
"firebase": "^12.11.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6"
|
"react-dom": "^19.2.6"
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
|
||||||
"vite": "^8.0.13"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"framework": "vite",
|
|
||||||
"buildCommand": "cd frontend && npm install && npm run build",
|
|
||||||
"outputDirectory": "frontend/dist"
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue