instbyte 1.6.2 → 1.7.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 CHANGED
@@ -167,6 +167,20 @@ The difference between *a tool you use* and *a tool you own.*
167
167
 
168
168
  **QR join** — built-in QR code so phones can join instantly without typing the URL.
169
169
 
170
+ **Dark mode** — follows system preference automatically. Override with the toggle in the header.
171
+
172
+ ---
173
+
174
+ ## Keyboard Shortcuts
175
+
176
+ | Key | Action |
177
+ |---|---|
178
+ | `/` | Focus search |
179
+ | `Escape` | Close previews, menus, or blur input |
180
+ | `Ctrl/Cmd + Enter` | Send message |
181
+ | `Ctrl/Cmd + K` | Jump to message input |
182
+ | `Tab` | Cycle channels |
183
+
170
184
  ---
171
185
 
172
186
  ## Manual / Self-hosted from Source
Binary file
Binary file
@@ -188,6 +188,7 @@ input[type=text]:focus {
188
188
 
189
189
  .logo {
190
190
  height: 24px;
191
+ filter: none;
191
192
  }
192
193
 
193
194
  .app-name {
@@ -686,6 +687,27 @@ input[type=text]:focus {
686
687
  padding: 60px 20px;
687
688
  }
688
689
 
690
+ .theme-toggle {
691
+ font-size: 15px;
692
+ padding: 2px 6px;
693
+ border-radius: 6px;
694
+ transition: background 0.15s ease;
695
+ }
696
+
697
+ .theme-toggle:hover {
698
+ background: #f3f4f6;
699
+ }
700
+
701
+ @media (prefers-color-scheme: dark) {
702
+ :root:not([data-theme="light"]) .theme-toggle:hover {
703
+ background: #2d3148;
704
+ }
705
+ }
706
+
707
+ :root[data-theme="dark"] .theme-toggle:hover {
708
+ background: #2d3148;
709
+ }
710
+
689
711
  @media (max-width: 640px) {
690
712
  .app-header {
691
713
  flex-wrap: wrap;
@@ -810,4 +832,642 @@ input[type=text]:focus {
810
832
  padding: 6px 12px;
811
833
  font-size: 12px;
812
834
  }
835
+ }
836
+
837
+ @media (prefers-color-scheme: dark) {
838
+ :root:not([data-theme="light"]) {
839
+
840
+ body {
841
+ background: #0f1117;
842
+ }
843
+
844
+ .app-header {
845
+ background: #1a1d27;
846
+ border-bottom-color: #2d3148;
847
+ }
848
+
849
+ .app-name {
850
+ color: #f3f4f6;
851
+ }
852
+
853
+ .search-wrapper input {
854
+ background: #252836;
855
+ border-color: #2d3148;
856
+ color: #f3f4f6;
857
+ }
858
+
859
+ .search-wrapper input:focus {
860
+ background: #2d3148;
861
+ border-color: #4b5563;
862
+ }
863
+
864
+ .search-wrapper input::placeholder {
865
+ color: #6b7280;
866
+ }
867
+
868
+ .search-icon {
869
+ color: #6b7280;
870
+ }
871
+
872
+ .logo {
873
+ filter: invert(1) brightness(1.2);
874
+ }
875
+
876
+ .link-btn {
877
+ color: #9ca3af;
878
+ }
879
+
880
+ .link-btn:hover {
881
+ color: #f3f4f6;
882
+ }
883
+
884
+ .channel-bar {
885
+ background: #1a1d27;
886
+ }
887
+
888
+ .channel-bar::after {
889
+ background: linear-gradient(to right, transparent, #1a1d27);
890
+ }
891
+
892
+ .channels button {
893
+ color: #9ca3af;
894
+ }
895
+
896
+ .channels button:hover {
897
+ background: #2d3148;
898
+ color: #f3f4f6;
899
+ }
900
+
901
+ .channels button.active {
902
+ background: #252836;
903
+ color: #f3f4f6;
904
+ }
905
+
906
+ .add-channel {
907
+ color: #9ca3af;
908
+ }
909
+
910
+ .add-channel:hover {
911
+ background: #2d3148;
912
+ }
913
+
914
+ .add-channel-mobile {
915
+ color: #9ca3af;
916
+ border-left-color: #2d3148;
917
+ }
918
+
919
+ .add-channel-mobile:hover {
920
+ background: #2d3148;
921
+ color: #f3f4f6;
922
+ }
923
+
924
+ .composer {
925
+ background: #1a1d27;
926
+ }
927
+
928
+ input[type=text] {
929
+ background: #252836;
930
+ border-color: #2d3148;
931
+ color: #f3f4f6;
932
+ }
933
+
934
+ input[type=text]::placeholder {
935
+ color: #6b7280;
936
+ }
937
+
938
+ .drop {
939
+ background: #1a1d27;
940
+ border-color: #2d3148;
941
+ color: #6b7280;
942
+ }
943
+
944
+ .item {
945
+ background: #1a1d27;
946
+ }
947
+
948
+ .item:hover {
949
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
950
+ }
951
+
952
+ .item a {
953
+ color: #60a5fa;
954
+ }
955
+
956
+ .meta {
957
+ color: #6b7280;
958
+ }
959
+
960
+ .preview-panel {
961
+ border-top-color: #2d3148;
962
+ }
963
+
964
+ .preview-panel embed,
965
+ .preview-panel iframe {
966
+ background: #252836;
967
+ }
968
+
969
+ .preview-panel pre {
970
+ background: #0f1117;
971
+ border-color: #2d3148;
972
+ color: #e5e7eb;
973
+ }
974
+
975
+ .preview-truncated,
976
+ .preview-error,
977
+ .preview-loading {
978
+ color: #6b7280;
979
+ }
980
+
981
+ .icon-btn {
982
+ background: #252836;
983
+ border-color: #2d3148;
984
+ color: #9ca3af;
985
+ }
986
+
987
+ .icon-btn:hover {
988
+ background: #2d3148;
989
+ }
990
+
991
+ .icon-btn.delete {
992
+ background: #1a1d27;
993
+ border-color: #7f1d1d;
994
+ color: #f87171;
995
+ }
996
+
997
+ .icon-btn.delete:hover {
998
+ background: #2d1515;
999
+ }
1000
+
1001
+ .icon-btn.preview-active {
1002
+ background: #1e3a5f;
1003
+ border-color: #2563eb;
1004
+ color: #60a5fa;
1005
+ }
1006
+
1007
+ .move-dropdown {
1008
+ background: #1a1d27;
1009
+ border-color: #2d3148;
1010
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
1011
+ }
1012
+
1013
+ .move-dropdown button {
1014
+ color: #d1d5db;
1015
+ }
1016
+
1017
+ .move-dropdown button:hover {
1018
+ background: #252836;
1019
+ color: #f3f4f6;
1020
+ }
1021
+
1022
+ .move-dropdown .dropdown-label {
1023
+ color: #6b7280;
1024
+ }
1025
+
1026
+ .context-menu {
1027
+ background: #1a1d27;
1028
+ border-color: #2d3148;
1029
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
1030
+ }
1031
+
1032
+ .context-menu button {
1033
+ color: #d1d5db;
1034
+ }
1035
+
1036
+ .context-menu button:hover {
1037
+ background: #252836;
1038
+ }
1039
+
1040
+ .context-menu button.danger {
1041
+ color: #f87171;
1042
+ }
1043
+
1044
+ .context-menu button.danger:hover {
1045
+ background: #2d1515;
1046
+ }
1047
+
1048
+ .context-menu button.muted {
1049
+ color: #4b5563;
1050
+ }
1051
+
1052
+ .context-menu .menu-divider {
1053
+ background: #2d3148;
1054
+ }
1055
+
1056
+ .qr-card {
1057
+ background: #1a1d27;
1058
+ border-color: #2d3148;
1059
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
1060
+ }
1061
+
1062
+ .qr-card .qr-label {
1063
+ color: #f3f4f6;
1064
+ }
1065
+
1066
+ .qr-card .qr-url {
1067
+ color: #9ca3af;
1068
+ }
1069
+
1070
+ .markdown-body {
1071
+ color: #e5e7eb;
1072
+ }
1073
+
1074
+ .markdown-body code {
1075
+ background: #252836;
1076
+ color: #e5e7eb;
1077
+ }
1078
+
1079
+ .markdown-body pre {
1080
+ background: #0f1117;
1081
+ border-color: #2d3148;
1082
+ }
1083
+
1084
+ .markdown-body a {
1085
+ color: #60a5fa;
1086
+ }
1087
+
1088
+ .size-tag.warn {
1089
+ background: #422006;
1090
+ color: #fbbf24;
1091
+ }
1092
+
1093
+ .size-tag.danger-light {
1094
+ background: #2d1515;
1095
+ color: #f87171;
1096
+ }
1097
+
1098
+ .left.flash {
1099
+ background: #064e3b;
1100
+ }
1101
+
1102
+ .left:hover::after {
1103
+ background: #374151;
1104
+ color: #f3f4f6;
1105
+ }
1106
+
1107
+ .empty-state {
1108
+ color: #4b5563;
1109
+ }
1110
+
1111
+ .upload-status {
1112
+ background: #1a1d27 !important;
1113
+ }
1114
+ }
1115
+ }
1116
+
1117
+ :root[data-theme="dark"] {
1118
+
1119
+ body {
1120
+ background: #0f1117;
1121
+ }
1122
+
1123
+ .app-header {
1124
+ background: #1a1d27;
1125
+ border-bottom-color: #2d3148;
1126
+ }
1127
+
1128
+ .app-name {
1129
+ color: #f3f4f6;
1130
+ }
1131
+
1132
+ .search-wrapper input {
1133
+ background: #252836;
1134
+ border-color: #2d3148;
1135
+ color: #f3f4f6;
1136
+ }
1137
+
1138
+ .search-wrapper input:focus {
1139
+ background: #2d3148;
1140
+ border-color: #4b5563;
1141
+ }
1142
+
1143
+ .search-wrapper input::placeholder {
1144
+ color: #6b7280;
1145
+ }
1146
+
1147
+ .search-icon {
1148
+ color: #6b7280;
1149
+ }
1150
+
1151
+ .logo {
1152
+ filter: invert(1) brightness(1.2);
1153
+ }
1154
+
1155
+ .link-btn {
1156
+ color: #9ca3af;
1157
+ }
1158
+
1159
+ .link-btn:hover {
1160
+ color: #f3f4f6;
1161
+ }
1162
+
1163
+ .channel-bar {
1164
+ background: #1a1d27;
1165
+ }
1166
+
1167
+ .channel-bar::after {
1168
+ background: linear-gradient(to right, transparent, #1a1d27);
1169
+ }
1170
+
1171
+ .channels button {
1172
+ color: #9ca3af;
1173
+ }
1174
+
1175
+ .channels button:hover {
1176
+ background: #2d3148;
1177
+ color: #f3f4f6;
1178
+ }
1179
+
1180
+ .channels button.active {
1181
+ background: #252836;
1182
+ color: #f3f4f6;
1183
+ }
1184
+
1185
+ .add-channel {
1186
+ color: #9ca3af;
1187
+ }
1188
+
1189
+ .header-right span {
1190
+ color: #f3f4f6;
1191
+ }
1192
+
1193
+ .add-channel:hover {
1194
+ background: #2d3148;
1195
+ }
1196
+
1197
+ .add-channel-mobile {
1198
+ color: #9ca3af;
1199
+ border-left-color: #2d3148;
1200
+ }
1201
+
1202
+ .add-channel-mobile:hover {
1203
+ background: #2d3148;
1204
+ color: #f3f4f6;
1205
+ }
1206
+
1207
+ .composer {
1208
+ background: #1a1d27;
1209
+ }
1210
+
1211
+ input[type=text] {
1212
+ background: #252836;
1213
+ border-color: #2d3148;
1214
+ color: #f3f4f6;
1215
+ }
1216
+
1217
+ input[type=text]::placeholder {
1218
+ color: #6b7280;
1219
+ }
1220
+
1221
+ .drop {
1222
+ background: #1a1d27;
1223
+ border-color: #2d3148;
1224
+ color: #6b7280;
1225
+ }
1226
+
1227
+ .item {
1228
+ background: #1a1d27;
1229
+ }
1230
+
1231
+ .item:hover {
1232
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
1233
+ }
1234
+
1235
+ .item a {
1236
+ color: #60a5fa;
1237
+ }
1238
+
1239
+ .meta {
1240
+ color: #6b7280;
1241
+ }
1242
+
1243
+ .preview-panel {
1244
+ border-top-color: #2d3148;
1245
+ }
1246
+
1247
+ .preview-panel embed,
1248
+ .preview-panel iframe {
1249
+ background: #252836;
1250
+ }
1251
+
1252
+ .preview-panel pre {
1253
+ background: #0f1117;
1254
+ border-color: #2d3148;
1255
+ color: #e5e7eb;
1256
+ }
1257
+
1258
+ .preview-truncated,
1259
+ .preview-error,
1260
+ .preview-loading {
1261
+ color: #6b7280;
1262
+ }
1263
+
1264
+ .icon-btn {
1265
+ background: #252836;
1266
+ border-color: #2d3148;
1267
+ color: #9ca3af;
1268
+ }
1269
+
1270
+ .icon-btn:hover {
1271
+ background: #2d3148;
1272
+ }
1273
+
1274
+ .icon-btn.delete {
1275
+ background: #1a1d27;
1276
+ border-color: #7f1d1d;
1277
+ color: #f87171;
1278
+ }
1279
+
1280
+ .icon-btn.delete:hover {
1281
+ background: #2d1515;
1282
+ }
1283
+
1284
+ .icon-btn.preview-active {
1285
+ background: #1e3a5f;
1286
+ border-color: #2563eb;
1287
+ color: #60a5fa;
1288
+ }
1289
+
1290
+ .move-dropdown {
1291
+ background: #1a1d27;
1292
+ border-color: #2d3148;
1293
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
1294
+ }
1295
+
1296
+ .move-dropdown button {
1297
+ color: #d1d5db;
1298
+ }
1299
+
1300
+ .move-dropdown button:hover {
1301
+ background: #252836;
1302
+ color: #f3f4f6;
1303
+ }
1304
+
1305
+ .move-dropdown .dropdown-label {
1306
+ color: #6b7280;
1307
+ }
1308
+
1309
+ .context-menu {
1310
+ background: #1a1d27;
1311
+ border-color: #2d3148;
1312
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
1313
+ }
1314
+
1315
+ .context-menu button {
1316
+ color: #d1d5db;
1317
+ }
1318
+
1319
+ .context-menu button:hover {
1320
+ background: #252836;
1321
+ }
1322
+
1323
+ .context-menu button.danger {
1324
+ color: #f87171;
1325
+ }
1326
+
1327
+ .context-menu button.danger:hover {
1328
+ background: #2d1515;
1329
+ }
1330
+
1331
+ .context-menu button.muted {
1332
+ color: #4b5563;
1333
+ }
1334
+
1335
+ .context-menu .menu-divider {
1336
+ background: #2d3148;
1337
+ }
1338
+
1339
+ .qr-card {
1340
+ background: #1a1d27;
1341
+ border-color: #2d3148;
1342
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
1343
+ }
1344
+
1345
+ .qr-card .qr-label {
1346
+ color: #f3f4f6;
1347
+ }
1348
+
1349
+ .qr-card .qr-url {
1350
+ color: #9ca3af;
1351
+ }
1352
+
1353
+ .markdown-body {
1354
+ color: #e5e7eb;
1355
+ }
1356
+
1357
+ .markdown-body code {
1358
+ background: #252836;
1359
+ color: #e5e7eb;
1360
+ }
1361
+
1362
+ .markdown-body pre {
1363
+ background: #0f1117;
1364
+ border-color: #2d3148;
1365
+ }
1366
+
1367
+ .markdown-body a {
1368
+ color: #60a5fa;
1369
+ }
1370
+
1371
+ .size-tag.warn {
1372
+ background: #422006;
1373
+ color: #fbbf24;
1374
+ }
1375
+
1376
+ .size-tag.danger-light {
1377
+ background: #2d1515;
1378
+ color: #f87171;
1379
+ }
1380
+
1381
+ .left.flash {
1382
+ background: #064e3b;
1383
+ }
1384
+
1385
+ .left:hover::after {
1386
+ background: #374151;
1387
+ color: #f3f4f6;
1388
+ }
1389
+
1390
+ .empty-state {
1391
+ color: #4b5563;
1392
+ }
1393
+
1394
+ .upload-status {
1395
+ background: #1a1d27 !important;
1396
+ }
1397
+ }
1398
+
1399
+ .undo-toast {
1400
+ position: fixed;
1401
+ bottom: 30px;
1402
+ left: 50%;
1403
+ transform: translateX(-50%) translateY(80px);
1404
+ background: #1f2937;
1405
+ color: #f3f4f6;
1406
+ padding: 12px 16px;
1407
+ border-radius: 10px;
1408
+ font-size: 13px;
1409
+ display: flex;
1410
+ align-items: center;
1411
+ gap: 12px;
1412
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
1413
+ z-index: 9999;
1414
+ opacity: 0;
1415
+ transition: transform 0.2s ease, opacity 0.2s ease;
1416
+ min-width: 220px;
1417
+ overflow: hidden;
1418
+ }
1419
+
1420
+ .undo-toast.show {
1421
+ transform: translateX(-50%) translateY(0);
1422
+ opacity: 1;
1423
+ }
1424
+
1425
+ .undo-toast #undoBtn {
1426
+ background: none;
1427
+ border: 1px solid #4b5563;
1428
+ color: #f3f4f6;
1429
+ padding: 4px 10px;
1430
+ border-radius: 6px;
1431
+ font-size: 12px;
1432
+ cursor: pointer;
1433
+ flex-shrink: 0;
1434
+ }
1435
+
1436
+ .undo-toast #undoBtn:hover {
1437
+ background: #374151;
1438
+ }
1439
+
1440
+ .undo-progress {
1441
+ position: absolute;
1442
+ bottom: 0;
1443
+ left: 0;
1444
+ height: 3px;
1445
+ width: 100%;
1446
+ background: #4b5563;
1447
+ transform-origin: left;
1448
+ animation: none;
1449
+ }
1450
+
1451
+ .undo-progress.running {
1452
+ animation: drain 5s linear forwards;
1453
+ }
1454
+
1455
+ @keyframes drain {
1456
+ from {
1457
+ transform: scaleX(1);
1458
+ }
1459
+
1460
+ to {
1461
+ transform: scaleX(0);
1462
+ }
1463
+ }
1464
+
1465
+ @media (prefers-color-scheme: dark) {
1466
+ :root:not([data-theme="light"]) .undo-toast {
1467
+ background: #374151;
1468
+ }
1469
+ }
1470
+
1471
+ :root[data-theme="dark"] .undo-toast {
1472
+ background: #374151;
813
1473
  }
package/client/index.html CHANGED
@@ -27,6 +27,7 @@
27
27
  <div class="header-right">
28
28
  <span id="who"></span>
29
29
  <button onclick="changeName()" class="link-btn">change</button>
30
+ <button id="themeToggle" onclick="cycleTheme()">🌙</button>
30
31
  <button id="logoutBtn" onclick="logout()" class="link-btn">logout</button>
31
32
  </div>
32
33
  </header>
@@ -41,7 +42,7 @@
41
42
  <div class="composer">
42
43
  <input id="msg" type="text" placeholder="Type message or paste link" onkeydown="handleEnter(event)" />
43
44
  <button onclick="sendText()">Send</button>
44
- <input type="file" id="fileInput" hidden>
45
+ <input type="file" id="fileInput" hidden multiple>
45
46
  <button onclick="fileInput.click()">Upload</button>
46
47
  </div>
47
48
  <div class="drop" id="drop">Drag files anywhere to upload</div>
@@ -71,6 +72,12 @@
71
72
 
72
73
  <div class="context-menu" id="channelMenu"></div>
73
74
 
75
+ <div id="undoToast" class="undo-toast">
76
+ <span id="undoMsg">Item deleted</span>
77
+ <button id="undoBtn" onclick="undoDelete()">Undo</button>
78
+ <div id="undoProgress" class="undo-progress"></div>
79
+ </div>
80
+
74
81
  <script src="/socket.io/socket.io.js"></script>
75
82
  <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js"></script>
76
83
  <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,60 @@
1
1
  const socket = io();
2
2
 
3
+ // ========================
4
+ // THEME MANAGEMENT (FIXED)
5
+ // ========================
6
+ const THEME_KEY = "instbyte_theme";
7
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
8
+
9
+ function getStoredTheme() {
10
+ return localStorage.getItem(THEME_KEY) || "auto";
11
+ }
12
+
13
+ function applyTheme(theme) {
14
+ const root = document.documentElement;
15
+ const btn = document.getElementById("themeToggle");
16
+
17
+ // Apply attribute EXACTLY
18
+ if (theme === "dark") {
19
+ root.setAttribute("data-theme", "dark");
20
+ } else if (theme === "light") {
21
+ root.setAttribute("data-theme", "light");
22
+ } else {
23
+ root.removeAttribute("data-theme"); // auto
24
+ }
25
+
26
+ if (!btn) return;
27
+
28
+ // Update icon based on CURRENT stored state
29
+ if (theme === "dark") btn.textContent = "☀️";
30
+ else if (theme === "light") btn.textContent = "🌙";
31
+ else {
32
+ btn.textContent = mq.matches ? "☀️" : "🌙";
33
+ }
34
+ }
35
+
36
+ function cycleTheme() {
37
+ const current = getStoredTheme();
38
+
39
+ let next;
40
+ if (current === "dark") next = "light";
41
+ else if (current === "light") next = "dark";
42
+ else next = "dark"; // auto → dark first
43
+
44
+ localStorage.setItem(THEME_KEY, next);
45
+ applyTheme(next);
46
+ }
47
+
48
+ // INITIAL LOAD
49
+ applyTheme(getStoredTheme());
50
+
51
+ // OS change listener (only when auto)
52
+ mq.addEventListener("change", () => {
53
+ if (getStoredTheme() === "auto") {
54
+ applyTheme("auto");
55
+ }
56
+ });
57
+
3
58
  async function applyBranding() {
4
59
  try {
5
60
  const res = await fetch("/branding");
@@ -249,6 +304,10 @@ document.getElementById("items").addEventListener("click", e => {
249
304
 
250
305
  let openDropdown = null;
251
306
 
307
+ let pendingDeleteId = null;
308
+ let pendingDeleteTimer = null;
309
+ let pendingDeleteEl = null;
310
+
252
311
  function toggleMoveDropdown(e, id, currentChannel) {
253
312
  e.stopPropagation();
254
313
 
@@ -332,6 +391,15 @@ function highlight() {
332
391
  }
333
392
 
334
393
  function setChannel(c) {
394
+ // flush any pending delete before switching
395
+ if (pendingDeleteId !== null) {
396
+ clearTimeout(pendingDeleteTimer);
397
+ fetch("/item/" + pendingDeleteId, { method: "DELETE" });
398
+ pendingDeleteId = null;
399
+ pendingDeleteTimer = null;
400
+ pendingDeleteEl = null;
401
+ hideUndoToast();
402
+ }
335
403
  channel = c;
336
404
  renderChannels();
337
405
  highlight();
@@ -356,6 +424,7 @@ function render(data) {
356
424
  data.forEach(i => {
357
425
  const div = document.createElement("div");
358
426
  div.className = "item";
427
+ div.dataset.itemId = i.id;
359
428
 
360
429
  let content = "";
361
430
 
@@ -456,6 +525,7 @@ function renderGrouped(data) {
456
525
  grouped[ch].forEach(i => {
457
526
  const div = document.createElement("div");
458
527
  div.className = "item";
528
+ div.dataset.itemId = i.id;
459
529
 
460
530
  let content = "";
461
531
 
@@ -544,7 +614,7 @@ async function sendText() {
544
614
  }
545
615
 
546
616
  function handleEnter(e) {
547
- if (e.key === "Enter" && !e.shiftKey) {
617
+ if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
548
618
  e.preventDefault();
549
619
  sendText();
550
620
  }
@@ -584,16 +654,69 @@ document.addEventListener("click", e => {
584
654
  const fileInput = document.getElementById("fileInput");
585
655
 
586
656
  fileInput.onchange = () => {
587
- const file = fileInput.files[0];
588
- if (file) uploadFile(file);
657
+ if (fileInput.files.length) uploadFiles(fileInput.files);
589
658
  };
590
659
 
591
660
  async function del(id, pinned) {
661
+ // pinned items keep confirm dialog
592
662
  if (pinned) {
593
663
  const confirmed = confirm("This item is pinned. Are you sure you want to delete it?");
594
664
  if (!confirmed) return;
665
+ await fetch("/item/" + id, { method: "DELETE" });
666
+ return;
667
+ }
668
+
669
+ // if another delete is pending, execute it immediately (don't await — fire and forget)
670
+ if (pendingDeleteId !== null) {
671
+ clearTimeout(pendingDeleteTimer);
672
+ fetch("/item/" + pendingDeleteId, { method: "DELETE" });
673
+ pendingDeleteId = null;
674
+ pendingDeleteTimer = null;
595
675
  }
596
- await fetch("/item/" + id, { method: "DELETE" });
676
+
677
+ // optimistically remove from UI
678
+ const el = document.querySelector(`[data-item-id="${id}"]`);
679
+ if (el) {
680
+ pendingDeleteEl = el.outerHTML;
681
+ el.remove();
682
+ }
683
+
684
+ pendingDeleteId = id;
685
+ showUndoToast();
686
+
687
+ pendingDeleteTimer = setTimeout(async () => {
688
+ await fetch("/item/" + pendingDeleteId, { method: "DELETE" });
689
+ pendingDeleteId = null;
690
+ pendingDeleteTimer = null;
691
+ pendingDeleteEl = null;
692
+ hideUndoToast();
693
+ }, 5000);
694
+ }
695
+
696
+ function undoDelete() {
697
+ if (pendingDeleteId === null) return;
698
+ clearTimeout(pendingDeleteTimer);
699
+ pendingDeleteId = null;
700
+ pendingDeleteTimer = null;
701
+ pendingDeleteEl = null;
702
+ hideUndoToast();
703
+ load(); // reload to restore item
704
+ }
705
+
706
+ function showUndoToast() {
707
+ const toast = document.getElementById("undoToast");
708
+ const progress = document.getElementById("undoProgress");
709
+ progress.classList.remove("running");
710
+ void progress.offsetWidth; // force reflow to restart animation
711
+ progress.classList.add("running");
712
+ toast.classList.add("show");
713
+ }
714
+
715
+ function hideUndoToast() {
716
+ const toast = document.getElementById("undoToast");
717
+ const progress = document.getElementById("undoProgress");
718
+ toast.classList.remove("show");
719
+ progress.classList.remove("running");
597
720
  }
598
721
 
599
722
  async function pin(id) {
@@ -612,7 +735,16 @@ socket.on("new-item", item => {
612
735
  });
613
736
 
614
737
  socket.on("delete-item", id => {
615
- load();
738
+ // don't reload if this item is already removed or pending
739
+ if (id == pendingDeleteId) return;
740
+ const el = document.querySelector(`[data-item-id="${id}"]`);
741
+ if (el) el.remove();
742
+
743
+ // show empty state if no items left
744
+ const items = document.getElementById("items");
745
+ if (items && !items.querySelector(".item")) {
746
+ items.innerHTML = `<div class="empty-state">Nothing here yet — paste, type, or drop a file to share</div>`;
747
+ }
616
748
  });
617
749
 
618
750
  socket.on("item-moved", ({ id, channel: toChannel }) => {
@@ -696,47 +828,63 @@ document.addEventListener("paste", async e => {
696
828
  });
697
829
  });
698
830
 
699
- function uploadFile(file) {
831
+ async function uploadFiles(files) {
832
+ if (!files || !files.length) return;
833
+
700
834
  const status = document.getElementById("uploadStatus");
701
835
  const bar = document.getElementById("uploadBar");
702
836
  const text = document.getElementById("uploadText");
703
837
 
704
- status.style.display = "block";
705
- bar.style.width = "0%";
706
- text.innerText = "Uploading: " + file.name;
838
+ const total = files.length;
839
+ const targetChannel = channel; // capture at start, won't change if user switches
707
840
 
708
- const form = new FormData();
709
- form.append("file", file);
710
- form.append("channel", channel);
711
- form.append("uploader", uploader);
841
+ for (let i = 0; i < total; i++) {
842
+ const file = files[i];
712
843
 
713
- const xhr = new XMLHttpRequest();
714
- xhr.open("POST", "/upload", true);
844
+ status.style.display = "block";
845
+ bar.style.width = "0%";
846
+ text.innerText = total > 1
847
+ ? `Uploading ${i + 1} of ${total} · ${file.name}`
848
+ : `Uploading: ${file.name}`;
715
849
 
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
- };
850
+ await new Promise((resolve) => {
851
+ const form = new FormData();
852
+ form.append("file", file);
853
+ form.append("channel", targetChannel);
854
+ form.append("uploader", uploader);
722
855
 
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
- };
856
+ const xhr = new XMLHttpRequest();
857
+ xhr.open("POST", "/upload", true);
733
858
 
734
- xhr.onerror = () => {
735
- status.style.display = "none";
736
- alert("Upload failed");
737
- };
859
+ xhr.upload.onprogress = e => {
860
+ if (e.lengthComputable) {
861
+ bar.style.width = Math.round((e.loaded / e.total) * 100) + "%";
862
+ }
863
+ };
864
+
865
+ xhr.onload = () => {
866
+ if (xhr.status === 413) {
867
+ text.innerText = `⚠ ${file.name} is too large — skipped`;
868
+ bar.style.width = "0%";
869
+ setTimeout(resolve, 1200);
870
+ return;
871
+ }
872
+ resolve();
873
+ };
874
+
875
+ xhr.onerror = () => {
876
+ text.innerText = `⚠ ${file.name} failed — skipped`;
877
+ bar.style.width = "0%";
878
+ setTimeout(resolve, 1200);
879
+ };
738
880
 
739
- xhr.send(form);
881
+ xhr.send(form);
882
+ });
883
+ }
884
+
885
+ status.style.display = "none";
886
+ fileInput.value = "";
887
+ load();
740
888
  }
741
889
 
742
890
 
@@ -922,15 +1070,105 @@ document.addEventListener("drop", async e => {
922
1070
  overlay.style.display = "none";
923
1071
  dragCounter = 0;
924
1072
 
925
- const file = e.dataTransfer.files[0];
926
- if (!file) return;
1073
+ const files = e.dataTransfer.files;
1074
+ if (!files.length) return;
1075
+ uploadFiles(files);
1076
+ });
1077
+
1078
+ // ========================
1079
+ // KEYBOARD SHORTCUTS
1080
+ // ========================
1081
+ document.addEventListener("keydown", e => {
1082
+ const active = document.activeElement;
1083
+ const isTyping = active && (active.id === "msg" || active.id === "search");
1084
+
1085
+ // Ctrl/Cmd + Enter — send message (only when msg is focused)
1086
+ if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
1087
+ if (active && active.id === "msg") {
1088
+ e.preventDefault();
1089
+ e.stopPropagation();
1090
+ sendText();
1091
+ }
1092
+ return;
1093
+ }
1094
+
1095
+ // Ctrl/Cmd + K — focus message input
1096
+ if ((e.ctrlKey || e.metaKey) && e.key === "k") {
1097
+ if (!isTyping) {
1098
+ e.preventDefault();
1099
+ document.getElementById("msg").focus();
1100
+ }
1101
+ return;
1102
+ }
1103
+
1104
+ // / — focus search
1105
+ if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
1106
+ e.preventDefault();
1107
+ const search = document.getElementById("search");
1108
+ search.focus();
1109
+ search.select();
1110
+ return;
1111
+ }
1112
+
1113
+ // Tab — cycle channels
1114
+ if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
1115
+ e.preventDefault();
1116
+ if (!channels.length) return;
1117
+ const currentIndex = channels.findIndex(c => c.name === channel);
1118
+ const nextIndex = (currentIndex + 1) % channels.length;
1119
+ setChannel(channels[nextIndex].name);
1120
+ return;
1121
+ }
1122
+
1123
+ // Escape — close state in priority order
1124
+ if (e.key === "Escape") {
1125
+
1126
+ // 1. blur any focused input first
1127
+ if (active && (active.id === "msg" || active.id === "search")) {
1128
+ if (active.id === "search" && active.value) {
1129
+ active.value = "";
1130
+ highlight();
1131
+ load();
1132
+ }
1133
+ active.blur();
1134
+ return;
1135
+ }
1136
+
1137
+ // 2. close open preview
1138
+ if (openPreviewId) {
1139
+ const panel = document.getElementById("preview-" + openPreviewId);
1140
+ const btn = document.getElementById("prevbtn-" + openPreviewId);
1141
+ if (panel) panel.classList.remove("open");
1142
+ if (btn) btn.classList.remove("preview-active");
1143
+ openPreviewId = null;
1144
+ return;
1145
+ }
1146
+
1147
+ // 3. close move dropdown
1148
+ if (openDropdown) {
1149
+ openDropdown.classList.remove("open");
1150
+ openDropdown = null;
1151
+ return;
1152
+ }
1153
+
1154
+ // 4. close context menu
1155
+ const contextMenu = document.getElementById("channelMenu");
1156
+ if (contextMenu.classList.contains("open")) {
1157
+ contextMenu.classList.remove("open");
1158
+ return;
1159
+ }
1160
+
1161
+ // 5. close QR card
1162
+ const qrCard = document.getElementById("qrCard");
1163
+ if (qrCard.classList.contains("open")) {
1164
+ qrCard.classList.remove("open");
1165
+ return;
1166
+ }
1167
+ }
927
1168
 
928
- const form = new FormData();
929
- form.append("file", file);
930
- form.append("channel", channel);
931
- form.append("uploader", uploader);
1169
+ // All remaining shortcuts — skip if typing
1170
+ if (isTyping) return;
932
1171
 
933
- uploadFile(file);
934
1172
  });
935
1173
 
936
1174
  (async function init() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instbyte",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "description": "A self-hosted LAN sharing utility for fast, frictionless file, link, and snippet exchange across devices — no cloud required.",
5
5
  "main": "server/server.js",
6
6
  "bin": {
@@ -26,7 +26,7 @@
26
26
  "socket-io",
27
27
  "sqlite"
28
28
  ],
29
- "author": "your name",
29
+ "author": "Mohit Gauniyal",
30
30
  "license": "MIT",
31
31
  "repository": {
32
32
  "type": "git",
@@ -39,9 +39,10 @@
39
39
  "cookie-parser": "^1.4.6",
40
40
  "express": "^4.18.2",
41
41
  "express-rate-limit": "^7.1.5",
42
+ "helmet": "^8.1.0",
42
43
  "multer": "^2.0.2",
43
44
  "sharp": "^0.33.2",
44
45
  "socket.io": "^4.6.1",
45
46
  "sqlite3": "^5.1.6"
46
47
  }
47
- }
48
+ }
package/server/server.js CHANGED
@@ -4,6 +4,7 @@ const os = require("os");
4
4
  const net = require("net");
5
5
  const cookieParser = require("cookie-parser");
6
6
  const rateLimit = require("express-rate-limit");
7
+ const helmet = require("helmet");
7
8
 
8
9
  let sharp = null;
9
10
  try { sharp = require("sharp"); } catch (e) { }
@@ -26,6 +27,10 @@ const app = express();
26
27
  const server = http.createServer(app);
27
28
  const io = new Server(server, { cors: { origin: "*" } });
28
29
 
30
+ app.use(helmet({
31
+ contentSecurityPolicy: false // disable CSP for now — it would block CDN scripts
32
+ }));
33
+
29
34
  app.use(express.json());
30
35
  app.use(cookieParser());
31
36
  app.use(requireAuth);
@@ -121,7 +126,7 @@ function requireAuth(req, res, next) {
121
126
  if (!config.auth.passphrase) return next(); // no passphrase set, skip
122
127
 
123
128
  // Allow the login route itself through
124
- if (req.path === "/login" || req.path === "/info") return next();
129
+ if (req.path === "/login" || req.path === "/info" || req.path === "/health") return next();
125
130
 
126
131
 
127
132
  // Check cookie
@@ -411,19 +416,27 @@ app.post("/channels", (req, res) => {
411
416
  const { name } = req.body;
412
417
  if (!name) return res.status(400).json({ error: "Name required" });
413
418
 
419
+ const trimmed = name.trim();
420
+ if (trimmed.length < 1 || trimmed.length > 32) {
421
+ return res.status(400).json({ error: "Channel name must be 1–32 characters" });
422
+ }
423
+ if (!/^[a-zA-Z0-9 _\-]+$/.test(trimmed)) {
424
+ return res.status(400).json({ error: "Only letters, numbers, spaces, hyphens, and underscores allowed" });
425
+ }
426
+
414
427
  db.get("SELECT COUNT(*) as count FROM channels", (err, row) => {
415
428
 
416
429
  if (row.count >= 10) {
417
430
  return res.status(400).json({ error: "Max 10 channels allowed" });
418
431
  }
419
432
 
420
- db.run("INSERT INTO channels (name) VALUES (?)", [name], function (err) {
433
+ db.run("INSERT INTO channels (name) VALUES (?)", [trimmed], function (err) {
421
434
 
422
435
  if (err) {
423
436
  return res.status(400).json({ error: "Channel exists" });
424
437
  }
425
- io.emit("channel-added", { id: this.lastID, name });
426
- res.json({ id: this.lastID, name });
438
+ io.emit("channel-added", { id: this.lastID, trimmed });
439
+ res.json({ id: this.lastID, name: trimmed });
427
440
 
428
441
  });
429
442
 
@@ -485,18 +498,25 @@ app.patch("/item/:id/move", (req, res) => {
485
498
  app.patch("/channels/:name", (req, res) => {
486
499
  const oldName = req.params.name;
487
500
  const { name: newName } = req.body;
488
-
489
501
  if (!newName) return res.status(400).json({ error: "Name required" });
490
502
 
503
+ const trimmed = newName.trim();
504
+ if (trimmed.length < 1 || trimmed.length > 32) {
505
+ return res.status(400).json({ error: "Channel name must be 1–32 characters" });
506
+ }
507
+ if (!/^[a-zA-Z0-9 _\-]+$/.test(trimmed)) {
508
+ return res.status(400).json({ error: "Only letters, numbers, spaces, hyphens, and underscores allowed" });
509
+ }
510
+
491
511
  db.get("SELECT * FROM channels WHERE name=?", [oldName], (err, row) => {
492
512
  if (!row) return res.status(404).json({ error: "Channel not found" });
493
513
 
494
- db.run("UPDATE channels SET name=? WHERE name=?", [newName, oldName], (err) => {
514
+ db.run("UPDATE channels SET name=? WHERE name=?", [trimmed, oldName], (err) => {
495
515
  if (err) return res.status(400).json({ error: "Channel name already exists" });
496
516
 
497
- db.run("UPDATE items SET channel=? WHERE channel=?", [newName, oldName], () => {
498
- io.emit("channel-renamed", { oldName, newName });
499
- res.json({ oldName, newName });
517
+ db.run("UPDATE items SET channel=? WHERE channel=?", [trimmed, oldName], () => {
518
+ io.emit("channel-renamed", { oldName, newName: trimmed });
519
+ res.json({ oldName, newName: trimmed });
500
520
  });
501
521
  });
502
522
  });
@@ -539,6 +559,15 @@ app.get("/branding", (req, res) => {
539
559
  });
540
560
  });
541
561
 
562
+ /* HEALTH MONITOR */
563
+ app.get("/health", (req, res) => {
564
+ res.json({
565
+ status: "ok",
566
+ uptime: Math.floor(process.uptime()),
567
+ version: require("../package.json").version
568
+ });
569
+ });
570
+
542
571
 
543
572
  /* FAVICON */
544
573
  app.get("/favicon-dynamic.png", async (req, res) => {
@@ -570,6 +599,7 @@ app.get("/favicon-dynamic.png", async (req, res) => {
570
599
  });
571
600
 
572
601
 
602
+
573
603
  /* LOGO */
574
604
  app.get("/logo-dynamic.png", (req, res) => {
575
605
  const b = config.branding;