instbyte 1.6.3 → 1.8.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 +20 -0
- package/client/assets/favicon-16.png +0 -0
- package/client/assets/favicon.png +0 -0
- package/client/css/app.css +1150 -291
- package/client/index.html +15 -4
- package/client/js/app.js +443 -50
- package/package.json +1 -1
- package/server/server.js +67 -4
package/client/index.html
CHANGED
|
@@ -25,9 +25,11 @@
|
|
|
25
25
|
</div>
|
|
26
26
|
</div>
|
|
27
27
|
<div class="header-right">
|
|
28
|
-
<span id="who"></span>
|
|
29
|
-
<
|
|
30
|
-
<button id="
|
|
28
|
+
<span id="who" class="username-display" onclick="changeName()" title="Click to change name"></span>
|
|
29
|
+
<span id="onlineCount" class="online-count"></span>
|
|
30
|
+
<button id="themeToggle" onclick="cycleTheme()" class="link-btn theme-toggle" title="Toggle theme">🌙</button>
|
|
31
|
+
<button id="logoutBtn" onclick="logout()" class="logout-btn">logout</button>
|
|
32
|
+
</div>
|
|
31
33
|
</div>
|
|
32
34
|
</header>
|
|
33
35
|
|
|
@@ -41,7 +43,7 @@
|
|
|
41
43
|
<div class="composer">
|
|
42
44
|
<input id="msg" type="text" placeholder="Type message or paste link" onkeydown="handleEnter(event)" />
|
|
43
45
|
<button onclick="sendText()">Send</button>
|
|
44
|
-
<input type="file" id="fileInput" hidden>
|
|
46
|
+
<input type="file" id="fileInput" hidden multiple>
|
|
45
47
|
<button onclick="fileInput.click()">Upload</button>
|
|
46
48
|
</div>
|
|
47
49
|
<div class="drop" id="drop">Drag files anywhere to upload</div>
|
|
@@ -53,6 +55,9 @@
|
|
|
53
55
|
</div>
|
|
54
56
|
</div>
|
|
55
57
|
<div id="items"></div>
|
|
58
|
+
<div id="loadMoreWrapper" style="display:none; text-align:center; margin:16px 0;">
|
|
59
|
+
<button id="loadMoreBtn" onclick="loadMore()" class="load-more-btn">Load more</button>
|
|
60
|
+
</div>
|
|
56
61
|
</div>
|
|
57
62
|
|
|
58
63
|
<div id="dragOverlay"
|
|
@@ -71,6 +76,12 @@
|
|
|
71
76
|
|
|
72
77
|
<div class="context-menu" id="channelMenu"></div>
|
|
73
78
|
|
|
79
|
+
<div id="undoToast" class="undo-toast">
|
|
80
|
+
<span id="undoMsg">Item deleted</span>
|
|
81
|
+
<button id="undoBtn" onclick="undoDelete()">Undo</button>
|
|
82
|
+
<div id="undoProgress" class="undo-progress"></div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
74
85
|
<script src="/socket.io/socket.io.js"></script>
|
|
75
86
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
|
|
76
87
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
package/client/js/app.js
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
const socket = io();
|
|
2
2
|
|
|
3
|
+
let currentPage = 1;
|
|
4
|
+
let hasMoreItems = false;
|
|
5
|
+
|
|
6
|
+
// ========================
|
|
7
|
+
// THEME MANAGEMENT (FIXED)
|
|
8
|
+
// ========================
|
|
9
|
+
const THEME_KEY = "instbyte_theme";
|
|
10
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
11
|
+
|
|
12
|
+
function getStoredTheme() {
|
|
13
|
+
return localStorage.getItem(THEME_KEY) || "auto";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function applyTheme(theme) {
|
|
17
|
+
const root = document.documentElement;
|
|
18
|
+
const btn = document.getElementById("themeToggle");
|
|
19
|
+
|
|
20
|
+
// Apply attribute EXACTLY
|
|
21
|
+
if (theme === "dark") {
|
|
22
|
+
root.setAttribute("data-theme", "dark");
|
|
23
|
+
} else if (theme === "light") {
|
|
24
|
+
root.setAttribute("data-theme", "light");
|
|
25
|
+
} else {
|
|
26
|
+
root.removeAttribute("data-theme"); // auto
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!btn) return;
|
|
30
|
+
|
|
31
|
+
// Update icon based on CURRENT stored state
|
|
32
|
+
if (theme === "dark") btn.textContent = "☀️";
|
|
33
|
+
else if (theme === "light") btn.textContent = "🌙";
|
|
34
|
+
else {
|
|
35
|
+
btn.textContent = mq.matches ? "☀️" : "🌙";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function cycleTheme() {
|
|
40
|
+
const current = getStoredTheme();
|
|
41
|
+
|
|
42
|
+
let next;
|
|
43
|
+
if (current === "dark") next = "light";
|
|
44
|
+
else if (current === "light") next = "dark";
|
|
45
|
+
else next = "dark"; // auto → dark first
|
|
46
|
+
|
|
47
|
+
localStorage.setItem(THEME_KEY, next);
|
|
48
|
+
applyTheme(next);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// INITIAL LOAD
|
|
52
|
+
applyTheme(getStoredTheme());
|
|
53
|
+
|
|
54
|
+
// OS change listener (only when auto)
|
|
55
|
+
mq.addEventListener("change", () => {
|
|
56
|
+
if (getStoredTheme() === "auto") {
|
|
57
|
+
applyTheme("auto");
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
3
61
|
async function applyBranding() {
|
|
4
62
|
try {
|
|
5
63
|
const res = await fetch("/branding");
|
|
@@ -39,6 +97,37 @@ function formatSize(bytes) {
|
|
|
39
97
|
return bytes + " B";
|
|
40
98
|
}
|
|
41
99
|
|
|
100
|
+
function playChime() {
|
|
101
|
+
try {
|
|
102
|
+
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
103
|
+
|
|
104
|
+
const gain = ctx.createGain();
|
|
105
|
+
gain.connect(ctx.destination);
|
|
106
|
+
gain.gain.setValueAtTime(0, ctx.currentTime);
|
|
107
|
+
gain.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 0.01);
|
|
108
|
+
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
|
|
109
|
+
|
|
110
|
+
const osc1 = ctx.createOscillator();
|
|
111
|
+
osc1.type = "sine";
|
|
112
|
+
osc1.frequency.setValueAtTime(880, ctx.currentTime);
|
|
113
|
+
osc1.frequency.exponentialRampToValueAtTime(1100, ctx.currentTime + 0.1);
|
|
114
|
+
osc1.connect(gain);
|
|
115
|
+
osc1.start(ctx.currentTime);
|
|
116
|
+
osc1.stop(ctx.currentTime + 0.6);
|
|
117
|
+
|
|
118
|
+
const osc2 = ctx.createOscillator();
|
|
119
|
+
osc2.type = "sine";
|
|
120
|
+
osc2.frequency.setValueAtTime(1320, ctx.currentTime + 0.08);
|
|
121
|
+
osc2.frequency.exponentialRampToValueAtTime(1760, ctx.currentTime + 0.2);
|
|
122
|
+
osc2.connect(gain);
|
|
123
|
+
osc2.start(ctx.currentTime + 0.08);
|
|
124
|
+
osc2.stop(ctx.currentTime + 0.6);
|
|
125
|
+
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// audio not supported or blocked — fail silently
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
42
131
|
function getSizeTag(bytes) {
|
|
43
132
|
if (!bytes) return "";
|
|
44
133
|
const mb = bytes / (1024 * 1024);
|
|
@@ -249,6 +338,10 @@ document.getElementById("items").addEventListener("click", e => {
|
|
|
249
338
|
|
|
250
339
|
let openDropdown = null;
|
|
251
340
|
|
|
341
|
+
let pendingDeleteId = null;
|
|
342
|
+
let pendingDeleteTimer = null;
|
|
343
|
+
let pendingDeleteEl = null;
|
|
344
|
+
|
|
252
345
|
function toggleMoveDropdown(e, id, currentChannel) {
|
|
253
346
|
e.stopPropagation();
|
|
254
347
|
|
|
@@ -304,6 +397,7 @@ document.addEventListener("click", () => {
|
|
|
304
397
|
|
|
305
398
|
let channel = null;
|
|
306
399
|
let channels = [];
|
|
400
|
+
let unreadChannels = new Set();
|
|
307
401
|
|
|
308
402
|
let uploader = localStorage.getItem("name") || "";
|
|
309
403
|
|
|
@@ -320,7 +414,7 @@ async function initName() {
|
|
|
320
414
|
uploader = prompt("Your name?", suggested) || suggested;
|
|
321
415
|
localStorage.setItem("name", uploader);
|
|
322
416
|
}
|
|
323
|
-
document.getElementById("who").innerText =
|
|
417
|
+
document.getElementById("who").innerText = uploader;
|
|
324
418
|
socket.emit("join", uploader);
|
|
325
419
|
}
|
|
326
420
|
|
|
@@ -332,16 +426,45 @@ function highlight() {
|
|
|
332
426
|
}
|
|
333
427
|
|
|
334
428
|
function setChannel(c) {
|
|
429
|
+
// flush any pending delete before switching
|
|
430
|
+
if (pendingDeleteId !== null) {
|
|
431
|
+
clearTimeout(pendingDeleteTimer);
|
|
432
|
+
fetch("/item/" + pendingDeleteId, { method: "DELETE" });
|
|
433
|
+
pendingDeleteId = null;
|
|
434
|
+
pendingDeleteTimer = null;
|
|
435
|
+
pendingDeleteEl = null;
|
|
436
|
+
hideUndoToast();
|
|
437
|
+
}
|
|
438
|
+
unreadChannels.delete(c); // clear unread when switching to channel
|
|
335
439
|
channel = c;
|
|
336
440
|
renderChannels();
|
|
337
441
|
highlight();
|
|
338
442
|
load();
|
|
339
443
|
}
|
|
340
444
|
|
|
341
|
-
async function load() {
|
|
342
|
-
|
|
445
|
+
async function load(resetPage = true) {
|
|
446
|
+
if (resetPage) currentPage = 1;
|
|
447
|
+
|
|
448
|
+
const res = await fetch(`/items/${channel}?page=${currentPage}`);
|
|
343
449
|
const data = await res.json();
|
|
344
|
-
|
|
450
|
+
|
|
451
|
+
console.log("load called, page:", currentPage, "data:", data);
|
|
452
|
+
|
|
453
|
+
hasMoreItems = data.hasMore;
|
|
454
|
+
|
|
455
|
+
if (currentPage === 1) {
|
|
456
|
+
render(data.items);
|
|
457
|
+
} else {
|
|
458
|
+
renderMore(data.items);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const wrapper = document.getElementById("loadMoreWrapper");
|
|
462
|
+
wrapper.style.display = hasMoreItems ? "block" : "none";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
async function loadMore() {
|
|
466
|
+
currentPage++;
|
|
467
|
+
await load(false);
|
|
345
468
|
}
|
|
346
469
|
|
|
347
470
|
function render(data) {
|
|
@@ -356,6 +479,7 @@ function render(data) {
|
|
|
356
479
|
data.forEach(i => {
|
|
357
480
|
const div = document.createElement("div");
|
|
358
481
|
div.className = "item";
|
|
482
|
+
div.dataset.itemId = i.id;
|
|
359
483
|
|
|
360
484
|
let content = "";
|
|
361
485
|
|
|
@@ -427,6 +551,74 @@ data-value="${i.type === 'file'
|
|
|
427
551
|
});
|
|
428
552
|
}
|
|
429
553
|
|
|
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
|
+
}
|
|
579
|
+
} 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
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
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, '"')}">
|
|
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>`;
|
|
617
|
+
|
|
618
|
+
el.appendChild(div);
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
430
622
|
function renderGrouped(data) {
|
|
431
623
|
const el = document.getElementById("items");
|
|
432
624
|
el.innerHTML = "";
|
|
@@ -456,6 +648,7 @@ function renderGrouped(data) {
|
|
|
456
648
|
grouped[ch].forEach(i => {
|
|
457
649
|
const div = document.createElement("div");
|
|
458
650
|
div.className = "item";
|
|
651
|
+
div.dataset.itemId = i.id;
|
|
459
652
|
|
|
460
653
|
let content = "";
|
|
461
654
|
|
|
@@ -544,7 +737,7 @@ async function sendText() {
|
|
|
544
737
|
}
|
|
545
738
|
|
|
546
739
|
function handleEnter(e) {
|
|
547
|
-
if (e.key === "Enter" && !e.shiftKey) {
|
|
740
|
+
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
|
548
741
|
e.preventDefault();
|
|
549
742
|
sendText();
|
|
550
743
|
}
|
|
@@ -555,7 +748,7 @@ function changeName() {
|
|
|
555
748
|
if (!n) return;
|
|
556
749
|
uploader = n;
|
|
557
750
|
localStorage.setItem("name", n);
|
|
558
|
-
document.getElementById("who").innerText =
|
|
751
|
+
document.getElementById("who").innerText = uploader;
|
|
559
752
|
}
|
|
560
753
|
|
|
561
754
|
let qrLoaded = false;
|
|
@@ -584,18 +777,83 @@ document.addEventListener("click", e => {
|
|
|
584
777
|
const fileInput = document.getElementById("fileInput");
|
|
585
778
|
|
|
586
779
|
fileInput.onchange = () => {
|
|
587
|
-
|
|
588
|
-
if (file) uploadFile(file);
|
|
780
|
+
if (fileInput.files.length) uploadFiles(fileInput.files);
|
|
589
781
|
};
|
|
590
782
|
|
|
591
783
|
async function del(id, pinned) {
|
|
784
|
+
// pinned items keep confirm dialog
|
|
592
785
|
if (pinned) {
|
|
593
786
|
const confirmed = confirm("This item is pinned. Are you sure you want to delete it?");
|
|
594
787
|
if (!confirmed) return;
|
|
788
|
+
await fetch("/item/" + id, { method: "DELETE" });
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// if another delete is pending, execute it immediately (don't await — fire and forget)
|
|
793
|
+
if (pendingDeleteId !== null) {
|
|
794
|
+
clearTimeout(pendingDeleteTimer);
|
|
795
|
+
fetch("/item/" + pendingDeleteId, { method: "DELETE" });
|
|
796
|
+
pendingDeleteId = null;
|
|
797
|
+
pendingDeleteTimer = null;
|
|
595
798
|
}
|
|
596
|
-
|
|
799
|
+
|
|
800
|
+
// optimistically remove from UI
|
|
801
|
+
const el = document.querySelector(`[data-item-id="${id}"]`);
|
|
802
|
+
if (el) {
|
|
803
|
+
pendingDeleteEl = el.outerHTML;
|
|
804
|
+
el.remove();
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
pendingDeleteId = id;
|
|
808
|
+
showUndoToast();
|
|
809
|
+
|
|
810
|
+
pendingDeleteTimer = setTimeout(async () => {
|
|
811
|
+
await fetch("/item/" + pendingDeleteId, { method: "DELETE" });
|
|
812
|
+
pendingDeleteId = null;
|
|
813
|
+
pendingDeleteTimer = null;
|
|
814
|
+
pendingDeleteEl = null;
|
|
815
|
+
hideUndoToast();
|
|
816
|
+
}, 5000);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function undoDelete() {
|
|
820
|
+
if (pendingDeleteId === null) return;
|
|
821
|
+
clearTimeout(pendingDeleteTimer);
|
|
822
|
+
pendingDeleteId = null;
|
|
823
|
+
pendingDeleteTimer = null;
|
|
824
|
+
pendingDeleteEl = null;
|
|
825
|
+
hideUndoToast();
|
|
826
|
+
load(); // reload to restore item
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function showUndoToast() {
|
|
830
|
+
const toast = document.getElementById("undoToast");
|
|
831
|
+
const progress = document.getElementById("undoProgress");
|
|
832
|
+
progress.classList.remove("running");
|
|
833
|
+
void progress.offsetWidth; // force reflow to restart animation
|
|
834
|
+
progress.classList.add("running");
|
|
835
|
+
toast.classList.add("show");
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function hideUndoToast() {
|
|
839
|
+
const toast = document.getElementById("undoToast");
|
|
840
|
+
const progress = document.getElementById("undoProgress");
|
|
841
|
+
toast.classList.remove("show");
|
|
842
|
+
progress.classList.remove("running");
|
|
597
843
|
}
|
|
598
844
|
|
|
845
|
+
|
|
846
|
+
// prime AudioContext on first interaction so chime works immediately after
|
|
847
|
+
let audioContextPrimed = false;
|
|
848
|
+
document.addEventListener("click", () => {
|
|
849
|
+
if (audioContextPrimed) return;
|
|
850
|
+
try {
|
|
851
|
+
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
852
|
+
ctx.resume().then(() => ctx.close());
|
|
853
|
+
audioContextPrimed = true;
|
|
854
|
+
} catch (e) { }
|
|
855
|
+
}, { once: false });
|
|
856
|
+
|
|
599
857
|
async function pin(id) {
|
|
600
858
|
await fetch("/pin/" + id, { method: "POST" });
|
|
601
859
|
load();
|
|
@@ -608,11 +866,32 @@ async function logout() {
|
|
|
608
866
|
}
|
|
609
867
|
|
|
610
868
|
socket.on("new-item", item => {
|
|
611
|
-
if (item.channel === channel)
|
|
869
|
+
if (item.channel === channel) {
|
|
870
|
+
load();
|
|
871
|
+
if (item.uploader !== uploader) playChime();
|
|
872
|
+
} else if (item.uploader !== uploader) {
|
|
873
|
+
// item arrived in a different channel — mark it unread
|
|
874
|
+
unreadChannels.add(item.channel);
|
|
875
|
+
renderChannels();
|
|
876
|
+
}
|
|
612
877
|
});
|
|
613
878
|
|
|
614
879
|
socket.on("delete-item", id => {
|
|
615
|
-
|
|
880
|
+
// don't reload if this item is already removed or pending
|
|
881
|
+
if (id == pendingDeleteId) return;
|
|
882
|
+
const el = document.querySelector(`[data-item-id="${id}"]`);
|
|
883
|
+
if (el) el.remove();
|
|
884
|
+
|
|
885
|
+
// show empty state if no items left
|
|
886
|
+
const items = document.getElementById("items");
|
|
887
|
+
if (items && !items.querySelector(".item")) {
|
|
888
|
+
items.innerHTML = `<div class="empty-state">Nothing here yet — paste, type, or drop a file to share</div>`;
|
|
889
|
+
}
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
socket.on("user-count", count => {
|
|
893
|
+
const el = document.getElementById("onlineCount");
|
|
894
|
+
if (el) el.textContent = `● ${count} online`;
|
|
616
895
|
});
|
|
617
896
|
|
|
618
897
|
socket.on("item-moved", ({ id, channel: toChannel }) => {
|
|
@@ -661,15 +940,17 @@ document.getElementById("search").addEventListener("input", async e => {
|
|
|
661
940
|
const q = e.target.value.trim();
|
|
662
941
|
|
|
663
942
|
if (!q) {
|
|
943
|
+
document.getElementById("loadMoreWrapper").style.display = "none";
|
|
664
944
|
highlight();
|
|
665
945
|
load();
|
|
666
946
|
return;
|
|
667
947
|
}
|
|
668
948
|
|
|
669
|
-
// remove active tab highlight while searching
|
|
670
949
|
document.querySelectorAll(".channels button")
|
|
671
950
|
.forEach(b => b.classList.remove("active"));
|
|
672
951
|
|
|
952
|
+
document.getElementById("loadMoreWrapper").style.display = "none";
|
|
953
|
+
|
|
673
954
|
const res = await fetch(`/search/${q}`);
|
|
674
955
|
const data = await res.json();
|
|
675
956
|
renderGrouped(data);
|
|
@@ -696,47 +977,63 @@ document.addEventListener("paste", async e => {
|
|
|
696
977
|
});
|
|
697
978
|
});
|
|
698
979
|
|
|
699
|
-
function
|
|
980
|
+
async function uploadFiles(files) {
|
|
981
|
+
if (!files || !files.length) return;
|
|
982
|
+
|
|
700
983
|
const status = document.getElementById("uploadStatus");
|
|
701
984
|
const bar = document.getElementById("uploadBar");
|
|
702
985
|
const text = document.getElementById("uploadText");
|
|
703
986
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
text.innerText = "Uploading: " + file.name;
|
|
987
|
+
const total = files.length;
|
|
988
|
+
const targetChannel = channel; // capture at start, won't change if user switches
|
|
707
989
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
form.append("channel", channel);
|
|
711
|
-
form.append("uploader", uploader);
|
|
990
|
+
for (let i = 0; i < total; i++) {
|
|
991
|
+
const file = files[i];
|
|
712
992
|
|
|
713
|
-
|
|
714
|
-
|
|
993
|
+
status.style.display = "block";
|
|
994
|
+
bar.style.width = "0%";
|
|
995
|
+
text.innerText = total > 1
|
|
996
|
+
? `Uploading ${i + 1} of ${total} · ${file.name}`
|
|
997
|
+
: `Uploading: ${file.name}`;
|
|
715
998
|
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
};
|
|
999
|
+
await new Promise((resolve) => {
|
|
1000
|
+
const form = new FormData();
|
|
1001
|
+
form.append("file", file);
|
|
1002
|
+
form.append("channel", targetChannel);
|
|
1003
|
+
form.append("uploader", uploader);
|
|
722
1004
|
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
if (xhr.status === 413) {
|
|
726
|
-
alert("File too large — 2GB maximum allowed.");
|
|
727
|
-
fileInput.value = "";
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
fileInput.value = "";
|
|
731
|
-
load();
|
|
732
|
-
};
|
|
1005
|
+
const xhr = new XMLHttpRequest();
|
|
1006
|
+
xhr.open("POST", "/upload", true);
|
|
733
1007
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
1008
|
+
xhr.upload.onprogress = e => {
|
|
1009
|
+
if (e.lengthComputable) {
|
|
1010
|
+
bar.style.width = Math.round((e.loaded / e.total) * 100) + "%";
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
xhr.onload = () => {
|
|
1015
|
+
if (xhr.status === 413) {
|
|
1016
|
+
text.innerText = `⚠ ${file.name} is too large — skipped`;
|
|
1017
|
+
bar.style.width = "0%";
|
|
1018
|
+
setTimeout(resolve, 1200);
|
|
1019
|
+
return;
|
|
1020
|
+
}
|
|
1021
|
+
resolve();
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
xhr.onerror = () => {
|
|
1025
|
+
text.innerText = `⚠ ${file.name} failed — skipped`;
|
|
1026
|
+
bar.style.width = "0%";
|
|
1027
|
+
setTimeout(resolve, 1200);
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
xhr.send(form);
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
738
1033
|
|
|
739
|
-
|
|
1034
|
+
status.style.display = "none";
|
|
1035
|
+
fileInput.value = "";
|
|
1036
|
+
load();
|
|
740
1037
|
}
|
|
741
1038
|
|
|
742
1039
|
|
|
@@ -828,6 +1125,12 @@ function renderChannels() {
|
|
|
828
1125
|
btn.appendChild(dot);
|
|
829
1126
|
}
|
|
830
1127
|
|
|
1128
|
+
if (unreadChannels.has(ch.name)) {
|
|
1129
|
+
const unread = document.createElement("span");
|
|
1130
|
+
unread.className = "ch-unread-dot";
|
|
1131
|
+
btn.appendChild(unread);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
831
1134
|
const more = document.createElement("span");
|
|
832
1135
|
more.className = "ch-more";
|
|
833
1136
|
more.innerText = "⋯";
|
|
@@ -922,15 +1225,105 @@ document.addEventListener("drop", async e => {
|
|
|
922
1225
|
overlay.style.display = "none";
|
|
923
1226
|
dragCounter = 0;
|
|
924
1227
|
|
|
925
|
-
const
|
|
926
|
-
if (!
|
|
1228
|
+
const files = e.dataTransfer.files;
|
|
1229
|
+
if (!files.length) return;
|
|
1230
|
+
uploadFiles(files);
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
// ========================
|
|
1234
|
+
// KEYBOARD SHORTCUTS
|
|
1235
|
+
// ========================
|
|
1236
|
+
document.addEventListener("keydown", e => {
|
|
1237
|
+
const active = document.activeElement;
|
|
1238
|
+
const isTyping = active && (active.id === "msg" || active.id === "search");
|
|
1239
|
+
|
|
1240
|
+
// Ctrl/Cmd + Enter — send message (only when msg is focused)
|
|
1241
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
|
1242
|
+
if (active && active.id === "msg") {
|
|
1243
|
+
e.preventDefault();
|
|
1244
|
+
e.stopPropagation();
|
|
1245
|
+
sendText();
|
|
1246
|
+
}
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Ctrl/Cmd + K — focus message input
|
|
1251
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
|
1252
|
+
if (!isTyping) {
|
|
1253
|
+
e.preventDefault();
|
|
1254
|
+
document.getElementById("msg").focus();
|
|
1255
|
+
}
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// / — focus search
|
|
1260
|
+
if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
|
|
1261
|
+
e.preventDefault();
|
|
1262
|
+
const search = document.getElementById("search");
|
|
1263
|
+
search.focus();
|
|
1264
|
+
search.select();
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// Tab — cycle channels
|
|
1269
|
+
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
1270
|
+
e.preventDefault();
|
|
1271
|
+
if (!channels.length) return;
|
|
1272
|
+
const currentIndex = channels.findIndex(c => c.name === channel);
|
|
1273
|
+
const nextIndex = (currentIndex + 1) % channels.length;
|
|
1274
|
+
setChannel(channels[nextIndex].name);
|
|
1275
|
+
return;
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Escape — close state in priority order
|
|
1279
|
+
if (e.key === "Escape") {
|
|
1280
|
+
|
|
1281
|
+
// 1. blur any focused input first
|
|
1282
|
+
if (active && (active.id === "msg" || active.id === "search")) {
|
|
1283
|
+
if (active.id === "search" && active.value) {
|
|
1284
|
+
active.value = "";
|
|
1285
|
+
highlight();
|
|
1286
|
+
load();
|
|
1287
|
+
}
|
|
1288
|
+
active.blur();
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
// 2. close open preview
|
|
1293
|
+
if (openPreviewId) {
|
|
1294
|
+
const panel = document.getElementById("preview-" + openPreviewId);
|
|
1295
|
+
const btn = document.getElementById("prevbtn-" + openPreviewId);
|
|
1296
|
+
if (panel) panel.classList.remove("open");
|
|
1297
|
+
if (btn) btn.classList.remove("preview-active");
|
|
1298
|
+
openPreviewId = null;
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// 3. close move dropdown
|
|
1303
|
+
if (openDropdown) {
|
|
1304
|
+
openDropdown.classList.remove("open");
|
|
1305
|
+
openDropdown = null;
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// 4. close context menu
|
|
1310
|
+
const contextMenu = document.getElementById("channelMenu");
|
|
1311
|
+
if (contextMenu.classList.contains("open")) {
|
|
1312
|
+
contextMenu.classList.remove("open");
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
// 5. close QR card
|
|
1317
|
+
const qrCard = document.getElementById("qrCard");
|
|
1318
|
+
if (qrCard.classList.contains("open")) {
|
|
1319
|
+
qrCard.classList.remove("open");
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
927
1323
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
form.append("channel", channel);
|
|
931
|
-
form.append("uploader", uploader);
|
|
1324
|
+
// All remaining shortcuts — skip if typing
|
|
1325
|
+
if (isTyping) return;
|
|
932
1326
|
|
|
933
|
-
uploadFile(file);
|
|
934
1327
|
});
|
|
935
1328
|
|
|
936
1329
|
(async function init() {
|