You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
142 lines
4.4 KiB
TypeScript
142 lines
4.4 KiB
TypeScript
// frontend/components/tasks/Comments.tsx
|
|
"use client";
|
|
import { useState } from "react";
|
|
import { Icon } from "@/components/Icon";
|
|
import type { Person, TaskComment } from "@/lib/types";
|
|
|
|
function relTime(iso: string): string {
|
|
if (!iso) return "방금";
|
|
const then = new Date(iso).getTime();
|
|
if (Number.isNaN(then)) return "방금";
|
|
const diff = Date.now() - then;
|
|
const m = Math.floor(diff / 60000);
|
|
if (m < 1) return "방금";
|
|
if (m < 60) return `${m}분 전`;
|
|
const h = Math.floor(m / 60);
|
|
if (h < 24) return `${h}시간 전`;
|
|
const d = Math.floor(h / 24);
|
|
if (d === 1) return "어제";
|
|
if (d < 7) return `${d}일 전`;
|
|
const dt = new Date(iso);
|
|
return `${dt.getMonth() + 1}/${dt.getDate()}`;
|
|
}
|
|
|
|
export function Comments({
|
|
list,
|
|
people,
|
|
me,
|
|
onAdd,
|
|
onEdit,
|
|
onDelete,
|
|
}: {
|
|
list: TaskComment[];
|
|
people: Record<string, Person>;
|
|
me: Person;
|
|
onAdd: (text: string) => void;
|
|
onEdit?: (cid: string, text: string) => void;
|
|
onDelete?: (cid: string) => void;
|
|
}) {
|
|
const [txt, setTxt] = useState("");
|
|
const [editId, setEditId] = useState<string | null>(null);
|
|
const [editTxt, setEditTxt] = useState("");
|
|
const submit = () => {
|
|
if (txt.trim()) {
|
|
onAdd(txt.trim());
|
|
setTxt("");
|
|
}
|
|
};
|
|
const startEdit = (c: TaskComment) => {
|
|
setEditId(c.id);
|
|
setEditTxt(c.text);
|
|
};
|
|
const saveEdit = () => {
|
|
if (editId && editTxt.trim()) onEdit?.(editId, editTxt.trim());
|
|
setEditId(null);
|
|
setEditTxt("");
|
|
};
|
|
return (
|
|
<div className="cmt">
|
|
<div className="cmt-list">
|
|
{list.length === 0 && (
|
|
<div className="cmt-empty">아직 코멘트가 없어요. 첫 의견을 남겨보세요.</div>
|
|
)}
|
|
{list.map((c) => {
|
|
const p = people[c.person_id] || me;
|
|
const mine = c.person_id === me.id;
|
|
const editing = editId === c.id;
|
|
return (
|
|
<div className="cmt-row" key={c.id}>
|
|
<div className="av" style={{ background: p.color }}>
|
|
{p.initial}
|
|
</div>
|
|
<div className="cmt-bub">
|
|
<div className="cmt-top">
|
|
<b>
|
|
{p.name}
|
|
{p.is_me ? " (나)" : ""}
|
|
</b>
|
|
<span className="t">
|
|
{relTime(c.created_at)}
|
|
{c.edited_at ? " · 수정됨" : ""}
|
|
</span>
|
|
{mine && onEdit && onDelete && !editing && (
|
|
<span className="cmt-acts">
|
|
<button onClick={() => startEdit(c)} aria-label="코멘트 수정">
|
|
<Icon name="pen" />
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(c.id)}
|
|
aria-label="코멘트 삭제"
|
|
className="danger"
|
|
>
|
|
<Icon name="trash" />
|
|
</button>
|
|
</span>
|
|
)}
|
|
</div>
|
|
{editing ? (
|
|
<div className="cmt-edit">
|
|
<input
|
|
value={editTxt}
|
|
autoFocus
|
|
onChange={(e) => setEditTxt(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") saveEdit();
|
|
if (e.key === "Escape") setEditId(null);
|
|
}}
|
|
/>
|
|
<button onClick={saveEdit} disabled={!editTxt.trim()}>
|
|
저장
|
|
</button>
|
|
<button className="ghost" onClick={() => setEditId(null)}>
|
|
취소
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="cmt-text">{c.text}</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<div className="cmt-compose">
|
|
<div className="av" style={{ background: me.color }}>
|
|
{me.initial}
|
|
</div>
|
|
<input
|
|
value={txt}
|
|
placeholder="코멘트 추가…"
|
|
onChange={(e) => setTxt(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") submit();
|
|
}}
|
|
/>
|
|
<button className="cmt-send" onClick={submit} disabled={!txt.trim()} aria-label="코멘트 등록">
|
|
<Icon name="send" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|