한국 멕시코 경기 댓글 기능 추가
parent
7625db36a0
commit
8a67094510
|
|
@ -13,7 +13,15 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
|
||||
from .config import settings
|
||||
from .database import init_db
|
||||
from .routers import admin, leaderboard, matches, predictions, share, visits
|
||||
from .routers import (
|
||||
admin,
|
||||
comments,
|
||||
leaderboard,
|
||||
matches,
|
||||
predictions,
|
||||
share,
|
||||
visits,
|
||||
)
|
||||
from .scoring import load_scoring_data
|
||||
from .seed import seed_if_empty
|
||||
|
||||
|
|
@ -41,6 +49,7 @@ app.add_middleware(
|
|||
)
|
||||
|
||||
app.include_router(matches.router)
|
||||
app.include_router(comments.router)
|
||||
app.include_router(predictions.router)
|
||||
app.include_router(leaderboard.router)
|
||||
app.include_router(admin.router)
|
||||
|
|
|
|||
|
|
@ -204,6 +204,29 @@ class PageVisit(Base):
|
|||
)
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
"""경기별 한마디(댓글). 비로그인 — device_id 로 식별, 투표 시 입력한 email 을
|
||||
닉네임(마스킹 표시)으로 사용. 테이블은 범용이고 노출 경기는 프론트가 제한한다.
|
||||
|
||||
- 최신순 조회(created_at DESC) + limit/offset 페이징("더보기" 인라인 펼침).
|
||||
- is_hidden=True 는 관리자/신고 숨김 — 조회에서 제외.
|
||||
"""
|
||||
|
||||
__tablename__ = "comments"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
match_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("matches.match_id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
device_id: Mapped[str] = mapped_column(String, index=True) # 비로그인 식별 + 쿨다운 기준
|
||||
email: Mapped[str] = mapped_column(String) # 소문자 정규화 — 마스킹해 표시
|
||||
body: Mapped[str] = mapped_column(String) # 길이 제한은 스키마(200자)에서
|
||||
is_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), index=True
|
||||
)
|
||||
|
||||
|
||||
class FootballCache(Base):
|
||||
"""축구 데이터 캐시 (API-Football 수집 결과) — 예측 프롬프트 조립용.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
"""경기별 한마디(댓글) API — 조회(최신순·페이징) · 작성(검증·쿨다운) · 본인 삭제.
|
||||
|
||||
신원 기준은 전부 **이메일**(포인트·리더보드와 동일). device_id 는 저장만 하고 로직엔 안 씀.
|
||||
- 닉네임: 투표 시 입력한 email 을 마스킹(ab***@gmail.com)해 노출.
|
||||
- 작성 자격: 그 이메일로 이 경기에 투표했어야 함.
|
||||
- 본인 댓글: 같은 이메일이면 어느 기기에서든 삭제 가능(다른 기기에서도 OK).
|
||||
- 방어(기본): body 길이 제한(스키마 200자) · 같은 이메일 연속 작성 쿨다운 · is_hidden 제외.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import Comment, Match, UserPrediction
|
||||
from ..schemas import CommentIn, CommentListOut, CommentOut, GenericOk
|
||||
|
||||
router = APIRouter(prefix="/api/matches", tags=["comments"])
|
||||
|
||||
# 같은 이메일 연속 작성 최소 간격(초) — 도배 방지(기본 방어).
|
||||
COMMENT_COOLDOWN_SECONDS = 5
|
||||
|
||||
|
||||
def _mask(email: str) -> str:
|
||||
"""leaderboard._mask 와 동일 규칙 — ab***@gmail.com."""
|
||||
name, _, domain = email.partition("@")
|
||||
head = name[:2] if len(name) >= 2 else name
|
||||
return f"{head}{'*' * max(1, len(name) - 2)}@{domain}"
|
||||
|
||||
|
||||
def _out(c: Comment, viewer_emails: set[str] | None = None) -> CommentOut:
|
||||
return CommentOut(
|
||||
id=c.id,
|
||||
nickname=_mask(c.email),
|
||||
body=c.body,
|
||||
createdAt=c.created_at.isoformat(),
|
||||
mine=bool(viewer_emails) and c.email in viewer_emails,
|
||||
)
|
||||
|
||||
|
||||
def _norm_emails(emails: list[str] | None) -> set[str]:
|
||||
"""뷰어가 보낸 이메일 목록을 소문자 집합으로 정규화."""
|
||||
return {e.strip().lower() for e in (emails or []) if e and e.strip()}
|
||||
|
||||
|
||||
@router.get("/{match_id}/comments", response_model=CommentListOut)
|
||||
async def list_comments(
|
||||
match_id: str,
|
||||
limit: int = Query(3, ge=1, le=50),
|
||||
offset: int = Query(0, ge=0),
|
||||
# 뷰어 본인 이메일 목록(localStorage 최근 이메일). 일치하는 댓글에 mine=True → 삭제 버튼 노출
|
||||
email: list[str] | None = Query(None),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CommentListOut:
|
||||
viewer = _norm_emails(email)
|
||||
base = (
|
||||
select(Comment)
|
||||
.where(Comment.match_id == match_id, Comment.is_hidden.is_(False))
|
||||
)
|
||||
total = (
|
||||
await db.execute(
|
||||
select(func.count()).select_from(base.subquery())
|
||||
)
|
||||
).scalar() or 0
|
||||
rows = (
|
||||
await db.execute(
|
||||
base.order_by(Comment.created_at.desc(), Comment.id.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
).scalars().all()
|
||||
return CommentListOut(
|
||||
items=[_out(c, viewer) for c in rows], total=total
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{match_id}/comments", response_model=CommentOut)
|
||||
async def create_comment(
|
||||
match_id: str, body: CommentIn, db: AsyncSession = Depends(get_db)
|
||||
) -> CommentOut:
|
||||
match = (
|
||||
await db.execute(select(Match).where(Match.match_id == match_id))
|
||||
).scalars().first()
|
||||
if not match:
|
||||
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
|
||||
|
||||
# 이 경기에 "그 이메일"로 투표한 사람만 작성 가능 — 포인트도 이메일 단위라 신원 기준을 통일.
|
||||
# device_id 는 게이트에 쓰지 않음(닉네임=이메일, 자격=이메일 투표 여부).
|
||||
email_norm = str(body.email).strip().lower()
|
||||
voted = (
|
||||
await db.execute(
|
||||
select(UserPrediction.id)
|
||||
.where(
|
||||
UserPrediction.match_id == match_id,
|
||||
func.lower(UserPrediction.email) == email_norm,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).scalar()
|
||||
if voted is None:
|
||||
raise HTTPException(status_code=403, detail="NOT_VOTED")
|
||||
|
||||
# 쿨다운: 같은 이메일의 가장 최근 작성과의 간격 확인
|
||||
last_at = (
|
||||
await db.execute(
|
||||
select(func.max(Comment.created_at)).where(
|
||||
func.lower(Comment.email) == email_norm
|
||||
)
|
||||
)
|
||||
).scalar()
|
||||
if last_at is not None:
|
||||
elapsed = datetime.now(timezone.utc) - last_at
|
||||
if elapsed < timedelta(seconds=COMMENT_COOLDOWN_SECONDS):
|
||||
raise HTTPException(status_code=429, detail="COMMENT_COOLDOWN")
|
||||
|
||||
comment = Comment(
|
||||
match_id=match_id,
|
||||
device_id=body.deviceId, # 저장만(분석용), 권한 판정엔 사용 안 함
|
||||
email=email_norm,
|
||||
body=body.body,
|
||||
)
|
||||
db.add(comment)
|
||||
await db.commit()
|
||||
await db.refresh(comment)
|
||||
return _out(comment, {email_norm}) # 작성자 본인이므로 mine=True
|
||||
|
||||
|
||||
@router.delete("/{match_id}/comments/{comment_id}", response_model=GenericOk)
|
||||
async def delete_comment(
|
||||
match_id: str,
|
||||
comment_id: int,
|
||||
# 뷰어 본인 이메일 목록 — 댓글 작성 이메일과 일치하면 어느 기기에서든 삭제 가능
|
||||
email: list[str] = Query(...),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> GenericOk:
|
||||
"""본인 댓글 삭제 — 작성에 쓰인 이메일과 같으면 기기 무관하게 삭제."""
|
||||
viewer = _norm_emails(email)
|
||||
if not viewer:
|
||||
raise HTTPException(status_code=422, detail="EMAIL_REQUIRED")
|
||||
comment = (
|
||||
await db.execute(
|
||||
select(Comment).where(
|
||||
Comment.id == comment_id, Comment.match_id == match_id
|
||||
)
|
||||
)
|
||||
).scalars().first()
|
||||
if comment is None:
|
||||
raise HTTPException(status_code=404, detail="COMMENT_NOT_FOUND")
|
||||
if comment.email not in viewer:
|
||||
raise HTTPException(status_code=403, detail="NOT_OWNER")
|
||||
await db.delete(comment)
|
||||
await db.commit()
|
||||
return GenericOk(ok=True)
|
||||
|
|
@ -122,6 +122,34 @@ class AdminSetResultIn(BaseModel):
|
|||
scoreB: int = Field(ge=0, le=50)
|
||||
|
||||
|
||||
# ── 댓글(경기별 한마디) ─────────────────────────────────────
|
||||
class CommentIn(BaseModel):
|
||||
deviceId: str = Field(min_length=8, max_length=64)
|
||||
email: EmailStr # 투표 시 입력한 이메일 — 마스킹해 닉네임으로 노출
|
||||
body: str = Field(min_length=1, max_length=200)
|
||||
|
||||
@field_validator("body")
|
||||
@classmethod
|
||||
def body_not_blank(cls, v: str) -> str:
|
||||
v = v.strip()
|
||||
if not v:
|
||||
raise ValueError("EMPTY_BODY")
|
||||
return v
|
||||
|
||||
|
||||
class CommentOut(BaseModel):
|
||||
id: int
|
||||
nickname: str # 마스킹된 이메일 (ab***@gmail.com)
|
||||
body: str
|
||||
createdAt: str # ISO8601
|
||||
mine: bool = False # 조회 요청 기기(device)가 작성한 댓글이면 True (삭제 버튼 노출용)
|
||||
|
||||
|
||||
class CommentListOut(BaseModel):
|
||||
items: list[CommentOut]
|
||||
total: int # 숨김 제외 전체 개수 — "더보기" 남은 수 계산용
|
||||
|
||||
|
||||
class GenericOk(BaseModel):
|
||||
ok: bool
|
||||
detail: str = ""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { dict, type Lang } from "@/lib/i18n";
|
||||
import { relativeTime } from "@/lib/format";
|
||||
import {
|
||||
listComments,
|
||||
postComment,
|
||||
deleteComment,
|
||||
getDeviceId,
|
||||
getRecentEmails,
|
||||
rememberEmail,
|
||||
ApiError,
|
||||
} from "@/lib/api";
|
||||
import type { Comment } from "@/lib/types";
|
||||
|
||||
const FIRST = 3; // 처음 보여줄 최신 댓글 수
|
||||
const PAGE = 10; // "더보기" 한 번에 더 불러올 수
|
||||
|
||||
// 경기별 한마디(댓글). 최신 3개 + "더보기" 인라인 펼침.
|
||||
export default function Comments({
|
||||
matchId,
|
||||
lang,
|
||||
}: {
|
||||
matchId: string;
|
||||
lang: Lang;
|
||||
}) {
|
||||
const t = dict(lang);
|
||||
const [items, setItems] = useState<Comment[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [cursor, setCursor] = useState(0); // 서버에서 페이징으로 받은 개수
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false); // true=전체 보기 / false=최신 FIRST개만
|
||||
|
||||
const [body, setBody] = useState("");
|
||||
// 투표 폼과 동일: 칸은 비우고 입력/포커스 시 datalist 드롭다운으로 최근 이메일 자동완성
|
||||
const [email, setEmail] = useState("");
|
||||
const [recentEmails, setRecentEmails] = useState<string[]>(getRecentEmails);
|
||||
const [posting, setPosting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
listComments(matchId, FIRST, 0)
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
setCursor(res.items.length);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [matchId]);
|
||||
|
||||
// 접힘 상태면 최신 FIRST개만 노출
|
||||
const visible = expanded ? items : items.slice(0, FIRST);
|
||||
const unloaded = Math.max(0, total - items.length); // 아직 서버에서 안 받은 수
|
||||
const hiddenWhenCollapsed = Math.max(0, total - FIRST); // 접힘 시 가려진 전체 수
|
||||
|
||||
// "더보기": 펼치고, 받아온 게 FIRST개뿐이면 다음 페이지도 가져온다
|
||||
async function showMore() {
|
||||
setExpanded(true);
|
||||
if (items.length <= FIRST && unloaded > 0) await loadMore();
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const res = await listComments(matchId, PAGE, cursor);
|
||||
setItems((prev) => {
|
||||
const seen = new Set(prev.map((c) => c.id));
|
||||
return [...prev, ...res.items.filter((c) => !seen.has(c.id))];
|
||||
});
|
||||
setTotal(res.total);
|
||||
setCursor((c) => c + res.items.length);
|
||||
} catch {
|
||||
/* 조용히 무시 — 다시 누르면 재시도 */
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
if (!window.confirm(t.commentDeleteConfirm)) return;
|
||||
try {
|
||||
await deleteComment(matchId, id);
|
||||
setItems((prev) => prev.filter((c) => c.id !== id));
|
||||
setTotal((n) => Math.max(0, n - 1));
|
||||
} catch {
|
||||
setError(t.commentError);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const text = body.trim();
|
||||
const mail = email.trim();
|
||||
if (posting) return;
|
||||
setError("");
|
||||
if (!mail) {
|
||||
setError(t.commentNeedEmail);
|
||||
return;
|
||||
}
|
||||
if (!text) return;
|
||||
setPosting(true);
|
||||
try {
|
||||
const created = await postComment(matchId, {
|
||||
deviceId: getDeviceId(),
|
||||
email: mail,
|
||||
body: text,
|
||||
});
|
||||
rememberEmail(mail);
|
||||
setRecentEmails(getRecentEmails()); // 방금 쓴 이메일을 자동완성 목록에 반영
|
||||
setItems((prev) => [created, ...prev]); // 낙관적: 맨 위에 즉시 표시
|
||||
setTotal((n) => n + 1);
|
||||
setBody("");
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 403)
|
||||
setError(t.commentNeedVote);
|
||||
else if (e instanceof ApiError && e.status === 429)
|
||||
setError(t.commentCooldown);
|
||||
else if (e instanceof ApiError && e.status === 422)
|
||||
setError(t.commentNeedEmail);
|
||||
else setError(t.commentError);
|
||||
} finally {
|
||||
setPosting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-7 rounded-2xl border border-[var(--line-d)] bg-[var(--bg2)] p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-[17px] font-extrabold text-white">
|
||||
💬 {t.commentsTitle}
|
||||
</h2>
|
||||
<span className="text-[13px] font-bold text-white/45">
|
||||
{t.commentsCount(total)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 입력 */}
|
||||
<div className="mt-3 space-y-2">
|
||||
<input
|
||||
type="email"
|
||||
inputMode="email"
|
||||
autoComplete="email"
|
||||
list="tp-comment-email-suggestions"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder={t.commentEmailPh}
|
||||
className="w-full rounded-lg border border-[var(--line-d)] bg-[#0f1217] px-3 py-2.5 text-[14px] text-white outline-none focus:border-[var(--green)]"
|
||||
/>
|
||||
{recentEmails.length > 0 && (
|
||||
<datalist id="tp-comment-email-suggestions">
|
||||
{recentEmails.map((e) => (
|
||||
<option key={e} value={e} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value.slice(0, 200))}
|
||||
placeholder={t.commentPh}
|
||||
rows={2}
|
||||
maxLength={200}
|
||||
className="min-h-[44px] flex-1 resize-none rounded-lg border border-[var(--line-d)] bg-[#0f1217] px-3 py-2.5 text-[15px] text-white outline-none focus:border-[var(--green)]"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={posting || !body.trim()}
|
||||
className="shrink-0 rounded-lg bg-[var(--green)] px-4 py-2.5 text-[15px] font-extrabold text-black transition active:scale-[0.98] disabled:opacity-40"
|
||||
>
|
||||
{t.commentSubmit}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
{error ? (
|
||||
<span className="text-[12px] font-bold text-red-400">{error}</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<span className="text-[11px] tabular-nums text-white/35">
|
||||
{body.length}/200
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-4 py-3 text-center text-[13px] text-white/40">
|
||||
{t.commentEmpty}
|
||||
</p>
|
||||
) : (
|
||||
<ul
|
||||
className={`mt-4 divide-y divide-[var(--line-d)] ${
|
||||
expanded ? "max-h-[360px] overflow-y-auto pr-1" : ""
|
||||
}`}
|
||||
>
|
||||
{visible.map((c) => (
|
||||
<li key={c.id} className="py-3 first:pt-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[13px] font-extrabold text-[var(--green)]">
|
||||
{c.nickname}
|
||||
</span>
|
||||
<span className="text-[11px] text-white/35">
|
||||
{relativeTime(c.createdAt, lang)}
|
||||
</span>
|
||||
{c.mine && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(c.id)}
|
||||
className="ml-auto text-[11px] font-bold text-white/35 transition hover:text-red-400"
|
||||
>
|
||||
{t.commentDelete}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-snug text-white/90">
|
||||
{c.body}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* 더보기 / 접기 */}
|
||||
{!expanded && hiddenWhenCollapsed > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={showMore}
|
||||
disabled={loadingMore}
|
||||
className="mt-2 w-full rounded-lg border border-[var(--line-d)] py-2.5 text-[13px] font-bold text-white/70 transition hover:text-white disabled:opacity-50"
|
||||
>
|
||||
{t.commentMore} ▾
|
||||
</button>
|
||||
)}
|
||||
{expanded && (
|
||||
<div className="mt-2 flex gap-2">
|
||||
{unloaded > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
className="flex-1 rounded-lg border border-[var(--line-d)] py-2.5 text-[13px] font-bold text-white/70 transition hover:text-white disabled:opacity-50"
|
||||
>
|
||||
{t.commentMore} ▾
|
||||
</button>
|
||||
)}
|
||||
{items.length > FIRST && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(false)}
|
||||
className="flex-1 rounded-lg border border-[var(--line-d)] py-2.5 text-[13px] font-bold text-white/70 transition hover:text-white"
|
||||
>
|
||||
{t.commentCollapse} ▴
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
// 백엔드 FastAPI 클라이언트. 동일 출처 /api (dev=vite proxy, prod=nginx proxy).
|
||||
import type { CrowdStats, Leaderboard, Match, ModelName } from "./types";
|
||||
import type {
|
||||
Comment,
|
||||
CommentList,
|
||||
CrowdStats,
|
||||
Leaderboard,
|
||||
Match,
|
||||
ModelName,
|
||||
} from "./types";
|
||||
import type { Lang } from "./i18n";
|
||||
|
||||
const BASE = import.meta.env.VITE_API_BASE || "/api";
|
||||
|
|
@ -66,6 +73,48 @@ export function getLeaderboard(): Promise<Leaderboard> {
|
|||
return http<Leaderboard>(`/leaderboard`);
|
||||
}
|
||||
|
||||
// ── 댓글(경기별 한마디) ──
|
||||
// 내 이메일(localStorage 최근 이메일) 목록을 함께 보내 mine 플래그/삭제 권한 판정에 사용
|
||||
function emailQuery(emails: string[]): string {
|
||||
return emails
|
||||
.filter(Boolean)
|
||||
.map((e) => `&email=${encodeURIComponent(e)}`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function listComments(
|
||||
matchId: string,
|
||||
limit: number,
|
||||
offset: number,
|
||||
): Promise<CommentList> {
|
||||
const q = emailQuery(getRecentEmails());
|
||||
return http<CommentList>(
|
||||
`/matches/${matchId}/comments?limit=${limit}&offset=${offset}${q}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function postComment(
|
||||
matchId: string,
|
||||
body: { deviceId: string; email: string; body: string },
|
||||
): Promise<Comment> {
|
||||
return http<Comment>(`/matches/${matchId}/comments`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// 본인 댓글 삭제 — 내 이메일과 작성 이메일이 같으면 어느 기기에서든 삭제 가능
|
||||
export function deleteComment(
|
||||
matchId: string,
|
||||
commentId: number,
|
||||
): Promise<{ ok: boolean }> {
|
||||
const q = emailQuery(getRecentEmails());
|
||||
return http<{ ok: boolean }>(
|
||||
`/matches/${matchId}/comments/${commentId}?${q.replace(/^&/, "")}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
}
|
||||
|
||||
// 방문 기록 (하루 1기기 1회 집계). 실패는 무시(fire-and-forget).
|
||||
export function recordVisit(): void {
|
||||
void http(`/visit`, {
|
||||
|
|
|
|||
|
|
@ -54,3 +54,18 @@ export function pct(part: number, total: number): number {
|
|||
if (!total) return 0;
|
||||
return Math.round((part / total) * 100);
|
||||
}
|
||||
|
||||
// "방금 전 / 3분 전 / 2시간 전 / 5일 전" — 댓글 상대시간
|
||||
export function relativeTime(iso: string, lang: Lang = "ko"): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return "";
|
||||
const sec = Math.max(0, Math.floor((Date.now() - then) / 1000));
|
||||
const en = lang === "en";
|
||||
if (sec < 60) return en ? "just now" : "방금 전";
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return en ? `${min}m ago` : `${min}분 전`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return en ? `${hr}h ago` : `${hr}시간 전`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return en ? `${day}d ago` : `${day}일 전`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,20 @@ type Dict = {
|
|||
footerDisc3: string;
|
||||
hook: (a: string, b: string) => string;
|
||||
moreMatches: string;
|
||||
commentsTitle: string;
|
||||
commentsCount: (n: number) => string;
|
||||
commentPh: string;
|
||||
commentEmailPh: string;
|
||||
commentSubmit: string;
|
||||
commentMore: string;
|
||||
commentCollapse: string;
|
||||
commentDelete: string;
|
||||
commentDeleteConfirm: string;
|
||||
commentEmpty: string;
|
||||
commentNeedEmail: string;
|
||||
commentNeedVote: string;
|
||||
commentCooldown: string;
|
||||
commentError: string;
|
||||
adLabel: string;
|
||||
ado2Tagline: string;
|
||||
ado2Sub: string;
|
||||
|
|
@ -171,6 +185,20 @@ export const DICT: Record<Lang, Dict> = {
|
|||
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
|
||||
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
|
||||
moreMatches: "다른 경기 투표하기",
|
||||
commentsTitle: "한마디",
|
||||
commentsCount: (n) => `총 ${n}개`,
|
||||
commentPh: "응원 한마디 남기기...",
|
||||
commentEmailPh: "이메일",
|
||||
commentSubmit: "등록",
|
||||
commentMore: "댓글 더보기",
|
||||
commentCollapse: "접기",
|
||||
commentDelete: "삭제",
|
||||
commentDeleteConfirm: "이 댓글을 삭제할까요?",
|
||||
commentEmpty: "첫 한마디를 남겨보세요!",
|
||||
commentNeedEmail: "이메일을 입력해주세요.",
|
||||
commentNeedVote: "이 경기에 투표한 후 댓글을 쓸 수 있어요.",
|
||||
commentCooldown: "잠시 후 다시 시도해주세요.",
|
||||
commentError: "등록에 실패했습니다. 잠시 후 다시 시도해주세요.",
|
||||
adLabel: "광고",
|
||||
ado2Tagline: "AI 마케팅 자동화 플랫폼",
|
||||
ado2Sub: "AI Marketing Automation Platform →",
|
||||
|
|
@ -241,6 +269,20 @@ 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",
|
||||
commentsTitle: "Comments",
|
||||
commentsCount: (n) => `${n} total`,
|
||||
commentPh: "Leave a comment...",
|
||||
commentEmailPh: "Email (shown as masked nickname)",
|
||||
commentSubmit: "Post",
|
||||
commentMore: "Show more",
|
||||
commentCollapse: "Collapse",
|
||||
commentDelete: "Delete",
|
||||
commentDeleteConfirm: "Delete this comment?",
|
||||
commentEmpty: "Be the first to comment!",
|
||||
commentNeedEmail: "Please enter your email.",
|
||||
commentNeedVote: "Vote on this match first to leave a comment.",
|
||||
commentCooldown: "Please wait a moment before posting again.",
|
||||
commentError: "Failed to post. Please try again shortly.",
|
||||
adLabel: "AD",
|
||||
ado2Tagline: "AI Marketing Automation Platform",
|
||||
ado2Sub: "Visit ado2.o2osolution.ai →",
|
||||
|
|
|
|||
|
|
@ -74,3 +74,16 @@ export interface Leaderboard {
|
|||
standings: Standing[];
|
||||
scoredMatches: number;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: number;
|
||||
nickname: string; // 마스킹된 이메일 (ab***@gmail.com)
|
||||
body: string;
|
||||
createdAt: string;
|
||||
mine: boolean; // 내(이메일) 댓글이면 true → 삭제 버튼 노출
|
||||
}
|
||||
|
||||
export interface CommentList {
|
||||
items: Comment[];
|
||||
total: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,16 @@ import { Link, useParams } from "react-router-dom";
|
|||
import Hero from "@/components/Hero";
|
||||
import MatchupHUD from "@/components/MatchupHUD";
|
||||
import Arena from "@/components/Arena";
|
||||
import Comments from "@/components/Comments";
|
||||
import Footer from "@/components/Footer";
|
||||
import { dict, teamShort } from "@/lib/i18n";
|
||||
import { useLang } from "@/lib/useLang";
|
||||
import { getMatch, matchUrl, ApiError } from "@/lib/api";
|
||||
import type { Match } from "@/lib/types";
|
||||
|
||||
// 댓글(한마디)을 노출할 경기 — 한국 vs 멕시코 (A조 2차전)
|
||||
const COMMENTS_MATCH_ID = "A_MEX_KOR_20260619";
|
||||
|
||||
// L2. 경기 상세(대결) 페이지
|
||||
export default function MatchDetail() {
|
||||
const { matchId = "" } = useParams();
|
||||
|
|
@ -98,6 +102,11 @@ export default function MatchDetail() {
|
|||
lang={lang}
|
||||
/>
|
||||
|
||||
{/* 한국–멕시코 경기에만 한마디(댓글) 노출 */}
|
||||
{match.matchId === COMMENTS_MATCH_ID && (
|
||||
<Comments matchId={match.matchId} lang={lang} />
|
||||
)}
|
||||
|
||||
{/* 하단 CTA — 전체 일정으로(다른 경기 투표). ADO2 버튼과 동일 사양(퍼플 #A65EFF) */}
|
||||
<Link
|
||||
to={lang === "en" ? "/?lang=en" : "/"}
|
||||
|
|
|
|||
Loading…
Reference in New Issue