This commit is contained in:
2026-05-18 13:22:11 +04:00
parent 8cda54548f
commit 5863574a78
24 changed files with 3060 additions and 467 deletions

182
API_CHANGES.md Normal file
View 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.

View File

@@ -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'),

View File

@@ -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 {

View File

@@ -84,13 +84,30 @@ type FinalGroups struct {
}
type StateResponse struct {
Competition CompetitionMeta `json:"competition"`
Players []Player `json:"players"`
Scores map[string]map[string]int `json:"scores"`
ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"`
Settings AppSettings `json:"settings"`
Derived DerivedState `json:"derived"`
ServerTime string `json:"serverTime"`
Competition CompetitionMeta `json:"competition"`
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 {
@@ -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"`
}

View File

@@ -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"
]
},

View File

@@ -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)

View File

@@ -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
View 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
}

View File

@@ -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,17 +53,32 @@ 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{
TitleAr: "بطولة دويتوايلر للرماية",
TitleEn: "Datwyler Shooting Event",
},
Players: players,
Scores: scoreMapToJSON(scoreMap),
Settings: settings,
Derived: derived,
ServerTime: time.Now().UTC().Format(time.RFC3339),
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 {
response.ScoreProofs = scoreProofMapToJSON(scoreProofs)
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -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')

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
<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>
</footer>
</section>
<footer class="live-mode-footer live-mode-fixed-footer">
<div class="live-footer-slot left">
<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>
</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>

View File

@@ -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>

View File

@@ -9,24 +9,102 @@
/>
</div>
<div class="table-wrap desktop-score-table">
<table class="score-table">
<thead>
<tr>
<th>#</th>
<th>{{ t('table.competitor') }}</th>
<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>{{ 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)">
<td class="mono">{{ index + 1 }}</td>
<td>
<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>
<tr>
<th>#</th>
<th>{{ t('table.competitor') }}</th>
<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"
: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 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">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<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"
: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), 'score-input-stage-highlight': isHighlightedStage(round.stage, section) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(round.stage, row.playerId)"
@focus="onScoreFocus(round.stage, row.playerId)"
@input="onScoreInput(round.stage, row.playerId, $event)"
@blur="onScoreCommit(round.stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
/>
</td>
<td class="mono strong">{{ totalScore(row.playerId) }}</td>
<td class="verify-round-cell">
<div class="verify-round-list">
<div v-for="round in roundStages" :key="'verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
<span class="verify-round-label">{{ round.label }}</span>
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
</button>
<img
v-if="hasScoreProof(round.stage, row.playerId)"
class="proof-thumb"
:src="scoreProofFor(round.stage, row.playerId)"
:alt="t('table.verification')"
@click="openProofPreview(round.stage, row.playerId)"
/>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="mobile-score-cards">
<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" />
<div>
@@ -34,30 +112,49 @@
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<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">
<input
class="score-input score-input-compact"
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(round.stage, row.playerId)"
@focus="onScoreFocus(round.stage, row.playerId)"
@input="onScoreInput(round.stage, row.playerId, $event)"
@blur="onScoreCommit(round.stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
/>
</td>
<td class="mono strong">{{ totalScore(row.playerId) }}</td>
<td class="verify-round-cell">
<div class="verify-round-list">
<div v-for="round in roundStages" :key="'verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
<div class="mono">#{{ index + 1 }}</div>
</div>
<div class="score-card-meta">
<span v-if="showGroup">{{ t('table.group') }}: {{ groupCell(row) }}</span>
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
</div>
<div class="mobile-round-grid">
<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), 'score-input-stage-highlight': isHighlightedStage(round.stage, section) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(round.stage, row.playerId)"
@focus="onScoreFocus(round.stage, row.playerId)"
@input="onScoreInput(round.stage, row.playerId, $event)"
@blur="onScoreCommit(round.stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
/>
</label>
</div>
<div class="mobile-total-line">
<span class="muted">{{ t('table.total') }}</span>
<strong class="mono">{{ totalScore(row.playerId) }}</strong>
</div>
<div class="mobile-verify-block">
<p class="mobile-verify-title">{{ t('table.verification') }}</p>
<div class="verify-round-list mobile">
<div v-for="round in roundStages" :key="'mobile-verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
<span class="verify-round-label">{{ round.label }}</span>
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
@@ -71,86 +168,20 @@
/>
</div>
</div>
</td>
</tr>
<tr v-if="filteredRows.length === 0">
<td :colspan="columnCount" class="muted center">{{ t('labels.noPlayers') }}</td>
</tr>
</tbody>
</table>
</div>
</article>
</div>
</template>
</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)">
<div class="score-card-head">
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
<div class="mono">#{{ index + 1 }}</div>
</div>
<div class="score-card-meta">
<span v-if="showGroup">{{ t('table.group') }}: {{ groupCell(row) }}</span>
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
</div>
<div class="mobile-round-grid">
<label v-for="round in roundStages" :key="'mobile-round-' + round.stage + '-' + row.playerId" class="mobile-round-item">
<span class="score-label">{{ round.label }}</span>
<input
class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(round.stage, row.playerId)"
@focus="onScoreFocus(round.stage, row.playerId)"
@input="onScoreInput(round.stage, row.playerId, $event)"
@blur="onScoreCommit(round.stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(round.stage, row.playerId)"
/>
</label>
</div>
<div class="mobile-total-line">
<span class="muted">{{ t('table.total') }}</span>
<strong class="mono">{{ totalScore(row.playerId) }}</strong>
</div>
<div class="mobile-verify-block">
<p class="mobile-verify-title">{{ t('table.verification') }}</p>
<div class="verify-round-list mobile">
<div v-for="round in roundStages" :key="'mobile-verify-' + round.stage + '-' + row.playerId" class="verify-round-item">
<span class="verify-round-label">{{ round.label }}</span>
<button class="btn btn-outline btn-xs" @click="openScoreProofUploader(round.stage, row.playerId)">
{{ hasScoreProof(round.stage, row.playerId) ? t('actions.replaceProof') : t('actions.uploadProof') }}
</button>
<img
v-if="hasScoreProof(round.stage, row.playerId)"
class="proof-thumb"
:src="scoreProofFor(round.stage, row.playerId)"
:alt="t('table.verification')"
@click="openProofPreview(round.stage, row.playerId)"
/>
</div>
</div>
</div>
</article>
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</div>
</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>

