From 2465bc2ec385e3967f28edb124dc7aac48c1293e Mon Sep 17 00:00:00 2001 From: Dian Afif Date: Wed, 1 Apr 2026 11:47:03 +0400 Subject: [PATCH] update --- .dockerignore | 11 + .env | 1 + .env.example | 6 + .gitignore | 21 + Dockerfile | 30 + Makefile | 56 + README.md | 151 ++ backend/.env | 1 + backend/ai_handlers.go | 95 + backend/auth.go | 93 + backend/config.go | 36 + backend/db.go | 76 + backend/events.go | 83 + backend/gemini.go | 203 ++ backend/go.mod | 29 + backend/go.sum | 75 + backend/handlers.go | 461 +++++ backend/main.go | 54 + backend/models.go | 141 ++ backend/routes.go | 57 + backend/settings.go | 47 + backend/state.go | 482 +++++ docker-compose.yml | 32 + frontend/index.html | 15 + frontend/package.json | 21 + frontend/pnpm-lock.yaml | 747 +++++++ frontend/src/App.vue | 1195 +++++++++++ frontend/src/components/AdminLoginPanel.vue | 35 + frontend/src/components/AdminPanel.vue | 355 ++++ frontend/src/components/AppHeader.vue | 47 + frontend/src/components/ImageCropModal.vue | 190 ++ frontend/src/components/ProofModal.vue | 24 + frontend/src/components/ResetStageModal.vue | 24 + frontend/src/components/ScoreAdviceModal.vue | 61 + frontend/src/components/ViewModePanel.vue | 465 +++++ .../components/admin/PlayersManagementTab.vue | 259 +++ .../src/components/admin/ScoreStageEditor.vue | 202 ++ frontend/src/constants/i18n.js | 313 +++ frontend/src/main.js | 5 + frontend/src/style.css | 1749 +++++++++++++++++ frontend/src/utils/groups.js | 28 + frontend/src/utils/nameTransliteration.js | 220 +++ frontend/vite.config.js | 14 + 43 files changed, 8210 insertions(+) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 backend/.env create mode 100644 backend/ai_handlers.go create mode 100644 backend/auth.go create mode 100644 backend/config.go create mode 100644 backend/db.go create mode 100644 backend/events.go create mode 100644 backend/gemini.go create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/handlers.go create mode 100644 backend/main.go create mode 100644 backend/models.go create mode 100644 backend/routes.go create mode 100644 backend/settings.go create mode 100644 backend/state.go create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/components/AdminLoginPanel.vue create mode 100644 frontend/src/components/AdminPanel.vue create mode 100644 frontend/src/components/AppHeader.vue create mode 100644 frontend/src/components/ImageCropModal.vue create mode 100644 frontend/src/components/ProofModal.vue create mode 100644 frontend/src/components/ResetStageModal.vue create mode 100644 frontend/src/components/ScoreAdviceModal.vue create mode 100644 frontend/src/components/ViewModePanel.vue create mode 100644 frontend/src/components/admin/PlayersManagementTab.vue create mode 100644 frontend/src/components/admin/ScoreStageEditor.vue create mode 100644 frontend/src/constants/i18n.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/style.css create mode 100644 frontend/src/utils/groups.js create mode 100644 frontend/src/utils/nameTransliteration.js create mode 100644 frontend/vite.config.js diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fb33dda --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +node_modules +**/node_modules +frontend/dist +bin +backend/data +data +*.db +*.db-* +.DS_Store diff --git a/.env b/.env new file mode 100644 index 0000000..053cbd4 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +GEMINI_API_KEY=AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0f3b63c --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +APP_PORT=8080 +IMAGE=repo.ssp-itinfra.com/admin/shooting-event:amd64-latest +ADMIN_USER=datwyler +ADMIN_PASS=datwyler +GEMINI_API_KEY=replace-with-your-key +GEMINI_MODEL=gemini-2.0-flash diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f75608 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# macOS +.DS_Store + +# Node +frontend/node_modules/ +frontend/dist/ + +node_modules/ +dist/ +# Go binaries +bin/ +backend/backend + +# Runtime/generated web assets +backend/web/ + +# SQLite runtime data +backend/data/ +*.db +*.db-shm +*.db-wal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e7c8a5b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# syntax=docker/dockerfile:1.7 + +FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder +WORKDIR /app/frontend +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN corepack enable && pnpm install --frozen-lockfile +COPY frontend/ ./ +RUN pnpm build + +FROM --platform=$BUILDPLATFORM golang:1.24 AS backend-builder +WORKDIR /app/backend +COPY backend/go.mod backend/go.sum ./ +RUN go mod download +COPY backend/ ./ +COPY --from=frontend-builder /app/frontend/dist ./web +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH:-amd64} go build -trimpath -ldflags="-s -w" -o /out/shooting-event . + +FROM --platform=$TARGETPLATFORM alpine:3.21 +RUN addgroup -S app && adduser -S app -G app && apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=backend-builder /out/shooting-event /app/shooting-event +COPY --from=backend-builder /app/backend/web /app/web +RUN mkdir -p /app/data && chown -R app:app /app +USER app +ENV PORT=8080 +ENV DB_PATH=/app/data/shooting.db +ENV WEB_DIR=/app/web +EXPOSE 8080 +ENTRYPOINT ["/app/shooting-event"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ecfd1f6 --- /dev/null +++ b/Makefile @@ -0,0 +1,56 @@ +SHELL := /bin/bash + +PNPM ?= pnpm +GO ?= go +CONTAINER_REG ?= repo.ssp-itinfra.com/admin +IMAGE_NAME ?= shooting-event +ARCH ?= amd64 +BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +BUILD_VERSION ?= $(shell git describe --tags --always --dirty) + +.PHONY: install dev dev-backend dev-frontend build build-frontend build-backend docker-build docker-run clean + +install: + cd frontend && $(PNPM) install + cd backend && $(GO) mod tidy + +dev-backend: + cd backend && $(GO) run . + +dev-frontend: + cd frontend && $(PNPM) dev + +dev: + @set -euo pipefail; \ + trap 'kill 0' INT TERM EXIT; \ + $(MAKE) dev-backend & \ + $(MAKE) dev-frontend & \ + wait + +build-frontend: + cd frontend && $(PNPM) build + +build-backend: + mkdir -p bin + cd backend && $(GO) build -o ../bin/shooting-event . + +build: build-frontend + rm -rf backend/web + mkdir -p backend/web + cp -R frontend/dist/. backend/web/ + $(MAKE) build-backend + +docker-build: + docker buildx build --load --platform=linux/$(ARCH) -t $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) . + docker tag $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-latest + docker tag $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-$(BUILD_VERSION) + docker push $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) + docker push $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-latest + docker push $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-$(BUILD_VERSION) + +docker-run: + mkdir -p data + docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) + +clean: + rm -rf bin frontend/dist backend/web diff --git a/README.md b/README.md index e69de29..ea9982c 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,151 @@ +# Datwyler Shooting Event System + +Production-ready full-stack web app based on your original live score concept. + +## Stack + +- Frontend: Vue 3 + Vite (pnpm) +- Backend: Go + Echo +- Database: SQLite +- Packaging: Single Docker image (frontend + backend) + +## Main Features + +- Bilingual UI: Arabic and English +- Runtime RTL/LTR switching +- Admin avatar crop/fit tool (drag + zoom before saving) +- AI score advisor for proof images (Gemini-powered suggestion + optional apply) +- Two clean modes: + - View Only screen for players/coaches/audience + - Admin Control Panel (login required) +- Admin credentials (default): + - Username: `datwyler` + - Password: `datwyler` + +## Tournament Flow Implemented + +1. Admin registers players and assigns groups (no hard 6-player limit). +2. View screen shows group assignment clearly. +3. Admin enters preliminary scores. +4. Overall ranking auto-calculates and highlights top 12 finalists. +5. If rank #12 cutoff is tied, qualification tie-break stage appears. +6. Top 12 split into final groups (1-6 and 7-12 seeds). +7. Admin enters final scores. +8. Podium is determined automatically. +9. If top-3 tie exists, podium tie-break stage appears. + +## API Highlights + +Public: + +- `GET /api/health` +- `GET /api/state` + +Admin: + +- `POST /api/admin/login` +- `POST /api/admin/logout` +- `POST /api/admin/players` +- `PUT /api/admin/players/:id` +- `DELETE /api/admin/players/:id` +- `PUT /api/admin/scores/:stage/:id` +- `POST /api/admin/scores/:stage/:id/advice` +- `POST /api/admin/scores/:stage/reset` + +Stages: + +- `preliminary` +- `prelim_tiebreak` +- `final` +- `final_tiebreak` + +## Local Development + +Install dependencies: + +```bash +make install +``` + +Run backend + frontend together: + +```bash +make dev +``` + +Notes: + +- Backend dev port is `18081`. +- Frontend runs on `5173` (or next free port, e.g. `5174` if busy). +- Frontend proxy is configured so `/api/*` works from Vite dev server. + +Run individually: + +```bash +make dev-backend +make dev-frontend +``` + +## Build + +```bash +make build +``` + +This builds frontend assets, copies them into backend `web/`, and compiles backend binary. + +## Docker + +Build image: + +```bash +make docker-build ARCH=amd64 +# or +make docker-build ARCH=arm64 +``` + +Run image: + +```bash +make docker-run ARCH=amd64 +# or +make docker-run ARCH=arm64 +``` + +## Docker Compose (Production) + +1. Copy environment template: + +```bash +cp .env.example .env +``` + +2. Edit `.env` for production credentials/tag. + +3. Start service: + +```bash +docker compose up -d +``` + +4. Check health: + +```bash +docker compose ps +``` + +5. View logs: + +```bash +docker compose logs -f +``` + +## Runtime Environment Variables + +- `PORT` (default `8080`) +- `DB_PATH` (default `./data/shooting.db`) +- `WEB_DIR` (default `./web`) +- `ADMIN_USER` (default `datwyler`) +- `ADMIN_PASS` (default `datwyler`) +- `GEMINI_API_KEY` (required for AI score advisor) +- `GEMINI_MODEL` (default `gemini-2.0-flash`) diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..053cbd4 --- /dev/null +++ b/backend/.env @@ -0,0 +1 @@ +GEMINI_API_KEY=AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc \ No newline at end of file diff --git a/backend/ai_handlers.go b/backend/ai_handlers.go new file mode 100644 index 0000000..4d747e9 --- /dev/null +++ b/backend/ai_handlers.go @@ -0,0 +1,95 @@ +package main + +import ( + "database/sql" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/labstack/echo/v4" +) + +func (a *App) handleScoreAdvice(c echo.Context) error { + stage, err := validateStage(c.Param("stage")) + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + + playerID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return writeError(c, http.StatusBadRequest, "invalid player id") + } + + if strings.TrimSpace(a.cfg.GeminiAPIKey) == "" { + return writeError(c, http.StatusServiceUnavailable, "gemini api key is not configured") + } + + proofImageData, err := a.loadScoreProofImage(stage, playerID) + if err != nil { + if err == sql.ErrNoRows { + return writeError(c, http.StatusBadRequest, "no proof image found for this score") + } + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("load proof image: %v", err)) + } + if strings.TrimSpace(proofImageData) == "" { + return writeError(c, http.StatusBadRequest, "no proof image found for this score") + } + + currentScore, _ := a.loadCurrentScore(stage, playerID) + + advice, err := a.generateScoreAdvice(stage, playerID, proofImageData, currentScore) + if err != nil { + return writeError(c, http.StatusBadGateway, err.Error()) + } + + return c.JSON(http.StatusOK, advice) +} + +func (a *App) loadScoreProofImage(stage string, playerID int) (string, error) { + var imageData string + err := a.db.QueryRow(`SELECT image_data FROM score_attachments WHERE stage = ? AND player_id = ?`, stage, playerID).Scan(&imageData) + if err != nil { + return "", err + } + return imageData, nil +} + +func (a *App) loadCurrentScore(stage string, playerID int) (int, error) { + var score int + err := a.db.QueryRow(`SELECT score FROM scores WHERE stage = ? AND player_id = ?`, stage, playerID).Scan(&score) + if err != nil { + if err == sql.ErrNoRows { + return 0, nil + } + return 0, err + } + return score, nil +} + +func (a *App) buildAdviceResponse(stage string, playerID int, raw scoreAdviceModelResponse) ScoreAdviceResponse { + advised := clampInt(raw.AdvisedScore, 0, 9999) + reason := strings.TrimSpace(raw.Reason) + if reason == "" { + reason = "AI estimated the score from visible impacts." + } + + return ScoreAdviceResponse{ + Stage: stage, + PlayerID: playerID, + AdvisedScore: advised, + Reason: reason, + GeneratedAt: time.Now().UTC().Format(time.RFC3339), + } +} + +func clampInt(value, min, max int) int { + if value < min { + return min + } + if value > max { + return max + } + return value +} diff --git a/backend/auth.go b/backend/auth.go new file mode 100644 index 0000000..3bb287d --- /dev/null +++ b/backend/auth.go @@ -0,0 +1,93 @@ +package main + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "net/http" + "strings" + "time" + + "github.com/labstack/echo/v4" +) + +func (a *App) isAdminRequest(c echo.Context) bool { + header := strings.TrimSpace(c.Request().Header.Get(echo.HeaderAuthorization)) + if header == "" || !strings.HasPrefix(strings.ToLower(header), "bearer ") { + return false + } + token := strings.TrimSpace(header[7:]) + if token == "" { + return false + } + return a.sessions.ValidateToken(token) +} + +func (a *App) adminOnly(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + a.sessions.PurgeExpired() + + header := strings.TrimSpace(c.Request().Header.Get(echo.HeaderAuthorization)) + if header == "" || !strings.HasPrefix(strings.ToLower(header), "bearer ") { + return writeError(c, http.StatusUnauthorized, "missing admin token") + } + token := strings.TrimSpace(header[7:]) + if token == "" || !a.sessions.ValidateToken(token) { + return writeError(c, http.StatusUnauthorized, "invalid or expired admin token") + } + return next(c) + } +} + +func (a *App) verifyAdmin(username, password string) bool { + u := strings.TrimSpace(username) + p := strings.TrimSpace(password) + if u == "" || p == "" { + return false + } + userMatch := subtle.ConstantTimeCompare([]byte(u), []byte(a.cfg.AdminUser)) == 1 + passMatch := subtle.ConstantTimeCompare([]byte(p), []byte(a.cfg.AdminPass)) == 1 + return userMatch && passMatch +} + +func (s *SessionStore) CreateToken() (string, time.Time, error) { + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return "", time.Time{}, err + } + token := hex.EncodeToString(raw) + expires := time.Now().UTC().Add(s.duration) + + s.mu.Lock() + s.tokens[token] = expires + s.mu.Unlock() + + return token, expires, nil +} + +func (s *SessionStore) ValidateToken(token string) bool { + s.mu.RLock() + expires, ok := s.tokens[token] + s.mu.RUnlock() + if !ok { + return false + } + return time.Now().UTC().Before(expires) +} + +func (s *SessionStore) DeleteToken(token string) { + s.mu.Lock() + delete(s.tokens, token) + s.mu.Unlock() +} + +func (s *SessionStore) PurgeExpired() { + now := time.Now().UTC() + s.mu.Lock() + for token, expiry := range s.tokens { + if now.After(expiry) { + delete(s.tokens, token) + } + } + s.mu.Unlock() +} diff --git a/backend/config.go b/backend/config.go new file mode 100644 index 0000000..d02c7cd --- /dev/null +++ b/backend/config.go @@ -0,0 +1,36 @@ +package main + +import ( + "os" + "strings" +) + +type Config struct { + Port string + DBPath string + WebDir string + AdminUser string + AdminPass string + GeminiAPIKey string + GeminiModel string +} + +func loadConfig() Config { + return Config{ + Port: envOrDefault("PORT", "8080"), + DBPath: envOrDefault("DB_PATH", "./data/shooting.db"), + WebDir: envOrDefault("WEB_DIR", "./web"), + AdminUser: envOrDefault("ADMIN_USER", "datwyler"), + AdminPass: envOrDefault("ADMIN_PASS", "datwyler"), + GeminiAPIKey: envOrDefault("GEMINI_API_KEY", "AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc"), + GeminiModel: envOrDefault("GEMINI_MODEL", "gemini-3.1-flash-lite-preview"), + } +} + +func envOrDefault(key, fallback string) string { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return fallback + } + return v +} diff --git a/backend/db.go b/backend/db.go new file mode 100644 index 0000000..dde0784 --- /dev/null +++ b/backend/db.go @@ -0,0 +1,76 @@ +package main + +import ( + "database/sql" + "fmt" + "os" + "path/filepath" + + _ "modernc.org/sqlite" +) + +func initDB(cfg Config) (*sql.DB, error) { + if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0o755); err != nil { + return nil, fmt.Errorf("create data dir: %w", err) + } + + db, err := sql.Open("sqlite", cfg.DBPath) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + if _, err := db.Exec(` +PRAGMA foreign_keys = ON; +PRAGMA journal_mode = WAL; +PRAGMA busy_timeout = 5000; +`); err != nil { + return nil, fmt.Errorf("sqlite pragmas: %w", err) + } + + schema := ` +CREATE TABLE IF NOT EXISTS players ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name_ar TEXT NOT NULL, + name_en TEXT NOT NULL, + group_code TEXT NOT NULL DEFAULT '', + image_data TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS scores ( + stage TEXT NOT NULL, + player_id INTEGER NOT NULL, + score INTEGER NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(stage, player_id), + FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS score_attachments ( + stage TEXT NOT NULL, + player_id INTEGER NOT NULL, + image_data TEXT NOT NULL DEFAULT '', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY(stage, 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 +); + +INSERT OR IGNORE INTO app_settings(key, value) VALUES + ('view_proof_in_view', '0'); +` + if _, err := db.Exec(schema); err != nil { + return nil, fmt.Errorf("apply schema: %w", err) + } + + return db, nil +} diff --git a/backend/events.go b/backend/events.go new file mode 100644 index 0000000..7551fee --- /dev/null +++ b/backend/events.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "net/http" + "time" + + "github.com/labstack/echo/v4" +) + +func (a *App) handleEvents(c echo.Context) error { + res := c.Response() + req := c.Request() + + res.Header().Set(echo.HeaderContentType, "text/event-stream") + res.Header().Set("Cache-Control", "no-cache, no-transform") + res.Header().Set("Connection", "keep-alive") + res.Header().Set("X-Accel-Buffering", "no") + res.WriteHeader(http.StatusOK) + + flusher, ok := res.Writer.(http.Flusher) + if !ok { + return writeError(c, http.StatusInternalServerError, "streaming not supported") + } + + if _, err := fmt.Fprintf(res, "event: ready\ndata: {\"ts\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil { + return nil + } + flusher.Flush() + + id, events := a.events.Subscribe() + defer a.events.Unsubscribe(id) + + keepAlive := time.NewTicker(20 * time.Second) + defer keepAlive.Stop() + + for { + select { + case <-req.Context().Done(): + return nil + case <-events: + if _, err := fmt.Fprintf(res, "event: state\ndata: {\"ts\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil { + return nil + } + flusher.Flush() + case t := <-keepAlive.C: + if _, err := fmt.Fprintf(res, ": ping %d\n\n", t.Unix()); err != nil { + return nil + } + flusher.Flush() + } + } +} + +func (h *EventHub) Subscribe() (int, <-chan struct{}) { + h.mu.Lock() + defer h.mu.Unlock() + h.nextID++ + id := h.nextID + ch := make(chan struct{}, 1) + h.subscribers[id] = ch + return id, ch +} + +func (h *EventHub) Unsubscribe(id int) { + h.mu.Lock() + defer h.mu.Unlock() + if ch, ok := h.subscribers[id]; ok { + delete(h.subscribers, id) + close(ch) + } +} + +func (h *EventHub) Broadcast() { + h.mu.RLock() + defer h.mu.RUnlock() + for _, ch := range h.subscribers { + select { + case ch <- struct{}{}: + default: + } + } +} diff --git a/backend/gemini.go b/backend/gemini.go new file mode 100644 index 0000000..caeaccb --- /dev/null +++ b/backend/gemini.go @@ -0,0 +1,203 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type scoreAdviceModelResponse struct { + AdvisedScore int `json:"advisedScore"` + Reason string `json:"reason"` +} + +type geminiGenerateRequest struct { + Contents []geminiContent `json:"contents"` + GenerationConfig geminiGenerationConfig `json:"generationConfig,omitempty"` + SafetySettings []map[string]interface{} `json:"safetySettings,omitempty"` +} + +type geminiContent struct { + Role string `json:"role,omitempty"` + Parts []geminiPart `json:"parts"` +} + +type geminiPart struct { + Text string `json:"text,omitempty"` + InlineData *geminiInlineData `json:"inline_data,omitempty"` +} + +type geminiInlineData struct { + MimeType string `json:"mime_type"` + Data string `json:"data"` +} + +type geminiGenerationConfig struct { + Temperature float64 `json:"temperature,omitempty"` + ResponseMimeType string `json:"responseMimeType,omitempty"` + MaxOutputTokens int `json:"maxOutputTokens,omitempty"` +} + +type geminiGenerateResponse struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + Error *struct { + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func (a *App) generateScoreAdvice(stage string, playerID int, imageData string, currentScore int) (ScoreAdviceResponse, error) { + mimeType, rawBase64, err := parseDataURI(imageData) + if err != nil { + return ScoreAdviceResponse{}, fmt.Errorf("invalid proof image data: %w", err) + } + + model := strings.TrimSpace(a.cfg.GeminiModel) + if model == "" { + model = "gemini-2.0-flash" + } + + requestPayload := geminiGenerateRequest{ + Contents: []geminiContent{ + { + Role: "user", + Parts: []geminiPart{ + {Text: scoreAdvicePrompt(stage, currentScore)}, + { + InlineData: &geminiInlineData{ + MimeType: mimeType, + Data: rawBase64, + }, + }, + }, + }, + }, + GenerationConfig: geminiGenerationConfig{ + Temperature: 0, + ResponseMimeType: "application/json", + MaxOutputTokens: 100, + }, + } + + body, err := json.Marshal(requestPayload) + if err != nil { + return ScoreAdviceResponse{}, fmt.Errorf("marshal gemini request: %w", err) + } + + endpoint := fmt.Sprintf( + "https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s", + url.PathEscape(model), + url.QueryEscape(a.cfg.GeminiAPIKey), + ) + + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return ScoreAdviceResponse{}, fmt.Errorf("create gemini request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 25 * time.Second} + resp, err := client.Do(req) + if err != nil { + return ScoreAdviceResponse{}, fmt.Errorf("call gemini api: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2_000_000)) + if err != nil { + return ScoreAdviceResponse{}, fmt.Errorf("read gemini response: %w", err) + } + + if resp.StatusCode >= 300 { + return ScoreAdviceResponse{}, fmt.Errorf("gemini api status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + } + + var generated geminiGenerateResponse + if err := json.Unmarshal(respBody, &generated); err != nil { + return ScoreAdviceResponse{}, fmt.Errorf("decode gemini response: %w", err) + } + if generated.Error != nil && strings.TrimSpace(generated.Error.Message) != "" { + return ScoreAdviceResponse{}, fmt.Errorf("gemini api error: %s", generated.Error.Message) + } + if len(generated.Candidates) == 0 || len(generated.Candidates[0].Content.Parts) == 0 { + return ScoreAdviceResponse{}, fmt.Errorf("gemini returned no advice") + } + + rawText := strings.TrimSpace(generated.Candidates[0].Content.Parts[0].Text) + jsonText := extractJSONObject(rawText) + if jsonText == "" { + return ScoreAdviceResponse{}, fmt.Errorf("gemini response was not valid json") + } + + var modelResponse scoreAdviceModelResponse + if err := json.Unmarshal([]byte(jsonText), &modelResponse); err != nil { + return ScoreAdviceResponse{}, fmt.Errorf("parse gemini advice json: %w", err) + } + + return a.buildAdviceResponse(stage, playerID, modelResponse), nil +} + +func parseDataURI(dataURI string) (string, string, error) { + value := strings.TrimSpace(dataURI) + if !strings.HasPrefix(value, "data:") { + return "", "", fmt.Errorf("expected data uri") + } + + parts := strings.SplitN(value, ",", 2) + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid data uri format") + } + + header := parts[0] + payload := strings.TrimSpace(parts[1]) + if payload == "" { + return "", "", fmt.Errorf("empty data uri payload") + } + + mimeType := "image/jpeg" + if semicolon := strings.Index(header, ";"); semicolon > len("data:") { + mimeType = strings.TrimSpace(header[len("data:"):semicolon]) + } + if mimeType == "" { + mimeType = "image/jpeg" + } + + return mimeType, payload, nil +} + +func extractJSONObject(raw string) string { + text := strings.TrimSpace(raw) + if text == "" { + return "" + } + if strings.HasPrefix(text, "```") { + text = strings.TrimPrefix(text, "```json") + text = strings.TrimPrefix(text, "```") + text = strings.TrimSuffix(strings.TrimSpace(text), "```") + text = strings.TrimSpace(text) + } + start := strings.Index(text, "{") + end := strings.LastIndex(text, "}") + if start == -1 || end == -1 || end <= start { + return "" + } + return strings.TrimSpace(text[start : end+1]) +} + +func scoreAdvicePrompt(stage string, currentScore int) string { + return fmt.Sprintf(`Target scoring assistant. +Stage: %s. Current score: %d. +Return STRICT JSON only: +{"advisedScore":,"reason":""} +Do not add markdown or extra fields.`, stage, currentScore) +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..42aaaff --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,29 @@ +module shooting-event/backend + +go 1.24.0 + +require ( + github.com/labstack/echo/v4 v4.13.4 + modernc.org/sqlite v1.39.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.40.0 // indirect + golang.org/x/sys v0.36.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/time v0.11.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..54e8544 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,75 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA= +github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= +modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/handlers.go b/backend/handlers.go new file mode 100644 index 0000000..0792b06 --- /dev/null +++ b/backend/handlers.go @@ -0,0 +1,461 @@ +package main + +import ( + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "strconv" + "strings" + "time" + + "github.com/labstack/echo/v4" +) + +func (a *App) handleHealth(c echo.Context) error { + return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) +} + +func (a *App) handleGetState(c echo.Context) error { + state, err := a.readState(a.isAdminRequest(c)) + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + return c.JSON(http.StatusOK, state) +} + +func (a *App) handleAdminLogin(c echo.Context) error { + var req AdminLoginRequest + if err := c.Bind(&req); err != nil { + return writeError(c, http.StatusBadRequest, "invalid request body") + } + + if !a.verifyAdmin(req.Username, req.Password) { + return writeError(c, http.StatusUnauthorized, "invalid credentials") + } + + token, expiry, err := a.sessions.CreateToken() + if err != nil { + return writeError(c, http.StatusInternalServerError, "failed to create session") + } + + return c.JSON(http.StatusOK, map[string]any{ + "token": token, + "expiresAt": expiry.Format(time.RFC3339), + "username": a.cfg.AdminUser, + }) +} + +func (a *App) handleAdminLogout(c echo.Context) error { + token := strings.TrimSpace(strings.TrimPrefix(c.Request().Header.Get(echo.HeaderAuthorization), "Bearer ")) + a.sessions.DeleteToken(token) + return c.JSON(http.StatusOK, map[string]bool{"ok": true}) +} + +func (a *App) handleUpdateAdminSettings(c echo.Context) error { + var req AdminSettingsUpdateRequest + if err := c.Bind(&req); err != nil { + return writeError(c, http.StatusBadRequest, "invalid request body") + } + if req.ViewProofInView == nil { + return writeError(c, http.StatusBadRequest, "viewProofInView is required") + } + + if err := a.updateViewProofInView(*req.ViewProofInView); err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + + 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) handleCreatePlayer(c echo.Context) error { + var req PlayerCreateRequest + if err := c.Bind(&req); err != nil { + return writeError(c, http.StatusBadRequest, "invalid request body") + } + + nameAr := strings.TrimSpace(req.NameAr) + nameEn := strings.TrimSpace(req.NameEn) + group := normalizeGroup(req.GroupCode) + if nameAr == "" || nameEn == "" { + return writeError(c, http.StatusBadRequest, "both Arabic and English names are required") + } + + tx, err := a.db.Begin() + if err != nil { + return writeError(c, http.StatusInternalServerError, "failed to start transaction") + } + defer tx.Rollback() + + res, err := tx.Exec(` +INSERT INTO players(name_ar, name_en, group_code, image_data) +VALUES(?, ?, ?, '') +`, nameAr, nameEn, group) + if err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("create player: %v", err)) + } + + id64, err := res.LastInsertId() + if err != nil { + return writeError(c, http.StatusInternalServerError, "failed to read new player id") + } + playerID := int(id64) + + for _, stage := range scoreStages { + if _, err := tx.Exec(`INSERT OR IGNORE INTO scores(stage, player_id, score) VALUES(?, ?, 0)`, stage, playerID); err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("create score row: %v", err)) + } + } + + if err := tx.Commit(); err != nil { + return writeError(c, http.StatusInternalServerError, "failed to commit create player") + } + + a.events.Broadcast() + + state, err := a.readState(true) + if err != nil { + return writeError(c, http.StatusInternalServerError, err.Error()) + } + + return c.JSON(http.StatusCreated, state) +} + +func (a *App) handleUpdatePlayer(c echo.Context) error { + playerID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return writeError(c, http.StatusBadRequest, "invalid player id") + } + + var req PlayerUpdateRequest + if err := c.Bind(&req); err != nil { + return writeError(c, http.StatusBadRequest, "invalid request body") + } + + updates := []string{} + args := []any{} + + if req.NameAr != nil { + nameAr := strings.TrimSpace(*req.NameAr) + if nameAr == "" { + return writeError(c, http.StatusBadRequest, "arabic name cannot be empty") + } + updates = append(updates, "name_ar = ?") + args = append(args, nameAr) + } + if req.NameEn != nil { + nameEn := strings.TrimSpace(*req.NameEn) + if nameEn == "" { + return writeError(c, http.StatusBadRequest, "english name cannot be empty") + } + updates = append(updates, "name_en = ?") + args = append(args, nameEn) + } + if req.GroupCode != nil { + updates = append(updates, "group_code = ?") + args = append(args, normalizeGroup(*req.GroupCode)) + } + if req.ImageData != nil { + img := strings.TrimSpace(*req.ImageData) + if len(img) > 2_500_000 { + return writeError(c, http.StatusBadRequest, "image payload too large") + } + updates = append(updates, "image_data = ?") + args = append(args, img) + } + if req.ClearImage { + updates = append(updates, "image_data = ''") + } + + if len(updates) == 0 { + return writeError(c, http.StatusBadRequest, "no fields to update") + } + + updates = append(updates, "updated_at = CURRENT_TIMESTAMP") + args = append(args, playerID) + + query := fmt.Sprintf("UPDATE players SET %s WHERE id = ?", strings.Join(updates, ", ")) + res, err := a.db.Exec(query, args...) + if err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update player: %v", err)) + } + affected, err := res.RowsAffected() + if err != nil { + return writeError(c, http.StatusInternalServerError, "failed to check update result") + } + if affected == 0 { + return writeError(c, http.StatusNotFound, "player not found") + } + + 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) handleDeletePlayer(c echo.Context) error { + playerID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return writeError(c, http.StatusBadRequest, "invalid player id") + } + + res, err := a.db.Exec(`DELETE FROM players WHERE id = ?`, playerID) + if err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("delete player: %v", err)) + } + affected, err := res.RowsAffected() + if err != nil { + return writeError(c, http.StatusInternalServerError, "failed to check delete result") + } + if affected == 0 { + return writeError(c, http.StatusNotFound, "player not found") + } + + 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) handleAutoGroupPlayers(c echo.Context) error { + var req AutoGroupPlayersRequest + if err := c.Bind(&req); err != nil { + return writeError(c, http.StatusBadRequest, "invalid request body") + } + + groups := normalizeGroups(req.Groups) + if len(groups) == 0 { + return writeError(c, http.StatusBadRequest, "at least one group is required") + } + + rows, err := a.db.Query(`SELECT id FROM players ORDER BY id ASC`) + if err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("query players: %v", err)) + } + defer rows.Close() + + playerIDs := []int{} + for rows.Next() { + var id int + if err := rows.Scan(&id); err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("scan player: %v", err)) + } + playerIDs = append(playerIDs, id) + } + if err := rows.Err(); err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("read players: %v", err)) + } + + rng := rand.New(rand.NewSource(time.Now().UnixNano())) + rng.Shuffle(len(playerIDs), func(i, j int) { + playerIDs[i], playerIDs[j] = playerIDs[j], playerIDs[i] + }) + + tx, err := a.db.Begin() + if err != nil { + return writeError(c, http.StatusInternalServerError, "failed to start transaction") + } + defer tx.Rollback() + + for i, playerID := range playerIDs { + groupCode := groups[i%len(groups)] + if _, err := tx.Exec(`UPDATE players SET group_code = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, groupCode, playerID); err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("assign player group: %v", err)) + } + } + + if err := tx.Commit(); err != nil { + return writeError(c, http.StatusInternalServerError, "failed to commit group assignment") + } + + 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 { + return writeError(c, http.StatusBadRequest, err.Error()) + } + + playerID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return writeError(c, http.StatusBadRequest, "invalid player id") + } + + var req ScoreUpdateRequest + if err := c.Bind(&req); err != nil { + return writeError(c, http.StatusBadRequest, "invalid request body") + } + if req.Score < 0 || req.Score > 9999 { + return writeError(c, http.StatusBadRequest, "score must be between 0 and 9999") + } + + res, err := a.db.Exec(` +INSERT INTO scores(stage, player_id, score) +VALUES(?, ?, ?) +ON CONFLICT(stage, player_id) DO UPDATE SET score = excluded.score, updated_at = CURRENT_TIMESTAMP +`, stage, playerID, req.Score) + if err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update score: %v", err)) + } + if _, err := res.RowsAffected(); err != nil { + return writeError(c, http.StatusInternalServerError, "failed to check score update") + } + + 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) handleResetStageScores(c echo.Context) error { + stage, err := validateStage(c.Param("stage")) + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + + var req ResetStageRequest + if err := c.Bind(&req); err != nil && !errors.Is(err, io.EOF) { + return writeError(c, http.StatusBadRequest, "invalid request body") + } + + if _, err := a.db.Exec(`UPDATE scores SET score = 0, updated_at = CURRENT_TIMESTAMP WHERE stage = ?`, stage); err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage scores: %v", err)) + } + if req.ResetProofs { + if _, err := a.db.Exec(`DELETE FROM score_attachments WHERE stage = ?`, stage); err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage proofs: %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) handleUpdateScoreProof(c echo.Context) error { + stage, err := validateStage(c.Param("stage")) + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + + playerID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return writeError(c, http.StatusBadRequest, "invalid player id") + } + + var req ScoreProofUpdateRequest + if err := c.Bind(&req); err != nil { + return writeError(c, http.StatusBadRequest, "invalid request body") + } + + imageData := strings.TrimSpace(req.ImageData) + if imageData == "" { + return writeError(c, http.StatusBadRequest, "imageData is required") + } + if len(imageData) > 5_000_000 { + return writeError(c, http.StatusBadRequest, "image payload too large") + } + + if _, err := a.db.Exec(` +INSERT INTO score_attachments(stage, player_id, image_data) +VALUES(?, ?, ?) +ON CONFLICT(stage, player_id) DO UPDATE SET image_data = excluded.image_data, updated_at = CURRENT_TIMESTAMP +`, stage, playerID, imageData); err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update score proof: %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) handleDeleteScoreProof(c echo.Context) error { + stage, err := validateStage(c.Param("stage")) + if err != nil { + return writeError(c, http.StatusBadRequest, err.Error()) + } + + playerID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return writeError(c, http.StatusBadRequest, "invalid player id") + } + + if _, err := a.db.Exec(`DELETE FROM score_attachments WHERE stage = ? AND player_id = ?`, stage, playerID); err != nil { + return writeError(c, http.StatusInternalServerError, fmt.Sprintf("delete score proof: %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 validateStage(stage string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(stage)) + for _, allowed := range scoreStages { + if normalized == allowed { + return normalized, nil + } + } + return "", fmt.Errorf("invalid stage") +} + +func normalizeGroup(group string) string { + return strings.TrimSpace(group) +} + +func normalizeGroups(groups []string) []string { + seen := map[string]bool{} + out := []string{} + for _, group := range groups { + normalized := normalizeGroup(group) + if normalized == "" || seen[normalized] { + continue + } + seen[normalized] = true + out = append(out, normalized) + } + return out +} + +func writeError(c echo.Context, status int, message string) error { + return c.JSON(status, map[string]string{"message": message}) +} diff --git a/backend/main.go b/backend/main.go new file mode 100644 index 0000000..e7ae55a --- /dev/null +++ b/backend/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "errors" + "log" + "net/http" + "time" + + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" +) + +func main() { + cfg := loadConfig() + + db, err := initDB(cfg) + if err != nil { + log.Fatalf("init db: %v", err) + } + defer db.Close() + + app := &App{ + db: db, + cfg: cfg, + sessions: &SessionStore{ + tokens: map[string]time.Time{}, + duration: 8 * time.Hour, + }, + events: &EventHub{ + subscribers: map[int]chan struct{}{}, + }, + } + + e := echo.New() + e.HideBanner = true + e.HidePort = true + e.Use(middleware.Recover()) + e.Use(middleware.Logger()) + e.Use(middleware.RequestID()) + e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ + AllowOrigins: []string{"*"}, + AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions}, + AllowHeaders: []string{echo.HeaderContentType, echo.HeaderAuthorization}, + })) + + registerAPIRoutes(e, app) + registerWebRoutes(e, cfg) + + addr := ":" + cfg.Port + log.Printf("listening on %s", addr) + if err := e.Start(addr); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server error: %v", err) + } +} diff --git a/backend/models.go b/backend/models.go new file mode 100644 index 0000000..bebdb65 --- /dev/null +++ b/backend/models.go @@ -0,0 +1,141 @@ +package main + +import ( + "database/sql" + "sync" + "time" +) + +var scoreStages = []string{"preliminary", "prelim_tiebreak", "final", "final_tiebreak"} + +type App struct { + db *sql.DB + cfg Config + sessions *SessionStore + events *EventHub +} + +type SessionStore struct { + mu sync.RWMutex + tokens map[string]time.Time + duration time.Duration +} + +type EventHub struct { + mu sync.RWMutex + nextID int + subscribers map[int]chan struct{} +} + +type CompetitionMeta struct { + TitleAr string `json:"titleAr"` + TitleEn string `json:"titleEn"` +} + +type Player struct { + ID int `json:"id"` + NameAr string `json:"nameAr"` + NameEn string `json:"nameEn"` + GroupCode string `json:"groupCode"` + ImageData string `json:"imageData"` +} + +type RankingRow struct { + PlayerID int `json:"playerId"` + NameAr string `json:"nameAr"` + NameEn string `json:"nameEn"` + GroupCode string `json:"groupCode"` + ImageData string `json:"imageData"` + Score int `json:"score"` + TieBreak int `json:"tieBreak"` + Rank int `json:"rank"` + Seed int `json:"seed"` + FinalGroup int `json:"finalGroup"` +} + +type TieBreakInfo struct { + Required bool `json:"required"` + Resolved bool `json:"resolved"` + Slots int `json:"slots"` + PlayerIDs []int `json:"playerIds"` +} + +type DerivedState struct { + PreliminaryRanking RankingBundle `json:"preliminaryRanking"` + Finalists []RankingRow `json:"finalists"` + FinalGroups FinalGroups `json:"finalGroups"` + FinalRanking RankingBundle `json:"finalRanking"` + Podium []RankingRow `json:"podium"` +} + +type RankingBundle struct { + Rows []RankingRow `json:"rows"` + TieBreak TieBreakInfo `json:"tieBreak"` + Unresolved bool `json:"unresolved"` +} + +type FinalGroups struct { + Group1 []RankingRow `json:"group1"` + Group2 []RankingRow `json:"group2"` +} + +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"` +} + +type AppSettings struct { + ViewProofInView bool `json:"viewProofInView"` +} + +type AdminLoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type PlayerCreateRequest struct { + NameAr string `json:"nameAr"` + NameEn string `json:"nameEn"` + GroupCode string `json:"groupCode"` +} + +type PlayerUpdateRequest struct { + NameAr *string `json:"nameAr"` + NameEn *string `json:"nameEn"` + GroupCode *string `json:"groupCode"` + ImageData *string `json:"imageData"` + ClearImage bool `json:"clearImage"` +} + +type ScoreUpdateRequest struct { + Score int `json:"score"` +} + +type ScoreProofUpdateRequest struct { + ImageData string `json:"imageData"` +} + +type AdminSettingsUpdateRequest struct { + ViewProofInView *bool `json:"viewProofInView"` +} + +type ResetStageRequest struct { + ResetProofs bool `json:"resetProofs"` +} + +type AutoGroupPlayersRequest struct { + Groups []string `json:"groups"` +} + +type ScoreAdviceResponse struct { + Stage string `json:"stage"` + PlayerID int `json:"playerId"` + AdvisedScore int `json:"advisedScore"` + Reason string `json:"reason"` + GeneratedAt string `json:"generatedAt"` +} diff --git a/backend/routes.go b/backend/routes.go new file mode 100644 index 0000000..43ab6f7 --- /dev/null +++ b/backend/routes.go @@ -0,0 +1,57 @@ +package main + +import ( + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/labstack/echo/v4" +) + +func registerAPIRoutes(e *echo.Echo, app *App) { + api := e.Group("/api") + api.GET("/health", app.handleHealth) + api.GET("/state", app.handleGetState) + api.GET("/events", app.handleEvents) + + api.POST("/admin/login", app.handleAdminLogin) + api.POST("/admin/logout", app.handleAdminLogout, app.adminOnly) + api.GET("/admin/state", app.handleGetState, app.adminOnly) + api.PUT("/admin/settings", app.handleUpdateAdminSettings, app.adminOnly) + api.POST("/admin/players", app.handleCreatePlayer, app.adminOnly) + api.POST("/admin/players/auto-group", app.handleAutoGroupPlayers, 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) + api.PUT("/admin/scores/:stage/:id/proof", app.handleUpdateScoreProof, app.adminOnly) + api.DELETE("/admin/scores/:stage/:id/proof", app.handleDeleteScoreProof, app.adminOnly) + api.POST("/admin/scores/:stage/:id/advice", app.handleScoreAdvice, app.adminOnly) + api.POST("/admin/scores/:stage/reset", app.handleResetStageScores, app.adminOnly) +} + +func registerWebRoutes(e *echo.Echo, cfg Config) { + e.GET("/*", func(c echo.Context) error { + requestPath := strings.TrimPrefix(c.Param("*"), "/") + if strings.HasPrefix(requestPath, "api") { + return echo.ErrNotFound + } + + if requestPath == "" { + requestPath = "index.html" + } + + clean := strings.TrimPrefix(filepath.Clean("/"+requestPath), "/") + assetPath := filepath.Join(cfg.WebDir, clean) + if stat, err := os.Stat(assetPath); err == nil && !stat.IsDir() { + return c.File(assetPath) + } + + indexPath := filepath.Join(cfg.WebDir, "index.html") + if stat, err := os.Stat(indexPath); err == nil && !stat.IsDir() { + return c.File(indexPath) + } + + return c.String(http.StatusNotFound, "frontend build not found. run make build") + }) +} diff --git a/backend/settings.go b/backend/settings.go new file mode 100644 index 0000000..3e706bd --- /dev/null +++ b/backend/settings.go @@ -0,0 +1,47 @@ +package main + +import ( + "database/sql" + "fmt" + "strings" +) + +func (a *App) readSettings() (AppSettings, error) { + var raw string + err := a.db.QueryRow(`SELECT value FROM app_settings WHERE key = 'view_proof_in_view'`).Scan(&raw) + if err != nil { + if err == sql.ErrNoRows { + return AppSettings{ViewProofInView: false}, nil + } + return AppSettings{}, fmt.Errorf("read app setting view_proof_in_view: %w", err) + } + return AppSettings{ViewProofInView: settingBool(raw)}, nil +} + +func (a *App) updateViewProofInView(enabled bool) error { + _, err := a.db.Exec(` +INSERT INTO app_settings(key, value, updated_at) +VALUES('view_proof_in_view', ?, CURRENT_TIMESTAMP) +ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP +`, settingString(enabled)) + if err != nil { + return fmt.Errorf("update app setting view_proof_in_view: %w", err) + } + return nil +} + +func settingBool(value string) bool { + switch strings.TrimSpace(strings.ToLower(value)) { + case "1", "true", "yes", "on": + return true + default: + return false + } +} + +func settingString(value bool) string { + if value { + return "1" + } + return "0" +} diff --git a/backend/state.go b/backend/state.go new file mode 100644 index 0000000..899bb45 --- /dev/null +++ b/backend/state.go @@ -0,0 +1,482 @@ +package main + +import ( + "fmt" + "sort" + "strconv" + "strings" + "time" +) + +func (a *App) readState(includeAllProofs bool) (StateResponse, error) { + players, err := a.readPlayers() + if err != nil { + return StateResponse{}, err + } + + if err := a.ensureScoreRows(players); err != nil { + return StateResponse{}, err + } + + settings, err := a.readSettings() + if err != nil { + return StateResponse{}, err + } + + scoreMap, err := a.readScores(players) + if err != nil { + return StateResponse{}, err + } + + includeScoreProofs := includeAllProofs || settings.ViewProofInView + var scoreProofs map[string]map[int]string + if includeScoreProofs { + scoreProofs, err = a.readScoreProofs(players) + if err != nil { + return StateResponse{}, err + } + } + + derived := computeDerived(players, scoreMap) + + 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), + } + if includeScoreProofs { + response.ScoreProofs = scoreProofMapToJSON(scoreProofs) + } + + return response, nil +} + +func (a *App) readPlayers() ([]Player, error) { + rows, err := a.db.Query(` +SELECT id, name_ar, name_en, group_code, image_data +FROM players +ORDER BY id ASC +`) + if err != nil { + return nil, fmt.Errorf("query players: %w", err) + } + defer rows.Close() + + players := []Player{} + for rows.Next() { + var p Player + if err := rows.Scan(&p.ID, &p.NameAr, &p.NameEn, &p.GroupCode, &p.ImageData); err != nil { + return nil, fmt.Errorf("scan player: %w", err) + } + players = append(players, p) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate players: %w", err) + } + return players, nil +} + +func (a *App) readScoreProofs(players []Player) (map[string]map[int]string, error) { + proofs := map[string]map[int]string{} + for _, stage := range scoreStages { + proofs[stage] = map[int]string{} + } + for _, p := range players { + for _, stage := range scoreStages { + proofs[stage][p.ID] = "" + } + } + + rows, err := a.db.Query(`SELECT stage, player_id, image_data FROM score_attachments`) + if err != nil { + return nil, fmt.Errorf("query score attachments: %w", err) + } + defer rows.Close() + + for rows.Next() { + var stage string + var playerID int + var imageData string + if err := rows.Scan(&stage, &playerID, &imageData); err != nil { + return nil, fmt.Errorf("scan score attachment: %w", err) + } + stage = strings.ToLower(strings.TrimSpace(stage)) + stageMap, ok := proofs[stage] + if !ok { + continue + } + stageMap[playerID] = imageData + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate score attachments: %w", err) + } + return proofs, nil +} + +func scoreProofMapToJSON(proofMap map[string]map[int]string) map[string]map[string]string { + out := map[string]map[string]string{} + for stage, stageMap := range proofMap { + out[stage] = map[string]string{} + for playerID, imageData := range stageMap { + if strings.TrimSpace(imageData) == "" { + continue + } + out[stage][strconv.Itoa(playerID)] = imageData + } + } + return out +} + +func (a *App) ensureScoreRows(players []Player) error { + if len(players) == 0 { + return nil + } + tx, err := a.db.Begin() + if err != nil { + return fmt.Errorf("begin score row ensure tx: %w", err) + } + defer tx.Rollback() + + for _, p := range players { + for _, stage := range scoreStages { + if _, err := tx.Exec(`INSERT OR IGNORE INTO scores(stage, player_id, score) VALUES(?, ?, 0)`, stage, p.ID); err != nil { + return fmt.Errorf("ensure score row (%s,%d): %w", stage, p.ID, err) + } + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit score row ensure tx: %w", err) + } + return nil +} + +func (a *App) readScores(players []Player) (map[string]map[int]int, error) { + scores := map[string]map[int]int{} + for _, stage := range scoreStages { + scores[stage] = map[int]int{} + } + for _, p := range players { + for _, stage := range scoreStages { + scores[stage][p.ID] = 0 + } + } + + rows, err := a.db.Query(`SELECT stage, player_id, score FROM scores`) + if err != nil { + return nil, fmt.Errorf("query scores: %w", err) + } + defer rows.Close() + + for rows.Next() { + var stage string + var playerID int + var score int + if err := rows.Scan(&stage, &playerID, &score); err != nil { + return nil, fmt.Errorf("scan score: %w", err) + } + stage = strings.ToLower(stage) + if _, ok := scores[stage]; !ok { + continue + } + scores[stage][playerID] = score + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate scores: %w", err) + } + + return scores, nil +} + +func scoreMapToJSON(scoreMap map[string]map[int]int) map[string]map[string]int { + out := map[string]map[string]int{} + for stage, stageMap := range scoreMap { + out[stage] = map[string]int{} + for playerID, value := range stageMap { + out[stage][strconv.Itoa(playerID)] = value + } + } + return out +} + +func computeDerived(players []Player, scores map[string]map[int]int) DerivedState { + playerByID := map[int]Player{} + for _, p := range players { + playerByID[p.ID] = p + } + + preRows := []RankingRow{} + for _, p := range players { + preRows = append(preRows, RankingRow{ + PlayerID: p.ID, + NameAr: p.NameAr, + NameEn: p.NameEn, + GroupCode: p.GroupCode, + ImageData: p.ImageData, + Score: scores["preliminary"][p.ID], + TieBreak: scores["prelim_tiebreak"][p.ID], + }) + } + + sort.SliceStable(preRows, func(i, j int) bool { + if preRows[i].Score != preRows[j].Score { + return preRows[i].Score > preRows[j].Score + } + return preRows[i].PlayerID < preRows[j].PlayerID + }) + assignDenseRankByScore(preRows) + + preTie := TieBreakInfo{Required: false, Resolved: true, Slots: 0, PlayerIDs: []int{}} + finalists := []RankingRow{} + + if len(preRows) <= 12 { + for i := range preRows { + row := preRows[i] + row.Seed = i + 1 + finalists = append(finalists, row) + } + } else { + cutoff := preRows[11].Score + above := []RankingRow{} + atCutoff := []RankingRow{} + for _, row := range preRows { + if row.Score > cutoff { + above = append(above, row) + } else if row.Score == cutoff { + atCutoff = append(atCutoff, row) + } + } + slots := 12 - len(above) + if slots < 0 { + slots = 0 + } + + if len(atCutoff) <= slots { + finalists = append(finalists, above...) + finalists = append(finalists, atCutoff...) + } else { + preTie.Required = true + preTie.Slots = slots + preTie.Resolved = true + for _, row := range atCutoff { + preTie.PlayerIDs = append(preTie.PlayerIDs, row.PlayerID) + } + sort.Ints(preTie.PlayerIDs) + + sort.SliceStable(atCutoff, func(i, j int) bool { + if atCutoff[i].TieBreak != atCutoff[j].TieBreak { + return atCutoff[i].TieBreak > atCutoff[j].TieBreak + } + return atCutoff[i].PlayerID < atCutoff[j].PlayerID + }) + + if slots > 0 { + boundary := atCutoff[slots-1].TieBreak + greater := 0 + equal := 0 + for _, row := range atCutoff { + if row.TieBreak > boundary { + greater++ + } else if row.TieBreak == boundary { + equal++ + } + } + if greater < slots && greater+equal > slots { + preTie.Resolved = false + } + } + + finalists = append(finalists, above...) + if slots > len(atCutoff) { + slots = len(atCutoff) + } + finalists = append(finalists, atCutoff[:slots]...) + } + + sort.SliceStable(finalists, func(i, j int) bool { + if finalists[i].Score != finalists[j].Score { + return finalists[i].Score > finalists[j].Score + } + if preTie.Required { + if finalists[i].TieBreak != finalists[j].TieBreak { + return finalists[i].TieBreak > finalists[j].TieBreak + } + } + return finalists[i].PlayerID < finalists[j].PlayerID + }) + assignDenseRankBy(finalists, func(a, b RankingRow) bool { + if a.Score != b.Score { + return false + } + if preTie.Required { + return a.TieBreak == b.TieBreak + } + return true + }) + } + + for i := range finalists { + finalists[i].Seed = i + 1 + if i < 6 { + finalists[i].FinalGroup = 1 + } else { + finalists[i].FinalGroup = 2 + } + } + + finalGroup1 := []RankingRow{} + finalGroup2 := []RankingRow{} + for _, row := range finalists { + if row.FinalGroup == 1 { + finalGroup1 = append(finalGroup1, row) + } else { + finalGroup2 = append(finalGroup2, row) + } + } + + finalRows := []RankingRow{} + for _, finalist := range finalists { + p := playerByID[finalist.PlayerID] + finalRows = append(finalRows, RankingRow{ + PlayerID: finalist.PlayerID, + NameAr: p.NameAr, + NameEn: p.NameEn, + GroupCode: p.GroupCode, + ImageData: p.ImageData, + Score: scores["final"][finalist.PlayerID], + TieBreak: scores["final_tiebreak"][finalist.PlayerID], + Seed: finalist.Seed, + FinalGroup: finalist.FinalGroup, + }) + } + + sort.SliceStable(finalRows, func(i, j int) bool { + if finalRows[i].Score != finalRows[j].Score { + return finalRows[i].Score > finalRows[j].Score + } + return finalRows[i].Seed < finalRows[j].Seed + }) + + finalTie := TieBreakInfo{Required: false, Resolved: true, Slots: 0, PlayerIDs: []int{}} + tiedTop := map[int]bool{} + if len(finalRows) > 0 { + i := 0 + for i < len(finalRows) { + j := i + 1 + for j < len(finalRows) && finalRows[j].Score == finalRows[i].Score { + j++ + } + if j-i > 1 && finalRows[i].Score > 0 { + startPos := i + 1 + endPos := j + if startPos <= 3 || endPos <= 3 || (startPos < 3 && endPos > 3) { + for k := i; k < j; k++ { + tiedTop[finalRows[k].PlayerID] = true + } + } + } + i = j + } + + if len(tiedTop) > 0 { + finalTie.Required = true + for id := range tiedTop { + finalTie.PlayerIDs = append(finalTie.PlayerIDs, id) + } + sort.Ints(finalTie.PlayerIDs) + + sort.SliceStable(finalRows, func(i, j int) bool { + if finalRows[i].Score != finalRows[j].Score { + return finalRows[i].Score > finalRows[j].Score + } + iti := tiedTop[finalRows[i].PlayerID] + itj := tiedTop[finalRows[j].PlayerID] + if iti && itj && finalRows[i].TieBreak != finalRows[j].TieBreak { + return finalRows[i].TieBreak > finalRows[j].TieBreak + } + return finalRows[i].Seed < finalRows[j].Seed + }) + } + } + + if finalTie.Required { + assignDenseRankBy(finalRows, func(a, b RankingRow) bool { + if a.Score != b.Score { + return false + } + if tiedTop[a.PlayerID] && tiedTop[b.PlayerID] { + return a.TieBreak == b.TieBreak + } + return true + }) + if len(finalRows) >= 3 { + third := finalRows[2] + greater := 0 + equal := 0 + for _, row := range finalRows { + if row.Score > third.Score { + greater++ + continue + } + if row.Score == third.Score { + itied := tiedTop[row.PlayerID] + ttied := tiedTop[third.PlayerID] + if itied && ttied { + if row.TieBreak > third.TieBreak { + greater++ + } else if row.TieBreak == third.TieBreak { + equal++ + } + } else { + equal++ + } + } + } + if greater < 3 && greater+equal > 3 { + finalTie.Resolved = false + } + } + } else { + assignDenseRankByScore(finalRows) + } + + podium := []RankingRow{} + for i := 0; i < len(finalRows) && i < 3; i++ { + podium = append(podium, finalRows[i]) + } + + return DerivedState{ + PreliminaryRanking: RankingBundle{Rows: preRows, TieBreak: preTie, Unresolved: preTie.Required && !preTie.Resolved}, + Finalists: finalists, + FinalGroups: FinalGroups{Group1: finalGroup1, Group2: finalGroup2}, + FinalRanking: RankingBundle{Rows: finalRows, TieBreak: finalTie, Unresolved: finalTie.Required && !finalTie.Resolved}, + Podium: podium, + } +} + +func assignDenseRankByScore(rows []RankingRow) { + assignDenseRankBy(rows, func(a, b RankingRow) bool { + return a.Score == b.Score + }) +} + +func assignDenseRankBy(rows []RankingRow, isEqual func(a, b RankingRow) bool) { + if len(rows) == 0 { + return + } + + currentRank := 1 + rows[0].Rank = currentRank + for i := 1; i < len(rows); i++ { + if !isEqual(rows[i], rows[i-1]) { + currentRank = i + 1 + } + rows[i].Rank = currentRank + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..08a9e4b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +services: + shooting-event: + container_name: shooting-event + image: ${IMAGE:-repo.ssp-itinfra.com/admin/shooting-event:amd64-latest} + restart: unless-stopped + ports: + - "${APP_PORT:-8080}:8080" + environment: + PORT: "8080" + DB_PATH: /app/data/shooting.db + WEB_DIR: /app/web + ADMIN_USER: ${ADMIN_USER:-datwyler} + ADMIN_PASS: ${ADMIN_PASS:-datwyler} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.0-flash} + volumes: + - shooting_event_data:/app/data + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8080/api/health >/dev/null 2>&1 || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + +volumes: + shooting_event_data: + name: shooting_event_data diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..920ee85 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + Shooting Event Tracker + + + + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..058f67b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "shooting-event-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0 --port 5173", + "build": "vite build", + "preview": "vite preview --host 0.0.0.0 --port 4173" + }, + "dependencies": { + "vue": "^3.5.22" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "vite": "^5.4.19" + }, + "engines": { + "node": ">=20" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..b21fde7 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,747 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + vue: + specifier: ^3.5.22 + version: 3.5.31 + devDependencies: + '@vitejs/plugin-vue': + specifier: ^5.2.1 + version: 5.2.4(vite@5.4.21)(vue@3.5.31) + vite: + specifier: ^5.4.19 + version: 5.4.21 + +packages: + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rollup/rollup-android-arm-eabi@4.60.1': + resolution: {integrity: sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.1': + resolution: {integrity: sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.1': + resolution: {integrity: sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.1': + resolution: {integrity: sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.1': + resolution: {integrity: sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.1': + resolution: {integrity: sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.60.1': + resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.60.1': + resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.60.1': + resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.60.1': + resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.60.1': + resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.1': + resolution: {integrity: sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + resolution: {integrity: sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + resolution: {integrity: sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.1': + resolution: {integrity: sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.1': + resolution: {integrity: sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.31': + resolution: {integrity: sha512-k/ueL14aNIEy5Onf0OVzR8kiqF/WThgLdFhxwa4e/KF/0qe38IwIdofoSWBTvvxQOesaz6riAFAUaYjoF9fLLQ==} + + '@vue/compiler-dom@3.5.31': + resolution: {integrity: sha512-BMY/ozS/xxjYqRFL+tKdRpATJYDTTgWSo0+AJvJNg4ig+Hgb0dOsHPXvloHQ5hmlivUqw1Yt2pPIqp4e0v1GUw==} + + '@vue/compiler-sfc@3.5.31': + resolution: {integrity: sha512-M8wpPgR9UJ8MiRGjppvx9uWJfLV7A/T+/rL8s/y3QG3u0c2/YZgff3d6SuimKRIhcYnWg5fTfDMlz2E6seUW8Q==} + + '@vue/compiler-ssr@3.5.31': + resolution: {integrity: sha512-h0xIMxrt/LHOvJKMri+vdYT92BrK3HFLtDqq9Pr/lVVfE4IyKZKvWf0vJFW10Yr6nX02OR4MkJwI0c1HDa1hog==} + + '@vue/reactivity@3.5.31': + resolution: {integrity: sha512-DtKXxk9E/KuVvt8VxWu+6Luc9I9ETNcqR1T1oW1gf02nXaZ1kuAx58oVu7uX9XxJR0iJCro6fqBLw9oSBELo5g==} + + '@vue/runtime-core@3.5.31': + resolution: {integrity: sha512-AZPmIHXEAyhpkmN7aWlqjSfYynmkWlluDNPHMCZKFHH+lLtxP/30UJmoVhXmbDoP1Ng0jG0fyY2zCj1PnSSA6Q==} + + '@vue/runtime-dom@3.5.31': + resolution: {integrity: sha512-xQJsNRmGPeDCJq/u813tyonNgWBFjzfVkBwDREdEWndBnGdHLHgkwNBQxLtg4zDrzKTEcnikUy1UUNecb3lJ6g==} + + '@vue/server-renderer@3.5.31': + resolution: {integrity: sha512-GJuwRvMcdZX/CriUnyIIOGkx3rMV3H6sOu0JhdKbduaeCji6zb60iOGMY7tFoN24NfsUYoFBhshZtGxGpxO4iA==} + peerDependencies: + vue: 3.5.31 + + '@vue/shared@3.5.31': + resolution: {integrity: sha512-nBxuiuS9Lj5bPkPbWogPUnjxxWpkRniX7e5UBQDWl6Fsf4roq9wwV+cR7ezQ4zXswNvPIlsdj1slcLB7XCsRAw==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.1: + resolution: {integrity: sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vue@3.5.31: + resolution: {integrity: sha512-iV/sU9SzOlmA/0tygSmjkEN6Jbs3nPoIPFhCMLD2STrjgOU8DX7ZtzMhg4ahVwf5Rp9KoFzcXeB1ZrVbLBp5/Q==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + +snapshots: + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rollup/rollup-android-arm-eabi@4.60.1': + optional: true + + '@rollup/rollup-android-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.1': + optional: true + + '@rollup/rollup-darwin-x64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.1': + optional: true + + '@types/estree@1.0.8': {} + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21)(vue@3.5.31)': + dependencies: + vite: 5.4.21 + vue: 3.5.31 + + '@vue/compiler-core@3.5.31': + dependencies: + '@babel/parser': 7.29.2 + '@vue/shared': 3.5.31 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.31': + dependencies: + '@vue/compiler-core': 3.5.31 + '@vue/shared': 3.5.31 + + '@vue/compiler-sfc@3.5.31': + dependencies: + '@babel/parser': 7.29.2 + '@vue/compiler-core': 3.5.31 + '@vue/compiler-dom': 3.5.31 + '@vue/compiler-ssr': 3.5.31 + '@vue/shared': 3.5.31 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.8 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.31': + dependencies: + '@vue/compiler-dom': 3.5.31 + '@vue/shared': 3.5.31 + + '@vue/reactivity@3.5.31': + dependencies: + '@vue/shared': 3.5.31 + + '@vue/runtime-core@3.5.31': + dependencies: + '@vue/reactivity': 3.5.31 + '@vue/shared': 3.5.31 + + '@vue/runtime-dom@3.5.31': + dependencies: + '@vue/reactivity': 3.5.31 + '@vue/runtime-core': 3.5.31 + '@vue/shared': 3.5.31 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.31(vue@3.5.31)': + dependencies: + '@vue/compiler-ssr': 3.5.31 + '@vue/shared': 3.5.31 + vue: 3.5.31 + + '@vue/shared@3.5.31': {} + + csstype@3.2.3: {} + + entities@7.0.1: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@2.0.2: {} + + fsevents@2.3.3: + optional: true + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + nanoid@3.3.11: {} + + picocolors@1.1.1: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.1 + '@rollup/rollup-android-arm64': 4.60.1 + '@rollup/rollup-darwin-arm64': 4.60.1 + '@rollup/rollup-darwin-x64': 4.60.1 + '@rollup/rollup-freebsd-arm64': 4.60.1 + '@rollup/rollup-freebsd-x64': 4.60.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.1 + '@rollup/rollup-linux-arm-musleabihf': 4.60.1 + '@rollup/rollup-linux-arm64-gnu': 4.60.1 + '@rollup/rollup-linux-arm64-musl': 4.60.1 + '@rollup/rollup-linux-loong64-gnu': 4.60.1 + '@rollup/rollup-linux-loong64-musl': 4.60.1 + '@rollup/rollup-linux-ppc64-gnu': 4.60.1 + '@rollup/rollup-linux-ppc64-musl': 4.60.1 + '@rollup/rollup-linux-riscv64-gnu': 4.60.1 + '@rollup/rollup-linux-riscv64-musl': 4.60.1 + '@rollup/rollup-linux-s390x-gnu': 4.60.1 + '@rollup/rollup-linux-x64-gnu': 4.60.1 + '@rollup/rollup-linux-x64-musl': 4.60.1 + '@rollup/rollup-openbsd-x64': 4.60.1 + '@rollup/rollup-openharmony-arm64': 4.60.1 + '@rollup/rollup-win32-arm64-msvc': 4.60.1 + '@rollup/rollup-win32-ia32-msvc': 4.60.1 + '@rollup/rollup-win32-x64-gnu': 4.60.1 + '@rollup/rollup-win32-x64-msvc': 4.60.1 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.8 + rollup: 4.60.1 + optionalDependencies: + fsevents: 2.3.3 + + vue@3.5.31: + dependencies: + '@vue/compiler-dom': 3.5.31 + '@vue/compiler-sfc': 3.5.31 + '@vue/runtime-dom': 3.5.31 + '@vue/server-renderer': 3.5.31(vue@3.5.31) + '@vue/shared': 3.5.31 diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..6e32f52 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,1195 @@ + + + diff --git a/frontend/src/components/AdminLoginPanel.vue b/frontend/src/components/AdminLoginPanel.vue new file mode 100644 index 0000000..c1837a7 --- /dev/null +++ b/frontend/src/components/AdminLoginPanel.vue @@ -0,0 +1,35 @@ + + + + diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue new file mode 100644 index 0000000..7a3f304 --- /dev/null +++ b/frontend/src/components/AdminPanel.vue @@ -0,0 +1,355 @@ + + + diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue new file mode 100644 index 0000000..d87063d --- /dev/null +++ b/frontend/src/components/AppHeader.vue @@ -0,0 +1,47 @@ + + + + diff --git a/frontend/src/components/ImageCropModal.vue b/frontend/src/components/ImageCropModal.vue new file mode 100644 index 0000000..1d09bfa --- /dev/null +++ b/frontend/src/components/ImageCropModal.vue @@ -0,0 +1,190 @@ + + + + diff --git a/frontend/src/components/ProofModal.vue b/frontend/src/components/ProofModal.vue new file mode 100644 index 0000000..faec023 --- /dev/null +++ b/frontend/src/components/ProofModal.vue @@ -0,0 +1,24 @@ + + + + diff --git a/frontend/src/components/ResetStageModal.vue b/frontend/src/components/ResetStageModal.vue new file mode 100644 index 0000000..1dfdc90 --- /dev/null +++ b/frontend/src/components/ResetStageModal.vue @@ -0,0 +1,24 @@ + + + + diff --git a/frontend/src/components/ScoreAdviceModal.vue b/frontend/src/components/ScoreAdviceModal.vue new file mode 100644 index 0000000..4d3ce46 --- /dev/null +++ b/frontend/src/components/ScoreAdviceModal.vue @@ -0,0 +1,61 @@ + + + diff --git a/frontend/src/components/ViewModePanel.vue b/frontend/src/components/ViewModePanel.vue new file mode 100644 index 0000000..1d7bb51 --- /dev/null +++ b/frontend/src/components/ViewModePanel.vue @@ -0,0 +1,465 @@ + + + diff --git a/frontend/src/components/admin/PlayersManagementTab.vue b/frontend/src/components/admin/PlayersManagementTab.vue new file mode 100644 index 0000000..0a292a1 --- /dev/null +++ b/frontend/src/components/admin/PlayersManagementTab.vue @@ -0,0 +1,259 @@ + + + diff --git a/frontend/src/components/admin/ScoreStageEditor.vue b/frontend/src/components/admin/ScoreStageEditor.vue new file mode 100644 index 0000000..ed81753 --- /dev/null +++ b/frontend/src/components/admin/ScoreStageEditor.vue @@ -0,0 +1,202 @@ + + + diff --git a/frontend/src/constants/i18n.js b/frontend/src/constants/i18n.js new file mode 100644 index 0000000..eab9f5f --- /dev/null +++ b/frontend/src/constants/i18n.js @@ -0,0 +1,313 @@ +export const I18N = { + ar: { + titleFallback: 'بطولة دويتوايلر للرماية', + subtitle: 'فئة المسدس · مسافات: 15م ← 20م ← 25م', + viewMode: 'وضع العرض', + adminMode: 'لوحة الإدارة', + adminLogin: 'تسجيل دخول الإدارة', + adminLoginDesc: 'أدخل بيانات الإدارة للتحكم الكامل في البطولة.', + adminPanel: 'لوحة التحكم الإدارية', + adminPanelDesc: 'إدارة اللاعبين، التوزيع، وإدخال النتائج لكل المراحل.', + defaultCredentials: 'بيانات الإدارة الافتراضية: datwyler / datwyler', + labels: { + liveTracker: 'LIVE TRACKER', + mode: 'الوضع', + language: 'اللغة', + lastSync: 'آخر مزامنة', + loading: 'جاري تحميل بيانات البطولة...', + players: 'لاعب', + group: 'المجموعة', + unassigned: 'غير معين', + emptyLive: 'لا يوجد لاعبين في هذه المجموعة', + allPlayers: 'جميع اللاعبين', + top12: 'أفضل 12 متأهل', + finalist: 'متأهل', + notFinalist: 'غير متأهل', + noPlayers: 'لا يوجد لاعبين بعد', + noFinalists: 'لا يوجد متأهلون بعد', + finalGroup1: 'المجموعة النهائية 1 (المراكز 1-6)', + finalGroup2: 'المجموعة النهائية 2 (المراكز 7-12)', + tieSlots: 'عدد المقاعد المتاحة من كسر التعادل', + waiting: 'بانتظار النتيجة', + viewProofInView: 'إظهار صور إثبات النتيجة في وضع العرض', + sortBy: 'الترتيب', + zoom: 'التكبير', + currentScore: 'النتيجة الحالية', + aiSuggestedScore: 'النتيجة المقترحة', + confidence: 'مستوى الثقة', + }, + sections: { + groupsTitle: 'عرض اللاعبين والمجموعات', + groupsSubtitle: 'شاشة نظيفة للمتابعة المباشرة حسب المجموعة.', + liveTitle: 'العرض الحي للمجموعة', + liveSubtitle: 'تدوير تلقائي بين المجموعات المسجلة كل 5 ثوان.', + overallTitle: 'الترتيب العام للمرحلة التمهيدية', + overallSubtitle: 'يتم تمييز أفضل 12 متأهل للنهائي.', + finalTitle: 'المرحلة النهائية', + finalSubtitle: 'تقسيم المتأهلين إلى مجموعتين حسب الترتيب.', + finalRanking: 'الترتيب النهائي', + podiumTitle: 'منصة التتويج', + podiumSubtitle: 'المراكز الثلاثة الأولى بعد فك أي تعادل.', + playersTitle: 'إدارة اللاعبين', + playersSubtitle: 'إضافة/تعديل/حذف لاعب، وتعديل الاسم والصورة والمجموعة.', + groupsConfigPlaceholder: 'المجموعات الأساسية (مثال: A,B,C,D)', + preliminaryAdminTitle: 'إدخال نتائج المرحلة التمهيدية', + preliminaryAdminSubtitle: 'إدخال يدوي للنتيجة التمهيدية لكل لاعب.', + prelimTieTitle: 'إدخال كسر تعادل التأهل (أفضل 12)', + prelimTieSubtitle: 'يظهر فقط اللاعبين المتعادلين على حد التأهل.', + finalAdminTitle: 'إدخال نتائج المرحلة النهائية', + finalAdminSubtitle: 'إدخال نتائج النهائي للمجموعتين.', + finalTieTitle: 'إدخال كسر تعادل منصة التتويج', + finalTieSubtitle: 'يظهر فقط عند تعادل المراكز 1-3.', + profileCropTitle: 'تعديل صورة اللاعب', + profileCropSubtitle: 'حرّك وكبّر الصورة لتناسب الإطار الدائري قبل الحفظ.', + aiAdvisorTitle: 'مساعد الذكاء الاصطناعي للنتيجة', + }, + table: { + competitor: 'اللاعب', + group: 'المجموعة', + rank: 'الترتيب', + score: 'النتيجة', + tieScore: 'نتيجة كسر التعادل', + verification: 'التحقق', + status: 'الحالة', + seed: 'التصنيف', + medal: 'الميدالية', + actions: 'الإجراءات', + arabicName: 'الاسم بالعربية', + englishName: 'الاسم بالإنجليزية', + }, + actions: { + refresh: 'تحديث', + login: 'دخول', + logout: 'تسجيل خروج', + updateGroups: 'تحديث المجموعات', + liveRotate: 'دوران تلقائي', + liveFixed: 'مجموعة محددة', + uploadProof: 'رفع إثبات', + replaceProof: 'استبدال الإثبات', + removeProof: 'حذف الإثبات', + addPlayer: 'إضافة لاعب', + removeImage: 'حذف الصورة', + delete: 'حذف', + resetScores: 'تصفير نتائج المرحلة', + resetOnlyScores: 'تصفير النتائج فقط', + resetScoresAndProofs: 'تصفير النتائج وحذف الإثبات', + randomEvenGroups: 'توزيع عشوائي متوازن', + searchPlayer: 'بحث بالاسم العربي أو الإنجليزي', + cancel: 'إلغاء', + convertArToEn: 'تحويل عربي → إنجليزي', + convertEnToAr: 'تحويل إنجليزي → عربي', + sortById: 'حسب الرقم', + sortByArabic: 'حسب الاسم العربي', + sortByEnglish: 'حسب الاسم الإنجليزي', + sortByGroup: 'حسب المجموعة', + aiAdvisor: 'اقتراح AI', + quickApplyAi: 'تطبيق سريع AI', + reAnalyze: 'إعادة التحليل', + applySuggestedScore: 'اعتماد النتيجة المقترحة', + applyCrop: 'تطبيق القص', + resetPosition: 'إعادة الضبط', + }, + auth: { + username: 'اسم المستخدم', + password: 'كلمة المرور', + }, + tabs: { + groups: 'المجموعات', + live: 'عرض حي', + overall: 'الترتيب العام', + final: 'النهائي', + podium: 'التتويج', + players: 'اللاعبون', + preliminary: 'التمهيدي', + prelimTie: 'كسر تعادل التأهل', + finalTie: 'كسر تعادل التتويج', + }, + messages: { + saved: 'تم الحفظ بنجاح.', + mustProvideNames: 'يرجى إدخال الاسم بالعربية والإنجليزية.', + noPrelimTie: 'لا يوجد تعادل على حد التأهل.', + noFinalTie: 'لا يوجد تعادل في المراكز 1-3.', + prelimTieUnresolved: 'تعادل التأهل غير محسوم. أدخل نتائج كسر التعادل لتحديد أفضل 12.', + finalTieUnresolved: 'تعادل منصة التتويج غير محسوم. أكمل إدخال كسر التعادل.', + confirmDelete: 'هل تريد حذف اللاعب؟', + confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟', + resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟', + invalidScore: 'النتيجة يجب أن تكون من 0 إلى 9999.', + unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.', + errorPrefix: 'حدث خطأ', + noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.', + noNameToConvert: 'لا يوجد اسم للتحويل.', + aiAnalyzing: 'جاري تحليل الصورة بالذكاء الاصطناعي...', + noProofForAi: 'لا توجد صورة إثبات لتحليلها.', + }, + }, + en: { + titleFallback: 'Datwyler Shooting Event', + subtitle: 'Pistol class · Distances: 15m ← 20m ← 25m', + viewMode: 'View Mode', + adminMode: 'Admin Panel', + adminLogin: 'Admin Login', + adminLoginDesc: 'Enter admin credentials for full tournament control.', + adminPanel: 'Admin Control Panel', + adminPanelDesc: 'Manage players, assignments, and scoring for all stages.', + defaultCredentials: 'Default admin credentials: datwyler / datwyler', + labels: { + liveTracker: 'LIVE TRACKER', + mode: 'Mode', + language: 'Language', + lastSync: 'Last sync', + loading: 'Loading tournament data...', + players: 'Players', + group: 'Group', + unassigned: 'Unassigned', + emptyLive: 'No players in this group', + allPlayers: 'All players', + top12: 'Top 12 finalists', + finalist: 'Finalist', + notFinalist: 'Not finalist', + noPlayers: 'No players yet', + noFinalists: 'No finalists yet', + finalGroup1: 'Final Group 1 (Seeds 1-6)', + finalGroup2: 'Final Group 2 (Seeds 7-12)', + tieSlots: 'Tie-break slots', + waiting: 'Waiting', + viewProofInView: 'Allow proof images in view mode', + sortBy: 'Sort by', + zoom: 'Zoom', + currentScore: 'Current score', + aiSuggestedScore: 'AI suggested score', + confidence: 'Confidence', + }, + sections: { + groupsTitle: 'Players & Groups Overview', + groupsSubtitle: 'Clean view screen for live grouping.', + liveTitle: 'Live Group Screen', + liveSubtitle: 'Automatic rotation through registered groups every 5 seconds.', + overallTitle: 'Preliminary Overall Ranking', + overallSubtitle: 'Top 12 finalists are highlighted.', + finalTitle: 'Final Stage', + finalSubtitle: 'Finalists are split into two groups by rank.', + finalRanking: 'Final Ranking', + podiumTitle: 'Podium', + podiumSubtitle: 'Top 3 after tie-break resolution.', + playersTitle: 'Players Management', + playersSubtitle: 'Add/update/remove players, names, images, and group assignment.', + groupsConfigPlaceholder: 'Primary groups (example: A,B,C,D)', + preliminaryAdminTitle: 'Preliminary Scoring', + preliminaryAdminSubtitle: 'Manually input each player preliminary score.', + prelimTieTitle: 'Qualification Tie-Break Scoring (Top 12)', + prelimTieSubtitle: 'Only players tied at qualification cutoff are shown.', + finalAdminTitle: 'Final Stage Scoring', + finalAdminSubtitle: 'Input final scores for both final groups.', + finalTieTitle: 'Podium Tie-Break Scoring', + finalTieSubtitle: 'Shown only when places 1-3 are tied.', + profileCropTitle: 'Adjust Player Photo', + profileCropSubtitle: 'Move and zoom to fit the circular avatar before saving.', + aiAdvisorTitle: 'AI Score Advisor', + }, + table: { + competitor: 'Player', + group: 'Group', + rank: 'Rank', + score: 'Score', + tieScore: 'Tie-Break Score', + verification: 'Verification', + status: 'Status', + seed: 'Seed', + medal: 'Medal', + actions: 'Actions', + arabicName: 'Arabic Name', + englishName: 'English Name', + }, + actions: { + refresh: 'Refresh', + login: 'Login', + logout: 'Logout', + updateGroups: 'Update Groups', + liveRotate: 'Auto Rotation', + liveFixed: 'Fixed Group', + uploadProof: 'Upload Proof', + replaceProof: 'Replace Proof', + removeProof: 'Remove Proof', + addPlayer: 'Add Player', + removeImage: 'Remove Image', + delete: 'Delete', + resetScores: 'Reset Stage Scores', + resetOnlyScores: 'Reset scores only', + resetScoresAndProofs: 'Reset scores and proofs', + randomEvenGroups: 'Random even grouping', + searchPlayer: 'Search by Arabic or English name', + cancel: 'Cancel', + convertArToEn: 'Convert Arabic → English', + convertEnToAr: 'Convert English → Arabic', + sortById: 'By ID', + sortByArabic: 'By Arabic name', + sortByEnglish: 'By English name', + sortByGroup: 'By group', + aiAdvisor: 'AI Advice', + quickApplyAi: 'Quick Apply AI', + reAnalyze: 'Re-analyze', + applySuggestedScore: 'Apply suggested score', + applyCrop: 'Apply crop', + resetPosition: 'Reset position', + }, + auth: { + username: 'Username', + password: 'Password', + }, + tabs: { + groups: 'Groups', + live: 'Live', + overall: 'Overall', + final: 'Final', + podium: 'Podium', + players: 'Players', + preliminary: 'Preliminary', + prelimTie: 'Prelim Tie-Break', + finalTie: 'Final Tie-Break', + }, + messages: { + saved: 'Saved successfully.', + mustProvideNames: 'Arabic and English names are required.', + noPrelimTie: 'No tie at qualification cutoff.', + noFinalTie: 'No tie in places 1-3.', + prelimTieUnresolved: 'Qualification tie is unresolved. Enter tie-break scores to finalize top 12.', + finalTieUnresolved: 'Podium tie is unresolved. Complete tie-break scoring.', + confirmDelete: 'Delete this player?', + confirmReset: 'Reset this stage scores?', + resetProofPrompt: 'Also remove all proof images for this stage?', + invalidScore: 'Score must be between 0 and 9999.', + unauthorized: 'Admin session expired. Please login again.', + errorPrefix: 'Error', + noGroupsConfigured: 'No primary groups configured.', + noNameToConvert: 'No name available to convert.', + aiAnalyzing: 'Analyzing image with AI...', + noProofForAi: 'No proof image available to analyze.', + }, + }, +} + +export function createInitialState() { + return { + competition: { titleAr: '', titleEn: '' }, + players: [], + scores: { + preliminary: {}, + prelim_tiebreak: {}, + final: {}, + final_tiebreak: {}, + }, + scoreProofs: {}, + settings: { viewProofInView: false }, + derived: { + preliminaryRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } }, + finalists: [], + finalGroups: { group1: [], group2: [] }, + finalRanking: { rows: [], tieBreak: { required: false, resolved: true, slots: 0, playerIds: [] } }, + podium: [], + }, + serverTime: '', + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..fe5bae3 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './style.css' + +createApp(App).mount('#app') diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..dc44dd7 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,1749 @@ +:root { + --navy: #1b1f40; + --teal: #008ca8; + --red: #ec2f23; + --gold: #d4920a; + --bg: #edf1f8; + --surface: #ffffff; + --border: #d8dce8; + --text: #1b1f40; + --muted: #687798; + --ok: #0d8a64; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + margin: 0; + min-height: 100%; +} + +body { + background: radial-gradient(circle at 10% 0%, #f7f8fc 0, #edf1f8 40%, #e8edf7 100%); + color: var(--text); + font-family: Cairo, sans-serif; +} + +.mode-fade-enter-active, +.mode-fade-leave-active { + transition: opacity 0.24s ease, transform 0.24s ease; +} + +.mode-fade-enter-from, +.mode-fade-leave-to { + opacity: 0; + transform: translateY(8px); +} + +.app-shell { + min-height: 100vh; +} + +.hidden-file-input { + display: none; +} + +.masthead { + position: relative; + background: linear-gradient(120deg, rgba(27, 31, 64, 0.05) 0%, rgba(0, 140, 168, 0.04) 100%), var(--surface); + border-bottom: 1px solid var(--border); + box-shadow: 0 12px 24px rgba(27, 31, 64, 0.08); +} + +.masthead::after { + content: ""; + position: absolute; + inset-inline: 0; + bottom: 0; + height: 4px; + background: linear-gradient(90deg, var(--navy) 0 33%, var(--teal) 33% 66%, var(--red) 66% 100%); +} + +.masthead-main { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; + padding: 24px 28px 26px; +} + +.masthead-title { + margin: 0; + font-size: clamp(30px, 5vw, 44px); + font-weight: 800; + letter-spacing: 0.6px; +} + +.masthead-subtitle { + margin: 8px 0 0; + color: var(--muted); + font-size: 15px; + font-weight: 700; +} + +.masthead-controls { + display: grid; + gap: 10px; + justify-items: end; + min-width: 360px; +} + +.controls-grid { + display: grid; + gap: 8px; +} + +.control-box { + display: grid; + gap: 4px; +} + +.control-label { + margin: 0; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.9px; + text-transform: uppercase; + color: #6b7a99; +} + +.status-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.language-switcher { + display: inline-flex; + border: 1px solid #cfd6e7; + background: #f2f6fc; + border-radius: 12px; + padding: 2px; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7); +} + +.lang-btn { + border: 0; + background: transparent; + color: var(--muted); + border-radius: 10px; + padding: 8px 13px; + font-weight: 700; + font-size: 13px; + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease; +} + +.lang-btn.active { + background: var(--navy); + color: #fff; +} + +.live-badge { + font-family: "IBM Plex Mono", monospace; + letter-spacing: 1px; + font-weight: 700; + color: var(--red); + font-size: 12px; +} + +.server-time { + color: var(--muted); + font-size: 12px; +} + +.tab-bar { + position: sticky; + top: 0; + z-index: 8; + display: grid; + grid-auto-flow: column; + grid-auto-columns: minmax(140px, 1fr); + overflow-x: auto; + background: var(--navy); + box-shadow: 0 8px 18px rgba(27, 31, 64, 0.25); + margin-bottom: 12px; +} + +.tab-btn { + border: 0; + border-inline-end: 1px solid rgba(255, 255, 255, 0.1); + background: transparent; + color: rgba(255, 255, 255, 0.64); + padding: 16px 14px; + font-weight: 700; + white-space: nowrap; + cursor: pointer; + transition: background-color 0.2s ease, color 0.2s ease, box-shadow 0.2s ease; +} + +.tab-btn.active { + color: #fff; + background: rgba(236, 47, 35, 0.2); + box-shadow: inset 0 -3px 0 var(--red); +} + +.mode-switcher .lang-btn { + min-width: 88px; +} + +.page-content { + padding: 24px; +} + +.panel { + background: rgba(255, 255, 255, 0.86); + backdrop-filter: blur(8px); + border: 1px solid var(--border); + border-radius: 16px; + padding: 24px; + box-shadow: 0 8px 20px rgba(27, 31, 64, 0.08); + animation: panel-fade-in 0.28s ease; +} + +.panel-heading h2 { + margin: 0; + font-size: clamp(24px, 4vw, 32px); + font-weight: 800; +} + +.panel-heading p { + margin: 8px 0 0; + color: var(--muted); + font-weight: 600; +} + +.panel-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 18px; + margin-bottom: 14px; +} + +.panel-actions.compact { + margin-top: 10px; + margin-bottom: 10px; +} + +.admin-head-panel { + margin-bottom: 12px; +} + +.admin-head { + display: flex; + justify-content: space-between; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.admin-head h2 { + margin: 0; +} + +.admin-head .muted { + margin-top: 6px; +} + +.admin-settings-row { + margin-top: 10px; +} + +.switch-row { + display: inline-flex; + align-items: center; + gap: 8px; + font-weight: 700; + color: var(--text); +} + +.switch-row input[type="checkbox"] { + width: 18px; + height: 18px; +} + +.admin-tab-bar { + margin-bottom: 12px; +} + +.admin-login-panel { + max-width: 560px; +} + +.admin-login-grid { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 8px; + margin-top: 12px; +} + +.inline-form { + display: grid; + grid-template-columns: 1.1fr 1.1fr 0.8fr auto; + gap: 8px; + margin-top: 14px; + margin-bottom: 14px; +} + +.group-setup-form { + grid-template-columns: 1fr auto; + align-items: center; +} + +.admin-summary-grid { + margin-top: 14px; + margin-bottom: 8px; +} + +.action-stack { + display: grid; + gap: 8px; +} + +.btn { + border: 2px solid transparent; + border-radius: 10px; + padding: 9px 14px; + font-weight: 700; + cursor: pointer; + font-family: inherit; + transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease; +} + +.btn-primary { + background: var(--teal); + color: #fff; + border-color: var(--teal); +} + +.btn-secondary { + background: var(--navy); + color: #fff; +} + +.btn-danger { + background: #fff6f5; + color: var(--red); + border-color: var(--red); +} + +.btn-outline { + background: transparent; + color: var(--text); + border-color: #c5ccdd; +} + +.btn.light { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.4); + color: #fff; +} + +.btn.light.active { + background: #fff; + border-color: #fff; + color: #0f1a41; +} + +.btn-success { + background: #eefaf6; + border-color: var(--ok); + color: var(--ok); +} + +.btn:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.btn:not(:disabled):hover { + transform: translateY(-1px); +} + +.btn-xs { + padding: 6px 8px; + font-size: 11px; + border-radius: 8px; +} + +.btn-ai { + background: linear-gradient(135deg, #111a4a 0%, #1b4f9c 50%, #00a4c3 100%); + color: #fff; + border-color: transparent; + box-shadow: 0 6px 14px rgba(0, 88, 168, 0.25); +} + +.hint-box { + margin-bottom: 16px; + background: #f3fbfe; + border-inline-start: 4px solid var(--teal); + border-radius: 10px; + padding: 12px 14px; + font-weight: 600; + color: #18455a; +} + +.hint-box.danger { + background: #fff5f5; + border-inline-start-color: var(--red); + color: #6f1f1a; +} + +.summary-grid { + display: grid; + grid-template-columns: repeat(5, minmax(130px, 1fr)); + gap: 12px; + margin-bottom: 18px; +} + +.summary-card { + background: #fff; + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px; + text-align: center; +} + +.summary-card h3 { + margin: 0; + font-size: 14px; + color: var(--muted); +} + +.summary-value { + margin: 4px 0; + font-size: 36px; + font-family: "Bebas Neue", sans-serif; +} + +.summary-status { + margin: 0; + font-size: 13px; + font-weight: 700; +} + +.summary-status.ok { + color: var(--ok); +} + +.summary-status.warn { + color: var(--red); +} + +.view-group-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 14px; +} + +.view-group-card { + border: 1px solid var(--border); + border-radius: 14px; + background: #fff; + overflow: hidden; + box-shadow: 0 10px 20px rgba(27, 31, 64, 0.06); + animation: fade-up 0.26s ease both; +} + +.view-group-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid #e7ebf4; + background: linear-gradient(180deg, #f8fbff 0%, #f1f6ff 100%); +} + +.view-group-card-head h3 { + margin: 0; + font-size: 15px; + font-weight: 800; +} + +.group-a { + border-top: 4px solid var(--navy); +} + +.group-b { + border-top: 4px solid var(--teal); +} + +.group-c { + border-top: 4px solid var(--red); +} + +.group-d { + border-top: 4px solid var(--gold); +} + +.group-u { + border-top: 4px solid #9aa6c2; +} + +.table-wrap { + overflow-x: auto; + border: 1px solid var(--border); + border-radius: 12px; + background: #fff; +} + +.score-table { + width: 100%; + border-collapse: collapse; +} + +.score-table thead th { + background: var(--navy); + color: #d9e8ff; + font-size: 12px; + letter-spacing: 0.5px; + text-transform: uppercase; + white-space: nowrap; + padding: 12px 10px; +} + +.score-table td { + border-top: 1px solid #e8ecf5; + padding: 11px 10px; + text-align: center; + white-space: nowrap; +} + +.row-group-a { + background: rgba(27, 31, 64, 0.04); +} + +.row-group-b { + background: rgba(0, 140, 168, 0.04); +} + +.row-group-c { + background: rgba(236, 47, 35, 0.04); +} + +.row-group-d { + background: rgba(212, 146, 10, 0.05); +} + +.row-group-empty { + background: rgba(154, 166, 194, 0.08); +} + +.qualified-row { + background: linear-gradient(90deg, rgba(0, 140, 168, 0.09), rgba(0, 140, 168, 0.02)); +} + +.podium-row { + background: linear-gradient(90deg, rgba(212, 146, 10, 0.12), rgba(212, 146, 10, 0.03)); +} + +.competitor-cell { + display: flex; + align-items: center; + gap: 10px; + text-align: start; +} + +.competitor-cell.compact .competitor-image { + width: 42px; + height: 42px; +} + +.competitor-image { + width: 70px; + height: 70px; + object-fit: cover; + border-radius: 50%; + border: 2px solid #d2dbef; + background: #eef2f8; +} + +.competitor-image.clickable { + cursor: pointer; +} + +.name-edit-grid { + display: flex; + align-items: center; + width: 100%; + gap: 6px; +} + +.name-edit-grid.vertical { + flex-direction: column; + align-items: stretch; +} + +.players-tools { + display: grid; + grid-template-columns: 1.2fr auto; + gap: 8px; + margin-bottom: 12px; +} + +.players-tools-elevated { + border: 1px solid var(--border); + border-radius: 14px; + padding: 12px; + background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%); + box-shadow: 0 6px 14px rgba(27, 31, 64, 0.06); + margin-top: 8px; + margin-bottom: 14px; +} + +.pm-config-card, +.pm-composer-card { + border: 1px solid var(--border); + border-radius: 14px; + background: linear-gradient(180deg, #ffffff 0%, #f9fbff 100%); + box-shadow: 0 8px 16px rgba(27, 31, 64, 0.06); + padding: 12px; + margin-bottom: 12px; +} + +.pm-config-top { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 8px; +} + +.pm-config-input { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; +} + +.pm-composer-grid { + display: grid; + grid-template-columns: 1.1fr 1.1fr 0.8fr auto; + gap: 8px; +} + +.pm-composer-actions { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} + +.players-sort-box { + display: grid; + gap: 4px; +} + +.group-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); + gap: 14px; +} + +.group-player-card { + border: 1px solid var(--border); + border-radius: 14px; + background: #fff; + overflow: hidden; + box-shadow: 0 10px 20px rgba(27, 31, 64, 0.06); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.group-player-card:hover { + transform: translateY(-2px); + box-shadow: 0 14px 26px rgba(27, 31, 64, 0.1); +} + +.group-player-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 12px 14px; + border-bottom: 1px solid #e7ebf4; + background: linear-gradient(180deg, #f8fbff 0%, #f1f6ff 100%); +} + +.group-player-card-head h3 { + margin: 0; + font-size: 15px; + font-weight: 800; +} + +.pm-count-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 10px; + border-radius: 999px; + background: #fff; + border: 1px solid #d5deef; + color: #344161; + font-size: 12px; + font-weight: 700; +} + +.group-player-list { + display: grid; + gap: 10px; + padding: 12px; +} + +.group-player-row { + border: 1px solid #e2e8f5; + border-radius: 10px; + background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%); + padding: 12px; +} + +.group-player-top { + display: flex; + justify-content: space-between; + gap: 8px; + align-items: flex-start; +} + +.group-player-actions { + margin-top: 10px; + display: grid; + grid-template-columns: 1fr auto auto; + gap: 8px; +} + +.name-convert-row { + margin-top: 10px; + margin-bottom: 0; +} + +.name-convert-row .btn { + flex: 1; +} + +.name-input { + border: 1px solid #c8d1e5; + border-radius: 8px; + width: 100%; + padding: 6px 8px; + font-size: 13px; + font-family: inherit; +} + +select.name-input { + cursor: pointer; + background: #fff; +} + +.name-input.ar { + direction: rtl; +} + +.name-main { + margin: 0; + font-weight: 700; +} + +.name-sub { + margin: 0; + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 12px; +} + +.group-select { + border: 1px solid #c8d1e5; + border-radius: 8px; + padding: 6px 8px; + background: #fff; +} + +.stage-filter-bar { + margin-bottom: 10px; +} + +.score-input { + width: 88px; + min-height: 40px; + border: 1px solid #c8d1e5; + border-radius: 8px; + padding: 8px 10px; + text-align: center; + font-family: "IBM Plex Mono", monospace; + font-weight: 700; + font-size: 16px; + color: #125773; + background: #f0fbff; +} + +.score-input:focus { + outline: 2px solid rgba(0, 140, 168, 0.45); + border-color: #1a9bb4; +} + +.score-input:disabled { + background: #e7ecf5; + color: #7d87a1; +} + +.proof-actions { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 6px; +} + +.proof-thumb { + width: 42px; + height: 42px; + border-radius: 8px; + object-fit: cover; + border: 1px solid #c8d1e5; + cursor: pointer; +} + +.proof-mini { + margin-inline-start: 6px; + border: 1px solid #c8d1e5; + background: #fff; + border-radius: 6px; + width: 28px; + height: 28px; + padding: 0; + vertical-align: middle; + cursor: pointer; +} + +.proof-mini img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 5px; +} + +.mono { + font-family: "IBM Plex Mono", monospace; +} + +.strong { + font-weight: 700; +} + +.rank { + font-size: 20px; + color: var(--gold); +} + +.badge.success { + background: rgba(13, 138, 100, 0.12); + border: 1px solid rgba(13, 138, 100, 0.4); + color: var(--ok); + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.muted { + color: var(--muted); +} + +.center { + text-align: center; +} + +.two-column { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.sub-heading { + margin: 0 0 10px; + font-size: 20px; +} + +.group-stage { + margin-top: 12px; +} + +.tie-group { + margin-top: 18px; +} + +.tie-score { + font-size: 12px; + border: 1px solid rgba(236, 47, 35, 0.4); + background: rgba(236, 47, 35, 0.08); + color: var(--red); + border-radius: 999px; + padding: 3px 8px; +} + +.mt-32 { + margin-top: 32px; +} + +.live-panel { + background: radial-gradient(circle at 15% 10%, #1a2653 0%, #101736 55%, #0b1026 100%); + color: #fff; + min-height: 70vh; + position: relative; + padding-top: 72px; +} + +.live-corner { + position: absolute; + top: 14px; + inset-inline-end: 14px; + z-index: 2; +} + +.live-title { + text-align: center; + font-size: 30px; + font-weight: 800; + color: #f1cf7a; +} + +.live-subtitle { + text-align: center; + color: rgba(255, 255, 255, 0.75); + margin: 8px 0 18px; +} + +.group-select { + border: 1px solid #c8d1e5; + border-radius: 8px; + padding: 6px 8px; + color: var(--text); +} + +.live-top-controls { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 0; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 12px; + padding: 8px; + backdrop-filter: blur(5px); +} + +.live-group-select { + min-width: 130px; + border-color: rgba(255, 255, 255, 0.4); + background: rgba(255, 255, 255, 0.1); +} + +.live-group-select option { + color: #0f1a41; +} + +.live-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.live-card { + display: flex; + align-items: center; + gap: 12px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + padding: 12px; + animation: fade-up 0.4s ease; +} + +.live-image { + width: 70px; + height: 70px; + border-radius: 50%; + border: 3px solid #3ec8e0; + object-fit: cover; +} + +.live-number { + margin: 0; + color: #84e4f7; + font-family: "IBM Plex Mono", monospace; +} + +.live-name-primary { + margin: 4px 0 2px; + font-weight: 800; + font-size: 20px; +} + +.live-name-secondary { + margin: 0; + color: rgba(255, 255, 255, 0.7); + font-family: "IBM Plex Mono", monospace; + font-size: 12px; +} + +.live-progress { + margin-top: 16px; + height: 5px; + width: 100%; + border-radius: 999px; + background: linear-gradient(90deg, var(--red), #f66f67); + animation: progress-5s linear; +} + +.empty-state { + text-align: center; + padding: 30px 10px; + color: var(--muted); + font-weight: 700; +} + +.empty-state.good { + color: var(--ok); +} + +.podium-panel { + background: #f0f2f8; +} + +.podium-wrapper { + display: flex; + justify-content: center; + align-items: flex-end; + height: 380px; + margin-top: 96px; + gap: 14px; + padding: 0 12px; + direction: ltr; +} + +.podium-col { + flex: 1; + max-width: 280px; + display: flex; + flex-direction: column; + align-items: center; + position: relative; + background: #fff; + border-radius: 16px 16px 0 0; + box-shadow: 0 10px 40px rgba(13, 27, 46, 0.1); + transition: transform 0.25s ease; +} + +.podium-col:hover { + transform: translateY(-4px); +} + +.podium-col.pos-1 { + height: 320px; + border-top: 8px solid #d4af37; + background: linear-gradient(180deg, #fffaf0 0%, #ffffff 100%); + z-index: 3; + box-shadow: 0 15px 50px rgba(212, 175, 55, 0.2); +} + +.podium-col.pos-2 { + height: 240px; + border-top: 8px solid #c0c0c0; + background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%); + z-index: 2; +} + +.podium-col.pos-3 { + height: 180px; + border-top: 8px solid #cd7f32; + background: linear-gradient(180deg, #fff5e6 0%, #ffffff 100%); + z-index: 1; +} + +.podium-avatar-wrap { + position: absolute; + top: -65px; + left: 50%; + transform: translateX(-50%); +} + +.podium-col.pos-1 .podium-avatar-wrap { + top: -85px; +} + +.podium-img { + width: 120px; + height: 120px; + border-radius: 50%; + object-fit: cover; + border: 5px solid #fff; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + background: #f0f2f8; +} + +.podium-col.pos-1 .podium-img { + width: 160px; + height: 160px; + border: 6px solid #d4af37; + box-shadow: 0 12px 30px rgba(212, 175, 55, 0.3); +} + +.podium-col.pos-2 .podium-img { + border-color: #c0c0c0; +} + +.podium-col.pos-3 .podium-img { + border-color: #cd7f32; +} + +.podium-medal-icon { + font-size: 46px; + margin-top: 65px; + line-height: 1; +} + +.podium-col.pos-1 .podium-medal-icon { + font-size: 64px; + margin-top: 85px; +} + +.podium-name { + font-family: Cairo, sans-serif; + font-size: 24px; + font-weight: 800; + color: var(--navy); + margin-top: 10px; + text-align: center; + line-height: 1.2; + padding: 0 10px; +} + +.podium-col.pos-1 .podium-name { + font-size: 28px; +} + +.podium-name.empty { + color: #8896b0; + font-weight: 600; + font-size: 20px; +} + +.podium-score { + margin-top: auto; + margin-bottom: 25px; + background: rgba(0, 0, 0, 0.04); + padding: 8px 20px; + border-radius: 20px; + font-family: "IBM Plex Mono", monospace; + font-size: 13px; + font-weight: 700; + color: var(--red); + border: 1px solid rgba(0, 0, 0, 0.05); + white-space: nowrap; +} + +.toast { + position: fixed; + left: 50%; + bottom: 24px; + transform: translateX(-50%) translateY(24px); + opacity: 0; + pointer-events: none; + transition: all 0.25s ease; + background: #163f7d; + color: #fff; + border-radius: 10px; + padding: 10px 14px; + font-weight: 700; + z-index: 20; +} + +.toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.toast.error { + background: #9b2a22; +} + +.toast.success { + background: #146e57; +} + +.auth-overlay { + position: fixed; + inset: 0; + background: rgba(9, 13, 30, 0.55); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 30; +} + +.auth-modal { + width: min(92vw, 360px); + background: #fff; + border-radius: 14px; + border-top: 6px solid var(--red); + padding: 18px; +} + +.auth-modal h3 { + margin: 0 0 6px; +} + +.auth-modal p { + margin: 0 0 12px; + color: var(--muted); +} + +.auth-modal input { + width: 100%; + border: 1px solid #c8d1e5; + border-radius: 8px; + padding: 10px; + margin-bottom: 8px; +} + +.auth-error { + color: var(--red); + font-weight: 700; +} + +.auth-actions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.loading-state { + min-height: 46vh; + display: grid; + place-items: center; + color: var(--muted); + font-weight: 700; +} + +.spinner { + width: 44px; + height: 44px; + border: 4px solid rgba(0, 140, 168, 0.2); + border-top-color: var(--teal); + border-radius: 50%; + animation: spin 0.9s linear infinite; +} + +.desktop-score-table { + display: block; +} + +.mobile-score-cards { + display: none; +} + +.score-card { + border: 1px solid var(--border); + border-radius: 12px; + background: #fff; + padding: 12px; + margin-bottom: 10px; +} + +.score-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.score-card-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: 12px; + color: var(--muted); + margin: 8px 0; +} + +.score-label { + display: block; + font-size: 12px; + font-weight: 700; + color: var(--muted); + margin-bottom: 5px; +} + +.mobile-proof-actions { + justify-content: flex-start; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(9, 13, 30, 0.55); + backdrop-filter: blur(4px); + display: grid; + place-items: center; + z-index: 40; + padding: 16px; +} + +.modal-card { + width: min(95vw, 460px); + background: #fff; + border-radius: 14px; + border: 1px solid var(--border); + padding: 16px; +} + +.modal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.modal-head h3 { + margin: 0; +} + +.modal-title { + margin: 0 0 8px; +} + +.modal-text { + margin: 0 0 10px; + color: var(--text); +} + +.modal-text.subtle { + color: var(--muted); + font-size: 13px; +} + +.modal-actions { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.proof-modal-card { + width: min(96vw, 920px); +} + +.proof-modal-image-wrap { + margin-top: 10px; + background: #111; + border-radius: 10px; + overflow: hidden; + max-height: 75vh; + display: grid; + place-items: center; +} + +.proof-modal-image { + width: 100%; + height: auto; + max-height: 75vh; + object-fit: contain; +} + +.image-crop-modal { + width: min(96vw, 520px); +} + +.crop-canvas-wrap { + position: relative; + width: 340px; + height: 340px; + margin: 0 auto; + border-radius: 14px; + overflow: hidden; + border: 1px solid #d8deec; + background: #f2f4fb; +} + +.crop-canvas { + width: 100%; + height: 100%; + touch-action: none; + display: block; + cursor: grab; +} + +.crop-canvas:active { + cursor: grabbing; +} + +.crop-circle-guide { + position: absolute; + inset: 16px; + border-radius: 50%; + border: 2px solid rgba(255, 255, 255, 0.95); + box-shadow: 0 0 0 999px rgba(10, 18, 44, 0.34); + pointer-events: none; +} + +.crop-controls { + margin-top: 12px; + display: grid; + gap: 6px; +} + +.crop-controls label { + font-size: 12px; + color: var(--muted); + font-weight: 700; +} + +.crop-controls input[type="range"] { + width: 100%; +} + +.modal-actions.split { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.ai-advice-modal { + width: min(98vw, 1100px); + background: radial-gradient(circle at 15% 0%, #f8fbff 0, #ffffff 35%, #f2f8ff 100%); +} + +.ai-advice-modal.compact { + width: min(96vw, 760px); +} + +.ai-modal-body { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 14px; + margin-top: 8px; +} + +.ai-modal-body.compact { + grid-template-columns: minmax(0, 1fr) 290px; +} + +.ai-image-wrap { + position: relative; + min-height: 320px; + border: 1px solid #d8deec; + border-radius: 12px; + background: #111827; + overflow: hidden; +} + +.ai-modal-body.compact .ai-image-wrap { + min-height: 250px; + max-height: 300px; +} + +.ai-proof-image { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +.ai-marker-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.ai-marker-dot { + position: absolute; + transform: translate(-50%, -50%); + width: 24px; + height: 24px; + border: 2px solid rgba(0, 230, 255, 0.9); + border-radius: 50%; + background: rgba(0, 230, 255, 0.18); + box-shadow: 0 0 12px rgba(0, 230, 255, 0.7); + color: #fff; + font-size: 10px; + font-weight: 700; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.ai-info-panel { + border: 1px solid #d8deec; + border-radius: 12px; + background: linear-gradient(180deg, #ffffff 0%, #f5f9ff 100%); + padding: 12px; +} + +.ai-loading-state { + min-height: 220px; +} + +.ai-metric-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +.ai-metric-card { + border: 1px solid #dde4f3; + border-radius: 10px; + background: #fff; + padding: 10px; +} + +.ai-metric-card.highlight { + border-color: #67c3ff; + box-shadow: inset 0 0 0 1px rgba(103, 195, 255, 0.35); +} + +.ai-metric-card.single { + margin-bottom: 8px; +} + +.ai-label { + margin: 0; + font-size: 11px; + color: var(--muted); + font-weight: 700; +} + +.ai-value { + margin: 5px 0 0; + font-size: 28px; + color: #1d2c55; +} + +.ai-confidence { + margin: 10px 0 8px; + font-size: 13px; + color: #2a4d78; +} + +.ai-summary { + margin: 0; + line-height: 1.45; + font-size: 13px; + color: #1b2d49; +} + +.ai-model { + margin: 10px 0 0; + font-size: 11px; + color: #6880aa; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes panel-fade-in { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes progress-5s { + from { + width: 0%; + } + to { + width: 100%; + } +} + +@media (max-width: 1180px) { + .summary-grid { + grid-template-columns: repeat(3, minmax(130px, 1fr)); + } + + .two-column { + grid-template-columns: 1fr; + } +} + +@media (max-width: 860px) { + .page-content { + padding: 12px; + } + + .panel { + padding: 14px; + } + + .masthead-main { + flex-direction: column; + padding: 16px; + } + + .masthead-controls { + justify-items: start; + min-width: 0; + width: 100%; + } + + .tab-bar { + grid-auto-columns: minmax(126px, 1fr); + } + + .summary-grid { + grid-template-columns: repeat(2, minmax(130px, 1fr)); + } + + .admin-login-grid { + grid-template-columns: 1fr; + } + + .inline-form { + grid-template-columns: 1fr; + } + + .group-setup-form { + grid-template-columns: 1fr; + } + + .pm-config-top, + .pm-config-input, + .pm-composer-grid { + grid-template-columns: 1fr; + } + + .pm-composer-actions { + flex-direction: column; + } + + .players-tools { + grid-template-columns: 1fr; + } + + .group-cards-grid { + grid-template-columns: 1fr; + } + + .view-group-grid { + grid-template-columns: 1fr; + } + + .group-player-actions { + grid-template-columns: 1fr; + } + + .live-grid { + grid-template-columns: 1fr; + } + + .status-row { + justify-content: flex-start; + } + + .live-corner { + position: static; + margin-bottom: 10px; + } + + .live-top-controls { + justify-content: stretch; + width: 100%; + } + + .live-top-controls .btn, + .live-group-select { + width: 100%; + } + + .podium-wrapper { + height: auto; + margin-top: 16px; + padding-top: 8px; + flex-direction: column; + align-items: stretch; + } + + .podium-col { + max-width: 100%; + border-radius: 16px; + min-height: 180px; + height: auto !important; + padding-top: 88px; + padding-bottom: 16px; + } + + .competitor-image { + width: 56px; + height: 56px; + } + + .score-input { + width: 100%; + } + + .proof-actions { + flex-direction: column; + } + + .proof-actions .btn { + width: 100%; + } + + .modal-actions.split { + grid-template-columns: 1fr; + } + + .ai-modal-body { + grid-template-columns: 1fr; + } + + .ai-modal-body.compact .ai-image-wrap { + min-height: 210px; + max-height: 240px; + } + + .crop-canvas-wrap { + width: 100%; + height: auto; + aspect-ratio: 1 / 1; + } + + .ai-marker-dot { + width: 20px; + height: 20px; + font-size: 9px; + } + + .desktop-score-table { + display: none; + } + + .mobile-score-cards { + display: block; + } +} diff --git a/frontend/src/utils/groups.js b/frontend/src/utils/groups.js new file mode 100644 index 0000000..a319e1e --- /dev/null +++ b/frontend/src/utils/groups.js @@ -0,0 +1,28 @@ +export const DEFAULT_PRIMARY_GROUPS = ['A', 'B', 'C', 'D'] + +export function normalizedGroupCode(value) { + return String(value || '').trim().toUpperCase() +} + +export function parseGroupList(raw) { + const items = String(raw || '') + .split(',') + .map((item) => normalizedGroupCode(item)) + .filter(Boolean) + return [...new Set(items)] +} + +export function loadPrimaryGroups(storage) { + const stored = storage.getItem('shooting_group_list') + const parsed = parseGroupList(stored) + return parsed.length > 0 ? parsed : DEFAULT_PRIMARY_GROUPS +} + +export function groupKey(code) { + const normalized = normalizedGroupCode(code) + if (normalized.startsWith('A')) return 'a' + if (normalized.startsWith('B')) return 'b' + if (normalized.startsWith('C')) return 'c' + if (normalized.startsWith('D')) return 'd' + return 'u' +} diff --git a/frontend/src/utils/nameTransliteration.js b/frontend/src/utils/nameTransliteration.js new file mode 100644 index 0000000..cc0cb41 --- /dev/null +++ b/frontend/src/utils/nameTransliteration.js @@ -0,0 +1,220 @@ +const ARABIC_DIACRITICS = /[\u064B-\u065F\u0670\u0640]/g + +const AR_TO_EN_WORD = { + محمد: 'Mohammad', + احمد: 'Ahmad', + محمود: 'Mahmoud', + عبدالرحمن: 'Abdulrahman', + عبدالله: 'Abdullah', + عبدالله: 'Abdullah', + عبدالاله: 'Abdulilah', + عمر: 'Omar', + علي: 'Ali', + خالد: 'Khaled', + خليل: 'Khalil', + يزن: 'Yazan', + يزيد: 'Yazeed', + معاذ: 'Moaz', + طارق: 'Tareq', + زيد: 'Zaid', + سامر: 'Samer', + سيف: 'Saif', + حسام: 'Hossam', + باسم: 'Bassem', + امجد: 'Amjad', + مأمون: 'Maamoun', + اياد: 'Eyad', + إياد: 'Eyad', + حمزة: 'Hamza', + حمزه: 'Hamza', + هيثم: 'Haitham', + وائل: 'Wael', + رائد: 'Raed', + فهد: 'Fahad', + فارس: 'Fares', + ناصر: 'Nasser', + جميل: 'Jameel', +} + +const EN_TO_AR_WORD = { + mohammad: 'محمد', + muhammad: 'محمد', + ahmad: 'أحمد', + ahmed: 'أحمد', + mahmoud: 'محمود', + abdullah: 'عبدالله', + abdallah: 'عبدالله', + abdulrahman: 'عبدالرحمن', + omar: 'عمر', + ali: 'علي', + khaled: 'خالد', + khalil: 'خليل', + yazan: 'يزن', + yazeed: 'يزيد', + moaz: 'معاذ', + tareq: 'طارق', + tariq: 'طارق', + zaid: 'زيد', + zaidan: 'زيدان', + samer: 'سامر', + saif: 'سيف', + hossam: 'حسام', + bassem: 'باسم', + amjad: 'أمجد', + eyad: 'إياد', + hamza: 'حمزة', + haitham: 'هيثم', + wael: 'وائل', + raed: 'رائد', + fahad: 'فهد', + fares: 'فارس', + nasser: 'ناصر', + jameel: 'جميل', +} + +const AR_TO_EN_CHAR = { + ا: 'a', + أ: 'a', + إ: 'i', + آ: 'aa', + ء: '', + ب: 'b', + ت: 't', + ث: 'th', + ج: 'j', + ح: 'h', + خ: 'kh', + د: 'd', + ذ: 'dh', + ر: 'r', + ز: 'z', + س: 's', + ش: 'sh', + ص: 's', + ض: 'd', + ط: 't', + ظ: 'z', + ع: 'a', + غ: 'gh', + ف: 'f', + ق: 'q', + ك: 'k', + ل: 'l', + م: 'm', + ن: 'n', + ه: 'h', + و: 'w', + ي: 'y', + ى: 'a', + ة: 'a', + ئ: 'e', + ؤ: 'o', +} + +const EN_DIGRAPHS = [ + ['sh', 'ش'], + ['kh', 'خ'], + ['gh', 'غ'], + ['th', 'ث'], + ['dh', 'ذ'], + ['ch', 'تش'], + ['ph', 'ف'], + ['aa', 'ا'], + ['ee', 'ي'], + ['oo', 'و'], + ['ou', 'و'], +] + +const EN_TO_AR_CHAR = { + a: 'ا', + b: 'ب', + c: 'ك', + d: 'د', + e: 'ي', + f: 'ف', + g: 'ج', + h: 'ه', + i: 'ي', + j: 'ج', + k: 'ك', + l: 'ل', + m: 'م', + n: 'ن', + o: 'و', + p: 'ب', + q: 'ق', + r: 'ر', + s: 'س', + t: 'ت', + u: 'و', + v: 'ف', + w: 'و', + x: 'كس', + y: 'ي', + z: 'ز', +} + +function normalizeArabicWord(word) { + return String(word || '') + .replace(ARABIC_DIACRITICS, '') + .replace(/[\u0622\u0623\u0625]/g, 'ا') + .replace(/ة/g, 'ه') + .trim() +} + +function titleCase(word) { + if (!word) return '' + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() +} + +function transliterateArabicWord(word) { + const normalized = normalizeArabicWord(word) + if (!normalized) return '' + if (AR_TO_EN_WORD[normalized]) return AR_TO_EN_WORD[normalized] + + let out = '' + for (const ch of normalized) { + out += AR_TO_EN_CHAR[ch] ?? ch + } + return titleCase(out.replace(/aa+/g, 'a')) +} + +function transliterateEnglishWord(word) { + const normalized = String(word || '').trim().toLowerCase() + if (!normalized) return '' + if (EN_TO_AR_WORD[normalized]) return EN_TO_AR_WORD[normalized] + + let left = normalized + let out = '' + while (left.length > 0) { + let matched = false + for (const [latin, arabic] of EN_DIGRAPHS) { + if (left.startsWith(latin)) { + out += arabic + left = left.slice(latin.length) + matched = true + break + } + } + if (matched) continue + out += EN_TO_AR_CHAR[left[0]] ?? left[0] + left = left.slice(1) + } + return out +} + +export function convertNameAuto(direction, value) { + const words = String(value || '') + .split(/\s+/) + .filter(Boolean) + if (words.length === 0) return '' + + if (direction === 'ar_to_en') { + return words.map(transliterateArabicWord).filter(Boolean).join(' ') + } + if (direction === 'en_to_ar') { + return words.map(transliterateEnglishWord).filter(Boolean).join(' ') + } + return String(value || '') +} + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..0cf6d69 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, +})