commit 366743960e55f59ea35712b917300bd7bf5bf46c
parent 498bd5fc78f829c1991a428c6e9d99497df4561b
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date: Sun, 17 Nov 2024 14:28:57 +0100
feat: Judge view
Diffstat:
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'>▿ 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)}>– 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'>+ 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()