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);
|
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;
|
||||||
|
|
|
@ -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">×</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">
|
||||||
|
|
|
@ -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);
|
||||||
|
|
Loading…
Reference in New Issue