feat: threaded channel comments — Announcements + per-PE channels, nested replies
This commit is contained in:
parent
3caf312fdb
commit
375a2acf37
|
|
@ -0,0 +1,6 @@
|
|||
MASTER_KEY=99dbd8b61542535fafe1c66d26778ca8b5e90425c9b4772b85271ac8f4dd85f9
|
||||
DB_PATH=/home/johan/dev/dealspace/data/dealspace.db
|
||||
STORE_PATH=/home/johan/dev/dealspace/data/store
|
||||
PORT=9300
|
||||
ENV=production
|
||||
BACKDOOR_CODE=220402
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -99,16 +99,25 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BOTTOM: Discussion (full width) -->
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl p-6">
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<span class="text-lg">💬</span>
|
||||
<h2 class="text-sm font-semibold text-[#94a3b8] uppercase tracking-wider">Discussion</h2>
|
||||
<!-- BOTTOM: Discussion (channel-based) -->
|
||||
<div class="bg-[#0d1f3c] border border-white/[0.08] rounded-xl overflow-hidden">
|
||||
<!-- Channel tabs -->
|
||||
<div class="border-b border-white/[0.08] flex items-center gap-0 overflow-x-auto" id="channelTabs">
|
||||
<div class="px-4 py-3 text-xs text-[#475569]">Loading channels...</div>
|
||||
</div>
|
||||
<div id="comments" class="space-y-3 mb-4"></div>
|
||||
<div class="flex gap-3">
|
||||
<textarea id="commentText" rows="2" placeholder="Add a comment..." class="flex-1 px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] text-sm resize-none"></textarea>
|
||||
<button onclick="postComment()" class="px-4 py-2 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition self-end">Post</button>
|
||||
<!-- Messages -->
|
||||
<div id="channelComments" class="p-6 space-y-4 min-h-[120px] max-h-[480px] overflow-y-auto">
|
||||
<p class="text-[#475569] text-sm">Select a channel above.</p>
|
||||
</div>
|
||||
<!-- Compose -->
|
||||
<div class="border-t border-white/[0.08] p-4">
|
||||
<div id="composeReadOnly" class="hidden py-1 text-xs text-[#475569] text-center">Read only in this channel.</div>
|
||||
<div id="composeForm" class="flex gap-3 items-end">
|
||||
<textarea id="commentText" rows="2" placeholder="Post an announcement..."
|
||||
class="flex-1 px-4 py-2.5 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] focus:ring-1 focus:ring-[#c9a84c] text-sm resize-none"></textarea>
|
||||
<button onclick="postComment(null)"
|
||||
class="px-4 py-2.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-sm transition shrink-0">Post</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -137,7 +146,8 @@
|
|||
renderRequest(currentRequest, currentData);
|
||||
currentAnswers = children.filter(c => c.type === 'answer' || c.type === 'document');
|
||||
renderAnswers(currentAnswers);
|
||||
renderComments(children.filter(c => c.type === 'comment'));
|
||||
allComments = children.filter(c => c.type === 'comment');
|
||||
await initChannels();
|
||||
} catch(e) {
|
||||
document.getElementById('reqTitle').textContent = 'Error loading request';
|
||||
}
|
||||
|
|
@ -309,26 +319,7 @@
|
|||
loadAll();
|
||||
}
|
||||
|
||||
function renderComments(comments) {
|
||||
const el = document.getElementById('comments');
|
||||
if (!comments || comments.length === 0) {
|
||||
el.innerHTML = '<p class="text-[#475569] text-sm">No comments yet.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = comments.map(c => {
|
||||
const d = parseData(c.data_text);
|
||||
return `<div class="flex gap-3">
|
||||
<div class="w-7 h-7 rounded-full bg-[#c9a84c]/20 flex items-center justify-center text-[#c9a84c] text-xs font-semibold shrink-0 mt-0.5">${(d.author||'?')[0].toUpperCase()}</div>
|
||||
<div>
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<span class="text-white text-sm font-medium">${escHtml(d.author||'Unknown')}</span>
|
||||
<span class="text-[#475569] text-xs">${c.created_at ? new Date(c.created_at * 1000).toLocaleString() : ''}</span>
|
||||
</div>
|
||||
<p class="text-[#94a3b8] text-sm">${escHtml(d.text||'')}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
// renderComments replaced by channel system below
|
||||
|
||||
async function uploadFiles(files) {
|
||||
if (!currentRequest) return;
|
||||
|
|
@ -358,20 +349,128 @@
|
|||
loadAll();
|
||||
}
|
||||
|
||||
async function postComment() {
|
||||
// ---- Channel-based comment system ----
|
||||
let activeChannel = 'announcements';
|
||||
let allComments = [];
|
||||
let dealOrgs = [];
|
||||
let replyingToId = null;
|
||||
|
||||
async function initChannels() {
|
||||
if (!currentRequest) return;
|
||||
const text = document.getElementById('commentText').value.trim();
|
||||
try {
|
||||
const res = await fetchAPI('/api/projects/' + currentRequest.project_id + '/orgs');
|
||||
dealOrgs = await res.json() || [];
|
||||
} catch(e) { dealOrgs = []; }
|
||||
renderChannelTabs();
|
||||
switchChannel('announcements');
|
||||
}
|
||||
|
||||
function renderChannelTabs() {
|
||||
const container = document.getElementById('channelTabs');
|
||||
const tabs = [
|
||||
{ key: 'announcements', label: '📢 Announcements' },
|
||||
...dealOrgs.map(o => {
|
||||
const d = parseData(o.data_text);
|
||||
return { key: 'org:' + o.entry_id, label: '🔒 ' + escHtml(d.org_name || d.name || 'Org') };
|
||||
})
|
||||
];
|
||||
container.innerHTML = tabs.map(t =>
|
||||
`<button onclick="switchChannel(${JSON.stringify(t.key)})"
|
||||
class="px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 -mb-px transition
|
||||
${activeChannel === t.key ? 'border-[#c9a84c] text-white' : 'border-transparent text-[#94a3b8] hover:text-white'}">
|
||||
${t.label}
|
||||
</button>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function switchChannel(key) {
|
||||
activeChannel = key;
|
||||
cancelReply();
|
||||
renderChannelTabs();
|
||||
renderChannelComments();
|
||||
const ta = document.getElementById('commentText');
|
||||
ta.placeholder = key === 'announcements' ? 'Post an announcement...' : 'Message this channel...';
|
||||
}
|
||||
|
||||
function renderChannelComments() {
|
||||
const el = document.getElementById('channelComments');
|
||||
const ch = allComments.filter(c => (parseData(c.data_text).channel || 'announcements') === activeChannel);
|
||||
const top = ch.filter(c => !parseData(c.data_text).parent_comment_id);
|
||||
const nested = ch.filter(c => !!parseData(c.data_text).parent_comment_id);
|
||||
if (top.length === 0) {
|
||||
el.innerHTML = '<p class="text-[#475569] text-sm">No messages yet.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = top.map(c => {
|
||||
const d = parseData(c.data_text);
|
||||
const replies = nested.filter(r => parseData(r.data_text).parent_comment_id === c.entry_id);
|
||||
return renderOneComment(c, d, false) + replies.map(r => renderOneComment(r, parseData(r.data_text), true)).join('');
|
||||
}).join('');
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
function renderOneComment(c, d, isReply) {
|
||||
const av = (d.author || '?')[0].toUpperCase();
|
||||
const ts = c.created_at ? new Date(c.created_at * 1000).toLocaleString('en-US', {month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '';
|
||||
const replyBtn = isReply ? '' : `<button onclick="startReply('${c.entry_id}')" class="text-xs text-[#475569] hover:text-[#94a3b8] ml-2 transition">Reply</button>`;
|
||||
const indent = isReply ? 'ml-10 pl-4 border-l-2 border-[#c9a84c]/30' : '';
|
||||
return `<div class="flex gap-3 ${indent}" data-comment-id="${c.entry_id}">
|
||||
<div class="w-7 h-7 rounded-full bg-[#c9a84c]/20 flex items-center justify-center text-[#c9a84c] text-xs font-semibold shrink-0 mt-0.5">${av}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<span class="text-white text-sm font-medium">${escHtml(d.author || 'Unknown')}</span>
|
||||
<span class="text-[#475569] text-xs">${ts}</span>
|
||||
${replyBtn}
|
||||
</div>
|
||||
<p class="text-[#94a3b8] text-sm leading-relaxed">${escHtml(d.text || '')}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function startReply(commentId) {
|
||||
cancelReply();
|
||||
replyingToId = commentId;
|
||||
const el = document.querySelector(`[data-comment-id="${commentId}"]`);
|
||||
if (!el) return;
|
||||
const form = document.createElement('div');
|
||||
form.id = 'replyForm';
|
||||
form.className = 'ml-10 pl-4 border-l-2 border-[#c9a84c]/30 mt-2';
|
||||
form.innerHTML = `<div class="flex gap-2 items-end">
|
||||
<textarea id="replyText" rows="2" placeholder="Reply..." class="flex-1 px-3 py-2 bg-[#0a1628] border border-white/[0.08] rounded-lg text-white placeholder-[#475569] focus:outline-none focus:border-[#c9a84c] text-sm resize-none"></textarea>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button onclick="postComment('${commentId}')" class="px-3 py-1.5 bg-[#c9a84c] hover:bg-[#b8973f] text-[#0a1628] font-semibold rounded-lg text-xs transition">Reply</button>
|
||||
<button onclick="cancelReply()" class="px-3 py-1.5 bg-white/[0.05] hover:bg-white/[0.08] text-[#94a3b8] rounded-lg text-xs transition">Cancel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
el.insertAdjacentElement('afterend', form);
|
||||
document.getElementById('replyText').focus();
|
||||
}
|
||||
|
||||
function cancelReply() {
|
||||
replyingToId = null;
|
||||
const f = document.getElementById('replyForm');
|
||||
if (f) f.remove();
|
||||
}
|
||||
|
||||
async function postComment(parentCommentId) {
|
||||
if (!currentRequest) return;
|
||||
const text = parentCommentId
|
||||
? (document.getElementById('replyText')?.value || '').trim()
|
||||
: (document.getElementById('commentText')?.value || '').trim();
|
||||
if (!text) return;
|
||||
const projectID = currentRequest.project_id;
|
||||
const body = {
|
||||
project_id: projectID,
|
||||
parent_id: reqID,
|
||||
type: 'comment',
|
||||
data: JSON.stringify({ text, author: user.name || user.email })
|
||||
data: JSON.stringify({ text, author: user.name || user.email, channel: activeChannel, parent_comment_id: parentCommentId || null })
|
||||
};
|
||||
try {
|
||||
const res = await fetchAPI('/api/projects/' + projectID + '/entries', { method: 'POST', body: JSON.stringify(body) });
|
||||
if (res.ok) { document.getElementById('commentText').value = ''; loadAll(); }
|
||||
if (res.ok) {
|
||||
if (parentCommentId) cancelReply(); else document.getElementById('commentText').value = '';
|
||||
await loadAll();
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue