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