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:
+534
-236
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user