feat: add better loading states and animations

- 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 <noreply@anthropic.com>
This commit is contained in:
2025-10-14 12:11:11 -07:00
parent 4866c11245
commit b9ea771ff0
2 changed files with 83 additions and 4 deletions

View File

@@ -469,6 +469,7 @@ async function handleEditMessage(messageDiv, originalContent) {
async function handleRegenerateMessage(messageDiv) { async function handleRegenerateMessage(messageDiv) {
const regenerateBtn = messageDiv.querySelector('.message-action-btn'); const regenerateBtn = messageDiv.querySelector('.message-action-btn');
regenerateBtn.disabled = true; regenerateBtn.disabled = true;
regenerateBtn.classList.add('loading');
try { try {
// Get the last user message // Get the last user message
@@ -479,6 +480,7 @@ async function handleRegenerateMessage(messageDiv) {
} catch (error) { } catch (error) {
console.error('Failed to regenerate message:', error); console.error('Failed to regenerate message:', error);
regenerateBtn.disabled = false; regenerateBtn.disabled = false;
regenerateBtn.classList.remove('loading');
addMessage(`Error regenerating message: ${error}`, false); addMessage(`Error regenerating message: ${error}`, false);
} }
} }
@@ -521,11 +523,17 @@ async function generateSwipeNonStream(messageDiv, userMessage) {
setStatus('Regeneration complete', 'success'); setStatus('Regeneration complete', 'success');
setTimeout(() => setStatus('Ready'), 2000); setTimeout(() => setStatus('Ready'), 2000);
const regenerateBtn = messageDiv.querySelector('.message-action-btn'); const regenerateBtn = messageDiv.querySelector('.message-action-btn');
if (regenerateBtn) regenerateBtn.disabled = false; if (regenerateBtn) {
regenerateBtn.disabled = false;
regenerateBtn.classList.remove('loading');
}
} catch (error) { } catch (error) {
setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error'); setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error');
const regenerateBtn = messageDiv.querySelector('.message-action-btn'); 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); addMessage(`Error regenerating message: ${error}`, false);
} }
} }
@@ -564,7 +572,10 @@ async function generateSwipeStream(messageDiv, userMessage) {
setStatus('Regeneration complete', 'success'); setStatus('Regeneration complete', 'success');
setTimeout(() => setStatus('Ready'), 2000); setTimeout(() => setStatus('Ready'), 2000);
const regenerateBtn = messageDiv.querySelector('.message-action-btn'); const regenerateBtn = messageDiv.querySelector('.message-action-btn');
if (regenerateBtn) regenerateBtn.disabled = false; if (regenerateBtn) {
regenerateBtn.disabled = false;
regenerateBtn.classList.remove('loading');
}
tokenUnlisten(); tokenUnlisten();
completeUnlisten(); completeUnlisten();
}); });
@@ -576,7 +587,10 @@ async function generateSwipeStream(messageDiv, userMessage) {
completeUnlisten(); completeUnlisten();
setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error'); setStatus(`Regeneration failed: ${error.substring(0, 40)}...`, 'error');
const regenerateBtn = messageDiv.querySelector('.message-action-btn'); 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); addMessage(`Error: ${error}`, false);
} }
} }
@@ -867,14 +881,18 @@ async function handleValidate() {
} }
validateBtn.disabled = true; validateBtn.disabled = true;
validateBtn.classList.add('loading');
validateBtn.textContent = 'Validating...'; validateBtn.textContent = 'Validating...';
validationMsg.style.display = 'none'; validationMsg.style.display = 'none';
setStatus('Validating API...', 'default');
try { try {
const models = await invoke('validate_api', { baseUrl, apiKey }); const models = await invoke('validate_api', { baseUrl, apiKey });
validationMsg.textContent = `Found ${models.length} models`; validationMsg.textContent = `Found ${models.length} models`;
validationMsg.className = 'validation-message success'; validationMsg.className = 'validation-message success';
setStatus('API validated successfully', 'success');
setTimeout(() => setStatus('Ready'), 2000);
modelSelect.innerHTML = '<option value="">Select a model</option>'; modelSelect.innerHTML = '<option value="">Select a model</option>';
models.forEach(model => { models.forEach(model => {
@@ -885,14 +903,17 @@ async function handleValidate() {
}); });
modelsGroup.style.display = 'flex'; modelsGroup.style.display = 'flex';
modelsGroup.classList.add('fade-in');
saveBtn.disabled = false; saveBtn.disabled = false;
} catch (error) { } catch (error) {
validationMsg.textContent = `Validation failed: ${error}`; validationMsg.textContent = `Validation failed: ${error}`;
validationMsg.className = 'validation-message error'; validationMsg.className = 'validation-message error';
setStatus('API validation failed', 'error');
modelsGroup.style.display = 'none'; modelsGroup.style.display = 'none';
saveBtn.disabled = true; saveBtn.disabled = true;
} finally { } finally {
validateBtn.disabled = false; validateBtn.disabled = false;
validateBtn.classList.remove('loading');
validateBtn.textContent = 'Validate'; validateBtn.textContent = 'Validate';
} }
} }
@@ -914,6 +935,7 @@ async function handleSaveSettings(e) {
} }
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.classList.add('loading');
saveBtn.textContent = 'Saving...'; saveBtn.textContent = 'Saving...';
setStatus('Saving configuration...', 'default'); setStatus('Saving configuration...', 'default');
@@ -935,6 +957,7 @@ async function handleSaveSettings(e) {
setStatus('Failed to save configuration', 'error'); setStatus('Failed to save configuration', 'error');
} finally { } finally {
saveBtn.disabled = false; saveBtn.disabled = false;
saveBtn.classList.remove('loading');
saveBtn.textContent = 'Save Configuration'; saveBtn.textContent = 'Save Configuration';
} }
} }
@@ -1292,6 +1315,7 @@ async function handleSaveCharacter(e) {
} }
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.classList.add('loading');
saveBtn.textContent = 'Saving...'; saveBtn.textContent = 'Saving...';
try { try {
@@ -1324,6 +1348,7 @@ async function handleSaveCharacter(e) {
characterMsg.className = 'validation-message error'; characterMsg.className = 'validation-message error';
} finally { } finally {
saveBtn.disabled = false; saveBtn.disabled = false;
saveBtn.classList.remove('loading');
saveBtn.textContent = 'Save Character'; saveBtn.textContent = 'Save Character';
} }
} }

View File

@@ -246,6 +246,19 @@ body {
} }
} }
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeIn 0.3s ease;
}
.message.user { .message.user {
justify-content: flex-end; 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 */ /* Light mode support */
@media (prefers-color-scheme: light) { @media (prefers-color-scheme: light) {
:root { :root {