Skip to main content

max / makenotwork

20.5 KB · 558 lines History Blame Raw
1 /* Multithreaded: Core JavaScript */
2 'use strict';
3
4 /* Everything below is wrapped in one IIFE so no helper (showToast,
5 showFormError, openSearchModal, ...) leaks onto `window`. Templates carry no
6 inline handlers (the CSP has no 'unsafe-inline'); every binding is via
7 addEventListener, so nothing needs to be a global. */
8 (function () {
9
10 (function() {
11 var csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
12 if (csrfToken) {
13 document.body.addEventListener('htmx:configRequest', function(evt) {
14 evt.detail.headers['X-CSRF-Token'] = csrfToken;
15 });
16 }
17 })();
18
19 /* TOAST NOTIFICATIONS */
20
21 function showToast(message, type) {
22 var container = document.getElementById('notifications');
23 if (!container) return;
24 var toast = document.createElement('div');
25 toast.className = 'toast toast-' + (type || 'error');
26 toast.textContent = message;
27 container.appendChild(toast);
28 setTimeout(function() {
29 toast.classList.add('fade-out');
30 setTimeout(function() { toast.remove(); }, 300);
31 }, 3000);
32 }
33
34 document.body.addEventListener('showToast', function(evt) {
35 showToast(evt.detail.message || 'Action completed', evt.detail.type || 'info');
36 });
37
38 /* INLINE FORM ERRORS
39
40 A failed submit (422) keeps the user on the page with their input intact and
41 shows a persistent error attached to the form, and when the handler names
42 the offending field via X-Form-Field, highlights and focuses that input.
43 This replaces the transient error toast for validation failures. */
44
45 function clearFormError(form) {
46 var prev = form.querySelector('.form-error');
47 if (prev) prev.remove();
48 form.querySelectorAll('[aria-invalid="true"]').forEach(function(el) {
49 el.removeAttribute('aria-invalid');
50 });
51 }
52
53 function showFormError(form, message, field) {
54 clearFormError(form);
55 var err = document.createElement('div');
56 err.className = 'form-error';
57 err.setAttribute('role', 'alert');
58 err.textContent = message;
59 form.insertBefore(err, form.firstChild);
60
61 var focusTarget = null;
62 if (field) {
63 focusTarget = form.querySelector('[name="' + field + '"]');
64 if (focusTarget) focusTarget.setAttribute('aria-invalid', 'true');
65 }
66 (focusTarget || err).scrollIntoView({ block: 'nearest', behavior: 'smooth' });
67 if (focusTarget) focusTarget.focus();
68 }
69
70 // Clear a field's invalid state (and the form error) once the user edits it.
71 document.addEventListener('input', function(e) {
72 var field = e.target;
73 if (field.getAttribute && field.getAttribute('aria-invalid') === 'true') {
74 var form = field.closest('form');
75 if (form) clearFormError(form);
76 }
77 });
78
79 document.body.addEventListener('htmx:responseError', function(evt) {
80 var container = document.getElementById('notifications');
81 if (!container) return;
82 var toast = document.createElement('div');
83 toast.className = 'toast toast-error';
84 var msg = document.createElement('span');
85 msg.textContent = 'An error occurred.';
86 toast.appendChild(msg);
87 var retryBtn = document.createElement('button');
88 retryBtn.textContent = 'Retry';
89 retryBtn.className = 'toast-retry-btn';
90 retryBtn.onclick = function() {
91 toast.remove();
92 var elt = evt.detail.elt;
93 if (elt) htmx.trigger(elt, htmx.closest(elt, '[hx-trigger]') ? 'htmx:trigger' : 'click');
94 };
95 toast.appendChild(retryBtn);
96 container.appendChild(toast);
97 setTimeout(function() {
98 toast.classList.add('fade-out');
99 setTimeout(function() { toast.remove(); }, 300);
100 }, 6000);
101 });
102
103 /* HTMX FORM STATE (loading buttons) */
104
105 document.body.addEventListener('htmx:beforeRequest', function(evt) {
106 var form = evt.detail.elt.closest('form');
107 if (form) {
108 var btn = form.querySelector('button[type="submit"], .primary');
109 if (btn) { btn.dataset.origText = btn.textContent; btn.textContent = 'Saving...'; btn.disabled = true; }
110 }
111 });
112
113 document.body.addEventListener('htmx:afterRequest', function(evt) {
114 var form = evt.detail.elt.closest('form');
115 if (form) {
116 var btn = form.querySelector('button[type="submit"], .primary');
117 if (btn && btn.dataset.origText) { btn.textContent = btn.dataset.origText; btn.disabled = false; }
118 }
119 });
120
121 /* SEARCH MODAL */
122
123 function openSearchModal() {
124 var modal = document.getElementById('search-modal');
125 if (!modal) return;
126 modal.hidden = false;
127 var input = document.getElementById('search-input');
128 if (input) { input.value = ''; input.focus(); }
129 document.getElementById('search-results').innerHTML = '';
130 }
131
132 function closeSearchModal() {
133 var modal = document.getElementById('search-modal');
134 if (modal) modal.hidden = true;
135 }
136
137 // Wire up search button and backdrop via event listeners (no inline handlers)
138 (function() {
139 var btn = document.getElementById('search-btn');
140 if (btn) btn.addEventListener('click', openSearchModal);
141 var backdrop = document.getElementById('search-backdrop');
142 if (backdrop) backdrop.addEventListener('click', closeSearchModal);
143 })();
144
145 (function() {
146 // Keyboard navigation within search results
147 document.addEventListener('keydown', function(e) {
148 var modal = document.getElementById('search-modal');
149 if (!modal || modal.hidden) return;
150
151 if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
152 e.preventDefault();
153 var items = modal.querySelectorAll('.search-result');
154 if (!items.length) return;
155 var active = modal.querySelector('.search-result.search-active');
156 var idx = -1;
157 if (active) {
158 active.classList.remove('search-active');
159 idx = Array.prototype.indexOf.call(items, active);
160 }
161 if (e.key === 'ArrowDown') idx = (idx + 1) % items.length;
162 else idx = idx <= 0 ? items.length - 1 : idx - 1;
163 items[idx].classList.add('search-active');
164 items[idx].scrollIntoView({ block: 'nearest' });
165 }
166
167 if (e.key === 'Enter') {
168 var active = modal.querySelector('.search-result.search-active');
169 if (active) {
170 var link = active.querySelector('a');
171 if (link) { window.location.href = link.href; e.preventDefault(); }
172 }
173 }
174 });
175 })();
176
177 /* KEYBOARD SHORTCUTS */
178
179 document.addEventListener('keydown', function(e) {
180 // Search shortcut: / key (when not in input)
181 if (e.key === '/' && !e.ctrlKey && !e.metaKey) {
182 var tag = document.activeElement?.tagName;
183 if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
184 e.preventDefault();
185 openSearchModal();
186 return;
187 }
188 if (e.key === 'Escape') {
189 var modal = document.getElementById('search-modal');
190 if (modal && !modal.hidden) { closeSearchModal(); return; }
191 var overlay = document.querySelector('.modal-overlay');
192 if (overlay) overlay.remove();
193 }
194 if ((e.metaKey || e.ctrlKey) && e.key === 's') {
195 e.preventDefault();
196 var form = document.activeElement?.closest('form');
197 if (form) { var btn = form.querySelector('button[type="submit"]'); if (btn) btn.click(); }
198 }
199 });
200
201 /* IMAGE CLICK: open full size in new tab */
202
203 document.addEventListener('click', function(e) {
204 var img = e.target;
205 if (img.tagName === 'IMG' && img.closest('.post-body')) {
206 window.open(img.src, '_blank');
207 }
208 });
209
210 /* NAV TOGGLE */
211
212 document.addEventListener('click', function(e) {
213 var toggle = document.getElementById('nav-toggle');
214 if (toggle && toggle.checked && e.target.closest('.nav-links a, .nav-links .link-button')) {
215 toggle.checked = false;
216 }
217 });
218
219 /* FORM POST WITH CSRF */
220
221 document.addEventListener('submit', function(e) {
222 // Another handler may have already cancelled this submit; respect it.
223 if (e.defaultPrevented) return;
224 var form = e.target;
225 if (form.method && form.method.toUpperCase() === 'POST') {
226 // Destructive-action guard. Inline `onsubmit="return confirm(...)"`
227 // cannot run under our CSP (no script-src 'unsafe-inline'), so the
228 // prompt lives here, driven by a `data-confirm` attribute on the form.
229 var confirmMsg = form.getAttribute('data-confirm');
230 if (confirmMsg && !window.confirm(confirmMsg)) {
231 e.preventDefault();
232 return;
233 }
234 var token = document.querySelector('meta[name="csrf-token"]')?.content;
235 if (!token) return;
236 e.preventDefault();
237 clearFormError(form);
238 fetch(form.action || window.location.href, {
239 method: 'POST',
240 headers: { 'X-CSRF-Token': token, 'Content-Type': 'application/x-www-form-urlencoded' },
241 body: new URLSearchParams(new FormData(form)),
242 redirect: 'follow',
243 }).then(function(resp) {
244 if (resp.ok) {
245 // Success: the draft is no longer needed, and the handler issued
246 // a redirect we should follow.
247 localStorage.removeItem('mt_draft:' + window.location.pathname);
248 window.location.href = resp.url;
249 return;
250 }
251 return resp.text().then(function(msg) {
252 if (resp.status === 422) {
253 // Validation failure: persistent inline error on the form,
254 // input preserved, offending field highlighted/focused.
255 showFormError(form, msg || 'Please check your input and try again.',
256 resp.headers.get('X-Form-Field'));
257 } else {
258 // Auth/rate-limit/server errors aren't field problems;
259 // a transient toast is the right surface.
260 showToast(msg || 'Something went wrong. Please try again.', 'error');
261 }
262 });
263 }).catch(function() {
264 showToast('Network error. Please try again.', 'error');
265 });
266 }
267 });
268
269 /* TOAST FROM URL PARAMETER */
270
271 (function() {
272 var p = new URLSearchParams(window.location.search).get('toast');
273 if (p) {
274 showToast(decodeURIComponent(p), 'success');
275 history.replaceState(null, '', window.location.pathname);
276 }
277 })();
278
279 /* DRAFT AUTO-SAVE */
280
281 (function() {
282 var body = document.getElementById('body') || document.getElementById('reply-body');
283 if (!body || body.tagName !== 'TEXTAREA') return;
284 // NB: draft autosave is independent of the read-position tracking opt-out
285 // (mt_tracking_enabled). They share no behavior; gating drafts on the
286 // privacy toggle silently dropped a long post on a validation error.
287
288 var title = document.getElementById('title');
289 if (title && title.tagName !== 'INPUT') title = null;
290 var key = 'mt_draft:' + window.location.pathname;
291 var timer = null;
292 var MAX_DRAFTS = 20;
293 var WEEK_MS = 7 * 24 * 60 * 60 * 1000;
294
295 // Restore
296 var raw = localStorage.getItem(key);
297 if (raw && !body.value.trim()) {
298 try {
299 var draft = JSON.parse(raw);
300 if (Date.now() - draft.ts > WEEK_MS) { localStorage.removeItem(key); }
301 else {
302 body.value = draft.body || '';
303 if (title && !title.value.trim()) title.value = draft.title || '';
304 var ind = document.createElement('div');
305 ind.className = 'draft-indicator';
306 ind.textContent = 'Draft restored. ';
307 var discard = document.createElement('a');
308 discard.textContent = 'Discard';
309 discard.href = '#';
310 discard.className = 'draft-discard';
311 discard.onclick = function(e) {
312 e.preventDefault();
313 localStorage.removeItem(key);
314 body.value = '';
315 if (title) title.value = '';
316 ind.remove();
317 };
318 ind.appendChild(discard);
319 body.parentNode.insertBefore(ind, body);
320 }
321 } catch(e) { localStorage.removeItem(key); }
322 }
323
324 // Save (debounced)
325 function save() {
326 var b = body.value.trim();
327 var t = title ? title.value.trim() : '';
328 if (!b && !t) { localStorage.removeItem(key); return; }
329 localStorage.setItem(key, JSON.stringify({ body: body.value, title: title ? title.value : '', ts: Date.now() }));
330 // LRU cleanup
331 var drafts = [];
332 for (var i = 0; i < localStorage.length; i++) {
333 var k = localStorage.key(i);
334 if (k && k.indexOf('mt_draft:') === 0 && k !== key) {
335 try { drafts.push({ k: k, ts: JSON.parse(localStorage.getItem(k)).ts }); } catch(e) {}
336 }
337 }
338 if (drafts.length >= MAX_DRAFTS) {
339 drafts.sort(function(a, b) { return a.ts - b.ts; });
340 while (drafts.length >= MAX_DRAFTS) { localStorage.removeItem(drafts.shift().k); }
341 }
342 }
343 function debounced() { clearTimeout(timer); timer = setTimeout(save, 1000); }
344 body.addEventListener('input', debounced);
345 if (title) title.addEventListener('input', debounced);
346
347 // The draft is cleared only on a successful submit (see the POST handler
348 // above), so a validation failure keeps the user's text.
349 })();
350
351 /* LOCAL UNREAD TRACKING (category pages) */
352
353 (function() {
354 if (localStorage.getItem('mt_tracking_enabled') === 'false') return;
355 var rows = document.querySelectorAll('tr[data-thread-id]');
356 if (!rows.length) return;
357
358 var KEY = 'mt_thread_state';
359 var MAX_ENTRIES = 1000;
360 var state = {};
361 try { state = JSON.parse(localStorage.getItem(KEY) || '{}'); } catch(e) { state = {}; }
362
363 rows.forEach(function(row) {
364 var tid = row.getAttribute('data-thread-id');
365 var count = parseInt(row.getAttribute('data-reply-count'), 10) || 0;
366 var prev = state[tid];
367 if (prev !== undefined && count > prev) {
368 row.classList.add('unread');
369 }
370 });
371
372 // On thread link click, store current count
373 rows.forEach(function(row) {
374 var link = row.querySelector('.thread-title a');
375 if (!link) return;
376 link.addEventListener('click', function() {
377 var tid = row.getAttribute('data-thread-id');
378 var count = parseInt(row.getAttribute('data-reply-count'), 10) || 0;
379 state[tid] = count;
380 // LRU cleanup
381 var keys = Object.keys(state);
382 if (keys.length > MAX_ENTRIES) {
383 keys.slice(0, keys.length - MAX_ENTRIES).forEach(function(k) { delete state[k]; });
384 }
385 localStorage.setItem(KEY, JSON.stringify(state));
386 });
387 });
388 })();
389
390 /* TRACKING OPT-OUT TOGGLE */
391
392 (function() {
393 var checkbox = document.getElementById('tracking-opt-out');
394 if (!checkbox) return;
395 var isDisabled = localStorage.getItem('mt_tracking_enabled') === 'false';
396 checkbox.checked = isDisabled;
397 checkbox.addEventListener('change', function() {
398 if (checkbox.checked) {
399 localStorage.setItem('mt_tracking_enabled', 'false');
400 } else {
401 localStorage.removeItem('mt_tracking_enabled');
402 }
403 });
404 })();
405
406 /* IMAGE UPLOAD (drag-and-drop + paste) */
407
408 (function() {
409 var textarea = document.getElementById('body') || document.getElementById('reply-body');
410 if (!textarea || textarea.tagName !== 'TEXTAREA') return;
411
412 var form = textarea.closest('form');
413 if (!form) return;
414
415 // Extract community slug from form action or URL
416 var match = window.location.pathname.match(/^\/p\/([^/]+)/);
417 if (!match) return;
418 var slug = match[1];
419
420 function uploadFile(file) {
421 var ALLOWED = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
422 if (ALLOWED.indexOf(file.type) === -1) {
423 showToast('Only PNG, JPG, GIF, and WebP images allowed.', 'error');
424 return;
425 }
426 if (file.size > 5 * 1024 * 1024) {
427 showToast('Image exceeds 5 MB limit.', 'error');
428 return;
429 }
430
431 // Insert placeholder
432 var placeholder = '![Uploading ' + file.name + '...]()';
433 var start = textarea.selectionStart;
434 textarea.value = textarea.value.substring(0, start) + placeholder + textarea.value.substring(textarea.selectionEnd);
435 textarea.selectionStart = textarea.selectionEnd = start + placeholder.length;
436
437 var formData = new FormData();
438 formData.append('file', file);
439
440 var token = document.querySelector('meta[name="csrf-token"]')?.content;
441 var headers = {};
442 if (token) headers['X-CSRF-Token'] = token;
443
444 fetch('/p/' + slug + '/upload', {
445 method: 'POST',
446 headers: headers,
447 body: formData,
448 })
449 .then(function(resp) {
450 if (!resp.ok) return resp.text().then(function(t) { throw new Error(t); });
451 return resp.json();
452 })
453 .then(function(data) {
454 textarea.value = textarea.value.replace(placeholder, data.markdown);
455 })
456 .catch(function(err) {
457 textarea.value = textarea.value.replace(placeholder, '');
458 showToast(err.message || 'Upload failed.', 'error');
459 });
460 }
461
462 // Drag and drop
463 textarea.addEventListener('dragover', function(e) {
464 e.preventDefault();
465 textarea.classList.add('drag-over');
466 });
467 textarea.addEventListener('dragleave', function() {
468 textarea.classList.remove('drag-over');
469 });
470 textarea.addEventListener('drop', function(e) {
471 e.preventDefault();
472 textarea.classList.remove('drag-over');
473 var files = e.dataTransfer?.files;
474 if (files) {
475 for (var i = 0; i < files.length; i++) {
476 if (files[i].type.indexOf('image/') === 0) uploadFile(files[i]);
477 }
478 }
479 });
480
481 // Paste
482 textarea.addEventListener('paste', function(e) {
483 var items = e.clipboardData?.items;
484 if (!items) return;
485 for (var i = 0; i < items.length; i++) {
486 if (items[i].type.indexOf('image/') === 0) {
487 e.preventDefault();
488 var file = items[i].getAsFile();
489 if (file) uploadFile(file);
490 return;
491 }
492 }
493 });
494 })();
495
496 /* SELECT-TO-QUOTE (thread pages) */
497
498 (function() {
499 var quoteBtn = null;
500
501 document.addEventListener('mouseup', function(e) {
502 var sel = window.getSelection();
503 if (!sel || sel.isCollapsed || !sel.toString().trim()) {
504 if (quoteBtn) { quoteBtn.remove(); quoteBtn = null; }
505 return;
506 }
507 var postBody = sel.anchorNode;
508 while (postBody && !postBody.classList) postBody = postBody.parentElement;
509 while (postBody && !postBody.classList.contains('post-body')) postBody = postBody.parentElement;
510 if (!postBody) return;
511 var postItem = postBody.closest('.post-item');
512 if (!postItem || postItem.classList.contains('post-removed')) return;
513 var replyBody = document.getElementById('reply-body');
514 if (!replyBody) return;
515
516 if (quoteBtn) quoteBtn.remove();
517 quoteBtn = document.createElement('button');
518 quoteBtn.className = 'quote-btn';
519 quoteBtn.textContent = 'Quote';
520 quoteBtn.type = 'button';
521
522 var range = sel.getRangeAt(0);
523 var rect = range.getBoundingClientRect();
524 quoteBtn.style.top = (window.scrollY + rect.top - 30) + 'px';
525 quoteBtn.style.left = (window.scrollX + rect.left) + 'px';
526 document.body.appendChild(quoteBtn);
527
528 quoteBtn.addEventListener('mousedown', function(ev) {
529 ev.preventDefault();
530 var text = sel.toString().trim();
531 var postId = postItem.getAttribute('data-post-id');
532 if (!text || !postId) return;
533
534 crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)).then(function(buf) {
535 var arr = new Uint8Array(buf);
536 var hash = '';
537 for (var i = 0; i < 4; i++) hash += ('0' + arr[i].toString(16)).slice(-2);
538 var quoted = text.split('\n').map(function(l) { return '> ' + l; }).join('\n');
539 var marker = '[quote:' + postId + ':' + hash + ']';
540 var insert = quoted + '\n' + marker + '\n\n';
541 replyBody.value = replyBody.value + insert;
542 replyBody.focus();
543 var form = document.getElementById('reply-form');
544 if (form) form.scrollIntoView({ behavior: 'smooth' });
545 if (quoteBtn) { quoteBtn.remove(); quoteBtn = null; }
546 });
547 });
548 });
549
550 document.addEventListener('mousedown', function(e) {
551 if (quoteBtn && e.target !== quoteBtn) {
552 quoteBtn.remove();
553 quoteBtn = null;
554 }
555 });
556 })();
557 })();
558