feat: polish multi-account dashboard and viewer

This commit is contained in:
2026-06-27 21:47:45 +02:00
parent ff1ca13a03
commit 23bb370836
5 changed files with 550 additions and 457 deletions
+147 -166
View File
@@ -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');
});