feat(frontend): inline notifications and loading states

- Add .notice styles and variants in frontend/css/style.css
- Add showNotice, hideNotice, confirmAction in frontend/js/app.js
- Replace all alert and confirm with inline notices
- Add loading states to Add Speaker and Generate Dialog
- Verified container IDs in index.html, grep clean, tests passing
This commit is contained in:
Steve White 2025-08-12 15:46:23 -05:00
parent b62eb0211f
commit 41f95cdee3
3 changed files with 171 additions and 25 deletions

View File

@ -449,6 +449,72 @@ footer {
border-top: 3px solid var(--primary-blue); border-top: 3px solid var(--primary-blue);
} }
/* Inline Notification */
.notice {
max-width: 1100px;
margin: 16px auto 0;
padding: 12px 16px;
border-radius: 6px;
border: 1px solid var(--border-medium);
background: var(--bg-white);
color: var(--text-primary);
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 1px 2px var(--shadow-light);
}
.notice--info {
border-color: var(--border-blue);
background: var(--bg-blue-light);
}
.notice--success {
border-color: #A7F3D0;
background: #ECFDF5;
}
.notice--warning {
border-color: var(--warning-border);
background: var(--warning-bg);
}
.notice--error {
border-color: var(--error-bg-dark);
background: #FEE2E2;
}
.notice__content {
flex: 1;
}
.notice__actions {
display: flex;
gap: 8px;
}
.notice__actions button {
padding: 6px 12px;
border-radius: 4px;
border: 1px solid var(--border-medium);
background: var(--bg-white);
cursor: pointer;
}
.notice__actions .btn-primary {
background: var(--primary-blue);
color: var(--text-white);
border: none;
}
.notice__close {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: var(--text-secondary);
}
@media (max-width: 900px) { @media (max-width: 900px) {
.panel-grid { .panel-grid {
flex-direction: column; flex-direction: column;

View File

@ -13,6 +13,13 @@
</div> </div>
</header> </header>
<!-- Global inline notification area -->
<div id="global-notice" class="notice" role="status" aria-live="polite" style="display:none;">
<div class="notice__content" id="global-notice-content"></div>
<div class="notice__actions" id="global-notice-actions"></div>
<button class="notice__close" id="global-notice-close" aria-label="Close notification">&times;</button>
</div>
<main class="container" role="main"> <main class="container" role="main">
<div class="panel-grid"> <div class="panel-grid">
<section id="dialog-editor" class="panel full-width-panel" aria-labelledby="dialog-editor-title"> <section id="dialog-editor" class="panel full-width-panel" aria-labelledby="dialog-editor-title">

View File

@ -1,6 +1,64 @@
import { getSpeakers, addSpeaker, deleteSpeaker, generateDialog } from './api.js'; import { getSpeakers, addSpeaker, deleteSpeaker, generateDialog } from './api.js';
import { API_BASE_URL, API_BASE_URL_FOR_FILES } from './config.js'; import { API_BASE_URL, API_BASE_URL_FOR_FILES } from './config.js';
// --- Global Inline Notification Helpers --- //
const noticeEl = document.getElementById('global-notice');
const noticeContentEl = document.getElementById('global-notice-content');
const noticeActionsEl = document.getElementById('global-notice-actions');
const noticeCloseBtn = document.getElementById('global-notice-close');
function hideNotice() {
if (!noticeEl) return;
noticeEl.style.display = 'none';
noticeEl.className = 'notice';
if (noticeContentEl) noticeContentEl.textContent = '';
if (noticeActionsEl) noticeActionsEl.innerHTML = '';
}
function showNotice(message, type = 'info', options = {}) {
if (!noticeEl || !noticeContentEl || !noticeActionsEl) {
console[type === 'error' ? 'error' : 'log']('[NOTICE]', message);
return () => {};
}
const { timeout = null, actions = [] } = options;
noticeEl.className = `notice notice--${type}`;
noticeContentEl.textContent = message;
noticeActionsEl.innerHTML = '';
actions.forEach(({ text, primary = false, onClick }) => {
const btn = document.createElement('button');
btn.textContent = text;
if (primary) btn.classList.add('btn-primary');
btn.onclick = () => {
try { onClick && onClick(); } finally { hideNotice(); }
};
noticeActionsEl.appendChild(btn);
});
if (noticeCloseBtn) noticeCloseBtn.onclick = hideNotice;
noticeEl.style.display = 'flex';
let timerId = null;
if (timeout && Number.isFinite(timeout)) {
timerId = window.setTimeout(hideNotice, timeout);
}
return () => {
if (timerId) window.clearTimeout(timerId);
hideNotice();
};
}
function confirmAction(message) {
return new Promise((resolve) => {
showNotice(message, 'warning', {
actions: [
{ text: 'Cancel', primary: false, onClick: () => resolve(false) },
{ text: 'Confirm', primary: true, onClick: () => resolve(true) },
],
});
});
}
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
console.log('DOM fully loaded and parsed'); console.log('DOM fully loaded and parsed');
initializeSpeakerManagement(); initializeSpeakerManagement();
@ -23,18 +81,24 @@ function initializeSpeakerManagement() {
const audioFile = formData.get('audio_file'); const audioFile = formData.get('audio_file');
if (!speakerName || !audioFile || audioFile.size === 0) { if (!speakerName || !audioFile || audioFile.size === 0) {
alert('Please provide a speaker name and an audio file.'); showNotice('Please provide a speaker name and an audio file.', 'warning', { timeout: 4000 });
return; return;
} }
try { try {
const submitBtn = addSpeakerForm.querySelector('button[type="submit"]');
const prevText = submitBtn ? submitBtn.textContent : null;
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Adding…'; }
const newSpeaker = await addSpeaker(formData); const newSpeaker = await addSpeaker(formData);
alert(`Speaker added: ${newSpeaker.name} (ID: ${newSpeaker.id})`); showNotice(`Speaker added: ${newSpeaker.name} (ID: ${newSpeaker.id})`, 'success', { timeout: 3000 });
addSpeakerForm.reset(); addSpeakerForm.reset();
loadSpeakers(); // Refresh speaker list loadSpeakers(); // Refresh speaker list
} catch (error) { } catch (error) {
console.error('Failed to add speaker:', error); console.error('Failed to add speaker:', error);
alert('Error adding speaker: ' + error.message); showNotice('Error adding speaker: ' + error.message, 'error');
} finally {
const submitBtn = addSpeakerForm.querySelector('button[type="submit"]');
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Add Speaker'; }
} }
}); });
} }
@ -79,23 +143,24 @@ async function loadSpeakers() {
} catch (error) { } catch (error) {
console.error('Failed to load speakers:', error); console.error('Failed to load speakers:', error);
speakerListUL.innerHTML = '<li>Error loading speakers. See console for details.</li>'; speakerListUL.innerHTML = '<li>Error loading speakers. See console for details.</li>';
alert('Error loading speakers: ' + error.message); showNotice('Error loading speakers: ' + error.message, 'error');
} }
} }
async function handleDeleteSpeaker(speakerId) { async function handleDeleteSpeaker(speakerId) {
if (!speakerId) { if (!speakerId) {
alert('Cannot delete speaker: Speaker ID is missing.'); showNotice('Cannot delete speaker: Speaker ID is missing.', 'warning', { timeout: 4000 });
return; return;
} }
if (!confirm(`Are you sure you want to delete speaker ${speakerId}?`)) return; const ok = await confirmAction(`Are you sure you want to delete speaker ${speakerId}?`);
if (!ok) return;
try { try {
await deleteSpeaker(speakerId); await deleteSpeaker(speakerId);
alert(`Speaker ${speakerId} deleted successfully.`); showNotice(`Speaker ${speakerId} deleted successfully.`, 'success', { timeout: 3000 });
loadSpeakers(); // Refresh speaker list loadSpeakers(); // Refresh speaker list
} catch (error) { } catch (error) {
console.error(`Failed to delete speaker ${speakerId}:`, error); console.error(`Failed to delete speaker ${speakerId}:`, error);
alert(`Error deleting speaker: ${error.message}`); showNotice(`Error deleting speaker: ${error.message}`, 'error');
} }
} }
@ -318,7 +383,7 @@ async function initializeDialogEditor() {
} catch (err) { } catch (err) {
console.error('Error in generateLine:', err); console.error('Error in generateLine:', err);
dialogItems[index].error = err.message || 'Failed to generate audio.'; dialogItems[index].error = err.message || 'Failed to generate audio.';
alert(dialogItems[index].error); showNotice(dialogItems[index].error, 'error');
} finally { } finally {
dialogItems[index].isGenerating = false; dialogItems[index].isGenerating = false;
renderDialogItems(); renderDialogItems();
@ -380,13 +445,13 @@ async function initializeDialogEditor() {
try { try {
availableSpeakersCache = await getSpeakers(); availableSpeakersCache = await getSpeakers();
} catch (error) { } catch (error) {
alert('Could not load speakers. Please try again.'); showNotice('Could not load speakers. Please try again.', 'error');
console.error('Error fetching speakers for dialog:', error); console.error('Error fetching speakers for dialog:', error);
return; return;
} }
} }
if (availableSpeakersCache.length === 0) { if (availableSpeakersCache.length === 0) {
alert('No speakers available. Please add a speaker first.'); showNotice('No speakers available. Please add a speaker first.', 'warning', { timeout: 4000 });
return; return;
} }
@ -416,7 +481,7 @@ async function initializeDialogEditor() {
const speakerId = speakerSelect.value; const speakerId = speakerSelect.value;
const text = textInput.value.trim(); const text = textInput.value.trim();
if (!speakerId || !text) { if (!speakerId || !text) {
alert('Please select a speaker and enter text.'); showNotice('Please select a speaker and enter text.', 'warning', { timeout: 4000 });
return; return;
} }
dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text })); dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text }));
@ -458,7 +523,7 @@ async function initializeDialogEditor() {
addButton.onclick = () => { addButton.onclick = () => {
const duration = parseFloat(durationInput.value); const duration = parseFloat(durationInput.value);
if (isNaN(duration) || duration <= 0) { if (isNaN(duration) || duration <= 0) {
alert('Invalid duration. Please enter a positive number.'); showNotice('Invalid duration. Please enter a positive number.', 'warning', { timeout: 4000 });
return; return;
} }
dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration })); dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration }));
@ -483,15 +548,18 @@ async function initializeDialogEditor() {
generateDialogBtn.addEventListener('click', async () => { generateDialogBtn.addEventListener('click', async () => {
const outputBaseName = outputBaseNameInput.value.trim(); const outputBaseName = outputBaseNameInput.value.trim();
if (!outputBaseName) { if (!outputBaseName) {
alert('Please enter an output base name.'); showNotice('Please enter an output base name.', 'warning', { timeout: 4000 });
outputBaseNameInput.focus(); outputBaseNameInput.focus();
return; return;
} }
if (dialogItems.length === 0) { if (dialogItems.length === 0) {
alert('Please add at least one speech or silence line to the dialog.'); showNotice('Please add at least one speech or silence line to the dialog.', 'warning', { timeout: 4000 });
return; // Prevent further execution if no dialog items return; // Prevent further execution if no dialog items
} }
const prevText = generateDialogBtn.textContent;
generateDialogBtn.disabled = true;
generateDialogBtn.textContent = 'Generating…';
// Smart dialog-wide generation: use pre-generated audio where present // Smart dialog-wide generation: use pre-generated audio where present
const dialogItemsToGenerate = dialogItems.map(item => { const dialogItemsToGenerate = dialogItems.map(item => {
// Only send minimal fields for items that need generation // Only send minimal fields for items that need generation
@ -543,7 +611,11 @@ async function initializeDialogEditor() {
} catch (error) { } catch (error) {
console.error('Dialog generation failed:', error); console.error('Dialog generation failed:', error);
if (generationLogPre) generationLogPre.textContent = `Error generating dialog: ${error.message}`; if (generationLogPre) generationLogPre.textContent = `Error generating dialog: ${error.message}`;
alert(`Error generating dialog: ${error.message}`); showNotice(`Error generating dialog: ${error.message}`, 'error');
}
finally {
generateDialogBtn.disabled = false;
generateDialogBtn.textContent = prevText;
} }
}); });
} }
@ -551,7 +623,7 @@ async function initializeDialogEditor() {
// --- Save/Load Script Functionality --- // --- Save/Load Script Functionality ---
function saveDialogScript() { function saveDialogScript() {
if (dialogItems.length === 0) { if (dialogItems.length === 0) {
alert('No dialog items to save. Please add some speech or silence lines first.'); showNotice('No dialog items to save. Please add some speech or silence lines first.', 'warning', { timeout: 4000 });
return; return;
} }
@ -596,11 +668,12 @@ async function initializeDialogEditor() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
console.log(`Dialog script saved as ${filename}`); console.log(`Dialog script saved as ${filename}`);
showNotice(`Dialog script saved as ${filename}`, 'success', { timeout: 3000 });
} }
function loadDialogScript(file) { function loadDialogScript(file) {
if (!file) { if (!file) {
alert('Please select a file to load.'); showNotice('Please select a file to load.', 'warning', { timeout: 4000 });
return; return;
} }
@ -623,19 +696,19 @@ async function initializeDialogEditor() {
} }
} catch (parseError) { } catch (parseError) {
console.error(`Error parsing line ${i + 1}:`, parseError); console.error(`Error parsing line ${i + 1}:`, parseError);
alert(`Error parsing line ${i + 1}: ${parseError.message}`); showNotice(`Error parsing line ${i + 1}: ${parseError.message}`, 'error');
return; return;
} }
} }
if (loadedItems.length === 0) { if (loadedItems.length === 0) {
alert('No valid dialog items found in the file.'); showNotice('No valid dialog items found in the file.', 'warning', { timeout: 4000 });
return; return;
} }
// Confirm replacement if existing items // Confirm replacement if existing items
if (dialogItems.length > 0) { if (dialogItems.length > 0) {
const confirmed = confirm( const confirmed = await confirmAction(
`This will replace your current dialog (${dialogItems.length} items) with the loaded script (${loadedItems.length} items). Continue?` `This will replace your current dialog (${dialogItems.length} items) with the loaded script (${loadedItems.length} items). Continue?`
); );
if (!confirmed) return; if (!confirmed) return;
@ -647,7 +720,7 @@ async function initializeDialogEditor() {
availableSpeakersCache = await getSpeakers(); availableSpeakersCache = await getSpeakers();
} catch (error) { } catch (error) {
console.error('Error fetching speakers:', error); console.error('Error fetching speakers:', error);
alert('Could not load speakers. Dialog loaded but speaker names may not display correctly.'); showNotice('Could not load speakers. Dialog loaded but speaker names may not display correctly.', 'warning', { timeout: 5000 });
} }
} }
@ -656,16 +729,16 @@ async function initializeDialogEditor() {
renderDialogItems(); renderDialogItems();
console.log(`Loaded ${loadedItems.length} dialog items from script`); console.log(`Loaded ${loadedItems.length} dialog items from script`);
alert(`Successfully loaded ${loadedItems.length} dialog items.`); showNotice(`Successfully loaded ${loadedItems.length} dialog items.`, 'success', { timeout: 3000 });
} catch (error) { } catch (error) {
console.error('Error loading dialog script:', error); console.error('Error loading dialog script:', error);
alert(`Error loading dialog script: ${error.message}`); showNotice(`Error loading dialog script: ${error.message}`, 'error');
} }
}; };
reader.onerror = function() { reader.onerror = function() {
alert('Error reading file. Please try again.'); showNotice('Error reading file. Please try again.', 'error');
}; };
reader.readAsText(file); reader.readAsText(file);