|
22 | 22 | const BACKEND = (cfg.backend || '').replace(/\/+$/, '') |
23 | 23 | const JWT_KEY = 'socket-pages:jwt' |
24 | 24 | const EMAIL_KEY = 'socket-pages:email' |
| 25 | + const COMPOSE_SIZE_KEY = 'socket-pages:compose-size' |
25 | 26 |
|
26 | 27 | const slug = document.body.getAttribute('data-slug') || '' |
27 | 28 | const partId = Number.parseInt( |
|
731 | 732 | </label> |
732 | 733 | <button type="submit" class="wt-primary">Verify</button> |
733 | 734 | <button type="button" class="wt-secondary wt-cancel">Cancel</button> |
| 735 | + <p class="wt-resend"> |
| 736 | + <button type="button" class="wt-resend-link" disabled>Resend code</button> |
| 737 | + <span class="wt-resend-timer" aria-live="polite"></span> |
| 738 | + </p> |
734 | 739 | <p class="wt-error" aria-live="polite"></p> |
735 | 740 | ` |
736 | 741 | // Set the email display via textContent, not string interp. |
|
758 | 763 | close() |
759 | 764 | resolve(false) |
760 | 765 | }) |
| 766 | + |
| 767 | + // Resend-code link with a 30s cooldown. Disabled + shows |
| 768 | + // "(Xs)" until the timer hits 0, then becomes clickable. |
| 769 | + // Clicking re-POSTs /auth/request for the pending email |
| 770 | + // and restarts the cooldown. Inline status writes into |
| 771 | + // `.wt-resend-timer` so assistive tech hears each |
| 772 | + // transition (aria-live="polite"). Clean up the |
| 773 | + // interval on close() so it doesn't fire after the |
| 774 | + // dialog is gone. |
| 775 | + const resendBtn = form.querySelector('.wt-resend-link') |
| 776 | + const resendTimer = form.querySelector('.wt-resend-timer') |
| 777 | + let cooldownSecs = 30 |
| 778 | + let resendInterval = null |
| 779 | + const startCooldown = () => { |
| 780 | + cooldownSecs = 30 |
| 781 | + resendBtn.disabled = true |
| 782 | + resendTimer.textContent = `(${cooldownSecs}s)` |
| 783 | + clearInterval(resendInterval) |
| 784 | + resendInterval = setInterval(() => { |
| 785 | + cooldownSecs -= 1 |
| 786 | + if (cooldownSecs <= 0) { |
| 787 | + clearInterval(resendInterval) |
| 788 | + resendInterval = null |
| 789 | + resendBtn.disabled = false |
| 790 | + resendTimer.textContent = '' |
| 791 | + return |
| 792 | + } |
| 793 | + resendTimer.textContent = `(${cooldownSecs}s)` |
| 794 | + }, 1000) |
| 795 | + } |
| 796 | + resendBtn.addEventListener('click', async () => { |
| 797 | + if (resendBtn.disabled) { |
| 798 | + return |
| 799 | + } |
| 800 | + resendBtn.disabled = true |
| 801 | + resendTimer.textContent = 'sending…' |
| 802 | + try { |
| 803 | + const res = await api('/auth/request', { |
| 804 | + method: 'POST', |
| 805 | + body: JSON.stringify({ email: pendingEmail }), |
| 806 | + }) |
| 807 | + if (!res.ok) { |
| 808 | + throw new Error('Could not resend. Try again.') |
| 809 | + } |
| 810 | + startCooldown() |
| 811 | + } catch (e) { |
| 812 | + resendBtn.disabled = false |
| 813 | + resendTimer.textContent = '' |
| 814 | + errEl.innerHTML = '' |
| 815 | + errEl.textContent = e?.message || 'Could not resend.' |
| 816 | + } |
| 817 | + }) |
| 818 | + // Arm the cooldown on first render so step 2 opens with |
| 819 | + // the resend link disabled (the code was just sent). |
| 820 | + startCooldown() |
| 821 | + // Tear down the interval when the dialog closes (cancel |
| 822 | + // button, outside-click, Escape) so timers don't leak. |
| 823 | + overlay.addEventListener('close', () => clearInterval(resendInterval), { |
| 824 | + once: true, |
| 825 | + }) |
761 | 826 | } |
762 | 827 |
|
763 | 828 | form.addEventListener('submit', async e => { |
|
823 | 888 |
|
824 | 889 | const ensureAuth = async () => { |
825 | 890 | if (state.jwt) { |
826 | | - return true |
| 891 | + // Stored token may be stale (expired, revoked, backend |
| 892 | + // reset its signing key). Verify before trusting it — |
| 893 | + // without this, the UI silently accepts the stale token, |
| 894 | + // later API calls 401, and the sign-in dialog never |
| 895 | + // appears because `ensureAuth` already returned true. |
| 896 | + // `silentCheck` hits /auth/check with a short timeout; |
| 897 | + // on failure we drop the token, clear the email, and |
| 898 | + // fall through to the sign-in flow. |
| 899 | + if (await silentCheck()) { |
| 900 | + return true |
| 901 | + } |
| 902 | + saveJwt(null, null) |
827 | 903 | } |
828 | 904 | return runAuthFlow() |
829 | 905 | } |
|
1074 | 1150 | return |
1075 | 1151 | } |
1076 | 1152 | case 'delete-comment': { |
| 1153 | + // Inline confirm UI — replace the .wt-actions row with |
| 1154 | + // a short Yes/No prompt. Native browser confirm() |
| 1155 | + // dialogs look out-of-place against the site chrome |
| 1156 | + // and steal focus. An inline prompt stays in the |
| 1157 | + // comment's own card, carries the site's colors, and |
| 1158 | + // is cancellable without hitting Escape on a modal. |
1077 | 1159 | const id = actionEl.dataset.id |
1078 | | - if (!confirm('Delete this comment?')) { |
| 1160 | + const card = actionEl.closest('.wt-comment') |
| 1161 | + const actions = card?.querySelector('.wt-actions') |
| 1162 | + if (!card || !actions) { |
1079 | 1163 | return |
1080 | 1164 | } |
| 1165 | + const confirmEl = document.createElement('div') |
| 1166 | + confirmEl.className = 'wt-confirm' |
| 1167 | + confirmEl.innerHTML = ` |
| 1168 | + <span class="wt-confirm-msg">Delete this comment?</span> |
| 1169 | + <button type="button" class="wt-confirm-yes" data-action="delete-confirm" data-id="${esc(id)}">Delete</button> |
| 1170 | + <button type="button" class="wt-confirm-no" data-action="delete-cancel">Cancel</button> |
| 1171 | + ` |
| 1172 | + actions.style.display = 'none' |
| 1173 | + card.appendChild(confirmEl) |
| 1174 | + confirmEl.querySelector('.wt-confirm-yes')?.focus() |
| 1175 | + return |
| 1176 | + } |
| 1177 | + case 'delete-cancel': { |
| 1178 | + const card = actionEl.closest('.wt-comment') |
| 1179 | + card?.querySelector('.wt-confirm')?.remove() |
| 1180 | + const actions = card?.querySelector('.wt-actions') |
| 1181 | + if (actions) { |
| 1182 | + actions.style.display = '' |
| 1183 | + } |
| 1184 | + return |
| 1185 | + } |
| 1186 | + case 'delete-confirm': { |
| 1187 | + const id = actionEl.dataset.id |
1081 | 1188 | try { |
1082 | 1189 | await apiJson(`/${slug}/api/comments/${id}`, { method: 'DELETE' }) |
1083 | 1190 | state.comments = state.comments.filter(x => x.id !== id) |
1084 | 1191 | renderAll() |
1085 | 1192 | refreshUnresolvedCount() |
1086 | 1193 | } catch { |
1087 | | - /* ignore */ |
| 1194 | + /* ignore — the card just stays; user can retry */ |
1088 | 1195 | } |
1089 | 1196 | return |
1090 | 1197 | } |
|
1125 | 1232 | // Draft auto-save keys. Scoped by file+line+parent so two composers |
1126 | 1233 | // on different anchors keep separate drafts, and a reply draft |
1127 | 1234 | // doesn't clobber a top-level draft on the same selection. |
| 1235 | + // Namespaced under `socket-pages:draft:` alongside theme / jwt / |
| 1236 | + // email / compose-size so every client-side pref shares one |
| 1237 | + // prefix (easy to audit / clear as a group). |
1128 | 1238 | const draftKey = (file, lineFrom, lineTo, parentId) => |
1129 | | - `wt:draft:${slug}:${file}:${lineFrom}-${lineTo}:${parentId || 'root'}` |
| 1239 | + `socket-pages:draft:${slug}:${file}:${lineFrom}-${lineTo}:${parentId || 'root'}` |
1130 | 1240 |
|
1131 | 1241 | // localStorage-backed draft store, gated by `navigator.locks` so |
1132 | 1242 | // two tabs that open the same composer don't race each other |
|
1182 | 1292 |
|
1183 | 1293 | const dialog = document.createElement('dialog') |
1184 | 1294 | dialog.className = 'wt-comment-form' |
| 1295 | + // Restore the user's last-chosen compose-size preference. |
| 1296 | + // `compact` (default) = anchored to the selected lines, |
| 1297 | + // textarea sized for a quick note. `fill` = gmail-style |
| 1298 | + // full-area composer, roomy for longer drafts. Persisted |
| 1299 | + // to localStorage under the same `socket-pages:` namespace |
| 1300 | + // as theme / jwt / email so all client-side prefs sit on |
| 1301 | + // one prefix. Errors swallowed (private mode / quota). |
| 1302 | + try { |
| 1303 | + if (localStorage.getItem(COMPOSE_SIZE_KEY) === 'fill') { |
| 1304 | + dialog.classList.add('wt-comment-form-fill') |
| 1305 | + } |
| 1306 | + } catch { |
| 1307 | + /* localStorage unavailable — default to compact */ |
| 1308 | + } |
1185 | 1309 | const form = document.createElement('form') |
1186 | 1310 | form.method = 'dialog' |
1187 | 1311 | form.innerHTML = ` |
1188 | 1312 | <div class="wt-comment-header"> |
1189 | 1313 | <strong>${esc(file)}</strong> |
1190 | 1314 | <span>Lines ${lineFrom}${lineTo !== lineFrom ? '–' + lineTo : ''}</span> |
| 1315 | + <button type="button" class="wt-comment-size-toggle" |
| 1316 | + aria-label="Toggle composer size" |
| 1317 | + title="Toggle composer size"></button> |
1191 | 1318 | </div> |
1192 | 1319 | <textarea class="wt-input wt-textarea" placeholder="Write a comment…" required maxlength="10000" enterkeyhint="send"></textarea> |
1193 | 1320 | <div class="wt-row"> |
|
1199 | 1326 | dialog.appendChild(form) |
1200 | 1327 | document.body.appendChild(dialog) |
1201 | 1328 |
|
| 1329 | + // Size-toggle: flips between compact (anchored-to-code) |
| 1330 | + // and fill (gmail-style). Persists the user's choice so |
| 1331 | + // the next comment they write opens in the same mode. |
| 1332 | + const sizeToggle = dialog.querySelector('.wt-comment-size-toggle') |
| 1333 | + const updateToggleLabel = () => { |
| 1334 | + const isFill = dialog.classList.contains('wt-comment-form-fill') |
| 1335 | + sizeToggle.textContent = isFill ? '⤡' : '⤢' |
| 1336 | + sizeToggle.setAttribute( |
| 1337 | + 'aria-label', |
| 1338 | + isFill ? 'Shrink composer' : 'Expand composer', |
| 1339 | + ) |
| 1340 | + sizeToggle.setAttribute( |
| 1341 | + 'title', |
| 1342 | + isFill ? 'Shrink composer' : 'Expand composer', |
| 1343 | + ) |
| 1344 | + } |
| 1345 | + updateToggleLabel() |
| 1346 | + sizeToggle.addEventListener('click', () => { |
| 1347 | + dialog.classList.toggle('wt-comment-form-fill') |
| 1348 | + const isFill = dialog.classList.contains('wt-comment-form-fill') |
| 1349 | + try { |
| 1350 | + localStorage.setItem(COMPOSE_SIZE_KEY, isFill ? 'fill' : 'compact') |
| 1351 | + } catch { |
| 1352 | + /* best-effort persistence */ |
| 1353 | + } |
| 1354 | + updateToggleLabel() |
| 1355 | + }) |
| 1356 | + |
1202 | 1357 | const close = () => { |
1203 | 1358 | if (dialog.open) { |
1204 | 1359 | dialog.close() |
|
0 commit comments