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/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
- <button onclick="changeName()" class="link-btn">change</button>
30
- <button id="logoutBtn" onclick="logout()" class="link-btn">logout</button>
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 = "You: " + uploader;
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
- const res = await fetch("/items/" + channel);
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
- render(data);
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, '&quot;')}">
596
+ ${content}
597
+ <div class="meta">${i.uploader}</div>
598
+ </div>
599
+ <div class="item-actions">
600
+ ${getPreviewType(i.filename) !== "none" && getPreviewType(i.filename) !== "image"
601
+ ? `<button class="icon-btn" id="prevbtn-${i.id}"
602
+ onclick="togglePreview(${i.id}, '${i.filename}')"
603
+ title="Preview">👁</button>`
604
+ : ""}
605
+ <button class="icon-btn" onclick="pin(${i.id})" title="${i.pinned ? 'Unpin' : 'Pin'}">
606
+ ${i.pinned ? "📍" : "📌"}
607
+ </button>
608
+ <div class="move-wrapper">
609
+ <button class="icon-btn" title="Move to channel"
610
+ onclick="toggleMoveDropdown(event, ${i.id}, '${i.channel}')">⇄</button>
611
+ <div class="move-dropdown"></div>
612
+ </div>
613
+ <button class="icon-btn delete" onclick="del(${i.id}, ${i.pinned})" title="Delete">🗑</button>
614
+ </div>
615
+ </div>
616
+ <div class="preview-panel" id="preview-${i.id}"></div>`;
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 = "You: " + uploader;
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
- const file = fileInput.files[0];
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
- await fetch("/item/" + id, { method: "DELETE" });
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) load();
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
- load();
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 uploadFile(file) {
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
- status.style.display = "block";
705
- bar.style.width = "0%";
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
- const form = new FormData();
709
- form.append("file", file);
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
- const xhr = new XMLHttpRequest();
714
- xhr.open("POST", "/upload", true);
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
- xhr.upload.onprogress = e => {
717
- if (e.lengthComputable) {
718
- const percent = Math.round((e.loaded / e.total) * 100);
719
- bar.style.width = percent + "%";
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
- xhr.onload = () => {
724
- status.style.display = "none";
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
- xhr.onerror = () => {
735
- status.style.display = "none";
736
- alert("Upload failed");
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
- xhr.send(form);
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 file = e.dataTransfer.files[0];
926
- if (!file) return;
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
- const form = new FormData();
929
- form.append("file", file);
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() {