feat(project): 세션 헤더 색상 채우기, 보드 댓글팝업, 채팅 첨부파일/이미지
- 세션 색상: 점 대신 세션 헤더 윗부분 배경을 통째로 칠함(글자/카운트 흰색으로) - 보드 업무 카드의 댓글 말풍선도 더블클릭하면 채팅 팝업 열리게(.pj-cmt-badge 공용) - 댓글 채팅 팝업에 파일/이미지 첨부 추가: 클립 버튼 업로드 + 클립보드 이미지 붙여넣기, 이미지는 썸네일, 파일은 확장자 아이콘+파일명으로 표시, 다운로드 가능, 업로드한 사람만 삭제(즉시 반영). task_attachments.comment_id(마이그 레이션 005)로 태깅해 업무 "첨부파일" 패널에도 그대로 함께 나온다. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+138
-11
@@ -195,6 +195,20 @@
|
||||
// getTaskTitle(taskId) — 채팅창 제목에 쓸 업무 제목을 페이지별 데이터에서
|
||||
// 찾아 돌려주는 콜백(홈은 homeTasks, 프로젝트 화면은 myTasksById/tasks).
|
||||
let cmtChat = null;
|
||||
// 확장자 → 이모지 아이콘(파일 종류 표시용). 이미지는 썸네일로 따로 보여주므로
|
||||
// 여기 없음.
|
||||
function chatFileIcon(filename) {
|
||||
const ext = String(filename || "").split(".").pop().toLowerCase();
|
||||
const map = {
|
||||
pdf: "📕", doc: "📄", docx: "📄", hwp: "📄", txt: "📃",
|
||||
xls: "📊", xlsx: "📊", csv: "📊",
|
||||
ppt: "📙", pptx: "📙",
|
||||
zip: "🗜️", rar: "🗜️", "7z": "🗜️",
|
||||
mp4: "🎬", mov: "🎬", avi: "🎬",
|
||||
mp3: "🎵", wav: "🎵",
|
||||
};
|
||||
return map[ext] || "📎";
|
||||
}
|
||||
function initCommentsChat(getTaskTitle) {
|
||||
const modal = el("pj-modal-comments");
|
||||
if (!modal) return null;
|
||||
@@ -215,13 +229,84 @@
|
||||
return ((wrap && wrap.dataset.me) || "").toLowerCase();
|
||||
}
|
||||
|
||||
// ── 보내기 전 대기 중인 파일(파일 선택/붙여넣기로 담은 것들) ──
|
||||
let pendingFiles = [];
|
||||
function renderPending() {
|
||||
const box = el("pj-cm-pending");
|
||||
if (!box) return;
|
||||
if (!pendingFiles.length) { box.hidden = true; box.innerHTML = ""; return; }
|
||||
box.hidden = false;
|
||||
box.innerHTML = pendingFiles.map(function (f, i) {
|
||||
const icon = f.type && f.type.indexOf("image/") === 0 ? "🖼️" : chatFileIcon(f.name);
|
||||
return '<span class="pj-chat-pending-chip">' +
|
||||
'<span class="pj-chat-att-icon">' + icon + "</span>" +
|
||||
'<span>' + escapeHtml(f.name) + "</span>" +
|
||||
'<button type="button" data-idx="' + i + '" title="빼기">×</button></span>';
|
||||
}).join("");
|
||||
box.querySelectorAll("button[data-idx]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
pendingFiles.splice(parseInt(btn.dataset.idx, 10), 1);
|
||||
renderPending();
|
||||
});
|
||||
});
|
||||
}
|
||||
const fileInput = el("pj-cm-file-input");
|
||||
if (fileInput) fileInput.addEventListener("change", function () {
|
||||
Array.prototype.slice.call(fileInput.files).forEach(function (f) { pendingFiles.push(f); });
|
||||
fileInput.value = "";
|
||||
renderPending();
|
||||
});
|
||||
const cmInput = el("pj-cm-input");
|
||||
if (cmInput) cmInput.addEventListener("paste", function (e) {
|
||||
const items = (e.clipboardData || window.clipboardData || {}).items || [];
|
||||
let handled = false;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type && items[i].type.indexOf("image/") === 0) {
|
||||
const file = items[i].getAsFile();
|
||||
if (file) { pendingFiles.push(file); handled = true; }
|
||||
}
|
||||
}
|
||||
if (handled) { e.preventDefault(); renderPending(); }
|
||||
});
|
||||
|
||||
// ── 댓글에 딸린 첨부(이미지/파일) 렌더 — 이미지는 썸네일, 그 외는 아이콘+파일명 ──
|
||||
function attachmentHtml(a, me) {
|
||||
const mine = (a.uploaded_by || "").toLowerCase() === me;
|
||||
const isImg = (a.content_type || "").indexOf("image/") === 0;
|
||||
const delBtn = mine
|
||||
? '<button type="button" class="pj-chat-att-del" data-id="' + a.id + '" title="삭제">' +
|
||||
'<span class="material-symbols-outlined">close</span></button>'
|
||||
: "";
|
||||
if (isImg) {
|
||||
return '<div class="pj-chat-att pj-chat-att-img" data-id="' + a.id + '">' +
|
||||
'<a href="/project/api/attachments/' + a.id + '/download" target="_blank" rel="noopener">' +
|
||||
'<img src="/project/api/attachments/' + a.id + '/inline" alt="' + escapeHtml(a.filename) + '" /></a>' +
|
||||
delBtn + "</div>";
|
||||
}
|
||||
return '<span class="pj-chat-att pj-chat-att-file" data-id="' + a.id + '">' +
|
||||
'<a href="/project/api/attachments/' + a.id + '/download">' +
|
||||
'<span class="pj-chat-att-icon">' + chatFileIcon(a.filename) + '</span>' +
|
||||
'<span class="pj-chat-att-name">' + escapeHtml(a.filename) + "</span></a>" +
|
||||
delBtn + "</span>";
|
||||
}
|
||||
|
||||
async function renderChat(taskId) {
|
||||
const me = currentUserEmail();
|
||||
const list = el("pj-chat-list");
|
||||
list.innerHTML = "<li class='pj-empty-sm'>불러오는 중…</li>";
|
||||
try {
|
||||
const res = await api("GET", "/project/api/tasks/" + taskId + "/comments");
|
||||
const comments = res.comments || [];
|
||||
const [cRes, aRes] = await Promise.all([
|
||||
api("GET", "/project/api/tasks/" + taskId + "/comments"),
|
||||
api("GET", "/project/api/tasks/" + taskId + "/attachments"),
|
||||
]);
|
||||
const comments = cRes.comments || [];
|
||||
// 댓글에 딸린 첨부만 채팅 말풍선에 표시(comment_id NULL = 설명란
|
||||
// 이미지/"첨부파일" 패널 전용이라 여기선 무시).
|
||||
const attByComment = {};
|
||||
(aRes.attachments || []).forEach(function (a) {
|
||||
if (a.comment_id == null) return;
|
||||
(attByComment[a.comment_id] = attByComment[a.comment_id] || []).push(a);
|
||||
});
|
||||
if (!comments.length) {
|
||||
list.innerHTML = "<li class='pj-empty-sm'>댓글 없음</li>";
|
||||
} else {
|
||||
@@ -230,17 +315,23 @@
|
||||
const mine = (c.author_email || "").toLowerCase() === me;
|
||||
const li = document.createElement("li");
|
||||
li.className = "pj-chat-msg" + (mine ? " mine" : "");
|
||||
const atts = attByComment[c.id] || [];
|
||||
const attsHtml = atts.length
|
||||
? '<div class="pj-chat-atts">' + atts.map(function (a) { return attachmentHtml(a, me); }).join("") + "</div>"
|
||||
: "";
|
||||
li.innerHTML =
|
||||
(mine ? "" : '<span class="pj-chat-name">' + escapeHtml(idOf(c.author_email)) + "</span>") +
|
||||
'<div class="pj-chat-bubble"></div>' +
|
||||
(c.body ? '<div class="pj-chat-bubble"></div>' : "") +
|
||||
attsHtml +
|
||||
'<span class="pj-chat-meta">' +
|
||||
(mine ? '<button type="button" class="pj-icon-btn pj-chat-del" title="삭제"><span class="material-symbols-outlined">close</span></button>' : "") +
|
||||
'<span class="pj-chat-time">' + (c.created_at || "").slice(0, 16).replace("T", " ") + "</span>" +
|
||||
"</span>";
|
||||
li.querySelector(".pj-chat-bubble").textContent = c.body;
|
||||
const bubble = li.querySelector(".pj-chat-bubble");
|
||||
if (bubble) bubble.textContent = c.body;
|
||||
if (mine) {
|
||||
li.querySelector(".pj-chat-del").addEventListener("click", async function () {
|
||||
if (!confirm("이 댓글을 삭제할까요?")) return;
|
||||
if (!confirm("이 댓글을 삭제할까요? 첨부된 파일도 함께 삭제됩니다.")) return;
|
||||
try {
|
||||
await api("DELETE", "/project/api/comments/" + c.id);
|
||||
const n = await renderChat(taskId);
|
||||
@@ -248,6 +339,16 @@
|
||||
} catch (e) { alert("삭제 실패: " + e.message); }
|
||||
});
|
||||
}
|
||||
li.querySelectorAll(".pj-chat-att-del").forEach(function (btn) {
|
||||
btn.addEventListener("click", async function (e) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
if (!confirm("이 파일을 삭제할까요?")) return;
|
||||
try {
|
||||
await api("DELETE", "/project/api/attachments/" + btn.dataset.id);
|
||||
await renderChat(taskId);
|
||||
} catch (err) { alert("삭제 실패: " + err.message); }
|
||||
});
|
||||
});
|
||||
list.appendChild(li);
|
||||
});
|
||||
list.scrollTop = list.scrollHeight;
|
||||
@@ -272,6 +373,8 @@
|
||||
async function open(taskId) {
|
||||
el("pj-cm-task-id").value = taskId;
|
||||
el("pj-cm-title").textContent = (getTaskTitle && getTaskTitle(taskId)) || "댓글";
|
||||
pendingFiles = [];
|
||||
renderPending();
|
||||
openModal(modal);
|
||||
const n = await renderChat(taskId);
|
||||
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
|
||||
@@ -294,10 +397,28 @@
|
||||
el("pj-cm-send").addEventListener("click", async function () {
|
||||
const taskId = el("pj-cm-task-id").value;
|
||||
const inp = el("pj-cm-input");
|
||||
const body = inp.value.trim();
|
||||
if (!taskId || !body) return;
|
||||
const bodyText = inp.value.trim();
|
||||
if (!taskId || (!bodyText && !pendingFiles.length)) return;
|
||||
const filesToSend = pendingFiles.slice();
|
||||
try {
|
||||
await api("POST", "/project/api/tasks/" + taskId + "/comments", { body: body });
|
||||
const res = await api("POST", "/project/api/tasks/" + taskId + "/comments",
|
||||
{ body: bodyText, has_attachment: filesToSend.length > 0 });
|
||||
const commentId = res.comment.id;
|
||||
for (let i = 0; i < filesToSend.length; i++) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", filesToSend[i]);
|
||||
fd.append("comment_id", String(commentId));
|
||||
try {
|
||||
const r = await fetch("/project/api/tasks/" + taskId + "/attachments", { method: "POST", body: fd });
|
||||
if (!r.ok) {
|
||||
let m = r.statusText;
|
||||
try { m = (await r.json()).detail || m; } catch (_) {}
|
||||
showToast("파일 업로드 실패(" + filesToSend[i].name + "): " + m, "error");
|
||||
}
|
||||
} catch (_) { showToast("파일 업로드 실패: " + filesToSend[i].name, "error"); }
|
||||
}
|
||||
pendingFiles = [];
|
||||
renderPending();
|
||||
inp.value = "";
|
||||
const n = await renderChat(taskId);
|
||||
if (n !== null) { markSeen(taskId, n); syncBadge(taskId, n); }
|
||||
@@ -2013,8 +2134,9 @@
|
||||
col.className = "pj-col" + (s.is_done_stage ? " pj-col-done" : "");
|
||||
col.dataset.stageId = s.id;
|
||||
col.innerHTML =
|
||||
'<div class="pj-col-head"' + (canManage ? ' draggable="true"' : "") + '>' +
|
||||
(s.color ? '<span class="pj-col-dot" style="background:' + s.color + '"></span>' : "") +
|
||||
'<div class="pj-col-head' + (s.color ? " pj-col-head-colored" : "") + '"' +
|
||||
(canManage ? ' draggable="true"' : "") +
|
||||
(s.color ? ' style="background:' + s.color + '"' : "") + '>' +
|
||||
'<span class="pj-col-name">' + escapeHtml(s.name) + "</span>" +
|
||||
(s.is_done_stage ? '<span class="pj-chip pj-chip-sm">완료</span>' : "") +
|
||||
'<span class="pj-col-count"></span>' +
|
||||
@@ -2238,9 +2360,14 @@
|
||||
? '<span class="pj-card-subtasks" title="하위 업무">' + (t.subtask_done || 0) + "/" + t.subtask_total + "</span>"
|
||||
: "") +
|
||||
(t.due_date ? '<span class="pj-card-due">' + t.due_date + "</span>" : "") +
|
||||
(t.comment_count ? '<span class="pj-card-cmt">' + cmtBadge(t) + "</span>" : "") +
|
||||
(t.comment_count
|
||||
? '<span class="pj-card-cmt pj-cmt-badge" data-task-id="' + t.id + '" data-count="' + t.comment_count +
|
||||
'" title="댓글 ' + t.comment_count + '개">' + cmtBadge(t) + "</span>"
|
||||
: "") +
|
||||
"</div>";
|
||||
card.addEventListener("click", function () { if (canManage) openTaskModal(t); });
|
||||
// 댓글 말풍선 더블클릭 → 채팅형 댓글 팝업(내 업무 패널과 동일 구현 공용).
|
||||
if (cmt) cmt.wireBadge(card.querySelector(".pj-cmt-badge"));
|
||||
if (canManage) {
|
||||
card.addEventListener("dragstart", function (e) {
|
||||
e.dataTransfer.setData("text/plain", String(t.id));
|
||||
|
||||
Reference in New Issue
Block a user