instbyte 1.8.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/README.md CHANGED
@@ -63,7 +63,7 @@ One person runs the server — everyone else on the same WiFi opens the URL in t
63
63
 
64
64
  **Sharing is instant:**
65
65
  - Type or paste text → hit Send
66
- - Paste anywhere on the page → auto-sends
66
+ - Paste anywhere on the page → auto-sends text or uploads images directly
67
67
  - Drag a file anywhere onto the page → uploads
68
68
  - Click any text item → copies to clipboard
69
69
  - Click any file item → downloads
@@ -125,7 +125,7 @@ Instbyte works out of the box with zero configuration. All options are optional
125
125
  | `server.port` | `3000` | Port to run on. Overridden by `PORT` env var if set |
126
126
  | `auth.passphrase` | `""` | Shared passphrase for access. Empty = no auth |
127
127
  | `storage.maxFileSize` | `"2GB"` | Max upload size. Accepts `KB`, `MB`, `GB` |
128
- | `storage.retention` | `"24h"` | How long before items auto-delete. Accepts `h`, `d` |
128
+ | `storage.retention` | `"24h"` | How long before items auto-delete. Accepts `h`, `d`, or `"never"` to disable cleanup entirely |
129
129
  | `branding.appName` | `"Instbyte"` | App name in header and browser tab |
130
130
  | `branding.primaryColor` | `"#111827"` | Primary brand color in hex. Full palette auto-derived |
131
131
  | `branding.logoPath` | — | Path to your logo file relative to where you run the command |
@@ -163,7 +163,7 @@ The difference between *a tool you use* and *a tool you own.*
163
163
 
164
164
  **Smart port handling** — if port 3000 is busy, Instbyte finds the next available port automatically.
165
165
 
166
- **Short-lived by design** — all content auto-deletes after 24 hours. Nothing lingers.
166
+ **Short-lived by design** — content auto-deletes after 24 hours by default. Configure retention per your needs, or disable cleanup entirely.
167
167
 
168
168
  **QR join** — built-in QR code so phones can join instantly without typing the URL.
169
169
 
@@ -175,6 +175,9 @@ The difference between *a tool you use* and *a tool you own.*
175
175
 
176
176
  **Presence awareness** — see how many people are currently connected in real time.
177
177
 
178
+ **Read receipts** — see how many devices have viewed each shared item. Updates live as teammates open the page.
179
+
180
+ **Item management** — add optional titles to label any item for future reference. Edit text items inline without deleting and re-pasting. Pinned items are protected from both manual deletion and auto-cleanup.
178
181
  ---
179
182
 
180
183
  ## Keyboard Shortcuts
