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
+77 -41
View File
@@ -68,7 +68,34 @@ function setBusy(button, busy) {
} }
function confirmAction(message) { 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') { function showToast(message, type = 'info') {
@@ -202,7 +229,7 @@ function renderAccountPanel(accountId) {
(item) => item.value, (item) => item.value,
); );
if (!runAllTracked && channels.length === 0 && enabled) { 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`, { const resp = await api(`/api/accounts/${accountId}/continuous`, {
method: 'POST', method: 'POST',
@@ -289,15 +316,17 @@ function updateSidebarAccount() {
const acc = state.accounts.find((a) => a.id === state.activeAccount); const acc = state.accounts.find((a) => a.id === state.activeAccount);
const nameEl = document.getElementById('active-account-name'); const nameEl = document.getElementById('active-account-name');
const statusEl = document.getElementById('active-account-status'); const statusEl = document.getElementById('active-account-status');
if (!nameEl || !statusEl) return;
statusEl.classList.remove('is-ok', 'is-error', 'is-dim');
if (acc) { if (acc) {
nameEl.textContent = acc.label || acc.id; nameEl.textContent = acc.label || acc.id;
const authOk = isAccountAuthorized(acc.auth); const authOk = isAccountAuthorized(acc.auth);
statusEl.textContent = authOk ? 'Authorized session' : 'Needs login'; statusEl.textContent = authOk ? 'Authorized session' : 'Needs login';
statusEl.style.color = authOk ? 'var(--ok)' : 'var(--danger)'; statusEl.classList.add(authOk ? 'is-ok' : 'is-error');
} else { } else {
nameEl.textContent = 'None'; nameEl.textContent = 'None';
statusEl.textContent = 'Add an account in Settings'; 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) { if (!channels.length) {
const row = document.createElement('tr'); const row = document.createElement('tr');
row.innerHTML = 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); tbody.appendChild(row);
} }
@@ -354,7 +383,7 @@ function renderChannels(accountId, channels) {
}); });
node.querySelector('.remove-btn').addEventListener('click', async () => { 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`, { 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 }),
@@ -379,7 +408,7 @@ function renderJobs(accountId, jobs) {
root.innerHTML = ''; root.innerHTML = '';
if (!jobs.length) { 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; return;
} }
@@ -628,8 +657,10 @@ function renderSummary(accountId, data) {
const toggle = panel.querySelector('.scrape-media-label'); const toggle = panel.querySelector('.scrape-media-label');
toggle.textContent = d.scrape_media ? 'ON' : 'OFF'; toggle.textContent = d.scrape_media ? 'ON' : 'OFF';
const healthOk = health.api_credentials && health.session_ready && health.data_dir_exists; const healthOk = health.api_credentials && health.session_ready && health.data_dir_exists;
panel.querySelector('.account-health-label').textContent = healthOk ? 'Ready' : 'Check'; const healthLabel = panel.querySelector('.account-health-label');
panel.querySelector('.account-health-label').style.color = healthOk ? 'var(--ok)' : 'var(--warn)'; 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 = [ panel.querySelector('.account-health-detail').textContent = [
`${health.message_count || 0} messages`, `${health.message_count || 0} messages`,
`${health.media_count || 0} media`, `${health.media_count || 0} media`,
@@ -721,7 +752,9 @@ async function loadAccounts() {
function renderSettingsAccounts() { function renderSettingsAccounts() {
const container = document.getElementById('accounts-list'); const container = document.getElementById('accounts-list');
if (!container) return;
const template = document.getElementById('account-list-item-template'); const template = document.getElementById('account-list-item-template');
if (!template) return;
container.innerHTML = ''; container.innerHTML = '';
state.accounts.forEach((acc) => { state.accounts.forEach((acc) => {
@@ -732,12 +765,12 @@ function renderSettingsAccounts() {
const authStatus = node.querySelector('.account-list-auth-status'); const authStatus = node.querySelector('.account-list-auth-status');
const authOk = isAccountAuthorized(acc.auth); const authOk = isAccountAuthorized(acc.auth);
authStatus.textContent = authOk ? 'Authorized' : 'Needs auth'; authStatus.textContent = authOk ? 'Authorized' : 'Needs auth';
authStatus.style.color = authOk ? 'var(--ok)' : 'var(--danger)'; authStatus.classList.remove('is-ok', 'is-error');
authStatus.style.fontSize = '0.78rem'; authStatus.classList.add(authOk ? 'is-ok' : 'is-error');
node.querySelector('.account-select-btn').addEventListener('click', () => { node.querySelector('.account-select-btn').addEventListener('click', () => {
switchAccount(acc.id); switchAccount(acc.id);
document.getElementById('settings-dialog').close(); document.getElementById('settings-dialog')?.close();
}); });
node.querySelector('.account-export-btn').addEventListener('click', async () => { node.querySelector('.account-export-btn').addEventListener('click', async () => {
@@ -751,7 +784,7 @@ function renderSettingsAccounts() {
}); });
node.querySelector('.account-remove-btn').addEventListener('click', async () => { 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 { try {
const removingActive = state.activeAccount === acc.id; const removingActive = state.activeAccount === acc.id;
await api(`/api/accounts/${acc.id}`, { method: 'DELETE' }); await api(`/api/accounts/${acc.id}`, { method: 'DELETE' });
@@ -773,6 +806,7 @@ function renderSettingsAccounts() {
function updateAuthSection(accountId) { function updateAuthSection(accountId) {
const label = document.getElementById('auth-account-label'); const label = document.getElementById('auth-account-label');
if (!label) return;
const acc = state.accounts.find((a) => a.id === accountId); const acc = state.accounts.find((a) => a.id === accountId);
label.textContent = acc ? acc.label || acc.id : '-'; label.textContent = acc ? acc.label || acc.id : '-';
} }
@@ -836,7 +870,7 @@ async function submitPassword(accountId) {
async function scrapeAll() { async function scrapeAll() {
if (!state.activeAccount) return; 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`, { await api(`/api/accounts/${state.activeAccount}/jobs/scrape`, {
method: 'POST', method: 'POST',
body: JSON.stringify({}), body: JSON.stringify({}),
@@ -846,7 +880,7 @@ async function scrapeAll() {
async function exportAll() { async function exportAll() {
if (!state.activeAccount) return; 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`, { await api(`/api/accounts/${state.activeAccount}/jobs/export`, {
method: 'POST', method: 'POST',
body: JSON.stringify({}), 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 settingsDialog = document.getElementById('settings-dialog');
const openSettingsBtn = document.getElementById('open-settings-btn'); const closeSettingsBtn = document.getElementById('close-settings-btn');
if (!openSettingsBtn.matches('a[href]')) { if (settingsDialog && closeSettingsBtn) {
openSettingsBtn.addEventListener('click', () => { closeSettingsBtn.addEventListener('click', () => settingsDialog.close());
// Update auth section for active account
if (state.activeAccount) {
updateAuthSection(state.activeAccount);
}
renderSettingsAccounts();
settingsDialog.showModal();
});
} }
document.getElementById('close-settings-btn').addEventListener('click', () => {
settingsDialog.close();
});
// ── Add account form ── // ── Add account form (only when legacy dialog markup exists) ──
document.getElementById('add-account-form').addEventListener('submit', async (event) => { const addAccountForm = document.getElementById('add-account-form');
if (addAccountForm) {
addAccountForm.addEventListener('submit', async (event) => {
event.preventDefault(); event.preventDefault();
const accountId = document.getElementById('add-account-id').value.trim(); const accountId = document.getElementById('add-account-id').value.trim();
const label = document.getElementById('add-account-label').value.trim(); const label = document.getElementById('add-account-label').value.trim();
@@ -945,9 +972,12 @@ async function main() {
} catch (err) { } catch (err) {
showToast('Failed to add account: ' + err.message, 'error'); 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]; const file = event.currentTarget.files?.[0];
if (!file) return; if (!file) return;
try { try {
@@ -966,10 +996,16 @@ async function main() {
} catch (err) { } catch (err) {
showToast(`Failed to import account: ${err.message}`, 'error'); showToast(`Failed to import account: ${err.message}`, 'error');
} }
}); });
}
// ── Credentials form ── // ── Credentials / auth forms (only when legacy dialog markup exists) ──
document.getElementById('credentials-form').addEventListener('submit', async (event) => { const bindOptional = (id, evt, handler) => {
const el = document.getElementById(id);
if (el) el.addEventListener(evt, handler);
};
bindOptional('credentials-form', 'submit', async (event) => {
event.preventDefault(); event.preventDefault();
if (!state.activeAccount) return; if (!state.activeAccount) return;
try { try {
@@ -980,7 +1016,7 @@ async function main() {
}); });
// ── QR login ── // ── QR login ──
document.getElementById('start-qr-btn').addEventListener('click', async (event) => { bindOptional('start-qr-btn', 'click', async (event) => {
setBusy(event.currentTarget, true); setBusy(event.currentTarget, true);
try { try {
if (!state.activeAccount) return; if (!state.activeAccount) return;
@@ -993,7 +1029,7 @@ async function main() {
}); });
// ── Phone ── // ── Phone ──
document.getElementById('phone-form').addEventListener('submit', async (event) => { bindOptional('phone-form', 'submit', async (event) => {
event.preventDefault(); event.preventDefault();
if (!state.activeAccount) return; if (!state.activeAccount) return;
try { try {
@@ -1004,7 +1040,7 @@ async function main() {
}); });
// ── Code ── // ── Code ──
document.getElementById('code-form').addEventListener('submit', async (event) => { bindOptional('code-form', 'submit', async (event) => {
event.preventDefault(); event.preventDefault();
if (!state.activeAccount) return; if (!state.activeAccount) return;
try { try {
@@ -1015,7 +1051,7 @@ async function main() {
}); });
// ── Password ── // ── Password ──
document.getElementById('password-form').addEventListener('submit', async (event) => { bindOptional('password-form', 'submit', async (event) => {
event.preventDefault(); event.preventDefault();
if (!state.activeAccount) return; if (!state.activeAccount) return;
try { try {
@@ -1026,7 +1062,7 @@ async function main() {
}); });
// ── Media toggle ── // ── Media toggle ──
document.getElementById('scrape-media-toggle').addEventListener('change', async (event) => { bindOptional('scrape-media-toggle', 'change', async (event) => {
const checked = event.currentTarget.checked; const checked = event.currentTarget.checked;
try { try {
await toggleMedia(checked); await toggleMedia(checked);
+73 -130
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telegram Scraper Control Panel</title> <title>Telegram Scraper Control Panel</title>
<link rel="stylesheet" href="/static/style.css?v=8" /> <link rel="stylesheet" href="/static/style.css?v=9" />
</head> </head>
<body> <body>
<div class="app-shell"> <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 <path d="m8 5-5 7 5 7m8-14 5 7-5 7M14 3l-4 18" /></svg
>API Docs</a >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> </nav>
<section class="status-card" id="active-account-card"> <section class="status-card" id="active-account-card">
@@ -90,87 +86,17 @@
</p> </p>
</div> </div>
</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> </section>
</main> </main>
</div> </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 ════════ --> <!-- ════════ Templates ════════ -->
<!-- Account Tab --> <!-- Account Tab -->
@@ -239,25 +165,38 @@
<p class="muted">Per-account channel list.</p> <p class="muted">Per-account channel list.</p>
</div> </div>
<form class="inline-form add-channel-form"> <form class="inline-form add-channel-form">
<input class="add-channel-id" name="channel_id" placeholder="ID or @username" required /> <input
<input class="add-channel-name" name="name" placeholder="Display name" /> 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> <button class="button primary" type="submit">Add</button>
</form> </form>
</div> </div>
<div class="table-wrap"> <div class="panel-body">
<table> <div class="table-wrap">
<thead> <table>
<tr> <thead>
<th>Channel</th> <tr>
<th>Messages</th> <th>Channel</th>
<th>Media</th> <th>Messages</th>
<th>Last message</th> <th>Media</th>
<th>Actions</th> <th>Last message</th>
</tr> <th>Actions</th>
</thead> </tr>
<tbody class="channels-table"></tbody> </thead>
</table> <tbody class="channels-table"></tbody>
</table>
</div>
</div> </div>
</section> </section>
@@ -269,7 +208,9 @@
</div> </div>
<button class="button refresh-jobs-btn">Refresh</button> <button class="button refresh-jobs-btn">Refresh</button>
</div> </div>
<div class="jobs-list"></div> <div class="panel-body">
<div class="jobs-list"></div>
</div>
</section> </section>
</div> </div>
@@ -283,40 +224,42 @@
</div> </div>
</div> </div>
<form class="continuous-form"> <div class="panel-body">
<label class="toggle-row"> <form class="continuous-form">
<span>Enabled</span> <label class="toggle-row">
<span class="switch"> <span>Enabled</span>
<input type="checkbox" class="continuous-enabled" checked /> <span class="switch">
<span class="switch-slider"></span> <input type="checkbox" class="continuous-enabled" checked />
</span> <span class="switch-slider"></span>
</label> </span>
<label class="stack-form"> </label>
<span class="muted small">Interval, minutes</span> <label class="field">
<input type="number" class="continuous-interval" min="1" value="1" /> <span class="field-label">Interval, minutes</span>
</label> <input type="number" class="continuous-interval" min="1" value="1" aria-label="Interval, minutes" />
<label class="toggle-row"> </label>
<span>All tracked channels</span> <label class="toggle-row">
<span class="switch"> <span>All tracked channels</span>
<input type="checkbox" class="continuous-all" checked /> <span class="switch">
<span class="switch-slider"></span> <input type="checkbox" class="continuous-all" checked />
</span> <span class="switch-slider"></span>
</label> </span>
<div class="channel-picker"> </label>
<div class="muted small">Continuous channel set</div> <div class="channel-picker">
<div class="checkbox-grid continuous-channel-list"></div> <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>Next run: <span class="continuous-next-run">-</span></div>
<div>Last error: <span class="continuous-last-error">-</span></div>
</div> </div>
<button class="button primary" type="submit">Save continuous settings</button>
</form>
<div class="continuous-meta"> <div class="log-viewer continuous-logs"></div>
<div>Status: <span class="continuous-status">-</span></div>
<div>Last run: <span class="continuous-last-iteration">-</span></div>
<div>Next run: <span class="continuous-next-run">-</span></div>
<div>Last error: <span class="continuous-last-error">-</span></div>
</div> </div>
<div class="log-viewer continuous-logs"></div>
</section> </section>
</div> </div>
</section> </section>
@@ -372,6 +315,6 @@
</div> </div>
</template> </template>
<script src="/static/app.js?v=5"></script> <script src="/static/app.js?v=9"></script>
</body> </body>
</html> </html>
+116 -19
View File
@@ -4,23 +4,45 @@
<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 Settings</title> <title>Telegram Scraper Settings</title>
<link rel="stylesheet" href="/static/style.css?v=8" /> <link rel="stylesheet" href="/static/style.css?v=9" />
</head> </head>
<body> <body>
<div class="app-shell settings-page-shell"> <div class="app-shell settings-page-shell">
<aside class="sidebar"> <aside class="sidebar">
<div class="brand-block"> <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> <div class="eyebrow">Telegram Scraper</div>
<h1>Settings</h1> <h1>Settings</h1>
<p class="muted">Account credentials, import/export, and runtime safety controls.</p> <p class="muted">Account credentials, import/export, and runtime safety controls.</p>
</div> </div>
<nav class="nav-links"> <nav class="nav-links">
<a class="nav-link" href="/">Dashboard</a> <a class="nav-link" href="/"
<a class="nav-link active" href="/settings">Settings</a> ><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<a class="nav-link" href="/viewer">Message Viewer</a> <rect x="4" y="4" width="6" height="6" />
<a class="nav-link" href="/swagger">API Docs</a> <rect x="14" y="4" width="6" height="6" />
<a class="nav-link" href="/health">Health</a> <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> </nav>
<section class="status-card"> <section class="status-card">
@@ -30,11 +52,21 @@
</aside> </aside>
<main class="content settings-page"> <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"> <section class="panel settings-hero">
<div> <div>
<div class="eyebrow">Account Settings</div> <div class="eyebrow">Account Settings</div>
<h2 id="settings-title">Loading accounts</h2> <h2 id="settings-title">Loading accounts</h2>
<p id="settings-subtitle" class="muted">Select an account to manage credentials and scraper options.</p> <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>
<div class="action-row"> <div class="action-row">
<button class="button" id="settings-export-btn" type="button" disabled>Export</button> <button class="button" id="settings-export-btn" type="button" disabled>Export</button>
@@ -44,8 +76,12 @@
<section id="settings-empty" class="panel hidden"> <section id="settings-empty" class="panel hidden">
<div class="empty-state"> <div class="empty-state">
<div class="eyebrow">Getting started</div>
<h2>Add first account</h2> <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> </div>
</section> </section>
@@ -76,17 +112,49 @@
<p class="muted">New accounts start without continuous scraping enabled.</p> <p class="muted">New accounts start without continuous scraping enabled.</p>
</div> </div>
</div> </div>
<div class="panel-body">
<form id="settings-add-account-form" class="stack-form"> <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 /> <label class="field">
<input id="settings-add-account-label" name="label" placeholder="Display name" /> <span class="field-label">Account ID</span>
<input id="settings-add-account-api-id" name="api_id" placeholder="API ID" /> <input
<input id="settings-add-account-api-hash" name="api_hash" placeholder="API Hash" /> id="settings-add-account-id"
<button class="button primary" type="submit">Add Account</button> 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> </form>
<label class="import-account-row"> <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" /> <input id="settings-import-account-file" type="file" accept="application/json,.json" />
</label> </label>
</div>
</div> </div>
<div class="panel"> <div class="panel">
@@ -96,9 +164,20 @@
<p class="muted" id="settings-auth-label">No account selected.</p> <p class="muted" id="settings-auth-label">No account selected.</p>
</div> </div>
</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"> <form id="settings-credentials-form" class="stack-form">
<input id="settings-api-id-input" name="api_id" placeholder="API ID" /> <label class="field">
<input id="settings-api-hash-input" name="api_hash" placeholder="API Hash" /> <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> <button class="button" type="submit">Save credentials</button>
</form> </form>
@@ -112,19 +191,35 @@
</div> </div>
<form id="settings-phone-form" class="stack-form"> <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> <button class="button" type="submit">Send code</button>
</form> </form>
<form id="settings-code-form" class="stack-form hidden"> <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> <button class="button" type="submit">Confirm code</button>
</form> </form>
<form id="settings-password-form" class="stack-form hidden"> <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> <button class="button" type="submit">Confirm password</button>
</form> </form>
</div>
</div> </div>
<div class="panel"> <div class="panel">
@@ -134,6 +229,7 @@
<p class="muted">Account-level parser settings.</p> <p class="muted">Account-level parser settings.</p>
</div> </div>
</div> </div>
<div class="panel-body">
<label class="toggle-row"> <label class="toggle-row">
<span>Download media</span> <span>Download media</span>
<span class="switch"> <span class="switch">
@@ -146,11 +242,12 @@
<div>Messages: <span id="settings-message-count">0</span></div> <div>Messages: <span id="settings-message-count">0</span></div>
<div>Media: <span id="settings-media-count">0</span></div> <div>Media: <span id="settings-media-count">0</span></div>
</div> </div>
</div>
</div> </div>
</section> </section>
</main> </main>
</div> </div>
<script src="/static/settings.js?v=1"></script> <script src="/static/settings.js?v=9"></script>
</body> </body>
</html> </html>
+44 -6
View File
@@ -38,6 +38,42 @@ function showToast(message, type = 'info') {
}, 3600); }, 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) { function downloadJson(filename, payload) {
const blob = new Blob([JSON.stringify(payload, null, 2) + '\n'], { const blob = new Blob([JSON.stringify(payload, null, 2) + '\n'], {
type: 'application/json', type: 'application/json',
@@ -105,7 +141,7 @@ function renderAccountList() {
root.innerHTML = ''; root.innerHTML = '';
if (!settingsState.accounts.length) { 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; return;
} }
@@ -168,17 +204,19 @@ function renderAccountData() {
: 'No account selected.'; : 'No account selected.';
document.getElementById('health-credentials').textContent = health.api_credentials ? 'Saved' : 'Missing'; 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 = document.getElementById('health-session').textContent =
health.session_ready || isAccountAuthorized(auth) ? 'Ready' : 'Missing'; health.session_ready || isAccountAuthorized(auth) ? 'Ready' : 'Missing';
document.getElementById('health-session').style.color = setHealthState(
health.session_ready || isAccountAuthorized(auth) ? 'var(--ok)' : 'var(--warn)'; document.getElementById('health-session'),
Boolean(health.session_ready || isAccountAuthorized(auth)),
);
document.getElementById('health-continuous').textContent = continuousStatus.running document.getElementById('health-continuous').textContent = continuousStatus.running
? 'Running' ? 'Running'
: continuousConfig.enabled : continuousConfig.enabled
? 'Enabled' ? 'Enabled'
: 'Stopped'; : '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( document.getElementById('health-last-scrape').textContent = displayTime(
health.last_scrape || continuousStatus.last_iteration_at, health.last_scrape || continuousStatus.last_iteration_at,
); );
@@ -342,7 +380,7 @@ async function exportAccount() {
async function deleteAccount() { async function deleteAccount() {
if (!settingsState.activeAccount) return; if (!settingsState.activeAccount) return;
const account = settingsState.accounts.find((item) => item.id === settingsState.activeAccount); 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' }); await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}`, { method: 'DELETE' });
setActiveAccount(null); setActiveAccount(null);
await loadAccounts(); 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 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 API</title> <title>Telegram Scraper API</title>
<link rel="stylesheet" href="/static/style.css?v=6" /> <link rel="stylesheet" href="/static/style.css?v=9" />
</head> </head>
<body> <body>
<div class="viewer-shell"> <div class="viewer-shell">
@@ -17,18 +17,44 @@
<a class="button button-small" href="/">Dashboard</a> <a class="button button-small" href="/">Dashboard</a>
</div> </div>
<nav class="nav-links"> <nav class="nav-links">
<a class="nav-link" href="/openapi.json">openapi.json</a> <a class="nav-link" href="/"
<a class="nav-link" href="/health">Health</a> ><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
<a class="nav-link" href="/viewer">Message Viewer</a> <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> </nav>
</aside> </aside>
<main class="viewer-main"> <main class="viewer-main">
<header class="viewer-header"> <header class="viewer-header">
<div> <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> <p class="muted">Local API surface exposed by the web UI server.</p>
</div> </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> </header>
<section id="api-docs" class="api-docs"></section> <section id="api-docs" class="api-docs"></section>
@@ -53,6 +79,6 @@
</article> </article>
</template> </template>
<script src="/static/swagger.js"></script> <script src="/static/swagger.js?v=9"></script>
</body> </body>
</html> </html>
+47 -2
View File
@@ -19,7 +19,30 @@ function renderSpec(spec) {
const methodTemplate = document.getElementById('api-method-template'); const methodTemplate = document.getElementById('api-method-template');
root.innerHTML = ''; 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); const section = sectionTemplate.content.firstElementChild.cloneNode(true);
section.querySelector('.api-path').textContent = path; section.querySelector('.api-path').textContent = path;
const methodsRoot = section.querySelector('.api-methods'); const methodsRoot = section.querySelector('.api-methods');
@@ -53,6 +76,28 @@ loadSpec()
docsEl.textContent = ''; docsEl.textContent = '';
const section = document.createElement('section'); const section = document.createElement('section');
section.className = 'panel'; 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); docsEl.appendChild(section);
}); });
+4 -4
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telegram Scraper Viewer</title> <title>Telegram Scraper Viewer</title>
<link rel="stylesheet" href="/static/style.css?v=8" /> <link rel="stylesheet" href="/static/style.css?v=9" />
</head> </head>
<body> <body>
<div class="viewer-shell"> <div class="viewer-shell">
@@ -15,7 +15,7 @@
<h1>Messages</h1> <h1>Messages</h1>
<p class="muted small"> <p class="muted small">
Account: Account:
<select id="viewer-account-select"></select> <select id="viewer-account-select" aria-label="Viewer account"></select>
</p> </p>
</div> </div>
<button id="sidebar-toggle" class="button button-small sidebar-toggle"></button> <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> <p id="viewer-subtitle" class="muted">Reading messages from the local SQLite database.</p>
</div> </div>
<div class="viewer-tools"> <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"> <label class="viewer-auto-refresh">
<input id="viewer-auto-refresh" type="checkbox" checked /> <input id="viewer-auto-refresh" type="checkbox" checked />
<span>Auto-refresh</span> <span>Auto-refresh</span>
@@ -80,6 +80,6 @@
</div> </div>
</template> </template>
<script src="/static/viewer.js"></script> <script src="/static/viewer.js?v=9"></script>
</body> </body>
</html> </html>
+7 -2
View File
@@ -395,10 +395,15 @@ function renderMessages(payload, append = false) {
viewerState.oldestMessageId = null; viewerState.oldestMessageId = null;
viewerState.newestMessageId = null; viewerState.newestMessageId = null;
const empty = document.createElement('div'); const empty = document.createElement('div');
empty.className = 'viewer-empty-state'; empty.className = 'empty-state viewer-empty-state';
empty.textContent = viewerState.search 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 messages found for "${viewerState.search}".`
: 'No saved messages in this channel yet.'; : 'No saved messages in this channel yet.';
empty.append(title, text);
root.appendChild(empty); root.appendChild(empty);
} }