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 = '&#x2715;';
        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();
    }
}