adding the whole website made by claude
This commit is contained in:
parent
7feda6ca0e
commit
e4b4ed59fb
16 changed files with 2125 additions and 1 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
node_modules/
|
||||
data/
|
||||
.env
|
||||
.git/
|
||||
public/vendor/
|
||||
*.log
|
||||
8
.env.example
Normal file
8
.env.example
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Shared password required to log in as admin (add/undo doses). Required.
|
||||
ADMIN_PASSWORD=changeme
|
||||
|
||||
# Host port to expose the app on. The container always listens on 3000 internally. Optional.
|
||||
PORT=3000
|
||||
|
||||
# IANA timezone used to decide what "today" is when logging a dose (e.g. Europe/Paris). Optional, defaults to UTC.
|
||||
TZ=UTC
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
data/
|
||||
.env
|
||||
public/vendor/
|
||||
*.log
|
||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
FROM node:22-bookworm-slim
|
||||
|
||||
# tzdata lets the TZ env var control what counts as "today" when logging a dose
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends tzdata \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production PORT=3000 DATA_DIR=/data
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY scripts ./scripts
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY server ./server
|
||||
COPY public ./public
|
||||
|
||||
EXPOSE 3000
|
||||
VOLUME ["/data"]
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
136
README.md
136
README.md
|
|
@ -1,2 +1,136 @@
|
|||
# leaderboard
|
||||
# Dose Leaderboard
|
||||
|
||||
A tiny self-hosted leaderboard for tracking wins ("**doses**") in a card game
|
||||
played with friends. The UI is in French; this README is in English for
|
||||
whoever's maintaining/deploying it.
|
||||
|
||||
- Anyone can view the standings.
|
||||
- A chart shows each player's doses **per day** (not cumulative — it goes up
|
||||
and down with how many wins landed on each day).
|
||||
- Only the admin (a single shared password) can add/remove players, and
|
||||
add/remove doses — for **any date**, not just today.
|
||||
|
||||
No accounts, no build step, one Docker container, data stored in a local
|
||||
SQLite file.
|
||||
|
||||
## Quick start (Docker)
|
||||
|
||||
Requires [Docker](https://docs.docker.com/get-docker/) and Docker Compose
|
||||
(bundled with Docker Desktop, or `docker-compose-plugin` on Linux).
|
||||
|
||||
```bash
|
||||
git clone <this repo> dose-leaderboard
|
||||
cd dose-leaderboard
|
||||
cp .env.example .env
|
||||
# edit .env and set ADMIN_PASSWORD to something only you and maybe a co-admin know
|
||||
|
||||
sudo docker compose up -d --build
|
||||
```
|
||||
|
||||
Open `http://<your-server>:3000` (or whatever `PORT` you set in `.env`).
|
||||
|
||||
Doses and players are stored in `./data/leaderboard.db`, which is
|
||||
bind-mounted into the container — the data survives rebuilds, restarts, and
|
||||
`docker compose down`. Back it up by copying that one file. (The container
|
||||
runs as `root`, so that directory ends up root-owned on the host — that's
|
||||
fine, only the container needs to write to it.)
|
||||
|
||||
To update after pulling new code:
|
||||
|
||||
```bash
|
||||
sudo docker compose up -d --build
|
||||
```
|
||||
|
||||
To stop it:
|
||||
|
||||
```bash
|
||||
sudo docker compose down
|
||||
```
|
||||
|
||||
## Using it (Classement des doses)
|
||||
|
||||
- **Standings ("Classement")**: everyone can see the list of players ranked
|
||||
by total doses, and the "Total des doses" stat row under the chart.
|
||||
- **Chart ("Doses par jour")**: one line per player, one point per day that
|
||||
has at least one dose (from anyone). Y-axis is that day's count — expect it
|
||||
to go up and down, not just climb.
|
||||
- **Admin login**: click "Connexion admin" top-right and enter the password
|
||||
from `ADMIN_PASSWORD`. The browser remembers it (in `localStorage`) until
|
||||
you click "Déconnexion" or clear site data.
|
||||
- **Add / remove a player**: admin-only. Logged in, an "Ajouter un joueur"
|
||||
form appears at the bottom, and a `✕` button appears next to each player in
|
||||
the standings (removing a player deletes all of their doses too — it asks
|
||||
for confirmation first).
|
||||
- **Add / remove a dose for a specific date**: admin-only. A date picker
|
||||
("Modifier les doses du :") appears above the standings, defaulting to
|
||||
today. Pick any date (not in the future), then use each player's `+`/`−`
|
||||
buttons to add or remove a dose on *that* date. `−` is disabled once that
|
||||
date's count for a player reaches 0.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required | Default | Meaning |
|
||||
|---|---|---|---|
|
||||
| `ADMIN_PASSWORD` | yes | — | Shared password for admin actions. The server refuses to start without it. |
|
||||
| `PORT` | no | `3000` | Host port Docker Compose publishes the app on. The container always listens on `3000` internally. |
|
||||
| `TZ` | no | `UTC` | IANA timezone (e.g. `Europe/Paris`) used to compute "today" (the default date picker value and the cap on how far in the future a dose date may be). |
|
||||
|
||||
## Running locally without Docker
|
||||
|
||||
Requires Node.js **22.5+** (uses the built-in `node:sqlite` module — no
|
||||
native dependencies to compile).
|
||||
|
||||
```bash
|
||||
npm install
|
||||
ADMIN_PASSWORD=changeme npm run dev # auto-restarts on file changes
|
||||
```
|
||||
|
||||
Then open `http://localhost:3000`. Data is stored in `./data/leaderboard.db`
|
||||
by default (override with `DATA_DIR`).
|
||||
|
||||
## API reference
|
||||
|
||||
All routes are under `/api`. Admin-only routes require an `x-admin-key`
|
||||
header matching `ADMIN_PASSWORD`.
|
||||
|
||||
| Method & path | Auth | Body / query | Notes |
|
||||
|---|---|---|---|
|
||||
| `GET /players?date=YYYY-MM-DD` | — | — | List of players with `total` doses and `dateCount` (doses on `date`, defaults to today). |
|
||||
| `POST /players` | admin | `{ name }` | Create a player. 409 if the name already exists (case-insensitive). |
|
||||
| `DELETE /players/:id` | admin | — | Delete a player and all their doses. |
|
||||
| `GET /timeseries` | — | — | `{ dates, series: [{ playerId, name, counts }] }` — `counts[i]` is that player's dose count on `dates[i]`. |
|
||||
| `POST /admin/verify` | — | `{ password }` | `{ ok: boolean }`, used by the UI to check a password before storing it. |
|
||||
| `POST /doses` | admin | `{ playerId, date? }` | Add one dose for `playerId` on `date` (defaults to today; can't be in the future). |
|
||||
| `DELETE /doses/latest?playerId=&date=` | admin | — | Remove the most recently logged dose for that player on `date` (defaults to today). |
|
||||
|
||||
## How it's built
|
||||
|
||||
- **Backend**: Node.js + Express, `server/`. SQLite via Node's built-in
|
||||
`node:sqlite`, one file at `DATA_DIR/leaderboard.db`.
|
||||
- **Frontend**: plain HTML/CSS/JS in `public/`, no build step. Chart.js is
|
||||
vendored into `public/vendor/` at install time (`scripts/copy-vendor.js`) so
|
||||
the app has no runtime dependency on a CDN.
|
||||
- **Auth**: a single admin password, checked against the `x-admin-key` header
|
||||
on write endpoints. Stateless — no sessions, no cookies.
|
||||
|
||||
```
|
||||
server/
|
||||
index.js — Express app bootstrap
|
||||
db.js — SQLite connection + schema
|
||||
routes.js — /api/* endpoints
|
||||
auth.js — admin-key middleware
|
||||
public/
|
||||
index.html, styles.css, app.js — the UI (French)
|
||||
vendor/ — generated, not committed (see .gitignore)
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"ADMIN_PASSWORD environment variable is required"** — you haven't set it
|
||||
in `.env` (Docker) or your shell (local dev).
|
||||
- **Permission errors writing to `./data`** — if you pre-created the `data`
|
||||
directory as a different user, make sure it's writable by whoever the
|
||||
container runs as (the container runs as `root` by default for simplicity;
|
||||
`chmod 777 data` or matching ownership fixes it).
|
||||
- **Wrong default date in the picker** — set `TZ` in `.env` to your local
|
||||
timezone and restart (`sudo docker compose up -d`).
|
||||
|
|
|
|||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
services:
|
||||
leaderboard:
|
||||
build: .
|
||||
container_name: dose-leaderboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
environment:
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- TZ=${TZ:-UTC}
|
||||
volumes:
|
||||
- ./data:/data
|
||||
851
package-lock.json
generated
Normal file
851
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,851 @@
|
|||
{
|
||||
"name": "dose-leaderboard",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dose-leaderboard",
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.4",
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.6",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.5",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"es-define-property": "^1.0.1",
|
||||
"side-channel": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
|
||||
"integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "~2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
|
||||
"integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "~0.19.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4",
|
||||
"side-channel-list": "^1.0.1",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
|
||||
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
package.json
Normal file
19
package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "dose-leaderboard",
|
||||
"version": "1.0.0",
|
||||
"description": "Leaderboard for tracking card-game wins (\"doses\") between friends",
|
||||
"private": true,
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
"start": "node server/index.js",
|
||||
"dev": "node --watch server/index.js",
|
||||
"postinstall": "node scripts/copy-vendor.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.4",
|
||||
"express": "^4.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.5.0"
|
||||
}
|
||||
}
|
||||
405
public/app.js
Normal file
405
public/app.js
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
(() => {
|
||||
const ADMIN_KEY_STORAGE = 'doseAdminKey';
|
||||
const PALETTE_SLOTS = 8;
|
||||
|
||||
function clientTodayStr() {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
const state = {
|
||||
players: [],
|
||||
timeseries: { dates: [], series: [] },
|
||||
adminKey: localStorage.getItem(ADMIN_KEY_STORAGE) || null,
|
||||
selectedDate: clientTodayStr(),
|
||||
chart: null,
|
||||
};
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const leaderboardBody = $('#leaderboard-body');
|
||||
const leaderboardEmpty = $('#leaderboard-empty');
|
||||
const chartEmpty = $('#chart-empty');
|
||||
const totalsRow = $('#totals-row');
|
||||
const totalsEmpty = $('#totals-empty');
|
||||
const adminStatus = $('#admin-status');
|
||||
const adminLoginBtn = $('#admin-login-btn');
|
||||
const adminLogoutBtn = $('#admin-logout-btn');
|
||||
const adminDialog = $('#admin-dialog');
|
||||
const adminLoginForm = $('#admin-login-form');
|
||||
const adminPasswordInput = $('#admin-password-input');
|
||||
const adminLoginError = $('#admin-login-error');
|
||||
const adminCancelBtn = $('#admin-cancel-btn');
|
||||
const addPlayerForm = $('#add-player-form');
|
||||
const playerNameInput = $('#player-name-input');
|
||||
const addPlayerError = $('#add-player-error');
|
||||
const doseDateInput = $('#dose-date-input');
|
||||
const adminOnlyEls = document.querySelectorAll('.admin-only');
|
||||
|
||||
doseDateInput.max = clientTodayStr();
|
||||
doseDateInput.value = state.selectedDate;
|
||||
|
||||
function cssVar(name) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
}
|
||||
|
||||
function colorForPlayer(playerId) {
|
||||
const index = (playerId - 1) % PALETTE_SLOTS;
|
||||
return cssVar(`--series-${index + 1}`);
|
||||
}
|
||||
|
||||
function dashForPlayer(playerId) {
|
||||
const cycle = Math.floor((playerId - 1) / PALETTE_SLOTS);
|
||||
return cycle === 0 ? [] : [6, 3];
|
||||
}
|
||||
|
||||
function formatDateLabel(iso) {
|
||||
const [y, m, d] = iso.split('-').map(Number);
|
||||
const date = new Date(y, m - 1, d);
|
||||
return date.toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const headers = Object.assign({}, options.headers);
|
||||
if (options.body) headers['Content-Type'] = 'application/json';
|
||||
if (options.auth && state.adminKey) headers['x-admin-key'] = state.adminKey;
|
||||
const res = await fetch(`/api${path}`, { ...options, headers });
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch (_) {
|
||||
/* no body */
|
||||
}
|
||||
if (!res.ok) {
|
||||
const error = new Error(data?.error || `Request failed (${res.status})`);
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [players, timeseries] = await Promise.all([
|
||||
api(`/players?date=${state.selectedDate}`),
|
||||
api('/timeseries'),
|
||||
]);
|
||||
state.players = players;
|
||||
state.timeseries = timeseries;
|
||||
renderLeaderboard();
|
||||
renderChart();
|
||||
renderTotals();
|
||||
}
|
||||
|
||||
function setAdminMode(isAdmin) {
|
||||
adminStatus.classList.toggle('hidden', !isAdmin);
|
||||
adminLoginBtn.classList.toggle('hidden', isAdmin);
|
||||
adminLogoutBtn.classList.toggle('hidden', !isAdmin);
|
||||
adminOnlyEls.forEach((el) => el.classList.toggle('hidden', !isAdmin));
|
||||
renderLeaderboard();
|
||||
}
|
||||
|
||||
function renderLeaderboard() {
|
||||
leaderboardBody.innerHTML = '';
|
||||
leaderboardEmpty.classList.toggle('hidden', state.players.length > 0);
|
||||
|
||||
state.players.forEach((player, index) => {
|
||||
const tr = document.createElement('tr');
|
||||
|
||||
const rankTd = document.createElement('td');
|
||||
rankTd.className = 'col-rank';
|
||||
rankTd.textContent = String(index + 1);
|
||||
|
||||
const nameTd = document.createElement('td');
|
||||
nameTd.className = 'col-name';
|
||||
const nameWrap = document.createElement('span');
|
||||
nameWrap.className = 'player-name';
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'color-dot';
|
||||
dot.style.background = colorForPlayer(player.id);
|
||||
nameWrap.appendChild(dot);
|
||||
nameWrap.appendChild(document.createTextNode(player.name));
|
||||
nameTd.appendChild(nameWrap);
|
||||
|
||||
const totalTd = document.createElement('td');
|
||||
totalTd.className = 'col-total';
|
||||
totalTd.textContent = String(player.total);
|
||||
|
||||
tr.append(rankTd, nameTd, totalTd);
|
||||
|
||||
if (state.adminKey) {
|
||||
const actionsTd = document.createElement('td');
|
||||
actionsTd.className = 'col-actions';
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'dose-controls';
|
||||
|
||||
const minusBtn = document.createElement('button');
|
||||
minusBtn.className = 'btn btn-icon';
|
||||
minusBtn.textContent = '−';
|
||||
minusBtn.disabled = player.dateCount <= 0;
|
||||
minusBtn.title = 'Retirer une dose à cette date';
|
||||
minusBtn.addEventListener('click', () => undoDose(player.id));
|
||||
|
||||
const dateCount = document.createElement('span');
|
||||
dateCount.className = 'today-count';
|
||||
dateCount.textContent = String(player.dateCount);
|
||||
|
||||
const plusBtn = document.createElement('button');
|
||||
plusBtn.className = 'btn btn-icon';
|
||||
plusBtn.textContent = '+';
|
||||
plusBtn.title = 'Ajouter une dose à cette date';
|
||||
plusBtn.addEventListener('click', () => addDose(player.id));
|
||||
|
||||
controls.append(minusBtn, dateCount, plusBtn);
|
||||
actionsTd.appendChild(controls);
|
||||
tr.appendChild(actionsTd);
|
||||
|
||||
const removeTd = document.createElement('td');
|
||||
removeTd.className = 'col-remove';
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'btn btn-icon btn-remove';
|
||||
removeBtn.textContent = '✕';
|
||||
removeBtn.title = 'Supprimer ce joueur';
|
||||
removeBtn.addEventListener('click', () => removePlayer(player.id, player.name));
|
||||
removeTd.appendChild(removeBtn);
|
||||
tr.appendChild(removeTd);
|
||||
}
|
||||
|
||||
leaderboardBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTotals() {
|
||||
totalsRow.innerHTML = '';
|
||||
totalsEmpty.classList.toggle('hidden', state.players.length > 0);
|
||||
|
||||
state.players.forEach((player) => {
|
||||
const tile = document.createElement('div');
|
||||
tile.className = 'stat-tile';
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'color-dot';
|
||||
dot.style.background = colorForPlayer(player.id);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'stat-name';
|
||||
name.textContent = player.name;
|
||||
|
||||
const value = document.createElement('span');
|
||||
value.className = 'stat-value';
|
||||
value.textContent = String(player.total);
|
||||
|
||||
tile.append(dot, name, value);
|
||||
totalsRow.appendChild(tile);
|
||||
});
|
||||
}
|
||||
|
||||
function renderChart() {
|
||||
const { dates, series } = state.timeseries;
|
||||
chartEmpty.classList.toggle('hidden', dates.length > 0);
|
||||
const canvas = $('#dose-chart');
|
||||
canvas.classList.toggle('hidden', dates.length === 0);
|
||||
|
||||
if (state.chart) {
|
||||
state.chart.destroy();
|
||||
state.chart = null;
|
||||
}
|
||||
if (dates.length === 0) return;
|
||||
|
||||
const textMuted = cssVar('--text-muted');
|
||||
const textPrimary = cssVar('--text-primary');
|
||||
const gridline = cssVar('--gridline');
|
||||
const surface = cssVar('--surface-1');
|
||||
|
||||
const datasets = series.map((s) => {
|
||||
const color = colorForPlayer(s.playerId);
|
||||
return {
|
||||
label: s.name,
|
||||
data: s.counts,
|
||||
borderColor: color,
|
||||
backgroundColor: color,
|
||||
borderWidth: 2,
|
||||
borderDash: dashForPlayer(s.playerId),
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 5,
|
||||
pointBackgroundColor: color,
|
||||
pointBorderColor: surface,
|
||||
pointBorderWidth: 2,
|
||||
tension: 0,
|
||||
};
|
||||
});
|
||||
|
||||
state.chart = new Chart(canvas.getContext('2d'), {
|
||||
type: 'line',
|
||||
data: { labels: dates, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
scales: {
|
||||
x: {
|
||||
grid: { display: false },
|
||||
border: { color: cssVar('--baseline') },
|
||||
ticks: {
|
||||
color: textMuted,
|
||||
callback: function (value) {
|
||||
const label = this.getLabelForValue(value);
|
||||
return formatDateLabel(label);
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: gridline },
|
||||
border: { display: false },
|
||||
ticks: { color: textMuted, precision: 0 },
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: datasets.length > 1,
|
||||
position: 'top',
|
||||
align: 'start',
|
||||
labels: {
|
||||
color: textPrimary,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
boxWidth: 8,
|
||||
boxHeight: 8,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: (items) => formatDateLabel(items[0].label),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function addDose(playerId) {
|
||||
try {
|
||||
await api('/doses', {
|
||||
method: 'POST',
|
||||
auth: true,
|
||||
body: JSON.stringify({ playerId, date: state.selectedDate }),
|
||||
});
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
handleAdminError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function undoDose(playerId) {
|
||||
try {
|
||||
await api(`/doses/latest?playerId=${playerId}&date=${state.selectedDate}`, {
|
||||
method: 'DELETE',
|
||||
auth: true,
|
||||
});
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
handleAdminError(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function removePlayer(playerId, name) {
|
||||
if (!confirm(`Supprimer ${name} et toutes ses doses ? Cette action est irréversible.`)) return;
|
||||
try {
|
||||
await api(`/players/${playerId}`, { method: 'DELETE', auth: true });
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
handleAdminError(err);
|
||||
}
|
||||
}
|
||||
|
||||
function handleAdminError(err) {
|
||||
if (err.status === 401) {
|
||||
clearAdminKey();
|
||||
alert('Votre session admin a expiré. Merci de vous reconnecter.');
|
||||
} else {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function clearAdminKey() {
|
||||
state.adminKey = null;
|
||||
localStorage.removeItem(ADMIN_KEY_STORAGE);
|
||||
setAdminMode(false);
|
||||
}
|
||||
|
||||
async function tryRestoreAdminSession() {
|
||||
if (!state.adminKey) return;
|
||||
try {
|
||||
const result = await api('/admin/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password: state.adminKey }),
|
||||
});
|
||||
if (result.ok) {
|
||||
setAdminMode(true);
|
||||
} else {
|
||||
clearAdminKey();
|
||||
}
|
||||
} catch (_) {
|
||||
clearAdminKey();
|
||||
}
|
||||
}
|
||||
|
||||
addPlayerForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
addPlayerError.classList.add('hidden');
|
||||
const name = playerNameInput.value.trim();
|
||||
if (!name) return;
|
||||
try {
|
||||
await api('/players', { method: 'POST', auth: true, body: JSON.stringify({ name }) });
|
||||
playerNameInput.value = '';
|
||||
await loadAll();
|
||||
} catch (err) {
|
||||
addPlayerError.textContent = err.message;
|
||||
addPlayerError.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
adminLoginBtn.addEventListener('click', () => {
|
||||
adminLoginError.classList.add('hidden');
|
||||
adminPasswordInput.value = '';
|
||||
adminDialog.showModal();
|
||||
adminPasswordInput.focus();
|
||||
});
|
||||
|
||||
adminCancelBtn.addEventListener('click', () => adminDialog.close());
|
||||
|
||||
adminLoginForm.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const password = adminPasswordInput.value;
|
||||
try {
|
||||
const result = await api('/admin/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (result.ok) {
|
||||
state.adminKey = password;
|
||||
localStorage.setItem(ADMIN_KEY_STORAGE, password);
|
||||
adminDialog.close();
|
||||
setAdminMode(true);
|
||||
} else {
|
||||
adminLoginError.classList.remove('hidden');
|
||||
}
|
||||
} catch (_) {
|
||||
adminLoginError.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
adminLogoutBtn.addEventListener('click', clearAdminKey);
|
||||
|
||||
doseDateInput.addEventListener('change', () => {
|
||||
if (!doseDateInput.value) return;
|
||||
state.selectedDate = doseDateInput.value;
|
||||
loadAll();
|
||||
});
|
||||
|
||||
(async function init() {
|
||||
await tryRestoreAdminSession();
|
||||
await loadAll();
|
||||
})();
|
||||
})();
|
||||
85
public/index.html
Normal file
85
public/index.html
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Classement des doses</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<header class="page-header">
|
||||
<h1>Classement des doses</h1>
|
||||
<div class="admin-controls">
|
||||
<span id="admin-status" class="admin-status hidden">Mode admin</span>
|
||||
<button id="admin-login-btn" class="btn btn-ghost">Connexion admin</button>
|
||||
<button id="admin-logout-btn" class="btn btn-ghost hidden">Déconnexion</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="card">
|
||||
<h2>Doses par jour</h2>
|
||||
<div class="chart-wrap">
|
||||
<canvas id="dose-chart" height="320"></canvas>
|
||||
</div>
|
||||
<p id="chart-empty" class="empty-state hidden">Enregistrez une dose pour voir le graphique.</p>
|
||||
|
||||
<h3 class="totals-heading">Total des doses</h3>
|
||||
<div class="totals-row" id="totals-row"></div>
|
||||
<p id="totals-empty" class="empty-state hidden">Aucune dose enregistrée.</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Classement</h2>
|
||||
<div id="date-picker-row" class="date-picker-row admin-only hidden">
|
||||
<label for="dose-date-input">Modifier les doses du :</label>
|
||||
<input type="date" id="dose-date-input" />
|
||||
</div>
|
||||
<table class="leaderboard" id="leaderboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="col-rank">#</th>
|
||||
<th class="col-name">Joueur</th>
|
||||
<th class="col-total">Doses</th>
|
||||
<th class="col-actions admin-only hidden">Modifier</th>
|
||||
<th class="col-remove admin-only hidden"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="leaderboard-body"></tbody>
|
||||
</table>
|
||||
<p id="leaderboard-empty" class="empty-state hidden">Aucun joueur pour le moment.</p>
|
||||
</section>
|
||||
|
||||
<section id="add-player-section" class="card admin-only hidden">
|
||||
<h2>Ajouter un joueur</h2>
|
||||
<form id="add-player-form" class="add-player-form">
|
||||
<input type="text" id="player-name-input" placeholder="Nom du joueur" maxlength="40" autocomplete="off" required />
|
||||
<button type="submit" class="btn btn-primary">Ajouter</button>
|
||||
</form>
|
||||
<p id="add-player-error" class="form-error hidden"></p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="page-footer">
|
||||
<p>Une victoire = une dose. Seul l'admin peut ajouter/annuler des doses et ajouter/supprimer des joueurs.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<dialog id="admin-dialog">
|
||||
<form id="admin-login-form" method="dialog">
|
||||
<h2>Connexion admin</h2>
|
||||
<label for="admin-password-input">Mot de passe</label>
|
||||
<input type="password" id="admin-password-input" autocomplete="current-password" required />
|
||||
<p id="admin-login-error" class="form-error hidden">Mot de passe incorrect.</p>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" id="admin-cancel-btn" class="btn btn-ghost">Annuler</button>
|
||||
<button type="submit" class="btn btn-primary">Connexion</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/vendor/chart.umd.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
333
public/styles.css
Normal file
333
public/styles.css
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
:root {
|
||||
color-scheme: light;
|
||||
--page-plane: #f9f9f7;
|
||||
--surface-1: #fcfcfb;
|
||||
--text-primary: #0b0b0b;
|
||||
--text-secondary: #52514e;
|
||||
--text-muted: #898781;
|
||||
--gridline: #e1e0d9;
|
||||
--baseline: #c3c2b7;
|
||||
--border: rgba(11, 11, 11, 0.10);
|
||||
--success-text: #006300;
|
||||
--danger-text: #b3261e;
|
||||
|
||||
--series-1: #2a78d6; /* blue */
|
||||
--series-2: #eb6834; /* orange */
|
||||
--series-3: #1baf7a; /* aqua */
|
||||
--series-4: #eda100; /* yellow */
|
||||
--series-5: #e87ba4; /* magenta */
|
||||
--series-6: #008300; /* green */
|
||||
--series-7: #4a3aa7; /* violet */
|
||||
--series-8: #e34948; /* red */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--page-plane: #0d0d0d;
|
||||
--surface-1: #1a1a19;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--gridline: #2c2c2a;
|
||||
--baseline: #383835;
|
||||
--border: rgba(255, 255, 255, 0.10);
|
||||
--success-text: #0ca30c;
|
||||
--danger-text: #e66767;
|
||||
|
||||
--series-1: #3987e5;
|
||||
--series-2: #d95926;
|
||||
--series-3: #199e70;
|
||||
--series-4: #c98500;
|
||||
--series-5: #d55181;
|
||||
--series-6: #008300;
|
||||
--series-7: #9085e9;
|
||||
--series-8: #e66767;
|
||||
}
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--page-plane);
|
||||
color: var(--text-primary);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 48px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.admin-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-status {
|
||||
font-size: 0.85rem;
|
||||
color: var(--success-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
background: var(--surface-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn:hover { background: var(--gridline); }
|
||||
|
||||
.btn-primary {
|
||||
background: var(--series-1);
|
||||
border-color: var(--series-1);
|
||||
color: #ffffff;
|
||||
}
|
||||
.btn-primary:hover { filter: brightness(0.92); }
|
||||
|
||||
.btn-ghost { background: transparent; }
|
||||
|
||||
.btn-icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.btn-icon:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.date-picker-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.date-picker-row input[type="date"] {
|
||||
font: inherit;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--baseline);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
table.leaderboard {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
table.leaderboard th {
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
}
|
||||
|
||||
table.leaderboard td {
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
}
|
||||
|
||||
table.leaderboard tr:last-child td { border-bottom: none; }
|
||||
|
||||
.col-rank { width: 36px; color: var(--text-muted); }
|
||||
.col-total { width: 90px; text-align: right; font-weight: 600; }
|
||||
.col-actions { width: 140px; text-align: right; }
|
||||
.col-remove { width: 40px; text-align: right; }
|
||||
|
||||
.btn-remove {
|
||||
color: var(--danger-text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.player-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.color-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.dose-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.today-count {
|
||||
min-width: 1.4em;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.totals-heading {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin: 20px 0 10px;
|
||||
}
|
||||
|
||||
.totals-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.stat-tile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--page-plane);
|
||||
}
|
||||
|
||||
.stat-tile .stat-name {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.stat-tile .stat-value {
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.add-player-form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.add-player-form input {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--baseline);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: var(--danger-text);
|
||||
font-size: 0.85rem;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.chart-wrap {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
|
||||
dialog {
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
padding: 0;
|
||||
background: var(--surface-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
#admin-login-form {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
#admin-login-form label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
#admin-login-form input {
|
||||
font: inherit;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--baseline);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
12
scripts/copy-vendor.js
Normal file
12
scripts/copy-vendor.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Copies the Chart.js UMD bundle into public/vendor so the app can serve it
|
||||
// itself (no CDN dependency at runtime).
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const src = path.join(__dirname, '..', 'node_modules', 'chart.js', 'dist', 'chart.umd.js');
|
||||
const destDir = path.join(__dirname, '..', 'public', 'vendor');
|
||||
const dest = path.join(destDir, 'chart.umd.js');
|
||||
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.copyFileSync(src, dest);
|
||||
console.log(`Copied ${path.relative(process.cwd(), src)} -> ${path.relative(process.cwd(), dest)}`);
|
||||
23
server/auth.js
Normal file
23
server/auth.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
const crypto = require('crypto');
|
||||
|
||||
function timingSafeEqual(a, b) {
|
||||
const bufA = Buffer.from(String(a));
|
||||
const bufB = Buffer.from(String(b));
|
||||
if (bufA.length !== bufB.length) return false;
|
||||
return crypto.timingSafeEqual(bufA, bufB);
|
||||
}
|
||||
|
||||
function isValidAdminKey(key) {
|
||||
const expected = process.env.ADMIN_PASSWORD;
|
||||
return Boolean(expected) && Boolean(key) && timingSafeEqual(key, expected);
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
const key = req.get('x-admin-key');
|
||||
if (!isValidAdminKey(key)) {
|
||||
return res.status(401).json({ error: "Clé admin invalide ou manquante" });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { requireAdmin, isValidAdminKey };
|
||||
28
server/db.js
Normal file
28
server/db.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
|
||||
const dataDir = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
|
||||
const db = new DatabaseSync(path.join(dataDir, 'leaderboard.db'));
|
||||
db.exec('PRAGMA journal_mode = WAL;');
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL COLLATE NOCASE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS doses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id INTEGER NOT NULL REFERENCES players(id) ON DELETE CASCADE,
|
||||
date TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_doses_player_date ON doses(player_id, date);
|
||||
`);
|
||||
|
||||
module.exports = db;
|
||||
19
server/index.js
Normal file
19
server/index.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
const path = require('path');
|
||||
const express = require('express');
|
||||
const routes = require('./routes');
|
||||
|
||||
if (!process.env.ADMIN_PASSWORD) {
|
||||
console.error('ADMIN_PASSWORD environment variable is required. Set it in .env and restart.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
app.use(express.json());
|
||||
app.use('/api', routes);
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Dose leaderboard listening on port ${port}`);
|
||||
});
|
||||
163
server/routes.js
Normal file
163
server/routes.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
const express = require('express');
|
||||
const db = require('./db');
|
||||
const { requireAdmin, isValidAdminKey } = require('./auth');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
function todayStr() {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// Resolves a caller-supplied date, falling back to today and rejecting
|
||||
// malformed or future dates (returns null on invalid input).
|
||||
function resolveDate(raw) {
|
||||
if (!raw) return todayStr();
|
||||
if (!DATE_RE.test(raw)) return null;
|
||||
if (raw > todayStr()) return null;
|
||||
return raw;
|
||||
}
|
||||
|
||||
// ---- Players -----------------------------------------------------------
|
||||
|
||||
router.get('/players', (req, res) => {
|
||||
const date = resolveDate(req.query.date) || todayStr();
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT p.id, p.name,
|
||||
COUNT(d.id) AS total,
|
||||
SUM(CASE WHEN d.date = ? THEN 1 ELSE 0 END) AS dateCount
|
||||
FROM players p
|
||||
LEFT JOIN doses d ON d.player_id = p.id
|
||||
GROUP BY p.id
|
||||
ORDER BY total DESC, p.name COLLATE NOCASE ASC`
|
||||
)
|
||||
.all(date);
|
||||
res.json(rows.map((r) => ({ ...r, dateCount: r.dateCount || 0 })));
|
||||
});
|
||||
|
||||
router.post('/players', requireAdmin, (req, res) => {
|
||||
const name = String(req.body?.name || '').trim();
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Le nom est requis' });
|
||||
}
|
||||
if (name.length > 40) {
|
||||
return res.status(400).json({ error: 'Le nom est trop long (40 caractères maximum)' });
|
||||
}
|
||||
try {
|
||||
const info = db.prepare('INSERT INTO players (name) VALUES (?)').run(name);
|
||||
res.status(201).json({ id: info.lastInsertRowid, name, total: 0, dateCount: 0 });
|
||||
} catch (err) {
|
||||
if (err.code === 'ERR_SQLITE_ERROR' && /UNIQUE constraint failed/.test(err.message)) {
|
||||
return res.status(409).json({ error: 'Un joueur avec ce nom existe déjà' });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/players/:id', requireAdmin, (req, res) => {
|
||||
const playerId = Number(req.params.id);
|
||||
const player = db.prepare('SELECT id FROM players WHERE id = ?').get(playerId);
|
||||
if (!player) {
|
||||
return res.status(404).json({ error: 'Joueur introuvable' });
|
||||
}
|
||||
db.exec('BEGIN');
|
||||
db.prepare('DELETE FROM doses WHERE player_id = ?').run(playerId);
|
||||
db.prepare('DELETE FROM players WHERE id = ?').run(playerId);
|
||||
db.exec('COMMIT');
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
// ---- Timeseries (doses per player, per date) -----------------------------
|
||||
|
||||
router.get('/timeseries', (req, res) => {
|
||||
const players = db.prepare('SELECT id, name FROM players ORDER BY id ASC').all();
|
||||
const dates = db
|
||||
.prepare('SELECT DISTINCT date FROM doses ORDER BY date ASC')
|
||||
.all()
|
||||
.map((r) => r.date);
|
||||
|
||||
const counts = db
|
||||
.prepare('SELECT player_id, date, COUNT(*) AS c FROM doses GROUP BY player_id, date')
|
||||
.all();
|
||||
|
||||
const byPlayerDate = new Map(); // `${playerId}|${date}` -> count
|
||||
for (const row of counts) {
|
||||
byPlayerDate.set(`${row.player_id}|${row.date}`, row.c);
|
||||
}
|
||||
|
||||
const series = players.map((p) => ({
|
||||
playerId: p.id,
|
||||
name: p.name,
|
||||
counts: dates.map((date) => byPlayerDate.get(`${p.id}|${date}`) || 0),
|
||||
}));
|
||||
|
||||
res.json({ dates, series });
|
||||
});
|
||||
|
||||
// ---- Admin auth ----------------------------------------------------------
|
||||
|
||||
router.post('/admin/verify', (req, res) => {
|
||||
const password = String(req.body?.password || '');
|
||||
res.json({ ok: isValidAdminKey(password) });
|
||||
});
|
||||
|
||||
// ---- Doses (admin only) --------------------------------------------------
|
||||
|
||||
router.post('/doses', requireAdmin, (req, res) => {
|
||||
const playerId = Number(req.body?.playerId);
|
||||
const player = db.prepare('SELECT id FROM players WHERE id = ?').get(playerId);
|
||||
if (!player) {
|
||||
return res.status(404).json({ error: 'Joueur introuvable' });
|
||||
}
|
||||
|
||||
const date = resolveDate(req.body?.date);
|
||||
if (!date) {
|
||||
return res.status(400).json({ error: 'Date invalide (elle ne peut pas être dans le futur)' });
|
||||
}
|
||||
|
||||
db.prepare('INSERT INTO doses (player_id, date) VALUES (?, ?)').run(playerId, date);
|
||||
|
||||
const total = db.prepare('SELECT COUNT(*) AS c FROM doses WHERE player_id = ?').get(playerId).c;
|
||||
const dateCount = db
|
||||
.prepare('SELECT COUNT(*) AS c FROM doses WHERE player_id = ? AND date = ?')
|
||||
.get(playerId, date).c;
|
||||
|
||||
res.status(201).json({ playerId, total, dateCount });
|
||||
});
|
||||
|
||||
router.delete('/doses/latest', requireAdmin, (req, res) => {
|
||||
const playerId = Number(req.query.playerId);
|
||||
const player = db.prepare('SELECT id FROM players WHERE id = ?').get(playerId);
|
||||
if (!player) {
|
||||
return res.status(404).json({ error: 'Joueur introuvable' });
|
||||
}
|
||||
|
||||
const date = resolveDate(req.query.date);
|
||||
if (!date) {
|
||||
return res.status(400).json({ error: 'Date invalide (elle ne peut pas être dans le futur)' });
|
||||
}
|
||||
|
||||
const latest = db
|
||||
.prepare('SELECT id FROM doses WHERE player_id = ? AND date = ? ORDER BY id DESC LIMIT 1')
|
||||
.get(playerId, date);
|
||||
|
||||
if (!latest) {
|
||||
return res.status(404).json({ error: 'Aucune dose enregistrée pour ce joueur à cette date' });
|
||||
}
|
||||
db.prepare('DELETE FROM doses WHERE id = ?').run(latest.id);
|
||||
|
||||
const total = db.prepare('SELECT COUNT(*) AS c FROM doses WHERE player_id = ?').get(playerId).c;
|
||||
const dateCount = db
|
||||
.prepare('SELECT COUNT(*) AS c FROM doses WHERE player_id = ? AND date = ?')
|
||||
.get(playerId, date).c;
|
||||
|
||||
res.json({ playerId, total, dateCount });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Loading…
Add table
Reference in a new issue