instbyte 1.7.0 → 1.9.0

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,5 +1,39 @@
1
1
  const socket = io();
2
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
+
3
37
  // ========================
4
38
  // THEME MANAGEMENT (FIXED)
5
39
  // ========================
@@ -86,6 +120,7 @@ async function applyBranding() {
86
120
  // Branding failed — default styles remain, no crash
87
121
  }
88
122
  }
123
+
89
124
  function formatSize(bytes) {
90
125
  if (!bytes) return "";
91
126
  if (bytes >= 1024 ** 3) return (bytes / 1024 ** 3).toFixed(1) + " GB";
@@ -94,6 +129,64 @@ function formatSize(bytes) {
94
129
  return bytes + " B";
95
130
  }
96
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
+
97
190
  function getSizeTag(bytes) {
98
191
  if (!bytes) return "";
99
192
  const mb = bytes / (1024 * 1024);
@@ -231,21 +324,37 @@ async function togglePreview(id, filename) {
231
324
 
232
325
  try {
233
326
  const res = await fetch(url);
234
-
235
327
  if (!res.ok) throw new Error("Failed to load");
236
328
 
237
- // Check size via content-length header before reading
238
329
  const contentLength = res.headers.get("content-length");
239
330
  if (contentLength && parseInt(contentLength) > MAX_TEXT_PREVIEW_BYTES) {
240
331
  panel.innerHTML = `
241
- <div class="preview-error">
242
- File is too large to preview (${formatSize(parseInt(contentLength))}).
243
- <a href="${url}" target="_blank">Open in new tab</a>
244
- </div>`;
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>`;
245
336
  return;
246
337
  }
247
338
 
248
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
249
358
  const lines = text.split("\n");
250
359
  const truncated = lines.length > MAX_TEXT_LINES;
251
360
  const preview = truncated
@@ -273,9 +382,9 @@ async function togglePreview(id, filename) {
273
382
 
274
383
  } catch (err) {
275
384
  panel.innerHTML = `
276
- <div class="preview-error">
277
- Could not load preview. <a href="${url}" target="_blank">Open directly</a>
278
- </div>`;
385
+ <div class="preview-error">
386
+ Could not load preview. <a href="${url}" target="_blank">Open directly</a>
387
+ </div>`;
279
388
  }
280
389
  }
281
390
  }
@@ -345,6 +454,48 @@ function toggleMoveDropdown(e, id, currentChannel) {
345
454
  openDropdown = dropdown;
346
455
  }
347
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
+
348
499
  async function moveItem(id, toChannel) {
349
500
  await fetch(`/item/${id}/move`, {
350
501
  method: "PATCH",
@@ -363,6 +514,7 @@ document.addEventListener("click", () => {
363
514
 
364
515
  let channel = null;
365
516
  let channels = [];
517
+ let unreadChannels = new Set();
366
518
 
367
519
  let uploader = localStorage.getItem("name") || "";
368
520
 
@@ -379,7 +531,7 @@ async function initName() {
379
531
  uploader = prompt("Your name?", suggested) || suggested;
380
532
  localStorage.setItem("name", uploader);
381
533
  }
382
- document.getElementById("who").innerText = "You: " + uploader;
534
+ document.getElementById("who").innerText = uploader;
383
535
  socket.emit("join", uploader);
384
536
  }
385
537
 
@@ -400,16 +552,304 @@ function setChannel(c) {
400
552
  pendingDeleteEl = null;
401
553
  hideUndoToast();
402
554
  }
555
+ unreadChannels.delete(c); // clear unread when switching to channel
403
556
  channel = c;
404
557
  renderChannels();
405
558
  highlight();
406
559
  load();
407
560
  }
408
561
 
409
- async function load() {
410
- const res = await fetch("/items/" + channel);
562
+ async function load(resetPage = true) {
563
+ if (resetPage) currentPage = 1;
564
+
565
+ const res = await fetch(`/items/${channel}?page=${currentPage}`);
411
566
  const data = await res.json();
412
- render(data);
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
+ });
413
853
  }
414
854
 
415
855
  function render(data) {
@@ -421,79 +861,12 @@ function render(data) {
421
861
  return;
422
862
  }
423
863
 
424
- data.forEach(i => {
425
- const div = document.createElement("div");
426
- div.className = "item";
427
- div.dataset.itemId = i.id;
428
-
429
- let content = "";
430
-
431
- if (i.type === "file") {
432
- const isImg = i.filename.match(/\.(jpg|png|jpeg|gif)$/i);
433
- const sizeLabel = formatSize(i.size);
434
- const sizeClass = getSizeTag(i.size);
435
- const sizeTag = sizeClass
436
- ? `<span class="size-tag ${sizeClass}">${sizeLabel}</span>`
437
- : sizeLabel
438
- ? `<span style="font-size:11px;color:#9ca3af;margin-left:6px;">${sizeLabel}</span>`
439
- : "";
440
-
441
- if (isImg) {
442
- content = `<img src="/uploads/${i.filename}" style="max-width:200px;border-radius:6px"><br>
443
- <a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
444
- } else {
445
- content = `<a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
446
- }
447
- } else {
448
- const isLink = i.content && i.content.startsWith("http");
449
- if (isLink) {
450
- content = `<a href="${i.content}" target="_blank">${i.content}</a>`;
451
- } else {
452
- content = renderText(i.content);
453
- }
454
- }
455
-
456
- const pinText = i.pinned ? "unpin" : "pin";
457
-
458
- const isFile = i.type === "file";
459
- const clickValue = isFile
460
- ? `/uploads/${i.filename}`
461
- : i.content;
462
- const tooltip = isFile ? "Click to download" : "Click to copy";
463
-
464
- div.innerHTML = `
465
- <div class="item-top">
466
- <div class="left"
467
- data-tooltip="${i.type === 'file' ? 'Click to download' : 'Click to copy'}"
468
- data-type="${i.type === 'file' ? 'file' : 'text'}"
469
- data-value="${i.type === 'file'
470
- ? `/uploads/${i.filename}`
471
- : (i.content || '').replace(/"/g, '&quot;')}">
472
- ${content}
473
- <div class="meta">${i.uploader}</div>
474
- </div>
475
- <div class="item-actions">
476
- ${getPreviewType(i.filename) !== "none" && getPreviewType(i.filename) !== "image"
477
- ? `<button class="icon-btn" id="prevbtn-${i.id}"
478
- onclick="togglePreview(${i.id}, '${i.filename}')"
479
- title="Preview">👁</button>`
480
- : ""}
481
- <button class="icon-btn" onclick="pin(${i.id})" title="${i.pinned ? 'Unpin' : 'Pin'}">
482
- ${i.pinned ? "📍" : "📌"}
483
- </button>
484
- <div class="move-wrapper">
485
- <button class="icon-btn" title="Move to channel"
486
- onclick="toggleMoveDropdown(event, ${i.id}, '${i.channel}')">⇄</button>
487
- <div class="move-dropdown"></div>
488
- </div>
489
- <button class="icon-btn delete" onclick="del(${i.id}, ${i.pinned})" title="Delete">🗑</button>
864
+ data.forEach(i => el.appendChild(buildItemEl(i)));
865
+ }
490
866
 
491
- </div>
492
- </div>
493
- <div class="preview-panel" id="preview-${i.id}"></div>
494
- `;
495
- el.appendChild(div);
496
- });
867
+ function renderMore(data) {
868
+ const el = document.getElementById("items");
869
+ data.forEach(i => el.appendChild(buildItemEl(i)));
497
870
  }
498
871
 
499
872
  function renderGrouped(data) {
@@ -505,7 +878,6 @@ function renderGrouped(data) {
505
878
  return;
506
879
  }
507
880
 
508
- // group by channel
509
881
  const grouped = {};
510
882
  data.forEach(item => {
511
883
  if (!grouped[item.channel]) grouped[item.channel] = [];
@@ -515,86 +887,11 @@ function renderGrouped(data) {
515
887
  Object.keys(grouped).forEach(ch => {
516
888
  const section = document.createElement("div");
517
889
  section.style.marginTop = "20px";
518
-
519
890
  section.innerHTML = `
520
- <div style="font-size:13px;font-weight:600;color:#6b7280;margin-bottom:8px;">
521
- ${ch.toUpperCase()}
522
- </div>
523
- `;
524
-
525
- grouped[ch].forEach(i => {
526
- const div = document.createElement("div");
527
- div.className = "item";
528
- div.dataset.itemId = i.id;
529
-
530
- let content = "";
531
-
532
- if (i.type === "file") {
533
- const isImg = i.filename.match(/\.(jpg|png|jpeg|gif)$/i);
534
- const sizeLabel = formatSize(i.size);
535
- const sizeClass = getSizeTag(i.size);
536
- const sizeTag = sizeClass
537
- ? `<span class="size-tag ${sizeClass}">${sizeLabel}</span>`
538
- : sizeLabel
539
- ? `<span style="font-size:11px;color:#9ca3af;margin-left:6px;">${sizeLabel}</span>`
540
- : "";
541
-
542
- if (isImg) {
543
- content = `<img src="/uploads/${i.filename}" style="max-width:200px;border-radius:6px"><br>
544
- <a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
545
- } else {
546
- content = `<a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
547
- }
548
- } else {
549
- const isLink = i.content && i.content.startsWith("http");
550
- if (isLink) {
551
- content = `<a href="${i.content}" target="_blank">${i.content}</a>`;
552
- } else {
553
- content = renderText(i.content);
554
- }
555
- }
556
-
557
- const isFile = i.type === "file";
558
- const clickValue = isFile
559
- ? `/uploads/${i.filename}`
560
- : i.content;
561
- const tooltip = isFile ? "Click to download" : "Click to copy";
562
-
563
- div.innerHTML = `
564
- <div class="item-top">
565
- <div class="left"
566
- data-tooltip="${i.type === 'file' ? 'Click to download' : 'Click to copy'}"
567
- data-type="${i.type === 'file' ? 'file' : 'text'}"
568
- data-value="${i.type === 'file'
569
- ? `/uploads/${i.filename}`
570
- : (i.content || '').replace(/"/g, '&quot;')}">
571
- ${content}
572
- <div class="meta">${i.uploader}</div>
573
- </div>
574
- <div class="item-actions">
575
- ${getPreviewType(i.filename) !== "none" && getPreviewType(i.filename) !== "image"
576
- ? `<button class="icon-btn" id="prevbtn-${i.id}"
577
- onclick="togglePreview(${i.id}, '${i.filename}')"
578
- title="Preview">👁</button>`
579
- : ""}
580
- <button class="icon-btn" onclick="pin(${i.id})" title="${i.pinned ? 'Unpin' : 'Pin'}">
581
- ${i.pinned ? "📍" : "📌"}
582
- </button>
583
- <div class="move-wrapper">
584
- <button class="icon-btn" title="Move to channel"
585
- onclick="toggleMoveDropdown(event, ${i.id}, '${i.channel}')">⇄</button>
586
- <div class="move-dropdown"></div>
587
- </div>
588
- <button class="icon-btn delete" onclick="del(${i.id}, ${i.pinned})" title="Delete">🗑</button>
589
-
590
- </div>
591
- </div>
592
- <div class="preview-panel" id="preview-${i.id}"></div>
593
- `;
594
-
595
- section.appendChild(div);
596
- });
597
-
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)));
598
895
  el.appendChild(section);
599
896
  });
600
897
  }
@@ -625,7 +922,7 @@ function changeName() {
625
922
  if (!n) return;
626
923
  uploader = n;
627
924
  localStorage.setItem("name", n);
628
- document.getElementById("who").innerText = "You: " + uploader;
925
+ document.getElementById("who").innerText = uploader;
629
926
  }
630
927
 
631
928
  let qrLoaded = false;
@@ -719,6 +1016,18 @@ function hideUndoToast() {
719
1016
  progress.classList.remove("running");
720
1017
  }
721
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
+
722
1031
  async function pin(id) {
723
1032
  await fetch("/pin/" + id, { method: "POST" });
724
1033
  load();
@@ -731,7 +1040,15 @@ async function logout() {
731
1040
  }
732
1041
 
733
1042
  socket.on("new-item", item => {
734
- if (item.channel === channel) load();
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
+ }
735
1052
  });
736
1053
 
737
1054
  socket.on("delete-item", id => {
@@ -747,6 +1064,61 @@ socket.on("delete-item", id => {
747
1064
  }
748
1065
  });
749
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
+
750
1122
  socket.on("item-moved", ({ id, channel: toChannel }) => {
751
1123
  if (toChannel !== channel) {
752
1124
  // item left this channel — remove it from view
@@ -793,15 +1165,17 @@ document.getElementById("search").addEventListener("input", async e => {
793
1165
  const q = e.target.value.trim();
794
1166
 
795
1167
  if (!q) {
1168
+ document.getElementById("loadMoreWrapper").style.display = "none";
796
1169
  highlight();
797
1170
  load();
798
1171
  return;
799
1172
  }
800
1173
 
801
- // remove active tab highlight while searching
802
1174
  document.querySelectorAll(".channels button")
803
1175
  .forEach(b => b.classList.remove("active"));
804
1176
 
1177
+ document.getElementById("loadMoreWrapper").style.display = "none";
1178
+
805
1179
  const res = await fetch(`/search/${q}`);
806
1180
  const data = await res.json();
807
1181
  renderGrouped(data);
@@ -814,6 +1188,26 @@ document.addEventListener("paste", async e => {
814
1188
  // if user is typing in input, don't auto-send
815
1189
  if (active && active.id === "msg") return;
816
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
817
1211
  const text = e.clipboardData.getData("text");
818
1212
  if (!text) return;
819
1213
 
@@ -976,6 +1370,12 @@ function renderChannels() {
976
1370
  btn.appendChild(dot);
977
1371
  }
978
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
+
979
1379
  const more = document.createElement("span");
980
1380
  more.className = "ch-more";
981
1381
  more.innerText = "⋯";
@@ -1179,6 +1579,7 @@ document.addEventListener("keydown", e => {
1179
1579
  if (!info.hasAuth) {
1180
1580
  document.getElementById("logoutBtn").style.display = "none";
1181
1581
  }
1582
+ retention = info.retention; // null if "never", ms value otherwise
1182
1583
  await loadChannels();
1183
1584
  load();
1184
1585
  })();