components_hyperlink_dialog.js


// Module-level cache so results persist across dialog instances within the session.
// Key: normalised URL string. Value: {accessible, title}.
const _titleCache = new Map();

/**
 * Modal dialog for adding or editing a hyperlink on a transcript segment or
 * text selection. Prompts for a URL (required), an optional display name,
 * an optional description, and optional editor notes.
 *
 * When the URL field is filled, the dialog:
 *  - Shows a spinner while the server proxy checks the URL
 *  - Shows a green check (accessible) or red × (not accessible) after the check
 *  - If a page title is found, shows a dismissible suggestion below the URL field
 *  - Caches results so re-visiting the same URL is instant
 */
export class HyperlinkDialog {
    /**
     * @param {object} callbacks - Callback functions and initial field values for the dialog.
     * @param {function} [callbacks.onSave]                - Called with {url, name, description, editorNotes}
     * @param {function} [callbacks.onDismiss]             - Called when the dialog is cancelled
     * @param {function} [callbacks.fetchTitle]            - async (url) => {accessible, title} via server proxy
     * @param {string}   [callbacks.initialUrl]            - Pre-fill the URL field (edit mode)
     * @param {string}   [callbacks.initialName]           - Pre-fill the name field (edit mode)
     * @param {string}   [callbacks.initialDescription]    - Pre-fill the description field (edit mode)
     * @param {string}   [callbacks.initialEditorNotes]    - Pre-fill the editor notes field (edit mode)
     */
    constructor({ onSave, onDismiss, fetchTitle, initialUrl = '', initialName = '', initialDescription = '', initialEditorNotes = '' } = {}) {
        this._onSave = onSave ?? (() => {});
        this._onDismiss = onDismiss ?? (() => {});
        this._fetchTitle = fetchTitle ?? null;
        this._initialUrl = initialUrl;
        this._initialName = initialName;
        this._initialDescription = initialDescription;
        this._initialEditorNotes = initialEditorNotes;
        this.#buildDialog();
    }

    /** Builds and attaches the dialog overlay and modal to the document body. */
    #buildDialog() {
        const overlay = document.createElement('div');
        overlay.className = 'confirm-dialog-overlay';

        const modal = document.createElement('div');
        modal.className = 'confirm-dialog-modal';
        modal.style.cssText = 'max-height:none;width:360px;padding:0;';

        const header = document.createElement('div');
        header.className = 'confirm-dialog-header';
        header.innerHTML = `<span>${this._initialUrl ? 'Edit hyperlink' : 'Add hyperlink'}</span>`;
        modal.appendChild(header);

        const body = document.createElement('div');
        body.style.cssText = 'padding:1rem 1.25rem;display:flex;flex-direction:column;gap:0.75rem;';

        const inputStyle = 'background:var(--surface);border:1px solid var(--border);border-radius:2px;' +
            'color:var(--text);font-family:var(--font-mono);font-size:var(--fs-caption);' +
            'padding:0.3rem 0.5rem;outline:none;width:100%;box-sizing:border-box;transition:border-color 0.15s;';
        const labelStyle = 'display:flex;flex-direction:column;gap:0.3rem;font-family:var(--font-mono);' +
            'font-size:var(--fs-caption);color:var(--muted);letter-spacing:0.04em;';

        const makeInput = (type, placeholder, value) => {
            const el = document.createElement('input');
            el.type = type; el.placeholder = placeholder; el.value = value;
            el.style.cssText = inputStyle;
            el.onfocus = () => { el.style.borderColor = 'var(--accent2-active)'; };
            el.onblur  = () => { el.style.borderColor = ''; };
            return el;
        };

        const makeTextarea = (placeholder, value, rows = 3) => {
            const el = document.createElement('textarea');
            el.placeholder = placeholder; el.value = value; el.rows = rows;
            el.style.cssText = inputStyle + 'resize:vertical;';
            el.onfocus = () => { el.style.borderColor = 'var(--accent2-active)'; };
            el.onblur  = () => { el.style.borderColor = ''; };
            return el;
        };

        const makeLabel = (text, input) => {
            const label = document.createElement('label');
            label.style.cssText = labelStyle;
            label.textContent = text;
            label.appendChild(input);
            return label;
        };

