commit d28d306354ed6d06539f432577dbed5a543457bd
parent daeca7b11046754ff558de058af68853db60779a
Author: Andreas Gruhler <andreas.gruhler@adfinis.com>
Date: Sun, 29 Sep 2024 23:55:00 +0200
feat: add settings
Diffstat:
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"}>
+ ⚙
+ </button> : ''}
+ </span>
+ <span>
+ {session.auth ? <button onClick={() => destroySession('auth')}>➭</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)}>– 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'>+ 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
+);