Harden account import/continuous behavior and finish multi-account UI polish

This commit is contained in:
2026-06-27 21:26:18 +02:00
parent 59cdad220a
commit 881e7ddfca
8 changed files with 572 additions and 35 deletions
+133 -13
View File
@@ -11,6 +11,7 @@ const state = {
};
const ACTIVE_ACCOUNT_STORAGE_KEY = "telegramScraper.activeAccount";
const jobStreams = new Map();
/* ── API helper ─────────────────────────────────────── */
@@ -70,6 +71,38 @@ function confirmAction(message) {
return window.confirm(message);
}
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";
document.body.appendChild(root);
}
const toast = document.createElement("div");
toast.className = `toast toast-${type}`;
toast.textContent = message;
root.appendChild(toast);
window.setTimeout(() => {
toast.classList.add("toast-hide");
window.setTimeout(() => toast.remove(), 250);
}, 3600);
}
function downloadJson(filename, payload) {
const blob = new Blob([JSON.stringify(payload, null, 2) + "\n"], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function isAccountAuthorized(auth) {
const status = auth?.auth_status || auth || {};
return (
@@ -203,7 +236,15 @@ function renderAccountPanel(accountId) {
container.appendChild(node);
}
function closeAllJobStreams() {
for (const [jobId, stream] of jobStreams) {
stream.close();
}
jobStreams.clear();
}
function switchAccount(accountId) {
closeAllJobStreams();
state.activeAccount = accountId;
saveActiveAccount(accountId);
@@ -257,6 +298,12 @@ function renderChannels(accountId, channels) {
const template = document.getElementById("channel-row-template");
tbody.innerHTML = "";
if (!channels.length) {
const row = document.createElement("tr");
row.innerHTML = '<td colspan="5"><div class="empty-state">No tracked channels yet. Add an ID or @username to start scraping this account.</div></td>';
tbody.appendChild(row);
}
channels.forEach((channel) => {
const node = template.content.firstElementChild.cloneNode(true);
node.querySelector(".channel-name").textContent = channel.name;
@@ -317,12 +364,13 @@ function renderJobs(accountId, jobs) {
root.innerHTML = "";
if (!jobs.length) {
root.innerHTML = '<div class="muted">No jobs yet.</div>';
root.innerHTML = '<div class="empty-state">No jobs yet. Start a scrape or export to see progress here.</div>';
return;
}
jobs.forEach((job) => {
const node = template.content.firstElementChild.cloneNode(true);
node.dataset.jobId = job.job_id;
node.querySelector(".job-title").textContent = job.title;
node.querySelector(".job-status").textContent = job.status;
node.querySelector(".job-time").textContent = [displayTime(job.started_at), displayTime(job.finished_at)]
@@ -330,9 +378,42 @@ function renderJobs(accountId, jobs) {
.join(" -> ");
node.querySelector(".job-logs").textContent = job.logs || job.error || "No logs.";
root.appendChild(node);
subscribeJobStream(accountId, job.job_id, job.status);
});
}
function updateRenderedJob(accountId, job) {
const panel = document.getElementById(`panel-${accountId}`);
const node = panel?.querySelector(`[data-job-id="${job.job_id}"]`);
if (!node) return;
node.querySelector(".job-status").textContent = job.status;
node.querySelector(".job-time").textContent = [displayTime(job.started_at), displayTime(job.finished_at)]
.filter((item) => item !== "-")
.join(" -> ");
node.querySelector(".job-logs").textContent = job.logs || job.error || "No logs.";
}
function subscribeJobStream(accountId, jobId, status) {
if (!["queued", "running"].includes(status) || jobStreams.has(jobId) || !window.EventSource) {
return;
}
const stream = new EventSource(`/api/jobs/${encodeURIComponent(jobId)}/events`);
jobStreams.set(jobId, stream);
stream.onmessage = (event) => {
const job = JSON.parse(event.data);
updateRenderedJob(accountId, job);
if (["completed", "failed"].includes(job.status)) {
stream.close();
jobStreams.delete(jobId);
refreshAccount(accountId);
}
};
stream.onerror = () => {
stream.close();
jobStreams.delete(jobId);
};
}
function renderContinuous(accountId, data) {
const panel = document.getElementById(`panel-${accountId}`);
if (!panel) return;
@@ -447,9 +528,18 @@ function renderSummary(accountId, data) {
const panel = document.getElementById(`panel-${accountId}`);
if (!panel) return;
const d = data.dashboard || data.state || {};
const health = data.health || {};
panel.querySelector(".forwarding-count").textContent = String((d.forwarding_rules || []).length);
const toggle = panel.querySelector(".scrape-media-label");
toggle.textContent = d.scrape_media ? "ON" : "OFF";
const healthOk = health.api_credentials && health.session_ready && health.data_dir_exists;
panel.querySelector(".account-health-label").textContent = healthOk ? "Ready" : "Check";
panel.querySelector(".account-health-label").style.color = healthOk ? "var(--ok)" : "var(--warn)";
panel.querySelector(".account-health-detail").textContent = [
`${health.message_count || 0} messages`,
`${health.media_count || 0} media`,
health.active_job ? `active: ${health.active_job.status}` : "idle",
].join(" | ");
}
/* ── Account data loading ───────────────────────────── */
@@ -550,6 +640,16 @@ function renderSettingsAccounts() {
document.getElementById("settings-dialog").close();
});
node.querySelector(".account-export-btn").addEventListener("click", async () => {
try {
const payload = await api(`/api/accounts/${acc.id}/export`);
downloadJson(`telegram-scraper-account-${acc.id}.json`, payload);
showToast(`Exported ${acc.label || acc.id}.`, "success");
} catch (err) {
showToast(`Failed to export account: ${err.message}`, "error");
}
});
node.querySelector(".account-remove-btn").addEventListener("click", async () => {
if (!confirmAction(`Remove account "${acc.label || acc.id}"? All its data will be deleted.`)) return;
try {
@@ -561,7 +661,7 @@ function renderSettingsAccounts() {
}
await loadAccounts();
} catch (err) {
alert(`Failed to remove account: ${err.message}`);
showToast(`Failed to remove account: ${err.message}`, "error");
}
});
@@ -677,13 +777,13 @@ async function toggleMedia(value) {
async function main() {
// ── Sidebar action buttons ──
document.getElementById("scrape-all-btn").addEventListener("click", async () => {
try { await scrapeAll(); } catch (err) { alert("Scrape error: " + err.message); }
try { await scrapeAll(); showToast("Scrape job queued.", "success"); } catch (err) { showToast("Scrape error: " + err.message, "error"); }
});
document.getElementById("export-all-btn").addEventListener("click", async () => {
try { await exportAll(); } catch (err) { alert("Export error: " + err.message); }
try { await exportAll(); showToast("Export job queued.", "success"); } catch (err) { showToast("Export error: " + err.message, "error"); }
});
document.getElementById("refresh-dialogs-btn").addEventListener("click", async () => {
try { await refreshDialogs(); } catch (err) { alert("Dialog refresh error: " + err.message); }
try { await refreshDialogs(); showToast("Dialog refresh queued.", "success"); } catch (err) { showToast("Dialog refresh error: " + err.message, "error"); }
});
// ── Settings dialog ──
@@ -725,7 +825,27 @@ async function main() {
switchAccount(accountId);
}
} catch (err) {
alert("Failed to add account: " + err.message);
showToast("Failed to add account: " + err.message, "error");
}
});
document.getElementById("import-account-file").addEventListener("change", async (event) => {
const file = event.currentTarget.files?.[0];
if (!file) return;
try {
const payload = JSON.parse(await file.text());
const accountId = payload.account_id || payload.id;
if (!accountId) throw new Error("account_id is missing in import file");
await api("/api/accounts/import", {
method: "POST",
body: JSON.stringify(payload),
});
event.currentTarget.value = "";
await loadAccounts();
switchAccount(accountId);
showToast(`Imported ${accountId}.`, "success");
} catch (err) {
showToast(`Failed to import account: ${err.message}`, "error");
}
});
@@ -736,7 +856,7 @@ async function main() {
try {
await saveCredentials(state.activeAccount);
} catch (err) {
alert("Failed to save credentials: " + err.message);
showToast("Failed to save credentials: " + err.message, "error");
}
});
@@ -747,7 +867,7 @@ async function main() {
if (!state.activeAccount) return;
await startQrLogin(state.activeAccount);
} catch (err) {
alert("QR login error: " + err.message);
showToast("QR login error: " + err.message, "error");
} finally {
setBusy(event.currentTarget, false);
}
@@ -760,7 +880,7 @@ async function main() {
try {
await requestPhoneCode(state.activeAccount);
} catch (err) {
alert("Phone code request error: " + err.message);
showToast("Phone code request error: " + err.message, "error");
}
});
@@ -771,7 +891,7 @@ async function main() {
try {
await submitPhoneCode(state.activeAccount);
} catch (err) {
alert("Code submit error: " + err.message);
showToast("Code submit error: " + err.message, "error");
}
});
@@ -782,14 +902,14 @@ async function main() {
try {
await submitPassword(state.activeAccount);
} catch (err) {
alert("Password error: " + err.message);
showToast("Password error: " + err.message, "error");
}
});
// ── Media toggle ──
document.getElementById("scrape-media-toggle").addEventListener("change", async (event) => {
const checked = event.currentTarget.checked;
try { await toggleMedia(checked); } catch (err) { alert("Media toggle error: " + err.message); }
try { await toggleMedia(checked); showToast("Media setting updated.", "success"); } catch (err) { showToast("Media toggle error: " + err.message, "error"); }
});
// ── Load accounts ──
@@ -805,5 +925,5 @@ async function main() {
main().catch((error) => {
console.error(error);
alert(error.message);
showToast(error.message, "error");
});
+10
View File
@@ -81,6 +81,10 @@
<input id="add-account-api-hash" name="api_hash" placeholder="API Hash" />
<button class="button primary" type="submit">Add Account</button>
</form>
<label class="import-account-row">
<span class="muted small">Import account settings JSON</span>
<input id="import-account-file" type="file" accept="application/json,.json" />
</label>
</section>
<!-- ── Account Auth ── (shown per account selected in UI) -->
@@ -165,6 +169,11 @@
<div class="section-title">Forwarding rules</div>
<div class="stat-value forwarding-count">0</div>
</div>
<div class="panel stat-panel">
<div class="section-title">Account health</div>
<div class="stat-value stat-compact account-health-label">-</div>
<div class="muted small account-health-detail"></div>
</div>
</section>
<section class="panel">
@@ -300,6 +309,7 @@
</div>
<div class="account-list-actions">
<button class="button button-small account-select-btn">Select</button>
<button class="button button-small account-export-btn">Export</button>
<button class="button button-small danger account-remove-btn">Remove</button>
</div>
</div>
+87
View File
@@ -638,6 +638,10 @@ tbody tr:hover {
padding: 16px 24px;
border-bottom: 1px solid var(--line);
background: #050505;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.viewer-header h2 {
@@ -647,6 +651,31 @@ tbody tr:hover {
letter-spacing: -0.04em;
}
.viewer-tools {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-wrap: wrap;
}
.viewer-tools input[type="search"] {
min-width: min(260px, 40vw);
}
.viewer-auto-refresh {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 34px;
color: var(--dim);
font-size: 0.82rem;
}
.viewer-auto-refresh input {
min-width: auto;
}
.messages-list {
flex: 1;
overflow-y: auto;
@@ -852,6 +881,54 @@ tbody tr:hover {
display: none;
}
.empty-state,
.viewer-empty-state {
padding: 16px;
color: var(--dim);
border: 1px solid var(--line);
border-radius: 14px;
background: #0a0a0a;
}
.viewer-empty-state {
align-self: center;
margin-top: 16px;
}
.toast-root {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 1000;
display: grid;
gap: 8px;
width: min(360px, calc(100vw - 36px));
}
.toast {
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 14px;
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;
}
.toast-success {
border-color: #1f5b52;
}
.toast-error {
border-color: #7a2a2a;
color: #ffd4d4;
}
.toast-hide {
opacity: 0;
transform: translateY(8px);
}
.api-docs {
display: grid;
gap: 16px;
@@ -1030,6 +1107,16 @@ tbody tr:hover {
background: #0f0f0f;
}
.import-account-row {
display: grid;
gap: 8px;
margin-top: 10px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: #0f0f0f;
}
/* ── Active Account Card ─────────────────────────────── */
#active-account-name {
+8
View File
@@ -27,6 +27,14 @@
<h2 id="viewer-title">Select a channel</h2>
<p id="viewer-subtitle" class="muted">Reading messages from the local SQLite database.</p>
</div>
<div class="viewer-tools">
<input id="viewer-search" type="search" placeholder="Search messages" />
<label class="viewer-auto-refresh">
<input id="viewer-auto-refresh" type="checkbox" checked />
<span>Auto-refresh</span>
</label>
<button id="viewer-refresh-btn" class="button button-small" type="button">Refresh</button>
</div>
</header>
<section id="messages-list" class="messages-list">
+74 -4
View File
@@ -8,7 +8,9 @@ const viewerState = {
oldestMessageId: null,
newestMessageId: null,
userId: null,
search: "",
loading: false,
autoRefreshTimer: null,
sentinelObserver: null,
};
@@ -21,6 +23,24 @@ async function api(path) {
return data;
}
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";
document.body.appendChild(root);
}
const toast = document.createElement("div");
toast.className = `toast toast-${type}`;
toast.textContent = message;
root.appendChild(toast);
window.setTimeout(() => {
toast.classList.add("toast-hide");
window.setTimeout(() => toast.remove(), 250);
}, 3600);
}
function formatDateHeader(dateStr) {
if (!dateStr) return "";
const d = new Date(dateStr.replace(" ", "T"));
@@ -136,10 +156,13 @@ function renderChannelList() {
}
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}`;
return `/api/accounts/${encodeURIComponent(viewerState.accountId)}/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}${search}`;
}
return `/api/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}`;
return `/api/channels/${encodeURIComponent(channelId)}/messages?limit=80${before}${search}`;
}
function channelsEndpoint() {
@@ -172,6 +195,9 @@ function renderAccountSelector() {
async function switchViewerAccount(accountId) {
viewerState.accountId = accountId || null;
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;
@@ -323,7 +349,7 @@ function renderMessages(payload, append = false) {
document.getElementById("viewer-title").textContent =
channel?.name || payload.channel_id;
document.getElementById("viewer-subtitle").textContent = channel
? `${channel.message_count} messages, 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.";
if (!append) {
@@ -371,6 +397,15 @@ function renderMessages(payload, append = false) {
viewerState.oldestMessageId = payload.messages[0].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";
empty.textContent = viewerState.search
? `No messages found for "${viewerState.search}".`
: "No saved messages in this channel yet.";
root.appendChild(empty);
}
if (!append) {
@@ -407,6 +442,40 @@ async function loadChannel(channelId, append = false) {
}
}
async function refreshCurrentChannel() {
if (!viewerState.channelId || viewerState.loading) return;
viewerState.oldestMessageId = null;
viewerState.newestMessageId = null;
await loadChannel(viewerState.channelId);
}
function setupViewerTools() {
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", () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
viewerState.search = searchInput.value.trim();
refreshCurrentChannel();
}, 250);
});
}
if (refreshBtn) {
refreshBtn.addEventListener("click", () => refreshCurrentChannel());
}
viewerState.autoRefreshTimer = window.setInterval(() => {
if (!autoRefresh || autoRefresh.checked) {
refreshCurrentChannel();
}
}, 30000);
}
function setupInfiniteScroll() {
const sentinel = document.getElementById("scroll-sentinel");
if (!sentinel) return;
@@ -456,6 +525,7 @@ async function main() {
}
setupInfiniteScroll();
setupViewerTools();
const toggle = document.getElementById("sidebar-toggle");
const sidebar = document.querySelector(".viewer-sidebar");
@@ -473,5 +543,5 @@ async function main() {
main().catch((error) => {
console.error(error);
alert(error.message);
showToast(error.message, "error");
});