import { z, defineMessage, BrowserChannel } from '../src/index.js';
// Define message types for todo operations
const addTodo = defineMessage('add-todo', z.object({
id: z.string(),
text: z.string(),
completed: z.boolean().default(false),
timestamp: z.number().optional()
}));
const toggleTodo = defineMessage('toggle-todo', z.object({
id: z.string(),
completed: z.boolean(),
timestamp: z.number().optional()
}));
const deleteTodo = defineMessage('delete-todo', z.object({
id: z.string(),
timestamp: z.number().optional()
}));
const syncTodos = defineMessage('sync-todos', z.object({
todos: z.array(z.object({
id: z.string(),
text: z.string(),
completed: z.boolean()
})),
timestamp: z.number().optional()
}));
// UI Elements
const statusIndicator = document.getElementById('statusIndicator')!;
const statusText = document.getElementById('statusText')!;
const wsUrlEl = document.getElementById('wsUrl') as HTMLInputElement;
const connectBtn = document.getElementById('connectBtn')!;
const connectionPanel = document.getElementById('connectionPanel')!;
const todosContainer = document.getElementById('todosContainer')!;
const emptyState = document.getElementById('emptyState')!;
const todoInputContainer = document.getElementById('todoInputContainer')!;
const todoForm = document.getElementById('todoForm') as HTMLFormElement;
const todoInput = document.getElementById('todoInput') as HTMLInputElement;
const addBtn = document.getElementById('addBtn') as HTMLButtonElement;
// Channel instance and state
let channel: BrowserChannel | null = null;
let isConnected = false;
let todos: { id: string; text: string; completed: boolean }[] = [];
// Render todos in the UI
function renderTodos() {
if (todos.length === 0) {
emptyState.style.display = 'block';
return;
}
emptyState.style.display = 'none';
// Clear existing todos (except empty state)
const todoItems = todosContainer.querySelectorAll('.todo-item');
todoItems.forEach(item => item.remove());
todos.forEach(todo => {
const todoEl = document.createElement('div');
todoEl.className = `todo-item ${todo.completed ? 'completed' : ''}`;
todoEl.innerHTML = `
${todo.completed ? '✓' : ''}
${todo.text}
`;
todosContainer.appendChild(todoEl);
});
// Add event listeners
addTodoEventListeners();
}
function addTodoEventListeners() {
// Toggle todo completion
document.querySelectorAll('.todo-checkbox').forEach(checkbox => {
checkbox.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const id = target.dataset.id!;
const todo = todos.find(t => t.id === id);
if (todo) {
toggleTodoItem(id, !todo.completed);
}
});
});
// Delete todo
document.querySelectorAll('.todo-delete').forEach(deleteBtn => {
deleteBtn.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const id = target.dataset.id!;
deleteTodoItem(id);
});
});
}
// Update connection status
function updateStatus(status: 'connected' | 'disconnected' | 'connecting') {
statusIndicator.className = `status-indicator ${status}`;
statusText.textContent = status.charAt(0).toUpperCase() + status.slice(1);
isConnected = status === 'connected';
(connectBtn as HTMLButtonElement).disabled = isConnected;
addBtn.disabled = !isConnected;
(todoInput as HTMLInputElement).disabled = !isConnected;
if (isConnected) {
connectBtn.textContent = 'Disconnect';
connectionPanel.classList.add('hidden');
todoInputContainer.classList.remove('hidden');
todoInput.focus();
} else {
connectBtn.textContent = 'Connect to Sync';
(connectBtn as HTMLButtonElement).disabled = false;
connectionPanel.classList.remove('hidden');
todoInputContainer.classList.add('hidden');
}
}
// Setup message handlers
function setupMessageHandlers() {
if (!channel) return;
// Listen for add todo events
channel.on(addTodo, (data) => {
const existingIndex = todos.findIndex(t => t.id === data.id);
if (existingIndex === -1) {
todos.push({
id: data.id,
text: data.text,
completed: data.completed
});
renderTodos();
}
});
// Listen for toggle todo events
channel.on(toggleTodo, (data) => {
const todo = todos.find(t => t.id === data.id);
if (todo) {
todo.completed = data.completed;
renderTodos();
}
});
// Listen for delete todo events
channel.on(deleteTodo, (data) => {
todos = todos.filter(t => t.id !== data.id);
renderTodos();
});
// Listen for sync todos (initial state from server)
channel.on(syncTodos, (data) => {
todos = data.todos;
renderTodos();
});
}
// Connect to WebSocket
async function connect() {
if (isConnected) {
await disconnect();
return;
}
try {
updateStatus('connecting');
emptyState.textContent = 'Connecting to server...';
channel = new BrowserChannel();
setupMessageHandlers();
await channel.connect(wsUrlEl.value);
updateStatus('connected');
emptyState.textContent = 'Connected! Add your first todo below.';
} catch (error) {
emptyState.textContent = `Connection failed: ${error}`;
updateStatus('disconnected');
channel = null;
}
}
// Disconnect from WebSocket
async function disconnect() {
if (channel && isConnected) {
try {
await channel.disconnect();
} catch (error) {
console.error('Disconnect error:', error);
}
channel = null;
}
updateStatus('disconnected');
emptyState.textContent = 'Disconnected from server. Connect to sync todos.';
todos = [];
renderTodos();
}
// Generate unique ID for todos
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
// Add a new todo
async function addTodoItem() {
if (!channel || !todoInput.value.trim()) return;
try {
const text = todoInput.value.trim();
const id = generateId();
// Add todo optimistically to local state
todos.push({ id, text, completed: false });
renderTodos();
// Send to server with validation
await channel.sendMessage(addTodo, {
id,
text,
completed: false,
timestamp: Date.now()
});
todoInput.value = '';
} catch (error) {
// Remove from local state if server request failed
todos = todos.filter(t => t.text !== todoInput.value.trim());
renderTodos();
console.error('Add todo error:', error);
}
}
// Toggle todo completion
async function toggleTodoItem(id: string, completed: boolean) {
if (!channel) return;
try {
// Update local state optimistically
const todo = todos.find(t => t.id === id);
if (todo) {
todo.completed = completed;
renderTodos();
}
// Send to server
await channel.sendMessage(toggleTodo, {
id,
completed,
timestamp: Date.now()
});
} catch (error) {
// Revert local state if server request failed
const todo = todos.find(t => t.id === id);
if (todo) {
todo.completed = !completed;
renderTodos();
}
console.error('Toggle todo error:', error);
}
}
// Delete a todo
async function deleteTodoItem(id: string) {
if (!channel) return;
try {
// Remove from local state optimistically
const todoToDelete = todos.find(t => t.id === id);
todos = todos.filter(t => t.id !== id);
renderTodos();
// Send to server
await channel.sendMessage(deleteTodo, {
id,
timestamp: Date.now()
});
} catch (error) {
// Restore local state if server request failed
if (todoToDelete) {
todos.push(todoToDelete);
renderTodos();
}
console.error('Delete todo error:', error);
}
}
// Event listeners
connectBtn.addEventListener('click', connect);
todoForm.addEventListener('submit', (e) => {
e.preventDefault();
addTodoItem();
});
todoInput.addEventListener('input', () => {
addBtn.disabled = !todoInput.value.trim() || !isConnected;
});
// Allow Enter key to trigger connect
wsUrlEl.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !(connectBtn as HTMLButtonElement).disabled) {
connect();
}
});
// Initialize
updateStatus('disconnected');
// Expose to window for debugging
(window as any).demo = {
channel: () => channel,
addTodo,
toggleTodo,
deleteTodo,
syncTodos,
todos: () => todos
};