        // ── URL field with inline status icon ──────────────────���──────────────
        const urlInput = makeInput('url', 'https://', this._initialUrl);
        urlInput.style.cssText = inputStyle + 'flex:1;width:auto;';

        // Status icon: spinner | ✓ | ✗ | empty
        const urlStatus = document.createElement('span');
        urlStatus.style.cssText = 'flex-shrink:0;width:18px;height:18px;display:flex;align-items:center;' +
            'justify-content:center;font-size:0.85rem;line-height:1;';

        const urlInputRow = document.createElement('div');
        urlInputRow.style.cssText = 'display:flex;align-items:center;gap:0.4rem;';
        urlInputRow.appendChild(urlInput);
        urlInputRow.appendChild(urlStatus);

        // Title suggestion row (hidden until a title is found)
        const titleSuggestion = document.createElement('button');
        titleSuggestion.type = 'button';
        titleSuggestion.style.cssText = 'display:none;align-self:flex-start;background:var(--accent2-faint);' +
            'border:1px solid var(--accent2-border);border-radius:2px;color:var(--accent2);' +
            'font-family:var(--font-mono);font-size:var(--fs-hint);padding:0.2rem 0.45rem;' +
            'cursor:pointer;text-align:left;transition:background 0.1s;white-space:nowrap;' +
            'overflow:hidden;text-overflow:ellipsis;max-width:100%;';
        titleSuggestion.onmouseenter = () => { titleSuggestion.style.background = 'var(--accent2-hover)'; };
        titleSuggestion.onmouseleave = () => { titleSuggestion.style.background = 'var(--accent2-faint)'; };

        const urlLabel = document.createElement('label');
        urlLabel.style.cssText = labelStyle;
        urlLabel.textContent = 'URL';
        urlLabel.appendChild(urlInputRow);
        urlLabel.appendChild(titleSuggestion);

        const nameInput  = makeInput('text', 'Display name',    this._initialName);
        const descInput  = makeTextarea('Brief description…',   this._initialDescription);
        const notesInput = makeTextarea('Notes visible only to editors…', this._initialEditorNotes, 2);

        body.appendChild(urlLabel);
        body.appendChild(makeLabel('Display name (optional)', nameInput));
        body.appendChild(makeLabel('Description (optional)', descInput));
        body.appendChild(makeLabel('Editor notes (optional)', notesInput));

        // Actions
        const actions = document.createElement('div');
        actions.style.cssText = 'display:flex;gap:0.5rem;justify-content:flex-end;margin-top:0.25rem;';

