mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 17:45:42 +00:00
2bfa1f3d7c
Add broad request logging in auth middleware and more granular debug logs for login handling (JSON decode errors and non-password login fields) to aid troubleshooting. Trim and sanitize username/password on the web login route and log whitespace/empty username cases and missing credentials. Extend isSameNetwork logic to treat loopback ↔ private-IP pairs as same network (covers local server → LAN target cases). Fix remote viewer templates to avoid inline string interpolation by injecting device data via JSON and setting DOM textContent (prevents XSS and EJS injection); also minor formatting/alignment tweaks in role handler comments/structs.
662 lines
23 KiB
HTML
662 lines
23 KiB
HTML
<%- include('layouts/viewer', {
|
|
title: (device && device.hostname) ? device.hostname : deviceId,
|
|
pageScripts: [],
|
|
body: `
|
|
|
|
<!-- Beta Banner -->
|
|
<div class="bdv-beta-banner" id="bdv-beta-banner">
|
|
<span>🔧 Web Remote Client — Beta</span>
|
|
<button id="bdv-beta-dismiss" class="bdv-beta-dismiss">×</button>
|
|
</div>
|
|
|
|
<!-- BetterDesk Native Remote Desktop Viewer -->
|
|
<div class="bdv-container" id="bdv-container" style="margin-top: 48px;">
|
|
|
|
<!-- Overlay shown before connection -->
|
|
<div class="bdv-overlay" id="bdv-overlay">
|
|
<div class="bdv-overlay-card">
|
|
<div class="bdv-overlay-icon">
|
|
<span class="material-icons">connected_tv</span>
|
|
</div>
|
|
<h2 class="bdv-overlay-title">Remote Desktop</h2>
|
|
<p class="bdv-overlay-device" id="bdv-overlay-hostname"></p>
|
|
<p class="bdv-overlay-id" id="bdv-overlay-deviceid"></p>
|
|
<div class="bdv-overlay-status" id="bdv-status-area">
|
|
<div class="spinner"></div>
|
|
<span id="bdv-status-text">Connecting to agent…</span>
|
|
</div>
|
|
<div class="bdv-overlay-actions" id="bdv-overlay-actions" style="display:none;">
|
|
<button class="btn btn-primary" id="btnReconnect">
|
|
<span class="material-icons">refresh</span> Reconnect
|
|
</button>
|
|
<a href="/devices" class="btn btn-secondary">
|
|
<span class="material-icons">arrow_back</span> Back
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Main canvas — shows JPEG frames -->
|
|
<canvas id="bdv-canvas" class="bdv-canvas" tabindex="0"></canvas>
|
|
|
|
<!-- Top toolbar (visible on hover / always when not streaming) -->
|
|
<div class="bdv-toolbar" id="bdv-toolbar">
|
|
<div class="bdv-toolbar-left">
|
|
<span class="material-icons">connected_tv</span>
|
|
<span id="bdv-toolbar-title"></span>
|
|
<span class="bdv-toolbar-sep"></span>
|
|
<span class="bdv-badge" id="bdv-fps-badge">—</span>
|
|
</div>
|
|
<div class="bdv-toolbar-center">
|
|
<!-- Scale mode selector -->
|
|
<select class="bdv-select" id="bdvScaleMode" title="Scale Mode">
|
|
<option value="fit" selected>Fit</option>
|
|
<option value="fill">Fill</option>
|
|
<option value="1:1">1:1</option>
|
|
<option value="stretch">Stretch</option>
|
|
</select>
|
|
<!-- Monitor selector (hidden until multi-monitor detected) -->
|
|
<select class="bdv-select bdv-hidden" id="bdvMonitor" title="Monitor"></select>
|
|
</div>
|
|
<div class="bdv-toolbar-right">
|
|
<button class="bdv-btn" id="btnClipboard" title="Clipboard Sync">
|
|
<span class="material-icons">content_paste</span>
|
|
</button>
|
|
<button class="bdv-btn" id="btnSpecialKeys" title="Special Keys">
|
|
<span class="material-icons">keyboard</span>
|
|
</button>
|
|
<button class="bdv-btn" id="btnChat" title="Open Chat">
|
|
<span class="material-icons">chat</span>
|
|
</button>
|
|
<button class="bdv-btn" id="btnFullscreen" title="Fullscreen (F11)">
|
|
<span class="material-icons">fullscreen</span>
|
|
</button>
|
|
<button class="bdv-btn bdv-btn--danger" id="btnStop" title="Stop session">
|
|
<span class="material-icons">stop_circle</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Special Keys dropdown menu -->
|
|
<div class="bdv-dropdown bdv-hidden" id="bdv-special-keys-menu">
|
|
<button class="bdv-dropdown-item" data-keys="ctrl+alt+delete">Ctrl+Alt+Del</button>
|
|
<button class="bdv-dropdown-item" data-keys="meta">Win / Super</button>
|
|
<button class="bdv-dropdown-item" data-keys="printscreen">Print Screen</button>
|
|
<button class="bdv-dropdown-item" data-keys="alt+tab">Alt+Tab</button>
|
|
<button class="bdv-dropdown-item" data-keys="alt+f4">Alt+F4</button>
|
|
<button class="bdv-dropdown-item" data-keys="ctrl+shift+escape">Task Manager</button>
|
|
</div>
|
|
|
|
<!-- Chat sidebar -->
|
|
<div class="bdv-chat" id="bdv-chat">
|
|
<div class="bdv-chat-header">
|
|
<span id="bdv-chat-title">Chat</span>
|
|
<button class="bdv-btn" id="btnCloseChat">
|
|
<span class="material-icons">close</span>
|
|
</button>
|
|
</div>
|
|
<div class="bdv-chat-messages" id="bdv-chat-messages"></div>
|
|
<div class="bdv-chat-input-row">
|
|
<input type="text" id="bdv-chat-input" class="bdv-chat-inp" placeholder="Type a message…" autocomplete="off" />
|
|
<button class="bdv-btn" id="btnSendChat">
|
|
<span class="material-icons">send</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script nonce="${cspNonce}">
|
|
(function() {
|
|
'use strict';
|
|
|
|
// ---- Config ----
|
|
const DEVICE_ID = ${JSON.stringify(deviceId)};
|
|
const HOSTNAME = ${JSON.stringify(device && device.hostname ? device.hostname : '')};
|
|
const WS_BASE = (location.protocol === 'https:' ? 'wss' : 'ws') + '://' + location.host;
|
|
const REMOTE_URL = WS_BASE + '/ws/remote-viewer/' + encodeURIComponent(DEVICE_ID);
|
|
const CHAT_URL = WS_BASE + '/ws/chat-operator/' + encodeURIComponent(DEVICE_ID);
|
|
|
|
// ---- Safely set dynamic text content (prevents XSS/EJS injection) ----
|
|
document.getElementById('bdv-overlay-hostname').textContent = HOSTNAME;
|
|
document.getElementById('bdv-overlay-deviceid').textContent = DEVICE_ID;
|
|
document.getElementById('bdv-toolbar-title').textContent = HOSTNAME || DEVICE_ID;
|
|
document.getElementById('bdv-chat-title').textContent = 'Chat — ' + DEVICE_ID;
|
|
|
|
// ---- DOM ----
|
|
const canvas = document.getElementById('bdv-canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
const overlay = document.getElementById('bdv-overlay');
|
|
const statusTxt = document.getElementById('bdv-status-text');
|
|
const actions = document.getElementById('bdv-overlay-actions');
|
|
const fpsBadge = document.getElementById('bdv-fps-badge');
|
|
const chatSide = document.getElementById('bdv-chat');
|
|
const chatMsgs = document.getElementById('bdv-chat-messages');
|
|
const chatInp = document.getElementById('bdv-chat-input');
|
|
const toolbar = document.getElementById('bdv-toolbar');
|
|
|
|
// ---- State ----
|
|
let remoteWs = null;
|
|
let chatWs = null;
|
|
let streaming = false;
|
|
let frameCount = 0;
|
|
let lastFpsTick = performance.now();
|
|
let chatOpen = false;
|
|
let mouseButtonsDown = 0;
|
|
|
|
// ---- FPS Counter ----
|
|
setInterval(() => {
|
|
const now = performance.now();
|
|
const dt = (now - lastFpsTick) / 1000;
|
|
const fps = (frameCount / dt).toFixed(1);
|
|
fpsBadge.textContent = streaming ? fps + ' fps' : '—';
|
|
frameCount = 0;
|
|
lastFpsTick = now;
|
|
}, 2000);
|
|
|
|
// ---- Remote WebSocket ----
|
|
function connectRemote() {
|
|
setStatus('Connecting to server…');
|
|
remoteWs = new WebSocket(REMOTE_URL);
|
|
remoteWs.binaryType = 'arraybuffer';
|
|
|
|
remoteWs.onopen = () => {
|
|
setStatus('Waiting for agent…');
|
|
};
|
|
|
|
remoteWs.onclose = (ev) => {
|
|
streaming = false;
|
|
setStatus('Disconnected — ' + (ev.reason || 'connection closed'));
|
|
showActions();
|
|
};
|
|
|
|
remoteWs.onmessage = (ev) => {
|
|
if (ev.data instanceof ArrayBuffer) {
|
|
// Binary = JPEG frame
|
|
handleJpegFrame(ev.data);
|
|
} else {
|
|
handleJsonFrame(JSON.parse(ev.data));
|
|
}
|
|
};
|
|
|
|
remoteWs.onerror = () => {
|
|
setStatus('Connection error');
|
|
showActions();
|
|
};
|
|
}
|
|
|
|
function handleJsonFrame(frame) {
|
|
switch (frame.type) {
|
|
case 'session-info':
|
|
if (frame.agent_ready) {
|
|
setStatus(frame.streaming ? 'Streaming…' : 'Agent ready — starting stream…');
|
|
} else {
|
|
setStatus('Waiting for agent to connect…');
|
|
}
|
|
streaming = frame.streaming;
|
|
break;
|
|
case 'agent-ready':
|
|
setStatus('Agent connected — starting stream…');
|
|
break;
|
|
case 'stream-started':
|
|
streaming = true;
|
|
overlay.style.display = 'none';
|
|
canvas.focus();
|
|
break;
|
|
case 'stream-stopped':
|
|
streaming = false;
|
|
setStatus('Stream stopped — ' + (frame.reason || ''));
|
|
overlay.style.display = 'flex';
|
|
showActions();
|
|
break;
|
|
case 'agent-disconnected':
|
|
streaming = false;
|
|
setStatus('Agent disconnected');
|
|
overlay.style.display = 'flex';
|
|
showActions();
|
|
break;
|
|
case 'clipboard':
|
|
// Receive clipboard text from remote device
|
|
if (frame.text && navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(frame.text).catch(() => {});
|
|
}
|
|
break;
|
|
case 'monitors':
|
|
// Populate monitor selector when remote reports multiple displays
|
|
if (frame.list && frame.list.length > 1) {
|
|
monitorSelect.innerHTML = '';
|
|
frame.list.forEach((m, i) => {
|
|
const opt = document.createElement('option');
|
|
opt.value = i;
|
|
opt.textContent = m.name || ('Monitor ' + (i + 1));
|
|
monitorSelect.appendChild(opt);
|
|
});
|
|
monitorSelect.classList.remove('bdv-hidden');
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
function handleJpegFrame(buffer) {
|
|
const blob = new Blob([buffer], { type: 'image/jpeg' });
|
|
const url = URL.createObjectURL(blob);
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
if (canvas.width !== img.width || canvas.height !== img.height) {
|
|
canvas.width = img.width;
|
|
canvas.height = img.height;
|
|
}
|
|
ctx.drawImage(img, 0, 0);
|
|
URL.revokeObjectURL(url);
|
|
frameCount++;
|
|
};
|
|
img.onerror = () => URL.revokeObjectURL(url);
|
|
img.src = url;
|
|
}
|
|
|
|
// ---- Input forwarding ----
|
|
canvas.addEventListener('mousemove', (e) => {
|
|
if (!streaming) return;
|
|
const { rx, ry } = canvasCoords(e);
|
|
sendRemote({ type: 'input', event_type: 'mouse_move', x: rx, y: ry });
|
|
});
|
|
|
|
canvas.addEventListener('mousedown', (e) => {
|
|
if (!streaming) return;
|
|
mouseButtonsDown |= (1 << e.button);
|
|
const { rx, ry } = canvasCoords(e);
|
|
sendRemote({ type: 'input', event_type: 'mouse_down', x: rx, y: ry, button: btnName(e.button) });
|
|
});
|
|
|
|
canvas.addEventListener('mouseup', (e) => {
|
|
if (!streaming) return;
|
|
mouseButtonsDown &= ~(1 << e.button);
|
|
const { rx, ry } = canvasCoords(e);
|
|
sendRemote({ type: 'input', event_type: 'mouse_up', x: rx, y: ry, button: btnName(e.button) });
|
|
});
|
|
|
|
canvas.addEventListener('wheel', (e) => {
|
|
if (!streaming) return;
|
|
e.preventDefault();
|
|
sendRemote({ type: 'input', event_type: 'wheel', delta_x: Math.round(e.deltaX), delta_y: Math.round(e.deltaY) });
|
|
}, { passive: false });
|
|
|
|
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
|
|
canvas.addEventListener('keydown', (e) => {
|
|
if (!streaming) return;
|
|
e.preventDefault();
|
|
sendRemote({ type: 'input', event_type: 'key_down', key: e.key });
|
|
});
|
|
|
|
canvas.addEventListener('keyup', (e) => {
|
|
if (!streaming) return;
|
|
e.preventDefault();
|
|
sendRemote({ type: 'input', event_type: 'key_up', key: e.key });
|
|
});
|
|
|
|
function canvasCoords(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const scaleX = canvas.width / rect.width;
|
|
const scaleY = canvas.height / rect.height;
|
|
return {
|
|
rx: Math.round((e.clientX - rect.left) * scaleX),
|
|
ry: Math.round((e.clientY - rect.top) * scaleY),
|
|
};
|
|
}
|
|
|
|
function btnName(b) {
|
|
return b === 2 ? 'right' : b === 1 ? 'middle' : 'left';
|
|
}
|
|
|
|
function sendRemote(obj) {
|
|
if (remoteWs && remoteWs.readyState === WebSocket.OPEN) {
|
|
remoteWs.send(JSON.stringify(obj));
|
|
}
|
|
}
|
|
|
|
// ---- Toolbar / UI ----
|
|
function setStatus(msg) { statusTxt.textContent = msg; }
|
|
function showActions() { actions.style.display = 'flex'; }
|
|
|
|
document.getElementById('bdv-beta-dismiss')?.addEventListener('click', () => {
|
|
document.getElementById('bdv-beta-banner').style.display = 'none';
|
|
});
|
|
|
|
document.getElementById('btnReconnect').onclick = () => {
|
|
actions.style.display = 'none';
|
|
if (remoteWs) remoteWs.close();
|
|
setTimeout(connectRemote, 200);
|
|
};
|
|
|
|
document.getElementById('btnStop').onclick = () => {
|
|
sendRemote({ type: 'stop' });
|
|
if (remoteWs) remoteWs.close();
|
|
};
|
|
|
|
document.getElementById('btnFullscreen').onclick = () => {
|
|
if (!document.fullscreenElement) {
|
|
document.getElementById('bdv-container').requestFullscreen();
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('fullscreenchange', () => {
|
|
const icon = document.getElementById('btnFullscreen').querySelector('span');
|
|
icon.textContent = document.fullscreenElement ? 'fullscreen_exit' : 'fullscreen';
|
|
});
|
|
|
|
// F11 shortcut for fullscreen
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'F11') {
|
|
e.preventDefault();
|
|
document.getElementById('btnFullscreen').click();
|
|
}
|
|
});
|
|
|
|
// ---- Scale Mode ----
|
|
const scaleSelect = document.getElementById('bdvScaleMode');
|
|
scaleSelect.addEventListener('change', () => {
|
|
sendRemote({ type: 'scale_mode', mode: scaleSelect.value });
|
|
});
|
|
|
|
// ---- Monitor Selector ----
|
|
const monitorSelect = document.getElementById('bdvMonitor');
|
|
monitorSelect.addEventListener('change', () => {
|
|
const idx = parseInt(monitorSelect.value, 10);
|
|
sendRemote({ type: 'switch_monitor', index: idx });
|
|
});
|
|
|
|
// ---- Special Keys ----
|
|
const specialKeysMenu = document.getElementById('bdv-special-keys-menu');
|
|
document.getElementById('btnSpecialKeys').onclick = (e) => {
|
|
e.stopPropagation();
|
|
specialKeysMenu.classList.toggle('bdv-hidden');
|
|
};
|
|
document.addEventListener('click', () => specialKeysMenu.classList.add('bdv-hidden'));
|
|
specialKeysMenu.addEventListener('click', (e) => {
|
|
const item = e.target.closest('[data-keys]');
|
|
if (!item) return;
|
|
sendRemote({ type: 'special_key', combo: item.dataset.keys });
|
|
specialKeysMenu.classList.add('bdv-hidden');
|
|
});
|
|
|
|
// ---- Clipboard Sync ----
|
|
document.getElementById('btnClipboard').onclick = async () => {
|
|
try {
|
|
const text = await navigator.clipboard.readText();
|
|
if (text) sendRemote({ type: 'clipboard', text });
|
|
} catch {
|
|
try {
|
|
const text = await navigator.clipboard.readText();
|
|
sendRemote({ type: 'clipboard_request' });
|
|
} catch { /* Clipboard API not available */ }
|
|
}
|
|
};
|
|
|
|
// Toolbar auto-hide
|
|
let hideTimer;
|
|
document.getElementById('bdv-container').addEventListener('mousemove', () => {
|
|
toolbar.classList.add('bdv-toolbar--visible');
|
|
clearTimeout(hideTimer);
|
|
hideTimer = setTimeout(() => {
|
|
if (streaming) toolbar.classList.remove('bdv-toolbar--visible');
|
|
}, 2500);
|
|
});
|
|
|
|
// ---- Chat ----
|
|
function connectChat() {
|
|
chatWs = new WebSocket(CHAT_URL);
|
|
chatWs.onopen = () => {};
|
|
chatWs.onclose = () => {};
|
|
chatWs.onmessage = (ev) => {
|
|
const frame = JSON.parse(ev.data);
|
|
if (frame.type === 'message') appendChatMsg(frame);
|
|
if (frame.type === 'history') frame.messages.forEach(appendChatMsg);
|
|
};
|
|
}
|
|
|
|
function appendChatMsg(msg) {
|
|
const div = document.createElement('div');
|
|
div.className = 'bdv-chat-msg bdv-chat-msg--' + (msg.from === 'operator' ? 'out' : 'in');
|
|
div.innerHTML = '<span class="bdv-chat-msg-text">' + escHtml(msg.text) + '</span>' +
|
|
'<span class="bdv-chat-msg-time">' + new Date(msg.timestamp).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit'}) + '</span>';
|
|
chatMsgs.appendChild(div);
|
|
chatMsgs.scrollTop = chatMsgs.scrollHeight;
|
|
}
|
|
|
|
function escHtml(str) {
|
|
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
function sendChatMsg() {
|
|
const text = chatInp.value.trim();
|
|
if (!text || !chatWs || chatWs.readyState !== WebSocket.OPEN) return;
|
|
chatWs.send(JSON.stringify({ type: 'message', text }));
|
|
chatInp.value = '';
|
|
}
|
|
|
|
document.getElementById('btnChat').onclick = () => {
|
|
chatOpen = !chatOpen;
|
|
chatSide.classList.toggle('bdv-chat--open', chatOpen);
|
|
};
|
|
|
|
document.getElementById('btnCloseChat').onclick = () => {
|
|
chatOpen = false;
|
|
chatSide.classList.remove('bdv-chat--open');
|
|
};
|
|
|
|
document.getElementById('btnSendChat').onclick = sendChatMsg;
|
|
chatInp.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendChatMsg(); });
|
|
|
|
// ---- Init ----
|
|
connectRemote();
|
|
connectChat();
|
|
})();
|
|
</script>
|
|
|
|
<style>
|
|
/* ---- BetterDesk Remote Viewer styles (scope: this page only) ---- */
|
|
|
|
.bdv-container {
|
|
position: fixed; inset: 0;
|
|
background: #111;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.bdv-canvas {
|
|
display: block;
|
|
max-width: 100%; max-height: 100%;
|
|
object-fit: contain;
|
|
cursor: none;
|
|
outline: none;
|
|
}
|
|
|
|
.bdv-overlay {
|
|
position: absolute; inset: 0;
|
|
background: rgba(0,0,0,.82);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 10;
|
|
}
|
|
|
|
.bdv-overlay-card {
|
|
background: #1a1a2e;
|
|
border: 1px solid #2d2d4a;
|
|
border-radius: 16px;
|
|
padding: 40px 48px;
|
|
text-align: center;
|
|
max-width: 400px;
|
|
width: 90%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12px;
|
|
color: #e8e8f0;
|
|
}
|
|
|
|
.bdv-overlay-icon .material-icons { font-size: 56px; color: #4f6ef7; }
|
|
.bdv-overlay-title { font-size: 1.4rem; font-weight: 700; }
|
|
.bdv-overlay-device { font-size: 1rem; color: #9898b0; }
|
|
.bdv-overlay-id { font-size: 0.8rem; color: #606078; font-family: monospace; }
|
|
|
|
.bdv-overlay-status {
|
|
display: flex; align-items: center; gap: 10px;
|
|
color: #9898b0; font-size: 0.9rem;
|
|
}
|
|
|
|
.bdv-overlay-actions { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
|
|
|
|
.bdv-toolbar {
|
|
position: absolute;
|
|
top: 0; left: 0; right: 0;
|
|
height: 48px;
|
|
background: linear-gradient(to bottom, rgba(0,0,0,.8), transparent);
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 16px;
|
|
gap: 10px;
|
|
z-index: 20;
|
|
opacity: 0;
|
|
transition: opacity .2s;
|
|
}
|
|
|
|
.bdv-toolbar--visible { opacity: 1; }
|
|
|
|
.bdv-toolbar-left, .bdv-toolbar-right {
|
|
display: flex; align-items: center; gap: 8px; color: #e8e8f0;
|
|
}
|
|
.bdv-toolbar-center {
|
|
display: flex; align-items: center; gap: 8px; margin: 0 auto;
|
|
}
|
|
.bdv-toolbar-right { margin-left: 0; }
|
|
|
|
.bdv-toolbar-sep { width: 1px; height: 16px; background: #444; }
|
|
.bdv-badge { background: rgba(255,255,255,.1); border-radius: 4px; padding: 2px 8px; font-size: 0.75rem; color: #ccc; }
|
|
|
|
.bdv-select {
|
|
background: rgba(255,255,255,.1);
|
|
border: 1px solid rgba(255,255,255,.15);
|
|
border-radius: 4px;
|
|
color: #e8e8f0;
|
|
font-size: 0.75rem;
|
|
padding: 3px 6px;
|
|
cursor: pointer;
|
|
outline: none;
|
|
}
|
|
.bdv-select:hover { background: rgba(255,255,255,.2); }
|
|
.bdv-select option { background: #1a1a2e; color: #e8e8f0; }
|
|
.bdv-hidden { display: none !important; }
|
|
|
|
.bdv-beta-banner {
|
|
position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
|
|
background: linear-gradient(90deg, #3b82f6, #2563eb);
|
|
color: #fff; text-align: center; padding: 6px 15px;
|
|
font-size: 12px; font-weight: 500;
|
|
display: flex; align-items: center; justify-content: center; gap: 12px;
|
|
}
|
|
.bdv-beta-dismiss {
|
|
background: none; border: none; color: rgba(255,255,255,.7);
|
|
cursor: pointer; font-size: 16px; line-height: 1;
|
|
}
|
|
.bdv-beta-dismiss:hover { color: #fff; }
|
|
|
|
.bdv-dropdown {
|
|
position: absolute; top: 52px; right: 200px;
|
|
background: #1a1a2e; border: 1px solid #2d2d4a;
|
|
border-radius: 8px; z-index: 25;
|
|
padding: 4px 0; min-width: 180px;
|
|
box-shadow: 0 4px 16px rgba(0,0,0,.5);
|
|
}
|
|
.bdv-dropdown-item {
|
|
display: block; width: 100%;
|
|
background: none; border: none; cursor: pointer;
|
|
color: #e8e8f0; font-size: 0.85rem;
|
|
padding: 8px 16px; text-align: left;
|
|
transition: background .15s;
|
|
}
|
|
.bdv-dropdown-item:hover { background: rgba(255,255,255,.1); }
|
|
|
|
.bdv-btn {
|
|
background: none; border: none; cursor: pointer; color: #e8e8f0;
|
|
padding: 4px; border-radius: 4px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
transition: background .15s;
|
|
}
|
|
.bdv-btn:hover { background: rgba(255,255,255,.15); }
|
|
.bdv-btn--danger:hover { background: rgba(239,68,68,.3); }
|
|
|
|
.bdv-chat {
|
|
position: absolute;
|
|
top: 0; right: -320px; bottom: 0;
|
|
width: 320px;
|
|
background: #1a1a2e;
|
|
border-left: 1px solid #2d2d4a;
|
|
z-index: 30;
|
|
display: flex; flex-direction: column;
|
|
transition: right .25s ease;
|
|
}
|
|
.bdv-chat--open { right: 0; }
|
|
|
|
.bdv-chat-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 12px 16px;
|
|
border-bottom: 1px solid #2d2d4a;
|
|
color: #e8e8f0; font-size: 0.9rem; font-weight: 600;
|
|
}
|
|
|
|
.bdv-chat-messages {
|
|
flex: 1; overflow-y: auto;
|
|
padding: 12px 14px;
|
|
display: flex; flex-direction: column; gap: 8px;
|
|
}
|
|
|
|
.bdv-chat-msg { display: flex; flex-direction: column; max-width: 85%; }
|
|
.bdv-chat-msg--out { align-self: flex-end; }
|
|
.bdv-chat-msg--in { align-self: flex-start; }
|
|
|
|
.bdv-chat-msg-text {
|
|
padding: 8px 12px;
|
|
border-radius: 12px;
|
|
font-size: 0.875rem;
|
|
word-break: break-word;
|
|
}
|
|
.bdv-chat-msg--out .bdv-chat-msg-text { background: #4f6ef7; color: #fff; border-bottom-right-radius: 4px; }
|
|
.bdv-chat-msg--in .bdv-chat-msg-text { background: #222240; color: #e8e8f0; border-bottom-left-radius: 4px; }
|
|
|
|
.bdv-chat-msg-time { font-size: 0.7rem; color: #606078; padding: 2px 4px; }
|
|
.bdv-chat-msg--out .bdv-chat-msg-time { text-align: right; }
|
|
|
|
.bdv-chat-input-row {
|
|
display: flex; gap: 6px; padding: 10px 12px;
|
|
border-top: 1px solid #2d2d4a;
|
|
}
|
|
|
|
.bdv-chat-inp {
|
|
flex: 1;
|
|
background: #222240; border: 1px solid #2d2d4a; border-radius: 8px;
|
|
padding: 8px 12px; color: #e8e8f0; font-size: 0.875rem;
|
|
outline: none;
|
|
}
|
|
.bdv-chat-inp:focus { border-color: #4f6ef7; }
|
|
|
|
/* Spinner reuse from theme.css (fallback inline) */
|
|
.spinner {
|
|
width: 20px; height: 20px;
|
|
border: 2px solid #2d2d4a;
|
|
border-top-color: #4f6ef7;
|
|
border-radius: 50%;
|
|
animation: spin .8s linear infinite;
|
|
display: inline-block;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
</style>
|
|
|
|
`
|
|
}) %>
|