init
This commit is contained in:
221
web/app.js
Normal file
221
web/app.js
Normal file
@@ -0,0 +1,221 @@
|
||||
"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) => ({'&':'&','<':'<','>':'>','"':'"','\'':'''}[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
|
||||
}
|
||||
|
||||
91
web/index.html
Normal file
91
web/index.html
Normal file
@@ -0,0 +1,91 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Virtuelle Zwischenablage</title>
|
||||
<style>
|
||||
:root { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; }
|
||||
body { margin: 0; background: #0b0f19; color: #e6e6e6; }
|
||||
header { padding: 16px 20px; border-bottom: 1px solid #1f2637; position: sticky; top: 0; background: #0b0f19; z-index: 1; }
|
||||
h1 { margin: 0; font-size: 18px; }
|
||||
main { max-width: 900px; margin: 0 auto; padding: 20px; }
|
||||
.row { display: grid; grid-template-columns: 1fr auto; gap: 10px; }
|
||||
.grid { display: grid; gap: 12px; }
|
||||
input, textarea, select, button { background: #131a2a; border: 1px solid #22304d; color: #e6e6e6; border-radius: 10px; padding: 10px; font-size: 14px; }
|
||||
button { cursor: pointer; }
|
||||
button.primary { background: #3b82f6; border-color: #3b82f6; color: #fff; }
|
||||
.card { border: 1px solid #1f2637; background: #0f1524; border-radius: 14px; padding: 14px; }
|
||||
.list { display: grid; gap: 10px; }
|
||||
.clip { display: grid; gap: 6px; border: 1px solid #22304d; padding: 10px; border-radius: 12px; background: #0b1222; }
|
||||
.meta { color: #9aa6c6; font-size: 12px; display: flex; gap: 10px; align-items: center; }
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.muted { color: #9aa6c6; }
|
||||
.inline { display: inline-flex; gap: 8px; align-items: center; }
|
||||
.pill { background: #14203a; border: 1px solid #22304d; padding: 2px 8px; border-radius: 999px; font-size: 12px; }
|
||||
a { color: #7cb2ff; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🗂️ Virtuelle Zwischenablage</h1>
|
||||
</header>
|
||||
<main>
|
||||
<div class="grid">
|
||||
<div class="card grid">
|
||||
<div class="row">
|
||||
<div class="inline">
|
||||
<label for="room" class="muted">Raum:</label>
|
||||
<input id="room" placeholder="z.B. default, team, dev" value="default" />
|
||||
<span id="tokenWrap" class="inline" style="display:none">
|
||||
<label for="token" class="muted">Token:</label>
|
||||
<input id="token" placeholder="geheimer Schlüssel" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="btnCopyLatest">Aktuell kopieren</button>
|
||||
<button id="btnShare">Share-Link</button>
|
||||
</div>
|
||||
</div>
|
||||
<textarea id="content" rows="5" placeholder="Text hier einfügen…"></textarea>
|
||||
<div class="row">
|
||||
<input id="author" placeholder="Optional: Name/Autor" />
|
||||
<button id="btnSend" class="primary">An Zwischenablage senden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="inline" style="justify-content: space-between; width: 100%">
|
||||
<div>
|
||||
<strong>Verlauf</strong>
|
||||
<span class="pill" id="status">not connected</span>
|
||||
</div>
|
||||
<div class="inline">
|
||||
<label for="limit" class="muted">Anzahl:</label>
|
||||
<select id="limit">
|
||||
<option>10</option>
|
||||
<option selected>25</option>
|
||||
<option>50</option>
|
||||
<option>100</option>
|
||||
</select>
|
||||
<button id="btnReload">Neu laden</button>
|
||||
<button id="btnClear">Raum leeren</button>
|
||||
<button id="btnDelete">Raum löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list" id="list"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="inline" style="justify-content: space-between; width: 100%">
|
||||
<strong>Räume</strong>
|
||||
<button id="btnRooms">Aktualisieren</button>
|
||||
</div>
|
||||
<div class="list" id="roomsList"></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user