instbyte 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/client/css/app.css +304 -3
- package/client/index.html +3 -0
- package/client/js/app.js +471 -225
- package/package.json +1 -1
- package/server/cleanup.js +3 -4
- package/server/config.js +1 -0
- package/server/db.js +3 -0
- package/server/server.js +53 -1
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ One person runs the server — everyone else on the same WiFi opens the URL in t
|
|
|
63
63
|
|
|
64
64
|
**Sharing is instant:**
|
|
65
65
|
- Type or paste text → hit Send
|
|
66
|
-
- Paste anywhere on the page → auto-sends
|
|
66
|
+
- Paste anywhere on the page → auto-sends text or uploads images directly
|
|
67
67
|
- Drag a file anywhere onto the page → uploads
|
|
68
68
|
- Click any text item → copies to clipboard
|
|
69
69
|
- Click any file item → downloads
|
|
@@ -125,7 +125,7 @@ Instbyte works out of the box with zero configuration. All options are optional
|
|
|
125
125
|
| `server.port` | `3000` | Port to run on. Overridden by `PORT` env var if set |
|
|
126
126
|
| `auth.passphrase` | `""` | Shared passphrase for access. Empty = no auth |
|
|
127
127
|
| `storage.maxFileSize` | `"2GB"` | Max upload size. Accepts `KB`, `MB`, `GB` |
|
|
128
|
-
| `storage.retention` | `"24h"` | How long before items auto-delete. Accepts `h`, `d` |
|
|
128
|
+
| `storage.retention` | `"24h"` | How long before items auto-delete. Accepts `h`, `d`, or `"never"` to disable cleanup entirely |
|
|
129
129
|
| `branding.appName` | `"Instbyte"` | App name in header and browser tab |
|
|
130
130
|
| `branding.primaryColor` | `"#111827"` | Primary brand color in hex. Full palette auto-derived |
|
|
131
131
|
| `branding.logoPath` | — | Path to your logo file relative to where you run the command |
|
|
@@ -163,7 +163,7 @@ The difference between *a tool you use* and *a tool you own.*
|
|
|
163
163
|
|
|
164
164
|
**Smart port handling** — if port 3000 is busy, Instbyte finds the next available port automatically.
|
|
165
165
|
|
|
166
|
-
**Short-lived by design** —
|
|
166
|
+
**Short-lived by design** — content auto-deletes after 24 hours by default. Configure retention per your needs, or disable cleanup entirely.
|
|
167
167
|
|
|
168
168
|
**QR join** — built-in QR code so phones can join instantly without typing the URL.
|
|
169
169
|
|
|
@@ -175,6 +175,9 @@ The difference between *a tool you use* and *a tool you own.*
|
|
|
175
175
|
|
|
176
176
|
**Presence awareness** — see how many people are currently connected in real time.
|
|
177
177
|
|
|
178
|
+
**Read receipts** — see how many devices have viewed each shared item. Updates live as teammates open the page.
|
|
179
|
+
|
|
180
|
+
**Item management** — add optional titles to label any item for future reference. Edit text items inline without deleting and re-pasting. Pinned items are protected from both manual deletion and auto-cleanup.
|
|
178
181
|
---
|
|
179
182
|
|
|
180
183
|
## Keyboard Shortcuts
|
package/client/css/app.css
CHANGED
|
@@ -396,7 +396,8 @@ input[type=text]:focus {
|
|
|
396
396
|
|
|
397
397
|
.item-actions {
|
|
398
398
|
display: flex;
|
|
399
|
-
gap:
|
|
399
|
+
gap: 6px;
|
|
400
|
+
align-items: flex-start;
|
|
400
401
|
}
|
|
401
402
|
|
|
402
403
|
.empty-state {
|
|
@@ -443,11 +444,16 @@ input[type=text]:focus {
|
|
|
443
444
|
background: #f9fafb;
|
|
444
445
|
border: 1px solid #e5e7eb;
|
|
445
446
|
border-radius: 6px;
|
|
446
|
-
padding:
|
|
447
|
+
padding: 5px 7px;
|
|
447
448
|
cursor: pointer;
|
|
448
|
-
font-size:
|
|
449
|
+
font-size: 13px;
|
|
449
450
|
line-height: 1;
|
|
450
451
|
color: #6b7280;
|
|
452
|
+
width: 28px;
|
|
453
|
+
height: 28px;
|
|
454
|
+
display: flex;
|
|
455
|
+
align-items: center;
|
|
456
|
+
justify-content: center;
|
|
451
457
|
}
|
|
452
458
|
|
|
453
459
|
.icon-btn:hover {
|
|
@@ -707,6 +713,12 @@ input[type=text]:focus {
|
|
|
707
713
|
font-size: 12px;
|
|
708
714
|
}
|
|
709
715
|
|
|
716
|
+
.markdown-preview {
|
|
717
|
+
padding: 4px 0;
|
|
718
|
+
max-height: 500px;
|
|
719
|
+
overflow-y: auto;
|
|
720
|
+
}
|
|
721
|
+
|
|
710
722
|
.preview-truncated {
|
|
711
723
|
font-size: 11px;
|
|
712
724
|
color: #9ca3af;
|
|
@@ -881,6 +893,159 @@ input[type=text]:focus {
|
|
|
881
893
|
background: #e5e7eb;
|
|
882
894
|
}
|
|
883
895
|
|
|
896
|
+
.item-title {
|
|
897
|
+
font-size: 11px;
|
|
898
|
+
font-weight: 600;
|
|
899
|
+
color: #6b7280;
|
|
900
|
+
text-transform: uppercase;
|
|
901
|
+
letter-spacing: 0.05em;
|
|
902
|
+
margin-bottom: 4px;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
.title-input {
|
|
906
|
+
font-size: 12px;
|
|
907
|
+
padding: 3px 7px;
|
|
908
|
+
border: 1px solid #d1d5db;
|
|
909
|
+
border-radius: 5px;
|
|
910
|
+
margin-bottom: 4px;
|
|
911
|
+
width: 200px;
|
|
912
|
+
display: block;
|
|
913
|
+
color: #111827;
|
|
914
|
+
background: #fff;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
.title-input:focus {
|
|
918
|
+
outline: none;
|
|
919
|
+
border-color: var(--color-primary);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
.edit-textarea {
|
|
923
|
+
width: min(600px, 60vw);
|
|
924
|
+
min-height: 80px;
|
|
925
|
+
padding: 8px 10px;
|
|
926
|
+
border: 1px solid #d1d5db;
|
|
927
|
+
border-radius: 6px;
|
|
928
|
+
font-size: 14px;
|
|
929
|
+
font-family: system-ui;
|
|
930
|
+
resize: vertical;
|
|
931
|
+
display: block;
|
|
932
|
+
box-sizing: border-box;
|
|
933
|
+
line-height: 1.5;
|
|
934
|
+
color: #111827;
|
|
935
|
+
background: #f9fafb;
|
|
936
|
+
margin-bottom: 6px;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
.edit-textarea:focus {
|
|
940
|
+
outline: none;
|
|
941
|
+
border-color: var(--color-primary);
|
|
942
|
+
background: #fff;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
.edit-hint {
|
|
946
|
+
font-size: 11px;
|
|
947
|
+
color: #9ca3af;
|
|
948
|
+
margin-bottom: 4px;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
.expiry-badge {
|
|
952
|
+
display: inline-block;
|
|
953
|
+
font-size: 10px;
|
|
954
|
+
font-weight: 500;
|
|
955
|
+
padding: 1px 6px;
|
|
956
|
+
border-radius: 4px;
|
|
957
|
+
margin-left: 6px;
|
|
958
|
+
vertical-align: middle;
|
|
959
|
+
background: #fef3c7;
|
|
960
|
+
color: #92400e;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
.expiry-badge.expiry-gone {
|
|
964
|
+
background: #fee2e2;
|
|
965
|
+
color: #991b1b;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
.seen-count {
|
|
969
|
+
font-size: px;
|
|
970
|
+
color: #9ca3af;
|
|
971
|
+
margin-left: 6px;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
.more-wrapper {
|
|
975
|
+
position: relative;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
.more-btn {
|
|
979
|
+
font-size: 16px;
|
|
980
|
+
padding: 4px 7px;
|
|
981
|
+
letter-spacing: 0;
|
|
982
|
+
line-height: 1;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
.more-dropdown {
|
|
986
|
+
position: absolute;
|
|
987
|
+
bottom: calc(100% + 6px);
|
|
988
|
+
right: 0;
|
|
989
|
+
background: #fff;
|
|
990
|
+
border: 1px solid #e5e7eb;
|
|
991
|
+
border-radius: 8px;
|
|
992
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
|
993
|
+
z-index: 100;
|
|
994
|
+
min-width: 160px;
|
|
995
|
+
overflow: hidden;
|
|
996
|
+
display: none;
|
|
997
|
+
padding: 4px;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
.more-dropdown.open {
|
|
1001
|
+
display: block;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
.more-dropdown button {
|
|
1005
|
+
display: block;
|
|
1006
|
+
width: 100%;
|
|
1007
|
+
text-align: left;
|
|
1008
|
+
background: none;
|
|
1009
|
+
border: none;
|
|
1010
|
+
padding: 8px 12px;
|
|
1011
|
+
font-size: 13px;
|
|
1012
|
+
color: #374151;
|
|
1013
|
+
cursor: pointer;
|
|
1014
|
+
border-radius: 6px;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
.more-dropdown button:hover {
|
|
1018
|
+
background: #f3f4f6;
|
|
1019
|
+
color: #111827;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
.more-dropdown .dropdown-label {
|
|
1023
|
+
font-size: 11px;
|
|
1024
|
+
color: #9ca3af;
|
|
1025
|
+
padding: 6px 12px 2px;
|
|
1026
|
+
font-weight: 500;
|
|
1027
|
+
text-transform: uppercase;
|
|
1028
|
+
letter-spacing: 0.05em;
|
|
1029
|
+
cursor: default;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
.icon-btn i {
|
|
1033
|
+
width: 14px;
|
|
1034
|
+
height: 14px;
|
|
1035
|
+
display: block;
|
|
1036
|
+
stroke-width: 2;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
.icon-btn.pinned i {
|
|
1040
|
+
color: #f59e0b;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
.item-new {
|
|
1044
|
+
border-left: 3px solid #22c55e;
|
|
1045
|
+
padding-left: 13px;
|
|
1046
|
+
background: #f0fdf4;
|
|
1047
|
+
transition: background 1.5s ease, border-color 1.5s ease;
|
|
1048
|
+
}
|
|
884
1049
|
|
|
885
1050
|
/* ============================================================
|
|
886
1051
|
MOBILE
|
|
@@ -1339,6 +1504,74 @@ input[type=text]:focus {
|
|
|
1339
1504
|
background: #2d3148;
|
|
1340
1505
|
}
|
|
1341
1506
|
|
|
1507
|
+
.markdown-preview {
|
|
1508
|
+
color: #e5e7eb;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
.item-title {
|
|
1512
|
+
color: #6b7280;
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
.title-input {
|
|
1516
|
+
background: #252836;
|
|
1517
|
+
border-color: #2d3148;
|
|
1518
|
+
color: #f3f4f6;
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
.edit-textarea {
|
|
1522
|
+
background: #252836;
|
|
1523
|
+
border-color: #2d3148;
|
|
1524
|
+
color: #f3f4f6;
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
.edit-textarea:focus {
|
|
1528
|
+
background: #1a1d27;
|
|
1529
|
+
border-color: #4b5563;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
.expiry-badge {
|
|
1533
|
+
background: #422006;
|
|
1534
|
+
color: #fbbf24;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
.expiry-badge.expiry-gone {
|
|
1538
|
+
background: #2d1515;
|
|
1539
|
+
color: #f87171;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
.seen-count {
|
|
1543
|
+
color: #6b7280;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
.more-dropdown {
|
|
1547
|
+
background: #1a1d27;
|
|
1548
|
+
border-color: #2d3148;
|
|
1549
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
.more-dropdown button {
|
|
1553
|
+
color: #d1d5db;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
.more-dropdown button:hover {
|
|
1557
|
+
background: #252836;
|
|
1558
|
+
color: #f3f4f6;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
.more-dropdown .dropdown-label {
|
|
1562
|
+
color: #6b7280;
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
.icon-btn.pinned i {
|
|
1566
|
+
color: #f59e0b;
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
.item-new {
|
|
1570
|
+
border-left: 3px solid #16a34a;
|
|
1571
|
+
padding-left: 13px;
|
|
1572
|
+
background: #052e16;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1342
1575
|
#uploadStatus {
|
|
1343
1576
|
background: #1a1d27 !important;
|
|
1344
1577
|
color: #e5e7eb !important;
|
|
@@ -1665,6 +1898,74 @@ input[type=text]:focus {
|
|
|
1665
1898
|
background: #2d3148;
|
|
1666
1899
|
}
|
|
1667
1900
|
|
|
1901
|
+
.markdown-preview {
|
|
1902
|
+
color: #e5e7eb;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
.item-title {
|
|
1906
|
+
color: #6b7280;
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
.title-input {
|
|
1910
|
+
background: #252836;
|
|
1911
|
+
border-color: #2d3148;
|
|
1912
|
+
color: #f3f4f6;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
.edit-textarea {
|
|
1916
|
+
background: #252836;
|
|
1917
|
+
border-color: #2d3148;
|
|
1918
|
+
color: #f3f4f6;
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
.edit-textarea:focus {
|
|
1922
|
+
background: #1a1d27;
|
|
1923
|
+
border-color: #4b5563;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
.expiry-badge {
|
|
1927
|
+
background: #422006;
|
|
1928
|
+
color: #fbbf24;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
.expiry-badge.expiry-gone {
|
|
1932
|
+
background: #2d1515;
|
|
1933
|
+
color: #f87171;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
.seen-count {
|
|
1937
|
+
color: #6b7280;
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
.more-dropdown {
|
|
1941
|
+
background: #1a1d27;
|
|
1942
|
+
border-color: #2d3148;
|
|
1943
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
.more-dropdown button {
|
|
1947
|
+
color: #d1d5db;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
.more-dropdown button:hover {
|
|
1951
|
+
background: #252836;
|
|
1952
|
+
color: #f3f4f6;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
.more-dropdown .dropdown-label {
|
|
1956
|
+
color: #6b7280;
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
.icon-btn.pinned i {
|
|
1960
|
+
color: #f59e0b;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
.item-new {
|
|
1964
|
+
border-left: 3px solid #16a34a;
|
|
1965
|
+
padding-left: 13px;
|
|
1966
|
+
background: #052e16;
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1668
1969
|
#uploadStatus {
|
|
1669
1970
|
background: #1a1d27 !important;
|
|
1670
1971
|
color: #e5e7eb !important;
|
package/client/index.html
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css">
|
|
11
11
|
<link rel="stylesheet" href="/css/app.css">
|
|
12
|
+
|
|
13
|
+
<script src="https://unpkg.com/lucide@0.513.0/dist/umd/lucide.min.js"></script>
|
|
12
14
|
</head>
|
|
13
15
|
|
|
14
16
|
<body>
|
|
@@ -87,6 +89,7 @@
|
|
|
87
89
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
88
90
|
<script src="/js/app.js"></script>
|
|
89
91
|
|
|
92
|
+
|
|
90
93
|
</body>
|
|
91
94
|
|
|
92
95
|
</html>
|
package/client/js/app.js
CHANGED
|
@@ -2,6 +2,37 @@ const socket = io();
|
|
|
2
2
|
|
|
3
3
|
let currentPage = 1;
|
|
4
4
|
let hasMoreItems = false;
|
|
5
|
+
let retention = 24 * 60 * 60 * 1000; // default 24h, overwritten on init
|
|
6
|
+
const newItemIds = new Set();
|
|
7
|
+
const seenEmitted = new Set(); // item IDs this session has already emitted seen for
|
|
8
|
+
|
|
9
|
+
const seenObserver = new IntersectionObserver((entries) => {
|
|
10
|
+
entries.forEach(entry => {
|
|
11
|
+
if (!entry.isIntersecting) return;
|
|
12
|
+
const id = parseInt(entry.target.dataset.itemId);
|
|
13
|
+
if (!id || seenEmitted.has(id)) return;
|
|
14
|
+
|
|
15
|
+
// wait 1 second of visibility before counting as seen
|
|
16
|
+
const timer = setTimeout(() => {
|
|
17
|
+
if (seenEmitted.has(id)) return;
|
|
18
|
+
seenEmitted.add(id);
|
|
19
|
+
socket.emit("seen", { id, name: uploader });
|
|
20
|
+
seenObserver.unobserve(entry.target); // done with this element
|
|
21
|
+
}, 1000);
|
|
22
|
+
|
|
23
|
+
// if element leaves viewport before 1s, cancel
|
|
24
|
+
entry.target._seenTimer = timer;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// cancel timers for elements that left viewport
|
|
28
|
+
entries.forEach(entry => {
|
|
29
|
+
if (entry.isIntersecting) return;
|
|
30
|
+
if (entry.target._seenTimer) {
|
|
31
|
+
clearTimeout(entry.target._seenTimer);
|
|
32
|
+
entry.target._seenTimer = null;
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}, { threshold: 0.5 }); // at least 50% of item must be visible
|
|
5
36
|
|
|
6
37
|
// ========================
|
|
7
38
|
// THEME MANAGEMENT (FIXED)
|
|
@@ -89,6 +120,7 @@ async function applyBranding() {
|
|
|
89
120
|
// Branding failed — default styles remain, no crash
|
|
90
121
|
}
|
|
91
122
|
}
|
|
123
|
+
|
|
92
124
|
function formatSize(bytes) {
|
|
93
125
|
if (!bytes) return "";
|
|
94
126
|
if (bytes >= 1024 ** 3) return (bytes / 1024 ** 3).toFixed(1) + " GB";
|
|
@@ -97,6 +129,33 @@ function formatSize(bytes) {
|
|
|
97
129
|
return bytes + " B";
|
|
98
130
|
}
|
|
99
131
|
|
|
132
|
+
function getExpiryBadge(createdAt) {
|
|
133
|
+
// if retention is null ("never"), nothing expires
|
|
134
|
+
if (retention === null) return "";
|
|
135
|
+
|
|
136
|
+
const expiresAt = createdAt + retention;
|
|
137
|
+
const remaining = expiresAt - Date.now();
|
|
138
|
+
const THREE_HOURS = 3 * 60 * 60 * 1000;
|
|
139
|
+
|
|
140
|
+
if (remaining > THREE_HOURS) return "";
|
|
141
|
+
if (remaining <= 0) return `<span class="expiry-badge expiry-gone">expired</span>`;
|
|
142
|
+
|
|
143
|
+
const m = Math.floor(remaining / 60000);
|
|
144
|
+
const h = Math.floor(m / 60);
|
|
145
|
+
const label = h > 0 ? `${h}h ${m % 60}m` : `${m}m`;
|
|
146
|
+
|
|
147
|
+
return `<span class="expiry-badge">⏱ ${label}</span>`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function escapeHtml(str) {
|
|
151
|
+
if (!str) return "";
|
|
152
|
+
return str
|
|
153
|
+
.replace(/&/g, "&")
|
|
154
|
+
.replace(/</g, "<")
|
|
155
|
+
.replace(/>/g, ">")
|
|
156
|
+
.replace(/"/g, """);
|
|
157
|
+
}
|
|
158
|
+
|
|
100
159
|
function playChime() {
|
|
101
160
|
try {
|
|
102
161
|
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
@@ -265,21 +324,37 @@ async function togglePreview(id, filename) {
|
|
|
265
324
|
|
|
266
325
|
try {
|
|
267
326
|
const res = await fetch(url);
|
|
268
|
-
|
|
269
327
|
if (!res.ok) throw new Error("Failed to load");
|
|
270
328
|
|
|
271
|
-
// Check size via content-length header before reading
|
|
272
329
|
const contentLength = res.headers.get("content-length");
|
|
273
330
|
if (contentLength && parseInt(contentLength) > MAX_TEXT_PREVIEW_BYTES) {
|
|
274
331
|
panel.innerHTML = `
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
332
|
+
<div class="preview-error">
|
|
333
|
+
File is too large to preview (${formatSize(parseInt(contentLength))}).
|
|
334
|
+
<a href="${url}" target="_blank">Open in new tab</a>
|
|
335
|
+
</div>`;
|
|
279
336
|
return;
|
|
280
337
|
}
|
|
281
338
|
|
|
282
339
|
const text = await res.text();
|
|
340
|
+
const ext = filename.split(".").pop().toLowerCase();
|
|
341
|
+
|
|
342
|
+
// markdown files — render as HTML, not syntax-highlighted raw text
|
|
343
|
+
if (ext === "md") {
|
|
344
|
+
const html = marked.parse(text);
|
|
345
|
+
const wrap = document.createElement("div");
|
|
346
|
+
wrap.className = "markdown-body markdown-preview";
|
|
347
|
+
wrap.innerHTML = html;
|
|
348
|
+
|
|
349
|
+
// syntax highlight any code blocks inside
|
|
350
|
+
wrap.querySelectorAll("pre code").forEach(el => hljs.highlightElement(el));
|
|
351
|
+
|
|
352
|
+
panel.innerHTML = "";
|
|
353
|
+
panel.appendChild(wrap);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// all other text files — syntax highlighted raw view
|
|
283
358
|
const lines = text.split("\n");
|
|
284
359
|
const truncated = lines.length > MAX_TEXT_LINES;
|
|
285
360
|
const preview = truncated
|
|
@@ -307,9 +382,9 @@ async function togglePreview(id, filename) {
|
|
|
307
382
|
|
|
308
383
|
} catch (err) {
|
|
309
384
|
panel.innerHTML = `
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
385
|
+
<div class="preview-error">
|
|
386
|
+
Could not load preview. <a href="${url}" target="_blank">Open directly</a>
|
|
387
|
+
</div>`;
|
|
313
388
|
}
|
|
314
389
|
}
|
|
315
390
|
}
|
|
@@ -379,6 +454,48 @@ function toggleMoveDropdown(e, id, currentChannel) {
|
|
|
379
454
|
openDropdown = dropdown;
|
|
380
455
|
}
|
|
381
456
|
|
|
457
|
+
function toggleMoreMenu(e, id, currentChannel) {
|
|
458
|
+
e.stopPropagation();
|
|
459
|
+
|
|
460
|
+
const moreDropdown = e.currentTarget.nextElementSibling;
|
|
461
|
+
const isOpen = moreDropdown.classList.contains("open");
|
|
462
|
+
|
|
463
|
+
// close any other open dropdown
|
|
464
|
+
if (openDropdown && openDropdown !== moreDropdown) {
|
|
465
|
+
openDropdown.classList.remove("open");
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (isOpen) {
|
|
469
|
+
moreDropdown.classList.remove("open");
|
|
470
|
+
openDropdown = null;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// build move list fresh each time
|
|
475
|
+
const moveList = moreDropdown.querySelector(".move-list");
|
|
476
|
+
const others = channels.filter(c => c.name !== currentChannel);
|
|
477
|
+
moveList.innerHTML = "";
|
|
478
|
+
|
|
479
|
+
if (others.length === 0) {
|
|
480
|
+
moveList.innerHTML = `<div class="dropdown-label" style="padding:4px 14px 8px">No other channels</div>`;
|
|
481
|
+
} else {
|
|
482
|
+
others.forEach(ch => {
|
|
483
|
+
const btn = document.createElement("button");
|
|
484
|
+
btn.innerText = ch.name;
|
|
485
|
+
btn.onclick = (ev) => {
|
|
486
|
+
ev.stopPropagation();
|
|
487
|
+
moveItem(id, ch.name);
|
|
488
|
+
moreDropdown.classList.remove("open");
|
|
489
|
+
openDropdown = null;
|
|
490
|
+
};
|
|
491
|
+
moveList.appendChild(btn);
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
moreDropdown.classList.add("open");
|
|
496
|
+
openDropdown = moreDropdown;
|
|
497
|
+
}
|
|
498
|
+
|
|
382
499
|
async function moveItem(id, toChannel) {
|
|
383
500
|
await fetch(`/item/${id}/move`, {
|
|
384
501
|
method: "PATCH",
|
|
@@ -467,158 +584,291 @@ async function loadMore() {
|
|
|
467
584
|
await load(false);
|
|
468
585
|
}
|
|
469
586
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
587
|
+
// ========================
|
|
588
|
+
// ITEM RENDERING
|
|
589
|
+
// ========================
|
|
590
|
+
function buildItemContent(i) {
|
|
591
|
+
if (i.type === "file") {
|
|
592
|
+
const isImg = i.filename.match(/\.(jpg|png|jpeg|gif)$/i);
|
|
593
|
+
const sizeLabel = formatSize(i.size);
|
|
594
|
+
const sizeClass = getSizeTag(i.size);
|
|
595
|
+
const sizeTag = sizeClass
|
|
596
|
+
? `<span class="size-tag ${sizeClass}">${sizeLabel}</span>`
|
|
597
|
+
: sizeLabel
|
|
598
|
+
? `<span style="font-size:11px;color:#9ca3af;margin-left:6px;">${sizeLabel}</span>`
|
|
599
|
+
: "";
|
|
600
|
+
|
|
601
|
+
if (isImg) {
|
|
602
|
+
return `<img src="/uploads/${i.filename}" style="max-width:200px;border-radius:6px"><br>
|
|
603
|
+
<a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
|
|
604
|
+
}
|
|
605
|
+
return `<a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
|
|
606
|
+
}
|
|
473
607
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
608
|
+
const isLink = i.content && i.content.startsWith("http");
|
|
609
|
+
if (isLink) return `<a href="${i.content}" target="_blank">${i.content}</a>`;
|
|
610
|
+
return renderText(i.content);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function buildItemEl(i) {
|
|
614
|
+
const div = document.createElement("div");
|
|
615
|
+
div.className = "item";
|
|
616
|
+
|
|
617
|
+
if (newItemIds.has(i.id) && !i.pinned) {
|
|
618
|
+
div.classList.add("item-new");
|
|
619
|
+
setTimeout(() => {
|
|
620
|
+
div.classList.remove("item-new");
|
|
621
|
+
newItemIds.delete(i.id);
|
|
622
|
+
}, 4000);
|
|
477
623
|
}
|
|
624
|
+
div.dataset.itemId = i.id;
|
|
625
|
+
|
|
626
|
+
const content = buildItemContent(i);
|
|
627
|
+
const isFile = i.type === "file";
|
|
628
|
+
const dataValue = isFile
|
|
629
|
+
? `/uploads/${i.filename}`
|
|
630
|
+
: (i.content || "").replace(/"/g, """);
|
|
631
|
+
const tooltip = isFile ? "Click to download" : "Click to copy";
|
|
632
|
+
|
|
633
|
+
// contextual slot — preview for files, edit for text, nothing otherwise
|
|
634
|
+
const contextualBtn = getPreviewType(i.filename) !== "none" && getPreviewType(i.filename) !== "image"
|
|
635
|
+
? `<button class="icon-btn" id="prevbtn-${i.id}"
|
|
636
|
+
onclick="togglePreview(${i.id}, '${i.filename}')"
|
|
637
|
+
title="Preview"><i data-lucide="eye"></i></button>`
|
|
638
|
+
: !isFile
|
|
639
|
+
? `<button class="icon-btn" onclick="editContent(${i.id})" title="Edit content"><i data-lucide="pencil-line"></i></button>`
|
|
640
|
+
: "";
|
|
641
|
+
|
|
642
|
+
const titleHtml = i.title
|
|
643
|
+
? `<div class="item-title" id="item-title-${i.id}">${escapeHtml(i.title)}</div>`
|
|
644
|
+
: `<div class="item-title" id="item-title-${i.id}" style="display:none"></div>`;
|
|
645
|
+
|
|
646
|
+
div.innerHTML = `
|
|
647
|
+
<div class="item-top">
|
|
648
|
+
<div class="left"
|
|
649
|
+
data-tooltip="${tooltip}"
|
|
650
|
+
data-type="${isFile ? "file" : "text"}"
|
|
651
|
+
data-value="${dataValue}">
|
|
652
|
+
${titleHtml}
|
|
653
|
+
${content}
|
|
654
|
+
<div class="meta">
|
|
655
|
+
${i.uploader}
|
|
656
|
+
<span class="seen-count" id="seen-${i.id}" style="display:none">👁 <span class="seen-num"></span></span>
|
|
657
|
+
${getExpiryBadge(i.created_at)}
|
|
658
|
+
</div>
|
|
659
|
+
</div>
|
|
660
|
+
<div class="item-actions">
|
|
661
|
+
${contextualBtn}
|
|
662
|
+
<button class="icon-btn ${i.pinned ? "pinned" : ""}" onclick="pin(${i.id})" title="${i.pinned ? "Unpin" : "Pin"}">
|
|
663
|
+
<i data-lucide="${i.pinned ? "pin-off" : "pin"}"></i>
|
|
664
|
+
</button>
|
|
665
|
+
<button class="icon-btn delete" onclick="del(${i.id}, ${i.pinned})" title="Delete">
|
|
666
|
+
<i data-lucide="trash-2"></i>
|
|
667
|
+
</button>
|
|
668
|
+
<div class="more-wrapper">
|
|
669
|
+
<button class="icon-btn more-btn" onclick="toggleMoreMenu(event, ${i.id}, '${i.channel}')" title="More">
|
|
670
|
+
<i data-lucide="more-vertical"></i>
|
|
671
|
+
</button>
|
|
672
|
+
<div class="more-dropdown">
|
|
673
|
+
<button onclick="editTitle(${i.id})">
|
|
674
|
+
<i data-lucide="tag"></i> Add / edit title
|
|
675
|
+
</button>
|
|
676
|
+
<div class="menu-divider"></div>
|
|
677
|
+
<div class="dropdown-label">Move to</div>
|
|
678
|
+
<div class="move-list"></div>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
</div>
|
|
683
|
+
<div class="preview-panel" id="preview-${i.id}"></div>`;
|
|
478
684
|
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
685
|
+
seenObserver.observe(div);
|
|
686
|
+
if (typeof lucide !== "undefined") lucide.createIcons({ nodes: [div] });
|
|
687
|
+
return div;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function editTitle(id) {
|
|
691
|
+
const titleEl = document.getElementById("item-title-" + id);
|
|
692
|
+
if (!titleEl) return;
|
|
693
|
+
|
|
694
|
+
const current = titleEl.dataset.value || titleEl.innerText.trim();
|
|
695
|
+
|
|
696
|
+
const input = document.createElement("input");
|
|
697
|
+
input.type = "text";
|
|
698
|
+
input.className = "title-input";
|
|
699
|
+
input.value = current;
|
|
700
|
+
input.placeholder = "Add a title...";
|
|
701
|
+
input.maxLength = 100;
|
|
702
|
+
|
|
703
|
+
titleEl.replaceWith(input);
|
|
704
|
+
input.focus();
|
|
705
|
+
input.select();
|
|
706
|
+
|
|
707
|
+
async function save() {
|
|
708
|
+
const newTitle = input.value.trim();
|
|
709
|
+
const res = await fetch(`/item/${id}/title`, {
|
|
710
|
+
method: "PATCH",
|
|
711
|
+
headers: { "Content-Type": "application/json" },
|
|
712
|
+
body: JSON.stringify({ title: newTitle })
|
|
713
|
+
});
|
|
714
|
+
if (!res.ok) { restore(); return; }
|
|
715
|
+
|
|
716
|
+
const fresh = document.createElement("div");
|
|
717
|
+
fresh.className = "item-title";
|
|
718
|
+
fresh.id = "item-title-" + id;
|
|
719
|
+
if (newTitle) {
|
|
720
|
+
fresh.innerText = newTitle;
|
|
721
|
+
fresh.style.display = "";
|
|
502
722
|
} else {
|
|
503
|
-
|
|
504
|
-
if (isLink) {
|
|
505
|
-
content = `<a href="${i.content}" target="_blank">${i.content}</a>`;
|
|
506
|
-
} else {
|
|
507
|
-
content = renderText(i.content);
|
|
508
|
-
}
|
|
723
|
+
fresh.style.display = "none";
|
|
509
724
|
}
|
|
725
|
+
input.replaceWith(fresh);
|
|
726
|
+
}
|
|
510
727
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
data-type="${i.type === 'file' ? 'file' : 'text'}"
|
|
524
|
-
data-value="${i.type === 'file'
|
|
525
|
-
? `/uploads/${i.filename}`
|
|
526
|
-
: (i.content || '').replace(/"/g, '"')}">
|
|
527
|
-
${content}
|
|
528
|
-
<div class="meta">${i.uploader}</div>
|
|
529
|
-
</div>
|
|
530
|
-
<div class="item-actions">
|
|
531
|
-
${getPreviewType(i.filename) !== "none" && getPreviewType(i.filename) !== "image"
|
|
532
|
-
? `<button class="icon-btn" id="prevbtn-${i.id}"
|
|
533
|
-
onclick="togglePreview(${i.id}, '${i.filename}')"
|
|
534
|
-
title="Preview">👁</button>`
|
|
535
|
-
: ""}
|
|
536
|
-
<button class="icon-btn" onclick="pin(${i.id})" title="${i.pinned ? 'Unpin' : 'Pin'}">
|
|
537
|
-
${i.pinned ? "📍" : "📌"}
|
|
538
|
-
</button>
|
|
539
|
-
<div class="move-wrapper">
|
|
540
|
-
<button class="icon-btn" title="Move to channel"
|
|
541
|
-
onclick="toggleMoveDropdown(event, ${i.id}, '${i.channel}')">⇄</button>
|
|
542
|
-
<div class="move-dropdown"></div>
|
|
543
|
-
</div>
|
|
544
|
-
<button class="icon-btn delete" onclick="del(${i.id}, ${i.pinned})" title="Delete">🗑</button>
|
|
728
|
+
function restore() {
|
|
729
|
+
const fresh = document.createElement("div");
|
|
730
|
+
fresh.className = "item-title";
|
|
731
|
+
fresh.id = "item-title-" + id;
|
|
732
|
+
if (current) {
|
|
733
|
+
fresh.innerText = current;
|
|
734
|
+
fresh.style.display = "";
|
|
735
|
+
} else {
|
|
736
|
+
fresh.style.display = "none";
|
|
737
|
+
}
|
|
738
|
+
input.replaceWith(fresh);
|
|
739
|
+
}
|
|
545
740
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
`;
|
|
550
|
-
el.appendChild(div);
|
|
741
|
+
input.addEventListener("keydown", e => {
|
|
742
|
+
if (e.key === "Enter") { e.preventDefault(); save(); }
|
|
743
|
+
if (e.key === "Escape") { e.preventDefault(); restore(); }
|
|
551
744
|
});
|
|
745
|
+
|
|
746
|
+
input.addEventListener("blur", save);
|
|
552
747
|
}
|
|
553
748
|
|
|
554
|
-
function
|
|
555
|
-
const
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
749
|
+
function editContent(id) {
|
|
750
|
+
const itemEl = document.querySelector(`.item[data-item-id="${id}"]`);
|
|
751
|
+
if (!itemEl) return;
|
|
752
|
+
|
|
753
|
+
const leftEl = itemEl.querySelector(".left");
|
|
754
|
+
if (!leftEl) return;
|
|
755
|
+
|
|
756
|
+
// grab current raw content from data-value
|
|
757
|
+
const current = leftEl.dataset.value || "";
|
|
758
|
+
|
|
759
|
+
// find the content node — everything except .meta and .item-title
|
|
760
|
+
const metaEl = leftEl.querySelector(".meta");
|
|
761
|
+
const titleEl = leftEl.querySelector(".item-title");
|
|
762
|
+
|
|
763
|
+
// collect content nodes (not meta, not title)
|
|
764
|
+
const contentNodes = Array.from(leftEl.childNodes).filter(n => {
|
|
765
|
+
if (n === metaEl) return false;
|
|
766
|
+
if (n === titleEl) return false;
|
|
767
|
+
return true;
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// replace content nodes with textarea
|
|
771
|
+
const textarea = document.createElement("textarea");
|
|
772
|
+
textarea.className = "edit-textarea";
|
|
773
|
+
textarea.value = current;
|
|
774
|
+
|
|
775
|
+
contentNodes.forEach(n => n.remove());
|
|
776
|
+
leftEl.insertBefore(textarea, metaEl);
|
|
777
|
+
textarea.focus();
|
|
778
|
+
|
|
779
|
+
// auto-height
|
|
780
|
+
textarea.style.height = Math.max(80, textarea.scrollHeight) + "px";
|
|
781
|
+
textarea.addEventListener("input", () => {
|
|
782
|
+
textarea.style.height = "auto";
|
|
783
|
+
textarea.style.height = textarea.scrollHeight + "px";
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
let done = false;
|
|
787
|
+
|
|
788
|
+
async function save() {
|
|
789
|
+
if (done) return;
|
|
790
|
+
done = true;
|
|
791
|
+
|
|
792
|
+
const newContent = textarea.value.trim();
|
|
793
|
+
if (!newContent) { restore(); return; }
|
|
794
|
+
if (newContent === current) { restore(); return; }
|
|
795
|
+
|
|
796
|
+
const res = await fetch(`/item/${id}/content`, {
|
|
797
|
+
method: "PATCH",
|
|
798
|
+
headers: { "Content-Type": "application/json" },
|
|
799
|
+
body: JSON.stringify({ content: newContent })
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
if (!res.ok) { done = false; restore(); return; }
|
|
803
|
+
|
|
804
|
+
leftEl.dataset.value = newContent;
|
|
805
|
+
restoreWithContent(newContent);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function restore() {
|
|
809
|
+
if (done) return;
|
|
810
|
+
done = true;
|
|
811
|
+
restoreWithContent(current);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function restoreWithContent(text) {
|
|
815
|
+
textarea.remove();
|
|
816
|
+
|
|
817
|
+
const isLink = text.startsWith("http");
|
|
818
|
+
let node;
|
|
819
|
+
if (isLink) {
|
|
820
|
+
node = document.createElement("a");
|
|
821
|
+
node.href = text;
|
|
822
|
+
node.target = "_blank";
|
|
823
|
+
node.innerText = text;
|
|
579
824
|
} else {
|
|
580
|
-
const
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
} else {
|
|
584
|
-
content = renderText(i.content);
|
|
585
|
-
}
|
|
825
|
+
const wrap = document.createElement("div");
|
|
826
|
+
wrap.innerHTML = renderText(text);
|
|
827
|
+
node = wrap;
|
|
586
828
|
}
|
|
587
829
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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>`;
|
|
830
|
+
leftEl.insertBefore(node, metaEl);
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
textarea.addEventListener("keydown", e => {
|
|
834
|
+
// Ctrl+Enter or Cmd+Enter saves
|
|
835
|
+
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
836
|
+
e.preventDefault();
|
|
837
|
+
save();
|
|
838
|
+
}
|
|
839
|
+
if (e.key === "Escape") {
|
|
840
|
+
e.preventDefault();
|
|
841
|
+
restore();
|
|
842
|
+
}
|
|
843
|
+
});
|
|
617
844
|
|
|
618
|
-
|
|
845
|
+
// blur saves only if focus didn't move to another part of same item
|
|
846
|
+
textarea.addEventListener("blur", e => {
|
|
847
|
+
setTimeout(() => {
|
|
848
|
+
if (!itemEl.contains(document.activeElement)) {
|
|
849
|
+
save();
|
|
850
|
+
}
|
|
851
|
+
}, 150);
|
|
619
852
|
});
|
|
620
853
|
}
|
|
621
854
|
|
|
855
|
+
function render(data) {
|
|
856
|
+
const el = document.getElementById("items");
|
|
857
|
+
el.innerHTML = "";
|
|
858
|
+
|
|
859
|
+
if (!data.length) {
|
|
860
|
+
el.innerHTML = `<div class="empty-state">Nothing here yet — paste, type, or drop a file to share</div>`;
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
data.forEach(i => el.appendChild(buildItemEl(i)));
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function renderMore(data) {
|
|
868
|
+
const el = document.getElementById("items");
|
|
869
|
+
data.forEach(i => el.appendChild(buildItemEl(i)));
|
|
870
|
+
}
|
|
871
|
+
|
|
622
872
|
function renderGrouped(data) {
|
|
623
873
|
const el = document.getElementById("items");
|
|
624
874
|
el.innerHTML = "";
|
|
@@ -628,7 +878,6 @@ function renderGrouped(data) {
|
|
|
628
878
|
return;
|
|
629
879
|
}
|
|
630
880
|
|
|
631
|
-
// group by channel
|
|
632
881
|
const grouped = {};
|
|
633
882
|
data.forEach(item => {
|
|
634
883
|
if (!grouped[item.channel]) grouped[item.channel] = [];
|
|
@@ -638,86 +887,11 @@ function renderGrouped(data) {
|
|
|
638
887
|
Object.keys(grouped).forEach(ch => {
|
|
639
888
|
const section = document.createElement("div");
|
|
640
889
|
section.style.marginTop = "20px";
|
|
641
|
-
|
|
642
890
|
section.innerHTML = `
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
grouped[ch].forEach(i => {
|
|
649
|
-
const div = document.createElement("div");
|
|
650
|
-
div.className = "item";
|
|
651
|
-
div.dataset.itemId = i.id;
|
|
652
|
-
|
|
653
|
-
let content = "";
|
|
654
|
-
|
|
655
|
-
if (i.type === "file") {
|
|
656
|
-
const isImg = i.filename.match(/\.(jpg|png|jpeg|gif)$/i);
|
|
657
|
-
const sizeLabel = formatSize(i.size);
|
|
658
|
-
const sizeClass = getSizeTag(i.size);
|
|
659
|
-
const sizeTag = sizeClass
|
|
660
|
-
? `<span class="size-tag ${sizeClass}">${sizeLabel}</span>`
|
|
661
|
-
: sizeLabel
|
|
662
|
-
? `<span style="font-size:11px;color:#9ca3af;margin-left:6px;">${sizeLabel}</span>`
|
|
663
|
-
: "";
|
|
664
|
-
|
|
665
|
-
if (isImg) {
|
|
666
|
-
content = `<img src="/uploads/${i.filename}" style="max-width:200px;border-radius:6px"><br>
|
|
667
|
-
<a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
|
|
668
|
-
} else {
|
|
669
|
-
content = `<a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
|
|
670
|
-
}
|
|
671
|
-
} else {
|
|
672
|
-
const isLink = i.content && i.content.startsWith("http");
|
|
673
|
-
if (isLink) {
|
|
674
|
-
content = `<a href="${i.content}" target="_blank">${i.content}</a>`;
|
|
675
|
-
} else {
|
|
676
|
-
content = renderText(i.content);
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
const isFile = i.type === "file";
|
|
681
|
-
const clickValue = isFile
|
|
682
|
-
? `/uploads/${i.filename}`
|
|
683
|
-
: i.content;
|
|
684
|
-
const tooltip = isFile ? "Click to download" : "Click to copy";
|
|
685
|
-
|
|
686
|
-
div.innerHTML = `
|
|
687
|
-
<div class="item-top">
|
|
688
|
-
<div class="left"
|
|
689
|
-
data-tooltip="${i.type === 'file' ? 'Click to download' : 'Click to copy'}"
|
|
690
|
-
data-type="${i.type === 'file' ? 'file' : 'text'}"
|
|
691
|
-
data-value="${i.type === 'file'
|
|
692
|
-
? `/uploads/${i.filename}`
|
|
693
|
-
: (i.content || '').replace(/"/g, '"')}">
|
|
694
|
-
${content}
|
|
695
|
-
<div class="meta">${i.uploader}</div>
|
|
696
|
-
</div>
|
|
697
|
-
<div class="item-actions">
|
|
698
|
-
${getPreviewType(i.filename) !== "none" && getPreviewType(i.filename) !== "image"
|
|
699
|
-
? `<button class="icon-btn" id="prevbtn-${i.id}"
|
|
700
|
-
onclick="togglePreview(${i.id}, '${i.filename}')"
|
|
701
|
-
title="Preview">👁</button>`
|
|
702
|
-
: ""}
|
|
703
|
-
<button class="icon-btn" onclick="pin(${i.id})" title="${i.pinned ? 'Unpin' : 'Pin'}">
|
|
704
|
-
${i.pinned ? "📍" : "📌"}
|
|
705
|
-
</button>
|
|
706
|
-
<div class="move-wrapper">
|
|
707
|
-
<button class="icon-btn" title="Move to channel"
|
|
708
|
-
onclick="toggleMoveDropdown(event, ${i.id}, '${i.channel}')">⇄</button>
|
|
709
|
-
<div class="move-dropdown"></div>
|
|
710
|
-
</div>
|
|
711
|
-
<button class="icon-btn delete" onclick="del(${i.id}, ${i.pinned})" title="Delete">🗑</button>
|
|
712
|
-
|
|
713
|
-
</div>
|
|
714
|
-
</div>
|
|
715
|
-
<div class="preview-panel" id="preview-${i.id}"></div>
|
|
716
|
-
`;
|
|
717
|
-
|
|
718
|
-
section.appendChild(div);
|
|
719
|
-
});
|
|
720
|
-
|
|
891
|
+
<div style="font-size:13px;font-weight:600;color:#6b7280;margin-bottom:8px;">
|
|
892
|
+
${ch.toUpperCase()}
|
|
893
|
+
</div>`;
|
|
894
|
+
grouped[ch].forEach(i => section.appendChild(buildItemEl(i)));
|
|
721
895
|
el.appendChild(section);
|
|
722
896
|
});
|
|
723
897
|
}
|
|
@@ -867,10 +1041,11 @@ async function logout() {
|
|
|
867
1041
|
|
|
868
1042
|
socket.on("new-item", item => {
|
|
869
1043
|
if (item.channel === channel) {
|
|
1044
|
+
if (!item.pinned) newItemIds.add(item.id);
|
|
870
1045
|
load();
|
|
871
1046
|
if (item.uploader !== uploader) playChime();
|
|
872
1047
|
} else if (item.uploader !== uploader) {
|
|
873
|
-
|
|
1048
|
+
if (!item.pinned) newItemIds.add(item.id);
|
|
874
1049
|
unreadChannels.add(item.channel);
|
|
875
1050
|
renderChannels();
|
|
876
1051
|
}
|
|
@@ -889,6 +1064,56 @@ socket.on("delete-item", id => {
|
|
|
889
1064
|
}
|
|
890
1065
|
});
|
|
891
1066
|
|
|
1067
|
+
socket.on("item-updated", ({ id, title, content, edited_at }) => {
|
|
1068
|
+
if (title !== undefined) {
|
|
1069
|
+
const titleEl = document.getElementById("item-title-" + id);
|
|
1070
|
+
if (titleEl) {
|
|
1071
|
+
titleEl.innerText = title;
|
|
1072
|
+
titleEl.style.display = title ? "" : "none";
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
if (content !== undefined) {
|
|
1077
|
+
const itemEl = document.querySelector(`.item[data-item-id="${id}"]`);
|
|
1078
|
+
if (!itemEl) return;
|
|
1079
|
+
const leftEl = itemEl.querySelector(".left");
|
|
1080
|
+
if (!leftEl) return;
|
|
1081
|
+
|
|
1082
|
+
// only update if not currently being edited
|
|
1083
|
+
if (leftEl.querySelector(".edit-textarea")) return;
|
|
1084
|
+
|
|
1085
|
+
leftEl.dataset.value = content;
|
|
1086
|
+
|
|
1087
|
+
const metaEl = leftEl.querySelector(".meta");
|
|
1088
|
+
const titleEl = leftEl.querySelector(".item-title");
|
|
1089
|
+
Array.from(leftEl.childNodes).forEach(n => {
|
|
1090
|
+
if (n !== metaEl && n !== titleEl) n.remove();
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
const isLink = content.startsWith("http");
|
|
1094
|
+
if (isLink) {
|
|
1095
|
+
const a = document.createElement("a");
|
|
1096
|
+
a.href = content;
|
|
1097
|
+
a.target = "_blank";
|
|
1098
|
+
a.innerText = content;
|
|
1099
|
+
leftEl.insertBefore(a, metaEl);
|
|
1100
|
+
} else {
|
|
1101
|
+
const wrap = document.createElement("div");
|
|
1102
|
+
wrap.innerHTML = renderText(content);
|
|
1103
|
+
leftEl.insertBefore(wrap, metaEl);
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
socket.on("seen-update", ({ id, count }) => {
|
|
1109
|
+
const el = document.getElementById("seen-" + id);
|
|
1110
|
+
if (!el) return;
|
|
1111
|
+
if (count >= 2) {
|
|
1112
|
+
el.querySelector(".seen-num").innerText = count;
|
|
1113
|
+
el.style.display = "";
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
|
|
892
1117
|
socket.on("user-count", count => {
|
|
893
1118
|
const el = document.getElementById("onlineCount");
|
|
894
1119
|
if (el) el.textContent = `● ${count} online`;
|
|
@@ -963,6 +1188,26 @@ document.addEventListener("paste", async e => {
|
|
|
963
1188
|
// if user is typing in input, don't auto-send
|
|
964
1189
|
if (active && active.id === "msg") return;
|
|
965
1190
|
|
|
1191
|
+
// check for image in clipboard first
|
|
1192
|
+
const items = e.clipboardData.items;
|
|
1193
|
+
for (const item of items) {
|
|
1194
|
+
if (item.type.startsWith("image/")) {
|
|
1195
|
+
const file = item.getAsFile();
|
|
1196
|
+
if (!file) continue;
|
|
1197
|
+
|
|
1198
|
+
// give it a meaningful name with timestamp
|
|
1199
|
+
const ext = item.type.split("/")[1] || "png";
|
|
1200
|
+
const named = new File(
|
|
1201
|
+
[file],
|
|
1202
|
+
`pasted-${Date.now()}.${ext}`,
|
|
1203
|
+
{ type: item.type }
|
|
1204
|
+
);
|
|
1205
|
+
uploadFiles([named]);
|
|
1206
|
+
return; // image found, don't fall through to text
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// no image — handle as text
|
|
966
1211
|
const text = e.clipboardData.getData("text");
|
|
967
1212
|
if (!text) return;
|
|
968
1213
|
|
|
@@ -1334,6 +1579,7 @@ document.addEventListener("keydown", e => {
|
|
|
1334
1579
|
if (!info.hasAuth) {
|
|
1335
1580
|
document.getElementById("logoutBtn").style.display = "none";
|
|
1336
1581
|
}
|
|
1582
|
+
retention = info.retention; // null if "never", ms value otherwise
|
|
1337
1583
|
await loadChannels();
|
|
1338
1584
|
load();
|
|
1339
1585
|
})();
|
package/package.json
CHANGED
package/server/cleanup.js
CHANGED
|
@@ -3,13 +3,12 @@ const fs = require("fs");
|
|
|
3
3
|
const path = require("path");
|
|
4
4
|
const config = require("./config");
|
|
5
5
|
|
|
6
|
-
const DAY = config.storage.retention;
|
|
7
|
-
|
|
8
6
|
setInterval(() => {
|
|
9
|
-
|
|
7
|
+
if (config.storage.retention === null) return;
|
|
8
|
+
const cutoff = Date.now() - config.storage.retention;
|
|
10
9
|
|
|
11
10
|
db.all(
|
|
12
|
-
`SELECT * FROM items WHERE created_at <
|
|
11
|
+
`SELECT * FROM items WHERE created_at < ? AND pinned = 0`,
|
|
13
12
|
[cutoff],
|
|
14
13
|
(err, rows) => {
|
|
15
14
|
rows.forEach((item) => {
|
package/server/config.js
CHANGED
|
@@ -30,6 +30,7 @@ function parseFileSize(val) {
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
function parseRetention(val) {
|
|
33
|
+
if (String(val).toLowerCase() === "never") return null;
|
|
33
34
|
if (typeof val === "number") return val;
|
|
34
35
|
const units = { h: 3600000, d: 86400000 };
|
|
35
36
|
const match = String(val).match(/^(\d+)(h|d)$/i);
|
package/server/db.js
CHANGED
|
@@ -36,6 +36,9 @@ db.serialize(() => {
|
|
|
36
36
|
db.run(`ALTER TABLE items ADD COLUMN size INTEGER DEFAULT 0`, () => { });
|
|
37
37
|
db.run(`ALTER TABLE channels ADD COLUMN pinned INTEGER DEFAULT 0`, () => { });
|
|
38
38
|
|
|
39
|
+
db.run(`ALTER TABLE items ADD COLUMN title TEXT DEFAULT ''`, () => { });
|
|
40
|
+
db.run(`ALTER TABLE items ADD COLUMN edited_at INTEGER DEFAULT NULL`, () => { });
|
|
41
|
+
|
|
39
42
|
// Insert default channels if empty
|
|
40
43
|
db.get("SELECT COUNT(*) as count FROM channels", (err, row) => {
|
|
41
44
|
if (err) {
|
package/server/server.js
CHANGED
|
@@ -525,6 +525,45 @@ app.patch("/item/:id/move", (req, res) => {
|
|
|
525
525
|
});
|
|
526
526
|
});
|
|
527
527
|
|
|
528
|
+
/* UPDATE ITEM TITLE */
|
|
529
|
+
app.patch("/item/:id/title", (req, res) => {
|
|
530
|
+
const { id } = req.params;
|
|
531
|
+
const { title } = req.body;
|
|
532
|
+
|
|
533
|
+
if (title === undefined) return res.status(400).json({ error: "Title required" });
|
|
534
|
+
|
|
535
|
+
db.run(
|
|
536
|
+
"UPDATE items SET title=? WHERE id=?",
|
|
537
|
+
[title.trim(), id],
|
|
538
|
+
function (err) {
|
|
539
|
+
if (err) return res.status(500).json({ error: "Update failed" });
|
|
540
|
+
if (this.changes === 0) return res.status(404).json({ error: "Item not found" });
|
|
541
|
+
io.emit("item-updated", { id: parseInt(id), title: title.trim() });
|
|
542
|
+
res.json({ id, title: title.trim() });
|
|
543
|
+
}
|
|
544
|
+
);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
/* UPDATE ITEM CONTENT */
|
|
548
|
+
app.patch("/item/:id/content", (req, res) => {
|
|
549
|
+
const { id } = req.params;
|
|
550
|
+
const { content } = req.body;
|
|
551
|
+
|
|
552
|
+
if (content === undefined) return res.status(400).json({ error: "Content required" });
|
|
553
|
+
if (content.trim() === "") return res.status(400).json({ error: "Content cannot be empty" });
|
|
554
|
+
|
|
555
|
+
db.run(
|
|
556
|
+
"UPDATE items SET content=?, edited_at=? WHERE id=? AND type='text'",
|
|
557
|
+
[content.trim(), Date.now(), id],
|
|
558
|
+
function (err) {
|
|
559
|
+
if (err) return res.status(500).json({ error: "Update failed" });
|
|
560
|
+
if (this.changes === 0) return res.status(404).json({ error: "Item not found or not editable" });
|
|
561
|
+
io.emit("item-updated", { id: parseInt(id), content: content.trim(), edited_at: Date.now() });
|
|
562
|
+
res.json({ id, content: content.trim() });
|
|
563
|
+
}
|
|
564
|
+
);
|
|
565
|
+
});
|
|
566
|
+
|
|
528
567
|
/* RENAME CHANNEL */
|
|
529
568
|
app.patch("/channels/:name", (req, res) => {
|
|
530
569
|
const oldName = req.params.name;
|
|
@@ -573,7 +612,8 @@ app.post("/channels/:name/pin", (req, res) => {
|
|
|
573
612
|
app.get("/info", (req, res) => {
|
|
574
613
|
res.json({
|
|
575
614
|
url: `http://${localIP}:${PORT}`,
|
|
576
|
-
hasAuth: !!config.auth.passphrase
|
|
615
|
+
hasAuth: !!config.auth.passphrase,
|
|
616
|
+
retention: config.storage.retention // null means "never"
|
|
577
617
|
});
|
|
578
618
|
});
|
|
579
619
|
|
|
@@ -650,6 +690,9 @@ app.get("/logo-dynamic.png", (req, res) => {
|
|
|
650
690
|
/* ============================
|
|
651
691
|
SOCKET CONNECTION LOGGING
|
|
652
692
|
============================ */
|
|
693
|
+
// in-memory seen tracking — item id → Set of socket ids
|
|
694
|
+
// resets on server restart, no DB needed
|
|
695
|
+
const seenBy = new Map();
|
|
653
696
|
|
|
654
697
|
let connectedUsers = 0;
|
|
655
698
|
|
|
@@ -664,6 +707,15 @@ io.on("connection", (socket) => {
|
|
|
664
707
|
console.log(username + " connected | total:", connectedUsers);
|
|
665
708
|
});
|
|
666
709
|
|
|
710
|
+
socket.on("seen", ({ id, name }) => {
|
|
711
|
+
if (!id || !name) return;
|
|
712
|
+
if (!seenBy.has(id)) seenBy.set(id, new Set());
|
|
713
|
+
seenBy.get(id).add(name); // name instead of socket.id
|
|
714
|
+
const count = seenBy.get(id).size;
|
|
715
|
+
console.log(`seen: item ${id} | count: ${count}`);
|
|
716
|
+
io.emit("seen-update", { id, count });
|
|
717
|
+
});
|
|
718
|
+
|
|
667
719
|
socket.on("disconnect", () => {
|
|
668
720
|
connectedUsers--;
|
|
669
721
|
console.log(username + " disconnected | total:", connectedUsers);
|