instbyte 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,946 @@
1
+ const socket = io();
2
+
3
+ async function applyBranding() {
4
+ try {
5
+ const res = await fetch("/branding");
6
+ const b = await res.json();
7
+
8
+ // Update page title and app name
9
+ document.title = b.appName;
10
+ const nameEl = document.getElementById("appName");
11
+ if (nameEl) nameEl.innerText = b.appName;
12
+
13
+ // Update logo src to dynamic route
14
+ const logoEl = document.getElementById("appLogo");
15
+ if (logoEl) logoEl.src = "/logo-dynamic.png";
16
+
17
+ // Inject CSS variables
18
+ const p = b.palette;
19
+ const root = document.documentElement;
20
+ root.style.setProperty("--color-primary", p.primary);
21
+ root.style.setProperty("--color-primary-hover", p.primaryHover);
22
+ root.style.setProperty("--color-primary-light", p.primaryLight);
23
+ root.style.setProperty("--color-primary-dark", p.primaryDark);
24
+ root.style.setProperty("--color-on-primary", p.onPrimary);
25
+ root.style.setProperty("--color-secondary", p.secondary);
26
+ root.style.setProperty("--color-secondary-hover", p.secondaryHover);
27
+ root.style.setProperty("--color-secondary-light", p.secondaryLight);
28
+ root.style.setProperty("--color-on-secondary", p.onSecondary);
29
+
30
+ } catch (e) {
31
+ // Branding failed — default styles remain, no crash
32
+ }
33
+ }
34
+ function formatSize(bytes) {
35
+ if (!bytes) return "";
36
+ if (bytes >= 1024 ** 3) return (bytes / 1024 ** 3).toFixed(1) + " GB";
37
+ if (bytes >= 1024 ** 2) return (bytes / 1024 ** 2).toFixed(1) + " MB";
38
+ if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KB";
39
+ return bytes + " B";
40
+ }
41
+
42
+ function getSizeTag(bytes) {
43
+ if (!bytes) return "";
44
+ const mb = bytes / (1024 * 1024);
45
+ if (mb > 1024) return "danger-dark";
46
+ if (mb > 500) return "danger-light";
47
+ if (mb > 100) return "warn";
48
+ return "";
49
+ }
50
+
51
+ // Configure marked
52
+ marked.setOptions({
53
+ breaks: true,
54
+ gfm: true
55
+ });
56
+
57
+ function looksLikeMarkdown(text) {
58
+ return /^#{1,3} |[*_`~]|\[.+\]\(.+\)|^[-*+] |^\d+\. |^```/m.test(text);
59
+ }
60
+
61
+ function renderText(text) {
62
+ if (!text) return "";
63
+ if (looksLikeMarkdown(text)) {
64
+ const html = marked.parse(text);
65
+ // highlight code blocks after parse
66
+ const wrap = document.createElement("div");
67
+ wrap.innerHTML = html;
68
+ wrap.querySelectorAll("pre code").forEach(el => hljs.highlightElement(el));
69
+ return `<div class="markdown-body">${wrap.innerHTML}</div>`;
70
+ }
71
+ // plain text — just escape and preserve newlines
72
+ return text
73
+ .replace(/&/g, "&amp;")
74
+ .replace(/</g, "&lt;")
75
+ .replace(/>/g, "&gt;")
76
+ .replace(/\n/g, "<br>");
77
+ }
78
+
79
+ const TEXT_EXTENSIONS = [
80
+ 'txt', 'md', 'js', 'ts', 'jsx', 'tsx', 'json', 'json5',
81
+ 'css', 'html', 'htm', 'xml', 'svg', 'sh', 'bash', 'zsh',
82
+ 'py', 'rb', 'php', 'java', 'c', 'cpp', 'h', 'cs', 'go',
83
+ 'rs', 'swift', 'kt', 'yaml', 'yml', 'toml', 'ini', 'env',
84
+ 'gitignore', 'dockerfile', 'sql', 'csv', 'log'
85
+ ];
86
+
87
+ const MAX_TEXT_PREVIEW_BYTES = 50 * 1024; // 50KB — beyond this we warn
88
+ const MAX_TEXT_LINES = 200;
89
+
90
+ function getPreviewType(filename) {
91
+ if (!filename) return "none";
92
+ const ext = filename.split(".").pop().toLowerCase();
93
+ if (/^(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(ext)) return "image";
94
+ if (/^(mp4|webm|ogg|mov)$/.test(ext)) return "video";
95
+ if (/^(mp3|wav|ogg|m4a|flac|aac)$/.test(ext)) return "audio";
96
+ if (ext === "pdf") return "pdf";
97
+ if (TEXT_EXTENSIONS.includes(ext)) return "text";
98
+ return "none";
99
+ }
100
+
101
+ function getLanguage(filename) {
102
+ const ext = filename.split(".").pop().toLowerCase();
103
+ const map = {
104
+ js: "javascript", ts: "typescript", jsx: "javascript",
105
+ tsx: "typescript", py: "python", rb: "ruby", php: "php",
106
+ java: "java", c: "c", cpp: "cpp", h: "c", cs: "csharp",
107
+ go: "go", rs: "rust", swift: "swift", kt: "kotlin",
108
+ html: "html", htm: "html", css: "css", json: "json",
109
+ json5: "json", xml: "xml", svg: "xml", sh: "bash",
110
+ bash: "bash", zsh: "bash", yaml: "yaml", yml: "yaml",
111
+ toml: "toml", sql: "sql", md: "markdown", csv: "plaintext",
112
+ txt: "plaintext", log: "plaintext", env: "plaintext",
113
+ dockerfile: "dockerfile"
114
+ };
115
+ return map[ext] || "plaintext";
116
+ }
117
+
118
+ let openPreviewId = null;
119
+
120
+ async function togglePreview(id, filename) {
121
+ const panel = document.getElementById("preview-" + id);
122
+ const btn = document.getElementById("prevbtn-" + id);
123
+ if (!panel || !btn) return;
124
+
125
+ const isOpen = panel.classList.contains("open");
126
+
127
+ // Close any other open preview first
128
+ if (openPreviewId && openPreviewId !== id) {
129
+ const otherPanel = document.getElementById("preview-" + openPreviewId);
130
+ const otherBtn = document.getElementById("prevbtn-" + openPreviewId);
131
+ if (otherPanel) otherPanel.classList.remove("open");
132
+ if (otherBtn) otherBtn.classList.remove("preview-active");
133
+ }
134
+
135
+ if (isOpen) {
136
+ panel.classList.remove("open");
137
+ btn.classList.remove("preview-active");
138
+ openPreviewId = null;
139
+ return;
140
+ }
141
+
142
+ // Open this one
143
+ panel.classList.add("open");
144
+ btn.classList.add("preview-active");
145
+ openPreviewId = id;
146
+
147
+ // Only build content once
148
+ if (panel.dataset.loaded) return;
149
+ panel.dataset.loaded = "true";
150
+
151
+ const type = getPreviewType(filename);
152
+ const url = `/uploads/${filename}`;
153
+
154
+ if (type === "image") {
155
+ panel.innerHTML = `<img src="${url}" alt="${filename}">`;
156
+
157
+ } else if (type === "video") {
158
+ panel.innerHTML = `
159
+ <video controls preload="metadata">
160
+ <source src="${url}">
161
+ Your browser doesn't support video preview.
162
+ </video>`;
163
+
164
+ } else if (type === "audio") {
165
+ panel.innerHTML = `
166
+ <audio controls preload="metadata">
167
+ <source src="${url}">
168
+ Your browser doesn't support audio preview.
169
+ </audio>`;
170
+
171
+ } else if (type === "pdf") {
172
+ panel.innerHTML = `<embed src="${url}" type="application/pdf">`;
173
+
174
+ } else if (type === "text") {
175
+ panel.innerHTML = `<div class="preview-loading">Loading...</div>`;
176
+
177
+ try {
178
+ const res = await fetch(url);
179
+
180
+ if (!res.ok) throw new Error("Failed to load");
181
+
182
+ // Check size via content-length header before reading
183
+ const contentLength = res.headers.get("content-length");
184
+ if (contentLength && parseInt(contentLength) > MAX_TEXT_PREVIEW_BYTES) {
185
+ panel.innerHTML = `
186
+ <div class="preview-error">
187
+ File is too large to preview (${formatSize(parseInt(contentLength))}).
188
+ <a href="${url}" target="_blank">Open in new tab</a>
189
+ </div>`;
190
+ return;
191
+ }
192
+
193
+ const text = await res.text();
194
+ const lines = text.split("\n");
195
+ const truncated = lines.length > MAX_TEXT_LINES;
196
+ const preview = truncated
197
+ ? lines.slice(0, MAX_TEXT_LINES).join("\n")
198
+ : text;
199
+
200
+ const lang = getLanguage(filename);
201
+ const code = document.createElement("code");
202
+ code.className = `language-${lang}`;
203
+ code.textContent = preview;
204
+
205
+ const pre = document.createElement("pre");
206
+ pre.appendChild(code);
207
+ hljs.highlightElement(code);
208
+
209
+ panel.innerHTML = "";
210
+ panel.appendChild(pre);
211
+
212
+ if (truncated) {
213
+ const note = document.createElement("div");
214
+ note.className = "preview-truncated";
215
+ note.innerHTML = `Showing first ${MAX_TEXT_LINES} of ${lines.length} lines. <a href="${url}" target="_blank">View full file</a>`;
216
+ panel.appendChild(note);
217
+ }
218
+
219
+ } catch (err) {
220
+ panel.innerHTML = `
221
+ <div class="preview-error">
222
+ Could not load preview. <a href="${url}" target="_blank">Open directly</a>
223
+ </div>`;
224
+ }
225
+ }
226
+ }
227
+
228
+ function handleRowClick(el, type, value) {
229
+ if (type === "text") {
230
+ navigator.clipboard.writeText(value).then(() => {
231
+ el.classList.add("flash");
232
+ setTimeout(() => el.classList.remove("flash"), 800);
233
+ });
234
+ } else {
235
+ const a = document.createElement("a");
236
+ a.href = value;
237
+ a.download = "";
238
+ a.click();
239
+ }
240
+ }
241
+
242
+ document.getElementById("items").addEventListener("click", e => {
243
+ const left = e.target.closest(".left");
244
+ if (!left) return;
245
+ const type = left.dataset.type;
246
+ const value = left.dataset.value;
247
+ handleRowClick(left, type, value);
248
+ });
249
+
250
+ let openDropdown = null;
251
+
252
+ function toggleMoveDropdown(e, id, currentChannel) {
253
+ e.stopPropagation();
254
+
255
+ // close any open one first
256
+ if (openDropdown && openDropdown !== e.currentTarget.nextElementSibling) {
257
+ openDropdown.classList.remove("open");
258
+ }
259
+
260
+ const dropdown = e.currentTarget.nextElementSibling;
261
+ const isOpen = dropdown.classList.contains("open");
262
+
263
+ if (isOpen) {
264
+ dropdown.classList.remove("open");
265
+ openDropdown = null;
266
+ return;
267
+ }
268
+
269
+ // build channel list fresh each time (channels may have changed)
270
+ const others = channels.filter(c => c.name !== currentChannel);
271
+ dropdown.innerHTML = `<div class="dropdown-label">Move to</div>`;
272
+
273
+ others.forEach(ch => {
274
+ const btn = document.createElement("button");
275
+ btn.innerText = ch.name;
276
+ btn.onclick = (ev) => {
277
+ ev.stopPropagation();
278
+ moveItem(id, ch.name);
279
+ dropdown.classList.remove("open");
280
+ openDropdown = null;
281
+ };
282
+ dropdown.appendChild(btn);
283
+ });
284
+
285
+ dropdown.classList.add("open");
286
+ openDropdown = dropdown;
287
+ }
288
+
289
+ async function moveItem(id, toChannel) {
290
+ await fetch(`/item/${id}/move`, {
291
+ method: "PATCH",
292
+ headers: { "Content-Type": "application/json" },
293
+ body: JSON.stringify({ channel: toChannel })
294
+ });
295
+ // socket will handle the re-render via item-moved event
296
+ }
297
+
298
+ document.addEventListener("click", () => {
299
+ if (openDropdown) {
300
+ openDropdown.classList.remove("open");
301
+ openDropdown = null;
302
+ }
303
+ });
304
+
305
+ let channel = null;
306
+ let channels = [];
307
+
308
+ let uploader = localStorage.getItem("name") || "";
309
+
310
+ async function initName() {
311
+ if (!uploader || uploader === "null" || uploader.trim() === "") {
312
+ let suggested = "USER-" + Math.floor(Math.random() * 1000);
313
+ const ua = navigator.userAgent;
314
+ if (/android/i.test(ua)) suggested = "Android-" + Math.floor(Math.random() * 1000);
315
+ else if (/iphone|ipad/i.test(ua)) suggested = "iPhone-" + Math.floor(Math.random() * 1000);
316
+ else if (/mac/i.test(ua)) suggested = "Mac-" + Math.floor(Math.random() * 1000);
317
+ else if (/windows/i.test(ua)) suggested = "Windows-" + Math.floor(Math.random() * 1000);
318
+ else if (/linux/i.test(ua)) suggested = "Linux-" + Math.floor(Math.random() * 1000);
319
+ else suggested = "User-" + Math.floor(Math.random() * 1000);
320
+ uploader = prompt("Your name?", suggested) || suggested;
321
+ localStorage.setItem("name", uploader);
322
+ }
323
+ document.getElementById("who").innerText = "You: " + uploader;
324
+ socket.emit("join", uploader);
325
+ }
326
+
327
+ function highlight() {
328
+ document.querySelectorAll(".channels button")
329
+ .forEach(b => b.classList.remove("active"));
330
+ const el = document.getElementById("ch-" + channel);
331
+ if (el) el.classList.add("active");
332
+ }
333
+
334
+ function setChannel(c) {
335
+ channel = c;
336
+ renderChannels();
337
+ highlight();
338
+ load();
339
+ }
340
+
341
+ async function load() {
342
+ const res = await fetch("/items/" + channel);
343
+ const data = await res.json();
344
+ render(data);
345
+ }
346
+
347
+ function render(data) {
348
+ const el = document.getElementById("items");
349
+ el.innerHTML = "";
350
+
351
+ if (!data.length) {
352
+ el.innerHTML = `<div class="empty-state">Nothing here yet — paste, type, or drop a file to share</div>`;
353
+ return;
354
+ }
355
+
356
+ data.forEach(i => {
357
+ const div = document.createElement("div");
358
+ div.className = "item";
359
+
360
+ let content = "";
361
+
362
+ if (i.type === "file") {
363
+ const isImg = i.filename.match(/\.(jpg|png|jpeg|gif)$/i);
364
+ const sizeLabel = formatSize(i.size);
365
+ const sizeClass = getSizeTag(i.size);
366
+ const sizeTag = sizeClass
367
+ ? `<span class="size-tag ${sizeClass}">${sizeLabel}</span>`
368
+ : sizeLabel
369
+ ? `<span style="font-size:11px;color:#9ca3af;margin-left:6px;">${sizeLabel}</span>`
370
+ : "";
371
+
372
+ if (isImg) {
373
+ content = `<img src="/uploads/${i.filename}" style="max-width:200px;border-radius:6px"><br>
374
+ <a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
375
+ } else {
376
+ content = `<a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
377
+ }
378
+ } else {
379
+ const isLink = i.content && i.content.startsWith("http");
380
+ if (isLink) {
381
+ content = `<a href="${i.content}" target="_blank">${i.content}</a>`;
382
+ } else {
383
+ content = renderText(i.content);
384
+ }
385
+ }
386
+
387
+ const pinText = i.pinned ? "unpin" : "pin";
388
+
389
+ const isFile = i.type === "file";
390
+ const clickValue = isFile
391
+ ? `/uploads/${i.filename}`
392
+ : i.content;
393
+ const tooltip = isFile ? "Click to download" : "Click to copy";
394
+
395
+ div.innerHTML = `
396
+ <div class="item-top">
397
+ <div class="left"
398
+ data-tooltip="${i.type === 'file' ? 'Click to download' : 'Click to copy'}"
399
+ data-type="${i.type === 'file' ? 'file' : 'text'}"
400
+ data-value="${i.type === 'file'
401
+ ? `/uploads/${i.filename}`
402
+ : (i.content || '').replace(/"/g, '&quot;')}">
403
+ ${content}
404
+ <div class="meta">${i.uploader}</div>
405
+ </div>
406
+ <div class="item-actions">
407
+ ${getPreviewType(i.filename) !== "none" && getPreviewType(i.filename) !== "image"
408
+ ? `<button class="icon-btn" id="prevbtn-${i.id}"
409
+ onclick="togglePreview(${i.id}, '${i.filename}')"
410
+ title="Preview">👁</button>`
411
+ : ""}
412
+ <button class="icon-btn" onclick="pin(${i.id})" title="${i.pinned ? 'Unpin' : 'Pin'}">
413
+ ${i.pinned ? "📍" : "📌"}
414
+ </button>
415
+ <div class="move-wrapper">
416
+ <button class="icon-btn" title="Move to channel"
417
+ onclick="toggleMoveDropdown(event, ${i.id}, '${i.channel}')">⇄</button>
418
+ <div class="move-dropdown"></div>
419
+ </div>
420
+ <button class="icon-btn delete" onclick="del(${i.id}, ${i.pinned})" title="Delete">🗑</button>
421
+
422
+ </div>
423
+ </div>
424
+ <div class="preview-panel" id="preview-${i.id}"></div>
425
+ `;
426
+ el.appendChild(div);
427
+ });
428
+ }
429
+
430
+ function renderGrouped(data) {
431
+ const el = document.getElementById("items");
432
+ el.innerHTML = "";
433
+
434
+ if (!data.length) {
435
+ el.innerHTML = "<div style='color:#6b7280;margin-top:10px;'>No results</div>";
436
+ return;
437
+ }
438
+
439
+ // group by channel
440
+ const grouped = {};
441
+ data.forEach(item => {
442
+ if (!grouped[item.channel]) grouped[item.channel] = [];
443
+ grouped[item.channel].push(item);
444
+ });
445
+
446
+ Object.keys(grouped).forEach(ch => {
447
+ const section = document.createElement("div");
448
+ section.style.marginTop = "20px";
449
+
450
+ section.innerHTML = `
451
+ <div style="font-size:13px;font-weight:600;color:#6b7280;margin-bottom:8px;">
452
+ ${ch.toUpperCase()}
453
+ </div>
454
+ `;
455
+
456
+ grouped[ch].forEach(i => {
457
+ const div = document.createElement("div");
458
+ div.className = "item";
459
+
460
+ let content = "";
461
+
462
+ if (i.type === "file") {
463
+ const isImg = i.filename.match(/\.(jpg|png|jpeg|gif)$/i);
464
+ const sizeLabel = formatSize(i.size);
465
+ const sizeClass = getSizeTag(i.size);
466
+ const sizeTag = sizeClass
467
+ ? `<span class="size-tag ${sizeClass}">${sizeLabel}</span>`
468
+ : sizeLabel
469
+ ? `<span style="font-size:11px;color:#9ca3af;margin-left:6px;">${sizeLabel}</span>`
470
+ : "";
471
+
472
+ if (isImg) {
473
+ content = `<img src="/uploads/${i.filename}" style="max-width:200px;border-radius:6px"><br>
474
+ <a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
475
+ } else {
476
+ content = `<a href="/uploads/${i.filename}" target="_blank">${i.filename}</a>${sizeTag}`;
477
+ }
478
+ } else {
479
+ const isLink = i.content && i.content.startsWith("http");
480
+ if (isLink) {
481
+ content = `<a href="${i.content}" target="_blank">${i.content}</a>`;
482
+ } else {
483
+ content = renderText(i.content);
484
+ }
485
+ }
486
+
487
+ const isFile = i.type === "file";
488
+ const clickValue = isFile
489
+ ? `/uploads/${i.filename}`
490
+ : i.content;
491
+ const tooltip = isFile ? "Click to download" : "Click to copy";
492
+
493
+ div.innerHTML = `
494
+ <div class="item-top">
495
+ <div class="left"
496
+ data-tooltip="${i.type === 'file' ? 'Click to download' : 'Click to copy'}"
497
+ data-type="${i.type === 'file' ? 'file' : 'text'}"
498
+ data-value="${i.type === 'file'
499
+ ? `/uploads/${i.filename}`
500
+ : (i.content || '').replace(/"/g, '&quot;')}">
501
+ ${content}
502
+ <div class="meta">${i.uploader}</div>
503
+ </div>
504
+ <div class="item-actions">
505
+ ${getPreviewType(i.filename) !== "none" && getPreviewType(i.filename) !== "image"
506
+ ? `<button class="icon-btn" id="prevbtn-${i.id}"
507
+ onclick="togglePreview(${i.id}, '${i.filename}')"
508
+ title="Preview">👁</button>`
509
+ : ""}
510
+ <button class="icon-btn" onclick="pin(${i.id})" title="${i.pinned ? 'Unpin' : 'Pin'}">
511
+ ${i.pinned ? "📍" : "📌"}
512
+ </button>
513
+ <div class="move-wrapper">
514
+ <button class="icon-btn" title="Move to channel"
515
+ onclick="toggleMoveDropdown(event, ${i.id}, '${i.channel}')">⇄</button>
516
+ <div class="move-dropdown"></div>
517
+ </div>
518
+ <button class="icon-btn delete" onclick="del(${i.id}, ${i.pinned})" title="Delete">🗑</button>
519
+
520
+ </div>
521
+ </div>
522
+ <div class="preview-panel" id="preview-${i.id}"></div>
523
+ `;
524
+
525
+ section.appendChild(div);
526
+ });
527
+
528
+ el.appendChild(section);
529
+ });
530
+ }
531
+
532
+ async function sendText() {
533
+ const input = document.getElementById("msg");
534
+ const text = input.value.trim();
535
+ if (!text) return;
536
+
537
+ await fetch("/text", {
538
+ method: "POST",
539
+ headers: { "Content-Type": "application/json" },
540
+ body: JSON.stringify({ content: text, channel, uploader })
541
+ });
542
+
543
+ input.value = "";
544
+ }
545
+
546
+ function handleEnter(e) {
547
+ if (e.key === "Enter" && !e.shiftKey) {
548
+ e.preventDefault();
549
+ sendText();
550
+ }
551
+ }
552
+
553
+ function changeName() {
554
+ const n = prompt("Enter name:");
555
+ if (!n) return;
556
+ uploader = n;
557
+ localStorage.setItem("name", n);
558
+ document.getElementById("who").innerText = "You: " + uploader;
559
+ }
560
+
561
+ let qrLoaded = false;
562
+
563
+ async function toggleQR() {
564
+ const card = document.getElementById("qrCard");
565
+ card.classList.toggle("open");
566
+
567
+ if (card.classList.contains("open") && !qrLoaded) {
568
+ const res = await fetch("/info");
569
+ const { url } = await res.json();
570
+ document.getElementById("qrUrl").innerText = url;
571
+ document.getElementById("qrImg").src =
572
+ `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(url)}`;
573
+ qrLoaded = true;
574
+ }
575
+ }
576
+
577
+ document.addEventListener("click", e => {
578
+ const widget = document.querySelector(".qr-widget");
579
+ if (!widget.contains(e.target)) {
580
+ document.getElementById("qrCard").classList.remove("open");
581
+ }
582
+ });
583
+
584
+ const fileInput = document.getElementById("fileInput");
585
+
586
+ fileInput.onchange = () => {
587
+ const file = fileInput.files[0];
588
+ if (file) uploadFile(file);
589
+ };
590
+
591
+ async function del(id, pinned) {
592
+ if (pinned) {
593
+ const confirmed = confirm("This item is pinned. Are you sure you want to delete it?");
594
+ if (!confirmed) return;
595
+ }
596
+ await fetch("/item/" + id, { method: "DELETE" });
597
+ }
598
+
599
+ async function pin(id) {
600
+ await fetch("/pin/" + id, { method: "POST" });
601
+ load();
602
+ }
603
+
604
+
605
+ async function logout() {
606
+ await fetch("/logout", { method: "POST" });
607
+ window.location.href = "/login";
608
+ }
609
+
610
+ socket.on("new-item", item => {
611
+ if (item.channel === channel) load();
612
+ });
613
+
614
+ socket.on("delete-item", id => {
615
+ load();
616
+ });
617
+
618
+ socket.on("item-moved", ({ id, channel: toChannel }) => {
619
+ if (toChannel !== channel) {
620
+ // item left this channel — remove it from view
621
+ load();
622
+ }
623
+ });
624
+
625
+ socket.on("channel-added", (ch) => {
626
+ if (!channels.find(c => c.name === ch.name)) {
627
+ channels.push(ch);
628
+ renderChannels();
629
+ }
630
+ });
631
+
632
+ socket.on("channel-deleted", ({ name }) => {
633
+ channels = channels.filter(c => c.name !== name);
634
+ if (channel === name) {
635
+ channel = channels.length ? channels[0].name : null;
636
+ load();
637
+ }
638
+ renderChannels();
639
+ highlight();
640
+ });
641
+
642
+ socket.on("channel-renamed", ({ oldName, newName }) => {
643
+ channels = channels.map(c => c.name === oldName ? { ...c, name: newName } : c);
644
+ if (channel === oldName) channel = newName;
645
+ renderChannels();
646
+ highlight();
647
+ });
648
+
649
+ socket.on("channel-pin-update", ({ name, pinned }) => {
650
+ if (pinned) {
651
+ channels = channels.map(c => c.name === name ? { ...c, pinned } : c);
652
+ channels.sort((a, b) => (b.pinned ? 1 : 0) - (a.pinned ? 1 : 0));
653
+ renderChannels();
654
+ highlight();
655
+ } else {
656
+ loadChannels();
657
+ }
658
+ });
659
+
660
+ document.getElementById("search").addEventListener("input", async e => {
661
+ const q = e.target.value.trim();
662
+
663
+ if (!q) {
664
+ highlight();
665
+ load();
666
+ return;
667
+ }
668
+
669
+ // remove active tab highlight while searching
670
+ document.querySelectorAll(".channels button")
671
+ .forEach(b => b.classList.remove("active"));
672
+
673
+ const res = await fetch(`/search/${q}`);
674
+ const data = await res.json();
675
+ renderGrouped(data);
676
+ });
677
+
678
+
679
+ document.addEventListener("paste", async e => {
680
+ const active = document.activeElement;
681
+
682
+ // if user is typing in input, don't auto-send
683
+ if (active && active.id === "msg") return;
684
+
685
+ const text = e.clipboardData.getData("text");
686
+ if (!text) return;
687
+
688
+ await fetch("/text", {
689
+ method: "POST",
690
+ headers: { "Content-Type": "application/json" },
691
+ body: JSON.stringify({
692
+ content: text,
693
+ channel,
694
+ uploader
695
+ })
696
+ });
697
+ });
698
+
699
+ function uploadFile(file) {
700
+ const status = document.getElementById("uploadStatus");
701
+ const bar = document.getElementById("uploadBar");
702
+ const text = document.getElementById("uploadText");
703
+
704
+ status.style.display = "block";
705
+ bar.style.width = "0%";
706
+ text.innerText = "Uploading: " + file.name;
707
+
708
+ const form = new FormData();
709
+ form.append("file", file);
710
+ form.append("channel", channel);
711
+ form.append("uploader", uploader);
712
+
713
+ const xhr = new XMLHttpRequest();
714
+ xhr.open("POST", "/upload", true);
715
+
716
+ xhr.upload.onprogress = e => {
717
+ if (e.lengthComputable) {
718
+ const percent = Math.round((e.loaded / e.total) * 100);
719
+ bar.style.width = percent + "%";
720
+ }
721
+ };
722
+
723
+ xhr.onload = () => {
724
+ status.style.display = "none";
725
+ if (xhr.status === 413) {
726
+ alert("File too large — 2GB maximum allowed.");
727
+ fileInput.value = "";
728
+ return;
729
+ }
730
+ fileInput.value = "";
731
+ load();
732
+ };
733
+
734
+ xhr.onerror = () => {
735
+ status.style.display = "none";
736
+ alert("Upload failed");
737
+ };
738
+
739
+ xhr.send(form);
740
+ }
741
+
742
+
743
+ const overlay = document.getElementById("dragOverlay");
744
+ let dragCounter = 0;
745
+
746
+ /* DRAG ENTER */
747
+ document.addEventListener("dragenter", e => {
748
+ e.preventDefault();
749
+ dragCounter++;
750
+ overlay.style.display = "flex";
751
+ });
752
+
753
+ /* DRAG LEAVE */
754
+ document.addEventListener("dragleave", e => {
755
+ dragCounter--;
756
+ if (dragCounter <= 0) {
757
+ overlay.style.display = "none";
758
+ dragCounter = 0;
759
+ }
760
+ });
761
+
762
+ /*
763
+ document.getElementById("addChannelBtn").onclick = async () => {
764
+ if (channels.length >= 10) { alert("Maximum 10 channels allowed"); return; }
765
+ const name = prompt("Channel name?");
766
+ if (!name) return;
767
+
768
+ const res = await fetch("/channels", {
769
+ method: "POST",
770
+ headers: { "Content-Type": "application/json" },
771
+ body: JSON.stringify({ name })
772
+ });
773
+
774
+ if (!res.ok) { const err = await res.json(); alert(err.error); return; }
775
+ await loadChannels();
776
+ };
777
+ */
778
+
779
+ function addChannelHandler() {
780
+ return async () => {
781
+ if (channels.length >= 10) { alert("Maximum 10 channels allowed"); return; }
782
+ const name = prompt("Channel name?");
783
+ if (!name) return;
784
+
785
+ const res = await fetch("/channels", {
786
+ method: "POST",
787
+ headers: { "Content-Type": "application/json" },
788
+ body: JSON.stringify({ name })
789
+ });
790
+
791
+ if (!res.ok) { const err = await res.json(); alert(err.error); return; }
792
+ await loadChannels();
793
+ };
794
+ }
795
+
796
+ document.getElementById("addChannelBtn").onclick = addChannelHandler();
797
+ document.getElementById("addChannelBtnDesktop").onclick = addChannelHandler();
798
+
799
+ async function loadChannels() {
800
+ const res = await fetch("/channels");
801
+ const data = await res.json();
802
+ channels = data; // [{ id, name, pinned }]
803
+ if (!channel && channels.length) channel = channels[0].name;
804
+ renderChannels();
805
+ highlight();
806
+ }
807
+
808
+
809
+ function renderChannels() {
810
+ const container = document.getElementById("channels");
811
+ container.innerHTML = "";
812
+
813
+ channels.forEach(ch => {
814
+ const btn = document.createElement("button");
815
+ btn.id = "ch-" + ch.name;
816
+
817
+ btn.onclick = (e) => {
818
+ if (e.target.classList.contains("ch-more")) return;
819
+ setChannel(ch.name);
820
+ };
821
+
822
+ btn.appendChild(document.createTextNode(ch.name));
823
+
824
+ if (ch.pinned) {
825
+ const dot = document.createElement("span");
826
+ dot.className = "ch-pin-dot";
827
+ dot.title = "Pinned — protected from deletion";
828
+ btn.appendChild(dot);
829
+ }
830
+
831
+ const more = document.createElement("span");
832
+ more.className = "ch-more";
833
+ more.innerText = "⋯";
834
+ more.title = "Channel options";
835
+ more.onclick = (e) => {
836
+ e.stopPropagation();
837
+ showChannelMenu(e, ch);
838
+ };
839
+ btn.appendChild(more);
840
+
841
+ btn.addEventListener("contextmenu", (e) => {
842
+ e.preventDefault();
843
+ showChannelMenu(e, ch);
844
+ });
845
+
846
+ if (ch.name === channel) btn.classList.add("active");
847
+ container.appendChild(btn);
848
+ });
849
+
850
+ }
851
+
852
+ function showChannelMenu(e, ch) {
853
+ const menu = document.getElementById("channelMenu");
854
+
855
+ menu.innerHTML = `
856
+ <button onclick="toggleChannelPin('${ch.name}')">
857
+ ${ch.pinned ? "📍 Unpin channel" : "📌 Pin channel"}
858
+ </button>
859
+ <div class="menu-divider"></div>
860
+ <button onclick="renameChannelPrompt('${ch.name}')">✎&nbsp; Rename</button>
861
+ <button class="${ch.pinned ? "muted" : "danger"}"
862
+ ${ch.pinned ? "" : `onclick="deleteChannel('${ch.name}')"`}>
863
+ 🗑&nbsp; Delete${ch.pinned ? " (pinned)" : ""}
864
+ </button>
865
+ `;
866
+
867
+ menu.style.top = e.clientY + "px";
868
+ menu.style.left = e.clientX + "px";
869
+ menu.classList.add("open");
870
+
871
+ requestAnimationFrame(() => {
872
+ const rect = menu.getBoundingClientRect();
873
+ if (rect.right > window.innerWidth - 8)
874
+ menu.style.left = (e.clientX - rect.width) + "px";
875
+ if (rect.bottom > window.innerHeight - 8)
876
+ menu.style.top = (e.clientY - rect.height) + "px";
877
+ });
878
+ }
879
+
880
+ document.addEventListener("click", () => {
881
+ document.getElementById("channelMenu").classList.remove("open");
882
+ });
883
+
884
+ document.addEventListener("contextmenu", (e) => {
885
+ if (!e.target.closest("#channels"))
886
+ document.getElementById("channelMenu").classList.remove("open");
887
+ });
888
+
889
+ async function toggleChannelPin(name) {
890
+ await fetch(`/channels/${name}/pin`, { method: "POST" });
891
+ }
892
+
893
+ async function renameChannelPrompt(name) {
894
+ const newName = prompt("Rename channel to:", name);
895
+ if (!newName || newName.trim() === name) return;
896
+
897
+ const res = await fetch("/channels/" + name, {
898
+ method: "PATCH",
899
+ headers: { "Content-Type": "application/json" },
900
+ body: JSON.stringify({ name: newName.trim() })
901
+ });
902
+
903
+ if (!res.ok) { const err = await res.json(); alert(err.error); }
904
+ }
905
+
906
+ async function deleteChannel(name) {
907
+ const confirmed = confirm(`Delete "${name}"? All items will be permanently removed.`);
908
+ if (!confirmed) return;
909
+
910
+ const res = await fetch("/channels/" + name, { method: "DELETE" });
911
+ if (!res.ok) { const err = await res.json(); alert(err.error); }
912
+ }
913
+
914
+ /* DRAG OVER */
915
+ document.addEventListener("dragover", e => {
916
+ e.preventDefault();
917
+ });
918
+
919
+ /* DROP ANYWHERE */
920
+ document.addEventListener("drop", async e => {
921
+ e.preventDefault();
922
+ overlay.style.display = "none";
923
+ dragCounter = 0;
924
+
925
+ const file = e.dataTransfer.files[0];
926
+ if (!file) return;
927
+
928
+ const form = new FormData();
929
+ form.append("file", file);
930
+ form.append("channel", channel);
931
+ form.append("uploader", uploader);
932
+
933
+ uploadFile(file);
934
+ });
935
+
936
+ (async function init() {
937
+ await applyBranding();
938
+ await initName();
939
+ const infoRes = await fetch("/info");
940
+ const info = await infoRes.json();
941
+ if (!info.hasAuth) {
942
+ document.getElementById("logoutBtn").style.display = "none";
943
+ }
944
+ await loadChannels();
945
+ load();
946
+ })();