instbyte 1.9.0 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/js/app.js CHANGED
@@ -1,1585 +1,1649 @@
1
- const socket = io();
2
-
3
- let currentPage = 1;
4
- let hasMoreItems = false;
5
- let retention = 24 * 60 * 60 * 1000; // default 24h, overwritten on init
6
- const newItemIds = new Set();
7
- const seenEmitted = new Set(); // item IDs this session has already emitted seen for
8
-
9
- const seenObserver = new IntersectionObserver((entries) => {
10
- entries.forEach(entry => {
11
- if (!entry.isIntersecting) return;
12
- const id = parseInt(entry.target.dataset.itemId);
13
- if (!id || seenEmitted.has(id)) return;
14
-
15
- // wait 1 second of visibility before counting as seen
16
- const timer = setTimeout(() => {
17
- if (seenEmitted.has(id)) return;
18
- seenEmitted.add(id);
19
- socket.emit("seen", { id, name: uploader });
20
- seenObserver.unobserve(entry.target); // done with this element
21
- }, 1000);
22
-
23
- // if element leaves viewport before 1s, cancel
24
- entry.target._seenTimer = timer;
25
- });
26
-
27
- // cancel timers for elements that left viewport
28
- entries.forEach(entry => {
29
- if (entry.isIntersecting) return;
30
- if (entry.target._seenTimer) {
31
- clearTimeout(entry.target._seenTimer);
32
- entry.target._seenTimer = null;
33
- }
34
- });
35
- }, { threshold: 0.5 }); // at least 50% of item must be visible
36
-
37
- // ========================
38
- // THEME MANAGEMENT (FIXED)
39
- // ========================
40
- const THEME_KEY = "instbyte_theme";
41
- const mq = window.matchMedia("(prefers-color-scheme: dark)");
42
-
43
- function getStoredTheme() {
44
- return localStorage.getItem(THEME_KEY) || "auto";
45
- }
46
-
47
- function applyTheme(theme) {
48
- const root = document.documentElement;
49
- const btn = document.getElementById("themeToggle");
50
-
51
- // Apply attribute EXACTLY
52
- if (theme === "dark") {
53
- root.setAttribute("data-theme", "dark");
54
- } else if (theme === "light") {
55
- root.setAttribute("data-theme", "light");
56
- } else {
57
- root.removeAttribute("data-theme"); // auto
58
- }
59
-
60
- if (!btn) return;
61
-
62
- // Update icon based on CURRENT stored state
63
- if (theme === "dark") btn.textContent = "☀️";
64
- else if (theme === "light") btn.textContent = "🌙";
65
- else {
66
- btn.textContent = mq.matches ? "☀️" : "🌙";
67
- }
68
- }
69
-
70
- function cycleTheme() {
71
- const current = getStoredTheme();
72
-
73
- let next;
74
- if (current === "dark") next = "light";
75
- else if (current === "light") next = "dark";
76
- else next = "dark"; // auto → dark first
77
-
78
- localStorage.setItem(THEME_KEY, next);
79
- applyTheme(next);
80
- }
81
-
82
- // INITIAL LOAD
83
- applyTheme(getStoredTheme());
84
-
85
- // OS change listener (only when auto)
86
- mq.addEventListener("change", () => {
87
- if (getStoredTheme() === "auto") {
88
- applyTheme("auto");
89
- }
90
- });
91
-
92
- async function applyBranding() {
93
- try {
94
- const res = await fetch("/branding");
95
- const b = await res.json();
96
-
97
- // Update page title and app name
98
- document.title = b.appName;
99
- const nameEl = document.getElementById("appName");
100
- if (nameEl) nameEl.innerText = b.appName;
101
-
102
- // Update logo src to dynamic route
103
- const logoEl = document.getElementById("appLogo");
104
- if (logoEl) logoEl.src = "/logo-dynamic.png";
105
-
106
- // Inject CSS variables
107
- const p = b.palette;
108
- const root = document.documentElement;
109
- root.style.setProperty("--color-primary", p.primary);
110
- root.style.setProperty("--color-primary-hover", p.primaryHover);
111
- root.style.setProperty("--color-primary-light", p.primaryLight);
112
- root.style.setProperty("--color-primary-dark", p.primaryDark);
113
- root.style.setProperty("--color-on-primary", p.onPrimary);
114
- root.style.setProperty("--color-secondary", p.secondary);
115
- root.style.setProperty("--color-secondary-hover", p.secondaryHover);
116
- root.style.setProperty("--color-secondary-light", p.secondaryLight);
117
- root.style.setProperty("--color-on-secondary", p.onSecondary);
118
-
119
- } catch (e) {
120
- // Branding failed — default styles remain, no crash
121
- }
122
- }
123
-
124
- function formatSize(bytes) {
125
- if (!bytes) return "";
126
- if (bytes >= 1024 ** 3) return (bytes / 1024 ** 3).toFixed(1) + " GB";
127
- if (bytes >= 1024 ** 2) return (bytes / 1024 ** 2).toFixed(1) + " MB";
128
- if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB";
129
- return bytes + " B";
130
- }
131
-
132
- function getExpiryBadge(createdAt) {
133
- // if retention is null ("never"), nothing expires
134
- if (retention === null) return "";
135
-
136
- const expiresAt = createdAt + retention;
137
- const remaining = expiresAt - Date.now();
138
- const THREE_HOURS = 3 * 60 * 60 * 1000;
139
-
140
- if (remaining > THREE_HOURS) return "";
141
- if (remaining <= 0) return `<span class="expiry-badge expiry-gone">expired</span>`;
142
-
143
- const m = Math.floor(remaining / 60000);
144
- const h = Math.floor(m / 60);
145
- const label = h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
146
-
147
- return `<span class="expiry-badge">⏱ ${label}</span>`;
148
- }
149
-
150
- function escapeHtml(str) {
151
- if (!str) return "";
152
- return str
153
- .replace(/&/g, "&amp;")
154
- .replace(/</g, "&lt;")
155
- .replace(/>/g, "&gt;")
156
- .replace(/"/g, "&quot;");
157
- }
158
-
159
- function playChime() {
160
- try {
161
- const ctx = new (window.AudioContext || window.webkitAudioContext)();
162
-
163
- const gain = ctx.createGain();
164
- gain.connect(ctx.destination);
165
- gain.gain.setValueAtTime(0, ctx.currentTime);
166
- gain.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 0.01);
167
- gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
168
-
169
- const osc1 = ctx.createOscillator();
170
- osc1.type = "sine";
171
- osc1.frequency.setValueAtTime(880, ctx.currentTime);
172
- osc1.frequency.exponentialRampToValueAtTime(1100, ctx.currentTime + 0.1);
173
- osc1.connect(gain);
174
- osc1.start(ctx.currentTime);
175
- osc1.stop(ctx.currentTime + 0.6);
176
-
177
- const osc2 = ctx.createOscillator();
178
- osc2.type = "sine";
179
- osc2.frequency.setValueAtTime(1320, ctx.currentTime + 0.08);
180
- osc2.frequency.exponentialRampToValueAtTime(1760, ctx.currentTime + 0.2);
181
- osc2.connect(gain);
182
- osc2.start(ctx.currentTime + 0.08);
183
- osc2.stop(ctx.currentTime + 0.6);
184
-
185
- } catch (e) {
186
- // audio not supported or blocked — fail silently
187
- }
188
- }
189
-
190
- function getSizeTag(bytes) {
191
- if (!bytes) return "";
192
- const mb = bytes / (1024 * 1024);
193
- if (mb > 1024) return "danger-dark";
194
- if (mb > 500) return "danger-light";
195
- if (mb > 100) return "warn";
196
- return "";
197
- }
198
-
199
- // Configure marked
200
- marked.setOptions({
201
- breaks: true,
202
- gfm: true
203
- });
204
-
205
- function looksLikeMarkdown(text) {
206
- return /^#{1,3} |[*_`~]|\[.+\]\(.+\)|^[-*+] |^\d+\. |^```/m.test(text);
207
- }
208
-
209
- function renderText(text) {
210
- if (!text) return "";
211
- if (looksLikeMarkdown(text)) {
212
- const html = marked.parse(text);
213
- // highlight code blocks after parse
214
- const wrap = document.createElement("div");
215
- wrap.innerHTML = html;
216
- wrap.querySelectorAll("pre code").forEach(el => hljs.highlightElement(el));
217
- return `<div class="markdown-body">${wrap.innerHTML}</div>`;
218
- }
219
- // plain text — just escape and preserve newlines
220
- return text
221
- .replace(/&/g, "&amp;")
222
- .replace(/</g, "&lt;")
223
- .replace(/>/g, "&gt;")
224
- .replace(/\n/g, "<br>");
225
- }
226
-
227
- const TEXT_EXTENSIONS = [
228
- 'txt', 'md', 'js', 'ts', 'jsx', 'tsx', 'json', 'json5',
229
- 'css', 'html', 'htm', 'xml', 'svg', 'sh', 'bash', 'zsh',
230
- 'py', 'rb', 'php', 'java', 'c', 'cpp', 'h', 'cs', 'go',
231
- 'rs', 'swift', 'kt', 'yaml', 'yml', 'toml', 'ini', 'env',
232
- 'gitignore', 'dockerfile', 'sql', 'csv', 'log'
233
- ];
234
-
235
- const MAX_TEXT_PREVIEW_BYTES = 50 * 1024; // 50KB — beyond this we warn
236
- const MAX_TEXT_LINES = 200;
237
-
238
- function getPreviewType(filename) {
239
- if (!filename) return "none";
240
- const ext = filename.split(".").pop().toLowerCase();
241
- if (/^(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(ext)) return "image";
242
- if (/^(mp4|webm|ogg|mov)$/.test(ext)) return "video";
243
- if (/^(mp3|wav|ogg|m4a|flac|aac)$/.test(ext)) return "audio";
244
- if (ext === "pdf") return "pdf";
245
- if (TEXT_EXTENSIONS.includes(ext)) return "text";
246
- return "none";
247
- }
248
-
249
- function getLanguage(filename) {
250
- const ext = filename.split(".").pop().toLowerCase();
251
- const map = {
252
- js: "javascript", ts: "typescript", jsx: "javascript",
253
- tsx: "typescript", py: "python", rb: "ruby", php: "php",
254
- java: "java", c: "c", cpp: "cpp", h: "c", cs: "csharp",
255
- go: "go", rs: "rust", swift: "swift", kt: "kotlin",
256
- html: "html", htm: "html", css: "css", json: "json",
257
- json5: "json", xml: "xml", svg: "xml", sh: "bash",
258
- bash: "bash", zsh: "bash", yaml: "yaml", yml: "yaml",
259
- toml: "toml", sql: "sql", md: "markdown", csv: "plaintext",
260
- txt: "plaintext", log: "plaintext", env: "plaintext",
261
- dockerfile: "dockerfile"
262
- };
263
- return map[ext] || "plaintext";
264
- }
265
-
266
- let openPreviewId = null;
267
-
268
- async function togglePreview(id, filename) {
269
- const panel = document.getElementById("preview-" + id);
270
- const btn = document.getElementById("prevbtn-" + id);
271
- if (!panel || !btn) return;
272
-
273
- const isOpen = panel.classList.contains("open");
274
-
275
- // Close any other open preview first
276
- if (openPreviewId && openPreviewId !== id) {
277
- const otherPanel = document.getElementById("preview-" + openPreviewId);
278
- const otherBtn = document.getElementById("prevbtn-" + openPreviewId);
279
- if (otherPanel) otherPanel.classList.remove("open");
280
- if (otherBtn) otherBtn.classList.remove("preview-active");
281
- }
282
-
283
- if (isOpen) {
284
- panel.classList.remove("open");
285
- btn.classList.remove("preview-active");
286
- openPreviewId = null;
287
- return;
288
- }
289
-
290
- // Open this one
291
- panel.classList.add("open");
292
- btn.classList.add("preview-active");
293
- openPreviewId = id;
294
-
295
- // Only build content once
296
- if (panel.dataset.loaded) return;
297
- panel.dataset.loaded = "true";
298
-
299
- const type = getPreviewType(filename);
300
- const url = `/uploads/${filename}`;
301
-
302
- if (type === "image") {
303
- panel.innerHTML = `<img src="${url}" alt="${filename}">`;
304
-
305
- } else if (type === "video") {
306
- panel.innerHTML = `
307
- <video controls preload="metadata">
308
- <source src="${url}">
309
- Your browser doesn't support video preview.
310
- </video>`;
311
-
312
- } else if (type === "audio") {
313
- panel.innerHTML = `
314
- <audio controls preload="metadata">
315
- <source src="${url}">
316
- Your browser doesn't support audio preview.
317
- </audio>`;
318
-
319
- } else if (type === "pdf") {
320
- panel.innerHTML = `<embed src="${url}" type="application/pdf">`;
321
-
322
- } else if (type === "text") {
323
- panel.innerHTML = `<div class="preview-loading">Loading...</div>`;
324
-
325
- try {
326
- const res = await fetch(url);
327
- if (!res.ok) throw new Error("Failed to load");
328
-
329
- const contentLength = res.headers.get("content-length");
330
- if (contentLength && parseInt(contentLength) > MAX_TEXT_PREVIEW_BYTES) {
331
- panel.innerHTML = `
332
- <div class="preview-error">
333
- File is too large to preview (${formatSize(parseInt(contentLength))}).
334
- <a href="${url}" target="_blank">Open in new tab</a>
335
- </div>`;
336
- return;
337
- }
338
-
339
- const text = await res.text();
340
- const ext = filename.split(".").pop().toLowerCase();
341
-
342
- // markdown files — render as HTML, not syntax-highlighted raw text
343
- if (ext === "md") {
344
- const html = marked.parse(text);
345
- const wrap = document.createElement("div");
346
- wrap.className = "markdown-body markdown-preview";
347
- wrap.innerHTML = html;
348
-
349
- // syntax highlight any code blocks inside
350
- wrap.querySelectorAll("pre code").forEach(el => hljs.highlightElement(el));
351
-
352
- panel.innerHTML = "";
353
- panel.appendChild(wrap);
354
- return;
355
- }
356
-
357
- // all other text files — syntax highlighted raw view
358
- const lines = text.split("\n");
359
- const truncated = lines.length > MAX_TEXT_LINES;
360
- const preview = truncated
361
- ? lines.slice(0, MAX_TEXT_LINES).join("\n")
362
- : text;
363
-
364
- const lang = getLanguage(filename);
365
- const code = document.createElement("code");
366
- code.className = `language-${lang}`;
367
- code.textContent = preview;
368
-
369
- const pre = document.createElement("pre");
370
- pre.appendChild(code);
371
- hljs.highlightElement(code);
372
-
373
- panel.innerHTML = "";
374
- panel.appendChild(pre);
375
-
376
- if (truncated) {
377
- const note = document.createElement("div");
378
- note.className = "preview-truncated";
379
- note.innerHTML = `Showing first ${MAX_TEXT_LINES} of ${lines.length} lines. <a href="${url}" target="_blank">View full file</a>`;
380
- panel.appendChild(note);
381
- }
382
-
383
- } catch (err) {
384
- panel.innerHTML = `
385
- <div class="preview-error">
386
- Could not load preview. <a href="${url}" target="_blank">Open directly</a>
387
- </div>`;
388
- }
389
- }
390
- }
391
-
392
- function handleRowClick(el, type, value) {
393
- if (type === "text") {
394
- navigator.clipboard.writeText(value).then(() => {
395
- el.classList.add("flash");
396
- setTimeout(() => el.classList.remove("flash"), 800);
397
- });
398
- } else {
399
- const a = document.createElement("a");
400
- a.href = value;
401
- a.download = "";
402
- a.click();
403
- }
404
- }
405
-
406
- document.getElementById("items").addEventListener("click", e => {
407
- const left = e.target.closest(".left");
408
- if (!left) return;
409
- const type = left.dataset.type;
410
- const value = left.dataset.value;
411
- handleRowClick(left, type, value);
412
- });
413
-
414
- let openDropdown = null;
415
-
416
- let pendingDeleteId = null;
417
- let pendingDeleteTimer = null;
418
- let pendingDeleteEl = null;
419
-
420
- function toggleMoveDropdown(e, id, currentChannel) {
421
- e.stopPropagation();
422
-
423
- // close any open one first
424
- if (openDropdown && openDropdown !== e.currentTarget.nextElementSibling) {
425
- openDropdown.classList.remove("open");
426
- }
427
-
428
- const dropdown = e.currentTarget.nextElementSibling;
429
- const isOpen = dropdown.classList.contains("open");
430
-
431
- if (isOpen) {
432
- dropdown.classList.remove("open");
433
- openDropdown = null;
434
- return;
435
- }
436
-
437
- // build channel list fresh each time (channels may have changed)
438
- const others = channels.filter(c => c.name !== currentChannel);
439
- dropdown.innerHTML = `<div class="dropdown-label">Move to</div>`;
440
-
441
- others.forEach(ch => {
442
- const btn = document.createElement("button");
443
- btn.innerText = ch.name;
444
- btn.onclick = (ev) => {
445
- ev.stopPropagation();
446
- moveItem(id, ch.name);
447
- dropdown.classList.remove("open");
448
- openDropdown = null;
449
- };
450
- dropdown.appendChild(btn);
451
- });
452
-
453
- dropdown.classList.add("open");
454
- openDropdown = dropdown;
455
- }
456
-
457
- function toggleMoreMenu(e, id, currentChannel) {
458
- e.stopPropagation();
459
-
460
- const moreDropdown = e.currentTarget.nextElementSibling;
461
- const isOpen = moreDropdown.classList.contains("open");
462
-
463
- // close any other open dropdown
464
- if (openDropdown && openDropdown !== moreDropdown) {
465
- openDropdown.classList.remove("open");
466
- }
467
-
468
- if (isOpen) {
469
- moreDropdown.classList.remove("open");
470
- openDropdown = null;
471
- return;
472
- }
473
-
474
- // build move list fresh each time
475
- const moveList = moreDropdown.querySelector(".move-list");
476
- const others = channels.filter(c => c.name !== currentChannel);
477
- moveList.innerHTML = "";
478
-
479
- if (others.length === 0) {
480
- moveList.innerHTML = `<div class="dropdown-label" style="padding:4px 14px 8px">No other channels</div>`;
481
- } else {
482
- others.forEach(ch => {
483
- const btn = document.createElement("button");
484
- btn.innerText = ch.name;
485
- btn.onclick = (ev) => {
486
- ev.stopPropagation();
487
- moveItem(id, ch.name);
488
- moreDropdown.classList.remove("open");
489
- openDropdown = null;
490
- };
491
- moveList.appendChild(btn);
492
- });
493
- }
494
-
495
- moreDropdown.classList.add("open");
496
- openDropdown = moreDropdown;
497
- }
498
-
499
- async function moveItem(id, toChannel) {
500
- await fetch(`/item/${id}/move`, {
501
- method: "PATCH",
502
- headers: { "Content-Type": "application/json" },
503
- body: JSON.stringify({ channel: toChannel })
504
- });
505
- // socket will handle the re-render via item-moved event
506
- }
507
-
508
- document.addEventListener("click", () => {
509
- if (openDropdown) {
510
- openDropdown.classList.remove("open");
511
- openDropdown = null;
512
- }
513
- });
514
-
515
- let channel = null;
516
- let channels = [];
517
- let unreadChannels = new Set();
518
-
519
- let uploader = localStorage.getItem("name") || "";
520
-
521
- async function initName() {
522
- if (!uploader || uploader === "null" || uploader.trim() === "") {
523
- let suggested = "USER-" + Math.floor(Math.random() * 1000);
524
- const ua = navigator.userAgent;
525
- if (/android/i.test(ua)) suggested = "Android-" + Math.floor(Math.random() * 1000);
526
- else if (/iphone|ipad/i.test(ua)) suggested = "iPhone-" + Math.floor(Math.random() * 1000);
527
- else if (/mac/i.test(ua)) suggested = "Mac-" + Math.floor(Math.random() * 1000);
528
- else if (/windows/i.test(ua)) suggested = "Windows-" + Math.floor(Math.random() * 1000);
529
- else if (/linux/i.test(ua)) suggested = "Linux-" + Math.floor(Math.random() * 1000);
530
- else suggested = "User-" + Math.floor(Math.random() * 1000);
531
- uploader = prompt("Your name?", suggested) || suggested;
532
- localStorage.setItem("name", uploader);
533
- }
534
- document.getElementById("who").innerText = uploader;
535
- socket.emit("join", uploader);
536
- }
537
-
538
- function highlight() {
539
- document.querySelectorAll(".channels button")
540
- .forEach(b => b.classList.remove("active"));
541
- const el = document.getElementById("ch-" + channel);
542
- if (el) el.classList.add("active");
543
- }
544
-
545
- function setChannel(c) {
546
- // flush any pending delete before switching
547
- if (pendingDeleteId !== null) {
548
- clearTimeout(pendingDeleteTimer);
549
- fetch("/item/" + pendingDeleteId, { method: "DELETE" });
550
- pendingDeleteId = null;
551
- pendingDeleteTimer = null;
552
- pendingDeleteEl = null;
553
- hideUndoToast();
554
- }
555
- unreadChannels.delete(c); // clear unread when switching to channel
556
- channel = c;
557
- renderChannels();
558
- highlight();
559
- load();
560
- }
561
-
562
- async function load(resetPage = true) {
563
- if (resetPage) currentPage = 1;
564
-
565
- const res = await fetch(`/items/${channel}?page=${currentPage}`);
566
- const data = await res.json();
567
-
568
- console.log("load called, page:", currentPage, "data:", data);
569
-
570
- hasMoreItems = data.hasMore;
571
-
572
- if (currentPage === 1) {
573
- render(data.items);
574
- } else {
575
- renderMore(data.items);
576
- }
577
-
578
- const wrapper = document.getElementById("loadMoreWrapper");
579
- wrapper.style.display = hasMoreItems ? "block" : "none";
580
- }
581
-
582
- async function loadMore() {
583
- currentPage++;
584
- await load(false);
585
- }
586
-
587
- // ========================
588
- // ITEM RENDERING
589
- // ========================
590
- function buildItemContent(i) {
591
- if (i.type === "file") {
592
- const isImg = i.filename.match(/\.(jpg|png|jpeg|gif)$/i);
593
- const sizeLabel = formatSize(i.size);
594
- const sizeClass = getSizeTag(i.size);
595
- const sizeTag = sizeClass
596
- ? `<span class="size-tag ${sizeClass}">${sizeLabel}</span>`
597
- : sizeLabel
598
- ? `<span style="font-size:11px;color:#9ca3af;margin-left:6px;">${sizeLabel}</span>`
599
- : "";
600
-
601
- if (isImg) {
602
- return `<img src="/uploads/${i.filename}" style="max-width:200px;border-radius:6px"><br>
603
- <a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
604
- }
605
- return `<a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
606
- }
607
-
608
- const isLink = i.content && i.content.startsWith("http");
609
- if (isLink) return `<a href="${i.content}" target="_blank">${i.content}</a>`;
610
- return renderText(i.content);
611
- }
612
-
613
- function buildItemEl(i) {
614
- const div = document.createElement("div");
615
- div.className = "item";
616
-
617
- if (newItemIds.has(i.id) && !i.pinned) {
618
- div.classList.add("item-new");
619
- setTimeout(() => {
620
- div.classList.remove("item-new");
621
- newItemIds.delete(i.id);
622
- }, 4000);
623
- }
624
- div.dataset.itemId = i.id;
625
-
626
- const content = buildItemContent(i);
627
- const isFile = i.type === "file";
628
- const dataValue = isFile
629
- ? `/uploads/${i.filename}`
630
- : (i.content || "").replace(/"/g, "&quot;");
631
- const tooltip = isFile ? "Click to download" : "Click to copy";
632
-
633
- // contextual slot — preview for files, edit for text, nothing otherwise
634
- const contextualBtn = getPreviewType(i.filename) !== "none" && getPreviewType(i.filename) !== "image"
635
- ? `<button class="icon-btn" id="prevbtn-${i.id}"
636
- onclick="togglePreview(${i.id}, '${i.filename}')"
637
- title="Preview"><i data-lucide="eye"></i></button>`
638
- : !isFile
639
- ? `<button class="icon-btn" onclick="editContent(${i.id})" title="Edit content"><i data-lucide="pencil-line"></i></button>`
640
- : "";
641
-
642
- const titleHtml = i.title
643
- ? `<div class="item-title" id="item-title-${i.id}">${escapeHtml(i.title)}</div>`
644
- : `<div class="item-title" id="item-title-${i.id}" style="display:none"></div>`;
645
-
646
- div.innerHTML = `
647
- <div class="item-top">
648
- <div class="left"
649
- data-tooltip="${tooltip}"
650
- data-type="${isFile ? "file" : "text"}"
651
- data-value="${dataValue}">
652
- ${titleHtml}
653
- ${content}
654
- <div class="meta">
655
- ${i.uploader}
656
- <span class="seen-count" id="seen-${i.id}" style="display:none">👁 <span class="seen-num"></span></span>
657
- ${getExpiryBadge(i.created_at)}
658
- </div>
659
- </div>
660
- <div class="item-actions">
661
- ${contextualBtn}
662
- <button class="icon-btn ${i.pinned ? "pinned" : ""}" onclick="pin(${i.id})" title="${i.pinned ? "Unpin" : "Pin"}">
663
- <i data-lucide="${i.pinned ? "pin-off" : "pin"}"></i>
664
- </button>
665
- <button class="icon-btn delete" onclick="del(${i.id}, ${i.pinned})" title="Delete">
666
- <i data-lucide="trash-2"></i>
667
- </button>
668
- <div class="more-wrapper">
669
- <button class="icon-btn more-btn" onclick="toggleMoreMenu(event, ${i.id}, '${i.channel}')" title="More">
670
- <i data-lucide="more-vertical"></i>
671
- </button>
672
- <div class="more-dropdown">
673
- <button onclick="editTitle(${i.id})">
674
- <i data-lucide="tag"></i> Add / edit title
675
- </button>
676
- <div class="menu-divider"></div>
677
- <div class="dropdown-label">Move to</div>
678
- <div class="move-list"></div>
679
- </div>
680
- </div>
681
- </div>
682
- </div>
683
- <div class="preview-panel" id="preview-${i.id}"></div>`;
684
-
685
- seenObserver.observe(div);
686
- if (typeof lucide !== "undefined") lucide.createIcons({ nodes: [div] });
687
- return div;
688
- }
689
-
690
- function editTitle(id) {
691
- const titleEl = document.getElementById("item-title-" + id);
692
- if (!titleEl) return;
693
-
694
- const current = titleEl.dataset.value || titleEl.innerText.trim();
695
-
696
- const input = document.createElement("input");
697
- input.type = "text";
698
- input.className = "title-input";
699
- input.value = current;
700
- input.placeholder = "Add a title...";
701
- input.maxLength = 100;
702
-
703
- titleEl.replaceWith(input);
704
- input.focus();
705
- input.select();
706
-
707
- async function save() {
708
- const newTitle = input.value.trim();
709
- const res = await fetch(`/item/${id}/title`, {
710
- method: "PATCH",
711
- headers: { "Content-Type": "application/json" },
712
- body: JSON.stringify({ title: newTitle })
713
- });
714
- if (!res.ok) { restore(); return; }
715
-
716
- const fresh = document.createElement("div");
717
- fresh.className = "item-title";
718
- fresh.id = "item-title-" + id;
719
- if (newTitle) {
720
- fresh.innerText = newTitle;
721
- fresh.style.display = "";
722
- } else {
723
- fresh.style.display = "none";
724
- }
725
- input.replaceWith(fresh);
726
- }
727
-
728
- function restore() {
729
- const fresh = document.createElement("div");
730
- fresh.className = "item-title";
731
- fresh.id = "item-title-" + id;
732
- if (current) {
733
- fresh.innerText = current;
734
- fresh.style.display = "";
735
- } else {
736
- fresh.style.display = "none";
737
- }
738
- input.replaceWith(fresh);
739
- }
740
-
741
- input.addEventListener("keydown", e => {
742
- if (e.key === "Enter") { e.preventDefault(); save(); }
743
- if (e.key === "Escape") { e.preventDefault(); restore(); }
744
- });
745
-
746
- input.addEventListener("blur", save);
747
- }
748
-
749
- function editContent(id) {
750
- const itemEl = document.querySelector(`.item[data-item-id="${id}"]`);
751
- if (!itemEl) return;
752
-
753
- const leftEl = itemEl.querySelector(".left");
754
- if (!leftEl) return;
755
-
756
- // grab current raw content from data-value
757
- const current = leftEl.dataset.value || "";
758
-
759
- // find the content node — everything except .meta and .item-title
760
- const metaEl = leftEl.querySelector(".meta");
761
- const titleEl = leftEl.querySelector(".item-title");
762
-
763
- // collect content nodes (not meta, not title)
764
- const contentNodes = Array.from(leftEl.childNodes).filter(n => {
765
- if (n === metaEl) return false;
766
- if (n === titleEl) return false;
767
- return true;
768
- });
769
-
770
- // replace content nodes with textarea
771
- const textarea = document.createElement("textarea");
772
- textarea.className = "edit-textarea";
773
- textarea.value = current;
774
-
775
- contentNodes.forEach(n => n.remove());
776
- leftEl.insertBefore(textarea, metaEl);
777
- textarea.focus();
778
-
779
- // auto-height
780
- textarea.style.height = Math.max(80, textarea.scrollHeight) + "px";
781
- textarea.addEventListener("input", () => {
782
- textarea.style.height = "auto";
783
- textarea.style.height = textarea.scrollHeight + "px";
784
- });
785
-
786
- let done = false;
787
-
788
- async function save() {
789
- if (done) return;
790
- done = true;
791
-
792
- const newContent = textarea.value.trim();
793
- if (!newContent) { restore(); return; }
794
- if (newContent === current) { restore(); return; }
795
-
796
- const res = await fetch(`/item/${id}/content`, {
797
- method: "PATCH",
798
- headers: { "Content-Type": "application/json" },
799
- body: JSON.stringify({ content: newContent })
800
- });
801
-
802
- if (!res.ok) { done = false; restore(); return; }
803
-
804
- leftEl.dataset.value = newContent;
805
- restoreWithContent(newContent);
806
- }
807
-
808
- function restore() {
809
- if (done) return;
810
- done = true;
811
- restoreWithContent(current);
812
- }
813
-
814
- function restoreWithContent(text) {
815
- textarea.remove();
816
-
817
- const isLink = text.startsWith("http");
818
- let node;
819
- if (isLink) {
820
- node = document.createElement("a");
821
- node.href = text;
822
- node.target = "_blank";
823
- node.innerText = text;
824
- } else {
825
- const wrap = document.createElement("div");
826
- wrap.innerHTML = renderText(text);
827
- node = wrap;
828
- }
829
-
830
- leftEl.insertBefore(node, metaEl);
831
- }
832
-
833
- textarea.addEventListener("keydown", e => {
834
- // Ctrl+Enter or Cmd+Enter saves
835
- if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
836
- e.preventDefault();
837
- save();
838
- }
839
- if (e.key === "Escape") {
840
- e.preventDefault();
841
- restore();
842
- }
843
- });
844
-
845
- // blur saves only if focus didn't move to another part of same item
846
- textarea.addEventListener("blur", e => {
847
- setTimeout(() => {
848
- if (!itemEl.contains(document.activeElement)) {
849
- save();
850
- }
851
- }, 150);
852
- });
853
- }
854
-
855
- function render(data) {
856
- const el = document.getElementById("items");
857
- el.innerHTML = "";
858
-
859
- if (!data.length) {
860
- el.innerHTML = `<div class="empty-state">Nothing here yet — paste, type, or drop a file to share</div>`;
861
- return;
862
- }
863
-
864
- data.forEach(i => el.appendChild(buildItemEl(i)));
865
- }
866
-
867
- function renderMore(data) {
868
- const el = document.getElementById("items");
869
- data.forEach(i => el.appendChild(buildItemEl(i)));
870
- }
871
-
872
- function renderGrouped(data) {
873
- const el = document.getElementById("items");
874
- el.innerHTML = "";
875
-
876
- if (!data.length) {
877
- el.innerHTML = "<div style='color:#6b7280;margin-top:10px;'>No results</div>";
878
- return;
879
- }
880
-
881
- const grouped = {};
882
- data.forEach(item => {
883
- if (!grouped[item.channel]) grouped[item.channel] = [];
884
- grouped[item.channel].push(item);
885
- });
886
-
887
- Object.keys(grouped).forEach(ch => {
888
- const section = document.createElement("div");
889
- section.style.marginTop = "20px";
890
- section.innerHTML = `
891
- <div style="font-size:13px;font-weight:600;color:#6b7280;margin-bottom:8px;">
892
- ${ch.toUpperCase()}
893
- </div>`;
894
- grouped[ch].forEach(i => section.appendChild(buildItemEl(i)));
895
- el.appendChild(section);
896
- });
897
- }
898
-
899
- async function sendText() {
900
- const input = document.getElementById("msg");
901
- const text = input.value.trim();
902
- if (!text) return;
903
-
904
- await fetch("/text", {
905
- method: "POST",
906
- headers: { "Content-Type": "application/json" },
907
- body: JSON.stringify({ content: text, channel, uploader })
908
- });
909
-
910
- input.value = "";
911
- }
912
-
913
- function handleEnter(e) {
914
- if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
915
- e.preventDefault();
916
- sendText();
917
- }
918
- }
919
-
920
- function changeName() {
921
- const n = prompt("Enter name:");
922
- if (!n) return;
923
- uploader = n;
924
- localStorage.setItem("name", n);
925
- document.getElementById("who").innerText = uploader;
926
- }
927
-
928
- let qrLoaded = false;
929
-
930
- async function toggleQR() {
931
- const card = document.getElementById("qrCard");
932
- card.classList.toggle("open");
933
-
934
- if (card.classList.contains("open") && !qrLoaded) {
935
- const res = await fetch("/info");
936
- const { url } = await res.json();
937
- document.getElementById("qrUrl").innerText = url;
938
- document.getElementById("qrImg").src =
939
- `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(url)}`;
940
- qrLoaded = true;
941
- }
942
- }
943
-
944
- document.addEventListener("click", e => {
945
- const widget = document.querySelector(".qr-widget");
946
- if (!widget.contains(e.target)) {
947
- document.getElementById("qrCard").classList.remove("open");
948
- }
949
- });
950
-
951
- const fileInput = document.getElementById("fileInput");
952
-
953
- fileInput.onchange = () => {
954
- if (fileInput.files.length) uploadFiles(fileInput.files);
955
- };
956
-
957
- async function del(id, pinned) {
958
- // pinned items keep confirm dialog
959
- if (pinned) {
960
- const confirmed = confirm("This item is pinned. Are you sure you want to delete it?");
961
- if (!confirmed) return;
962
- await fetch("/item/" + id, { method: "DELETE" });
963
- return;
964
- }
965
-
966
- // if another delete is pending, execute it immediately (don't await — fire and forget)
967
- if (pendingDeleteId !== null) {
968
- clearTimeout(pendingDeleteTimer);
969
- fetch("/item/" + pendingDeleteId, { method: "DELETE" });
970
- pendingDeleteId = null;
971
- pendingDeleteTimer = null;
972
- }
973
-
974
- // optimistically remove from UI
975
- const el = document.querySelector(`[data-item-id="${id}"]`);
976
- if (el) {
977
- pendingDeleteEl = el.outerHTML;
978
- el.remove();
979
- }
980
-
981
- pendingDeleteId = id;
982
- showUndoToast();
983
-
984
- pendingDeleteTimer = setTimeout(async () => {
985
- await fetch("/item/" + pendingDeleteId, { method: "DELETE" });
986
- pendingDeleteId = null;
987
- pendingDeleteTimer = null;
988
- pendingDeleteEl = null;
989
- hideUndoToast();
990
- }, 5000);
991
- }
992
-
993
- function undoDelete() {
994
- if (pendingDeleteId === null) return;
995
- clearTimeout(pendingDeleteTimer);
996
- pendingDeleteId = null;
997
- pendingDeleteTimer = null;
998
- pendingDeleteEl = null;
999
- hideUndoToast();
1000
- load(); // reload to restore item
1001
- }
1002
-
1003
- function showUndoToast() {
1004
- const toast = document.getElementById("undoToast");
1005
- const progress = document.getElementById("undoProgress");
1006
- progress.classList.remove("running");
1007
- void progress.offsetWidth; // force reflow to restart animation
1008
- progress.classList.add("running");
1009
- toast.classList.add("show");
1010
- }
1011
-
1012
- function hideUndoToast() {
1013
- const toast = document.getElementById("undoToast");
1014
- const progress = document.getElementById("undoProgress");
1015
- toast.classList.remove("show");
1016
- progress.classList.remove("running");
1017
- }
1018
-
1019
-
1020
- // prime AudioContext on first interaction so chime works immediately after
1021
- let audioContextPrimed = false;
1022
- document.addEventListener("click", () => {
1023
- if (audioContextPrimed) return;
1024
- try {
1025
- const ctx = new (window.AudioContext || window.webkitAudioContext)();
1026
- ctx.resume().then(() => ctx.close());
1027
- audioContextPrimed = true;
1028
- } catch (e) { }
1029
- }, { once: false });
1030
-
1031
- async function pin(id) {
1032
- await fetch("/pin/" + id, { method: "POST" });
1033
- load();
1034
- }
1035
-
1036
-
1037
- async function logout() {
1038
- await fetch("/logout", { method: "POST" });
1039
- window.location.href = "/login";
1040
- }
1041
-
1042
- socket.on("new-item", item => {
1043
- if (item.channel === channel) {
1044
- if (!item.pinned) newItemIds.add(item.id);
1045
- load();
1046
- if (item.uploader !== uploader) playChime();
1047
- } else if (item.uploader !== uploader) {
1048
- if (!item.pinned) newItemIds.add(item.id);
1049
- unreadChannels.add(item.channel);
1050
- renderChannels();
1051
- }
1052
- });
1053
-
1054
- socket.on("delete-item", id => {
1055
- // don't reload if this item is already removed or pending
1056
- if (id == pendingDeleteId) return;
1057
- const el = document.querySelector(`[data-item-id="${id}"]`);
1058
- if (el) el.remove();
1059
-
1060
- // show empty state if no items left
1061
- const items = document.getElementById("items");
1062
- if (items && !items.querySelector(".item")) {
1063
- items.innerHTML = `<div class="empty-state">Nothing here yet — paste, type, or drop a file to share</div>`;
1064
- }
1065
- });
1066
-
1067
- socket.on("item-updated", ({ id, title, content, edited_at }) => {
1068
- if (title !== undefined) {
1069
- const titleEl = document.getElementById("item-title-" + id);
1070
- if (titleEl) {
1071
- titleEl.innerText = title;
1072
- titleEl.style.display = title ? "" : "none";
1073
- }
1074
- }
1075
-
1076
- if (content !== undefined) {
1077
- const itemEl = document.querySelector(`.item[data-item-id="${id}"]`);
1078
- if (!itemEl) return;
1079
- const leftEl = itemEl.querySelector(".left");
1080
- if (!leftEl) return;
1081
-
1082
- // only update if not currently being edited
1083
- if (leftEl.querySelector(".edit-textarea")) return;
1084
-
1085
- leftEl.dataset.value = content;
1086
-
1087
- const metaEl = leftEl.querySelector(".meta");
1088
- const titleEl = leftEl.querySelector(".item-title");
1089
- Array.from(leftEl.childNodes).forEach(n => {
1090
- if (n !== metaEl && n !== titleEl) n.remove();
1091
- });
1092
-
1093
- const isLink = content.startsWith("http");
1094
- if (isLink) {
1095
- const a = document.createElement("a");
1096
- a.href = content;
1097
- a.target = "_blank";
1098
- a.innerText = content;
1099
- leftEl.insertBefore(a, metaEl);
1100
- } else {
1101
- const wrap = document.createElement("div");
1102
- wrap.innerHTML = renderText(content);
1103
- leftEl.insertBefore(wrap, metaEl);
1104
- }
1105
- }
1106
- });
1107
-
1108
- socket.on("seen-update", ({ id, count }) => {
1109
- const el = document.getElementById("seen-" + id);
1110
- if (!el) return;
1111
- if (count >= 2) {
1112
- el.querySelector(".seen-num").innerText = count;
1113
- el.style.display = "";
1114
- }
1115
- });
1116
-
1117
- socket.on("user-count", count => {
1118
- const el = document.getElementById("onlineCount");
1119
- if (el) el.textContent = `● ${count} online`;
1120
- });
1121
-
1122
- socket.on("item-moved", ({ id, channel: toChannel }) => {
1123
- if (toChannel !== channel) {
1124
- // item left this channel — remove it from view
1125
- load();
1126
- }
1127
- });
1128
-
1129
- socket.on("channel-added", (ch) => {
1130
- if (!channels.find(c => c.name === ch.name)) {
1131
- channels.push(ch);
1132
- renderChannels();
1133
- }
1134
- });
1135
-
1136
- socket.on("channel-deleted", ({ name }) => {
1137
- channels = channels.filter(c => c.name !== name);
1138
- if (channel === name) {
1139
- channel = channels.length ? channels[0].name : null;
1140
- load();
1141
- }
1142
- renderChannels();
1143
- highlight();
1144
- });
1145
-
1146
- socket.on("channel-renamed", ({ oldName, newName }) => {
1147
- channels = channels.map(c => c.name === oldName ? { ...c, name: newName } : c);
1148
- if (channel === oldName) channel = newName;
1149
- renderChannels();
1150
- highlight();
1151
- });
1152
-
1153
- socket.on("channel-pin-update", ({ name, pinned }) => {
1154
- if (pinned) {
1155
- channels = channels.map(c => c.name === name ? { ...c, pinned } : c);
1156
- channels.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
1157
- renderChannels();
1158
- highlight();
1159
- } else {
1160
- loadChannels();
1161
- }
1162
- });
1163
-
1164
- document.getElementById("search").addEventListener("input", async e => {
1165
- const q = e.target.value.trim();
1166
-
1167
- if (!q) {
1168
- document.getElementById("loadMoreWrapper").style.display = "none";
1169
- highlight();
1170
- load();
1171
- return;
1172
- }
1173
-
1174
- document.querySelectorAll(".channels button")
1175
- .forEach(b => b.classList.remove("active"));
1176
-
1177
- document.getElementById("loadMoreWrapper").style.display = "none";
1178
-
1179
- const res = await fetch(`/search/${q}`);
1180
- const data = await res.json();
1181
- renderGrouped(data);
1182
- });
1183
-
1184
-
1185
- document.addEventListener("paste", async e => {
1186
- const active = document.activeElement;
1187
-
1188
- // if user is typing in input, don't auto-send
1189
- if (active && active.id === "msg") return;
1190
-
1191
- // check for image in clipboard first
1192
- const items = e.clipboardData.items;
1193
- for (const item of items) {
1194
- if (item.type.startsWith("image/")) {
1195
- const file = item.getAsFile();
1196
- if (!file) continue;
1197
-
1198
- // give it a meaningful name with timestamp
1199
- const ext = item.type.split("/")[1] || "png";
1200
- const named = new File(
1201
- [file],
1202
- `pasted-${Date.now()}.${ext}`,
1203
- { type: item.type }
1204
- );
1205
- uploadFiles([named]);
1206
- return; // image found, don't fall through to text
1207
- }
1208
- }
1209
-
1210
- // no image — handle as text
1211
- const text = e.clipboardData.getData("text");
1212
- if (!text) return;
1213
-
1214
- await fetch("/text", {
1215
- method: "POST",
1216
- headers: { "Content-Type": "application/json" },
1217
- body: JSON.stringify({
1218
- content: text,
1219
- channel,
1220
- uploader
1221
- })
1222
- });
1223
- });
1224
-
1225
- async function uploadFiles(files) {
1226
- if (!files || !files.length) return;
1227
-
1228
- const status = document.getElementById("uploadStatus");
1229
- const bar = document.getElementById("uploadBar");
1230
- const text = document.getElementById("uploadText");
1231
-
1232
- const total = files.length;
1233
- const targetChannel = channel; // capture at start, won't change if user switches
1234
-
1235
- for (let i = 0; i < total; i++) {
1236
- const file = files[i];
1237
-
1238
- status.style.display = "block";
1239
- bar.style.width = "0%";
1240
- text.innerText = total > 1
1241
- ? `Uploading ${i + 1} of ${total} · ${file.name}`
1242
- : `Uploading: ${file.name}`;
1243
-
1244
- await new Promise((resolve) => {
1245
- const form = new FormData();
1246
- form.append("file", file);
1247
- form.append("channel", targetChannel);
1248
- form.append("uploader", uploader);
1249
-
1250
- const xhr = new XMLHttpRequest();
1251
- xhr.open("POST", "/upload", true);
1252
-
1253
- xhr.upload.onprogress = e => {
1254
- if (e.lengthComputable) {
1255
- bar.style.width = Math.round((e.loaded / e.total) * 100) + "%";
1256
- }
1257
- };
1258
-
1259
- xhr.onload = () => {
1260
- if (xhr.status === 413) {
1261
- text.innerText = `⚠ ${file.name} is too large — skipped`;
1262
- bar.style.width = "0%";
1263
- setTimeout(resolve, 1200);
1264
- return;
1265
- }
1266
- resolve();
1267
- };
1268
-
1269
- xhr.onerror = () => {
1270
- text.innerText = `⚠ ${file.name} failed — skipped`;
1271
- bar.style.width = "0%";
1272
- setTimeout(resolve, 1200);
1273
- };
1274
-
1275
- xhr.send(form);
1276
- });
1277
- }
1278
-
1279
- status.style.display = "none";
1280
- fileInput.value = "";
1281
- load();
1282
- }
1283
-
1284
-
1285
- const overlay = document.getElementById("dragOverlay");
1286
- let dragCounter = 0;
1287
-
1288
- /* DRAG ENTER */
1289
- document.addEventListener("dragenter", e => {
1290
- e.preventDefault();
1291
- dragCounter++;
1292
- overlay.style.display = "flex";
1293
- });
1294
-
1295
- /* DRAG LEAVE */
1296
- document.addEventListener("dragleave", e => {
1297
- dragCounter--;
1298
- if (dragCounter <= 0) {
1299
- overlay.style.display = "none";
1300
- dragCounter = 0;
1301
- }
1302
- });
1303
-
1304
- /*
1305
- document.getElementById("addChannelBtn").onclick = async () => {
1306
- if (channels.length >= 10) { alert("Maximum 10 channels allowed"); return; }
1307
- const name = prompt("Channel name?");
1308
- if (!name) return;
1309
-
1310
- const res = await fetch("/channels", {
1311
- method: "POST",
1312
- headers: { "Content-Type": "application/json" },
1313
- body: JSON.stringify({ name })
1314
- });
1315
-
1316
- if (!res.ok) { const err = await res.json(); alert(err.error); return; }
1317
- await loadChannels();
1318
- };
1319
- */
1320
-
1321
- function addChannelHandler() {
1322
- return async () => {
1323
- if (channels.length >= 10) { alert("Maximum 10 channels allowed"); return; }
1324
- const name = prompt("Channel name?");
1325
- if (!name) return;
1326
-
1327
- const res = await fetch("/channels", {
1328
- method: "POST",
1329
- headers: { "Content-Type": "application/json" },
1330
- body: JSON.stringify({ name })
1331
- });
1332
-
1333
- if (!res.ok) { const err = await res.json(); alert(err.error); return; }
1334
- await loadChannels();
1335
- };
1336
- }
1337
-
1338
- document.getElementById("addChannelBtn").onclick = addChannelHandler();
1339
- document.getElementById("addChannelBtnDesktop").onclick = addChannelHandler();
1340
-
1341
- async function loadChannels() {
1342
- const res = await fetch("/channels");
1343
- const data = await res.json();
1344
- channels = data; // [{ id, name, pinned }]
1345
- if (!channel && channels.length) channel = channels[0].name;
1346
- renderChannels();
1347
- highlight();
1348
- }
1349
-
1350
-
1351
- function renderChannels() {
1352
- const container = document.getElementById("channels");
1353
- container.innerHTML = "";
1354
-
1355
- channels.forEach(ch => {
1356
- const btn = document.createElement("button");
1357
- btn.id = "ch-" + ch.name;
1358
-
1359
- btn.onclick = (e) => {
1360
- if (e.target.classList.contains("ch-more")) return;
1361
- setChannel(ch.name);
1362
- };
1363
-
1364
- btn.appendChild(document.createTextNode(ch.name));
1365
-
1366
- if (ch.pinned) {
1367
- const dot = document.createElement("span");
1368
- dot.className = "ch-pin-dot";
1369
- dot.title = "Pinned protected from deletion";
1370
- btn.appendChild(dot);
1371
- }
1372
-
1373
- if (unreadChannels.has(ch.name)) {
1374
- const unread = document.createElement("span");
1375
- unread.className = "ch-unread-dot";
1376
- btn.appendChild(unread);
1377
- }
1378
-
1379
- const more = document.createElement("span");
1380
- more.className = "ch-more";
1381
- more.innerText = "⋯";
1382
- more.title = "Channel options";
1383
- more.onclick = (e) => {
1384
- e.stopPropagation();
1385
- showChannelMenu(e, ch);
1386
- };
1387
- btn.appendChild(more);
1388
-
1389
- btn.addEventListener("contextmenu", (e) => {
1390
- e.preventDefault();
1391
- showChannelMenu(e, ch);
1392
- });
1393
-
1394
- if (ch.name === channel) btn.classList.add("active");
1395
- container.appendChild(btn);
1396
- });
1397
-
1398
- }
1399
-
1400
- function showChannelMenu(e, ch) {
1401
- const menu = document.getElementById("channelMenu");
1402
-
1403
- menu.innerHTML = `
1404
- <button onclick="toggleChannelPin('${ch.name}')">
1405
- ${ch.pinned ? "📍 Unpin channel" : "📌 Pin channel"}
1406
- </button>
1407
- <div class="menu-divider"></div>
1408
- <button onclick="renameChannelPrompt('${ch.name}')">✎&nbsp; Rename</button>
1409
- <button class="${ch.pinned ? "muted" : "danger"}"
1410
- ${ch.pinned ? "" : `onclick="deleteChannel('${ch.name}')"`}>
1411
- 🗑&nbsp; Delete${ch.pinned ? " (pinned)" : ""}
1412
- </button>
1413
- `;
1414
-
1415
- menu.style.top = e.clientY + "px";
1416
- menu.style.left = e.clientX + "px";
1417
- menu.classList.add("open");
1418
-
1419
- requestAnimationFrame(() => {
1420
- const rect = menu.getBoundingClientRect();
1421
- if (rect.right > window.innerWidth - 8)
1422
- menu.style.left = (e.clientX - rect.width) + "px";
1423
- if (rect.bottom > window.innerHeight - 8)
1424
- menu.style.top = (e.clientY - rect.height) + "px";
1425
- });
1426
- }
1427
-
1428
- document.addEventListener("click", () => {
1429
- document.getElementById("channelMenu").classList.remove("open");
1430
- });
1431
-
1432
- document.addEventListener("contextmenu", (e) => {
1433
- if (!e.target.closest("#channels"))
1434
- document.getElementById("channelMenu").classList.remove("open");
1435
- });
1436
-
1437
- async function toggleChannelPin(name) {
1438
- await fetch(`/channels/${name}/pin`, { method: "POST" });
1439
- }
1440
-
1441
- async function renameChannelPrompt(name) {
1442
- const newName = prompt("Rename channel to:", name);
1443
- if (!newName || newName.trim() === name) return;
1444
-
1445
- const res = await fetch("/channels/" + name, {
1446
- method: "PATCH",
1447
- headers: { "Content-Type": "application/json" },
1448
- body: JSON.stringify({ name: newName.trim() })
1449
- });
1450
-
1451
- if (!res.ok) { const err = await res.json(); alert(err.error); }
1452
- }
1453
-
1454
- async function deleteChannel(name) {
1455
- const confirmed = confirm(`Delete "${name}"? All items will be permanently removed.`);
1456
- if (!confirmed) return;
1457
-
1458
- const res = await fetch("/channels/" + name, { method: "DELETE" });
1459
- if (!res.ok) { const err = await res.json(); alert(err.error); }
1460
- }
1461
-
1462
- /* DRAG OVER */
1463
- document.addEventListener("dragover", e => {
1464
- e.preventDefault();
1465
- });
1466
-
1467
- /* DROP ANYWHERE */
1468
- document.addEventListener("drop", async e => {
1469
- e.preventDefault();
1470
- overlay.style.display = "none";
1471
- dragCounter = 0;
1472
-
1473
- const files = e.dataTransfer.files;
1474
- if (!files.length) return;
1475
- uploadFiles(files);
1476
- });
1477
-
1478
- // ========================
1479
- // KEYBOARD SHORTCUTS
1480
- // ========================
1481
- document.addEventListener("keydown", e => {
1482
- const active = document.activeElement;
1483
- const isTyping = active && (active.id === "msg" || active.id === "search");
1484
-
1485
- // Ctrl/Cmd + Enter — send message (only when msg is focused)
1486
- if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
1487
- if (active && active.id === "msg") {
1488
- e.preventDefault();
1489
- e.stopPropagation();
1490
- sendText();
1491
- }
1492
- return;
1493
- }
1494
-
1495
- // Ctrl/Cmd + K — focus message input
1496
- if ((e.ctrlKey || e.metaKey) && e.key === "k") {
1497
- if (!isTyping) {
1498
- e.preventDefault();
1499
- document.getElementById("msg").focus();
1500
- }
1501
- return;
1502
- }
1503
-
1504
- // / — focus search
1505
- if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
1506
- e.preventDefault();
1507
- const search = document.getElementById("search");
1508
- search.focus();
1509
- search.select();
1510
- return;
1511
- }
1512
-
1513
- // Tab — cycle channels
1514
- if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
1515
- e.preventDefault();
1516
- if (!channels.length) return;
1517
- const currentIndex = channels.findIndex(c => c.name === channel);
1518
- const nextIndex = (currentIndex + 1) % channels.length;
1519
- setChannel(channels[nextIndex].name);
1520
- return;
1521
- }
1522
-
1523
- // Escape close state in priority order
1524
- if (e.key === "Escape") {
1525
-
1526
- // 1. blur any focused input first
1527
- if (active && (active.id === "msg" || active.id === "search")) {
1528
- if (active.id === "search" && active.value) {
1529
- active.value = "";
1530
- highlight();
1531
- load();
1532
- }
1533
- active.blur();
1534
- return;
1535
- }
1536
-
1537
- // 2. close open preview
1538
- if (openPreviewId) {
1539
- const panel = document.getElementById("preview-" + openPreviewId);
1540
- const btn = document.getElementById("prevbtn-" + openPreviewId);
1541
- if (panel) panel.classList.remove("open");
1542
- if (btn) btn.classList.remove("preview-active");
1543
- openPreviewId = null;
1544
- return;
1545
- }
1546
-
1547
- // 3. close move dropdown
1548
- if (openDropdown) {
1549
- openDropdown.classList.remove("open");
1550
- openDropdown = null;
1551
- return;
1552
- }
1553
-
1554
- // 4. close context menu
1555
- const contextMenu = document.getElementById("channelMenu");
1556
- if (contextMenu.classList.contains("open")) {
1557
- contextMenu.classList.remove("open");
1558
- return;
1559
- }
1560
-
1561
- // 5. close QR card
1562
- const qrCard = document.getElementById("qrCard");
1563
- if (qrCard.classList.contains("open")) {
1564
- qrCard.classList.remove("open");
1565
- return;
1566
- }
1567
- }
1568
-
1569
- // All remaining shortcuts skip if typing
1570
- if (isTyping) return;
1571
-
1572
- });
1573
-
1574
- (async function init() {
1575
- await applyBranding();
1576
- await initName();
1577
- const infoRes = await fetch("/info");
1578
- const info = await infoRes.json();
1579
- if (!info.hasAuth) {
1580
- document.getElementById("logoutBtn").style.display = "none";
1581
- }
1582
- retention = info.retention; // null if "never", ms value otherwise
1583
- await loadChannels();
1584
- load();
1
+ const socket = io();
2
+
3
+ let currentPage = 1;
4
+ let hasMoreItems = false;
5
+ let retention = 24 * 60 * 60 * 1000; // default 24h, overwritten on init
6
+ const newItemIds = new Set();
7
+ const seenEmitted = new Set(); // item IDs this session has already emitted seen for
8
+
9
+ const seenObserver = new IntersectionObserver((entries) => {
10
+ entries.forEach(entry => {
11
+ if (!entry.isIntersecting) return;
12
+ const id = parseInt(entry.target.dataset.itemId);
13
+ if (!id || seenEmitted.has(id)) return;
14
+
15
+ // wait 1 second of visibility before counting as seen
16
+ const timer = setTimeout(() => {
17
+ if (seenEmitted.has(id)) return;
18
+ seenEmitted.add(id);
19
+ socket.emit("seen", { id, name: uploader });
20
+ seenObserver.unobserve(entry.target); // done with this element
21
+ }, 1000);
22
+
23
+ // if element leaves viewport before 1s, cancel
24
+ entry.target._seenTimer = timer;
25
+ });
26
+
27
+ // cancel timers for elements that left viewport
28
+ entries.forEach(entry => {
29
+ if (entry.isIntersecting) return;
30
+ if (entry.target._seenTimer) {
31
+ clearTimeout(entry.target._seenTimer);
32
+ entry.target._seenTimer = null;
33
+ }
34
+ });
35
+ }, { threshold: 0.5 }); // at least 50% of item must be visible
36
+
37
+ // ========================
38
+ // THEME MANAGEMENT (FIXED)
39
+ // ========================
40
+ const THEME_KEY = "instbyte_theme";
41
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
42
+
43
+ function getStoredTheme() {
44
+ return localStorage.getItem(THEME_KEY) || "auto";
45
+ }
46
+
47
+ function applyTheme(theme) {
48
+ const root = document.documentElement;
49
+ const btn = document.getElementById("themeToggle");
50
+
51
+ // Apply attribute EXACTLY
52
+ if (theme === "dark") {
53
+ root.setAttribute("data-theme", "dark");
54
+ } else if (theme === "light") {
55
+ root.setAttribute("data-theme", "light");
56
+ } else {
57
+ root.removeAttribute("data-theme"); // auto
58
+ }
59
+
60
+ if (!btn) return;
61
+
62
+ // Update icon based on CURRENT stored state
63
+ if (theme === "dark") btn.textContent = "☀️";
64
+ else if (theme === "light") btn.textContent = "🌙";
65
+ else {
66
+ btn.textContent = mq.matches ? "☀️" : "🌙";
67
+ }
68
+ }
69
+
70
+ function cycleTheme() {
71
+ const current = getStoredTheme();
72
+
73
+ let next;
74
+ if (current === "dark") next = "light";
75
+ else if (current === "light") next = "dark";
76
+ else next = "dark"; // auto → dark first
77
+
78
+ localStorage.setItem(THEME_KEY, next);
79
+ applyTheme(next);
80
+ }
81
+
82
+ // INITIAL LOAD
83
+ applyTheme(getStoredTheme());
84
+
85
+ // OS change listener (only when auto)
86
+ mq.addEventListener("change", () => {
87
+ if (getStoredTheme() === "auto") {
88
+ applyTheme("auto");
89
+ }
90
+ });
91
+
92
+ async function applyBranding() {
93
+ try {
94
+ const res = await fetch("/branding");
95
+ const b = await res.json();
96
+
97
+ // Update page title and app name
98
+ document.title = b.appName;
99
+ const nameEl = document.getElementById("appName");
100
+ if (nameEl) nameEl.innerText = b.appName;
101
+
102
+ // Update logo src to dynamic route
103
+ const logoEl = document.getElementById("appLogo");
104
+ if (logoEl) logoEl.src = "/logo-dynamic.png";
105
+
106
+ // Inject CSS variables
107
+ const p = b.palette;
108
+ const root = document.documentElement;
109
+ root.style.setProperty("--color-primary", p.primary);
110
+ root.style.setProperty("--color-primary-hover", p.primaryHover);
111
+ root.style.setProperty("--color-primary-light", p.primaryLight);
112
+ root.style.setProperty("--color-primary-dark", p.primaryDark);
113
+ root.style.setProperty("--color-on-primary", p.onPrimary);
114
+ root.style.setProperty("--color-secondary", p.secondary);
115
+ root.style.setProperty("--color-secondary-hover", p.secondaryHover);
116
+ root.style.setProperty("--color-secondary-light", p.secondaryLight);
117
+ root.style.setProperty("--color-on-secondary", p.onSecondary);
118
+
119
+ } catch (e) {
120
+ // Branding failed — default styles remain, no crash
121
+ }
122
+ }
123
+
124
+ function formatSize(bytes) {
125
+ if (!bytes) return "";
126
+ if (bytes >= 1024 ** 3) return (bytes / 1024 ** 3).toFixed(1) + " GB";
127
+ if (bytes >= 1024 ** 2) return (bytes / 1024 ** 2).toFixed(1) + " MB";
128
+ if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB";
129
+ return bytes + " B";
130
+ }
131
+
132
+ function getExpiryBadge(createdAt) {
133
+ // if retention is null ("never"), nothing expires
134
+ if (retention === null) return "";
135
+
136
+ const expiresAt = createdAt + retention;
137
+ const remaining = expiresAt - Date.now();
138
+ const THREE_HOURS = 3 * 60 * 60 * 1000;
139
+
140
+ if (remaining > THREE_HOURS) return "";
141
+ if (remaining <= 0) return `<span class="expiry-badge expiry-gone">expired</span>`;
142
+
143
+ const m = Math.floor(remaining / 60000);
144
+ const h = Math.floor(m / 60);
145
+ const label = h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
146
+
147
+ return `<span class="expiry-badge">⏱ ${label}</span>`;
148
+ }
149
+
150
+ function escapeHtml(str) {
151
+ if (!str) return "";
152
+ return str
153
+ .replace(/&/g, "&amp;")
154
+ .replace(/</g, "&lt;")
155
+ .replace(/>/g, "&gt;")
156
+ .replace(/"/g, "&quot;");
157
+ }
158
+
159
+ function playChime() {
160
+ try {
161
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
162
+
163
+ const gain = ctx.createGain();
164
+ gain.connect(ctx.destination);
165
+ gain.gain.setValueAtTime(0, ctx.currentTime);
166
+ gain.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 0.01);
167
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
168
+
169
+ const osc1 = ctx.createOscillator();
170
+ osc1.type = "sine";
171
+ osc1.frequency.setValueAtTime(880, ctx.currentTime);
172
+ osc1.frequency.exponentialRampToValueAtTime(1100, ctx.currentTime + 0.1);
173
+ osc1.connect(gain);
174
+ osc1.start(ctx.currentTime);
175
+ osc1.stop(ctx.currentTime + 0.6);
176
+
177
+ const osc2 = ctx.createOscillator();
178
+ osc2.type = "sine";
179
+ osc2.frequency.setValueAtTime(1320, ctx.currentTime + 0.08);
180
+ osc2.frequency.exponentialRampToValueAtTime(1760, ctx.currentTime + 0.2);
181
+ osc2.connect(gain);
182
+ osc2.start(ctx.currentTime + 0.08);
183
+ osc2.stop(ctx.currentTime + 0.6);
184
+
185
+ } catch (e) {
186
+ // audio not supported or blocked — fail silently
187
+ }
188
+ }
189
+
190
+ function getSizeTag(bytes) {
191
+ if (!bytes) return "";
192
+ const mb = bytes / (1024 * 1024);
193
+ if (mb > 1024) return "danger-dark";
194
+ if (mb > 500) return "danger-light";
195
+ if (mb > 100) return "warn";
196
+ return "";
197
+ }
198
+
199
+ // Configure marked
200
+ marked.setOptions({
201
+ breaks: true,
202
+ gfm: true
203
+ });
204
+
205
+ function looksLikeMarkdown(text) {
206
+ return /^#{1,3} |[*_`~]|\[.+\]\(.+\)|^[-*+] |^\d+\. |^```/m.test(text);
207
+ }
208
+
209
+ function renderText(text) {
210
+ if (!text) return "";
211
+ if (looksLikeMarkdown(text)) {
212
+ const html = marked.parse(text);
213
+ // highlight code blocks after parse
214
+ const wrap = document.createElement("div");
215
+ wrap.innerHTML = html;
216
+ wrap.querySelectorAll("pre code").forEach(el => hljs.highlightElement(el));
217
+ return `<div class="markdown-body">${wrap.innerHTML}</div>`;
218
+ }
219
+ // plain text — just escape and preserve newlines
220
+ return text
221
+ .replace(/&/g, "&amp;")
222
+ .replace(/</g, "&lt;")
223
+ .replace(/>/g, "&gt;")
224
+ .replace(/\n/g, "<br>");
225
+ }
226
+
227
+ const TEXT_EXTENSIONS = [
228
+ 'txt', 'md', 'js', 'ts', 'jsx', 'tsx', 'json', 'json5',
229
+ 'css', 'html', 'htm', 'xml', 'svg', 'sh', 'bash', 'zsh',
230
+ 'py', 'rb', 'php', 'java', 'c', 'cpp', 'h', 'cs', 'go',
231
+ 'rs', 'swift', 'kt', 'yaml', 'yml', 'toml', 'ini', 'env',
232
+ 'gitignore', 'dockerfile', 'sql', 'csv', 'log'
233
+ ];
234
+
235
+ const MAX_TEXT_PREVIEW_BYTES = 50 * 1024; // 50KB — beyond this we warn
236
+ const MAX_TEXT_LINES = 200;
237
+
238
+ function getPreviewType(filename) {
239
+ if (!filename) return "none";
240
+ const ext = filename.split(".").pop().toLowerCase();
241
+ if (/^(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(ext)) return "image";
242
+ if (/^(mp4|webm|ogg|mov)$/.test(ext)) return "video";
243
+ if (/^(mp3|wav|ogg|m4a|flac|aac)$/.test(ext)) return "audio";
244
+ if (ext === "pdf") return "pdf";
245
+ if (TEXT_EXTENSIONS.includes(ext)) return "text";
246
+ return "none";
247
+ }
248
+
249
+ function getLanguage(filename) {
250
+ const ext = filename.split(".").pop().toLowerCase();
251
+ const map = {
252
+ js: "javascript", ts: "typescript", jsx: "javascript",
253
+ tsx: "typescript", py: "python", rb: "ruby", php: "php",
254
+ java: "java", c: "c", cpp: "cpp", h: "c", cs: "csharp",
255
+ go: "go", rs: "rust", swift: "swift", kt: "kotlin",
256
+ html: "html", htm: "html", css: "css", json: "json",
257
+ json5: "json", xml: "xml", svg: "xml", sh: "bash",
258
+ bash: "bash", zsh: "bash", yaml: "yaml", yml: "yaml",
259
+ toml: "toml", sql: "sql", md: "markdown", csv: "plaintext",
260
+ txt: "plaintext", log: "plaintext", env: "plaintext",
261
+ dockerfile: "dockerfile"
262
+ };
263
+ return map[ext] || "plaintext";
264
+ }
265
+
266
+ let openPreviewId = null;
267
+
268
+ async function togglePreview(id, filename) {
269
+ const panel = document.getElementById("preview-" + id);
270
+ const btn = document.getElementById("prevbtn-" + id);
271
+ if (!panel || !btn) return;
272
+
273
+ const isOpen = panel.classList.contains("open");
274
+
275
+ // Close any other open preview first
276
+ if (openPreviewId && openPreviewId !== id) {
277
+ const otherPanel = document.getElementById("preview-" + openPreviewId);
278
+ const otherBtn = document.getElementById("prevbtn-" + openPreviewId);
279
+ if (otherPanel) otherPanel.classList.remove("open");
280
+ if (otherBtn) otherBtn.classList.remove("preview-active");
281
+ }
282
+
283
+ if (isOpen) {
284
+ panel.classList.remove("open");
285
+ btn.classList.remove("preview-active");
286
+ openPreviewId = null;
287
+ return;
288
+ }
289
+
290
+ // Open this one
291
+ panel.classList.add("open");
292
+ btn.classList.add("preview-active");
293
+ openPreviewId = id;
294
+
295
+ // Only build content once
296
+ if (panel.dataset.loaded) return;
297
+ panel.dataset.loaded = "true";
298
+
299
+ const type = getPreviewType(filename);
300
+ const url = `/uploads/${filename}`;
301
+
302
+ if (type === "image") {
303
+ panel.innerHTML = `<img src="${url}" alt="${filename}">`;
304
+
305
+ } else if (type === "video") {
306
+ panel.innerHTML = `
307
+ <video controls preload="metadata">
308
+ <source src="${url}">
309
+ Your browser doesn't support video preview.
310
+ </video>`;
311
+
312
+ } else if (type === "audio") {
313
+ panel.innerHTML = `
314
+ <audio controls preload="metadata">
315
+ <source src="${url}">
316
+ Your browser doesn't support audio preview.
317
+ </audio>`;
318
+
319
+ } else if (type === "pdf") {
320
+ panel.innerHTML = `<embed src="${url}" type="application/pdf">`;
321
+
322
+ } else if (type === "text") {
323
+ panel.innerHTML = `<div class="preview-loading">Loading...</div>`;
324
+
325
+ try {
326
+ const res = await fetch(url);
327
+ if (!res.ok) throw new Error("Failed to load");
328
+
329
+ const contentLength = res.headers.get("content-length");
330
+ if (contentLength && parseInt(contentLength) > MAX_TEXT_PREVIEW_BYTES) {
331
+ panel.innerHTML = `
332
+ <div class="preview-error">
333
+ File is too large to preview (${formatSize(parseInt(contentLength))}).
334
+ <a href="${url}" target="_blank">Open in new tab</a>
335
+ </div>`;
336
+ return;
337
+ }
338
+
339
+ const text = await res.text();
340
+ const ext = filename.split(".").pop().toLowerCase();
341
+
342
+ // markdown files — render as HTML, not syntax-highlighted raw text
343
+ if (ext === "md") {
344
+ const html = marked.parse(text);
345
+ const wrap = document.createElement("div");
346
+ wrap.className = "markdown-body markdown-preview";
347
+ wrap.innerHTML = html;
348
+
349
+ // syntax highlight any code blocks inside
350
+ wrap.querySelectorAll("pre code").forEach(el => hljs.highlightElement(el));
351
+
352
+ panel.innerHTML = "";
353
+ panel.appendChild(wrap);
354
+ return;
355
+ }
356
+
357
+ // all other text files — syntax highlighted raw view
358
+ const lines = text.split("\n");
359
+ const truncated = lines.length > MAX_TEXT_LINES;
360
+ const preview = truncated
361
+ ? lines.slice(0, MAX_TEXT_LINES).join("\n")
362
+ : text;
363
+
364
+ const lang = getLanguage(filename);
365
+ const code = document.createElement("code");
366
+ code.className = `language-${lang}`;
367
+ code.textContent = preview;
368
+
369
+ const pre = document.createElement("pre");
370
+ pre.appendChild(code);
371
+ hljs.highlightElement(code);
372
+
373
+ panel.innerHTML = "";
374
+ panel.appendChild(pre);
375
+
376
+ if (truncated) {
377
+ const note = document.createElement("div");
378
+ note.className = "preview-truncated";
379
+ note.innerHTML = `Showing first ${MAX_TEXT_LINES} of ${lines.length} lines. <a href="${url}" target="_blank">View full file</a>`;
380
+ panel.appendChild(note);
381
+ }
382
+
383
+ } catch (err) {
384
+ panel.innerHTML = `
385
+ <div class="preview-error">
386
+ Could not load preview. <a href="${url}" target="_blank">Open directly</a>
387
+ </div>`;
388
+ }
389
+ }
390
+ }
391
+
392
+ function handleRowClick(el, type, value) {
393
+ if (type === "text") {
394
+ navigator.clipboard.writeText(value).then(() => {
395
+ el.classList.add("flash");
396
+ setTimeout(() => el.classList.remove("flash"), 800);
397
+ });
398
+ } else {
399
+ const a = document.createElement("a");
400
+ a.href = value;
401
+ a.download = "";
402
+ a.click();
403
+ }
404
+ }
405
+
406
+ document.getElementById("items").addEventListener("click", e => {
407
+ const left = e.target.closest(".left");
408
+ if (!left) return;
409
+ const type = left.dataset.type;
410
+ const value = left.dataset.value;
411
+ handleRowClick(left, type, value);
412
+ });
413
+
414
+ let openDropdown = null;
415
+
416
+ let pendingDeleteId = null;
417
+ let pendingDeleteTimer = null;
418
+ let pendingDeleteEl = null;
419
+
420
+ function toggleMoveDropdown(e, id, currentChannel) {
421
+ e.stopPropagation();
422
+
423
+ // close any open one first
424
+ if (openDropdown && openDropdown !== e.currentTarget.nextElementSibling) {
425
+ openDropdown.classList.remove("open");
426
+ }
427
+
428
+ const dropdown = e.currentTarget.nextElementSibling;
429
+ const isOpen = dropdown.classList.contains("open");
430
+
431
+ if (isOpen) {
432
+ dropdown.classList.remove("open");
433
+ openDropdown = null;
434
+ return;
435
+ }
436
+
437
+ // build channel list fresh each time (channels may have changed)
438
+ const others = channels.filter(c => c.name !== currentChannel);
439
+ dropdown.innerHTML = `<div class="dropdown-label">Move to</div>`;
440
+
441
+ others.forEach(ch => {
442
+ const btn = document.createElement("button");
443
+ btn.innerText = ch.name;
444
+ btn.onclick = (ev) => {
445
+ ev.stopPropagation();
446
+ moveItem(id, ch.name);
447
+ dropdown.classList.remove("open");
448
+ openDropdown = null;
449
+ };
450
+ dropdown.appendChild(btn);
451
+ });
452
+
453
+ dropdown.classList.add("open");
454
+ openDropdown = dropdown;
455
+ }
456
+
457
+ function toggleMoreMenu(e, id, currentChannel) {
458
+ e.stopPropagation();
459
+
460
+ const moreDropdown = e.currentTarget.nextElementSibling;
461
+ const isOpen = moreDropdown.classList.contains("open");
462
+
463
+ // close any other open dropdown
464
+ if (openDropdown && openDropdown !== moreDropdown) {
465
+ openDropdown.classList.remove("open");
466
+ }
467
+
468
+ if (isOpen) {
469
+ moreDropdown.classList.remove("open");
470
+ openDropdown = null;
471
+ return;
472
+ }
473
+
474
+ // build move list fresh each time
475
+ const moveList = moreDropdown.querySelector(".move-list");
476
+ const others = channels.filter(c => c.name !== currentChannel);
477
+ moveList.innerHTML = "";
478
+
479
+ if (others.length === 0) {
480
+ moveList.innerHTML = `<div class="dropdown-label" style="padding:4px 14px 8px">No other channels</div>`;
481
+ } else {
482
+ others.forEach(ch => {
483
+ const btn = document.createElement("button");
484
+ btn.innerText = ch.name;
485
+ btn.onclick = (ev) => {
486
+ ev.stopPropagation();
487
+ moveItem(id, ch.name);
488
+ moreDropdown.classList.remove("open");
489
+ openDropdown = null;
490
+ };
491
+ moveList.appendChild(btn);
492
+ });
493
+ }
494
+
495
+ moreDropdown.classList.add("open");
496
+ openDropdown = moreDropdown;
497
+ }
498
+
499
+ async function moveItem(id, toChannel) {
500
+ await fetch(`/item/${id}/move`, {
501
+ method: "PATCH",
502
+ headers: { "Content-Type": "application/json" },
503
+ body: JSON.stringify({ channel: toChannel })
504
+ });
505
+ // socket will handle the re-render via item-moved event
506
+ }
507
+
508
+ document.addEventListener("click", () => {
509
+ if (openDropdown) {
510
+ openDropdown.classList.remove("open");
511
+ openDropdown = null;
512
+ }
513
+ });
514
+
515
+ let channel = null;
516
+ let channels = [];
517
+ let unreadChannels = new Set();
518
+
519
+ let uploader = localStorage.getItem("name") || "";
520
+
521
+ async function initName() {
522
+ if (!uploader || uploader === "null" || uploader.trim() === "") {
523
+ let suggested = "USER-" + Math.floor(Math.random() * 1000);
524
+ const ua = navigator.userAgent;
525
+ if (/android/i.test(ua)) suggested = "Android-" + Math.floor(Math.random() * 1000);
526
+ else if (/iphone|ipad/i.test(ua)) suggested = "iPhone-" + Math.floor(Math.random() * 1000);
527
+ else if (/mac/i.test(ua)) suggested = "Mac-" + Math.floor(Math.random() * 1000);
528
+ else if (/windows/i.test(ua)) suggested = "Windows-" + Math.floor(Math.random() * 1000);
529
+ else if (/linux/i.test(ua)) suggested = "Linux-" + Math.floor(Math.random() * 1000);
530
+ else suggested = "User-" + Math.floor(Math.random() * 1000);
531
+ uploader = prompt("Your name?", suggested) || suggested;
532
+ localStorage.setItem("name", uploader);
533
+ }
534
+ document.getElementById("who").innerText = uploader;
535
+ socket.emit("join", uploader);
536
+ }
537
+
538
+ function highlight() {
539
+ document.querySelectorAll(".channels button")
540
+ .forEach(b => b.classList.remove("active"));
541
+ const el = document.getElementById("ch-" + channel);
542
+ if (el) el.classList.add("active");
543
+ }
544
+
545
+ function setChannel(c) {
546
+ // flush any pending delete before switching
547
+ if (pendingDeleteId !== null) {
548
+ clearTimeout(pendingDeleteTimer);
549
+ fetch("/item/" + pendingDeleteId, { method: "DELETE" });
550
+ pendingDeleteId = null;
551
+ pendingDeleteTimer = null;
552
+ pendingDeleteEl = null;
553
+ hideUndoToast();
554
+ }
555
+ unreadChannels.delete(c); // clear unread when switching to channel
556
+ channel = c;
557
+ renderChannels();
558
+ highlight();
559
+ load();
560
+ }
561
+
562
+ async function load(resetPage = true) {
563
+ if (resetPage) currentPage = 1;
564
+
565
+ const res = await fetch(`/items/${channel}?page=${currentPage}`);
566
+ const data = await res.json();
567
+
568
+ console.log("load called, page:", currentPage, "data:", data);
569
+
570
+ hasMoreItems = data.hasMore;
571
+
572
+ if (currentPage === 1) {
573
+ render(data.items);
574
+ } else {
575
+ renderMore(data.items);
576
+ }
577
+
578
+ const wrapper = document.getElementById("loadMoreWrapper");
579
+ wrapper.style.display = hasMoreItems ? "block" : "none";
580
+ }
581
+
582
+ async function loadMore() {
583
+ currentPage++;
584
+ await load(false);
585
+ }
586
+
587
+ // ========================
588
+ // ITEM RENDERING
589
+ // ========================
590
+ function buildItemContent(i) {
591
+ if (i.type === "file") {
592
+ const isImg = i.filename.match(/\.(jpg|png|jpeg|gif)$/i);
593
+ const sizeLabel = formatSize(i.size);
594
+ const sizeClass = getSizeTag(i.size);
595
+ const sizeTag = sizeClass
596
+ ? `<span class="size-tag ${sizeClass}">${sizeLabel}</span>`
597
+ : sizeLabel
598
+ ? `<span style="font-size:11px;color:#9ca3af;margin-left:6px;">${sizeLabel}</span>`
599
+ : "";
600
+
601
+ if (isImg) {
602
+ return `<img src="/uploads/${i.filename}" style="max-width:200px;border-radius:6px"><br>
603
+ <a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
604
+ }
605
+ return `<a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
606
+ }
607
+
608
+ const isLink = i.content && i.content.startsWith("http");
609
+ if (isLink) return `<a href="${i.content}" target="_blank">${i.content}</a>`;
610
+ return renderText(i.content);
611
+ }
612
+
613
+ function buildItemEl(i) {
614
+ const div = document.createElement("div");
615
+ div.className = "item";
616
+
617
+ if (newItemIds.has(i.id) && !i.pinned) {
618
+ div.classList.add("item-new");
619
+ setTimeout(() => {
620
+ div.classList.remove("item-new");
621
+ newItemIds.delete(i.id);
622
+ }, 4000);
623
+ }
624
+ div.dataset.itemId = i.id;
625
+
626
+ const content = buildItemContent(i);
627
+ const isFile = i.type === "file";
628
+ const dataValue = isFile
629
+ ? `/uploads/${i.filename}`
630
+ : (i.content || "").replace(/"/g, "&quot;");
631
+ const tooltip = isFile ? "Click to download" : "Click to copy";
632
+
633
+ // contextual slot — preview for files, edit for text, nothing otherwise
634
+ const contextualBtn = getPreviewType(i.filename) !== "none" && getPreviewType(i.filename) !== "image"
635
+ ? `<button class="icon-btn" id="prevbtn-${i.id}"
636
+ onclick="togglePreview(${i.id}, '${i.filename}')"
637
+ title="Preview"><i data-lucide="eye"></i></button>`
638
+ : !isFile
639
+ ? `<button class="icon-btn" onclick="editContent(${i.id})" title="Edit content"><i data-lucide="pencil-line"></i></button>`
640
+ : "";
641
+
642
+ const titleHtml = i.title
643
+ ? `<div class="item-title" id="item-title-${i.id}">${escapeHtml(i.title)}</div>`
644
+ : `<div class="item-title" id="item-title-${i.id}" style="display:none"></div>`;
645
+
646
+ div.innerHTML = `
647
+ <div class="item-top">
648
+ <div class="left"
649
+ data-tooltip="${tooltip}"
650
+ data-type="${isFile ? "file" : "text"}"
651
+ data-value="${dataValue}">
652
+ ${titleHtml}
653
+ ${content}
654
+ <div class="meta">
655
+ ${i.uploader}
656
+ <span class="seen-count" id="seen-${i.id}" style="display:none">👁 <span class="seen-num"></span></span>
657
+ ${getExpiryBadge(i.created_at)}
658
+ </div>
659
+ </div>
660
+ <div class="item-actions">
661
+ ${contextualBtn}
662
+ <button class="icon-btn ${i.pinned ? "pinned" : ""}" onclick="pin(${i.id})" title="${i.pinned ? "Unpin" : "Pin"}">
663
+ <i data-lucide="${i.pinned ? "pin-off" : "pin"}"></i>
664
+ </button>
665
+ <button class="icon-btn delete" onclick="del(${i.id}, ${i.pinned})" title="Delete">
666
+ <i data-lucide="trash-2"></i>
667
+ </button>
668
+ <div class="more-wrapper">
669
+ <button class="icon-btn more-btn" onclick="toggleMoreMenu(event, ${i.id}, '${i.channel}')" title="More">
670
+ <i data-lucide="more-vertical"></i>
671
+ </button>
672
+ <div class="more-dropdown">
673
+ <button onclick="editTitle(${i.id})">
674
+ <i data-lucide="tag"></i> Add / edit title
675
+ </button>
676
+ <div class="menu-divider"></div>
677
+ <div class="dropdown-label">Move to</div>
678
+ <div class="move-list"></div>
679
+ </div>
680
+ </div>
681
+ </div>
682
+ </div>
683
+ <div class="preview-panel" id="preview-${i.id}"></div>`;
684
+
685
+ seenObserver.observe(div);
686
+ if (typeof lucide !== "undefined") {
687
+ lucide.createIcons({ nodes: [div] });
688
+ } else {
689
+ console.warn("Lucide not yet defined when building item", i.id);
690
+ }
691
+ return div;
692
+ }
693
+
694
+ function editTitle(id) {
695
+ const titleEl = document.getElementById("item-title-" + id);
696
+ if (!titleEl) return;
697
+
698
+ const current = titleEl.dataset.value || titleEl.innerText.trim();
699
+
700
+ const input = document.createElement("input");
701
+ input.type = "text";
702
+ input.className = "title-input";
703
+ input.value = current;
704
+ input.placeholder = "Add a title...";
705
+ input.maxLength = 100;
706
+
707
+ titleEl.replaceWith(input);
708
+ input.focus();
709
+ input.select();
710
+
711
+ async function save() {
712
+ const newTitle = input.value.trim();
713
+ const res = await fetch(`/item/${id}/title`, {
714
+ method: "PATCH",
715
+ headers: { "Content-Type": "application/json" },
716
+ body: JSON.stringify({ title: newTitle })
717
+ });
718
+ if (!res.ok) { restore(); return; }
719
+
720
+ const fresh = document.createElement("div");
721
+ fresh.className = "item-title";
722
+ fresh.id = "item-title-" + id;
723
+ if (newTitle) {
724
+ fresh.innerText = newTitle;
725
+ fresh.style.display = "";
726
+ } else {
727
+ fresh.style.display = "none";
728
+ }
729
+ input.replaceWith(fresh);
730
+ }
731
+
732
+ function restore() {
733
+ const fresh = document.createElement("div");
734
+ fresh.className = "item-title";
735
+ fresh.id = "item-title-" + id;
736
+ if (current) {
737
+ fresh.innerText = current;
738
+ fresh.style.display = "";
739
+ } else {
740
+ fresh.style.display = "none";
741
+ }
742
+ input.replaceWith(fresh);
743
+ }
744
+
745
+ input.addEventListener("keydown", e => {
746
+ if (e.key === "Enter") { e.preventDefault(); save(); }
747
+ if (e.key === "Escape") { e.preventDefault(); restore(); }
748
+ });
749
+
750
+ input.addEventListener("blur", save);
751
+ }
752
+
753
+ function editContent(id) {
754
+ const itemEl = document.querySelector(`.item[data-item-id="${id}"]`);
755
+ if (!itemEl) return;
756
+
757
+ const leftEl = itemEl.querySelector(".left");
758
+ if (!leftEl) return;
759
+
760
+ // grab current raw content from data-value
761
+ const current = leftEl.dataset.value || "";
762
+
763
+ // find the content node everything except .meta and .item-title
764
+ const metaEl = leftEl.querySelector(".meta");
765
+ const titleEl = leftEl.querySelector(".item-title");
766
+
767
+ // collect content nodes (not meta, not title)
768
+ const contentNodes = Array.from(leftEl.childNodes).filter(n => {
769
+ if (n === metaEl) return false;
770
+ if (n === titleEl) return false;
771
+ return true;
772
+ });
773
+
774
+ // replace content nodes with textarea
775
+ const textarea = document.createElement("textarea");
776
+ textarea.className = "edit-textarea";
777
+ textarea.value = current;
778
+
779
+ contentNodes.forEach(n => n.remove());
780
+ leftEl.insertBefore(textarea, metaEl);
781
+ textarea.focus();
782
+
783
+ // auto-height
784
+ textarea.style.height = Math.max(80, textarea.scrollHeight) + "px";
785
+ textarea.addEventListener("input", () => {
786
+ textarea.style.height = "auto";
787
+ textarea.style.height = textarea.scrollHeight + "px";
788
+ });
789
+
790
+ let done = false;
791
+
792
+ async function save() {
793
+ if (done) return;
794
+ done = true;
795
+
796
+ const newContent = textarea.value.trim();
797
+ if (!newContent) { restore(); return; }
798
+ if (newContent === current) { restore(); return; }
799
+
800
+ const res = await fetch(`/item/${id}/content`, {
801
+ method: "PATCH",
802
+ headers: { "Content-Type": "application/json" },
803
+ body: JSON.stringify({ content: newContent })
804
+ });
805
+
806
+ if (!res.ok) { done = false; restore(); return; }
807
+
808
+ leftEl.dataset.value = newContent;
809
+ restoreWithContent(newContent);
810
+ }
811
+
812
+ function restore() {
813
+ if (done) return;
814
+ done = true;
815
+ restoreWithContent(current);
816
+ }
817
+
818
+ function restoreWithContent(text) {
819
+ textarea.remove();
820
+
821
+ const isLink = text.startsWith("http");
822
+ let node;
823
+ if (isLink) {
824
+ node = document.createElement("a");
825
+ node.href = text;
826
+ node.target = "_blank";
827
+ node.innerText = text;
828
+ } else {
829
+ const wrap = document.createElement("div");
830
+ wrap.innerHTML = renderText(text);
831
+ node = wrap;
832
+ }
833
+
834
+ leftEl.insertBefore(node, metaEl);
835
+ }
836
+
837
+ textarea.addEventListener("keydown", e => {
838
+ // Ctrl+Enter or Cmd+Enter saves
839
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
840
+ e.preventDefault();
841
+ save();
842
+ }
843
+ if (e.key === "Escape") {
844
+ e.preventDefault();
845
+ restore();
846
+ }
847
+ });
848
+
849
+ // blur saves only if focus didn't move to another part of same item
850
+ textarea.addEventListener("blur", e => {
851
+ setTimeout(() => {
852
+ if (!itemEl.contains(document.activeElement)) {
853
+ save();
854
+ }
855
+ }, 150);
856
+ });
857
+ }
858
+
859
+ function render(data) {
860
+ const el = document.getElementById("items");
861
+ el.innerHTML = "";
862
+
863
+ if (!data.length) {
864
+ el.innerHTML = `<div class="empty-state">Nothing here yet — paste, type, or drop a file to share</div>`;
865
+ return;
866
+ }
867
+
868
+ data.forEach(i => el.appendChild(buildItemEl(i)));
869
+ }
870
+
871
+ function renderMore(data) {
872
+ const el = document.getElementById("items");
873
+ data.forEach(i => el.appendChild(buildItemEl(i)));
874
+ }
875
+
876
+ function renderGrouped(data) {
877
+ const el = document.getElementById("items");
878
+ el.innerHTML = "";
879
+
880
+ if (!data.length) {
881
+ el.innerHTML = "<div style='color:#6b7280;margin-top:10px;'>No results</div>";
882
+ return;
883
+ }
884
+
885
+ const grouped = {};
886
+ data.forEach(item => {
887
+ if (!grouped[item.channel]) grouped[item.channel] = [];
888
+ grouped[item.channel].push(item);
889
+ });
890
+
891
+ Object.keys(grouped).forEach(ch => {
892
+ const section = document.createElement("div");
893
+ section.style.marginTop = "20px";
894
+ section.innerHTML = `
895
+ <div style="font-size:13px;font-weight:600;color:#6b7280;margin-bottom:8px;">
896
+ ${ch.toUpperCase()}
897
+ </div>`;
898
+ grouped[ch].forEach(i => section.appendChild(buildItemEl(i)));
899
+ el.appendChild(section);
900
+ });
901
+ }
902
+
903
+ async function sendText() {
904
+ const input = document.getElementById("msg");
905
+ const text = input.value.trim();
906
+ if (!text) return;
907
+
908
+ await fetch("/text", {
909
+ method: "POST",
910
+ headers: { "Content-Type": "application/json" },
911
+ body: JSON.stringify({ content: text, channel, uploader })
912
+ });
913
+
914
+ input.value = "";
915
+ }
916
+
917
+ function handleEnter(e) {
918
+ if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
919
+ e.preventDefault();
920
+ sendText();
921
+ }
922
+ }
923
+
924
+ function changeName() {
925
+ const n = prompt("Enter name:");
926
+ if (!n) return;
927
+ uploader = n;
928
+ localStorage.setItem("name", n);
929
+ document.getElementById("who").innerText = uploader;
930
+ }
931
+
932
+ let qrLoaded = false;
933
+
934
+ async function toggleQR() {
935
+ const card = document.getElementById("qrCard");
936
+ card.classList.toggle("open");
937
+
938
+ if (card.classList.contains("open") && !qrLoaded) {
939
+ const res = await fetch("/info");
940
+ const { url } = await res.json();
941
+ document.getElementById("qrUrl").innerText = url;
942
+ document.getElementById("qrImg").src =
943
+ `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(url)}`;
944
+ qrLoaded = true;
945
+ }
946
+ }
947
+
948
+ document.addEventListener("click", e => {
949
+ const widget = document.querySelector(".qr-widget");
950
+ if (!widget.contains(e.target)) {
951
+ document.getElementById("qrCard").classList.remove("open");
952
+ }
953
+ });
954
+
955
+ const fileInput = document.getElementById("fileInput");
956
+
957
+ fileInput.onchange = () => {
958
+ if (fileInput.files.length) uploadFiles(fileInput.files);
959
+ };
960
+
961
+ async function del(id, pinned) {
962
+ // pinned items keep confirm dialog
963
+ if (pinned) {
964
+ const confirmed = confirm("This item is pinned. Are you sure you want to delete it?");
965
+ if (!confirmed) return;
966
+ await fetch("/item/" + id, { method: "DELETE" });
967
+ return;
968
+ }
969
+
970
+ // if another delete is pending, execute it immediately (don't await — fire and forget)
971
+ if (pendingDeleteId !== null) {
972
+ clearTimeout(pendingDeleteTimer);
973
+ fetch("/item/" + pendingDeleteId, { method: "DELETE" });
974
+ pendingDeleteId = null;
975
+ pendingDeleteTimer = null;
976
+ }
977
+
978
+ // optimistically remove from UI
979
+ const el = document.querySelector(`[data-item-id="${id}"]`);
980
+ if (el) {
981
+ pendingDeleteEl = el.outerHTML;
982
+ el.remove();
983
+ }
984
+
985
+ pendingDeleteId = id;
986
+ showUndoToast();
987
+
988
+ pendingDeleteTimer = setTimeout(async () => {
989
+ await fetch("/item/" + pendingDeleteId, { method: "DELETE" });
990
+ pendingDeleteId = null;
991
+ pendingDeleteTimer = null;
992
+ pendingDeleteEl = null;
993
+ hideUndoToast();
994
+ }, 5000);
995
+ }
996
+
997
+ function undoDelete() {
998
+ if (pendingDeleteId === null) return;
999
+ clearTimeout(pendingDeleteTimer);
1000
+ pendingDeleteId = null;
1001
+ pendingDeleteTimer = null;
1002
+ pendingDeleteEl = null;
1003
+ hideUndoToast();
1004
+ load(); // reload to restore item
1005
+ }
1006
+
1007
+ function showUndoToast() {
1008
+ const toast = document.getElementById("undoToast");
1009
+ const progress = document.getElementById("undoProgress");
1010
+ progress.classList.remove("running");
1011
+ void progress.offsetWidth; // force reflow to restart animation
1012
+ progress.classList.add("running");
1013
+ toast.classList.add("show");
1014
+ }
1015
+
1016
+ function hideUndoToast() {
1017
+ const toast = document.getElementById("undoToast");
1018
+ const progress = document.getElementById("undoProgress");
1019
+ toast.classList.remove("show");
1020
+ progress.classList.remove("running");
1021
+ }
1022
+
1023
+
1024
+ // prime AudioContext on first interaction so chime works immediately after
1025
+ let audioContextPrimed = false;
1026
+ document.addEventListener("click", () => {
1027
+ if (audioContextPrimed) return;
1028
+ try {
1029
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
1030
+ ctx.resume().then(() => ctx.close());
1031
+ audioContextPrimed = true;
1032
+ } catch (e) { }
1033
+ }, { once: false });
1034
+
1035
+ async function pin(id) {
1036
+ await fetch("/pin/" + id, { method: "POST" });
1037
+ load();
1038
+ }
1039
+
1040
+
1041
+ async function logout() {
1042
+ await fetch("/logout", { method: "POST" });
1043
+ window.location.href = "/login";
1044
+ }
1045
+
1046
+ socket.on("new-item", item => {
1047
+ if (document.getElementById("search").value.trim() !== "") return;
1048
+
1049
+ if (item.channel === channel) {
1050
+ const container = document.getElementById("items");
1051
+
1052
+ const empty = container.querySelector(".empty-state");
1053
+ if (empty) empty.remove();
1054
+
1055
+ if (!item.pinned) newItemIds.add(item.id);
1056
+
1057
+ const el = buildItemEl(item);
1058
+
1059
+ if (item.pinned) {
1060
+ container.prepend(el);
1061
+ } else {
1062
+ const existing = container.querySelectorAll(".item");
1063
+ let insertBefore = null;
1064
+ for (let i = 0; i < existing.length; i++) {
1065
+ if (!existing[i].querySelector(".icon-btn.pinned")) {
1066
+ insertBefore = existing[i];
1067
+ break;
1068
+ }
1069
+ }
1070
+ if (insertBefore) {
1071
+ container.insertBefore(el, insertBefore);
1072
+ } else {
1073
+ container.appendChild(el);
1074
+ }
1075
+ }
1076
+
1077
+ if (typeof lucide !== "undefined") lucide.createIcons({ nodes: [el] });
1078
+ if (item.uploader !== uploader) playChime();
1079
+
1080
+ } else if (item.uploader !== uploader) {
1081
+ if (!item.pinned) newItemIds.add(item.id);
1082
+ unreadChannels.add(item.channel);
1083
+ renderChannels();
1084
+ }
1085
+ });
1086
+
1087
+ socket.on("delete-item", id => {
1088
+ // don't reload if this item is already removed or pending
1089
+ if (id == pendingDeleteId) return;
1090
+ const el = document.querySelector(`[data-item-id="${id}"]`);
1091
+ if (el) el.remove();
1092
+
1093
+ // show empty state if no items left
1094
+ const items = document.getElementById("items");
1095
+ if (items && !items.querySelector(".item")) {
1096
+ items.innerHTML = `<div class="empty-state">Nothing here yet — paste, type, or drop a file to share</div>`;
1097
+ }
1098
+ });
1099
+
1100
+ socket.on("item-updated", ({ id, title, content, edited_at }) => {
1101
+ if (title !== undefined) {
1102
+ const titleEl = document.getElementById("item-title-" + id);
1103
+ if (titleEl) {
1104
+ titleEl.innerText = title;
1105
+ titleEl.style.display = title ? "" : "none";
1106
+ }
1107
+ }
1108
+
1109
+ if (content !== undefined) {
1110
+ const itemEl = document.querySelector(`.item[data-item-id="${id}"]`);
1111
+ if (!itemEl) return;
1112
+ const leftEl = itemEl.querySelector(".left");
1113
+ if (!leftEl) return;
1114
+
1115
+ // only update if not currently being edited
1116
+ if (leftEl.querySelector(".edit-textarea")) return;
1117
+
1118
+ leftEl.dataset.value = content;
1119
+
1120
+ const metaEl = leftEl.querySelector(".meta");
1121
+ const titleEl = leftEl.querySelector(".item-title");
1122
+ Array.from(leftEl.childNodes).forEach(n => {
1123
+ if (n !== metaEl && n !== titleEl) n.remove();
1124
+ });
1125
+
1126
+ const isLink = content.startsWith("http");
1127
+ if (isLink) {
1128
+ const a = document.createElement("a");
1129
+ a.href = content;
1130
+ a.target = "_blank";
1131
+ a.innerText = content;
1132
+ leftEl.insertBefore(a, metaEl);
1133
+ } else {
1134
+ const wrap = document.createElement("div");
1135
+ wrap.innerHTML = renderText(content);
1136
+ leftEl.insertBefore(wrap, metaEl);
1137
+ }
1138
+ }
1139
+ });
1140
+
1141
+ socket.on("seen-update", ({ id, count }) => {
1142
+ const el = document.getElementById("seen-" + id);
1143
+ if (!el) return;
1144
+ if (count >= 2) {
1145
+ el.querySelector(".seen-num").innerText = count;
1146
+ el.style.display = "";
1147
+ }
1148
+ });
1149
+
1150
+ socket.on("user-count", count => {
1151
+ const el = document.getElementById("onlineCount");
1152
+ if (el) el.textContent = `● ${count} online`;
1153
+ });
1154
+
1155
+ socket.on("item-moved", ({ id, channel: toChannel }) => {
1156
+ if (toChannel !== channel) {
1157
+ // item left this channel — remove it from view
1158
+ load();
1159
+ }
1160
+ });
1161
+
1162
+ socket.on("channel-added", (ch) => {
1163
+ if (!channels.find(c => c.name === ch.name)) {
1164
+ channels.push(ch);
1165
+ renderChannels();
1166
+ }
1167
+ });
1168
+
1169
+ socket.on("channel-deleted", ({ name }) => {
1170
+ channels = channels.filter(c => c.name !== name);
1171
+ if (channel === name) {
1172
+ channel = channels.length ? channels[0].name : null;
1173
+ load();
1174
+ }
1175
+ renderChannels();
1176
+ highlight();
1177
+ });
1178
+
1179
+ socket.on("channel-renamed", ({ oldName, newName }) => {
1180
+ channels = channels.map(c => c.name === oldName ? { ...c, name: newName } : c);
1181
+ if (channel === oldName) channel = newName;
1182
+ renderChannels();
1183
+ highlight();
1184
+ });
1185
+
1186
+ socket.on("channel-pin-update", ({ name, pinned }) => {
1187
+ if (pinned) {
1188
+ channels = channels.map(c => c.name === name ? { ...c, pinned } : c);
1189
+ channels.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
1190
+ renderChannels();
1191
+ highlight();
1192
+ } else {
1193
+ loadChannels();
1194
+ }
1195
+ });
1196
+
1197
+ document.getElementById("search").addEventListener("input", async e => {
1198
+ const q = e.target.value.trim();
1199
+
1200
+ if (!q) {
1201
+ document.getElementById("loadMoreWrapper").style.display = "none";
1202
+ highlight();
1203
+ load();
1204
+ return;
1205
+ }
1206
+
1207
+ document.querySelectorAll(".channels button")
1208
+ .forEach(b => b.classList.remove("active"));
1209
+
1210
+ document.getElementById("loadMoreWrapper").style.display = "none";
1211
+
1212
+ const res = await fetch(`/search/${q}`);
1213
+ const data = await res.json();
1214
+ renderGrouped(data);
1215
+ });
1216
+
1217
+
1218
+ document.addEventListener("paste", async e => {
1219
+ const active = document.activeElement;
1220
+
1221
+ // if user is typing in input, don't auto-send
1222
+ if (active && active.id === "msg") return;
1223
+
1224
+ // check for image in clipboard first
1225
+ const items = e.clipboardData.items;
1226
+ for (const item of items) {
1227
+ if (item.type.startsWith("image/")) {
1228
+ const file = item.getAsFile();
1229
+ if (!file) continue;
1230
+
1231
+ // give it a meaningful name with timestamp
1232
+ const ext = item.type.split("/")[1] || "png";
1233
+ const named = new File(
1234
+ [file],
1235
+ `pasted-${Date.now()}.${ext}`,
1236
+ { type: item.type }
1237
+ );
1238
+ uploadFiles([named]);
1239
+ return; // image found, don't fall through to text
1240
+ }
1241
+ }
1242
+
1243
+ // no image — handle as text
1244
+ const text = e.clipboardData.getData("text");
1245
+ if (!text) return;
1246
+
1247
+ await fetch("/text", {
1248
+ method: "POST",
1249
+ headers: { "Content-Type": "application/json" },
1250
+ body: JSON.stringify({
1251
+ content: text,
1252
+ channel,
1253
+ uploader
1254
+ })
1255
+ });
1256
+ });
1257
+
1258
+ async function uploadFiles(files) {
1259
+ if (!files || !files.length) return;
1260
+
1261
+ const status = document.getElementById("uploadStatus");
1262
+ const bar = document.getElementById("uploadBar");
1263
+ const text = document.getElementById("uploadText");
1264
+
1265
+ const total = files.length;
1266
+ const targetChannel = channel; // capture at start, won't change if user switches
1267
+
1268
+ for (let i = 0; i < total; i++) {
1269
+ const file = files[i];
1270
+
1271
+ status.style.display = "block";
1272
+ bar.style.width = "0%";
1273
+ text.innerText = total > 1
1274
+ ? `Uploading ${i + 1} of ${total} · ${file.name}`
1275
+ : `Uploading: ${file.name}`;
1276
+
1277
+ await new Promise((resolve) => {
1278
+ const form = new FormData();
1279
+ form.append("file", file);
1280
+ form.append("channel", targetChannel);
1281
+ form.append("uploader", uploader);
1282
+
1283
+ const xhr = new XMLHttpRequest();
1284
+ xhr.open("POST", "/upload", true);
1285
+
1286
+ // Stall detection — if no progress for 30s, the connection is dead.
1287
+ // We don't use xhr.timeout because that would kill legitimate large
1288
+ // file uploads on slow LANs.
1289
+ // Instead we track the last progress event and abort if nothing moves for 30 seconds.
1290
+ const STALL_MS = 30_000;
1291
+ let stallTimer = null;
1292
+
1293
+ function resetStallTimer() {
1294
+ clearTimeout(stallTimer);
1295
+ stallTimer = setTimeout(() => {
1296
+ xhr.abort();
1297
+ text.innerText = `⚠ ${file.name} stalled — skipped`;
1298
+ bar.style.width = "0%";
1299
+ setTimeout(resolve, 1200);
1300
+ }, STALL_MS);
1301
+ }
1302
+
1303
+ xhr.upload.onprogress = e => {
1304
+ if (e.lengthComputable) {
1305
+ bar.style.width = Math.round((e.loaded / e.total) * 100) + "%";
1306
+ }
1307
+ resetStallTimer(); // data is moving, push the timer out
1308
+ };
1309
+
1310
+ xhr.onload = () => {
1311
+ clearTimeout(stallTimer);
1312
+ if (xhr.status === 413) {
1313
+ text.innerText = `⚠ ${file.name} is too large — skipped`;
1314
+ bar.style.width = "0%";
1315
+ setTimeout(resolve, 1200);
1316
+ return;
1317
+ }
1318
+ resolve();
1319
+ };
1320
+
1321
+ xhr.onerror = () => {
1322
+ clearTimeout(stallTimer);
1323
+ text.innerText = `⚠ ${file.name} failed skipped`;
1324
+ bar.style.width = "0%";
1325
+ setTimeout(resolve, 1200);
1326
+ };
1327
+
1328
+ xhr.onabort = () => {
1329
+ clearTimeout(stallTimer);
1330
+ // ontimeout/stall already set the message — only handle
1331
+ // explicit user-triggered aborts here if we add a cancel
1332
+ // button in future. For now just resolve to unblock queue.
1333
+ resolve();
1334
+ };
1335
+
1336
+ resetStallTimer(); // start timer — covers stall before first progress
1337
+ xhr.send(form);
1338
+ });
1339
+ }
1340
+
1341
+ status.style.display = "none";
1342
+ fileInput.value = "";
1343
+ // Delayed fallback socket event should have already updated the DOM by now,
1344
+ // but this catches any case where the socket path didn't fire (e.g. network hiccup).
1345
+ setTimeout(() => load(), 500);
1346
+ }
1347
+
1348
+
1349
+ const overlay = document.getElementById("dragOverlay");
1350
+ let dragCounter = 0;
1351
+
1352
+ /* DRAG ENTER */
1353
+ document.addEventListener("dragenter", e => {
1354
+ e.preventDefault();
1355
+ dragCounter++;
1356
+ overlay.style.display = "flex";
1357
+ });
1358
+
1359
+ /* DRAG LEAVE */
1360
+ document.addEventListener("dragleave", e => {
1361
+ dragCounter--;
1362
+ if (dragCounter <= 0) {
1363
+ overlay.style.display = "none";
1364
+ dragCounter = 0;
1365
+ }
1366
+ });
1367
+
1368
+ /*
1369
+ document.getElementById("addChannelBtn").onclick = async () => {
1370
+ if (channels.length >= 10) { alert("Maximum 10 channels allowed"); return; }
1371
+ const name = prompt("Channel name?");
1372
+ if (!name) return;
1373
+
1374
+ const res = await fetch("/channels", {
1375
+ method: "POST",
1376
+ headers: { "Content-Type": "application/json" },
1377
+ body: JSON.stringify({ name })
1378
+ });
1379
+
1380
+ if (!res.ok) { const err = await res.json(); alert(err.error); return; }
1381
+ await loadChannels();
1382
+ };
1383
+ */
1384
+
1385
+ function addChannelHandler() {
1386
+ return async () => {
1387
+ if (channels.length >= 10) { alert("Maximum 10 channels allowed"); return; }
1388
+ const name = prompt("Channel name?");
1389
+ if (!name) return;
1390
+
1391
+ const res = await fetch("/channels", {
1392
+ method: "POST",
1393
+ headers: { "Content-Type": "application/json" },
1394
+ body: JSON.stringify({ name })
1395
+ });
1396
+
1397
+ if (!res.ok) { const err = await res.json(); alert(err.error); return; }
1398
+ await loadChannels();
1399
+ };
1400
+ }
1401
+
1402
+ document.getElementById("addChannelBtn").onclick = addChannelHandler();
1403
+ document.getElementById("addChannelBtnDesktop").onclick = addChannelHandler();
1404
+
1405
+ async function loadChannels() {
1406
+ const res = await fetch("/channels");
1407
+ const data = await res.json();
1408
+ channels = data; // [{ id, name, pinned }]
1409
+ if (!channel && channels.length) channel = channels[0].name;
1410
+ renderChannels();
1411
+ highlight();
1412
+ }
1413
+
1414
+
1415
+ function renderChannels() {
1416
+ const container = document.getElementById("channels");
1417
+ container.innerHTML = "";
1418
+
1419
+ channels.forEach(ch => {
1420
+ const btn = document.createElement("button");
1421
+ btn.id = "ch-" + ch.name;
1422
+
1423
+ btn.onclick = (e) => {
1424
+ if (e.target.classList.contains("ch-more")) return;
1425
+ setChannel(ch.name);
1426
+ };
1427
+
1428
+ btn.appendChild(document.createTextNode(ch.name));
1429
+
1430
+ if (ch.pinned) {
1431
+ const dot = document.createElement("span");
1432
+ dot.className = "ch-pin-dot";
1433
+ dot.title = "Pinned — protected from deletion";
1434
+ btn.appendChild(dot);
1435
+ }
1436
+
1437
+ if (unreadChannels.has(ch.name)) {
1438
+ const unread = document.createElement("span");
1439
+ unread.className = "ch-unread-dot";
1440
+ btn.appendChild(unread);
1441
+ }
1442
+
1443
+ const more = document.createElement("span");
1444
+ more.className = "ch-more";
1445
+ more.innerText = "";
1446
+ more.title = "Channel options";
1447
+ more.onclick = (e) => {
1448
+ e.stopPropagation();
1449
+ showChannelMenu(e, ch);
1450
+ };
1451
+ btn.appendChild(more);
1452
+
1453
+ btn.addEventListener("contextmenu", (e) => {
1454
+ e.preventDefault();
1455
+ showChannelMenu(e, ch);
1456
+ });
1457
+
1458
+ if (ch.name === channel) btn.classList.add("active");
1459
+ container.appendChild(btn);
1460
+ });
1461
+
1462
+ }
1463
+
1464
+ function showChannelMenu(e, ch) {
1465
+ const menu = document.getElementById("channelMenu");
1466
+
1467
+ menu.innerHTML = `
1468
+ <button onclick="toggleChannelPin('${ch.name}')">
1469
+ ${ch.pinned ? "📍 Unpin channel" : "📌 Pin channel"}
1470
+ </button>
1471
+ <div class="menu-divider"></div>
1472
+ <button onclick="renameChannelPrompt('${ch.name}')">✎&nbsp; Rename</button>
1473
+ <button class="${ch.pinned ? "muted" : "danger"}"
1474
+ ${ch.pinned ? "" : `onclick="deleteChannel('${ch.name}')"`}>
1475
+ 🗑&nbsp; Delete${ch.pinned ? " (pinned)" : ""}
1476
+ </button>
1477
+ `;
1478
+
1479
+ menu.style.top = e.clientY + "px";
1480
+ menu.style.left = e.clientX + "px";
1481
+ menu.classList.add("open");
1482
+
1483
+ requestAnimationFrame(() => {
1484
+ const rect = menu.getBoundingClientRect();
1485
+ if (rect.right > window.innerWidth - 8)
1486
+ menu.style.left = (e.clientX - rect.width) + "px";
1487
+ if (rect.bottom > window.innerHeight - 8)
1488
+ menu.style.top = (e.clientY - rect.height) + "px";
1489
+ });
1490
+ }
1491
+
1492
+ document.addEventListener("click", () => {
1493
+ document.getElementById("channelMenu").classList.remove("open");
1494
+ });
1495
+
1496
+ document.addEventListener("contextmenu", (e) => {
1497
+ if (!e.target.closest("#channels"))
1498
+ document.getElementById("channelMenu").classList.remove("open");
1499
+ });
1500
+
1501
+ async function toggleChannelPin(name) {
1502
+ await fetch(`/channels/${name}/pin`, { method: "POST" });
1503
+ }
1504
+
1505
+ async function renameChannelPrompt(name) {
1506
+ const newName = prompt("Rename channel to:", name);
1507
+ if (!newName || newName.trim() === name) return;
1508
+
1509
+ const res = await fetch("/channels/" + name, {
1510
+ method: "PATCH",
1511
+ headers: { "Content-Type": "application/json" },
1512
+ body: JSON.stringify({ name: newName.trim() })
1513
+ });
1514
+
1515
+ if (!res.ok) { const err = await res.json(); alert(err.error); }
1516
+ }
1517
+
1518
+ async function deleteChannel(name) {
1519
+ const confirmed = confirm(`Delete "${name}"? All items will be permanently removed.`);
1520
+ if (!confirmed) return;
1521
+
1522
+ const res = await fetch("/channels/" + name, { method: "DELETE" });
1523
+ if (!res.ok) { const err = await res.json(); alert(err.error); }
1524
+ }
1525
+
1526
+ /* DRAG OVER */
1527
+ document.addEventListener("dragover", e => {
1528
+ e.preventDefault();
1529
+ });
1530
+
1531
+ /* DROP ANYWHERE */
1532
+ document.addEventListener("drop", async e => {
1533
+ e.preventDefault();
1534
+ overlay.style.display = "none";
1535
+ dragCounter = 0;
1536
+
1537
+ const files = e.dataTransfer.files;
1538
+ if (!files.length) return;
1539
+ uploadFiles(files);
1540
+ });
1541
+
1542
+ // ========================
1543
+ // KEYBOARD SHORTCUTS
1544
+ // ========================
1545
+ document.addEventListener("keydown", e => {
1546
+ const active = document.activeElement;
1547
+ const isTyping = active && (active.id === "msg" || active.id === "search");
1548
+
1549
+ // Ctrl/Cmd + Enter — send message (only when msg is focused)
1550
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
1551
+ if (active && active.id === "msg") {
1552
+ e.preventDefault();
1553
+ e.stopPropagation();
1554
+ sendText();
1555
+ }
1556
+ return;
1557
+ }
1558
+
1559
+ // Ctrl/Cmd + K — focus message input
1560
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
1561
+ if (!isTyping) {
1562
+ e.preventDefault();
1563
+ document.getElementById("msg").focus();
1564
+ }
1565
+ return;
1566
+ }
1567
+
1568
+ // / — focus search
1569
+ if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
1570
+ e.preventDefault();
1571
+ const search = document.getElementById("search");
1572
+ search.focus();
1573
+ search.select();
1574
+ return;
1575
+ }
1576
+
1577
+ // Tab cycle channels
1578
+ if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
1579
+ e.preventDefault();
1580
+ if (!channels.length) return;
1581
+ const currentIndex = channels.findIndex(c => c.name === channel);
1582
+ const nextIndex = (currentIndex + 1) % channels.length;
1583
+ setChannel(channels[nextIndex].name);
1584
+ return;
1585
+ }
1586
+
1587
+ // Escape — close state in priority order
1588
+ if (e.key === "Escape") {
1589
+
1590
+ // 1. blur any focused input first
1591
+ if (active && (active.id === "msg" || active.id === "search")) {
1592
+ if (active.id === "search" && active.value) {
1593
+ active.value = "";
1594
+ highlight();
1595
+ load();
1596
+ }
1597
+ active.blur();
1598
+ return;
1599
+ }
1600
+
1601
+ // 2. close open preview
1602
+ if (openPreviewId) {
1603
+ const panel = document.getElementById("preview-" + openPreviewId);
1604
+ const btn = document.getElementById("prevbtn-" + openPreviewId);
1605
+ if (panel) panel.classList.remove("open");
1606
+ if (btn) btn.classList.remove("preview-active");
1607
+ openPreviewId = null;
1608
+ return;
1609
+ }
1610
+
1611
+ // 3. close move dropdown
1612
+ if (openDropdown) {
1613
+ openDropdown.classList.remove("open");
1614
+ openDropdown = null;
1615
+ return;
1616
+ }
1617
+
1618
+ // 4. close context menu
1619
+ const contextMenu = document.getElementById("channelMenu");
1620
+ if (contextMenu.classList.contains("open")) {
1621
+ contextMenu.classList.remove("open");
1622
+ return;
1623
+ }
1624
+
1625
+ // 5. close QR card
1626
+ const qrCard = document.getElementById("qrCard");
1627
+ if (qrCard.classList.contains("open")) {
1628
+ qrCard.classList.remove("open");
1629
+ return;
1630
+ }
1631
+ }
1632
+
1633
+ // All remaining shortcuts — skip if typing
1634
+ if (isTyping) return;
1635
+
1636
+ });
1637
+
1638
+ (async function init() {
1639
+ await applyBranding();
1640
+ await initName();
1641
+ const infoRes = await fetch("/info");
1642
+ const info = await infoRes.json();
1643
+ if (!info.hasAuth) {
1644
+ document.getElementById("logoutBtn").style.display = "none";
1645
+ }
1646
+ retention = info.retention; // null if "never", ms value otherwise
1647
+ await loadChannels();
1648
+ load();
1585
1649
  })();