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 +14 -0
- package/client/assets/favicon-16.png +0 -0
- package/client/assets/favicon.png +0 -0
- package/client/css/app.css +660 -0
- package/client/index.html +8 -1
- package/client/js/app.js +281 -43
- package/package.json +4 -3
- package/server/server.js +39 -9
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
|
package/client/css/app.css
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
705
|
-
|
|
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
|
-
|
|
709
|
-
|
|
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
|
-
|
|
714
|
-
|
|
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
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
-
|
|
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
|
-
};
|
|
856
|
+
const xhr = new XMLHttpRequest();
|
|
857
|
+
xhr.open("POST", "/upload", true);
|
|
733
858
|
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
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
|
|
926
|
-
if (!
|
|
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
|
-
|
|
929
|
-
|
|
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.
|
|
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": "
|
|
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 (?)", [
|
|
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,
|
|
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=?", [
|
|
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=?", [
|
|
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;
|