sidepanel.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  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 inputVpsPassword = document.getElementById('input-vps-password');
  27. const selectMailProvider = document.getElementById('select-mail-provider');
  28. const rowInbucketHost = document.getElementById('row-inbucket-host');
  29. const inputInbucketHost = document.getElementById('input-inbucket-host');
  30. const rowInbucketMailbox = document.getElementById('row-inbucket-mailbox');
  31. const inputInbucketMailbox = document.getElementById('input-inbucket-mailbox');
  32. const inputRunCount = document.getElementById('input-run-count');
  33. const inputAutoSkipFailures = document.getElementById('input-auto-skip-failures');
  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. const LOG_LEVEL_LABELS = {
  45. info: '信息',
  46. ok: '成功',
  47. warn: '警告',
  48. error: '错误',
  49. };
  50. function showToast(message, type = 'error', duration = 4000) {
  51. const toast = document.createElement('div');
  52. toast.className = `toast toast-${type}`;
  53. toast.innerHTML = `${TOAST_ICONS[type] || ''}<span class="toast-msg">${escapeHtml(message)}</span><button class="toast-close">&times;</button>`;
  54. toast.querySelector('.toast-close').addEventListener('click', () => dismissToast(toast));
  55. toastContainer.appendChild(toast);
  56. if (duration > 0) {
  57. setTimeout(() => dismissToast(toast), duration);
  58. }
  59. }
  60. function dismissToast(toast) {
  61. if (!toast.parentNode) return;
  62. toast.classList.add('toast-exit');
  63. toast.addEventListener('animationend', () => toast.remove());
  64. }
  65. // ============================================================
  66. // State Restore on load
  67. // ============================================================
  68. async function restoreState() {
  69. try {
  70. const state = await chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' });
  71. if (state.oauthUrl) {
  72. displayOauthUrl.textContent = state.oauthUrl;
  73. displayOauthUrl.classList.add('has-value');
  74. }
  75. if (state.localhostUrl) {
  76. displayLocalhostUrl.textContent = state.localhostUrl;
  77. displayLocalhostUrl.classList.add('has-value');
  78. }
  79. if (state.email) {
  80. inputEmail.value = state.email;
  81. }
  82. syncPasswordField(state);
  83. if (state.vpsUrl) {
  84. inputVpsUrl.value = state.vpsUrl;
  85. }
  86. if (state.vpsPassword) {
  87. inputVpsPassword.value = state.vpsPassword;
  88. }
  89. if (state.mailProvider) {
  90. selectMailProvider.value = state.mailProvider;
  91. }
  92. if (state.inbucketHost) {
  93. inputInbucketHost.value = state.inbucketHost;
  94. }
  95. if (state.inbucketMailbox) {
  96. inputInbucketMailbox.value = state.inbucketMailbox;
  97. }
  98. inputAutoSkipFailures.checked = Boolean(state.autoRunSkipFailures);
  99. if (state.stepStatuses) {
  100. for (const [step, status] of Object.entries(state.stepStatuses)) {
  101. updateStepUI(Number(step), status);
  102. }
  103. }
  104. if (state.logs) {
  105. for (const entry of state.logs) {
  106. appendLog(entry);
  107. }
  108. }
  109. updateStatusDisplay(state);
  110. updateProgressCounter();
  111. updateMailProviderUI();
  112. } catch (err) {
  113. console.error('Failed to restore state:', err);
  114. }
  115. }
  116. function syncPasswordField(state) {
  117. inputPassword.value = state.customPassword || state.password || '';
  118. }
  119. function updateMailProviderUI() {
  120. const useInbucket = selectMailProvider.value === 'inbucket';
  121. rowInbucketHost.style.display = useInbucket ? '' : 'none';
  122. rowInbucketMailbox.style.display = useInbucket ? '' : 'none';
  123. }
  124. // ============================================================
  125. // UI Updates
  126. // ============================================================
  127. function updateStepUI(step, status) {
  128. const statusEl = document.querySelector(`.step-status[data-step="${step}"]`);
  129. const row = document.querySelector(`.step-row[data-step="${step}"]`);
  130. if (statusEl) statusEl.textContent = STATUS_ICONS[status] || '';
  131. if (row) {
  132. row.className = `step-row ${status}`;
  133. }
  134. updateButtonStates();
  135. updateProgressCounter();
  136. }
  137. function updateProgressCounter() {
  138. let completed = 0;
  139. document.querySelectorAll('.step-row').forEach(row => {
  140. if (row.classList.contains('completed')) completed++;
  141. });
  142. stepsProgress.textContent = `${completed} / 9`;
  143. }
  144. function updateButtonStates() {
  145. const statuses = {};
  146. document.querySelectorAll('.step-row').forEach(row => {
  147. const step = Number(row.dataset.step);
  148. if (row.classList.contains('completed')) statuses[step] = 'completed';
  149. else if (row.classList.contains('running')) statuses[step] = 'running';
  150. else if (row.classList.contains('failed')) statuses[step] = 'failed';
  151. else if (row.classList.contains('stopped')) statuses[step] = 'stopped';
  152. else statuses[step] = 'pending';
  153. });
  154. const anyRunning = Object.values(statuses).some(s => s === 'running');
  155. for (let step = 1; step <= 9; step++) {
  156. const btn = document.querySelector(`.step-btn[data-step="${step}"]`);
  157. if (!btn) continue;
  158. if (anyRunning) {
  159. btn.disabled = true;
  160. } else if (step === 1) {
  161. btn.disabled = false;
  162. } else {
  163. const prevStatus = statuses[step - 1];
  164. const currentStatus = statuses[step];
  165. btn.disabled = !(prevStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'completed' || currentStatus === 'stopped');
  166. }
  167. }
  168. updateStopButtonState(anyRunning || autoContinueBar.style.display !== 'none');
  169. }
  170. function updateStopButtonState(active) {
  171. btnStop.disabled = !active;
  172. }
  173. function updateStatusDisplay(state) {
  174. if (!state || !state.stepStatuses) return;
  175. statusBar.className = 'status-bar';
  176. const running = Object.entries(state.stepStatuses).find(([, s]) => s === 'running');
  177. if (running) {
  178. displayStatus.textContent = `步骤 ${running[0]} 运行中...`;
  179. statusBar.classList.add('running');
  180. return;
  181. }
  182. const failed = Object.entries(state.stepStatuses).find(([, s]) => s === 'failed');
  183. if (failed) {
  184. displayStatus.textContent = `步骤 ${failed[0]} 失败`;
  185. statusBar.classList.add('failed');
  186. return;
  187. }
  188. const stopped = Object.entries(state.stepStatuses).find(([, s]) => s === 'stopped');
  189. if (stopped) {
  190. displayStatus.textContent = `步骤 ${stopped[0]} 已停止`;
  191. statusBar.classList.add('stopped');
  192. return;
  193. }
  194. const lastCompleted = Object.entries(state.stepStatuses)
  195. .filter(([, s]) => s === 'completed')
  196. .map(([k]) => Number(k))
  197. .sort((a, b) => b - a)[0];
  198. if (lastCompleted === 9) {
  199. displayStatus.textContent = '全部步骤已完成';
  200. statusBar.classList.add('completed');
  201. } else if (lastCompleted) {
  202. displayStatus.textContent = `步骤 ${lastCompleted} 已完成`;
  203. } else {
  204. displayStatus.textContent = '就绪';
  205. }
  206. }
  207. function appendLog(entry) {
  208. const time = new Date(entry.timestamp).toLocaleTimeString('zh-CN', { hour12: false });
  209. const levelLabel = LOG_LEVEL_LABELS[entry.level] || entry.level;
  210. const line = document.createElement('div');
  211. line.className = `log-line log-${entry.level}`;
  212. const stepMatch = entry.message.match(/(?:Step\s+(\d+)|步骤\s*(\d+))/);
  213. const stepNum = stepMatch ? (stepMatch[1] || stepMatch[2]) : null;
  214. let html = `<span class="log-time">${time}</span> `;
  215. html += `<span class="log-level log-level-${entry.level}">${levelLabel}</span> `;
  216. if (stepNum) {
  217. html += `<span class="log-step-tag step-${stepNum}">步${stepNum}</span>`;
  218. }
  219. html += `<span class="log-msg">${escapeHtml(entry.message)}</span>`;
  220. line.innerHTML = html;
  221. logArea.appendChild(line);
  222. logArea.scrollTop = logArea.scrollHeight;
  223. }
  224. function escapeHtml(text) {
  225. const div = document.createElement('div');
  226. div.textContent = text;
  227. return div.innerHTML;
  228. }
  229. async function fetchDuckEmail() {
  230. const defaultLabel = '获取';
  231. btnFetchEmail.disabled = true;
  232. btnFetchEmail.textContent = '...';
  233. try {
  234. const response = await chrome.runtime.sendMessage({
  235. type: 'FETCH_DUCK_EMAIL',
  236. source: 'sidepanel',
  237. payload: { generateNew: true },
  238. });
  239. if (response?.error) {
  240. throw new Error(response.error);
  241. }
  242. if (!response?.email) {
  243. throw new Error('未返回 Duck 邮箱。');
  244. }
  245. inputEmail.value = response.email;
  246. showToast(`已获取 ${response.email}`, 'success', 2500);
  247. return response.email;
  248. } catch (err) {
  249. showToast(`自动获取失败:${err.message}`, 'error');
  250. throw err;
  251. } finally {
  252. btnFetchEmail.disabled = false;
  253. btnFetchEmail.textContent = defaultLabel;
  254. }
  255. }
  256. function syncPasswordToggleLabel() {
  257. btnTogglePassword.textContent = inputPassword.type === 'password' ? '显示' : '隐藏';
  258. }
  259. // ============================================================
  260. // Button Handlers
  261. // ============================================================
  262. document.querySelectorAll('.step-btn').forEach(btn => {
  263. btn.addEventListener('click', async () => {
  264. const step = Number(btn.dataset.step);
  265. if (step === 3) {
  266. const email = inputEmail.value.trim();
  267. if (!email) {
  268. showToast('请先粘贴邮箱,或先点击获取。', 'warn');
  269. return;
  270. }
  271. await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step, email } });
  272. } else {
  273. await chrome.runtime.sendMessage({ type: 'EXECUTE_STEP', source: 'sidepanel', payload: { step } });
  274. }
  275. });
  276. });
  277. btnFetchEmail.addEventListener('click', async () => {
  278. await fetchDuckEmail().catch(() => {});
  279. });
  280. btnTogglePassword.addEventListener('click', () => {
  281. inputPassword.type = inputPassword.type === 'password' ? 'text' : 'password';
  282. syncPasswordToggleLabel();
  283. });
  284. btnStop.addEventListener('click', async () => {
  285. btnStop.disabled = true;
  286. await chrome.runtime.sendMessage({ type: 'STOP_FLOW', source: 'sidepanel', payload: {} });
  287. showToast('正在停止当前流程...', 'warn', 2000);
  288. });
  289. // Auto Run
  290. btnAutoRun.addEventListener('click', async () => {
  291. const totalRuns = parseInt(inputRunCount.value) || 1;
  292. btnAutoRun.disabled = true;
  293. inputRunCount.disabled = true;
  294. 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> 运行中...';
  295. await chrome.runtime.sendMessage({
  296. type: 'AUTO_RUN',
  297. source: 'sidepanel',
  298. payload: {
  299. totalRuns,
  300. autoRunSkipFailures: inputAutoSkipFailures.checked,
  301. },
  302. });
  303. });
  304. btnAutoContinue.addEventListener('click', async () => {
  305. const email = inputEmail.value.trim();
  306. if (!email) {
  307. showToast('请先获取或粘贴 DuckDuckGo 邮箱。', 'warn');
  308. return;
  309. }
  310. autoContinueBar.style.display = 'none';
  311. await chrome.runtime.sendMessage({ type: 'RESUME_AUTO_RUN', source: 'sidepanel', payload: { email } });
  312. });
  313. // Reset
  314. btnReset.addEventListener('click', async () => {
  315. if (confirm('确认重置全部步骤和数据吗?')) {
  316. await chrome.runtime.sendMessage({ type: 'RESET', source: 'sidepanel' });
  317. displayOauthUrl.textContent = '等待中...';
  318. displayOauthUrl.classList.remove('has-value');
  319. displayLocalhostUrl.textContent = '等待中...';
  320. displayLocalhostUrl.classList.remove('has-value');
  321. inputEmail.value = '';
  322. displayStatus.textContent = '就绪';
  323. statusBar.className = 'status-bar';
  324. logArea.innerHTML = '';
  325. document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
  326. document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
  327. btnAutoRun.disabled = false;
  328. inputRunCount.disabled = false;
  329. 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> 自动';
  330. autoContinueBar.style.display = 'none';
  331. updateStopButtonState(false);
  332. updateButtonStates();
  333. updateProgressCounter();
  334. }
  335. });
  336. // Clear log
  337. btnClearLog.addEventListener('click', () => {
  338. logArea.innerHTML = '';
  339. });
  340. // Save settings on change
  341. inputEmail.addEventListener('change', async () => {
  342. const email = inputEmail.value.trim();
  343. if (email) {
  344. await chrome.runtime.sendMessage({ type: 'SAVE_EMAIL', source: 'sidepanel', payload: { email } });
  345. }
  346. });
  347. inputVpsUrl.addEventListener('change', async () => {
  348. const vpsUrl = inputVpsUrl.value.trim();
  349. if (vpsUrl) {
  350. await chrome.runtime.sendMessage({ type: 'SAVE_SETTING', source: 'sidepanel', payload: { vpsUrl } });
  351. }
  352. });
  353. inputVpsPassword.addEventListener('change', async () => {
  354. await chrome.runtime.sendMessage({
  355. type: 'SAVE_SETTING',
  356. source: 'sidepanel',
  357. payload: { vpsPassword: inputVpsPassword.value },
  358. });
  359. });
  360. inputPassword.addEventListener('change', async () => {
  361. await chrome.runtime.sendMessage({
  362. type: 'SAVE_SETTING',
  363. source: 'sidepanel',
  364. payload: { customPassword: inputPassword.value },
  365. });
  366. });
  367. selectMailProvider.addEventListener('change', async () => {
  368. updateMailProviderUI();
  369. await chrome.runtime.sendMessage({
  370. type: 'SAVE_SETTING', source: 'sidepanel',
  371. payload: { mailProvider: selectMailProvider.value },
  372. });
  373. });
  374. inputInbucketMailbox.addEventListener('change', async () => {
  375. await chrome.runtime.sendMessage({
  376. type: 'SAVE_SETTING',
  377. source: 'sidepanel',
  378. payload: { inbucketMailbox: inputInbucketMailbox.value.trim() },
  379. });
  380. });
  381. inputInbucketHost.addEventListener('change', async () => {
  382. await chrome.runtime.sendMessage({
  383. type: 'SAVE_SETTING',
  384. source: 'sidepanel',
  385. payload: { inbucketHost: inputInbucketHost.value.trim() },
  386. });
  387. });
  388. inputAutoSkipFailures.addEventListener('change', async () => {
  389. await chrome.runtime.sendMessage({
  390. type: 'SAVE_SETTING',
  391. source: 'sidepanel',
  392. payload: { autoRunSkipFailures: inputAutoSkipFailures.checked },
  393. });
  394. });
  395. // ============================================================
  396. // Listen for Background broadcasts
  397. // ============================================================
  398. chrome.runtime.onMessage.addListener((message) => {
  399. switch (message.type) {
  400. case 'LOG_ENTRY':
  401. appendLog(message.payload);
  402. if (message.payload.level === 'error') {
  403. showToast(message.payload.message, 'error');
  404. }
  405. break;
  406. case 'STEP_STATUS_CHANGED': {
  407. const { step, status } = message.payload;
  408. updateStepUI(step, status);
  409. chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(updateStatusDisplay);
  410. if (status === 'completed') {
  411. chrome.runtime.sendMessage({ type: 'GET_STATE', source: 'sidepanel' }).then(state => {
  412. syncPasswordField(state);
  413. if (state.oauthUrl) {
  414. displayOauthUrl.textContent = state.oauthUrl;
  415. displayOauthUrl.classList.add('has-value');
  416. }
  417. if (state.localhostUrl) {
  418. displayLocalhostUrl.textContent = state.localhostUrl;
  419. displayLocalhostUrl.classList.add('has-value');
  420. }
  421. });
  422. }
  423. break;
  424. }
  425. case 'AUTO_RUN_RESET': {
  426. // Full UI reset for next run
  427. displayOauthUrl.textContent = '等待中...';
  428. displayOauthUrl.classList.remove('has-value');
  429. displayLocalhostUrl.textContent = '等待中...';
  430. displayLocalhostUrl.classList.remove('has-value');
  431. inputEmail.value = '';
  432. displayStatus.textContent = '就绪';
  433. statusBar.className = 'status-bar';
  434. logArea.innerHTML = '';
  435. document.querySelectorAll('.step-row').forEach(row => row.className = 'step-row');
  436. document.querySelectorAll('.step-status').forEach(el => el.textContent = '');
  437. updateStopButtonState(false);
  438. updateProgressCounter();
  439. break;
  440. }
  441. case 'DATA_UPDATED': {
  442. if (message.payload.email) {
  443. inputEmail.value = message.payload.email;
  444. }
  445. if (message.payload.password !== undefined) {
  446. inputPassword.value = message.payload.password || '';
  447. }
  448. if (message.payload.oauthUrl) {
  449. displayOauthUrl.textContent = message.payload.oauthUrl;
  450. displayOauthUrl.classList.add('has-value');
  451. }
  452. if (message.payload.localhostUrl) {
  453. displayLocalhostUrl.textContent = message.payload.localhostUrl;
  454. displayLocalhostUrl.classList.add('has-value');
  455. }
  456. break;
  457. }
  458. case 'AUTO_RUN_STATUS': {
  459. const { phase, currentRun, totalRuns, attemptRun } = message.payload;
  460. const attemptLabel = attemptRun ? ` · 尝试${attemptRun}` : '';
  461. const runLabel = totalRuns > 1 ? ` (${currentRun}/${totalRuns}${attemptLabel})` : (attemptLabel ? ` (${attemptLabel.slice(3)})` : '');
  462. switch (phase) {
  463. case 'waiting_email':
  464. autoContinueBar.style.display = 'flex';
  465. btnAutoRun.innerHTML = `已暂停${runLabel}`;
  466. updateStopButtonState(true);
  467. break;
  468. case 'running':
  469. btnAutoRun.innerHTML = `运行中${runLabel}`;
  470. updateStopButtonState(true);
  471. break;
  472. case 'retrying':
  473. btnAutoRun.innerHTML = `重试中${runLabel}`;
  474. updateStopButtonState(true);
  475. break;
  476. case 'complete':
  477. btnAutoRun.disabled = false;
  478. inputRunCount.disabled = false;
  479. 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> 自动';
  480. autoContinueBar.style.display = 'none';
  481. updateStopButtonState(false);
  482. break;
  483. case 'stopped':
  484. btnAutoRun.disabled = false;
  485. inputRunCount.disabled = false;
  486. 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> 自动';
  487. autoContinueBar.style.display = 'none';
  488. updateStopButtonState(false);
  489. break;
  490. }
  491. break;
  492. }
  493. }
  494. });
  495. // ============================================================
  496. // Theme Toggle
  497. // ============================================================
  498. const btnTheme = document.getElementById('btn-theme');
  499. function setTheme(theme) {
  500. document.documentElement.setAttribute('data-theme', theme);
  501. localStorage.setItem('multipage-theme', theme);
  502. }
  503. function initTheme() {
  504. const saved = localStorage.getItem('multipage-theme');
  505. if (saved) {
  506. setTheme(saved);
  507. } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
  508. setTheme('dark');
  509. }
  510. }
  511. btnTheme.addEventListener('click', () => {
  512. const current = document.documentElement.getAttribute('data-theme');
  513. setTheme(current === 'dark' ? 'light' : 'dark');
  514. });
  515. // ============================================================
  516. // Init
  517. // ============================================================
  518. initTheme();
  519. restoreState().then(() => {
  520. syncPasswordToggleLabel();
  521. updateButtonStates();
  522. });