myheats

Live heats, scoring and leaderboard for sport events
git clone https://git.in0rdr.ch/myheats.git
Log | Files | Refs | Pull requests | README | LICENSE

commit 366743960e55f59ea35712b917300bd7bf5bf46c
parent 498bd5fc78f829c1991a428c6e9d99497df4561b
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date:   Sun, 17 Nov 2024 14:28:57 +0100

feat: Judge view

Diffstat:
Msrc/api/db.cjs | 52+++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/api/server.cjs | 75++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Msrc/frontend/App.jsx | 9+++++++++
Asrc/frontend/Judges.jsx | 151++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/frontend/utils.js | 19+++++++++++++++++--
5 files changed, 302 insertions(+), 4 deletions(-)

diff --git a/src/api/db.cjs b/src/api/db.cjs @@ -122,7 +122,19 @@ async function allAthletes() { ` return athletes } catch (error) { - console.error('Error occurred in allHeats:', error); + console.error('Error occurred in allAthletes:', error); + throw error + } +} + +async function allJudges() { + try { + const athletes = await sql` + select * from judges + ` + return athletes + } catch (error) { + console.error('Error occurred in allJudges:', error); throw error } } @@ -186,6 +198,41 @@ async function removeAthlete(id) { } } +async function addJudge(email, firstname, lastname) { + try { + const judge = await sql` + insert into judges ( + email, + firstname, + lastname + ) + values ( + ${email}, + ${firstname}, + ${lastname} + ) + returning * + ` + return judge + } catch (error) { + console.error('Error occurred in addJudge:', error); + throw error + } +} + +async function removeJudge(id) { + try { + const judge = await sql` + delete from judges where id = ${id} + returning * + ` + return judge + } catch (error) { + console.error('Error occurred in removeJudge:', error); + throw error + } +} + async function removeAthleteFromHeat(startlistId) { try { const startlist = await sql` @@ -451,6 +498,7 @@ module.exports = { allHeats, getHeat, allAthletes, + allJudges, newHeat, removeHeat, distinctStartlist, @@ -465,6 +513,8 @@ module.exports = { addAthleteToHeat, addAthlete, removeAthlete, + addJudge, + removeJudge, removeAthleteFromHeat, getSetting, allSettings, diff --git a/src/api/server.cjs b/src/api/server.cjs @@ -59,6 +59,7 @@ const paths = [ '/v1/auth/requestMagicLink', '/v1/auth/invalidateToken', // not implemented '/v1/leaderboard/allHeats', + '/v1/leaderboard/allJudges', // 🔒 authenticated '/v1/leaderboard/allAthletes', // 🔒 authenticated '/v1/leaderboard/newHeat', // 🔒 authenticated '/v1/leaderboard/getHeat', // 🔒 authenticated @@ -73,6 +74,8 @@ const paths = [ '/v1/leaderboard/removeAthleteFromHeat', // 🔒 authenticated '/v1/leaderboard/addAthlete', // 🔒 authenticated '/v1/leaderboard/removeAthlete', // 🔒 authenticated + '/v1/leaderboard/addJudge', // 🔒 authenticated + '/v1/leaderboard/removeJudge', // 🔒 authenticated '/v1/leaderboard/allSettings', '/v1/leaderboard/getSetting', // 🔒 authenticated '/v1/leaderboard/updateSetting', // 🔒 authenticated @@ -157,6 +160,19 @@ server.on('request', async (req, res) => { serverError(res, error); } break + case '/v1/leaderboard/allJudges': + try { + await verifyToken(req, token) + const judges = await db.allJudges() + + res.end(JSON.stringify({ + message: 'All judges', + data: judges, + })); + } catch(error) { + serverError(res, error); + } + break case '/v1/leaderboard/allAthletes': try { await verifyToken(req, token) @@ -623,7 +639,7 @@ server.on('request', async (req, res) => { input.school, ); if (athlete.length < 1) { - throw new Error("Startlist not updated") + throw new Error("Athlete not removed") } res.end(JSON.stringify({ @@ -661,6 +677,63 @@ server.on('request', async (req, res) => { } }) break + case '/v1/leaderboard/addJudge': + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + await verifyToken(req, token) + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' addJudge request for:', input); + + const judge = await db.addJudge( + input.email, + input.firstname, + input.lastname, + ); + if (judge.length < 1) { + throw new Error("Judge not added") + } + + res.end(JSON.stringify({ + message: 'Judge created', + data: judge[0] + })); + } catch (error) { + serverError(res, error); + } + }) + break + case '/v1/leaderboard/removeJudge': + req.on('data', chunk => { + body.push(chunk); + }).on('end', async () => { + const b = Buffer.concat(body); + try { + await verifyToken(req, token) + if (b.length < 1) { + throw new Error("Empty request body") + } + input = JSON.parse(b); + console.log(' removeJudge request for:', input); + + const judge = await db.removeJudge(input.judge_id) + if (judge.length < 1) { + throw new Error("Judge not removed") + } + res.end(JSON.stringify({ + message: 'Judge removed', + data: judge[0], + })); + } catch (error) { + serverError(res, error); + } + }) + break case '/v1/leaderboard/updateSetting': req.on('data', chunk => { body.push(chunk); diff --git a/src/frontend/App.jsx b/src/frontend/App.jsx @@ -9,6 +9,7 @@ import './css/yellow.css' const Score = lazy(() => import('./Score')) const Heats = lazy(() => import('./Heats')) +const Judges = lazy(() => import('./Judges')) const Athletes = lazy(() => import('./Athletes')) const Startlist = lazy(() => import('./Startlist')) const Auth = lazy(() => import('./Auth')) @@ -96,6 +97,13 @@ function Layout() { Athletes </NavLink> </li> : ''} + {session.auth ? <li> + <NavLink + className={({ isActive, isPending }) => isPending ? "pending" : isActive ? `active ${theme}.active}` : ""} + to="/judges"> + Judges + </NavLink> + </li> : ''} </ul> </nav> <main> @@ -147,6 +155,7 @@ function App() { <Route path="/" element={<Leaderboard session={session} />} /> <Route path="/score" element={<Score session={session} />} /> <Route path="/heats" element={<Heats session={session} />} /> + <Route path="/judges" element={<Judges session={session} />} /> <Route path="/athletes" element={<Athletes session={session} />} /> <Route path="/heats/startlist/:heatId" element={<Startlist session={session} />} /> <Route path="/auth" element={<Auth />} /> diff --git a/src/frontend/Judges.jsx b/src/frontend/Judges.jsx @@ -0,0 +1,151 @@ +import { lazy, useEffect, useState } from 'react' +import { exportJudgesToCSV } from './utils' +const Auth = lazy(() => import('./Auth')) + +const api_uri = import.meta.env.VITE_API_URI +const api_port = import.meta.env.VITE_API_PORT +const locale = import.meta.env.VITE_LOCALE + +async function addJudge(e, session) { + e.preventDefault() + + // Read the form data + const formData = new FormData(e.target); + const formJson = Object.fromEntries(formData.entries()); + + // create new judge + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/addJudge`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, + body: JSON.stringify({ + "email": formJson.email, + "firstname": formJson.firstname, + "lastname": formJson.lastname + }), + }) + const { data, error } = await res.json() + if (error) { + alert('Failed to create new judge: ' + error.message) + } + window.location.reload() +} + +async function deleteJudge(e, judgeId, email, session) { + e.preventDefault() + + if (window.confirm(`Do you really want to delete judge ${email}?`)) { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/removeJudge`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, + body: JSON.stringify({ + "judge_id": judgeId, + }), + }) + const { data, error } = await res.json() + if (error) { + alert('Failed to delete judge: ' + error.message) + } + window.location.reload() + } +} + +// export judges +function ExportForm(judges) { + return ( + <div className='exportForm'> + <form method='post' onSubmit={e => exportJudgesToCSV(e, judges)}> + <button type='submit'>&#9663; export</button> + </form> + </div> + ) +} + +function JudgeForm({session}) { + const [loading, setLoading] = useState(false) + const [judges, setJudges] = useState([]) + const dateOptions = { + year: "numeric", + month: "2-digit", + day: "2-digit", + } + + useEffect(() => { + (async () => { + setLoading(true) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allJudges`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + } + }) + const { data, error } = await res.json() + if (error) { + console.log(error) + } else { + setJudges(data) + } + setLoading(false) + })(); + }, []) + + return ( + <div className='JudgeForm'> + <button disabled={!loading} className='loading'>↺ loading</button> + <form method='post' onSubmit={e => addJudge(e, session)}> + <table> + <thead> + <tr> + <th>Created at</th> + <th>Email *</th> + <th>Firstname</th> + <th>Lastname</th> + </tr> + </thead> + <tbody> + {judges.map(j => ( + <tr key={j.id}> + <td data-title='Created at' className='right'>{new Date(j.created_at).toLocaleDateString(locale, dateOptions)}</td> + <td data-title='Email'>{j.email}</td> + <td data-title='Firstname'>{j.firstname}</td> + <td data-title='Lastname'>{j.lastname}</td> + <td><button onClick={e => deleteJudge(e, j.id, j.email, session)}>&ndash; del</button></td> + </tr> + ))} + <tr className='input'> + <td className='right'><i>* required</i></td> + <td data-title='Email *'> + <input type='text' name='email' /> + </td> + <td data-title='Firstname'> + <input type='text' name='firstname' /> + </td> + <td data-title='Lastname'> + <input type='text' name='lastname' /> + </td> + <td> + <button type='submit'>&#43; new</button> + </td> + </tr> + </tbody> + </table> + </form> + <ExportForm judges={judges} /> + </div> + ) +} + +function Judges({session}) { + return ( + <div> + {!session.auth ? <Auth /> : <JudgeForm session={session} />} + </div> + ) + } + +export default Judges diff --git a/src/frontend/utils.js b/src/frontend/utils.js @@ -24,13 +24,28 @@ export const exportAthletesToCSV = async function(e, { athletes }) { // append athlete data for (let i = 0; i < athletes.length; i++) { - csv += athletes[i].created_at + "," + athletes[i].nr+ "," + athletes[i].firstname+ "," - + athletes[i].lastname+ "," + athletes[i].birthday+ "," + athletes[i].school + "\n" + csv += athletes[i].created_at + "," + athletes[i].nr + "," + athletes[i].firstname + "," + + athletes[i].lastname + "," + athletes[i].birthday + "," + athletes[i].school + "\n" } exportCSV(csv, "athletes") } +export const exportJudgesToCSV = async function(e, { judges }) { + e.preventDefault() + + // csv header + let csv = "created_at,email,firstname,lastname\n" + + // append judge data + for (let i = 0; i < judges.length; i++) { + csv += judges[i].created_at + "," + judges[i].email + "," + + judges[i].firstname+ "," + judges[i].lastname + "\n" + } + + exportCSV(csv, "judges") +} + export const exportLeaderboardToCSV = async function(e, leaderboard, heatSelection, rankingComp) { e.preventDefault()