@@ -396,7 +396,8 @@ input[type=text]:focus {
396
396
 
397
397
  .item-actions {
398
398
  display: flex;
399
- gap: 8px;
399
+ gap: 6px;
400
+ align-items: flex-start;
400
401
  }
401
402
 
402
403
  .empty-state {
@@ -443,11 +444,16 @@ input[type=text]:focus {
443
444
  background: #f9fafb;
444
445
  border: 1px solid #e5e7eb;
445
446
  border-radius: 6px;
446
- padding: 6px 8px;
447
+ padding: 5px 7px;
447
448
  cursor: pointer;
448
- font-size: 14px;
449
+ font-size: 13px;
449
450
  line-height: 1;
450
451
  color: #6b7280;
452
+ width: 28px;
453
+ height: 28px;
454
+ display: flex;
455
+ align-items: center;
456
+ justify-content: center;
451
457
  }
452
458
 
453
459
  .icon-btn:hover {
@@ -707,6 +713,12 @@ input[type=text]:focus {
707
713
  font-size: 12px;
708
714
  }
709
715
 
716
+ .markdown-preview {
717
+ padding: 4px 0;
718
+ max-height: 500px;
719
+ overflow-y: auto;
720
+ }
721
+
710
722
  .preview-truncated {
711
723
  font-size: 11px;
712
724
  color: #9ca3af;
@@ -881,6 +893,159 @@ input[type=text]:focus {
881
893
  background: #e5e7eb;
882
894
  }
883
895
 
896
+ .item-title {
897
+ font-size: 11px;
898
+ font-weight: 600;
899
+ color: #6b7280;
900
+ text-transform: uppercase;
901
+ letter-spacing: 0.05em;
902
+ margin-bottom: 4px;
903
+ }
904
+
905
+ .title-input {
906
+ font-size: 12px;
907
+ padding: 3px 7px;
908
+ border: 1px solid #d1d5db;
909
+ border-radius: 5px;
910
+ margin-bottom: 4px;
911
+ width: 200px;
912
+ display: block;
913
+ color: #111827;
914
+ background: #fff;
915
+ }
916
+
917
+ .title-input:focus {
918
+ outline: none;
919
+ border-color: var(--color-primary);
920
+ }
921
+
922
+ .edit-textarea {
923
+ width: min(600px, 60vw);
924
+ min-height: 80px;
925
+ padding: 8px 10px;
926
+ border: 1px solid #d1d5db;
927
+ border-radius: 6px;
928
+ font-size: 14px;
929
+ font-family: system-ui;
930
+ resize: vertical;
931
+ display: block;
932
+ box-sizing: border-box;
933
+ line-height: 1.5;
934
+ color: #111827;
935
+ background: #f9fafb;
936
+ margin-bottom: 6px;
937
+ }
938
+
939
+ .edit-textarea:focus {
940
+ outline: none;
941
+ border-color: var(--color-primary);
942
+ background: #fff;
943
+ }
944
+
945
+ .edit-hint {
946
+ font-size: 11px;
947
+ color: #9ca3af;
948
+ margin-bottom: 4px;
949
+ }
950
+
951
+ .expiry-badge {
952
+ display: inline-block;
953
+ font-size: 10px;
954
+ font-weight: 500;
955
+ padding: 1px 6px;
956
+ border-radius: 4px;
957
+ margin-left: 6px;
958
+ vertical-align: middle;
959
+ background: #fef3c7;
960
+ color: #92400e;
961
+ }
962
+
963
+ .expiry-badge.expiry-gone {
964
+ background: #fee2e2;
965
+ color: #991b1b;
966
+ }
967
+
968
+ .seen-count {
969
+ font-size: px;
970
+ color: #9ca3af;
971
+ margin-left: 6px;
972
+ }
973
+
974
+ .more-wrapper {
975
+ position: relative;
976
+ }
977
+
978
+ .more-btn {
979
+ font-size: 16px;
980
+ padding: 4px 7px;
981
+ letter-spacing: 0;
982
+ line-height: 1;
983
+ }
984
+
985
+ .more-dropdown {
986
+ position: absolute;
987
+ bottom: calc(100% + 6px);
988
+ right: 0;
989
+ background: #fff;
990
+ border: 1px solid #e5e7eb;
991
+ border-radius: 8px;
992
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
993
+ z-index: 100;
994
+ min-width: 160px;
995
+ overflow: hidden;
996
+ display: none;
997
+ padding: 4px;
998
+ }
999
+
1000
+ .more-dropdown.open {
1001
+ display: block;
1002
+ }
1003
+
1004
+ .more-dropdown button {
1005
+ display: block;
1006
+ width: 100%;
1007
+ text-align: left;
1008
+ background: none;
1009
+ border: none;
1010
+ padding: 8px 12px;
1011
+ font-size: 13px;
1012
+ color: #374151;
1013
+ cursor: pointer;
1014
+ border-radius: 6px;
1015
+ }
1016
+
1017
+ .more-dropdown button:hover {
1018
+ background: #f3f4f6;
1019
+ color: #111827;
1020
+ }
1021
+
1022
+ .more-dropdown .dropdown-label {
1023
+ font-size: 11px;
1024
+ color: #9ca3af;
1025
+ padding: 6px 12px 2px;
1026
+ font-weight: 500;
1027
+ text-transform: uppercase;
1028
+ letter-spacing: 0.05em;
1029
+ cursor: default;
1030
+ }
1031
+
1032
+ .icon-btn i {
1033
+ width: 14px;
1034
+ height: 14px;
1035
+ display: block;
1036
+ stroke-width: 2;
1037
+ }
1038
+
1039
+ .icon-btn.pinned i {
1040
+ color: #f59e0b;
1041
+ }
1042
+
1043
+ .item-new {
1044
+ border-left: 3px solid #22c55e;
1045
+ padding-left: 13px;
1046
+ background: #f0fdf4;
1047
+ transition: background 1.5s ease, border-color 1.5s ease;
1048
+ }
884
1049
 
885
1050
  /* ============================================================
886
1051
  MOBILE
@@ -1339,6 +1504,74 @@ input[type=text]:focus {
1339
1504
  background: #2d3148;
1340
1505
  }
1341
1506
 
1507
+ .markdown-preview {
1508
+ color: #e5e7eb;
1509
+ }
1510
+
1511
+ .item-title {
1512
+ color: #6b7280;
1513
+ }
1514
+
1515
+ .title-input {
1516
+ background: #252836;
1517
+ border-color: #2d3148;
1518
+ color: #f3f4f6;
1519
+ }
1520
+
1521
+ .edit-textarea {
1522
+ background: #252836;
1523
+ border-color: #2d3148;
1524
+ color: #f3f4f6;
1525
+ }
1526
+
1527
+ .edit-textarea:focus {
1528
+ background: #1a1d27;
1529
+ border-color: #4b5563;
1530
+ }
1531
+
1532
+ .expiry-badge {
1533
+ background: #422006;
1534
+ color: #fbbf24;
1535
+ }
1536
+
1537
+ .expiry-badge.expiry-gone {
1538
+ background: #2d1515;
1539
+ color: #f87171;
1540
+ }
1541
+
1542
+ .seen-count {
1543
+ color: #6b7280;
1544
+ }
1545
+
1546
+ .more-dropdown {
1547
+ background: #1a1d27;
1548
+ border-color: #2d3148;
1549
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
1550
+ }
1551
+
1552
+ .more-dropdown button {
1553
+ color: #d1d5db;
1554
+ }
1555
+
1556
+ .more-dropdown button:hover {
1557
+ background: #252836;
1558
+ color: #f3f4f6;
1559
+ }
1560
+
1561
+ .more-dropdown .dropdown-label {
1562
+ color: #6b7280;
1563
+ }
1564
+
1565
+ .icon-btn.pinned i {
1566
+ color: #f59e0b;
1567
+ }
1568
+
1569
+ .item-new {
1570
+ border-left: 3px solid #16a34a;
1571
+ padding-left: 13px;
1572
+ background: #052e16;
1573
+ }
1574
+
1342
1575
  #uploadStatus {
1343
1576
  background: #1a1d27 !important;
1344
1577
  color: #e5e7eb !important;
@@ -1665,6 +1898,74 @@ input[type=text]:focus {
1665
1898
  background: #2d3148;
1666
1899
  }
1667
1900
 
1901
+ .markdown-preview {
1902
+ color: #e5e7eb;
1903
+ }
1904
+
1905
+ .item-title {
1906
+ color: #6b7280;
1907
+ }
1908
+
1909
+ .title-input {
1910
+ background: #252836;
1911
+ border-color: #2d3148;
1912
+ color: #f3f4f6;
1913
+ }
1914
+
1915
+ .edit-textarea {
1916
+ background: #252836;
1917
+ border-color: #2d3148;
1918
+ color: #f3f4f6;
1919
+ }
1920
+
1921
+ .edit-textarea:focus {
1922
+ background: #1a1d27;
1923
+ border-color: #4b5563;
1924
+ }
1925
+
1926
+ .expiry-badge {
1927
+ background: #422006;
1928
+ color: #fbbf24;
1929
+ }
1930
+
1931
+ .expiry-badge.expiry-gone {
1932
+ background: #2d1515;
1933
+ color: #f87171;
1934
+ }
1935
+
1936
+ .seen-count {
1937
+ color: #6b7280;
1938
+ }
1939
+
1940
+ .more-dropdown {
1941
+ background: #1a1d27;
1942
+ border-color: #2d3148;
1943
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
1944
+ }
1945
+
1946
+ .more-dropdown button {
1947
+ color: #d1d5db;
1948
+ }
1949
+
1950
+ .more-dropdown button:hover {
1951
+ background: #252836;
1952
+ color: #f3f4f6;
1953
+ }
1954
+
1955
+ .more-dropdown .dropdown-label {
1956
+ color: #6b7280;
1957
+ }
1958
+
1959
+ .icon-btn.pinned i {
1960
+ color: #f59e0b;
1961
+ }
1962
+
1963
+ .item-new {
1964
+ border-left: 3px solid #16a34a;
1965
+ padding-left: 13px;
1966
+ background: #052e16;
1967
+ }
1968
+
1668
1969
  #uploadStatus {
1669
1970
  background: #1a1d27 !important;
1670
1971
  color: #e5e7eb !important;
package/client/index.html CHANGED
@@ -9,6 +9,8 @@
9
9
 
10
10
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
11
11
  <link rel="stylesheet" href="/css/app.css">
12
+
13
+ <script src="https://unpkg.com/lucide@0.513.0/dist/umd/lucide.min.js"></script>
12
14
  </head>
13
15
 
14
16
  <body>
@@ -87,6 +89,7 @@
87
89
  <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
88
90
  <script src="/js/app.js"></script>
89
91
 
92
+
90
93
  </body>
91
94
 
92
95
  </html>
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
 
@@ -1334,6 +1579,7 @@ document.addEventListener("keydown", e => {
1334
1579
  if (!info.hasAuth) {
1335
1580
  document.getElementById("logoutBtn").style.display = "none";
1336
1581
  }
1582
+ retention = info.retention; // null if "never", ms value otherwise
1337
1583
  await loadChannels();
1338
1584
  load();
1339
1585
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instbyte",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "description": "A self-hosted LAN sharing utility for fast, frictionless file, link, and snippet exchange across devices — no cloud required.",
5
5
  "main": "server/server.js",
6
6
  "bin": {
package/server/cleanup.js CHANGED
@@ -3,13 +3,12 @@ const fs = require("fs");
3
3
  const path = require("path");
4
4
  const config = require("./config");
5
5
 
6
- const DAY = config.storage.retention;
7
-
8
6
  setInterval(() => {
9
- const cutoff = Date.now() - DAY;
7
+ if (config.storage.retention === null) return;
8
+ const cutoff = Date.now() - config.storage.retention;
10
9
 
11
10
  db.all(
12
- `SELECT * FROM items WHERE created_at < ?`,
11
+ `SELECT * FROM items WHERE created_at < ? AND pinned = 0`,
13
12
  [cutoff],
14
13
  (err, rows) => {
15
14
  rows.forEach((item) => {
package/server/config.js CHANGED
@@ -30,6 +30,7 @@ function parseFileSize(val) {
30
30
  }
31
31
 
32
32
  function parseRetention(val) {
33
+ if (String(val).toLowerCase() === "never") return null;
33
34
  if (typeof val === "number") return val;
34
35
  const units = { h: 3600000, d: 86400000 };
35
36
  const match = String(val).match(/^(\d+)(h|d)$/i);
package/server/db.js CHANGED
@@ -36,6 +36,9 @@ db.serialize(() => {
36
36
  db.run(`ALTER TABLE items ADD COLUMN size INTEGER DEFAULT 0`, () => { });
37
37
  db.run(`ALTER TABLE channels ADD COLUMN pinned INTEGER DEFAULT 0`, () => { });
38
38
 
39
+ db.run(`ALTER TABLE items ADD COLUMN title TEXT DEFAULT ''`, () => { });
40
+ db.run(`ALTER TABLE items ADD COLUMN edited_at INTEGER DEFAULT NULL`, () => { });
41
+
39
42
  // Insert default channels if empty
40
43
  db.get("SELECT COUNT(*) as count FROM channels", (err, row) => {
41
44
  if (err) {
package/server/server.js CHANGED
@@ -525,6 +525,45 @@ app.patch("/item/:id/move", (req, res) => {
525
525
  });
526
526
  });
527
527
 
528
+ /* UPDATE ITEM TITLE */
529
+ app.patch("/item/:id/title", (req, res) => {
530
+ const { id } = req.params;
531
+ const { title } = req.body;
532
+
533
+ if (title === undefined) return res.status(400).json({ error: "Title required" });
534
+
535
+ db.run(
536
+ "UPDATE items SET title=? WHERE id=?",
537
+ [title.trim(), id],
538
+ function (err) {
539
+ if (err) return res.status(500).json({ error: "Update failed" });
540
+ if (this.changes === 0) return res.status(404).json({ error: "Item not found" });
541
+ io.emit("item-updated", { id: parseInt(id), title: title.trim() });
542
+ res.json({ id, title: title.trim() });
543
+ }
544
+ );
545
+ });
546
+
547
+ /* UPDATE ITEM CONTENT */
548
+ app.patch("/item/:id/content", (req, res) => {
549
+ const { id } = req.params;
550
+ const { content } = req.body;
551
+
552
+ if (content === undefined) return res.status(400).json({ error: "Content required" });
553
+ if (content.trim() === "") return res.status(400).json({ error: "Content cannot be empty" });
554
+
555
+ db.run(
556
+ "UPDATE items SET content=?, edited_at=? WHERE id=? AND type='text'",
557
+ [content.trim(), Date.now(), id],
558
+ function (err) {
559
+ if (err) return res.status(500).json({ error: "Update failed" });
560
+ if (this.changes === 0) return res.status(404).json({ error: "Item not found or not editable" });
561
+ io.emit("item-updated", { id: parseInt(id), content: content.trim(), edited_at: Date.now() });
562
+ res.json({ id, content: content.trim() });
563
+ }
564
+ );
565
+ });
566
+
528
567
  /* RENAME CHANNEL */
529
568
  app.patch("/channels/:name", (req, res) => {
530
569
  const oldName = req.params.name;
@@ -573,7 +612,8 @@ app.post("/channels/:name/pin", (req, res) => {
573
612
  app.get("/info", (req, res) => {
574
613
  res.json({
575
614
  url: `http://${localIP}:${PORT}`,
576
- hasAuth: !!config.auth.passphrase
615
+ hasAuth: !!config.auth.passphrase,
616
+ retention: config.storage.retention // null means "never"
577
617
  });
578
618
  });
579
619
 
@@ -650,6 +690,9 @@ app.get("/logo-dynamic.png", (req, res) => {
650
690
  /* ============================
651
691
  SOCKET CONNECTION LOGGING
652
692
  ============================ */
693
+ // in-memory seen tracking — item id → Set of socket ids
694
+ // resets on server restart, no DB needed
695
+ const seenBy = new Map();
653
696
 
654
697
  let connectedUsers = 0;
655
698
 
@@ -664,6 +707,15 @@ io.on("connection", (socket) => {
664
707
  console.log(username + " connected | total:", connectedUsers);
665
708
  });
666
709
 
710
+ socket.on("seen", ({ id, name }) => {
711
+ if (!id || !name) return;
712
+ if (!seenBy.has(id)) seenBy.set(id, new Set());
713
+ seenBy.get(id).add(name); // name instead of socket.id
714
+ const count = seenBy.get(id).size;
715
+ console.log(`seen: item ${id} | count: ${count}`);
716
+ io.emit("seen-update", { id, count });
717
+ });
718
+
667
719
  socket.on("disconnect", () => {
668
720
  connectedUsers--;
669
721
  console.log(username + " disconnected | total:", connectedUsers);