sidepanel.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. // sidepanel/sidepanel.js — Side Panel logic
  2. const STATUS_ICONS = {
  3. pending: '',
  4. running: '',
  5. completed: '\u2713', // ✓
  6. failed: '\u2717', // ✗
  7. stopped: '\u25A0', // ■
  8. };
  9. const logArea = document.getElementById('log-area');
  10. const displayOauthUrl = document.getElementById('display-oauth-url');
  11. const displayLocalhostUrl = document.getElementById('display-localhost-url');
  12. const displayStatus = document.getElementById('display-status');
  13. const statusBar = document.getElementById('status-bar');
  14. const inputEmail = document.getElementById('input-email');
  15. const inputPassword = document.getElementById('input-password');
  16. const btnFetchEmail = document.getElementById('btn-fetch-email');
  17. const btnTogglePassword = document.getElementById('btn-toggle-password');
  18. const btnStop = document.getElementById('btn-stop');
  19. const btnReset = document.getElementById('btn-reset');
  20. const stepsProgress = document.getElementById('steps-progress');
  21. const btnAutoRun = document.getElementById('btn-auto-run');
  22. const btnAutoContinue = document.getElementById('btn-auto-continue');
  23. const autoContinueBar = document.getElementById('auto-continue-bar');
  24. const btnClearLog = document.getElementById('btn-clear-log');
  25. const inputVpsUrl = document.getElementById('input-vps-url');
  26. const selectMailProvider = document.getElementById('select-mail-provider');
  27. const rowTempApiUrl = document.getElementById('row-temp-api-url');
  28. const inputTempApiUrl = document.getElementById('input-temp-api-url');
  29. const rowInbucketHost = document.getElementById('row-inbucket-host');
  30. const inputInbucketHost = document.getElementById('input-inbucket-host');
  31. const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
  32. const inputInbucketMailbox = document.getElementById('input-inbucket-mailbox');
  33. const inputRunCount = document.getElementById('input-run-count');
  34. // ============================================================
  35. // Toast Notifications
  36. // ============================================================
  37. const toastContainer = document.getElementById('toast-container');
  38. const TOAST_ICONS = {
  39. error: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>',
  40. warn: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
  41. success: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
  42. info: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
  43. };
  44. function showToast(message, type = 'error', duration = 4000) {
  45. const toast = document.createElement('div');
  46. toast.className = `toast toast-${type}`;
  47. toast.innerHTML = `${TOAST_ICONS[type] || ''}<span class="toast-msg">${escapeHtml(message)}</span><button class="toast-close">&times;</button>`;
  48. toast.querySelector('.toast-close').addEventListener('click', () => dismissToast(toast));
  49. toastContainer.appendChild(toast);
  50. if (duration > 0) {
  51. setTimeout(() => dismissToast(toast), duration);
  52. }
  53. }
  54. function dismissToast(toast) {
  55. if (!toast.parentNode) return;
  56. toast.classList.add('toast-exit');
  57. toast.addEventListener('animationend', () => toast.remove());
  58. }
  59. // ============================================================
  60. // State Restore on load
  61. // ============================================================
  62. async function restoreState() {
  63. try {
  64. const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' });
  65. if (state.oauthUrl) {
  66. displayOauthUrl.textContent = state.oauthUrl;
  67. displayOauthUrl.classList.add('has-value');
  68. }
  69. if (state.localhostUrl) {
  70. displayLocalhostUrl.textContent = state.localhostUrl;
  71. displayLocalhostUrl.classList.add('has-value');
  72. }
  73. if (state.email) {
  74. inputEmail.value = state.email;
  75. }
  76. syncPasswordField(state);
  77. if (state.vpsUrl) {
  78. inputVpsUrl.value = state.vpsUrl;
  79. }
  80. if (state.mailProvider) {
  81. selectMailProvider.value = state.mailProvider;
  82. }
  83. if (state.tempApiUrl) {
  84. inputTempApiUrl.value = state.tempApiUrl;
  85. }
  86. if (state.inbucketHost) {
  87. inputInbucketHost.value = state.inbucketHost;
  88. }
  89. if (state.inbucketMailbox) {
  90. inputInbucketMailbox.value = state.inbucketMailbox;
  91. }
  92. if (state.stepStatuses) {
  93. for (const [step, status] of Object.entries(state.stepStatuses)) {
  94. updateStepUI(Number(step), status);
  95. }
  96. }
  97. if (state.logs) {
  98. for (const entry of state.logs) {
  99. appendLog(entry);
  100. }
  101. }
  102. updateStatusDisplay(state);
  103. updateProgressCounter();
  104. updateMailProviderUI();
  105. // Sync temp API URL from UI to state (handles browser autocomplete / form restore)
  106. const currentTempApiUrl = inputTempApiUrl.value.trim();
  107. if (currentTempApiUrl && currentTempApiUrl !== (state.tempApiUrl || '')) {
  108. await chrome.runtime.sendMessage({
  109. type: 'SAVE_SETTING',
  110. source: 'sidepanel',
  111. payload: { tempApiUrl: currentTempApiUrl },
  112. });
  113. }
  114. } catch (err) {
  115. console.error('Failed to restore state:', err);
  116. }
  117. }
  118. function syncPasswordField(state) {
  119. inputPassword.value = state.customPassword || state.password || '';
  120. }
  121. function updateMailProviderUI() {
  122. const val = selectMailProvider.value;
  123. rowTempApiUrl.style.display = val === 'temp-api' ? '' : 'none';
  124. rowInbucketHost.style.display = val === 'inbucket' ? '' : 'none';
  125. rowInbucketMailbox.style.display = val === 'inbucket' ? '' : 'none';
  126. }
  127. // ============================================================
  128. // UI Updates
  129. // ============================================================
  130. function updateStepUI(step, status) {
  131. const statusEl = document.querySelector(`.step-status[data-step="${step}"]`);
  132. const row = document.querySelector(`.step-row[data-step="${step}"]`);
  133. if (statusEl) statusEl.textContent = STATUS_ICONS[status] || '';
  134. if (row) {
  135. row.className = `step-row ${status}`;
  136. }
  137. updateButtonStates();
  138. updateProgressCounter();
  139. }
  140. function updateProgressCounter() {
  141. let completed = 0;
  142. document.querySelectorAll('.step-row').forEach(row => {
  143. if (row.classList.contains('completed')) completed++;
  144. });
  145. stepsProgress.textContent = `${completed} / 9`;
  146. }
  147. function updateButtonStates() {
  148. const statuses = {};
  149. document.querySelectorAll('.step-row').forEach(row => {
  150. const step = Number(row.dataset.step);
  151. if (row.classList.contains('completed')) statuses[step] = 'completed';
  152. else if (row.classList.contains('running')) statuses[step] = 'running';
  153. else if (row.classList.contains('failed')) statuses[step] = 'failed';
  154. else if (row.classList.contains('stopped')) statuses[step] = 'stopped';
  155. else statuses[step] = 'pending';
  156. });
  157. const anyRunning = Object.values(statuses).some(s => s === 'running');
  158. for (let step = 1; step <= 9; step++) {
  159. const btn = document.querySelector(`.step-btn[data-step="${step}"]`);
  160. if (!btn) continue;
  161. if (anyRunning) {
  162. btn.disabled = true;
  163. } else if (step === 1) {
  164. btn.disabled = false;
  165. } else {
  166. const prevStatus = statuses[step - 1];
  167. const currentStatus = statuses[step];
  168. btn.disabled = !(prevStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'completed' || currentStatus === 'stopped');
  169. }
  170. }
  171. updateStopButtonState(anyRunning || autoContinueBar.style.display !== 'none');
  172. }
  173. function updateStopButtonState(active) {
  174. btnStop.disabled = !active;
  175. }
  176. function updateStatusDisplay(state) {
  177. if (!state || !state.stepStatuses) return;
  178. statusBar.className = 'status-bar';
  179. const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running');
  180. if (running) {
  181. displayStatus.textContent = `Step ${running[0]} running...`;
  182. statusBar.classList.add('running');
  183. return;
  184. }
  185. const failed = Object.entries(state.stepStatuses).find(([, s]) => s === 'failed');
  186. if (failed) {
  187. displayStatus.textContent = `Step ${failed[0]} failed`;
  188. statusBar.classList.add('failed');
  189. return;
  190. }
  191. const stopped = Object.entries(state.stepStatuses).find(([, s]) => s === 'stopped');
  192. if (stopped) {
  193. displayStatus.textContent = `Step ${stopped[0]} stopped`;
  194. statusBar.classList.add('stopped');
  195. return;
  196. }
  197. const lastCompleted = Object.entries(state.stepStatuses)
  198. .filter(([, s]) => s === 'completed')
  199. .map(([k]) => Number(k))
  200. .sort((a, b) => b - a)[0];
  201. if (lastCompleted === 9) {
  202. displayStatus.textContent = 'All steps completed!';
  203. statusBar.classList.add('completed');
  204. } else if (lastCompleted) {
  205. displayStatus.textContent = `Step ${lastCompleted} done`;
  206. } else {
  207. displayStatus.textContent = 'Ready';
  208. }
  209. }
  210. function appendLog(entry) {
  211. const time = new Date(entry.timestamp).toLocaleTimeString('en-US', { hour12: false });
  212. const levelLabel = entry.level.toUpperCase();
  213. const line = document.createElement('div');
  214. line.className = `log-line log-${entry.level}`;
  215. const stepMatch = entry.message.match(/Step (\d)/);
  216. const stepNum = stepMatch ? stepMatch[1] : null;
  217. let html = `<span class="log-time">${time}</span> `;
  218. html += `<span class="log-level log-level-${entry.level}">${levelLabel}</span> `;
  219. if (stepNum) {
  220. html += `<span class="log-step-tag step-${stepNum}">S${stepNum}</span>`;
  221. }
  222. html += `<span class="log-msg">${escapeHtml(entry.message)}</span>`;
  223. line.innerHTML = html;
  224. logArea.appendChild(line);
  225. logArea.scrollTop = logArea.scrollHeight;
  226. }
  227. function escapeHtml(text) {
  228. const div = document.createElement('div');
  229. div.textContent = text;
  230. return div.innerHTML;
  231. }
  232. async function fetchDuckEmail() {
  233. const defaultLabel = 'Auto';
  234. btnFetchEmail.disabled = true;
  235. btnFetchEmail.textContent = '...';
  236. try {
  237. const response = await chrome.runtime.sendMessage({
  238. type: 'FETCH_DUCK_EMAIL',
  239. source: 'sidepanel',
  240. payload: { generateNew: true },
  241. });
  242. if (response?.error) {
  243. throw new Error(response.error);
  244. }
  245. if (!response?.email) {
  246. throw new Error('Duck email was not returned.');
  247. }
  248. inputEmail.value = response.email;
  249. showToast(`Fetched ${response.email}`, 'success', 2500);
  250. return response.email;
  251. } catch (err) {
  252. showToast(`Auto fetch failed: ${err.message}`, 'error');
  253. throw err;
  254. } finally {
  255. btnFetchEmail.disabled = false;
  256. btnFetchEmail.textContent = defaultLabel;
  257. }
  258. }
  259. function syncPasswordToggleLabel() {
  260. btnTogglePassword.textContent = inputPassword.type === 'password' ? 'Show' : 'Hide';
  261. }
  262. // ============================================================
  263. // Button Handlers
  264. // ============================================================
  265. document.querySelectorAll('.step-btn').forEach(btn => {
  266. btn.addEventListener('click', async () => {
  267. const step = Number(btn.dataset.step);
  268. if (step === 3) {
  269. const email = inputEmail.value.trim();
  270. if (!email) {
  271. showToast('Please paste email address or use Auto first', 'warn');
  272. return;
  273. }
  274. await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } });
  275. } else {
  276. await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
  277. }
  278. });
  279. });
  280. btnFetchEmail.addEventListener('click', async () => {
  281. await fetchDuckEmail().catch(() => {});
  282. });
  283. btnTogglePassword.addEventListener('click', () => {
  284. inputPassword.type = inputPassword.type === 'password' ? 'text' : 'password';
  285. syncPasswordToggleLabel();
  286. });
  287. btnStop.addEventListener('click', async () => {
  288. btnStop.disabled = true;
  289. await chrome.runtime.sendMessage({ type: 'STOP_FLOW', source: 'sidepanel', payload: {} });
  290. showToast('Stopping current flow...', 'warn', 2000);
  291. });
  292. // Auto Run
  293. btnAutoRun.addEventListener('click', async () => {
  294. const totalRuns = parseInt(inputRunCount.value) || 1;
  295. btnAutoRun.disabled = true;
  296. inputRunCount.disabled = true;
  297. btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg> Running...';
  298. await chrome.runtime.sendMessage({ type: 'AUTO_RUN', source: 'sidepanel', payload: { totalRuns } });
  299. });
  300. btnAutoContinue.addEventListener('click', async () => {
  301. const email = inputEmail.value.trim();
  302. if (!email) {
  303. showToast('Please fetch or paste DuckDuckGo email first!', 'warn');
  304. return;
  305. }
  306. autoContinueBar.style.display = 'none';
  307. await chrome.runtime.sendMessage({ type: 'RESUME_AUTO_RUN', source: 'sidepanel', payload: { email } });
  308. });
  309. // Reset
  310. btnReset.addEventListener('click', async () => {
  311. if (confirm('Reset all steps and data?')) {
  312. await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
  313. displayOauthUrl.textContent = 'Waiting...';
  314. displayOauthUrl.classList.remove('has-value');
  315. displayLocalhostUrl.textContent = 'Waiting...';
  316. displayLocalhostUrl.classList.remove('has-value');
  317. inputEmail.value = '';
  318. displayStatus.textContent = 'Ready';
  319. statusBar.className = 'status-bar';
  320. logArea.innerHTML = '';
  321. document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
  322. document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
  323. btnAutoRun.disabled = false;
  324. inputRunCount.disabled = false;
  325. btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
  326. autoContinueBar.style.display = 'none';
  327. updateStopButtonState(false);
  328. updateButtonStates();
  329. updateProgressCounter();
  330. }
  331. });
  332. // Clear log
  333. btnClearLog.addEventListener('click', () => {
  334. logArea.innerHTML = '';
  335. });
  336. // Save settings on change
  337. inputEmail.addEventListener('change', async () => {
  338. const email = inputEmail.value.trim();
  339. if (email) {
  340. await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } });
  341. }
  342. });
  343. inputVpsUrl.addEventListener('change', async () => {
  344. const vpsUrl = inputVpsUrl.value.trim();
  345. if (vpsUrl) {
  346. await chrome.runtime.sendMessage({ type: 'SAVE_SETTING', source: 'sidepanel', payload: { vpsUrl } });
  347. }
  348. });
  349. inputPassword.addEventListener('change', async () => {
  350. await chrome.runtime.sendMessage({
  351. type: 'SAVE_SETTING',
  352. source: 'sidepanel',
  353. payload: { customPassword: inputPassword.value },
  354. });
  355. });
  356. selectMailProvider.addEventListener('change', async () => {
  357. updateMailProviderUI();
  358. await chrome.runtime.sendMessage({
  359. type: 'SAVE_SETTING', source: 'sidepanel',
  360. payload: { mailProvider: selectMailProvider.value },
  361. });
  362. });
  363. inputInbucketMailbox.addEventListener('change', async () => {
  364. await chrome.runtime.sendMessage({
  365. type: 'SAVE_SETTING',
  366. source: 'sidepanel',
  367. payload: { inbucketMailbox: inputInbucketMailbox.value.trim() },
  368. });
  369. });
  370. // Save on both 'change' (blur) and 'input' (immediate, e.g. paste)
  371. for (const evt of ['change', 'input']) {
  372. inputTempApiUrl.addEventListener(evt, async () => {
  373. await chrome.runtime.sendMessage({
  374. type: 'SAVE_SETTING',
  375. source: 'sidepanel',
  376. payload: { tempApiUrl: inputTempApiUrl.value.trim() },
  377. });
  378. });
  379. }
  380. inputInbucketHost.addEventListener('change', async () => {
  381. await chrome.runtime.sendMessage({
  382. type: 'SAVE_SETTING',
  383. source: 'sidepanel',
  384. payload: { inbucketHost: inputInbucketHost.value.trim() },
  385. });
  386. });
  387. // ============================================================
  388. // Listen for Background broadcasts
  389. // ============================================================
  390. chrome.runtime.onMessage.addListener((message) => {
  391. switch (message.type) {
  392. case 'LOG_ENTRY':
  393. appendLog(message.payload);
  394. if (message.payload.level === 'error') {
  395. showToast(message.payload.message, 'error');
  396. }
  397. break;
  398. case 'STEP_STATUS_CHANGED': {
  399. const { step, status } = message.payload;
  400. updateStepUI(step, status);
  401. chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(updateStatusDisplay);
  402. if (status === 'completed') {
  403. chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(state => {
  404. syncPasswordField(state);
  405. if (state.oauthUrl) {
  406. displayOauthUrl.textContent = state.oauthUrl;
  407. displayOauthUrl.classList.add('has-value');
  408. }
  409. if (state.localhostUrl) {
  410. displayLocalhostUrl.textContent = state.localhostUrl;
  411. displayLocalhostUrl.classList.add('has-value');
  412. }
  413. });
  414. }
  415. break;
  416. }
  417. case 'AUTO_RUN_RESET': {
  418. // Full UI reset for next run
  419. displayOauthUrl.textContent = 'Waiting...';
  420. displayOauthUrl.classList.remove('has-value');
  421. displayLocalhostUrl.textContent = 'Waiting...';
  422. displayLocalhostUrl.classList.remove('has-value');
  423. inputEmail.value = '';
  424. displayStatus.textContent = 'Ready';
  425. statusBar.className = 'status-bar';
  426. logArea.innerHTML = '';
  427. document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
  428. document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
  429. updateStopButtonState(false);
  430. updateProgressCounter();
  431. break;
  432. }
  433. case 'DATA_UPDATED': {
  434. if (message.payload.email) {
  435. inputEmail.value = message.payload.email;
  436. }
  437. if (message.payload.password !== undefined) {
  438. inputPassword.value = message.payload.password || '';
  439. }
  440. if (message.payload.oauthUrl) {
  441. displayOauthUrl.textContent = message.payload.oauthUrl;
  442. displayOauthUrl.classList.add('has-value');
  443. }
  444. if (message.payload.localhostUrl) {
  445. displayLocalhostUrl.textContent = message.payload.localhostUrl;
  446. displayLocalhostUrl.classList.add('has-value');
  447. }
  448. break;
  449. }
  450. case 'AUTO_RUN_STATUS': {
  451. const { phase, currentRun, totalRuns, successCount } = message.payload;
  452. const successLabel = successCount > 0 ? ` ok:${successCount}` : '';
  453. switch (phase) {
  454. case 'waiting_email':
  455. autoContinueBar.style.display = 'flex';
  456. btnAutoRun.innerHTML = `Paused${successLabel}`;
  457. updateStopButtonState(true);
  458. break;
  459. case 'running':
  460. btnAutoRun.innerHTML = `Running${successLabel}`;
  461. updateStopButtonState(true);
  462. break;
  463. case 'complete':
  464. btnAutoRun.disabled = false;
  465. inputRunCount.disabled = false;
  466. btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
  467. autoContinueBar.style.display = 'none';
  468. updateStopButtonState(false);
  469. break;
  470. case 'stopped':
  471. btnAutoRun.disabled = false;
  472. inputRunCount.disabled = false;
  473. btnAutoRun.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Auto';
  474. autoContinueBar.style.display = 'none';
  475. updateStopButtonState(false);
  476. break;
  477. }
  478. break;
  479. }
  480. }
  481. });
  482. // ============================================================
  483. // Theme Toggle
  484. // ============================================================
  485. const btnTheme = document.getElementById('btn-theme');
  486. function setTheme(theme) {
  487. document.documentElement.setAttribute('data-theme', theme);
  488. localStorage.setItem('multipage-theme', theme);
  489. }
  490. function initTheme() {
  491. const saved = localStorage.getItem('multipage-theme');
  492. if (saved) {
  493. setTheme(saved);
  494. } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
  495. setTheme('dark');
  496. }
  497. }
  498. btnTheme.addEventListener('click', () => {
  499. const current = document.documentElement.getAttribute('data-theme');
  500. setTheme(current === 'dark' ? 'light' : 'dark');
  501. });
  502. // ============================================================
  503. // Init
  504. // ============================================================
  505. initTheme();
  506. restoreState().then(() => {
  507. syncPasswordToggleLabel();
  508. updateButtonStates();
  509. });