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:
parent
b62eb0211f
commit
41f95cdee3
|
@ -449,6 +449,72 @@ footer {
|
|||
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) {
|
||||
.panel-grid {
|
||||
flex-direction: column;
|
||||
|
|
|
@ -13,6 +13,13 @@
|
|||
</div>
|
||||
</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">×</button>
|
||||
</div>
|
||||
|
||||
<main class="container" role="main">
|
||||
<div class="panel-grid">
|
||||
<section id="dialog-editor" class="panel full-width-panel" aria-labelledby="dialog-editor-title">
|
||||
|
|
|
@ -1,6 +1,64 @@
|
|||
import { getSpeakers, addSpeaker, deleteSpeaker, generateDialog } from './api.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 () => {
|
||||
console.log('DOM fully loaded and parsed');
|
||||
initializeSpeakerManagement();
|
||||
|
@ -23,18 +81,24 @@ function initializeSpeakerManagement() {
|
|||
const audioFile = formData.get('audio_file');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
alert(`Speaker added: ${newSpeaker.name} (ID: ${newSpeaker.id})`);
|
||||
showNotice(`Speaker added: ${newSpeaker.name} (ID: ${newSpeaker.id})`, 'success', { timeout: 3000 });
|
||||
addSpeakerForm.reset();
|
||||
loadSpeakers(); // Refresh speaker list
|
||||
} catch (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) {
|
||||
console.error('Failed to load speakers:', error);
|
||||
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) {
|
||||
if (!speakerId) {
|
||||
alert('Cannot delete speaker: Speaker ID is missing.');
|
||||
showNotice('Cannot delete speaker: Speaker ID is missing.', 'warning', { timeout: 4000 });
|
||||
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 {
|
||||
await deleteSpeaker(speakerId);
|
||||
alert(`Speaker ${speakerId} deleted successfully.`);
|
||||
showNotice(`Speaker ${speakerId} deleted successfully.`, 'success', { timeout: 3000 });
|
||||
loadSpeakers(); // Refresh speaker list
|
||||
} catch (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) {
|
||||
console.error('Error in generateLine:', err);
|
||||
dialogItems[index].error = err.message || 'Failed to generate audio.';
|
||||
alert(dialogItems[index].error);
|
||||
showNotice(dialogItems[index].error, 'error');
|
||||
} finally {
|
||||
dialogItems[index].isGenerating = false;
|
||||
renderDialogItems();
|
||||
|
@ -380,13 +445,13 @@ async function initializeDialogEditor() {
|
|||
try {
|
||||
availableSpeakersCache = await getSpeakers();
|
||||
} 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);
|
||||
return;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -416,7 +481,7 @@ async function initializeDialogEditor() {
|
|||
const speakerId = speakerSelect.value;
|
||||
const text = textInput.value.trim();
|
||||
if (!speakerId || !text) {
|
||||
alert('Please select a speaker and enter text.');
|
||||
showNotice('Please select a speaker and enter text.', 'warning', { timeout: 4000 });
|
||||
return;
|
||||
}
|
||||
dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text }));
|
||||
|
@ -458,7 +523,7 @@ async function initializeDialogEditor() {
|
|||
addButton.onclick = () => {
|
||||
const duration = parseFloat(durationInput.value);
|
||||
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;
|
||||
}
|
||||
dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration }));
|
||||
|
@ -483,15 +548,18 @@ async function initializeDialogEditor() {
|
|||
generateDialogBtn.addEventListener('click', async () => {
|
||||
const outputBaseName = outputBaseNameInput.value.trim();
|
||||
if (!outputBaseName) {
|
||||
alert('Please enter an output base name.');
|
||||
showNotice('Please enter an output base name.', 'warning', { timeout: 4000 });
|
||||
outputBaseNameInput.focus();
|
||||
return;
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
const prevText = generateDialogBtn.textContent;
|
||||
generateDialogBtn.disabled = true;
|
||||
generateDialogBtn.textContent = 'Generating…';
|
||||
// Smart dialog-wide generation: use pre-generated audio where present
|
||||
const dialogItemsToGenerate = dialogItems.map(item => {
|
||||
// Only send minimal fields for items that need generation
|
||||
|
@ -543,7 +611,11 @@ async function initializeDialogEditor() {
|
|||
} catch (error) {
|
||||
console.error('Dialog generation failed:', error);
|
||||
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 ---
|
||||
function saveDialogScript() {
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -596,11 +668,12 @@ async function initializeDialogEditor() {
|
|||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log(`Dialog script saved as ${filename}`);
|
||||
showNotice(`Dialog script saved as ${filename}`, 'success', { timeout: 3000 });
|
||||
}
|
||||
|
||||
function loadDialogScript(file) {
|
||||
if (!file) {
|
||||
alert('Please select a file to load.');
|
||||
showNotice('Please select a file to load.', 'warning', { timeout: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -623,19 +696,19 @@ async function initializeDialogEditor() {
|
|||
}
|
||||
} catch (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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// Confirm replacement if existing items
|
||||
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?`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
@ -647,7 +720,7 @@ async function initializeDialogEditor() {
|
|||
availableSpeakersCache = await getSpeakers();
|
||||
} catch (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();
|
||||
|
||||
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) {
|
||||
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() {
|
||||
alert('Error reading file. Please try again.');
|
||||
showNotice('Error reading file. Please try again.', 'error');
|
||||
};
|
||||
|
||||
reader.readAsText(file);
|
||||
|
|
Loading…
Reference in New Issue