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/README.md +12 -3
- package/client/css/app.css +943 -443
- package/client/index.html +11 -4
- package/client/js/app.js +569 -168
- package/package.json +1 -1
- package/server/cleanup.js +3 -4
- package/server/config.js +1 -0
- package/server/db.js +3 -0
- package/server/server.js +120 -5
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, "&")
|
|
154
|
+
.replace(/</g, "<")
|
|
155
|
+
.replace(/>/g, ">")
|
|
156
|
+
.replace(/"/g, """);
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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, """);
|
|
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
|
-
|
|
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, '"')}">
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
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, '"')}">
|
|
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 =
|
|
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)
|
|
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
|
})();
|