feat(ai-character): 댓글과 팬톡 운영 기능 구현
This commit is contained in:
47
src/features/comments/components/CommentForm.tsx
Normal file
47
src/features/comments/components/CommentForm.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { focusFirstInvalidControl } from "@/shared/lib/focus-first-invalid-control";
|
||||
|
||||
export function CommentForm({ errorId, isSaving, label, onSubmit, submitLabel }: { readonly errorId: string; readonly isSaving: boolean; readonly label: string; readonly onSubmit: (comment: string) => Promise<boolean>; readonly submitLabel: string }) {
|
||||
const [comment, setComment] = useState("");
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const trimmedComment = comment.trim();
|
||||
|
||||
async function submit() {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (trimmedComment.length === 0) {
|
||||
setErrorMessage("댓글 내용을 입력해 주세요.");
|
||||
queueMicrotask(() => focusFirstInvalidControl(formRef.current));
|
||||
return;
|
||||
}
|
||||
|
||||
const isSubmitted = await onSubmit(trimmedComment);
|
||||
if (isSubmitted) {
|
||||
setComment("");
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-2" onSubmit={(event) => { event.preventDefault(); void submit(); }} ref={formRef}>
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
{label}
|
||||
<textarea aria-describedby={errorMessage === null ? undefined : errorId} aria-invalid={errorMessage === null ? undefined : true} className="min-h-24 rounded-md border border-input bg-card px-3 py-2 text-base font-normal text-foreground" onChange={(event) => {
|
||||
const nextComment = event.currentTarget.value;
|
||||
if (nextComment.trim().length > 0) {
|
||||
setErrorMessage(null);
|
||||
}
|
||||
setComment(nextComment);
|
||||
}} value={comment} />
|
||||
</label>
|
||||
{errorMessage === null ? null : <p aria-label={`${label} 오류`} className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" id={errorId} role="alert">{errorMessage}</p>}
|
||||
<button className="w-fit rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving || trimmedComment.length === 0} type="submit">
|
||||
{submitLabel}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
47
src/features/comments/components/CommentItem.tsx
Normal file
47
src/features/comments/components/CommentItem.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import type { CommentRecord } from "@/features/comments/model/types";
|
||||
import { formatSeoulDateTime } from "@/shared/lib/formatters";
|
||||
|
||||
export function CommentItem({ canDelete = true, canEdit, comment, isSaving, onDelete, onEdit, onShowReplies, showRepliesButton }: { readonly canDelete?: boolean; readonly canEdit: boolean; readonly comment: CommentRecord; readonly isSaving: boolean; readonly onDelete: () => void; readonly onEdit: (comment: string) => void; readonly onShowReplies?: () => void; readonly showRepliesButton?: boolean }) {
|
||||
const [draft, setDraft] = useState(comment.comment);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const label = `${comment.comment}`;
|
||||
|
||||
function saveEdit() {
|
||||
const nextComment = draft.trim();
|
||||
if (nextComment.length === 0) {
|
||||
return;
|
||||
}
|
||||
onEdit(nextComment);
|
||||
setIsEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="rounded-lg border border-border bg-card p-3" aria-label={`${comment.nickname} 댓글`}>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">{comment.nickname}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatSeoulDateTime(comment.date)}{comment.isSecret ? " · 비밀" : ""}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{showRepliesButton === true && onShowReplies !== undefined ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onShowReplies} type="button">{label} 답글 보기</button> : null}
|
||||
{canEdit ? <button className="rounded-md border border-input bg-card px-3 py-2 text-sm font-semibold hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={() => setIsEditing(true)} type="button">{label} 수정</button> : null}
|
||||
{canDelete ? <button className="rounded-md border border-destructive bg-card px-3 py-2 text-sm font-semibold text-destructive hover:bg-accent disabled:opacity-60" disabled={isSaving} onClick={onDelete} type="button">{label} 삭제</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
{isEditing ? (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
||||
댓글 수정 내용
|
||||
<textarea className="min-h-24 rounded-md border border-input bg-card px-3 py-2 text-base font-normal" onChange={(event) => setDraft(event.currentTarget.value)} value={draft} />
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)] disabled:opacity-60" disabled={isSaving || draft.trim().length === 0} onClick={saveEdit} type="button">수정 저장</button>
|
||||
<button className="rounded-md border border-input bg-card px-4 py-2 font-semibold hover:bg-accent" onClick={() => { setDraft(comment.comment); setIsEditing(false); }} type="button">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
) : <p className="mt-3 break-words text-sm">{comment.comment}</p>}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
182
src/features/comments/components/CommentThread.tsx
Normal file
182
src/features/comments/components/CommentThread.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { createComment, deleteComment, getReplies, getRootComments, updateComment } from "@/features/comments/api/comment-api";
|
||||
import { CommentForm } from "@/features/comments/components/CommentForm";
|
||||
import { CommentItem } from "@/features/comments/components/CommentItem";
|
||||
import type { CommentPage, CommentRecord, CommentTarget } from "@/features/comments/model/types";
|
||||
import { ApiError } from "@/shared/api/api-error";
|
||||
import type { ApiClient } from "@/shared/api/client";
|
||||
import { PageState } from "@/shared/ui/page-state";
|
||||
import { ResourcePagination } from "@/shared/ui/resource-pagination";
|
||||
|
||||
type LoadState =
|
||||
| { readonly status: "loading" }
|
||||
| { readonly data: CommentPage; readonly status: "content" }
|
||||
| { readonly message: string; readonly status: "error" };
|
||||
|
||||
type ReplyEntry = {
|
||||
readonly page: number;
|
||||
readonly state: LoadState;
|
||||
};
|
||||
|
||||
type ReplyState = Record<number, ReplyEntry>;
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof ApiError ? error.message : "댓글을 처리하지 못했습니다.";
|
||||
}
|
||||
|
||||
function canEdit(comment: CommentRecord, target: CommentTarget): boolean {
|
||||
return comment.writerId === target.creatorId;
|
||||
}
|
||||
|
||||
export function CommentThread({ apiClient, canMutate = true, target }: { readonly apiClient: ApiClient; readonly canMutate?: boolean; readonly target: CommentTarget }) {
|
||||
const audioContentId = target.kind === "audio" ? target.contentId : "";
|
||||
const communityPostId = target.kind === "community" ? target.postId : "";
|
||||
const requestTarget = useMemo<CommentTarget>(() => target.kind === "audio"
|
||||
? { kind: "audio", characterId: target.characterId, contentId: audioContentId, creatorId: target.creatorId }
|
||||
: { kind: "community", characterId: target.characterId, postId: communityPostId, creatorId: target.creatorId }, [audioContentId, communityPostId, target.characterId, target.creatorId, target.kind]);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [expandedRootIds, setExpandedRootIds] = useState<readonly number[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [rootPage, setRootPage] = useState(0);
|
||||
const [rootReloadKey, setRootReloadKey] = useState(0);
|
||||
const [roots, setRoots] = useState<LoadState>({ status: "loading" });
|
||||
const [replies, setReplies] = useState<ReplyState>({});
|
||||
const isSavingRef = useRef(false);
|
||||
|
||||
const loadReplies = useCallback(async function loadRepliesForPage(rootId: number, page = 0): Promise<void> {
|
||||
setReplies((current) => ({ ...current, [rootId]: { page, state: { status: "loading" } } }));
|
||||
try {
|
||||
const data = await getReplies(apiClient, requestTarget, { commentId: rootId, page, size: pageSize });
|
||||
if (page > 0 && data.items.length === 0 && data.totalCount <= page * pageSize) {
|
||||
await loadRepliesForPage(rootId, page - 1);
|
||||
return;
|
||||
}
|
||||
setReplies((current) => ({ ...current, [rootId]: { page, state: { data, status: "content" } } }));
|
||||
} catch (error: unknown) {
|
||||
setReplies((current) => ({ ...current, [rootId]: { page, state: { message: getErrorMessage(error), status: "error" } } }));
|
||||
}
|
||||
}, [apiClient, requestTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
let isCurrent = true;
|
||||
setRoots((current) => current.status === "content" ? current : { status: "loading" });
|
||||
void getRootComments(apiClient, requestTarget, { page: rootPage, size: pageSize })
|
||||
.then((data) => {
|
||||
if (isCurrent) {
|
||||
setRoots({ data, status: "content" });
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (isCurrent) {
|
||||
setRoots({ message: getErrorMessage(error), status: "error" });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
isCurrent = false;
|
||||
};
|
||||
}, [apiClient, requestTarget, rootPage, rootReloadKey]);
|
||||
|
||||
async function runMutation(work: () => Promise<null>, rootId?: number): Promise<boolean> {
|
||||
if (isSavingRef.current) {
|
||||
return false;
|
||||
}
|
||||
isSavingRef.current = true;
|
||||
setIsSaving(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await work();
|
||||
setRootReloadKey((key) => key + 1);
|
||||
if (rootId !== undefined) {
|
||||
await loadReplies(rootId, replies[rootId]?.page ?? 0);
|
||||
}
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
setErrorMessage(getErrorMessage(error));
|
||||
return false;
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createRoot(comment: string): Promise<boolean> {
|
||||
if (requestTarget.kind === "audio") {
|
||||
return runMutation(() => createComment(apiClient, requestTarget, { comment, parentId: null, isSecret: false, languageCode: null }));
|
||||
}
|
||||
return runMutation(() => createComment(apiClient, requestTarget, { comment, parentId: null, isSecret: false }));
|
||||
}
|
||||
|
||||
async function createReply(rootId: number, comment: string): Promise<boolean> {
|
||||
if (requestTarget.kind === "audio") {
|
||||
return runMutation(() => createComment(apiClient, requestTarget, { comment, parentId: rootId, isSecret: false, languageCode: null }), rootId);
|
||||
}
|
||||
return runMutation(() => createComment(apiClient, requestTarget, { comment, parentId: rootId, isSecret: false }), rootId);
|
||||
}
|
||||
|
||||
function toggleReplies(rootId: number) {
|
||||
setExpandedRootIds((current) => current.includes(rootId) ? current.filter((id) => id !== rootId) : [...current, rootId]);
|
||||
if (replies[rootId] === undefined) {
|
||||
void loadReplies(rootId, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderReplies(root: CommentRecord) {
|
||||
if (!expandedRootIds.includes(root.id)) {
|
||||
return null;
|
||||
}
|
||||
const entry = replies[root.id] ?? { page: 0, state: { status: "loading" } };
|
||||
const state = entry.state;
|
||||
if (state.status === "loading") {
|
||||
return <PageState state="loading" title="답글을 불러오는 중" />;
|
||||
}
|
||||
if (state.status === "error") {
|
||||
return <PageState description={state.message} onRetry={() => void loadReplies(root.id)} state="error" title="답글 조회 실패" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label={`${root.comment} 답글`} className="ml-0 flex flex-col gap-3 border-l border-border pl-3 sm:ml-4">
|
||||
{canMutate ? <CommentForm errorId={`comment-reply-${root.id}-error`} isSaving={isSaving} label={`${root.comment}에 답글`} onSubmit={(comment) => createReply(root.id, comment)} submitLabel="답글 등록" /> : null}
|
||||
{state.data.items.map((reply) => (
|
||||
<CommentItem canDelete={canMutate} canEdit={canMutate && canEdit(reply, requestTarget)} comment={reply} isSaving={isSaving} key={reply.id} onDelete={() => void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: reply.id }), root.id)} onEdit={(comment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: reply.id, request: { comment } }), root.id)} />
|
||||
))}
|
||||
{state.data.totalCount > pageSize ? <ResourcePagination data={{ totalCount: state.data.totalCount, page: entry.page, size: pageSize, hasNext: (entry.page + 1) * pageSize < state.data.totalCount, items: state.data.items }} onPageChange={(page) => void loadReplies(root.id, page)} onSizeChange={() => void loadReplies(root.id, 0)} sizeOptions={[20]} /> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-4 rounded-lg border border-border bg-muted p-4" aria-labelledby="comment-thread-title">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-xs font-semibold text-info">COMMENTS</p>
|
||||
<h2 className="text-xl font-semibold" id="comment-thread-title">댓글 관리</h2>
|
||||
</div>
|
||||
{canMutate ? <CommentForm errorId="comment-root-error" isSaving={isSaving} label="새 댓글" onSubmit={createRoot} submitLabel="댓글 등록" /> : null}
|
||||
{errorMessage === null ? null : <p className="rounded-md border border-destructive bg-card p-3 text-sm font-semibold text-destructive" role="alert">{errorMessage}</p>}
|
||||
{isSaving ? <p className="rounded-md border border-border bg-card p-3 text-sm font-semibold" role="status">댓글을 저장하는 중</p> : null}
|
||||
{roots.status === "loading" ? <PageState state="loading" title="댓글을 불러오는 중" /> : null}
|
||||
{roots.status === "error" ? <PageState description={roots.message} onRetry={() => setRootReloadKey((key) => key + 1)} state="error" title="댓글 조회 실패" /> : null}
|
||||
{roots.status === "content" ? (
|
||||
<>
|
||||
<p className="text-sm font-semibold text-muted-foreground">총 {roots.data.totalCount.toLocaleString("ko-KR")}개</p>
|
||||
{roots.data.totalCount === 0 ? <PageState description="새 댓글을 등록해 첫 대화를 시작할 수 있습니다." state="empty" title="댓글이 없습니다" /> : (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
{roots.data.items.map((comment) => (
|
||||
<div className="flex flex-col gap-3" key={comment.id}>
|
||||
<CommentItem canDelete={canMutate} canEdit={canMutate && canEdit(comment, requestTarget)} comment={comment} isSaving={isSaving} onDelete={() => void runMutation(() => deleteComment(apiClient, requestTarget, { commentId: comment.id }))} onEdit={(nextComment) => void runMutation(() => updateComment(apiClient, requestTarget, { commentId: comment.id, request: { comment: nextComment } }))} onShowReplies={() => toggleReplies(comment.id)} showRepliesButton={comment.replyCount > 0 || expandedRootIds.includes(comment.id)} />
|
||||
{renderReplies(comment)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ResourcePagination data={{ totalCount: roots.data.totalCount, page: rootPage, size: pageSize, hasNext: (rootPage + 1) * pageSize < roots.data.totalCount, items: roots.data.items }} onPageChange={setRootPage} onSizeChange={() => setRootPage(0)} sizeOptions={[20]} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user