instbyte 1.8.0 → 1.9.1

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
@@ -2,6 +2,37 @@ const socket = io();
2
2
 
3
3
  let currentPage = 1;
4
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
5
36
 
6
37
  // ========================
7
38
  // THEME MANAGEMENT (FIXED)
@@ -89,6 +120,7 @@ async function applyBranding() {
89
120
  // Branding failed — default styles remain, no crash
90
121
  }
91
122
  }
123
+
92
124
  function formatSize(bytes) {
93
125
  if (!bytes) return "";
94
126
  if (bytes >= 1024 ** 3) return (bytes / 1024 ** 3).toFixed(1) + " GB";
@@ -97,6 +129,33 @@ function formatSize(bytes) {
97
129
  return bytes + " B";
98
130
  }
99
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
+
100
159
  function playChime() {
101
160
  try {
102
161
  const ctx = new (window.AudioContext || window.webkitAudioContext)();
@@ -265,21 +324,37 @@ async function togglePreview(id, filename) {
265
324
 
266
325
  try {
267
326
  const res = await fetch(url);
268
-
269
327
  if (!res.ok) throw new Error("Failed to load");
270
328
 
271
- // Check size via content-length header before reading
272
329
  const contentLength = res.headers.get("content-length");
273
330
  if (contentLength && parseInt(contentLength) > MAX_TEXT_PREVIEW_BYTES) {
274
331
  panel.innerHTML = `
275
- <div class="preview-error">
276
- File is too large to preview (${formatSize(parseInt(contentLength))}).
277
- <a href="${url}" target="_blank">Open in new tab</a>
278
- </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>`;
279
336
  return;
280
337
  }
281
338
 
282
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
283
358
  const lines = text.split("\n");
284
359
  const truncated = lines.length > MAX_TEXT_LINES;
285
360
  const preview = truncated
@@ -307,9 +382,9 @@ async function togglePreview(id, filename) {
307
382
 
308
383
  } catch (err) {
309
384
  panel.innerHTML = `
310
- <div class="preview-error">
311
- Could not load preview. <a href="${url}" target="_blank">Open directly</a>
312
- </div>`;
385
+ <div class="preview-error">
386
+ Could not load preview. <a href="${url}" target="_blank">Open directly</a>
387
+ </div>`;
313
388
  }
314
389
  }
315
390
  }
@@ -379,6 +454,48 @@ function toggleMoveDropdown(e, id, currentChannel) {
379
454
  openDropdown = dropdown;
380
455
  }
381
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
+
382
499
  async function moveItem(id, toChannel) {
383
500
  await fetch(`/item/${id}/move`, {
384
501
  method: "PATCH",
@@ -467,158 +584,291 @@ async function loadMore() {
467
584
  await load(false);
468
585
  }
469
586
 
470
- function render(data) {
471
- const el = document.getElementById("items");
472
- el.innerHTML = "";
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
+ }
473
607
 
474
- if (!data.length) {
475
- el.innerHTML = `<div class="empty-state">Nothing here yet — paste, type, or drop a file to share</div>`;
476
- return;
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);
477
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>`;
478
684
 
479
- data.forEach(i => {
480
- const div = document.createElement("div");
481
- div.className = "item";
482
- div.dataset.itemId = i.id;
483
-
484
- let content = "";
485
-
486
- if (i.type === "file") {
487
- const isImg = i.filename.match(/\.(jpg|png|jpeg|gif)$/i);
488
- const sizeLabel = formatSize(i.size);
489
- const sizeClass = getSizeTag(i.size);
490
- const sizeTag = sizeClass
491
- ? `<span class="size-tag ${sizeClass}">${sizeLabel}</span>`
492
- : sizeLabel
493
- ? `<span style="font-size:11px;color:#9ca3af;margin-left:6px;">${sizeLabel}</span>`
494
- : "";
495
-
496
- if (isImg) {
497
- content = `<img src="/uploads/${i.filename}" style="max-width:200px;border-radius:6px"><br>
498
- <a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
499
- } else {
500
- content = `<a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
501
- }
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 = "";
502
722
  } else {
503
- const isLink = i.content && i.content.startsWith("http");
504
- if (isLink) {
505
- content = `<a href="${i.content}" target="_blank">${i.content}</a>`;
506
- } else {
507
- content = renderText(i.content);
508
- }
723
+ fresh.style.display = "none";
509
724
  }
725
+ input.replaceWith(fresh);
726
+ }
510
727
 
511
- const pinText = i.pinned ? "unpin" : "pin";
512
-
513
- const isFile = i.type === "file";
514
- const clickValue = isFile
515
- ? `/uploads/${i.filename}`
516
- : i.content;
517
- const tooltip = isFile ? "Click to download" : "Click to copy";
518
-
519
- div.innerHTML = `
520
- <div class="item-top">
521
- <div class="left"
522
- data-tooltip="${i.type === 'file' ? 'Click to download' : 'Click to copy'}"
523
- data-type="${i.type === 'file' ? 'file' : 'text'}"
524
- data-value="${i.type === 'file'
525
- ? `/uploads/${i.filename}`
526
- : (i.content || '').replace(/"/g, '&quot;')}">
527
- ${content}
528
- <div class="meta">${i.uploader}</div>
529
- </div>
530
- <div class="item-actions">
531
- ${getPreviewType(i.filename) !== "none" && getPreviewType(i.filename) !== "image"
532
- ? `<button class="icon-btn" id="prevbtn-${i.id}"
533
- onclick="togglePreview(${i.id}, '${i.filename}')"
534
- title="Preview">👁</button>`
535
- : ""}
536
- <button class="icon-btn" onclick="pin(${i.id})" title="${i.pinned ? 'Unpin' : 'Pin'}">
537
- ${i.pinned ? "📍" : "📌"}
538
- </button>
539
- <div class="move-wrapper">
540
- <button class="icon-btn" title="Move to channel"
541
- onclick="toggleMoveDropdown(event, ${i.id}, '${i.channel}')">⇄</button>
542
- <div class="move-dropdown"></div>
543
- </div>
544
- <button class="icon-btn delete" onclick="del(${i.id}, ${i.pinned})" title="Delete">🗑</button>
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
+ }
545
740
 
546
- </div>
547
- </div>
548
- <div class="preview-panel" id="preview-${i.id}"></div>
549
- `;
550
- el.appendChild(div);
741
+ input.addEventListener("keydown", e => {
742
+ if (e.key === "Enter") { e.preventDefault(); save(); }
743
+ if (e.key === "Escape") { e.preventDefault(); restore(); }
551
744
  });
745
+
746
+ input.addEventListener("blur", save);
552
747
  }
553
748
 
554
- function renderMore(data) {
555
- const el = document.getElementById("items");
556
- data.forEach(i => {
557
- const div = document.createElement("div");
558
- div.className = "item";
559
- div.dataset.itemId = i.id;
560
-
561
- let content = "";
562
-
563
- if (i.type === "file") {
564
- const isImg = i.filename.match(/\.(jpg|png|jpeg|gif)$/i);
565
- const sizeLabel = formatSize(i.size);
566
- const sizeClass = getSizeTag(i.size);
567
- const sizeTag = sizeClass
568
- ? `<span class="size-tag ${sizeClass}">${sizeLabel}</span>`
569
- : sizeLabel
570
- ? `<span style="font-size:11px;color:#9ca3af;margin-left:6px;">${sizeLabel}</span>`
571
- : "";
572
-
573
- if (isImg) {
574
- content = `<img src="/uploads/${i.filename}" style="max-width:200px;border-radius:6px"><br>
575
- <a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
576
- } else {
577
- content = `<a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
578
- }
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;
579
824
  } else {
580
- const isLink = i.content && i.content.startsWith("http");
581
- if (isLink) {
582
- content = `<a href="${i.content}" target="_blank">${i.content}</a>`;
583
- } else {
584
- content = renderText(i.content);
585
- }
825
+ const wrap = document.createElement("div");
826
+ wrap.innerHTML = renderText(text);
827
+ node = wrap;
586
828
  }
587
829
 
588
- div.innerHTML = `
589
- <div class="item-top">
590
- <div class="left"
591
- data-tooltip="${i.type === 'file' ? 'Click to download' : 'Click to copy'}"
592
- data-type="${i.type === 'file' ? 'file' : 'text'}"
593
- data-value="${i.type === 'file'
594
- ? `/uploads/${i.filename}`
595
- : (i.content || '').replace(/"/g, '&quot;')}">
596
- ${content}
597
- <div class="meta">${i.uploader}</div>
598
- </div>
599
- <div class="item-actions">
600
- ${getPreviewType(i.filename) !== "none" && getPreviewType(i.filename) !== "image"
601
- ? `<button class="icon-btn" id="prevbtn-${i.id}"
602
- onclick="togglePreview(${i.id}, '${i.filename}')"
603
- title="Preview">👁</button>`
604
- : ""}
605
- <button class="icon-btn" onclick="pin(${i.id})" title="${i.pinned ? 'Unpin' : 'Pin'}">
606
- ${i.pinned ? "📍" : "📌"}
607
- </button>
608
- <div class="move-wrapper">
609
- <button class="icon-btn" title="Move to channel"
610
- onclick="toggleMoveDropdown(event, ${i.id}, '${i.channel}')">⇄</button>
611
- <div class="move-dropdown"></div>
612
- </div>
613
- <button class="icon-btn delete" onclick="del(${i.id}, ${i.pinned})" title="Delete">🗑</button>
614
- </div>
615
- </div>
616
- <div class="preview-panel" id="preview-${i.id}"></div>`;
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
+ });
617
844
 
618
- el.appendChild(div);
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);
619
852
  });
620
853
  }
621
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
+
622
872
  function renderGrouped(data) {
623
873
  const el = document.getElementById("items");
624
874
  el.innerHTML = "";
@@ -628,7 +878,6 @@ function renderGrouped(data) {
628
878
  return;
629
879
  }
630
880
 
631
- // group by channel
632
881
  const grouped = {};
633
882
  data.forEach(item => {
634
883
  if (!grouped[item.channel]) grouped[item.channel] = [];
@@ -638,86 +887,11 @@ function renderGrouped(data) {
638
887
  Object.keys(grouped).forEach(ch => {
639
888
  const section = document.createElement("div");
640
889
  section.style.marginTop = "20px";
641
-
642
890
  section.innerHTML = `
643
- <div style="font-size:13px;font-weight:600;color:#6b7280;margin-bottom:8px;">
644
- ${ch.toUpperCase()}
645
- </div>
646
- `;
647
-
648
- grouped[ch].forEach(i => {
649
- const div = document.createElement("div");
650
- div.className = "item";
651
- div.dataset.itemId = i.id;
652
-
653
- let content = "";
654
-
655
- if (i.type === "file") {
656
- const isImg = i.filename.match(/\.(jpg|png|jpeg|gif)$/i);
657
- const sizeLabel = formatSize(i.size);
658
- const sizeClass = getSizeTag(i.size);
659
- const sizeTag = sizeClass
660
- ? `<span class="size-tag ${sizeClass}">${sizeLabel}</span>`
661
- : sizeLabel
662
- ? `<span style="font-size:11px;color:#9ca3af;margin-left:6px;">${sizeLabel}</span>`
663
- : "";
664
-
665
- if (isImg) {
666
- content = `<img src="/uploads/${i.filename}" style="max-width:200px;border-radius:6px"><br>
667
- <a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
668
- } else {
669
- content = `<a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
670
- }
671
- } else {
672
- const isLink = i.content && i.content.startsWith("http");
673
- if (isLink) {
674
- content = `<a href="${i.content}" target="_blank">${i.content}</a>`;
675
- } else {
676
- content = renderText(i.content);
677
- }
678
- }
679
-
680
- const isFile = i.type === "file";
681
- const clickValue = isFile
682
- ? `/uploads/${i.filename}`
683
- : i.content;
684
- const tooltip = isFile ? "Click to download" : "Click to copy";
685
-
686
- div.innerHTML = `
687
- <div class="item-top">
688
- <div class="left"
689
- data-tooltip="${i.type === 'file' ? 'Click to download' : 'Click to copy'}"
690
- data-type="${i.type === 'file' ? 'file' : 'text'}"
691
- data-value="${i.type === 'file'
692
- ? `/uploads/${i.filename}`
693
- : (i.content || '').replace(/"/g, '&quot;')}">
694
- ${content}
695
- <div class="meta">${i.uploader}</div>
696
- </div>
697
- <div class="item-actions">
698
- ${getPreviewType(i.filename) !== "none" && getPreviewType(i.filename) !== "image"
699
- ? `<button class="icon-btn" id="prevbtn-${i.id}"
700
- onclick="togglePreview(${i.id}, '${i.filename}')"
701
- title="Preview">👁</button>`
702
- : ""}
703
- <button class="icon-btn" onclick="pin(${i.id})" title="${i.pinned ? 'Unpin' : 'Pin'}">
704
- ${i.pinned ? "📍" : "📌"}
705
- </button>
706
- <div class="move-wrapper">
707
- <button class="icon-btn" title="Move to channel"
708
- onclick="toggleMoveDropdown(event, ${i.id}, '${i.channel}')">⇄</button>
709
- <div class="move-dropdown"></div>
710
- </div>
711
- <button class="icon-btn delete" onclick="del(${i.id}, ${i.pinned})" title="Delete">🗑</button>
712
-
713
- </div>
714
- </div>
715
- <div class="preview-panel" id="preview-${i.id}"></div>
716
- `;
717
-
718
- section.appendChild(div);
719
- });
720
-
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)));
721
895
  el.appendChild(section);
722
896
  });
723
897
  }
@@ -867,10 +1041,11 @@ async function logout() {
867
1041
 
868
1042
  socket.on("new-item", item => {
869
1043
  if (item.channel === channel) {
1044
+ if (!item.pinned) newItemIds.add(item.id);
870
1045
  load();
871
1046
  if (item.uploader !== uploader) playChime();
872
1047
  } else if (item.uploader !== uploader) {
873
- // item arrived in a different channel — mark it unread
1048
+ if (!item.pinned) newItemIds.add(item.id);
874
1049
  unreadChannels.add(item.channel);
875
1050
  renderChannels();
876
1051
  }
@@ -889,6 +1064,56 @@ socket.on("delete-item", id => {
889
1064
  }
890
1065
  });
891
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
+
892
1117
  socket.on("user-count", count => {
893
1118
  const el = document.getElementById("onlineCount");
894
1119
  if (el) el.textContent = `● ${count} online`;
@@ -963,6 +1188,26 @@ document.addEventListener("paste", async e => {
963
1188
  // if user is typing in input, don't auto-send
964
1189
  if (active && active.id === "msg") return;
965
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
966
1211
  const text = e.clipboardData.getData("text");
967
1212
  if (!text) return;
968
1213
 
@@ -1005,13 +1250,32 @@ async function uploadFiles(files) {
1005
1250
  const xhr = new XMLHttpRequest();
1006
1251
  xhr.open("POST", "/upload", true);
1007
1252
 
1253
+ // Stall detection — if no progress for 30s, the connection is dead.
1254
+ // We don't use xhr.timeout because that would kill legitimate large
1255
+ // file uploads on slow LANs.
1256
+ // Instead we track the last progress event and abort if nothing moves for 30 seconds.
1257
+ const STALL_MS = 30_000;
1258
+ let stallTimer = null;
1259
+
1260
+ function resetStallTimer() {
1261
+ clearTimeout(stallTimer);
1262
+ stallTimer = setTimeout(() => {
1263
+ xhr.abort();
1264
+ text.innerText = `⚠ ${file.name} stalled — skipped`;
1265
+ bar.style.width = "0%";
1266
+ setTimeout(resolve, 1200);
1267
+ }, STALL_MS);
1268
+ }
1269
+
1008
1270
  xhr.upload.onprogress = e => {
1009
1271
  if (e.lengthComputable) {
1010
1272
  bar.style.width = Math.round((e.loaded / e.total) * 100) + "%";
1011
1273
  }
1274
+ resetStallTimer(); // data is moving, push the timer out
1012
1275
  };
1013
1276
 
1014
1277
  xhr.onload = () => {
1278
+ clearTimeout(stallTimer);
1015
1279
  if (xhr.status === 413) {
1016
1280
  text.innerText = `⚠ ${file.name} is too large — skipped`;
1017
1281
  bar.style.width = "0%";
@@ -1022,11 +1286,21 @@ async function uploadFiles(files) {
1022
1286
  };
1023
1287
 
1024
1288
  xhr.onerror = () => {
1289
+ clearTimeout(stallTimer);
1025
1290
  text.innerText = `⚠ ${file.name} failed — skipped`;
1026
1291
  bar.style.width = "0%";
1027
1292
  setTimeout(resolve, 1200);
1028
1293
  };
1029
1294
 
1295
+ xhr.onabort = () => {
1296
+ clearTimeout(stallTimer);
1297
+ // ontimeout/stall already set the message — only handle
1298
+ // explicit user-triggered aborts here if we add a cancel
1299
+ // button in future. For now just resolve to unblock queue.
1300
+ resolve();
1301
+ };
1302
+
1303
+ resetStallTimer(); // start timer — covers stall before first progress
1030
1304
  xhr.send(form);
1031
1305
  });
1032
1306
  }
@@ -1334,6 +1608,7 @@ document.addEventListener("keydown", e => {
1334
1608
  if (!info.hasAuth) {
1335
1609
  document.getElementById("logoutBtn").style.display = "none";
1336
1610
  }
1611
+ retention = info.retention; // null if "never", ms value otherwise
1337
1612
  await loadChannels();
1338
1613
  load();
1339
1614
  })();