Merge branch 'develop' of https://gitea.o2o.kr/castad/o2o-triple-pick into develop
commit
220eb946c1
|
|
@ -205,11 +205,12 @@ class PageVisit(Base):
|
||||||
|
|
||||||
|
|
||||||
class Comment(Base):
|
class Comment(Base):
|
||||||
"""경기별 한마디(댓글). 비로그인 — device_id 로 식별, 투표 시 입력한 email 을
|
"""경기별 한마디(댓글). 완전 익명 — 인증/이메일 없음.
|
||||||
닉네임(마스킹 표시)으로 사용. 테이블은 범용이고 노출 경기는 프론트가 제한한다.
|
|
||||||
|
|
||||||
- 최신순 조회(created_at DESC) + limit/offset 페이징("더보기" 인라인 펼침).
|
- author_hash: sha256(device_id). 원본 기기ID는 저장하지 않음(추적 불가). 쿨다운 식별용.
|
||||||
- is_hidden=True 는 관리자/신고 숨김 — 조회에서 제외.
|
- nickname: 축구+코믹 한국어 5글자(기기 해시로 자동 배정, 기기당 고정).
|
||||||
|
- id(PK)·author_hash 는 내부용으로 API 응답에 노출하지 않음.
|
||||||
|
- 최신순 조회(created_at DESC) + limit/offset 페이징. is_hidden=True 는 조회 제외.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "comments"
|
__tablename__ = "comments"
|
||||||
|
|
@ -218,8 +219,8 @@ class Comment(Base):
|
||||||
match_id: Mapped[str] = mapped_column(
|
match_id: Mapped[str] = mapped_column(
|
||||||
ForeignKey("matches.match_id", ondelete="CASCADE"), index=True
|
ForeignKey("matches.match_id", ondelete="CASCADE"), index=True
|
||||||
)
|
)
|
||||||
device_id: Mapped[str] = mapped_column(String, index=True) # 비로그인 식별 + 쿨다운 기준
|
author_hash: Mapped[str] = mapped_column(String, index=True) # sha256(device_id) — 원본 비저장
|
||||||
email: Mapped[str] = mapped_column(String) # 소문자 정규화 — 마스킹해 표시
|
nickname: Mapped[str] = mapped_column(String) # 축구 코믹 한국어 5글자
|
||||||
body: Mapped[str] = mapped_column(String) # 길이 제한은 스키마(200자)에서
|
body: Mapped[str] = mapped_column(String) # 길이 제한은 스키마(200자)에서
|
||||||
is_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
|
is_hidden: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,18 @@
|
||||||
"""경기별 한마디(댓글) API — 조회(최신순·페이징) · 작성(검증·쿨다운) · 본인 삭제.
|
"""경기별 한마디(댓글) API — 완전 익명. 조회(최신순·페이징) · 작성(검증·쿨다운).
|
||||||
|
|
||||||
신원 기준은 전부 **이메일**(포인트·리더보드와 동일). device_id 는 저장만 하고 로직엔 안 씀.
|
신원/인증 없음:
|
||||||
- 닉네임: 투표 시 입력한 email 을 마스킹(ab***@gmail.com)해 노출.
|
- author_hash = sha256(device_id). 기기 원본 ID는 저장하지 않음(추적 불가). 쿨다운 전용.
|
||||||
- 작성 자격: 그 이메일로 이 경기에 투표했어야 함.
|
- nickname: 축구+코믹 한국어 5글자. **세션 단위 발급** — 클라가 sessionStorage 에 캐싱해
|
||||||
- 본인 댓글: 같은 이메일이면 어느 기기에서든 삭제 가능(다른 기기에서도 OK).
|
한 세션 동안 같은 닉을 재사용, 탭/창을 닫았다 켜거나 시크릿이면 새로 발급.
|
||||||
- 방어(기본): body 길이 제한(스키마 200자) · 같은 이메일 연속 작성 쿨다운 · is_hidden 제외.
|
발급은 서버 단어풀에서만(욕설/사칭 방지) — 작성 시에도 풀 검증.
|
||||||
|
- id(PK)·author_hash 는 내부용으로 API 응답에 노출하지 않음.
|
||||||
|
- 방어(기본): body 길이 제한(스키마 200자) · 같은 기기 연속 작성 쿨다운 · is_hidden 제외.
|
||||||
|
- 테이블은 범용(모든 경기)이고, 노출 경기 제한은 프론트가 담당.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import random
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
@ -15,47 +20,71 @@ from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..database import get_db
|
from ..database import get_db
|
||||||
from ..models import Comment, Match, UserPrediction
|
from ..models import Comment, Match
|
||||||
from ..schemas import CommentIn, CommentListOut, CommentOut, GenericOk
|
from ..schemas import CommentIn, CommentListOut, CommentNicknameOut, CommentOut
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/matches", tags=["comments"])
|
router = APIRouter(prefix="/api/matches", tags=["comments"])
|
||||||
|
|
||||||
# 같은 이메일 연속 작성 최소 간격(초) — 도배 방지(기본 방어).
|
# 같은 기기 연속 작성 최소 간격(초) — 도배 방지(기본 방어).
|
||||||
COMMENT_COOLDOWN_SECONDS = 5
|
COMMENT_COOLDOWN_SECONDS = 5
|
||||||
|
|
||||||
|
# 닉네임 조합: 2글자(코믹 수식) + 3글자(축구 역할) = 항상 한국어 5글자.
|
||||||
def _mask(email: str) -> str:
|
# 40 × 30 = 1200가지.
|
||||||
"""leaderboard._mask 와 동일 규칙 — ab***@gmail.com."""
|
_NICK_A = [
|
||||||
name, _, domain = email.partition("@")
|
"잔디", "침대", "벤치", "후보", "똥손", "헛발", "발컨", "노룩", "왼발", "멘붕",
|
||||||
head = name[:2] if len(name) >= 2 else name
|
"국대", "동네", "주말", "폭발", "광속", "진지", "발끝", "번개", "백수", "천재",
|
||||||
return f"{head}{'*' * max(1, len(name) - 2)}@{domain}"
|
"야수", "괴물", "폭격", "강철", "무적", "질풍", "돌풍", "불꽃", "발광", "분노",
|
||||||
|
"음속", "폭탄", "슈퍼", "만년", "전설", "비밀", "미친", "라면", "치킨", "출근",
|
||||||
|
]
|
||||||
|
_NICK_B = [
|
||||||
|
"드리블", "골사냥", "해결사", "종결자", "자판기", "수비수", "골키퍼", "패스왕",
|
||||||
|
"헤더왕", "골게터", "오버랩", "프리킥", "발재간", "삽질러", "똥볼러", "돌파왕",
|
||||||
|
"압박왕", "태클왕", "중거리", "발리슛", "빌드업", "백패스", "자책골", "골기계",
|
||||||
|
"어시왕", "역습왕", "수문장", "골부자", "골가뭄", "발연기",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _out(c: Comment, viewer_emails: set[str] | None = None) -> CommentOut:
|
def _author_hash(device_id: str) -> str:
|
||||||
|
"""기기 원본 ID는 저장하지 않고 해시만 사용(추적 불가)."""
|
||||||
|
return hashlib.sha256(device_id.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
_NICK_A_SET = set(_NICK_A)
|
||||||
|
_NICK_B_SET = set(_NICK_B)
|
||||||
|
|
||||||
|
|
||||||
|
def _random_nickname() -> str:
|
||||||
|
"""랜덤 닉네임. 두 단어 조합으로 항상 5글자."""
|
||||||
|
return random.choice(_NICK_A) + random.choice(_NICK_B)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_nickname(n: str) -> bool:
|
||||||
|
"""서버 단어풀에서 나온 정상 닉인지 검증(앞2 + 뒤3)."""
|
||||||
|
n = (n or "").strip()
|
||||||
|
return len(n) == 5 and n[:2] in _NICK_A_SET and n[2:] in _NICK_B_SET
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{match_id}/comments/nickname", response_model=CommentNicknameOut)
|
||||||
|
async def issue_nickname(match_id: str) -> CommentNicknameOut:
|
||||||
|
"""세션 시작 시 랜덤 닉 발급(클라가 sessionStorage 에 캐싱해 재사용)."""
|
||||||
|
return CommentNicknameOut(nickname=_random_nickname())
|
||||||
|
|
||||||
|
|
||||||
|
def _out(c: Comment) -> CommentOut:
|
||||||
return CommentOut(
|
return CommentOut(
|
||||||
id=c.id,
|
nickname=c.nickname,
|
||||||
nickname=_mask(c.email),
|
|
||||||
body=c.body,
|
body=c.body,
|
||||||
createdAt=c.created_at.isoformat(),
|
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)
|
@router.get("/{match_id}/comments", response_model=CommentListOut)
|
||||||
async def list_comments(
|
async def list_comments(
|
||||||
match_id: str,
|
match_id: str,
|
||||||
limit: int = Query(3, ge=1, le=50),
|
limit: int = Query(3, ge=1, le=50),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
# 뷰어 본인 이메일 목록(localStorage 최근 이메일). 일치하는 댓글에 mine=True → 삭제 버튼 노출
|
|
||||||
email: list[str] | None = Query(None),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> CommentListOut:
|
) -> CommentListOut:
|
||||||
viewer = _norm_emails(email)
|
|
||||||
base = (
|
base = (
|
||||||
select(Comment)
|
select(Comment)
|
||||||
.where(Comment.match_id == match_id, Comment.is_hidden.is_(False))
|
.where(Comment.match_id == match_id, Comment.is_hidden.is_(False))
|
||||||
|
|
@ -72,9 +101,7 @@ async def list_comments(
|
||||||
.offset(offset)
|
.offset(offset)
|
||||||
)
|
)
|
||||||
).scalars().all()
|
).scalars().all()
|
||||||
return CommentListOut(
|
return CommentListOut(items=[_out(c) for c in rows], total=total)
|
||||||
items=[_out(c, viewer) for c in rows], total=total
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{match_id}/comments", response_model=CommentOut)
|
@router.post("/{match_id}/comments", response_model=CommentOut)
|
||||||
|
|
@ -87,27 +114,13 @@ async def create_comment(
|
||||||
if not match:
|
if not match:
|
||||||
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
|
raise HTTPException(status_code=404, detail="MATCH_NOT_FOUND")
|
||||||
|
|
||||||
# 이 경기에 "그 이메일"로 투표한 사람만 작성 가능 — 포인트도 이메일 단위라 신원 기준을 통일.
|
author_hash = _author_hash(body.deviceId)
|
||||||
# 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 = (
|
last_at = (
|
||||||
await db.execute(
|
await db.execute(
|
||||||
select(func.max(Comment.created_at)).where(
|
select(func.max(Comment.created_at)).where(
|
||||||
func.lower(Comment.email) == email_norm
|
Comment.author_hash == author_hash
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
).scalar()
|
).scalar()
|
||||||
|
|
@ -116,41 +129,15 @@ async def create_comment(
|
||||||
if elapsed < timedelta(seconds=COMMENT_COOLDOWN_SECONDS):
|
if elapsed < timedelta(seconds=COMMENT_COOLDOWN_SECONDS):
|
||||||
raise HTTPException(status_code=429, detail="COMMENT_COOLDOWN")
|
raise HTTPException(status_code=429, detail="COMMENT_COOLDOWN")
|
||||||
|
|
||||||
|
# 세션 캐싱된 닉을 사용하되, 풀에 없는(조작된) 값이면 새 랜덤으로 대체
|
||||||
|
nickname = body.nickname if _valid_nickname(body.nickname) else _random_nickname()
|
||||||
comment = Comment(
|
comment = Comment(
|
||||||
match_id=match_id,
|
match_id=match_id,
|
||||||
device_id=body.deviceId, # 저장만(분석용), 권한 판정엔 사용 안 함
|
author_hash=author_hash,
|
||||||
email=email_norm,
|
nickname=nickname,
|
||||||
body=body.body,
|
body=body.body,
|
||||||
)
|
)
|
||||||
db.add(comment)
|
db.add(comment)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(comment)
|
await db.refresh(comment)
|
||||||
return _out(comment, {email_norm}) # 작성자 본인이므로 mine=True
|
return _out(comment)
|
||||||
|
|
||||||
|
|
||||||
@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,10 +122,10 @@ class AdminSetResultIn(BaseModel):
|
||||||
scoreB: int = Field(ge=0, le=50)
|
scoreB: int = Field(ge=0, le=50)
|
||||||
|
|
||||||
|
|
||||||
# ── 댓글(경기별 한마디) ─────────────────────────────────────
|
# ── 댓글(경기별 한마디) — 완전 익명 ─────────────────────────
|
||||||
class CommentIn(BaseModel):
|
class CommentIn(BaseModel):
|
||||||
deviceId: str = Field(min_length=8, max_length=64)
|
deviceId: str = Field(min_length=8, max_length=64) # 서버에서 해시 후 폐기(원본 비저장)
|
||||||
email: EmailStr # 투표 시 입력한 이메일 — 마스킹해 닉네임으로 노출
|
nickname: str = Field(min_length=1, max_length=12) # 세션 캐싱된 닉(서버 풀 검증)
|
||||||
body: str = Field(min_length=1, max_length=200)
|
body: str = Field(min_length=1, max_length=200)
|
||||||
|
|
||||||
@field_validator("body")
|
@field_validator("body")
|
||||||
|
|
@ -138,11 +138,14 @@ class CommentIn(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class CommentOut(BaseModel):
|
class CommentOut(BaseModel):
|
||||||
id: int
|
# id·author_hash 등 내부 식별자는 노출하지 않음(닉네임만 공개)
|
||||||
nickname: str # 마스킹된 이메일 (ab***@gmail.com)
|
nickname: str # 축구 코믹 한국어 5글자 (세션 발급)
|
||||||
body: str
|
body: str
|
||||||
createdAt: str # ISO8601
|
createdAt: str # ISO8601
|
||||||
mine: bool = False # 조회 요청 기기(device)가 작성한 댓글이면 True (삭제 버튼 노출용)
|
|
||||||
|
|
||||||
|
class CommentNicknameOut(BaseModel):
|
||||||
|
nickname: str # 세션 시작 시 발급받는 랜덤 닉(클라이언트가 sessionStorage에 캐싱)
|
||||||
|
|
||||||
|
|
||||||
class CommentListOut(BaseModel):
|
class CommentListOut(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,9 @@ import { relativeTime } from "@/lib/format";
|
||||||
import {
|
import {
|
||||||
listComments,
|
listComments,
|
||||||
postComment,
|
postComment,
|
||||||
deleteComment,
|
getSessionNickname,
|
||||||
|
rerollSessionNickname,
|
||||||
getDeviceId,
|
getDeviceId,
|
||||||
getRecentEmails,
|
|
||||||
rememberEmail,
|
|
||||||
ApiError,
|
ApiError,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import type { Comment } from "@/lib/types";
|
import type { Comment } from "@/lib/types";
|
||||||
|
|
@ -15,7 +14,10 @@ import type { Comment } from "@/lib/types";
|
||||||
const FIRST = 3; // 처음 보여줄 최신 댓글 수
|
const FIRST = 3; // 처음 보여줄 최신 댓글 수
|
||||||
const PAGE = 10; // "더보기" 한 번에 더 불러올 수
|
const PAGE = 10; // "더보기" 한 번에 더 불러올 수
|
||||||
|
|
||||||
// 경기별 한마디(댓글). 최신 3개 + "더보기" 인라인 펼침.
|
// 한 댓글을 식별하는 합성 키 (id 비노출이라 시각+내용으로 dedup/렌더)
|
||||||
|
const keyOf = (c: Comment) => `${c.createdAt}|${c.nickname}|${c.body}`;
|
||||||
|
|
||||||
|
// 경기별 한마디(댓글) — 완전 익명. 최신 3개 + 더보기/접기.
|
||||||
export default function Comments({
|
export default function Comments({
|
||||||
matchId,
|
matchId,
|
||||||
lang,
|
lang,
|
||||||
|
|
@ -28,12 +30,10 @@ export default function Comments({
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [cursor, setCursor] = useState(0); // 서버에서 페이징으로 받은 개수
|
const [cursor, setCursor] = useState(0); // 서버에서 페이징으로 받은 개수
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(false); // true=전체 보기 / false=최신 FIRST개만
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const [body, setBody] = useState("");
|
const [body, setBody] = useState("");
|
||||||
// 투표 폼과 동일: 칸은 비우고 입력/포커스 시 datalist 드롭다운으로 최근 이메일 자동완성
|
const [nickname, setNickname] = useState(""); // 세션 닉(캐싱)
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [recentEmails, setRecentEmails] = useState<string[]>(getRecentEmails);
|
|
||||||
const [posting, setPosting] = useState(false);
|
const [posting, setPosting] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
|
@ -47,17 +47,18 @@ export default function Comments({
|
||||||
setCursor(res.items.length);
|
setCursor(res.items.length);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
getSessionNickname(matchId)
|
||||||
|
.then((n) => alive && setNickname(n))
|
||||||
|
.catch(() => {});
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [matchId]);
|
}, [matchId]);
|
||||||
|
|
||||||
// 접힘 상태면 최신 FIRST개만 노출
|
|
||||||
const visible = expanded ? items : items.slice(0, FIRST);
|
const visible = expanded ? items : items.slice(0, FIRST);
|
||||||
const unloaded = Math.max(0, total - items.length); // 아직 서버에서 안 받은 수
|
const unloaded = Math.max(0, total - items.length);
|
||||||
const hiddenWhenCollapsed = Math.max(0, total - FIRST); // 접힘 시 가려진 전체 수
|
const hiddenWhenCollapsed = Math.max(0, total - FIRST);
|
||||||
|
|
||||||
// "더보기": 펼치고, 받아온 게 FIRST개뿐이면 다음 페이지도 가져온다
|
|
||||||
async function showMore() {
|
async function showMore() {
|
||||||
setExpanded(true);
|
setExpanded(true);
|
||||||
if (items.length <= FIRST && unloaded > 0) await loadMore();
|
if (items.length <= FIRST && unloaded > 0) await loadMore();
|
||||||
|
|
@ -69,8 +70,8 @@ export default function Comments({
|
||||||
try {
|
try {
|
||||||
const res = await listComments(matchId, PAGE, cursor);
|
const res = await listComments(matchId, PAGE, cursor);
|
||||||
setItems((prev) => {
|
setItems((prev) => {
|
||||||
const seen = new Set(prev.map((c) => c.id));
|
const seen = new Set(prev.map(keyOf));
|
||||||
return [...prev, ...res.items.filter((c) => !seen.has(c.id))];
|
return [...prev, ...res.items.filter((c) => !seen.has(keyOf(c)))];
|
||||||
});
|
});
|
||||||
setTotal(res.total);
|
setTotal(res.total);
|
||||||
setCursor((c) => c + res.items.length);
|
setCursor((c) => c + res.items.length);
|
||||||
|
|
@ -81,46 +82,31 @@ export default function Comments({
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id: number) {
|
async function reroll() {
|
||||||
if (!window.confirm(t.commentDeleteConfirm)) return;
|
|
||||||
try {
|
try {
|
||||||
await deleteComment(matchId, id);
|
const n = await rerollSessionNickname(matchId);
|
||||||
setItems((prev) => prev.filter((c) => c.id !== id));
|
setNickname(n);
|
||||||
setTotal((n) => Math.max(0, n - 1));
|
|
||||||
} catch {
|
} catch {
|
||||||
setError(t.commentError);
|
/* 무시 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const text = body.trim();
|
const text = body.trim();
|
||||||
const mail = email.trim();
|
if (posting || !text) return;
|
||||||
if (posting) return;
|
|
||||||
setError("");
|
setError("");
|
||||||
if (!mail) {
|
|
||||||
setError(t.commentNeedEmail);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!text) return;
|
|
||||||
setPosting(true);
|
setPosting(true);
|
||||||
try {
|
try {
|
||||||
const created = await postComment(matchId, {
|
const created = await postComment(matchId, {
|
||||||
deviceId: getDeviceId(),
|
deviceId: getDeviceId(),
|
||||||
email: mail,
|
nickname,
|
||||||
body: text,
|
body: text,
|
||||||
});
|
});
|
||||||
rememberEmail(mail);
|
|
||||||
setRecentEmails(getRecentEmails()); // 방금 쓴 이메일을 자동완성 목록에 반영
|
|
||||||
setItems((prev) => [created, ...prev]); // 낙관적: 맨 위에 즉시 표시
|
setItems((prev) => [created, ...prev]); // 낙관적: 맨 위에 즉시 표시
|
||||||
setTotal((n) => n + 1);
|
setTotal((n) => n + 1);
|
||||||
setBody("");
|
setBody("");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 403)
|
if (e instanceof ApiError && e.status === 429) setError(t.commentCooldown);
|
||||||
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);
|
else setError(t.commentError);
|
||||||
} finally {
|
} finally {
|
||||||
setPosting(false);
|
setPosting(false);
|
||||||
|
|
@ -141,22 +127,20 @@ export default function Comments({
|
||||||
|
|
||||||
{/* 입력 */}
|
{/* 입력 */}
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
<input
|
{nickname && (
|
||||||
type="email"
|
<p className="flex items-center gap-1.5 text-[12px] font-bold text-white/45">
|
||||||
inputMode="email"
|
⚽ {t.commentAs}{" "}
|
||||||
autoComplete="email"
|
<span className="text-[var(--green)]">{nickname}</span>
|
||||||
list="tp-comment-email-suggestions"
|
<button
|
||||||
value={email}
|
type="button"
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onClick={reroll}
|
||||||
placeholder={t.commentEmailPh}
|
title={t.commentReroll}
|
||||||
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)]"
|
aria-label={t.commentReroll}
|
||||||
/>
|
className="grid h-5 w-5 place-items-center rounded-full text-[13px] leading-none text-white/45 transition hover:text-white active:rotate-180"
|
||||||
{recentEmails.length > 0 && (
|
>
|
||||||
<datalist id="tp-comment-email-suggestions">
|
↻
|
||||||
{recentEmails.map((e) => (
|
</button>
|
||||||
<option key={e} value={e} />
|
</p>
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex items-end gap-2">
|
<div className="flex items-end gap-2">
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -199,8 +183,8 @@ export default function Comments({
|
||||||
expanded ? "max-h-[360px] overflow-y-auto pr-1" : ""
|
expanded ? "max-h-[360px] overflow-y-auto pr-1" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{visible.map((c) => (
|
{visible.map((c, i) => (
|
||||||
<li key={c.id} className="py-3 first:pt-0">
|
<li key={`${keyOf(c)}-${i}`} className="py-3 first:pt-0">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-[13px] font-extrabold text-[var(--green)]">
|
<span className="text-[13px] font-extrabold text-[var(--green)]">
|
||||||
{c.nickname}
|
{c.nickname}
|
||||||
|
|
@ -208,15 +192,6 @@ export default function Comments({
|
||||||
<span className="text-[11px] text-white/35">
|
<span className="text-[11px] text-white/35">
|
||||||
{relativeTime(c.createdAt, lang)}
|
{relativeTime(c.createdAt, lang)}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<p className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-snug text-white/90">
|
<p className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-snug text-white/90">
|
||||||
{c.body}
|
{c.body}
|
||||||
|
|
|
||||||
|
|
@ -73,29 +73,20 @@ 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(
|
export function listComments(
|
||||||
matchId: string,
|
matchId: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
offset: number,
|
offset: number,
|
||||||
): Promise<CommentList> {
|
): Promise<CommentList> {
|
||||||
const q = emailQuery(getRecentEmails());
|
|
||||||
return http<CommentList>(
|
return http<CommentList>(
|
||||||
`/matches/${matchId}/comments?limit=${limit}&offset=${offset}${q}`,
|
`/matches/${matchId}/comments?limit=${limit}&offset=${offset}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function postComment(
|
export function postComment(
|
||||||
matchId: string,
|
matchId: string,
|
||||||
body: { deviceId: string; email: string; body: string },
|
body: { deviceId: string; nickname: string; body: string },
|
||||||
): Promise<Comment> {
|
): Promise<Comment> {
|
||||||
return http<Comment>(`/matches/${matchId}/comments`, {
|
return http<Comment>(`/matches/${matchId}/comments`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -103,16 +94,38 @@ export function postComment(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 본인 댓글 삭제 — 내 이메일과 작성 이메일이 같으면 어느 기기에서든 삭제 가능
|
// ── 세션 닉네임: 세션당 한 번 발급받아 sessionStorage 에 캐싱 ──
|
||||||
export function deleteComment(
|
// 탭/창을 닫으면 비워지고(껐다 켜면 새 닉), 시크릿창은 별도 저장소라 새로 발급됨.
|
||||||
matchId: string,
|
const SESSION_NICK_KEY = "tp_session_nick";
|
||||||
commentId: number,
|
export async function getSessionNickname(matchId: string): Promise<string> {
|
||||||
): Promise<{ ok: boolean }> {
|
try {
|
||||||
const q = emailQuery(getRecentEmails());
|
const cached = sessionStorage.getItem(SESSION_NICK_KEY);
|
||||||
return http<{ ok: boolean }>(
|
if (cached) return cached;
|
||||||
`/matches/${matchId}/comments/${commentId}?${q.replace(/^&/, "")}`,
|
} catch {
|
||||||
{ method: "DELETE" },
|
/* sessionStorage 불가 환경은 매번 발급 */
|
||||||
|
}
|
||||||
|
const { nickname } = await http<{ nickname: string }>(
|
||||||
|
`/matches/${matchId}/comments/nickname`,
|
||||||
);
|
);
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(SESSION_NICK_KEY, nickname);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
return nickname;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 닉네임 새로 뽑기 — 서버에서 새 닉 받아 세션 캐시 갱신
|
||||||
|
export async function rerollSessionNickname(matchId: string): Promise<string> {
|
||||||
|
const { nickname } = await http<{ nickname: string }>(
|
||||||
|
`/matches/${matchId}/comments/nickname`,
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(SESSION_NICK_KEY, nickname);
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
return nickname;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 방문 기록 (하루 1기기 1회 집계). 실패는 무시(fire-and-forget).
|
// 방문 기록 (하루 1기기 1회 집계). 실패는 무시(fire-and-forget).
|
||||||
|
|
|
||||||
|
|
@ -102,15 +102,12 @@ type Dict = {
|
||||||
commentsTitle: string;
|
commentsTitle: string;
|
||||||
commentsCount: (n: number) => string;
|
commentsCount: (n: number) => string;
|
||||||
commentPh: string;
|
commentPh: string;
|
||||||
commentEmailPh: string;
|
commentAs: string;
|
||||||
|
commentReroll: string;
|
||||||
commentSubmit: string;
|
commentSubmit: string;
|
||||||
commentMore: string;
|
commentMore: string;
|
||||||
commentCollapse: string;
|
commentCollapse: string;
|
||||||
commentDelete: string;
|
|
||||||
commentDeleteConfirm: string;
|
|
||||||
commentEmpty: string;
|
commentEmpty: string;
|
||||||
commentNeedEmail: string;
|
|
||||||
commentNeedVote: string;
|
|
||||||
commentCooldown: string;
|
commentCooldown: string;
|
||||||
commentError: string;
|
commentError: string;
|
||||||
adLabel: string;
|
adLabel: string;
|
||||||
|
|
@ -195,15 +192,12 @@ export const DICT: Record<Lang, Dict> = {
|
||||||
commentsTitle: "한마디",
|
commentsTitle: "한마디",
|
||||||
commentsCount: (n) => `총 ${n}개`,
|
commentsCount: (n) => `총 ${n}개`,
|
||||||
commentPh: "응원 한마디 남기기...",
|
commentPh: "응원 한마디 남기기...",
|
||||||
commentEmailPh: "이메일",
|
commentAs: "이번 닉네임",
|
||||||
|
commentReroll: "닉네임 새로 뽑기",
|
||||||
commentSubmit: "등록",
|
commentSubmit: "등록",
|
||||||
commentMore: "댓글 더보기",
|
commentMore: "댓글 더보기",
|
||||||
commentCollapse: "접기",
|
commentCollapse: "접기",
|
||||||
commentDelete: "삭제",
|
|
||||||
commentDeleteConfirm: "이 댓글을 삭제할까요?",
|
|
||||||
commentEmpty: "첫 한마디를 남겨보세요!",
|
commentEmpty: "첫 한마디를 남겨보세요!",
|
||||||
commentNeedEmail: "이메일을 입력해주세요.",
|
|
||||||
commentNeedVote: "이 경기에 투표한 후 댓글을 쓸 수 있어요.",
|
|
||||||
commentCooldown: "잠시 후 다시 시도해주세요.",
|
commentCooldown: "잠시 후 다시 시도해주세요.",
|
||||||
commentError: "등록에 실패했습니다. 잠시 후 다시 시도해주세요.",
|
commentError: "등록에 실패했습니다. 잠시 후 다시 시도해주세요.",
|
||||||
adLabel: "광고",
|
adLabel: "광고",
|
||||||
|
|
@ -285,15 +279,12 @@ export const DICT: Record<Lang, Dict> = {
|
||||||
commentsTitle: "Comments",
|
commentsTitle: "Comments",
|
||||||
commentsCount: (n) => `${n} total`,
|
commentsCount: (n) => `${n} total`,
|
||||||
commentPh: "Leave a comment...",
|
commentPh: "Leave a comment...",
|
||||||
commentEmailPh: "Email (shown as masked nickname)",
|
commentAs: "Your name",
|
||||||
|
commentReroll: "Get a new name",
|
||||||
commentSubmit: "Post",
|
commentSubmit: "Post",
|
||||||
commentMore: "Show more",
|
commentMore: "Show more",
|
||||||
commentCollapse: "Collapse",
|
commentCollapse: "Collapse",
|
||||||
commentDelete: "Delete",
|
|
||||||
commentDeleteConfirm: "Delete this comment?",
|
|
||||||
commentEmpty: "Be the first to 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.",
|
commentCooldown: "Please wait a moment before posting again.",
|
||||||
commentError: "Failed to post. Please try again shortly.",
|
commentError: "Failed to post. Please try again shortly.",
|
||||||
adLabel: "AD",
|
adLabel: "AD",
|
||||||
|
|
|
||||||
|
|
@ -76,11 +76,9 @@ export interface Leaderboard {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Comment {
|
export interface Comment {
|
||||||
id: number;
|
nickname: string; // 축구 코믹 한국어 5글자 (서버가 기기 해시로 자동 배정)
|
||||||
nickname: string; // 마스킹된 이메일 (ab***@gmail.com)
|
|
||||||
body: string;
|
body: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
mine: boolean; // 내(이메일) 댓글이면 true → 삭제 버튼 노출
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommentList {
|
export interface CommentList {
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,6 @@ 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();
|
||||||
|
|
@ -102,10 +99,8 @@ export default function MatchDetail() {
|
||||||
lang={lang}
|
lang={lang}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 한국–멕시코 경기에만 한마디(댓글) 노출 */}
|
{/* 한마디(댓글) — 전 경기 노출 (경기 상태 무관) */}
|
||||||
{match.matchId === COMMENTS_MATCH_ID && (
|
<Comments matchId={match.matchId} lang={lang} />
|
||||||
<Comments matchId={match.matchId} lang={lang} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 하단 CTA — 전체 일정으로(다른 경기 투표). ADO2 버튼과 동일 사양(퍼플 #A65EFF) */}
|
{/* 하단 CTA — 전체 일정으로(다른 경기 투표). ADO2 버튼과 동일 사양(퍼플 #A65EFF) */}
|
||||||
<Link
|
<Link
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue