full sync
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
11
.project
Normal file
11
.project
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<projectDescription>
|
||||||
|
<name>Live_RC</name>
|
||||||
|
<comment></comment>
|
||||||
|
<projects>
|
||||||
|
</projects>
|
||||||
|
<buildSpec>
|
||||||
|
</buildSpec>
|
||||||
|
<natures>
|
||||||
|
</natures>
|
||||||
|
</projectDescription>
|
||||||
11
AMMC/LICENCE
Normal file
11
AMMC/LICENCE
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation
|
||||||
|
files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use,
|
||||||
|
copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to
|
||||||
|
whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
|
||||||
|
THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS
|
||||||
|
OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||||
|
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
25
AMMC/README.md
Normal file
25
AMMC/README.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Amb Mylaps My Converter (AMMC) application readme
|
||||||
|
|
||||||
|
This is utility converting data from race timing devices to JSON or database. See
|
||||||
|
website [AMMC](http://www.ammconverter.eu) for
|
||||||
|
more details about AMM converter.
|
||||||
|
|
||||||
|
Content of the package:
|
||||||
|
|
||||||
|
- `passing.schema.json` - JSON schema of the passing record
|
||||||
|
- `windows64` - directory with Windows executable
|
||||||
|
- `linux_x86-64` - directory with Linux executable
|
||||||
|
- `apple_m` - directory with Apple M CPU executables
|
||||||
|
- `libammc.h` - AMMC header file for C/C++ developers
|
||||||
|
- `passing.schema.json` - Passing record JSON schema
|
||||||
|
|
||||||
|
Binary commands:
|
||||||
|
|
||||||
|
- ammc-amb(.exe) - converts data from AMB / MyLaps devices
|
||||||
|
- ammc-prochip(.exe) - converts data from Prochip devices
|
||||||
|
- ammc-vostok(.exe) - converts data from Vostok decoders
|
||||||
|
- ammc-x2(.exe) - converts data from MyLaps X2 server
|
||||||
|
- ammc-sim(.exe) - device simulator
|
||||||
|
|
||||||
|
All details on website https://ammconverter.eu or https://ammconverter.com
|
||||||
|
|
||||||
87977
AMMC/THIRDPARTY.toml
Normal file
87977
AMMC/THIRDPARTY.toml
Normal file
File diff suppressed because it is too large
Load Diff
BIN
AMMC/ammc-latest.zip
Normal file
BIN
AMMC/ammc-latest.zip
Normal file
Binary file not shown.
BIN
AMMC/apple_m/ammc-amb
Normal file
BIN
AMMC/apple_m/ammc-amb
Normal file
Binary file not shown.
BIN
AMMC/apple_m/ammc-prochip
Normal file
BIN
AMMC/apple_m/ammc-prochip
Normal file
Binary file not shown.
BIN
AMMC/apple_m/ammc-sim
Normal file
BIN
AMMC/apple_m/ammc-sim
Normal file
Binary file not shown.
BIN
AMMC/apple_m/ammc-vostok
Normal file
BIN
AMMC/apple_m/ammc-vostok
Normal file
Binary file not shown.
BIN
AMMC/apple_m/libammc.dylib
Normal file
BIN
AMMC/apple_m/libammc.dylib
Normal file
Binary file not shown.
41
AMMC/libammc.h
Normal file
41
AMMC/libammc.h
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
#include <stdarg.h>
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
|
||||||
|
char *p3_to_json(const char *msg);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Safety
|
||||||
|
*
|
||||||
|
* convert p3 binary data to JSON for jvm based languages
|
||||||
|
*/
|
||||||
|
jstring Java_com_skoky_AmmcBridge_p3_1to_1json(JNIEnv env, JClass, JString p3_bin);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Safety
|
||||||
|
*
|
||||||
|
* converts p3 network response to json fo rjvm languages
|
||||||
|
*/
|
||||||
|
jstring Java_com_skoky_AmmcBridge_p3_1network_1to_1json(JNIEnv env, JClass, JString p3_bin);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Safety
|
||||||
|
*
|
||||||
|
* encodes json message to p3 hex binary
|
||||||
|
*/
|
||||||
|
jstring Java_com_skoky_AmmcBridge_encode(JNIEnv env, JClass, JString json_str);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Safety
|
||||||
|
*
|
||||||
|
* converts P3 time to millis
|
||||||
|
*/
|
||||||
|
jstring Java_com_skoky_AmmcBridge_time_1to_1millis(JNIEnv env, JClass, JString str_time);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* # Safety
|
||||||
|
*
|
||||||
|
* returns p3 lib version
|
||||||
|
*/
|
||||||
|
jstring Java_com_skoky_AmmcBridge_version(JNIEnv env, JClass);
|
||||||
BIN
AMMC/linux_x86-64/ammc-amb
Normal file
BIN
AMMC/linux_x86-64/ammc-amb
Normal file
Binary file not shown.
BIN
AMMC/linux_x86-64/ammc-prochip
Normal file
BIN
AMMC/linux_x86-64/ammc-prochip
Normal file
Binary file not shown.
BIN
AMMC/linux_x86-64/ammc-sim
Executable file
BIN
AMMC/linux_x86-64/ammc-sim
Executable file
Binary file not shown.
BIN
AMMC/linux_x86-64/ammc-vostok
Normal file
BIN
AMMC/linux_x86-64/ammc-vostok
Normal file
Binary file not shown.
BIN
AMMC/linux_x86-64/ammc-x2
Normal file
BIN
AMMC/linux_x86-64/ammc-x2
Normal file
Binary file not shown.
BIN
AMMC/linux_x86-64/libammc.so
Normal file
BIN
AMMC/linux_x86-64/libammc.so
Normal file
Binary file not shown.
BIN
AMMC/linux_x86-64/libmylapssdk.so
Normal file
BIN
AMMC/linux_x86-64/libmylapssdk.so
Normal file
Binary file not shown.
167
AMMC/passing.schema.json
Normal file
167
AMMC/passing.schema.json
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "Passing",
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"decoder_id": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"controller_id": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"request_id": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"passing_number": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint64",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"transponder": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint32",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"rtc_time": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"strength": {
|
||||||
|
"type": [
|
||||||
|
"number",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "double"
|
||||||
|
},
|
||||||
|
"hits": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint16",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 65535
|
||||||
|
},
|
||||||
|
"low_battery": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"resend": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"modified": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"gps_locked": {
|
||||||
|
"type": [
|
||||||
|
"boolean",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"tran_code": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user_flags": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint32",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"driver_id": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint8",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 255
|
||||||
|
},
|
||||||
|
"sport": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint8",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 255
|
||||||
|
},
|
||||||
|
"voltage": {
|
||||||
|
"type": [
|
||||||
|
"number",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "double"
|
||||||
|
},
|
||||||
|
"temperature": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "int8",
|
||||||
|
"minimum": -128,
|
||||||
|
"maximum": 127
|
||||||
|
},
|
||||||
|
"car_id": {
|
||||||
|
"type": [
|
||||||
|
"integer",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"format": "uint8",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 255
|
||||||
|
},
|
||||||
|
"loop_id": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"empty_fields": {
|
||||||
|
"type": [
|
||||||
|
"array",
|
||||||
|
"null"
|
||||||
|
],
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"msg"
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
AMMC/windows64/MyLapsSDK.dll
Normal file
BIN
AMMC/windows64/MyLapsSDK.dll
Normal file
Binary file not shown.
BIN
AMMC/windows64/ammc-amb.exe
Normal file
BIN
AMMC/windows64/ammc-amb.exe
Normal file
Binary file not shown.
BIN
AMMC/windows64/ammc-prochip.exe
Normal file
BIN
AMMC/windows64/ammc-prochip.exe
Normal file
Binary file not shown.
BIN
AMMC/windows64/ammc-sim.exe
Normal file
BIN
AMMC/windows64/ammc-sim.exe
Normal file
Binary file not shown.
BIN
AMMC/windows64/ammc-vostok.exe
Normal file
BIN
AMMC/windows64/ammc-vostok.exe
Normal file
Binary file not shown.
BIN
AMMC/windows64/ammc-x2.exe
Normal file
BIN
AMMC/windows64/ammc-x2.exe
Normal file
Binary file not shown.
BIN
AMMC/windows64/ammc.dll
Normal file
BIN
AMMC/windows64/ammc.dll
Normal file
Binary file not shown.
182
README.md
Normal file
182
README.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# JMK RB Live Event
|
||||||
|
|
||||||
|
RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika heat/finaler), AMMC WebSocket och lokal SQLite-lagring på Windows.
|
||||||
|
|
||||||
|
## Vad som ingår
|
||||||
|
- Event-lägen:
|
||||||
|
- `Race (driver transponders)`
|
||||||
|
- `Track Event (shared cars)`
|
||||||
|
- UI-separering:
|
||||||
|
- `Event` = sponsor-event med delade bilar/transpondrar
|
||||||
|
- `Race Setup` = riktiga race med personlig transponder per förare
|
||||||
|
- `Race Setup` innehåller nu även:
|
||||||
|
- välj exakt vilka förare som är med i racet
|
||||||
|
- practice-ranking
|
||||||
|
- kval-ranking med `poäng` eller `bästa resultat`
|
||||||
|
- inbyggd guide för hur man skapar race steg för steg
|
||||||
|
- beskrivningar direkt i alla fält under `Raceformat`
|
||||||
|
- sessionstyp `Free Practice` för löpande varvtider utan seedning
|
||||||
|
- auto-generering av kvalheat från practice-ranking eller klasslista
|
||||||
|
- reseeding av kommande kvalheat från aktuell ranking
|
||||||
|
- auto-generering av `A/B/C...` finaler från ranking
|
||||||
|
- sparad manuell grid per session via dragbar grid-editor
|
||||||
|
- auto-reseed hoppar över heat där manuell grid har låsts tills du återställer den
|
||||||
|
- tydlig `Lås/Lås upp grid` i grid-editorn per session
|
||||||
|
- final-ranking över flera leg med räknade finalheat
|
||||||
|
- valbar `bump-up` mellan finaler
|
||||||
|
- reserverade bump-platser i högre finaler
|
||||||
|
- visuell finalmatris med reserverade bump-platser
|
||||||
|
- dragbar grid-editor för positionsstart
|
||||||
|
- utskrift/export av heatsheets per kval/final
|
||||||
|
- overlay-vy för extern leaderboard-skärm
|
||||||
|
- flera overlay-lägen: leaderboard, speaker och results
|
||||||
|
- speaker-overlay med eventmarkörer och separata speaker-cues
|
||||||
|
- speaker-cues och klubbinfo/PDF-header styrs från `Settings`
|
||||||
|
- logo-upload för overlay från `Settings`
|
||||||
|
- extra speaker-cues för `session start`, `new best lap` och `top 3 change`
|
||||||
|
- valbart PDF-tema: `classic`, `minimal`, `motorsport`
|
||||||
|
- utskrift av startlistor och resultat
|
||||||
|
- servergenererad PDF-export för startlistor, heatsheets och resultat
|
||||||
|
- genererade kval/finaler ärver tid och starttyp från raceformatet
|
||||||
|
- finish-ljud som siren i stället för browser-röst
|
||||||
|
- Sessioner: `practice`, `qualification`, `heat`, `final`
|
||||||
|
- Sponsor-verktyg:
|
||||||
|
- Skapa rundor automatiskt (`qualification`, `heat`, `final`)
|
||||||
|
- Auto-assign förare -> bil per session
|
||||||
|
- Live timing från AMMC WebSocket (`msg: "PASSING"`)
|
||||||
|
- Hanterad AMMC från webbgränssnittet:
|
||||||
|
- backend kan starta/stoppa lokal `ammc-amb` på Windows, Linux och macOS
|
||||||
|
- läser bundlade binärer från `AMMC/windows64`, `AMMC/linux_x86-64`, `AMMC/apple_m`
|
||||||
|
- Redigering i UI:
|
||||||
|
- Klasser, eventnamn/datum, förare och bilar kan redigeras direkt
|
||||||
|
- Live race-kontroll:
|
||||||
|
- Nedräkning under pågående session
|
||||||
|
- Auto-finish vid tidslut med status `Race is finished`
|
||||||
|
- Leaderboard-sortering: varv först, därefter närmast måltid för sessionen (t.ex. 5 min = 600s)
|
||||||
|
- Browserljud för passing (`blipp` eller tala förarnamn) och målgång
|
||||||
|
- Sessioninställningar för `Mass start`, `Position start`, `Staggered`
|
||||||
|
- `Timing` visar grid/startordning för aktiv `Position start`-session
|
||||||
|
- leaderboard visar både `gap till ledaren`, `gap till bilen framför` och `eget delta` mot förra varvet
|
||||||
|
- Practice/Kval kan seedas på bästa `2` eller `3` varv i sessionsinställningar
|
||||||
|
- Persistens:
|
||||||
|
- Frontend state i browser (`localStorage`)
|
||||||
|
- Samma state + passeringar sparas i lokal SQLite via Node-backend
|
||||||
|
- Inbyggd `Guide`-meny i appen med steg-för-steg för:
|
||||||
|
- Sponsor-event (10 personer / 4 bilar)
|
||||||
|
- Vanligt race
|
||||||
|
- AMMC + npm setup på Windows och Linux
|
||||||
|
- Språkval i UI: `SV` / `EN`
|
||||||
|
|
||||||
|
## Windows installation
|
||||||
|
Kör i PowerShell i projektmappen.
|
||||||
|
|
||||||
|
1. Installera Node.js LTS (18+).
|
||||||
|
2. Installera dependencies:
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
3. Starta servern i bakgrunden:
|
||||||
|
```powershell
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
4. Öppna:
|
||||||
|
- `http://localhost:8081`
|
||||||
|
- eller från annan dator: `http://<server-ip>:8081`
|
||||||
|
|
||||||
|
Vanliga kommandon:
|
||||||
|
```powershell
|
||||||
|
npm start
|
||||||
|
npm stop
|
||||||
|
npm restart
|
||||||
|
npm run status
|
||||||
|
npm run start:fg
|
||||||
|
```
|
||||||
|
|
||||||
|
- `npm start` startar `live_event` i bakgrunden
|
||||||
|
- `npm stop` stoppar processen via `data/server.pid`
|
||||||
|
- `npm restart` startar om backend
|
||||||
|
- `npm run status` visar om backend kör
|
||||||
|
- `npm run start:fg` kör i foreground för felsökning
|
||||||
|
|
||||||
|
### Windows scripts (bakgrundsstart)
|
||||||
|
Det finns färdiga `.bat`-filer i mappen `windows/`:
|
||||||
|
- `windows\\start_ammc.bat` startar AMMC i bakgrunden
|
||||||
|
- `windows\\start_backend.bat` startar `npm start` i bakgrunden (logg: `logs\\backend.log`)
|
||||||
|
- `windows\\start_all.bat` startar både AMMC + backend och öppnar webbsidan
|
||||||
|
- `windows\\stop_all.bat` stoppar backend på port `8081` och `ammc-amb.exe`
|
||||||
|
|
||||||
|
SQLite-filen skapas automatiskt här:
|
||||||
|
- `data\\rc_timing.sqlite`
|
||||||
|
|
||||||
|
## Koppla mot AMMC
|
||||||
|
Det finns nu två sätt:
|
||||||
|
|
||||||
|
### A. Hanterad AMMC i webbgränssnittet
|
||||||
|
Viktigt:
|
||||||
|
- AMMC körs på samma host där `npm start` / `node server.js` körs.
|
||||||
|
- Om du öppnar sidan från en annan laptop startas ingen AMMC där.
|
||||||
|
- Fältet `AMMC binär` i `Settings` är en sökväg på backend-hosten, inte på klienten som surfar in.
|
||||||
|
|
||||||
|
1. Lägg AMMC-binärerna i projektmappen `AMMC/` (redan gjort i denna repo).
|
||||||
|
2. Öppna `Settings`.
|
||||||
|
3. Aktivera `Hanterad AMMC / Managed AMMC`.
|
||||||
|
4. Sätt `Decoder IP / host`, kontrollera port `9000`, spara.
|
||||||
|
5. Klicka `Starta AMMC / Start AMMC`.
|
||||||
|
6. Klicka `Använd serverns WS-url / Use server WS URL` så sätts klienten till t.ex. `ws://<server-ip>:9000`.
|
||||||
|
|
||||||
|
Standardbinärer:
|
||||||
|
- Linux-host: `AMMC/linux_x86-64/ammc-amb`
|
||||||
|
- Windows-host: `AMMC/windows64/ammc-amb.exe`
|
||||||
|
- macOS-host: `AMMC/apple_m/ammc-amb`
|
||||||
|
|
||||||
|
### B. Manuell start
|
||||||
|
Starta AMMC med WebSocket (exempel):
|
||||||
|
```powershell
|
||||||
|
ammc-amb.exe -w 9000 192.168.1.11
|
||||||
|
```
|
||||||
|
|
||||||
|
I appen:
|
||||||
|
1. `Settings`
|
||||||
|
2. Sätt `WebSocket URL` till t.ex. `ws://127.0.0.1:9000`
|
||||||
|
3. Sätt `Backend URL` till `http://127.0.0.1:8081`
|
||||||
|
4. Klicka `Test Backend`
|
||||||
|
5. Gå till `Timing` och klicka `Connect Decoder`
|
||||||
|
|
||||||
|
Om du kör Linux-brandvägg (UFW), öppna porten:
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 8081/tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto reload vid uppdatering
|
||||||
|
- Servern bevakar `index.html`, `src/app.js` och `src/styles.css`.
|
||||||
|
- När du uppdaterar filer i `live_event` och sparar, laddar klienten om sidan automatiskt.
|
||||||
|
- Om backendkoden ändras, kör `npm restart`.
|
||||||
|
|
||||||
|
## Verifiera att SQLite sparar
|
||||||
|
Hämta senaste passeringar via API:
|
||||||
|
- `http://localhost:8081/api/passings`
|
||||||
|
|
||||||
|
Hämta sparad app-state:
|
||||||
|
- `http://localhost:8081/api/state`
|
||||||
|
|
||||||
|
## Viktig regel för sponsor-event
|
||||||
|
- I **samma pågående session** måste varje aktiv bil ha unikt transponder-ID.
|
||||||
|
- Samma transponder-ID kan återanvändas i **nästa session** (Heat 1 -> Heat 2 -> Heat 3 -> Final 1 ...).
|
||||||
|
|
||||||
|
## Referens AMMC JSON
|
||||||
|
Officiell quick-start:
|
||||||
|
- https://www.ammconverter.eu/docs/intro/quick-start/
|
||||||
|
|
||||||
|
Exempelmeddelande:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msg": "PASSING",
|
||||||
|
"passing_number": 1,
|
||||||
|
"transponder": 232323,
|
||||||
|
"rtc_time": "2022-10-11T22:57:36.099+02:00",
|
||||||
|
"strength": 0.0,
|
||||||
|
"resend": false,
|
||||||
|
"tran_code": "ID:232323",
|
||||||
|
"loop_id": "55"
|
||||||
|
}
|
||||||
|
```
|
||||||
63
index.html
Normal file
63
index.html
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>JMK RB Live Event</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700&family=Orbitron:wght@500;700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link rel="stylesheet" href="./src/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark">RC</div>
|
||||||
|
<div>
|
||||||
|
<h1 id="brandTitle">JMK RB</h1>
|
||||||
|
<p id="brandSubtitle">Live Event</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav id="nav" class="nav"></nav>
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<p id="connectionBadge" class="badge badge-offline">Decoder Offline</p>
|
||||||
|
<small id="clock"></small>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<h2 id="pageTitle">Dashboard</h2>
|
||||||
|
<p id="pageSubtitle">RC race timing with AMMC integration</p>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<label class="lang-wrap">
|
||||||
|
<span id="languageLabel">Language</span>
|
||||||
|
<select id="languageSelect" class="lang-select">
|
||||||
|
<option value="sv">SV</option>
|
||||||
|
<option value="en">EN</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p class="chip" id="activeSessionChip">No Active Session</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section id="view" class="view"></section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template id="tableTemplate">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script type="module" src="./src/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "rc-timing-control",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "RC timing app with AMMC websocket ingest and local SQLite persistence",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node scripts/serverctl.js start",
|
||||||
|
"start:fg": "node server.js",
|
||||||
|
"stop": "node scripts/serverctl.js stop",
|
||||||
|
"status": "node scripts/serverctl.js status",
|
||||||
|
"restart": "node scripts/serverctl.js restart"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^11.8.1",
|
||||||
|
"express": "^4.21.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
153
scripts/serverctl.js
Normal file
153
scripts/serverctl.js
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, "..");
|
||||||
|
const DATA_DIR = path.join(ROOT, "data");
|
||||||
|
const LOG_DIR = path.join(ROOT, "logs");
|
||||||
|
const PID_FILE = path.join(DATA_DIR, "server.pid");
|
||||||
|
const OUT_LOG = path.join(LOG_DIR, "server.out.log");
|
||||||
|
const ERR_LOG = path.join(LOG_DIR, "server.err.log");
|
||||||
|
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const command = process.argv[2] || "status";
|
||||||
|
|
||||||
|
if (command === "start") {
|
||||||
|
start();
|
||||||
|
} else if (command === "stop") {
|
||||||
|
stop();
|
||||||
|
} else if (command === "restart") {
|
||||||
|
restart();
|
||||||
|
} else if (command === "status") {
|
||||||
|
status();
|
||||||
|
} else {
|
||||||
|
console.error(`Unknown command: ${command}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
const pid = readPid();
|
||||||
|
if (pid && isRunning(pid)) {
|
||||||
|
console.log(`Live Event server already running with PID ${pid}`);
|
||||||
|
console.log(`Logs: ${OUT_LOG}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupStalePid();
|
||||||
|
|
||||||
|
const stdoutFd = fs.openSync(OUT_LOG, "a");
|
||||||
|
const stderrFd = fs.openSync(ERR_LOG, "a");
|
||||||
|
const child = spawn(process.execPath, ["server.js"], {
|
||||||
|
cwd: ROOT,
|
||||||
|
detached: true,
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: ["ignore", stdoutFd, stderrFd],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
RC_TIMING_PID_FILE: PID_FILE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
child.unref();
|
||||||
|
fs.writeFileSync(PID_FILE, `${child.pid}\n`);
|
||||||
|
|
||||||
|
console.log(`Live Event server started in background`);
|
||||||
|
console.log(`PID: ${child.pid}`);
|
||||||
|
console.log(`Logs: ${OUT_LOG}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
const pid = readPid();
|
||||||
|
if (!pid) {
|
||||||
|
console.log("Live Event server is not running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRunning(pid)) {
|
||||||
|
cleanupStalePid();
|
||||||
|
console.log("Removed stale pid file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGTERM");
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Could not stop PID ${pid}: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopped = await waitForExit(pid, 5000);
|
||||||
|
if (!stopped && process.platform !== "win32") {
|
||||||
|
try {
|
||||||
|
process.kill(pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
await waitForExit(pid, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupStalePid();
|
||||||
|
console.log(`Live Event server stopped`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restart() {
|
||||||
|
await stop();
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
function status() {
|
||||||
|
const pid = readPid();
|
||||||
|
if (pid && isRunning(pid)) {
|
||||||
|
console.log(`Live Event server is running`);
|
||||||
|
console.log(`PID: ${pid}`);
|
||||||
|
console.log(`Logs: ${OUT_LOG}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupStalePid();
|
||||||
|
console.log("Live Event server is not running");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readPid() {
|
||||||
|
if (!fs.existsSync(PID_FILE)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const raw = fs.readFileSync(PID_FILE, "utf8").trim();
|
||||||
|
const pid = Number(raw);
|
||||||
|
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunning(pid) {
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupStalePid() {
|
||||||
|
const pid = readPid();
|
||||||
|
if (!pid || !isRunning(pid)) {
|
||||||
|
fs.rmSync(PID_FILE, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForExit(pid, timeoutMs) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const started = Date.now();
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (!isRunning(pid)) {
|
||||||
|
clearInterval(timer);
|
||||||
|
resolve(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() - started >= timeoutMs) {
|
||||||
|
clearInterval(timer);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
});
|
||||||
|
}
|
||||||
796
server.js
Normal file
796
server.js
Normal file
@@ -0,0 +1,796 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const os = require("os");
|
||||||
|
const { spawn } = require("child_process");
|
||||||
|
const express = require("express");
|
||||||
|
const Database = require("better-sqlite3");
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 8081);
|
||||||
|
const HOST = process.env.HOST || "0.0.0.0";
|
||||||
|
const DB_DIR = path.join(__dirname, "data");
|
||||||
|
const DB_PATH = path.join(DB_DIR, "rc_timing.sqlite");
|
||||||
|
const AMMC_CONFIG_PATH = path.join(DB_DIR, "ammc_config.json");
|
||||||
|
const PID_FILE = process.env.RC_TIMING_PID_FILE || "";
|
||||||
|
const WATCHED_FILES = [
|
||||||
|
path.join(__dirname, "index.html"),
|
||||||
|
path.join(__dirname, "src", "app.js"),
|
||||||
|
path.join(__dirname, "src", "styles.css"),
|
||||||
|
];
|
||||||
|
|
||||||
|
fs.mkdirSync(DB_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
db.pragma("journal_mode = WAL");
|
||||||
|
db.pragma("foreign_keys = ON");
|
||||||
|
|
||||||
|
initSchema();
|
||||||
|
const appVersion = {
|
||||||
|
revision: 1,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
watchAppFiles();
|
||||||
|
const ammcState = {
|
||||||
|
process: null,
|
||||||
|
pid: null,
|
||||||
|
startedAt: null,
|
||||||
|
stoppedAt: null,
|
||||||
|
lastExitCode: null,
|
||||||
|
lastExitSignal: null,
|
||||||
|
lastError: "",
|
||||||
|
lastOutput: [],
|
||||||
|
config: loadAmmcConfig(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json({ limit: "20mb" }));
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
res.status(204).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/health", (_req, res) => {
|
||||||
|
res.json({ ok: true, dbPath: DB_PATH });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/app-version", (_req, res) => {
|
||||||
|
res.json(appVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/ammc/config", (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
config: ammcState.config,
|
||||||
|
status: buildAmmcStatus(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/ammc/config", (req, res) => {
|
||||||
|
if (!req.body || typeof req.body !== "object") {
|
||||||
|
res.status(400).json({ error: "Expected JSON body" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ammcState.config = normalizeAmmcConfig(req.body, ammcState.config);
|
||||||
|
saveAmmcConfig(ammcState.config);
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
config: ammcState.config,
|
||||||
|
status: buildAmmcStatus(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
status: buildAmmcStatus(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/ammc/status", (_req, res) => {
|
||||||
|
res.json(buildAmmcStatus());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/ammc/start", (_req, res) => {
|
||||||
|
try {
|
||||||
|
startAmmcProcess();
|
||||||
|
res.json({ ok: true, status: buildAmmcStatus() });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
status: buildAmmcStatus(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/ammc/stop", async (_req, res) => {
|
||||||
|
try {
|
||||||
|
await stopAmmcProcess();
|
||||||
|
res.json({ ok: true, status: buildAmmcStatus() });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
status: buildAmmcStatus(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/state", (_req, res) => {
|
||||||
|
const row = db.prepare("SELECT state_json, updated_at FROM app_state WHERE id = 1").get();
|
||||||
|
if (!row) {
|
||||||
|
res.json({ state: null, updatedAt: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
res.json({ state: JSON.parse(row.state_json), updatedAt: row.updated_at });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: "Stored app state is invalid JSON" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/state", (req, res) => {
|
||||||
|
if (!req.body || typeof req.body !== "object") {
|
||||||
|
res.status(400).json({ error: "Expected JSON body" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateJson = JSON.stringify(req.body);
|
||||||
|
const nowIso = new Date().toISOString();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO app_state (id, state_json, updated_at)
|
||||||
|
VALUES (1, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
state_json = excluded.state_json,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
`
|
||||||
|
).run(stateJson, nowIso);
|
||||||
|
|
||||||
|
res.json({ ok: true, updatedAt: nowIso });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/passings", (req, res) => {
|
||||||
|
const { sessionId, passing } = req.body || {};
|
||||||
|
if (!sessionId || !passing || typeof passing !== "object") {
|
||||||
|
res.status(400).json({ error: "Expected { sessionId, passing }" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stmt = db.prepare(
|
||||||
|
`
|
||||||
|
INSERT INTO passings (
|
||||||
|
session_id,
|
||||||
|
timestamp_ms,
|
||||||
|
transponder,
|
||||||
|
driver_id,
|
||||||
|
driver_name,
|
||||||
|
car_id,
|
||||||
|
car_name,
|
||||||
|
strength,
|
||||||
|
loop_id,
|
||||||
|
resend,
|
||||||
|
raw_json,
|
||||||
|
created_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
String(sessionId),
|
||||||
|
Number(passing.timestamp || Date.now()),
|
||||||
|
String(passing.transponder || ""),
|
||||||
|
passing.driverId ? String(passing.driverId) : null,
|
||||||
|
passing.driverName ? String(passing.driverName) : null,
|
||||||
|
passing.carId ? String(passing.carId) : null,
|
||||||
|
passing.carName ? String(passing.carName) : null,
|
||||||
|
typeof passing.strength === "number" ? passing.strength : null,
|
||||||
|
passing.loopId ? String(passing.loopId) : null,
|
||||||
|
passing.resend ? 1 : 0,
|
||||||
|
JSON.stringify(passing),
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/api/passings", (req, res) => {
|
||||||
|
const sessionId = req.query.sessionId ? String(req.query.sessionId) : null;
|
||||||
|
const limit = Math.min(1000, Math.max(1, Number(req.query.limit || 200)));
|
||||||
|
|
||||||
|
const rows = sessionId
|
||||||
|
? db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM passings
|
||||||
|
WHERE session_id = ?
|
||||||
|
ORDER BY timestamp_ms DESC
|
||||||
|
LIMIT ?
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(sessionId, limit)
|
||||||
|
: db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM passings
|
||||||
|
ORDER BY timestamp_ms DESC
|
||||||
|
LIMIT ?
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(limit);
|
||||||
|
|
||||||
|
res.json({ rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/api/export/pdf", (req, res) => {
|
||||||
|
try {
|
||||||
|
const payload = normalizePdfExportPayload(req.body || {});
|
||||||
|
const pdf = buildSimplePdf(payload);
|
||||||
|
res.setHeader("Content-Type", "application/pdf");
|
||||||
|
res.setHeader("Content-Disposition", `attachment; filename="${payload.filename}"`);
|
||||||
|
res.send(pdf);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(400).json({ error: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
express.static(__dirname, {
|
||||||
|
setHeaders: (res, filePath) => {
|
||||||
|
if (filePath.endsWith(".html") || filePath.endsWith(".js") || filePath.endsWith(".css")) {
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get("*", (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, "index.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, HOST, () => {
|
||||||
|
writePidFile();
|
||||||
|
console.log(`RC Timing server listening on http://${HOST}:${PORT}`);
|
||||||
|
const lanUrls = getLanUrls(PORT);
|
||||||
|
if (lanUrls.length) {
|
||||||
|
console.log(`LAN access: ${lanUrls.join(" , ")}`);
|
||||||
|
}
|
||||||
|
console.log(`SQLite database: ${DB_PATH}`);
|
||||||
|
console.log(`AMMC executable: ${buildAmmcStatus().resolvedExecutablePath}`);
|
||||||
|
maybeStartManagedAmmc();
|
||||||
|
});
|
||||||
|
|
||||||
|
function initSchema() {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS app_state (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
state_json TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS passings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
session_id TEXT NOT NULL,
|
||||||
|
timestamp_ms INTEGER NOT NULL,
|
||||||
|
transponder TEXT NOT NULL,
|
||||||
|
driver_id TEXT,
|
||||||
|
driver_name TEXT,
|
||||||
|
car_id TEXT,
|
||||||
|
car_name TEXT,
|
||||||
|
strength REAL,
|
||||||
|
loop_id TEXT,
|
||||||
|
resend INTEGER NOT NULL DEFAULT 0,
|
||||||
|
raw_json TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_passings_session ON passings(session_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_passings_timestamp ON passings(timestamp_ms);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePdfExportPayload(input = {}) {
|
||||||
|
const title = String(input.title || "JMK RB Live Event").trim();
|
||||||
|
const subtitle = String(input.subtitle || "").trim();
|
||||||
|
const brandName = String(input.brandName || "JMK RB").trim();
|
||||||
|
const brandTagline = String(input.brandTagline || "Live Event").trim();
|
||||||
|
const footer = String(input.footer || "").trim();
|
||||||
|
const theme = ["classic", "minimal", "motorsport"].includes(String(input.theme || "").toLowerCase())
|
||||||
|
? String(input.theme).toLowerCase()
|
||||||
|
: "classic";
|
||||||
|
const filenameBase = String(input.filename || "export.pdf").trim() || "export.pdf";
|
||||||
|
const filename = filenameBase.toLowerCase().endsWith(".pdf") ? filenameBase : `${filenameBase}.pdf`;
|
||||||
|
const sections = Array.isArray(input.sections)
|
||||||
|
? input.sections.map((section) => ({
|
||||||
|
title: String(section?.title || "").trim(),
|
||||||
|
headers: Array.isArray(section?.headers) ? section.headers.map((value) => String(value || "").trim()) : [],
|
||||||
|
rows: Array.isArray(section?.rows)
|
||||||
|
? section.rows.map((row) => (Array.isArray(row) ? row.map((value) => String(value ?? "").trim()) : [String(row ?? "").trim()]))
|
||||||
|
: [],
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return { title, subtitle, brandName, brandTagline, footer, theme, filename, sections };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSimplePdf(payload) {
|
||||||
|
const pageWidth = 595;
|
||||||
|
const pageHeight = 842;
|
||||||
|
const marginLeft = 42;
|
||||||
|
const marginTop = 56;
|
||||||
|
const lineHeight = 15;
|
||||||
|
const maxChars = 86;
|
||||||
|
const lines = buildPdfLines(payload, maxChars);
|
||||||
|
const linesPerPage = Math.max(10, Math.floor((pageHeight - marginTop * 2) / lineHeight));
|
||||||
|
const pages = [];
|
||||||
|
for (let index = 0; index < lines.length; index += linesPerPage) {
|
||||||
|
pages.push(lines.slice(index, index + linesPerPage));
|
||||||
|
}
|
||||||
|
if (!pages.length) {
|
||||||
|
pages.push(["No content"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const objects = [];
|
||||||
|
objects.push("1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n");
|
||||||
|
|
||||||
|
const kids = [];
|
||||||
|
const fontObjectId = 3 + pages.length * 2;
|
||||||
|
for (let index = 0; index < pages.length; index += 1) {
|
||||||
|
const pageObjectId = 3 + index * 2;
|
||||||
|
const contentObjectId = 4 + index * 2;
|
||||||
|
kids.push(`${pageObjectId} 0 R`);
|
||||||
|
const contentStream = buildPdfContentStream(pages[index], marginLeft, pageHeight - marginTop, lineHeight);
|
||||||
|
objects.push(
|
||||||
|
`${pageObjectId} 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${pageWidth} ${pageHeight}] /Resources << /Font << /F1 ${fontObjectId} 0 R >> >> /Contents ${contentObjectId} 0 R >>\nendobj\n`
|
||||||
|
);
|
||||||
|
objects.push(`${contentObjectId} 0 obj\n<< /Length ${Buffer.byteLength(contentStream, "utf8")} >>\nstream\n${contentStream}\nendstream\nendobj\n`);
|
||||||
|
}
|
||||||
|
objects.splice(1, 0, `2 0 obj\n<< /Type /Pages /Count ${pages.length} /Kids [${kids.join(" ")}] >>\nendobj\n`);
|
||||||
|
objects.push(`${fontObjectId} 0 obj\n<< /Type /Font /Subtype /Type1 /BaseFont /Courier >>\nendobj\n`);
|
||||||
|
|
||||||
|
let pdf = "%PDF-1.4\n";
|
||||||
|
const offsets = [0];
|
||||||
|
objects.forEach((object) => {
|
||||||
|
offsets.push(Buffer.byteLength(pdf, "utf8"));
|
||||||
|
pdf += object;
|
||||||
|
});
|
||||||
|
const xrefOffset = Buffer.byteLength(pdf, "utf8");
|
||||||
|
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||||||
|
pdf += "0000000000 65535 f \n";
|
||||||
|
offsets.slice(1).forEach((offset) => {
|
||||||
|
pdf += `${String(offset).padStart(10, "0")} 00000 n \n`;
|
||||||
|
});
|
||||||
|
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
||||||
|
return Buffer.from(pdf, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPdfLines(payload, maxChars) {
|
||||||
|
const lines = [];
|
||||||
|
const separator =
|
||||||
|
payload.theme === "minimal"
|
||||||
|
? ""
|
||||||
|
: payload.theme === "motorsport"
|
||||||
|
? "=".repeat(Math.min(maxChars, 70))
|
||||||
|
: "-".repeat(Math.min(maxChars, 70));
|
||||||
|
if (payload.brandName) {
|
||||||
|
lines.push(payload.brandName);
|
||||||
|
}
|
||||||
|
if (payload.brandTagline) {
|
||||||
|
lines.push(payload.brandTagline);
|
||||||
|
}
|
||||||
|
if (separator) {
|
||||||
|
lines.push(separator);
|
||||||
|
}
|
||||||
|
if (payload.brandName || payload.brandTagline) {
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
lines.push(payload.title);
|
||||||
|
if (payload.subtitle) {
|
||||||
|
lines.push(payload.subtitle);
|
||||||
|
}
|
||||||
|
payload.sections.forEach((section) => {
|
||||||
|
lines.push("");
|
||||||
|
if (section.title) {
|
||||||
|
lines.push(payload.theme === "motorsport" ? `> ${section.title}` : section.title);
|
||||||
|
}
|
||||||
|
if (section.headers.length) {
|
||||||
|
const widths = getColumnWidths(section.headers, section.rows, maxChars);
|
||||||
|
lines.push(formatPdfRow(section.headers, widths));
|
||||||
|
lines.push(formatPdfRow(widths.map((width) => (payload.theme === "minimal" ? " ".repeat(Math.min(width, 1)) : "-".repeat(Math.min(width, 12)))), widths));
|
||||||
|
section.rows.forEach((row) => {
|
||||||
|
lines.push(formatPdfRow(row, widths));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
section.rows.forEach((row) => {
|
||||||
|
row.forEach((value) => {
|
||||||
|
wrapPdfText(value, maxChars).forEach((line) => lines.push(line));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (payload.footer) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(payload.footer);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColumnWidths(headers, rows, maxChars) {
|
||||||
|
const widths = headers.map((header, index) => {
|
||||||
|
const columnValues = rows.map((row) => String(row[index] || ""));
|
||||||
|
const longest = Math.max(header.length, ...columnValues.map((value) => value.length), 4);
|
||||||
|
return Math.min(26, Math.max(4, longest));
|
||||||
|
});
|
||||||
|
let total = widths.reduce((sum, width) => sum + width, 0) + Math.max(0, (widths.length - 1) * 3);
|
||||||
|
while (total > maxChars) {
|
||||||
|
const widestIndex = widths.findIndex((width) => width === Math.max(...widths));
|
||||||
|
if (widths[widestIndex] <= 4) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
widths[widestIndex] -= 1;
|
||||||
|
total -= 1;
|
||||||
|
}
|
||||||
|
return widths;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPdfRow(values, widths) {
|
||||||
|
return values
|
||||||
|
.map((value, index) => String(value || "").slice(0, widths[index]).padEnd(widths[index], " "))
|
||||||
|
.join(" | ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapPdfText(text, maxChars) {
|
||||||
|
const words = String(text || "").split(/\s+/).filter(Boolean);
|
||||||
|
if (!words.length) {
|
||||||
|
return [""];
|
||||||
|
}
|
||||||
|
const lines = [];
|
||||||
|
let current = "";
|
||||||
|
words.forEach((word) => {
|
||||||
|
const next = current ? `${current} ${word}` : word;
|
||||||
|
if (next.length > maxChars) {
|
||||||
|
if (current) {
|
||||||
|
lines.push(current);
|
||||||
|
current = word;
|
||||||
|
} else {
|
||||||
|
lines.push(word.slice(0, maxChars));
|
||||||
|
current = word.slice(maxChars);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (current) {
|
||||||
|
lines.push(current);
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPdfContentStream(lines, x, y, lineHeight) {
|
||||||
|
const escaped = lines.map((line) => escapePdfText(line));
|
||||||
|
return [
|
||||||
|
"BT",
|
||||||
|
"/F1 11 Tf",
|
||||||
|
`${lineHeight} TL`,
|
||||||
|
`${x} ${y} Td`,
|
||||||
|
...escaped.map((line) => `(${line}) Tj\nT*`),
|
||||||
|
"ET",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapePdfText(text) {
|
||||||
|
return String(text || "")
|
||||||
|
.replaceAll("\\", "\\\\")
|
||||||
|
.replaceAll("(", "\\(")
|
||||||
|
.replaceAll(")", "\\)");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLanUrls(port) {
|
||||||
|
const interfaces = os.networkInterfaces();
|
||||||
|
const urls = [];
|
||||||
|
Object.values(interfaces).forEach((records) => {
|
||||||
|
(records || []).forEach((rec) => {
|
||||||
|
if (rec && rec.family === "IPv4" && !rec.internal) {
|
||||||
|
urls.push(`http://${rec.address}:${port}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchAppFiles() {
|
||||||
|
WATCHED_FILES.forEach((filePath) => {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.watchFile(filePath, { interval: 1000 }, (curr, prev) => {
|
||||||
|
if (curr.mtimeMs !== prev.mtimeMs) {
|
||||||
|
bumpAppVersion();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bumpAppVersion() {
|
||||||
|
appVersion.revision += 1;
|
||||||
|
appVersion.updatedAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultAmmcExecutablePath(platform = process.platform) {
|
||||||
|
if (platform === "win32") {
|
||||||
|
return path.join(__dirname, "AMMC", "windows64", "ammc-amb.exe");
|
||||||
|
}
|
||||||
|
if (platform === "darwin") {
|
||||||
|
return path.join(__dirname, "AMMC", "apple_m", "ammc-amb");
|
||||||
|
}
|
||||||
|
return path.join(__dirname, "AMMC", "linux_x86-64", "ammc-amb");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAmmcConfig(input = {}, previous = {}) {
|
||||||
|
const decoderHost = String(input.decoderHost ?? previous.decoderHost ?? "").trim();
|
||||||
|
const executablePath = String(input.executablePath ?? previous.executablePath ?? getDefaultAmmcExecutablePath()).trim();
|
||||||
|
const workingDirectory = String(input.workingDirectory ?? previous.workingDirectory ?? "").trim();
|
||||||
|
const extraArgs = String(input.extraArgs ?? previous.extraArgs ?? "").trim();
|
||||||
|
const wsPortValue = Number(input.wsPort ?? previous.wsPort ?? 9000);
|
||||||
|
if (!Number.isFinite(wsPortValue) || wsPortValue < 1 || wsPortValue > 65535) {
|
||||||
|
throw new Error("AMMC WebSocket port must be between 1 and 65535");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
managedEnabled: Boolean(input.managedEnabled ?? previous.managedEnabled ?? false),
|
||||||
|
autoStart: Boolean(input.autoStart ?? previous.autoStart ?? false),
|
||||||
|
decoderHost,
|
||||||
|
wsPort: Math.round(wsPortValue),
|
||||||
|
executablePath,
|
||||||
|
workingDirectory,
|
||||||
|
extraArgs,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAmmcConfig() {
|
||||||
|
if (!fs.existsSync(AMMC_CONFIG_PATH)) {
|
||||||
|
return normalizeAmmcConfig({
|
||||||
|
managedEnabled: false,
|
||||||
|
autoStart: false,
|
||||||
|
decoderHost: "",
|
||||||
|
wsPort: 9000,
|
||||||
|
executablePath: getDefaultAmmcExecutablePath(),
|
||||||
|
workingDirectory: "",
|
||||||
|
extraArgs: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(AMMC_CONFIG_PATH, "utf8");
|
||||||
|
return normalizeAmmcConfig(JSON.parse(raw));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to load AMMC config: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
return normalizeAmmcConfig({
|
||||||
|
managedEnabled: false,
|
||||||
|
autoStart: false,
|
||||||
|
decoderHost: "",
|
||||||
|
wsPort: 9000,
|
||||||
|
executablePath: getDefaultAmmcExecutablePath(),
|
||||||
|
workingDirectory: "",
|
||||||
|
extraArgs: "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAmmcConfig(config) {
|
||||||
|
fs.writeFileSync(AMMC_CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAmmcExecutable(executablePath) {
|
||||||
|
const value = String(executablePath || "").trim();
|
||||||
|
if (!value) {
|
||||||
|
return getDefaultAmmcExecutablePath();
|
||||||
|
}
|
||||||
|
if (path.isAbsolute(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return path.join(__dirname, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAmmcWorkingDirectory(config, executablePath) {
|
||||||
|
const explicitDir = String(config.workingDirectory || "").trim();
|
||||||
|
if (!explicitDir) {
|
||||||
|
return path.dirname(executablePath);
|
||||||
|
}
|
||||||
|
return path.isAbsolute(explicitDir) ? explicitDir : path.join(__dirname, explicitDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseExtraArgs(extraArgs) {
|
||||||
|
return String(extraArgs || "")
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAmmcRunning() {
|
||||||
|
return Boolean(ammcState.process && ammcState.process.exitCode == null && !ammcState.process.killed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAmmcStatus() {
|
||||||
|
const resolvedExecutablePath = resolveAmmcExecutable(ammcState.config.executablePath);
|
||||||
|
const workingDirectory = resolveAmmcWorkingDirectory(ammcState.config, resolvedExecutablePath);
|
||||||
|
return {
|
||||||
|
running: isAmmcRunning(),
|
||||||
|
pid: ammcState.pid,
|
||||||
|
startedAt: ammcState.startedAt,
|
||||||
|
stoppedAt: ammcState.stoppedAt,
|
||||||
|
lastExitCode: ammcState.lastExitCode,
|
||||||
|
lastExitSignal: ammcState.lastExitSignal,
|
||||||
|
lastError: ammcState.lastError,
|
||||||
|
lastOutput: ammcState.lastOutput,
|
||||||
|
serverPlatform: process.platform,
|
||||||
|
executableExists: fs.existsSync(resolvedExecutablePath),
|
||||||
|
resolvedExecutablePath,
|
||||||
|
workingDirectory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendAmmcOutput(stream, chunk) {
|
||||||
|
const lines = String(chunk || "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
lines.forEach((line) => {
|
||||||
|
ammcState.lastOutput.push({
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
stream,
|
||||||
|
line,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ammcState.lastOutput.length > 30) {
|
||||||
|
ammcState.lastOutput.splice(0, ammcState.lastOutput.length - 30);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAmmcProcess() {
|
||||||
|
if (isAmmcRunning()) {
|
||||||
|
return buildAmmcStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = normalizeAmmcConfig(ammcState.config, ammcState.config);
|
||||||
|
ammcState.config = config;
|
||||||
|
saveAmmcConfig(config);
|
||||||
|
|
||||||
|
if (!config.managedEnabled) {
|
||||||
|
throw new Error("Enable managed AMMC first");
|
||||||
|
}
|
||||||
|
if (!config.decoderHost) {
|
||||||
|
throw new Error("Set decoder IP/host before starting AMMC");
|
||||||
|
}
|
||||||
|
|
||||||
|
const executablePath = resolveAmmcExecutable(config.executablePath);
|
||||||
|
if (!fs.existsSync(executablePath)) {
|
||||||
|
throw new Error(`AMMC executable not found: ${executablePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = ["-w", String(config.wsPort), config.decoderHost, ...parseExtraArgs(config.extraArgs)];
|
||||||
|
const workingDirectory = resolveAmmcWorkingDirectory(config, executablePath);
|
||||||
|
|
||||||
|
ammcState.lastError = "";
|
||||||
|
appendAmmcOutput("system", `Starting ${path.basename(executablePath)} ${args.join(" ")}`);
|
||||||
|
|
||||||
|
const child = spawn(executablePath, args, {
|
||||||
|
cwd: workingDirectory,
|
||||||
|
windowsHide: true,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
ammcState.process = child;
|
||||||
|
ammcState.pid = child.pid || null;
|
||||||
|
ammcState.startedAt = new Date().toISOString();
|
||||||
|
ammcState.stoppedAt = null;
|
||||||
|
ammcState.lastExitCode = null;
|
||||||
|
ammcState.lastExitSignal = null;
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk) => appendAmmcOutput("stdout", chunk));
|
||||||
|
child.stderr.on("data", (chunk) => appendAmmcOutput("stderr", chunk));
|
||||||
|
child.on("error", (error) => {
|
||||||
|
ammcState.lastError = error instanceof Error ? error.message : String(error);
|
||||||
|
appendAmmcOutput("error", ammcState.lastError);
|
||||||
|
});
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
ammcState.lastExitCode = code;
|
||||||
|
ammcState.lastExitSignal = signal;
|
||||||
|
ammcState.stoppedAt = new Date().toISOString();
|
||||||
|
appendAmmcOutput("system", `AMMC stopped (code=${code}, signal=${signal || "none"})`);
|
||||||
|
ammcState.process = null;
|
||||||
|
ammcState.pid = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildAmmcStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAmmcProcess() {
|
||||||
|
if (!isAmmcRunning() || !ammcState.process) {
|
||||||
|
return Promise.resolve(buildAmmcStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = ammcState.process;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = () => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve(buildAmmcStatus());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
child.once("exit", finish);
|
||||||
|
child.kill();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!settled && isAmmcRunning()) {
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeStartManagedAmmc() {
|
||||||
|
if (!ammcState.config.managedEnabled || !ammcState.config.autoStart) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
startAmmcProcess();
|
||||||
|
console.log("Managed AMMC started");
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error);
|
||||||
|
ammcState.lastError = msg;
|
||||||
|
console.error(`Managed AMMC failed to start: ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
await stopAmmcProcess();
|
||||||
|
removePidFile();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
process.on("exit", removePidFile);
|
||||||
|
|
||||||
|
function writePidFile() {
|
||||||
|
if (!PID_FILE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(PID_FILE, `${process.pid}\n`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to write pid file: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePidFile() {
|
||||||
|
if (!PID_FILE || !fs.existsSync(PID_FILE)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(PID_FILE, "utf8").trim();
|
||||||
|
if (String(process.pid) === raw) {
|
||||||
|
fs.rmSync(PID_FILE, { force: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore pid cleanup errors on shutdown
|
||||||
|
}
|
||||||
|
}
|
||||||
5688
src/app.js
Normal file
5688
src/app.js
Normal file
File diff suppressed because it is too large
Load Diff
892
src/styles.css
Normal file
892
src/styles.css
Normal file
@@ -0,0 +1,892 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #07090e;
|
||||||
|
--bg-soft: #0f1420;
|
||||||
|
--panel: #131a28;
|
||||||
|
--panel-2: #0c1220;
|
||||||
|
--line: #273149;
|
||||||
|
--text: #f3f7ff;
|
||||||
|
--muted: #98a7c8;
|
||||||
|
--accent: #e10600;
|
||||||
|
--accent-2: #ff3b30;
|
||||||
|
--ok: #26c281;
|
||||||
|
--warn: #f5a623;
|
||||||
|
--shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Barlow, "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 15% 0%, rgba(225, 6, 0, 0.18), transparent 30%),
|
||||||
|
radial-gradient(circle at 100% 80%, rgba(37, 59, 103, 0.22), transparent 30%),
|
||||||
|
linear-gradient(160deg, #05070b 0%, #090d16 55%, #07090e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(225, 6, 0, 0.1), transparent 25%),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
-32deg,
|
||||||
|
rgba(255, 255, 255, 0.02) 0,
|
||||||
|
rgba(255, 255, 255, 0.02) 6px,
|
||||||
|
transparent 6px,
|
||||||
|
transparent 18px
|
||||||
|
),
|
||||||
|
var(--panel-2);
|
||||||
|
padding: 20px 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, #ff574f 0%, var(--accent) 55%, #8b0000 100%);
|
||||||
|
box-shadow: 0 8px 20px rgba(225, 6, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
border-color: #405076;
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
border-color: rgba(255, 88, 79, 0.55);
|
||||||
|
background: linear-gradient(90deg, rgba(225, 6, 0, 0.3), rgba(225, 6, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-online {
|
||||||
|
color: #b8ffd6;
|
||||||
|
border-color: rgba(38, 194, 129, 0.7);
|
||||||
|
background: rgba(38, 194, 129, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-offline {
|
||||||
|
color: #ffd2cf;
|
||||||
|
border-color: rgba(225, 6, 0, 0.7);
|
||||||
|
background: rgba(225, 6, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px 24px 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-select {
|
||||||
|
width: 70px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols-4 {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background:
|
||||||
|
linear-gradient(170deg, rgba(225, 6, 0, 0.12), transparent 40%),
|
||||||
|
linear-gradient(180deg, #151d2d 0%, #121927 100%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
margin: 6px 0;
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: linear-gradient(180deg, #131a28 0%, #101724 100%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid #425273;
|
||||||
|
background: linear-gradient(180deg, #1a2334 0%, #141c2b 100%);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 9px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
border-color: #60739b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
border-color: #b11714;
|
||||||
|
background: linear-gradient(180deg, #f20d07 0%, #d30702 52%, #8e0603 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
border-color: #843137;
|
||||||
|
background: linear-gradient(180deg, #8f222a, #66181e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: start;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-card-checkbox {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols-5 {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols-2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols-3 {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
background: #0f1522;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6578a4;
|
||||||
|
box-shadow: 0 0 0 3px rgba(225, 6, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-box {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #0b1019;
|
||||||
|
color: #c8d6f5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 9px 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
color: #bdc8e3;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-group {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-group h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-group ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-group li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-inline {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-card input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item:hover {
|
||||||
|
border-color: #60739b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item-active {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item-over {
|
||||||
|
border-color: rgba(225, 6, 0, 0.8);
|
||||||
|
background: rgba(225, 6, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-preview {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-preview img,
|
||||||
|
.overlay-logo {
|
||||||
|
max-width: 180px;
|
||||||
|
max-height: 90px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-logo {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid h4,
|
||||||
|
.final-card h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-list,
|
||||||
|
.matrix-slots {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-list {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-item,
|
||||||
|
.matrix-slot,
|
||||||
|
.matrix-session-row,
|
||||||
|
.final-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-item,
|
||||||
|
.matrix-slot,
|
||||||
|
.matrix-session-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-matrix {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-card {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-slot {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-slot-reserved {
|
||||||
|
border-color: rgba(245, 166, 35, 0.6);
|
||||||
|
background: rgba(245, 166, 35, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-session-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-session-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-mode .sidebar,
|
||||||
|
.overlay-mode .topbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-mode .app-shell {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-mode .content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 15% 0%, rgba(225, 6, 0, 0.18), transparent 30%),
|
||||||
|
radial-gradient(circle at 100% 80%, rgba(37, 59, 103, 0.22), transparent 30%),
|
||||||
|
linear-gradient(160deg, #05070b 0%, #090d16 55%, #07090e 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-header h1 {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.8rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-kicker {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-meta {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-clock {
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-size: clamp(2.6rem, 5vw, 4.8rem);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-status {
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.7fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-speaker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-speaker-main {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 28px;
|
||||||
|
background: rgba(7, 12, 20, 0.82);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-speaker-label {
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-speaker-main h2 {
|
||||||
|
margin: 12px 0;
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-size: clamp(2.8rem, 6vw, 5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-speaker-side,
|
||||||
|
.overlay-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-table-wrap,
|
||||||
|
.overlay-side-card,
|
||||||
|
.overlay-empty {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(7, 12, 20, 0.82);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-table-wrap {
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-side {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-side-card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-side-card h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-passing {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-passing:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: calc(100vh - 48px);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 3px 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-green {
|
||||||
|
border-color: rgba(38, 194, 129, 0.6);
|
||||||
|
color: #b7ffd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-pill {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-1 {
|
||||||
|
background: linear-gradient(180deg, #ffd95a, #d3a80e);
|
||||||
|
color: #1a1400;
|
||||||
|
border-color: #ffd95a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-2 {
|
||||||
|
background: linear-gradient(180deg, #dce4ef, #99a7b9);
|
||||||
|
color: #0f141b;
|
||||||
|
border-color: #dce4ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-3 {
|
||||||
|
background: linear-gradient(180deg, #d19562, #8c5d34);
|
||||||
|
color: #1a0f05;
|
||||||
|
border-color: #d19562;
|
||||||
|
}
|
||||||
|
|
||||||
|
.best {
|
||||||
|
color: #dfb9ff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ffb5b5;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finish-banner {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(225, 6, 0, 0.7);
|
||||||
|
background: rgba(225, 6, 0, 0.2);
|
||||||
|
color: #ffd6d4;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(4, 7, 12, 0.76);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(225, 6, 0, 0.08), transparent 20%),
|
||||||
|
linear-gradient(180deg, #131a28 0%, #101724 100%);
|
||||||
|
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-16 {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.cols-4,
|
||||||
|
.cols-5 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
position: static;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols-3,
|
||||||
|
.cols-4,
|
||||||
|
.cols-5 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-card-checkbox {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-speaker {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
174
windows/README.md
Normal file
174
windows/README.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# JMK RB Live Event
|
||||||
|
|
||||||
|
RC timing app med sponsor-eventflöde (delade bilar/transpondrar mellan olika heat/finaler), AMMC WebSocket och lokal SQLite-lagring på Windows.
|
||||||
|
|
||||||
|
## Vad som ingår
|
||||||
|
- Event-lägen:
|
||||||
|
- `Race (driver transponders)`
|
||||||
|
- `Track Event (shared cars)`
|
||||||
|
- UI-separering:
|
||||||
|
- `Event` = sponsor-event med delade bilar/transpondrar
|
||||||
|
- `Race Setup` = riktiga race med personlig transponder per förare
|
||||||
|
- `Race Setup` innehåller nu även:
|
||||||
|
- välj exakt vilka förare som är med i racet
|
||||||
|
- practice-ranking
|
||||||
|
- kval-ranking med `poäng` eller `bästa resultat`
|
||||||
|
- inbyggd guide för hur man skapar race steg för steg
|
||||||
|
- beskrivningar direkt i alla fält under `Raceformat`
|
||||||
|
- sessionstyp `Free Practice` för löpande varvtider utan seedning
|
||||||
|
- auto-generering av kvalheat från practice-ranking eller klasslista
|
||||||
|
- reseeding av kommande kvalheat från aktuell ranking
|
||||||
|
- auto-generering av `A/B/C...` finaler från ranking
|
||||||
|
- sparad manuell grid per session via dragbar grid-editor
|
||||||
|
- final-ranking över flera leg med räknade finalheat
|
||||||
|
- valbar `bump-up` mellan finaler
|
||||||
|
- reserverade bump-platser i högre finaler
|
||||||
|
- visuell finalmatris med reserverade bump-platser
|
||||||
|
- dragbar grid-editor för positionsstart
|
||||||
|
- utskrift/export av heatsheets per kval/final
|
||||||
|
- overlay-vy för extern leaderboard-skärm
|
||||||
|
- flera overlay-lägen: leaderboard, speaker och results
|
||||||
|
- utskrift av startlistor och resultat
|
||||||
|
- genererade kval/finaler ärver tid och starttyp från raceformatet
|
||||||
|
- finish-ljud som siren i stället för browser-röst
|
||||||
|
- Sessioner: `practice`, `qualification`, `heat`, `final`
|
||||||
|
- Sponsor-verktyg:
|
||||||
|
- Skapa rundor automatiskt (`qualification`, `heat`, `final`)
|
||||||
|
- Auto-assign förare -> bil per session
|
||||||
|
- Live timing från AMMC WebSocket (`msg: "PASSING"`)
|
||||||
|
- Hanterad AMMC från webbgränssnittet:
|
||||||
|
- backend kan starta/stoppa lokal `ammc-amb` på Windows, Linux och macOS
|
||||||
|
- läser bundlade binärer från `AMMC/windows64`, `AMMC/linux_x86-64`, `AMMC/apple_m`
|
||||||
|
- Redigering i UI:
|
||||||
|
- Klasser, eventnamn/datum, förare och bilar kan redigeras direkt
|
||||||
|
- Live race-kontroll:
|
||||||
|
- Nedräkning under pågående session
|
||||||
|
- Auto-finish vid tidslut med status `Race is finished`
|
||||||
|
- Leaderboard-sortering: varv först, därefter närmast måltid för sessionen (t.ex. 5 min = 600s)
|
||||||
|
- Browserljud för passing (`blipp` eller tala förarnamn) och målgång
|
||||||
|
- Sessioninställningar för `Mass start`, `Position start`, `Staggered`
|
||||||
|
- `Timing` visar grid/startordning för aktiv `Position start`-session
|
||||||
|
- leaderboard visar både `gap till ledaren`, `gap till bilen framför` och `eget delta` mot förra varvet
|
||||||
|
- Practice/Kval kan seedas på bästa `2` eller `3` varv i sessionsinställningar
|
||||||
|
- Persistens:
|
||||||
|
- Frontend state i browser (`localStorage`)
|
||||||
|
- Samma state + passeringar sparas i lokal SQLite via Node-backend
|
||||||
|
- Inbyggd `Guide`-meny i appen med steg-för-steg för:
|
||||||
|
- Sponsor-event (10 personer / 4 bilar)
|
||||||
|
- Vanligt race
|
||||||
|
- AMMC + npm setup på Windows och Linux
|
||||||
|
- Språkval i UI: `SV` / `EN`
|
||||||
|
|
||||||
|
## Windows installation
|
||||||
|
Kör i PowerShell i projektmappen.
|
||||||
|
|
||||||
|
1. Installera Node.js LTS (18+).
|
||||||
|
2. Installera dependencies:
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
3. Starta servern i bakgrunden:
|
||||||
|
```powershell
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
4. Öppna:
|
||||||
|
- `http://localhost:8081`
|
||||||
|
- eller från annan dator: `http://<server-ip>:8081`
|
||||||
|
|
||||||
|
Vanliga kommandon:
|
||||||
|
```powershell
|
||||||
|
npm start
|
||||||
|
npm stop
|
||||||
|
npm restart
|
||||||
|
npm run status
|
||||||
|
npm run start:fg
|
||||||
|
```
|
||||||
|
|
||||||
|
- `npm start` startar `live_event` i bakgrunden
|
||||||
|
- `npm stop` stoppar processen via `data/server.pid`
|
||||||
|
- `npm restart` startar om backend
|
||||||
|
- `npm run status` visar om backend kör
|
||||||
|
- `npm run start:fg` kör i foreground för felsökning
|
||||||
|
|
||||||
|
### Windows scripts (bakgrundsstart)
|
||||||
|
Det finns färdiga `.bat`-filer i mappen `windows/`:
|
||||||
|
- `windows\\start_ammc.bat` startar AMMC i bakgrunden
|
||||||
|
- `windows\\start_backend.bat` startar `npm start` i bakgrunden (logg: `logs\\backend.log`)
|
||||||
|
- `windows\\start_all.bat` startar både AMMC + backend och öppnar webbsidan
|
||||||
|
- `windows\\stop_all.bat` stoppar backend på port `8081` och `ammc-amb.exe`
|
||||||
|
|
||||||
|
SQLite-filen skapas automatiskt här:
|
||||||
|
- `data\\rc_timing.sqlite`
|
||||||
|
|
||||||
|
## Koppla mot AMMC
|
||||||
|
Det finns nu två sätt:
|
||||||
|
|
||||||
|
### A. Hanterad AMMC i webbgränssnittet
|
||||||
|
Viktigt:
|
||||||
|
- AMMC körs på samma host där `npm start` / `node server.js` körs.
|
||||||
|
- Om du öppnar sidan från en annan laptop startas ingen AMMC där.
|
||||||
|
- Fältet `AMMC binär` i `Settings` är en sökväg på backend-hosten, inte på klienten som surfar in.
|
||||||
|
|
||||||
|
1. Lägg AMMC-binärerna i projektmappen `AMMC/` (redan gjort i denna repo).
|
||||||
|
2. Öppna `Settings`.
|
||||||
|
3. Aktivera `Hanterad AMMC / Managed AMMC`.
|
||||||
|
4. Sätt `Decoder IP / host`, kontrollera port `9000`, spara.
|
||||||
|
5. Klicka `Starta AMMC / Start AMMC`.
|
||||||
|
6. Klicka `Använd serverns WS-url / Use server WS URL` så sätts klienten till t.ex. `ws://<server-ip>:9000`.
|
||||||
|
|
||||||
|
Standardbinärer:
|
||||||
|
- Linux-host: `AMMC/linux_x86-64/ammc-amb`
|
||||||
|
- Windows-host: `AMMC/windows64/ammc-amb.exe`
|
||||||
|
- macOS-host: `AMMC/apple_m/ammc-amb`
|
||||||
|
|
||||||
|
### B. Manuell start
|
||||||
|
Starta AMMC med WebSocket (exempel):
|
||||||
|
```powershell
|
||||||
|
ammc-amb.exe -w 9000 192.168.1.11
|
||||||
|
```
|
||||||
|
|
||||||
|
I appen:
|
||||||
|
1. `Settings`
|
||||||
|
2. Sätt `WebSocket URL` till t.ex. `ws://127.0.0.1:9000`
|
||||||
|
3. Sätt `Backend URL` till `http://127.0.0.1:8081`
|
||||||
|
4. Klicka `Test Backend`
|
||||||
|
5. Gå till `Timing` och klicka `Connect Decoder`
|
||||||
|
|
||||||
|
Om du kör Linux-brandvägg (UFW), öppna porten:
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 8081/tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto reload vid uppdatering
|
||||||
|
- Servern bevakar `index.html`, `src/app.js` och `src/styles.css`.
|
||||||
|
- När du uppdaterar filer i `live_event` och sparar, laddar klienten om sidan automatiskt.
|
||||||
|
- Om backendkoden ändras, kör `npm restart`.
|
||||||
|
|
||||||
|
## Verifiera att SQLite sparar
|
||||||
|
Hämta senaste passeringar via API:
|
||||||
|
- `http://localhost:8081/api/passings`
|
||||||
|
|
||||||
|
Hämta sparad app-state:
|
||||||
|
- `http://localhost:8081/api/state`
|
||||||
|
|
||||||
|
## Viktig regel för sponsor-event
|
||||||
|
- I **samma pågående session** måste varje aktiv bil ha unikt transponder-ID.
|
||||||
|
- Samma transponder-ID kan återanvändas i **nästa session** (Heat 1 -> Heat 2 -> Heat 3 -> Final 1 ...).
|
||||||
|
|
||||||
|
## Referens AMMC JSON
|
||||||
|
Officiell quick-start:
|
||||||
|
- https://www.ammconverter.eu/docs/intro/quick-start/
|
||||||
|
|
||||||
|
Exempelmeddelande:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"msg": "PASSING",
|
||||||
|
"passing_number": 1,
|
||||||
|
"transponder": 232323,
|
||||||
|
"rtc_time": "2022-10-11T22:57:36.099+02:00",
|
||||||
|
"strength": 0.0,
|
||||||
|
"resend": false,
|
||||||
|
"tran_code": "ID:232323",
|
||||||
|
"loop_id": "55"
|
||||||
|
}
|
||||||
|
```
|
||||||
5171
windows/src/app.js
Normal file
5171
windows/src/app.js
Normal file
File diff suppressed because it is too large
Load Diff
873
windows/src/styles.css
Normal file
873
windows/src/styles.css
Normal file
@@ -0,0 +1,873 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #07090e;
|
||||||
|
--bg-soft: #0f1420;
|
||||||
|
--panel: #131a28;
|
||||||
|
--panel-2: #0c1220;
|
||||||
|
--line: #273149;
|
||||||
|
--text: #f3f7ff;
|
||||||
|
--muted: #98a7c8;
|
||||||
|
--accent: #e10600;
|
||||||
|
--accent-2: #ff3b30;
|
||||||
|
--ok: #26c281;
|
||||||
|
--warn: #f5a623;
|
||||||
|
--shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Barlow, "Segoe UI", sans-serif;
|
||||||
|
color: var(--text);
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 15% 0%, rgba(225, 6, 0, 0.18), transparent 30%),
|
||||||
|
radial-gradient(circle at 100% 80%, rgba(37, 59, 103, 0.22), transparent 30%),
|
||||||
|
linear-gradient(160deg, #05070b 0%, #090d16 55%, #07090e 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 280px 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(225, 6, 0, 0.1), transparent 25%),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
-32deg,
|
||||||
|
rgba(255, 255, 255, 0.02) 0,
|
||||||
|
rgba(255, 255, 255, 0.02) 6px,
|
||||||
|
transparent 6px,
|
||||||
|
transparent 18px
|
||||||
|
),
|
||||||
|
var(--panel-2);
|
||||||
|
padding: 20px 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 46px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, #ff574f 0%, var(--accent) 55%, #8b0000 100%);
|
||||||
|
box-shadow: 0 8px 20px rgba(225, 6, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
border-color: #405076;
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
border-color: rgba(255, 88, 79, 0.55);
|
||||||
|
background: linear-gradient(90deg, rgba(225, 6, 0, 0.3), rgba(225, 6, 0, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
bottom: 16px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-online {
|
||||||
|
color: #b8ffd6;
|
||||||
|
border-color: rgba(38, 194, 129, 0.7);
|
||||||
|
background: rgba(38, 194, 129, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-offline {
|
||||||
|
color: #ffd2cf;
|
||||||
|
border-color: rgba(225, 6, 0, 0.7);
|
||||||
|
background: rgba(225, 6, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px 24px 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
margin: 0;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-wrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lang-select {
|
||||||
|
width: 70px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols-4 {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background:
|
||||||
|
linear-gradient(170deg, rgba(225, 6, 0, 0.12), transparent 40%),
|
||||||
|
linear-gradient(180deg, #151d2d 0%, #121927 100%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card h3 {
|
||||||
|
margin: 6px 0;
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.4fr 1fr;
|
||||||
|
gap: 14px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: linear-gradient(180deg, #131a28 0%, #101724 100%);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border: 1px solid #425273;
|
||||||
|
background: linear-gradient(180deg, #1a2334 0%, #141c2b 100%);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 9px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
border-color: #60739b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
border-color: #b11714;
|
||||||
|
background: linear-gradient(180deg, #f20d07 0%, #d30702 52%, #8e0603 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
border-color: #843137;
|
||||||
|
background: linear-gradient(180deg, #8f222a, #66181e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
align-content: start;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.025);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-card-checkbox {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-label {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols-5 {
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols-2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols-3 {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
background: #0f1522;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6578a4;
|
||||||
|
box-shadow: 0 0 0 3px rgba(225, 6, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-box {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #0b1019;
|
||||||
|
color: #c8d6f5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: "SFMono-Regular", Consolas, monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table thead {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th,
|
||||||
|
.data-table td {
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 9px 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table th {
|
||||||
|
color: #bdc8e3;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.55px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data-table tbody tr:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simple-list li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-group {
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-group h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-group ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assignment-group li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-inline {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-card input {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item:hover {
|
||||||
|
border-color: #60739b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item-active {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-item-over {
|
||||||
|
border-color: rgba(225, 6, 0, 0.8);
|
||||||
|
background: rgba(225, 6, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid h4,
|
||||||
|
.final-card h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-list,
|
||||||
|
.matrix-slots {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-list {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-item,
|
||||||
|
.matrix-slot,
|
||||||
|
.matrix-session-row,
|
||||||
|
.final-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-grid-item,
|
||||||
|
.matrix-slot,
|
||||||
|
.matrix-session-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-matrix {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-card {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.final-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-slot {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-slot-reserved {
|
||||||
|
border-color: rgba(245, 166, 35, 0.6);
|
||||||
|
background: rgba(245, 166, 35, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-session-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.matrix-session-row {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-mode .sidebar,
|
||||||
|
.overlay-mode .topbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-mode .app-shell {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-mode .content {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 15% 0%, rgba(225, 6, 0, 0.18), transparent 30%),
|
||||||
|
radial-gradient(circle at 100% 80%, rgba(37, 59, 103, 0.22), transparent 30%),
|
||||||
|
linear-gradient(160deg, #05070b 0%, #090d16 55%, #07090e 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-header h1 {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-size: clamp(2rem, 4vw, 3.8rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-kicker {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-meta {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-clock {
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-size: clamp(2.6rem, 5vw, 4.8rem);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-status {
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-board {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.5fr) minmax(320px, 0.7fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-speaker {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-speaker-main {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 28px;
|
||||||
|
background: rgba(7, 12, 20, 0.82);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-speaker-label {
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-speaker-main h2 {
|
||||||
|
margin: 12px 0;
|
||||||
|
font-family: Orbitron, sans-serif;
|
||||||
|
font-size: clamp(2.8rem, 6vw, 5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-speaker-side,
|
||||||
|
.overlay-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-table-wrap,
|
||||||
|
.overlay-side-card,
|
||||||
|
.overlay-empty {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(7, 12, 20, 0.82);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-table-wrap {
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-side {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-side-card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-side-card h3 {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-passing {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-passing:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-empty {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: calc(100vh - 48px);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
padding: 3px 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-green {
|
||||||
|
border-color: rgba(38, 194, 129, 0.6);
|
||||||
|
color: #b7ffd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-pill {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-1 {
|
||||||
|
background: linear-gradient(180deg, #ffd95a, #d3a80e);
|
||||||
|
color: #1a1400;
|
||||||
|
border-color: #ffd95a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-2 {
|
||||||
|
background: linear-gradient(180deg, #dce4ef, #99a7b9);
|
||||||
|
color: #0f141b;
|
||||||
|
border-color: #dce4ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pos-3 {
|
||||||
|
background: linear-gradient(180deg, #d19562, #8c5d34);
|
||||||
|
color: #1a0f05;
|
||||||
|
border-color: #d19562;
|
||||||
|
}
|
||||||
|
|
||||||
|
.best {
|
||||||
|
color: #dfb9ff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ffb5b5;
|
||||||
|
min-height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.finish-banner {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(225, 6, 0, 0.7);
|
||||||
|
background: rgba(225, 6, 0, 0.2);
|
||||||
|
color: #ffd6d4;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(4, 7, 12, 0.76);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-card {
|
||||||
|
width: min(760px, 100%);
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(225, 6, 0, 0.08), transparent 20%),
|
||||||
|
linear-gradient(180deg, #131a28 0%, #101724 100%);
|
||||||
|
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-16 {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.cols-4,
|
||||||
|
.cols-5 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
position: static;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols-3,
|
||||||
|
.cols-4,
|
||||||
|
.cols-5 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-card-checkbox {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-board {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-speaker {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-overlay {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
windows/start_all.bat
Normal file
10
windows/start_all.bat
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
call "%~dp0start_ammc.bat"
|
||||||
|
call "%~dp0start_backend.bat"
|
||||||
|
|
||||||
|
start "" "http://localhost:8081"
|
||||||
|
|
||||||
|
echo [OK] AMMC + backend started
|
||||||
|
endlocal
|
||||||
23
windows/start_ammc.bat
Normal file
23
windows/start_ammc.bat
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
if "%AMMC_EXE%"=="" set "AMMC_EXE=%~dp0ammc-amb.exe"
|
||||||
|
if not exist "%AMMC_EXE%" if exist "%~dp0..\ammc-amb.exe" set "AMMC_EXE=%~dp0..\ammc-amb.exe"
|
||||||
|
if "%DECODER_IP%"=="" set "DECODER_IP=192.168.1.11"
|
||||||
|
if "%WS_PORT%"=="" set "WS_PORT=9000"
|
||||||
|
|
||||||
|
if not exist "%AMMC_EXE%" (
|
||||||
|
echo [ERROR] Could not find AMMC executable:
|
||||||
|
echo %AMMC_EXE%
|
||||||
|
echo Set AMMC_EXE in this file or as environment variable.
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
start "AMMC" /min cmd /c "\"%AMMC_EXE%\" -w %WS_PORT% %DECODER_IP%"
|
||||||
|
|
||||||
|
echo [OK] AMMC started in background
|
||||||
|
|
||||||
|
echo Decoder IP: %DECODER_IP%
|
||||||
|
|
||||||
|
echo WebSocket: ws://127.0.0.1:%WS_PORT%
|
||||||
|
endlocal
|
||||||
15
windows/start_backend.bat
Normal file
15
windows/start_backend.bat
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
for %%I in ("%~dp0..") do set "APP_DIR=%%~fI"
|
||||||
|
set "LOG_DIR=%APP_DIR%\logs"
|
||||||
|
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||||
|
|
||||||
|
start "RC Timing Backend" /min cmd /c "cd /d \"%APP_DIR%\" && npm start >> \"%LOG_DIR%\backend.log\" 2>&1"
|
||||||
|
|
||||||
|
echo [OK] Backend started in background
|
||||||
|
|
||||||
|
echo URL: http://localhost:8081
|
||||||
|
|
||||||
|
echo Log: %LOG_DIR%\backend.log
|
||||||
|
endlocal
|
||||||
11
windows/stop_all.bat
Normal file
11
windows/stop_all.bat
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
for /f "tokens=5" %%p in ('netstat -ano ^| findstr ":8081" ^| findstr "LISTENING"') do (
|
||||||
|
taskkill /PID %%p /F >nul 2>&1
|
||||||
|
)
|
||||||
|
|
||||||
|
taskkill /IM ammc-amb.exe /F >nul 2>&1
|
||||||
|
|
||||||
|
echo [OK] Stopped backend on :8081 and ammc-amb.exe (if running)
|
||||||
|
endlocal
|
||||||
Reference in New Issue
Block a user