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 d28d306354ed6d06539f432577dbed5a543457bd
parent daeca7b11046754ff558de058af68853db60779a
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date:   Sun, 29 Sep 2024 23:55:00 +0200

feat: add settings

Diffstat:
Aschema/05-settings.sql | 17+++++++++++++++++
Msrc/api/db.cjs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/api/server.cjs | 95++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Dsrc/frontend/App.css | 183-------------------------------------------------------------------------------
Msrc/frontend/App.jsx | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Msrc/frontend/Settings.jsx | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/frontend/css/App.css | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/frontend/css/blue.module.css | 11+++++++++++
Rsrc/frontend/index.css -> src/frontend/css/index.css | 0
Asrc/frontend/css/red.module.css | 11+++++++++++
Asrc/frontend/css/yellow.module.css | 11+++++++++++
Msrc/frontend/main.jsx | 5++---
12 files changed, 603 insertions(+), 196 deletions(-)

diff --git a/schema/05-settings.sql b/schema/05-settings.sql @@ -0,0 +1,17 @@ +CREATE TABLE public.settings ( + id bigint NOT NULL, + name text NOT NULL, + value text NOT NULL +); + +ALTER TABLE public.settings ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME public.settings_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + +ALTER TABLE ONLY public.settings + ADD CONSTRAINT settings_pkey PRIMARY KEY (name); diff --git a/src/api/db.cjs b/src/api/db.cjs @@ -344,6 +344,62 @@ async function setScore(heat, athlete, judge, score) { } } +async function getSetting(name) { + try { + const setting = await sql` + select * from settings + where name = ${name} + ` + return setting + } catch (error) { + console.error('Error occurred in getSetting:', error); + throw error + } +} + +async function allSettings() { + try { + const settings = await sql` + select * from settings + ` + return settings + } catch (error) { + console.error('Error occurred in allSettings:', error); + throw error + } +} + +async function removeSetting(name) { + try { + const setting = await sql` + delete from settings where name = ${name} + returning * + ` + return setting + } catch (error) { + console.error('Error occurred in removeSetting:', error); + throw error + } +} + +// "upsert" setting +async function updateSetting(name, value) { + try { + const setting = await sql` + insert into public.settings as s (name, value) + values (${name}, ${value}) + on conflict (name) do update + set value = ${value} + where s.name = ${name} + returning * + ` + return setting + } catch (error) { + console.error('Error occurred in updateSetting:', error); + throw error + } +} + function removeClient(sock) { console.log("- Client removed") clients.delete(sock) @@ -410,4 +466,8 @@ module.exports = { addAthlete, removeAthlete, removeAthleteFromHeat, + getSetting, + allSettings, + updateSetting, + removeSetting, } diff --git a/src/api/server.cjs b/src/api/server.cjs @@ -66,13 +66,17 @@ const paths = [ '/v1/leaderboard/distinctStartlist', '/v1/leaderboard/startlistWithAthletes', // 🔒 authenticated '/v1/leaderboard/scoresForHeatAndAthlete', - '/v1/leaderboard/scoreSummaryForHeatAndAthlete' + '/v1/leaderboard/scoreSummaryForHeatAndAthlete', '/v1/leaderboard/getScore', // 🔒 authenticated '/v1/leaderboard/setScore', // 🔒 authenticated '/v1/leaderboard/addAthleteToHeat', // 🔒 authenticated '/v1/leaderboard/removeAthleteFromHeat', // 🔒 authenticated '/v1/leaderboard/addAthlete', // 🔒 authenticated '/v1/leaderboard/removeAthlete', // 🔒 authenticated + '/v1/leaderboard/allSettings', // 🔒 authenticated + '/v1/leaderboard/getSetting', // 🔒 authenticated + '/v1/leaderboard/updateSetting', // 🔒 authenticated + '/v1/leaderboard/removeSetting', // 🔒 authenticated ] console.log("Backend API:", api_uri); @@ -166,6 +170,43 @@ server.on('request', async (req, res) => { serverError(res, error); } break + case '/v1/leaderboard/getSetting': + try { + await verifyToken(req, token) + + let name = search_params.get('name'); + const setting = await db.getSetting(name) + + if (setting.length < 1) { + noContent(res); + return + } + res.end(JSON.stringify({ + message: `Setting with name ${name}`, + data: setting[0].value, + })); + } catch(error) { + serverError(res, error); + } + break + case '/v1/leaderboard/allSettings': + try { + await verifyToken(req, token) + + const settings = await db.allSettings() + + if (settings.length < 1) { + noContent(res); + return + } + res.end(JSON.stringify({ + message: 'All settings', + data: settings, + })); + } catch(error) { + serverError(res, error); + } + break default: const pathExists = paths.find((i) => i === url.pathname); if (pathExists) { @@ -622,6 +663,58 @@ server.on('request', async (req, res) => { } }) break + case '/v1/leaderboard/updateSetting': + 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(' updateSetting request for:', input); + + const setting = await db.updateSetting(input.name, input.value) + if (setting.length < 1) { + throw new Error("Setting not updated") + } + res.end(JSON.stringify({ + message: 'Setting updated', + data: setting[0], + })); + } catch (error) { + serverError(res, error); + } + }) + break + case '/v1/leaderboard/removeSetting': + 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(' removeSetting request for:', input); + + const settings = await db.removeSetting(input.name) + if (settings.length < 1) { + throw new Error("Setting not removed") + } + res.end(JSON.stringify({ + message: 'Setting removed', + data: settings[0], + })); + } catch (error) { + serverError(res, error); + } + }) + break default: const pathExists = paths.find((i) => i === url.pathname); if (pathExists) { diff --git a/src/frontend/App.css b/src/frontend/App.css @@ -1,183 +0,0 @@ -#root { - display: flex; - flex-direction: column; - min-height: 100vh; - justify-content: space-between; -} - -main { - flex: 1; - border-top: 1px solid #e1e1e7; - border-bottom: 1px solid #e1e1e7; - padding: 30px 0; -} - -nav { - background: #f9f9fc; - border-bottom: 1px solid white; -} - -nav ul { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - padding: 0; -} - -nav li { - flex: 1; - list-style-type: none; - white-space: nowrap; -} - -nav li a { - text-transform: uppercase; - text-align: center; - font-size: 0.8em; - text-decoration: none; - padding: 10px; - display: block; -} -nav a.active { - font-weight: bold; - background: white; -} - -table { - border-collapse: collapse; - width: 100%; -} - -th, td { - padding: 5px 20px; - text-align: left; -} - -th { - font-weight: normal; - font-size: 0.8em; - text-transform: uppercase; - color: #b0b0b6; -} - -tbody tr:nth-child(even):not(.input) { - background-color: #f9f9fc; -} - -footer { - padding: 5px; - display: flex; - flex-wrap: wrap; - justify-content: space-between; - align-items: center; - border-top: 1px solid white; - background: #f9f9fc; -} -footer span { - flex: 1; -} -footer .login { - text-align: right; -} -footer .login button { - width: auto; -} - -.right { - text-align: right; -} - -.scoreInput { - font-size: 25px; -} - -.loginForm, .exportForm { - margin: 30px; -} -.loginForm button, .loginForm input, .exportForm button { - width: 250px; - display: block; -} - -.heatInfo { - padding: 20px; -} -.heatInfo li { - list-style: none; -} - -/* increment rank row number */ -table.leaderboard tr{ - counter-increment: rowNumber; -} -table.leaderboard tr td:first-child::before { - content: counter(rowNumber); - min-width: 1em; - margin-right: 0.5em; -} - -/* highlight totals */ -table.leaderboard td:last-child { - font-weight: bold; -} - -@media(min-width: 1600px) { - main > div { - width: 75%; - margin: 0 auto; - } -} - -/* https://css-tricks.com/making-tables-responsive-with-minimal-css */ -@media(max-width: 1100px) { - table thead { - left: -9999px; - position: absolute; - visibility: hidden; - } - - table tr { - display: flex; - flex-direction: row; - flex-wrap: wrap; - padding: 20px 0; - } - - table tr:not(:last-child) { - border-bottom: 1px solid #e1e1e7; - } - - table td { - margin: 0 -1px -1px 0; - padding-top: 35px; - margin-bottom: 25px; - position: relative; - width: 35%; - text-align: left !important; - } - - table td:before { - content: attr(data-title); - position: absolute; - top: 3px; - left: 20px; - font-size: 0.8em; - text-transform: uppercase; - color: #b0b0b6; - } - - table.leaderboard td:nth-child(-n+6) { - /* background: rgb(236, 236, 236); */ - } - - table td button { - position: absolute; - bottom: 0; - height: 50px; - width: 100%; - } - - table td:empty { - display: none; - } -} diff --git a/src/frontend/App.jsx b/src/frontend/App.jsx @@ -1,8 +1,12 @@ -import './App.css' import { Suspense, lazy, useState, useEffect, Fragment } from 'react' import { BrowserRouter as Router, Routes, Route, Outlet, Link, NavLink } from 'react-router-dom' import { CookiesProvider, useCookies } from 'react-cookie' +import './css/App.css' +import blue from './css/blue.module.css' +import red from './css/red.module.css' +import yellow from './css/yellow.module.css' + const Score = lazy(() => import('./Score')) const Heats = lazy(() => import('./Heats')) const Athletes = lazy(() => import('./Athletes')) @@ -10,40 +14,77 @@ const Startlist = lazy(() => import('./Startlist')) const Auth = lazy(() => import('./Auth')) const AuthVerify = lazy(() => import('./AuthVerify')) const Leaderboard = lazy(() => import('./Leaderboard')) +const Settings = lazy(() => import('./Settings')) + +const api_uri = import.meta.env.VITE_API_URI +const api_port = import.meta.env.VITE_API_PORT document.title = import.meta.env.VITE_APP_DOC_TITLE ? import.meta.env.VITE_APP_DOC_TITLE : 'My Heats' function Layout() { const [session, setSession, destroySession] = useCookies(['auth']) + const [settings, setSettings] = useState(new Map()) + + // load stylesheet based on settings + const colors = ['red', 'blue', 'yellow']; + let styles + + if (settings.style && colors.includes(settings.style)) { + styles = eval(settings.style) + //console.info(`I see ${settings.style}. Try one of "style=${colors}" in ${window.location.origin}/settings`) + } + + useEffect(() => { + (async () => { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allSettings`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + } + }) + if (res.status !== 204) { + const { data, error } = await res.json() + if (error) { + console.error(error) + } else { + let s = new Map() + data.forEach(setting => { + s[setting.name] = setting.value + }) + setSettings(s) + } + } + })(); + }, []) return ( <Fragment> - <nav> + <nav className={styles?.themed}> <ul> <li> <NavLink - className={({ isActive, isPending }) => isPending ? "pending" : isActive ? "active" : ""} + className={({ isActive, isPending }) => isPending ? "pending" : isActive ? `active ${styles?.active}` : ""} to="/"> Leaderboard </NavLink> </li> {session.auth ? <li> <NavLink - className={({ isActive, isPending }) => isPending ? "pending" : isActive ? "active" : ""} + className={({ isActive, isPending }) => isPending ? "pending" : isActive ? `active ${styles?.active}` : ""} to="/score"> Scoring </NavLink> </li> : ''} {session.auth ? <li> <NavLink - className={({ isActive, isPending }) => isPending ? "pending" : isActive ? "active" : ""} + className={({ isActive, isPending }) => isPending ? "pending" : isActive ? `active ${styles?.active}` : ""} to="/heats"> Heats and Startlists </NavLink> </li> : ''} {session.auth ? <li> <NavLink - className={({ isActive, isPending }) => isPending ? "pending" : isActive ? "active" : ""} + className={({ isActive, isPending }) => isPending ? "pending" : isActive ? `active ${styles?.active}` : ""} to="/athletes"> Athletes </NavLink> @@ -55,9 +96,15 @@ function Layout() { </main> <footer> <br /> - <span className='version'>MyHeats <a href="https://code.in0rdr.ch/myheats/refs.html">v0.6-nightly</a></span> - <span className='login'> - {session.auth ? <button onClick={() => destroySession('auth')}>Sign out {session.auth.email}</button> : + <span>MyHeats <a href="https://code.in0rdr.ch/myheats/refs.html">v0.6-nightly</a></span> + <span> + {session.auth ? <button + onClick={() => location.href="/settings"}> + &#9881; + </button> : ''} + </span> + <span> + {session.auth ? <button onClick={() => destroySession('auth')}>&#10157;</button> : <NavLink className={({ isActive, isPending }) => isPending ? "pending" : isActive ? "active" : ""} to="/auth"> @@ -94,6 +141,7 @@ function App() { <Route path="/startlist/:heatId" element={<Startlist session={session} />} /> <Route path="/auth" element={<Auth />} /> <Route path="/authverify" element={<AuthVerify />} /> + <Route path="/settings" element={<Settings session={session} />} /> <Route path="*" element={<NoMatch />} /> </Route> </Routes> diff --git a/src/frontend/Settings.jsx b/src/frontend/Settings.jsx @@ -0,0 +1,155 @@ +import { lazy, useEffect, useState } from 'react' +const Auth = lazy(() => import('./Auth')) + +const api_uri = import.meta.env.VITE_API_URI +const api_port = import.meta.env.VITE_API_PORT + +async function updateSetting(e, session) { + e.preventDefault() + + // Read the form data + const formData = new FormData(e.target); + const formJson = Object.fromEntries(formData.entries()); + console.log(formJson) + + // Add setting + try { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/updateSetting`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, + body: JSON.stringify({ + "name": formJson.name === '' ? null : formJson.name, + "value": formJson.value === '' ? null : formJson.value, + }), + }) + const { data, error } = await res.json() + if (error) { + alert('Failed to add setting: ' + error) + } + window.location.reload() + } catch (error) { + console.error('Failed to add setting: ' + error) + } +} + +async function deleteSetting(e, name, session) { + e.preventDefault() + + if (window.confirm('Do you really want to delete setting "' + name + '"?')) { + const { data, error } = await removeSetting(name, session) + if (error === undefined) { + window.location.reload() + } else { + console.error(error) + } + } +} + +export async function removeSetting(name, session) { + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/removeSetting`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + }, + body: JSON.stringify({ + "name": name + }), + }) + return await res.json() +} + + +function SettingsForm({session}) { + const [loading, setLoading] = useState(false) + const [settings, setSettings] = useState([]) + + useEffect(() => { + (async () => { + setLoading(true) + const res = await fetch(`${api_uri}:${api_port}/v1/leaderboard/allSettings`, { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${session.auth.token}`, + } + }) + if (res.status !== 204) { + const { data, error } = await res.json() + if (error) { + console.error(error) + } else { + setSettings(data) + } + } + setLoading(false) + })(); + }, []) + + return ( + <div> + <button disabled={!loading}>{loading ? '↺ loading' : ''}</button> + <form method='post' onSubmit={e => updateSetting(e, session)}> + <table> + <thead> + <tr> + <th></th> + <th>Setting *</th> + <th>Value *</th> + <th>New/delete</th> + </tr> + </thead> + <tbody> + {settings.map(s => ( + <tr key={s.name} data-name={s.name}> + <td></td> + <td data-title='Setting'> + {s.name} + </td> + <td data-title='Value'> + {s.value} + </td> + <td> + <button onClick={e => deleteSetting(e, s.name, session)}>&ndash; del</button> + </td> + </tr> + ))} + <tr className='input'> + <td className='right'><i>* required</i></td> + <td data-title='Setting *'> + <input type='text' name='name' /> + </td> + <td data-title='Value *'> + <input type='text' name='value' /> + </td> + <td> + <button type='submit'>&#43; new</button> + </td> + </tr> + </tbody> + <tfoot> + <tr> + <td></td> + <td></td> + <td></td> + <td> + </td> + </tr> + </tfoot> + </table> + </form> + </div> + ) +} + +function Settings({session}) { + return ( + <div> + {!session.auth ? <Auth /> : <SettingsForm session={session} />} + </div> + ) +} + +export default Settings diff --git a/src/frontend/css/App.css b/src/frontend/css/App.css @@ -0,0 +1,185 @@ +#root { + display: flex; + flex-direction: column; + min-height: 100vh; + justify-content: space-between; +} + +main { + flex: 1; + border-top: 1px solid #e1e1e7; + border-bottom: 1px solid #e1e1e7; + padding: 30px 0; +} + +nav { + background: #f9f9fc; + border-bottom: 1px solid white; +} + +nav ul { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + padding: 0; +} + +nav li { + flex: 1; + list-style-type: none; + white-space: nowrap; +} + +nav li a { + text-transform: uppercase; + text-align: center; + font-size: 0.8em; + text-decoration: none; + padding: 10px; + display: block; +} +nav a.active { + font-weight: bold; + background: white; +} + +table { + border-collapse: collapse; + width: 100%; +} + +th, td { + padding: 5px 20px; + text-align: left; +} + +th { + font-weight: normal; + font-size: 0.8em; + text-transform: uppercase; + color: #b0b0b6; +} + +tbody tr:nth-child(even):not(.input) { + background-color: #f9f9fc; +} + +footer { + padding: 5px; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + border-top: 1px solid white; + background: #f9f9fc; +} +footer span { + font-size: 0.8em; +} +footer span a { + font-size: 1em; +} +footer span button { + font-size: 1.5em; + flex: 1; + margin-left: 20px; +} + +.right { + text-align: right; +} + +.scoreInput { + font-size: 25px; +} + +.loginForm, .exportForm { + margin: 30px; +} +.loginForm button, .loginForm input, .exportForm button { + width: 250px; + display: block; +} + +.heatInfo { + padding: 20px; +} +.heatInfo li { + list-style: none; +} + +/* increment rank row number */ +table.leaderboard tr{ + counter-increment: rowNumber; +} +table.leaderboard tr td:first-child::before { + content: counter(rowNumber); + min-width: 1em; + margin-right: 0.5em; +} + +/* highlight totals */ +table.leaderboard td:last-child { + font-weight: bold; +} + +@media(min-width: 1600px) { + main > div { + width: 75%; + margin: 0 auto; + } +} + +/* https://css-tricks.com/making-tables-responsive-with-minimal-css */ +@media(max-width: 1100px) { + table thead { + left: -9999px; + position: absolute; + visibility: hidden; + } + + table tr { + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding: 20px 0; + } + + table tr:not(:last-child) { + border-bottom: 1px solid #e1e1e7; + } + + table td { + margin: 0 -1px -1px 0; + padding-top: 35px; + margin-bottom: 25px; + position: relative; + width: 35%; + text-align: left !important; + } + + table td:before { + content: attr(data-title); + position: absolute; + top: 3px; + left: 20px; + font-size: 0.8em; + text-transform: uppercase; + color: #b0b0b6; + } + + table.leaderboard td:nth-child(-n+6) { + /* background: rgb(236, 236, 236); */ + } + + table td button { + position: absolute; + bottom: 0; + height: 50px; + width: 100%; + } + + table td:empty { + display: none; + } +} diff --git a/src/frontend/css/blue.module.css b/src/frontend/css/blue.module.css @@ -0,0 +1,11 @@ +nav.themed { + background: #144780; + border-bottom: 1px solid white; +} +nav.themed a.active { + font-weight: bold; + background: #145dae; +} +nav.themed a { + color: white; +} diff --git a/src/frontend/index.css b/src/frontend/css/index.css diff --git a/src/frontend/css/red.module.css b/src/frontend/css/red.module.css @@ -0,0 +1,11 @@ +nav.themed { + background: #bb0d15; + border-bottom: 1px solid white; +} +nav.themed a.active { + font-weight: bold; + background: #cc3037; +} +nav.themed a { + color: white; +} diff --git a/src/frontend/css/yellow.module.css b/src/frontend/css/yellow.module.css @@ -0,0 +1,11 @@ +nav.themed { + background: #fd0; + border-bottom: 1px solid #fffceb; +} +nav.themed a.active { + font-weight: bold; + background: #ffe95a; +} +nav.themed a { + color: black; +} diff --git a/src/frontend/main.jsx b/src/frontend/main.jsx @@ -1,6 +1,6 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import './index.css'; +import './css/index.css'; import App from './App'; const root = ReactDOM.createRoot(document.getElementById('root')); @@ -8,4 +8,4 @@ root.render( <React.StrictMode> <App /> </React.StrictMode> -); -\ No newline at end of file +);