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:
+77
-41
@@ -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);
|
||||
}
|
||||
renderSettingsAccounts();
|
||||
settingsDialog.showModal();
|
||||
});
|
||||
const closeSettingsBtn = document.getElementById('close-settings-btn');
|
||||
if (settingsDialog && closeSettingsBtn) {
|
||||
closeSettingsBtn.addEventListener('click', () => settingsDialog.close());
|
||||
}
|
||||
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();
|
||||
@@ -945,9 +972,12 @@ async function main() {
|
||||
} catch (err) {
|
||||
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 {
|
||||
@@ -966,10 +996,16 @@ async function main() {
|
||||
} catch (err) {
|
||||
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);
|
||||
|
||||
+73
-130
@@ -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,25 +165,38 @@
|
||||
<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="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 class="panel-body">
|
||||
<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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -269,7 +208,9 @@
|
||||
</div>
|
||||
<button class="button refresh-jobs-btn">Refresh</button>
|
||||
</div>
|
||||
<div class="jobs-list"></div>
|
||||
<div class="panel-body">
|
||||
<div class="jobs-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -283,40 +224,42 @@
|
||||
</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 class="panel-body">
|
||||
<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="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>
|
||||
<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>Next run: <span class="continuous-next-run">-</span></div>
|
||||
<div>Last error: <span class="continuous-last-error">-</span></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 class="log-viewer continuous-logs"></div>
|
||||
</div>
|
||||
|
||||
<div class="log-viewer continuous-logs"></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
@@ -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,17 +112,49 @@
|
||||
<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 > 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">
|
||||
@@ -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 > 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,19 +191,35 @@
|
||||
</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">
|
||||
@@ -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">
|
||||
@@ -146,11 +242,12 @@
|
||||
<div>Messages: <span id="settings-message-count">0</span></div>
|
||||
<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
@@ -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
File diff suppressed because it is too large
Load Diff
+32
-6
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user