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
+532 -234
View File
@@ -1,9 +1,17 @@
/* ── State ───────────────────────────────────────────── */
const state = {
dashboard: null,
auth: null,
continuous: null,
accounts: [],
activeAccount: null,
dashboards: {},
continuous: {},
channels: {},
jobs: {},
authStates: {},
};
/* ── API helper ─────────────────────────────────────── */
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json" },
@@ -16,6 +24,8 @@ async function api(path, options = {}) {
return data;
}
/* ── Format helpers ─────────────────────────────────── */
function formatDate(value) {
if (!value) return "-";
return value.replace("T", " ").replace("+00:00", " UTC");
@@ -58,47 +68,165 @@ function confirmAction(message) {
return window.confirm(message);
}
function renderAuth(auth) {
document.getElementById("auth-status").textContent = auth.status;
document.getElementById("auth-details").textContent = auth.details || "";
function isAccountAuthorized(auth) {
const status = auth?.auth_status || auth || {};
return (
auth?.phase === "authorized" ||
auth?.status === "authorized" ||
status.status === "ready" ||
status.status === "authorized"
);
}
function toggleHidden(id, hidden) {
document.getElementById(id).classList.toggle("hidden", hidden);
/* ── Account tab management ─────────────────────────── */
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) {
state.auth = authData;
renderAuth(authData.auth_status);
function renderAccountPanel(accountId) {
const container = document.getElementById("account-panels");
const template = document.getElementById("account-panel-template");
const apiId = authData.saved_credentials?.api_id ?? "";
document.getElementById("api-id-input").value = apiId || "";
document.getElementById("api-hash-input").placeholder = authData.saved_credentials?.api_hash_present
? "API Hash saved"
: "API Hash";
// Don't duplicate
if (document.getElementById(`panel-${accountId}`)) return;
const showQr = Boolean(authData.qr_image);
toggleHidden("qr-wrap", !showQr);
if (showQr) {
document.getElementById("qr-image").src = authData.qr_image;
const node = template.content.firstElementChild.cloneNode(true);
node.id = `panel-${accountId}`;
node.dataset.accountId = accountId;
// 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");
toggleHidden("password-form", authData.phase !== "password_required");
// Update sidebar
updateSidebarAccount();
// Refresh data
refreshAccount(accountId);
// Update settings auth section
updateAuthSection(accountId);
}
function renderSummary(data) {
document.getElementById("channel-count").textContent = String(data.state.channel_count);
document.getElementById("forwarding-count").textContent = String(data.state.forwarding_rules.length);
const toggle = document.getElementById("scrape-media-toggle");
toggle.checked = Boolean(data.state.scrape_media);
document.getElementById("scrape-media-label").textContent = toggle.checked ? "ON" : "OFF";
function updateSidebarAccount() {
const acc = state.accounts.find((a) => a.id === state.activeAccount);
const nameEl = document.getElementById("active-account-name");
const statusEl = document.getElementById("active-account-status");
if (acc) {
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) {
const tbody = document.getElementById("channels-table");
tbody.innerHTML = "";
/* ── Dashboard rendering ────────────────────────────── */
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");
tbody.innerHTML = "";
channels.forEach((channel) => {
const node = template.content.firstElementChild.cloneNode(true);
@@ -109,50 +237,55 @@ function renderChannels(channels) {
node.querySelector(".last-date").textContent = channel.last_date || "-";
node.querySelector(".scrape-btn").addEventListener("click", async () => {
await api("/api/jobs/scrape", {
await api(`/api/accounts/${accountId}/jobs/scrape`, {
method: "POST",
body: JSON.stringify({ channel_id: channel.channel_id }),
});
await refreshDashboard();
await refreshAccount(accountId);
});
node.querySelector(".export-btn").addEventListener("click", async () => {
await api("/api/jobs/export-channel", {
await api(`/api/accounts/${accountId}/jobs/export-channel`, {
method: "POST",
body: JSON.stringify({ channel_id: channel.channel_id }),
});
await refreshDashboard();
await refreshAccount(accountId);
});
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 () => {
await api("/api/jobs/rescrape-media", {
await api(`/api/accounts/${accountId}/jobs/rescrape-media`, {
method: "POST",
body: JSON.stringify({ channel_id: channel.channel_id }),
});
await refreshDashboard();
await refreshAccount(accountId);
});
node.querySelector(".remove-btn").addEventListener("click", async () => {
if (!confirmAction(`Remove ${channel.name} from tracked channels?`)) return;
await api("/api/channels/remove", {
await api(`/api/accounts/${accountId}/channels/remove`, {
method: "POST",
body: JSON.stringify({ channel_id: channel.channel_id }),
});
await refreshDashboard();
await refreshAccount(accountId);
});
tbody.appendChild(node);
});
// Update channel count stat
panel.querySelector(".channel-count").textContent = String(channels.length);
}
function renderJobs(jobs) {
const root = document.getElementById("jobs-list");
root.innerHTML = "";
function renderJobs(accountId, jobs) {
const panel = document.getElementById(`panel-${accountId}`);
if (!panel) return;
const root = panel.querySelector(".jobs-list");
const template = document.getElementById("job-template");
root.innerHTML = "";
if (!jobs.length) {
root.innerHTML = '<div class="muted">No jobs yet.</div>';
@@ -171,18 +304,25 @@ function renderJobs(jobs) {
});
}
function renderContinuousChannelPicker(channels, config) {
const root = document.getElementById("continuous-channel-list");
function renderContinuous(accountId, data) {
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 selected = new Set(config.channels || []);
root.innerHTML = "";
chList.innerHTML = "";
if (!channels.length) {
root.innerHTML = '<div class="muted">No tracked channels.</div>';
return;
}
channels.forEach((channel) => {
channels.forEach((ch) => {
const label = document.createElement("label");
label.className = "checkbox-row";
label.classList.toggle("disabled", runAll);
@@ -190,15 +330,59 @@ function renderContinuousChannelPicker(channels, config) {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "continuous-channel-checkbox";
checkbox.value = channel.channel_id;
checkbox.checked = runAll || selected.has(channel.channel_id);
checkbox.value = ch.channel_id;
checkbox.checked = runAll || selected.has(ch.channel_id);
checkbox.disabled = runAll;
const text = document.createElement("span");
text.textContent = `${channel.name} (${channel.channel_id})`;
text.textContent = `${ch.name} (${ch.channel_id})`;
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) {
const root = document.getElementById("continuous-logs");
const entries = (status.log_entries || status.logs || []).map(normalizeLogEntry);
root.innerHTML = "";
function renderSummary(accountId, data) {
const panel = document.getElementById(`panel-${accountId}`);
if (!panel) return;
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) {
const empty = document.createElement("div");
empty.className = "log-line";
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>';
root.appendChild(empty);
/* ── Account data loading ───────────────────────────── */
async function refreshAccount(accountId) {
try {
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;
}
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;
document.getElementById("no-accounts-msg").classList.add("hidden");
const timeNode = document.createElement("span");
timeNode.className = "log-time";
timeNode.textContent = time;
// Render tabs
renderAccountTabs(accounts);
const ageNode = document.createElement("span");
ageNode.className = "log-age";
ageNode.textContent = entry.timestamp ? relativeTime(entry.timestamp) : "-";
// Render panels for each account
accounts.forEach((acc) => renderAccountPanel(acc.id));
const levelNode = document.createElement("span");
levelNode.className = "log-level";
levelNode.textContent = entry.level.toUpperCase();
// Determine active account
const activeId = state.activeAccount || accounts[0].id;
switchAccount(activeId);
const messageNode = document.createElement("span");
messageNode.className = "log-message";
messageNode.textContent = entry.message;
// Update settings
renderSettingsAccounts();
}
row.append(timeNode, ageNode, levelNode, messageNode);
root.appendChild(row);
/* ── Settings: Accounts list ────────────────────────── */
function renderSettingsAccounts() {
const container = document.getElementById("accounts-list");
const template = document.getElementById("account-list-item-template");
container.innerHTML = "";
state.accounts.forEach((acc) => {
const node = template.content.firstElementChild.cloneNode(true);
node.querySelector(".account-list-label").textContent = acc.label || acc.id;
node.querySelector(".account-list-id").textContent = acc.id;
const authStatus = node.querySelector(".account-list-auth-status");
const authOk = isAccountAuthorized(acc.auth);
authStatus.textContent = authOk ? "Authorized" : "Needs auth";
authStatus.style.color = authOk ? "var(--ok)" : "var(--danger)";
authStatus.style.fontSize = "0.78rem";
node.querySelector(".account-select-btn").addEventListener("click", () => {
switchAccount(acc.id);
document.getElementById("settings-dialog").close();
});
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);
});
}
function renderContinuous(data) {
state.continuous = data;
const config = data.config || {};
const status = data.status || {};
/* ── Auth (operates on active account) ───────────────── */
document.getElementById("continuous-enabled").checked = Boolean(config.enabled);
document.getElementById("continuous-interval").value = config.interval_minutes || 1;
document.getElementById("continuous-all").checked = Boolean(config.run_all_tracked);
renderContinuousChannelPicker(state.dashboard?.channels || [], config);
document.getElementById("continuous-status").textContent = status.running ? "running" : "stopped";
document.getElementById("continuous-last-iteration").textContent = status.last_iteration_at
? `${relativeTime(status.last_iteration_at)} (${displayTime(status.last_iteration_at)})`
: "-";
document.getElementById("continuous-last-error").textContent = status.last_error || "-";
renderContinuousLogs(status);
function updateAuthSection(accountId) {
const label = document.getElementById("auth-account-label");
const acc = state.accounts.find((a) => a.id === accountId);
label.textContent = acc ? (acc.label || acc.id) : "-";
}
function setupTabs() {
const tabs = document.querySelectorAll(".content-tab");
const panels = document.querySelectorAll(".tab-panel");
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
const targetId = tab.dataset.tabTarget;
tabs.forEach((item) => item.classList.toggle("active", item === tab));
panels.forEach((panel) => panel.classList.toggle("active", panel.id === targetId));
});
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 refreshDashboard() {
const data = await api("/api/dashboard");
const authData = await api("/api/auth");
const continuousData = await api("/api/continuous");
state.dashboard = data;
renderAuthPanel(authData);
renderSummary(data);
renderChannels(data.channels);
renderJobs(data.jobs);
renderContinuous(continuousData);
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() {
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");
document.getElementById("open-settings-btn").addEventListener("click", () => {
// Update auth section for active account
if (state.activeAccount) {
updateAuthSection(state.activeAccount);
}
renderSettingsAccounts();
settingsDialog.showModal();
});
document.getElementById("close-settings-btn").addEventListener("click", () => {
settingsDialog.close();
});
document.getElementById("scrape-all-btn").addEventListener("click", async (event) => {
if (!confirmAction("Queue scraping for all tracked channels?")) return;
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) => {
// ── Add account form ──
document.getElementById("add-account-form").addEventListener("submit", async (event) => {
event.preventDefault();
const channelId = document.getElementById("add-channel-id").value.trim();
const name = document.getElementById("add-channel-name").value.trim();
if (!channelId) return;
await api("/api/channels/add", {
const accountId = document.getElementById("add-account-id").value.trim();
const label = document.getElementById("add-account-label").value.trim();
const apiId = document.getElementById("add-account-api-id").value.trim();
const apiHash = document.getElementById("add-account-api-hash").value.trim();
if (!accountId) return;
try {
await api("/api/accounts", {
method: "POST",
body: JSON.stringify({ channel_id: channelId, name }),
body: JSON.stringify({
account_id: accountId,
label: label || accountId,
api_id: apiId ? parseInt(apiId) : undefined,
api_hash: apiHash || undefined,
}),
});
event.currentTarget.reset();
await refreshDashboard();
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) => {
event.preventDefault();
const apiId = document.getElementById("api-id-input").value.trim();
const apiHash = document.getElementById("api-hash-input").value.trim();
await api("/api/auth/credentials", {
method: "POST",
body: JSON.stringify({ api_id: apiId, api_hash: apiHash }),
});
await refreshDashboard();
if (!state.activeAccount) return;
try {
await saveCredentials(state.activeAccount);
} catch (err) {
alert("Failed to save credentials: " + err.message);
}
});
// ── QR login ──
document.getElementById("start-qr-btn").addEventListener("click", async (event) => {
setBusy(event.currentTarget, true);
try {
await api("/api/auth/qr/start", {
method: "POST",
body: JSON.stringify({}),
});
await refreshDashboard();
if (!state.activeAccount) return;
await startQrLogin(state.activeAccount);
} catch (err) {
alert("QR login error: " + err.message);
} finally {
setBusy(event.currentTarget, false);
}
});
// ── Phone ──
document.getElementById("phone-form").addEventListener("submit", async (event) => {
event.preventDefault();
const phone = document.getElementById("phone-input").value.trim();
await api("/api/auth/phone/request", {
method: "POST",
body: JSON.stringify({ phone }),
});
await refreshDashboard();
if (!state.activeAccount) return;
try {
await requestPhoneCode(state.activeAccount);
} catch (err) {
alert("Phone code request error: " + err.message);
}
});
// ── Code ──
document.getElementById("code-form").addEventListener("submit", async (event) => {
event.preventDefault();
const code = document.getElementById("code-input").value.trim();
await api("/api/auth/phone/submit", {
method: "POST",
body: JSON.stringify({ code }),
});
event.currentTarget.reset();
await refreshDashboard();
if (!state.activeAccount) return;
try {
await submitPhoneCode(state.activeAccount);
} catch (err) {
alert("Code submit error: " + err.message);
}
});
// ── Password ──
document.getElementById("password-form").addEventListener("submit", async (event) => {
event.preventDefault();
const password = document.getElementById("password-input").value.trim();
await api("/api/auth/password", {
method: "POST",
body: JSON.stringify({ password }),
});
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;
if (!state.activeAccount) return;
try {
await submitPassword(state.activeAccount);
} catch (err) {
alert("Password error: " + err.message);
}
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", () => {
renderContinuousChannelPicker(state.dashboard?.channels || [], {
...(state.continuous?.config || {}),
run_all_tracked: document.getElementById("continuous-all").checked,
});
// ── 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); }
});
await refreshDashboard();
window.setInterval(refreshDashboard, 5000);
// ── Load accounts ──
await loadAccounts();
// ── Periodic refresh ──
window.setInterval(() => {
if (state.activeAccount) {
refreshAccount(state.activeAccount);
}
}, 5000);
}
main().catch((error) => {
+177 -107
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<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>
<body>
<div class="app-shell">
@@ -22,10 +22,10 @@
<a class="nav-link" href="/health">Health</a>
</nav>
<section class="status-card">
<div class="section-title">Telegram Status</div>
<div id="auth-status" class="status-badge">Checking...</div>
<p id="auth-details" class="muted small"></p>
<section class="status-card" id="active-account-card">
<div class="section-title">Active Account</div>
<div id="active-account-name" class="status-badge">Loading...</div>
<p id="active-account-status" class="muted small"></p>
</section>
<section class="status-card">
@@ -38,127 +38,54 @@
</aside>
<main class="content">
<div class="content-tabs">
<button class="content-tab active" data-tab-target="overview-tab" type="button">Overview</button>
<button class="content-tab" data-tab-target="continuous-tab" type="button">Continuous</button>
<!-- Account Tabs -->
<div id="account-tabs" class="account-tabs"></div>
<!-- Account Dashboard Panels -->
<div id="account-panels">
<!-- Each account's dashboard is rendered here dynamically -->
</div>
<section id="overview-tab" class="tab-panel active">
<section class="panel-grid">
<div class="panel stat-panel">
<div class="section-title">Channels</div>
<div id="channel-count" class="stat-value">0</div>
</div>
<div class="panel stat-panel">
<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">
<!-- Fallback for no accounts -->
<section id="no-accounts-msg" class="panel hidden">
<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>
<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>
<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>
</main>
</div>
<!-- ════════ Settings Dialog ════════ -->
<dialog id="settings-dialog" class="settings-dialog">
<div class="dialog-shell">
<div class="dialog-header">
<div>
<div class="eyebrow">Settings</div>
<h2>Telegram & Scraping</h2>
<h2>Global & Accounts</h2>
</div>
<button class="button button-small" id="close-settings-btn" type="button">Close</button>
</div>
<!-- ── Accounts Management ── -->
<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">
<input id="api-id-input" name="api_id" placeholder="API ID" />
<input id="api-hash-input" name="api_hash" placeholder="API Hash" />
@@ -190,6 +117,7 @@
</form>
</section>
<!-- ── Scraping ── -->
<section class="settings-section">
<div class="section-title">Scraping</div>
<label class="toggle-row">
@@ -203,6 +131,132 @@
</div>
</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">
<tr>
<td>
@@ -224,6 +278,7 @@
</tr>
</template>
<!-- Job Template -->
<template id="job-template">
<article class="job-card">
<div class="job-head">
@@ -235,6 +290,21 @@
</article>
</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>
</html>
+303 -98
View File
@@ -1,17 +1,24 @@
:root {
color-scheme: dark;
--bg-color: #050505;
--panel: #0c0c0c;
--panel-alt: #080808;
--text-color: #e0e0e0;
--accent: #ffffff;
--dim: #6f6f6f;
--line: #2a2a2a;
--danger: #ff6b6b;
--ok: #7ee787;
--warn: #f0c674;
--info: #8ab4f8;
--font-mono: "Courier New", Courier, monospace;
--bg-color: #000000;
--panel: #0b0b0b;
--panel-solid: #101010;
--panel-alt: #050505;
--text-color: #f2f3f5;
--accent: #1fb9aa;
--accent-2: #58a6ff;
--bubble-own: #12342f;
--bubble-other: #171717;
--dim: #9aa0a6;
--line: #242424;
--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 {
margin: 0;
background: var(--bg-color);
background: #000;
color: var(--text-color);
font-family: var(--font-mono);
font-size: 16px;
line-height: 1.55;
font-family: var(--font-ui);
font-size: 15px;
line-height: 1.5;
}
a {
@@ -35,8 +42,9 @@ a {
a:hover,
button:hover {
background: var(--text-color);
color: var(--bg-color);
border-color: #333;
background: #151515;
color: var(--text-color);
}
code,
@@ -50,19 +58,19 @@ button {
height: 100vh;
overflow: hidden;
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
grid-template-columns: 308px minmax(0, 1fr);
}
.sidebar {
background: var(--panel-alt);
border-right: 1px dashed var(--line);
padding: 28px 24px;
background: #050505;
border-right: 1px solid var(--line);
padding: 24px 20px;
}
.content {
overflow-y: auto;
padding: 28px;
max-width: 1320px;
padding: 24px;
max-width: 1440px;
width: 100%;
}
@@ -70,21 +78,16 @@ button {
.dialog-header h2 {
margin: 0;
color: var(--accent);
font-size: 1.45rem;
text-transform: uppercase;
letter-spacing: 0;
font-size: 1.5rem;
letter-spacing: -0.04em;
}
.eyebrow,
.section-title {
color: var(--dim);
font-size: 0.78rem;
font-size: 0.75rem;
text-transform: uppercase;
}
.eyebrow::before,
.section-title::before {
content: "./";
letter-spacing: 0.08em;
}
.muted {
@@ -112,8 +115,9 @@ input,
.content-tab,
.viewer-channel-item {
border: 1px solid var(--line);
background: var(--panel);
background: #101010;
color: var(--text-color);
border-radius: 999px;
}
.nav-link,
@@ -125,21 +129,23 @@ input,
min-height: 42px;
padding: 9px 13px;
cursor: pointer;
transition: background-color 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
}
.nav-link.active,
.button.primary,
.content-tab.active {
border-color: var(--accent);
color: var(--accent);
box-shadow: inset 3px 0 0 var(--accent);
border-color: #0f6f67;
color: #ffffff;
background: #0f6f67;
box-shadow: none;
}
.nav-link.active:hover,
.button.primary:hover,
.content-tab.active:hover {
background: var(--panel);
color: var(--accent);
background: #128277;
color: #ffffff;
}
.button.danger {
@@ -164,7 +170,9 @@ input,
.message-card,
.settings-dialog {
background: var(--panel);
border: 1px dashed var(--line);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.status-card,
@@ -185,10 +193,10 @@ input,
.status-badge {
display: inline-flex;
padding: 4px 8px;
padding: 6px 10px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--ok);
text-transform: uppercase;
}
.content-tabs {
@@ -228,8 +236,9 @@ input,
.stat-value {
margin-top: 8px;
font-size: 2rem;
font-size: 2.25rem;
font-weight: 700;
letter-spacing: -0.05em;
}
.stat-compact {
@@ -250,8 +259,9 @@ input,
input {
min-width: 160px;
padding: 9px 11px;
padding: 10px 13px;
outline: 0;
border-radius: 14px;
}
input:focus {
@@ -286,7 +296,8 @@ input:focus {
position: absolute;
inset: 0;
border: 1px solid var(--line);
background: #101010;
background: rgba(255, 255, 255, 0.06);
border-radius: 999px;
}
.switch-slider::before {
@@ -297,6 +308,7 @@ input:focus {
width: 16px;
height: 16px;
background: var(--dim);
border-radius: 50%;
transition: transform 0.16s ease, background-color 0.16s ease;
}
@@ -323,11 +335,11 @@ td {
padding: 12px 10px;
text-align: left;
vertical-align: top;
border-bottom: 1px dashed var(--line);
border-bottom: 1px solid var(--line);
}
tbody tr:hover {
background: #101010;
background: #111;
}
.action-row {
@@ -375,7 +387,9 @@ tbody tr:hover {
gap: 10px;
min-height: 38px;
padding: 8px;
border: 1px dashed var(--line);
border: 1px solid var(--line);
border-radius: 14px;
background: #0f0f0f;
}
.checkbox-row input {
@@ -391,7 +405,8 @@ tbody tr:hover {
max-height: 420px;
overflow: auto;
border: 1px solid var(--line);
background: #030303;
background: rgba(0, 0, 0, 0.2);
border-radius: 16px;
}
.log-line {
@@ -404,7 +419,7 @@ tbody tr:hover {
}
.log-line:hover {
background: #101010;
background: #111;
}
.log-time,
@@ -455,13 +470,14 @@ tbody tr:hover {
.settings-section {
margin-top: 18px;
padding-top: 16px;
border-top: 1px dashed var(--line);
border-top: 1px solid var(--line);
}
.qr-wrap {
margin-top: 14px;
padding: 12px;
border: 1px dashed var(--line);
border: 1px solid var(--line);
border-radius: 16px;
}
.qr-wrap img {
@@ -481,18 +497,18 @@ tbody tr:hover {
.viewer-shell {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
grid-template-columns: 360px minmax(0, 1fr);
height: 100vh;
overflow: hidden;
}
.viewer-sidebar {
background: var(--panel-alt);
border-right: 1px dashed var(--line);
padding: 28px 24px;
background: #050505;
border-right: 1px solid var(--line);
padding: 14px 10px;
display: flex;
flex-direction: column;
gap: 20px;
gap: 12px;
overflow-y: auto;
}
@@ -505,48 +521,104 @@ tbody tr:hover {
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 10px 6px 8px;
}
.viewer-sidebar-head h1 {
margin: 0;
color: var(--accent);
font-size: 1.45rem;
text-transform: uppercase;
letter-spacing: 0;
letter-spacing: -0.04em;
}
.viewer-channel-list {
display: grid;
gap: 10px;
gap: 2px;
}
.viewer-channel-item {
display: grid;
gap: 4px;
grid-template-columns: 52px minmax(0, 1fr);
align-items: center;
gap: 10px;
width: 100%;
padding: 12px;
min-height: 68px;
padding: 8px 10px;
text-align: left;
cursor: pointer;
border: 1px solid var(--line);
background: var(--panel);
border: 1px solid transparent;
background: transparent;
color: var(--text-color);
border-radius: 12px;
}
.viewer-channel-item.active {
border-color: var(--accent);
box-shadow: inset 3px 0 0 var(--accent);
border-color: transparent;
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 {
color: var(--accent);
color: var(--text-color);
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.viewer-channel-meta {
.viewer-channel-time,
.viewer-channel-preview {
color: var(--dim);
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 ---------- */
.viewer-main {
@@ -554,27 +626,31 @@ tbody tr:hover {
flex-direction: column;
height: 100vh;
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 {
flex-shrink: 0;
padding: 20px 28px;
border-bottom: 1px dashed var(--line);
background: var(--panel-alt);
padding: 16px 24px;
border-bottom: 1px solid var(--line);
background: #050505;
}
.viewer-header h2 {
margin: 0;
color: var(--accent);
font-size: 1.45rem;
text-transform: uppercase;
letter-spacing: 0;
letter-spacing: -0.04em;
}
.messages-list {
flex: 1;
overflow-y: auto;
padding: 12px 28px 20px;
padding: 18px 34px 22px;
display: flex;
flex-direction: column;
}
@@ -594,9 +670,7 @@ tbody tr:hover {
gap: 12px;
margin: 12px 0;
font-size: 0.78rem;
color: var(--dim);
text-transform: uppercase;
letter-spacing: 0.03em;
color: #d6e6e8;
flex-shrink: 0;
}
@@ -605,7 +679,14 @@ tbody tr:hover {
content: "";
flex: 1;
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 {
@@ -627,15 +708,16 @@ tbody tr:hover {
/* ---------- Chat bubble ---------- */
.chat-bubble {
max-width: 75%;
padding: 7px 11px;
background: #1c1c1c;
border: 1px solid var(--line);
border-radius: 1px;
margin-bottom: 4px;
max-width: min(680px, 74%);
padding: 8px 12px 7px;
background: var(--bubble-other);
border: 1px solid #242424;
border-radius: 18px 18px 18px 6px;
margin-bottom: 6px;
overflow-wrap: anywhere;
word-break: break-word;
position: relative;
box-shadow: none;
}
.chat-bubble::before {
@@ -647,19 +729,20 @@ tbody tr:hover {
.chat-bubble-wrap:not(.chat-bubble-wrap--own) .chat-bubble::before {
left: -12px;
border-right-color: #1c1c1c;
border-right-color: var(--bubble-other);
border-left: 0;
}
.chat-bubble-wrap--own .chat-bubble::before {
right: -12px;
border-left-color: #1e3a5f;
border-left-color: var(--bubble-own);
border-right: 0;
}
.chat-bubble-wrap--own .chat-bubble {
background: #1e3a5f;
border-color: #2b5278;
background: var(--bubble-own);
border-color: #1f5b52;
border-radius: 18px 18px 6px 18px;
}
.chat-bubble--highlight {
@@ -675,7 +758,7 @@ tbody tr:hover {
.chat-sender {
font-size: 0.82rem;
color: var(--info);
color: var(--accent);
font-weight: 700;
margin-bottom: 2px;
}
@@ -690,8 +773,10 @@ tbody tr:hover {
display: flex;
gap: 6px;
margin-bottom: 4px;
padding: 4px 0;
padding: 6px 8px;
cursor: pointer;
border-radius: 10px;
background: rgba(0, 0, 0, 0.18);
}
.chat-reply.hidden {
@@ -702,7 +787,7 @@ tbody tr:hover {
flex-shrink: 0;
width: 2px;
background: var(--accent);
border-radius: 1px;
border-radius: 999px;
opacity: 0.5;
}
@@ -729,6 +814,7 @@ tbody tr:hover {
.chat-text {
white-space: pre-wrap;
line-height: 1.45;
font-size: 0.96rem;
}
.chat-text:empty {
@@ -749,7 +835,8 @@ tbody tr:hover {
.chat-media video {
display: block;
max-width: 100%;
border: 1px solid var(--line);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 14px;
}
/* ---------- Footer ---------- */
@@ -783,7 +870,7 @@ tbody tr:hover {
}
.api-method {
border-top: 1px dashed var(--line);
border-top: 1px solid var(--line);
padding-top: 12px;
}
@@ -827,7 +914,7 @@ tbody tr:hover {
.sidebar,
.viewer-sidebar {
border-right: 0;
border-bottom: 1px dashed var(--line);
border-bottom: 1px solid var(--line);
}
.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 ---------- */
@media (max-width: 768px) {
@@ -844,13 +1049,13 @@ tbody tr:hover {
.viewer-sidebar {
position: fixed;
left: -300px;
left: -360px;
top: 0;
bottom: 0;
width: 280px;
width: 340px;
z-index: 100;
transition: left 0.2s ease;
border-right: 1px dashed var(--line);
border-right: 1px solid var(--line);
border-bottom: 0;
}
@@ -939,7 +1144,7 @@ tbody tr:hover {
}
.viewer-sidebar {
width: 260px;
width: 300px;
padding: 16px;
}