update
This commit is contained in:
182
API_CHANGES.md
Normal file
182
API_CHANGES.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# API Changes (Not Yet Reflected in `backend/openapi/openapi.json`)
|
||||
|
||||
This document lists additive API changes currently implemented in the Go backend.
|
||||
|
||||
## Summary
|
||||
|
||||
Existing endpoints remain compatible.
|
||||
|
||||
Added capabilities:
|
||||
|
||||
1. Position board persistence for live/admin ordering.
|
||||
2. Stage session control (`start`/`end`) for event flow.
|
||||
3. Stage history retrieval.
|
||||
4. New state fields (`positionBoards`, `current_stage`, `last_stage`).
|
||||
|
||||
## 1) Position Board Endpoint
|
||||
|
||||
- Method: `PUT`
|
||||
- Path: `/api/admin/positions/:board`
|
||||
- Auth: admin bearer token (`Authorization: Bearer <token>`)
|
||||
|
||||
### Supported `:board` values
|
||||
|
||||
- `preliminary`
|
||||
- `final`
|
||||
- `prelim_tiebreak`
|
||||
- `final_tiebreak`
|
||||
|
||||
### Request body
|
||||
|
||||
```json
|
||||
{
|
||||
"slots": [
|
||||
{ "playerId": 12, "groupKey": "A", "position": 1 },
|
||||
{ "playerId": 15, "groupKey": "A", "position": 2 },
|
||||
{ "playerId": 20, "groupKey": "B", "position": 1 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Fields:
|
||||
|
||||
- `playerId` (integer, required)
|
||||
- `groupKey` (string, required)
|
||||
- `position` (integer > 0, required)
|
||||
|
||||
Notes:
|
||||
|
||||
- The request replaces all saved slots for the target board.
|
||||
- For `preliminary`, `players.group_code` is synchronized from `groupKey`.
|
||||
- Special value `UNASSIGNED` maps to empty player group.
|
||||
|
||||
### Response
|
||||
|
||||
- `200 OK` with full updated admin state payload.
|
||||
|
||||
## 2) Stage Session Control Endpoints
|
||||
|
||||
### Start stage
|
||||
|
||||
- Method: `POST`
|
||||
- Path: `/api/admin/stage/start`
|
||||
- Auth: admin bearer token
|
||||
|
||||
Request body:
|
||||
|
||||
```json
|
||||
{
|
||||
"phase": "preliminary",
|
||||
"group": "A",
|
||||
"round": 2
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `phase` must be one of:
|
||||
- `preliminary`
|
||||
- `prelim_tiebreak`
|
||||
- `final`
|
||||
- `final_tiebreak`
|
||||
- `group` validation depends on phase:
|
||||
- `preliminary`: group code string (or `UNASSIGNED`)
|
||||
- `final`: `"1"` or `"2"`
|
||||
- tie-break phases: positive numeric string
|
||||
- `round` normalization:
|
||||
- `preliminary`: `1..3`
|
||||
- `final`: `1..2`
|
||||
- tie-break phases: always `1`
|
||||
- If another stage is active (started, not ended), API returns `409`.
|
||||
|
||||
Response:
|
||||
|
||||
- `200 OK` with full updated admin state payload.
|
||||
|
||||
### End stage
|
||||
|
||||
- Method: `POST`
|
||||
- Path: `/api/admin/stage/end`
|
||||
- Auth: admin bearer token
|
||||
|
||||
Rules:
|
||||
|
||||
- Ends the currently active stage by setting `ended_at`.
|
||||
- If no active stage exists, API returns `400`.
|
||||
|
||||
Response:
|
||||
|
||||
- `200 OK` with full updated admin state payload.
|
||||
|
||||
## 3) Stage History Endpoint
|
||||
|
||||
- Method: `GET`
|
||||
- Path: `/api/admin/stage/history`
|
||||
- Auth: admin bearer token
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"phase": "preliminary",
|
||||
"group": "A",
|
||||
"round": 2,
|
||||
"startedAt": "2026-04-30T08:27:13Z",
|
||||
"endedAt": "2026-04-30T08:28:01Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 4) New State Fields
|
||||
|
||||
Both endpoints now include these fields:
|
||||
|
||||
- `GET /api/state`
|
||||
- `GET /api/admin/state`
|
||||
|
||||
```json
|
||||
{
|
||||
"positionBoards": {
|
||||
"preliminary": {
|
||||
"12": { "groupKey": "A", "position": 1 }
|
||||
},
|
||||
"final": {
|
||||
"12": { "groupKey": "1", "position": 3 }
|
||||
},
|
||||
"prelim_tiebreak": {},
|
||||
"final_tiebreak": {}
|
||||
},
|
||||
"current_stage": {
|
||||
"id": 7,
|
||||
"phase": "preliminary",
|
||||
"group": "A",
|
||||
"round": 2,
|
||||
"startedAt": "2026-04-30T09:00:00Z"
|
||||
},
|
||||
"last_stage": {
|
||||
"id": 6,
|
||||
"phase": "final",
|
||||
"group": "1",
|
||||
"round": 1,
|
||||
"startedAt": "2026-04-30T08:45:00Z",
|
||||
"endedAt": "2026-04-30T08:55:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `positionBoards` keys are player IDs as strings.
|
||||
- `current_stage` is `null` when no stage is active.
|
||||
- `last_stage` is `null` when no stage session has ever started.
|
||||
- `last_stage` is the most recently started session (ended or still active).
|
||||
|
||||
## Compatibility Impact
|
||||
|
||||
- Additive only; existing consumers remain functional.
|
||||
- Consumers can ignore unknown fields safely.
|
||||
- Consumers needing control/monitoring can use new stage endpoints and fields.
|
||||
@@ -59,12 +59,31 @@ CREATE TABLE IF NOT EXISTS score_attachments (
|
||||
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS player_positions (
|
||||
board TEXT NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
group_key TEXT NOT NULL DEFAULT '',
|
||||
position INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(board, player_id),
|
||||
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS stage_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
phase TEXT NOT NULL,
|
||||
group_key TEXT NOT NULL DEFAULT '',
|
||||
round INTEGER NOT NULL DEFAULT 1,
|
||||
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ended_at DATETIME
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO app_settings(key, value) VALUES
|
||||
('view_proof_in_view', '0'),
|
||||
('live_active_view', 'group_live'),
|
||||
|
||||
@@ -78,6 +78,84 @@ func (a *App) handleUpdateAdminSettings(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (a *App) handleStartCurrentStage(c echo.Context) error {
|
||||
var req StageControlStartRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
phase, ok := normalizeStagePhase(req.Phase)
|
||||
if !ok {
|
||||
return writeError(c, http.StatusBadRequest, "invalid phase")
|
||||
}
|
||||
groupKey, ok := normalizeStageGroup(phase, req.GroupKey)
|
||||
if !ok {
|
||||
return writeError(c, http.StatusBadRequest, "invalid group for selected phase")
|
||||
}
|
||||
round := normalizeStageRound(phase, req.Round)
|
||||
|
||||
current, err := a.readCurrentStage()
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if current != nil {
|
||||
return writeError(c, http.StatusConflict, "there is already an active stage. end it first")
|
||||
}
|
||||
|
||||
_, err = a.db.Exec(`
|
||||
INSERT INTO stage_sessions(phase, group_key, round, started_at)
|
||||
VALUES(?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, phase, groupKey, round)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("start stage: %v", err))
|
||||
}
|
||||
|
||||
a.events.Broadcast()
|
||||
|
||||
state, err := a.readState(true)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (a *App) handleEndCurrentStage(c echo.Context) error {
|
||||
current, err := a.readCurrentStage()
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
if current == nil {
|
||||
return writeError(c, http.StatusBadRequest, "no active stage to end")
|
||||
}
|
||||
|
||||
_, err = a.db.Exec(`
|
||||
UPDATE stage_sessions
|
||||
SET ended_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ? AND ended_at IS NULL
|
||||
`, current.ID)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("end stage: %v", err))
|
||||
}
|
||||
|
||||
a.events.Broadcast()
|
||||
|
||||
state, err := a.readState(true)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (a *App) handleGetStageHistory(c echo.Context) error {
|
||||
items, err := a.readStageHistory()
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]any{
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) handleCreatePlayer(c echo.Context) error {
|
||||
var req PlayerCreateRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
@@ -295,6 +373,77 @@ func (a *App) handleAutoGroupPlayers(c echo.Context) error {
|
||||
return c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdatePositionBoard(c echo.Context) error {
|
||||
board, ok := normalizePositionBoard(c.Param("board"))
|
||||
if !ok {
|
||||
return writeError(c, http.StatusBadRequest, "invalid position board")
|
||||
}
|
||||
|
||||
var req PositionBoardUpdateRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return writeError(c, http.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
seen := map[int]bool{}
|
||||
for _, slot := range req.Slots {
|
||||
if slot.PlayerID <= 0 {
|
||||
return writeError(c, http.StatusBadRequest, "invalid player id")
|
||||
}
|
||||
if seen[slot.PlayerID] {
|
||||
return writeError(c, http.StatusBadRequest, "duplicate player id in slots")
|
||||
}
|
||||
seen[slot.PlayerID] = true
|
||||
if strings.TrimSpace(slot.GroupKey) == "" {
|
||||
return writeError(c, http.StatusBadRequest, "groupKey is required")
|
||||
}
|
||||
if slot.Position <= 0 {
|
||||
return writeError(c, http.StatusBadRequest, "position must be positive")
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := a.db.Begin()
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, "failed to start position transaction")
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.Exec(`DELETE FROM player_positions WHERE board = ?`, board); err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("clear board positions: %v", err))
|
||||
}
|
||||
|
||||
for _, slot := range req.Slots {
|
||||
groupKey := normalizePositionGroupKey(board, slot.GroupKey, "")
|
||||
if _, err := tx.Exec(`
|
||||
INSERT INTO player_positions(board, player_id, group_key, position, updated_at)
|
||||
VALUES(?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`, board, slot.PlayerID, groupKey, slot.Position); err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("save board position: %v", err))
|
||||
}
|
||||
|
||||
if board == positionBoardPreliminary {
|
||||
groupCode := strings.TrimSpace(groupKey)
|
||||
if strings.EqualFold(groupCode, positionBoardUnassigned) {
|
||||
groupCode = ""
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE players SET group_code = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, groupCode, slot.PlayerID); err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update player group from board: %v", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, "failed to commit positions")
|
||||
}
|
||||
|
||||
a.events.Broadcast()
|
||||
|
||||
state, err := a.readState(true)
|
||||
if err != nil {
|
||||
return writeError(c, http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
return c.JSON(http.StatusOK, state)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdateScore(c echo.Context) error {
|
||||
stage, err := validateStage(c.Param("stage"))
|
||||
if err != nil {
|
||||
|
||||
@@ -88,11 +88,28 @@ type StateResponse struct {
|
||||
Players []Player `json:"players"`
|
||||
Scores map[string]map[string]int `json:"scores"`
|
||||
ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"`
|
||||
PositionBoards map[string]map[string]PositionSlot `json:"positionBoards"`
|
||||
Settings AppSettings `json:"settings"`
|
||||
Derived DerivedState `json:"derived"`
|
||||
CurrentStage *StageSessionState `json:"current_stage"`
|
||||
LastStage *StageSessionState `json:"last_stage"`
|
||||
ServerTime string `json:"serverTime"`
|
||||
}
|
||||
|
||||
type StageSessionState struct {
|
||||
ID int `json:"id"`
|
||||
Phase string `json:"phase"`
|
||||
GroupKey string `json:"group"`
|
||||
Round int `json:"round"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
EndedAt string `json:"endedAt,omitempty"`
|
||||
}
|
||||
|
||||
type PositionSlot struct {
|
||||
GroupKey string `json:"groupKey"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
type AppSettings struct {
|
||||
ViewProofInView bool `json:"viewProofInView"`
|
||||
LiveMode LiveModeSettings `json:"liveMode"`
|
||||
@@ -170,6 +187,16 @@ type AutoGroupPlayersRequest struct {
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
|
||||
type PositionBoardUpdateRequest struct {
|
||||
Slots []PositionBoardSlotInput `json:"slots"`
|
||||
}
|
||||
|
||||
type PositionBoardSlotInput struct {
|
||||
PlayerID int `json:"playerId"`
|
||||
GroupKey string `json:"groupKey"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
type ScoreAdviceResponse struct {
|
||||
Stage string `json:"stage"`
|
||||
PlayerID int `json:"playerId"`
|
||||
@@ -177,3 +204,9 @@ type ScoreAdviceResponse struct {
|
||||
Reason string `json:"reason"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
}
|
||||
|
||||
type StageControlStartRequest struct {
|
||||
Phase string `json:"phase"`
|
||||
GroupKey string `json:"group"`
|
||||
Round int `json:"round"`
|
||||
}
|
||||
|
||||
@@ -1080,6 +1080,7 @@
|
||||
"prelim_overall",
|
||||
"final_groups",
|
||||
"final_tie",
|
||||
"final_overall",
|
||||
"podium"
|
||||
]
|
||||
},
|
||||
@@ -1149,6 +1150,7 @@
|
||||
"prelim_overall",
|
||||
"final_groups",
|
||||
"final_tie",
|
||||
"final_overall",
|
||||
"podium"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -22,9 +22,13 @@ func registerAPIRoutes(e *echo.Echo, app *App) {
|
||||
api.POST("/admin/login", app.handleAdminLogin)
|
||||
api.POST("/admin/logout", app.handleAdminLogout, app.adminOnly)
|
||||
api.GET("/admin/state", app.handleGetState, app.adminOnly)
|
||||
api.GET("/admin/stage/history", app.handleGetStageHistory, app.adminOnly)
|
||||
api.PUT("/admin/settings", app.handleUpdateAdminSettings, app.adminOnly)
|
||||
api.POST("/admin/stage/start", app.handleStartCurrentStage, app.adminOnly)
|
||||
api.POST("/admin/stage/end", app.handleEndCurrentStage, app.adminOnly)
|
||||
api.POST("/admin/players", app.handleCreatePlayer, app.adminOnly)
|
||||
api.POST("/admin/players/auto-group", app.handleAutoGroupPlayers, app.adminOnly)
|
||||
api.PUT("/admin/positions/:board", app.handleUpdatePositionBoard, app.adminOnly)
|
||||
api.PUT("/admin/players/:id", app.handleUpdatePlayer, app.adminOnly)
|
||||
api.DELETE("/admin/players/:id", app.handleDeletePlayer, app.adminOnly)
|
||||
api.PUT("/admin/scores/:stage/:id", app.handleUpdateScore, app.adminOnly)
|
||||
|
||||
@@ -355,7 +355,7 @@ func normalizeRotationPlayerCountInt(value int) int {
|
||||
|
||||
func normalizeLiveActiveView(value string, fallback string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(value)) {
|
||||
case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "podium":
|
||||
case "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "final_overall", "podium":
|
||||
return strings.TrimSpace(strings.ToLower(value))
|
||||
default:
|
||||
if strings.TrimSpace(strings.ToLower(fallback)) != "" {
|
||||
|
||||
172
backend/stage_sessions.go
Normal file
172
backend/stage_sessions.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
stagePhasePreliminary = "preliminary"
|
||||
stagePhasePrelimTie = "prelim_tiebreak"
|
||||
stagePhaseFinal = "final"
|
||||
stagePhaseFinalTie = "final_tiebreak"
|
||||
)
|
||||
|
||||
var allowedStagePhases = map[string]bool{
|
||||
stagePhasePreliminary: true,
|
||||
stagePhasePrelimTie: true,
|
||||
stagePhaseFinal: true,
|
||||
stagePhaseFinalTie: true,
|
||||
}
|
||||
|
||||
func normalizeStagePhase(raw string) (string, bool) {
|
||||
phase := strings.ToLower(strings.TrimSpace(raw))
|
||||
if !allowedStagePhases[phase] {
|
||||
return "", false
|
||||
}
|
||||
return phase, true
|
||||
}
|
||||
|
||||
func normalizeStageGroup(phase string, raw string) (string, bool) {
|
||||
group := strings.TrimSpace(raw)
|
||||
switch phase {
|
||||
case stagePhasePreliminary:
|
||||
if strings.EqualFold(group, positionBoardUnassigned) {
|
||||
return positionBoardUnassigned, true
|
||||
}
|
||||
group = normalizePreliminaryGroupKey(group)
|
||||
if group == "" {
|
||||
return "", false
|
||||
}
|
||||
return group, true
|
||||
case stagePhaseFinal:
|
||||
parsed, err := strconv.Atoi(group)
|
||||
if err != nil || (parsed != 1 && parsed != 2) {
|
||||
return "", false
|
||||
}
|
||||
return strconv.Itoa(parsed), true
|
||||
case stagePhasePrelimTie, stagePhaseFinalTie:
|
||||
parsed, err := strconv.Atoi(group)
|
||||
if err != nil || parsed <= 0 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.Itoa(parsed), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeStageRound(phase string, round int) int {
|
||||
switch phase {
|
||||
case stagePhasePreliminary:
|
||||
if round < 1 {
|
||||
return 1
|
||||
}
|
||||
if round > 3 {
|
||||
return 3
|
||||
}
|
||||
return round
|
||||
case stagePhaseFinal:
|
||||
if round < 1 {
|
||||
return 1
|
||||
}
|
||||
if round > 2 {
|
||||
return 2
|
||||
}
|
||||
return round
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTimestamp(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if parsed, err := time.Parse(time.RFC3339Nano, trimmed); err == nil {
|
||||
return parsed.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if parsed, err := time.Parse("2006-01-02 15:04:05", trimmed); err == nil {
|
||||
return parsed.UTC().Format(time.RFC3339)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func scanStageSession(scanner interface{ Scan(dest ...any) error }) (*StageSessionState, error) {
|
||||
var item StageSessionState
|
||||
var ended sql.NullString
|
||||
if err := scanner.Scan(&item.ID, &item.Phase, &item.GroupKey, &item.Round, &item.StartedAt, &ended); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Phase = strings.ToLower(strings.TrimSpace(item.Phase))
|
||||
item.GroupKey = strings.TrimSpace(item.GroupKey)
|
||||
item.StartedAt = normalizeTimestamp(item.StartedAt)
|
||||
if ended.Valid {
|
||||
item.EndedAt = normalizeTimestamp(ended.String)
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (a *App) readCurrentStage() (*StageSessionState, error) {
|
||||
row := a.db.QueryRow(`
|
||||
SELECT id, phase, group_key, round, started_at, ended_at
|
||||
FROM stage_sessions
|
||||
WHERE ended_at IS NULL
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`)
|
||||
item, err := scanStageSession(row)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read current stage: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (a *App) readLastStage() (*StageSessionState, error) {
|
||||
row := a.db.QueryRow(`
|
||||
SELECT id, phase, group_key, round, started_at, ended_at
|
||||
FROM stage_sessions
|
||||
ORDER BY started_at DESC, id DESC
|
||||
LIMIT 1
|
||||
`)
|
||||
item, err := scanStageSession(row)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("read last stage: %w", err)
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (a *App) readStageHistory() ([]StageSessionState, error) {
|
||||
rows, err := a.db.Query(`
|
||||
SELECT id, phase, group_key, round, started_at, ended_at
|
||||
FROM stage_sessions
|
||||
ORDER BY started_at DESC, id DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query stage history: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]StageSessionState, 0)
|
||||
for rows.Next() {
|
||||
item, scanErr := scanStageSession(rows)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("scan stage history: %w", scanErr)
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate stage history: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
234
backend/state.go
234
backend/state.go
@@ -8,6 +8,21 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
positionBoardPreliminary = "preliminary"
|
||||
positionBoardFinal = "final"
|
||||
positionBoardPrelimTie = "prelim_tiebreak"
|
||||
positionBoardFinalTie = "final_tiebreak"
|
||||
positionBoardUnassigned = "UNASSIGNED"
|
||||
)
|
||||
|
||||
var allowedPositionBoards = map[string]bool{
|
||||
positionBoardPreliminary: true,
|
||||
positionBoardFinal: true,
|
||||
positionBoardPrelimTie: true,
|
||||
positionBoardFinalTie: true,
|
||||
}
|
||||
|
||||
func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
|
||||
players, err := a.readPlayers()
|
||||
if err != nil {
|
||||
@@ -38,6 +53,18 @@ func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
|
||||
}
|
||||
|
||||
derived := computeDerived(players, scoreMap)
|
||||
positionBoards, err := a.readPositionBoards(players, derived)
|
||||
if err != nil {
|
||||
return StateResponse{}, err
|
||||
}
|
||||
currentStage, err := a.readCurrentStage()
|
||||
if err != nil {
|
||||
return StateResponse{}, err
|
||||
}
|
||||
lastStage, err := a.readLastStage()
|
||||
if err != nil {
|
||||
return StateResponse{}, err
|
||||
}
|
||||
|
||||
response := StateResponse{
|
||||
Competition: CompetitionMeta{
|
||||
@@ -46,8 +73,11 @@ func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
|
||||
},
|
||||
Players: players,
|
||||
Scores: scoreMapToJSON(scoreMap),
|
||||
PositionBoards: positionBoardMapToJSON(positionBoards),
|
||||
Settings: settings,
|
||||
Derived: derived,
|
||||
CurrentStage: currentStage,
|
||||
LastStage: lastStage,
|
||||
ServerTime: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if includeScoreProofs {
|
||||
@@ -133,6 +163,210 @@ func scoreProofMapToJSON(proofMap map[string]map[int]string) map[string]map[stri
|
||||
return out
|
||||
}
|
||||
|
||||
func positionBoardMapToJSON(positionMap map[string]map[int]PositionSlot) map[string]map[string]PositionSlot {
|
||||
out := map[string]map[string]PositionSlot{}
|
||||
for board := range allowedPositionBoards {
|
||||
out[board] = map[string]PositionSlot{}
|
||||
}
|
||||
for board, boardMap := range positionMap {
|
||||
if _, ok := out[board]; !ok {
|
||||
out[board] = map[string]PositionSlot{}
|
||||
}
|
||||
for playerID, slot := range boardMap {
|
||||
out[board][strconv.Itoa(playerID)] = slot
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) readPositionBoards(players []Player, derived DerivedState) (map[string]map[int]PositionSlot, error) {
|
||||
defaults := defaultPositionBoards(players, derived)
|
||||
merged := map[string]map[int]PositionSlot{}
|
||||
for board, boardMap := range defaults {
|
||||
merged[board] = map[int]PositionSlot{}
|
||||
for playerID, slot := range boardMap {
|
||||
merged[board][playerID] = slot
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := a.db.Query(`SELECT board, player_id, group_key, position FROM player_positions`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query player positions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var boardRaw string
|
||||
var playerID int
|
||||
var groupKey string
|
||||
var position int
|
||||
if err := rows.Scan(&boardRaw, &playerID, &groupKey, &position); err != nil {
|
||||
return nil, fmt.Errorf("scan player position: %w", err)
|
||||
}
|
||||
board, ok := normalizePositionBoard(boardRaw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
boardMap := merged[board]
|
||||
if boardMap == nil {
|
||||
continue
|
||||
}
|
||||
defaultSlot, exists := boardMap[playerID]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
pos := normalizePositionValue(position, defaultSlot.Position)
|
||||
group := normalizePositionGroupKey(board, groupKey, defaultSlot.GroupKey)
|
||||
boardMap[playerID] = PositionSlot{
|
||||
GroupKey: group,
|
||||
Position: pos,
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate player positions: %w", err)
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func defaultPositionBoards(players []Player, derived DerivedState) map[string]map[int]PositionSlot {
|
||||
out := map[string]map[int]PositionSlot{
|
||||
positionBoardPreliminary: {},
|
||||
positionBoardFinal: {},
|
||||
positionBoardPrelimTie: {},
|
||||
positionBoardFinalTie: {},
|
||||
}
|
||||
|
||||
preRows := make([]Player, 0, len(players))
|
||||
preRows = append(preRows, players...)
|
||||
sort.SliceStable(preRows, func(i, j int) bool {
|
||||
ga := normalizePreliminaryGroupKey(preRows[i].GroupCode)
|
||||
gb := normalizePreliminaryGroupKey(preRows[j].GroupCode)
|
||||
if ga != gb {
|
||||
return ga < gb
|
||||
}
|
||||
return preRows[i].ID < preRows[j].ID
|
||||
})
|
||||
preGroupCounts := map[string]int{}
|
||||
for _, player := range preRows {
|
||||
group := normalizePreliminaryGroupKey(player.GroupCode)
|
||||
preGroupCounts[group]++
|
||||
out[positionBoardPreliminary][player.ID] = PositionSlot{
|
||||
GroupKey: group,
|
||||
Position: preGroupCounts[group],
|
||||
}
|
||||
}
|
||||
|
||||
finalGroupCounts := map[string]int{}
|
||||
for _, row := range derived.FinalGroups.Group1 {
|
||||
group := "1"
|
||||
finalGroupCounts[group]++
|
||||
out[positionBoardFinal][row.PlayerID] = PositionSlot{GroupKey: group, Position: finalGroupCounts[group]}
|
||||
}
|
||||
for _, row := range derived.FinalGroups.Group2 {
|
||||
group := "2"
|
||||
finalGroupCounts[group]++
|
||||
out[positionBoardFinal][row.PlayerID] = PositionSlot{GroupKey: group, Position: finalGroupCounts[group]}
|
||||
}
|
||||
|
||||
preRowsByID := map[int]RankingRow{}
|
||||
for _, row := range derived.PreliminaryRanking.Rows {
|
||||
preRowsByID[row.PlayerID] = row
|
||||
}
|
||||
preTieRows := make([]RankingRow, 0, len(derived.PreliminaryRanking.TieBreak.PlayerIDs))
|
||||
for _, id := range derived.PreliminaryRanking.TieBreak.PlayerIDs {
|
||||
row, ok := preRowsByID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
preTieRows = append(preTieRows, row)
|
||||
}
|
||||
sort.SliceStable(preTieRows, func(i, j int) bool {
|
||||
if preTieRows[i].TieBreak != preTieRows[j].TieBreak {
|
||||
return preTieRows[i].TieBreak > preTieRows[j].TieBreak
|
||||
}
|
||||
if preTieRows[i].Score != preTieRows[j].Score {
|
||||
return preTieRows[i].Score > preTieRows[j].Score
|
||||
}
|
||||
return preTieRows[i].PlayerID < preTieRows[j].PlayerID
|
||||
})
|
||||
for idx, row := range preTieRows {
|
||||
groupKey := strconv.Itoa((idx / 6) + 1)
|
||||
position := (idx % 6) + 1
|
||||
out[positionBoardPrelimTie][row.PlayerID] = PositionSlot{GroupKey: groupKey, Position: position}
|
||||
}
|
||||
|
||||
finalRowsByID := map[int]RankingRow{}
|
||||
for _, row := range derived.FinalRanking.Rows {
|
||||
finalRowsByID[row.PlayerID] = row
|
||||
}
|
||||
finalTieRows := make([]RankingRow, 0, len(derived.FinalRanking.TieBreak.PlayerIDs))
|
||||
for _, id := range derived.FinalRanking.TieBreak.PlayerIDs {
|
||||
row, ok := finalRowsByID[id]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
finalTieRows = append(finalTieRows, row)
|
||||
}
|
||||
sort.SliceStable(finalTieRows, func(i, j int) bool {
|
||||
if finalTieRows[i].TieBreak != finalTieRows[j].TieBreak {
|
||||
return finalTieRows[i].TieBreak > finalTieRows[j].TieBreak
|
||||
}
|
||||
if finalTieRows[i].Score != finalTieRows[j].Score {
|
||||
return finalTieRows[i].Score > finalTieRows[j].Score
|
||||
}
|
||||
return finalTieRows[i].PlayerID < finalTieRows[j].PlayerID
|
||||
})
|
||||
for idx, row := range finalTieRows {
|
||||
groupKey := strconv.Itoa((idx / 6) + 1)
|
||||
position := (idx % 6) + 1
|
||||
out[positionBoardFinalTie][row.PlayerID] = PositionSlot{GroupKey: groupKey, Position: position}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizePositionBoard(board string) (string, bool) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(board))
|
||||
if !allowedPositionBoards[normalized] {
|
||||
return "", false
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func normalizePreliminaryGroupKey(groupCode string) string {
|
||||
normalized := strings.ToUpper(strings.TrimSpace(groupCode))
|
||||
if normalized == "" {
|
||||
return positionBoardUnassigned
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizePositionGroupKey(board, groupKey, fallback string) string {
|
||||
if board == positionBoardPreliminary {
|
||||
normalized := normalizePreliminaryGroupKey(groupKey)
|
||||
if normalized == "" {
|
||||
return fallback
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
normalized := strings.TrimSpace(groupKey)
|
||||
if normalized == "" {
|
||||
return fallback
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizePositionValue(position int, fallback int) int {
|
||||
if position > 0 {
|
||||
return position
|
||||
}
|
||||
if fallback > 0 {
|
||||
return fallback
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (a *App) ensureScoreRows(players []Player) error {
|
||||
if len(players) == 0 {
|
||||
return nil
|
||||
|
||||
BIN
frontend/public/arture.png
Normal file
BIN
frontend/public/arture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 53 KiB |
BIN
frontend/public/logo_white.png
Normal file
BIN
frontend/public/logo_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="app-shell" :class="'lang-' + language">
|
||||
<div class="app-shell" :class="['lang-' + language, 'mode-' + mode]">
|
||||
<input ref="imageInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onImageSelected" />
|
||||
<input ref="scoreProofInput" type="file" class="hidden-file-input" accept="image/*" capture="environment" @change="onScoreProofSelected" />
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
v-else-if="mode === 'live'"
|
||||
:t="t"
|
||||
:live-settings="liveSettings"
|
||||
:position-boards="positionBoards"
|
||||
:live-group-code="liveMonitorGroupCode"
|
||||
:live-group-members="liveMonitorMembers"
|
||||
:prelim-tie-rows="prelimTieRows"
|
||||
@@ -78,6 +79,7 @@
|
||||
:finalists="finalists"
|
||||
:live-final-group="liveMonitorFinalGroup"
|
||||
:live-final-rows="liveMonitorFinalRows"
|
||||
:final-overall-rows="finalRows"
|
||||
:final-tie-rows="finalTieRows"
|
||||
:podium-ordered="podiumOrdered"
|
||||
:player-image="playerImage"
|
||||
@@ -112,6 +114,12 @@
|
||||
:admin-tab="adminTab"
|
||||
:view-proof-in-view="canViewProofs"
|
||||
:live-settings="liveSettings"
|
||||
:stage-control="stageControl"
|
||||
:stage-options="stageOptions"
|
||||
:current-stage="currentStage"
|
||||
:last-stage="lastStage"
|
||||
:active-scoring-stage="currentScoringStageKey"
|
||||
:active-scoring-group="currentScoringGroupKey"
|
||||
:group-setup-input="groupSetupInput"
|
||||
:admin-group-cards="adminGroupCards"
|
||||
:new-player="newPlayer"
|
||||
@@ -131,6 +139,7 @@
|
||||
:final-total-score="finalTotalScore"
|
||||
:prelim-tie="prelimTie"
|
||||
:final-tie="finalTie"
|
||||
:position-boards="positionBoards"
|
||||
:score-filters="scoreFilters"
|
||||
:display-name="displayName"
|
||||
:secondary-name="secondaryName"
|
||||
@@ -149,6 +158,10 @@
|
||||
@logout="adminLogout"
|
||||
@toggle-view-proof="updateViewProofSetting"
|
||||
@update-live-setting="updateLiveModeSetting"
|
||||
@update-stage-control="updateStageControl"
|
||||
@start-stage="startCurrentStage"
|
||||
@end-stage="endCurrentStage"
|
||||
@navigate-current-stage="navigateToCurrentStage"
|
||||
@change-admin-tab="adminTab = $event"
|
||||
@update:group-setup-input="groupSetupInput = $event"
|
||||
@save-group-setup="saveGroupSetup"
|
||||
@@ -165,6 +178,7 @@
|
||||
@remove-player-image="removePlayerImage"
|
||||
@delete-player="deletePlayer"
|
||||
@request-reset-stage="openResetStageModal"
|
||||
@save-position-board="savePositionBoard"
|
||||
@update-score-filter="updateScoreFilter"
|
||||
/>
|
||||
</template>
|
||||
@@ -219,6 +233,7 @@ const FALLBACK_SYNC_MS = 15000
|
||||
const STREAM_RETRY_MS = 1500
|
||||
const PRELIM_ROUND_STAGES = ['prelim_r1', 'prelim_r2', 'prelim_r3']
|
||||
const FINAL_ROUND_STAGES = ['final_r1', 'final_r2']
|
||||
const POSITION_BOARDS = ['preliminary', 'final', 'prelim_tiebreak', 'final_tiebreak']
|
||||
|
||||
const loading = ref(true)
|
||||
const mode = ref('live')
|
||||
@@ -247,6 +262,11 @@ const scoreFilters = reactive({
|
||||
prelim_tiebreak: '',
|
||||
final_tiebreak: '',
|
||||
})
|
||||
const stageControl = reactive({
|
||||
phase: 'preliminary',
|
||||
group: '',
|
||||
round: 1,
|
||||
})
|
||||
|
||||
const liveGroupIndex = ref(0)
|
||||
const liveMode = ref('rotate')
|
||||
@@ -298,6 +318,14 @@ const finalRows = computed(() => state.value.derived?.finalRanking?.rows || [])
|
||||
const podiumRows = computed(() => state.value.derived?.podium || [])
|
||||
const prelimTie = computed(() => state.value.derived?.preliminaryRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] })
|
||||
const finalTie = computed(() => state.value.derived?.finalRanking?.tieBreak || { required: false, resolved: true, slots: 0, playerIds: [] })
|
||||
const positionBoards = computed(() => {
|
||||
const raw = state.value.positionBoards || {}
|
||||
const out = {}
|
||||
for (const board of POSITION_BOARDS) {
|
||||
out[board] = raw[board] || {}
|
||||
}
|
||||
return out
|
||||
})
|
||||
const canViewProofs = computed(() => Boolean(state.value.settings?.viewProofInView))
|
||||
const liveSettings = computed(() => {
|
||||
const raw = state.value.settings?.liveMode || {}
|
||||
@@ -320,6 +348,10 @@ const liveSettings = computed(() => {
|
||||
rotationPlayerCount: Number(raw.rotationPlayerCount) > 0 ? Number(raw.rotationPlayerCount) : 12,
|
||||
}
|
||||
})
|
||||
const currentStage = computed(() => state.value.current_stage || null)
|
||||
const lastStage = computed(() => state.value.last_stage || null)
|
||||
const currentScoringStageKey = computed(() => mapStageSessionToScoreStage(currentStage.value))
|
||||
const currentScoringGroupKey = computed(() => mapStageSessionToScoreGroup(currentStage.value))
|
||||
|
||||
const viewTabs = computed(() => [
|
||||
{ id: 'groups', label: t('tabs.groups') },
|
||||
@@ -409,7 +441,8 @@ const liveGroupCode = computed(() => {
|
||||
const liveMembers = computed(() => {
|
||||
const code = normalizedGroupCode(liveGroupCode.value)
|
||||
if (!code) return []
|
||||
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
||||
const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
||||
return sortRowsByPreliminaryPosition(rows)
|
||||
})
|
||||
|
||||
const liveMonitorGroupRotationCodes = computed(() => activeGroupCodes.value)
|
||||
@@ -424,7 +457,8 @@ const liveMonitorGroupCode = computed(() => {
|
||||
const liveMonitorMembers = computed(() => {
|
||||
const code = normalizedGroupCode(liveMonitorGroupCode.value)
|
||||
if (!code) return []
|
||||
return playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
||||
const rows = playersSorted.value.filter((player) => normalizedGroupCode(player.groupCode) === code)
|
||||
return sortRowsByPreliminaryPosition(rows)
|
||||
})
|
||||
const liveMonitorFinalGroup = computed(() => {
|
||||
if (liveSettings.value.finalDisplayMode === 'fixed') {
|
||||
@@ -432,17 +466,32 @@ const liveMonitorFinalGroup = computed(() => {
|
||||
}
|
||||
return liveMonitorFinalIndex.value % 2 === 0 ? 1 : 2
|
||||
})
|
||||
const liveMonitorFinalRows = computed(() => (liveMonitorFinalGroup.value === 2 ? finalGroup2.value : finalGroup1.value))
|
||||
const finalBoardGroupedRows = computed(() => {
|
||||
const sorted = sortRowsByBoardPosition('final', adminFinalRows.value)
|
||||
const grouped = {}
|
||||
for (const row of sorted) {
|
||||
const key = positionSlotFor('final', row.playerId)?.groupKey || String(row.finalGroup || 1)
|
||||
if (!grouped[key]) grouped[key] = []
|
||||
grouped[key].push(row)
|
||||
}
|
||||
return grouped
|
||||
})
|
||||
const liveMonitorFinalRows = computed(() => {
|
||||
const key = String(liveMonitorFinalGroup.value === 2 ? 2 : 1)
|
||||
return finalBoardGroupedRows.value[key] || []
|
||||
})
|
||||
const podiumOrdered = computed(() => [podiumRows.value[1] || null, podiumRows.value[0] || null, podiumRows.value[2] || null])
|
||||
|
||||
const prelimTieRows = computed(() => {
|
||||
const ids = new Set(prelimTie.value.playerIds || [])
|
||||
return preliminaryRows.value.filter((row) => ids.has(row.playerId))
|
||||
const rows = preliminaryRows.value.filter((row) => ids.has(row.playerId))
|
||||
return sortRowsByBoardPosition('prelim_tiebreak', rows)
|
||||
})
|
||||
|
||||
const finalTieRows = computed(() => {
|
||||
const ids = new Set(finalTie.value.playerIds || [])
|
||||
return finalRows.value.filter((row) => ids.has(row.playerId))
|
||||
const rows = finalRows.value.filter((row) => ids.has(row.playerId))
|
||||
return sortRowsByBoardPosition('final_tiebreak', rows)
|
||||
})
|
||||
|
||||
const playerByID = computed(() => {
|
||||
@@ -483,8 +532,7 @@ const adminPreliminaryRows = computed(() => {
|
||||
rank: derived?.rank ?? 0,
|
||||
}
|
||||
})
|
||||
rows.sort(compareRowsByGroupThenId)
|
||||
return rows
|
||||
return sortRowsByBoardPosition('preliminary', rows)
|
||||
})
|
||||
|
||||
const adminPrelimTieRows = computed(() => {
|
||||
@@ -504,8 +552,7 @@ const adminPrelimTieRows = computed(() => {
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
rows.sort(compareRowsByScoreGroupThenId)
|
||||
return rows
|
||||
return sortRowsByBoardPosition('prelim_tiebreak', rows)
|
||||
})
|
||||
|
||||
const adminFinalRows = computed(() => {
|
||||
@@ -525,17 +572,7 @@ const adminFinalRows = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const groupA = Number(a.finalGroup || 0)
|
||||
const groupB = Number(b.finalGroup || 0)
|
||||
if (groupA !== groupB) return groupA - groupB
|
||||
const seedA = Number(a.seed || 0)
|
||||
const seedB = Number(b.seed || 0)
|
||||
if (seedA !== seedB) return seedA - seedB
|
||||
return a.playerId - b.playerId
|
||||
})
|
||||
|
||||
return rows
|
||||
return sortRowsByBoardPosition('final', rows)
|
||||
})
|
||||
|
||||
const adminFinalTieRows = computed(() => {
|
||||
@@ -558,8 +595,29 @@ const adminFinalTieRows = computed(() => {
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
rows.sort(compareRowsByScoreGroupThenId)
|
||||
return rows
|
||||
return sortRowsByBoardPosition('final_tiebreak', rows)
|
||||
})
|
||||
|
||||
const stageOptions = computed(() => {
|
||||
const preliminaryGroups = collectUniqueStrings([
|
||||
...assignableGroups.value,
|
||||
...activeGroupCodes.value,
|
||||
])
|
||||
const finalGroups = collectUniqueStrings([
|
||||
...Object.keys(finalBoardGroupedRows.value || {}),
|
||||
'1',
|
||||
'2',
|
||||
]).filter((value) => value === '1' || value === '2')
|
||||
|
||||
const prelimTieGroups = collectUniqueStrings((adminPrelimTieRows.value || []).map((row) => String(row._boardGroupKey || row.scoreGroup || '1')))
|
||||
const finalTieGroups = collectUniqueStrings((adminFinalTieRows.value || []).map((row) => String(row._boardGroupKey || row.scoreGroup || '1')))
|
||||
|
||||
return {
|
||||
preliminary: preliminaryGroups,
|
||||
prelim_tiebreak: prelimTieGroups.length > 0 ? prelimTieGroups : ['1'],
|
||||
final: finalGroups.length > 0 ? finalGroups : ['1', '2'],
|
||||
final_tiebreak: finalTieGroups.length > 0 ? finalTieGroups : ['1'],
|
||||
}
|
||||
})
|
||||
|
||||
watch(language, (value) => {
|
||||
@@ -643,6 +701,14 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => `${stageControl.phase}|${JSON.stringify(stageOptions.value)}`,
|
||||
() => {
|
||||
applyStageControlDefaults()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
onFullscreenChange()
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
@@ -728,7 +794,7 @@ async function toggleFullscreen() {
|
||||
|
||||
function normalizeLiveActiveView(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase()
|
||||
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'podium'])
|
||||
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'final_overall', 'podium'])
|
||||
return allowed.has(normalized) ? normalized : 'group_live'
|
||||
}
|
||||
|
||||
@@ -788,6 +854,116 @@ function compareRowsByScoreGroupThenId(a, b) {
|
||||
return compareRowsByGroupThenId(a, b)
|
||||
}
|
||||
|
||||
function positionSlotFor(board, playerId) {
|
||||
const boardMap = positionBoards.value?.[board] || {}
|
||||
return boardMap[String(playerId)] || boardMap[playerId] || null
|
||||
}
|
||||
|
||||
function normalizePositionGroup(board, raw, fallback = '') {
|
||||
if (board === 'preliminary') {
|
||||
const normalized = normalizedGroupCode(raw || '')
|
||||
if (normalized) return normalized
|
||||
if (String(raw || '').trim().toUpperCase() === 'UNASSIGNED') return 'UNASSIGNED'
|
||||
return fallback || 'UNASSIGNED'
|
||||
}
|
||||
const parsed = Number.parseInt(String(raw || ''), 10)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return String(parsed)
|
||||
return fallback || '1'
|
||||
}
|
||||
|
||||
function normalizePositionValue(raw, fallback = 1) {
|
||||
const parsed = Number(raw)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed)
|
||||
return fallback
|
||||
}
|
||||
|
||||
function positionGroupSortValue(board, groupKey) {
|
||||
if (board === 'preliminary') {
|
||||
if (groupKey === 'UNASSIGNED') return Number.MAX_SAFE_INTEGER - 1
|
||||
const idx = assignableGroups.value.indexOf(groupKey)
|
||||
if (idx >= 0) return idx
|
||||
return Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
return Number.parseInt(String(groupKey || '0'), 10) || Number.MAX_SAFE_INTEGER
|
||||
}
|
||||
|
||||
function sortRowsByPreliminaryPosition(rows) {
|
||||
return sortRowsByBoardPosition('preliminary', rows)
|
||||
}
|
||||
|
||||
function sortRowsByBoardPosition(board, rows) {
|
||||
const list = Array.isArray(rows) ? rows.slice() : []
|
||||
if (list.length <= 1) return list
|
||||
|
||||
const fallbackRows = list.slice()
|
||||
if (board === 'preliminary') {
|
||||
fallbackRows.sort(compareRowsByGroupThenId)
|
||||
} else if (board === 'final') {
|
||||
fallbackRows.sort((a, b) => {
|
||||
const groupA = Number(a.finalGroup || 0)
|
||||
const groupB = Number(b.finalGroup || 0)
|
||||
if (groupA !== groupB) return groupA - groupB
|
||||
const seedA = Number(a.seed || 0)
|
||||
const seedB = Number(b.seed || 0)
|
||||
if (seedA !== seedB) return seedA - seedB
|
||||
return Number(a.playerId || a.id || 0) - Number(b.playerId || b.id || 0)
|
||||
})
|
||||
} else {
|
||||
fallbackRows.sort(compareRowsByScoreGroupThenId)
|
||||
}
|
||||
|
||||
const fallbackMap = new Map()
|
||||
const fallbackCountByGroup = new Map()
|
||||
for (let index = 0; index < fallbackRows.length; index += 1) {
|
||||
const row = fallbackRows[index]
|
||||
const playerId = Number(row.playerId || row.id || 0)
|
||||
let fallbackGroup = '1'
|
||||
if (board === 'preliminary') {
|
||||
fallbackGroup = normalizePositionGroup(board, row.groupCode, 'UNASSIGNED')
|
||||
} else if (board === 'final') {
|
||||
fallbackGroup = normalizePositionGroup(board, row.finalGroup, '1')
|
||||
} else if (board === 'prelim_tiebreak' || board === 'final_tiebreak') {
|
||||
const scoreGroup = Number.parseInt(String(row.scoreGroup || ''), 10)
|
||||
const finalGroup = Number.parseInt(String(row.finalGroup || ''), 10)
|
||||
if (Number.isFinite(scoreGroup) && scoreGroup > 0) {
|
||||
fallbackGroup = String(scoreGroup)
|
||||
} else if (Number.isFinite(finalGroup) && finalGroup > 0) {
|
||||
fallbackGroup = String(finalGroup)
|
||||
} else {
|
||||
fallbackGroup = '1'
|
||||
}
|
||||
} else {
|
||||
fallbackGroup = '1'
|
||||
}
|
||||
const nextPos = (fallbackCountByGroup.get(fallbackGroup) || 0) + 1
|
||||
fallbackCountByGroup.set(fallbackGroup, nextPos)
|
||||
fallbackMap.set(playerId, { groupKey: fallbackGroup, position: nextPos, index })
|
||||
}
|
||||
|
||||
return list
|
||||
.map((row) => {
|
||||
const playerId = Number(row.playerId || row.id || 0)
|
||||
const fallback = fallbackMap.get(playerId) || { groupKey: '1', position: Number.MAX_SAFE_INTEGER, index: Number.MAX_SAFE_INTEGER }
|
||||
const slot = positionSlotFor(board, playerId)
|
||||
const groupKey = normalizePositionGroup(board, slot?.groupKey, fallback.groupKey)
|
||||
const position = normalizePositionValue(slot?.position, fallback.position)
|
||||
return { row, groupKey, position, fallbackIndex: fallback.index }
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const groupOrderA = positionGroupSortValue(board, a.groupKey)
|
||||
const groupOrderB = positionGroupSortValue(board, b.groupKey)
|
||||
if (groupOrderA !== groupOrderB) return groupOrderA - groupOrderB
|
||||
if (a.groupKey !== b.groupKey) return String(a.groupKey).localeCompare(String(b.groupKey))
|
||||
if (a.position !== b.position) return a.position - b.position
|
||||
return a.fallbackIndex - b.fallbackIndex
|
||||
})
|
||||
.map((entry) => ({
|
||||
...entry.row,
|
||||
_boardGroupKey: entry.groupKey,
|
||||
_boardPosition: entry.position,
|
||||
}))
|
||||
}
|
||||
|
||||
function saveGroupSetup() {
|
||||
const parsed = parseGroupList(groupSetupInput.value)
|
||||
primaryGroups.value = parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS
|
||||
@@ -829,6 +1005,22 @@ function updateScoreFilter({ stage, value }) {
|
||||
scoreFilters[stage] = value
|
||||
}
|
||||
|
||||
async function savePositionBoard(payload) {
|
||||
const board = String(payload?.board || '').trim().toLowerCase()
|
||||
if (!POSITION_BOARDS.includes(board)) return
|
||||
const slots = Array.isArray(payload?.slots) ? payload.slots : []
|
||||
|
||||
try {
|
||||
state.value = await api(`/admin/positions/${board}`, {
|
||||
method: 'PUT',
|
||||
admin: true,
|
||||
body: { slots },
|
||||
})
|
||||
} catch (error) {
|
||||
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function autoGroupEvenly() {
|
||||
if (primaryGroups.value.length === 0) {
|
||||
showToast(t('messages.noGroupsConfigured'), 'error')
|
||||
@@ -990,6 +1182,139 @@ async function updateLiveModeSetting(patch) {
|
||||
await updateAdminSettings({ liveMode: patch })
|
||||
}
|
||||
|
||||
function collectUniqueStrings(values) {
|
||||
const set = new Set()
|
||||
for (const raw of values || []) {
|
||||
const value = String(raw || '').trim()
|
||||
if (!value) continue
|
||||
set.add(value)
|
||||
}
|
||||
return [...set].sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
|
||||
}
|
||||
|
||||
function stageRoundMax(phase) {
|
||||
if (phase === 'preliminary') return 3
|
||||
if (phase === 'final') return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
function mapStageSessionToScoreStage(stage) {
|
||||
if (!stage || typeof stage !== 'object') return ''
|
||||
const phase = String(stage.phase || '').trim().toLowerCase()
|
||||
const round = Number(stage.round || 1)
|
||||
if (phase === 'preliminary') return `prelim_r${Math.min(3, Math.max(1, Math.floor(round || 1)))}`
|
||||
if (phase === 'final') return `final_r${Math.min(2, Math.max(1, Math.floor(round || 1)))}`
|
||||
if (phase === 'prelim_tiebreak') return 'prelim_tiebreak'
|
||||
if (phase === 'final_tiebreak') return 'final_tiebreak'
|
||||
return ''
|
||||
}
|
||||
|
||||
function mapStageSessionToScoreGroup(stage) {
|
||||
if (!stage || typeof stage !== 'object') return ''
|
||||
const phase = String(stage.phase || '').trim().toLowerCase()
|
||||
const rawGroup = String(stage.group || '').trim()
|
||||
if (!rawGroup) return ''
|
||||
if (phase === 'preliminary') {
|
||||
if (rawGroup.toUpperCase() === 'UNASSIGNED') return 'UNASSIGNED'
|
||||
return normalizedGroupCode(rawGroup)
|
||||
}
|
||||
const parsed = Number.parseInt(rawGroup, 10)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return String(parsed)
|
||||
return rawGroup
|
||||
}
|
||||
|
||||
function mapStagePhaseToAdminTab(phase) {
|
||||
const normalized = String(phase || '').trim().toLowerCase()
|
||||
if (normalized === 'preliminary') return 'preliminary'
|
||||
if (normalized === 'prelim_tiebreak') return 'prelimTie'
|
||||
if (normalized === 'final') return 'final'
|
||||
if (normalized === 'final_tiebreak') return 'finalTie'
|
||||
return ''
|
||||
}
|
||||
|
||||
function applyStageControlDefaults() {
|
||||
const phase = String(stageControl.phase || 'preliminary').trim().toLowerCase()
|
||||
if (!['preliminary', 'prelim_tiebreak', 'final', 'final_tiebreak'].includes(phase)) {
|
||||
stageControl.phase = 'preliminary'
|
||||
} else {
|
||||
stageControl.phase = phase
|
||||
}
|
||||
|
||||
const maxRound = stageRoundMax(stageControl.phase)
|
||||
const round = Number(stageControl.round || 1)
|
||||
stageControl.round = Math.min(maxRound, Math.max(1, Number.isFinite(round) ? Math.floor(round) : 1))
|
||||
|
||||
const options = stageOptions.value?.[stageControl.phase] || []
|
||||
if (options.length === 0) {
|
||||
stageControl.group = ''
|
||||
return
|
||||
}
|
||||
const current = String(stageControl.group || '').trim()
|
||||
if (!options.includes(current)) {
|
||||
stageControl.group = options[0]
|
||||
}
|
||||
}
|
||||
|
||||
function updateStageControl(patch) {
|
||||
if (!patch || typeof patch !== 'object') return
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'phase')) {
|
||||
stageControl.phase = String(patch.phase || 'preliminary').trim().toLowerCase()
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'group')) {
|
||||
stageControl.group = String(patch.group || '').trim()
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, 'round')) {
|
||||
stageControl.round = Number(patch.round || 1)
|
||||
}
|
||||
applyStageControlDefaults()
|
||||
}
|
||||
|
||||
async function startCurrentStage() {
|
||||
applyStageControlDefaults()
|
||||
if (!stageControl.group) {
|
||||
showToast(t('messages.noGroupForPhase'), 'error')
|
||||
return
|
||||
}
|
||||
try {
|
||||
state.value = await api('/admin/stage/start', {
|
||||
method: 'POST',
|
||||
admin: true,
|
||||
body: {
|
||||
phase: stageControl.phase,
|
||||
group: stageControl.group,
|
||||
round: stageControl.round,
|
||||
},
|
||||
})
|
||||
showToast(t('messages.saved'))
|
||||
} catch (error) {
|
||||
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function endCurrentStage() {
|
||||
try {
|
||||
state.value = await api('/admin/stage/end', {
|
||||
method: 'POST',
|
||||
admin: true,
|
||||
})
|
||||
showToast(t('messages.saved'))
|
||||
} catch (error) {
|
||||
showToast(`${t('messages.errorPrefix')}: ${error.message}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function navigateToCurrentStage() {
|
||||
const active = currentStage.value
|
||||
if (!active) {
|
||||
showToast(t('labels.noCurrentStage'), 'error')
|
||||
return
|
||||
}
|
||||
const nextTab = mapStagePhaseToAdminTab(active.phase)
|
||||
if (nextTab) {
|
||||
adminTab.value = nextTab
|
||||
}
|
||||
}
|
||||
|
||||
async function createPlayer() {
|
||||
if (!newPlayer.nameAr || !newPlayer.nameEn) {
|
||||
showToast(t('messages.mustProvideNames'), 'error')
|
||||
|
||||
@@ -59,7 +59,20 @@
|
||||
<h2>{{ t('liveMode') }}</h2>
|
||||
<p>{{ t('labels.liveModeVisibility') }}</p>
|
||||
</div>
|
||||
<LiveSettingsCard :t="t" :live-settings="liveSettings" :assignable-groups="assignableGroups" @update-live-setting="$emit('update-live-setting', $event)" />
|
||||
<LiveSettingsCard
|
||||
:t="t"
|
||||
:live-settings="liveSettings"
|
||||
:assignable-groups="assignableGroups"
|
||||
:stage-control="stageControl"
|
||||
:stage-options="stageOptions"
|
||||
:current-stage="currentStage"
|
||||
:last-stage="lastStage"
|
||||
@update-live-setting="$emit('update-live-setting', $event)"
|
||||
@update-stage-control="$emit('update-stage-control', $event)"
|
||||
@start-stage="$emit('start-stage')"
|
||||
@end-stage="$emit('end-stage')"
|
||||
@navigate-current-stage="$emit('navigate-current-stage')"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-show="adminTab === 'preliminary'" class="panel">
|
||||
@@ -81,6 +94,18 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PositionBoardEditor
|
||||
:t="t"
|
||||
board="preliminary"
|
||||
:rows="preliminaryScoreRows"
|
||||
:slots="positionBoards.preliminary"
|
||||
:player-image="playerImage"
|
||||
:display-name="displayName"
|
||||
:secondary-name="secondaryName"
|
||||
:available-groups="assignableGroups"
|
||||
@save="$emit('save-position-board', $event)"
|
||||
/>
|
||||
|
||||
<MultiRoundScoreEditor
|
||||
:t="t"
|
||||
:rows="preliminaryScoreRows"
|
||||
@@ -92,6 +117,10 @@
|
||||
:total-score="preliminaryTotalScore"
|
||||
:show-filter="false"
|
||||
:filter-text="scoreFilters.preliminary"
|
||||
:section-by-group="true"
|
||||
:overflow-limit="6"
|
||||
:highlight-stage="activeScoringStage"
|
||||
:highlight-group="activeScoringGroup"
|
||||
:show-group="true"
|
||||
:show-rank="false"
|
||||
:player-image="playerImage"
|
||||
@@ -128,12 +157,26 @@
|
||||
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'prelim_tiebreak')">{{ t('actions.resetScores') }}</button>
|
||||
</div>
|
||||
|
||||
<PositionBoardEditor
|
||||
:t="t"
|
||||
board="prelim_tiebreak"
|
||||
:rows="prelimTieScoreRows"
|
||||
:slots="positionBoards.prelim_tiebreak"
|
||||
:player-image="playerImage"
|
||||
:display-name="displayName"
|
||||
:secondary-name="secondaryName"
|
||||
:numeric-groups="true"
|
||||
@save="$emit('save-position-board', $event)"
|
||||
/>
|
||||
|
||||
<ScoreStageEditor
|
||||
:t="t"
|
||||
stage="prelim_tiebreak"
|
||||
color-mode="score"
|
||||
:rows="prelimTieScoreRows"
|
||||
:filter-text="scoreFilters.prelim_tiebreak"
|
||||
:section-by-group="true"
|
||||
:tie-break-section-mode="true"
|
||||
:show-score-before-input="true"
|
||||
:input-label="t('table.tieScore')"
|
||||
:player-image="playerImage"
|
||||
@@ -150,6 +193,8 @@
|
||||
:remove-score-proof="removeScoreProof"
|
||||
:open-proof-preview="openProofPreview"
|
||||
:request-score-advice="requestScoreAdvice"
|
||||
:highlight-stage="activeScoringStage"
|
||||
:highlight-group="activeScoringGroup"
|
||||
@update:filter="$emit('update-score-filter', { stage: 'prelim_tiebreak', value: $event })"
|
||||
/>
|
||||
</template>
|
||||
@@ -174,6 +219,18 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PositionBoardEditor
|
||||
:t="t"
|
||||
board="final"
|
||||
:rows="finalScoreRows"
|
||||
:slots="positionBoards.final"
|
||||
:player-image="playerImage"
|
||||
:display-name="displayName"
|
||||
:secondary-name="secondaryName"
|
||||
:numeric-groups="true"
|
||||
@save="$emit('save-position-board', $event)"
|
||||
/>
|
||||
|
||||
<MultiRoundScoreEditor
|
||||
:t="t"
|
||||
:rows="finalScoreRows"
|
||||
@@ -184,6 +241,10 @@
|
||||
:total-score="finalTotalScore"
|
||||
:show-filter="false"
|
||||
:filter-text="scoreFilters.final_common"
|
||||
:section-by-group="true"
|
||||
:overflow-limit="6"
|
||||
:highlight-stage="activeScoringStage"
|
||||
:highlight-group="activeScoringGroup"
|
||||
:show-group="true"
|
||||
:show-seed="true"
|
||||
:player-image="playerImage"
|
||||
@@ -215,12 +276,26 @@
|
||||
<button class="btn btn-outline" @click="$emit('request-reset-stage', 'final_tiebreak')">{{ t('actions.resetScores') }}</button>
|
||||
</div>
|
||||
|
||||
<PositionBoardEditor
|
||||
:t="t"
|
||||
board="final_tiebreak"
|
||||
:rows="finalTieScoreRows"
|
||||
:slots="positionBoards.final_tiebreak"
|
||||
:player-image="playerImage"
|
||||
:display-name="displayName"
|
||||
:secondary-name="secondaryName"
|
||||
:numeric-groups="true"
|
||||
@save="$emit('save-position-board', $event)"
|
||||
/>
|
||||
|
||||
<ScoreStageEditor
|
||||
:t="t"
|
||||
stage="final_tiebreak"
|
||||
color-mode="score"
|
||||
:rows="finalTieScoreRows"
|
||||
:filter-text="scoreFilters.final_tiebreak"
|
||||
:section-by-group="true"
|
||||
:tie-break-section-mode="true"
|
||||
:show-score-before-input="true"
|
||||
:input-label="t('table.tieScore')"
|
||||
:player-image="playerImage"
|
||||
@@ -237,6 +312,8 @@
|
||||
:remove-score-proof="removeScoreProof"
|
||||
:open-proof-preview="openProofPreview"
|
||||
:request-score-advice="requestScoreAdvice"
|
||||
:highlight-stage="activeScoringStage"
|
||||
:highlight-group="activeScoringGroup"
|
||||
@update:filter="$emit('update-score-filter', { stage: 'final_tiebreak', value: $event })"
|
||||
/>
|
||||
</template>
|
||||
@@ -287,6 +364,7 @@ import PlayersManagementTab from './admin/PlayersManagementTab.vue'
|
||||
import ScoreStageEditor from './admin/ScoreStageEditor.vue'
|
||||
import LiveSettingsCard from './admin/LiveSettingsCard.vue'
|
||||
import MultiRoundScoreEditor from './admin/MultiRoundScoreEditor.vue'
|
||||
import PositionBoardEditor from './admin/PositionBoardEditor.vue'
|
||||
|
||||
defineProps({
|
||||
t: { type: Function, required: true },
|
||||
@@ -294,6 +372,12 @@ defineProps({
|
||||
adminTab: { type: String, required: true },
|
||||
viewProofInView: { type: Boolean, default: false },
|
||||
liveSettings: { type: Object, required: true },
|
||||
stageControl: { type: Object, required: true },
|
||||
stageOptions: { type: Object, required: true },
|
||||
currentStage: { type: Object, default: null },
|
||||
lastStage: { type: Object, default: null },
|
||||
activeScoringStage: { type: String, default: '' },
|
||||
activeScoringGroup: { type: String, default: '' },
|
||||
groupSetupInput: { type: String, default: '' },
|
||||
adminGroupCards: { type: Array, required: true },
|
||||
newPlayer: { type: Object, required: true },
|
||||
@@ -313,6 +397,7 @@ defineProps({
|
||||
finalTotalScore: { type: Function, required: true },
|
||||
prelimTie: { type: Object, required: true },
|
||||
finalTie: { type: Object, required: true },
|
||||
positionBoards: { type: Object, required: true },
|
||||
scoreFilters: { type: Object, required: true },
|
||||
displayName: { type: Function, required: true },
|
||||
secondaryName: { type: Function, required: true },
|
||||
@@ -334,6 +419,10 @@ defineEmits([
|
||||
'logout',
|
||||
'toggle-view-proof',
|
||||
'update-live-setting',
|
||||
'update-stage-control',
|
||||
'start-stage',
|
||||
'end-stage',
|
||||
'navigate-current-stage',
|
||||
'change-admin-tab',
|
||||
'update:group-setup-input',
|
||||
'save-group-setup',
|
||||
@@ -350,6 +439,7 @@ defineEmits([
|
||||
'remove-player-image',
|
||||
'delete-player',
|
||||
'request-reset-stage',
|
||||
'save-position-board',
|
||||
'update-score-filter',
|
||||
])
|
||||
</script>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
<div class="masthead-main">
|
||||
<div class="masthead-brand">
|
||||
<picture>
|
||||
<source srcset="/logo.svg" type="image/svg+xml" />
|
||||
<img class="masthead-logo" src="/logo.png" :alt="competitionTitle" />
|
||||
<source v-if="mode !== 'live'" srcset="/logo.svg" type="image/svg+xml" />
|
||||
<img class="masthead-logo" :src="mode === 'live' ? '/logo_white.png' : '/logo.png'" :alt="competitionTitle" />
|
||||
</picture>
|
||||
<p class="masthead-subtitle">{{ t('subtitle') }}</p>
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="control-box">
|
||||
<p class="control-label">{{ t('labels.mode') }}</p>
|
||||
<div class="language-switcher mode-switcher">
|
||||
<button class="lang-btn" :class="{ active: mode === 'view' }" @click="changeMode('view')">{{ t('viewMode') }}</button>
|
||||
<!-- <button class="lang-btn" :class="{ active: mode === 'view' }" @click="changeMode('view')">{{ t('viewMode') }}</button> -->
|
||||
<button class="lang-btn" :class="{ active: mode === 'live' }" @click="changeMode('live')">{{ t('liveMode') }}</button>
|
||||
<button class="lang-btn" :class="{ active: mode === 'admin' }" @click="changeMode('admin')">{{ t('adminMode') }}</button>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<table class="score-table live-score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('table.rank') }}</th>
|
||||
<th>{{ t('table.position') }}</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.round1') }}</th>
|
||||
<th>{{ t('table.round2') }}</th>
|
||||
@@ -40,8 +40,8 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id">
|
||||
<td class="mono rank">{{ entry.rank }}</td>
|
||||
<tr v-for="(entry, index) in rankedLiveGroupMembers" :key="'live-group-' + entry.player.id">
|
||||
<td class="mono rank">{{ index + 1 }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(entry.player)" :alt="entry.player.nameAr" class="competitor-image" />
|
||||
@@ -69,41 +69,42 @@
|
||||
<h3>{{ t('sections.prelimTieTitle') }}</h3>
|
||||
<div class="live-chip-row">
|
||||
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
||||
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
||||
<span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="live-swap" mode="out-in">
|
||||
<div :key="'prelim-tie-page-' + listPageIndex" class="live-swap-block">
|
||||
<article v-if="activePrelimTieGroup" :key="'live-pre-tie-group-' + activePrelimTieGroup.groupKey" class="live-tie-group-card">
|
||||
<h4 class="live-tie-group-title">{{ t('labels.group') }} {{ activePrelimTieGroup.groupKey }}</h4>
|
||||
<div class="table-wrap">
|
||||
<table class="score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{{ t('table.position') }}</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.tieScore') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in pagedPrelimTieRows" :key="'live-pre-tie-' + row.playerId">
|
||||
<td class="mono">{{ listPageStart + index + 1 }}</td>
|
||||
<tr v-for="entry in activePrelimTieGroup.rows" :key="'live-pre-tie-' + activePrelimTieGroup.groupKey + '-' + entry.row.playerId">
|
||||
<td class="mono">{{ entry.position }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
|
||||
<div>
|
||||
<p class="name-main">{{ displayName(row) }}</p>
|
||||
<p class="name-sub">{{ secondaryName(row) }}</p>
|
||||
<p class="name-main">{{ displayName(entry.row) }}</p>
|
||||
<p class="name-sub">{{ secondaryName(entry.row) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono strong">{{ row.tieBreak }}</td>
|
||||
<td class="mono strong">{{ entry.row.tieBreak }}</td>
|
||||
</tr>
|
||||
<tr v-if="prelimTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noPrelimTie') }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</Transition>
|
||||
<div v-if="groupedPrelimTieRows.length === 0" class="muted center">{{ t('messages.noPrelimTie') }}</div>
|
||||
</article>
|
||||
|
||||
<article v-else-if="activeView === 'prelim_overall'" class="live-screen-card wide">
|
||||
@@ -173,7 +174,7 @@
|
||||
<table class="score-table live-score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('table.rank') }}</th>
|
||||
<th>{{ t('table.position') }}</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.round1') }}</th>
|
||||
<th>{{ t('table.round2') }}</th>
|
||||
@@ -181,8 +182,8 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId">
|
||||
<td class="mono rank">{{ entry.rank }}</td>
|
||||
<tr v-for="(entry, index) in rankedLiveFinalRows" :key="'live-final-group-' + entry.row.playerId">
|
||||
<td class="mono rank">{{ index + 1 }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
|
||||
@@ -209,24 +210,67 @@
|
||||
<h3>{{ t('sections.finalTieTitle') }}</h3>
|
||||
<div class="live-chip-row">
|
||||
<span class="live-chip">{{ t('table.tieScore') }}</span>
|
||||
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
||||
<span v-if="tieGroupCountForActiveView > 1" class="live-chip">{{ currentTieGroupPageDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="live-swap" mode="out-in">
|
||||
<div :key="'final-tie-page-' + listPageIndex" class="live-swap-block">
|
||||
<article v-if="activeFinalTieGroup" :key="'live-final-tie-group-' + activeFinalTieGroup.groupKey" class="live-tie-group-card">
|
||||
<h4 class="live-tie-group-title">{{ t('labels.group') }} {{ activeFinalTieGroup.groupKey }}</h4>
|
||||
<div class="table-wrap">
|
||||
<table class="score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>{{ t('table.position') }}</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.tieScore') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in pagedFinalTieRows" :key="'live-final-tie-' + row.playerId">
|
||||
<td class="mono">{{ listPageStart + index + 1 }}</td>
|
||||
<tr v-for="entry in activeFinalTieGroup.rows" :key="'live-final-tie-' + activeFinalTieGroup.groupKey + '-' + entry.row.playerId">
|
||||
<td class="mono">{{ entry.position }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(entry.row)" :alt="entry.row.nameAr" class="competitor-image" />
|
||||
<div>
|
||||
<p class="name-main">{{ displayName(entry.row) }}</p>
|
||||
<p class="name-sub">{{ secondaryName(entry.row) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono strong">{{ entry.row.tieBreak }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</Transition>
|
||||
<div v-if="groupedFinalTieRows.length === 0" class="muted center">{{ t('messages.noFinalTie') }}</div>
|
||||
</article>
|
||||
|
||||
<article v-else-if="activeView === 'final_overall'" class="live-screen-card wide">
|
||||
<div class="live-screen-head">
|
||||
<h3>{{ t('sections.finalRanking') }}</h3>
|
||||
<div class="live-chip-row">
|
||||
<span v-if="listPageCount > 1" class="live-chip">{{ currentListPageDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="live-swap" mode="out-in">
|
||||
<div :key="'final-overall-page-' + listPageIndex" class="live-swap-block">
|
||||
<div class="table-wrap">
|
||||
<table class="score-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('table.rank') }}</th>
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th>{{ t('table.total') }}</th>
|
||||
<th>{{ t('table.tieScore') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in pagedFinalOverallRows" :key="'live-final-overall-' + row.playerId">
|
||||
<td class="mono rank">{{ row.rank }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||
@@ -236,9 +280,10 @@
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mono strong">{{ row.tieBreak }}</td>
|
||||
<td class="mono strong">{{ row.score }}</td>
|
||||
<td class="mono">{{ row.tieBreak }}</td>
|
||||
</tr>
|
||||
<tr v-if="finalTieRows.length === 0"><td colspan="3" class="muted center">{{ t('messages.noFinalTie') }}</td></tr>
|
||||
<tr v-if="finalOverallRows.length === 0"><td colspan="4" class="muted center">{{ t('labels.noFinalists') }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -291,20 +336,23 @@
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="live-mode-footer live-mode-fixed-footer">
|
||||
<footer class="live-mode-footer">
|
||||
|
||||
<div class="live-footer-slot left">
|
||||
<img src="/datwyler_logo.png" alt="Datwyler logo" class="live-footer-logo" />
|
||||
</div>
|
||||
<div class="live-footer-slot center">
|
||||
<img src="/arture.png" alt="Arture logo" class="live-footer-logo" />
|
||||
</div>
|
||||
<div class="live-footer-slot right">
|
||||
<img src="/simon_logo.png" alt="Simon logo" class="live-footer-logo" />
|
||||
</div>
|
||||
|
||||
<div class="live-footer-slot center">
|
||||
<img src="/arture_logo.png" alt="Arture logo" class="live-footer-logo" />
|
||||
</div>
|
||||
<div class="live-footer-slot right">
|
||||
<img src="/datwyler_logo.png" alt="Datwyler logo" class="live-footer-logo" />
|
||||
</div>
|
||||
|
||||
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -314,6 +362,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
const props = defineProps({
|
||||
t: { type: Function, required: true },
|
||||
liveSettings: { type: Object, required: true },
|
||||
positionBoards: { type: Object, default: () => ({}) },
|
||||
liveGroupCode: { type: String, default: '' },
|
||||
liveGroupMembers: { type: Array, required: true },
|
||||
prelimTieRows: { type: Array, required: true },
|
||||
@@ -321,6 +370,7 @@ const props = defineProps({
|
||||
finalists: { type: Array, required: true },
|
||||
liveFinalGroup: { type: Number, required: true },
|
||||
liveFinalRows: { type: Array, required: true },
|
||||
finalOverallRows: { type: Array, required: true },
|
||||
finalTieRows: { type: Array, required: true },
|
||||
podiumOrdered: { type: Array, required: true },
|
||||
playerImage: { type: Function, required: true },
|
||||
@@ -339,7 +389,9 @@ const props = defineProps({
|
||||
const finalistIds = computed(() => new Set((props.finalists || []).map((row) => row.playerId)))
|
||||
const activeView = computed(() => props.liveSettings.activeView || 'group_live')
|
||||
const listPageIndex = ref(0)
|
||||
const tieGroupIndex = ref(0)
|
||||
let listRotationTimer = null
|
||||
let tieRotationTimer = null
|
||||
|
||||
const rotationIntervalSec = computed(() => {
|
||||
const raw = Number(props.liveSettings.rotationIntervalSec || 5)
|
||||
@@ -356,9 +408,8 @@ const rotationPlayerCount = computed(() => {
|
||||
})
|
||||
|
||||
const listRowsForActiveView = computed(() => {
|
||||
if (activeView.value === 'prelim_tie') return props.prelimTieRows || []
|
||||
if (activeView.value === 'prelim_overall') return props.preliminaryRows || []
|
||||
if (activeView.value === 'final_tie') return props.finalTieRows || []
|
||||
if (activeView.value === 'final_overall') return props.finalOverallRows || []
|
||||
return []
|
||||
})
|
||||
|
||||
@@ -368,30 +419,37 @@ const listPageCount = computed(() => {
|
||||
return Math.max(1, Math.ceil(total / rotationPlayerCount.value))
|
||||
})
|
||||
|
||||
const listPageStart = computed(() => listPageIndex.value * rotationPlayerCount.value)
|
||||
const currentListPageDisplay = computed(() => `${listPageIndex.value + 1} / ${listPageCount.value}`)
|
||||
|
||||
const pagedPrelimTieRows = computed(() => paginateRows(props.prelimTieRows || []))
|
||||
const pagedPreliminaryRows = computed(() => paginateRows(props.preliminaryRows || []))
|
||||
const pagedFinalTieRows = computed(() => paginateRows(props.finalTieRows || []))
|
||||
const pagedFinalOverallRows = computed(() => paginateRows(props.finalOverallRows || []))
|
||||
const groupedPrelimTieRows = computed(() => groupTieRowsByPositionBoard('prelim_tiebreak', props.prelimTieRows || []))
|
||||
const groupedFinalTieRows = computed(() => groupTieRowsByPositionBoard('final_tiebreak', props.finalTieRows || []))
|
||||
const tieGroupCountForActiveView = computed(() => {
|
||||
if (activeView.value === 'prelim_tie') return groupedPrelimTieRows.value.length
|
||||
if (activeView.value === 'final_tie') return groupedFinalTieRows.value.length
|
||||
return 0
|
||||
})
|
||||
const currentTieGroupPageDisplay = computed(() => {
|
||||
const total = tieGroupCountForActiveView.value
|
||||
if (total <= 0) return '0 / 0'
|
||||
return `${(tieGroupIndex.value % total) + 1} / ${total}`
|
||||
})
|
||||
const activePrelimTieGroup = computed(() => {
|
||||
const groups = groupedPrelimTieRows.value
|
||||
if (groups.length === 0) return null
|
||||
return groups[tieGroupIndex.value % groups.length]
|
||||
})
|
||||
const activeFinalTieGroup = computed(() => {
|
||||
const groups = groupedFinalTieRows.value
|
||||
if (groups.length === 0) return null
|
||||
return groups[tieGroupIndex.value % groups.length]
|
||||
})
|
||||
const rankedLiveGroupMembers = computed(() => {
|
||||
const rows = (props.liveGroupMembers || []).map((player) => ({
|
||||
return (props.liveGroupMembers || []).map((player) => ({
|
||||
player,
|
||||
total: Number(props.prelimTotal(player.id) || 0),
|
||||
}))
|
||||
rows.sort((a, b) => {
|
||||
if (a.total !== b.total) return b.total - a.total
|
||||
return a.player.id - b.player.id
|
||||
})
|
||||
|
||||
let prevTotal = null
|
||||
let prevRank = 0
|
||||
return rows.map((entry, idx) => {
|
||||
const rank = prevTotal === entry.total ? prevRank : idx + 1
|
||||
prevTotal = entry.total
|
||||
prevRank = rank
|
||||
return { ...entry, rank }
|
||||
})
|
||||
})
|
||||
|
||||
const activeViewLabel = computed(() => {
|
||||
@@ -401,6 +459,7 @@ const activeViewLabel = computed(() => {
|
||||
prelim_overall: props.t('labels.liveViewPrelimOverall'),
|
||||
final_groups: props.t('labels.liveViewFinalGroups'),
|
||||
final_tie: props.t('labels.liveViewFinalTie'),
|
||||
final_overall: props.t('labels.liveViewFinalOverall'),
|
||||
podium: props.t('labels.liveViewPodium'),
|
||||
}
|
||||
return map[activeView.value] || map.group_live
|
||||
@@ -419,27 +478,10 @@ const groupClassKey = computed(() => {
|
||||
return 'u'
|
||||
})
|
||||
const rankedLiveFinalRows = computed(() => {
|
||||
const rows = (props.liveFinalRows || []).map((row) => ({
|
||||
return (props.liveFinalRows || []).map((row) => ({
|
||||
row,
|
||||
total: Number(props.finalTotal(row.playerId) || 0),
|
||||
}))
|
||||
|
||||
rows.sort((a, b) => {
|
||||
if (a.total !== b.total) return b.total - a.total
|
||||
const seedA = Number(a.row.seed || 0)
|
||||
const seedB = Number(b.row.seed || 0)
|
||||
if (seedA !== seedB) return seedA - seedB
|
||||
return a.row.playerId - b.row.playerId
|
||||
})
|
||||
|
||||
let prevTotal = null
|
||||
let prevRank = 0
|
||||
return rows.map((entry, idx) => {
|
||||
const rank = prevTotal === entry.total ? prevRank : idx + 1
|
||||
prevTotal = entry.total
|
||||
prevRank = rank
|
||||
return { ...entry, rank }
|
||||
})
|
||||
})
|
||||
|
||||
function isFinalist(playerId) {
|
||||
@@ -448,15 +490,17 @@ function isFinalist(playerId) {
|
||||
|
||||
watch(
|
||||
() =>
|
||||
`${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalTieRows.length}`,
|
||||
`${activeView.value}|${rotationIntervalSec.value}|${rotationPlayerCount.value}|${props.prelimTieRows.length}|${props.preliminaryRows.length}|${props.finalOverallRows.length}|${props.finalTieRows.length}`,
|
||||
() => {
|
||||
restartListRotation()
|
||||
restartTieRotation()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopListRotation()
|
||||
stopTieRotation()
|
||||
})
|
||||
|
||||
function paginateRows(rows) {
|
||||
@@ -468,7 +512,7 @@ function restartListRotation() {
|
||||
stopListRotation()
|
||||
listPageIndex.value = 0
|
||||
|
||||
const autoRotateView = activeView.value === 'prelim_tie' || activeView.value === 'prelim_overall' || activeView.value === 'final_tie'
|
||||
const autoRotateView = activeView.value === 'prelim_overall' || activeView.value === 'final_overall'
|
||||
if (!autoRotateView) return
|
||||
if (listPageCount.value <= 1) return
|
||||
|
||||
@@ -487,4 +531,62 @@ function stopListRotation() {
|
||||
listRotationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function restartTieRotation() {
|
||||
stopTieRotation()
|
||||
tieGroupIndex.value = 0
|
||||
|
||||
const total = tieGroupCountForActiveView.value
|
||||
if (total <= 1) return
|
||||
if (activeView.value !== 'prelim_tie' && activeView.value !== 'final_tie') return
|
||||
|
||||
tieRotationTimer = setInterval(() => {
|
||||
if (tieGroupCountForActiveView.value <= 1) {
|
||||
tieGroupIndex.value = 0
|
||||
return
|
||||
}
|
||||
tieGroupIndex.value = (tieGroupIndex.value + 1) % tieGroupCountForActiveView.value
|
||||
}, rotationIntervalSec.value * 1000)
|
||||
}
|
||||
|
||||
function stopTieRotation() {
|
||||
if (tieRotationTimer) {
|
||||
clearInterval(tieRotationTimer)
|
||||
tieRotationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function boardSlot(board, playerId) {
|
||||
const boardMap = props.positionBoards?.[board] || {}
|
||||
return boardMap[String(playerId)] || boardMap[playerId] || null
|
||||
}
|
||||
|
||||
function groupTieRowsByPositionBoard(board, rows) {
|
||||
const mapped = (rows || []).map((row, index) => {
|
||||
const slot = boardSlot(board, row.playerId)
|
||||
const fallbackGroup = String(Math.floor(index / 6) + 1)
|
||||
const fallbackPos = (index % 6) + 1
|
||||
const groupKeyRaw = String(slot?.groupKey || fallbackGroup).trim()
|
||||
const parsedGroup = Number.parseInt(groupKeyRaw, 10)
|
||||
const groupKey = Number.isFinite(parsedGroup) && parsedGroup > 0 ? String(parsedGroup) : fallbackGroup
|
||||
const position = Number(slot?.position) > 0 ? Math.floor(Number(slot.position)) : fallbackPos
|
||||
return { row, groupKey, position }
|
||||
})
|
||||
|
||||
const groups = new Map()
|
||||
for (const entry of mapped) {
|
||||
if (!groups.has(entry.groupKey)) groups.set(entry.groupKey, [])
|
||||
groups.get(entry.groupKey).push(entry)
|
||||
}
|
||||
|
||||
return [...groups.entries()]
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.map(([groupKey, items]) => ({
|
||||
groupKey,
|
||||
rows: items.sort((a, b) => {
|
||||
if (a.position !== b.position) return a.position - b.position
|
||||
return a.row.playerId - b.row.playerId
|
||||
}),
|
||||
}))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<option value="prelim_overall">{{ t('labels.liveViewPrelimOverall') }}</option>
|
||||
<option value="final_groups">{{ t('labels.liveViewFinalGroups') }}</option>
|
||||
<option value="final_tie">{{ t('labels.liveViewFinalTie') }}</option>
|
||||
<option value="final_overall">{{ t('labels.liveViewFinalOverall') }}</option>
|
||||
<option value="podium">{{ t('labels.liveViewPodium') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -77,19 +78,126 @@
|
||||
@change="emitPatch({ rotationPlayerCount: Number($event.target.value) || 12 })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<hr class="live-settings-divider" />
|
||||
|
||||
<div class="live-settings-row">
|
||||
<label class="control-label">{{ t('labels.stageControl') }}</label>
|
||||
<div class="stage-control-grid">
|
||||
<div class="stage-select-card">
|
||||
<p class="stage-select-label">{{ t('labels.stagePhase') }}</p>
|
||||
<select class="name-input" :value="stageControl.phase" @change="updateStageControl({ phase: $event.target.value })">
|
||||
<option value="preliminary">{{ t('labels.phasePreliminary') }}</option>
|
||||
<option value="prelim_tiebreak">{{ t('labels.phasePrelimTie') }}</option>
|
||||
<option value="final">{{ t('labels.phaseFinal') }}</option>
|
||||
<option value="final_tiebreak">{{ t('labels.phaseFinalTie') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="stage-select-card">
|
||||
<p class="stage-select-label">{{ t('labels.stageGroup') }}</p>
|
||||
<select class="name-input" :value="stageControl.group" @change="updateStageControl({ group: $event.target.value })" :disabled="groupOptions.length === 0">
|
||||
<option v-if="groupOptions.length === 0" value="">{{ t('messages.noGroupForPhase') }}</option>
|
||||
<option v-for="group in groupOptions" :key="'stage-group-' + stageControl.phase + '-' + group" :value="group">{{ group }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="stage-select-card">
|
||||
<p class="stage-select-label">{{ t('labels.stageRound') }}</p>
|
||||
<select class="name-input" :value="String(stageControl.round)" @change="updateStageControl({ round: Number($event.target.value) || 1 })">
|
||||
<option v-for="round in roundOptions" :key="'stage-round-' + stageControl.phase + '-' + round" :value="String(round)">
|
||||
{{ round }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="stage-selection-preview">{{ selectedStageSummary }}</p>
|
||||
<div class="panel-actions compact stage-action-row">
|
||||
<button class="btn btn-primary stage-start-btn" @click="$emit('start-stage')" :disabled="groupOptions.length === 0">
|
||||
{{ t('actions.startStage') }}
|
||||
</button>
|
||||
<button class="btn btn-outline" @click="$emit('end-stage')">{{ t('actions.endStage') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stage-status-grid">
|
||||
<div class="stage-status-card">
|
||||
<p class="stage-status-title">{{ t('labels.currentStage') }}</p>
|
||||
<template v-if="currentStage">
|
||||
<p class="stage-status-main">{{ formatStageMain(currentStage) }}</p>
|
||||
<p class="stage-status-sub">{{ t('labels.startedAt') }}: {{ currentStage.startedAt || '-' }}</p>
|
||||
<div class="panel-actions compact stage-status-actions">
|
||||
<button class="btn btn-primary btn-xs" @click="$emit('navigate-current-stage')">{{ t('actions.goToStageScoring') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<p v-else class="stage-status-empty">{{ t('labels.noCurrentStage') }}</p>
|
||||
</div>
|
||||
<div class="stage-status-card">
|
||||
<p class="stage-status-title">{{ t('labels.lastStage') }}</p>
|
||||
<template v-if="lastStage">
|
||||
<p class="stage-status-main">{{ formatStageMain(lastStage) }}</p>
|
||||
<p class="stage-status-sub">{{ t('labels.startedAt') }}: {{ lastStage.startedAt || '-' }}</p>
|
||||
<p v-if="lastStage.endedAt" class="stage-status-sub">{{ t('labels.endedAt') }}: {{ lastStage.endedAt }}</p>
|
||||
</template>
|
||||
<p v-else class="stage-status-empty">{{ t('labels.noLastStage') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
t: { type: Function, required: true },
|
||||
liveSettings: { type: Object, required: true },
|
||||
assignableGroups: { type: Array, required: true },
|
||||
stageControl: { type: Object, required: true },
|
||||
stageOptions: { type: Object, required: true },
|
||||
currentStage: { type: Object, default: null },
|
||||
lastStage: { type: Object, default: null },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update-live-setting'])
|
||||
const emit = defineEmits(['update-live-setting', 'update-stage-control', 'start-stage', 'end-stage', 'navigate-current-stage'])
|
||||
|
||||
const groupOptions = computed(() => {
|
||||
const key = String(props.stageControl.phase || '').trim().toLowerCase()
|
||||
const list = props.stageOptions?.[key]
|
||||
return Array.isArray(list) ? list : []
|
||||
})
|
||||
|
||||
const roundOptions = computed(() => {
|
||||
const phase = String(props.stageControl.phase || '').trim().toLowerCase()
|
||||
if (phase === 'preliminary') return [1, 2, 3]
|
||||
if (phase === 'final') return [1, 2]
|
||||
return [1]
|
||||
})
|
||||
const selectedStageSummary = computed(() => {
|
||||
const phase = formatPhase(props.stageControl.phase)
|
||||
const group = String(props.stageControl.group || '').trim() || '-'
|
||||
const round = Number(props.stageControl.round || 1)
|
||||
return `${props.t('labels.stagePhase')}: ${phase} · ${props.t('labels.stageGroup')}: ${group} · ${props.t('labels.stageRound')}: ${round}`
|
||||
})
|
||||
|
||||
function emitPatch(patch) {
|
||||
emit('update-live-setting', patch)
|
||||
}
|
||||
|
||||
function updateStageControl(patch) {
|
||||
emit('update-stage-control', patch)
|
||||
}
|
||||
|
||||
function formatPhase(phase) {
|
||||
const normalized = String(phase || '').trim().toLowerCase()
|
||||
if (normalized === 'preliminary') return props.t('labels.phasePreliminary')
|
||||
if (normalized === 'prelim_tiebreak') return props.t('labels.phasePrelimTie')
|
||||
if (normalized === 'final') return props.t('labels.phaseFinal')
|
||||
if (normalized === 'final_tiebreak') return props.t('labels.phaseFinalTie')
|
||||
return normalized || '-'
|
||||
}
|
||||
|
||||
function formatStageMain(stage) {
|
||||
if (!stage) return '-'
|
||||
const group = String(stage.group || '').trim() || '-'
|
||||
const round = Number(stage.round || 1)
|
||||
return `${formatPhase(stage.phase)} · ${props.t('labels.stageGroup')} ${group} · ${props.t('labels.stageRound')} ${round}`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="section in groupedSections"
|
||||
:key="'multi-section-' + section.key"
|
||||
class="score-section-block"
|
||||
:class="{ 'score-section-overflow': isOverflow(section) }"
|
||||
:style="section.style"
|
||||
>
|
||||
<button class="score-section-toggle" type="button" @click="toggleSection(section.key)">
|
||||
<span class="score-section-title">{{ section.label }}</span>
|
||||
<span class="score-section-meta">{{ section.rows.length }} • {{ isCollapsed(section.key) ? '+' : '−' }}</span>
|
||||
</button>
|
||||
<div v-if="isOverflow(section)" class="score-section-alert">{{ section.label }} > {{ overflowLimit }}</div>
|
||||
|
||||
<template v-if="!isCollapsed(section.key)">
|
||||
<div class="table-wrap desktop-score-table">
|
||||
<table class="score-table">
|
||||
<thead>
|
||||
@@ -18,13 +32,19 @@
|
||||
<th v-if="showGroup">{{ t('table.group') }}</th>
|
||||
<th v-if="showRank">{{ t('table.rank') }}</th>
|
||||
<th v-if="showSeed">{{ t('table.seed') }}</th>
|
||||
<th v-for="round in roundStages" :key="'head-' + round.stage">{{ round.label }}</th>
|
||||
<th
|
||||
v-for="round in roundStages"
|
||||
:key="'head-' + round.stage"
|
||||
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
|
||||
>
|
||||
{{ round.label }}
|
||||
</th>
|
||||
<th>{{ t('table.total') }}</th>
|
||||
<th>{{ t('table.verification') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in filteredRows" :key="'multi-row-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)">
|
||||
<tr v-for="(row, index) in section.rows" :key="'multi-row-' + section.key + '-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row, section)">
|
||||
<td class="mono">{{ index + 1 }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
@@ -38,10 +58,15 @@
|
||||
<td v-if="showGroup" class="mono">{{ groupCell(row) }}</td>
|
||||
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
|
||||
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
|
||||
<td v-for="round in roundStages" :key="'cell-' + round.stage + '-' + row.playerId" class="multi-round-cell">
|
||||
<td
|
||||
v-for="round in roundStages"
|
||||
:key="'cell-' + round.stage + '-' + row.playerId"
|
||||
class="multi-round-cell"
|
||||
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
|
||||
>
|
||||
<input
|
||||
class="score-input score-input-compact"
|
||||
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId) }"
|
||||
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(round.stage, section) }"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
@@ -73,15 +98,12 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="filteredRows.length === 0">
|
||||
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mobile-score-cards">
|
||||
<article v-for="(row, index) in filteredRows" :key="'multi-mobile-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row)">
|
||||
<article v-for="(row, index) in section.rows" :key="'multi-mobile-' + section.key + '-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row, section)">
|
||||
<div class="score-card-head">
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||
@@ -100,11 +122,16 @@
|
||||
</div>
|
||||
|
||||
<div class="mobile-round-grid">
|
||||
<label v-for="round in roundStages" :key="'mobile-round-' + round.stage + '-' + row.playerId" class="mobile-round-item">
|
||||
<label
|
||||
v-for="round in roundStages"
|
||||
:key="'mobile-round-' + round.stage + '-' + row.playerId"
|
||||
class="mobile-round-item"
|
||||
:class="{ 'stage-column-highlight': isHighlightedStage(round.stage, section) }"
|
||||
>
|
||||
<span class="score-label">{{ round.label }}</span>
|
||||
<input
|
||||
class="score-input"
|
||||
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId) }"
|
||||
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(round.stage, section) }"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
@@ -143,14 +170,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { scoreGroupStyle } from '../../utils/scoreGroupTheme'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { groupThemeStyleForKey, scoreGroupStyle } from '../../utils/scoreGroupTheme'
|
||||
import { normalizedGroupCode } from '../../utils/groups'
|
||||
|
||||
const props = defineProps({
|
||||
t: { type: Function, required: true },
|
||||
@@ -162,6 +193,8 @@ const props = defineProps({
|
||||
showGroup: { type: Boolean, default: false },
|
||||
showRank: { type: Boolean, default: false },
|
||||
showSeed: { type: Boolean, default: false },
|
||||
sectionByGroup: { type: Boolean, default: false },
|
||||
overflowLimit: { type: Number, default: 0 },
|
||||
playerImage: { type: Function, required: true },
|
||||
displayName: { type: Function, required: true },
|
||||
secondaryName: { type: Function, required: true },
|
||||
@@ -174,10 +207,14 @@ const props = defineProps({
|
||||
scoreProofFor: { type: Function, required: true },
|
||||
openScoreProofUploader: { type: Function, required: true },
|
||||
openProofPreview: { type: Function, required: true },
|
||||
highlightStage: { type: String, default: '' },
|
||||
highlightGroup: { type: String, default: '' },
|
||||
})
|
||||
|
||||
defineEmits(['update:filter'])
|
||||
|
||||
const collapsedSections = ref({})
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const query = String(props.filterText || '').trim().toLowerCase()
|
||||
if (!query) return props.rows
|
||||
@@ -188,22 +225,144 @@ const filteredRows = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const columnCount = computed(() => {
|
||||
let count = 4 + props.roundStages.length
|
||||
if (props.showGroup) count += 1
|
||||
if (props.showRank) count += 1
|
||||
if (props.showSeed) count += 1
|
||||
return count
|
||||
const groupedSections = computed(() => {
|
||||
if (!props.sectionByGroup) {
|
||||
return [
|
||||
{
|
||||
key: 'all',
|
||||
label: props.t('labels.allPlayers'),
|
||||
rows: filteredRows.value,
|
||||
style: null,
|
||||
},
|
||||
].filter((section) => section.rows.length > 0)
|
||||
}
|
||||
|
||||
const buckets = new Map()
|
||||
for (const row of filteredRows.value) {
|
||||
const key = sectionKeyForRow(row)
|
||||
if (!buckets.has(key)) {
|
||||
buckets.set(key, {
|
||||
key,
|
||||
label: sectionLabelForKey(key),
|
||||
rows: [],
|
||||
})
|
||||
}
|
||||
buckets.get(key).rows.push(row)
|
||||
}
|
||||
|
||||
return [...buckets.values()]
|
||||
.sort((a, b) => sectionSortValue(a.key) - sectionSortValue(b.key) || String(a.key).localeCompare(String(b.key)))
|
||||
.map((section) => ({
|
||||
...section,
|
||||
style: sectionStyleFor(section.key, section.rows[0]),
|
||||
}))
|
||||
})
|
||||
|
||||
watch(
|
||||
groupedSections,
|
||||
(sections) => {
|
||||
const next = {}
|
||||
for (const section of sections) {
|
||||
next[section.key] = collapsedSections.value[section.key] || false
|
||||
}
|
||||
collapsedSections.value = next
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function sectionKeyForRow(row) {
|
||||
const boardGroup = String(row?._boardGroupKey || '').trim()
|
||||
if (boardGroup) return `B${boardGroup}`
|
||||
|
||||
const finalGroup = Number.parseInt(String(row?.finalGroup || ''), 10)
|
||||
if (Number.isFinite(finalGroup) && finalGroup > 0) return `F${finalGroup}`
|
||||
|
||||
const group = normalizedGroupCode(row?.groupCode || '')
|
||||
if (group) return `G${group}`
|
||||
|
||||
return 'U'
|
||||
}
|
||||
|
||||
function sectionLabelForKey(key) {
|
||||
if (key === 'U') return `${props.t('labels.group')} ${props.t('labels.unassigned')}`
|
||||
if (key.startsWith('B')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||
if (key.startsWith('F')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||
if (key.startsWith('G')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||
return `${props.t('labels.group')} ${key}`
|
||||
}
|
||||
|
||||
function sectionSortValue(key) {
|
||||
if (key === 'U') return 9999
|
||||
if (key.startsWith('B')) {
|
||||
const raw = key.slice(1)
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed
|
||||
const code = String(raw).toUpperCase()
|
||||
if (code === 'A') return 1
|
||||
if (code === 'B') return 2
|
||||
if (code === 'C') return 3
|
||||
if (code === 'D') return 4
|
||||
return 5000
|
||||
}
|
||||
if (key.startsWith('F')) return Number.parseInt(key.slice(1), 10) || 9998
|
||||
if (key.startsWith('G')) {
|
||||
const code = String(key.slice(1)).toUpperCase()
|
||||
if (code === 'A') return 1
|
||||
if (code === 'B') return 2
|
||||
if (code === 'C') return 3
|
||||
if (code === 'D') return 4
|
||||
}
|
||||
return 5000
|
||||
}
|
||||
|
||||
function groupCell(row) {
|
||||
if (row?._boardGroupKey) return row._boardGroupKey
|
||||
if (row.finalGroup === 1 || row.finalGroup === 2) {
|
||||
return row.finalGroup
|
||||
}
|
||||
return row.groupCode || props.t('labels.unassigned')
|
||||
}
|
||||
|
||||
function scoreGroupStyleFor(row) {
|
||||
function sectionStyleFor(key, sampleRow) {
|
||||
if (key.startsWith('B')) return groupThemeStyleForKey(key)
|
||||
return scoreGroupStyle(sampleRow)
|
||||
}
|
||||
|
||||
function scoreGroupStyleFor(row, section) {
|
||||
if (section?.key?.startsWith('B') && section?.style) return section.style
|
||||
return scoreGroupStyle(row)
|
||||
}
|
||||
|
||||
function isCollapsed(sectionKey) {
|
||||
return Boolean(collapsedSections.value[sectionKey])
|
||||
}
|
||||
|
||||
function toggleSection(sectionKey) {
|
||||
collapsedSections.value[sectionKey] = !collapsedSections.value[sectionKey]
|
||||
}
|
||||
|
||||
function isOverflow(section) {
|
||||
const limit = Number(props.overflowLimit || 0)
|
||||
if (!Number.isFinite(limit) || limit <= 0) return false
|
||||
return Number(section?.rows?.length || 0) > limit
|
||||
}
|
||||
|
||||
function isHighlightedStage(stage, section) {
|
||||
const stageMatch = String(props.highlightStage || '').trim().toLowerCase() === String(stage || '').trim().toLowerCase()
|
||||
if (!stageMatch) return false
|
||||
const wanted = String(props.highlightGroup || '').trim().toUpperCase()
|
||||
if (!wanted) return true
|
||||
return sectionGroupKey(section) === wanted
|
||||
}
|
||||
|
||||
function sectionGroupKey(section) {
|
||||
const key = String(section?.key || '').trim()
|
||||
if (!key) return ''
|
||||
if (key === 'U') return 'UNASSIGNED'
|
||||
if (key.startsWith('B') || key.startsWith('F') || key.startsWith('G') || key.startsWith('T')) {
|
||||
return String(key.slice(1)).trim().toUpperCase()
|
||||
}
|
||||
return key.toUpperCase()
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -83,10 +83,15 @@
|
||||
<article v-for="group in groupedCards" :key="'group-card-' + group.code" class="group-player-card" :class="'group-' + group.key">
|
||||
<header class="group-player-card-head">
|
||||
<h3>{{ group.label }}</h3>
|
||||
<div class="group-card-head-actions">
|
||||
<span class="pm-count-badge mono">{{ group.players.length }}</span>
|
||||
<button class="group-card-toggle" type="button" @click="toggleGroup(group.code)">
|
||||
{{ isGroupCollapsed(group.code) ? '+' : '−' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="group-player-list" v-if="group.players.length > 0">
|
||||
<div class="group-player-list" v-if="group.players.length > 0 && !isGroupCollapsed(group.code)">
|
||||
<div class="group-player-row" v-for="player in group.players" :key="'card-player-' + player.id">
|
||||
<div class="group-player-top">
|
||||
<div class="competitor-cell">
|
||||
@@ -131,14 +136,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||
<div v-else-if="group.players.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
t: { type: Function, required: true },
|
||||
@@ -227,6 +232,29 @@ const groupedCards = computed(() => {
|
||||
return cards
|
||||
})
|
||||
|
||||
const collapsedGroups = ref({})
|
||||
|
||||
watch(
|
||||
groupedCards,
|
||||
(cards) => {
|
||||
const next = {}
|
||||
for (const card of cards) {
|
||||
next[card.code || 'U'] = collapsedGroups.value[card.code || 'U'] || false
|
||||
}
|
||||
collapsedGroups.value = next
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function isGroupCollapsed(code) {
|
||||
return Boolean(collapsedGroups.value[code || 'U'])
|
||||
}
|
||||
|
||||
function toggleGroup(code) {
|
||||
const key = code || 'U'
|
||||
collapsedGroups.value[key] = !collapsedGroups.value[key]
|
||||
}
|
||||
|
||||
function comparePlayers(a, b, sort) {
|
||||
if (sort === 'nameAr') {
|
||||
return String(a.nameAr || '').localeCompare(String(b.nameAr || ''), 'ar') || a.id - b.id
|
||||
|
||||
249
frontend/src/components/admin/PositionBoardEditor.vue
Normal file
249
frontend/src/components/admin/PositionBoardEditor.vue
Normal file
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<div class="position-board">
|
||||
<div class="position-board-head">
|
||||
<button class="position-board-toggle" type="button" @click="collapsedAll = !collapsedAll">
|
||||
<span class="position-board-title">{{ t('labels.groupAssignment') }}</span>
|
||||
<span class="position-board-meta">{{ columns.length }} • {{ collapsedAll ? '+' : '−' }}</span>
|
||||
</button>
|
||||
<div v-if="hasOverflowColumns" class="position-board-alert">{{ t('labels.group') }} > 6</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!collapsedAll" class="position-board-grid">
|
||||
<section
|
||||
v-for="column in columns"
|
||||
:key="board + '-col-' + column.key"
|
||||
class="position-column"
|
||||
:class="{ 'position-column-overflow': shouldHighlightOverflow && column.items.length > 6 }"
|
||||
>
|
||||
<header class="position-column-head">
|
||||
<div class="position-column-toggle">
|
||||
<span class="position-column-title">{{ columnLabel(column.key) }}</span>
|
||||
<span class="position-column-count" :class="{ danger: shouldHighlightOverflow && column.items.length > 6 }">{{ column.items.length }}</span>
|
||||
</div>
|
||||
<div v-if="shouldHighlightOverflow && column.items.length > 6" class="position-column-alert">
|
||||
{{ t('labels.group') }} {{ column.key }} > 6
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="position-column-list" @dragover.prevent @drop="onDropAtEnd(column.key)">
|
||||
<article
|
||||
v-for="(item, index) in column.items"
|
||||
:key="board + '-card-' + item.playerId"
|
||||
class="position-card"
|
||||
draggable="true"
|
||||
@dragstart="onDragStart($event, column.key, index, item.playerId)"
|
||||
@dragover.prevent
|
||||
@drop="onDropBefore(column.key, index)"
|
||||
>
|
||||
<div class="position-badge">{{ index + 1 }}</div>
|
||||
<img :src="playerImage(item)" :alt="item.nameAr" class="position-avatar" />
|
||||
<div class="position-card-text">
|
||||
<p class="name-main">{{ displayName(item) }}</p>
|
||||
<p class="name-sub">{{ secondaryName(item) }}</p>
|
||||
<p v-if="scoreText(item)" class="position-score">{{ scoreText(item) }}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div v-if="column.items.length === 0" class="position-empty" @dragover.prevent @drop="onDropAtEnd(column.key)">
|
||||
{{ t('labels.noPlayers') }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="position-actions" v-if="isNumericBoard">
|
||||
<button class="btn btn-outline btn-xs" @click="addNumericGroup">+ {{ t('labels.group') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
t: { type: Function, required: true },
|
||||
board: { type: String, required: true },
|
||||
rows: { type: Array, required: true },
|
||||
slots: { type: Object, default: () => ({}) },
|
||||
playerImage: { type: Function, required: true },
|
||||
displayName: { type: Function, required: true },
|
||||
secondaryName: { type: Function, required: true },
|
||||
groupLabelPrefix: { type: String, default: '' },
|
||||
numericGroups: { type: Boolean, default: false },
|
||||
availableGroups: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['save'])
|
||||
|
||||
const columns = ref([])
|
||||
const dragging = ref(null)
|
||||
const collapsedAll = ref(false)
|
||||
|
||||
const isNumericBoard = computed(() => props.numericGroups)
|
||||
const shouldHighlightOverflow = computed(
|
||||
() =>
|
||||
props.board === 'preliminary' ||
|
||||
props.board === 'final' ||
|
||||
props.board === 'prelim_tiebreak' ||
|
||||
props.board === 'final_tiebreak',
|
||||
)
|
||||
const hasOverflowColumns = computed(
|
||||
() => shouldHighlightOverflow.value && columns.value.some((column) => Number(column?.items?.length || 0) > 6),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => `${props.board}|${props.rows.map((row) => row.playerId).join('|')}|${JSON.stringify(props.slots || {})}`,
|
||||
() => {
|
||||
rebuildColumns()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function rebuildColumns() {
|
||||
const mapped = (props.rows || []).map((row, idx) => {
|
||||
const slot = props.slots?.[String(row.playerId)] || props.slots?.[row.playerId] || null
|
||||
return {
|
||||
...row,
|
||||
slotGroup: normalizeGroup(slot?.groupKey, row, idx),
|
||||
slotPosition: normalizePosition(slot?.position, idx + 1),
|
||||
}
|
||||
})
|
||||
|
||||
const byGroup = new Map()
|
||||
for (const item of mapped) {
|
||||
if (!byGroup.has(item.slotGroup)) byGroup.set(item.slotGroup, [])
|
||||
byGroup.get(item.slotGroup).push(item)
|
||||
}
|
||||
|
||||
const groupKeys = [...byGroup.keys()].sort(compareGroupKeys)
|
||||
if (!isNumericBoard.value) {
|
||||
for (const group of props.availableGroups || []) {
|
||||
const normalized = String(group || '').trim().toUpperCase()
|
||||
if (!normalized) continue
|
||||
if (!groupKeys.includes(normalized)) groupKeys.push(normalized)
|
||||
}
|
||||
groupKeys.sort(compareGroupKeys)
|
||||
}
|
||||
let nextColumns = groupKeys.map((key) => {
|
||||
const items = byGroup.get(key)
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (a.slotPosition !== b.slotPosition) return a.slotPosition - b.slotPosition
|
||||
return a.playerId - b.playerId
|
||||
})
|
||||
return { key, items }
|
||||
})
|
||||
|
||||
columns.value = nextColumns
|
||||
}
|
||||
|
||||
function normalizeGroup(groupKey, row, idx) {
|
||||
if (isNumericBoard.value) {
|
||||
const parsed = Number.parseInt(String(groupKey || ''), 10)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return String(parsed)
|
||||
const rowScoreGroup = Number.parseInt(String(row?.scoreGroup || ''), 10)
|
||||
if (Number.isFinite(rowScoreGroup) && rowScoreGroup > 0) return String(rowScoreGroup)
|
||||
const rowFinalGroup = Number.parseInt(String(row?.finalGroup || ''), 10)
|
||||
if (Number.isFinite(rowFinalGroup) && rowFinalGroup > 0) return String(rowFinalGroup)
|
||||
return '1'
|
||||
}
|
||||
|
||||
const value = String(groupKey || row.groupCode || '').trim().toUpperCase()
|
||||
return value || 'UNASSIGNED'
|
||||
}
|
||||
|
||||
function normalizePosition(position, fallback) {
|
||||
const parsed = Number(position)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed)
|
||||
return fallback
|
||||
}
|
||||
|
||||
function compareGroupKeys(a, b) {
|
||||
if (isNumericBoard.value) {
|
||||
return Number(a) - Number(b)
|
||||
}
|
||||
if (a === 'UNASSIGNED') return 1
|
||||
if (b === 'UNASSIGNED') return -1
|
||||
return a.localeCompare(b)
|
||||
}
|
||||
|
||||
function columnLabel(key) {
|
||||
if (isNumericBoard.value) {
|
||||
return `${props.t('labels.group')} ${key}`
|
||||
}
|
||||
if (key === 'UNASSIGNED') return props.t('labels.unassigned')
|
||||
return `${props.groupLabelPrefix || props.t('labels.group')} ${key}`
|
||||
}
|
||||
|
||||
function scoreText(item) {
|
||||
if (props.board === 'preliminary') return `${props.t('table.total')}: ${Number(item.score || 0)}`
|
||||
if (props.board === 'final') return `${props.t('table.total')}: ${Number(item.score || 0)}`
|
||||
return `${props.t('table.tieScore')}: ${Number(item.tieBreak || 0)}`
|
||||
}
|
||||
|
||||
function onDragStart(event, groupKey, index, playerId) {
|
||||
dragging.value = { groupKey, index, playerId }
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function onDropBefore(targetGroupKey, targetIndex) {
|
||||
moveDragged(targetGroupKey, targetIndex)
|
||||
}
|
||||
|
||||
function onDropAtEnd(targetGroupKey) {
|
||||
const column = columns.value.find((entry) => entry.key === targetGroupKey)
|
||||
const targetIndex = column ? column.items.length : 0
|
||||
moveDragged(targetGroupKey, targetIndex)
|
||||
}
|
||||
|
||||
function moveDragged(targetGroupKey, targetIndex) {
|
||||
const drag = dragging.value
|
||||
if (!drag) return
|
||||
|
||||
const sourceCol = columns.value.find((entry) => entry.key === drag.groupKey)
|
||||
const targetCol = columns.value.find((entry) => entry.key === targetGroupKey)
|
||||
if (!sourceCol || !targetCol) {
|
||||
dragging.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const sourceIndex = sourceCol.items.findIndex((item) => item.playerId === drag.playerId)
|
||||
if (sourceIndex < 0) {
|
||||
dragging.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const [moved] = sourceCol.items.splice(sourceIndex, 1)
|
||||
let insertAt = targetIndex
|
||||
if (sourceCol.key === targetCol.key && sourceIndex < targetIndex) {
|
||||
insertAt -= 1
|
||||
}
|
||||
if (insertAt < 0) insertAt = 0
|
||||
if (insertAt > targetCol.items.length) insertAt = targetCol.items.length
|
||||
targetCol.items.splice(insertAt, 0, moved)
|
||||
|
||||
dragging.value = null
|
||||
emitSave()
|
||||
}
|
||||
|
||||
function emitSave() {
|
||||
const slots = []
|
||||
for (const column of columns.value) {
|
||||
column.items.forEach((item, idx) => {
|
||||
slots.push({
|
||||
playerId: item.playerId,
|
||||
groupKey: column.key,
|
||||
position: idx + 1,
|
||||
})
|
||||
})
|
||||
}
|
||||
emit('save', { board: props.board, slots })
|
||||
}
|
||||
|
||||
function addNumericGroup() {
|
||||
if (!isNumericBoard.value) return
|
||||
const keys = columns.value.map((entry) => Number(entry.key)).filter((value) => Number.isFinite(value) && value > 0)
|
||||
const next = keys.length > 0 ? Math.max(...keys) + 1 : 1
|
||||
columns.value = [...columns.value, { key: String(next), items: [] }]
|
||||
}
|
||||
</script>
|
||||
@@ -9,6 +9,13 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-for="section in groupedSections" :key="'section-' + stage + '-' + section.key" class="score-section-block" :class="{ 'tie-break': tieBreakSectionMode }" :style="section.style">
|
||||
<button class="score-section-toggle" type="button" @click="toggleSection(section.key)">
|
||||
<span class="score-section-title">{{ section.label }}</span>
|
||||
<span class="score-section-meta">{{ section.rows.length }} • {{ isCollapsed(section.key) ? '+' : '−' }}</span>
|
||||
</button>
|
||||
|
||||
<template v-if="!isCollapsed(section.key)">
|
||||
<div class="table-wrap desktop-score-table">
|
||||
<table class="score-table">
|
||||
<thead>
|
||||
@@ -17,14 +24,19 @@
|
||||
<th>{{ t('table.competitor') }}</th>
|
||||
<th v-if="showGroup">{{ t('table.group') }}</th>
|
||||
<th v-if="showScoreBeforeInput">{{ t('table.score') }}</th>
|
||||
<th>{{ inputLabel }}</th>
|
||||
<th :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">{{ inputLabel }}</th>
|
||||
<th>{{ t('table.verification') }}</th>
|
||||
<th v-if="showRank">{{ t('table.rank') }}</th>
|
||||
<th v-if="showSeed">{{ t('table.seed') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in filteredRows" :key="'desk-' + stage + '-' + row.playerId" class="score-group-row" :style="scoreGroupStyleFor(row)">
|
||||
<tr
|
||||
v-for="(row, index) in section.rows"
|
||||
:key="'desk-' + stage + '-' + section.key + '-' + row.playerId"
|
||||
class="score-group-row"
|
||||
:style="scoreRowStyleFor(row, section)"
|
||||
>
|
||||
<td class="mono">{{ index + 1 }}</td>
|
||||
<td>
|
||||
<div class="competitor-cell compact">
|
||||
@@ -37,10 +49,10 @@
|
||||
</td>
|
||||
<td v-if="showGroup" class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
|
||||
<td v-if="showScoreBeforeInput" class="mono strong">{{ row.score }}</td>
|
||||
<td>
|
||||
<td :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">
|
||||
<input
|
||||
class="score-input"
|
||||
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId) }"
|
||||
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(stage, section) }"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
@@ -58,13 +70,6 @@
|
||||
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
|
||||
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||
</button>
|
||||
<!-- <button
|
||||
v-if="hasScoreProof(stage, row.playerId)"
|
||||
class="btn btn-ai btn-xs"
|
||||
@click="requestScoreAdvice(stage, row.playerId)"
|
||||
>
|
||||
{{ t('actions.aiAdvisor') }}
|
||||
</button> -->
|
||||
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
|
||||
{{ t('actions.removeProof') }}
|
||||
</button>
|
||||
@@ -80,15 +85,17 @@
|
||||
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
|
||||
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
|
||||
</tr>
|
||||
<tr v-if="filteredRows.length === 0">
|
||||
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mobile-score-cards">
|
||||
<article v-for="(row, index) in filteredRows" :key="'mob-' + stage + '-' + row.playerId" class="score-card score-group-card" :style="scoreGroupStyleFor(row)">
|
||||
<article
|
||||
v-for="(row, index) in section.rows"
|
||||
:key="'mob-' + stage + '-' + section.key + '-' + row.playerId"
|
||||
class="score-card score-group-card"
|
||||
:style="scoreRowStyleFor(row, section)"
|
||||
>
|
||||
<div class="score-card-head">
|
||||
<div class="competitor-cell compact">
|
||||
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
|
||||
@@ -107,10 +114,10 @@
|
||||
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
|
||||
</div>
|
||||
|
||||
<label class="score-label">{{ inputLabel }}</label>
|
||||
<label class="score-label" :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">{{ inputLabel }}</label>
|
||||
<input
|
||||
class="score-input"
|
||||
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId) }"
|
||||
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(stage, section) }"
|
||||
type="number"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
@@ -127,14 +134,6 @@
|
||||
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(stage, row.playerId)">
|
||||
{{ hasScoreProof(stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
|
||||
</button>
|
||||
<!-- <button
|
||||
v-if="hasScoreProof(stage, row.playerId)"
|
||||
class="btn btn-ai btn-xs"
|
||||
@click="requestScoreAdvice(stage, row.playerId)"
|
||||
>
|
||||
{{ t('actions.aiAdvisor') }}
|
||||
</button> -->
|
||||
|
||||
<button v-if="hasScoreProof(stage, row.playerId)" class="btn btn-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
|
||||
{{ t('actions.removeProof') }}
|
||||
</button>
|
||||
@@ -147,14 +146,18 @@
|
||||
/>
|
||||
</div>
|
||||
</article>
|
||||
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="groupedSections.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { scoreGroupStyle, scoreValueStyle } from '../../utils/scoreGroupTheme'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { groupThemeStyleForKey, scoreGroupStyle, scoreValueStyle } from '../../utils/scoreGroupTheme'
|
||||
import { normalizedGroupCode } from '../../utils/groups'
|
||||
|
||||
const props = defineProps({
|
||||
t: { type: Function, required: true },
|
||||
@@ -168,6 +171,8 @@ const props = defineProps({
|
||||
colorMode: { type: String, default: 'group' },
|
||||
showScoreBeforeInput: { type: Boolean, default: false },
|
||||
inputLabel: { type: String, required: true },
|
||||
sectionByGroup: { type: Boolean, default: false },
|
||||
tieBreakSectionMode: { type: Boolean, default: false },
|
||||
playerImage: { type: Function, required: true },
|
||||
displayName: { type: Function, required: true },
|
||||
secondaryName: { type: Function, required: true },
|
||||
@@ -182,10 +187,14 @@ const props = defineProps({
|
||||
removeScoreProof: { type: Function, required: true },
|
||||
openProofPreview: { type: Function, required: true },
|
||||
requestScoreAdvice: { type: Function, required: true },
|
||||
highlightStage: { type: String, default: '' },
|
||||
highlightGroup: { type: String, default: '' },
|
||||
})
|
||||
|
||||
defineEmits(['update:filter'])
|
||||
|
||||
const collapsedSections = ref({})
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const query = String(props.filterText || '').trim().toLowerCase()
|
||||
if (!query) return props.rows
|
||||
@@ -196,19 +205,151 @@ const filteredRows = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const columnCount = computed(() => {
|
||||
let count = 4
|
||||
if (props.showGroup) count += 1
|
||||
if (props.showScoreBeforeInput) count += 1
|
||||
if (props.showRank) count += 1
|
||||
if (props.showSeed) count += 1
|
||||
return count
|
||||
const groupedSections = computed(() => {
|
||||
if (!props.sectionByGroup) {
|
||||
return [
|
||||
{
|
||||
key: 'all',
|
||||
label: props.t('labels.allPlayers'),
|
||||
rows: filteredRows.value,
|
||||
style: null,
|
||||
},
|
||||
].filter((section) => section.rows.length > 0)
|
||||
}
|
||||
|
||||
const buckets = new Map()
|
||||
for (const row of filteredRows.value) {
|
||||
const key = sectionKeyForRow(row)
|
||||
if (!buckets.has(key)) {
|
||||
buckets.set(key, {
|
||||
key,
|
||||
label: sectionLabelForKey(key),
|
||||
rows: [],
|
||||
})
|
||||
}
|
||||
buckets.get(key).rows.push(row)
|
||||
}
|
||||
|
||||
return [...buckets.values()]
|
||||
.sort((a, b) => sectionSortValue(a.key) - sectionSortValue(b.key) || String(a.key).localeCompare(String(b.key)))
|
||||
.map((section) => ({
|
||||
...section,
|
||||
style: sectionStyleFor(section.key, section.rows[0]),
|
||||
}))
|
||||
})
|
||||
|
||||
function scoreGroupStyleFor(row) {
|
||||
if (props.colorMode === 'score') {
|
||||
return scoreValueStyle(row?.score)
|
||||
watch(
|
||||
groupedSections,
|
||||
(sections) => {
|
||||
const next = {}
|
||||
for (const section of sections) {
|
||||
next[section.key] = collapsedSections.value[section.key] || false
|
||||
}
|
||||
collapsedSections.value = next
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function sectionKeyForRow(row) {
|
||||
const boardGroup = String(row?._boardGroupKey || '').trim()
|
||||
if (boardGroup) return `B${boardGroup}`
|
||||
|
||||
if (props.tieBreakSectionMode) {
|
||||
const tieGroup = Number.parseInt(String(row?.scoreGroup || ''), 10)
|
||||
if (Number.isFinite(tieGroup) && tieGroup > 0) return `T${tieGroup}`
|
||||
}
|
||||
|
||||
const finalGroup = Number.parseInt(String(row?.finalGroup || ''), 10)
|
||||
if (Number.isFinite(finalGroup) && finalGroup > 0) return `F${finalGroup}`
|
||||
|
||||
const group = normalizedGroupCode(row?.groupCode || '')
|
||||
if (group) return `G${group}`
|
||||
|
||||
return 'U'
|
||||
}
|
||||
|
||||
function sectionLabelForKey(key) {
|
||||
if (key === 'U') return `${props.t('labels.group')} ${props.t('labels.unassigned')}`
|
||||
if (key.startsWith('B')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||
if (key.startsWith('T')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||
if (key.startsWith('F')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||
if (key.startsWith('G')) return `${props.t('labels.group')} ${key.slice(1)}`
|
||||
return `${props.t('labels.group')} ${key}`
|
||||
}
|
||||
|
||||
function sectionSortValue(key) {
|
||||
if (key === 'U') return 9999
|
||||
if (key.startsWith('B')) {
|
||||
const raw = key.slice(1)
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed
|
||||
const code = String(raw).toUpperCase()
|
||||
if (code === 'A') return 1
|
||||
if (code === 'B') return 2
|
||||
if (code === 'C') return 3
|
||||
if (code === 'D') return 4
|
||||
return 5000 + hashToHue(code)
|
||||
}
|
||||
if (key.startsWith('T') || key.startsWith('F')) return Number.parseInt(key.slice(1), 10) || 9998
|
||||
if (key.startsWith('G')) {
|
||||
const raw = key.slice(1)
|
||||
const code = String(raw || '').trim().toUpperCase()
|
||||
if (code === 'A') return 1
|
||||
if (code === 'B') return 2
|
||||
if (code === 'C') return 3
|
||||
if (code === 'D') return 4
|
||||
return 5000 + hashToHue(code)
|
||||
}
|
||||
return 8000 + hashToHue(key)
|
||||
}
|
||||
|
||||
function sectionStyleFor(key, sampleRow) {
|
||||
if (key.startsWith('B')) return groupThemeStyleForKey(key)
|
||||
if (props.tieBreakSectionMode) {
|
||||
return groupThemeStyleForKey(key, { tieBreak: true })
|
||||
}
|
||||
return scoreGroupStyle(sampleRow)
|
||||
}
|
||||
|
||||
function scoreRowStyleFor(row, section) {
|
||||
if (section?.key?.startsWith('B') && section?.style) return section.style
|
||||
if (props.tieBreakSectionMode && section?.style) return section.style
|
||||
if (props.colorMode === 'score') return scoreValueStyle(row?.score)
|
||||
return scoreGroupStyle(row)
|
||||
}
|
||||
|
||||
function isCollapsed(sectionKey) {
|
||||
return Boolean(collapsedSections.value[sectionKey])
|
||||
}
|
||||
|
||||
function toggleSection(sectionKey) {
|
||||
collapsedSections.value[sectionKey] = !collapsedSections.value[sectionKey]
|
||||
}
|
||||
|
||||
function isHighlightedStage(stageName, section) {
|
||||
const stageMatch = String(props.highlightStage || '').trim().toLowerCase() === String(stageName || '').trim().toLowerCase()
|
||||
if (!stageMatch) return false
|
||||
const wanted = String(props.highlightGroup || '').trim().toUpperCase()
|
||||
if (!wanted) return true
|
||||
return sectionGroupKey(section) === wanted
|
||||
}
|
||||
|
||||
function sectionGroupKey(section) {
|
||||
const key = String(section?.key || '').trim()
|
||||
if (!key) return ''
|
||||
if (key === 'U') return 'UNASSIGNED'
|
||||
if (key.startsWith('B') || key.startsWith('F') || key.startsWith('G') || key.startsWith('T')) {
|
||||
return String(key.slice(1)).trim().toUpperCase()
|
||||
}
|
||||
return key.toUpperCase()
|
||||
}
|
||||
|
||||
function hashToHue(value) {
|
||||
const input = String(value || 'U')
|
||||
let hash = 0
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash * 33 + input.charCodeAt(i)) % 360
|
||||
}
|
||||
return (hash + 360) % 360
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -41,6 +41,7 @@ export const I18N = {
|
||||
liveViewPrelimOverall: 'الترتيب العام للتمهيدي',
|
||||
liveViewFinalGroups: 'شاشة مجموعات النهائي',
|
||||
liveViewFinalTie: 'كسر تعادل النهائي',
|
||||
liveViewFinalOverall: 'الترتيب العام للنهائي',
|
||||
liveViewPodium: 'منصة التتويج',
|
||||
showGroupLive: 'إظهار شاشة المجموعات الحية',
|
||||
showPrelimTie: 'إظهار كسر تعادل التمهيدي',
|
||||
@@ -57,6 +58,21 @@ export const I18N = {
|
||||
currentScore: 'النتيجة الحالية',
|
||||
aiSuggestedScore: 'النتيجة المقترحة',
|
||||
confidence: 'مستوى الثقة',
|
||||
stageControl: 'المرحلة الحالية',
|
||||
stagePhase: 'الطور',
|
||||
stageGroup: 'المجموعة',
|
||||
stageRound: 'الجولة',
|
||||
currentStage: 'المرحلة الجارية',
|
||||
lastStage: 'آخر مرحلة',
|
||||
startedAt: 'بدأت',
|
||||
endedAt: 'انتهت',
|
||||
noCurrentStage: 'لا توجد مرحلة جارية',
|
||||
noLastStage: 'لا توجد مرحلة سابقة',
|
||||
phasePreliminary: 'التمهيدي',
|
||||
phasePrelimTie: 'كسر تعادل التمهيدي',
|
||||
phaseFinal: 'النهائي',
|
||||
phaseFinalTie: 'كسر تعادل النهائي',
|
||||
groupAssignment: 'توزيع المجموعات',
|
||||
},
|
||||
sections: {
|
||||
groupsTitle: 'عرض اللاعبين والمجموعات',
|
||||
@@ -93,6 +109,7 @@ export const I18N = {
|
||||
competitor: 'اللاعب',
|
||||
group: 'المجموعة',
|
||||
rank: 'الترتيب',
|
||||
position: 'الموضع',
|
||||
score: 'النتيجة',
|
||||
total: 'المجموع',
|
||||
round1: 'جولة 1',
|
||||
@@ -142,6 +159,9 @@ export const I18N = {
|
||||
liveModeFixed: 'ثابت',
|
||||
enterFullscreen: 'دخول ملء الشاشة',
|
||||
exitFullscreen: 'الخروج من ملء الشاشة',
|
||||
startStage: 'بدء المرحلة',
|
||||
endStage: 'إنهاء المرحلة',
|
||||
goToStageScoring: 'الانتقال إلى إدخال نتائج المرحلة',
|
||||
},
|
||||
auth: {
|
||||
username: 'اسم المستخدم',
|
||||
@@ -176,6 +196,7 @@ export const I18N = {
|
||||
noNameToConvert: 'لا يوجد اسم للتحويل.',
|
||||
aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...',
|
||||
noProofForAi: 'لا توجد صورة إثبات لتحليلها.',
|
||||
noGroupForPhase: 'لا توجد مجموعات متاحة لهذا الطور.',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
@@ -220,6 +241,7 @@ export const I18N = {
|
||||
liveViewPrelimOverall: 'Preliminary overall',
|
||||
liveViewFinalGroups: 'Final groups board',
|
||||
liveViewFinalTie: 'Final tie-break',
|
||||
liveViewFinalOverall: 'Final overall ranking',
|
||||
liveViewPodium: 'Podium',
|
||||
showGroupLive: 'Show live group board',
|
||||
showPrelimTie: 'Show preliminary tie-break',
|
||||
@@ -236,6 +258,21 @@ export const I18N = {
|
||||
currentScore: 'Current score',
|
||||
aiSuggestedScore: 'AI suggested score',
|
||||
confidence: 'Confidence',
|
||||
stageControl: 'Current Stage Control',
|
||||
stagePhase: 'Phase',
|
||||
stageGroup: 'Group',
|
||||
stageRound: 'Round',
|
||||
currentStage: 'Current Stage',
|
||||
lastStage: 'Last Stage',
|
||||
startedAt: 'Started',
|
||||
endedAt: 'Ended',
|
||||
noCurrentStage: 'No active stage',
|
||||
noLastStage: 'No previous stage',
|
||||
phasePreliminary: 'Preliminary',
|
||||
phasePrelimTie: 'Preliminary Tie-Break',
|
||||
phaseFinal: 'Final',
|
||||
phaseFinalTie: 'Final Tie-Break',
|
||||
groupAssignment: 'Group Assignment',
|
||||
},
|
||||
sections: {
|
||||
groupsTitle: 'Players & Groups Overview',
|
||||
@@ -272,6 +309,7 @@ export const I18N = {
|
||||
competitor: 'Player',
|
||||
group: 'Group',
|
||||
rank: 'Rank',
|
||||
position: 'Position',
|
||||
score: 'Score',
|
||||
total: 'Total',
|
||||
round1: 'Round 1',
|
||||
@@ -321,6 +359,9 @@ export const I18N = {
|
||||
liveModeFixed: 'Fixed',
|
||||
enterFullscreen: 'Enter Fullscreen',
|
||||
exitFullscreen: 'Exit Fullscreen',
|
||||
startStage: 'Start Stage',
|
||||
endStage: 'End Stage',
|
||||
goToStageScoring: 'Go To Stage Scoring',
|
||||
},
|
||||
auth: {
|
||||
username: 'Username',
|
||||
@@ -355,6 +396,7 @@ export const I18N = {
|
||||
noNameToConvert: 'No name available to convert.',
|
||||
aiAnalyzing: 'Analyzing image with AI...',
|
||||
noProofForAi: 'No proof image available to analyze.',
|
||||
noGroupForPhase: 'No groups available for this phase.',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -398,6 +440,8 @@ export function createInitialState() {
|
||||
finalRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } },
|
||||
podium: [],
|
||||
},
|
||||
current_stage: null,
|
||||
last_stage: null,
|
||||
serverTime: '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,30 @@ body {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-shell.mode-live {
|
||||
background: #0d1931;
|
||||
}
|
||||
|
||||
.app-shell.mode-live .masthead {
|
||||
background: #0d1931;
|
||||
border-bottom: 1px solid rgba(0, 140, 168, 0.45);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.app-shell.mode-live .masthead::after {
|
||||
height: 3px;
|
||||
background: #ed2e23;
|
||||
}
|
||||
|
||||
.app-shell.mode-live .masthead-title,
|
||||
.app-shell.mode-live .masthead-subtitle {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.app-shell.mode-live .page-content {
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.hidden-file-input {
|
||||
display: none;
|
||||
}
|
||||
@@ -387,6 +411,107 @@ body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.live-settings-divider {
|
||||
border: 0;
|
||||
border-top: 1px dashed #d4deef;
|
||||
margin: 14px 0 10px;
|
||||
}
|
||||
|
||||
.stage-control-grid {
|
||||
grid-template-columns: 1.1fr 1fr 0.8fr;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stage-select-card {
|
||||
border: 1px solid #d4deef;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.stage-select-label {
|
||||
margin: 0 0 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: #5d6d8e;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.stage-selection-preview {
|
||||
margin: 8px 0 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 9px;
|
||||
background: #f4f8ff;
|
||||
color: #244372;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stage-action-row {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.stage-start-btn {
|
||||
min-width: 158px;
|
||||
box-shadow: 0 8px 18px rgba(0, 140, 168, 0.26);
|
||||
}
|
||||
|
||||
.stage-start-btn:disabled {
|
||||
background: #e7edf8;
|
||||
color: #6e7f9f;
|
||||
border-color: #d0d9ea;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.stage-status-grid {
|
||||
margin-top: 8px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stage-status-card {
|
||||
border: 1px solid #d4deef;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.stage-status-title {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stage-status-main {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
.stage-status-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 11px;
|
||||
color: #5c6a86;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
}
|
||||
|
||||
.stage-status-empty {
|
||||
margin: 6px 0 0;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stage-status-actions {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.switch-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -816,6 +941,24 @@ body {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.group-card-head-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.group-card-toggle {
|
||||
border: 1px solid #d5deef;
|
||||
background: #fff;
|
||||
color: #2f4063;
|
||||
border-radius: 8px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pm-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -939,6 +1082,17 @@ select.name-input {
|
||||
animation: score-over-limit-blink 0.7s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.stage-column-highlight {
|
||||
background: linear-gradient(180deg, rgba(0, 140, 168, 0.08) 0%, rgba(13, 25, 49, 0.04) 100%);
|
||||
border-top: 2px solid rgba(0, 140, 168, 0.35);
|
||||
}
|
||||
|
||||
.score-input-stage-highlight {
|
||||
border-color: #0d8a64;
|
||||
background: linear-gradient(180deg, #effaf6 0%, #e8f6ff 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(13, 138, 100, 0.18);
|
||||
}
|
||||
|
||||
@keyframes score-over-limit-blink {
|
||||
0%,
|
||||
100% {
|
||||
@@ -1049,7 +1203,7 @@ select.name-input {
|
||||
}
|
||||
|
||||
.live-panel {
|
||||
background: radial-gradient(circle at 15% 10%, #1a2653 0%, #101736 55%, #0b1026 100%);
|
||||
background: #0d1931;
|
||||
color: #fff;
|
||||
min-height: 70vh;
|
||||
position: relative;
|
||||
@@ -1067,7 +1221,7 @@ select.name-input {
|
||||
text-align: center;
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
color: #f1cf7a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.live-subtitle {
|
||||
@@ -1156,7 +1310,7 @@ select.name-input {
|
||||
height: 5px;
|
||||
width: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, var(--red), #f66f67);
|
||||
background: #ed2e23;
|
||||
animation: progress-5s linear;
|
||||
}
|
||||
|
||||
@@ -1173,9 +1327,10 @@ select.name-input {
|
||||
|
||||
.live-mode-root {
|
||||
position: relative;
|
||||
--live-footer-height: 74px;
|
||||
--live-footer-offset: 16px;
|
||||
padding-bottom: calc(var(--live-footer-height) + var(--live-footer-offset) + env(safe-area-inset-bottom));
|
||||
--live-navy: #0d1931;
|
||||
--live-blue: #008ca8;
|
||||
--live-red: #ed2e23;
|
||||
--live-white: #ffffff;
|
||||
}
|
||||
|
||||
.live-mode-shell {
|
||||
@@ -1183,29 +1338,30 @@ select.name-input {
|
||||
min-height: 72vh;
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background-size: contain;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
box-shadow: 0 16px 38px rgba(7, 19, 42, 0.24);
|
||||
display: grid;
|
||||
grid-template-rows: 1fr auto;
|
||||
border: 1px solid rgba(0, 140, 168, 0.5);
|
||||
box-shadow: 0 22px 44px rgba(0, 0, 0, 0.45);
|
||||
background: var(--live-navy);
|
||||
}
|
||||
|
||||
.live-mode-overlay {
|
||||
min-height: 72vh;
|
||||
padding: 16px;
|
||||
background: #171b3c;
|
||||
padding: 16px 16px 12px;
|
||||
background: var(--live-navy);
|
||||
}
|
||||
|
||||
.live-mode-header h2 {
|
||||
margin: 0;
|
||||
color: rgba(240, 248, 255, 0.84);
|
||||
color: var(--live-white);
|
||||
font-size: clamp(22px, 3vw, 34px);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.live-mode-header p {
|
||||
margin: 6px 0 12px;
|
||||
color: rgba(240, 248, 255, 0.84);
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.live-mode-grid {
|
||||
@@ -1219,11 +1375,10 @@ select.name-input {
|
||||
}
|
||||
|
||||
.live-screen-card {
|
||||
border: 1px solid rgba(200, 223, 255, 0.28);
|
||||
border: 1px solid rgba(0, 140, 168, 0.45);
|
||||
border-radius: 14px;
|
||||
background: #1a1e3f;
|
||||
box-shadow: 0 8px 18px rgba(7, 19, 42, 0.18);
|
||||
backdrop-filter: blur(3px);
|
||||
background: #0d1931;
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||
padding: 10px;
|
||||
animation: fade-up 0.32s ease both;
|
||||
}
|
||||
@@ -1238,9 +1393,9 @@ select.name-input {
|
||||
}
|
||||
|
||||
.live-focus-banner {
|
||||
border: 1px solid rgba(168, 205, 242, 0.4);
|
||||
border: 1px solid rgba(0, 140, 168, 0.45);
|
||||
border-radius: 14px;
|
||||
background: rgba(23, 27, 60, 0.9);
|
||||
background: #0d1931;
|
||||
padding: 10px 14px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
@@ -1252,7 +1407,7 @@ select.name-input {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.9px;
|
||||
color: rgba(201, 224, 248, 0.82);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -1261,8 +1416,8 @@ select.name-input {
|
||||
font-size: clamp(34px, 6vw, 58px);
|
||||
line-height: 0.95;
|
||||
letter-spacing: 1.2px;
|
||||
color: #f6d68a;
|
||||
text-shadow: 0 3px 10px rgba(0, 0, 0, 0.35);
|
||||
color: #ffffff;
|
||||
text-shadow: 0 3px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.live-focus-banner.focus-a .live-focus-title,
|
||||
@@ -1271,7 +1426,7 @@ select.name-input {
|
||||
.live-focus-banner.focus-d .live-focus-title,
|
||||
.live-focus-banner.focus-u .live-focus-title,
|
||||
.live-focus-banner.focus-final .live-focus-title {
|
||||
color: #d9e3f2;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.live-screen-head {
|
||||
@@ -1292,7 +1447,7 @@ select.name-input {
|
||||
|
||||
.live-screen-head h3 {
|
||||
margin: 0;
|
||||
color: #eaf4ff;
|
||||
color: var(--live-white);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
@@ -1306,17 +1461,18 @@ select.name-input {
|
||||
.live-chip {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(130, 187, 230, 0.38);
|
||||
background: rgba(27, 39, 75, 0.72);
|
||||
color: #cae9ff;
|
||||
border: 1px solid rgba(0, 140, 168, 0.45);
|
||||
background: #0d1931;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
|
||||
.live-chip.strong {
|
||||
border-color: rgba(255, 214, 124, 0.4);
|
||||
color: #ffd67c;
|
||||
border-color: rgba(237, 46, 35, 0.6);
|
||||
color: #ffffff;
|
||||
background: rgba(237, 46, 35, 0.16);
|
||||
}
|
||||
|
||||
.live-swap-enter-active,
|
||||
@@ -1339,9 +1495,8 @@ select.name-input {
|
||||
.live-mode-footer {
|
||||
margin-top: 0;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(158, 198, 238, 0.38);
|
||||
border-radius: 14px;
|
||||
background: #1a1e3f;
|
||||
border-top: 1px solid rgba(0, 140, 168, 0.45);
|
||||
background: #0d1931;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
align-items: center;
|
||||
@@ -1350,10 +1505,10 @@ select.name-input {
|
||||
}
|
||||
|
||||
.live-mode-fixed-footer {
|
||||
position: fixed;
|
||||
inset-inline: 24px;
|
||||
bottom: calc(var(--live-footer-offset) + env(safe-area-inset-bottom));
|
||||
box-shadow: 0 10px 24px rgba(8, 18, 39, 0.02);
|
||||
position: static;
|
||||
inset-inline: auto;
|
||||
bottom: auto;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.live-footer-slot {
|
||||
@@ -1375,21 +1530,265 @@ select.name-input {
|
||||
|
||||
.live-footer-logo {
|
||||
max-width: min(35vw, 200px);
|
||||
padding: 10px;
|
||||
padding: 8px 6px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.28));
|
||||
}
|
||||
|
||||
.live-footer-slot.right .live-footer-logo {
|
||||
max-width: min(45vw, 280px);
|
||||
}
|
||||
|
||||
.live-tie-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.live-tie-group-card {
|
||||
border: 1px solid rgba(0, 140, 168, 0.45);
|
||||
border-radius: 12px;
|
||||
background: #0d1931;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.live-tie-group-title {
|
||||
margin: 2px 0 8px;
|
||||
font-size: 14px;
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.multi-round-cell {
|
||||
min-width: 98px;
|
||||
}
|
||||
|
||||
.position-board {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.position-board-head {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.position-board-toggle {
|
||||
width: 100%;
|
||||
border: 1px solid #d4deed;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f3f8ff 100%);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.position-board-title {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: #1b1f40;
|
||||
}
|
||||
|
||||
.position-board-meta {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
color: #576284;
|
||||
}
|
||||
|
||||
.position-board-alert {
|
||||
margin: 6px 4px 0;
|
||||
display: inline-block;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
background: #ed2e23;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
animation: position-overflow-text-flash 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.position-board-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.position-column {
|
||||
border: 1px solid #d4deed;
|
||||
border-radius: 12px;
|
||||
background: #f7faff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.position-column.position-column-overflow {
|
||||
border-color: #ed2e23;
|
||||
animation: position-overflow-flash 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.position-column.position-column-overflow .position-column-head {
|
||||
background: rgba(237, 46, 35, 0.14);
|
||||
border-bottom-color: rgba(237, 46, 35, 0.5);
|
||||
}
|
||||
|
||||
.position-column-head {
|
||||
border-bottom: 1px solid #d8e2f2;
|
||||
background: #edf3ff;
|
||||
}
|
||||
|
||||
.position-column-toggle {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes position-overflow-flash {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(237, 46, 35, 0.08);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px rgba(237, 46, 35, 0.32);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(237, 46, 35, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.position-column-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1b1f40;
|
||||
}
|
||||
|
||||
.position-column-count {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
color: #576284;
|
||||
}
|
||||
|
||||
.position-column-count.danger {
|
||||
color: #a31610;
|
||||
font-weight: 800;
|
||||
animation: position-overflow-text-flash 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.position-column-alert {
|
||||
margin: 0 12px 8px;
|
||||
display: inline-block;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
background: #ed2e23;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
animation: position-overflow-text-flash 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes position-overflow-text-flash {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.position-column-list {
|
||||
min-height: 72px;
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.position-card {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
border: 1px solid #d1dbed;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.position-card:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.position-badge {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
background: #1b1f40;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.position-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1px solid #d4deed;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.position-card-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.position-card-text .name-main {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.position-card-text .name-sub {
|
||||
margin: 1px 0 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.position-score {
|
||||
margin: 4px 0 0;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 11px;
|
||||
color: #556286;
|
||||
}
|
||||
|
||||
.position-empty {
|
||||
border: 1px dashed #cfd8ea;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: #7483a7;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.position-actions {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.score-input-compact {
|
||||
width: 84px;
|
||||
min-height: 36px;
|
||||
@@ -1468,27 +1867,38 @@ select.name-input {
|
||||
aspect-ratio: 16 / 10;
|
||||
}
|
||||
|
||||
.live-score-table thead th {
|
||||
background: #171b3c;
|
||||
.live-score-table thead th,
|
||||
.live-screen-card .score-table thead th {
|
||||
background: #008ca8;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.live-screen-card .score-table {
|
||||
background: #0d1931;
|
||||
}
|
||||
|
||||
.live-screen-card .score-table td {
|
||||
color: #f2f8ff;
|
||||
border-top-color: rgba(158, 186, 216, 0.22);
|
||||
color: #ffffff;
|
||||
border-top-color: rgba(0, 140, 168, 0.24);
|
||||
background: #0d1931;
|
||||
}
|
||||
|
||||
.live-screen-card .name-main {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.live-screen-card .name-sub,
|
||||
.live-screen-card .muted {
|
||||
color: rgba(213, 232, 255, 0.72);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.live-screen-card .table-wrap {
|
||||
border-color: rgba(158, 186, 216, 0.28);
|
||||
background: #171b3c;
|
||||
border-color: rgba(0, 140, 168, 0.32);
|
||||
background: #0d1931;
|
||||
}
|
||||
|
||||
.live-screen-card .qualified-row {
|
||||
background: rgba(16, 160, 194, 0.12);
|
||||
background: rgba(0, 140, 168, 0.16);
|
||||
}
|
||||
|
||||
.live-podium {
|
||||
@@ -1499,8 +1909,8 @@ select.name-input {
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col {
|
||||
background: #1a1e3f;
|
||||
border: 1px solid rgba(177, 205, 236, 0.26);
|
||||
background: #0d1931;
|
||||
border: 1px solid rgba(0, 140, 168, 0.34);
|
||||
box-shadow: 0 18px 34px rgba(2, 10, 25, 0.45);
|
||||
min-width: 0;
|
||||
max-width: 330px;
|
||||
@@ -1508,21 +1918,21 @@ select.name-input {
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-1 {
|
||||
border-top: 10px solid rgba(231, 196, 95, 0.4);
|
||||
background: #1a1e3f;
|
||||
border-top: 10px solid #d4af37;
|
||||
background: #0d1931;
|
||||
height: 430px;
|
||||
box-shadow: 0 22px 44px rgba(231, 196, 95, 0.15);
|
||||
box-shadow: 0 22px 44px rgba(212, 175, 55, 0.2);
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-2 {
|
||||
border-top: 10px solid rgba(184, 195, 215, 0.4);
|
||||
background: #1a1e3f;
|
||||
border-top: 10px solid #c0c0c0;
|
||||
background: #0d1931;
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-3 {
|
||||
border-top: 10px solid rgba(207, 139, 69, 0.4);
|
||||
background: #1a1e3f;
|
||||
border-top: 10px solid #cd7f32;
|
||||
background: #0d1931;
|
||||
height: 320px;
|
||||
}
|
||||
|
||||
@@ -1538,6 +1948,20 @@ select.name-input {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border-width: 6px;
|
||||
border-color: rgba(0, 140, 168, 0.82);
|
||||
background: #0d1931;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-1 .podium-img {
|
||||
border-color: #d4af37;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-2 .podium-img {
|
||||
border-color: #c0c0c0;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-3 .podium-img {
|
||||
border-color: #cd7f32;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-1 .podium-img {
|
||||
@@ -1575,7 +1999,7 @@ select.name-input {
|
||||
}
|
||||
|
||||
.podium-live-card .podium-name.empty {
|
||||
color: rgba(204, 220, 240, 0.78);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 22px;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
@@ -1585,20 +2009,32 @@ select.name-input {
|
||||
|
||||
.podium-live-card .podium-score {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ffd888;
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
border-color: rgba(0, 140, 168, 0.34);
|
||||
margin-bottom: 0;
|
||||
font-size: 16px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-1 .podium-score {
|
||||
border-color: #d4af37;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-2 .podium-score {
|
||||
border-color: #c0c0c0;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-3 .podium-score {
|
||||
border-color: #cd7f32;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-col.pos-1 .podium-score {
|
||||
font-size: 18px;
|
||||
padding: 12px 18px;
|
||||
}
|
||||
|
||||
.podium-live-card .podium-medal-icon {
|
||||
filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.35));
|
||||
filter: grayscale(1) brightness(1.2) drop-shadow(0 4px 10px rgba(0, 0, 0, 0.35));
|
||||
font-size: 60px;
|
||||
margin-top: 86px;
|
||||
}
|
||||
@@ -1774,7 +2210,7 @@ select.name-input {
|
||||
}
|
||||
|
||||
.podium-live-card .podium-score-sub {
|
||||
color: rgba(220, 234, 252, 0.86);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.toast {
|
||||
@@ -1874,6 +2310,97 @@ select.name-input {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.score-section-block {
|
||||
border: 1px solid var(--score-group-border, var(--border));
|
||||
border-radius: 12px;
|
||||
background: var(--score-group-soft, #f7f9ff);
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.score-section-block.score-section-overflow {
|
||||
border-color: #ed2e23;
|
||||
animation: score-section-overflow-flash 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.score-section-toggle {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
box-shadow: inset 4px 0 0 var(--score-group-accent, #7f8ca8);
|
||||
}
|
||||
|
||||
.score-section-title {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.score-section-meta {
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.score-section-alert {
|
||||
margin: 0 12px 8px;
|
||||
display: inline-block;
|
||||
font-family: "IBM Plex Mono", monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
background: #ed2e23;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
animation: score-section-overflow-text-flash 1.1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.score-section-block .table-wrap {
|
||||
border: 0;
|
||||
border-top: 1px dashed var(--score-group-border, #d2daec);
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.score-section-block.tie-break {
|
||||
box-shadow: 0 8px 20px rgba(27, 31, 64, 0.08);
|
||||
}
|
||||
|
||||
.score-section-block.tie-break .score-section-toggle {
|
||||
background: var(--score-group-soft, rgba(127, 140, 168, 0.14));
|
||||
box-shadow: inset 6px 0 0 var(--score-group-accent, #7f8ca8);
|
||||
}
|
||||
|
||||
@keyframes score-section-overflow-flash {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(237, 46, 35, 0.1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px rgba(237, 46, 35, 0.3);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(237, 46, 35, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes score-section-overflow-text-flash {
|
||||
0% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-score-cards {
|
||||
display: none;
|
||||
}
|
||||
@@ -2323,7 +2850,9 @@ select.name-input {
|
||||
}
|
||||
|
||||
.live-settings-grid,
|
||||
.inline-settings {
|
||||
.inline-settings,
|
||||
.stage-control-grid,
|
||||
.stage-status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -2339,11 +2868,6 @@ select.name-input {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.live-mode-root {
|
||||
--live-footer-height: 62px;
|
||||
--live-footer-offset: 10px;
|
||||
}
|
||||
|
||||
.live-mode-shell {
|
||||
border-radius: 12px;
|
||||
min-height: 70dvh;
|
||||
@@ -2373,7 +2897,7 @@ select.name-input {
|
||||
}
|
||||
|
||||
.live-mode-fixed-footer {
|
||||
inset-inline: 10px;
|
||||
inset-inline: auto;
|
||||
}
|
||||
|
||||
.live-footer-logo {
|
||||
@@ -2384,6 +2908,10 @@ select.name-input {
|
||||
max-width: min(40vw, 150px);
|
||||
}
|
||||
|
||||
.live-tie-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { normalizedGroupCode } from './groups'
|
||||
|
||||
const PRESET = {
|
||||
A: { accent: '#2f4d9a', soft: 'rgba(47, 77, 154, 0.10)', border: 'rgba(47, 77, 154, 0.34)' },
|
||||
B: { accent: '#0d8fa5', soft: 'rgba(13, 143, 165, 0.10)', border: 'rgba(13, 143, 165, 0.34)' },
|
||||
C: { accent: '#d54a3f', soft: 'rgba(213, 74, 63, 0.10)', border: 'rgba(213, 74, 63, 0.34)' },
|
||||
D: { accent: '#c2891f', soft: 'rgba(194, 137, 31, 0.12)', border: 'rgba(194, 137, 31, 0.36)' },
|
||||
F1: { accent: '#5b4fc9', soft: 'rgba(91, 79, 201, 0.11)', border: 'rgba(91, 79, 201, 0.36)' },
|
||||
F2: { accent: '#0f9f63', soft: 'rgba(15, 159, 99, 0.11)', border: 'rgba(15, 159, 99, 0.36)' },
|
||||
U: { accent: '#7f8ca8', soft: 'rgba(127, 140, 168, 0.11)', border: 'rgba(127, 140, 168, 0.34)' },
|
||||
A: { accent: '#0d1931', soft: 'rgba(13, 25, 49, 0.14)', border: 'rgba(13, 25, 49, 0.44)' },
|
||||
B: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' },
|
||||
C: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' },
|
||||
D: { accent: '#1b325f', soft: 'rgba(27, 50, 95, 0.14)', border: 'rgba(27, 50, 95, 0.42)' },
|
||||
F1: { accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.14)', border: 'rgba(237, 46, 35, 0.44)' },
|
||||
F2: { accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.14)', border: 'rgba(0, 140, 168, 0.44)' },
|
||||
U: { accent: '#4c5f86', soft: 'rgba(76, 95, 134, 0.14)', border: 'rgba(76, 95, 134, 0.4)' },
|
||||
}
|
||||
|
||||
const THEME_PALETTE = [
|
||||
{ accent: '#0d1931', soft: 'rgba(13, 25, 49, 0.15)', border: 'rgba(13, 25, 49, 0.46)' },
|
||||
{ accent: '#008ca8', soft: 'rgba(0, 140, 168, 0.15)', border: 'rgba(0, 140, 168, 0.46)' },
|
||||
{ accent: '#ed2e23', soft: 'rgba(237, 46, 35, 0.15)', border: 'rgba(237, 46, 35, 0.46)' },
|
||||
{ accent: '#1b325f', soft: 'rgba(27, 50, 95, 0.15)', border: 'rgba(27, 50, 95, 0.44)' },
|
||||
{ accent: '#0d5f72', soft: 'rgba(13, 95, 114, 0.15)', border: 'rgba(13, 95, 114, 0.44)' },
|
||||
{ accent: '#b8261d', soft: 'rgba(184, 38, 29, 0.15)', border: 'rgba(184, 38, 29, 0.44)' },
|
||||
]
|
||||
|
||||
export function scoreGroupToken(row) {
|
||||
const finalGroup = Number(row?.finalGroup || 0)
|
||||
if (finalGroup === 1) return 'F1'
|
||||
@@ -30,12 +39,7 @@ export function scoreGroupStyle(row) {
|
||||
}
|
||||
}
|
||||
|
||||
const hue = hashToHue(token)
|
||||
return {
|
||||
'--score-group-accent': `hsl(${hue} 70% 38%)`,
|
||||
'--score-group-soft': `hsla(${hue}, 72%, 46%, 0.10)`,
|
||||
'--score-group-border': `hsla(${hue}, 70%, 38%, 0.34)`,
|
||||
}
|
||||
return groupThemeStyleForKey(token)
|
||||
}
|
||||
|
||||
export function scoreValueStyle(scoreValue) {
|
||||
@@ -49,6 +53,26 @@ export function scoreValueStyle(scoreValue) {
|
||||
}
|
||||
}
|
||||
|
||||
export function groupThemeStyleForKey(key, options = {}) {
|
||||
const raw = String(key || '').trim().toUpperCase()
|
||||
const compact = raw.replace(/^[A-Z]/, '')
|
||||
const numeric = Number.parseInt(compact || raw, 10)
|
||||
const idx = Number.isFinite(numeric) && numeric > 0 ? (numeric - 1) % THEME_PALETTE.length : hashToHue(raw || 'U') % THEME_PALETTE.length
|
||||
const base = THEME_PALETTE[idx]
|
||||
if (!options.tieBreak) {
|
||||
return {
|
||||
'--score-group-accent': base.accent,
|
||||
'--score-group-soft': base.soft,
|
||||
'--score-group-border': base.border,
|
||||
}
|
||||
}
|
||||
return {
|
||||
'--score-group-accent': base.accent,
|
||||
'--score-group-soft': base.soft.replace('0.15', '0.2'),
|
||||
'--score-group-border': base.border,
|
||||
}
|
||||
}
|
||||
|
||||
function hashToHue(value) {
|
||||
const input = String(value || 'U')
|
||||
let hash = 0
|
||||
|
||||
Reference in New Issue
Block a user