mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
feat(server): initial implementation
This commit captures the full server code accumulated across
several development sessions. It includes the following layers,
applied in roughly this order during development:
1. Base infrastructure
- cmd/poc-server HTTP+WebSocket server, /health, /stats
- internal/transport wire types (Envelope + all message types)
- internal/catalog, internal/realtime, internal/stats
- internal/storage/postgres with migrations 001-004
- .env, .env.example, docker-compose, Dockerfile, scripts/
2. Swagger documentation
- go get github.com/swaggo/http-swagger
- swag init produces docs/docs.go
- /swagger/index.html UI, /swagger/doc.json spec
- annotations on health/stats/ws and catalog handlers
3. Races API
- internal/races/{store,service,keyset,types}.go
- migration 005_races.sql: finished_races, race_plans, race_queue
- GET /api/races with keyset pagination, status filter
- GET /api/races/upcoming
- POST /api/races/queue/join, GET, DELETE
- POST/GET /api/races/plans, DELETE /{id}
- lobby.RaceMeta simplification (no host_id/host_name)
4. Races seeder
- internal/races/seed with deterministic generator
- --seed-races / --reset CLI flags in main.go
- 30 finished + 5 live + 5 plans + 4 queue entries
5. Drivers and clans
- internal/drivers, internal/clans (CRUD, validation)
- migration 008_drivers_clans.sql
- /api/clans and /api/drivers endpoints
- 3-letter uppercase nickname/tag validation
- lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag
6. Podium
- internal/transport.RacePodiumEntry
- lobby.SetDriverProfile for in-memory metadata sync
- migration 007_podium.sql (podium JSONB column)
- seeder populates top-3 per finished race
7. Live races persistence
- migration 009_live_persistence.sql: live_races,
live_race_drivers, lobby_drivers
- internal/races/live_store.go: LiveStore with write-side
mirror for lobby.Service mutations
- Service.collectLive and Upcoming read from Postgres
- main.go RestoreFromDB rehydrates lobby on startup
8. Unify live + finished into one races table
- migration 010_unify_races.sql: rename finished_races to
races, expand status CHECK, merge live rows
- PgStore now hosts both write paths; LiveStore is a
thin facade implementing lobby.Persistence
- seeder resetAll drops only finished/cancelled rows and
race_plans / race_queue / lobby_drivers
Each layer is consistent on its own; cross-layer changes are
visible in the file history. A future refactor may split this
commit into the per-stage boundaries listed above.
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
/* x0gp · PoC web client stylesheet
|
||||
Минималистичный тёмный UI. Без зависимостей, без сборки. */
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; height: 100%; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
|
||||
Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
|
||||
background: #0e1117;
|
||||
color: #e6edf3;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 10px 16px;
|
||||
background: #161b22;
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
.brand .logo {
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
color: #58a6ff;
|
||||
font-size: 18px;
|
||||
}
|
||||
.brand .tag {
|
||||
margin-left: 8px;
|
||||
padding: 2px 6px;
|
||||
font-size: 11px;
|
||||
background: #1f6feb;
|
||||
color: #fff;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.conn {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
background: #6e7681;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
.conn.connected { background: #2ea043; }
|
||||
.conn.connecting { background: #d29922; }
|
||||
.conn.error { background: #f85149; }
|
||||
|
||||
.hud-top {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
.hud-top b { color: #e6edf3; font-weight: 600; }
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 360px;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* Track canvas */
|
||||
.track-wrap {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.track-wrap canvas {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
background: #0a0d12;
|
||||
border-radius: 4px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
.legend i.dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.dot.me { background: #58a6ff; }
|
||||
.dot.other { background: #f0883e; }
|
||||
.dot.ghost { background: #d29922; }
|
||||
|
||||
/* Controls aside */
|
||||
.controls { display: flex; flex-direction: column; gap: 12px; }
|
||||
.card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
.card h3 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #8b949e;
|
||||
}
|
||||
.card label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card input[type=text] {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
padding: 6px 8px;
|
||||
background: #0e1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
color: #e6edf3;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
.card input[type=text]:focus {
|
||||
outline: none;
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
color: #e6edf3;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #30363d; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
.btn.primary { background: #238636; border-color: #2ea043; }
|
||||
.btn.primary:hover:not(:disabled) { background: #2ea043; }
|
||||
|
||||
.hint {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
/* Pedals / bars */
|
||||
.pedals { display: flex; flex-direction: column; gap: 6px; }
|
||||
.bar {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr 44px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bar-track {
|
||||
position: relative;
|
||||
height: 12px;
|
||||
background: #0e1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.bar-fill {
|
||||
position: absolute;
|
||||
top: 0; bottom: 0;
|
||||
left: 50%;
|
||||
width: 0%;
|
||||
transition: left 60ms linear, width 60ms linear;
|
||||
}
|
||||
.bar-fill.green { background: #2ea043; }
|
||||
.bar-fill.red { background: #f85149; }
|
||||
.bar-fill.blue { background: #58a6ff; }
|
||||
.bar b {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #e6edf3;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Steering wheel visual */
|
||||
.wheel {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.wheel-disc {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle at center, #30363d 0%, #21262d 70%);
|
||||
border: 2px solid #484f58;
|
||||
position: relative;
|
||||
transition: transform 80ms linear;
|
||||
}
|
||||
.wheel-disc::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 50%;
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
background: #f0883e;
|
||||
transform: translateX(-50%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Telemetry */
|
||||
.tel {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr;
|
||||
gap: 4px 12px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tel dt { color: #8b949e; }
|
||||
.tel dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
/* Log */
|
||||
.log-card .log {
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
background: #0a0d12;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 4px;
|
||||
height: 160px;
|
||||
overflow-y: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
|
||||
"Liberation Mono", monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: #c9d1d9;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.log .l-info { color: #58a6ff; }
|
||||
.log .l-warn { color: #d29922; }
|
||||
.log .l-error { color: #f85149; }
|
||||
.log .l-rx { color: #2ea043; }
|
||||
.log .l-tx { color: #f0883e; }
|
||||
.log .l-meta { color: #8b949e; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
Reference in New Issue
Block a user