한국 멕시코 경기 댓글 기능 추가
parent
7625db36a0
commit
8a67094510
|
|
@ -13,7 +13,15 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .database import init_db
|
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 .scoring import load_scoring_data
|
||||||
from .seed import seed_if_empty
|
from .seed import seed_if_empty
|
||||||
|
|
||||||
|
|
@ -41,6 +49,7 @@ app.add_middleware(
|
||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(matches.router)
|
app.include_router(matches.router)
|
||||||
|
app.include_router(comments.router)
|
||||||
app.include_router(predictions.router)
|
app.include_router(predictions.router)
|
||||||
app.include_router(leaderboard.router)
|
app.include_router(leaderboard.router)
|
||||||
app.include_router(admin.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):
|
class FootballCache(Base):
|
||||||
"""축구 데이터 캐시 (API-Football 수집 결과) — 예측 프롬프트 조립용.
|
"""축구 데이터 캐시 (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)
|
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):
|
class GenericOk(BaseModel):
|
||||||
ok: bool
|
ok: bool
|
||||||
detail: str = ""
|
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).
|
// 백엔드 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";
|
import type { Lang } from "./i18n";
|
||||||
|
|
||||||
const BASE = import.meta.env.VITE_API_BASE || "/api";
|
const BASE = import.meta.env.VITE_API_BASE || "/api";
|
||||||
|
|
@ -66,6 +73,48 @@ export function getLeaderboard(): Promise<Leaderboard> {
|
||||||
return http<Leaderboard>(`/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).
|
// 방문 기록 (하루 1기기 1회 집계). 실패는 무시(fire-and-forget).
|
||||||
export function recordVisit(): void {
|
export function recordVisit(): void {
|
||||||
void http(`/visit`, {
|
void http(`/visit`, {
|
||||||
|
|
|
||||||
|
|
@ -54,3 +54,18 @@ export function pct(part: number, total: number): number {
|
||||||
if (!total) return 0;
|
if (!total) return 0;
|
||||||
return Math.round((part / total) * 100);
|
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;
|
footerDisc3: string;
|
||||||
hook: (a: string, b: string) => string;
|
hook: (a: string, b: string) => string;
|
||||||
moreMatches: 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;
|
adLabel: string;
|
||||||
ado2Tagline: string;
|
ado2Tagline: string;
|
||||||
ado2Sub: string;
|
ado2Sub: string;
|
||||||
|
|
@ -171,6 +185,20 @@ export const DICT: Record<Lang, Dict> = {
|
||||||
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
|
footerDisc3: "100만 원 이벤트는 무료 참여형 챌린지입니다. 지급·동점 처리 조건은 별도 약관에 따릅니다. 이메일은 결과·이벤트 알림 목적으로만 사용됩니다.",
|
||||||
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
|
hook: (a, b) => `${a} vs ${b}, AI의 선택은 갈렸다`,
|
||||||
moreMatches: "다른 경기 투표하기",
|
moreMatches: "다른 경기 투표하기",
|
||||||
|
commentsTitle: "한마디",
|
||||||
|
commentsCount: (n) => `총 ${n}개`,
|
||||||
|
commentPh: "응원 한마디 남기기...",
|
||||||
|
commentEmailPh: "이메일",
|
||||||
|
commentSubmit: "등록",
|
||||||
|
commentMore: "댓글 더보기",
|
||||||
|
commentCollapse: "접기",
|
||||||
|
commentDelete: "삭제",
|
||||||
|
commentDeleteConfirm: "이 댓글을 삭제할까요?",
|
||||||
|
commentEmpty: "첫 한마디를 남겨보세요!",
|
||||||
|
commentNeedEmail: "이메일을 입력해주세요.",
|
||||||
|
commentNeedVote: "이 경기에 투표한 후 댓글을 쓸 수 있어요.",
|
||||||
|
commentCooldown: "잠시 후 다시 시도해주세요.",
|
||||||
|
commentError: "등록에 실패했습니다. 잠시 후 다시 시도해주세요.",
|
||||||
adLabel: "광고",
|
adLabel: "광고",
|
||||||
ado2Tagline: "AI 마케팅 자동화 플랫폼",
|
ado2Tagline: "AI 마케팅 자동화 플랫폼",
|
||||||
ado2Sub: "AI Marketing Automation Platform →",
|
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.",
|
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}`,
|
hook: (a, b) => `AI is split on ${a} vs ${b}`,
|
||||||
moreMatches: "Vote on another match",
|
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",
|
adLabel: "AD",
|
||||||
ado2Tagline: "AI Marketing Automation Platform",
|
ado2Tagline: "AI Marketing Automation Platform",
|
||||||
ado2Sub: "Visit ado2.o2osolution.ai →",
|
ado2Sub: "Visit ado2.o2osolution.ai →",
|
||||||
|
|
|
||||||
|
|
@ -74,3 +74,16 @@ export interface Leaderboard {
|
||||||
standings: Standing[];
|
standings: Standing[];
|
||||||
scoredMatches: number;
|
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 Hero from "@/components/Hero";
|
||||||
import MatchupHUD from "@/components/MatchupHUD";
|
import MatchupHUD from "@/components/MatchupHUD";
|
||||||
import Arena from "@/components/Arena";
|
import Arena from "@/components/Arena";
|
||||||
|
import Comments from "@/components/Comments";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import { dict, teamShort } from "@/lib/i18n";
|
import { dict, teamShort } from "@/lib/i18n";
|
||||||
import { useLang } from "@/lib/useLang";
|
import { useLang } from "@/lib/useLang";
|
||||||
import { getMatch, matchUrl, ApiError } from "@/lib/api";
|
import { getMatch, matchUrl, ApiError } from "@/lib/api";
|
||||||
import type { Match } from "@/lib/types";
|
import type { Match } from "@/lib/types";
|
||||||
|
|
||||||
|
// 댓글(한마디)을 노출할 경기 — 한국 vs 멕시코 (A조 2차전)
|
||||||
|
const COMMENTS_MATCH_ID = "A_MEX_KOR_20260619";
|
||||||
|
|
||||||
// L2. 경기 상세(대결) 페이지
|
// L2. 경기 상세(대결) 페이지
|
||||||
export default function MatchDetail() {
|
export default function MatchDetail() {
|
||||||
const { matchId = "" } = useParams();
|
const { matchId = "" } = useParams();
|
||||||
|
|
@ -98,6 +102,11 @@ export default function MatchDetail() {
|
||||||
lang={lang}
|
lang={lang}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 한국–멕시코 경기에만 한마디(댓글) 노출 */}
|
||||||
|
{match.matchId === COMMENTS_MATCH_ID && (
|
||||||
|
<Comments matchId={match.matchId} lang={lang} />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 하단 CTA — 전체 일정으로(다른 경기 투표). ADO2 버튼과 동일 사양(퍼플 #A65EFF) */}
|
{/* 하단 CTA — 전체 일정으로(다른 경기 투표). ADO2 버튼과 동일 사양(퍼플 #A65EFF) */}
|
||||||
<Link
|
<Link
|
||||||
to={lang === "en" ? "/?lang=en" : "/"}
|
to={lang === "en" ? "/?lang=en" : "/"}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue