feat: polish multi-account dashboard and viewer
This commit is contained in:
+348
-256
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -51,7 +51,9 @@
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h2>No Accounts Configured</h2>
|
||||
<p class="muted">Click "Add Account" in Settings to get started, or use the legacy single-account mode.</p>
|
||||
<p class="muted">
|
||||
Click "Add Account" in Settings to get started, or use the legacy single-account mode.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
+30
-15
@@ -17,8 +17,8 @@
|
||||
--info: #8ec7ff;
|
||||
--radius: 18px;
|
||||
--shadow: none;
|
||||
--font-ui: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||
--font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
|
||||
--font-ui: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -129,7 +129,10 @@ input,
|
||||
min-height: 42px;
|
||||
padding: 9px 13px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
|
||||
transition:
|
||||
background-color 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.nav-link.active,
|
||||
@@ -301,7 +304,7 @@ input:focus {
|
||||
}
|
||||
|
||||
.switch-slider::before {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
@@ -309,7 +312,9 @@ input:focus {
|
||||
height: 16px;
|
||||
background: var(--dim);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.16s ease, background-color 0.16s ease;
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.switch input:checked + .switch-slider {
|
||||
@@ -628,9 +633,12 @@ tbody tr:hover {
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at 20px 20px, rgba(255, 255, 255, 0.025) 1px, transparent 1.5px),
|
||||
radial-gradient(circle at 58px 54px, rgba(255, 255, 255, 0.018) 1px, transparent 1.5px),
|
||||
#000;
|
||||
background-size: auto, 84px 84px, 84px 84px, auto;
|
||||
radial-gradient(circle at 58px 54px, rgba(255, 255, 255, 0.018) 1px, transparent 1.5px), #000;
|
||||
background-size:
|
||||
auto,
|
||||
84px 84px,
|
||||
84px 84px,
|
||||
auto;
|
||||
}
|
||||
|
||||
.viewer-header {
|
||||
@@ -659,7 +667,7 @@ tbody tr:hover {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.viewer-tools input[type="search"] {
|
||||
.viewer-tools input[type='search'] {
|
||||
min-width: min(260px, 40vw);
|
||||
}
|
||||
|
||||
@@ -705,7 +713,7 @@ tbody tr:hover {
|
||||
|
||||
.chat-date-sep::before,
|
||||
.chat-date-sep::after {
|
||||
content: "";
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: #242424;
|
||||
@@ -750,7 +758,7 @@ tbody tr:hover {
|
||||
}
|
||||
|
||||
.chat-bubble::before {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
border: 6px solid transparent;
|
||||
@@ -779,8 +787,13 @@ tbody tr:hover {
|
||||
}
|
||||
|
||||
@keyframes chat-highlight {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(139, 180, 248, 0); }
|
||||
50% { box-shadow: 0 0 0 3px rgba(139, 180, 248, 0.4); }
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(139, 180, 248, 0);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px rgba(139, 180, 248, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Sender ---------- */
|
||||
@@ -912,7 +925,9 @@ tbody tr:hover {
|
||||
background: #111;
|
||||
color: var(--text-color);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.36);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
@@ -1151,7 +1166,7 @@ tbody tr:hover {
|
||||
}
|
||||
|
||||
.viewer-sidebar.open::before {
|
||||
content: "";
|
||||
content: '';
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
+5
-2
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -13,7 +13,10 @@
|
||||
<div>
|
||||
<div class="eyebrow">Export Viewer</div>
|
||||
<h1>Messages</h1>
|
||||
<p class="muted small">Account: <select id="viewer-account-select"></select></p>
|
||||
<p class="muted small">
|
||||
Account:
|
||||
<select id="viewer-account-select"></select>
|
||||
</p>
|
||||
</div>
|
||||
<button id="sidebar-toggle" class="button button-small sidebar-toggle">☰</button>
|
||||
<a class="button button-small" href="/">Dashboard</a>
|
||||
|
||||
+147
-166
@@ -1,4 +1,4 @@
|
||||
if ("scrollRestoration" in history) history.scrollRestoration = "manual";
|
||||
if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
|
||||
|
||||
const viewerState = {
|
||||
accountId: null,
|
||||
@@ -8,7 +8,7 @@ const viewerState = {
|
||||
oldestMessageId: null,
|
||||
newestMessageId: null,
|
||||
userId: null,
|
||||
search: "",
|
||||
search: '',
|
||||
loading: false,
|
||||
autoRefreshTimer: null,
|
||||
sentinelObserver: null,
|
||||
@@ -18,135 +18,139 @@ async function api(path) {
|
||||
const response = await fetch(path);
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || "Request failed");
|
||||
throw new Error(data.error || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function showToast(message, type = "info") {
|
||||
let root = document.getElementById("toast-root");
|
||||
function showToast(message, type = 'info') {
|
||||
let root = document.getElementById('toast-root');
|
||||
if (!root) {
|
||||
root = document.createElement("div");
|
||||
root.id = "toast-root";
|
||||
root.className = "toast-root";
|
||||
root = document.createElement('div');
|
||||
root.id = 'toast-root';
|
||||
root.className = 'toast-root';
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
const toast = document.createElement("div");
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
root.appendChild(toast);
|
||||
window.setTimeout(() => {
|
||||
toast.classList.add("toast-hide");
|
||||
toast.classList.add('toast-hide');
|
||||
window.setTimeout(() => toast.remove(), 250);
|
||||
}, 3600);
|
||||
}
|
||||
|
||||
function formatDateHeader(dateStr) {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr.replace(" ", "T"));
|
||||
if (isNaN(d.getTime())) return "";
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr.replace(' ', 'T'));
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const now = new Date();
|
||||
const diff = now - d;
|
||||
const oneDay = 86400000;
|
||||
if (diff < oneDay && d.getDate() === now.getDate()) return "Today";
|
||||
if (diff < 2 * oneDay && d.getDate() === now.getDate() - 1) return "Yesterday";
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
if (diff < oneDay && d.getDate() === now.getDate()) return 'Today';
|
||||
if (diff < 2 * oneDay && d.getDate() === now.getDate() - 1) return 'Yesterday';
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function formatTime(dateStr) {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr.replace(" ", "T"));
|
||||
if (isNaN(d.getTime())) return "";
|
||||
return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr.replace(' ', 'T'));
|
||||
if (isNaN(d.getTime())) return '';
|
||||
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function chatListTime(dateStr) {
|
||||
if (!dateStr) return "";
|
||||
const d = new Date(dateStr.replace(" ", "T"));
|
||||
if (isNaN(d.getTime())) return "";
|
||||
if (!dateStr) return '';
|
||||
const d = new Date(dateStr.replace(' ', 'T'));
|
||||
if (isNaN(d.getTime())) return '';
|
||||
const now = new Date();
|
||||
if (d.toDateString() === now.toDateString()) {
|
||||
return d.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" });
|
||||
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function initials(value) {
|
||||
const source = String(value || "?").replace(/^@/, "").trim();
|
||||
const source = String(value || '?')
|
||||
.replace(/^@/, '')
|
||||
.trim();
|
||||
const words = source.split(/\s+/).filter(Boolean);
|
||||
if (words.length > 1) return (words[0][0] + words[1][0]).toUpperCase();
|
||||
return source.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function dateKey(dateStr) {
|
||||
if (!dateStr) return "";
|
||||
if (!dateStr) return '';
|
||||
return dateStr.slice(0, 10);
|
||||
}
|
||||
|
||||
function textNodeWithBreaks(text) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
if (!text) return fragment;
|
||||
const parts = text.split("\n");
|
||||
const parts = text.split('\n');
|
||||
parts.forEach((part, index) => {
|
||||
if (index > 0) fragment.appendChild(document.createElement("br"));
|
||||
if (index > 0) fragment.appendChild(document.createElement('br'));
|
||||
fragment.appendChild(document.createTextNode(part));
|
||||
});
|
||||
return fragment;
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
const list = document.getElementById("messages-list");
|
||||
const list = document.getElementById('messages-list');
|
||||
if (!list) return;
|
||||
const go = () => { list.scrollTop = list.scrollHeight; };
|
||||
const go = () => {
|
||||
list.scrollTop = list.scrollHeight;
|
||||
};
|
||||
go();
|
||||
requestAnimationFrame(go);
|
||||
setTimeout(go, 150);
|
||||
}
|
||||
|
||||
function makeScrollSentinel() {
|
||||
const sentinel = document.createElement("div");
|
||||
sentinel.id = "scroll-sentinel";
|
||||
sentinel.className = "scroll-sentinel";
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.id = 'scroll-sentinel';
|
||||
sentinel.className = 'scroll-sentinel';
|
||||
return sentinel;
|
||||
}
|
||||
|
||||
function resetMessageView(title = "Select a channel", subtitle = "Reading messages from the local SQLite database.") {
|
||||
const root = document.getElementById("messages-list");
|
||||
function resetMessageView(title = 'Select a channel', subtitle = 'Reading messages from the local SQLite database.') {
|
||||
const root = document.getElementById('messages-list');
|
||||
if (root) {
|
||||
root.innerHTML = "";
|
||||
root.innerHTML = '';
|
||||
root.appendChild(makeScrollSentinel());
|
||||
}
|
||||
document.getElementById("viewer-title").textContent = title;
|
||||
document.getElementById("viewer-subtitle").textContent = subtitle;
|
||||
document.getElementById('viewer-title').textContent = title;
|
||||
document.getElementById('viewer-subtitle').textContent = subtitle;
|
||||
viewerState.oldestMessageId = null;
|
||||
viewerState.newestMessageId = null;
|
||||
}
|
||||
|
||||
function renderChannelList() {
|
||||
const root = document.getElementById("viewer-channel-list");
|
||||
const root = document.getElementById('viewer-channel-list');
|
||||
if (!root) return;
|
||||
root.innerHTML = "";
|
||||
const template = document.getElementById("viewer-channel-template");
|
||||
root.innerHTML = '';
|
||||
const template = document.getElementById('viewer-channel-template');
|
||||
if (!template) return;
|
||||
viewerState.channels.forEach((channel) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
const preview =
|
||||
channel.last_message_preview ||
|
||||
(channel.has_database ? "No text in the last saved message" : "No local database yet");
|
||||
node.querySelector(".viewer-channel-avatar").textContent = initials(channel.name || channel.channel_id);
|
||||
node.querySelector(".viewer-channel-name").textContent = channel.name;
|
||||
node.querySelector(".viewer-channel-time").textContent = chatListTime(channel.last_date);
|
||||
node.querySelector(".viewer-channel-preview").textContent = preview;
|
||||
node.querySelector(".viewer-channel-count").textContent = String(channel.message_count || 0);
|
||||
(channel.has_database ? 'No text in the last saved message' : 'No local database yet');
|
||||
node.querySelector('.viewer-channel-avatar').textContent = initials(channel.name || channel.channel_id);
|
||||
node.querySelector('.viewer-channel-name').textContent = channel.name;
|
||||
node.querySelector('.viewer-channel-time').textContent = chatListTime(channel.last_date);
|
||||
node.querySelector('.viewer-channel-preview').textContent = preview;
|
||||
node.querySelector('.viewer-channel-count').textContent = String(channel.message_count || 0);
|
||||
if (channel.channel_id === viewerState.channelId) {
|
||||
node.classList.add("active");
|
||||
node.classList.add('active');
|
||||
}
|
||||
node.addEventListener("click", () => {
|
||||
document.querySelector(".viewer-sidebar")?.classList.remove("open");
|
||||
node.addEventListener('click', () => {
|
||||
document.querySelector('.viewer-sidebar')?.classList.remove('open');
|
||||
viewerState.oldestMessageId = null;
|
||||
viewerState.newestMessageId = null;
|
||||
loadChannel(channel.channel_id);
|
||||
@@ -155,10 +159,8 @@ function renderChannelList() {
|
||||
});
|
||||
}
|
||||
|
||||
function messageEndpoint(channelId, before = "") {
|
||||
const search = viewerState.search
|
||||
? `&search=${encodeURIComponent(viewerState.search)}`
|
||||
: "";
|
||||
function messageEndpoint(channelId, before = '') {
|
||||
const search = viewerState.search ? `&search=${encodeURIComponent(viewerState.search)}` : '';
|
||||
if (viewerState.accountId) {
|
||||
return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}${search}`;
|
||||
}
|
||||
@@ -169,22 +171,22 @@ function channelsEndpoint() {
|
||||
if (viewerState.accountId) {
|
||||
return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels`;
|
||||
}
|
||||
return "/api/channels";
|
||||
return '/api/channels';
|
||||
}
|
||||
|
||||
function renderAccountSelector() {
|
||||
const sel = document.getElementById("viewer-account-select");
|
||||
const sel = document.getElementById('viewer-account-select');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = "";
|
||||
sel.innerHTML = '';
|
||||
const legacy = viewerState.accounts.length === 0;
|
||||
if (legacy) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = "Legacy";
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = 'Legacy';
|
||||
sel.appendChild(opt);
|
||||
} else {
|
||||
viewerState.accounts.forEach((acc) => {
|
||||
const opt = document.createElement("option");
|
||||
const opt = document.createElement('option');
|
||||
opt.value = acc.id;
|
||||
opt.textContent = acc.label || acc.id;
|
||||
if (acc.id === viewerState.accountId) opt.selected = true;
|
||||
@@ -195,12 +197,12 @@ function renderAccountSelector() {
|
||||
|
||||
async function switchViewerAccount(accountId) {
|
||||
viewerState.accountId = accountId || null;
|
||||
viewerState.search = "";
|
||||
const searchInput = document.getElementById("viewer-search");
|
||||
if (searchInput) searchInput.value = "";
|
||||
viewerState.search = '';
|
||||
const searchInput = document.getElementById('viewer-search');
|
||||
if (searchInput) searchInput.value = '';
|
||||
viewerState.accounts.forEach((acc) => {
|
||||
if (acc.id === accountId) {
|
||||
const opts = document.getElementById("viewer-account-select")?.options;
|
||||
const opts = document.getElementById('viewer-account-select')?.options;
|
||||
if (opts) {
|
||||
for (let i = 0; i < opts.length; i++) {
|
||||
opts[i].selected = opts[i].value === accountId;
|
||||
@@ -211,10 +213,10 @@ async function switchViewerAccount(accountId) {
|
||||
try {
|
||||
const authData = viewerState.accountId
|
||||
? await api(`/api/accounts/${encodeURIComponent(viewerState.accountId)}/auth`)
|
||||
: await api("/api/auth");
|
||||
: await api('/api/auth');
|
||||
viewerState.userId = authData.user_id || null;
|
||||
} catch (e) {
|
||||
console.warn("Could not fetch user ID:", e);
|
||||
console.warn('Could not fetch user ID:', e);
|
||||
}
|
||||
const channels = await api(channelsEndpoint());
|
||||
viewerState.channels = channels;
|
||||
@@ -226,99 +228,93 @@ async function switchViewerAccount(accountId) {
|
||||
await loadChannel(viewerState.channelId);
|
||||
} else {
|
||||
resetMessageView(
|
||||
"No channels",
|
||||
viewerState.accountId
|
||||
? "This account has no tracked channels yet."
|
||||
: "No local legacy channels found."
|
||||
'No channels',
|
||||
viewerState.accountId ? 'This account has no tracked channels yet.' : 'No local legacy channels found.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadViewerAccount(params) {
|
||||
const requested = params.get("account");
|
||||
const requested = params.get('account');
|
||||
try {
|
||||
const payload = await api("/api/accounts");
|
||||
const payload = await api('/api/accounts');
|
||||
viewerState.accounts = payload.accounts || [];
|
||||
} catch {
|
||||
viewerState.accounts = [];
|
||||
}
|
||||
const requestedExists = requested && viewerState.accounts.some((acc) => acc.id === requested);
|
||||
viewerState.accountId = requestedExists
|
||||
? requested
|
||||
: viewerState.accounts[0]?.id || null;
|
||||
viewerState.accountId = requestedExists ? requested : viewerState.accounts[0]?.id || null;
|
||||
|
||||
renderAccountSelector();
|
||||
|
||||
const sel = document.getElementById("viewer-account-select");
|
||||
const sel = document.getElementById('viewer-account-select');
|
||||
if (sel) {
|
||||
sel.addEventListener("change", () => switchViewerAccount(sel.value));
|
||||
sel.addEventListener('change', () => switchViewerAccount(sel.value));
|
||||
}
|
||||
|
||||
try {
|
||||
const authData = viewerState.accountId
|
||||
? await api(`/api/accounts/${encodeURIComponent(viewerState.accountId)}/auth`)
|
||||
: await api("/api/auth");
|
||||
: await api('/api/auth');
|
||||
viewerState.userId = authData.user_id || null;
|
||||
} catch (e) {
|
||||
console.warn("Could not fetch user ID:", e);
|
||||
console.warn('Could not fetch user ID:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function buildMessageNode(message, root) {
|
||||
const template = document.getElementById("message-template");
|
||||
const template = document.getElementById('message-template');
|
||||
const wrap = template.content.firstElementChild.cloneNode(true);
|
||||
const bubble = wrap.querySelector(".chat-bubble");
|
||||
const bubble = wrap.querySelector('.chat-bubble');
|
||||
|
||||
bubble.dataset.messageId = String(message.message_id);
|
||||
|
||||
const isOwn = viewerState.userId && message.sender_id === viewerState.userId;
|
||||
if (isOwn) {
|
||||
wrap.classList.add("chat-bubble-wrap--own");
|
||||
wrap.classList.add('chat-bubble-wrap--own');
|
||||
}
|
||||
|
||||
const sender = bubble.querySelector(".chat-sender");
|
||||
sender.textContent = message.sender_name || "";
|
||||
const sender = bubble.querySelector('.chat-sender');
|
||||
sender.textContent = message.sender_name || '';
|
||||
|
||||
const replyEl = bubble.querySelector(".chat-reply");
|
||||
const replyEl = bubble.querySelector('.chat-reply');
|
||||
const replyMsg = message.reply_to_message;
|
||||
if (message.reply_to) {
|
||||
replyEl.classList.remove("hidden");
|
||||
bubble.querySelector(".chat-reply-author").textContent =
|
||||
replyMsg?.sender_name || `#${message.reply_to}`;
|
||||
bubble.querySelector(".chat-reply-text").textContent =
|
||||
replyMsg?.text || "(message not available)";
|
||||
replyEl.addEventListener("click", () => {
|
||||
replyEl.classList.remove('hidden');
|
||||
bubble.querySelector('.chat-reply-author').textContent = replyMsg?.sender_name || `#${message.reply_to}`;
|
||||
bubble.querySelector('.chat-reply-text').textContent = replyMsg?.text || '(message not available)';
|
||||
replyEl.addEventListener('click', () => {
|
||||
const target = root.querySelector(`[data-message-id="${message.reply_to}"]`);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
target.classList.add("chat-bubble--highlight");
|
||||
setTimeout(() => target.classList.remove("chat-bubble--highlight"), 2000);
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
target.classList.add('chat-bubble--highlight');
|
||||
setTimeout(() => target.classList.remove('chat-bubble--highlight'), 2000);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
replyEl.classList.add("hidden");
|
||||
replyEl.classList.add('hidden');
|
||||
}
|
||||
|
||||
bubble.querySelector(".chat-text").appendChild(textNodeWithBreaks(message.text || ""));
|
||||
bubble.querySelector('.chat-text').appendChild(textNodeWithBreaks(message.text || ''));
|
||||
|
||||
const media = bubble.querySelector(".chat-media");
|
||||
if (message.media_url && message.media_kind === "image") {
|
||||
const img = document.createElement("img");
|
||||
const media = bubble.querySelector('.chat-media');
|
||||
if (message.media_url && message.media_kind === 'image') {
|
||||
const img = document.createElement('img');
|
||||
img.src = message.media_url;
|
||||
img.loading = "lazy";
|
||||
img.loading = 'lazy';
|
||||
media.appendChild(img);
|
||||
} else if (message.media_url && message.media_kind === "video") {
|
||||
const video = document.createElement("video");
|
||||
} else if (message.media_url && message.media_kind === 'video') {
|
||||
const video = document.createElement('video');
|
||||
video.src = message.media_url;
|
||||
video.controls = true;
|
||||
video.preload = "metadata";
|
||||
video.preload = 'metadata';
|
||||
media.appendChild(video);
|
||||
} else if (message.media_url) {
|
||||
const link = document.createElement("a");
|
||||
const link = document.createElement('a');
|
||||
link.href = message.media_url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noreferrer";
|
||||
link.textContent = "Open file";
|
||||
link.target = '_blank';
|
||||
link.rel = 'noreferrer';
|
||||
link.textContent = 'Open file';
|
||||
media.appendChild(link);
|
||||
}
|
||||
|
||||
@@ -327,41 +323,40 @@ function buildMessageNode(message, root) {
|
||||
if (message.views) footerParts.push(`views: ${message.views}`);
|
||||
if (message.forwards) footerParts.push(`forwards: ${message.forwards}`);
|
||||
if (message.reactions) footerParts.push(`reactions: ${message.reactions}`);
|
||||
bubble.querySelector(".chat-footer").textContent = footerParts.join(" | ");
|
||||
bubble.querySelector('.chat-footer').textContent = footerParts.join(' | ');
|
||||
|
||||
return { wrap, dateKey: dateKey(message.date) };
|
||||
}
|
||||
|
||||
function makeDateSep(text) {
|
||||
const el = document.createElement("div");
|
||||
el.className = "chat-date-sep";
|
||||
const el = document.createElement('div');
|
||||
el.className = 'chat-date-sep';
|
||||
el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
function renderMessages(payload, append = false) {
|
||||
const root = document.getElementById("messages-list");
|
||||
const root = document.getElementById('messages-list');
|
||||
if (!root) return;
|
||||
let sentinel = document.getElementById("scroll-sentinel");
|
||||
let sentinel = document.getElementById('scroll-sentinel');
|
||||
if (!sentinel) sentinel = makeScrollSentinel();
|
||||
|
||||
const channel = payload.channel;
|
||||
document.getElementById("viewer-title").textContent =
|
||||
channel?.name || payload.channel_id;
|
||||
document.getElementById("viewer-subtitle").textContent = channel
|
||||
? `${payload.messages.length} shown${viewerState.search ? ` for "${viewerState.search}"` : ""}, ${channel.message_count} total, latest: ${channel.last_date || "-"}`
|
||||
: "No local database found for this channel yet.";
|
||||
document.getElementById('viewer-title').textContent = channel?.name || payload.channel_id;
|
||||
document.getElementById('viewer-subtitle').textContent = channel
|
||||
? `${payload.messages.length} shown${viewerState.search ? ` for "${viewerState.search}"` : ''}, ${channel.message_count} total, latest: ${channel.last_date || '-'}`
|
||||
: 'No local database found for this channel yet.';
|
||||
|
||||
if (!append) {
|
||||
root.innerHTML = "";
|
||||
root.innerHTML = '';
|
||||
root.appendChild(sentinel);
|
||||
}
|
||||
|
||||
let lastKey = null;
|
||||
if (append) {
|
||||
const firstExisting = root.querySelector("[data-message-id]");
|
||||
const firstExisting = root.querySelector('[data-message-id]');
|
||||
if (firstExisting) {
|
||||
const candidate = firstExisting.closest(".chat-bubble-wrap");
|
||||
const candidate = firstExisting.closest('.chat-bubble-wrap');
|
||||
if (candidate && candidate._dateKey) lastKey = candidate._dateKey;
|
||||
}
|
||||
}
|
||||
@@ -395,16 +390,15 @@ function renderMessages(payload, append = false) {
|
||||
|
||||
if (payload.messages.length) {
|
||||
viewerState.oldestMessageId = payload.messages[0].message_id;
|
||||
viewerState.newestMessageId =
|
||||
payload.messages[payload.messages.length - 1].message_id;
|
||||
viewerState.newestMessageId = payload.messages[payload.messages.length - 1].message_id;
|
||||
} else if (!append) {
|
||||
viewerState.oldestMessageId = null;
|
||||
viewerState.newestMessageId = null;
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "viewer-empty-state";
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'viewer-empty-state';
|
||||
empty.textContent = viewerState.search
|
||||
? `No messages found for "${viewerState.search}".`
|
||||
: "No saved messages in this channel yet.";
|
||||
: 'No saved messages in this channel yet.';
|
||||
root.appendChild(empty);
|
||||
}
|
||||
|
||||
@@ -419,14 +413,11 @@ async function loadChannel(channelId, append = false) {
|
||||
viewerState.channelId = channelId;
|
||||
renderChannelList();
|
||||
|
||||
const before =
|
||||
append && viewerState.oldestMessageId
|
||||
? `&before=${viewerState.oldestMessageId}`
|
||||
: "";
|
||||
const before = append && viewerState.oldestMessageId ? `&before=${viewerState.oldestMessageId}` : '';
|
||||
try {
|
||||
const payload = await api(messageEndpoint(channelId, before));
|
||||
if (append) {
|
||||
const list = document.getElementById("messages-list");
|
||||
const list = document.getElementById('messages-list');
|
||||
const prevScrollHeight = list.scrollHeight;
|
||||
renderMessages(payload, true);
|
||||
requestAnimationFrame(() => {
|
||||
@@ -436,7 +427,7 @@ async function loadChannel(channelId, append = false) {
|
||||
renderMessages(payload, false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load messages:", err);
|
||||
console.error('Failed to load messages:', err);
|
||||
} finally {
|
||||
viewerState.loading = false;
|
||||
}
|
||||
@@ -450,13 +441,13 @@ async function refreshCurrentChannel() {
|
||||
}
|
||||
|
||||
function setupViewerTools() {
|
||||
const searchInput = document.getElementById("viewer-search");
|
||||
const refreshBtn = document.getElementById("viewer-refresh-btn");
|
||||
const autoRefresh = document.getElementById("viewer-auto-refresh");
|
||||
const searchInput = document.getElementById('viewer-search');
|
||||
const refreshBtn = document.getElementById('viewer-refresh-btn');
|
||||
const autoRefresh = document.getElementById('viewer-auto-refresh');
|
||||
let searchTimer = null;
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener("input", () => {
|
||||
searchInput.addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
viewerState.search = searchInput.value.trim();
|
||||
@@ -466,7 +457,7 @@ function setupViewerTools() {
|
||||
}
|
||||
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener("click", () => refreshCurrentChannel());
|
||||
refreshBtn.addEventListener('click', () => refreshCurrentChannel());
|
||||
}
|
||||
|
||||
viewerState.autoRefreshTimer = window.setInterval(() => {
|
||||
@@ -477,7 +468,7 @@ function setupViewerTools() {
|
||||
}
|
||||
|
||||
function setupInfiniteScroll() {
|
||||
const sentinel = document.getElementById("scroll-sentinel");
|
||||
const sentinel = document.getElementById('scroll-sentinel');
|
||||
if (!sentinel) return;
|
||||
if (viewerState.sentinelObserver) {
|
||||
viewerState.sentinelObserver.disconnect();
|
||||
@@ -485,17 +476,12 @@ function setupInfiniteScroll() {
|
||||
viewerState.sentinelObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (
|
||||
entry.isIntersecting &&
|
||||
viewerState.channelId &&
|
||||
viewerState.oldestMessageId &&
|
||||
!viewerState.loading
|
||||
) {
|
||||
if (entry.isIntersecting && viewerState.channelId && viewerState.oldestMessageId && !viewerState.loading) {
|
||||
loadChannel(viewerState.channelId, true);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ root: document.getElementById("messages-list"), threshold: 0.1 }
|
||||
{ root: document.getElementById('messages-list'), threshold: 0.1 },
|
||||
);
|
||||
viewerState.sentinelObserver.observe(sentinel);
|
||||
}
|
||||
@@ -504,38 +490,33 @@ async function main() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
await loadViewerAccount(params);
|
||||
viewerState.channels = await api(channelsEndpoint());
|
||||
const requestedChannel = params.get("channel");
|
||||
const requestedChannel = params.get('channel');
|
||||
const requestedChannelExists =
|
||||
requestedChannel &&
|
||||
viewerState.channels.some((channel) => channel.channel_id === requestedChannel);
|
||||
viewerState.channelId = requestedChannelExists
|
||||
? requestedChannel
|
||||
: viewerState.channels[0]?.channel_id || null;
|
||||
requestedChannel && viewerState.channels.some((channel) => channel.channel_id === requestedChannel);
|
||||
viewerState.channelId = requestedChannelExists ? requestedChannel : viewerState.channels[0]?.channel_id || null;
|
||||
renderChannelList();
|
||||
|
||||
if (viewerState.channelId) {
|
||||
await loadChannel(viewerState.channelId);
|
||||
} else {
|
||||
resetMessageView(
|
||||
"No channels",
|
||||
viewerState.accountId
|
||||
? "This account has no tracked channels yet."
|
||||
: "No local legacy channels found."
|
||||
'No channels',
|
||||
viewerState.accountId ? 'This account has no tracked channels yet.' : 'No local legacy channels found.',
|
||||
);
|
||||
}
|
||||
|
||||
setupInfiniteScroll();
|
||||
setupViewerTools();
|
||||
|
||||
const toggle = document.getElementById("sidebar-toggle");
|
||||
const sidebar = document.querySelector(".viewer-sidebar");
|
||||
const toggle = document.getElementById('sidebar-toggle');
|
||||
const sidebar = document.querySelector('.viewer-sidebar');
|
||||
if (toggle && sidebar) {
|
||||
toggle.addEventListener("click", () => {
|
||||
sidebar.classList.toggle("open");
|
||||
toggle.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('open');
|
||||
});
|
||||
sidebar.addEventListener("click", (e) => {
|
||||
sidebar.addEventListener('click', (e) => {
|
||||
if (e.target === sidebar) {
|
||||
sidebar.classList.remove("open");
|
||||
sidebar.classList.remove('open');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -543,5 +524,5 @@ async function main() {
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
showToast(error.message, "error");
|
||||
showToast(error.message, 'error');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user