components_tutorial_dialog.js
const SLIDES = [
{
title: 'Welcome to SourceQuote',
body: 'SourceQuote is a transcription workspace for audio and video. This quick tour covers the key features — you can revisit it any time from Settings → App Behavior.',
image: "/static/media/tutorial_slides/sourcequote.png",
},
{
title: 'Loading a Project',
body: 'Click the + button in the sidebar to create a new project, or click an existing one in the projects list to open it.',
image: "/static/media/tutorial_slides/open_project.png",
},
{
title: 'Loading Audio',
body: 'With a project open, click "+ Import Audio" to add your audio file to the Waveform panel. Wait for the file to process and upload, and you should see the waveform.',
image: "/static/media/tutorial_slides/import_audio.png",
},
{
title: 'Transcribing Audio',
body: 'With a project that has loaded audio, click the Transcribe button in the toolbar. Choose your language and model, then let the engine generate a transcript automatically. If the button is greyed out, the most likely reason is the file has not processed on the server yet.',
image: "/static/media/tutorial_slides/transcribing.png",
},
{
title: 'Navigating the Transcript',
body: 'The waveform panel stays in sync — click a word to jump to that point in the audio, or click a place in the waveform to jump to that point in the transcript.',
image: "/static/media/tutorial_slides/navigation.png",
},
{
title: 'Speakers',
body: 'Assign or reassign speaker labels to paragraphs using the Transcript panel. The transcription ML may get speaker attribution wrong, so you may change who says which sentence in the Transcript panel.',
image: "/static/media/tutorial_slides/paragraph_speakers.png",
},
{
title: 'Presentation & Export',
body: 'To share your project, you can Present by clicking the present button in the top right, export the transcript to a variety of formats, or create a Live Quote for a specific area of text.',
image: "/static/media/tutorial_slides/live_quotes.png",
},
];
/** Modal dialog that walks the user through tutorial slides. */
export class TutorialDialog {
#currentIndex = 0;
#overlay = null;
#slideImage = null;
#slideTitle = null;
#slideBody = null;
#dots = [];
#prevBtn = null;
#nextBtn = null;
#keydownCapture = null;
/** Creates and displays a new tutorial dialog. */
constructor() {
this.#buildDialog();
this.#renderSlide(0);
this.#installKeyboardBlock();
}
/** Shows tutorial if the "show on load" preference is enabled. */
static maybeShow() {
if (localStorage.getItem('show-tutorial-on-load') !== 'false') {
new TutorialDialog();
}
}
/** Always shows the tutorial regardless of preference. */
static show() {
new TutorialDialog();
}
/** Builds the dialog DOM and appends it to `document.body`. */
#buildDialog() {
const overlay = document.createElement('div');
overlay.className = 'tutorial-dialog-overlay';
this.#overlay = overlay;
const modal = document.createElement('div');
modal.className = 'tutorial-dialog-modal';
// Header
const header = document.createElement('div');
header.className = 'tutorial-dialog-header';
header.innerHTML = '<span>Tutorial</span>';
const closeBtn = document.createElement('button');
closeBtn.className = 'tutorial-dialog-close';
closeBtn.title = 'Close';
closeBtn.innerHTML = '✕';
closeBtn.addEventListener('click', () => this.#close());
header.appendChild(closeBtn);
modal.appendChild(header);
// Slide image area
const imageArea = document.createElement('div');
imageArea.className = 'tutorial-slide-image';
this.#slideImage = imageArea;
modal.appendChild(imageArea);
// Slide text content
const content = document.createElement('div');
content.className = 'tutorial-slide-content';
const title = document.createElement('div');
title.className = 'tutorial-slide-title';
const body = document.createElement('div');
body.className = 'tutorial-slide-body';
content.appendChild(title);
content.appendChild(body);
this.#slideTitle = title;
this.#slideBody = body;
modal.appendChild(content);
// Footer
const footer = document.createElement('div');
footer.className = 'tutorial-dialog-footer';
// "Show on load" checkbox
const showOnLoadLabel = document.createElement('label');
showOnLoadLabel.className = 'tutorial-show-on-load';
const showOnLoadCheckbox = document.createElement('input');
showOnLoadCheckbox.type = 'checkbox';
showOnLoadCheckbox.checked = localStorage.getItem('show-tutorial-on-load') !== 'false';
showOnLoadCheckbox.addEventListener('change', (e) => {
localStorage.setItem('show-tutorial-on-load', e.target.checked ? 'true' : 'false');
window.syncPrefsToServer?.();
});
showOnLoadLabel.appendChild(showOnLoadCheckbox);
showOnLoadLabel.appendChild(document.createTextNode('Show on load'));
const skipBtn = document.createElement('button');
skipBtn.className = 'sample-btn';
skipBtn.textContent = 'Skip Tutorial';
skipBtn.style.cssText = 'padding:0.375rem 0.825rem;font-size:var(--fs-label);';
skipBtn.addEventListener('click', () => this.#close());
// Dot indicators
const dotsEl = document.createElement('div');
dotsEl.className = 'tutorial-dialog-dots';
this.#dots = SLIDES.map((_, i) => {
const dot = document.createElement('div');
dot.className = 'tutorial-dot';
dot.addEventListener('click', () => this.#renderSlide(i));
dotsEl.appendChild(dot);
return dot;
});
const prevBtn = document.createElement('button');
prevBtn.className = 'sample-btn';
prevBtn.textContent = '← Prev';
prevBtn.style.cssText = 'padding:0.45rem 0.975rem;font-size:var(--fs-label);';
prevBtn.addEventListener('click', () => this.#renderSlide(this.#currentIndex - 1));
this.#prevBtn = prevBtn;
const nextBtn = document.createElement('button');
nextBtn.className = 'sample-btn';
nextBtn.textContent = 'Next →';
nextBtn.style.cssText = 'padding:0.45rem 0.975rem;font-size:var(--fs-label);';
nextBtn.addEventListener('click', () => {
if (this.#currentIndex === SLIDES.length - 1) {
this.#close();
} else {
this.#renderSlide(this.#currentIndex + 1);
}
});
this.#nextBtn = nextBtn;
// Layout: [Skip] ... [Show on load] ... [dots] [Prev] [Next]
footer.appendChild(skipBtn);
footer.appendChild(showOnLoadLabel);
const nav = document.createElement('div');
nav.className = 'tutorial-dialog-nav';
nav.appendChild(dotsEl);
nav.appendChild(prevBtn);
nav.appendChild(nextBtn);
footer.appendChild(nav);
modal.appendChild(footer);
overlay.appendChild(modal);
// Clicking the backdrop closes the dialog
let _mouseDownOnOverlay = false;
overlay.addEventListener('mousedown', (e) => { _mouseDownOnOverlay = e.target === overlay; });
overlay.addEventListener('click', (e) => {
if (e.target === overlay && _mouseDownOnOverlay) this.#close();
});
document.body.appendChild(overlay);
}
/**
* Renders the slide at `index`, updating image, text, dots, and nav buttons.
* @param {number} index - Zero-based slide index to display.
*/
#renderSlide(index) {
if (index < 0 || index >= SLIDES.length) return;
this.#currentIndex = index;
const slide = SLIDES[index];
// Update image/media area
this.#slideImage.innerHTML = '';
if (slide.image) {
const isVideo = /\.(mp4|webm|gif)$/i.test(slide.image);
if (isVideo && !slide.image.endsWith('.gif')) {
const video = document.createElement('video');
video.src = slide.image;
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
this.#slideImage.appendChild(video);
} else {
const img = document.createElement('img');
img.src = slide.image;
img.alt = slide.title;
this.#slideImage.appendChild(img);
}
} else {
const placeholder = document.createElement('span');
placeholder.className = 'tutorial-slide-placeholder';
placeholder.textContent = `Slide ${index + 1} of ${SLIDES.length}`;
this.#slideImage.appendChild(placeholder);
}
this.#slideTitle.textContent = slide.title;
this.#slideBody.textContent = slide.body;
// Update dots
this.#dots.forEach((dot, i) => dot.classList.toggle('active', i === index));
// Update buttons
this.#prevBtn.disabled = index === 0;
this.#nextBtn.textContent = index === SLIDES.length - 1 ? 'Done ✓' : 'Next →';
}
/** Captures keyboard events at the document level to trap focus inside the dialog. */
#installKeyboardBlock() {
const FOCUSABLE = 'button:not([disabled]), input, [tabindex]:not([tabindex="-1"])';
this.#keydownCapture = (e) => {
// Stop all key events from reaching any other document-level handler
e.stopImmediatePropagation();
if (e.key === 'Escape') {
this.#close();
return;
}
if (e.key === 'Tab') {
const focusable = [...this.#overlay.querySelectorAll(FOCUSABLE)];
if (!focusable.length) { e.preventDefault(); return; }
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) { e.preventDefault(); last.focus(); }
} else {
if (document.activeElement === last) { e.preventDefault(); first.focus(); }
}
}
};
document.addEventListener('keydown', this.#keydownCapture, true);
// Focus the first focusable element in the modal
requestAnimationFrame(() => {
const first = this.#overlay.querySelector('button:not([disabled]), input');
first?.focus();
});
}
/** Removes the dialog from the DOM and detaches the keyboard listener. */
#close() {
document.removeEventListener('keydown', this.#keydownCapture, true);
this.#overlay.remove();
}
}