Replace full-page navigation with a two-pane lobby. Sidebar holds the session list as clickable cards; an iframe in the content pane swaps its src on click so switching sessions takes one click instead of two navigations. - #lobby-shell grid (260px sidebar + iframe pane) - Cards become role=button, kill button stops propagation - activateSession/deactivateSession with hash routing (location.hash <-> active session, replaceState so back stack stays clean) - Killed active session deactivates the iframe before re-render - 5s session poll preserves currentActive; deactivates if gone - Mobile media query collapses to one column CSP frame-ancestors already permits same-origin embedding (*.viktorbarzin.me), no infra changes needed. Direct-link ?arg=<name> path is unchanged.
676 lines
30 KiB
HTML
676 lines
30 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Terminal</title>
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
|
<style>
|
|
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background: #000; }
|
|
#terminal { height: 100%; width: 100%; }
|
|
.hidden { display: none !important; }
|
|
#toast {
|
|
position: fixed; top: 16px; right: 16px; z-index: 9999;
|
|
background: #1a1a2e; color: #a29bfe; border: 1px solid #333;
|
|
border-radius: 8px; padding: 10px 18px; font-family: monospace;
|
|
font-size: 14px; opacity: 0; transition: opacity 0.3s;
|
|
pointer-events: none; max-width: 500px; word-break: break-all;
|
|
}
|
|
#toast.visible { opacity: 1; }
|
|
#toast.error { color: #e74c3c; border-color: #e74c3c; }
|
|
#toast.success { color: #2ecc71; border-color: #2ecc71; }
|
|
#paste-btn {
|
|
position: fixed; bottom: 24px; right: 24px; z-index: 9999;
|
|
width: 48px; height: 48px; border-radius: 12px;
|
|
background: rgba(108, 92, 231, 0.6); border: 1px solid rgba(162, 155, 254, 0.4);
|
|
color: #eee; font-size: 22px; cursor: pointer;
|
|
display: flex; align-items: center; justify-content: center;
|
|
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
|
transition: background 0.2s, transform 0.1s;
|
|
touch-action: manipulation; -webkit-tap-highlight-color: transparent;
|
|
}
|
|
#paste-btn:hover { background: rgba(108, 92, 231, 0.85); }
|
|
#paste-btn:active { transform: scale(0.92); }
|
|
#img-btn {
|
|
position: fixed; bottom: 24px; right: 80px; z-index: 9999;
|
|
width: 48px; height: 48px; border-radius: 12px;
|
|
background: rgba(108, 92, 231, 0.6); border: 1px solid rgba(162, 155, 254, 0.4);
|
|
color: #eee; font-size: 22px; cursor: pointer;
|
|
display: flex; align-items: center; justify-content: center;
|
|
backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
|
|
transition: background 0.2s, transform 0.1s;
|
|
touch-action: manipulation; -webkit-tap-highlight-color: transparent;
|
|
}
|
|
#img-btn:hover { background: rgba(108, 92, 231, 0.85); }
|
|
#img-btn:active { transform: scale(0.92); }
|
|
#img-input { display: none; }
|
|
|
|
/* Lobby */
|
|
#lobby {
|
|
display: none; height: 100vh; box-sizing: border-box;
|
|
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace;
|
|
color: #eee; background: #1a1a2e;
|
|
}
|
|
#lobby.visible { display: block; }
|
|
#lobby.access-denied { padding: 32px; overflow-y: auto; }
|
|
|
|
#lobby-shell {
|
|
display: grid; grid-template-columns: 260px 1fr; height: 100vh;
|
|
}
|
|
#lobby-sidebar {
|
|
padding: 20px 14px; overflow-y: auto; border-right: 1px solid #2a2a3f;
|
|
background: #1a1a2e; min-width: 0;
|
|
}
|
|
#lobby-content {
|
|
position: relative; background: #000; min-width: 0;
|
|
height: 100vh; overflow: hidden;
|
|
}
|
|
#session-frame { width: 100%; height: 100%; border: 0; display: block; }
|
|
#session-frame.hidden { display: none; }
|
|
#lobby-empty {
|
|
color: #888; display: flex; align-items: center; justify-content: center;
|
|
height: 100%; font-style: italic; padding: 24px; text-align: center;
|
|
}
|
|
#lobby-empty.hidden { display: none; }
|
|
|
|
.lobby-header { font-size: 18px; color: #a29bfe; margin: 0 0 4px 0; }
|
|
.lobby-sub { color: #888; font-size: 12px; margin: 0 0 18px 0; line-height: 1.5; }
|
|
.new-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 18px; }
|
|
.new-row input {
|
|
box-sizing: border-box; padding: 8px 12px; border-radius: 8px;
|
|
border: 1px solid #333; background: #0f0f1f; color: #eee;
|
|
font-family: inherit; font-size: 13px;
|
|
}
|
|
.new-row input:focus { outline: none; border-color: #a29bfe; }
|
|
.new-row button {
|
|
padding: 8px 12px; border-radius: 8px;
|
|
border: 1px solid rgba(162, 155, 254, 0.4);
|
|
background: rgba(108, 92, 231, 0.6); color: #eee;
|
|
font-family: inherit; font-size: 13px; cursor: pointer;
|
|
}
|
|
.new-row button:hover { background: rgba(108, 92, 231, 0.85); }
|
|
|
|
.session-list { display: flex; flex-direction: column; gap: 6px; }
|
|
.session-card {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
background: #0f0f1f; border: 1px solid #2a2a3f;
|
|
border-left: 3px solid transparent; border-radius: 8px;
|
|
padding: 10px 12px; cursor: pointer;
|
|
transition: background 0.15s, border-color 0.15s;
|
|
font-family: inherit; color: inherit; text-align: left;
|
|
}
|
|
.session-card:hover { background: #161629; }
|
|
.session-card:focus-visible {
|
|
outline: 2px solid rgba(162, 155, 254, 0.6); outline-offset: -2px;
|
|
}
|
|
.session-card.active {
|
|
background: #161629;
|
|
border-color: rgba(162, 155, 254, 0.5);
|
|
border-left-color: rgba(162, 155, 254, 0.95);
|
|
}
|
|
.session-meta {
|
|
display: flex; flex-direction: column; gap: 2px;
|
|
min-width: 0; flex: 1;
|
|
}
|
|
.session-name {
|
|
font-size: 14px; font-weight: 600; color: #eee;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.session-detail { font-size: 11px; color: #888; }
|
|
.session-detail.attached { color: #2ecc71; }
|
|
.session-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
|
.session-actions button {
|
|
padding: 4px 8px; border-radius: 6px; border: 1px solid #333;
|
|
background: #1f1f33; color: #eee; font-family: inherit;
|
|
font-size: 11px; cursor: pointer;
|
|
}
|
|
.session-actions button.kill {
|
|
color: #e74c3c; border-color: rgba(231, 76, 60, 0.4);
|
|
}
|
|
.session-actions button:hover { filter: brightness(1.3); }
|
|
.empty { color: #888; font-style: italic; padding: 12px 0; font-size: 12px; }
|
|
|
|
@media (max-width: 720px) {
|
|
#lobby-shell {
|
|
grid-template-columns: 1fr;
|
|
grid-template-rows: auto 60vh;
|
|
}
|
|
#lobby-sidebar {
|
|
border-right: 0; border-bottom: 1px solid #2a2a3f;
|
|
max-height: 40vh;
|
|
}
|
|
#lobby-content { height: 60vh; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="terminal"></div>
|
|
<div id="lobby">
|
|
<div id="lobby-shell">
|
|
<aside id="lobby-sidebar">
|
|
<h1 class="lobby-header">tmux sessions</h1>
|
|
<p class="lobby-sub">Pick a session or create one. Sessions persist after you close the tab.</p>
|
|
<div class="new-row">
|
|
<input id="new-name" type="text" placeholder="new session name" maxlength="32" autocomplete="off">
|
|
<button id="new-btn">Create & Open</button>
|
|
</div>
|
|
<div id="session-list" class="session-list"></div>
|
|
</aside>
|
|
<main id="lobby-content">
|
|
<div id="lobby-empty">Pick a session, or create one above.</div>
|
|
<iframe id="session-frame" class="hidden" referrerpolicy="same-origin" allow="clipboard-read; clipboard-write" title="terminal session"></iframe>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
<div id="toast"></div>
|
|
<button id="img-btn" title="Upload image">📷</button>
|
|
<button id="paste-btn" title="Paste from clipboard">📋</button>
|
|
<input type="file" id="img-input" accept="image/*">
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.18.0/lib/addon-webgl.min.js"></script>
|
|
<script>
|
|
(async function() {
|
|
const NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/;
|
|
const SESSIONS_API = '/api/sessions/sessions';
|
|
const WHOAMI_API = '/api/sessions/whoami';
|
|
const params = new URLSearchParams(location.search);
|
|
const rawArg = params.get('arg');
|
|
const validArg = rawArg && NAME_RE.test(rawArg) ? rawArg : null;
|
|
|
|
function showToast(msg, type, duration) {
|
|
const el = document.getElementById('toast');
|
|
el.textContent = msg;
|
|
el.className = 'visible' + (type ? ' ' + type : '');
|
|
clearTimeout(el._timer);
|
|
el._timer = setTimeout(() => { el.className = ''; }, duration || 3000);
|
|
}
|
|
|
|
function clearChildren(el) {
|
|
while (el.firstChild) el.removeChild(el.firstChild);
|
|
}
|
|
|
|
function emptyState(text) {
|
|
const e = document.createElement('div');
|
|
e.className = 'empty';
|
|
e.textContent = text;
|
|
return e;
|
|
}
|
|
|
|
function showAccessDenied(detail) {
|
|
document.getElementById('terminal').classList.add('hidden');
|
|
document.getElementById('paste-btn').classList.add('hidden');
|
|
document.getElementById('img-btn').classList.add('hidden');
|
|
const lobby = document.getElementById('lobby');
|
|
clearChildren(lobby);
|
|
const h = document.createElement('h1');
|
|
h.className = 'lobby-header';
|
|
h.textContent = 'Access denied';
|
|
lobby.appendChild(h);
|
|
const p = document.createElement('p');
|
|
p.className = 'lobby-sub';
|
|
p.textContent = detail || 'You do not have a terminal account on this server.';
|
|
lobby.appendChild(p);
|
|
const p2 = document.createElement('p');
|
|
p2.className = 'lobby-sub';
|
|
p2.textContent = 'Authentik logs you in; access here requires an OS-user mapping in /etc/ttyd-user-map. Ask Viktor to add one.';
|
|
lobby.appendChild(p2);
|
|
lobby.classList.add('visible', 'access-denied');
|
|
document.title = 'access denied';
|
|
}
|
|
|
|
// Preflight: ask tmux-api who we are. 403 = unmapped Authentik user → deny.
|
|
let whoami = null;
|
|
try {
|
|
const resp = await fetch(WHOAMI_API, { credentials: 'same-origin' });
|
|
if (resp.status === 401 || resp.status === 403) {
|
|
showAccessDenied((await resp.text()).trim());
|
|
return;
|
|
}
|
|
if (!resp.ok) {
|
|
showAccessDenied('Preflight failed: HTTP ' + resp.status);
|
|
return;
|
|
}
|
|
whoami = await resp.json();
|
|
} catch (err) {
|
|
showAccessDenied('Preflight failed: ' + err.message);
|
|
return;
|
|
}
|
|
|
|
if (!validArg) {
|
|
// ============================================================
|
|
// LOBBY MODE — no valid ?arg=, render sidebar + iframe shell
|
|
// ============================================================
|
|
document.getElementById('terminal').classList.add('hidden');
|
|
document.getElementById('paste-btn').classList.add('hidden');
|
|
document.getElementById('img-btn').classList.add('hidden');
|
|
document.getElementById('lobby').classList.add('visible');
|
|
const baseTitle = 'tmux sessions (' + whoami.osUser + ')';
|
|
document.title = baseTitle;
|
|
document.querySelector('#lobby .lobby-sub').textContent =
|
|
'Logged in as ' + whoami.osUser + ' (' + whoami.authentik + '). Sessions are kernel-isolated per Unix user; you only see your own.';
|
|
|
|
const listEl = document.getElementById('session-list');
|
|
const newNameEl = document.getElementById('new-name');
|
|
const newBtnEl = document.getElementById('new-btn');
|
|
const frameEl = document.getElementById('session-frame');
|
|
const emptyEl = document.getElementById('lobby-empty');
|
|
|
|
let currentActive = null;
|
|
|
|
function relativeTime(epochSec) {
|
|
if (!epochSec) return '';
|
|
const diff = Math.floor(Date.now() / 1000) - epochSec;
|
|
if (diff < 60) return diff + 's ago';
|
|
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
|
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
|
return Math.floor(diff / 86400) + 'd ago';
|
|
}
|
|
|
|
function updateActiveCard() {
|
|
listEl.querySelectorAll('.session-card').forEach(c => {
|
|
if (c.dataset.name === currentActive) c.classList.add('active');
|
|
else c.classList.remove('active');
|
|
});
|
|
}
|
|
|
|
function activateSession(name) {
|
|
if (!NAME_RE.test(name)) { showToast('Invalid name', 'error'); return; }
|
|
if (currentActive === name) { updateActiveCard(); return; }
|
|
currentActive = name;
|
|
emptyEl.classList.add('hidden');
|
|
frameEl.classList.remove('hidden');
|
|
frameEl.src = '/?arg=' + encodeURIComponent(name);
|
|
if (location.hash.slice(1) !== name) {
|
|
history.replaceState(null, '', '#' + name);
|
|
}
|
|
document.title = 'tmux: ' + whoami.osUser + '/' + name;
|
|
updateActiveCard();
|
|
}
|
|
|
|
function deactivateSession() {
|
|
if (!currentActive && frameEl.classList.contains('hidden')) return;
|
|
currentActive = null;
|
|
frameEl.classList.add('hidden');
|
|
emptyEl.classList.remove('hidden');
|
|
frameEl.src = 'about:blank';
|
|
if (location.hash) {
|
|
history.replaceState(null, '', location.pathname + location.search);
|
|
}
|
|
document.title = baseTitle;
|
|
updateActiveCard();
|
|
}
|
|
|
|
async function killSession(name) {
|
|
if (!NAME_RE.test(name)) { showToast('Invalid name', 'error'); return; }
|
|
if (!confirm('Kill tmux session "' + name + '"? Any running processes inside it will be terminated.')) return;
|
|
try {
|
|
const resp = await fetch(SESSIONS_API + '/' + encodeURIComponent(name), {
|
|
method: 'DELETE', credentials: 'same-origin'
|
|
});
|
|
if (resp.ok) {
|
|
showToast('Killed ' + name, 'success');
|
|
} else if (resp.status === 404) {
|
|
showToast('Session not found', 'error');
|
|
} else {
|
|
showToast('Kill failed: HTTP ' + resp.status, 'error');
|
|
}
|
|
if (currentActive === name) deactivateSession();
|
|
renderLobby();
|
|
} catch (err) {
|
|
showToast('Kill error: ' + err.message, 'error');
|
|
}
|
|
}
|
|
|
|
function renderCard(s) {
|
|
const card = document.createElement('div');
|
|
card.className = 'session-card';
|
|
card.dataset.name = s.name;
|
|
card.setAttribute('role', 'button');
|
|
card.setAttribute('tabindex', '0');
|
|
card.setAttribute('aria-label', 'Activate session ' + s.name);
|
|
if (s.name === currentActive) card.classList.add('active');
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'session-meta';
|
|
const name = document.createElement('div');
|
|
name.className = 'session-name';
|
|
name.textContent = s.name;
|
|
meta.appendChild(name);
|
|
const detail = document.createElement('div');
|
|
detail.className = 'session-detail' + (s.attached > 0 ? ' attached' : '');
|
|
const attachedText = s.attached > 0 ? (s.attached + ' attached') : 'idle';
|
|
detail.textContent = attachedText + ' · ' + relativeTime(s.lastActivity);
|
|
meta.appendChild(detail);
|
|
card.appendChild(meta);
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'session-actions';
|
|
const killBtn = document.createElement('button');
|
|
killBtn.className = 'kill';
|
|
killBtn.textContent = 'Kill';
|
|
killBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
killSession(s.name);
|
|
});
|
|
actions.appendChild(killBtn);
|
|
card.appendChild(actions);
|
|
|
|
card.addEventListener('click', () => activateSession(s.name));
|
|
card.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
activateSession(s.name);
|
|
}
|
|
});
|
|
|
|
return card;
|
|
}
|
|
|
|
async function renderLobby() {
|
|
try {
|
|
const resp = await fetch(SESSIONS_API, { credentials: 'same-origin' });
|
|
if (!resp.ok) {
|
|
clearChildren(listEl);
|
|
listEl.appendChild(emptyState('Failed to load sessions: HTTP ' + resp.status));
|
|
return;
|
|
}
|
|
const sessions = await resp.json();
|
|
if (currentActive && !sessions.some(s => s.name === currentActive)) {
|
|
deactivateSession();
|
|
}
|
|
clearChildren(listEl);
|
|
if (sessions.length === 0) {
|
|
listEl.appendChild(emptyState('No sessions yet.'));
|
|
return;
|
|
}
|
|
sessions.sort((a, b) => b.lastActivity - a.lastActivity);
|
|
for (const s of sessions) listEl.appendChild(renderCard(s));
|
|
} catch (err) {
|
|
clearChildren(listEl);
|
|
listEl.appendChild(emptyState('Error: ' + err.message));
|
|
}
|
|
}
|
|
|
|
newBtnEl.addEventListener('click', () => {
|
|
const name = newNameEl.value.trim();
|
|
if (!NAME_RE.test(name)) {
|
|
showToast('Name must match ^[a-zA-Z0-9_-]{1,32}$', 'error', 4000);
|
|
return;
|
|
}
|
|
newNameEl.value = '';
|
|
activateSession(name);
|
|
});
|
|
newNameEl.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') newBtnEl.click();
|
|
});
|
|
|
|
window.addEventListener('hashchange', () => {
|
|
const hash = location.hash.slice(1);
|
|
if (hash && NAME_RE.test(hash)) {
|
|
if (hash !== currentActive) activateSession(hash);
|
|
} else if (currentActive) {
|
|
deactivateSession();
|
|
}
|
|
});
|
|
|
|
renderLobby();
|
|
setInterval(renderLobby, 5000);
|
|
|
|
const initialHash = location.hash.slice(1);
|
|
if (initialHash && NAME_RE.test(initialHash)) {
|
|
activateSession(initialHash);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// ============================================================
|
|
// TERMINAL MODE — valid ?arg=<session>, attach via ttyd
|
|
// ============================================================
|
|
|
|
// ttyd binary protocol
|
|
const MSG_OUTPUT = 0x30; // '0' - terminal output
|
|
const MSG_SET_PREFS = 0x31; // '1' - JSON preferences
|
|
const MSG_SET_TITLE = 0x32; // '2' - window title
|
|
const MSG_INPUT = '0'; // client → server: input data
|
|
const MSG_RESIZE = '1'; // client → server: {columns, rows}
|
|
|
|
let ws = null;
|
|
const textEncoder = new TextEncoder();
|
|
const textDecoder = new TextDecoder();
|
|
|
|
const term = new Terminal({
|
|
cursorBlink: true,
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, 'Courier New', monospace",
|
|
fontSize: 15,
|
|
theme: {
|
|
background: '#1a1a2e',
|
|
foreground: '#eee',
|
|
cursor: '#a29bfe',
|
|
selectionBackground: 'rgba(162, 155, 254, 0.3)'
|
|
},
|
|
allowProposedApi: true
|
|
});
|
|
|
|
const fitAddon = new FitAddon.FitAddon();
|
|
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
|
term.loadAddon(fitAddon);
|
|
term.loadAddon(webLinksAddon);
|
|
|
|
try {
|
|
const webglAddon = new WebglAddon.WebglAddon();
|
|
webglAddon.onContextLoss(() => { webglAddon.dispose(); });
|
|
term.loadAddon(webglAddon);
|
|
} catch (e) {
|
|
console.warn('WebGL addon failed:', e);
|
|
}
|
|
|
|
term.open(document.getElementById('terminal'));
|
|
fitAddon.fit();
|
|
|
|
document.title = 'tmux: ' + whoami.osUser + '/' + validArg;
|
|
|
|
function sendInput(data) {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
const payload = textEncoder.encode(data);
|
|
const buf = new Uint8Array(payload.length + 1);
|
|
buf[0] = MSG_INPUT.charCodeAt(0);
|
|
buf.set(payload, 1);
|
|
ws.send(buf.buffer);
|
|
}
|
|
|
|
function sendResize() {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
const json = JSON.stringify({ columns: term.cols, rows: term.rows });
|
|
const payload = textEncoder.encode(json);
|
|
const buf = new Uint8Array(payload.length + 1);
|
|
buf[0] = MSG_RESIZE.charCodeAt(0);
|
|
buf.set(payload, 1);
|
|
ws.send(buf.buffer);
|
|
}
|
|
|
|
term.onData(sendInput);
|
|
term.onBinary((data) => {
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
const bytes = new Uint8Array(data.length + 1);
|
|
bytes[0] = MSG_INPUT.charCodeAt(0);
|
|
for (let i = 0; i < data.length; i++) bytes[i + 1] = data.charCodeAt(i);
|
|
ws.send(bytes.buffer);
|
|
});
|
|
|
|
term.onResize(() => sendResize());
|
|
window.addEventListener('resize', () => fitAddon.fit());
|
|
|
|
term.attachCustomKeyEventHandler((e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && term.hasSelection()) {
|
|
navigator.clipboard.writeText(term.getSelection());
|
|
return false;
|
|
}
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
|
|
return false; // let browser paste event fire
|
|
}
|
|
return true;
|
|
});
|
|
|
|
document.addEventListener('paste', async (e) => {
|
|
const items = e.clipboardData?.items;
|
|
if (!items) return;
|
|
|
|
for (const item of items) {
|
|
if (item.type.startsWith('image/')) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const blob = item.getAsFile();
|
|
if (!blob) { showToast('Failed to read image', 'error'); return; }
|
|
|
|
showToast('Uploading image...', '');
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('image', blob);
|
|
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
|
|
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
|
|
const { path } = await resp.json();
|
|
sendInput(path);
|
|
showToast('Pasted: ' + path, 'success', 4000);
|
|
} catch (err) {
|
|
showToast('Upload error: ' + err.message, 'error', 5000);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
const text = e.clipboardData.getData('text');
|
|
if (text) {
|
|
e.preventDefault();
|
|
sendInput(text);
|
|
}
|
|
}, true);
|
|
|
|
document.getElementById('paste-btn').addEventListener('click', async () => {
|
|
try {
|
|
if (navigator.clipboard.read) {
|
|
const items = await navigator.clipboard.read();
|
|
for (const item of items) {
|
|
const imageType = item.types.find(t => t.startsWith('image/'));
|
|
if (imageType) {
|
|
const blob = await item.getType(imageType);
|
|
showToast('Uploading image...', '');
|
|
const formData = new FormData();
|
|
formData.append('image', blob);
|
|
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
|
|
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
|
|
const { path } = await resp.json();
|
|
sendInput(path);
|
|
showToast('Pasted: ' + path, 'success', 4000);
|
|
return;
|
|
}
|
|
if (item.types.includes('text/plain')) {
|
|
const blob = await item.getType('text/plain');
|
|
const text = await blob.text();
|
|
if (text) { sendInput(text); return; }
|
|
}
|
|
}
|
|
} else {
|
|
const text = await navigator.clipboard.readText();
|
|
if (text) sendInput(text);
|
|
}
|
|
} catch (err) {
|
|
showToast('Clipboard access denied', 'error', 3000);
|
|
console.error('Clipboard read failed:', err);
|
|
}
|
|
term.focus();
|
|
});
|
|
|
|
document.getElementById('img-btn').addEventListener('click', () => {
|
|
document.getElementById('img-input').click();
|
|
});
|
|
document.getElementById('img-input').addEventListener('change', async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
e.target.value = ''; // reset so same file can be re-selected
|
|
|
|
showToast('Uploading image...', '');
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('image', file);
|
|
const resp = await fetch('/clipboard/upload', { method: 'POST', body: formData });
|
|
if (!resp.ok) { showToast('Upload failed: ' + await resp.text(), 'error', 5000); return; }
|
|
const { path } = await resp.json();
|
|
sendInput(path);
|
|
showToast('Pasted: ' + path, 'success', 4000);
|
|
} catch (err) {
|
|
showToast('Upload error: ' + err.message, 'error', 5000);
|
|
}
|
|
term.focus();
|
|
});
|
|
|
|
function connect() {
|
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const argSuffix = '?arg=' + encodeURIComponent(validArg);
|
|
const base = location.pathname.replace(/\/+$/, '');
|
|
const wsUrl = proto + '//' + location.host + base + '/ws' + argSuffix;
|
|
|
|
fetch(base + '/token' + argSuffix, { credentials: 'same-origin' })
|
|
.then(r => r.json())
|
|
.then(tokenData => {
|
|
const token = tokenData.token || '';
|
|
ws = new WebSocket(wsUrl, ['tty']);
|
|
ws.binaryType = 'arraybuffer';
|
|
|
|
ws.onopen = () => {
|
|
console.log('Connected to ttyd (session: ' + validArg + ')');
|
|
const initMsg = JSON.stringify({
|
|
AuthToken: token,
|
|
columns: term.cols,
|
|
rows: term.rows
|
|
});
|
|
ws.send(initMsg);
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
const data = event.data;
|
|
if (data instanceof ArrayBuffer) {
|
|
const view = new Uint8Array(data);
|
|
if (view.length < 1) return;
|
|
const msgType = view[0];
|
|
const payload = view.slice(1);
|
|
|
|
switch (msgType) {
|
|
case MSG_OUTPUT:
|
|
term.write(payload);
|
|
break;
|
|
case MSG_SET_PREFS:
|
|
try {
|
|
const prefs = JSON.parse(textDecoder.decode(payload));
|
|
console.log('ttyd prefs:', prefs);
|
|
} catch (e) {}
|
|
break;
|
|
case MSG_SET_TITLE:
|
|
document.title = textDecoder.decode(payload);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
term.write('\r\n\x1b[31mDisconnected. Reconnecting...\x1b[0m\r\n');
|
|
setTimeout(connect, 3000);
|
|
};
|
|
|
|
ws.onerror = (e) => console.error('WebSocket error:', e);
|
|
})
|
|
.catch(err => {
|
|
console.error('Token fetch failed:', err);
|
|
term.write('\r\n\x1b[31mFailed to connect. Retrying...\x1b[0m\r\n');
|
|
setTimeout(connect, 3000);
|
|
});
|
|
}
|
|
|
|
connect();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|