From 1a803b7fa0eb56e5763f21f9cad98e23c37a6053 Mon Sep 17 00:00:00 2001 From: jwkim Date: Wed, 10 Jun 2026 15:11:56 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EC=B6=94=EA=B0=80,=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AD=EA=B8=B0=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Dockerfile | 1 + backend/app/main.py | 2 + backend/app/models.py | 26 ++++++++ backend/app/routers/leaderboard.py | 68 ++++++++----------- backend/app/scoring.py | 95 +++++++++++++++++++++++++- backend/app/services/grading.py | 16 +++-- backend/app/services/points.py | 99 ++++++++++++++++++++++++++++ backend/app/worker.py | 2 + backend/data/scoring.json | 7 ++ frontend/public/assets/flags/alg.svg | 1 + frontend/public/assets/flags/arg.svg | 1 + frontend/public/assets/flags/aus.svg | 1 + frontend/public/assets/flags/aut.svg | 1 + frontend/public/assets/flags/bel.svg | 1 + frontend/public/assets/flags/bih.svg | 1 + frontend/public/assets/flags/bra.svg | 1 + frontend/public/assets/flags/can.svg | 1 + frontend/public/assets/flags/civ.svg | 1 + frontend/public/assets/flags/cod.svg | 1 + frontend/public/assets/flags/col.svg | 1 + frontend/public/assets/flags/cpv.svg | 1 + frontend/public/assets/flags/cro.svg | 1 + frontend/public/assets/flags/cuw.svg | 1 + frontend/public/assets/flags/cze.svg | 1 + frontend/public/assets/flags/ecu.svg | 1 + frontend/public/assets/flags/egy.svg | 1 + frontend/public/assets/flags/eng.svg | 1 + frontend/public/assets/flags/esp.svg | 1 + frontend/public/assets/flags/fra.svg | 1 + frontend/public/assets/flags/ger.svg | 1 + frontend/public/assets/flags/gha.svg | 1 + frontend/public/assets/flags/hai.svg | 1 + frontend/public/assets/flags/irn.svg | 1 + frontend/public/assets/flags/irq.svg | 1 + frontend/public/assets/flags/jor.svg | 1 + frontend/public/assets/flags/jpn.svg | 1 + frontend/public/assets/flags/kor.svg | 1 + frontend/public/assets/flags/ksa.svg | 1 + frontend/public/assets/flags/mar.svg | 1 + frontend/public/assets/flags/mex.svg | 1 + frontend/public/assets/flags/ned.svg | 1 + frontend/public/assets/flags/nor.svg | 1 + frontend/public/assets/flags/nzl.svg | 1 + frontend/public/assets/flags/pan.svg | 1 + frontend/public/assets/flags/par.svg | 1 + frontend/public/assets/flags/por.svg | 1 + frontend/public/assets/flags/qat.svg | 1 + frontend/public/assets/flags/rsa.svg | 1 + frontend/public/assets/flags/sco.svg | 1 + frontend/public/assets/flags/sen.svg | 1 + frontend/public/assets/flags/sui.svg | 1 + frontend/public/assets/flags/swe.svg | 1 + frontend/public/assets/flags/tun.svg | 1 + frontend/public/assets/flags/tur.svg | 1 + frontend/public/assets/flags/uru.svg | 1 + frontend/public/assets/flags/usa.svg | 1 + frontend/public/assets/flags/uzb.svg | 1 + frontend/src/components/TeamFlag.tsx | 30 ++++----- 58 files changed, 326 insertions(+), 68 deletions(-) create mode 100644 backend/app/services/points.py create mode 100644 backend/data/scoring.json create mode 100644 frontend/public/assets/flags/alg.svg create mode 100644 frontend/public/assets/flags/arg.svg create mode 100644 frontend/public/assets/flags/aus.svg create mode 100644 frontend/public/assets/flags/aut.svg create mode 100644 frontend/public/assets/flags/bel.svg create mode 100644 frontend/public/assets/flags/bih.svg create mode 100644 frontend/public/assets/flags/bra.svg create mode 100644 frontend/public/assets/flags/can.svg create mode 100644 frontend/public/assets/flags/civ.svg create mode 100644 frontend/public/assets/flags/cod.svg create mode 100644 frontend/public/assets/flags/col.svg create mode 100644 frontend/public/assets/flags/cpv.svg create mode 100644 frontend/public/assets/flags/cro.svg create mode 100644 frontend/public/assets/flags/cuw.svg create mode 100644 frontend/public/assets/flags/cze.svg create mode 100644 frontend/public/assets/flags/ecu.svg create mode 100644 frontend/public/assets/flags/egy.svg create mode 100644 frontend/public/assets/flags/eng.svg create mode 100644 frontend/public/assets/flags/esp.svg create mode 100644 frontend/public/assets/flags/fra.svg create mode 100644 frontend/public/assets/flags/ger.svg create mode 100644 frontend/public/assets/flags/gha.svg create mode 100644 frontend/public/assets/flags/hai.svg create mode 100644 frontend/public/assets/flags/irn.svg create mode 100644 frontend/public/assets/flags/irq.svg create mode 100644 frontend/public/assets/flags/jor.svg create mode 100644 frontend/public/assets/flags/jpn.svg create mode 100644 frontend/public/assets/flags/kor.svg create mode 100644 frontend/public/assets/flags/ksa.svg create mode 100644 frontend/public/assets/flags/mar.svg create mode 100644 frontend/public/assets/flags/mex.svg create mode 100644 frontend/public/assets/flags/ned.svg create mode 100644 frontend/public/assets/flags/nor.svg create mode 100644 frontend/public/assets/flags/nzl.svg create mode 100644 frontend/public/assets/flags/pan.svg create mode 100644 frontend/public/assets/flags/par.svg create mode 100644 frontend/public/assets/flags/por.svg create mode 100644 frontend/public/assets/flags/qat.svg create mode 100644 frontend/public/assets/flags/rsa.svg create mode 100644 frontend/public/assets/flags/sco.svg create mode 100644 frontend/public/assets/flags/sen.svg create mode 100644 frontend/public/assets/flags/sui.svg create mode 100644 frontend/public/assets/flags/swe.svg create mode 100644 frontend/public/assets/flags/tun.svg create mode 100644 frontend/public/assets/flags/tur.svg create mode 100644 frontend/public/assets/flags/uru.svg create mode 100644 frontend/public/assets/flags/usa.svg create mode 100644 frontend/public/assets/flags/uzb.svg diff --git a/backend/Dockerfile b/backend/Dockerfile index 76c5b37..716e187 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -15,6 +15,7 @@ COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app ./app +COPY data ./data EXPOSE 8000 diff --git a/backend/app/main.py b/backend/app/main.py index f0460cd..7bcbbc7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,6 +14,7 @@ from fastapi.middleware.cors import CORSMiddleware from .config import settings from .database import init_db from .routers import admin, leaderboard, matches, predictions +from .scoring import load_scoring_data from .seed import seed_if_empty logging.basicConfig(level=logging.INFO) @@ -22,6 +23,7 @@ log = logging.getLogger("triplepick") @asynccontextmanager async def lifespan(app: FastAPI): # noqa: ANN201 + load_scoring_data() # data/scoring.json → 배점·배제 대상 await init_db() await seed_if_empty() log.info("API ready") diff --git a/backend/app/models.py b/backend/app/models.py index ed60d63..dc4df40 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -5,6 +5,7 @@ - ai_predictions GPT/Claude/Gemini 예측 (경기 × 모델) - crowd_stats 군중 투표 분포 (경기당 1행, 원자적 증분) - user_predictions 유저 픽 (이메일 식별, 채점/알림 플래그 포함) +- user_points 유저별 누적 포인트 (채점 시 갱신, 이메일당 1행) """ from __future__ import annotations @@ -152,3 +153,28 @@ class UserPrediction(Base): updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now() ) + + +class UserPoints(Base): + """유저별 누적 포인트 — 채점(grade_prediction) 결과를 이메일 단위로 집계. + + 등급별 횟수 컬럼은 scoring.json 의 key(score_*) 와 1:1 대응. + exact_count 는 리더보드 동점 보정 1순위(정확 스코어 횟수)에 사용. + """ + + __tablename__ = "user_points" + + email: Mapped[str] = mapped_column(String, primary_key=True) # 소문자 정규화 + total_points: Mapped[int] = mapped_column(Integer, default=0) + exact_count: Mapped[int] = mapped_column(Integer, default=0) + close_count: Mapped[int] = mapped_column(Integer, default=0) + outcome_count: Mapped[int] = mapped_column(Integer, default=0) + partial_count: Mapped[int] = mapped_column(Integer, default=0) + miss_count: Mapped[int] = mapped_column(Integer, default=0) + matches_played: Mapped[int] = mapped_column(Integer, default=0) + first_scored_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/routers/leaderboard.py b/backend/app/routers/leaderboard.py index 1052a7d..8735bde 100644 --- a/backend/app/routers/leaderboard.py +++ b/backend/app/routers/leaderboard.py @@ -1,19 +1,18 @@ """리더보드 API — 누적 포인트 1위 (docs/SCORING.md §3 랭킹 규칙). +user_points 테이블(채점 시 누적 갱신) 기준 랭킹. 1차 정렬: 누적 포인트. 동점: 정확스코어 횟수 → 적중률 → 참여수 → 최초도달. 주최측/임직원 배제(P1). 이메일은 마스킹하여 노출. """ from __future__ import annotations -from collections import defaultdict - from fastapi import APIRouter, Depends, Query -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from ..database import get_db -from ..models import UserPrediction -from ..scoring import SCORE, is_excluded +from ..models import Match, UserPoints +from ..scoring import is_excluded from ..schemas import LeaderboardOut, StandingOut router = APIRouter(prefix="/api/leaderboard", tags=["leaderboard"]) @@ -30,51 +29,36 @@ async def leaderboard( limit: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db) ) -> LeaderboardOut: rows = ( - await db.execute( - select(UserPrediction).where( - UserPrediction.points.is_not(None), - UserPrediction.email.is_not(None), - ) - ) + await db.execute(select(UserPoints)) ).scalars().all() - agg: dict[str, dict] = defaultdict( - lambda: {"points": 0, "exact": 0, "played": 0, "first": None} - ) - matches: set[str] = set() - for r in rows: - email = (r.email or "").strip().lower() - if not email or is_excluded(email): - continue - matches.add(r.match_id) - a = agg[email] - a["points"] += r.points or 0 - a["played"] += 1 - if (r.points or 0) >= SCORE["EXACT"]: - a["exact"] += 1 - ts = r.scored_at or r.created_at - if a["first"] is None or (ts and ts < a["first"]): - a["first"] = ts - ranked = sorted( - agg.items(), - key=lambda kv: ( - -kv[1]["points"], - -kv[1]["exact"], - -(kv[1]["points"] / kv[1]["played"] if kv[1]["played"] else 0), - -kv[1]["played"], - kv[1]["first"].timestamp() if kv[1]["first"] else 0, + (r for r in rows if not is_excluded(r.email)), + key=lambda r: ( + -r.total_points, + -r.exact_count, + -(r.total_points / r.matches_played if r.matches_played else 0), + -r.matches_played, + r.first_scored_at.timestamp() if r.first_scored_at else 0, ), ) + scored_matches = ( + await db.execute( + select(func.count()) + .select_from(Match) + .where(Match.result_outcome.is_not(None)) + ) + ).scalar() or 0 + standings = [ StandingOut( rank=i + 1, - emailMasked=_mask(email), - totalPoints=v["points"], - exactCount=v["exact"], - matchesPlayed=v["played"], + emailMasked=_mask(r.email), + totalPoints=r.total_points, + exactCount=r.exact_count, + matchesPlayed=r.matches_played, ) - for i, (email, v) in enumerate(ranked[:limit]) + for i, r in enumerate(ranked[:limit]) ] - return LeaderboardOut(standings=standings, scoredMatches=len(matches)) + return LeaderboardOut(standings=standings, scoredMatches=scored_matches) diff --git a/backend/app/scoring.py b/backend/app/scoring.py index 82d3c06..b3b526e 100644 --- a/backend/app/scoring.py +++ b/backend/app/scoring.py @@ -1,11 +1,18 @@ """채점 로직 — docs/SCORING.md (SSOT) 그대로 구현. 배점: 정확 스코어 5 · 근접(승패+득실차) 3 · 승패 2 · 부분(한 팀 득점 일치) 1 · 빗나감 0. -단조 증가(정확>근접>승패>부분>빗나감). 배점 수치는 SCORE 상수만 교체하면 됨. +단조 증가(정확>근접>승패>부분>빗나감). +배점·배제 대상은 backend/data/scoring.json 에서 기동 시 로드(없으면 아래 기본값). """ from __future__ import annotations -# 팀 확정 후 이 상수만 교체 (docs/SCORING.md §2) +import json +import logging +from pathlib import Path + +log = logging.getLogger("triplepick.scoring") + +# 기본 배점 — backend/data/scoring.json 로드 시 덮어씀 (docs/SCORING.md §2) SCORE = {"EXACT": 5, "CLOSE": 3, "OUTCOME": 2, "PARTIAL": 1, "MISS": 0} @@ -40,3 +47,87 @@ STAFF_DOMAINS = ["o2o.kr", "aio2o.kr"] def is_excluded(email: str) -> bool: e = email.strip().lower() return e in STAFF_EMAILS or any(e.endswith("@" + d) for d in STAFF_DOMAINS) + + +# ── 외부 설정 로드 (backend/data/scoring.json, key-value) ────── +DATA_FILE = Path(__file__).resolve().parent.parent / "data" / "scoring.json" + +# 등급 → scoring.json 의 key +_JSON_KEY = { + "EXACT": "score_exact", + "CLOSE": "score_close", + "OUTCOME": "score_outcome", + "PARTIAL": "score_partial", + "MISS": "score_miss", +} + + +def grade_prediction( + pick_a: int, pick_b: int, result_a: int, result_b: int, + path: Path | None = None, +) -> tuple[str, int]: + """유저 투표(픽) vs 실제 결과 → scoring.json 에서 해당 등급의 (key, value). + + score_prediction 과 같은 판정 기준. 예: 승패+득실차 일치 → ("score_close", 3). + scoring.json 이 없거나 키가 빠지면 기본 SCORE 값으로 대체. + """ + p = path or DATA_FILE + try: + data = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e) + data = {} + + if pick_a == result_a and pick_b == result_b: + grade = "EXACT" + elif outcome_of(pick_a, pick_b) == outcome_of(result_a, result_b): + grade = "CLOSE" if (pick_a - pick_b) == (result_a - result_b) else "OUTCOME" + elif pick_a == result_a or pick_b == result_b: + grade = "PARTIAL" + else: + grade = "MISS" + + key = _JSON_KEY[grade] + value = data[key] if isinstance(data.get(key), int) else SCORE[grade] + return key, value + +_SCORE_KEYS = { + "score_exact": "EXACT", + "score_close": "CLOSE", + "score_outcome": "OUTCOME", + "score_partial": "PARTIAL", + "score_miss": "MISS", +} + + +def load_scoring_data(path: Path | None = None) -> dict: + """data/scoring.json 을 읽어 배점(SCORE)·배제 대상을 갱신. 기동 시 1회 호출. + + SCORE/STAFF_EMAILS/STAFF_DOMAINS 는 타 모듈이 import 한 객체라 재할당 대신 + in-place 갱신. 파일이 없거나 깨져도 기본값으로 동작(부팅 실패 방지). + """ + p = path or DATA_FILE + try: + data = json.loads(p.read_text(encoding="utf-8")) + except FileNotFoundError: + log.warning("scoring data 없음 (%s) — 기본 배점 사용", p) + return {} + except (OSError, json.JSONDecodeError) as e: + log.warning("scoring data 읽기 실패 (%s): %s — 기본 배점 사용", p, e) + return {} + + for key, score_key in _SCORE_KEYS.items(): + if isinstance(data.get(key), int): + SCORE[score_key] = data[key] + if isinstance(data.get("excluded_emails"), list): + STAFF_EMAILS.clear() + STAFF_EMAILS.update(e.strip().lower() for e in data["excluded_emails"]) + if isinstance(data.get("excluded_domains"), list): + STAFF_DOMAINS[:] = [ + d.strip().lower().lstrip("@") for d in data["excluded_domains"] + ] + log.info( + "scoring data 로드 (%s): 배점 %s · 배제 이메일 %d · 도메인 %s", + p, SCORE, len(STAFF_EMAILS), STAFF_DOMAINS, + ) + return data diff --git a/backend/app/services/grading.py b/backend/app/services/grading.py index eb95117..835cba6 100644 --- a/backend/app/services/grading.py +++ b/backend/app/services/grading.py @@ -1,6 +1,7 @@ -"""채점 서비스 — 결과 입력 시 해당 경기 유저 픽 전수 채점. +"""채점 서비스 — 결과 입력 시 해당 경기 유저 픽 전수 채점 + 유저별 누적. -관리자 setResult 와 워커가 공용으로 사용. scoring.score_prediction 적용. +관리자 setResult 와 워커가 공용으로 사용. scoring.grade_prediction 적용 +(scoring.json 배점) 후 services.points 로 user_points 누적 갱신. """ from __future__ import annotations @@ -11,13 +12,14 @@ from sqlalchemy.ext.asyncio import AsyncSession from ..domain import now_utc from ..models import Match, UserPrediction -from ..scoring import outcome_of, score_prediction +from ..scoring import grade_prediction, outcome_of +from .points import accumulate_user_points log = logging.getLogger("triplepick.grading") async def apply_result(db: AsyncSession, match: Match, score_a: int, score_b: int) -> int: - """결과 기록 + 해당 경기 픽 전수 채점. 채점된 픽 수 반환.""" + """결과 기록 + 해당 경기 픽 전수 채점 + 유저별 포인트 누적. 채점된 픽 수 반환.""" match.result_score_a = score_a match.result_score_b = score_b match.result_outcome = outcome_of(score_a, score_b) @@ -31,9 +33,13 @@ async def apply_result(db: AsyncSession, match: Match, score_a: int, score_b: in ).scalars().all() for p in picks: - p.points = score_prediction(p.score_a, p.score_b, score_a, score_b) + key, value = grade_prediction(p.score_a, p.score_b, score_a, score_b) + p.points = value p.scored_at = now_utc() + # 채점된 픽의 유저(이메일)별 누적 포인트 갱신 — 같은 트랜잭션 + await accumulate_user_points(db, {p.email for p in picks}) + await db.commit() log.info("graded %d picks for %s (%d-%d)", len(picks), match.match_id, score_a, score_b) return len(picks) diff --git a/backend/app/services/points.py b/backend/app/services/points.py new file mode 100644 index 0000000..538e763 --- /dev/null +++ b/backend/app/services/points.py @@ -0,0 +1,99 @@ +"""유저별 포인트 누적 — grade_prediction 의 (key, value) 를 user_points 에 반영. + +채점(apply_result)에서 호출. 결과 정정(재채점)에도 안전하도록 영향받은 +이메일의 채점 가능한 픽 전체를 재집계해 upsert 한다(idempotent — 몇 번을 +다시 돌려도 같은 결과, 증분 방식의 이중 누적 위험 없음). +""" +from __future__ import annotations + +import logging + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from ..domain import now_utc +from ..models import Match, UserPoints, UserPrediction +from ..scoring import grade_prediction + +log = logging.getLogger("triplepick.points") + +# scoring.json 의 key → user_points 등급별 횟수 컬럼 +_KEY_TO_COL = { + "score_exact": "exact_count", + "score_close": "close_count", + "score_outcome": "outcome_count", + "score_partial": "partial_count", + "score_miss": "miss_count", +} + + +def _empty() -> dict: + return { + "total_points": 0, + "exact_count": 0, + "close_count": 0, + "outcome_count": 0, + "partial_count": 0, + "miss_count": 0, + "matches_played": 0, + "first_scored_at": None, + } + + +async def accumulate_user_points( + db: AsyncSession, emails: set[str | None] +) -> int: + """이메일별 누적 포인트 재집계 → user_points upsert. 갱신 행 수 반환. + + 커밋은 호출자(apply_result) 책임 — 채점과 누적이 한 트랜잭션으로 묶인다. + """ + targets = {e.strip().lower() for e in emails if e and e.strip()} + if not targets: + return 0 + + rows = ( + await db.execute( + select(UserPrediction, Match) + .join(Match, UserPrediction.match_id == Match.match_id) + .where( + func.lower(UserPrediction.email).in_(targets), + Match.result_score_a.is_not(None), + Match.result_score_b.is_not(None), + ) + ) + ).all() + + agg: dict[str, dict] = {e: _empty() for e in targets} + for pick, match in rows: + key, value = grade_prediction( + pick.score_a, pick.score_b, match.result_score_a, match.result_score_b + ) + t = agg[pick.email.strip().lower()] + t["total_points"] += value + t[_KEY_TO_COL[key]] += 1 + t["matches_played"] += 1 + ts = pick.scored_at or pick.created_at + if ts and (t["first_scored_at"] is None or ts < t["first_scored_at"]): + t["first_scored_at"] = ts + + existing = { + up.email: up + for up in ( + await db.execute(select(UserPoints).where(UserPoints.email.in_(targets))) + ).scalars() + } + for email, t in agg.items(): + row = existing.get(email) + if row is None: + row = UserPoints(email=email) + db.add(row) + for col, val in t.items(): + setattr(row, col, val) + row.updated_at = now_utc() + + log.info( + "user_points: %d명 누적 갱신 (%s)", + len(agg), + ", ".join(f"{e}={t['total_points']}p" for e, t in agg.items()), + ) + return len(agg) diff --git a/backend/app/worker.py b/backend/app/worker.py index 1231bef..37fcac8 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -29,6 +29,7 @@ from .config import settings from .database import SessionLocal, init_db from .domain import ensure_aware, now_utc from .models import AIPrediction, Match, UserPrediction +from .scoring import load_scoring_data from .services.ai import MatchContext, PROVIDERS, ProviderUnavailable from .services.email import EmailUnavailable, build_result_email, send_email from .services.schedule_fetch import fetch_schedule @@ -217,6 +218,7 @@ def build_scheduler() -> AsyncIOScheduler: async def main() -> None: + load_scoring_data() # data/scoring.json → 배점·배제 대상 (채점·결과메일에 사용) await init_db() # 테이블 보장 (idempotent). 시드는 API 가 담당. # 기동 시 1회: 일정 동기화 → AI 예측 생성(키 있을 때 GPT·Gemini 즉시 채움) await sync_schedule_job() diff --git a/backend/data/scoring.json b/backend/data/scoring.json new file mode 100644 index 0000000..feff08f --- /dev/null +++ b/backend/data/scoring.json @@ -0,0 +1,7 @@ +{ + "score_exact": 5, + "score_close": 3, + "score_outcome": 2, + "score_partial": 1, + "score_miss": 0 +} diff --git a/frontend/public/assets/flags/alg.svg b/frontend/public/assets/flags/alg.svg new file mode 100644 index 0000000..2b23ca4 --- /dev/null +++ b/frontend/public/assets/flags/alg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/arg.svg b/frontend/public/assets/flags/arg.svg new file mode 100644 index 0000000..db54af5 --- /dev/null +++ b/frontend/public/assets/flags/arg.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/aus.svg b/frontend/public/assets/flags/aus.svg new file mode 100644 index 0000000..38a47de --- /dev/null +++ b/frontend/public/assets/flags/aus.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/aut.svg b/frontend/public/assets/flags/aut.svg new file mode 100644 index 0000000..1229a26 --- /dev/null +++ b/frontend/public/assets/flags/aut.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/bel.svg b/frontend/public/assets/flags/bel.svg new file mode 100644 index 0000000..107b639 --- /dev/null +++ b/frontend/public/assets/flags/bel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/bih.svg b/frontend/public/assets/flags/bih.svg new file mode 100644 index 0000000..177c78d --- /dev/null +++ b/frontend/public/assets/flags/bih.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/bra.svg b/frontend/public/assets/flags/bra.svg new file mode 100644 index 0000000..203e64d --- /dev/null +++ b/frontend/public/assets/flags/bra.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/can.svg b/frontend/public/assets/flags/can.svg new file mode 100644 index 0000000..154cb19 --- /dev/null +++ b/frontend/public/assets/flags/can.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/civ.svg b/frontend/public/assets/flags/civ.svg new file mode 100644 index 0000000..4448319 --- /dev/null +++ b/frontend/public/assets/flags/civ.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/cod.svg b/frontend/public/assets/flags/cod.svg new file mode 100644 index 0000000..5892af0 --- /dev/null +++ b/frontend/public/assets/flags/cod.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/col.svg b/frontend/public/assets/flags/col.svg new file mode 100644 index 0000000..2f3901d --- /dev/null +++ b/frontend/public/assets/flags/col.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/cpv.svg b/frontend/public/assets/flags/cpv.svg new file mode 100644 index 0000000..0fd3001 --- /dev/null +++ b/frontend/public/assets/flags/cpv.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/cro.svg b/frontend/public/assets/flags/cro.svg new file mode 100644 index 0000000..ad24ab9 --- /dev/null +++ b/frontend/public/assets/flags/cro.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/cuw.svg b/frontend/public/assets/flags/cuw.svg new file mode 100644 index 0000000..e8953e6 --- /dev/null +++ b/frontend/public/assets/flags/cuw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/cze.svg b/frontend/public/assets/flags/cze.svg new file mode 100644 index 0000000..3915399 --- /dev/null +++ b/frontend/public/assets/flags/cze.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/ecu.svg b/frontend/public/assets/flags/ecu.svg new file mode 100644 index 0000000..61979d7 --- /dev/null +++ b/frontend/public/assets/flags/ecu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/egy.svg b/frontend/public/assets/flags/egy.svg new file mode 100644 index 0000000..95fdd19 --- /dev/null +++ b/frontend/public/assets/flags/egy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/eng.svg b/frontend/public/assets/flags/eng.svg new file mode 100644 index 0000000..f474d10 --- /dev/null +++ b/frontend/public/assets/flags/eng.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/esp.svg b/frontend/public/assets/flags/esp.svg new file mode 100644 index 0000000..8e6e4c2 --- /dev/null +++ b/frontend/public/assets/flags/esp.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/fra.svg b/frontend/public/assets/flags/fra.svg new file mode 100644 index 0000000..6eb0e18 --- /dev/null +++ b/frontend/public/assets/flags/fra.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/ger.svg b/frontend/public/assets/flags/ger.svg new file mode 100644 index 0000000..60e5efc --- /dev/null +++ b/frontend/public/assets/flags/ger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/gha.svg b/frontend/public/assets/flags/gha.svg new file mode 100644 index 0000000..fae8956 --- /dev/null +++ b/frontend/public/assets/flags/gha.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/hai.svg b/frontend/public/assets/flags/hai.svg new file mode 100644 index 0000000..b6ca967 --- /dev/null +++ b/frontend/public/assets/flags/hai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/irn.svg b/frontend/public/assets/flags/irn.svg new file mode 100644 index 0000000..272eff0 --- /dev/null +++ b/frontend/public/assets/flags/irn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/irq.svg b/frontend/public/assets/flags/irq.svg new file mode 100644 index 0000000..2b62c58 --- /dev/null +++ b/frontend/public/assets/flags/irq.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/jor.svg b/frontend/public/assets/flags/jor.svg new file mode 100644 index 0000000..5a4eccf --- /dev/null +++ b/frontend/public/assets/flags/jor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/jpn.svg b/frontend/public/assets/flags/jpn.svg new file mode 100644 index 0000000..0f02949 --- /dev/null +++ b/frontend/public/assets/flags/jpn.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/kor.svg b/frontend/public/assets/flags/kor.svg new file mode 100644 index 0000000..a44c6e1 --- /dev/null +++ b/frontend/public/assets/flags/kor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/ksa.svg b/frontend/public/assets/flags/ksa.svg new file mode 100644 index 0000000..5eee1b5 --- /dev/null +++ b/frontend/public/assets/flags/ksa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/mar.svg b/frontend/public/assets/flags/mar.svg new file mode 100644 index 0000000..d6b54a3 --- /dev/null +++ b/frontend/public/assets/flags/mar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/mex.svg b/frontend/public/assets/flags/mex.svg new file mode 100644 index 0000000..b82436b --- /dev/null +++ b/frontend/public/assets/flags/mex.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/ned.svg b/frontend/public/assets/flags/ned.svg new file mode 100644 index 0000000..6598c0b --- /dev/null +++ b/frontend/public/assets/flags/ned.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/nor.svg b/frontend/public/assets/flags/nor.svg new file mode 100644 index 0000000..ebb8fbd --- /dev/null +++ b/frontend/public/assets/flags/nor.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/nzl.svg b/frontend/public/assets/flags/nzl.svg new file mode 100644 index 0000000..4f6af3c --- /dev/null +++ b/frontend/public/assets/flags/nzl.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/pan.svg b/frontend/public/assets/flags/pan.svg new file mode 100644 index 0000000..c625603 --- /dev/null +++ b/frontend/public/assets/flags/pan.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/par.svg b/frontend/public/assets/flags/par.svg new file mode 100644 index 0000000..63f38a7 --- /dev/null +++ b/frontend/public/assets/flags/par.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/por.svg b/frontend/public/assets/flags/por.svg new file mode 100644 index 0000000..0c610be --- /dev/null +++ b/frontend/public/assets/flags/por.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/qat.svg b/frontend/public/assets/flags/qat.svg new file mode 100644 index 0000000..65b2184 --- /dev/null +++ b/frontend/public/assets/flags/qat.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/rsa.svg b/frontend/public/assets/flags/rsa.svg new file mode 100644 index 0000000..a00b44a --- /dev/null +++ b/frontend/public/assets/flags/rsa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/sco.svg b/frontend/public/assets/flags/sco.svg new file mode 100644 index 0000000..be27bb2 --- /dev/null +++ b/frontend/public/assets/flags/sco.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/sen.svg b/frontend/public/assets/flags/sen.svg new file mode 100644 index 0000000..3860b33 --- /dev/null +++ b/frontend/public/assets/flags/sen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/sui.svg b/frontend/public/assets/flags/sui.svg new file mode 100644 index 0000000..f7833a5 --- /dev/null +++ b/frontend/public/assets/flags/sui.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/swe.svg b/frontend/public/assets/flags/swe.svg new file mode 100644 index 0000000..b32a8f9 --- /dev/null +++ b/frontend/public/assets/flags/swe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/tun.svg b/frontend/public/assets/flags/tun.svg new file mode 100644 index 0000000..482b55a --- /dev/null +++ b/frontend/public/assets/flags/tun.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/tur.svg b/frontend/public/assets/flags/tur.svg new file mode 100644 index 0000000..5b046e6 --- /dev/null +++ b/frontend/public/assets/flags/tur.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/uru.svg b/frontend/public/assets/flags/uru.svg new file mode 100644 index 0000000..9f5b3d6 --- /dev/null +++ b/frontend/public/assets/flags/uru.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/usa.svg b/frontend/public/assets/flags/usa.svg new file mode 100644 index 0000000..9735dfa --- /dev/null +++ b/frontend/public/assets/flags/usa.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/assets/flags/uzb.svg b/frontend/public/assets/flags/uzb.svg new file mode 100644 index 0000000..379ab12 --- /dev/null +++ b/frontend/public/assets/flags/uzb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/TeamFlag.tsx b/frontend/src/components/TeamFlag.tsx index f96f842..370df2f 100644 --- a/frontend/src/components/TeamFlag.tsx +++ b/frontend/src/components/TeamFlag.tsx @@ -1,7 +1,9 @@ +import { useState } from "react"; import type { Team } from "@/lib/types"; // 모든 경기에 일반화된 국기 렌더. -// KOR = 실제 png 자산, CZE = 인라인 SVG(비율 보존), 그 외 = 이모지(컨테이너 높이에 비례, 안 잘림). +// 월드컵 출전 48개국 SVG 자산(/assets/flags/{fifa코드}.svg, 예: kor.svg)을 사용하고, +// 자산이 없는 코드는 이모지 폴백(컨테이너 높이에 비례, 안 잘림). export default function TeamFlag({ team, className = "", @@ -9,30 +11,20 @@ export default function TeamFlag({ team: Team; className?: string; }) { - if (team.code === "KOR") { + const [failed, setFailed] = useState(false); + + if (!failed && team.code) { return ( {team.name} setFailed(true)} + className={`flag-img object-cover border border-[var(--line-d)] ${className}`} /> ); } - if (team.code === "CZE") { - return ( - - - - - - ); - } - // 그 외 국가 — 이모지 국기. 컨테이너 높이(cqh) 기준으로 크기를 잡아 잘림/찌그러짐 방지. + + // 폴백 — 이모지 국기. 컨테이너 높이(cqh) 기준으로 크기를 잡아 잘림/찌그러짐 방지. return (