Files
virtual-clipboard/web/app.js
2025-09-06 00:48:39 +02:00

222 lines
8.0 KiB
JavaScript

"use strict";
const qs = (sel) => document.querySelector(sel);
const roomsListEl = qs('#roomsList');
const roomEl = qs('#room');
const tokenEl = qs('#token');
const tokenWrap = qs('#tokenWrap');
const contentEl = qs('#content');
const authorEl = qs('#author');
const statusEl = qs('#status');
const listEl = qs('#list');
let sse; // <— globale Referenz auf EventSource
const seen = new Set(); // falls noch nicht vorhanden (für Dedupe)
// remember last-used settings
roomEl.value = localStorage.getItem('vc.room') || 'default';
tokenEl.value = localStorage.getItem('vc.token') || '';
authorEl.value = localStorage.getItem('vc.author') || '';
roomEl.addEventListener('change', () => localStorage.setItem('vc.room', roomEl.value.trim()));
tokenEl.addEventListener('change', () => localStorage.setItem('vc.token', tokenEl.value));
authorEl.addEventListener('change', () => localStorage.setItem('vc.author', authorEl.value));
async function getJSON(path) {
const url = new URL(path, location.href);
const headers = { 'Accept': 'application/json' };
const token = tokenEl.value.trim();
if (token) headers['X-Token'] = token;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
async function postJSON(path, body) {
const url = new URL(path, location.href);
const headers = { 'Content-Type': 'application/json' };
const token = tokenEl.value.trim();
if (token) headers['X-Token'] = token;
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body) });
if (!res.ok) throw new Error(await res.text());
return res.json();
}
async function refreshHistory() {
const n = Number(qs('#limit').value);
const data = await getJSON(`/api/${encodeURIComponent(roomEl.value.trim())}/history?limit=${n}`);
listEl.innerHTML = '';
for (const c of data.reverse()) {
listEl.appendChild(renderClip(c));
}
}
function renderClip(c) {
const el = document.createElement('div');
el.className = 'clip';
const when = new Date(c.created_at).toLocaleString();
el.innerHTML = `
<div class="meta">
<span class="pill">${c.type}</span>
<span>${when}</span>
${c.author ? `<span>von <strong>${escapeHtml(c.author)}</strong></span>` : ''}
<span class="muted">ID ${c.id.slice(-6)}</span>
<span style="margin-left:auto" class="inline">
<button data-copy>In Zwischenablage</button>
</span>
</div>
<div style="white-space: pre-wrap; word-break: break-word">${linkify(escapeHtml(c.content))}</div>
`;
el.querySelector('[data-copy]').addEventListener('click', async () => {
await navigator.clipboard.writeText(c.content);
});
return el;
}
function linkify(text) {
const urlRE = /(https?:\/\/[^\s]+)/g;
return text.replace(urlRE, (u) => `<a href="${u}" target="_blank" rel="noreferrer noopener">${u}</a>`);
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (ch) => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;','\'':'&#39;'}[ch]));
}
async function detectTokenRequirement() {
try {
const s = await getJSON('/status');
tokenWrap.style.display = s.requiresToken ? 'inline-flex' : 'none';
} catch (e) {
console.warn(e);
}
}
async function sendClip() {
const content = contentEl.value;
if (!content.trim()) return;
const body = { type: 'text', content, author: authorEl.value || undefined };
await postJSON(`/api/${encodeURIComponent(roomEl.value.trim())}/clip`, body);
contentEl.value = '';
}
async function copyLatest() {
const c = await getJSON(`/api/${encodeURIComponent(roomEl.value.trim())}/latest`);
await navigator.clipboard.writeText(c.content);
}
function shareLink() {
const url = new URL(location.href);
url.searchParams.set('room', roomEl.value.trim());
if (tokenEl.value) url.searchParams.set('token', tokenEl.value.trim());
navigator.clipboard.writeText(url.toString());
alert('Link in Zwischenablage');
}
function connectStream() {
const params = new URLSearchParams();
if (tokenEl.value.trim()) params.set('token', tokenEl.value.trim());
const src = new EventSource(`/api/${encodeURIComponent(roomEl.value.trim())}/stream?${params}`);
statusEl.textContent = 'verbunden';
src.addEventListener('clip', (ev) => {
const c = JSON.parse(ev.data);
if (seen.has(c.id)) return;
seen.add(c.id);
listEl.prepend(renderClip(c));
});
src.onerror = () => {
statusEl.textContent = 'getrennt';
try { src.close(); } catch {}
setTimeout(() => { sse = connectStream(); }, 1500);
};
return src;
}
// wire up UI
document.addEventListener('DOMContentLoaded', () => {
qs('#btnSend').addEventListener('click', sendClip);
qs('#btnRooms')?.addEventListener('click', loadRooms);
qs('#btnCopyLatest').addEventListener('click', copyLatest);
qs('#btnReload').addEventListener('click', refreshHistory);
qs('#btnShare').addEventListener('click', shareLink);
qs('#btnClear').addEventListener('click', async () => {
if (!confirm('Verlauf in diesem Raum wirklich löschen?')) return;
await fetch(`/api/${encodeURIComponent(roomEl.value.trim())}/history`, { method: 'DELETE', headers: tokenEl.value ? {'X-Token': tokenEl.value} : {} });
seen.clear();
await refreshHistory();
});
qs('#btnDelete').addEventListener('click', async () => {
if (!confirm('Diesen Raum komplett löschen?')) return;
await fetch(`/api/${encodeURIComponent(roomEl.value.trim())}`, { method: 'DELETE', headers: tokenEl.value ? {'X-Token': tokenEl.value} : {} });
seen.clear();
listEl.innerHTML = '';
});
const url = new URL(location.href);
if (url.searchParams.get('room')) {
roomEl.value = url.searchParams.get('room');
localStorage.setItem('vc.room', roomEl.value);
}
if (url.searchParams.get('token')) {
tokenEl.value = url.searchParams.get('token');
localStorage.setItem('vc.token', tokenEl.value);
}
detectTokenRequirement();
refreshHistory().catch(console.error);
loadRooms();
sse = connectStream();
});
async function loadRooms() {
try {
const s = await getJSON('/status');
const rooms = Array.isArray(s.rooms) ? s.rooms : [];
roomsListEl.innerHTML = '';
for (const r of rooms) {
const item = document.createElement('div');
item.className = 'clip'; // kleiner Stil-Reuse
item.innerHTML = `
<div class="inline" style="justify-content: space-between; width:100%">
<div><strong>${escapeHtml(r)}</strong></div>
<div class="inline" style="gap:6px">
<button data-open>Öffnen</button>
<button data-clear title="Verlauf leeren">Leeren</button>
<button data-del title="Raum löschen">Löschen</button>
</div>
</div>`;
item.querySelector('[data-open]').addEventListener('click', () => switchRoom(r));
item.querySelector('[data-clear]').addEventListener('click', async () => {
if (!confirm(`Verlauf in Raum "${r}" wirklich löschen?`)) return;
await fetch(`/api/${encodeURIComponent(r)}/history`, { method: 'DELETE', headers: tokenEl.value ? {'X-Token': tokenEl.value} : {} });
if (r === roomEl.value.trim()) { seen.clear(); await refreshHistory(); }
await loadRooms();
});
item.querySelector('[data-del]').addEventListener('click', async () => {
if (!confirm(`Raum "${r}" wirklich löschen?`)) return;
await fetch(`/api/${encodeURIComponent(r)}`, { method: 'DELETE', headers: tokenEl.value ? {'X-Token': tokenEl.value} : {} });
if (r === roomEl.value.trim()) {
seen.clear();
listEl.innerHTML = '';
// aktuelle Verbindung trennen
if (sse) { try { sse.close(); } catch{} sse = null; }
}
await loadRooms();
});
roomsListEl.appendChild(item);
}
} catch (e) {
console.warn('rooms load failed', e);
}
}
async function switchRoom(newRoom) {
roomEl.value = newRoom;
localStorage.setItem('vc.room', newRoom);
seen.clear();
listEl.innerHTML = '';
if (sse) { try { sse.close(); } catch{} sse = null; }
await refreshHistory().catch(console.error);
sse = connectStream(); // neue Verbindung
}