        const cancelBtn = document.createElement('button');
        cancelBtn.className = 'sample-btn';
        cancelBtn.textContent = 'Cancel';
        cancelBtn.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);';
        cancelBtn.addEventListener('click', () => { overlay.remove(); this._onDismiss(); });
        actions.appendChild(cancelBtn);

        const saveBtn = document.createElement('button');
        saveBtn.className = 'sample-btn';
        saveBtn.textContent = 'Save';
        saveBtn.style.cssText = 'padding:0.35rem 0.75rem;font-size:var(--fs-label);' +
            'border-color:var(--accent2-border);color:var(--accent2);';
        saveBtn.onmouseenter = () => { saveBtn.style.background = 'var(--accent2-faint)'; saveBtn.style.borderColor = 'var(--accent2-glow)'; };
        saveBtn.onmouseleave = () => { saveBtn.style.background = ''; saveBtn.style.borderColor = 'var(--accent2-border)'; };
        actions.appendChild(saveBtn);

        body.appendChild(actions);
        modal.appendChild(body);
        overlay.appendChild(modal);

        const doSave = () => {
            const url = urlInput.value.trim();
            if (!url) { urlInput.focus(); urlInput.style.borderColor = 'var(--danger-border)'; return; }
            overlay.remove();
            this._onSave({
                url,
                name:        nameInput.value.trim()  || null,
                description: descInput.value.trim()  || null,
                editorNotes: notesInput.value.trim()  || null,
            });
        };

        saveBtn.addEventListener('click', doSave);

        const cancel = () => { overlay.remove(); this._onDismiss(); };

        // ── URL status helpers ───────────────────────��─────────────────────────
        const setStatusIdle    = () => { urlStatus.innerHTML = ''; urlStatus.textContent = ''; urlStatus.style.fontWeight = ''; };
        const setStatusLoading = () => {
            urlStatus.style.fontWeight = '';
            urlStatus.innerHTML = '<span style="display:inline-block;width:12px;height:12px;' +
                'border:1.5px solid var(--border);border-top-color:var(--accent2);border-radius:50%;' +
                'animation:hlnk-spin 0.7s linear infinite;"></span>';
        };
        const setStatusValid   = () => {
            urlStatus.style.fontWeight = '';
            urlStatus.innerHTML = '<span class="icon icon-check" style="width:13px;height:13px;color:var(--success,#4caf50);"></span>';
        };
        const setStatusUnknown = () => {
            urlStatus.innerHTML = '';
            urlStatus.textContent = '?';
            urlStatus.style.color = 'var(--muted)';
            urlStatus.style.fontWeight = '600';
        };
        const setStatusInvalid = () => {
            urlStatus.style.fontWeight = '';
            urlStatus.innerHTML = '<span class="icon icon-close" style="width:13px;height:13px;color:var(--danger,#e05252);"></span>';
        };

        // ── Auto-fetch page title ──────────────────────────────────────────────
        if (this._fetchTitle) {
            let _fetchTimer = null;
            let _fetchSeq   = 0;
            let _isPaste    = false;

            const setSuggestionLoading = () => {
                titleSuggestion.textContent = 'Checking URL…';
                titleSuggestion.style.display = 'block';
                titleSuggestion.style.color = 'var(--muted)';
                titleSuggestion.style.background = 'var(--surface2)';
                titleSuggestion.style.borderColor = 'var(--border)';
                titleSuggestion.style.cursor = 'default';
                titleSuggestion.onmouseenter = null;
                titleSuggestion.onmouseleave = null;
                titleSuggestion.onclick = null;
            };

            const setSuggestionUnknown = () => {
                titleSuggestion.textContent = 'Could not verify URL';
                titleSuggestion.style.display = 'block';
                titleSuggestion.style.color = 'var(--muted)';
                titleSuggestion.style.background = 'var(--surface2)';
                titleSuggestion.style.borderColor = 'var(--border)';
                titleSuggestion.style.cursor = 'default';
                titleSuggestion.onmouseenter = null;
                titleSuggestion.onmouseleave = null;
                titleSuggestion.onclick = null;
            };

            const setSuggestionInvalid = () => {
                titleSuggestion.textContent = 'Invalid URL';
                titleSuggestion.style.display = 'block';
                titleSuggestion.style.color = 'var(--danger, #e05252)';
                titleSuggestion.style.background = 'var(--danger-subtle, rgba(224,82,82,0.08))';
                titleSuggestion.style.borderColor = 'var(--danger-border, rgba(224,82,82,0.35))';
                titleSuggestion.style.cursor = 'default';
                titleSuggestion.onmouseenter = null;
                titleSuggestion.onmouseleave = null;
                titleSuggestion.onclick = null;
            };

            const setSuggestionActive = (title) => {
                titleSuggestion.textContent = `+ Add as title: "${title}"`;
                titleSuggestion.style.display = 'block';
                titleSuggestion.style.color = 'var(--accent2)';
                titleSuggestion.style.background = 'var(--accent2-faint)';
                titleSuggestion.style.borderColor = 'var(--accent2-border)';
                titleSuggestion.style.cursor = 'pointer';
                titleSuggestion.onmouseenter = () => { titleSuggestion.style.background = 'var(--accent2-hover)'; };
                titleSuggestion.onmouseleave = () => { titleSuggestion.style.background = 'var(--accent2-faint)'; };
                titleSuggestion.onclick = () => { nameInput.value = title; titleSuggestion.style.display = 'none'; };
            };

            const applyResult = (seq, { accessible, title }) => {
                if (seq !== _fetchSeq) return;
                if (accessible === true) {
                    setStatusValid();
                    if (title && title !== nameInput.value.trim()) { setSuggestionActive(title); } else { titleSuggestion.style.display = 'none'; }
                } else if (accessible === null) {
                    setStatusUnknown();
                    if (title && title !== nameInput.value.trim()) { setSuggestionActive(title); } else { setSuggestionUnknown(); }
                } else {
                    setStatusInvalid();
                    setSuggestionInvalid();
                }
            };

            const doFetch = async (url, seq) => {
                if (_titleCache.has(url)) { applyResult(seq, _titleCache.get(url)); return; }
                setStatusLoading();
                setSuggestionLoading();
                try {
                    const result = await this._fetchTitle(url);
                    if (result.accessible === true) _titleCache.set(url, result); // only cache confirmed successes
                    applyResult(seq, result);
                } catch {
                    if (seq === _fetchSeq) { setStatusInvalid(); setSuggestionInvalid(); }
                }
            };

            // When the display name is manually edited, re-show the suggestion if
            // the cached title for the current URL differs from the new value.
            nameInput.addEventListener('input', () => {
                const url = urlInput.value.trim();
                if (!url) return;
                const cached = _titleCache.get(url);
                if (!cached?.title) return;
                if (cached.title !== nameInput.value.trim()) {
                    setSuggestionActive(cached.title);
                } else {
                    titleSuggestion.style.display = 'none';
                }
            });

            urlInput.addEventListener('paste', () => { _isPaste = true; });

            urlInput.addEventListener('input', () => {
                clearTimeout(_fetchTimer);
                _fetchSeq++;
                const url = urlInput.value.trim();
                if (!url) { setStatusIdle(); titleSuggestion.style.display = 'none'; _isPaste = false; return; }

                if (_titleCache.has(url)) {
                    applyResult(_fetchSeq, _titleCache.get(url));
                    _isPaste = false;
                    return;
                }

                setStatusLoading();
                setSuggestionLoading();
                const delay = _isPaste ? 50 : 700;
                _isPaste = false;
                _fetchTimer = setTimeout(() => doFetch(url, _fetchSeq), delay);
            });
        }

        // ── Keyboard navigation ──────────────────────────────���─────────────────
        const handleNav = (e, idx, fields) => {
            if (e.key === 'Escape') { e.preventDefault(); cancel(); return; }
            if (e.key === 'Tab') {
                e.preventDefault();
                const next = e.shiftKey ? fields[idx - 1] : fields[idx + 1];
                if (next) next.focus(); else if (!e.shiftKey) saveBtn.focus();
                return;
            }
            e.stopPropagation();
        };

        const allFields = [urlInput, nameInput, descInput, notesInput];

        urlInput.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') { e.preventDefault(); doSave(); return; }
            handleNav(e, 0, allFields);
        });
        nameInput.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') { e.preventDefault(); doSave(); return; }
            handleNav(e, 1, allFields);
        });
        descInput.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') { e.preventDefault(); cancel(); return; }
            if (e.key === 'Tab') { e.preventDefault(); e.shiftKey ? nameInput.focus() : notesInput.focus(); return; }
            e.stopPropagation();
        });
        notesInput.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') { e.preventDefault(); cancel(); return; }
            if (e.key === 'Tab') { e.preventDefault(); e.shiftKey ? descInput.focus() : saveBtn.focus(); return; }
            e.stopPropagation();
        });
        saveBtn.addEventListener('keydown', (e) => {
            if (e.key === 'Escape') { e.preventDefault(); cancel(); return; }
            if (e.key === 'Tab' && e.shiftKey) { e.preventDefault(); notesInput.focus(); return; }
            if (e.key === 'Enter') { e.preventDefault(); doSave(); return; }
            e.stopPropagation();
        });

        let _mouseDownOnOverlay = false;
        overlay.addEventListener('mousedown', (e) => { _mouseDownOnOverlay = e.target === overlay; });
        overlay.addEventListener('click', (e) => {
            if (e.target === overlay && _mouseDownOnOverlay) { overlay.remove(); this._onDismiss(); }
        });

        document.body.appendChild(overlay);
        requestAnimationFrame(() => urlInput.focus());
    }
}