o2o-triple-pick/frontend/src/components/Comments.tsx
2026-07-22 11:51:57 +09:00

241 lines
7.7 KiB
TypeScript

import { useEffect, useState } from "react";
import { dict, type Lang } from "@/lib/i18n";
import { relativeTime } from "@/lib/format";
import {
listComments,
postComment,
getSessionNickname,
rerollSessionNickname,
getDeviceId,
ApiError,
} from "@/lib/api";
import type { Comment } from "@/lib/types";
const FIRST = 3; // 처음 보여줄 최신 댓글 수
const PAGE = 10; // "더보기" 한 번에 더 불러올 수
// 한 댓글을 식별하는 합성 키 (id 비노출이라 시각+내용으로 dedup/렌더)
const keyOf = (c: Comment) => `${c.createdAt}|${c.nickname}|${c.body}`;
// 경기별 한마디(댓글) — 완전 익명. 최신 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);
const [body, setBody] = useState("");
const [nickname, setNickname] = useState(""); // 세션 닉(캐싱)
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(() => {});
getSessionNickname(matchId)
.then((n) => alive && setNickname(n))
.catch(() => {});
return () => {
alive = false;
};
}, [matchId]);
const visible = expanded ? items : items.slice(0, FIRST);
const unloaded = Math.max(0, total - items.length);
const hiddenWhenCollapsed = Math.max(0, total - 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(keyOf));
return [...prev, ...res.items.filter((c) => !seen.has(keyOf(c)))];
});
setTotal(res.total);
setCursor((c) => c + res.items.length);
} catch {
/* 조용히 무시 — 다시 누르면 재시도 */
} finally {
setLoadingMore(false);
}
}
async function reroll() {
try {
const n = await rerollSessionNickname(matchId);
setNickname(n);
} catch {
/* 무시 */
}
}
async function submit() {
const text = body.trim();
if (posting || !text) return;
setError("");
setPosting(true);
try {
const created = await postComment(matchId, {
deviceId: getDeviceId(),
nickname,
body: text,
});
setItems((prev) => [created, ...prev]); // 낙관적: 맨 위에 즉시 표시
setTotal((n) => n + 1);
setBody("");
} catch (e) {
if (e instanceof ApiError && e.status === 429) setError(t.commentCooldown);
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">
{nickname && (
<p className="flex items-center gap-1.5 text-[12px] font-bold text-white/45">
{/^(KBO|MLB)_/.test(matchId) ? "⚾" : "⚽"} {t.commentAs}{" "}
<span className="text-[var(--green)]">{nickname}</span>
<button
type="button"
onClick={reroll}
title={t.commentReroll}
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"
>
↻
</button>
</p>
)}
<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 scroll-dark" : ""
}`}
>
{visible.map((c, i) => (
<li key={`${keyOf(c)}-${i}`} 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>
</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>
);
}