instbyte 1.9.1 → 1.9.3
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/LICENSE +21 -21
- package/README.md +360 -230
- package/bin/instbyte.js +27 -27
- package/client/assets/lucide.min.js +12 -0
- package/client/css/app.css +1972 -1972
- package/client/index.html +94 -94
- package/client/js/app.js +1648 -1613
- package/package.json +11 -4
- package/server/cleanup.js +27 -26
- package/server/config.js +69 -69
- package/server/db.js +60 -60
- package/server/server.js +871 -865
package/client/js/app.js
CHANGED
|
@@ -1,1614 +1,1649 @@
|
|
|
1
|
-
const socket = io();
|
|
2
|
-
|
|
3
|
-
let currentPage = 1;
|
|
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
|
|
36
|
-
|
|
37
|
-
// ========================
|
|
38
|
-
// THEME MANAGEMENT (FIXED)
|
|
39
|
-
// ========================
|
|
40
|
-
const THEME_KEY = "instbyte_theme";
|
|
41
|
-
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
42
|
-
|
|
43
|
-
function getStoredTheme() {
|
|
44
|
-
return localStorage.getItem(THEME_KEY) || "auto";
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function applyTheme(theme) {
|
|
48
|
-
const root = document.documentElement;
|
|
49
|
-
const btn = document.getElementById("themeToggle");
|
|
50
|
-
|
|
51
|
-
// Apply attribute EXACTLY
|
|
52
|
-
if (theme === "dark") {
|
|
53
|
-
root.setAttribute("data-theme", "dark");
|
|
54
|
-
} else if (theme === "light") {
|
|
55
|
-
root.setAttribute("data-theme", "light");
|
|
56
|
-
} else {
|
|
57
|
-
root.removeAttribute("data-theme"); // auto
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (!btn) return;
|
|
61
|
-
|
|
62
|
-
// Update icon based on CURRENT stored state
|
|
63
|
-
if (theme === "dark") btn.textContent = "☀️";
|
|
64
|
-
else if (theme === "light") btn.textContent = "🌙";
|
|
65
|
-
else {
|
|
66
|
-
btn.textContent = mq.matches ? "☀️" : "🌙";
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function cycleTheme() {
|
|
71
|
-
const current = getStoredTheme();
|
|
72
|
-
|
|
73
|
-
let next;
|
|
74
|
-
if (current === "dark") next = "light";
|
|
75
|
-
else if (current === "light") next = "dark";
|
|
76
|
-
else next = "dark"; // auto → dark first
|
|
77
|
-
|
|
78
|
-
localStorage.setItem(THEME_KEY, next);
|
|
79
|
-
applyTheme(next);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// INITIAL LOAD
|
|
83
|
-
applyTheme(getStoredTheme());
|
|
84
|
-
|
|
85
|
-
// OS change listener (only when auto)
|
|
86
|
-
mq.addEventListener("change", () => {
|
|
87
|
-
if (getStoredTheme() === "auto") {
|
|
88
|
-
applyTheme("auto");
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
async function applyBranding() {
|
|
93
|
-
try {
|
|
94
|
-
const res = await fetch("/branding");
|
|
95
|
-
const b = await res.json();
|
|
96
|
-
|
|
97
|
-
// Update page title and app name
|
|
98
|
-
document.title = b.appName;
|
|
99
|
-
const nameEl = document.getElementById("appName");
|
|
100
|
-
if (nameEl) nameEl.innerText = b.appName;
|
|
101
|
-
|
|
102
|
-
// Update logo src to dynamic route
|
|
103
|
-
const logoEl = document.getElementById("appLogo");
|
|
104
|
-
if (logoEl) logoEl.src = "/logo-dynamic.png";
|
|
105
|
-
|
|
106
|
-
// Inject CSS variables
|
|
107
|
-
const p = b.palette;
|
|
108
|
-
const root = document.documentElement;
|
|
109
|
-
root.style.setProperty("--color-primary", p.primary);
|
|
110
|
-
root.style.setProperty("--color-primary-hover", p.primaryHover);
|
|
111
|
-
root.style.setProperty("--color-primary-light", p.primaryLight);
|
|
112
|
-
root.style.setProperty("--color-primary-dark", p.primaryDark);
|
|
113
|
-
root.style.setProperty("--color-on-primary", p.onPrimary);
|
|
114
|
-
root.style.setProperty("--color-secondary", p.secondary);
|
|
115
|
-
root.style.setProperty("--color-secondary-hover", p.secondaryHover);
|
|
116
|
-
root.style.setProperty("--color-secondary-light", p.secondaryLight);
|
|
117
|
-
root.style.setProperty("--color-on-secondary", p.onSecondary);
|
|
118
|
-
|
|
119
|
-
} catch (e) {
|
|
120
|
-
// Branding failed — default styles remain, no crash
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function formatSize(bytes) {
|
|
125
|
-
if (!bytes) return "";
|
|
126
|
-
if (bytes >= 1024 ** 3) return (bytes / 1024 ** 3).toFixed(1) + " GB";
|
|
127
|
-
if (bytes >= 1024 ** 2) return (bytes / 1024 ** 2).toFixed(1) + " MB";
|
|
128
|
-
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
129
|
-
return bytes + " B";
|
|
130
|
-
}
|
|
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
|
-
|
|
159
|
-
function playChime() {
|
|
160
|
-
try {
|
|
161
|
-
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
162
|
-
|
|
163
|
-
const gain = ctx.createGain();
|
|
164
|
-
gain.connect(ctx.destination);
|
|
165
|
-
gain.gain.setValueAtTime(0, ctx.currentTime);
|
|
166
|
-
gain.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 0.01);
|
|
167
|
-
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
|
|
168
|
-
|
|
169
|
-
const osc1 = ctx.createOscillator();
|
|
170
|
-
osc1.type = "sine";
|
|
171
|
-
osc1.frequency.setValueAtTime(880, ctx.currentTime);
|
|
172
|
-
osc1.frequency.exponentialRampToValueAtTime(1100, ctx.currentTime + 0.1);
|
|
173
|
-
osc1.connect(gain);
|
|
174
|
-
osc1.start(ctx.currentTime);
|
|
175
|
-
osc1.stop(ctx.currentTime + 0.6);
|
|
176
|
-
|
|
177
|
-
const osc2 = ctx.createOscillator();
|
|
178
|
-
osc2.type = "sine";
|
|
179
|
-
osc2.frequency.setValueAtTime(1320, ctx.currentTime + 0.08);
|
|
180
|
-
osc2.frequency.exponentialRampToValueAtTime(1760, ctx.currentTime + 0.2);
|
|
181
|
-
osc2.connect(gain);
|
|
182
|
-
osc2.start(ctx.currentTime + 0.08);
|
|
183
|
-
osc2.stop(ctx.currentTime + 0.6);
|
|
184
|
-
|
|
185
|
-
} catch (e) {
|
|
186
|
-
// audio not supported or blocked — fail silently
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function getSizeTag(bytes) {
|
|
191
|
-
if (!bytes) return "";
|
|
192
|
-
const mb = bytes / (1024 * 1024);
|
|
193
|
-
if (mb > 1024) return "danger-dark";
|
|
194
|
-
if (mb > 500) return "danger-light";
|
|
195
|
-
if (mb > 100) return "warn";
|
|
196
|
-
return "";
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Configure marked
|
|
200
|
-
marked.setOptions({
|
|
201
|
-
breaks: true,
|
|
202
|
-
gfm: true
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
function looksLikeMarkdown(text) {
|
|
206
|
-
return /^#{1,3} |[*_`~]|\[.+\]\(.+\)|^[-*+] |^\d+\. |^```/m.test(text);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function renderText(text) {
|
|
210
|
-
if (!text) return "";
|
|
211
|
-
if (looksLikeMarkdown(text)) {
|
|
212
|
-
const html = marked.parse(text);
|
|
213
|
-
// highlight code blocks after parse
|
|
214
|
-
const wrap = document.createElement("div");
|
|
215
|
-
wrap.innerHTML = html;
|
|
216
|
-
wrap.querySelectorAll("pre code").forEach(el => hljs.highlightElement(el));
|
|
217
|
-
return `<div class="markdown-body">${wrap.innerHTML}</div>`;
|
|
218
|
-
}
|
|
219
|
-
// plain text — just escape and preserve newlines
|
|
220
|
-
return text
|
|
221
|
-
.replace(/&/g, "&")
|
|
222
|
-
.replace(/</g, "<")
|
|
223
|
-
.replace(/>/g, ">")
|
|
224
|
-
.replace(/\n/g, "<br>");
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
const TEXT_EXTENSIONS = [
|
|
228
|
-
'txt', 'md', 'js', 'ts', 'jsx', 'tsx', 'json', 'json5',
|
|
229
|
-
'css', 'html', 'htm', 'xml', 'svg', 'sh', 'bash', 'zsh',
|
|
230
|
-
'py', 'rb', 'php', 'java', 'c', 'cpp', 'h', 'cs', 'go',
|
|
231
|
-
'rs', 'swift', 'kt', 'yaml', 'yml', 'toml', 'ini', 'env',
|
|
232
|
-
'gitignore', 'dockerfile', 'sql', 'csv', 'log'
|
|
233
|
-
];
|
|
234
|
-
|
|
235
|
-
const MAX_TEXT_PREVIEW_BYTES = 50 * 1024; // 50KB — beyond this we warn
|
|
236
|
-
const MAX_TEXT_LINES = 200;
|
|
237
|
-
|
|
238
|
-
function getPreviewType(filename) {
|
|
239
|
-
if (!filename) return "none";
|
|
240
|
-
const ext = filename.split(".").pop().toLowerCase();
|
|
241
|
-
if (/^(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(ext)) return "image";
|
|
242
|
-
if (/^(mp4|webm|ogg|mov)$/.test(ext)) return "video";
|
|
243
|
-
if (/^(mp3|wav|ogg|m4a|flac|aac)$/.test(ext)) return "audio";
|
|
244
|
-
if (ext === "pdf") return "pdf";
|
|
245
|
-
if (TEXT_EXTENSIONS.includes(ext)) return "text";
|
|
246
|
-
return "none";
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function getLanguage(filename) {
|
|
250
|
-
const ext = filename.split(".").pop().toLowerCase();
|
|
251
|
-
const map = {
|
|
252
|
-
js: "javascript", ts: "typescript", jsx: "javascript",
|
|
253
|
-
tsx: "typescript", py: "python", rb: "ruby", php: "php",
|
|
254
|
-
java: "java", c: "c", cpp: "cpp", h: "c", cs: "csharp",
|
|
255
|
-
go: "go", rs: "rust", swift: "swift", kt: "kotlin",
|
|
256
|
-
html: "html", htm: "html", css: "css", json: "json",
|
|
257
|
-
json5: "json", xml: "xml", svg: "xml", sh: "bash",
|
|
258
|
-
bash: "bash", zsh: "bash", yaml: "yaml", yml: "yaml",
|
|
259
|
-
toml: "toml", sql: "sql", md: "markdown", csv: "plaintext",
|
|
260
|
-
txt: "plaintext", log: "plaintext", env: "plaintext",
|
|
261
|
-
dockerfile: "dockerfile"
|
|
262
|
-
};
|
|
263
|
-
return map[ext] || "plaintext";
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
let openPreviewId = null;
|
|
267
|
-
|
|
268
|
-
async function togglePreview(id, filename) {
|
|
269
|
-
const panel = document.getElementById("preview-" + id);
|
|
270
|
-
const btn = document.getElementById("prevbtn-" + id);
|
|
271
|
-
if (!panel || !btn) return;
|
|
272
|
-
|
|
273
|
-
const isOpen = panel.classList.contains("open");
|
|
274
|
-
|
|
275
|
-
// Close any other open preview first
|
|
276
|
-
if (openPreviewId && openPreviewId !== id) {
|
|
277
|
-
const otherPanel = document.getElementById("preview-" + openPreviewId);
|
|
278
|
-
const otherBtn = document.getElementById("prevbtn-" + openPreviewId);
|
|
279
|
-
if (otherPanel) otherPanel.classList.remove("open");
|
|
280
|
-
if (otherBtn) otherBtn.classList.remove("preview-active");
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
if (isOpen) {
|
|
284
|
-
panel.classList.remove("open");
|
|
285
|
-
btn.classList.remove("preview-active");
|
|
286
|
-
openPreviewId = null;
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// Open this one
|
|
291
|
-
panel.classList.add("open");
|
|
292
|
-
btn.classList.add("preview-active");
|
|
293
|
-
openPreviewId = id;
|
|
294
|
-
|
|
295
|
-
// Only build content once
|
|
296
|
-
if (panel.dataset.loaded) return;
|
|
297
|
-
panel.dataset.loaded = "true";
|
|
298
|
-
|
|
299
|
-
const type = getPreviewType(filename);
|
|
300
|
-
const url = `/uploads/${filename}`;
|
|
301
|
-
|
|
302
|
-
if (type === "image") {
|
|
303
|
-
panel.innerHTML = `<img src="${url}" alt="${filename}">`;
|
|
304
|
-
|
|
305
|
-
} else if (type === "video") {
|
|
306
|
-
panel.innerHTML = `
|
|
307
|
-
<video controls preload="metadata">
|
|
308
|
-
<source src="${url}">
|
|
309
|
-
Your browser doesn't support video preview.
|
|
310
|
-
</video>`;
|
|
311
|
-
|
|
312
|
-
} else if (type === "audio") {
|
|
313
|
-
panel.innerHTML = `
|
|
314
|
-
<audio controls preload="metadata">
|
|
315
|
-
<source src="${url}">
|
|
316
|
-
Your browser doesn't support audio preview.
|
|
317
|
-
</audio>`;
|
|
318
|
-
|
|
319
|
-
} else if (type === "pdf") {
|
|
320
|
-
panel.innerHTML = `<embed src="${url}" type="application/pdf">`;
|
|
321
|
-
|
|
322
|
-
} else if (type === "text") {
|
|
323
|
-
panel.innerHTML = `<div class="preview-loading">Loading...</div>`;
|
|
324
|
-
|
|
325
|
-
try {
|
|
326
|
-
const res = await fetch(url);
|
|
327
|
-
if (!res.ok) throw new Error("Failed to load");
|
|
328
|
-
|
|
329
|
-
const contentLength = res.headers.get("content-length");
|
|
330
|
-
if (contentLength && parseInt(contentLength) > MAX_TEXT_PREVIEW_BYTES) {
|
|
331
|
-
panel.innerHTML = `
|
|
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>`;
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
|
|
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
|
|
358
|
-
const lines = text.split("\n");
|
|
359
|
-
const truncated = lines.length > MAX_TEXT_LINES;
|
|
360
|
-
const preview = truncated
|
|
361
|
-
? lines.slice(0, MAX_TEXT_LINES).join("\n")
|
|
362
|
-
: text;
|
|
363
|
-
|
|
364
|
-
const lang = getLanguage(filename);
|
|
365
|
-
const code = document.createElement("code");
|
|
366
|
-
code.className = `language-${lang}`;
|
|
367
|
-
code.textContent = preview;
|
|
368
|
-
|
|
369
|
-
const pre = document.createElement("pre");
|
|
370
|
-
pre.appendChild(code);
|
|
371
|
-
hljs.highlightElement(code);
|
|
372
|
-
|
|
373
|
-
panel.innerHTML = "";
|
|
374
|
-
panel.appendChild(pre);
|
|
375
|
-
|
|
376
|
-
if (truncated) {
|
|
377
|
-
const note = document.createElement("div");
|
|
378
|
-
note.className = "preview-truncated";
|
|
379
|
-
note.innerHTML = `Showing first ${MAX_TEXT_LINES} of ${lines.length} lines. <a href="${url}" target="_blank">View full file</a>`;
|
|
380
|
-
panel.appendChild(note);
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
} catch (err) {
|
|
384
|
-
panel.innerHTML = `
|
|
385
|
-
<div class="preview-error">
|
|
386
|
-
Could not load preview. <a href="${url}" target="_blank">Open directly</a>
|
|
387
|
-
</div>`;
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
function handleRowClick(el, type, value) {
|
|
393
|
-
if (type === "text") {
|
|
394
|
-
navigator.clipboard.writeText(value).then(() => {
|
|
395
|
-
el.classList.add("flash");
|
|
396
|
-
setTimeout(() => el.classList.remove("flash"), 800);
|
|
397
|
-
});
|
|
398
|
-
} else {
|
|
399
|
-
const a = document.createElement("a");
|
|
400
|
-
a.href = value;
|
|
401
|
-
a.download = "";
|
|
402
|
-
a.click();
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
document.getElementById("items").addEventListener("click", e => {
|
|
407
|
-
const left = e.target.closest(".left");
|
|
408
|
-
if (!left) return;
|
|
409
|
-
const type = left.dataset.type;
|
|
410
|
-
const value = left.dataset.value;
|
|
411
|
-
handleRowClick(left, type, value);
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
let openDropdown = null;
|
|
415
|
-
|
|
416
|
-
let pendingDeleteId = null;
|
|
417
|
-
let pendingDeleteTimer = null;
|
|
418
|
-
let pendingDeleteEl = null;
|
|
419
|
-
|
|
420
|
-
function toggleMoveDropdown(e, id, currentChannel) {
|
|
421
|
-
e.stopPropagation();
|
|
422
|
-
|
|
423
|
-
// close any open one first
|
|
424
|
-
if (openDropdown && openDropdown !== e.currentTarget.nextElementSibling) {
|
|
425
|
-
openDropdown.classList.remove("open");
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const dropdown = e.currentTarget.nextElementSibling;
|
|
429
|
-
const isOpen = dropdown.classList.contains("open");
|
|
430
|
-
|
|
431
|
-
if (isOpen) {
|
|
432
|
-
dropdown.classList.remove("open");
|
|
433
|
-
openDropdown = null;
|
|
434
|
-
return;
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
// build channel list fresh each time (channels may have changed)
|
|
438
|
-
const others = channels.filter(c => c.name !== currentChannel);
|
|
439
|
-
dropdown.innerHTML = `<div class="dropdown-label">Move to</div>`;
|
|
440
|
-
|
|
441
|
-
others.forEach(ch => {
|
|
442
|
-
const btn = document.createElement("button");
|
|
443
|
-
btn.innerText = ch.name;
|
|
444
|
-
btn.onclick = (ev) => {
|
|
445
|
-
ev.stopPropagation();
|
|
446
|
-
moveItem(id, ch.name);
|
|
447
|
-
dropdown.classList.remove("open");
|
|
448
|
-
openDropdown = null;
|
|
449
|
-
};
|
|
450
|
-
dropdown.appendChild(btn);
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
dropdown.classList.add("open");
|
|
454
|
-
openDropdown = dropdown;
|
|
455
|
-
}
|
|
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
|
-
|
|
499
|
-
async function moveItem(id, toChannel) {
|
|
500
|
-
await fetch(`/item/${id}/move`, {
|
|
501
|
-
method: "PATCH",
|
|
502
|
-
headers: { "Content-Type": "application/json" },
|
|
503
|
-
body: JSON.stringify({ channel: toChannel })
|
|
504
|
-
});
|
|
505
|
-
// socket will handle the re-render via item-moved event
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
document.addEventListener("click", () => {
|
|
509
|
-
if (openDropdown) {
|
|
510
|
-
openDropdown.classList.remove("open");
|
|
511
|
-
openDropdown = null;
|
|
512
|
-
}
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
let channel = null;
|
|
516
|
-
let channels = [];
|
|
517
|
-
let unreadChannels = new Set();
|
|
518
|
-
|
|
519
|
-
let uploader = localStorage.getItem("name") || "";
|
|
520
|
-
|
|
521
|
-
async function initName() {
|
|
522
|
-
if (!uploader || uploader === "null" || uploader.trim() === "") {
|
|
523
|
-
let suggested = "USER-" + Math.floor(Math.random() * 1000);
|
|
524
|
-
const ua = navigator.userAgent;
|
|
525
|
-
if (/android/i.test(ua)) suggested = "Android-" + Math.floor(Math.random() * 1000);
|
|
526
|
-
else if (/iphone|ipad/i.test(ua)) suggested = "iPhone-" + Math.floor(Math.random() * 1000);
|
|
527
|
-
else if (/mac/i.test(ua)) suggested = "Mac-" + Math.floor(Math.random() * 1000);
|
|
528
|
-
else if (/windows/i.test(ua)) suggested = "Windows-" + Math.floor(Math.random() * 1000);
|
|
529
|
-
else if (/linux/i.test(ua)) suggested = "Linux-" + Math.floor(Math.random() * 1000);
|
|
530
|
-
else suggested = "User-" + Math.floor(Math.random() * 1000);
|
|
531
|
-
uploader = prompt("Your name?", suggested) || suggested;
|
|
532
|
-
localStorage.setItem("name", uploader);
|
|
533
|
-
}
|
|
534
|
-
document.getElementById("who").innerText = uploader;
|
|
535
|
-
socket.emit("join", uploader);
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
function highlight() {
|
|
539
|
-
document.querySelectorAll(".channels button")
|
|
540
|
-
.forEach(b => b.classList.remove("active"));
|
|
541
|
-
const el = document.getElementById("ch-" + channel);
|
|
542
|
-
if (el) el.classList.add("active");
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function setChannel(c) {
|
|
546
|
-
// flush any pending delete before switching
|
|
547
|
-
if (pendingDeleteId !== null) {
|
|
548
|
-
clearTimeout(pendingDeleteTimer);
|
|
549
|
-
fetch("/item/" + pendingDeleteId, { method: "DELETE" });
|
|
550
|
-
pendingDeleteId = null;
|
|
551
|
-
pendingDeleteTimer = null;
|
|
552
|
-
pendingDeleteEl = null;
|
|
553
|
-
hideUndoToast();
|
|
554
|
-
}
|
|
555
|
-
unreadChannels.delete(c); // clear unread when switching to channel
|
|
556
|
-
channel = c;
|
|
557
|
-
renderChannels();
|
|
558
|
-
highlight();
|
|
559
|
-
load();
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
async function load(resetPage = true) {
|
|
563
|
-
if (resetPage) currentPage = 1;
|
|
564
|
-
|
|
565
|
-
const res = await fetch(`/items/${channel}?page=${currentPage}`);
|
|
566
|
-
const data = await res.json();
|
|
567
|
-
|
|
568
|
-
console.log("load called, page:", currentPage, "data:", data);
|
|
569
|
-
|
|
570
|
-
hasMoreItems = data.hasMore;
|
|
571
|
-
|
|
572
|
-
if (currentPage === 1) {
|
|
573
|
-
render(data.items);
|
|
574
|
-
} else {
|
|
575
|
-
renderMore(data.items);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
const wrapper = document.getElementById("loadMoreWrapper");
|
|
579
|
-
wrapper.style.display = hasMoreItems ? "block" : "none";
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
async function loadMore() {
|
|
583
|
-
currentPage++;
|
|
584
|
-
await load(false);
|
|
585
|
-
}
|
|
586
|
-
|
|
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
|
-
}
|
|
607
|
-
|
|
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);
|
|
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>`;
|
|
684
|
-
|
|
685
|
-
seenObserver.observe(div);
|
|
686
|
-
if (typeof lucide !== "undefined")
|
|
687
|
-
|
|
688
|
-
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
input
|
|
701
|
-
input.
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
input.
|
|
705
|
-
input.
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
}
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
const
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
const
|
|
762
|
-
|
|
763
|
-
//
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
textarea.
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
textarea.
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
if (
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
const
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
node =
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
if (e.key === "
|
|
840
|
-
e.preventDefault();
|
|
841
|
-
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
if (
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
const
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
}
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
document.addEventListener("
|
|
1493
|
-
|
|
1494
|
-
});
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
if (
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
}
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
//
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
(
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1
|
+
const socket = io();
|
|
2
|
+
|
|
3
|
+
let currentPage = 1;
|
|
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
|
|
36
|
+
|
|
37
|
+
// ========================
|
|
38
|
+
// THEME MANAGEMENT (FIXED)
|
|
39
|
+
// ========================
|
|
40
|
+
const THEME_KEY = "instbyte_theme";
|
|
41
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
42
|
+
|
|
43
|
+
function getStoredTheme() {
|
|
44
|
+
return localStorage.getItem(THEME_KEY) || "auto";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function applyTheme(theme) {
|
|
48
|
+
const root = document.documentElement;
|
|
49
|
+
const btn = document.getElementById("themeToggle");
|
|
50
|
+
|
|
51
|
+
// Apply attribute EXACTLY
|
|
52
|
+
if (theme === "dark") {
|
|
53
|
+
root.setAttribute("data-theme", "dark");
|
|
54
|
+
} else if (theme === "light") {
|
|
55
|
+
root.setAttribute("data-theme", "light");
|
|
56
|
+
} else {
|
|
57
|
+
root.removeAttribute("data-theme"); // auto
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!btn) return;
|
|
61
|
+
|
|
62
|
+
// Update icon based on CURRENT stored state
|
|
63
|
+
if (theme === "dark") btn.textContent = "☀️";
|
|
64
|
+
else if (theme === "light") btn.textContent = "🌙";
|
|
65
|
+
else {
|
|
66
|
+
btn.textContent = mq.matches ? "☀️" : "🌙";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function cycleTheme() {
|
|
71
|
+
const current = getStoredTheme();
|
|
72
|
+
|
|
73
|
+
let next;
|
|
74
|
+
if (current === "dark") next = "light";
|
|
75
|
+
else if (current === "light") next = "dark";
|
|
76
|
+
else next = "dark"; // auto → dark first
|
|
77
|
+
|
|
78
|
+
localStorage.setItem(THEME_KEY, next);
|
|
79
|
+
applyTheme(next);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// INITIAL LOAD
|
|
83
|
+
applyTheme(getStoredTheme());
|
|
84
|
+
|
|
85
|
+
// OS change listener (only when auto)
|
|
86
|
+
mq.addEventListener("change", () => {
|
|
87
|
+
if (getStoredTheme() === "auto") {
|
|
88
|
+
applyTheme("auto");
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
async function applyBranding() {
|
|
93
|
+
try {
|
|
94
|
+
const res = await fetch("/branding");
|
|
95
|
+
const b = await res.json();
|
|
96
|
+
|
|
97
|
+
// Update page title and app name
|
|
98
|
+
document.title = b.appName;
|
|
99
|
+
const nameEl = document.getElementById("appName");
|
|
100
|
+
if (nameEl) nameEl.innerText = b.appName;
|
|
101
|
+
|
|
102
|
+
// Update logo src to dynamic route
|
|
103
|
+
const logoEl = document.getElementById("appLogo");
|
|
104
|
+
if (logoEl) logoEl.src = "/logo-dynamic.png";
|
|
105
|
+
|
|
106
|
+
// Inject CSS variables
|
|
107
|
+
const p = b.palette;
|
|
108
|
+
const root = document.documentElement;
|
|
109
|
+
root.style.setProperty("--color-primary", p.primary);
|
|
110
|
+
root.style.setProperty("--color-primary-hover", p.primaryHover);
|
|
111
|
+
root.style.setProperty("--color-primary-light", p.primaryLight);
|
|
112
|
+
root.style.setProperty("--color-primary-dark", p.primaryDark);
|
|
113
|
+
root.style.setProperty("--color-on-primary", p.onPrimary);
|
|
114
|
+
root.style.setProperty("--color-secondary", p.secondary);
|
|
115
|
+
root.style.setProperty("--color-secondary-hover", p.secondaryHover);
|
|
116
|
+
root.style.setProperty("--color-secondary-light", p.secondaryLight);
|
|
117
|
+
root.style.setProperty("--color-on-secondary", p.onSecondary);
|
|
118
|
+
|
|
119
|
+
} catch (e) {
|
|
120
|
+
// Branding failed — default styles remain, no crash
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatSize(bytes) {
|
|
125
|
+
if (!bytes) return "";
|
|
126
|
+
if (bytes >= 1024 ** 3) return (bytes / 1024 ** 3).toFixed(1) + " GB";
|
|
127
|
+
if (bytes >= 1024 ** 2) return (bytes / 1024 ** 2).toFixed(1) + " MB";
|
|
128
|
+
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB";
|
|
129
|
+
return bytes + " B";
|
|
130
|
+
}
|
|
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
|
+
|
|
159
|
+
function playChime() {
|
|
160
|
+
try {
|
|
161
|
+
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
162
|
+
|
|
163
|
+
const gain = ctx.createGain();
|
|
164
|
+
gain.connect(ctx.destination);
|
|
165
|
+
gain.gain.setValueAtTime(0, ctx.currentTime);
|
|
166
|
+
gain.gain.linearRampToValueAtTime(0.15, ctx.currentTime + 0.01);
|
|
167
|
+
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6);
|
|
168
|
+
|
|
169
|
+
const osc1 = ctx.createOscillator();
|
|
170
|
+
osc1.type = "sine";
|
|
171
|
+
osc1.frequency.setValueAtTime(880, ctx.currentTime);
|
|
172
|
+
osc1.frequency.exponentialRampToValueAtTime(1100, ctx.currentTime + 0.1);
|
|
173
|
+
osc1.connect(gain);
|
|
174
|
+
osc1.start(ctx.currentTime);
|
|
175
|
+
osc1.stop(ctx.currentTime + 0.6);
|
|
176
|
+
|
|
177
|
+
const osc2 = ctx.createOscillator();
|
|
178
|
+
osc2.type = "sine";
|
|
179
|
+
osc2.frequency.setValueAtTime(1320, ctx.currentTime + 0.08);
|
|
180
|
+
osc2.frequency.exponentialRampToValueAtTime(1760, ctx.currentTime + 0.2);
|
|
181
|
+
osc2.connect(gain);
|
|
182
|
+
osc2.start(ctx.currentTime + 0.08);
|
|
183
|
+
osc2.stop(ctx.currentTime + 0.6);
|
|
184
|
+
|
|
185
|
+
} catch (e) {
|
|
186
|
+
// audio not supported or blocked — fail silently
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function getSizeTag(bytes) {
|
|
191
|
+
if (!bytes) return "";
|
|
192
|
+
const mb = bytes / (1024 * 1024);
|
|
193
|
+
if (mb > 1024) return "danger-dark";
|
|
194
|
+
if (mb > 500) return "danger-light";
|
|
195
|
+
if (mb > 100) return "warn";
|
|
196
|
+
return "";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Configure marked
|
|
200
|
+
marked.setOptions({
|
|
201
|
+
breaks: true,
|
|
202
|
+
gfm: true
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
function looksLikeMarkdown(text) {
|
|
206
|
+
return /^#{1,3} |[*_`~]|\[.+\]\(.+\)|^[-*+] |^\d+\. |^```/m.test(text);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function renderText(text) {
|
|
210
|
+
if (!text) return "";
|
|
211
|
+
if (looksLikeMarkdown(text)) {
|
|
212
|
+
const html = marked.parse(text);
|
|
213
|
+
// highlight code blocks after parse
|
|
214
|
+
const wrap = document.createElement("div");
|
|
215
|
+
wrap.innerHTML = html;
|
|
216
|
+
wrap.querySelectorAll("pre code").forEach(el => hljs.highlightElement(el));
|
|
217
|
+
return `<div class="markdown-body">${wrap.innerHTML}</div>`;
|
|
218
|
+
}
|
|
219
|
+
// plain text — just escape and preserve newlines
|
|
220
|
+
return text
|
|
221
|
+
.replace(/&/g, "&")
|
|
222
|
+
.replace(/</g, "<")
|
|
223
|
+
.replace(/>/g, ">")
|
|
224
|
+
.replace(/\n/g, "<br>");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const TEXT_EXTENSIONS = [
|
|
228
|
+
'txt', 'md', 'js', 'ts', 'jsx', 'tsx', 'json', 'json5',
|
|
229
|
+
'css', 'html', 'htm', 'xml', 'svg', 'sh', 'bash', 'zsh',
|
|
230
|
+
'py', 'rb', 'php', 'java', 'c', 'cpp', 'h', 'cs', 'go',
|
|
231
|
+
'rs', 'swift', 'kt', 'yaml', 'yml', 'toml', 'ini', 'env',
|
|
232
|
+
'gitignore', 'dockerfile', 'sql', 'csv', 'log'
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
const MAX_TEXT_PREVIEW_BYTES = 50 * 1024; // 50KB — beyond this we warn
|
|
236
|
+
const MAX_TEXT_LINES = 200;
|
|
237
|
+
|
|
238
|
+
function getPreviewType(filename) {
|
|
239
|
+
if (!filename) return "none";
|
|
240
|
+
const ext = filename.split(".").pop().toLowerCase();
|
|
241
|
+
if (/^(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(ext)) return "image";
|
|
242
|
+
if (/^(mp4|webm|ogg|mov)$/.test(ext)) return "video";
|
|
243
|
+
if (/^(mp3|wav|ogg|m4a|flac|aac)$/.test(ext)) return "audio";
|
|
244
|
+
if (ext === "pdf") return "pdf";
|
|
245
|
+
if (TEXT_EXTENSIONS.includes(ext)) return "text";
|
|
246
|
+
return "none";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function getLanguage(filename) {
|
|
250
|
+
const ext = filename.split(".").pop().toLowerCase();
|
|
251
|
+
const map = {
|
|
252
|
+
js: "javascript", ts: "typescript", jsx: "javascript",
|
|
253
|
+
tsx: "typescript", py: "python", rb: "ruby", php: "php",
|
|
254
|
+
java: "java", c: "c", cpp: "cpp", h: "c", cs: "csharp",
|
|
255
|
+
go: "go", rs: "rust", swift: "swift", kt: "kotlin",
|
|
256
|
+
html: "html", htm: "html", css: "css", json: "json",
|
|
257
|
+
json5: "json", xml: "xml", svg: "xml", sh: "bash",
|
|
258
|
+
bash: "bash", zsh: "bash", yaml: "yaml", yml: "yaml",
|
|
259
|
+
toml: "toml", sql: "sql", md: "markdown", csv: "plaintext",
|
|
260
|
+
txt: "plaintext", log: "plaintext", env: "plaintext",
|
|
261
|
+
dockerfile: "dockerfile"
|
|
262
|
+
};
|
|
263
|
+
return map[ext] || "plaintext";
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let openPreviewId = null;
|
|
267
|
+
|
|
268
|
+
async function togglePreview(id, filename) {
|
|
269
|
+
const panel = document.getElementById("preview-" + id);
|
|
270
|
+
const btn = document.getElementById("prevbtn-" + id);
|
|
271
|
+
if (!panel || !btn) return;
|
|
272
|
+
|
|
273
|
+
const isOpen = panel.classList.contains("open");
|
|
274
|
+
|
|
275
|
+
// Close any other open preview first
|
|
276
|
+
if (openPreviewId && openPreviewId !== id) {
|
|
277
|
+
const otherPanel = document.getElementById("preview-" + openPreviewId);
|
|
278
|
+
const otherBtn = document.getElementById("prevbtn-" + openPreviewId);
|
|
279
|
+
if (otherPanel) otherPanel.classList.remove("open");
|
|
280
|
+
if (otherBtn) otherBtn.classList.remove("preview-active");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (isOpen) {
|
|
284
|
+
panel.classList.remove("open");
|
|
285
|
+
btn.classList.remove("preview-active");
|
|
286
|
+
openPreviewId = null;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Open this one
|
|
291
|
+
panel.classList.add("open");
|
|
292
|
+
btn.classList.add("preview-active");
|
|
293
|
+
openPreviewId = id;
|
|
294
|
+
|
|
295
|
+
// Only build content once
|
|
296
|
+
if (panel.dataset.loaded) return;
|
|
297
|
+
panel.dataset.loaded = "true";
|
|
298
|
+
|
|
299
|
+
const type = getPreviewType(filename);
|
|
300
|
+
const url = `/uploads/${filename}`;
|
|
301
|
+
|
|
302
|
+
if (type === "image") {
|
|
303
|
+
panel.innerHTML = `<img src="${url}" alt="${filename}">`;
|
|
304
|
+
|
|
305
|
+
} else if (type === "video") {
|
|
306
|
+
panel.innerHTML = `
|
|
307
|
+
<video controls preload="metadata">
|
|
308
|
+
<source src="${url}">
|
|
309
|
+
Your browser doesn't support video preview.
|
|
310
|
+
</video>`;
|
|
311
|
+
|
|
312
|
+
} else if (type === "audio") {
|
|
313
|
+
panel.innerHTML = `
|
|
314
|
+
<audio controls preload="metadata">
|
|
315
|
+
<source src="${url}">
|
|
316
|
+
Your browser doesn't support audio preview.
|
|
317
|
+
</audio>`;
|
|
318
|
+
|
|
319
|
+
} else if (type === "pdf") {
|
|
320
|
+
panel.innerHTML = `<embed src="${url}" type="application/pdf">`;
|
|
321
|
+
|
|
322
|
+
} else if (type === "text") {
|
|
323
|
+
panel.innerHTML = `<div class="preview-loading">Loading...</div>`;
|
|
324
|
+
|
|
325
|
+
try {
|
|
326
|
+
const res = await fetch(url);
|
|
327
|
+
if (!res.ok) throw new Error("Failed to load");
|
|
328
|
+
|
|
329
|
+
const contentLength = res.headers.get("content-length");
|
|
330
|
+
if (contentLength && parseInt(contentLength) > MAX_TEXT_PREVIEW_BYTES) {
|
|
331
|
+
panel.innerHTML = `
|
|
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>`;
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
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
|
|
358
|
+
const lines = text.split("\n");
|
|
359
|
+
const truncated = lines.length > MAX_TEXT_LINES;
|
|
360
|
+
const preview = truncated
|
|
361
|
+
? lines.slice(0, MAX_TEXT_LINES).join("\n")
|
|
362
|
+
: text;
|
|
363
|
+
|
|
364
|
+
const lang = getLanguage(filename);
|
|
365
|
+
const code = document.createElement("code");
|
|
366
|
+
code.className = `language-${lang}`;
|
|
367
|
+
code.textContent = preview;
|
|
368
|
+
|
|
369
|
+
const pre = document.createElement("pre");
|
|
370
|
+
pre.appendChild(code);
|
|
371
|
+
hljs.highlightElement(code);
|
|
372
|
+
|
|
373
|
+
panel.innerHTML = "";
|
|
374
|
+
panel.appendChild(pre);
|
|
375
|
+
|
|
376
|
+
if (truncated) {
|
|
377
|
+
const note = document.createElement("div");
|
|
378
|
+
note.className = "preview-truncated";
|
|
379
|
+
note.innerHTML = `Showing first ${MAX_TEXT_LINES} of ${lines.length} lines. <a href="${url}" target="_blank">View full file</a>`;
|
|
380
|
+
panel.appendChild(note);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
} catch (err) {
|
|
384
|
+
panel.innerHTML = `
|
|
385
|
+
<div class="preview-error">
|
|
386
|
+
Could not load preview. <a href="${url}" target="_blank">Open directly</a>
|
|
387
|
+
</div>`;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function handleRowClick(el, type, value) {
|
|
393
|
+
if (type === "text") {
|
|
394
|
+
navigator.clipboard.writeText(value).then(() => {
|
|
395
|
+
el.classList.add("flash");
|
|
396
|
+
setTimeout(() => el.classList.remove("flash"), 800);
|
|
397
|
+
});
|
|
398
|
+
} else {
|
|
399
|
+
const a = document.createElement("a");
|
|
400
|
+
a.href = value;
|
|
401
|
+
a.download = "";
|
|
402
|
+
a.click();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
document.getElementById("items").addEventListener("click", e => {
|
|
407
|
+
const left = e.target.closest(".left");
|
|
408
|
+
if (!left) return;
|
|
409
|
+
const type = left.dataset.type;
|
|
410
|
+
const value = left.dataset.value;
|
|
411
|
+
handleRowClick(left, type, value);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
let openDropdown = null;
|
|
415
|
+
|
|
416
|
+
let pendingDeleteId = null;
|
|
417
|
+
let pendingDeleteTimer = null;
|
|
418
|
+
let pendingDeleteEl = null;
|
|
419
|
+
|
|
420
|
+
function toggleMoveDropdown(e, id, currentChannel) {
|
|
421
|
+
e.stopPropagation();
|
|
422
|
+
|
|
423
|
+
// close any open one first
|
|
424
|
+
if (openDropdown && openDropdown !== e.currentTarget.nextElementSibling) {
|
|
425
|
+
openDropdown.classList.remove("open");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const dropdown = e.currentTarget.nextElementSibling;
|
|
429
|
+
const isOpen = dropdown.classList.contains("open");
|
|
430
|
+
|
|
431
|
+
if (isOpen) {
|
|
432
|
+
dropdown.classList.remove("open");
|
|
433
|
+
openDropdown = null;
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// build channel list fresh each time (channels may have changed)
|
|
438
|
+
const others = channels.filter(c => c.name !== currentChannel);
|
|
439
|
+
dropdown.innerHTML = `<div class="dropdown-label">Move to</div>`;
|
|
440
|
+
|
|
441
|
+
others.forEach(ch => {
|
|
442
|
+
const btn = document.createElement("button");
|
|
443
|
+
btn.innerText = ch.name;
|
|
444
|
+
btn.onclick = (ev) => {
|
|
445
|
+
ev.stopPropagation();
|
|
446
|
+
moveItem(id, ch.name);
|
|
447
|
+
dropdown.classList.remove("open");
|
|
448
|
+
openDropdown = null;
|
|
449
|
+
};
|
|
450
|
+
dropdown.appendChild(btn);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
dropdown.classList.add("open");
|
|
454
|
+
openDropdown = dropdown;
|
|
455
|
+
}
|
|
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
|
+
|
|
499
|
+
async function moveItem(id, toChannel) {
|
|
500
|
+
await fetch(`/item/${id}/move`, {
|
|
501
|
+
method: "PATCH",
|
|
502
|
+
headers: { "Content-Type": "application/json" },
|
|
503
|
+
body: JSON.stringify({ channel: toChannel })
|
|
504
|
+
});
|
|
505
|
+
// socket will handle the re-render via item-moved event
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
document.addEventListener("click", () => {
|
|
509
|
+
if (openDropdown) {
|
|
510
|
+
openDropdown.classList.remove("open");
|
|
511
|
+
openDropdown = null;
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
let channel = null;
|
|
516
|
+
let channels = [];
|
|
517
|
+
let unreadChannels = new Set();
|
|
518
|
+
|
|
519
|
+
let uploader = localStorage.getItem("name") || "";
|
|
520
|
+
|
|
521
|
+
async function initName() {
|
|
522
|
+
if (!uploader || uploader === "null" || uploader.trim() === "") {
|
|
523
|
+
let suggested = "USER-" + Math.floor(Math.random() * 1000);
|
|
524
|
+
const ua = navigator.userAgent;
|
|
525
|
+
if (/android/i.test(ua)) suggested = "Android-" + Math.floor(Math.random() * 1000);
|
|
526
|
+
else if (/iphone|ipad/i.test(ua)) suggested = "iPhone-" + Math.floor(Math.random() * 1000);
|
|
527
|
+
else if (/mac/i.test(ua)) suggested = "Mac-" + Math.floor(Math.random() * 1000);
|
|
528
|
+
else if (/windows/i.test(ua)) suggested = "Windows-" + Math.floor(Math.random() * 1000);
|
|
529
|
+
else if (/linux/i.test(ua)) suggested = "Linux-" + Math.floor(Math.random() * 1000);
|
|
530
|
+
else suggested = "User-" + Math.floor(Math.random() * 1000);
|
|
531
|
+
uploader = prompt("Your name?", suggested) || suggested;
|
|
532
|
+
localStorage.setItem("name", uploader);
|
|
533
|
+
}
|
|
534
|
+
document.getElementById("who").innerText = uploader;
|
|
535
|
+
socket.emit("join", uploader);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function highlight() {
|
|
539
|
+
document.querySelectorAll(".channels button")
|
|
540
|
+
.forEach(b => b.classList.remove("active"));
|
|
541
|
+
const el = document.getElementById("ch-" + channel);
|
|
542
|
+
if (el) el.classList.add("active");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function setChannel(c) {
|
|
546
|
+
// flush any pending delete before switching
|
|
547
|
+
if (pendingDeleteId !== null) {
|
|
548
|
+
clearTimeout(pendingDeleteTimer);
|
|
549
|
+
fetch("/item/" + pendingDeleteId, { method: "DELETE" });
|
|
550
|
+
pendingDeleteId = null;
|
|
551
|
+
pendingDeleteTimer = null;
|
|
552
|
+
pendingDeleteEl = null;
|
|
553
|
+
hideUndoToast();
|
|
554
|
+
}
|
|
555
|
+
unreadChannels.delete(c); // clear unread when switching to channel
|
|
556
|
+
channel = c;
|
|
557
|
+
renderChannels();
|
|
558
|
+
highlight();
|
|
559
|
+
load();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function load(resetPage = true) {
|
|
563
|
+
if (resetPage) currentPage = 1;
|
|
564
|
+
|
|
565
|
+
const res = await fetch(`/items/${channel}?page=${currentPage}`);
|
|
566
|
+
const data = await res.json();
|
|
567
|
+
|
|
568
|
+
console.log("load called, page:", currentPage, "data:", data);
|
|
569
|
+
|
|
570
|
+
hasMoreItems = data.hasMore;
|
|
571
|
+
|
|
572
|
+
if (currentPage === 1) {
|
|
573
|
+
render(data.items);
|
|
574
|
+
} else {
|
|
575
|
+
renderMore(data.items);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const wrapper = document.getElementById("loadMoreWrapper");
|
|
579
|
+
wrapper.style.display = hasMoreItems ? "block" : "none";
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function loadMore() {
|
|
583
|
+
currentPage++;
|
|
584
|
+
await load(false);
|
|
585
|
+
}
|
|
586
|
+
|
|
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
|
+
}
|
|
607
|
+
|
|
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);
|
|
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>`;
|
|
684
|
+
|
|
685
|
+
seenObserver.observe(div);
|
|
686
|
+
if (typeof lucide !== "undefined") {
|
|
687
|
+
lucide.createIcons({ nodes: [div] });
|
|
688
|
+
} else {
|
|
689
|
+
console.warn("Lucide not yet defined when building item", i.id);
|
|
690
|
+
}
|
|
691
|
+
return div;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function editTitle(id) {
|
|
695
|
+
const titleEl = document.getElementById("item-title-" + id);
|
|
696
|
+
if (!titleEl) return;
|
|
697
|
+
|
|
698
|
+
const current = titleEl.dataset.value || titleEl.innerText.trim();
|
|
699
|
+
|
|
700
|
+
const input = document.createElement("input");
|
|
701
|
+
input.type = "text";
|
|
702
|
+
input.className = "title-input";
|
|
703
|
+
input.value = current;
|
|
704
|
+
input.placeholder = "Add a title...";
|
|
705
|
+
input.maxLength = 100;
|
|
706
|
+
|
|
707
|
+
titleEl.replaceWith(input);
|
|
708
|
+
input.focus();
|
|
709
|
+
input.select();
|
|
710
|
+
|
|
711
|
+
async function save() {
|
|
712
|
+
const newTitle = input.value.trim();
|
|
713
|
+
const res = await fetch(`/item/${id}/title`, {
|
|
714
|
+
method: "PATCH",
|
|
715
|
+
headers: { "Content-Type": "application/json" },
|
|
716
|
+
body: JSON.stringify({ title: newTitle })
|
|
717
|
+
});
|
|
718
|
+
if (!res.ok) { restore(); return; }
|
|
719
|
+
|
|
720
|
+
const fresh = document.createElement("div");
|
|
721
|
+
fresh.className = "item-title";
|
|
722
|
+
fresh.id = "item-title-" + id;
|
|
723
|
+
if (newTitle) {
|
|
724
|
+
fresh.innerText = newTitle;
|
|
725
|
+
fresh.style.display = "";
|
|
726
|
+
} else {
|
|
727
|
+
fresh.style.display = "none";
|
|
728
|
+
}
|
|
729
|
+
input.replaceWith(fresh);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function restore() {
|
|
733
|
+
const fresh = document.createElement("div");
|
|
734
|
+
fresh.className = "item-title";
|
|
735
|
+
fresh.id = "item-title-" + id;
|
|
736
|
+
if (current) {
|
|
737
|
+
fresh.innerText = current;
|
|
738
|
+
fresh.style.display = "";
|
|
739
|
+
} else {
|
|
740
|
+
fresh.style.display = "none";
|
|
741
|
+
}
|
|
742
|
+
input.replaceWith(fresh);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
input.addEventListener("keydown", e => {
|
|
746
|
+
if (e.key === "Enter") { e.preventDefault(); save(); }
|
|
747
|
+
if (e.key === "Escape") { e.preventDefault(); restore(); }
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
input.addEventListener("blur", save);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function editContent(id) {
|
|
754
|
+
const itemEl = document.querySelector(`.item[data-item-id="${id}"]`);
|
|
755
|
+
if (!itemEl) return;
|
|
756
|
+
|
|
757
|
+
const leftEl = itemEl.querySelector(".left");
|
|
758
|
+
if (!leftEl) return;
|
|
759
|
+
|
|
760
|
+
// grab current raw content from data-value
|
|
761
|
+
const current = leftEl.dataset.value || "";
|
|
762
|
+
|
|
763
|
+
// find the content node — everything except .meta and .item-title
|
|
764
|
+
const metaEl = leftEl.querySelector(".meta");
|
|
765
|
+
const titleEl = leftEl.querySelector(".item-title");
|
|
766
|
+
|
|
767
|
+
// collect content nodes (not meta, not title)
|
|
768
|
+
const contentNodes = Array.from(leftEl.childNodes).filter(n => {
|
|
769
|
+
if (n === metaEl) return false;
|
|
770
|
+
if (n === titleEl) return false;
|
|
771
|
+
return true;
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
// replace content nodes with textarea
|
|
775
|
+
const textarea = document.createElement("textarea");
|
|
776
|
+
textarea.className = "edit-textarea";
|
|
777
|
+
textarea.value = current;
|
|
778
|
+
|
|
779
|
+
contentNodes.forEach(n => n.remove());
|
|
780
|
+
leftEl.insertBefore(textarea, metaEl);
|
|
781
|
+
textarea.focus();
|
|
782
|
+
|
|
783
|
+
// auto-height
|
|
784
|
+
textarea.style.height = Math.max(80, textarea.scrollHeight) + "px";
|
|
785
|
+
textarea.addEventListener("input", () => {
|
|
786
|
+
textarea.style.height = "auto";
|
|
787
|
+
textarea.style.height = textarea.scrollHeight + "px";
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
let done = false;
|
|
791
|
+
|
|
792
|
+
async function save() {
|
|
793
|
+
if (done) return;
|
|
794
|
+
done = true;
|
|
795
|
+
|
|
796
|
+
const newContent = textarea.value.trim();
|
|
797
|
+
if (!newContent) { restore(); return; }
|
|
798
|
+
if (newContent === current) { restore(); return; }
|
|
799
|
+
|
|
800
|
+
const res = await fetch(`/item/${id}/content`, {
|
|
801
|
+
method: "PATCH",
|
|
802
|
+
headers: { "Content-Type": "application/json" },
|
|
803
|
+
body: JSON.stringify({ content: newContent })
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
if (!res.ok) { done = false; restore(); return; }
|
|
807
|
+
|
|
808
|
+
leftEl.dataset.value = newContent;
|
|
809
|
+
restoreWithContent(newContent);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function restore() {
|
|
813
|
+
if (done) return;
|
|
814
|
+
done = true;
|
|
815
|
+
restoreWithContent(current);
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function restoreWithContent(text) {
|
|
819
|
+
textarea.remove();
|
|
820
|
+
|
|
821
|
+
const isLink = text.startsWith("http");
|
|
822
|
+
let node;
|
|
823
|
+
if (isLink) {
|
|
824
|
+
node = document.createElement("a");
|
|
825
|
+
node.href = text;
|
|
826
|
+
node.target = "_blank";
|
|
827
|
+
node.innerText = text;
|
|
828
|
+
} else {
|
|
829
|
+
const wrap = document.createElement("div");
|
|
830
|
+
wrap.innerHTML = renderText(text);
|
|
831
|
+
node = wrap;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
leftEl.insertBefore(node, metaEl);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
textarea.addEventListener("keydown", e => {
|
|
838
|
+
// Ctrl+Enter or Cmd+Enter saves
|
|
839
|
+
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
|
840
|
+
e.preventDefault();
|
|
841
|
+
save();
|
|
842
|
+
}
|
|
843
|
+
if (e.key === "Escape") {
|
|
844
|
+
e.preventDefault();
|
|
845
|
+
restore();
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
// blur saves only if focus didn't move to another part of same item
|
|
850
|
+
textarea.addEventListener("blur", e => {
|
|
851
|
+
setTimeout(() => {
|
|
852
|
+
if (!itemEl.contains(document.activeElement)) {
|
|
853
|
+
save();
|
|
854
|
+
}
|
|
855
|
+
}, 150);
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
function render(data) {
|
|
860
|
+
const el = document.getElementById("items");
|
|
861
|
+
el.innerHTML = "";
|
|
862
|
+
|
|
863
|
+
if (!data.length) {
|
|
864
|
+
el.innerHTML = `<div class="empty-state">Nothing here yet — paste, type, or drop a file to share</div>`;
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
data.forEach(i => el.appendChild(buildItemEl(i)));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function renderMore(data) {
|
|
872
|
+
const el = document.getElementById("items");
|
|
873
|
+
data.forEach(i => el.appendChild(buildItemEl(i)));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function renderGrouped(data) {
|
|
877
|
+
const el = document.getElementById("items");
|
|
878
|
+
el.innerHTML = "";
|
|
879
|
+
|
|
880
|
+
if (!data.length) {
|
|
881
|
+
el.innerHTML = "<div style='color:#6b7280;margin-top:10px;'>No results</div>";
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const grouped = {};
|
|
886
|
+
data.forEach(item => {
|
|
887
|
+
if (!grouped[item.channel]) grouped[item.channel] = [];
|
|
888
|
+
grouped[item.channel].push(item);
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
Object.keys(grouped).forEach(ch => {
|
|
892
|
+
const section = document.createElement("div");
|
|
893
|
+
section.style.marginTop = "20px";
|
|
894
|
+
section.innerHTML = `
|
|
895
|
+
<div style="font-size:13px;font-weight:600;color:#6b7280;margin-bottom:8px;">
|
|
896
|
+
${ch.toUpperCase()}
|
|
897
|
+
</div>`;
|
|
898
|
+
grouped[ch].forEach(i => section.appendChild(buildItemEl(i)));
|
|
899
|
+
el.appendChild(section);
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
async function sendText() {
|
|
904
|
+
const input = document.getElementById("msg");
|
|
905
|
+
const text = input.value.trim();
|
|
906
|
+
if (!text) return;
|
|
907
|
+
|
|
908
|
+
await fetch("/text", {
|
|
909
|
+
method: "POST",
|
|
910
|
+
headers: { "Content-Type": "application/json" },
|
|
911
|
+
body: JSON.stringify({ content: text, channel, uploader })
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
input.value = "";
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
function handleEnter(e) {
|
|
918
|
+
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
|
|
919
|
+
e.preventDefault();
|
|
920
|
+
sendText();
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function changeName() {
|
|
925
|
+
const n = prompt("Enter name:");
|
|
926
|
+
if (!n) return;
|
|
927
|
+
uploader = n;
|
|
928
|
+
localStorage.setItem("name", n);
|
|
929
|
+
document.getElementById("who").innerText = uploader;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
let qrLoaded = false;
|
|
933
|
+
|
|
934
|
+
async function toggleQR() {
|
|
935
|
+
const card = document.getElementById("qrCard");
|
|
936
|
+
card.classList.toggle("open");
|
|
937
|
+
|
|
938
|
+
if (card.classList.contains("open") && !qrLoaded) {
|
|
939
|
+
const res = await fetch("/info");
|
|
940
|
+
const { url } = await res.json();
|
|
941
|
+
document.getElementById("qrUrl").innerText = url;
|
|
942
|
+
document.getElementById("qrImg").src =
|
|
943
|
+
`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(url)}`;
|
|
944
|
+
qrLoaded = true;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
document.addEventListener("click", e => {
|
|
949
|
+
const widget = document.querySelector(".qr-widget");
|
|
950
|
+
if (!widget.contains(e.target)) {
|
|
951
|
+
document.getElementById("qrCard").classList.remove("open");
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
|
|
955
|
+
const fileInput = document.getElementById("fileInput");
|
|
956
|
+
|
|
957
|
+
fileInput.onchange = () => {
|
|
958
|
+
if (fileInput.files.length) uploadFiles(fileInput.files);
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
async function del(id, pinned) {
|
|
962
|
+
// pinned items keep confirm dialog
|
|
963
|
+
if (pinned) {
|
|
964
|
+
const confirmed = confirm("This item is pinned. Are you sure you want to delete it?");
|
|
965
|
+
if (!confirmed) return;
|
|
966
|
+
await fetch("/item/" + id, { method: "DELETE" });
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// if another delete is pending, execute it immediately (don't await — fire and forget)
|
|
971
|
+
if (pendingDeleteId !== null) {
|
|
972
|
+
clearTimeout(pendingDeleteTimer);
|
|
973
|
+
fetch("/item/" + pendingDeleteId, { method: "DELETE" });
|
|
974
|
+
pendingDeleteId = null;
|
|
975
|
+
pendingDeleteTimer = null;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// optimistically remove from UI
|
|
979
|
+
const el = document.querySelector(`[data-item-id="${id}"]`);
|
|
980
|
+
if (el) {
|
|
981
|
+
pendingDeleteEl = el.outerHTML;
|
|
982
|
+
el.remove();
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
pendingDeleteId = id;
|
|
986
|
+
showUndoToast();
|
|
987
|
+
|
|
988
|
+
pendingDeleteTimer = setTimeout(async () => {
|
|
989
|
+
await fetch("/item/" + pendingDeleteId, { method: "DELETE" });
|
|
990
|
+
pendingDeleteId = null;
|
|
991
|
+
pendingDeleteTimer = null;
|
|
992
|
+
pendingDeleteEl = null;
|
|
993
|
+
hideUndoToast();
|
|
994
|
+
}, 5000);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function undoDelete() {
|
|
998
|
+
if (pendingDeleteId === null) return;
|
|
999
|
+
clearTimeout(pendingDeleteTimer);
|
|
1000
|
+
pendingDeleteId = null;
|
|
1001
|
+
pendingDeleteTimer = null;
|
|
1002
|
+
pendingDeleteEl = null;
|
|
1003
|
+
hideUndoToast();
|
|
1004
|
+
load(); // reload to restore item
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function showUndoToast() {
|
|
1008
|
+
const toast = document.getElementById("undoToast");
|
|
1009
|
+
const progress = document.getElementById("undoProgress");
|
|
1010
|
+
progress.classList.remove("running");
|
|
1011
|
+
void progress.offsetWidth; // force reflow to restart animation
|
|
1012
|
+
progress.classList.add("running");
|
|
1013
|
+
toast.classList.add("show");
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function hideUndoToast() {
|
|
1017
|
+
const toast = document.getElementById("undoToast");
|
|
1018
|
+
const progress = document.getElementById("undoProgress");
|
|
1019
|
+
toast.classList.remove("show");
|
|
1020
|
+
progress.classList.remove("running");
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
// prime AudioContext on first interaction so chime works immediately after
|
|
1025
|
+
let audioContextPrimed = false;
|
|
1026
|
+
document.addEventListener("click", () => {
|
|
1027
|
+
if (audioContextPrimed) return;
|
|
1028
|
+
try {
|
|
1029
|
+
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
1030
|
+
ctx.resume().then(() => ctx.close());
|
|
1031
|
+
audioContextPrimed = true;
|
|
1032
|
+
} catch (e) { }
|
|
1033
|
+
}, { once: false });
|
|
1034
|
+
|
|
1035
|
+
async function pin(id) {
|
|
1036
|
+
await fetch("/pin/" + id, { method: "POST" });
|
|
1037
|
+
load();
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
async function logout() {
|
|
1042
|
+
await fetch("/logout", { method: "POST" });
|
|
1043
|
+
window.location.href = "/login";
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
socket.on("new-item", item => {
|
|
1047
|
+
if (document.getElementById("search").value.trim() !== "") return;
|
|
1048
|
+
|
|
1049
|
+
if (item.channel === channel) {
|
|
1050
|
+
const container = document.getElementById("items");
|
|
1051
|
+
|
|
1052
|
+
const empty = container.querySelector(".empty-state");
|
|
1053
|
+
if (empty) empty.remove();
|
|
1054
|
+
|
|
1055
|
+
if (!item.pinned) newItemIds.add(item.id);
|
|
1056
|
+
|
|
1057
|
+
const el = buildItemEl(item);
|
|
1058
|
+
|
|
1059
|
+
if (item.pinned) {
|
|
1060
|
+
container.prepend(el);
|
|
1061
|
+
} else {
|
|
1062
|
+
const existing = container.querySelectorAll(".item");
|
|
1063
|
+
let insertBefore = null;
|
|
1064
|
+
for (let i = 0; i < existing.length; i++) {
|
|
1065
|
+
if (!existing[i].querySelector(".icon-btn.pinned")) {
|
|
1066
|
+
insertBefore = existing[i];
|
|
1067
|
+
break;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (insertBefore) {
|
|
1071
|
+
container.insertBefore(el, insertBefore);
|
|
1072
|
+
} else {
|
|
1073
|
+
container.appendChild(el);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
if (typeof lucide !== "undefined") lucide.createIcons({ nodes: [el] });
|
|
1078
|
+
if (item.uploader !== uploader) playChime();
|
|
1079
|
+
|
|
1080
|
+
} else if (item.uploader !== uploader) {
|
|
1081
|
+
if (!item.pinned) newItemIds.add(item.id);
|
|
1082
|
+
unreadChannels.add(item.channel);
|
|
1083
|
+
renderChannels();
|
|
1084
|
+
}
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
socket.on("delete-item", id => {
|
|
1088
|
+
// don't reload if this item is already removed or pending
|
|
1089
|
+
if (id == pendingDeleteId) return;
|
|
1090
|
+
const el = document.querySelector(`[data-item-id="${id}"]`);
|
|
1091
|
+
if (el) el.remove();
|
|
1092
|
+
|
|
1093
|
+
// show empty state if no items left
|
|
1094
|
+
const items = document.getElementById("items");
|
|
1095
|
+
if (items && !items.querySelector(".item")) {
|
|
1096
|
+
items.innerHTML = `<div class="empty-state">Nothing here yet — paste, type, or drop a file to share</div>`;
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
socket.on("item-updated", ({ id, title, content, edited_at }) => {
|
|
1101
|
+
if (title !== undefined) {
|
|
1102
|
+
const titleEl = document.getElementById("item-title-" + id);
|
|
1103
|
+
if (titleEl) {
|
|
1104
|
+
titleEl.innerText = title;
|
|
1105
|
+
titleEl.style.display = title ? "" : "none";
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
if (content !== undefined) {
|
|
1110
|
+
const itemEl = document.querySelector(`.item[data-item-id="${id}"]`);
|
|
1111
|
+
if (!itemEl) return;
|
|
1112
|
+
const leftEl = itemEl.querySelector(".left");
|
|
1113
|
+
if (!leftEl) return;
|
|
1114
|
+
|
|
1115
|
+
// only update if not currently being edited
|
|
1116
|
+
if (leftEl.querySelector(".edit-textarea")) return;
|
|
1117
|
+
|
|
1118
|
+
leftEl.dataset.value = content;
|
|
1119
|
+
|
|
1120
|
+
const metaEl = leftEl.querySelector(".meta");
|
|
1121
|
+
const titleEl = leftEl.querySelector(".item-title");
|
|
1122
|
+
Array.from(leftEl.childNodes).forEach(n => {
|
|
1123
|
+
if (n !== metaEl && n !== titleEl) n.remove();
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
const isLink = content.startsWith("http");
|
|
1127
|
+
if (isLink) {
|
|
1128
|
+
const a = document.createElement("a");
|
|
1129
|
+
a.href = content;
|
|
1130
|
+
a.target = "_blank";
|
|
1131
|
+
a.innerText = content;
|
|
1132
|
+
leftEl.insertBefore(a, metaEl);
|
|
1133
|
+
} else {
|
|
1134
|
+
const wrap = document.createElement("div");
|
|
1135
|
+
wrap.innerHTML = renderText(content);
|
|
1136
|
+
leftEl.insertBefore(wrap, metaEl);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
socket.on("seen-update", ({ id, count }) => {
|
|
1142
|
+
const el = document.getElementById("seen-" + id);
|
|
1143
|
+
if (!el) return;
|
|
1144
|
+
if (count >= 2) {
|
|
1145
|
+
el.querySelector(".seen-num").innerText = count;
|
|
1146
|
+
el.style.display = "";
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
socket.on("user-count", count => {
|
|
1151
|
+
const el = document.getElementById("onlineCount");
|
|
1152
|
+
if (el) el.textContent = `● ${count} online`;
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
socket.on("item-moved", ({ id, channel: toChannel }) => {
|
|
1156
|
+
if (toChannel !== channel) {
|
|
1157
|
+
// item left this channel — remove it from view
|
|
1158
|
+
load();
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
socket.on("channel-added", (ch) => {
|
|
1163
|
+
if (!channels.find(c => c.name === ch.name)) {
|
|
1164
|
+
channels.push(ch);
|
|
1165
|
+
renderChannels();
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
socket.on("channel-deleted", ({ name }) => {
|
|
1170
|
+
channels = channels.filter(c => c.name !== name);
|
|
1171
|
+
if (channel === name) {
|
|
1172
|
+
channel = channels.length ? channels[0].name : null;
|
|
1173
|
+
load();
|
|
1174
|
+
}
|
|
1175
|
+
renderChannels();
|
|
1176
|
+
highlight();
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
socket.on("channel-renamed", ({ oldName, newName }) => {
|
|
1180
|
+
channels = channels.map(c => c.name === oldName ? { ...c, name: newName } : c);
|
|
1181
|
+
if (channel === oldName) channel = newName;
|
|
1182
|
+
renderChannels();
|
|
1183
|
+
highlight();
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
socket.on("channel-pin-update", ({ name, pinned }) => {
|
|
1187
|
+
if (pinned) {
|
|
1188
|
+
channels = channels.map(c => c.name === name ? { ...c, pinned } : c);
|
|
1189
|
+
channels.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
|
|
1190
|
+
renderChannels();
|
|
1191
|
+
highlight();
|
|
1192
|
+
} else {
|
|
1193
|
+
loadChannels();
|
|
1194
|
+
}
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
document.getElementById("search").addEventListener("input", async e => {
|
|
1198
|
+
const q = e.target.value.trim();
|
|
1199
|
+
|
|
1200
|
+
if (!q) {
|
|
1201
|
+
document.getElementById("loadMoreWrapper").style.display = "none";
|
|
1202
|
+
highlight();
|
|
1203
|
+
load();
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
document.querySelectorAll(".channels button")
|
|
1208
|
+
.forEach(b => b.classList.remove("active"));
|
|
1209
|
+
|
|
1210
|
+
document.getElementById("loadMoreWrapper").style.display = "none";
|
|
1211
|
+
|
|
1212
|
+
const res = await fetch(`/search/${q}`);
|
|
1213
|
+
const data = await res.json();
|
|
1214
|
+
renderGrouped(data);
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
document.addEventListener("paste", async e => {
|
|
1219
|
+
const active = document.activeElement;
|
|
1220
|
+
|
|
1221
|
+
// if user is typing in input, don't auto-send
|
|
1222
|
+
if (active && active.id === "msg") return;
|
|
1223
|
+
|
|
1224
|
+
// check for image in clipboard first
|
|
1225
|
+
const items = e.clipboardData.items;
|
|
1226
|
+
for (const item of items) {
|
|
1227
|
+
if (item.type.startsWith("image/")) {
|
|
1228
|
+
const file = item.getAsFile();
|
|
1229
|
+
if (!file) continue;
|
|
1230
|
+
|
|
1231
|
+
// give it a meaningful name with timestamp
|
|
1232
|
+
const ext = item.type.split("/")[1] || "png";
|
|
1233
|
+
const named = new File(
|
|
1234
|
+
[file],
|
|
1235
|
+
`pasted-${Date.now()}.${ext}`,
|
|
1236
|
+
{ type: item.type }
|
|
1237
|
+
);
|
|
1238
|
+
uploadFiles([named]);
|
|
1239
|
+
return; // image found, don't fall through to text
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
// no image — handle as text
|
|
1244
|
+
const text = e.clipboardData.getData("text");
|
|
1245
|
+
if (!text) return;
|
|
1246
|
+
|
|
1247
|
+
await fetch("/text", {
|
|
1248
|
+
method: "POST",
|
|
1249
|
+
headers: { "Content-Type": "application/json" },
|
|
1250
|
+
body: JSON.stringify({
|
|
1251
|
+
content: text,
|
|
1252
|
+
channel,
|
|
1253
|
+
uploader
|
|
1254
|
+
})
|
|
1255
|
+
});
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
async function uploadFiles(files) {
|
|
1259
|
+
if (!files || !files.length) return;
|
|
1260
|
+
|
|
1261
|
+
const status = document.getElementById("uploadStatus");
|
|
1262
|
+
const bar = document.getElementById("uploadBar");
|
|
1263
|
+
const text = document.getElementById("uploadText");
|
|
1264
|
+
|
|
1265
|
+
const total = files.length;
|
|
1266
|
+
const targetChannel = channel; // capture at start, won't change if user switches
|
|
1267
|
+
|
|
1268
|
+
for (let i = 0; i < total; i++) {
|
|
1269
|
+
const file = files[i];
|
|
1270
|
+
|
|
1271
|
+
status.style.display = "block";
|
|
1272
|
+
bar.style.width = "0%";
|
|
1273
|
+
text.innerText = total > 1
|
|
1274
|
+
? `Uploading ${i + 1} of ${total} · ${file.name}`
|
|
1275
|
+
: `Uploading: ${file.name}`;
|
|
1276
|
+
|
|
1277
|
+
await new Promise((resolve) => {
|
|
1278
|
+
const form = new FormData();
|
|
1279
|
+
form.append("file", file);
|
|
1280
|
+
form.append("channel", targetChannel);
|
|
1281
|
+
form.append("uploader", uploader);
|
|
1282
|
+
|
|
1283
|
+
const xhr = new XMLHttpRequest();
|
|
1284
|
+
xhr.open("POST", "/upload", true);
|
|
1285
|
+
|
|
1286
|
+
// Stall detection — if no progress for 30s, the connection is dead.
|
|
1287
|
+
// We don't use xhr.timeout because that would kill legitimate large
|
|
1288
|
+
// file uploads on slow LANs.
|
|
1289
|
+
// Instead we track the last progress event and abort if nothing moves for 30 seconds.
|
|
1290
|
+
const STALL_MS = 30_000;
|
|
1291
|
+
let stallTimer = null;
|
|
1292
|
+
|
|
1293
|
+
function resetStallTimer() {
|
|
1294
|
+
clearTimeout(stallTimer);
|
|
1295
|
+
stallTimer = setTimeout(() => {
|
|
1296
|
+
xhr.abort();
|
|
1297
|
+
text.innerText = `⚠ ${file.name} stalled — skipped`;
|
|
1298
|
+
bar.style.width = "0%";
|
|
1299
|
+
setTimeout(resolve, 1200);
|
|
1300
|
+
}, STALL_MS);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
xhr.upload.onprogress = e => {
|
|
1304
|
+
if (e.lengthComputable) {
|
|
1305
|
+
bar.style.width = Math.round((e.loaded / e.total) * 100) + "%";
|
|
1306
|
+
}
|
|
1307
|
+
resetStallTimer(); // data is moving, push the timer out
|
|
1308
|
+
};
|
|
1309
|
+
|
|
1310
|
+
xhr.onload = () => {
|
|
1311
|
+
clearTimeout(stallTimer);
|
|
1312
|
+
if (xhr.status === 413) {
|
|
1313
|
+
text.innerText = `⚠ ${file.name} is too large — skipped`;
|
|
1314
|
+
bar.style.width = "0%";
|
|
1315
|
+
setTimeout(resolve, 1200);
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
resolve();
|
|
1319
|
+
};
|
|
1320
|
+
|
|
1321
|
+
xhr.onerror = () => {
|
|
1322
|
+
clearTimeout(stallTimer);
|
|
1323
|
+
text.innerText = `⚠ ${file.name} failed — skipped`;
|
|
1324
|
+
bar.style.width = "0%";
|
|
1325
|
+
setTimeout(resolve, 1200);
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
xhr.onabort = () => {
|
|
1329
|
+
clearTimeout(stallTimer);
|
|
1330
|
+
// ontimeout/stall already set the message — only handle
|
|
1331
|
+
// explicit user-triggered aborts here if we add a cancel
|
|
1332
|
+
// button in future. For now just resolve to unblock queue.
|
|
1333
|
+
resolve();
|
|
1334
|
+
};
|
|
1335
|
+
|
|
1336
|
+
resetStallTimer(); // start timer — covers stall before first progress
|
|
1337
|
+
xhr.send(form);
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
status.style.display = "none";
|
|
1342
|
+
fileInput.value = "";
|
|
1343
|
+
// Delayed fallback — socket event should have already updated the DOM by now,
|
|
1344
|
+
// but this catches any case where the socket path didn't fire (e.g. network hiccup).
|
|
1345
|
+
setTimeout(() => load(), 500);
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
|
|
1349
|
+
const overlay = document.getElementById("dragOverlay");
|
|
1350
|
+
let dragCounter = 0;
|
|
1351
|
+
|
|
1352
|
+
/* DRAG ENTER */
|
|
1353
|
+
document.addEventListener("dragenter", e => {
|
|
1354
|
+
e.preventDefault();
|
|
1355
|
+
dragCounter++;
|
|
1356
|
+
overlay.style.display = "flex";
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
/* DRAG LEAVE */
|
|
1360
|
+
document.addEventListener("dragleave", e => {
|
|
1361
|
+
dragCounter--;
|
|
1362
|
+
if (dragCounter <= 0) {
|
|
1363
|
+
overlay.style.display = "none";
|
|
1364
|
+
dragCounter = 0;
|
|
1365
|
+
}
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
/*
|
|
1369
|
+
document.getElementById("addChannelBtn").onclick = async () => {
|
|
1370
|
+
if (channels.length >= 10) { alert("Maximum 10 channels allowed"); return; }
|
|
1371
|
+
const name = prompt("Channel name?");
|
|
1372
|
+
if (!name) return;
|
|
1373
|
+
|
|
1374
|
+
const res = await fetch("/channels", {
|
|
1375
|
+
method: "POST",
|
|
1376
|
+
headers: { "Content-Type": "application/json" },
|
|
1377
|
+
body: JSON.stringify({ name })
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
if (!res.ok) { const err = await res.json(); alert(err.error); return; }
|
|
1381
|
+
await loadChannels();
|
|
1382
|
+
};
|
|
1383
|
+
*/
|
|
1384
|
+
|
|
1385
|
+
function addChannelHandler() {
|
|
1386
|
+
return async () => {
|
|
1387
|
+
if (channels.length >= 10) { alert("Maximum 10 channels allowed"); return; }
|
|
1388
|
+
const name = prompt("Channel name?");
|
|
1389
|
+
if (!name) return;
|
|
1390
|
+
|
|
1391
|
+
const res = await fetch("/channels", {
|
|
1392
|
+
method: "POST",
|
|
1393
|
+
headers: { "Content-Type": "application/json" },
|
|
1394
|
+
body: JSON.stringify({ name })
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
if (!res.ok) { const err = await res.json(); alert(err.error); return; }
|
|
1398
|
+
await loadChannels();
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
document.getElementById("addChannelBtn").onclick = addChannelHandler();
|
|
1403
|
+
document.getElementById("addChannelBtnDesktop").onclick = addChannelHandler();
|
|
1404
|
+
|
|
1405
|
+
async function loadChannels() {
|
|
1406
|
+
const res = await fetch("/channels");
|
|
1407
|
+
const data = await res.json();
|
|
1408
|
+
channels = data; // [{ id, name, pinned }]
|
|
1409
|
+
if (!channel && channels.length) channel = channels[0].name;
|
|
1410
|
+
renderChannels();
|
|
1411
|
+
highlight();
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
function renderChannels() {
|
|
1416
|
+
const container = document.getElementById("channels");
|
|
1417
|
+
container.innerHTML = "";
|
|
1418
|
+
|
|
1419
|
+
channels.forEach(ch => {
|
|
1420
|
+
const btn = document.createElement("button");
|
|
1421
|
+
btn.id = "ch-" + ch.name;
|
|
1422
|
+
|
|
1423
|
+
btn.onclick = (e) => {
|
|
1424
|
+
if (e.target.classList.contains("ch-more")) return;
|
|
1425
|
+
setChannel(ch.name);
|
|
1426
|
+
};
|
|
1427
|
+
|
|
1428
|
+
btn.appendChild(document.createTextNode(ch.name));
|
|
1429
|
+
|
|
1430
|
+
if (ch.pinned) {
|
|
1431
|
+
const dot = document.createElement("span");
|
|
1432
|
+
dot.className = "ch-pin-dot";
|
|
1433
|
+
dot.title = "Pinned — protected from deletion";
|
|
1434
|
+
btn.appendChild(dot);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
if (unreadChannels.has(ch.name)) {
|
|
1438
|
+
const unread = document.createElement("span");
|
|
1439
|
+
unread.className = "ch-unread-dot";
|
|
1440
|
+
btn.appendChild(unread);
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const more = document.createElement("span");
|
|
1444
|
+
more.className = "ch-more";
|
|
1445
|
+
more.innerText = "⋯";
|
|
1446
|
+
more.title = "Channel options";
|
|
1447
|
+
more.onclick = (e) => {
|
|
1448
|
+
e.stopPropagation();
|
|
1449
|
+
showChannelMenu(e, ch);
|
|
1450
|
+
};
|
|
1451
|
+
btn.appendChild(more);
|
|
1452
|
+
|
|
1453
|
+
btn.addEventListener("contextmenu", (e) => {
|
|
1454
|
+
e.preventDefault();
|
|
1455
|
+
showChannelMenu(e, ch);
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
if (ch.name === channel) btn.classList.add("active");
|
|
1459
|
+
container.appendChild(btn);
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
function showChannelMenu(e, ch) {
|
|
1465
|
+
const menu = document.getElementById("channelMenu");
|
|
1466
|
+
|
|
1467
|
+
menu.innerHTML = `
|
|
1468
|
+
<button onclick="toggleChannelPin('${ch.name}')">
|
|
1469
|
+
${ch.pinned ? "📍 Unpin channel" : "📌 Pin channel"}
|
|
1470
|
+
</button>
|
|
1471
|
+
<div class="menu-divider"></div>
|
|
1472
|
+
<button onclick="renameChannelPrompt('${ch.name}')">✎ Rename</button>
|
|
1473
|
+
<button class="${ch.pinned ? "muted" : "danger"}"
|
|
1474
|
+
${ch.pinned ? "" : `onclick="deleteChannel('${ch.name}')"`}>
|
|
1475
|
+
🗑 Delete${ch.pinned ? " (pinned)" : ""}
|
|
1476
|
+
</button>
|
|
1477
|
+
`;
|
|
1478
|
+
|
|
1479
|
+
menu.style.top = e.clientY + "px";
|
|
1480
|
+
menu.style.left = e.clientX + "px";
|
|
1481
|
+
menu.classList.add("open");
|
|
1482
|
+
|
|
1483
|
+
requestAnimationFrame(() => {
|
|
1484
|
+
const rect = menu.getBoundingClientRect();
|
|
1485
|
+
if (rect.right > window.innerWidth - 8)
|
|
1486
|
+
menu.style.left = (e.clientX - rect.width) + "px";
|
|
1487
|
+
if (rect.bottom > window.innerHeight - 8)
|
|
1488
|
+
menu.style.top = (e.clientY - rect.height) + "px";
|
|
1489
|
+
});
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
document.addEventListener("click", () => {
|
|
1493
|
+
document.getElementById("channelMenu").classList.remove("open");
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
document.addEventListener("contextmenu", (e) => {
|
|
1497
|
+
if (!e.target.closest("#channels"))
|
|
1498
|
+
document.getElementById("channelMenu").classList.remove("open");
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
async function toggleChannelPin(name) {
|
|
1502
|
+
await fetch(`/channels/${name}/pin`, { method: "POST" });
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
async function renameChannelPrompt(name) {
|
|
1506
|
+
const newName = prompt("Rename channel to:", name);
|
|
1507
|
+
if (!newName || newName.trim() === name) return;
|
|
1508
|
+
|
|
1509
|
+
const res = await fetch("/channels/" + name, {
|
|
1510
|
+
method: "PATCH",
|
|
1511
|
+
headers: { "Content-Type": "application/json" },
|
|
1512
|
+
body: JSON.stringify({ name: newName.trim() })
|
|
1513
|
+
});
|
|
1514
|
+
|
|
1515
|
+
if (!res.ok) { const err = await res.json(); alert(err.error); }
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
async function deleteChannel(name) {
|
|
1519
|
+
const confirmed = confirm(`Delete "${name}"? All items will be permanently removed.`);
|
|
1520
|
+
if (!confirmed) return;
|
|
1521
|
+
|
|
1522
|
+
const res = await fetch("/channels/" + name, { method: "DELETE" });
|
|
1523
|
+
if (!res.ok) { const err = await res.json(); alert(err.error); }
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
/* DRAG OVER */
|
|
1527
|
+
document.addEventListener("dragover", e => {
|
|
1528
|
+
e.preventDefault();
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
/* DROP ANYWHERE */
|
|
1532
|
+
document.addEventListener("drop", async e => {
|
|
1533
|
+
e.preventDefault();
|
|
1534
|
+
overlay.style.display = "none";
|
|
1535
|
+
dragCounter = 0;
|
|
1536
|
+
|
|
1537
|
+
const files = e.dataTransfer.files;
|
|
1538
|
+
if (!files.length) return;
|
|
1539
|
+
uploadFiles(files);
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
// ========================
|
|
1543
|
+
// KEYBOARD SHORTCUTS
|
|
1544
|
+
// ========================
|
|
1545
|
+
document.addEventListener("keydown", e => {
|
|
1546
|
+
const active = document.activeElement;
|
|
1547
|
+
const isTyping = active && (active.id === "msg" || active.id === "search");
|
|
1548
|
+
|
|
1549
|
+
// Ctrl/Cmd + Enter — send message (only when msg is focused)
|
|
1550
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
|
|
1551
|
+
if (active && active.id === "msg") {
|
|
1552
|
+
e.preventDefault();
|
|
1553
|
+
e.stopPropagation();
|
|
1554
|
+
sendText();
|
|
1555
|
+
}
|
|
1556
|
+
return;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// Ctrl/Cmd + K — focus message input
|
|
1560
|
+
if ((e.ctrlKey || e.metaKey) && e.key === "k") {
|
|
1561
|
+
if (!isTyping) {
|
|
1562
|
+
e.preventDefault();
|
|
1563
|
+
document.getElementById("msg").focus();
|
|
1564
|
+
}
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// / — focus search
|
|
1569
|
+
if (e.key === "/" && !e.ctrlKey && !e.metaKey) {
|
|
1570
|
+
e.preventDefault();
|
|
1571
|
+
const search = document.getElementById("search");
|
|
1572
|
+
search.focus();
|
|
1573
|
+
search.select();
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
// Tab — cycle channels
|
|
1578
|
+
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.shiftKey) {
|
|
1579
|
+
e.preventDefault();
|
|
1580
|
+
if (!channels.length) return;
|
|
1581
|
+
const currentIndex = channels.findIndex(c => c.name === channel);
|
|
1582
|
+
const nextIndex = (currentIndex + 1) % channels.length;
|
|
1583
|
+
setChannel(channels[nextIndex].name);
|
|
1584
|
+
return;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Escape — close state in priority order
|
|
1588
|
+
if (e.key === "Escape") {
|
|
1589
|
+
|
|
1590
|
+
// 1. blur any focused input first
|
|
1591
|
+
if (active && (active.id === "msg" || active.id === "search")) {
|
|
1592
|
+
if (active.id === "search" && active.value) {
|
|
1593
|
+
active.value = "";
|
|
1594
|
+
highlight();
|
|
1595
|
+
load();
|
|
1596
|
+
}
|
|
1597
|
+
active.blur();
|
|
1598
|
+
return;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// 2. close open preview
|
|
1602
|
+
if (openPreviewId) {
|
|
1603
|
+
const panel = document.getElementById("preview-" + openPreviewId);
|
|
1604
|
+
const btn = document.getElementById("prevbtn-" + openPreviewId);
|
|
1605
|
+
if (panel) panel.classList.remove("open");
|
|
1606
|
+
if (btn) btn.classList.remove("preview-active");
|
|
1607
|
+
openPreviewId = null;
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// 3. close move dropdown
|
|
1612
|
+
if (openDropdown) {
|
|
1613
|
+
openDropdown.classList.remove("open");
|
|
1614
|
+
openDropdown = null;
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// 4. close context menu
|
|
1619
|
+
const contextMenu = document.getElementById("channelMenu");
|
|
1620
|
+
if (contextMenu.classList.contains("open")) {
|
|
1621
|
+
contextMenu.classList.remove("open");
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
// 5. close QR card
|
|
1626
|
+
const qrCard = document.getElementById("qrCard");
|
|
1627
|
+
if (qrCard.classList.contains("open")) {
|
|
1628
|
+
qrCard.classList.remove("open");
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// All remaining shortcuts — skip if typing
|
|
1634
|
+
if (isTyping) return;
|
|
1635
|
+
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
(async function init() {
|
|
1639
|
+
await applyBranding();
|
|
1640
|
+
await initName();
|
|
1641
|
+
const infoRes = await fetch("/info");
|
|
1642
|
+
const info = await infoRes.json();
|
|
1643
|
+
if (!info.hasAuth) {
|
|
1644
|
+
document.getElementById("logoutBtn").style.display = "none";
|
|
1645
|
+
}
|
|
1646
|
+
retention = info.retention; // null if "never", ms value otherwise
|
|
1647
|
+
await loadChannels();
|
|
1648
|
+
load();
|
|
1614
1649
|
})();
|