style(webui): unify UI across all pages to solid modern system

tokens instead of hardcoded, v9 everywhere, dialog guarded, confirm->dialog, empty unified, health out of nav, swagger icons/topbar, gutters, th uppercase, content 1280 centered.
This commit is contained in:
2026-09-08 12:30:50 +02:00
parent 2a75537fb9
commit ef62b8e94a
9 changed files with 1587 additions and 1668 deletions
+75 -39
View File
@@ -68,7 +68,34 @@ function setBusy(button, busy) {
}
function confirmAction(message) {
return window.confirm(message);
return new Promise((resolve) => {
let settled = false;
const done = (value) => {
if (settled) return;
settled = true;
if (dialog.open) dialog.close();
resolve(value);
};
let dialog = document.getElementById('confirm-dialog');
if (!dialog) {
dialog = document.createElement('dialog');
dialog.id = 'confirm-dialog';
dialog.className = 'confirm-dialog';
dialog.innerHTML =
'<div class="confirm-dialog-body"><div class="eyebrow">Confirm</div>' +
'<p class="confirm-dialog-message muted"></p>' +
'<div class="confirm-dialog-actions">' +
'<button class="button" data-confirm-cancel type="button">Cancel</button>' +
'<button class="button primary" data-confirm-ok type="button">Confirm</button>' +
'</div></div>';
document.body.appendChild(dialog);
}
dialog.querySelector('.confirm-dialog-message').textContent = message;
dialog.querySelector('[data-confirm-ok]').onclick = () => done(true);
dialog.querySelector('[data-confirm-cancel]').onclick = () => done(false);
dialog.onclose = () => done(false);
if (!dialog.open) dialog.showModal();
});
}
function showToast(message, type = 'info') {
@@ -202,7 +229,7 @@ function renderAccountPanel(accountId) {
(item) => item.value,
);
if (!runAllTracked && channels.length === 0 && enabled) {
if (!confirmAction('Continuous scraping enabled with no selected channels. Save anyway?')) return;
if (!(await confirmAction('Continuous scraping enabled with no selected channels. Save anyway?'))) return;
}
const resp = await api(`/api/accounts/${accountId}/continuous`, {
method: 'POST',
@@ -289,15 +316,17 @@ 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 (!nameEl || !statusEl) return;
statusEl.classList.remove('is-ok', 'is-error', 'is-dim');
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)';
statusEl.classList.add(authOk ? 'is-ok' : 'is-error');
} else {
nameEl.textContent = 'None';
statusEl.textContent = 'Add an account in Settings';
statusEl.style.color = 'var(--dim)';
statusEl.classList.add('is-dim');
}
}
@@ -313,7 +342,7 @@ function renderChannels(accountId, channels) {
if (!channels.length) {
const row = document.createElement('tr');
row.innerHTML =
'<td colspan="5"><div class="empty-state">No tracked channels yet. Add an ID or @username to start scraping this account.</div></td>';
'<td colspan="5"><div class="empty-state"><p class="muted">No tracked channels yet. Add an ID or @username to start scraping this account.</p></div></td>';
tbody.appendChild(row);
}
@@ -354,7 +383,7 @@ function renderChannels(accountId, channels) {
});
node.querySelector('.remove-btn').addEventListener('click', async () => {
if (!confirmAction(`Remove ${channel.name} from tracked channels?`)) return;
if (!(await confirmAction(`Remove ${channel.name} from tracked channels?`))) return;
await api(`/api/accounts/${accountId}/channels/remove`, {
method: 'POST',
body: JSON.stringify({ channel_id: channel.channel_id }),
@@ -379,7 +408,7 @@ function renderJobs(accountId, jobs) {
root.innerHTML = '';
if (!jobs.length) {
root.innerHTML = '<div class="empty-state">No jobs yet. Start a scrape or export to see progress here.</div>';
root.innerHTML = '<div class="empty-state"><p class="muted">No jobs yet. Start a scrape or export to see progress here.</p></div>';
return;
}
@@ -628,8 +657,10 @@ function renderSummary(accountId, data) {
const toggle = panel.querySelector('.scrape-media-label');
toggle.textContent = d.scrape_media ? 'ON' : 'OFF';
const healthOk = health.api_credentials && health.session_ready && health.data_dir_exists;
panel.querySelector('.account-health-label').textContent = healthOk ? 'Ready' : 'Check';
panel.querySelector('.account-health-label').style.color = healthOk ? 'var(--ok)' : 'var(--warn)';
const healthLabel = panel.querySelector('.account-health-label');
healthLabel.textContent = healthOk ? 'Ready' : 'Check';
healthLabel.classList.remove('is-ok', 'is-warn');
healthLabel.classList.add(healthOk ? 'is-ok' : 'is-warn');
panel.querySelector('.account-health-detail').textContent = [
`${health.message_count || 0} messages`,
`${health.media_count || 0} media`,
@@ -721,7 +752,9 @@ async function loadAccounts() {
function renderSettingsAccounts() {
const container = document.getElementById('accounts-list');
if (!container) return;
const template = document.getElementById('account-list-item-template');
if (!template) return;
container.innerHTML = '';
state.accounts.forEach((acc) => {
@@ -732,12 +765,12 @@ function renderSettingsAccounts() {
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';
authStatus.classList.remove('is-ok', 'is-error');
authStatus.classList.add(authOk ? 'is-ok' : 'is-error');
node.querySelector('.account-select-btn').addEventListener('click', () => {
switchAccount(acc.id);
document.getElementById('settings-dialog').close();
document.getElementById('settings-dialog')?.close();
});
node.querySelector('.account-export-btn').addEventListener('click', async () => {
@@ -751,7 +784,7 @@ function renderSettingsAccounts() {
});
node.querySelector('.account-remove-btn').addEventListener('click', async () => {
if (!confirmAction(`Remove account "${acc.label || acc.id}"? All its data will be deleted.`)) return;
if (!(await confirmAction(`Remove account "${acc.label || acc.id}"? All its data will be deleted.`))) return;
try {
const removingActive = state.activeAccount === acc.id;
await api(`/api/accounts/${acc.id}`, { method: 'DELETE' });
@@ -773,6 +806,7 @@ function renderSettingsAccounts() {
function updateAuthSection(accountId) {
const label = document.getElementById('auth-account-label');
if (!label) return;
const acc = state.accounts.find((a) => a.id === accountId);
label.textContent = acc ? acc.label || acc.id : '-';
}
@@ -836,7 +870,7 @@ async function submitPassword(accountId) {
async function scrapeAll() {
if (!state.activeAccount) return;
if (!confirmAction('Queue scraping for all tracked channels?')) return;
if (!(await confirmAction('Queue scraping for all tracked channels?'))) return;
await api(`/api/accounts/${state.activeAccount}/jobs/scrape`, {
method: 'POST',
body: JSON.stringify({}),
@@ -846,7 +880,7 @@ async function scrapeAll() {
async function exportAll() {
if (!state.activeAccount) return;
if (!confirmAction('Queue export for all tracked channels?')) return;
if (!(await confirmAction('Queue export for all tracked channels?'))) return;
await api(`/api/accounts/${state.activeAccount}/jobs/export`, {
method: 'POST',
body: JSON.stringify({}),
@@ -901,25 +935,18 @@ async function main() {
}
});
// ── Settings dialog ──
// ── Legacy settings dialog (removed from dashboard; Settings page owns auth) ──
// Kept guarded so old markup, if present, never throws.
const settingsDialog = document.getElementById('settings-dialog');
const openSettingsBtn = document.getElementById('open-settings-btn');
if (!openSettingsBtn.matches('a[href]')) {
openSettingsBtn.addEventListener('click', () => {
// Update auth section for active account
if (state.activeAccount) {
updateAuthSection(state.activeAccount);
const closeSettingsBtn = document.getElementById('close-settings-btn');
if (settingsDialog && closeSettingsBtn) {
closeSettingsBtn.addEventListener('click', () => settingsDialog.close());
}
renderSettingsAccounts();
settingsDialog.showModal();
});
}
document.getElementById('close-settings-btn').addEventListener('click', () => {
settingsDialog.close();
});
// ── Add account form ──
document.getElementById('add-account-form').addEventListener('submit', async (event) => {
// ── Add account form (only when legacy dialog markup exists) ──
const addAccountForm = document.getElementById('add-account-form');
if (addAccountForm) {
addAccountForm.addEventListener('submit', async (event) => {
event.preventDefault();
const accountId = document.getElementById('add-account-id').value.trim();
const label = document.getElementById('add-account-label').value.trim();
@@ -946,8 +973,11 @@ async function main() {
showToast('Failed to add account: ' + err.message, 'error');
}
});
}
document.getElementById('import-account-file').addEventListener('change', async (event) => {
const importAccountFile = document.getElementById('import-account-file');
if (importAccountFile) {
importAccountFile.addEventListener('change', async (event) => {
const file = event.currentTarget.files?.[0];
if (!file) return;
try {
@@ -967,9 +997,15 @@ async function main() {
showToast(`Failed to import account: ${err.message}`, 'error');
}
});
}
// ── Credentials form ──
document.getElementById('credentials-form').addEventListener('submit', async (event) => {
// ── Credentials / auth forms (only when legacy dialog markup exists) ──
const bindOptional = (id, evt, handler) => {
const el = document.getElementById(id);
if (el) el.addEventListener(evt, handler);
};
bindOptional('credentials-form', 'submit', async (event) => {
event.preventDefault();
if (!state.activeAccount) return;
try {
@@ -980,7 +1016,7 @@ async function main() {
});
// ── QR login ──
document.getElementById('start-qr-btn').addEventListener('click', async (event) => {
bindOptional('start-qr-btn', 'click', async (event) => {
setBusy(event.currentTarget, true);
try {
if (!state.activeAccount) return;
@@ -993,7 +1029,7 @@ async function main() {
});
// ── Phone ──
document.getElementById('phone-form').addEventListener('submit', async (event) => {
bindOptional('phone-form', 'submit', async (event) => {
event.preventDefault();
if (!state.activeAccount) return;
try {
@@ -1004,7 +1040,7 @@ async function main() {
});
// ── Code ──
document.getElementById('code-form').addEventListener('submit', async (event) => {
bindOptional('code-form', 'submit', async (event) => {
event.preventDefault();
if (!state.activeAccount) return;
try {
@@ -1015,7 +1051,7 @@ async function main() {
});
// ── Password ──
document.getElementById('password-form').addEventListener('submit', async (event) => {
bindOptional('password-form', 'submit', async (event) => {
event.preventDefault();
if (!state.activeAccount) return;
try {
@@ -1026,7 +1062,7 @@ async function main() {
});
// ── Media toggle ──
document.getElementById('scrape-media-toggle').addEventListener('change', async (event) => {
bindOptional('scrape-media-toggle', 'change', async (event) => {
const checked = event.currentTarget.checked;
try {
await toggleMedia(checked);
+31 -88
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=8" />
<link rel="stylesheet" href="/static/style.css?v=9" />
</head>
<body>
<div class="app-shell">
@@ -43,10 +43,6 @@
<path d="m8 5-5 7 5 7m8-14 5 7-5 7M14 3l-4 18" /></svg
>API Docs</a
>
<a class="nav-link" href="/health"
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12h4l2-6 4 12 2-6h6" /></svg
>Health</a
>
</nav>
<section class="status-card" id="active-account-card">
@@ -90,87 +86,17 @@
</p>
</div>
</div>
<div class="panel-body">
<div class="empty-state">
<div class="empty-actions">
<a class="button primary" href="/settings">Open Settings</a>
</div>
</div>
</div>
</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>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">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>
<label class="import-account-row">
<span class="muted small">Import account settings JSON</span>
<input id="import-account-file" type="file" accept="application/json,.json" />
</label>
</section>
<!-- ── Account Auth ── (shown per account selected in UI) -->
<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" />
<button class="button" type="submit">Save credentials</button>
</form>
<div class="auth-actions">
<button class="button primary" id="start-qr-btn" type="button">Start QR login</button>
</div>
<div id="qr-wrap" class="qr-wrap hidden">
<img id="qr-image" alt="Telegram QR login" />
<p class="muted small">Open Telegram -> Settings -> Devices -> Scan QR.</p>
</div>
<form id="phone-form" class="stack-form">
<input id="phone-input" name="phone" placeholder="+1234567890" />
<button class="button" type="submit">Send code</button>
</form>
<form id="code-form" class="stack-form hidden">
<input id="code-input" name="code" placeholder="Telegram code" />
<button class="button" type="submit">Confirm code</button>
</form>
<form id="password-form" class="stack-form hidden">
<input id="password-input" name="password" type="password" placeholder="2FA password" />
<button class="button" type="submit">Confirm password</button>
</form>
</section>
<!-- ── Scraping ── -->
<section class="settings-section">
<div class="section-title">Scraping</div>
<label class="toggle-row">
<span>Download media</span>
<span class="switch">
<input id="scrape-media-toggle" type="checkbox" />
<span class="switch-slider"></span>
</span>
</label>
</section>
</div>
</dialog>
<!-- ════════ Templates ════════ -->
<!-- Account Tab -->
@@ -239,12 +165,24 @@
<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" />
<input
class="add-channel-id"
name="channel_id"
placeholder="ID or @username"
aria-label="Channel ID or username"
required
/>
<input
class="add-channel-name"
name="name"
placeholder="Display name"
aria-label="Channel display name"
/>
<button class="button primary" type="submit">Add</button>
</form>
</div>
<div class="panel-body">
<div class="table-wrap">
<table>
<thead>
@@ -259,6 +197,7 @@
<tbody class="channels-table"></tbody>
</table>
</div>
</div>
</section>
<section class="panel jobs-panel">
@@ -269,7 +208,9 @@
</div>
<button class="button refresh-jobs-btn">Refresh</button>
</div>
<div class="panel-body">
<div class="jobs-list"></div>
</div>
</section>
</div>
@@ -283,6 +224,7 @@
</div>
</div>
<div class="panel-body">
<form class="continuous-form">
<label class="toggle-row">
<span>Enabled</span>
@@ -291,9 +233,9 @@
<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 class="field">
<span class="field-label">Interval, minutes</span>
<input type="number" class="continuous-interval" min="1" value="1" aria-label="Interval, minutes" />
</label>
<label class="toggle-row">
<span>All tracked channels</span>
@@ -317,6 +259,7 @@
</div>
<div class="log-viewer continuous-logs"></div>
</div>
</section>
</div>
</section>
@@ -372,6 +315,6 @@
</div>
</template>
<script src="/static/app.js?v=5"></script>
<script src="/static/app.js?v=9"></script>
</body>
</html>
+116 -19
View File
@@ -4,23 +4,45 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telegram Scraper Settings</title>
<link rel="stylesheet" href="/static/style.css?v=8" />
<link rel="stylesheet" href="/static/style.css?v=9" />
</head>
<body>
<div class="app-shell settings-page-shell">
<aside class="sidebar">
<div class="brand-block">
<svg class="brand-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="m21 3-7.6 18-3.8-7.6L2 9.6zM9.6 13.4 14 10" />
</svg>
<div class="eyebrow">Telegram Scraper</div>
<h1>Settings</h1>
<p class="muted">Account credentials, import/export, and runtime safety controls.</p>
</div>
<nav class="nav-links">
<a class="nav-link" href="/">Dashboard</a>
<a class="nav-link active" href="/settings">Settings</a>
<a class="nav-link" href="/viewer">Message Viewer</a>
<a class="nav-link" href="/swagger">API Docs</a>
<a class="nav-link" href="/health">Health</a>
<a class="nav-link" href="/"
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="4" width="6" height="6" />
<rect x="14" y="4" width="6" height="6" />
<rect x="4" y="14" width="6" height="6" />
<rect x="14" y="14" width="6" height="6" /></svg
>Dashboard</a
>
<a class="nav-link active" href="/settings"
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path
d="M12 2v3m0 14v3M2 12h3m14 0h3m-2.9-7.1-2.1 2.1M4.9 19.1 7 17m0-10-2.1-2.1m12.2 14.2-2.1-2.1" /></svg
>Settings</a
>
<a class="nav-link" href="/viewer"
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v12H8l-4 3z" /></svg>Message
Viewer</a
>
<a class="nav-link" href="/swagger"
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="m8 5-5 7 5 7m8-14 5 7-5 7M14 3l-4 18" /></svg
>API Docs</a
>
</nav>
<section class="status-card">
@@ -30,11 +52,21 @@
</aside>
<main class="content settings-page">
<header class="dashboard-topbar">
<div class="dashboard-title"><span class="menu-icon" aria-hidden="true"></span>Settings</div>
<div class="runtime-status">
<span class="runtime-dot"></span>Service: Running <i></i> Scraper: Idle <i></i
><span class="runtime-clock"></span>
</div>
</header>
<section class="panel settings-hero">
<div>
<div class="eyebrow">Account Settings</div>
<h2 id="settings-title">Loading accounts</h2>
<p id="settings-subtitle" class="muted">Select an account to manage credentials and scraper options.</p>
<p class="muted small" id="settings-hero-hint">
First step: add your first account below. Export and Delete unlock after you select an account.
</p>
</div>
<div class="action-row">
<button class="button" id="settings-export-btn" type="button" disabled>Export</button>
@@ -44,8 +76,12 @@
<section id="settings-empty" class="panel hidden">
<div class="empty-state">
<div class="eyebrow">Getting started</div>
<h2>Add first account</h2>
<p class="muted">Create an account below or import an exported account JSON.</p>
<p class="muted">Create an account below or import an exported account JSON. No accounts yet.</p>
<div class="empty-actions">
<a class="button primary" href="#settings-add-account-form">Add first account</a>
</div>
</div>
</section>
@@ -76,18 +112,50 @@
<p class="muted">New accounts start without continuous scraping enabled.</p>
</div>
</div>
<div class="panel-body">
<form id="settings-add-account-form" class="stack-form">
<input id="settings-add-account-id" name="account_id" placeholder="Account ID (e.g. work)" required />
<input id="settings-add-account-label" name="label" placeholder="Display name" />
<input id="settings-add-account-api-id" name="api_id" placeholder="API ID" />
<input id="settings-add-account-api-hash" name="api_hash" placeholder="API Hash" />
<button class="button primary" type="submit">Add Account</button>
<label class="field">
<span class="field-label">Account ID</span>
<input
id="settings-add-account-id"
name="account_id"
placeholder="Account ID (e.g. work)"
aria-label="Account ID"
required
/>
<span class="field-hint">Lowercase short id used in file names and URLs.</span>
</label>
<label class="field">
<span class="field-label">Display name</span>
<input
id="settings-add-account-label"
name="label"
placeholder="Display name"
aria-label="Display name"
/>
</label>
<label class="field">
<span class="field-label">API ID</span>
<input id="settings-add-account-api-id" name="api_id" placeholder="API ID" aria-label="API ID" />
<span class="field-hint">From my.telegram.org &gt; API development tools.</span>
</label>
<label class="field">
<span class="field-label">API Hash</span>
<input id="settings-add-account-api-hash" name="api_hash" placeholder="API Hash" aria-label="API Hash" />
<span class="field-hint">Paste the hash exactly as shown on my.telegram.org.</span>
</label>
<button class="button primary button-block" type="submit">Add Account</button>
</form>
<label class="import-account-row">
<span class="muted small">Import account settings JSON</span>
<span class="field-label">Import account settings JSON</span>
<span class="muted small"
>Choose a file previously exported from Settings. It must be account JSON, for example:</span
>
<span class="file-example">{"account_id": "work", "api_id": 12345, ...}</span>
<input id="settings-import-account-file" type="file" accept="application/json,.json" />
</label>
</div>
</div>
<div class="panel">
<div class="panel-header">
@@ -96,9 +164,20 @@
<p class="muted" id="settings-auth-label">No account selected.</p>
</div>
</div>
<div class="panel-body">
<p class="muted small" id="settings-credentials-hint">
Select an account first, then save API credentials and complete login below.
</p>
<form id="settings-credentials-form" class="stack-form">
<input id="settings-api-id-input" name="api_id" placeholder="API ID" />
<input id="settings-api-hash-input" name="api_hash" placeholder="API Hash" />
<label class="field">
<span class="field-label">API ID</span>
<input id="settings-api-id-input" name="api_id" placeholder="API ID" aria-label="API ID" />
<span class="field-hint">From my.telegram.org &gt; API development tools.</span>
</label>
<label class="field">
<span class="field-label">API Hash</span>
<input id="settings-api-hash-input" name="api_hash" placeholder="API Hash" aria-label="API Hash" />
</label>
<button class="button" type="submit">Save credentials</button>
</form>
@@ -112,20 +191,36 @@
</div>
<form id="settings-phone-form" class="stack-form">
<input id="settings-phone-input" name="phone" placeholder="+1234567890" />
<label class="field">
<span class="field-label">Phone number</span>
<input id="settings-phone-input" name="phone" placeholder="+1234567890" aria-label="Phone number" />
</label>
<button class="button" type="submit">Send code</button>
</form>
<form id="settings-code-form" class="stack-form hidden">
<input id="settings-code-input" name="code" placeholder="Telegram code" />
<label class="field">
<span class="field-label">Telegram code</span>
<input id="settings-code-input" name="code" placeholder="Telegram code" aria-label="Telegram code" />
</label>
<button class="button" type="submit">Confirm code</button>
</form>
<form id="settings-password-form" class="stack-form hidden">
<input id="settings-password-input" name="password" type="password" placeholder="2FA password" />
<label class="field">
<span class="field-label">2FA password</span>
<input
id="settings-password-input"
name="password"
type="password"
placeholder="2FA password"
aria-label="2FA password"
/>
</label>
<button class="button" type="submit">Confirm password</button>
</form>
</div>
</div>
<div class="panel">
<div class="panel-header">
@@ -134,6 +229,7 @@
<p class="muted">Account-level parser settings.</p>
</div>
</div>
<div class="panel-body">
<label class="toggle-row">
<span>Download media</span>
<span class="switch">
@@ -147,10 +243,11 @@
<div>Media: <span id="settings-media-count">0</span></div>
</div>
</div>
</div>
</section>
</main>
</div>
<script src="/static/settings.js?v=1"></script>
<script src="/static/settings.js?v=9"></script>
</body>
</html>
+44 -6
View File
@@ -38,6 +38,42 @@ function showToast(message, type = 'info') {
}, 3600);
}
function confirmAction(message) {
return new Promise((resolve) => {
let settled = false;
const done = (value) => {
if (settled) return;
settled = true;
if (dialog.open) dialog.close();
resolve(value);
};
let dialog = document.getElementById('confirm-dialog');
if (!dialog) {
dialog = document.createElement('dialog');
dialog.id = 'confirm-dialog';
dialog.className = 'confirm-dialog';
dialog.innerHTML =
'<div class="confirm-dialog-body"><div class="eyebrow">Confirm</div>' +
'<p class="confirm-dialog-message muted"></p>' +
'<div class="confirm-dialog-actions">' +
'<button class="button" data-confirm-cancel type="button">Cancel</button>' +
'<button class="button primary" data-confirm-ok type="button">Confirm</button>' +
'</div></div>';
document.body.appendChild(dialog);
}
dialog.querySelector('.confirm-dialog-message').textContent = message;
dialog.querySelector('[data-confirm-ok]').onclick = () => done(true);
dialog.querySelector('[data-confirm-cancel]').onclick = () => done(false);
dialog.onclose = () => done(false);
if (!dialog.open) dialog.showModal();
});
}
function setHealthState(el, ok, warnOnly = false) {
el.classList.remove('is-ok', 'is-warn', 'is-dim');
el.classList.add(ok ? 'is-ok' : warnOnly ? 'is-dim' : 'is-warn');
}
function downloadJson(filename, payload) {
const blob = new Blob([JSON.stringify(payload, null, 2) + '\n'], {
type: 'application/json',
@@ -105,7 +141,7 @@ function renderAccountList() {
root.innerHTML = '';
if (!settingsState.accounts.length) {
root.innerHTML = '<div class="empty-state">No accounts yet.</div>';
root.innerHTML = '<div class="empty-state"><p class="muted">No accounts yet.</p></div>';
return;
}
@@ -168,17 +204,19 @@ function renderAccountData() {
: 'No account selected.';
document.getElementById('health-credentials').textContent = health.api_credentials ? 'Saved' : 'Missing';
document.getElementById('health-credentials').style.color = health.api_credentials ? 'var(--ok)' : 'var(--warn)';
setHealthState(document.getElementById('health-credentials'), Boolean(health.api_credentials));
document.getElementById('health-session').textContent =
health.session_ready || isAccountAuthorized(auth) ? 'Ready' : 'Missing';
document.getElementById('health-session').style.color =
health.session_ready || isAccountAuthorized(auth) ? 'var(--ok)' : 'var(--warn)';
setHealthState(
document.getElementById('health-session'),
Boolean(health.session_ready || isAccountAuthorized(auth)),
);
document.getElementById('health-continuous').textContent = continuousStatus.running
? 'Running'
: continuousConfig.enabled
? 'Enabled'
: 'Stopped';
document.getElementById('health-continuous').style.color = continuousStatus.running ? 'var(--ok)' : 'var(--dim)';
setHealthState(document.getElementById('health-continuous'), Boolean(continuousStatus.running), true);
document.getElementById('health-last-scrape').textContent = displayTime(
health.last_scrape || continuousStatus.last_iteration_at,
);
@@ -342,7 +380,7 @@ async function exportAccount() {
async function deleteAccount() {
if (!settingsState.activeAccount) return;
const account = settingsState.accounts.find((item) => item.id === settingsState.activeAccount);
if (!window.confirm(`Remove account "${accountLabel(account)}"? All its account data will be deleted.`)) return;
if (!(await confirmAction(`Remove account "${accountLabel(account)}"? All its account data will be deleted.`))) return;
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}`, { method: 'DELETE' });
setActiveAccount(null);
await loadAccounts();
+1187 -1458
View File
File diff suppressed because it is too large Load Diff
+32 -6
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telegram Scraper API</title>
<link rel="stylesheet" href="/static/style.css?v=6" />
<link rel="stylesheet" href="/static/style.css?v=9" />
</head>
<body>
<div class="viewer-shell">
@@ -17,18 +17,44 @@
<a class="button button-small" href="/">Dashboard</a>
</div>
<nav class="nav-links">
<a class="nav-link" href="/openapi.json">openapi.json</a>
<a class="nav-link" href="/health">Health</a>
<a class="nav-link" href="/viewer">Message Viewer</a>
<a class="nav-link" href="/"
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="4" width="6" height="6" />
<rect x="14" y="4" width="6" height="6" />
<rect x="4" y="14" width="6" height="6" />
<rect x="14" y="14" width="6" height="6" /></svg
>Dashboard</a
>
<a class="nav-link" href="/settings"
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path
d="M12 2v3m0 14v3M2 12h3m14 0h3m-2.9-7.1-2.1 2.1M4.9 19.1 7 17m0-10-2.1-2.1m12.2 14.2-2.1-2.1" /></svg
>Settings</a
>
<a class="nav-link" href="/viewer"
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v12H8l-4 3z" /></svg>Message
Viewer</a
>
<a class="nav-link active" href="/swagger"
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d="m8 5-5 7 5 7m8-14 5 7-5 7M14 3l-4 18" /></svg
>API Docs</a
>
</nav>
</aside>
<main class="viewer-main">
<header class="viewer-header">
<div>
<h2>Endpoints</h2>
<div class="eyebrow">OpenAPI</div>
<h2>API Docs</h2>
<p class="muted">Local API surface exposed by the web UI server.</p>
</div>
<div class="viewer-tools">
<div class="runtime-status"><span class="runtime-dot"></span>Service: Running</div>
<a class="button button-small" href="/openapi.json">openapi.json</a>
</div>
</header>
<section id="api-docs" class="api-docs"></section>
@@ -53,6 +79,6 @@
</article>
</template>
<script src="/static/swagger.js"></script>
<script src="/static/swagger.js?v=9"></script>
</body>
</html>
+47 -2
View File
@@ -19,7 +19,30 @@ function renderSpec(spec) {
const methodTemplate = document.getElementById('api-method-template');
root.innerHTML = '';
Object.entries(spec.paths || {}).forEach(([path, methods]) => {
const paths = Object.entries(spec.paths || {});
if (!paths.length) {
const section = document.createElement('section');
section.className = 'panel';
const header = document.createElement('div');
header.className = 'panel-header';
const title = document.createElement('h2');
title.textContent = 'No endpoints';
header.appendChild(title);
const body = document.createElement('div');
body.className = 'panel-body';
const empty = document.createElement('div');
empty.className = 'empty-state';
const text = document.createElement('p');
text.className = 'muted';
text.textContent = 'The OpenAPI spec contains no paths.';
empty.appendChild(text);
body.appendChild(empty);
section.append(header, body);
root.appendChild(section);
return;
}
paths.forEach(([path, methods]) => {
const section = sectionTemplate.content.firstElementChild.cloneNode(true);
section.querySelector('.api-path').textContent = path;
const methodsRoot = section.querySelector('.api-methods');
@@ -53,6 +76,28 @@ loadSpec()
docsEl.textContent = '';
const section = document.createElement('section');
section.className = 'panel';
section.textContent = error.message;
const header = document.createElement('div');
header.className = 'panel-header';
const title = document.createElement('h2');
title.textContent = 'Failed to load API spec';
header.appendChild(title);
const body = document.createElement('div');
body.className = 'panel-body';
const empty = document.createElement('div');
empty.className = 'empty-state';
const text = document.createElement('p');
text.className = 'muted';
text.textContent = error.message;
const actions = document.createElement('div');
actions.className = 'empty-actions';
const retry = document.createElement('button');
retry.className = 'button';
retry.type = 'button';
retry.textContent = 'Retry';
retry.addEventListener('click', () => window.location.reload());
actions.appendChild(retry);
empty.append(text, actions);
body.appendChild(empty);
section.append(header, body);
docsEl.appendChild(section);
});
+4 -4
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telegram Scraper Viewer</title>
<link rel="stylesheet" href="/static/style.css?v=8" />
<link rel="stylesheet" href="/static/style.css?v=9" />
</head>
<body>
<div class="viewer-shell">
@@ -15,7 +15,7 @@
<h1>Messages</h1>
<p class="muted small">
Account:
<select id="viewer-account-select"></select>
<select id="viewer-account-select" aria-label="Viewer account"></select>
</p>
</div>
<button id="sidebar-toggle" class="button button-small sidebar-toggle"></button>
@@ -31,7 +31,7 @@
<p id="viewer-subtitle" class="muted">Reading messages from the local SQLite database.</p>
</div>
<div class="viewer-tools">
<input id="viewer-search" type="search" placeholder="Search messages" />
<input id="viewer-search" type="search" placeholder="Search messages" aria-label="Search messages" />
<label class="viewer-auto-refresh">
<input id="viewer-auto-refresh" type="checkbox" checked />
<span>Auto-refresh</span>
@@ -80,6 +80,6 @@
</div>
</template>
<script src="/static/viewer.js"></script>
<script src="/static/viewer.js?v=9"></script>
</body>
</html>
+7 -2
View File
@@ -395,10 +395,15 @@ function renderMessages(payload, append = false) {
viewerState.oldestMessageId = null;
viewerState.newestMessageId = null;
const empty = document.createElement('div');
empty.className = 'viewer-empty-state';
empty.textContent = viewerState.search
empty.className = 'empty-state viewer-empty-state';
const title = document.createElement('h2');
title.textContent = viewerState.search ? 'No messages found' : 'No saved messages';
const text = document.createElement('p');
text.className = 'muted';
text.textContent = viewerState.search
? `No messages found for "${viewerState.search}".`
: 'No saved messages in this channel yet.';
empty.append(title, text);
root.appendChild(empty);
}