Skip to main content

max / makenotwork

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