From b9ea771ff0d61e027666081eb2fa12cdce9c928a Mon Sep 17 00:00:00 2001 From: matt Date: Tue, 14 Oct 2025 12:11:11 -0700 Subject: [PATCH] feat: add better loading states and animations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added spinning loading indicators on all buttons - Fade-in animations for newly revealed elements - Loading states with visual feedback (opacity, cursor) - Smooth CSS animations for spinners and fades - Loading indicators on: API validation, save operations, regenerate buttons, and character operations - Improved UX with clear visual feedback during async operations 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/main.js | 33 ++++++++++++++++++++++++++---- src/styles.css | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/main.js b/src/main.js index 1576098..96c976a 100644 --- a/src/main.js +++ b/src/main.js @@ -469,6 +469,7 @@ async function handleEditMessage(messageDiv, originalContent) { async function handleRegenerateMessage(messageDiv) { const regenerateBtn = messageDiv.querySelector('.message-action-btn'); regenerateBtn.disabled = true; + regenerateBtn.classList.add('loading'); try { // Get the last user message @@ -479,6 +480,7 @@ async function handleRegenerateMessage(messageDiv) { } catch (error) { console.error('Failed to regenerate message:', error); regenerateBtn.disabled = false; + regenerateBtn.classList.remove('loading'); addMessage(`Error regenerating message: ${error}`, false); } } @@ -521,11 +523,17 @@ async function generateSwipeNonStream(messageDiv, userMessage) { setStatus('Regeneration complete', 'success'); setTimeout(() => setStatus('Ready'), 2000); const regenerateBtn = messageDiv.querySelector('.message-action-btn'); - if (regenerateBtn) regenerateBtn.disabled = false; + if (regenerateBtn) { + regenerateBtn.disabled = false; + regenerateBtn.classList.remove('loading'); + } } catch (error) { setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error'); const regenerateBtn = messageDiv.querySelector('.message-action-btn'); - if (regenerateBtn) regenerateBtn.disabled = false; + if (regenerateBtn) { + regenerateBtn.disabled = false; + regenerateBtn.classList.remove('loading'); + } addMessage(`Error regenerating message: ${error}`, false); } } @@ -564,7 +572,10 @@ async function generateSwipeStream(messageDiv, userMessage) { setStatus('Regeneration complete', 'success'); setTimeout(() => setStatus('Ready'), 2000); const regenerateBtn = messageDiv.querySelector('.message-action-btn'); - if (regenerateBtn) regenerateBtn.disabled = false; + if (regenerateBtn) { + regenerateBtn.disabled = false; + regenerateBtn.classList.remove('loading'); + } tokenUnlisten(); completeUnlisten(); }); @@ -576,7 +587,10 @@ async function generateSwipeStream(messageDiv, userMessage) { completeUnlisten(); setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error'); const regenerateBtn = messageDiv.querySelector('.message-action-btn'); - if (regenerateBtn) regenerateBtn.disabled = false; + if (regenerateBtn) { + regenerateBtn.disabled = false; + regenerateBtn.classList.remove('loading'); + } addMessage(`Error: ${error}`, false); } } @@ -867,14 +881,18 @@ async function handleValidate() { } validateBtn.disabled = true; + validateBtn.classList.add('loading'); validateBtn.textContent = 'Validating...'; validationMsg.style.display = 'none'; + setStatus('Validating API...', 'default'); try { const models = await invoke('validate_api', { baseUrl, apiKey }); validationMsg.textContent = `Found ${models.length} models`; validationMsg.className = 'validation-message success'; + setStatus('API validated successfully', 'success'); + setTimeout(() => setStatus('Ready'), 2000); modelSelect.innerHTML = ''; models.forEach(model => { @@ -885,14 +903,17 @@ async function handleValidate() { }); modelsGroup.style.display = 'flex'; + modelsGroup.classList.add('fade-in'); saveBtn.disabled = false; } catch (error) { validationMsg.textContent = `Validation failed: ${error}`; validationMsg.className = 'validation-message error'; + setStatus('API validation failed', 'error'); modelsGroup.style.display = 'none'; saveBtn.disabled = true; } finally { validateBtn.disabled = false; + validateBtn.classList.remove('loading'); validateBtn.textContent = 'Validate'; } } @@ -914,6 +935,7 @@ async function handleSaveSettings(e) { } saveBtn.disabled = true; + saveBtn.classList.add('loading'); saveBtn.textContent = 'Saving...'; setStatus('Saving configuration...', 'default'); @@ -935,6 +957,7 @@ async function handleSaveSettings(e) { setStatus('Failed to save configuration', 'error'); } finally { saveBtn.disabled = false; + saveBtn.classList.remove('loading'); saveBtn.textContent = 'Save Configuration'; } } @@ -1292,6 +1315,7 @@ async function handleSaveCharacter(e) { } saveBtn.disabled = true; + saveBtn.classList.add('loading'); saveBtn.textContent = 'Saving...'; try { @@ -1324,6 +1348,7 @@ async function handleSaveCharacter(e) { characterMsg.className = 'validation-message error'; } finally { saveBtn.disabled = false; + saveBtn.classList.remove('loading'); saveBtn.textContent = 'Save Character'; } } diff --git a/src/styles.css b/src/styles.css index da63d7e..5cc08e9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -246,6 +246,19 @@ body { } } +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.fade-in { + animation: fadeIn 0.3s ease; +} + .message.user { justify-content: flex-end; } @@ -757,6 +770,47 @@ body { } } +/* Loading spinner */ +.loading-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: currentColor; + animation: spin 0.8s linear infinite; + margin-left: 8px; + vertical-align: middle; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Button loading state */ +.btn-primary.loading, +.btn-secondary.loading, +.message-action-btn.loading { + opacity: 0.7; + cursor: wait; + position: relative; +} + +.btn-primary.loading::after, +.btn-secondary.loading::after { + content: ''; + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + width: 12px; + height: 12px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: white; + animation: spin 0.8s linear infinite; +} + /* Light mode support */ @media (prefers-color-scheme: light) { :root {