mcp-web-ui
Version:
Ultra-lightweight vanilla JavaScript framework for MCP servers - Zero dependencies, perfect security, 2-3KB bundle size
1,076 lines (1,002 loc) • 42.9 kB
JavaScript
import express from 'express';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { fileURLToPath } from 'url';
// Add HTML escaping utility
const escapeHtml = (unsafe) => {
return unsafe
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
};
// Add JSON sanitization for safe embedding
const safeJsonStringify = (data) => {
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
};
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Individual UI server instance for a session
*/
export class UIServer {
session;
schema;
dataSource;
onUpdate;
pollInterval;
bindAddress;
app;
server = null;
dataPollingInterval = null;
constructor(session, schema, dataSource, onUpdate, pollInterval = 2000, bindAddress = 'localhost') {
this.session = session;
this.schema = schema;
this.dataSource = dataSource;
this.onUpdate = onUpdate;
this.pollInterval = pollInterval;
this.bindAddress = bindAddress;
this.app = express();
this.setupMiddleware();
this.setupRoutes();
}
/**
* Start the server on the session's assigned port
*/
async start() {
return new Promise((resolve, reject) => {
try {
this.server = this.app.listen(this.session.port, this.bindAddress, () => {
this.log('INFO', `UI server started on ${this.bindAddress}:${this.session.port}`);
this.startDataPolling();
resolve();
});
this.server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
this.log('ERROR', `Port ${this.session.port} already in use`);
reject(new Error(`Port ${this.session.port} already in use`));
}
else {
reject(error);
}
});
}
catch (error) {
reject(error);
}
});
}
/**
* Stop the server and cleanup
*/
async stop() {
return new Promise((resolve) => {
// Stop data polling
if (this.dataPollingInterval) {
clearInterval(this.dataPollingInterval);
this.dataPollingInterval = null;
}
// Close HTTP server
if (this.server) {
this.server.close(() => {
this.log('INFO', `UI server stopped on port ${this.session.port}`);
resolve();
});
}
else {
resolve();
}
});
}
/**
* Setup Express middleware
*/
setupMiddleware() {
// Serve Alpine.js CSP build locally
this.app.use('/vendor', express.static('node_modules/@alpinejs/csp/dist'));
// Security headers
this.app.use((req, res, next) => {
// Generate nonce for each request
const nonce = this.generateNonce();
res.locals.nonce = nonce;
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-XSS-Protection', '1; mode=block');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
// Secure CSP with self-hosted Alpine.js CSP build (no 'unsafe-eval')
res.setHeader('Content-Security-Policy', "default-src 'self'; " +
`script-src 'self' 'nonce-${nonce}'; ` +
"style-src 'self' 'unsafe-inline'; " +
"connect-src 'self';");
next();
});
// Body parser with limits
this.app.use(express.json({ limit: '1mb' }));
this.app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// Secure token-based authentication middleware
this.app.use((req, res, next) => {
// Allow only static files without token (health check now requires auth)
if (req.path.startsWith('/static/')) {
return next();
}
const token = req.query.token || req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({
success: false,
error: 'Authentication token required',
timestamp: new Date().toISOString()
});
}
// Timing-safe token comparison to prevent timing attacks
const expectedToken = this.session.token;
if (token.length !== expectedToken.length) {
return res.status(403).json({
success: false,
error: 'Invalid token',
timestamp: new Date().toISOString()
});
}
let isValid = true;
for (let i = 0; i < token.length; i++) {
if (token[i] !== expectedToken[i]) {
isValid = false;
}
}
if (!isValid) {
return res.status(403).json({
success: false,
error: 'Invalid token',
timestamp: new Date().toISOString()
});
}
// Check if this is a user action (updates) vs polling (data fetch)
const isUserAction = req.method === 'POST' || req.path.includes('/update') || req.path.includes('/extend');
// Update activity only for user actions, not data polling
if (isUserAction) {
this.session.lastActivity = new Date();
this.log('INFO', `User action detected: ${req.method} ${req.path}`);
}
next();
});
}
/**
* Setup API routes
*/
setupRoutes() {
// Main UI route
this.app.get('/', async (req, res) => {
try {
const initialData = await this.dataSource(this.session.userId);
const templateData = {
session: this.session,
schema: this.schema,
initialData,
config: {
pollInterval: this.pollInterval,
apiBase: `/api`
},
nonce: res.locals.nonce
};
// Render HTML template
const html = this.renderTemplate(templateData);
res.send(html);
}
catch (error) {
this.log('ERROR', `Failed to render UI: ${error}`);
res.status(500).send('Internal Server Error');
}
});
// API endpoint to get current data
this.app.get('/api/data', async (req, res) => {
try {
const data = await this.dataSource(this.session.userId);
const response = {
success: true,
data,
timestamp: new Date().toISOString()
};
res.json(response);
}
catch (error) {
const response = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
};
res.status(500).json(response);
}
});
// API endpoint to handle updates
this.app.post('/api/update', async (req, res) => {
try {
const { action, data } = req.body;
const result = await this.onUpdate(action, data, this.session.userId);
const response = {
success: true,
data: result,
timestamp: new Date().toISOString()
};
res.json(response);
}
catch (error) {
const response = {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
timestamp: new Date().toISOString()
};
res.status(500).json(response);
}
});
// API endpoint to extend session
this.app.post('/api/extend-session', (req, res) => {
const { minutes = 30 } = req.body;
// Validate extension minutes
if (typeof minutes !== 'number' || minutes < 5 || minutes > 120) {
return res.status(400).json({
success: false,
error: 'Extension minutes must be between 5 and 120',
timestamp: new Date().toISOString()
});
}
// Check if session can be extended (not expired)
if (new Date() > this.session.expiresAt) {
return res.status(410).json({
success: false,
error: 'Session has already expired',
timestamp: new Date().toISOString()
});
}
this.session.expiresAt = new Date(this.session.expiresAt.getTime() + minutes * 60 * 1000);
this.session.lastActivity = new Date();
const response = {
success: true,
data: { expiresAt: this.session.expiresAt },
timestamp: new Date().toISOString()
};
res.json(response);
});
// Health check (now requires authentication)
this.app.get('/api/health', (req, res) => {
res.json({
success: true,
data: {
// Don't expose sensitive session ID or user ID
status: 'active',
expiresAt: this.session.expiresAt,
uptime: Date.now() - this.session.startTime.getTime()
},
timestamp: new Date().toISOString()
});
});
// Serve static files
const templatesPath = path.resolve(__dirname, '..', '..', 'templates', 'static');
this.log('INFO', `Serving static files from: ${templatesPath}`);
// Check if the templates directory exists
try {
if (fs.existsSync(templatesPath)) {
this.app.use('/static', express.static(templatesPath));
this.log('INFO', 'Static file serving configured successfully');
}
else {
this.log('ERROR', `Templates directory not found at: ${templatesPath}`);
// Fallback: try to serve CSS inline if templates directory is missing
this.app.get('/static/styles.css', (req, res) => {
res.type('text/css');
res.send(this.getFallbackCSS());
});
}
}
catch (error) {
this.log('ERROR', `Error setting up static file serving: ${error}`);
}
}
/**
* Start polling for data changes
*/
startDataPolling() {
if (this.schema.polling?.enabled !== false) {
this.dataPollingInterval = setInterval(async () => {
// This would trigger real-time updates via SSE or WebSocket
// For now, the client will poll /api/data
}, this.pollInterval);
}
}
/**
* Render HTML template with data
*/
renderTemplate(data) {
// Secure template rendering with proper escaping
const safeTitle = escapeHtml(data.schema.title);
const safeDescription = data.schema.description ? escapeHtml(data.schema.description) : '';
// Get nonce from response locals
const nonce = data.nonce || this.generateNonce();
return `
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${safeTitle}</title>
<script defer nonce="${nonce}" src="/vendor/cdn.min.js"></script>
<link href="/static/styles.css" rel="stylesheet">
</head>
<body>
<div id="app" x-data="mcpUI()" x-init="init()" x-cloak>
<header class="header">
<h1>${safeTitle}</h1>
${safeDescription ? `<p class="description">${safeDescription}</p>` : ''}
<div class="session-info">
<span>Session expires: <span x-text="formatTime(expiresAt)"></span></span>
<button @click="extendSession()" class="btn-extend">Extend</button>
</div>
</header>
<!-- Undo Toast Notifications -->
<div class="undo-container">
<template x-for="undoAction in undoActions" :key="undoAction.id">
<div class="undo-toast" x-transition>
<div class="undo-content">
<span class="undo-message">✓ Todoodle completed</span>
<button @click="undoComplete(undoAction.id)" class="undo-button">
Undo
</button>
</div>
<div class="undo-progress">
<div class="undo-progress-bar"></div>
</div>
</div>
</template>
</div>
<main class="main">
<div x-show="loading" class="loading">Loading...</div>
<div x-show="!loading">
${this.renderComponents(data.schema.components)}
</div>
</main>
</div>
<script nonce="${nonce}">
function mcpUI() {
return {
data: ${safeJsonStringify(data.initialData)},
loading: false,
expiresAt: new Date('${data.session.expiresAt.toISOString()}'),
scrollPosition: 0,
// Add form state
showAddForm: false,
newTodoText: '',
newTodoCategory: '',
newTodoPriority: 'medium',
newTodoDueDate: '',
// Undo system state
undoActions: [], // Array of undo actions: {id, type, originalState, timeoutId}
pendingCompletes: new Set(), // Track items with pending completion
init() {
this.startPolling();
// Enhanced scroll position preservation
this.initScrollPreservation();
},
initScrollPreservation() {
// Better scroll position tracking
let ticking = false;
const updateScrollPosition = () => {
this.scrollPosition = window.scrollY;
ticking = false;
};
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(updateScrollPosition);
ticking = true;
}
});
},
startPolling() {
// Only poll when tab is active
let isActive = true;
document.addEventListener('visibilitychange', () => {
isActive = !document.hidden;
if (isActive) {
this.fetchData(); // Immediate fetch when tab becomes active
}
});
setInterval(() => {
if (isActive) {
this.fetchData();
}
}, ${data.config.pollInterval});
},
async fetchData() {
try {
const response = await fetch('/api/data?token=${data.session.token}');
const result = await response.json();
if (result.success) {
const newData = result.data;
// Smart diff - only update if data actually changed
if (!this.dataEquals(this.data, newData)) {
this.updateDataSmart(newData);
}
}
} catch (error) {
console.error('Failed to fetch data:', error);
}
},
// Smart data comparison
dataEquals(oldData, newData) {
if (oldData.length !== newData.length) return false;
for (let i = 0; i < oldData.length; i++) {
const oldItem = oldData[i];
const newItem = newData[i];
// Compare key fields that matter for UI
if (oldItem.id !== newItem.id ||
oldItem.text !== newItem.text ||
oldItem.completed !== newItem.completed ||
oldItem.priority !== newItem.priority ||
oldItem.category !== newItem.category ||
oldItem.dueDate !== newItem.dueDate) {
return false;
}
}
return true;
},
// Smart data update with smooth transitions
updateDataSmart(newData) {
const currentScroll = window.scrollY;
const focusedElement = document.activeElement;
const focusedId = focusedElement?.id;
// Use Alpine.js reactivity properly
this.data.splice(0, this.data.length, ...newData);
// Restore focus and scroll after DOM update
this.$nextTick(() => {
// Restore focus if it was on a specific element
if (focusedId && document.getElementById(focusedId)) {
document.getElementById(focusedId).focus();
}
// Restore scroll position
window.scrollTo(0, currentScroll);
});
},
// Optimistic update for better responsiveness
async updateData(action, updateData) {
const currentScroll = window.scrollY;
let optimisticUpdate = null;
// Apply optimistic update immediately
if (action === 'toggle') {
optimisticUpdate = this.applyOptimisticToggle(updateData);
// For complete actions, don't send to server immediately - wait for undo timeout
if (updateData.completed) {
// Find the undo action we just created
const undoAction = this.undoActions.find(a => a.id === updateData.id);
if (undoAction) {
// Set up delayed server call
const originalTimeoutId = undoAction.timeoutId;
undoAction.timeoutId = setTimeout(async () => {
// Send to server after undo timeout
try {
const response = await fetch('/api/update?token=${data.session.token}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, data: updateData })
});
const result = await response.json();
if (result.success) {
undoAction.serverConfirmed = true;
this.confirmUndoAction(undoAction);
} else {
// Server failed, revert the action
this.performUndo(undoAction);
}
} catch (error) {
console.error('Failed to confirm completion:', error);
this.performUndo(undoAction);
}
}, 5000);
}
return; // Don't proceed with normal server call
}
} else if (action === 'delete') {
optimisticUpdate = this.applyOptimisticDelete(updateData);
}
// Normal server call for non-completion actions
try {
const response = await fetch('/api/update?token=${data.session.token}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action, data: updateData })
});
const result = await response.json();
if (!result.success) {
// Revert optimistic update on failure
if (optimisticUpdate) {
this.revertOptimisticUpdate(optimisticUpdate);
}
throw new Error(result.error);
}
// Success - the optimistic update is confirmed
// Refresh data after a short delay to get any server-side changes
setTimeout(() => this.fetchData(), 100);
} catch (error) {
console.error('Failed to update data:', error);
// Revert optimistic update on error
if (optimisticUpdate) {
this.revertOptimisticUpdate(optimisticUpdate);
}
} finally {
// Restore scroll position
requestAnimationFrame(() => {
window.scrollTo(0, currentScroll);
});
}
},
// Apply optimistic toggle update
applyOptimisticToggle(updateData) {
const item = this.data.find(i => i.id === updateData.id);
if (item) {
const originalCompleted = item.completed;
if (updateData.completed) {
// Completing an item - use undo system
return this.handleCompleteWithUndo(item);
} else {
// Uncompleting an item - direct action (no undo needed)
item.completed = updateData.completed;
return () => { item.completed = originalCompleted; };
}
}
return null;
},
// Apply optimistic delete update
applyOptimisticDelete(updateData) {
const index = this.data.findIndex(i => i.id === updateData.id);
if (index !== -1) {
const removedItem = this.data.splice(index, 1)[0];
// Return undo function
return () => {
this.data.splice(index, 0, removedItem);
};
}
return null;
},
// Revert optimistic update
revertOptimisticUpdate(undoFn) {
if (undoFn) {
undoFn();
}
},
// Undo system methods
handleCompleteWithUndo(item) {
// Mark as pending completion
this.pendingCompletes.add(item.id);
// Apply the visual change immediately
item.completed = true;
// Create undo action with 5-second timeout
const undoAction = {
id: item.id,
type: 'complete',
originalState: { ...item, completed: false },
timeoutId: null,
serverConfirmed: false
};
// Set up auto-confirm timeout
undoAction.timeoutId = setTimeout(() => {
this.confirmUndoAction(undoAction);
}, 5000);
// Add to undo actions array
this.undoActions.push(undoAction);
// Return undo function for error cases
return () => {
this.performUndo(undoAction);
};
},
performUndo(undoAction) {
// Find the item and revert it
const item = this.data.find(i => i.id === undoAction.id);
if (item) {
Object.assign(item, undoAction.originalState);
}
// Clear timeout
if (undoAction.timeoutId) {
clearTimeout(undoAction.timeoutId);
}
// Remove from pending and undo actions
this.pendingCompletes.delete(undoAction.id);
this.undoActions = this.undoActions.filter(action => action.id !== undoAction.id);
// If server confirmed, need to send uncomplete request
if (undoAction.serverConfirmed) {
// Note: Current server doesn't support uncomplete, so we'll refresh data
this.fetchData();
}
},
confirmUndoAction(undoAction) {
// Remove from undo actions
this.undoActions = this.undoActions.filter(action => action.id !== undoAction.id);
this.pendingCompletes.delete(undoAction.id);
},
undoComplete(itemId) {
const undoAction = this.undoActions.find(action => action.id === itemId);
if (undoAction) {
this.performUndo(undoAction);
}
},
// Check if item has pending undo
hasPendingUndo(itemId) {
return this.undoActions.some(action => action.id === itemId);
},
// Add new todoodle functionality
async addTodoodle(text, category = '', priority = 'medium', dueDate = '') {
if (!text.trim()) return;
const currentScroll = window.scrollY;
// Create optimistic new item
const tempId = 'temp-' + Date.now();
const newItem = {
id: tempId,
text: text.trim(),
category: category.trim(),
priority: priority,
dueDate: dueDate,
completed: false,
createdAt: new Date().toISOString()
};
// Add optimistically to the top of the list
this.data.unshift(newItem);
try {
const response = await fetch('/api/update?token=${data.session.token}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'add',
data: {
text: text.trim(),
category: category.trim(),
priority: priority,
dueDate: dueDate
}
})
});
const result = await response.json();
if (result.success) {
// Replace temp item with real item from server
const index = this.data.findIndex(item => item.id === tempId);
if (index !== -1) {
this.data.splice(index, 1, result.data);
}
// Clear the form
this.resetAddForm();
// Refresh data to get proper order
setTimeout(() => this.fetchData(), 100);
} else {
throw new Error(result.error);
}
} catch (error) {
console.error('Failed to add todoodle:', error);
// Remove optimistic item on failure
const index = this.data.findIndex(item => item.id === tempId);
if (index !== -1) {
this.data.splice(index, 1);
}
} finally {
// Restore scroll position
requestAnimationFrame(() => {
window.scrollTo(0, currentScroll);
});
}
},
toggleAddForm() {
this.showAddForm = !this.showAddForm;
if (this.showAddForm) {
// Focus on the text input when form is shown
this.$nextTick(() => {
const input = document.getElementById('new-todo-text');
if (input) input.focus();
});
}
},
resetAddForm() {
this.newTodoText = '';
this.newTodoCategory = '';
this.newTodoPriority = 'medium';
this.newTodoDueDate = '';
this.showAddForm = false;
},
async submitAddForm() {
await this.addTodoodle(
this.newTodoText,
this.newTodoCategory,
this.newTodoPriority,
this.newTodoDueDate
);
},
async extendSession() {
try {
const response = await fetch('/api/extend-session?token=${data.session.token}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ minutes: 30 })
});
const result = await response.json();
if (result.success) {
this.expiresAt = new Date(result.data.expiresAt);
}
} catch (error) {
console.error('Failed to extend session:', error);
}
},
formatTime(date) {
return new Date(date).toLocaleTimeString();
},
formatDueDate(dueDate) {
if (!dueDate) return '';
const due = new Date(dueDate);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
// Reset times for accurate date comparison
due.setHours(0, 0, 0, 0);
today.setHours(0, 0, 0, 0);
tomorrow.setHours(0, 0, 0, 0);
if (due.getTime() === today.getTime()) {
return 'Due Today';
} else if (due.getTime() === tomorrow.getTime()) {
return 'Due Tomorrow';
} else if (due < today) {
const diffDays = Math.floor((today - due) / (1000 * 60 * 60 * 24));
return diffDays + ' day' + (diffDays > 1 ? 's' : '') + ' overdue';
} else {
const diffDays = Math.floor((due - today) / (1000 * 60 * 60 * 24));
if (diffDays <= 7) {
return 'Due in ' + diffDays + ' day' + (diffDays > 1 ? 's' : '');
} else {
return 'Due ' + due.toLocaleDateString();
}
}
}
}
}
// Register Alpine.js component for CSP build
document.addEventListener('alpine:init', () => {
Alpine.data('mcpUI', mcpUI);
});
</script>
</body>
</html>`;
}
/**
* Render UI components based on schema
*/
renderComponents(components) {
return components.map(component => {
switch (component.type) {
case 'list':
return this.renderListComponent(component);
default:
return `<div>Unsupported component type: ${component.type}</div>`;
}
}).join('\n');
}
/**
* Render a list component (like todo list)
*/
renderListComponent(component) {
return `
<div class="component component-list" id="${component.id}">
${component.title ? `<h2>${component.title}</h2>` : ''}
<!-- Add New Todoodle Section -->
<div class="add-todoodle-section">
<button @click="toggleAddForm()" class="btn-add-todo" :class="{ 'active': showAddForm }">
<span x-show="!showAddForm">+ Add New Todoodle</span>
<span x-show="showAddForm">Cancel</span>
</button>
<!-- Add Form (collapsible) -->
<div x-show="showAddForm" x-transition class="add-form">
<form @submit.prevent="submitAddForm()">
<div class="form-row">
<input
type="text"
id="new-todo-text"
x-model="newTodoText"
placeholder="What needs to be done?"
class="form-input form-input-main"
required
>
</div>
<div class="form-row form-row-secondary">
<div class="form-group">
<label for="new-todo-category">Category</label>
<input
type="text"
id="new-todo-category"
x-model="newTodoCategory"
placeholder="e.g. work, personal"
class="form-input form-input-small"
>
</div>
<div class="form-group">
<label for="new-todo-priority">Priority</label>
<select
id="new-todo-priority"
x-model="newTodoPriority"
class="form-input form-input-small"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="urgent">Urgent</option>
</select>
</div>
<div class="form-group">
<label for="new-todo-duedate">Due Date</label>
<input
type="date"
id="new-todo-duedate"
x-model="newTodoDueDate"
class="form-input form-input-small"
>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-submit" :disabled="!newTodoText.trim()">
Add Todoodle
</button>
<button type="button" @click="resetAddForm()" class="btn-cancel">
Clear
</button>
</div>
</form>
</div>
</div>
<!-- Todoodles List -->
<div class="list-container">
<div x-show="data.length === 0" class="empty-state">
<p>No todoodles yet! Add your first one above.</p>
</div>
<template x-for="item in data" :key="item.id">
<div class="list-item" :class="{ 'completed': item.completed, 'temp-item': item.id && item.id.startsWith('temp-') }">
<div class="item-checkbox">
<input
type="checkbox"
:checked="item.completed"
@change="updateData('toggle', {id: item.id, completed: $event.target.checked})"
:id="'item-' + item.id"
>
</div>
<div class="item-content">
<div class="item-main">
<label :for="'item-' + item.id" class="item-text" x-text="item.text"></label>
<div class="item-meta">
<span x-show="item.category" x-text="item.category" class="badge badge-category"></span>
<span x-show="item.priority" x-text="item.priority.toUpperCase()" :class="'badge badge-priority priority-' + item.priority"></span>
<span x-show="item.dueDate" x-text="formatDueDate(item.dueDate)" class="badge badge-due"></span>
</div>
</div>
</div>
<div class="item-actions">
<button @click="updateData('delete', {id: item.id})" class="btn-delete" title="Delete">
×
</button>
</div>
</div>
</template>
</div>
</div>`;
}
/**
* Simple logging utility
*/
log(level, message) {
const timestamp = new Date().toISOString();
console.error(`[${timestamp}][${level}][UIServer:${this.session.port}] ${message}`);
}
/**
* Fallback CSS when templates directory is not found
*/
getFallbackCSS() {
return `
/* Fallback CSS for MCP Web UI */
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
padding: 0;
background-color: #f8fafc;
color: #334155;
line-height: 1.6;
}
.header {
background: white;
border-bottom: 1px solid #e2e8f0;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.header h1 {
margin: 0;
color: #1e293b;
font-size: 1.5rem;
font-weight: 600;
}
.main {
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
}
.component {
background: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
overflow: hidden;
}
.list-container {
padding: 1rem;
}
.list-item {
display: flex;
align-items: flex-start;
padding: 1rem 0.75rem;
border-bottom: 1px solid #f1f5f9;
transition: all 0.3s ease;
}
.list-item:hover {
background-color: #f8fafc;
}
.item-content {
flex: 1;
min-width: 0;
}
.item-text {
font-weight: 500;
color: #1e293b;
cursor: pointer;
}
.add-todoodle-section {
padding: 1.5rem 2rem;
border-bottom: 1px solid #e2e8f0;
background: #fefefe;
}
.btn-add-todo {
background: #10b981;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
cursor: pointer;
font-weight: 600;
}
.add-form {
margin-top: 1rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 0.5rem;
padding: 1.5rem;
}
.form-input {
border: 1px solid #d1d5db;
border-radius: 0.375rem;
padding: 0.75rem;
font-size: 0.875rem;
background: white;
width: 100%;
margin-bottom: 1rem;
}
.btn-submit {
background: #3b82f6;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.375rem;
cursor: pointer;
font-weight: 600;
}
`;
}
/**
* Generate a cryptographically secure nonce for CSP
*/
generateNonce() {
return crypto.randomBytes(16).toString('base64');
}
}
//# sourceMappingURL=UIServer.js.map