feat: threaded channel comments — Announcements + per-PE channels, nested replies

This commit is contained in:
James 2026-03-07 13:15:18 -05:00
parent 3caf312fdb
commit 375a2acf37
6 changed files with 139 additions and 34 deletions

6
.env Normal file
View File

@ -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

BIN
data/dealspace.db Normal file

Binary file not shown.

BIN
data/dealspace.db-shm Normal file

Binary file not shown.

BIN
data/dealspace.db-wal Normal file

Binary file not shown.

BIN
dealspace

Binary file not shown.

View File

@ -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">&#128172;</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) {}
}