241 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|