instbyte 1.7.0 → 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 +6 -0
- package/client/css/app.css +648 -449
- package/client/index.html +8 -4
- package/client/js/app.js +162 -7
- package/package.json +1 -1
- package/server/server.js +67 -4
package/client/index.html
CHANGED
|
@@ -25,10 +25,11 @@
|
|
|
25
25
|
</div>
|
|
26
26
|
</div>
|
|
27
27
|
<div class="header-right">
|
|
28
|
-
<span id="who"></span>
|
|
29
|
-
<
|
|
30
|
-
<button id="themeToggle" onclick="cycleTheme()">🌙</button>
|
|
31
|
-
<button id="logoutBtn" onclick="logout()" class="
|
|
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>
|
|
32
33
|
</div>
|
|
33
34
|
</header>
|
|
34
35
|
|
|
@@ -54,6 +55,9 @@
|
|
|
54
55
|
</div>
|
|
55
56
|
</div>
|
|
56
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>
|
|
57
61
|
</div>
|
|
58
62
|
|
|
59
63
|
<div id="dragOverlay"
|
package/client/js/app.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
const socket = io();
|
|
2
2
|
|
|
3
|
+
let currentPage = 1;
|
|
4
|
+
let hasMoreItems = false;
|
|
5
|
+
|
|
3
6
|
// ========================
|
|
4
7
|
// THEME MANAGEMENT (FIXED)
|
|
5
8
|
// ========================
|
|
@@ -94,6 +97,37 @@ function formatSize(bytes) {
|
|
|
94
97
|
return bytes + " B";
|
|
95
98
|
}
|
|
96
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
|
+
|
|
97
131
|
function getSizeTag(bytes) {
|
|
98
132
|
if (!bytes) return "";
|
|
99
133
|
const mb = bytes / (1024 * 1024);
|
|
@@ -363,6 +397,7 @@ document.addEventListener("click", () => {
|
|
|
363
397
|
|
|
364
398
|
let channel = null;
|
|
365
399
|
let channels = [];
|
|
400
|
+
let unreadChannels = new Set();
|
|
366
401
|
|
|
367
402
|
let uploader = localStorage.getItem("name") || "";
|
|
368
403
|
|
|
@@ -379,7 +414,7 @@ async function initName() {
|
|
|
379
414
|
uploader = prompt("Your name?", suggested) || suggested;
|
|
380
415
|
localStorage.setItem("name", uploader);
|
|
381
416
|
}
|
|
382
|
-
document.getElementById("who").innerText =
|
|
417
|
+
document.getElementById("who").innerText = uploader;
|
|
383
418
|
socket.emit("join", uploader);
|
|
384
419
|
}
|
|
385
420
|
|
|
@@ -400,16 +435,36 @@ function setChannel(c) {
|
|
|
400
435
|
pendingDeleteEl = null;
|
|
401
436
|
hideUndoToast();
|
|
402
437
|
}
|
|
438
|
+
unreadChannels.delete(c); // clear unread when switching to channel
|
|
403
439
|
channel = c;
|
|
404
440
|
renderChannels();
|
|
405
441
|
highlight();
|
|
406
442
|
load();
|
|
407
443
|
}
|
|
408
444
|
|
|
409
|
-
async function load() {
|
|
410
|
-
|
|
445
|
+
async function load(resetPage = true) {
|
|
446
|
+
if (resetPage) currentPage = 1;
|
|
447
|
+
|
|
448
|
+
const res = await fetch(`/items/${channel}?page=${currentPage}`);
|
|
411
449
|
const data = await res.json();
|
|
412
|
-
|
|
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);
|
|
413
468
|
}
|
|
414
469
|
|
|
415
470
|
function render(data) {
|
|
@@ -496,6 +551,74 @@ data-value="${i.type === 'file'
|
|
|
496
551
|
});
|
|
497
552
|
}
|
|
498
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
|
+
|
|
499
622
|
function renderGrouped(data) {
|
|
500
623
|
const el = document.getElementById("items");
|
|
501
624
|
el.innerHTML = "";
|
|
@@ -625,7 +748,7 @@ function changeName() {
|
|
|
625
748
|
if (!n) return;
|
|
626
749
|
uploader = n;
|
|
627
750
|
localStorage.setItem("name", n);
|
|
628
|
-
document.getElementById("who").innerText =
|
|
751
|
+
document.getElementById("who").innerText = uploader;
|
|
629
752
|
}
|
|
630
753
|
|
|
631
754
|
let qrLoaded = false;
|
|
@@ -719,6 +842,18 @@ function hideUndoToast() {
|
|
|
719
842
|
progress.classList.remove("running");
|
|
720
843
|
}
|
|
721
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
|
+
|
|
722
857
|
async function pin(id) {
|
|
723
858
|
await fetch("/pin/" + id, { method: "POST" });
|
|
724
859
|
load();
|
|
@@ -731,7 +866,14 @@ async function logout() {
|
|
|
731
866
|
}
|
|
732
867
|
|
|
733
868
|
socket.on("new-item", item => {
|
|
734
|
-
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
|
+
}
|
|
735
877
|
});
|
|
736
878
|
|
|
737
879
|
socket.on("delete-item", id => {
|
|
@@ -747,6 +889,11 @@ socket.on("delete-item", id => {
|
|
|
747
889
|
}
|
|
748
890
|
});
|
|
749
891
|
|
|
892
|
+
socket.on("user-count", count => {
|
|
893
|
+
const el = document.getElementById("onlineCount");
|
|
894
|
+
if (el) el.textContent = `● ${count} online`;
|
|
895
|
+
});
|
|
896
|
+
|
|
750
897
|
socket.on("item-moved", ({ id, channel: toChannel }) => {
|
|
751
898
|
if (toChannel !== channel) {
|
|
752
899
|
// item left this channel — remove it from view
|
|
@@ -793,15 +940,17 @@ document.getElementById("search").addEventListener("input", async e => {
|
|
|
793
940
|
const q = e.target.value.trim();
|
|
794
941
|
|
|
795
942
|
if (!q) {
|
|
943
|
+
document.getElementById("loadMoreWrapper").style.display = "none";
|
|
796
944
|
highlight();
|
|
797
945
|
load();
|
|
798
946
|
return;
|
|
799
947
|
}
|
|
800
948
|
|
|
801
|
-
// remove active tab highlight while searching
|
|
802
949
|
document.querySelectorAll(".channels button")
|
|
803
950
|
.forEach(b => b.classList.remove("active"));
|
|
804
951
|
|
|
952
|
+
document.getElementById("loadMoreWrapper").style.display = "none";
|
|
953
|
+
|
|
805
954
|
const res = await fetch(`/search/${q}`);
|
|
806
955
|
const data = await res.json();
|
|
807
956
|
renderGrouped(data);
|
|
@@ -976,6 +1125,12 @@ function renderChannels() {
|
|
|
976
1125
|
btn.appendChild(dot);
|
|
977
1126
|
}
|
|
978
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
|
+
|
|
979
1134
|
const more = document.createElement("span");
|
|
980
1135
|
more.className = "ch-more";
|
|
981
1136
|
more.innerText = "⋯";
|
package/package.json
CHANGED
package/server/server.js
CHANGED
|
@@ -366,16 +366,47 @@ app.post("/pin/:id", (req, res) => {
|
|
|
366
366
|
/* GET ITEMS */
|
|
367
367
|
app.get("/items/:channel", (req, res) => {
|
|
368
368
|
const channel = req.params.channel;
|
|
369
|
+
const page = parseInt(req.query.page) || 1;
|
|
370
|
+
const limit = 10;
|
|
371
|
+
const offset = (page - 1) * limit;
|
|
369
372
|
|
|
370
|
-
db.
|
|
371
|
-
`SELECT * FROM items WHERE channel=?
|
|
373
|
+
db.get(
|
|
374
|
+
`SELECT COUNT(*) as count FROM items WHERE channel=? AND pinned=0`,
|
|
372
375
|
[channel],
|
|
373
|
-
(err,
|
|
374
|
-
res.json(
|
|
376
|
+
(err, row) => {
|
|
377
|
+
if (err) return res.status(500).json({ error: "DB error" });
|
|
378
|
+
|
|
379
|
+
const totalUnpinned = row.count;
|
|
380
|
+
const hasMore = offset + limit < totalUnpinned;
|
|
381
|
+
|
|
382
|
+
db.all(
|
|
383
|
+
`SELECT * FROM items WHERE channel=? AND pinned=0
|
|
384
|
+
ORDER BY created_at DESC LIMIT ? OFFSET ?`,
|
|
385
|
+
[channel, limit, offset],
|
|
386
|
+
(err, unpinned) => {
|
|
387
|
+
if (err) return res.status(500).json({ error: "DB error" });
|
|
388
|
+
|
|
389
|
+
if (page === 1) {
|
|
390
|
+
// only fetch pinned on first page
|
|
391
|
+
db.all(
|
|
392
|
+
`SELECT * FROM items WHERE channel=? AND pinned=1
|
|
393
|
+
ORDER BY created_at DESC`,
|
|
394
|
+
[channel],
|
|
395
|
+
(err, pinned) => {
|
|
396
|
+
if (err) return res.status(500).json({ error: "DB error" });
|
|
397
|
+
res.json({ items: [...pinned, ...unpinned], hasMore, page });
|
|
398
|
+
}
|
|
399
|
+
);
|
|
400
|
+
} else {
|
|
401
|
+
res.json({ items: unpinned, hasMore, page });
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
);
|
|
375
405
|
}
|
|
376
406
|
);
|
|
377
407
|
});
|
|
378
408
|
|
|
409
|
+
|
|
379
410
|
/* SEARCH */
|
|
380
411
|
app.get("/search/:channel/:q", (req, res) => {
|
|
381
412
|
const { channel, q } = req.params;
|
|
@@ -624,6 +655,7 @@ let connectedUsers = 0;
|
|
|
624
655
|
|
|
625
656
|
io.on("connection", (socket) => {
|
|
626
657
|
connectedUsers++;
|
|
658
|
+
io.emit("user-count", connectedUsers);
|
|
627
659
|
|
|
628
660
|
let username = "Unknown";
|
|
629
661
|
|
|
@@ -635,6 +667,7 @@ io.on("connection", (socket) => {
|
|
|
635
667
|
socket.on("disconnect", () => {
|
|
636
668
|
connectedUsers--;
|
|
637
669
|
console.log(username + " disconnected | total:", connectedUsers);
|
|
670
|
+
io.emit("user-count", connectedUsers);
|
|
638
671
|
});
|
|
639
672
|
});
|
|
640
673
|
|
|
@@ -696,3 +729,33 @@ findFreePort(PREFERRED).then(p => {
|
|
|
696
729
|
console.log("");
|
|
697
730
|
});
|
|
698
731
|
});
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
// ========================
|
|
735
|
+
// GRACEFUL SHUTDOWN
|
|
736
|
+
// ========================
|
|
737
|
+
function shutdown(signal) {
|
|
738
|
+
console.log(`\n${signal} received — shutting down gracefully...`);
|
|
739
|
+
|
|
740
|
+
// stop accepting new connections
|
|
741
|
+
server.close(() => {
|
|
742
|
+
console.log("HTTP server closed");
|
|
743
|
+
|
|
744
|
+
// close database connection
|
|
745
|
+
db.close((err) => {
|
|
746
|
+
if (err) console.error("Error closing database:", err);
|
|
747
|
+
else console.log("Database connection closed");
|
|
748
|
+
console.log("Shutdown complete");
|
|
749
|
+
process.exit(0);
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// force exit after 10 seconds if something hangs
|
|
754
|
+
setTimeout(() => {
|
|
755
|
+
console.error("Forced shutdown after timeout");
|
|
756
|
+
process.exit(1);
|
|
757
|
+
}, 10000);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
761
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|