내 지난 얘측 보기 기능 추가, 한국 멕시코 경기 응원가 1곡 추가
parent
0e5a63a28b
commit
b4fc96f299
|
|
@ -11,12 +11,14 @@ from __future__ import annotations
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from .config import settings
|
||||
from .models import AIPrediction, CrowdStats, Match
|
||||
from .models import AIPrediction, CrowdStats, Match, UserPrediction
|
||||
from .schemas import (
|
||||
AIPredictionOut,
|
||||
CrowdStatsOut,
|
||||
MatchOut,
|
||||
MatchResult,
|
||||
MyPredictionOut,
|
||||
MyResultOut,
|
||||
Team,
|
||||
)
|
||||
|
||||
|
|
@ -116,6 +118,29 @@ def crowd_out(c: CrowdStats | None, match_id: str) -> CrowdStatsOut:
|
|||
)
|
||||
|
||||
|
||||
def my_prediction_out(p: UserPrediction, m: Match) -> MyPredictionOut:
|
||||
"""유저 픽 1건을 경기 정보와 합쳐 직렬화 (내 지난 예측 목록용)."""
|
||||
result = None
|
||||
if m.result_outcome is not None:
|
||||
result = MyResultOut(
|
||||
scoreA=m.result_score_a or 0,
|
||||
scoreB=m.result_score_b or 0,
|
||||
outcome=m.result_outcome, # type: ignore[arg-type]
|
||||
hitOutcome=p.outcome == m.result_outcome,
|
||||
)
|
||||
return MyPredictionOut(
|
||||
matchId=p.match_id,
|
||||
teamA=_team_a(m),
|
||||
teamB=_team_b(m),
|
||||
kickoffKst=kst_iso(m.kickoff_at),
|
||||
outcome=p.outcome, # type: ignore[arg-type]
|
||||
scoreA=p.score_a,
|
||||
scoreB=p.score_b,
|
||||
submittedAt=kst_iso(p.updated_at or p.created_at),
|
||||
result=result,
|
||||
)
|
||||
|
||||
|
||||
def match_out(
|
||||
m: Match,
|
||||
lang: str = "ko",
|
||||
|
|
|
|||
|
|
@ -7,10 +7,16 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..database import get_db
|
||||
from ..domain import compute_phase, crowd_out, is_open_for_voting, is_votable
|
||||
from ..domain import (
|
||||
compute_phase,
|
||||
crowd_out,
|
||||
is_open_for_voting,
|
||||
is_votable,
|
||||
my_prediction_out,
|
||||
)
|
||||
from ..models import AIPrediction, CrowdStats, Match, UserPrediction
|
||||
from ..scoring import outcome_of
|
||||
from ..schemas import SubmitPredictionIn, SubmitPredictionOut
|
||||
from ..schemas import MyPredictionOut, SubmitPredictionIn, SubmitPredictionOut
|
||||
|
||||
router = APIRouter(prefix="/api/predictions", tags=["predictions"])
|
||||
|
||||
|
|
@ -36,6 +42,25 @@ async def _adjust_crowd(
|
|||
)
|
||||
|
||||
|
||||
@router.get("/mine", response_model=list[MyPredictionOut])
|
||||
async def my_predictions(
|
||||
email: str, db: AsyncSession = Depends(get_db)
|
||||
) -> list[MyPredictionOut]:
|
||||
"""이메일 기준 내 지난 예측 목록 (최신 제출순). 로그인 없음 — 이메일이 신원."""
|
||||
e = email.strip().lower()
|
||||
if not e:
|
||||
return []
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(UserPrediction, Match)
|
||||
.join(Match, Match.match_id == UserPrediction.match_id)
|
||||
.where(func.lower(UserPrediction.email) == e)
|
||||
.order_by(UserPrediction.updated_at.desc())
|
||||
)
|
||||
).all()
|
||||
return [my_prediction_out(up, m) for up, m in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=SubmitPredictionOut)
|
||||
async def submit_prediction(
|
||||
body: SubmitPredictionIn, db: AsyncSession = Depends(get_db)
|
||||
|
|
|
|||
|
|
@ -90,6 +90,26 @@ class SubmitPredictionOut(BaseModel):
|
|||
crowd: CrowdStatsOut
|
||||
|
||||
|
||||
# ── 내 지난 예측 (이메일 기준, 로그인 없음) ──────────────────
|
||||
class MyResultOut(BaseModel):
|
||||
scoreA: int
|
||||
scoreB: int
|
||||
outcome: Outcome
|
||||
hitOutcome: bool # 내 outcome 이 실제 결과와 일치했는지
|
||||
|
||||
|
||||
class MyPredictionOut(BaseModel):
|
||||
matchId: str
|
||||
teamA: Team
|
||||
teamB: Team
|
||||
kickoffKst: str
|
||||
outcome: Outcome
|
||||
scoreA: int
|
||||
scoreB: int
|
||||
submittedAt: str # ISO — 최신순 정렬 기준(updated_at)
|
||||
result: MyResultOut | None = None # 경기 종료 시에만 채워짐
|
||||
|
||||
|
||||
# ── 리더보드 ────────────────────────────────────────────────
|
||||
class StandingOut(BaseModel):
|
||||
rank: int
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.3 MiB |
|
|
@ -7,14 +7,28 @@ import type {
|
|||
ModelName,
|
||||
Match,
|
||||
AIPrediction,
|
||||
MyPrediction,
|
||||
Team,
|
||||
} from "@/lib/types";
|
||||
import { ApiError, getDeviceId, getRecentEmails, rememberEmail, submitPrediction } from "@/lib/api";
|
||||
import {
|
||||
ApiError,
|
||||
fetchMyPredictions,
|
||||
getDeviceId,
|
||||
getRecentEmails,
|
||||
rememberEmail,
|
||||
submitPrediction,
|
||||
} from "@/lib/api";
|
||||
import Countdown from "./Countdown";
|
||||
import TeamFlag from "./TeamFlag";
|
||||
|
||||
type Step = "form" | "done";
|
||||
|
||||
// 제출 시각 ISO → "6.18" (내 예측 목록 우측 라벨)
|
||||
function fmtSubmitted(iso: string): string {
|
||||
const m = iso.match(/\d{4}-(\d{2})-(\d{2})/);
|
||||
return m ? `${+m[1]}.${+m[2]}` : "";
|
||||
}
|
||||
|
||||
const MODEL_ICON: Record<ModelName, string> = {
|
||||
GPT: "/icons/gpt.png",
|
||||
Claude: "/icons/claude.jpg",
|
||||
|
|
@ -51,6 +65,8 @@ export default function Arena({
|
|||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
const [myPreds, setMyPreds] = useState<MyPrediction[]>([]);
|
||||
const [votesExpanded, setVotesExpanded] = useState(false);
|
||||
|
||||
// 점수 선택 시 승/무/패 자동 선택 (스코어가 결과의 소스)
|
||||
useEffect(() => {
|
||||
|
|
@ -59,6 +75,25 @@ export default function Arena({
|
|||
);
|
||||
}, [scoreA, scoreB]);
|
||||
|
||||
// 재방문 시: 가장 최근 사용 이메일로 내 지난 예측 자동 로드 (로그인 대체).
|
||||
// 이메일 칸은 비워둠 — develop UX(포커스 시 datalist 자동완성)를 유지.
|
||||
useEffect(() => {
|
||||
const last = getRecentEmails()[0];
|
||||
if (last) fetchMyPredictions(last).then(setMyPreds).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 이 경기에 대한 내 기존 픽 (있으면 스코어 프리필 + 목록에서 강조)
|
||||
const myThisMatch = useMemo(
|
||||
() => myPreds.find((p) => p.matchId === match.matchId),
|
||||
[myPreds, match.matchId],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (myThisMatch) {
|
||||
setScoreA(myThisMatch.scoreA);
|
||||
setScoreB(myThisMatch.scoreB);
|
||||
}
|
||||
}, [myThisMatch]);
|
||||
|
||||
// 투표 가능 여부는 백엔드 계산(votingOpen, demo 반영) + phase 라벨.
|
||||
const finished = !!match.result;
|
||||
const disabled = finished || !match.votingOpen;
|
||||
|
|
@ -98,6 +133,8 @@ export default function Arena({
|
|||
setCrowd(res.crowd); // 서버 권위 분포로 갱신
|
||||
rememberEmail(email.trim()); // 다음 투표 자동완성용으로 기억
|
||||
setRecentEmails(getRecentEmails());
|
||||
// 방금 제출 포함해 내 지난 예측 갱신(이번 경기 강조/upsert 반영)
|
||||
fetchMyPredictions(email.trim()).then(setMyPreds).catch(() => {});
|
||||
setStep("done");
|
||||
} catch (e) {
|
||||
const msg =
|
||||
|
|
@ -304,7 +341,13 @@ export default function Arena({
|
|||
list="tp-email-suggestions"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setEmail(v);
|
||||
// 유효 이메일이면 그 즉시 내 지난 예측 조회(다른 기기/세션 흔적까지 표시)
|
||||
if (EMAIL_RE.test(v.trim()))
|
||||
fetchMyPredictions(v.trim()).then(setMyPreds).catch(() => {});
|
||||
}}
|
||||
placeholder={t.emailPh}
|
||||
className="w-full rounded-2xl border border-[var(--share)] bg-[#0f1217] px-4 py-3 text-[16px] text-white shadow-[0_0_0_2px_rgba(166,94,255,0.12)] outline-none focus:shadow-[0_0_0_2px_rgba(166,94,255,0.3)]"
|
||||
/>
|
||||
|
|
@ -366,6 +409,79 @@ export default function Arena({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 내 지난 예측 (이메일 기준, 로그인 없음) ===== */}
|
||||
{myPreds.length > 0 && (
|
||||
<section className="mt-5 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
|
||||
<div className="mb-3 flex items-baseline justify-between gap-2">
|
||||
<h3 className="text-[16px] font-extrabold">{t.myVotesTitle}</h3>
|
||||
<span className="shrink-0 text-[12px] text-[var(--ink-muted)]">
|
||||
{t.myVotesSub}
|
||||
</span>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{(votesExpanded ? myPreds : myPreds.slice(0, 1)).map((p) => {
|
||||
const isThis = p.matchId === match.matchId;
|
||||
const pA = teamShort(p.teamA, lang);
|
||||
const pB = teamShort(p.teamB, lang);
|
||||
const pickTxt =
|
||||
p.outcome === "TEAM_A_WIN"
|
||||
? `${pA} ${t.win}`
|
||||
: p.outcome === "TEAM_B_WIN"
|
||||
? `${pB} ${t.win}`
|
||||
: t.drawLabel;
|
||||
return (
|
||||
<li
|
||||
key={p.matchId}
|
||||
className={`flex items-center justify-between gap-3 rounded-xl border px-3 py-2.5 ${
|
||||
isThis
|
||||
? "border-[var(--share)] bg-[rgba(166,94,255,0.1)]"
|
||||
: "border-[var(--line-d)]"
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[15px] font-bold text-white">
|
||||
{pA} {p.scoreA}-{p.scoreB} {pB}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] text-[var(--ink-muted)]">
|
||||
{pickTxt}
|
||||
{isThis && (
|
||||
<span className="text-[var(--share)]"> · {t.thisMatch}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{p.result ? (
|
||||
<span
|
||||
className={`shrink-0 text-[13px] font-bold ${
|
||||
p.result.hitOutcome
|
||||
? "text-[var(--share)]"
|
||||
: "text-[var(--ink-muted)]"
|
||||
}`}
|
||||
>
|
||||
{p.result.hitOutcome ? t.hit : t.miss}
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 text-[11px] text-[var(--ink-muted)]">
|
||||
{fmtSubmitted(p.submittedAt)}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{myPreds.length > 1 && (
|
||||
<button
|
||||
onClick={() => setVotesExpanded((v) => !v)}
|
||||
className="mt-3 flex w-full items-center justify-center gap-1 rounded-xl border border-[var(--line-d)] py-2.5 text-[13px] font-bold text-[var(--ink-muted)] transition active:scale-[0.99]"
|
||||
>
|
||||
{votesExpanded
|
||||
? t.myVotesShowLess
|
||||
: t.myVotesShowMore(myPreds.length - 1)}
|
||||
<span aria-hidden>{votesExpanded ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ===== 골드 상금 (003) ===== */}
|
||||
<section className="gold-card mt-5 rounded-2xl p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
|
|
|
|||
|
|
@ -180,7 +180,7 @@ export default function Comments({
|
|||
) : (
|
||||
<ul
|
||||
className={`mt-4 divide-y divide-[var(--line-d)] ${
|
||||
expanded ? "max-h-[360px] overflow-y-auto pr-1" : ""
|
||||
expanded ? "max-h-[360px] overflow-y-auto pr-1 scroll-dark" : ""
|
||||
}`}
|
||||
>
|
||||
{visible.map((c, i) => (
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
--gold-btn-2: #15a99b; /* 틸그린 */
|
||||
|
||||
--gpt: #15a99b; /* 틸그린 (teal green) */
|
||||
--claude: #e8814a;
|
||||
/* Claude 오렌지 토큰 제거 — 오렌지 금지 규칙. Claude 표시는 공식 로고 이미지가 담당 */
|
||||
--gemini: #5b8cff;
|
||||
|
||||
--share: #a65eff; /* 공유/바이럴 액션 (ADO2 브랜드 포인트 퍼플) */
|
||||
|
|
@ -152,6 +152,25 @@ body {
|
|||
box-shadow: 0 6px 18px rgba(21, 169, 155, 0.3);
|
||||
}
|
||||
|
||||
/* 다크 스크롤바 — 흰색 기본 스크롤바 제거 */
|
||||
.scroll-dark {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--line-d) transparent;
|
||||
}
|
||||
.scroll-dark::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.scroll-dark::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scroll-dark::-webkit-scrollbar-thumb {
|
||||
background: var(--line-d);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.scroll-dark::-webkit-scrollbar-thumb:hover {
|
||||
background: #3a414e;
|
||||
}
|
||||
|
||||
@keyframes barfill {
|
||||
from {
|
||||
width: 0;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
Leaderboard,
|
||||
Match,
|
||||
ModelName,
|
||||
MyPrediction,
|
||||
} from "./types";
|
||||
import type { Lang } from "./i18n";
|
||||
|
||||
|
|
@ -73,6 +74,13 @@ export function getLeaderboard(): Promise<Leaderboard> {
|
|||
return http<Leaderboard>(`/leaderboard`);
|
||||
}
|
||||
|
||||
// 내 지난 예측 — 이메일 기준 조회(로그인 없음). 최신순. 빈 이메일이면 빈 배열.
|
||||
export function fetchMyPredictions(email: string): Promise<MyPrediction[]> {
|
||||
const e = email.trim();
|
||||
if (!e) return Promise.resolve([]);
|
||||
return http<MyPrediction[]>(`/predictions/mine?email=${encodeURIComponent(e)}`);
|
||||
}
|
||||
|
||||
// ── 댓글(경기별 한마디) — 완전 익명 ──
|
||||
export function listComments(
|
||||
matchId: string,
|
||||
|
|
|
|||
|
|
@ -100,6 +100,12 @@ type Dict = {
|
|||
footerDisc3: string;
|
||||
hook: (a: string, b: string) => string;
|
||||
moreMatches: string;
|
||||
// 내 지난 예측 (이메일 기준)
|
||||
myVotesTitle: string;
|
||||
myVotesSub: string;
|
||||
thisMatch: string;
|
||||
myVotesShowMore: (n: number) => string;
|
||||
myVotesShowLess: string;
|
||||
commentsTitle: string;
|
||||
commentsCount: (n: number) => string;
|
||||
commentPh: string;
|
||||
|
|
@ -193,6 +199,11 @@ export const DICT: Record<Lang, Dict> = {
|
|||
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
|
||||
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
|
||||
moreMatches: "다른 경기 투표하기",
|
||||
myVotesTitle: "내 지난 예측",
|
||||
myVotesSub: "이메일 기준 · 최신순",
|
||||
thisMatch: "이번 경기",
|
||||
myVotesShowMore: (n) => `더보기 +${n}`,
|
||||
myVotesShowLess: "접기",
|
||||
commentsTitle: "한마디",
|
||||
commentsCount: (n) => `총 ${n}개`,
|
||||
commentPh: "응원 한마디 남기기...",
|
||||
|
|
@ -283,6 +294,11 @@ export const DICT: Record<Lang, Dict> = {
|
|||
footerDisc3: "The ₩1,000,000 event is a free-to-enter challenge. Payout & tie-break terms follow separate rules. Email is used only for result & event alerts.",
|
||||
hook: (a, b) => `AI is split on ${a} vs ${b}`,
|
||||
moreMatches: "Vote on another match",
|
||||
myVotesTitle: "Your past predictions",
|
||||
myVotesSub: "By email · newest first",
|
||||
thisMatch: "This match",
|
||||
myVotesShowMore: (n) => `Show ${n} more`,
|
||||
myVotesShowLess: "Show less",
|
||||
commentsTitle: "Comments",
|
||||
commentsCount: (n) => `${n} total`,
|
||||
commentPh: "Leave a comment...",
|
||||
|
|
|
|||
|
|
@ -13,12 +13,14 @@ export type Track = {
|
|||
// VISIBLE_PATHS는 이 맵의 키에서 자동 생성됩니다.
|
||||
export const PATH_PLAYLISTS: Record<string, Track[]> = {
|
||||
"/match/A_MEX_KOR_20260619": [
|
||||
{ title: "Red Wave 2026", artist: "aio2o", src: "/audio/Red Wave 2026.mp3", cover: "/thumbnail/Red Wave 2026.webp" },
|
||||
{ title: "El Tri vs 붉은 악마", artist: "aio2o", src: "/audio/Mexico vs Korea 2026.mp3", cover: "/thumbnail/Mexico vs Korea 2026.webp" },
|
||||
{ title: "Red Wave: 멕시코를 넘어", artist: "aio2o", src: "/audio/Red Wave 2026.mp3", cover: "/thumbnail/Red Wave 2026.webp" },
|
||||
{ title: "붉은 물결", artist: "aio2o", src: "/audio/붉은 물결.mp3", cover: "/thumbnail/붉은 물결.webp" },
|
||||
{ title: "Triple Pick, We Believe", artist: "aio2o", src: "/audio/Triple Pick, We Believe.mp3", cover: "/thumbnail/Triple Pick, We Believe.webp" },
|
||||
],
|
||||
"/match/A_RSA_KOR_20260625": [
|
||||
{ title: "붉은 물결", artist: "aio2o", src: "/audio/붉은 물결.mp3", cover: "/thumbnail/붉은 물결.webp" },
|
||||
{ title: "Triple Pick, We Believe", artist: "aio2o", src: "/audio/Triple Pick, We Believe.mp3", cover: "/thumbnail/Triple Pick, We Believe.webp" },
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,24 @@ export interface UserPick {
|
|||
scoreB: number;
|
||||
}
|
||||
|
||||
// 내 지난 예측 (이메일 기준, 로그인 없음) — GET /api/predictions/mine
|
||||
export interface MyPrediction {
|
||||
matchId: string;
|
||||
teamA: Team;
|
||||
teamB: Team;
|
||||
kickoffKst: string;
|
||||
outcome: Outcome;
|
||||
scoreA: number;
|
||||
scoreB: number;
|
||||
submittedAt: string; // ISO — 최신순 정렬 기준
|
||||
result?: {
|
||||
scoreA: number;
|
||||
scoreB: number;
|
||||
outcome: Outcome;
|
||||
hitOutcome: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Standing {
|
||||
rank: number;
|
||||
emailMasked: string;
|
||||
|
|
|
|||
Loading…
Reference in New Issue