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/README.md +6 -3
- package/client/css/app.css +304 -3
- package/client/index.html +3 -0
- package/client/js/app.js +500 -225
- package/package.json +1 -1
- package/server/cleanup.js +3 -4
- package/server/config.js +1 -0
- package/server/db.js +4 -0
- package/server/server.js +108 -3
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, "&")
|
|
154
|
+
.replace(/</g, "<")
|
|
155
|
+
.replace(/>/g, ">")
|
|
156
|
+
.replace(/"/g, """);
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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, """);
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
data-type="${i.type === 'file' ? 'file' : 'text'}"
|
|
524
|
-
data-value="${i.type === 'file'
|
|
525
|
-
? `/uploads/${i.filename}`
|
|
526
|
-
: (i.content || '').replace(/"/g, '"')}">
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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, '"')}">
|
|
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
|
-
|
|
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
|
})();
|