View File

@@ -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>
<span class="pm-count-badge mono">{{ group.players.length }}</span>
<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

View 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>

View File

@@ -9,24 +9,94 @@
/>
</div>
<div class="table-wrap desktop-score-table">
<table class="score-table">
<thead>
<tr>
<th>#</th>
<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>{{ 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)">
<td class="mono">{{ index + 1 }}</td>
<td>
<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>
<tr>
<th>#</th>
<th>{{ t('table.competitor') }}</th>
<th v-if="showGroup">{{ t('table.group') }}</th>
<th v-if="showScoreBeforeInput">{{ t('table.score') }}</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 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">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td v-if="showGroup" class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
<td v-if="showScoreBeforeInput" class="mono strong">{{ row.score }}</td>
<td :class="{ 'stage-column-highlight': isHighlightedStage(stage, section) }">
<input
class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId), 'score-input-stage-highlight': isHighlightedStage(stage, section) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)"
@blur="onScoreCommit(stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
/>
</td>
<td>
<div class="proof-actions">
<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-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
{{ t('actions.removeProof') }}
</button>
<img
v-if="hasScoreProof(stage, row.playerId)"
class="proof-thumb"
:src="scoreProofFor(stage, row.playerId)"
:alt="t('table.verification')"
@click="openProofPreview(stage, row.playerId)"
/>
</div>
</td>
<td v-if="showRank" class="mono rank">{{ row.rank }}</td>
<td v-if="showSeed" class="mono">{{ row.seed }}</td>
</tr>
</tbody>
</table>
</div>
<div class="mobile-score-cards">
<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" />
<div>
@@ -34,127 +104,60 @@
<p class="name-sub">{{ secondaryName(row) }}</p>
</div>
</div>
</td>
<td v-if="showGroup" class="mono">{{ row.groupCode || t('labels.unassigned') }}</td>
<td v-if="showScoreBeforeInput" class="mono strong">{{ row.score }}</td>
<td>
<input
class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)"
@blur="onScoreCommit(stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
/>
</td>
<td>
<div class="proof-actions">
<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>
<img
v-if="hasScoreProof(stage, row.playerId)"
class="proof-thumb"
:src="scoreProofFor(stage, row.playerId)"
:alt="t('table.verification')"
@click="openProofPreview(stage, row.playerId)"
/>
</div>
</td>
<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)">
<div class="score-card-head">
<div class="competitor-cell compact">
<img :src="playerImage(row)" :alt="row.nameAr" class="competitor-image" />
<div>
<p class="name-main">{{ displayName(row) }}</p>
<p class="name-sub">{{ secondaryName(row) }}</p>
<div class="mono">#{{ index + 1 }}</div>
</div>
</div>
<div class="mono">#{{ index + 1 }}</div>
</div>
<div class="score-card-meta">
<span v-if="showGroup">{{ t('table.group') }}: {{ row.groupCode || t('labels.unassigned') }}</span>
<span v-if="showScoreBeforeInput">{{ t('table.score') }}: {{ row.score }}</span>
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
</div>
<div class="score-card-meta">
<span v-if="showGroup">{{ t('table.group') }}: {{ row.groupCode || t('labels.unassigned') }}</span>
<span v-if="showScoreBeforeInput">{{ t('table.score') }}: {{ row.score }}</span>
<span v-if="showRank">{{ t('table.rank') }}: {{ row.rank }}</span>
<span v-if="showSeed">{{ t('table.seed') }}: {{ row.seed }}</span>
</div>
<label class="score-label">{{ inputLabel }}</label>
<input
class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)"
@blur="onScoreCommit(stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
/>
<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), 'score-input-stage-highlight': isHighlightedStage(stage, section) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="100"
:value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)"
@blur="onScoreCommit(stage, row.playerId)"
@keydown.enter.prevent="onScoreCommit(stage, row.playerId)"
/>
<div class="proof-actions mobile-proof-actions">
<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>
<img
v-if="hasScoreProof(stage, row.playerId)"
class="proof-thumb"
:src="scoreProofFor(stage, row.playerId)"
:alt="t('table.verification')"
@click="openProofPreview(stage, row.playerId)"
/>
<div class="proof-actions mobile-proof-actions">
<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-danger btn-xs" @click="removeScoreProof(stage, row.playerId)">
{{ t('actions.removeProof') }}
</button>
<img
v-if="hasScoreProof(stage, row.playerId)"
class="proof-thumb"
:src="scoreProofFor(stage, row.playerId)"
:alt="t('table.verification')"
@click="openProofPreview(stage, row.playerId)"
/>
</div>
</article>
</div>
</article>
<div v-if="filteredRows.length === 0" class="empty-state">{{ t('labels.noPlayers') }}</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>

View File

@@ -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: '',
}
}

View File

@@ -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;
}

View File

@@ -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