feat: add dedicated account settings page
This commit is contained in:
1 parent
715cb448b7
commit
a2c5ca7f2b
6 files changed
+677
-9
No files matched your search
@@ -0,0 +1,424 @@
|
||||
const settingsState = {
|
||||
accounts: [],
|
||||
activeAccount: null,
|
||||
dashboard: null,
|
||||
auth: null,
|
||||
continuous: null,
|
||||
};
|
||||
|
||||
const ACTIVE_ACCOUNT_STORAGE_KEY = 'telegramScraper.activeAccount';
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function showToast(message, type = 'info') {
|
||||
let root = document.getElementById('toast-root');
|
||||
if (!root) {
|
||||
root = document.createElement('div');
|
||||
root.id = 'toast-root';
|
||||
root.className = 'toast-root';
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
root.appendChild(toast);
|
||||
window.setTimeout(() => {
|
||||
toast.classList.add('toast-hide');
|
||||
window.setTimeout(() => toast.remove(), 250);
|
||||
}, 3600);
|
||||
}
|
||||
|
||||
function downloadJson(filename, payload) {
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2) + '\n'], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function displayTime(value) {
|
||||
if (!value) return '-';
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return String(value).replace('T', ' ').replace('+00:00', ' UTC');
|
||||
return date.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function isAccountAuthorized(auth) {
|
||||
const status = auth?.auth_status || auth || {};
|
||||
return (
|
||||
auth?.phase === 'authorized' ||
|
||||
auth?.status === 'authorized' ||
|
||||
status.status === 'ready' ||
|
||||
status.status === 'authorized'
|
||||
);
|
||||
}
|
||||
|
||||
function accountLabel(account) {
|
||||
return account?.label || account?.id || 'No account';
|
||||
}
|
||||
|
||||
function setActiveAccount(accountId) {
|
||||
settingsState.activeAccount = accountId || null;
|
||||
if (accountId) {
|
||||
localStorage.setItem(ACTIVE_ACCOUNT_STORAGE_KEY, accountId);
|
||||
} else {
|
||||
localStorage.removeItem(ACTIVE_ACCOUNT_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function chooseInitialAccount() {
|
||||
const requested = new URLSearchParams(window.location.search).get('account');
|
||||
if (requested && settingsState.accounts.some((account) => account.id === requested)) {
|
||||
return requested;
|
||||
}
|
||||
const saved = localStorage.getItem(ACTIVE_ACCOUNT_STORAGE_KEY);
|
||||
if (saved && settingsState.accounts.some((account) => account.id === saved)) {
|
||||
return saved;
|
||||
}
|
||||
return settingsState.accounts[0]?.id || null;
|
||||
}
|
||||
|
||||
function renderAccountList() {
|
||||
const root = document.getElementById('settings-account-list');
|
||||
root.innerHTML = '';
|
||||
|
||||
if (!settingsState.accounts.length) {
|
||||
root.innerHTML = '<div class="empty-state">No accounts yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
settingsState.accounts.forEach((account) => {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'settings-account-button';
|
||||
button.type = 'button';
|
||||
button.classList.toggle('active', account.id === settingsState.activeAccount);
|
||||
|
||||
const name = document.createElement('span');
|
||||
name.className = 'settings-account-name';
|
||||
name.textContent = accountLabel(account);
|
||||
|
||||
const status = document.createElement('span');
|
||||
status.className = isAccountAuthorized(account.auth) ? 'settings-account-status ok' : 'settings-account-status';
|
||||
status.textContent = isAccountAuthorized(account.auth) ? 'Authorized' : 'Needs auth';
|
||||
|
||||
button.append(name, status);
|
||||
button.addEventListener('click', () => loadAccount(account.id));
|
||||
root.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
function renderEmptyState() {
|
||||
const hasAccount = Boolean(settingsState.activeAccount);
|
||||
document.getElementById('settings-empty').classList.toggle('hidden', hasAccount);
|
||||
document.getElementById('settings-export-btn').disabled = !hasAccount;
|
||||
document.getElementById('settings-delete-btn').disabled = !hasAccount;
|
||||
document.getElementById('settings-credentials-form').querySelectorAll('input, button').forEach((node) => {
|
||||
node.disabled = !hasAccount;
|
||||
});
|
||||
document.getElementById('settings-start-qr-btn').disabled = !hasAccount;
|
||||
document.getElementById('settings-phone-form').querySelectorAll('input, button').forEach((node) => {
|
||||
node.disabled = !hasAccount;
|
||||
});
|
||||
document.getElementById('settings-scrape-media-toggle').disabled = !hasAccount;
|
||||
}
|
||||
|
||||
function renderAccountData() {
|
||||
const account = settingsState.accounts.find((item) => item.id === settingsState.activeAccount);
|
||||
const dashboard = settingsState.dashboard || {};
|
||||
const health = dashboard.health || {};
|
||||
const auth = settingsState.auth || account?.auth || {};
|
||||
const continuous = settingsState.continuous || {};
|
||||
const continuousStatus = continuous.status || {};
|
||||
const continuousConfig = continuous.config || {};
|
||||
|
||||
document.getElementById('settings-title').textContent = account ? accountLabel(account) : 'No account selected';
|
||||
document.getElementById('settings-subtitle').textContent = account
|
||||
? `Account ID: ${account.id}`
|
||||
: 'Create or import an account to manage settings.';
|
||||
document.getElementById('settings-auth-label').textContent = account
|
||||
? `${accountLabel(account)} auth status`
|
||||
: '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)';
|
||||
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)';
|
||||
document.getElementById('health-continuous').textContent = continuousStatus.running
|
||||
? 'Running'
|
||||
: continuousConfig.enabled
|
||||
? 'Enabled'
|
||||
: 'Stopped';
|
||||
document.getElementById('health-continuous').style.color = continuousStatus.running ? 'var(--ok)' : 'var(--dim)';
|
||||
document.getElementById('health-last-scrape').textContent = displayTime(
|
||||
health.last_scrape || continuousStatus.last_iteration_at,
|
||||
);
|
||||
|
||||
document.getElementById('settings-scrape-media-toggle').checked = Boolean(dashboard.dashboard?.scrape_media);
|
||||
document.getElementById('settings-channel-count').textContent = String(health.channel_count || 0);
|
||||
document.getElementById('settings-message-count').textContent = String(health.message_count || 0);
|
||||
document.getElementById('settings-media-count').textContent = String(health.media_count || 0);
|
||||
renderEmptyState();
|
||||
renderAccountList();
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
const payload = await api('/api/accounts');
|
||||
settingsState.accounts = payload.accounts || [];
|
||||
setActiveAccount(chooseInitialAccount());
|
||||
renderAccountList();
|
||||
if (settingsState.activeAccount) {
|
||||
await loadAccount(settingsState.activeAccount, { preserveUrl: true });
|
||||
} else {
|
||||
settingsState.dashboard = null;
|
||||
settingsState.auth = null;
|
||||
settingsState.continuous = null;
|
||||
renderAccountData();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAccount(accountId, options = {}) {
|
||||
setActiveAccount(accountId);
|
||||
const [dashboard, auth, continuous] = await Promise.all([
|
||||
api(`/api/accounts/${encodeURIComponent(accountId)}`),
|
||||
api(`/api/accounts/${encodeURIComponent(accountId)}/auth`).catch(() => ({})),
|
||||
api(`/api/accounts/${encodeURIComponent(accountId)}/continuous`).catch(() => ({})),
|
||||
]);
|
||||
settingsState.dashboard = dashboard;
|
||||
settingsState.auth = auth;
|
||||
settingsState.continuous = continuous;
|
||||
if (!options.preserveUrl) {
|
||||
history.replaceState(null, '', `/settings?account=${encodeURIComponent(accountId)}`);
|
||||
}
|
||||
renderAccountData();
|
||||
}
|
||||
|
||||
async function createAccount(event) {
|
||||
event.preventDefault();
|
||||
const accountId = document.getElementById('settings-add-account-id').value.trim();
|
||||
const label = document.getElementById('settings-add-account-label').value.trim();
|
||||
const apiId = document.getElementById('settings-add-account-api-id').value.trim();
|
||||
const apiHash = document.getElementById('settings-add-account-api-hash').value.trim();
|
||||
if (!accountId) return;
|
||||
|
||||
await api('/api/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
account_id: accountId,
|
||||
label: label || accountId,
|
||||
api_id: apiId ? parseInt(apiId) : undefined,
|
||||
api_hash: apiHash || undefined,
|
||||
}),
|
||||
});
|
||||
event.currentTarget.reset();
|
||||
await loadAccounts();
|
||||
await loadAccount(accountId);
|
||||
showToast(`Added ${accountId}.`, 'success');
|
||||
}
|
||||
|
||||
async function importAccount(event) {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
const payload = JSON.parse(await file.text());
|
||||
const accountId = payload.account_id || payload.id;
|
||||
if (!accountId) throw new Error('account_id is missing in import file');
|
||||
await api('/api/accounts/import', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
event.currentTarget.value = '';
|
||||
await loadAccounts();
|
||||
await loadAccount(accountId);
|
||||
showToast(`Imported ${accountId}.`, 'success');
|
||||
} catch (err) {
|
||||
showToast(`Failed to import account: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCredentials(event) {
|
||||
event.preventDefault();
|
||||
if (!settingsState.activeAccount) return;
|
||||
const apiId = document.getElementById('settings-api-id-input').value.trim();
|
||||
const apiHash = document.getElementById('settings-api-hash-input').value.trim();
|
||||
if (!apiId || !apiHash) return;
|
||||
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/credentials`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ api_id: apiId, api_hash: apiHash }),
|
||||
});
|
||||
await loadAccount(settingsState.activeAccount);
|
||||
showToast('Credentials saved.', 'success');
|
||||
}
|
||||
|
||||
async function startQrLogin() {
|
||||
if (!settingsState.activeAccount) return;
|
||||
const data = await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/qr/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (data.qr_image) {
|
||||
document.getElementById('settings-qr-image').src = data.qr_image;
|
||||
document.getElementById('settings-qr-wrap').classList.remove('hidden');
|
||||
}
|
||||
await loadAccount(settingsState.activeAccount);
|
||||
}
|
||||
|
||||
async function requestPhoneCode(event) {
|
||||
event.preventDefault();
|
||||
if (!settingsState.activeAccount) return;
|
||||
const phone = document.getElementById('settings-phone-input').value.trim();
|
||||
if (!phone) return;
|
||||
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/phone/request`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ phone }),
|
||||
});
|
||||
document.getElementById('settings-code-form').classList.remove('hidden');
|
||||
await loadAccount(settingsState.activeAccount);
|
||||
}
|
||||
|
||||
async function submitPhoneCode(event) {
|
||||
event.preventDefault();
|
||||
if (!settingsState.activeAccount) return;
|
||||
const code = document.getElementById('settings-code-input').value.trim();
|
||||
if (!code) return;
|
||||
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/phone/submit`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
document.getElementById('settings-code-input').value = '';
|
||||
await loadAccount(settingsState.activeAccount);
|
||||
}
|
||||
|
||||
async function submitPassword(event) {
|
||||
event.preventDefault();
|
||||
if (!settingsState.activeAccount) return;
|
||||
const password = document.getElementById('settings-password-input').value.trim();
|
||||
if (!password) return;
|
||||
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/auth/password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
document.getElementById('settings-password-input').value = '';
|
||||
await loadAccount(settingsState.activeAccount);
|
||||
}
|
||||
|
||||
async function exportAccount() {
|
||||
if (!settingsState.activeAccount) return;
|
||||
const payload = await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/export`);
|
||||
downloadJson(`telegram-scraper-account-${settingsState.activeAccount}.json`, payload);
|
||||
showToast('Account exported.', 'success');
|
||||
}
|
||||
|
||||
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;
|
||||
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}`, { method: 'DELETE' });
|
||||
setActiveAccount(null);
|
||||
await loadAccounts();
|
||||
showToast('Account deleted.', 'success');
|
||||
}
|
||||
|
||||
async function toggleMedia(event) {
|
||||
if (!settingsState.activeAccount) return;
|
||||
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}/settings/media`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ value: event.currentTarget.checked }),
|
||||
});
|
||||
await loadAccount(settingsState.activeAccount);
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
document.getElementById('settings-add-account-form').addEventListener('submit', async (event) => {
|
||||
try {
|
||||
await createAccount(event);
|
||||
} catch (err) {
|
||||
showToast(`Failed to add account: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
document.getElementById('settings-import-account-file').addEventListener('change', importAccount);
|
||||
document.getElementById('settings-credentials-form').addEventListener('submit', async (event) => {
|
||||
try {
|
||||
await saveCredentials(event);
|
||||
} catch (err) {
|
||||
showToast(`Failed to save credentials: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
document.getElementById('settings-start-qr-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
await startQrLogin();
|
||||
} catch (err) {
|
||||
showToast(`QR login failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
document.getElementById('settings-phone-form').addEventListener('submit', async (event) => {
|
||||
try {
|
||||
await requestPhoneCode(event);
|
||||
} catch (err) {
|
||||
showToast(`Phone login failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
document.getElementById('settings-code-form').addEventListener('submit', async (event) => {
|
||||
try {
|
||||
await submitPhoneCode(event);
|
||||
} catch (err) {
|
||||
showToast(`Code submit failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
document.getElementById('settings-password-form').addEventListener('submit', async (event) => {
|
||||
try {
|
||||
await submitPassword(event);
|
||||
} catch (err) {
|
||||
showToast(`Password submit failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
document.getElementById('settings-export-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
await exportAccount();
|
||||
} catch (err) {
|
||||
showToast(`Export failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
document.getElementById('settings-delete-btn').addEventListener('click', async () => {
|
||||
try {
|
||||
await deleteAccount();
|
||||
} catch (err) {
|
||||
showToast(`Delete failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
document.getElementById('settings-scrape-media-toggle').addEventListener('change', async (event) => {
|
||||
try {
|
||||
await toggleMedia(event);
|
||||
} catch (err) {
|
||||
showToast(`Media setting failed: ${err.message}`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindEvents();
|
||||
loadAccounts().catch((err) => {
|
||||
console.error(err);
|
||||
showToast(`Failed to load settings: ${err.message}`, 'error');
|
||||
});
|
||||
Reference in new issue
Block a user