feat: add per-account web UI with account tabs

- Account tab bar with tab switching (loadAccounts, switchAccount)
- Per-account dashboard panel showing auth status, channels, jobs, continuous
- Settings dialog with account management (add/delete accounts, credentials)
- Auth forms per account (QR, phone, password, set credentials)
- All action buttons with error handling (alert on failure)
- AMOLED dark theme with Telegram-style message bubbles
- Account tab and account list CSS styles
This commit is contained in:
2026-06-27 15:20:49 +02:00
parent bb4e28a710
commit 62a6a492e0
3 changed files with 1017 additions and 444 deletions
+534 -236
View File
@@ -1,9 +1,17 @@
/* ── State ───────────────────────────────────────────── */
const state = { const state = {
dashboard: null, accounts: [],
auth: null, activeAccount: null,
continuous: null, dashboards: {},
continuous: {},
channels: {},
jobs: {},
authStates: {},
}; };
/* ── API helper ─────────────────────────────────────── */
async function api(path, options = {}) { async function api(path, options = {}) {
const response = await fetch(path, { const response = await fetch(path, {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -16,6 +24,8 @@ async function api(path, options = {}) {
return data; return data;
} }
/* ── Format helpers ─────────────────────────────────── */
function formatDate(value) { function formatDate(value) {
if (!value) return "-"; if (!value) return "-";
return value.replace("T", " ").replace("+00:00", " UTC"); return value.replace("T", " ").replace("+00:00", " UTC");
@@ -58,47 +68,165 @@ function confirmAction(message) {
return window.confirm(message); return window.confirm(message);
} }
function renderAuth(auth) { function isAccountAuthorized(auth) {
document.getElementById("auth-status").textContent = auth.status; const status = auth?.auth_status || auth || {};
document.getElementById("auth-details").textContent = auth.details || ""; return (
auth?.phase === "authorized" ||
auth?.status === "authorized" ||
status.status === "ready" ||
status.status === "authorized"
);
} }
function toggleHidden(id, hidden) { /* ── Account tab management ─────────────────────────── */
document.getElementById(id).classList.toggle("hidden", hidden);
function renderAccountTabs(accounts) {
const container = document.getElementById("account-tabs");
const template = document.getElementById("account-tab-template");
container.innerHTML = "";
accounts.forEach((acc) => {
const node = template.content.firstElementChild.cloneNode(true);
node.dataset.accountId = acc.id;
node.querySelector(".account-tab-name").textContent = acc.label || acc.id;
const statusEl = node.querySelector(".account-tab-status");
if (acc.auth) {
const authOk = isAccountAuthorized(acc.auth);
statusEl.textContent = authOk ? "●" : "○";
statusEl.className = "account-tab-status " + (authOk ? "ok" : "");
}
if (acc.id === state.activeAccount) {
node.classList.add("active");
}
node.addEventListener("click", () => switchAccount(acc.id));
container.appendChild(node);
});
} }
function renderAuthPanel(authData) { function renderAccountPanel(accountId) {
state.auth = authData; const container = document.getElementById("account-panels");
renderAuth(authData.auth_status); const template = document.getElementById("account-panel-template");
const apiId = authData.saved_credentials?.api_id ?? ""; // Don't duplicate
document.getElementById("api-id-input").value = apiId || ""; if (document.getElementById(`panel-${accountId}`)) return;
document.getElementById("api-hash-input").placeholder = authData.saved_credentials?.api_hash_present
? "API Hash saved"
: "API Hash";
const showQr = Boolean(authData.qr_image); const node = template.content.firstElementChild.cloneNode(true);
toggleHidden("qr-wrap", !showQr); node.id = `panel-${accountId}`;
if (showQr) { node.dataset.accountId = accountId;
document.getElementById("qr-image").src = authData.qr_image;
// Add/remove channel form
const addForm = node.querySelector(".add-channel-form");
addForm.addEventListener("submit", async (event) => {
event.preventDefault();
const channelId = addForm.querySelector(".add-channel-id").value.trim();
const name = addForm.querySelector(".add-channel-name").value.trim();
if (!channelId) return;
await api(`/api/accounts/${accountId}/channels/add`, {
method: "POST",
body: JSON.stringify({ channel_id: channelId, name }),
});
addForm.reset();
await refreshAccount(accountId);
});
// Continuous form
const contForm = node.querySelector(".continuous-form");
contForm.addEventListener("submit", async (event) => {
event.preventDefault();
const enabled = contForm.querySelector(".continuous-enabled").checked;
const intervalMinutes = contForm.querySelector(".continuous-interval").value;
const runAllTracked = contForm.querySelector(".continuous-all").checked;
const channels = Array.from(contForm.querySelectorAll(".continuous-channel-checkbox:checked")).map(
(item) => item.value
);
if (!runAllTracked && channels.length === 0 && enabled) {
if (!confirmAction("Continuous scraping enabled with no selected channels. Save anyway?")) return;
}
await api(`/api/accounts/${accountId}/continuous`, {
method: "POST",
body: JSON.stringify({ enabled, interval_minutes: intervalMinutes, run_all_tracked: runAllTracked, channels }),
});
await refreshAccount(accountId);
});
// "All tracked" checkbox toggles individual checkboxes
const contAll = contForm.querySelector(".continuous-all");
contAll.addEventListener("change", () => {
const checkboxes = contForm.querySelectorAll(".continuous-channel-checkbox");
checkboxes.forEach((cb) => { cb.disabled = contAll.checked; });
});
// Content tabs within account panel
const contentTabs = node.querySelectorAll(".content-tab");
contentTabs.forEach((tab) => {
tab.addEventListener("click", () => {
const targetId = tab.dataset.tabTarget;
contentTabs.forEach((t) => t.classList.toggle("active", t === tab));
node.querySelectorAll(".tab-panel").forEach((p) => {
p.classList.toggle("active", p.dataset.panel === targetId);
});
});
});
// Refresh jobs
node.querySelector(".refresh-jobs-btn").addEventListener("click", () => refreshAccount(accountId));
container.appendChild(node);
}
function switchAccount(accountId) {
if (state.activeAccount === accountId) return;
state.activeAccount = accountId;
// Update tabs
document.querySelectorAll(".account-tab").forEach((tab) => {
tab.classList.toggle("active", tab.dataset.accountId === accountId);
});
// Show/hide panels
document.querySelectorAll(".account-panel").forEach((panel) => {
panel.classList.toggle("hidden", panel.dataset.accountId !== accountId);
});
// Ensure panel exists
if (!document.getElementById(`panel-${accountId}`)) {
renderAccountPanel(accountId);
} }
toggleHidden("code-form", authData.phase !== "code_required"); // Update sidebar
toggleHidden("password-form", authData.phase !== "password_required"); updateSidebarAccount();
// Refresh data
refreshAccount(accountId);
// Update settings auth section
updateAuthSection(accountId);
} }
function renderSummary(data) { function updateSidebarAccount() {
document.getElementById("channel-count").textContent = String(data.state.channel_count); const acc = state.accounts.find((a) => a.id === state.activeAccount);
document.getElementById("forwarding-count").textContent = String(data.state.forwarding_rules.length); const nameEl = document.getElementById("active-account-name");
const toggle = document.getElementById("scrape-media-toggle"); const statusEl = document.getElementById("active-account-status");
toggle.checked = Boolean(data.state.scrape_media); if (acc) {
document.getElementById("scrape-media-label").textContent = toggle.checked ? "ON" : "OFF"; nameEl.textContent = acc.label || acc.id;
const authOk = isAccountAuthorized(acc.auth);
statusEl.textContent = authOk ? "Authorized session" : "Needs login";
statusEl.style.color = authOk ? "var(--ok)" : "var(--danger)";
} else {
nameEl.textContent = "None";
statusEl.textContent = "Add an account in Settings";
statusEl.style.color = "var(--dim)";
}
} }
function renderChannels(channels) { /* ── Dashboard rendering ────────────────────────────── */
const tbody = document.getElementById("channels-table");
tbody.innerHTML = ""; function renderChannels(accountId, channels) {
const panel = document.getElementById(`panel-${accountId}`);
if (!panel) return;
const tbody = panel.querySelector(".channels-table");
const template = document.getElementById("channel-row-template"); const template = document.getElementById("channel-row-template");
tbody.innerHTML = "";
channels.forEach((channel) => { channels.forEach((channel) => {
const node = template.content.firstElementChild.cloneNode(true); const node = template.content.firstElementChild.cloneNode(true);
@@ -109,50 +237,55 @@ function renderChannels(channels) {
node.querySelector(".last-date").textContent = channel.last_date || "-"; node.querySelector(".last-date").textContent = channel.last_date || "-";
node.querySelector(".scrape-btn").addEventListener("click", async () => { node.querySelector(".scrape-btn").addEventListener("click", async () => {
await api("/api/jobs/scrape", { await api(`/api/accounts/${accountId}/jobs/scrape`, {
method: "POST", method: "POST",
body: JSON.stringify({ channel_id: channel.channel_id }), body: JSON.stringify({ channel_id: channel.channel_id }),
}); });
await refreshDashboard(); await refreshAccount(accountId);
}); });
node.querySelector(".export-btn").addEventListener("click", async () => { node.querySelector(".export-btn").addEventListener("click", async () => {
await api("/api/jobs/export-channel", { await api(`/api/accounts/${accountId}/jobs/export-channel`, {
method: "POST", method: "POST",
body: JSON.stringify({ channel_id: channel.channel_id }), body: JSON.stringify({ channel_id: channel.channel_id }),
}); });
await refreshDashboard(); await refreshAccount(accountId);
}); });
node.querySelector(".export-view-btn").addEventListener("click", () => { node.querySelector(".export-view-btn").addEventListener("click", () => {
window.location.href = `/viewer?channel=${encodeURIComponent(channel.channel_id)}`; window.location.href = `/viewer?channel=${encodeURIComponent(channel.channel_id)}&account=${encodeURIComponent(accountId)}`;
}); });
node.querySelector(".media-btn").addEventListener("click", async () => { node.querySelector(".media-btn").addEventListener("click", async () => {
await api("/api/jobs/rescrape-media", { await api(`/api/accounts/${accountId}/jobs/rescrape-media`, {
method: "POST", method: "POST",
body: JSON.stringify({ channel_id: channel.channel_id }), body: JSON.stringify({ channel_id: channel.channel_id }),
}); });
await refreshDashboard(); await refreshAccount(accountId);
}); });
node.querySelector(".remove-btn").addEventListener("click", async () => { node.querySelector(".remove-btn").addEventListener("click", async () => {
if (!confirmAction(`Remove ${channel.name} from tracked channels?`)) return; if (!confirmAction(`Remove ${channel.name} from tracked channels?`)) return;
await api("/api/channels/remove", { await api(`/api/accounts/${accountId}/channels/remove`, {
method: "POST", method: "POST",
body: JSON.stringify({ channel_id: channel.channel_id }), body: JSON.stringify({ channel_id: channel.channel_id }),
}); });
await refreshDashboard(); await refreshAccount(accountId);
}); });
tbody.appendChild(node); tbody.appendChild(node);
}); });
// Update channel count stat
panel.querySelector(".channel-count").textContent = String(channels.length);
} }
function renderJobs(jobs) { function renderJobs(accountId, jobs) {
const root = document.getElementById("jobs-list"); const panel = document.getElementById(`panel-${accountId}`);
root.innerHTML = ""; if (!panel) return;
const root = panel.querySelector(".jobs-list");
const template = document.getElementById("job-template"); const template = document.getElementById("job-template");
root.innerHTML = "";
if (!jobs.length) { if (!jobs.length) {
root.innerHTML = '<div class="muted">No jobs yet.</div>'; root.innerHTML = '<div class="muted">No jobs yet.</div>';
@@ -171,18 +304,25 @@ function renderJobs(jobs) {
}); });
} }
function renderContinuousChannelPicker(channels, config) { function renderContinuous(accountId, data) {
const root = document.getElementById("continuous-channel-list"); const panel = document.getElementById(`panel-${accountId}`);
if (!panel) return;
const config = data.config || {};
const status = data.status || {};
const contForm = panel.querySelector(".continuous-form");
contForm.querySelector(".continuous-enabled").checked = Boolean(config.enabled);
contForm.querySelector(".continuous-interval").value = config.interval_minutes || 1;
contForm.querySelector(".continuous-all").checked = Boolean(config.run_all_tracked);
// Channel picker
const chList = panel.querySelector(".continuous-channel-list");
const channels = state.channels[accountId] || [];
const runAll = Boolean(config.run_all_tracked); const runAll = Boolean(config.run_all_tracked);
const selected = new Set(config.channels || []); const selected = new Set(config.channels || []);
root.innerHTML = ""; chList.innerHTML = "";
if (!channels.length) { channels.forEach((ch) => {
root.innerHTML = '<div class="muted">No tracked channels.</div>';
return;
}
channels.forEach((channel) => {
const label = document.createElement("label"); const label = document.createElement("label");
label.className = "checkbox-row"; label.className = "checkbox-row";
label.classList.toggle("disabled", runAll); label.classList.toggle("disabled", runAll);
@@ -190,15 +330,59 @@ function renderContinuousChannelPicker(channels, config) {
const checkbox = document.createElement("input"); const checkbox = document.createElement("input");
checkbox.type = "checkbox"; checkbox.type = "checkbox";
checkbox.className = "continuous-channel-checkbox"; checkbox.className = "continuous-channel-checkbox";
checkbox.value = channel.channel_id; checkbox.value = ch.channel_id;
checkbox.checked = runAll || selected.has(channel.channel_id); checkbox.checked = runAll || selected.has(ch.channel_id);
checkbox.disabled = runAll; checkbox.disabled = runAll;
const text = document.createElement("span"); const text = document.createElement("span");
text.textContent = `${channel.name} (${channel.channel_id})`; text.textContent = `${ch.name} (${ch.channel_id})`;
label.append(checkbox, text); label.append(checkbox, text);
root.appendChild(label); chList.appendChild(label);
});
// Meta
panel.querySelector(".continuous-status").textContent = status.running ? "running" : "stopped";
panel.querySelector(".continuous-last-iteration").textContent = status.last_iteration_at
? `${relativeTime(status.last_iteration_at)} (${displayTime(status.last_iteration_at)})`
: "-";
panel.querySelector(".continuous-last-error").textContent = status.last_error || "-";
// Logs
const logsRoot = panel.querySelector(".continuous-logs");
const entries = (status.log_entries || status.logs || []).map(normalizeLogEntry);
logsRoot.innerHTML = "";
if (!entries.length) {
logsRoot.innerHTML = '<div class="log-line"><span class="log-time">-</span><span class="log-level">INFO</span><span class="log-message">No logs yet.</span></div>';
return;
}
entries.slice().reverse().forEach((entry) => {
const row = document.createElement("div");
row.className = `log-line ${entry.level}`;
const time = entry.timestamp ? displayTime(entry.timestamp).split(", ").pop() : entry.legacyTime || "-";
const absolute = entry.timestamp ? displayTime(entry.timestamp) : entry.legacyTime || "-";
row.title = absolute;
const timeNode = document.createElement("span");
timeNode.className = "log-time";
timeNode.textContent = time;
const ageNode = document.createElement("span");
ageNode.className = "log-age";
ageNode.textContent = entry.timestamp ? relativeTime(entry.timestamp) : "-";
const levelNode = document.createElement("span");
levelNode.className = "log-level";
levelNode.textContent = entry.level.toUpperCase();
const messageNode = document.createElement("span");
messageNode.className = "log-message";
messageNode.textContent = entry.message;
row.append(timeNode, ageNode, levelNode, messageNode);
logsRoot.appendChild(row);
}); });
} }
@@ -230,246 +414,360 @@ function normalizeLogEntry(entry) {
}; };
} }
function renderContinuousLogs(status) { function renderSummary(accountId, data) {
const root = document.getElementById("continuous-logs"); const panel = document.getElementById(`panel-${accountId}`);
const entries = (status.log_entries || status.logs || []).map(normalizeLogEntry); if (!panel) return;
root.innerHTML = ""; const d = data.dashboard || data.state || {};
panel.querySelector(".forwarding-count").textContent = String((d.forwarding_rules || []).length);
const toggle = panel.querySelector(".scrape-media-label");
toggle.textContent = d.scrape_media ? "ON" : "OFF";
}
if (!entries.length) { /* ── Account data loading ───────────────────────────── */
const empty = document.createElement("div");
empty.className = "log-line"; async function refreshAccount(accountId) {
empty.innerHTML = '<span class="log-time">-</span><span class="log-age">-</span><span class="log-level">INFO</span><span class="log-message">No logs yet.</span>'; try {
root.appendChild(empty); const [dashboard, authData, continuousData] = await Promise.all([
api(`/api/accounts/${accountId}`),
api(`/api/accounts/${accountId}/auth`).catch(() => ({})),
api(`/api/accounts/${accountId}/continuous`).catch(() => ({})),
]);
state.dashboards[accountId] = dashboard;
state.continuous[accountId] = continuousData;
state.authStates[accountId] = authData;
state.channels[accountId] = dashboard.channels || [];
renderSummary(accountId, dashboard);
renderChannels(accountId, dashboard.channels || []);
renderJobs(accountId, dashboard.jobs || []);
renderContinuous(accountId, continuousData);
// Update tab status indicator
const tab = document.querySelector(`.account-tab[data-account-id="${accountId}"]`);
if (tab) {
const statusEl = tab.querySelector(".account-tab-status");
const authOk = isAccountAuthorized(authData);
statusEl.textContent = authOk ? "●" : "○";
statusEl.className = "account-tab-status " + (authOk ? "ok" : "");
}
} catch (err) {
console.error(`Failed to refresh account ${accountId}:`, err);
}
}
async function loadAccounts() {
let accounts = [];
try {
const resp = await api("/api/accounts");
accounts = resp.accounts || [];
} catch {
// Legacy fallback — check /api/auth
try {
const legacy = await api("/api/auth");
if (legacy.saved_credentials?.api_id) {
accounts = [{ id: "default", label: "Default", auth: legacy.auth_status || legacy }];
}
} catch {
// No accounts at all
}
}
state.accounts = accounts;
if (accounts.length === 0) {
document.getElementById("no-accounts-msg").classList.remove("hidden");
return; return;
} }
entries.slice().reverse().forEach((entry) => { document.getElementById("no-accounts-msg").classList.add("hidden");
const row = document.createElement("div");
row.className = `log-line ${entry.level}`;
const time = entry.timestamp ? displayTime(entry.timestamp).split(", ").pop() : entry.legacyTime || "-";
const absolute = entry.timestamp ? displayTime(entry.timestamp) : entry.legacyTime || "-";
row.title = absolute;
const timeNode = document.createElement("span"); // Render tabs
timeNode.className = "log-time"; renderAccountTabs(accounts);
timeNode.textContent = time;
const ageNode = document.createElement("span"); // Render panels for each account
ageNode.className = "log-age"; accounts.forEach((acc) => renderAccountPanel(acc.id));
ageNode.textContent = entry.timestamp ? relativeTime(entry.timestamp) : "-";
const levelNode = document.createElement("span"); // Determine active account
levelNode.className = "log-level"; const activeId = state.activeAccount || accounts[0].id;
levelNode.textContent = entry.level.toUpperCase(); switchAccount(activeId);
const messageNode = document.createElement("span"); // Update settings
messageNode.className = "log-message"; renderSettingsAccounts();
messageNode.textContent = entry.message;
row.append(timeNode, ageNode, levelNode, messageNode);
root.appendChild(row);
});
} }
function renderContinuous(data) { /* ── Settings: Accounts list ────────────────────────── */
state.continuous = data;
const config = data.config || {};
const status = data.status || {};
document.getElementById("continuous-enabled").checked = Boolean(config.enabled); function renderSettingsAccounts() {
document.getElementById("continuous-interval").value = config.interval_minutes || 1; const container = document.getElementById("accounts-list");
document.getElementById("continuous-all").checked = Boolean(config.run_all_tracked); const template = document.getElementById("account-list-item-template");
renderContinuousChannelPicker(state.dashboard?.channels || [], config); container.innerHTML = "";
document.getElementById("continuous-status").textContent = status.running ? "running" : "stopped"; state.accounts.forEach((acc) => {
document.getElementById("continuous-last-iteration").textContent = status.last_iteration_at const node = template.content.firstElementChild.cloneNode(true);
? `${relativeTime(status.last_iteration_at)} (${displayTime(status.last_iteration_at)})` node.querySelector(".account-list-label").textContent = acc.label || acc.id;
: "-"; node.querySelector(".account-list-id").textContent = acc.id;
document.getElementById("continuous-last-error").textContent = status.last_error || "-";
renderContinuousLogs(status);
}
function setupTabs() { const authStatus = node.querySelector(".account-list-auth-status");
const tabs = document.querySelectorAll(".content-tab"); const authOk = isAccountAuthorized(acc.auth);
const panels = document.querySelectorAll(".tab-panel"); authStatus.textContent = authOk ? "Authorized" : "Needs auth";
authStatus.style.color = authOk ? "var(--ok)" : "var(--danger)";
authStatus.style.fontSize = "0.78rem";
tabs.forEach((tab) => { node.querySelector(".account-select-btn").addEventListener("click", () => {
tab.addEventListener("click", () => { switchAccount(acc.id);
const targetId = tab.dataset.tabTarget; document.getElementById("settings-dialog").close();
tabs.forEach((item) => item.classList.toggle("active", item === tab));
panels.forEach((panel) => panel.classList.toggle("active", panel.id === targetId));
}); });
node.querySelector(".account-remove-btn").addEventListener("click", async () => {
if (!confirmAction(`Remove account "${acc.label || acc.id}"? All its data will be deleted.`)) return;
try {
await api(`/api/accounts/${acc.id}`, { method: "DELETE" });
await loadAccounts();
if (state.activeAccount === acc.id) {
state.activeAccount = null;
}
} catch (err) {
alert(`Failed to remove account: ${err.message}`);
}
});
container.appendChild(node);
}); });
} }
async function refreshDashboard() { /* ── Auth (operates on active account) ───────────────── */
const data = await api("/api/dashboard");
const authData = await api("/api/auth"); function updateAuthSection(accountId) {
const continuousData = await api("/api/continuous"); const label = document.getElementById("auth-account-label");
state.dashboard = data; const acc = state.accounts.find((a) => a.id === accountId);
renderAuthPanel(authData); label.textContent = acc ? (acc.label || acc.id) : "-";
renderSummary(data);
renderChannels(data.channels);
renderJobs(data.jobs);
renderContinuous(continuousData);
} }
async function saveCredentials(accountId) {
const apiId = document.getElementById("api-id-input").value.trim();
const apiHash = document.getElementById("api-hash-input").value.trim();
if (!apiId || !apiHash) return;
await api(`/api/accounts/${accountId}/auth/credentials`, {
method: "POST",
body: JSON.stringify({ api_id: apiId, api_hash: apiHash }),
});
await loadAccounts();
}
async function startQrLogin(accountId) {
const data = await api(`/api/accounts/${accountId}/auth/qr/start`, {
method: "POST",
body: JSON.stringify({}),
});
if (data.qr_image) {
document.getElementById("qr-image").src = data.qr_image;
document.getElementById("qr-wrap").classList.remove("hidden");
}
await loadAccounts();
}
async function requestPhoneCode(accountId) {
const phone = document.getElementById("phone-input").value.trim();
if (!phone) return;
await api(`/api/accounts/${accountId}/auth/phone/request`, {
method: "POST",
body: JSON.stringify({ phone }),
});
await loadAccounts();
}
async function submitPhoneCode(accountId) {
const code = document.getElementById("code-input").value.trim();
if (!code) return;
await api(`/api/accounts/${accountId}/auth/phone/submit`, {
method: "POST",
body: JSON.stringify({ code }),
});
document.getElementById("code-input").value = "";
await loadAccounts();
}
async function submitPassword(accountId) {
const password = document.getElementById("password-input").value.trim();
if (!password) return;
await api(`/api/accounts/${accountId}/auth/password`, {
method: "POST",
body: JSON.stringify({ password }),
});
document.getElementById("password-input").value = "";
await loadAccounts();
}
/* ── Scrape / Export all (for active account) ───────── */
async function scrapeAll() {
if (!state.activeAccount) return;
if (!confirmAction("Queue scraping for all tracked channels?")) return;
await api(`/api/accounts/${state.activeAccount}/jobs/scrape`, {
method: "POST",
body: JSON.stringify({}),
});
await refreshAccount(state.activeAccount);
}
async function exportAll() {
if (!state.activeAccount) return;
if (!confirmAction("Queue export for all tracked channels?")) return;
await api(`/api/accounts/${state.activeAccount}/jobs/export`, {
method: "POST",
body: JSON.stringify({}),
});
await refreshAccount(state.activeAccount);
}
async function refreshDialogs() {
if (!state.activeAccount) return;
await api(`/api/accounts/${state.activeAccount}/jobs/refresh-dialogs`, {
method: "POST",
body: JSON.stringify({}),
});
await refreshAccount(state.activeAccount);
}
async function toggleMedia(value) {
if (!state.activeAccount) return;
await api(`/api/accounts/${state.activeAccount}/settings/media`, {
method: "POST",
body: JSON.stringify({ value }),
});
await refreshAccount(state.activeAccount);
}
/* ── Main ───────────────────────────────────────────── */
async function main() { async function main() {
setupTabs(); // ── Sidebar action buttons ──
document.getElementById("scrape-all-btn").addEventListener("click", async () => {
try { await scrapeAll(); } catch (err) { alert("Scrape error: " + err.message); }
});
document.getElementById("export-all-btn").addEventListener("click", async () => {
try { await exportAll(); } catch (err) { alert("Export error: " + err.message); }
});
document.getElementById("refresh-dialogs-btn").addEventListener("click", async () => {
try { await refreshDialogs(); } catch (err) { alert("Dialog refresh error: " + err.message); }
});
// ── Settings dialog ──
const settingsDialog = document.getElementById("settings-dialog"); const settingsDialog = document.getElementById("settings-dialog");
document.getElementById("open-settings-btn").addEventListener("click", () => { document.getElementById("open-settings-btn").addEventListener("click", () => {
// Update auth section for active account
if (state.activeAccount) {
updateAuthSection(state.activeAccount);
}
renderSettingsAccounts();
settingsDialog.showModal(); settingsDialog.showModal();
}); });
document.getElementById("close-settings-btn").addEventListener("click", () => { document.getElementById("close-settings-btn").addEventListener("click", () => {
settingsDialog.close(); settingsDialog.close();
}); });
document.getElementById("scrape-all-btn").addEventListener("click", async (event) => { // ── Add account form ──
if (!confirmAction("Queue scraping for all tracked channels?")) return; document.getElementById("add-account-form").addEventListener("submit", async (event) => {
setBusy(event.currentTarget, true);
try {
await api("/api/jobs/scrape", { method: "POST", body: JSON.stringify({}) });
await refreshDashboard();
} finally {
setBusy(event.currentTarget, false);
}
});
document.getElementById("export-all-btn").addEventListener("click", async (event) => {
if (!confirmAction("Queue export for all tracked channels?")) return;
setBusy(event.currentTarget, true);
try {
await api("/api/jobs/export", { method: "POST", body: JSON.stringify({}) });
await refreshDashboard();
} finally {
setBusy(event.currentTarget, false);
}
});
document.getElementById("refresh-dialogs-btn").addEventListener("click", async (event) => {
setBusy(event.currentTarget, true);
try {
await api("/api/jobs/refresh-dialogs", { method: "POST", body: JSON.stringify({}) });
await refreshDashboard();
} finally {
setBusy(event.currentTarget, false);
}
});
document.getElementById("refresh-jobs-btn").addEventListener("click", refreshDashboard);
document.getElementById("scrape-media-toggle").addEventListener("change", async (event) => {
const checked = event.currentTarget.checked;
document.getElementById("scrape-media-label").textContent = checked ? "ON" : "OFF";
await api("/api/settings/media", {
method: "POST",
body: JSON.stringify({ value: checked }),
});
await refreshDashboard();
});
document.getElementById("add-channel-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const channelId = document.getElementById("add-channel-id").value.trim(); const accountId = document.getElementById("add-account-id").value.trim();
const name = document.getElementById("add-channel-name").value.trim(); const label = document.getElementById("add-account-label").value.trim();
if (!channelId) return; const apiId = document.getElementById("add-account-api-id").value.trim();
await api("/api/channels/add", { const apiHash = document.getElementById("add-account-api-hash").value.trim();
method: "POST", if (!accountId) return;
body: JSON.stringify({ channel_id: channelId, name }),
}); try {
event.currentTarget.reset(); await api("/api/accounts", {
await refreshDashboard(); method: "POST",
body: JSON.stringify({
account_id: accountId,
label: label || accountId,
api_id: apiId ? parseInt(apiId) : undefined,
api_hash: apiHash || undefined,
}),
});
event.target.reset();
await loadAccounts();
if (!state.activeAccount) {
switchAccount(accountId);
}
} catch (err) {
alert("Failed to add account: " + err.message);
}
}); });
// ── Credentials form ──
document.getElementById("credentials-form").addEventListener("submit", async (event) => { document.getElementById("credentials-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const apiId = document.getElementById("api-id-input").value.trim(); if (!state.activeAccount) return;
const apiHash = document.getElementById("api-hash-input").value.trim(); try {
await api("/api/auth/credentials", { await saveCredentials(state.activeAccount);
method: "POST", } catch (err) {
body: JSON.stringify({ api_id: apiId, api_hash: apiHash }), alert("Failed to save credentials: " + err.message);
}); }
await refreshDashboard();
}); });
// ── QR login ──
document.getElementById("start-qr-btn").addEventListener("click", async (event) => { document.getElementById("start-qr-btn").addEventListener("click", async (event) => {
setBusy(event.currentTarget, true); setBusy(event.currentTarget, true);
try { try {
await api("/api/auth/qr/start", { if (!state.activeAccount) return;
method: "POST", await startQrLogin(state.activeAccount);
body: JSON.stringify({}), } catch (err) {
}); alert("QR login error: " + err.message);
await refreshDashboard();
} finally { } finally {
setBusy(event.currentTarget, false); setBusy(event.currentTarget, false);
} }
}); });
// ── Phone ──
document.getElementById("phone-form").addEventListener("submit", async (event) => { document.getElementById("phone-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const phone = document.getElementById("phone-input").value.trim(); if (!state.activeAccount) return;
await api("/api/auth/phone/request", { try {
method: "POST", await requestPhoneCode(state.activeAccount);
body: JSON.stringify({ phone }), } catch (err) {
}); alert("Phone code request error: " + err.message);
await refreshDashboard(); }
}); });
// ── Code ──
document.getElementById("code-form").addEventListener("submit", async (event) => { document.getElementById("code-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const code = document.getElementById("code-input").value.trim(); if (!state.activeAccount) return;
await api("/api/auth/phone/submit", { try {
method: "POST", await submitPhoneCode(state.activeAccount);
body: JSON.stringify({ code }), } catch (err) {
}); alert("Code submit error: " + err.message);
event.currentTarget.reset(); }
await refreshDashboard();
}); });
// ── Password ──
document.getElementById("password-form").addEventListener("submit", async (event) => { document.getElementById("password-form").addEventListener("submit", async (event) => {
event.preventDefault(); event.preventDefault();
const password = document.getElementById("password-input").value.trim(); if (!state.activeAccount) return;
await api("/api/auth/password", { try {
method: "POST", await submitPassword(state.activeAccount);
body: JSON.stringify({ password }), } catch (err) {
}); alert("Password error: " + err.message);
event.currentTarget.reset();
await refreshDashboard();
});
document.getElementById("continuous-form").addEventListener("submit", async (event) => {
event.preventDefault();
const enabled = document.getElementById("continuous-enabled").checked;
const intervalMinutes = document.getElementById("continuous-interval").value;
const runAllTracked = document.getElementById("continuous-all").checked;
const channels = Array.from(document.querySelectorAll(".continuous-channel-checkbox:checked")).map(
(item) => item.value,
);
if (!runAllTracked && channels.length === 0 && enabled) {
if (!confirmAction("Continuous scraping is enabled with no selected channels. Save anyway?")) return;
} }
await api("/api/continuous", {
method: "POST",
body: JSON.stringify({
enabled,
interval_minutes: intervalMinutes,
run_all_tracked: runAllTracked,
channels,
}),
});
await refreshDashboard();
}); });
document.getElementById("continuous-all").addEventListener("change", () => { // ── Media toggle ──
renderContinuousChannelPicker(state.dashboard?.channels || [], { document.getElementById("scrape-media-toggle").addEventListener("change", async (event) => {
...(state.continuous?.config || {}), const checked = event.currentTarget.checked;
run_all_tracked: document.getElementById("continuous-all").checked, try { await toggleMedia(checked); } catch (err) { alert("Media toggle error: " + err.message); }
});
}); });
await refreshDashboard(); // ── Load accounts ──
window.setInterval(refreshDashboard, 5000); await loadAccounts();
// ── Periodic refresh ──
window.setInterval(() => {
if (state.activeAccount) {
refreshAccount(state.activeAccount);
}
}, 5000);
} }
main().catch((error) => { main().catch((error) => {
+180 -110
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telegram Scraper Control Panel</title> <title>Telegram Scraper Control Panel</title>
<link rel="stylesheet" href="/static/style.css?v=2" /> <link rel="stylesheet" href="/static/style.css?v=4" />
</head> </head>
<body> <body>
<div class="app-shell"> <div class="app-shell">
@@ -22,10 +22,10 @@
<a class="nav-link" href="/health">Health</a> <a class="nav-link" href="/health">Health</a>
</nav> </nav>
<section class="status-card"> <section class="status-card" id="active-account-card">
<div class="section-title">Telegram Status</div> <div class="section-title">Active Account</div>
<div id="auth-status" class="status-badge">Checking...</div> <div id="active-account-name" class="status-badge">Loading...</div>
<p id="auth-details" class="muted small"></p> <p id="active-account-status" class="muted small"></p>
</section> </section>
<section class="status-card"> <section class="status-card">
@@ -38,127 +38,54 @@
</aside> </aside>
<main class="content"> <main class="content">
<div class="content-tabs"> <!-- Account Tabs -->
<button class="content-tab active" data-tab-target="overview-tab" type="button">Overview</button> <div id="account-tabs" class="account-tabs"></div>
<button class="content-tab" data-tab-target="continuous-tab" type="button">Continuous</button>
<!-- Account Dashboard Panels -->
<div id="account-panels">
<!-- Each account's dashboard is rendered here dynamically -->
</div> </div>
<section id="overview-tab" class="tab-panel active"> <!-- Fallback for no accounts -->
<section class="panel-grid"> <section id="no-accounts-msg" class="panel hidden">
<div class="panel stat-panel"> <div class="panel-header">
<div class="section-title">Channels</div> <div>
<div id="channel-count" class="stat-value">0</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>
</div> </div>
<div class="panel stat-panel"> </div>
<div class="section-title">Media scraping</div>
<div id="scrape-media-label" class="stat-value stat-compact">OFF</div>
</div>
<div class="panel stat-panel">
<div class="section-title">Forwarding rules</div>
<div id="forwarding-count" class="stat-value">0</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>Tracked Channels</h2>
<p class="muted">Stored in <code>data/state.json</code>. Message stats come from SQLite.</p>
</div>
<form id="add-channel-form" class="inline-form">
<input id="add-channel-id" name="channel_id" placeholder="ID or @username" required />
<input id="add-channel-name" name="name" placeholder="Display name" />
<button class="button primary" type="submit">Add</button>
</form>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Channel</th>
<th>Messages</th>
<th>Media</th>
<th>Last message</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="channels-table"></tbody>
</table>
</div>
</section>
<section class="panel jobs-panel">
<div class="panel-header">
<div>
<h2>Jobs</h2>
<p class="muted">Queued work runs one job at a time to keep the Telegram session stable.</p>
</div>
<button class="button" id="refresh-jobs-btn">Refresh</button>
</div>
<div id="jobs-list" class="jobs-list"></div>
</section>
</section>
<section id="continuous-tab" class="tab-panel">
<section class="panel">
<div class="panel-header">
<div>
<h2>Continuous Scraping</h2>
<p class="muted">Runs on a timer and keeps a compact, highlighted run log.</p>
</div>
</div>
<form id="continuous-form" class="continuous-form">
<label class="toggle-row">
<span>Enabled</span>
<span class="switch">
<input id="continuous-enabled" type="checkbox" checked />
<span class="switch-slider"></span>
</span>
</label>
<label class="stack-form">
<span class="muted small">Interval, minutes</span>
<input id="continuous-interval" type="number" min="1" value="1" />
</label>
<label class="toggle-row">
<span>All tracked channels</span>
<span class="switch">
<input id="continuous-all" type="checkbox" checked />
<span class="switch-slider"></span>
</span>
</label>
<div class="channel-picker">
<div class="muted small">Continuous channel set</div>
<div id="continuous-channel-list" class="checkbox-grid"></div>
</div>
<button class="button primary" type="submit">Save continuous settings</button>
</form>
<div class="continuous-meta">
<div>Status: <span id="continuous-status">-</span></div>
<div>Last run: <span id="continuous-last-iteration">-</span></div>
<div>Last error: <span id="continuous-last-error">-</span></div>
</div>
<div id="continuous-logs" class="log-viewer"></div>
</section>
</section> </section>
</main> </main>
</div> </div>
<!-- ════════ Settings Dialog ════════ -->
<dialog id="settings-dialog" class="settings-dialog"> <dialog id="settings-dialog" class="settings-dialog">
<div class="dialog-shell"> <div class="dialog-shell">
<div class="dialog-header"> <div class="dialog-header">
<div> <div>
<div class="eyebrow">Settings</div> <div class="eyebrow">Settings</div>
<h2>Telegram & Scraping</h2> <h2>Global & Accounts</h2>
</div> </div>
<button class="button button-small" id="close-settings-btn" type="button">Close</button> <button class="button button-small" id="close-settings-btn" type="button">Close</button>
</div> </div>
<!-- ── Accounts Management ── -->
<section class="settings-section"> <section class="settings-section">
<div class="section-title">Telegram Login</div> <div class="section-title">Accounts</div>
<div id="accounts-list" class="accounts-list"></div>
<form id="add-account-form" class="stack-form add-account-form">
<input id="add-account-id" name="account_id" placeholder="Account ID (e.g. work)" required />
<input id="add-account-label" name="label" placeholder="Display name (e.g. Work Account)" />
<input id="add-account-api-id" name="api_id" placeholder="API ID" />
<input id="add-account-api-hash" name="api_hash" placeholder="API Hash" />
<button class="button primary" type="submit">Add Account</button>
</form>
</section>
<!-- ── Account Auth ── (shown per account selected in UI) -->
<section class="settings-section" id="account-auth-section">
<div class="section-title">Account Auth: <span id="auth-account-label">-</span></div>
<form id="credentials-form" class="stack-form"> <form id="credentials-form" class="stack-form">
<input id="api-id-input" name="api_id" placeholder="API ID" /> <input id="api-id-input" name="api_id" placeholder="API ID" />
<input id="api-hash-input" name="api_hash" placeholder="API Hash" /> <input id="api-hash-input" name="api_hash" placeholder="API Hash" />
@@ -190,6 +117,7 @@
</form> </form>
</section> </section>
<!-- ── Scraping ── -->
<section class="settings-section"> <section class="settings-section">
<div class="section-title">Scraping</div> <div class="section-title">Scraping</div>
<label class="toggle-row"> <label class="toggle-row">
@@ -203,6 +131,132 @@
</div> </div>
</dialog> </dialog>
<!-- ════════ Templates ════════ -->
<!-- Account Tab -->
<template id="account-tab-template">
<button class="account-tab" type="button">
<span class="account-tab-name"></span>
<span class="account-tab-status"></span>
</button>
</template>
<!-- Account Dashboard Panel -->
<template id="account-panel-template">
<section class="account-panel">
<!-- Dashboard tabs: Overview, Continuous -->
<div class="content-tabs">
<button class="content-tab active" data-tab-target="overview" type="button">Overview</button>
<button class="content-tab" data-tab-target="continuous" type="button">Continuous</button>
</div>
<!-- Overview Tab -->
<div class="tab-panel active" data-panel="overview">
<section class="panel-grid">
<div class="panel stat-panel">
<div class="section-title">Channels</div>
<div class="stat-value channel-count">0</div>
</div>
<div class="panel stat-panel">
<div class="section-title">Media scraping</div>
<div class="stat-value stat-compact scrape-media-label">OFF</div>
</div>
<div class="panel stat-panel">
<div class="section-title">Forwarding rules</div>
<div class="stat-value forwarding-count">0</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<div>
<h2>Tracked Channels</h2>
<p class="muted">Per-account channel list.</p>
</div>
<form class="inline-form add-channel-form">
<input class="add-channel-id" name="channel_id" placeholder="ID or @username" required />
<input class="add-channel-name" name="name" placeholder="Display name" />
<button class="button primary" type="submit">Add</button>
</form>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Channel</th>
<th>Messages</th>
<th>Media</th>
<th>Last message</th>
<th>Actions</th>
</tr>
</thead>
<tbody class="channels-table"></tbody>
</table>
</div>
</section>
<section class="panel jobs-panel">
<div class="panel-header">
<div>
<h2>Jobs</h2>
<p class="muted">Queued work runs one job at a time.</p>
</div>
<button class="button refresh-jobs-btn">Refresh</button>
</div>
<div class="jobs-list"></div>
</section>
</div>
<!-- Continuous Tab -->
<div class="tab-panel" data-panel="continuous">
<section class="panel">
<div class="panel-header">
<div>
<h2>Continuous Scraping</h2>
<p class="muted">Per-account continuous scrape settings.</p>
</div>
</div>
<form class="continuous-form">
<label class="toggle-row">
<span>Enabled</span>
<span class="switch">
<input type="checkbox" class="continuous-enabled" checked />
<span class="switch-slider"></span>
</span>
</label>
<label class="stack-form">
<span class="muted small">Interval, minutes</span>
<input type="number" class="continuous-interval" min="1" value="1" />
</label>
<label class="toggle-row">
<span>All tracked channels</span>
<span class="switch">
<input type="checkbox" class="continuous-all" checked />
<span class="switch-slider"></span>
</span>
</label>
<div class="channel-picker">
<div class="muted small">Continuous channel set</div>
<div class="checkbox-grid continuous-channel-list"></div>
</div>
<button class="button primary" type="submit">Save continuous settings</button>
</form>
<div class="continuous-meta">
<div>Status: <span class="continuous-status">-</span></div>
<div>Last run: <span class="continuous-last-iteration">-</span></div>
<div>Last error: <span class="continuous-last-error">-</span></div>
</div>
<div class="log-viewer continuous-logs"></div>
</section>
</div>
</section>
</template>
<!-- Channel Row -->
<template id="channel-row-template"> <template id="channel-row-template">
<tr> <tr>
<td> <td>
@@ -224,6 +278,7 @@
</tr> </tr>
</template> </template>
<!-- Job Template -->
<template id="job-template"> <template id="job-template">
<article class="job-card"> <article class="job-card">
<div class="job-head"> <div class="job-head">
@@ -235,6 +290,21 @@
</article> </article>
</template> </template>
<script src="/static/app.js"></script> <!-- Account list item in settings -->
<template id="account-list-item-template">
<div class="account-list-item">
<div class="account-list-info">
<span class="account-list-label"></span>
<span class="muted small account-list-id"></span>
<span class="account-list-auth-status"></span>
</div>
<div class="account-list-actions">
<button class="button button-small account-select-btn">Select</button>
<button class="button button-small danger account-remove-btn">Remove</button>
</div>
</div>
</template>
<script src="/static/app.js?v=3"></script>
</body> </body>
</html> </html>
+303 -98
View File
@@ -1,17 +1,24 @@
:root { :root {
color-scheme: dark; color-scheme: dark;
--bg-color: #050505; --bg-color: #000000;
--panel: #0c0c0c; --panel: #0b0b0b;
--panel-alt: #080808; --panel-solid: #101010;
--text-color: #e0e0e0; --panel-alt: #050505;
--accent: #ffffff; --text-color: #f2f3f5;
--dim: #6f6f6f; --accent: #1fb9aa;
--line: #2a2a2a; --accent-2: #58a6ff;
--danger: #ff6b6b; --bubble-own: #12342f;
--ok: #7ee787; --bubble-other: #171717;
--warn: #f0c674; --dim: #9aa0a6;
--info: #8ab4f8; --line: #242424;
--font-mono: "Courier New", Courier, monospace; --danger: #ff7373;
--ok: #65e6a4;
--warn: #f6c969;
--info: #8ec7ff;
--radius: 18px;
--shadow: none;
--font-ui: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
--font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
} }
* { * {
@@ -20,11 +27,11 @@
body { body {
margin: 0; margin: 0;
background: var(--bg-color); background: #000;
color: var(--text-color); color: var(--text-color);
font-family: var(--font-mono); font-family: var(--font-ui);
font-size: 16px; font-size: 15px;
line-height: 1.55; line-height: 1.5;
} }
a { a {
@@ -35,8 +42,9 @@ a {
a:hover, a:hover,
button:hover { button:hover {
background: var(--text-color); border-color: #333;
color: var(--bg-color); background: #151515;
color: var(--text-color);
} }
code, code,
@@ -50,19 +58,19 @@ button {
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
display: grid; display: grid;
grid-template-columns: 300px minmax(0, 1fr); grid-template-columns: 308px minmax(0, 1fr);
} }
.sidebar { .sidebar {
background: var(--panel-alt); background: #050505;
border-right: 1px dashed var(--line); border-right: 1px solid var(--line);
padding: 28px 24px; padding: 24px 20px;
} }
.content { .content {
overflow-y: auto; overflow-y: auto;
padding: 28px; padding: 24px;
max-width: 1320px; max-width: 1440px;
width: 100%; width: 100%;
} }
@@ -70,21 +78,16 @@ button {
.dialog-header h2 { .dialog-header h2 {
margin: 0; margin: 0;
color: var(--accent); color: var(--accent);
font-size: 1.45rem; font-size: 1.5rem;
text-transform: uppercase; letter-spacing: -0.04em;
letter-spacing: 0;
} }
.eyebrow, .eyebrow,
.section-title { .section-title {
color: var(--dim); color: var(--dim);
font-size: 0.78rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
} letter-spacing: 0.08em;
.eyebrow::before,
.section-title::before {
content: "./";
} }
.muted { .muted {
@@ -112,8 +115,9 @@ input,
.content-tab, .content-tab,
.viewer-channel-item { .viewer-channel-item {
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--panel); background: #101010;
color: var(--text-color); color: var(--text-color);
border-radius: 999px;
} }
.nav-link, .nav-link,
@@ -125,21 +129,23 @@ 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;
} }
.nav-link.active, .nav-link.active,
.button.primary, .button.primary,
.content-tab.active { .content-tab.active {
border-color: var(--accent); border-color: #0f6f67;
color: var(--accent); color: #ffffff;
box-shadow: inset 3px 0 0 var(--accent); background: #0f6f67;
box-shadow: none;
} }
.nav-link.active:hover, .nav-link.active:hover,
.button.primary:hover, .button.primary:hover,
.content-tab.active:hover { .content-tab.active:hover {
background: var(--panel); background: #128277;
color: var(--accent); color: #ffffff;
} }
.button.danger { .button.danger {
@@ -164,7 +170,9 @@ input,
.message-card, .message-card,
.settings-dialog { .settings-dialog {
background: var(--panel); background: var(--panel);
border: 1px dashed var(--line); border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
} }
.status-card, .status-card,
@@ -185,10 +193,10 @@ input,
.status-badge { .status-badge {
display: inline-flex; display: inline-flex;
padding: 4px 8px; padding: 6px 10px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 999px;
color: var(--ok); color: var(--ok);
text-transform: uppercase;
} }
.content-tabs { .content-tabs {
@@ -228,8 +236,9 @@ input,
.stat-value { .stat-value {
margin-top: 8px; margin-top: 8px;
font-size: 2rem; font-size: 2.25rem;
font-weight: 700; font-weight: 700;
letter-spacing: -0.05em;
} }
.stat-compact { .stat-compact {
@@ -250,8 +259,9 @@ input,
input { input {
min-width: 160px; min-width: 160px;
padding: 9px 11px; padding: 10px 13px;
outline: 0; outline: 0;
border-radius: 14px;
} }
input:focus { input:focus {
@@ -286,7 +296,8 @@ input:focus {
position: absolute; position: absolute;
inset: 0; inset: 0;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #101010; background: rgba(255, 255, 255, 0.06);
border-radius: 999px;
} }
.switch-slider::before { .switch-slider::before {
@@ -297,6 +308,7 @@ input:focus {
width: 16px; width: 16px;
height: 16px; height: 16px;
background: var(--dim); 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;
} }
@@ -323,11 +335,11 @@ td {
padding: 12px 10px; padding: 12px 10px;
text-align: left; text-align: left;
vertical-align: top; vertical-align: top;
border-bottom: 1px dashed var(--line); border-bottom: 1px solid var(--line);
} }
tbody tr:hover { tbody tr:hover {
background: #101010; background: #111;
} }
.action-row { .action-row {
@@ -375,7 +387,9 @@ tbody tr:hover {
gap: 10px; gap: 10px;
min-height: 38px; min-height: 38px;
padding: 8px; padding: 8px;
border: 1px dashed var(--line); border: 1px solid var(--line);
border-radius: 14px;
background: #0f0f0f;
} }
.checkbox-row input { .checkbox-row input {
@@ -391,7 +405,8 @@ tbody tr:hover {
max-height: 420px; max-height: 420px;
overflow: auto; overflow: auto;
border: 1px solid var(--line); border: 1px solid var(--line);
background: #030303; background: rgba(0, 0, 0, 0.2);
border-radius: 16px;
} }
.log-line { .log-line {
@@ -404,7 +419,7 @@ tbody tr:hover {
} }
.log-line:hover { .log-line:hover {
background: #101010; background: #111;
} }
.log-time, .log-time,
@@ -455,13 +470,14 @@ tbody tr:hover {
.settings-section { .settings-section {
margin-top: 18px; margin-top: 18px;
padding-top: 16px; padding-top: 16px;
border-top: 1px dashed var(--line); border-top: 1px solid var(--line);
} }
.qr-wrap { .qr-wrap {
margin-top: 14px; margin-top: 14px;
padding: 12px; padding: 12px;
border: 1px dashed var(--line); border: 1px solid var(--line);
border-radius: 16px;
} }
.qr-wrap img { .qr-wrap img {
@@ -481,18 +497,18 @@ tbody tr:hover {
.viewer-shell { .viewer-shell {
display: grid; display: grid;
grid-template-columns: 300px minmax(0, 1fr); grid-template-columns: 360px minmax(0, 1fr);
height: 100vh; height: 100vh;
overflow: hidden; overflow: hidden;
} }
.viewer-sidebar { .viewer-sidebar {
background: var(--panel-alt); background: #050505;
border-right: 1px dashed var(--line); border-right: 1px solid var(--line);
padding: 28px 24px; padding: 14px 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 20px; gap: 12px;
overflow-y: auto; overflow-y: auto;
} }
@@ -505,48 +521,104 @@ tbody tr:hover {
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
padding: 10px 6px 8px;
} }
.viewer-sidebar-head h1 { .viewer-sidebar-head h1 {
margin: 0; margin: 0;
color: var(--accent); color: var(--accent);
font-size: 1.45rem; font-size: 1.45rem;
text-transform: uppercase; letter-spacing: -0.04em;
letter-spacing: 0;
} }
.viewer-channel-list { .viewer-channel-list {
display: grid; display: grid;
gap: 10px; gap: 2px;
} }
.viewer-channel-item { .viewer-channel-item {
display: grid; display: grid;
gap: 4px; grid-template-columns: 52px minmax(0, 1fr);
align-items: center;
gap: 10px;
width: 100%; width: 100%;
padding: 12px; min-height: 68px;
padding: 8px 10px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
border: 1px solid var(--line); border: 1px solid transparent;
background: var(--panel); background: transparent;
color: var(--text-color); color: var(--text-color);
border-radius: 12px;
} }
.viewer-channel-item.active { .viewer-channel-item.active {
border-color: var(--accent); border-color: transparent;
box-shadow: inset 3px 0 0 var(--accent); background: #12342f;
box-shadow: none;
}
.viewer-channel-item:hover {
background: #111;
}
.viewer-channel-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
border-radius: 50%;
background: #5f6b70;
color: white;
font-weight: 700;
letter-spacing: -0.03em;
}
.viewer-channel-copy {
display: grid;
gap: 3px;
min-width: 0;
}
.viewer-channel-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-width: 0;
} }
.viewer-channel-name { .viewer-channel-name {
color: var(--accent); color: var(--text-color);
font-weight: 700; font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.viewer-channel-meta { .viewer-channel-time,
.viewer-channel-preview {
color: var(--dim); color: var(--dim);
font-size: 0.82rem; font-size: 0.82rem;
} }
.viewer-channel-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.viewer-channel-count {
min-width: 22px;
padding: 2px 7px;
border-radius: 999px;
background: #12342f;
color: var(--accent);
font-size: 0.76rem;
text-align: center;
}
/* ---------- Main panel ---------- */ /* ---------- Main panel ---------- */
.viewer-main { .viewer-main {
@@ -554,27 +626,31 @@ tbody tr:hover {
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
overflow: hidden; 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;
} }
.viewer-header { .viewer-header {
flex-shrink: 0; flex-shrink: 0;
padding: 20px 28px; padding: 16px 24px;
border-bottom: 1px dashed var(--line); border-bottom: 1px solid var(--line);
background: var(--panel-alt); background: #050505;
} }
.viewer-header h2 { .viewer-header h2 {
margin: 0; margin: 0;
color: var(--accent); color: var(--accent);
font-size: 1.45rem; font-size: 1.45rem;
text-transform: uppercase; letter-spacing: -0.04em;
letter-spacing: 0;
} }
.messages-list { .messages-list {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 12px 28px 20px; padding: 18px 34px 22px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
@@ -594,9 +670,7 @@ tbody tr:hover {
gap: 12px; gap: 12px;
margin: 12px 0; margin: 12px 0;
font-size: 0.78rem; font-size: 0.78rem;
color: var(--dim); color: #d6e6e8;
text-transform: uppercase;
letter-spacing: 0.03em;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -605,7 +679,14 @@ tbody tr:hover {
content: ""; content: "";
flex: 1; flex: 1;
height: 1px; height: 1px;
background: var(--line); background: #242424;
}
.chat-date-sep {
align-self: center;
padding: 5px 12px;
border-radius: 999px;
background: #111;
} }
.chat-date-sep:empty { .chat-date-sep:empty {
@@ -627,15 +708,16 @@ tbody tr:hover {
/* ---------- Chat bubble ---------- */ /* ---------- Chat bubble ---------- */
.chat-bubble { .chat-bubble {
max-width: 75%; max-width: min(680px, 74%);
padding: 7px 11px; padding: 8px 12px 7px;
background: #1c1c1c; background: var(--bubble-other);
border: 1px solid var(--line); border: 1px solid #242424;
border-radius: 1px; border-radius: 18px 18px 18px 6px;
margin-bottom: 4px; margin-bottom: 6px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
position: relative; position: relative;
box-shadow: none;
} }
.chat-bubble::before { .chat-bubble::before {
@@ -647,19 +729,20 @@ tbody tr:hover {
.chat-bubble-wrap:not(.chat-bubble-wrap--own) .chat-bubble::before { .chat-bubble-wrap:not(.chat-bubble-wrap--own) .chat-bubble::before {
left: -12px; left: -12px;
border-right-color: #1c1c1c; border-right-color: var(--bubble-other);
border-left: 0; border-left: 0;
} }
.chat-bubble-wrap--own .chat-bubble::before { .chat-bubble-wrap--own .chat-bubble::before {
right: -12px; right: -12px;
border-left-color: #1e3a5f; border-left-color: var(--bubble-own);
border-right: 0; border-right: 0;
} }
.chat-bubble-wrap--own .chat-bubble { .chat-bubble-wrap--own .chat-bubble {
background: #1e3a5f; background: var(--bubble-own);
border-color: #2b5278; border-color: #1f5b52;
border-radius: 18px 18px 6px 18px;
} }
.chat-bubble--highlight { .chat-bubble--highlight {
@@ -675,7 +758,7 @@ tbody tr:hover {
.chat-sender { .chat-sender {
font-size: 0.82rem; font-size: 0.82rem;
color: var(--info); color: var(--accent);
font-weight: 700; font-weight: 700;
margin-bottom: 2px; margin-bottom: 2px;
} }
@@ -690,8 +773,10 @@ tbody tr:hover {
display: flex; display: flex;
gap: 6px; gap: 6px;
margin-bottom: 4px; margin-bottom: 4px;
padding: 4px 0; padding: 6px 8px;
cursor: pointer; cursor: pointer;
border-radius: 10px;
background: rgba(0, 0, 0, 0.18);
} }
.chat-reply.hidden { .chat-reply.hidden {
@@ -702,7 +787,7 @@ tbody tr:hover {
flex-shrink: 0; flex-shrink: 0;
width: 2px; width: 2px;
background: var(--accent); background: var(--accent);
border-radius: 1px; border-radius: 999px;
opacity: 0.5; opacity: 0.5;
} }
@@ -729,6 +814,7 @@ tbody tr:hover {
.chat-text { .chat-text {
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.45; line-height: 1.45;
font-size: 0.96rem;
} }
.chat-text:empty { .chat-text:empty {
@@ -749,7 +835,8 @@ tbody tr:hover {
.chat-media video { .chat-media video {
display: block; display: block;
max-width: 100%; max-width: 100%;
border: 1px solid var(--line); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
} }
/* ---------- Footer ---------- */ /* ---------- Footer ---------- */
@@ -783,7 +870,7 @@ tbody tr:hover {
} }
.api-method { .api-method {
border-top: 1px dashed var(--line); border-top: 1px solid var(--line);
padding-top: 12px; padding-top: 12px;
} }
@@ -827,7 +914,7 @@ tbody tr:hover {
.sidebar, .sidebar,
.viewer-sidebar { .viewer-sidebar {
border-right: 0; border-right: 0;
border-bottom: 1px dashed var(--line); border-bottom: 1px solid var(--line);
} }
.panel-grid { .panel-grid {
@@ -835,6 +922,124 @@ tbody tr:hover {
} }
} }
/* ── Account Tabs ─────────────────────────────────────── */
.account-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
}
.account-tab {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 7px 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: #101010;
color: var(--text-color);
cursor: pointer;
font: inherit;
font-size: 0.88rem;
}
.account-tab.active {
border-color: #0f6f67;
color: #ffffff;
background: #0f6f67;
}
.account-tab .account-tab-status {
font-size: 0.72rem;
opacity: 0.7;
}
.account-tab .account-tab-status.ok {
color: var(--ok);
}
.account-tab .account-tab-status.errored {
color: var(--danger);
}
/* ── Account Panels ──────────────────────────────────── */
.account-panel {
display: block;
}
.account-panel.hidden {
display: none;
}
/* ── Settings: Account List ──────────────────────────── */
.accounts-list {
display: grid;
gap: 8px;
margin-bottom: 12px;
}
.account-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: #0f0f0f;
}
.account-list-info {
display: grid;
gap: 2px;
min-width: 0;
}
.account-list-label {
font-weight: 700;
}
.account-list-id {
font-size: 0.82rem;
}
.account-list-auth-status {
font-size: 0.78rem;
}
.account-list-actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.add-account-form {
display: grid;
gap: 8px;
margin-top: 8px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: #0f0f0f;
}
/* ── Active Account Card ─────────────────────────────── */
#active-account-name {
word-break: break-all;
}
#active-account-status {
margin-top: 6px;
}
/* ---------- Mobile: tablet & phone ---------- */ /* ---------- Mobile: tablet & phone ---------- */
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -844,13 +1049,13 @@ tbody tr:hover {
.viewer-sidebar { .viewer-sidebar {
position: fixed; position: fixed;
left: -300px; left: -360px;
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 280px; width: 340px;
z-index: 100; z-index: 100;
transition: left 0.2s ease; transition: left 0.2s ease;
border-right: 1px dashed var(--line); border-right: 1px solid var(--line);
border-bottom: 0; border-bottom: 0;
} }
@@ -939,7 +1144,7 @@ tbody tr:hover {
} }
.viewer-sidebar { .viewer-sidebar {
width: 260px; width: 300px;
padding: 16px; padding: 16px;
} }