Initial commit

This commit is contained in:
2026-02-03 17:20:06 -05:00
commit 701e387a5b
16 changed files with 3453 additions and 0 deletions

204
src/js/api.js Normal file
View File

@@ -0,0 +1,204 @@
// ==========================================
// API Functions
// ==========================================
async function fetchModels(showInModal = true) {
const endpoint = showInModal
? document.getElementById('apiEndpoint').value.trim()
: State.settings.apiEndpoint;
const apiKey = showInModal
? document.getElementById('apiKey').value.trim()
: State.settings.apiKey;
if (!endpoint || !apiKey) {
showToast('⚠️ Configure API endpoint and key first', 'warning');
if (!showInModal) openSettings();
return;
}
const btn = showInModal
? document.getElementById('fetchModelsBtn')
: document.getElementById('quickFetchBtn');
const originalText = btn.innerHTML;
btn.innerHTML = '⏳';
btn.disabled = true;
try {
const response = await fetch(endpoint.replace(/\/$/, '') + '/models', {
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`API Error: ${response.status}`);
}
const data = await response.json();
const models = data.data || data.models || data || [];
State.models = Array.isArray(models)
? models.map(m => ({
id: m.id || m.name || m,
owned_by: m.owned_by || null
})).sort((a, b) => a.id.localeCompare(b.id))
: [];
saveModels();
if (showInModal) {
populateModelSelect(document.getElementById('model'));
}
updateQuickModelSelector();
showToast(`✅ Loaded ${State.models.length} models`, 'success');
} catch (error) {
console.error('Fetch models error:', error);
showToast(`❌ Failed: ${error.message}`, 'error');
} finally {
btn.innerHTML = originalText;
btn.disabled = false;
}
}
async function sendApiRequest(messages) {
const chatContainer = document.getElementById('chatMessages');
// Add typing indicator
const typingDiv = document.createElement('div');
typingDiv.className = 'message assistant';
typingDiv.id = 'typingIndicator';
typingDiv.innerHTML = `
<div class="message-content">
<div class="message-avatar">🤖</div>
<div class="message-body">
<div class="message-role">Assistant</div>
<div class="typing-indicator"><span></span><span></span><span></span></div>
</div>
</div>
`;
chatContainer.appendChild(typingDiv);
scrollToBottom();
State.isGenerating = true;
State.abortController = new AbortController();
document.getElementById('stopBtn').classList.add('visible');
document.getElementById('sendBtn').disabled = true;
try {
const endpoint = State.settings.apiEndpoint.replace(/\/$/, '') + '/chat/completions';
const model = document.getElementById('quickModel').value || State.settings.model;
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${State.settings.apiKey}`
},
body: JSON.stringify({
model,
messages: messages.map(m => ({ role: m.role, content: m.content })),
max_tokens: State.settings.maxTokens,
temperature: State.settings.temperature,
top_p: State.settings.topP,
presence_penalty: State.settings.presencePenalty,
stream: State.settings.stream
}),
signal: State.abortController.signal
});
if (!response.ok) {
const error = await response.text();
throw new Error(`API Error: ${response.status} - ${error}`);
}
document.getElementById('typingIndicator')?.remove();
let assistantContent = '';
if (State.settings.stream) {
const assistantDiv = document.createElement('div');
assistantDiv.className = 'message assistant';
assistantDiv.innerHTML = `
<div class="message-content">
<div class="message-avatar">🤖</div>
<div class="message-body">
<div class="message-header">
<span class="message-role">Assistant</span>
</div>
<div class="message-text" id="streamingContent"></div>
</div>
</div>
`;
chatContainer.appendChild(assistantDiv);
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
const content = parsed.choices?.[0]?.delta?.content || '';
assistantContent += content;
document.getElementById('streamingContent').innerHTML = formatMessage(assistantContent);
scrollToBottom();
} catch (e) {}
}
}
}
} else {
const data = await response.json();
assistantContent = data.choices?.[0]?.message?.content || 'No response';
}
messages.push({
role: 'assistant',
content: assistantContent,
timestamp: new Date().toISOString()
});
updateChat(State.currentChatId, { messages, model });
if (!State.settings.stream) {
renderMessages(messages);
}
updateRegenerateButton(messages);
} catch (error) {
document.getElementById('typingIndicator')?.remove();
if (error.name === 'AbortError') {
showToast('⚠️ Generation stopped', 'warning');
} else {
console.error('API Error:', error);
showToast(`${error.message}`, 'error');
}
} finally {
State.isGenerating = false;
State.abortController = null;
document.getElementById('stopBtn').classList.remove('visible');
document.getElementById('sendBtn').disabled = false;
}
}
function stopGeneration() {
if (State.abortController) {
State.abortController.abort();
State.abortController = null;
}
State.isGenerating = false;
document.getElementById('stopBtn').classList.remove('visible');
document.getElementById('sendBtn').disabled = false;
}

234
src/js/app.js Normal file
View File

@@ -0,0 +1,234 @@
// ==========================================
// Chat Switchboard - Main Application
// ==========================================
function init() {
console.log('🔀 Chat Switchboard initializing...');
loadSettings();
loadChats();
loadModels();
initializeEventListeners();
updateQuickModelSelector();
renderChatHistory();
console.log('✅ Chat Switchboard ready');
}
function initializeEventListeners() {
// Settings modal
document.getElementById('settingsBtn').addEventListener('click', openSettings);
document.getElementById('configureBtn').addEventListener('click', openSettings);
document.getElementById('closeModalBtn').addEventListener('click', closeSettings);
document.getElementById('cancelBtn').addEventListener('click', closeSettings);
document.getElementById('saveBtn').addEventListener('click', handleSaveSettings);
document.getElementById('fetchModelsBtn').addEventListener('click', () => fetchModels(true));
// Chat controls
document.getElementById('newChatBtn').addEventListener('click', newChat);
document.getElementById('sendBtn').addEventListener('click', sendMessage);
document.getElementById('stopBtn').addEventListener('click', stopGeneration);
document.getElementById('regenerateBtn').addEventListener('click', regenerateResponse);
document.getElementById('toggleSidebarBtn').addEventListener('click', toggleSidebar);
document.getElementById('quickFetchBtn').addEventListener('click', () => fetchModels(false));
// Quick model selector
document.getElementById('quickModel').addEventListener('change', function() {
if (this.value) {
State.settings.model = this.value;
saveSettings();
}
});
// Export dropdown
document.getElementById('exportBtn').addEventListener('click', function(e) {
e.stopPropagation();
document.getElementById('exportDropdown').classList.toggle('show');
});
document.addEventListener('click', function() {
document.getElementById('exportDropdown').classList.remove('show');
});
// Message input
const messageInput = document.getElementById('messageInput');
messageInput.addEventListener('keydown', handleKeyDown);
messageInput.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 200) + 'px';
});
// Settings modal model sync
document.getElementById('model').addEventListener('change', function() {
if (this.value) document.getElementById('modelCustom').value = '';
});
document.getElementById('modelCustom').addEventListener('input', function() {
if (this.value) document.getElementById('model').value = '';
});
// Close modal on overlay click
document.getElementById('settingsModal').addEventListener('click', function(e) {
if (e.target === this) closeSettings();
});
// Global keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === ',') {
e.preventDefault();
openSettings();
}
if (e.ctrlKey && e.key === 'b') {
e.preventDefault();
toggleSidebar();
}
if (e.key === 'Escape') {
if (State.isGenerating) {
stopGeneration();
} else if (document.getElementById('settingsModal').classList.contains('active')) {
closeSettings();
}
}
});
}
function handleKeyDown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function handleSaveSettings() {
State.settings.apiEndpoint = document.getElementById('apiEndpoint').value.trim();
State.settings.apiKey = document.getElementById('apiKey').value.trim();
const modelCustom = document.getElementById('modelCustom').value.trim();
const modelSelect = document.getElementById('model').value;
State.settings.model = modelCustom || modelSelect || 'gpt-3.5-turbo';
State.settings.stream = document.getElementById('streamResponse').checked;
State.settings.saveHistory = document.getElementById('saveHistory').checked;
State.settings.showThinking = document.getElementById('showThinking').checked;
State.settings.systemPrompt = document.getElementById('systemPrompt').value.trim();
State.settings.maxTokens = parseInt(document.getElementById('maxTokens').value) || 4096;
State.settings.temperature = parseFloat(document.getElementById('temperature').value) || 0.7;
State.settings.topP = parseFloat(document.getElementById('topP').value) || 1;
State.settings.presencePenalty = parseFloat(document.getElementById('presencePenalty').value) || 0;
saveSettings();
updateQuickModelSelector();
closeSettings();
showToast('✅ Settings saved', 'success');
}
function newChat() {
State.currentChatId = null;
document.getElementById('chatMessages').innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
</div>
<h2>Start a Conversation</h2>
<p>Type a message below to begin</p>
</div>
`;
document.getElementById('regenerateBtn').style.display = 'none';
renderChatHistory();
document.getElementById('messageInput').focus();
}
function loadChat(chatId) {
const chat = getChat(chatId);
if (chat) {
State.currentChatId = chatId;
if (chat.model) {
State.settings.model = chat.model;
updateQuickModelSelector();
}
renderMessages(chat.messages);
renderChatHistory();
updateRegenerateButton(chat.messages);
}
}
function handleDeleteChat(chatId, event) {
event.stopPropagation();
if (confirm('Delete this chat?')) {
deleteChat(chatId);
if (State.currentChatId === chatId) {
newChat();
}
renderChatHistory();
showToast('🗑️ Chat deleted', 'success');
}
}
async function sendMessage() {
const input = document.getElementById('messageInput');
const message = input.value.trim();
if (!message || State.isGenerating) return;
if (!State.settings.apiEndpoint || !State.settings.apiKey) {
showToast('⚠️ Configure API settings first', 'warning');
openSettings();
return;
}
input.value = '';
input.style.height = 'auto';
let messages = [];
if (State.settings.systemPrompt) {
messages.push({ role: 'system', content: State.settings.systemPrompt });
}
if (State.currentChatId) {
const chat = getChat(State.currentChatId);
if (chat) messages = [...chat.messages];
}
messages.push({
role: 'user',
content: message,
timestamp: new Date().toISOString()
});
if (!State.currentChatId) {
const title = message.substring(0, 50) + (message.length > 50 ? '...' : '');
const chat = createChat(title, messages);
State.currentChatId = chat.id;
} else {
updateChat(State.currentChatId, { messages });
}
renderMessages(messages);
renderChatHistory();
await sendApiRequest(messages);
}
async function regenerateResponse() {
if (State.isGenerating || !State.currentChatId) return;
const chat = getChat(State.currentChatId);
if (!chat || chat.messages.length === 0) return;
// Remove last assistant message(s)
while (chat.messages.length > 0 && chat.messages[chat.messages.length - 1].role === 'assistant') {
chat.messages.pop();
}
if (chat.messages.length === 0) return;
updateChat(State.currentChatId, { messages: chat.messages });
renderMessages(chat.messages);
await sendApiRequest(chat.messages);
}
// Initialize on DOM ready
document.addEventListener('DOMContentLoaded', init);

91
src/js/state.js Normal file
View File

@@ -0,0 +1,91 @@
// ==========================================
// State Management
// ==========================================
const State = {
settings: {
apiEndpoint: '',
apiKey: '',
model: 'gpt-3.5-turbo',
stream: true,
saveHistory: true,
showThinking: true,
systemPrompt: '',
maxTokens: 4096,
temperature: 0.7,
topP: 1,
presencePenalty: 0
},
models: [],
chats: [],
currentChatId: null,
isGenerating: false,
abortController: null
};
function loadSettings() {
const saved = Storage.get('chatSwitchboard_settings');
if (saved) {
State.settings = { ...State.settings, ...saved };
}
}
function saveSettings() {
Storage.set('chatSwitchboard_settings', State.settings);
}
function loadModels() {
State.models = Storage.get('chatSwitchboard_models', []);
}
function saveModels() {
Storage.set('chatSwitchboard_models', State.models);
}
function loadChats() {
State.chats = Storage.get('chatSwitchboard_chats', []);
}
function saveChats() {
if (State.settings.saveHistory) {
Storage.set('chatSwitchboard_chats', State.chats);
}
}
function createChat(title, messages) {
const chat = {
id: Date.now().toString(),
title,
messages,
model: State.settings.model,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
State.chats.unshift(chat);
if (State.chats.length > 100) {
State.chats = State.chats.slice(0, 100);
}
saveChats();
return chat;
}
function updateChat(chatId, updates) {
const chat = State.chats.find(c => c.id === chatId);
if (chat) {
Object.assign(chat, updates, { updatedAt: new Date().toISOString() });
saveChats();
}
}
function deleteChat(chatId) {
State.chats = State.chats.filter(c => c.id !== chatId);
saveChats();
}
function getChat(chatId) {
return State.chats.find(c => c.id === chatId);
}
function getCurrentChat() {
return State.currentChatId ? getChat(State.currentChatId) : null;
}

45
src/js/storage.js Normal file
View File

@@ -0,0 +1,45 @@
// ==========================================
// Storage Utilities
// ==========================================
const Storage = {
get(key, defaultValue = null) {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch (e) {
console.error('Storage get error:', e);
return defaultValue;
}
},
set(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
return true;
} catch (e) {
console.error('Storage set error:', e);
return false;
}
},
remove(key) {
try {
localStorage.removeItem(key);
return true;
} catch (e) {
console.error('Storage remove error:', e);
return false;
}
},
clear() {
try {
localStorage.clear();
return true;
} catch (e) {
console.error('Storage clear error:', e);
return false;
}
}
};

314
src/js/ui.js Normal file
View File

@@ -0,0 +1,314 @@
// ==========================================
// UI Functions
// ==========================================
function updateSettingsUI() {
document.getElementById('apiEndpoint').value = State.settings.apiEndpoint || '';
document.getElementById('apiKey').value = State.settings.apiKey || '';
const modelSelect = document.getElementById('model');
const modelCustom = document.getElementById('modelCustom');
const savedModel = State.settings.model || 'gpt-3.5-turbo';
populateModelSelect(modelSelect);
const optionExists = Array.from(modelSelect.options).some(opt => opt.value === savedModel);
if (optionExists) {
modelSelect.value = savedModel;
modelCustom.value = '';
} else {
modelSelect.value = '';
modelCustom.value = savedModel;
}
document.getElementById('streamResponse').checked = State.settings.stream;
document.getElementById('saveHistory').checked = State.settings.saveHistory;
document.getElementById('showThinking').checked = State.settings.showThinking;
document.getElementById('systemPrompt').value = State.settings.systemPrompt || '';
document.getElementById('maxTokens').value = State.settings.maxTokens;
document.getElementById('temperature').value = State.settings.temperature;
document.getElementById('topP').value = State.settings.topP;
document.getElementById('presencePenalty').value = State.settings.presencePenalty;
}
function populateModelSelect(selectElement) {
const currentValue = selectElement.value;
selectElement.innerHTML = '<option value="">-- Select a model --</option>';
State.models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.id + (model.owned_by ? ` (${model.owned_by})` : '');
selectElement.appendChild(option);
});
if (currentValue) {
selectElement.value = currentValue;
}
}
function updateQuickModelSelector() {
const quickSelect = document.getElementById('quickModel');
const currentModel = State.settings.model;
quickSelect.innerHTML = '';
if (State.models.length === 0) {
quickSelect.innerHTML = '<option value="">-- Fetch models first --</option>';
if (currentModel) {
const opt = document.createElement('option');
opt.value = currentModel;
opt.textContent = currentModel;
quickSelect.appendChild(opt);
quickSelect.value = currentModel;
}
return;
}
State.models.forEach(model => {
const option = document.createElement('option');
option.value = model.id;
option.textContent = model.id;
quickSelect.appendChild(option);
});
if (currentModel) {
const exists = Array.from(quickSelect.options).some(opt => opt.value === currentModel);
if (exists) {
quickSelect.value = currentModel;
} else {
const opt = document.createElement('option');
opt.value = currentModel;
opt.textContent = currentModel + ' (custom)';
quickSelect.insertBefore(opt, quickSelect.firstChild);
quickSelect.value = currentModel;
}
}
}
function openSettings() {
updateSettingsUI();
document.getElementById('settingsModal').classList.add('active');
}
function closeSettings() {
document.getElementById('settingsModal').classList.remove('active');
}
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('collapsed');
}
function renderChatHistory() {
const container = document.getElementById('chatHistory');
if (State.chats.length === 0) {
container.innerHTML = '<div style="padding: 1rem; text-align: center; color: var(--text-secondary); font-size: 0.875rem;">No chat history</div>';
return;
}
container.innerHTML = State.chats.map(chat => `
<div class="chat-history-item ${chat.id === State.currentChatId ? 'active' : ''}"
onclick="loadChat('${chat.id}')">
<span class="title">${escapeHtml(chat.title)}</span>
<div class="item-actions">
<button class="item-btn delete" onclick="handleDeleteChat('${chat.id}', event)" title="Delete">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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>
</svg>
</button>
</div>
</div>
`).join('');
}
function renderMessages(messages) {
const container = document.getElementById('chatMessages');
if (!messages || messages.length === 0) {
newChat();
return;
}
container.innerHTML = messages
.filter(m => m.role !== 'system')
.map((msg, idx) => createMessageHTML(msg, idx))
.join('');
scrollToBottom();
}
function createMessageHTML(message, index) {
const isUser = message.role === 'user';
const time = message.timestamp ? new Date(message.timestamp).toLocaleTimeString() : '';
const formattedContent = formatMessage(message.content);
return `
<div class="message ${message.role}" data-index="${index}">
<div class="message-content">
<div class="message-avatar">${isUser ? '👤' : '🤖'}</div>
<div class="message-body">
<div class="message-header">
<span class="message-role">${isUser ? 'You' : 'Assistant'}</span>
<div class="message-actions">
<button class="message-action-btn" onclick="copyMessage(${index})" title="Copy">📋 Copy</button>
</div>
</div>
${time ? `<div class="message-time">${time}</div>` : ''}
<div class="message-text">${formattedContent}</div>
</div>
</div>
</div>
`;
}
function formatMessage(content) {
let formatted = content;
// Extract and format thinking blocks FIRST (before escaping)
if (State.settings.showThinking) {
formatted = formatted.replace(/<thinking>([\s\S]*?)<\/thinking>/gi, (match, thinkingContent) => {
const escapedContent = escapeHtml(thinkingContent.trim());
const blockId = 'thinking-' + Math.random().toString(36).substr(2, 9);
return `__THINKING_BLOCK_${blockId}__${escapedContent}__END_THINKING_BLOCK__`;
});
} else {
// Remove thinking blocks if setting is off
formatted = formatted.replace(/<thinking>[\s\S]*?<\/thinking>/gi, '');
}
// Now escape HTML for the rest
formatted = escapeHtml(formatted);
// Restore thinking blocks with proper HTML
formatted = formatted.replace(/__THINKING_BLOCK_([\w-]+)__([\s\S]*?)__END_THINKING_BLOCK__/g, (match, blockId, content) => {
return `
<div class="thinking-block" id="${blockId}">
<div class="thinking-header" onclick="toggleThinkingBlock('${blockId}')">
<span class="thinking-toggle">▼</span>
<span class="thinking-label">💭 Thinking</span>
</div>
<div class="thinking-content">${content.replace(/\n/g, '<br>')}</div>
</div>
`;
});
// Code blocks with copy button
formatted = formatted.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
const codeId = 'code-' + Math.random().toString(36).substr(2, 9);
return `<pre><code id="${codeId}" class="language-${lang}">${code.trim()}</code><button class="copy-code-btn" onclick="copyCode('${codeId}')">Copy</button></pre>`;
});
// Inline code
formatted = formatted.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic
formatted = formatted.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// Line breaks (but not inside pre/code)
formatted = formatted.replace(/\n/g, '<br>');
return formatted;
}
function toggleThinkingBlock(blockId) {
const block = document.getElementById(blockId);
if (block) {
block.classList.toggle('collapsed');
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function scrollToBottom() {
const container = document.getElementById('chatMessages');
container.scrollTop = container.scrollHeight;
}
function updateRegenerateButton(messages) {
const btn = document.getElementById('regenerateBtn');
const hasAssistantMessage = messages && messages.some(m => m.role === 'assistant');
btn.style.display = hasAssistantMessage ? 'flex' : 'none';
}
function showToast(message, type = 'success') {
const container = document.getElementById('toastContainer');
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<span>${message}</span>
<button class="toast-close" onclick="this.parentElement.remove()">✕</button>
`;
container.appendChild(toast);
setTimeout(() => toast.remove(), 5000);
}
// Message actions
function copyMessage(index) {
const chat = getCurrentChat();
if (chat && chat.messages[index]) {
navigator.clipboard.writeText(chat.messages[index].content)
.then(() => showToast('📋 Copied to clipboard', 'success'))
.catch(() => showToast('❌ Failed to copy', 'error'));
}
}
function copyCode(codeId) {
const codeElement = document.getElementById(codeId);
if (codeElement) {
navigator.clipboard.writeText(codeElement.textContent)
.then(() => showToast('📋 Code copied', 'success'))
.catch(() => showToast('❌ Failed to copy', 'error'));
}
}
function exportChat(format) {
const chat = getCurrentChat();
if (!chat) {
showToast('⚠️ No chat to export', 'warning');
return;
}
let content, filename, mimeType;
const title = chat.title.replace(/[^a-z0-9]/gi, '_');
switch (format) {
case 'markdown':
content = `# ${chat.title}\n\n` + chat.messages
.filter(m => m.role !== 'system')
.map(m => `## ${m.role === 'user' ? 'You' : 'Assistant'}\n\n${m.content}`)
.join('\n\n---\n\n');
filename = `${title}.md`;
mimeType = 'text/markdown';
break;
case 'json':
content = JSON.stringify(chat, null, 2);
filename = `${title}.json`;
mimeType = 'application/json';
break;
case 'text':
content = chat.messages
.filter(m => m.role !== 'system')
.map(m => `[${m.role === 'user' ? 'You' : 'Assistant'}]\n${m.content}`)
.join('\n\n');
filename = `${title}.txt`;
mimeType = 'text/plain';
break;
}
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
showToast(`📥 Exported as ${format}`, 'success');
document.getElementById('exportDropdown').classList.remove('show');
}