Merge branch 'dev/uart_terminal'
This commit is contained in:
@@ -51,6 +51,7 @@ async def restrict_to_lan(request: Request, call_next):
|
||||
|
||||
# 掛載靜態網頁檔案,將預設首頁導向到 /ui/
|
||||
app.mount("/ui", StaticFiles(directory="static", html=True), name="static")
|
||||
app.mount("/uart_terminal", StaticFiles(directory="uart_example", html=True), name="uart_terminal")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
@@ -58,6 +59,11 @@ def redirect_to_ui():
|
||||
return RedirectResponse(url="/ui/")
|
||||
|
||||
|
||||
@app.get("/uart_terminal")
|
||||
def redirect_to_uart_terminal():
|
||||
return RedirectResponse(url="/uart_terminal/")
|
||||
|
||||
|
||||
@app.post("/api/design")
|
||||
def design_filter(params: DesignParams):
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Web UART Terminal
|
||||
|
||||
A sleek, premium, self-contained web application that enables serial port communication directly from your browser using the **Web Serial API**. It supports standard UART configuration controls (Baud rate, data bits, stop bits, parity), ASCII/Hex display, Hex data sending, log exporting, and a responsive dark/light themed user interface.
|
||||
|
||||

|
||||
|
||||
## Prerequisites & Compatibility
|
||||
|
||||
1. **Browser Support**: The Web Serial API is supported in modern Chromium-based browsers, including:
|
||||
- Google Chrome (version 89+)
|
||||
- Microsoft Edge (version 89+)
|
||||
- Opera (version 75+)
|
||||
- *(Note: Firefox, Safari, and iOS browsers are not supported at this time.)*
|
||||
2. **Secure Context**: The browser only permits Web Serial API calls in a secure context (`https://`) or from `localhost` (which Vite handles automatically).
|
||||
|
||||
## Quick Start
|
||||
|
||||
To launch the development server locally:
|
||||
|
||||
1. **Install dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. **Start the local server**:
|
||||
```bash
|
||||
cd ui/uart_terminal
|
||||
# If not create the virtual environment
|
||||
python3 -m venv .venv
|
||||
# Activate it
|
||||
source .venv/bin/activate
|
||||
# start server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. **Open the browser**:
|
||||
Navigate to the URL printed in the terminal (usually `http://localhost:3000`).
|
||||
|
||||
## Features & Usage
|
||||
|
||||
- **Connect / Disconnect**: Click the circular green play icon in the top left to request a COM/USB serial port and open connection. The button changes to a red stop icon when connected.
|
||||
- **Save Logs**: Click the blue download button next to the connection status to export the accumulated console output log as a `.txt` file.
|
||||
- **Scroll Modes**: Switch between "Auto-scroll" and "No scroll" to control terminal navigation.
|
||||
- **Display Formats**:
|
||||
- `Auto`: Prints incoming data as decoded UTF-8 text string (default).
|
||||
- `Hex`: Displays incoming bytes as space-separated hexadecimal numbers (e.g., `55 AA 01 02`).
|
||||
- **Data Transmission**:
|
||||
- Enter text in the send input at the bottom and click the Send button or press `Enter` to transmit.
|
||||
- Switch the send format select dropdown between `ASCII` and `Hex` to format data packages.
|
||||
- **Hex Parsing Rules**: When in Hex mode, you can input hex characters with or without spaces (e.g., `55 AA FF` or `55aaff`). The string must contain an even number of characters and valid hexadecimal characters only.
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
- **Send Data**: Press `Enter` (or `Ctrl+Enter`) inside the input bar to send.
|
||||
- **Insert Tabs**: Press `Ctrl+Tab` in the input bar to insert a literal tab character (`\t`) instead of losing input focus.
|
||||
@@ -0,0 +1,761 @@
|
||||
// DOM Elements Selection
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const connectBtn = document.getElementById('connect-btn');
|
||||
const downloadBtn = document.getElementById('download-btn');
|
||||
const statusBanner = document.getElementById('status-banner');
|
||||
const statusText = document.getElementById('status-text');
|
||||
|
||||
// Settings Dropdowns
|
||||
const baudRateSelect = document.getElementById('baud-rate');
|
||||
const dataBitsSelect = document.getElementById('data-bits');
|
||||
const stopBitsSelect = document.getElementById('stop-bits');
|
||||
const paritySelect = document.getElementById('parity');
|
||||
|
||||
// Controls & Console
|
||||
const scrollModeSelect = document.getElementById('scroll-mode');
|
||||
const displayFormatSelect = document.getElementById('display-format');
|
||||
const terminalContainer = document.getElementById('terminal-container');
|
||||
|
||||
// Sending Inputs
|
||||
const sendInput = document.getElementById('send-input');
|
||||
const sendFormatSelect = document.getElementById('send-format');
|
||||
const sendEolSelect = document.getElementById('send-eol');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const clearBtn = document.getElementById('clear-btn');
|
||||
const addSessionBtn = document.getElementById('add-session-btn');
|
||||
const sessionListContainer = document.getElementById('session-list');
|
||||
const downloadTypeSelect = document.getElementById('download-type');
|
||||
|
||||
// PortSession Class definition
|
||||
class PortSession {
|
||||
constructor(id, name) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
|
||||
// Web Serial State
|
||||
this.port = null;
|
||||
this.reader = null;
|
||||
this.writer = null;
|
||||
this.keepReading = false;
|
||||
this.rxLogBuffer = [];
|
||||
this.txLogBuffer = [];
|
||||
this.combinedLogBuffer = [];
|
||||
this.downloadType = 'rx_tx';
|
||||
this.commandHistory = [];
|
||||
this.historyIndex = -1;
|
||||
this.isTerminalEmpty = true;
|
||||
this.deviceInfo = '';
|
||||
|
||||
// Settings state
|
||||
this.baudRate = '115200';
|
||||
this.dataBits = '8';
|
||||
this.stopBits = '1';
|
||||
this.parity = 'none';
|
||||
|
||||
// Console controls
|
||||
this.scrollMode = 'auto';
|
||||
this.displayFormat = 'auto';
|
||||
this.sendFormat = 'ascii';
|
||||
this.sendEol = 'crlf';
|
||||
this.sendText = '';
|
||||
|
||||
// Dedicated Terminal Element
|
||||
this.terminalOutputElement = document.createElement('div');
|
||||
this.terminalOutputElement.id = `terminal-output-${this.id}`;
|
||||
this.terminalOutputElement.className = 'terminal-output empty';
|
||||
this.terminalOutputElement.tabIndex = 0;
|
||||
this.terminalOutputElement.textContent = 'No data received yet...';
|
||||
}
|
||||
}
|
||||
|
||||
// Global Application State
|
||||
let sessions = [];
|
||||
let activeSession = null;
|
||||
|
||||
// Initialize theme and browser support on load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initTheme();
|
||||
checkWebSerialSupport();
|
||||
|
||||
// Wire up main event listeners
|
||||
addSessionBtn.addEventListener('click', addSession);
|
||||
|
||||
baudRateSelect.addEventListener('change', () => {
|
||||
if (activeSession) {
|
||||
activeSession.baudRate = baudRateSelect.value;
|
||||
renderSessionsList();
|
||||
}
|
||||
});
|
||||
|
||||
dataBitsSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.dataBits = dataBitsSelect.value;
|
||||
});
|
||||
|
||||
stopBitsSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.stopBits = stopBitsSelect.value;
|
||||
});
|
||||
|
||||
paritySelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.parity = paritySelect.value;
|
||||
});
|
||||
|
||||
scrollModeSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.scrollMode = scrollModeSelect.value;
|
||||
});
|
||||
|
||||
displayFormatSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.displayFormat = displayFormatSelect.value;
|
||||
});
|
||||
|
||||
sendFormatSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.sendFormat = sendFormatSelect.value;
|
||||
});
|
||||
|
||||
sendEolSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.sendEol = sendEolSelect.value;
|
||||
});
|
||||
|
||||
sendInput.addEventListener('input', () => {
|
||||
if (activeSession) activeSession.sendText = sendInput.value;
|
||||
});
|
||||
|
||||
downloadTypeSelect.addEventListener('change', () => {
|
||||
if (activeSession) activeSession.downloadType = downloadTypeSelect.value;
|
||||
});
|
||||
|
||||
// Initialize with a default session
|
||||
addSession();
|
||||
});
|
||||
|
||||
// Toast notification helper
|
||||
function showToast(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-msg show toast-${type}`;
|
||||
toast.textContent = message;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.classList.remove('show');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 1. Theme Configuration (Dark / Light Mode)
|
||||
// -------------------------------------------------------------
|
||||
function initTheme() {
|
||||
const savedTheme = localStorage.getItem('theme') || 'dark';
|
||||
if (savedTheme === 'light') {
|
||||
document.body.classList.add('light-theme');
|
||||
} else {
|
||||
document.body.classList.remove('light-theme');
|
||||
}
|
||||
}
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
document.body.classList.toggle('light-theme');
|
||||
const activeTheme = document.body.classList.contains('light-theme') ? 'light' : 'dark';
|
||||
localStorage.setItem('theme', activeTheme);
|
||||
});
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 2. Web Serial API Checks & State Updates
|
||||
// -------------------------------------------------------------
|
||||
function checkWebSerialSupport() {
|
||||
if (!('serial' in navigator)) {
|
||||
statusBanner.className = 'status-banner error';
|
||||
statusText.innerHTML = '<strong>Web Serial API not supported.</strong> Please use a compatible Chromium-based browser (Chrome, Edge, Opera).';
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.style.opacity = '0.5';
|
||||
connectBtn.style.cursor = 'not-allowed';
|
||||
addSessionBtn.disabled = true;
|
||||
addSessionBtn.style.opacity = '0.5';
|
||||
addSessionBtn.style.cursor = 'not-allowed';
|
||||
showToast('Web Serial API is not supported in this browser.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function updateConnectionUI(connected, errorMsg = '') {
|
||||
if (!activeSession) return;
|
||||
|
||||
if (connected && activeSession.port) {
|
||||
connectBtn.className = 'connect-btn connected';
|
||||
connectBtn.title = 'Disconnect from serial port';
|
||||
statusBanner.className = 'status-banner connected';
|
||||
|
||||
const info = activeSession.port.getInfo();
|
||||
const portLabel = info.usbVendorId
|
||||
? `USB Device (VID: 0x${info.usbVendorId.toString(16).toUpperCase()}, PID: 0x${info.usbProductId.toString(16).toUpperCase()})`
|
||||
: 'Serial Device';
|
||||
|
||||
statusText.innerHTML = `Connected to <strong>${portLabel}</strong> at ${activeSession.baudRate} bps.`;
|
||||
} else {
|
||||
connectBtn.className = 'connect-btn disconnected';
|
||||
connectBtn.title = 'Connect to serial port';
|
||||
|
||||
if (errorMsg) {
|
||||
statusBanner.className = 'status-banner error';
|
||||
statusText.textContent = errorMsg;
|
||||
} else {
|
||||
statusBanner.className = 'status-banner warn';
|
||||
statusText.textContent = 'Not connected. Click the connect button to select a port.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 3. Serial Session Management
|
||||
// -------------------------------------------------------------
|
||||
function addSession() {
|
||||
const id = Date.now().toString(36) + Math.random().toString(36).substr(2, 5);
|
||||
const name = `Session ${sessions.length + 1}`;
|
||||
const newSession = new PortSession(id, name);
|
||||
|
||||
sessions.push(newSession);
|
||||
terminalContainer.appendChild(newSession.terminalOutputElement);
|
||||
|
||||
selectSession(newSession);
|
||||
showToast(`${name} created.`, 'success');
|
||||
}
|
||||
|
||||
function selectSession(session) {
|
||||
if (activeSession) {
|
||||
activeSession.sendText = sendInput.value;
|
||||
}
|
||||
|
||||
activeSession = session;
|
||||
renderSessionsList();
|
||||
|
||||
// Show only active session's terminal DOM element
|
||||
const terminals = terminalContainer.querySelectorAll('.terminal-output');
|
||||
terminals.forEach(el => {
|
||||
if (el.id === activeSession.terminalOutputElement.id) {
|
||||
el.classList.remove('hidden');
|
||||
} else {
|
||||
el.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Sync dropdown UI values
|
||||
baudRateSelect.value = activeSession.baudRate;
|
||||
dataBitsSelect.value = activeSession.dataBits;
|
||||
stopBitsSelect.value = activeSession.stopBits;
|
||||
paritySelect.value = activeSession.parity;
|
||||
|
||||
scrollModeSelect.value = activeSession.scrollMode;
|
||||
displayFormatSelect.value = activeSession.displayFormat;
|
||||
sendFormatSelect.value = activeSession.sendFormat;
|
||||
sendEolSelect.value = activeSession.sendEol;
|
||||
sendInput.value = activeSession.sendText;
|
||||
downloadTypeSelect.value = activeSession.downloadType;
|
||||
|
||||
downloadBtn.disabled = (activeSession.rxLogBuffer.length === 0 && activeSession.txLogBuffer.length === 0);
|
||||
|
||||
// Sync status banner and buttons
|
||||
const isConnected = activeSession.port !== null && activeSession.port.readable;
|
||||
updateConnectionUI(isConnected);
|
||||
}
|
||||
|
||||
async function deleteSession(session, event) {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (sessions.length <= 1) {
|
||||
showToast('Cannot delete the last remaining session.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disconnect if connected
|
||||
if (session.port) {
|
||||
await disconnectSession(session);
|
||||
}
|
||||
|
||||
// Remove terminal from DOM
|
||||
if (session.terminalOutputElement && session.terminalOutputElement.parentNode) {
|
||||
session.terminalOutputElement.parentNode.removeChild(session.terminalOutputElement);
|
||||
}
|
||||
|
||||
const index = sessions.indexOf(session);
|
||||
if (index > -1) {
|
||||
sessions.splice(index, 1);
|
||||
}
|
||||
|
||||
if (activeSession === session) {
|
||||
const nextActive = sessions[Math.max(0, index - 1)];
|
||||
selectSession(nextActive);
|
||||
} else {
|
||||
renderSessionsList();
|
||||
}
|
||||
|
||||
showToast(`${session.name} deleted.`, 'success');
|
||||
}
|
||||
|
||||
function renderSessionsList() {
|
||||
sessionListContainer.innerHTML = '';
|
||||
|
||||
sessions.forEach(session => {
|
||||
const item = document.createElement('div');
|
||||
item.className = `session-item ${session === activeSession ? 'active' : ''}`;
|
||||
item.addEventListener('click', () => selectSession(session));
|
||||
|
||||
const leftGroup = document.createElement('div');
|
||||
leftGroup.className = 'session-item-left';
|
||||
|
||||
// Status dot
|
||||
const dot = document.createElement('span');
|
||||
const isConnected = session.port !== null && session.port.readable;
|
||||
dot.className = `status-dot ${isConnected ? 'connected' : 'disconnected'}`;
|
||||
leftGroup.appendChild(dot);
|
||||
|
||||
// Session labels
|
||||
const info = document.createElement('div');
|
||||
info.className = 'session-info';
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'session-name';
|
||||
nameEl.textContent = session.name;
|
||||
|
||||
// Double click to rename
|
||||
nameEl.addEventListener('dblclick', (e) => {
|
||||
e.stopPropagation();
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = session.name;
|
||||
input.className = 'session-rename-input';
|
||||
|
||||
const saveRename = () => {
|
||||
const val = input.value.trim();
|
||||
if (val) {
|
||||
session.name = val;
|
||||
}
|
||||
renderSessionsList();
|
||||
};
|
||||
|
||||
input.addEventListener('keydown', (evt) => {
|
||||
if (evt.key === 'Enter') {
|
||||
saveRename();
|
||||
} else if (evt.key === 'Escape') {
|
||||
renderSessionsList();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('blur', saveRename);
|
||||
|
||||
info.innerHTML = '';
|
||||
info.appendChild(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
|
||||
const detailEl = document.createElement('span');
|
||||
detailEl.className = 'session-detail';
|
||||
if (isConnected) {
|
||||
detailEl.textContent = `${session.baudRate} bps | ${session.deviceInfo || 'Connected'}`;
|
||||
} else {
|
||||
detailEl.textContent = `${session.baudRate} bps | Disconnected`;
|
||||
}
|
||||
|
||||
info.appendChild(nameEl);
|
||||
info.appendChild(detailEl);
|
||||
leftGroup.appendChild(info);
|
||||
item.appendChild(leftGroup);
|
||||
|
||||
// Close / Delete Session button
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'session-delete-btn';
|
||||
deleteBtn.title = 'Delete Session';
|
||||
deleteBtn.innerHTML = `
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width: 14px; height: 14px;">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
`;
|
||||
deleteBtn.addEventListener('click', (e) => deleteSession(session, e));
|
||||
item.appendChild(deleteBtn);
|
||||
|
||||
sessionListContainer.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 4. Serial Port Connection & Reader Loop
|
||||
// -------------------------------------------------------------
|
||||
async function connectSession(session) {
|
||||
try {
|
||||
session.port = await navigator.serial.requestPort();
|
||||
|
||||
const options = {
|
||||
baudRate: parseInt(session.baudRate, 10),
|
||||
dataBits: parseInt(session.dataBits, 10),
|
||||
stopBits: parseInt(session.stopBits, 10),
|
||||
parity: session.parity
|
||||
};
|
||||
|
||||
await session.port.open(options);
|
||||
|
||||
const info = session.port.getInfo();
|
||||
if (info.usbVendorId) {
|
||||
session.deviceInfo = `USB VID:0x${info.usbVendorId.toString(16).toUpperCase()}`;
|
||||
} else {
|
||||
session.deviceInfo = 'Serial Device';
|
||||
}
|
||||
|
||||
if (session === activeSession) {
|
||||
updateConnectionUI(true);
|
||||
}
|
||||
renderSessionsList();
|
||||
|
||||
session.keepReading = true;
|
||||
startSessionReadingLoop(session);
|
||||
|
||||
showToast(`${session.name} connected.`, 'success');
|
||||
} catch (err) {
|
||||
console.error('Connection failed:', err);
|
||||
session.port = null;
|
||||
if (session === activeSession) {
|
||||
updateConnectionUI(false, `Connection failed: ${err.message}`);
|
||||
}
|
||||
showToast(`Failed to open port for ${session.name}.`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectSession(session) {
|
||||
session.keepReading = false;
|
||||
|
||||
if (session.reader) {
|
||||
try {
|
||||
await session.reader.cancel();
|
||||
} catch (e) {
|
||||
console.error('Error cancelling reader:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(async () => {
|
||||
if (session.port) {
|
||||
try {
|
||||
await session.port.close();
|
||||
} catch (e) {
|
||||
console.error('Error closing port:', e);
|
||||
}
|
||||
session.port = null;
|
||||
}
|
||||
session.deviceInfo = '';
|
||||
if (session === activeSession) {
|
||||
updateConnectionUI(false);
|
||||
}
|
||||
renderSessionsList();
|
||||
showToast(`${session.name} disconnected.`, 'success');
|
||||
resolve();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
async function startSessionReadingLoop(session) {
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (session.port && session.port.readable && session.keepReading) {
|
||||
try {
|
||||
session.reader = session.port.readable.getReader();
|
||||
|
||||
while (session.keepReading) {
|
||||
const { value, done } = await session.reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
handleIncomingSessionData(session, value, decoder);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Read error:', err);
|
||||
if (session === activeSession) {
|
||||
updateConnectionUI(false, `Port disconnected: ${err.message}`);
|
||||
}
|
||||
showToast(`${session.name} disconnected unexpectedly.`, 'error');
|
||||
session.port = null;
|
||||
renderSessionsList();
|
||||
break;
|
||||
} finally {
|
||||
if (session.reader) {
|
||||
session.reader.releaseLock();
|
||||
session.reader = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleIncomingSessionData(session, uint8Array, decoder) {
|
||||
const format = session.displayFormat;
|
||||
let formattedText = '';
|
||||
|
||||
if (format === 'hex') {
|
||||
formattedText = Array.from(uint8Array)
|
||||
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
||||
.join(' ') + ' ';
|
||||
} else {
|
||||
formattedText = decoder.decode(uint8Array);
|
||||
}
|
||||
|
||||
session.rxLogBuffer.push(formattedText);
|
||||
session.combinedLogBuffer.push(formattedText);
|
||||
appendSessionTerminalText(session, formattedText, 'received');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 5. Writing Data (Transmission)
|
||||
// -------------------------------------------------------------
|
||||
async function sendSessionData() {
|
||||
if (!activeSession) return;
|
||||
const session = activeSession;
|
||||
|
||||
if (!session.port || !session.port.writable) {
|
||||
showToast(`Cannot send. ${session.name} is not connected.`, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const rawInput = sendInput.value;
|
||||
if (!rawInput) return;
|
||||
|
||||
// Add command to history (maximum 10 items)
|
||||
const trimmed = rawInput.trim();
|
||||
if (trimmed) {
|
||||
const history = session.commandHistory;
|
||||
if (history.length === 0 || history[history.length - 1] !== trimmed) {
|
||||
history.push(trimmed);
|
||||
if (history.length > 10) {
|
||||
history.shift();
|
||||
}
|
||||
}
|
||||
session.historyIndex = history.length;
|
||||
}
|
||||
|
||||
const format = session.sendFormat;
|
||||
const eol = session.sendEol;
|
||||
let bytesToSend;
|
||||
|
||||
let eolText = '';
|
||||
let eolBytes = new Uint8Array(0);
|
||||
if (eol === 'lf') {
|
||||
eolText = '\n';
|
||||
eolBytes = new Uint8Array([10]);
|
||||
} else if (eol === 'cr') {
|
||||
eolText = '\r';
|
||||
eolBytes = new Uint8Array([13]);
|
||||
} else if (eol === 'crlf') {
|
||||
eolText = '\r\n';
|
||||
eolBytes = new Uint8Array([13, 10]);
|
||||
}
|
||||
|
||||
try {
|
||||
if (format === 'hex') {
|
||||
const parsedBytes = parseHexString(rawInput);
|
||||
bytesToSend = new Uint8Array(parsedBytes.length + eolBytes.length);
|
||||
bytesToSend.set(parsedBytes);
|
||||
bytesToSend.set(eolBytes, parsedBytes.length);
|
||||
} else {
|
||||
bytesToSend = new TextEncoder().encode(rawInput + eolText);
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.message, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.writer = session.port.writable.getWriter();
|
||||
await session.writer.write(bytesToSend);
|
||||
|
||||
let displaySent = '';
|
||||
if (format === 'hex') {
|
||||
displaySent = Array.from(bytesToSend)
|
||||
.map(b => b.toString(16).padStart(2, '0').toUpperCase())
|
||||
.join(' ') + ' ';
|
||||
} else {
|
||||
displaySent = rawInput;
|
||||
if (eol === 'lf') displaySent += '\\n';
|
||||
else if (eol === 'cr') displaySent += '\\r';
|
||||
else if (eol === 'crlf') displaySent += '\\r\\n';
|
||||
}
|
||||
|
||||
appendSessionTerminalText(session, `\n[Sent] ${displaySent}\n`, 'sent');
|
||||
|
||||
// Save sent log
|
||||
session.txLogBuffer.push(`[Sent] ${displaySent}\n`);
|
||||
session.combinedLogBuffer.push(`\n[Sent] ${displaySent}\n`);
|
||||
|
||||
sendInput.value = '';
|
||||
session.sendText = '';
|
||||
|
||||
} catch (err) {
|
||||
console.error('Send error:', err);
|
||||
showToast(`Failed to send: ${err.message}`, 'error');
|
||||
} finally {
|
||||
if (session.writer) {
|
||||
session.writer.releaseLock();
|
||||
session.writer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseHexString(hexString) {
|
||||
const clean = hexString.replace(/\s+/g, '');
|
||||
|
||||
if (!/^[0-9a-fA-F]*$/.test(clean)) {
|
||||
throw new Error('Hex contains invalid characters (0-9, A-F only)');
|
||||
}
|
||||
|
||||
if (clean.length % 2 !== 0) {
|
||||
throw new Error('Hex string must have an even number of characters');
|
||||
}
|
||||
|
||||
const result = new Uint8Array(clean.length / 2);
|
||||
for (let i = 0; i < clean.length; i += 2) {
|
||||
result[i / 2] = parseInt(clean.substring(i, i + 2), 16);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 6. Terminal Console Operations
|
||||
// -------------------------------------------------------------
|
||||
function appendSessionTerminalText(session, text, className = '') {
|
||||
if (session.isTerminalEmpty) {
|
||||
session.terminalOutputElement.textContent = '';
|
||||
session.terminalOutputElement.classList.remove('empty');
|
||||
session.isTerminalEmpty = false;
|
||||
if (session === activeSession) {
|
||||
downloadBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
if (className) {
|
||||
span.className = `terminal-line ${className}`;
|
||||
}
|
||||
span.textContent = text;
|
||||
session.terminalOutputElement.appendChild(span);
|
||||
|
||||
if (session.scrollMode === 'auto') {
|
||||
session.terminalOutputElement.scrollTop = session.terminalOutputElement.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function clearTerminal() {
|
||||
if (!activeSession) return;
|
||||
activeSession.terminalOutputElement.innerHTML = 'No data received yet...';
|
||||
activeSession.terminalOutputElement.classList.add('empty');
|
||||
activeSession.isTerminalEmpty = true;
|
||||
activeSession.rxLogBuffer = [];
|
||||
activeSession.txLogBuffer = [];
|
||||
activeSession.combinedLogBuffer = [];
|
||||
downloadBtn.disabled = true;
|
||||
showToast('Terminal cleared.', 'success');
|
||||
}
|
||||
|
||||
function downloadLog() {
|
||||
if (!activeSession) return;
|
||||
|
||||
let content = '';
|
||||
let filenameSuffix = '';
|
||||
|
||||
if (activeSession.downloadType === 'rx') {
|
||||
content = activeSession.rxLogBuffer.join('');
|
||||
filenameSuffix = 'rx';
|
||||
} else if (activeSession.downloadType === 'tx') {
|
||||
content = activeSession.txLogBuffer.join('');
|
||||
filenameSuffix = 'tx';
|
||||
} else {
|
||||
content = activeSession.combinedLogBuffer.join('');
|
||||
filenameSuffix = 'combined';
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
showToast('No logs to download for the selected type.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const dateStr = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `uart_log_${activeSession.name.replace(/\s+/g, '_')}_${filenameSuffix}_${dateStr}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, 100);
|
||||
|
||||
showToast('Download started.', 'success');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 7. Event Listeners & Keyboard Shortcuts
|
||||
// -------------------------------------------------------------
|
||||
connectBtn.addEventListener('click', () => {
|
||||
if (activeSession) {
|
||||
if (activeSession.port) {
|
||||
disconnectSession(activeSession);
|
||||
} else {
|
||||
connectSession(activeSession);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sendBtn.addEventListener('click', sendSessionData);
|
||||
clearBtn.addEventListener('click', clearTerminal);
|
||||
downloadBtn.addEventListener('click', downloadLog);
|
||||
|
||||
sendInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
sendSessionData();
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (activeSession && activeSession.commandHistory.length > 0) {
|
||||
if (activeSession.historyIndex > 0) {
|
||||
activeSession.historyIndex--;
|
||||
sendInput.value = activeSession.commandHistory[activeSession.historyIndex];
|
||||
activeSession.sendText = sendInput.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (activeSession && activeSession.commandHistory.length > 0) {
|
||||
if (activeSession.historyIndex < activeSession.commandHistory.length - 1) {
|
||||
activeSession.historyIndex++;
|
||||
sendInput.value = activeSession.commandHistory[activeSession.historyIndex];
|
||||
activeSession.sendText = sendInput.value;
|
||||
} else {
|
||||
activeSession.historyIndex = activeSession.commandHistory.length;
|
||||
sendInput.value = '';
|
||||
activeSession.sendText = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.key === 'Tab' && e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
const start = sendInput.selectionStart;
|
||||
const end = sendInput.selectionEnd;
|
||||
|
||||
sendInput.value = sendInput.value.substring(0, start) + '\t' + sendInput.value.substring(end);
|
||||
sendInput.selectionStart = sendInput.selectionEnd = start + 1;
|
||||
if (activeSession) activeSession.sendText = sendInput.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-detect browser USB disconnect events globally
|
||||
if ('serial' in navigator) {
|
||||
navigator.serial.addEventListener('disconnect', async (event) => {
|
||||
const disconnectedSession = sessions.find(s => s.port === event.target);
|
||||
if (disconnectedSession) {
|
||||
await disconnectSession(disconnectedSession);
|
||||
showToast(`${disconnectedSession.name} device disconnected.`, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# Implementation Plan - Web UART Terminal
|
||||
|
||||
This plan details the implementation of a modern, responsive, and feature-rich Web UART Terminal application. The design will match the dark-themed UI provided in the user's reference image and support the Web Serial API for real-time serial communication with hardware.
|
||||
|
||||
## User Review Required
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Browser Compatibility**: The Web Serial API is supported in Chromium-based browsers (Chrome, Edge, Opera). It is not supported by default in Firefox or Safari. A friendly warning banner will be displayed to users accessing the page from unsupported browsers.
|
||||
> **Local Server Requirement**: The Web Serial API requires a secure context (HTTPS) or localhost to work. To facilitate this, we will configure a simple dev environment using **Vite** so you can run it locally with `npm run dev` (serving at `http://localhost:5173`).
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
We will create a self-contained, lightweight, and high-performance Vanilla HTML/CSS/JS application within the `/home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal` directory.
|
||||
|
||||
### UI & Dev Environment Configuration
|
||||
|
||||
---
|
||||
|
||||
#### [NEW] [package.json](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/package.json)
|
||||
- Define a project package with `vite` as a dev dependency.
|
||||
- Include scripts for local development (`npm run dev`) and building (`npm run build`).
|
||||
|
||||
#### [NEW] [vite.config.js](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/vite.config.js)
|
||||
- A basic configuration file for Vite to ensure it runs correctly and resolves static assets.
|
||||
|
||||
#### [NEW] [index.html](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/index.html)
|
||||
- Create the HTML skeleton of the UART Terminal.
|
||||
- Match the layout in the screenshot:
|
||||
- Header: App Title, Theme Toggle button.
|
||||
- Action Control: Connect/Disconnect button (circle with play/stop icon), Download button (square download icon).
|
||||
- Info Banner: "Not connected" warning banner, dynamically changing based on serial connection status.
|
||||
- Serial Settings Grid: Dropdowns for Baud Rate, Data Bits, Stop Bits, Parity.
|
||||
- Received Data Section: Section title, controls (Scroll: Auto-scroll/None, Display: Auto/Hex/Text), console output textarea/container.
|
||||
- Send Data Footer: Form input with keyboard shortcut tooltip, Send Format dropdown (ASCII/Hex), Send button, and Clear Terminal (trash) button.
|
||||
- Embed all required SVG icons inline for zero external dependencies and fast load times.
|
||||
- Include a Google Font link (e.g., "Inter" for UI typography, "JetBrains Mono" for the terminal console).
|
||||
|
||||
#### [NEW] [index.css](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/index.css)
|
||||
- Implement a premium design system using CSS custom properties (variables) for HSL colors.
|
||||
- Support Dark Mode (default) and Light Mode.
|
||||
- Incorporate subtle animations (transitions on hover/active, pulsing connection status, smooth modal fades).
|
||||
- Implement responsive grids and flex layouts, ensuring pixel-perfect alignment with the reference image.
|
||||
- Design custom scrollbars for the terminal output.
|
||||
|
||||
#### [NEW] [app.js](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/app.js)
|
||||
- Implement core UART logic via Web Serial API:
|
||||
- Request serial port and open connection using selected settings (Baud Rate, Data Bits, Stop Bits, Parity).
|
||||
- Background reading loop with cancellation support.
|
||||
- Writing loop supporting ASCII/Hex conversion.
|
||||
- Implement helper utilities:
|
||||
- Hex parser (validating and converting hex string inputs like `AA BB CC` or `AABBCC` to Uint8Array).
|
||||
- Hex formatter (representing received bytes as spaced hex strings e.g. `00 12 AF`).
|
||||
- Implement UI interactions:
|
||||
- Terminal text appending with Auto-scroll capability.
|
||||
- Download log: converts terminal content to a `.txt` blob and triggers a browser download.
|
||||
- Clear log: clears the screen and reset states.
|
||||
- Keyboard shortcuts (e.g. `Ctrl+Enter` to send, `Ctrl+Tab` to insert tab in input, etc.).
|
||||
- Theme toggling with `localStorage` persistence.
|
||||
|
||||
#### [NEW] [README.md](file:///home/roy/zData/WTPCode/pec930/pec930_pfc_llc/ui/uart_terminal/README.md)
|
||||
- Instructions on installing dependencies (`npm install`) and launching the development server (`npm run dev`).
|
||||
- Quick notes on Web Serial browser compatibility and security requirements.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Manual Verification
|
||||
1. Launch the local dev server using `npm run dev`.
|
||||
2. Access the app via Chrome or Edge browser.
|
||||
3. Verify that the UI matches the reference screenshot (dark theme, layouts, styling, icons, font sizes).
|
||||
4. Verify the theme toggle (switching from dark to light mode and back).
|
||||
5. Verify inputs:
|
||||
- Check if serial configuration dropdowns have correct options.
|
||||
- Verify that clicking "Connect" triggers the browser's Serial Port prompt.
|
||||
- Verify Hex transmission (e.g., inputting `48 45 4C 4C 4F` in Hex mode sends `HELLO`).
|
||||
- Verify Text transmission.
|
||||
- Test "Clear" (trash bin) and "Download" buttons.
|
||||
- Test auto-scroll behaviour with mock data.
|
||||
@@ -0,0 +1,61 @@
|
||||
# UART Terminal Multi-Port Implementation Plan
|
||||
|
||||
The goal is to modify the UART Terminal to support multiple concurrent serial port sessions. We will introduce a sidebar panel on the left to display all sessions (e.g., active/inactive state, baud rate, and custom names) and a main workspace on the right to manage the configuration and terminal log of the currently selected session.
|
||||
|
||||
## User Review Required
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - **Default Session**: On app load, a default "Session 1" will be created and selected. This ensures that users who just want to use a single port don't have to perform any additional clicks.
|
||||
> - **Concurrent Connections**: Each session will maintain its own active connection, serial reader loop, terminal logs, and configurations. This allows the user to have multiple serial devices connected and communicating simultaneously, switching between them at will without losing context or scroll history.
|
||||
> - **Session Renaming**: Users can double-click a session name in the sidebar to rename it to something descriptive (e.g., "MCU 1", "GPS Module").
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### UI / Layout Configuration
|
||||
|
||||
#### [MODIFY] [index.html](file:///home/roy/zData/WTPCode/bodeplot/uart_example/index.html)
|
||||
- Wrap the `<main class="app-main">` and a new `<aside class="app-sidebar">` inside a container `<div class="app-layout">`.
|
||||
- Adjust container structure:
|
||||
- Increase `.app-container` max-width to `1200px` to comfortably host the sidebar and terminal split.
|
||||
- Implement dynamic placeholders for the terminal output elements. Instead of a single `#terminal-output` div, we'll make `#terminal-container` host dynamically created terminal-output elements for each session, toggling their visibility based on the active session.
|
||||
- Add an "Add Session" (`#add-session-btn`) button to the sidebar.
|
||||
|
||||
#### [MODIFY] [index.css](file:///home/roy/zData/WTPCode/bodeplot/uart_example/index.css)
|
||||
- Increase `.app-container` `max-width` to `1200px`.
|
||||
- Add rules for `.app-layout` (CSS Grid or Flexbox layout, split `260px` and `1fr`).
|
||||
- Add styles for the sidebar (`.app-sidebar`):
|
||||
- `.sidebar-header` (flex row, title, add-session button).
|
||||
- `.session-list` (flex column, scrolling).
|
||||
- `.session-item` (states: hover, active, status-dots, title/subtitle, hover delete button).
|
||||
- `.session-rename-input` (input box replacing title on double-click).
|
||||
- Add support for displaying/hiding terminal sessions via class `.hidden { display: none !important; }`.
|
||||
|
||||
### State & Logic Configuration
|
||||
|
||||
#### [MODIFY] [app.js](file:///home/roy/zData/WTPCode/bodeplot/uart_example/app.js)
|
||||
- Define a `PortSession` class to encapsulate all states of a single UART connection:
|
||||
- ID, name, connection state, terminal DOM element, log buffers, input text cache.
|
||||
- Port options: `baudRate`, `dataBits`, `stopBits`, `parity`.
|
||||
- Terminal configs: `scrollMode`, `displayFormat`, `sendFormat`, `sendEol`.
|
||||
- Maintain global variables:
|
||||
- `sessions` array.
|
||||
- `activeSession` pointer.
|
||||
- Refactor connection functions (`connectSerial`, `disconnectSerial`, `startReadingLoop`, `sendData`) to execute against the active session's parameters.
|
||||
- Re-route UI change listeners (Baud Rate selector, display formats, send input, etc.) to update properties on `activeSession` when changed.
|
||||
- Build functions to:
|
||||
- Add a session (`addSession()`).
|
||||
- Select/Activate a session (`activateSession(session)`).
|
||||
- Delete/Remove a session (`deleteSession(session)`).
|
||||
- Sync UI selectors with active session states.
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Automated Tests
|
||||
We will run `npm run dev` or a local static files server to review the layout, and use our understanding of the DOM state to ensure multiple concurrent sessions behave correctly.
|
||||
|
||||
### Manual Verification
|
||||
1. **Multiple Tabs**: Add 3 ports in the sidebar. Verify they are named Session 1, Session 2, Session 3.
|
||||
2. **Settings Persistence**: Change Baud Rate in Session 1 to `9600` and Session 2 to `115200`. Switch between them and verify that the dropdown value updates to reflect the active session's settings.
|
||||
3. **Session Renaming**: Double-click "Session 1" name in the sidebar, type "Test Port", and press Enter. Verify the label changes.
|
||||
4. **Delete Session**: Select a session and click the `x` delete icon. Verify it gets removed. If connected, it should disconnect safely first.
|
||||
5. **Console logs isolation**: Verify that sending data or logs in one session does not bleed into another session's terminal panel.
|
||||
@@ -0,0 +1,952 @@
|
||||
/* CSS Variables for Premium Design System */
|
||||
:root {
|
||||
/* Dark Theme Variables (Default) */
|
||||
--bg-app: #0b0f19;
|
||||
--bg-panel: #111827;
|
||||
--bg-input: #1f2937;
|
||||
--border-color: #374151;
|
||||
--border-hover: #4b5563;
|
||||
--border-focus: #6366f1;
|
||||
--text-main: #f9fafb;
|
||||
--text-muted: #9ca3af;
|
||||
--terminal-bg: #090d16;
|
||||
--terminal-text: #e5e7eb;
|
||||
--terminal-border: #1f2937;
|
||||
|
||||
/* Accent Colors */
|
||||
--accent-green: #10b981;
|
||||
--accent-green-hover: #059669;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-blue-hover: #2563eb;
|
||||
--accent-purple: #6366f1;
|
||||
--accent-purple-hover: #4f46e5;
|
||||
--accent-warn-bg: #fef3c7;
|
||||
--accent-warn-text: #b45309;
|
||||
--accent-warn-border: #f59e0b;
|
||||
--accent-danger: #ef4444;
|
||||
--accent-danger-hover: #dc2626;
|
||||
|
||||
/* Layout and Styling Details */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', source-code-pro, Menlo, Monaco, Consolas, monospace;
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.15);
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-normal: 0.25s ease;
|
||||
}
|
||||
|
||||
/* Light Theme Variables */
|
||||
body.light-theme {
|
||||
--bg-app: #f3f4f6;
|
||||
--bg-panel: #ffffff;
|
||||
--bg-input: #f9fafb;
|
||||
--border-color: #d1d5db;
|
||||
--border-hover: #9ca3af;
|
||||
--border-focus: #4f46e5;
|
||||
--text-main: #111827;
|
||||
--text-muted: #6b7280;
|
||||
--terminal-bg: #f8fafc;
|
||||
--terminal-text: #0f172a;
|
||||
--terminal-border: #e2e8f0;
|
||||
|
||||
--accent-warn-bg: #fffbeb;
|
||||
--accent-warn-text: #b45309;
|
||||
--accent-warn-border: #f59e0b;
|
||||
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
/* Base resets & styling */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-app);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-ui);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
transition: background-color var(--transition-normal), color var(--transition-normal);
|
||||
}
|
||||
|
||||
/* App container */
|
||||
.app-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
padding: 2.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
color: var(--text-main);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#theme-toggle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-main);
|
||||
padding: 8px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
#theme-toggle:hover {
|
||||
border-color: var(--border-hover);
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
body.light-theme #theme-toggle:hover {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
/* Toggle Sun/Moon icons depending on active class */
|
||||
.sun-icon {
|
||||
display: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.moon-icon {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
body.light-theme .sun-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
body.light-theme .moon-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
.app-main {
|
||||
background-color: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
transition: background-color var(--transition-normal), border-color var(--transition-normal);
|
||||
}
|
||||
|
||||
/* Action Controls & Banner Row */
|
||||
.control-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Circular Connect Button */
|
||||
.connect-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
transition: transform var(--transition-fast), background-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.connect-btn:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.connect-btn.disconnected {
|
||||
background-color: var(--accent-green);
|
||||
}
|
||||
|
||||
.connect-btn.disconnected:hover {
|
||||
background-color: var(--accent-green-hover);
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.4);
|
||||
}
|
||||
|
||||
.connect-btn.connected {
|
||||
background-color: var(--accent-danger);
|
||||
}
|
||||
|
||||
.connect-btn.connected:hover {
|
||||
background-color: var(--accent-danger-hover);
|
||||
box-shadow: 0 0 12px rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
|
||||
.connect-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Play/Stop SVG displays */
|
||||
.connect-btn.disconnected .stop-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.connect-btn.disconnected .play-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.connect-btn.connected .stop-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.connect-btn.connected .play-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Download Button */
|
||||
.download-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background-color: var(--accent-blue);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform var(--transition-fast), background-color var(--transition-fast), opacity var(--transition-fast);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.download-btn:hover:not(:disabled) {
|
||||
background-color: var(--accent-blue-hover);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.download-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.download-btn svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
/* Status Banner */
|
||||
.status-banner {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 1.25rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
border: 1px solid transparent;
|
||||
transition: background-color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Status Banner Themes */
|
||||
.status-banner.warn {
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
border-color: rgba(245, 158, 11, 0.25);
|
||||
color: var(--accent-warn-border);
|
||||
}
|
||||
|
||||
body.light-theme .status-banner.warn {
|
||||
background-color: var(--accent-warn-bg);
|
||||
border-color: var(--accent-warn-border);
|
||||
color: var(--accent-warn-text);
|
||||
}
|
||||
|
||||
.status-banner.connected {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
border-color: rgba(16, 185, 129, 0.25);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
body.light-theme .status-banner.connected {
|
||||
background-color: #ecfdf5;
|
||||
border-color: var(--accent-green);
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.status-banner.error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
border-color: rgba(239, 68, 68, 0.25);
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
|
||||
body.light-theme .status-banner.error {
|
||||
background-color: #fef2f2;
|
||||
border-color: var(--accent-danger);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
/* Port Config Dropdowns Grid */
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.control-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.action-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.settings-group label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Custom Styled Select Dropdown */
|
||||
.select-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.select-wrapper select {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.select-wrapper select:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.select-wrapper select:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
/* Select arrow icon custom styling */
|
||||
.select-wrapper::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
right: 1.25rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 10px;
|
||||
height: 6px;
|
||||
background-color: var(--text-muted);
|
||||
clip-path: polygon(100% 0, 0 0, 50% 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Small Select Dropdowns in UI Controls */
|
||||
.select-wrapper-sm select {
|
||||
padding: 0.4rem 2rem 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.select-wrapper-sm::after {
|
||||
right: 0.75rem;
|
||||
width: 8px;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
/* Received Data Terminal Area */
|
||||
.received-data-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.received-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.received-header h2 {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.received-controls {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.control-select-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.control-select-group label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.terminal-output {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
background-color: var(--terminal-bg);
|
||||
border: 1px solid var(--terminal-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: var(--terminal-text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-normal), color var(--transition-normal);
|
||||
}
|
||||
|
||||
.terminal-output:focus {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.terminal-output.empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for Terminal Output */
|
||||
.terminal-output::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.terminal-output::-webkit-scrollbar-track {
|
||||
background: var(--terminal-bg);
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
}
|
||||
|
||||
.terminal-output::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 5px;
|
||||
border: 2px solid var(--terminal-bg);
|
||||
}
|
||||
|
||||
.terminal-output::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-hover);
|
||||
}
|
||||
|
||||
/* Send Data Controls Footer */
|
||||
.send-data-section {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.send-bar {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-container input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.95rem;
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input-container input:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.input-container input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
|
||||
}
|
||||
|
||||
.input-container input::placeholder {
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.send-format-wrapper {
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-eol-wrapper {
|
||||
width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Button UI */
|
||||
.send-btn, .clear-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #ffffff;
|
||||
transition: transform var(--transition-fast), background-color var(--transition-fast);
|
||||
box-shadow: var(--shadow-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background-color: var(--accent-purple);
|
||||
}
|
||||
|
||||
.send-btn:hover {
|
||||
background-color: var(--accent-purple-hover);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.send-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
border-color: var(--accent-danger);
|
||||
color: var(--accent-danger);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
body.light-theme .clear-btn:hover {
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
|
||||
.clear-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.send-btn svg, .clear-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
/* Terminal Line Highlights & Formatting */
|
||||
.terminal-line {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.terminal-line.sent {
|
||||
color: var(--accent-purple);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
body.light-theme .terminal-line.sent {
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.terminal-line.error {
|
||||
color: var(--accent-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.terminal-line.system {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Toast Notification Utility */
|
||||
.toast-msg {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
background-color: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transform: translateY(100px);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.3s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.toast-msg.show {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toast-success {
|
||||
border-color: var(--accent-green);
|
||||
color: var(--accent-green);
|
||||
}
|
||||
|
||||
.toast-error {
|
||||
border-color: var(--accent-danger);
|
||||
color: var(--accent-danger);
|
||||
}
|
||||
|
||||
/* Sidebar and Split Layout */
|
||||
.app-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
background-color: var(--bg-panel);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
transition: background-color var(--transition-normal), border-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.add-session-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background-color: var(--accent-purple);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform var(--transition-fast), background-color var(--transition-fast);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.add-session-btn:hover {
|
||||
background-color: var(--accent-purple-hover);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.add-session-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for Session List */
|
||||
.session-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.session-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.session-list::-webkit-scrollbar-thumb {
|
||||
background: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.session-list::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-hover);
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
|
||||
user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.session-item:hover {
|
||||
border-color: var(--border-hover);
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
body.light-theme .session-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.01);
|
||||
}
|
||||
|
||||
.session-item.active {
|
||||
border-color: var(--border-focus);
|
||||
background-color: rgba(99, 102, 241, 0.08);
|
||||
box-shadow: 0 0 0 1px var(--border-focus);
|
||||
}
|
||||
|
||||
body.light-theme .session-item.active {
|
||||
background-color: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
|
||||
.session-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
transition: background-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background-color: var(--accent-green);
|
||||
box-shadow: 0 0 8px var(--accent-green);
|
||||
}
|
||||
|
||||
.session-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-name {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-detail {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 1px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.session-delete-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color var(--transition-fast), background-color var(--transition-fast);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.session-item:hover .session-delete-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-delete-btn:hover {
|
||||
color: var(--accent-danger);
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.session-rename-input {
|
||||
background-color: var(--bg-panel);
|
||||
border: 1px solid var(--border-focus);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Download Group Control Styling */
|
||||
.download-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background-color: var(--bg-input);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 3px 6px 3px 3px;
|
||||
height: 48px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: border-color var(--transition-fast), background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.download-group:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.download-group .download-btn {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 6px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.download-group .download-btn:hover:not(:disabled) {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.download-select-wrapper {
|
||||
width: 105px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.download-select-wrapper select {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
padding: 0.4rem 1.75rem 0.4rem 0.5rem !important;
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-main);
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.download-select-wrapper select option {
|
||||
background-color: var(--bg-panel);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.download-select-wrapper::after {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>UART Terminal</title>
|
||||
<!-- Google Fonts for premium design: Inter for UI, JetBrains Mono for monospace data -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet">
|
||||
<link rel="stylesheet" href="./index.css">
|
||||
</head>
|
||||
|
||||
<body class="dark-theme">
|
||||
<div class="app-container">
|
||||
<!-- Header -->
|
||||
<header class="app-header">
|
||||
<h1>UART Terminal</h1>
|
||||
<button id="theme-toggle" class="icon-btn" aria-label="Toggle Theme">
|
||||
<!-- Sun Icon -->
|
||||
<svg class="sun-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
<!-- Moon Icon -->
|
||||
<svg class="moon-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- App Layout Split -->
|
||||
<div class="app-layout">
|
||||
<!-- Sidebar Panel -->
|
||||
<aside class="app-sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2>Sessions</h2>
|
||||
<button id="add-session-btn" class="add-session-btn" title="Add new session">
|
||||
<!-- Plus Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="session-list" class="session-list">
|
||||
<!-- Session items dynamically injected -->
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Workspace -->
|
||||
<main class="app-main">
|
||||
|
||||
<!-- Connection Controls & Status -->
|
||||
<section class="control-row">
|
||||
<div class="action-buttons">
|
||||
<button id="connect-btn" class="connect-btn disconnected" title="Connect to serial port">
|
||||
<!-- Play Icon -->
|
||||
<svg class="play-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="6,4 20,12 6,20"></polygon>
|
||||
</svg>
|
||||
<!-- Stop Icon -->
|
||||
<svg class="stop-icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<rect x="5" y="5" width="14" height="14" rx="2"></rect>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="download-group">
|
||||
<button id="download-btn" class="download-btn" title="Download log" disabled>
|
||||
<!-- Download Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||||
<polyline points="7 10 12 15 17 10"></polyline>
|
||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="select-wrapper select-wrapper-sm download-select-wrapper">
|
||||
<select id="download-type" title="Select download content">
|
||||
<option value="rx_tx" selected>RX & TX</option>
|
||||
<option value="rx">RX Only</option>
|
||||
<option value="tx">TX Only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connection Status Banner -->
|
||||
<div id="status-banner" class="status-banner warn">
|
||||
<svg class="status-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||||
</svg>
|
||||
<span id="status-text">Not connected. Click the connect button to select a port.</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings Form Grid -->
|
||||
<section class="settings-grid">
|
||||
<div class="settings-group">
|
||||
<label for="baud-rate">Baud Rate</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="baud-rate">
|
||||
<option value="300">300</option>
|
||||
<option value="1200">1200</option>
|
||||
<option value="2400">2400</option>
|
||||
<option value="4800">4800</option>
|
||||
<option value="9600">9600</option>
|
||||
<option value="19200">19200</option>
|
||||
<option value="38400">38400</option>
|
||||
<option value="57600">57600</option>
|
||||
<option value="74880">74880</option>
|
||||
<option value="115200" selected>115200</option>
|
||||
<option value="230400">230400</option>
|
||||
<option value="250000">250000</option>
|
||||
<option value="500000">500000</option>
|
||||
<option value="1000000">1000000</option>
|
||||
<option value="2000000">2000000</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="data-bits">Data Bits</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="data-bits">
|
||||
<option value="8" selected>8</option>
|
||||
<option value="7">7</option>
|
||||
<option value="6">6</option>
|
||||
<option value="5">5</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="stop-bits">Stop Bits</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="stop-bits">
|
||||
<option value="1" selected>1</option>
|
||||
<option value="2">2</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label for="parity">Parity</label>
|
||||
<div class="select-wrapper">
|
||||
<select id="parity">
|
||||
<option value="none" selected>none</option>
|
||||
<option value="even">even</option>
|
||||
<option value="odd">odd</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Received Data Section -->
|
||||
<section class="received-data-section">
|
||||
<div class="received-header">
|
||||
<h2>Received Data</h2>
|
||||
<div class="received-controls">
|
||||
<div class="control-select-group">
|
||||
<label for="scroll-mode">Scroll</label>
|
||||
<div class="select-wrapper select-wrapper-sm">
|
||||
<select id="scroll-mode">
|
||||
<option value="auto" selected>Auto-scroll</option>
|
||||
<option value="none">No scroll</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-select-group">
|
||||
<label for="display-format">Display Format</label>
|
||||
<div class="select-wrapper select-wrapper-sm">
|
||||
<select id="display-format">
|
||||
<option value="auto" selected>Auto</option>
|
||||
<option value="text">Text</option>
|
||||
<option value="hex">Hex</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Console -->
|
||||
<div id="terminal-container" class="terminal-wrapper">
|
||||
<!-- Terminal elements will be dynamically appended here per session -->
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Send Data Input Area -->
|
||||
<section class="send-data-section">
|
||||
<div class="send-bar">
|
||||
<div class="input-container">
|
||||
<input type="text" id="send-input" placeholder="Enter data to send..." autocomplete="off">
|
||||
</div>
|
||||
<div class="select-wrapper select-wrapper-sm send-format-wrapper">
|
||||
<select id="send-format">
|
||||
<option value="ascii" selected>ASCII</option>
|
||||
<option value="hex">Hex</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="select-wrapper select-wrapper-sm send-eol-wrapper">
|
||||
<select id="send-eol">
|
||||
<option value="none">None</option>
|
||||
<option value="lf">LF (\n)</option>
|
||||
<option value="cr">CR (\r)</option>
|
||||
<option value="crlf" selected>CRLF (\r\n)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="send-btn" class="send-btn" title="Send Data (Ctrl+Enter)">
|
||||
<!-- Send / Paper Airplane Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="clear-btn" class="clear-btn" title="Clear Terminal Console">
|
||||
<!-- Trash Icon -->
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="./app.js"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Generated
+1026
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "web-uart-terminal",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^5.2.11"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
open: false, // Don't auto open browser in command-line environments to avoid errors
|
||||
},
|
||||
base: './',
|
||||
});
|
||||
Reference in New Issue
Block a user