import { delay, extractErrorMsg, } from "@ibgib/helper-gib/dist/helpers/utils-helper.mjs"; import { GLOBAL_LOG_A_LOT } from "../constants.mjs"; import { UIThemeInfo } from "./ui-types.mjs"; import { storageGet, storagePut } from "../storage/storage-helpers.web.mjs"; import { UI_THEME_INFO_KEY } from "./ui-constants.mjs"; import type { getGlobalDbName, getGlobalStoreName } from "../helpers.web.mjs"; const logalot = GLOBAL_LOG_A_LOT; /** * Shows the fullscreen dialog with the given title, message, and optional prompt. * * @param {object} details - Options for showing the dialog. * @param {string} details.title - Title of the dialog. * @param {string} details.message - Message to display in the dialog body. * @param {boolean} [details.prompt] - If true, shows a text input for prompting the user. * @returns {Promise} - A promise that resolves with the text entered by the user (if prompt is true) or null if canceled/dismissed. */ export interface FullscreenDialogController { close: () => void; update: (opts: { title?: string; msg?: string; animationEmoji?: string }) => void; } export interface ShowFullscreenDialogOpts { title: string; msg: string; /** * determines if to show the cancel button. does NOT control whether or not * we show the input control. * * so if you want to prompt just for a boolean, just set this to true. If * you need actual string input from the user, set {@link showInput} to * true. * * @see {@link showInput} */ prompt?: boolean; /** * this flag determines if the user input field is shown. If this is truthy, * then {@link prompt} will be ignored and always set to `true`. * * @see {@link prompt} */ showInput?: boolean; defaultValue?: string; okButtonTitle?: string; cancelButtonTitle?: string; isPassword?: boolean; isBusy?: boolean; animationEmoji?: string; passkeys?: Record; passkeyButtonTitle?: string; onPasskeyAuth?: (passkeyEntry?: any) => Promise; } export async function showFullscreenDialog(opts: ShowFullscreenDialogOpts & { isBusy: true }): Promise; export async function showFullscreenDialog(opts: ShowFullscreenDialogOpts & { isBusy?: false }): Promise; export async function showFullscreenDialog({ title, msg, prompt, showInput, defaultValue, okButtonTitle, cancelButtonTitle, isPassword, isBusy, animationEmoji, passkeys, passkeyButtonTitle, onPasskeyAuth, }: ShowFullscreenDialogOpts): Promise { const lc = `[${showFullscreenDialog.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: 037b228a2568181cd3eb25dba4e03225)`); } // const dialog = document.getElementById(ID_FULLSCREEN_DIALOG) as HTMLDialogElement; // const dialogBody = document.getElementById(ID_FULLSCREEN_DIALOG_BODY) as HTMLDialogElement; // const titleElement = document.getElementById(ID_FULLSCREEN_DIALOG_TITLE) as HTMLElement; // const messageElement = document.getElementById(ID_FULLSCREEN_DIALOG_MESSAGE) as HTMLElement; // const promptInput = document.getElementById(ID_FULLSCREEN_DIALOG_PROMPT_INPUT) as HTMLInputElement; // const okButton = document.getElementById(ID_FULLSCREEN_DIALOG_OK_BUTTON) as HTMLButtonElement; // const cancelButton = document.getElementById(ID_FULLSCREEN_DIALOG_CANCEL_BUTTON) as HTMLButtonElement; const dialog = document.getElementById('fullscreen-dialog') as HTMLDialogElement; const dialogBody = document.getElementById('fullscreen-dialog-body') as HTMLDialogElement; const titleElement = document.getElementById('fullscreen-dialog-title') as HTMLElement; const messageElement = document.getElementById('fullscreen-dialog-message') as HTMLElement; const promptInput = document.getElementById('fullscreen-dialog-prompt-input') as HTMLInputElement; const okButton = document.getElementById('fullscreen-dialog-ok-button') as HTMLButtonElement; const cancelButton = document.getElementById('fullscreen-dialog-cancel-button') as HTMLButtonElement; if (!dialog || !dialogBody || !titleElement || !messageElement || !promptInput || !okButton || !cancelButton) { throw new Error(`(UNEXPECTED) One or more dialog elements not found in DOM (E: 578e37c0271cc57a99e95e780ad18a25)`); } titleElement.textContent = title || ''; [...messageElement.childNodes as any].forEach(child => messageElement.removeChild(child)); // messageElement.innerHTML = ''; let msgLines = (msg || '').split('\n'); msgLines.forEach(line => { const pLine = document.createElement('p'); pLine.textContent = line; messageElement.appendChild(pLine); }); // messageElement.textContent = opts.msg || ''; // Add dynamic animation emoji if provided if (animationEmoji) { const animDiv = document.createElement('div'); animDiv.id = 'fullscreen-dialog-animation-emoji'; animDiv.textContent = animationEmoji; animDiv.style.fontSize = '3.5rem'; animDiv.style.textAlign = 'center'; animDiv.style.margin = '1.5rem 0'; animDiv.style.animation = 'spin-bounce 5s infinite ease-in-out'; messageElement.appendChild(animDiv); } // Clean up any previous passkey container let prevPasskeyContainer = document.getElementById('fullscreen-dialog-passkey-container'); if (prevPasskeyContainer) { prevPasskeyContainer.remove(); } let passkeyContainer: HTMLDivElement | null = null; let btnPasskey: HTMLButtonElement | null = null; let selectPasskey: HTMLSelectElement | null = null; const validPasskeys = Object.values(passkeys || {}).filter(p => p !== null && p !== undefined); if (validPasskeys.length > 0 && onPasskeyAuth && !isBusy) { passkeyContainer = document.createElement('div'); passkeyContainer.id = 'fullscreen-dialog-passkey-container'; passkeyContainer.style.margin = '1rem 0'; passkeyContainer.style.textAlign = 'center'; btnPasskey = document.createElement('button'); btnPasskey.id = 'fullscreen-dialog-btn-passkey'; btnPasskey.type = 'button'; btnPasskey.className = 'action-btn'; btnPasskey.style.width = '100%'; btnPasskey.style.padding = '0.75rem 1rem'; btnPasskey.style.background = 'linear-gradient(135deg, #1b5e20, #2e7d32)'; btnPasskey.style.color = '#ffffff'; btnPasskey.style.fontWeight = 'bold'; btnPasskey.style.borderRadius = '8px'; btnPasskey.style.border = '1px solid rgba(255,255,255,0.2)'; btnPasskey.style.cursor = 'pointer'; btnPasskey.style.fontSize = '0.95rem'; btnPasskey.style.marginBottom = '0.75rem'; btnPasskey.textContent = passkeyButtonTitle || '🛡️ Touch ID / Security Key'; if (validPasskeys.length > 1) { selectPasskey = document.createElement('select'); selectPasskey.style.width = '100%'; selectPasskey.style.padding = '0.4rem'; selectPasskey.style.marginBottom = '0.5rem'; selectPasskey.style.borderRadius = '4px'; selectPasskey.style.background = 'rgba(0,0,0,0.3)'; selectPasskey.style.color = 'inherit'; selectPasskey.style.border = '1px solid rgba(255,255,255,0.2)'; validPasskeys.forEach((p: any, idx: number) => { const opt = document.createElement('option'); opt.value = idx.toString(); opt.textContent = `${p.name || 'Key'} (${p.credentialId?.substring(0, 8)}...)`; selectPasskey!.appendChild(opt); }); passkeyContainer.appendChild(selectPasskey); } passkeyContainer.appendChild(btnPasskey); const divider = document.createElement('div'); divider.style.display = 'flex'; divider.style.alignItems = 'center'; divider.style.justifyContent = 'center'; divider.style.gap = '0.5rem'; divider.style.fontSize = '0.8rem'; divider.style.color = 'var(--clr-text-secondary, #b3b3b3)'; divider.style.margin = '0.5rem 0'; divider.innerHTML = ` OR enter Password below `; passkeyContainer.appendChild(divider); promptInput.parentElement?.insertBefore(passkeyContainer, promptInput); } // reset the input classList and button displays promptInput.classList.remove('collapsed'); cancelButton.classList.remove('collapsed'); cancelButton.style.display = 'inline-block'; okButton.style.display = 'inline-block'; okButton.textContent = okButtonTitle || 'OK'; // Prompt-specific setup const onKeypress = (ev: KeyboardEvent) => { /** * apparently an input has ev.key of 'Enter' but textarea is '\n' */ const isEnter = ev.key === 'Enter' || ev.key === '\n'; if (isEnter && ev.ctrlKey) { okButton.click(); } }; if (isBusy) { promptInput.classList.add('collapsed'); cancelButton.style.display = 'none'; okButton.style.display = 'none'; } else if (prompt || showInput) { promptInput.addEventListener('keypress', onKeypress); if (showInput) { promptInput.autofocus = true; promptInput.value = defaultValue ?? ''; promptInput.addEventListener('keypress', (ev) => { if (ev.key === 'Enter') { okButton.click(); } }); if (isPassword) { promptInput.type = 'password'; promptInput.autocomplete = 'off'; promptInput.required = true; } else { promptInput.type = 'text'; promptInput.autocomplete = 'on'; promptInput.required = false; promptInput.attributes['auto-complete'] = 'on'; } } else { promptInput.autofocus = false; promptInput.classList.add('collapsed'); } cancelButton.textContent = cancelButtonTitle || 'Cancel'; } else { promptInput.classList.add('collapsed'); // Hide prompt input cancelButton.style.display = 'none'; // Hide cancel button for alerts } return new Promise((resolve) => { if (isBusy) { const onCancelEvent = (ev: Event) => { ev.preventDefault(); }; dialog.addEventListener('cancel', onCancelEvent); const close = () => { dialog.removeEventListener('cancel', onCancelEvent); dialog.close(); }; const update = ({ title: newTitle, msg: newMsg, animationEmoji: newEmoji, }: { title?: string; msg?: string; animationEmoji?: string; }) => { if (newTitle !== undefined) { titleElement.textContent = newTitle; } if (newMsg !== undefined) { // Clear non-emoji nodes [...messageElement.childNodes as any].forEach(child => { if (child.id !== 'fullscreen-dialog-animation-emoji') { messageElement.removeChild(child); } }); // Insert new lines before the animation emoji let newMsgLines = newMsg.split('\n'); const animDiv = document.getElementById('fullscreen-dialog-animation-emoji'); newMsgLines.forEach(line => { const pLine = document.createElement('p'); pLine.textContent = line; if (animDiv) { messageElement.insertBefore(pLine, animDiv); } else { messageElement.appendChild(pLine); } }); } if (newEmoji !== undefined) { let animDiv = document.getElementById('fullscreen-dialog-animation-emoji'); if (newEmoji) { if (!animDiv) { animDiv = document.createElement('div'); animDiv.id = 'fullscreen-dialog-animation-emoji'; animDiv.style.fontSize = '3.5rem'; animDiv.style.textAlign = 'center'; animDiv.style.margin = '1.5rem 0'; animDiv.style.animation = 'spin-bounce 5s infinite ease-in-out'; messageElement.appendChild(animDiv); } animDiv.textContent = newEmoji; } else if (animDiv) { animDiv.remove(); } } }; if (!dialog.open) { dialog.showModal(); } delay(1000).then(() => { dialogBody.scrollTo({ top: 0, behavior: 'smooth' }); }); resolve({ close, update }); return; } // handlers /** helper used in other handlers */ const removeEventListeners = () => { if (prompt || showInput) { promptInput.removeEventListener('keypress', onKeypress); } if (btnPasskey) { btnPasskey.removeEventListener('click', onPasskeyClick); } if (passkeyContainer) { passkeyContainer.remove(); } okButton.removeEventListener('click', onOK); cancelButton.removeEventListener('click', onCancel); dialog.removeEventListener('close', onClose); // Remove dialog-level listener too }; /** user clicks OK or hits "ENTER" in input */ const onOK = () => { removeEventListeners(); let result = showInput ? promptInput.value ?? '' : true.toString(); if (promptInput) { promptInput.value = ''; } // Clear input for next time dialog.close(result); if (validPasskeys.length > 0) { resolve({ secret: result, poolId: 'manage' }); } else { resolve(result); } }; /** user clicks Passkey button */ const onPasskeyClick = async () => { try { if (!onPasskeyAuth) return; btnPasskey!.disabled = true; btnPasskey!.textContent = 'Waiting for biometrics / security key...'; let selectedPasskey = validPasskeys[0]; if (selectPasskey) { const selectedIdx = parseInt(selectPasskey.value, 10); selectedPasskey = validPasskeys[selectedIdx] || validPasskeys[0]; } const passkeySecret = await onPasskeyAuth(selectedPasskey); if (passkeySecret) { removeEventListeners(); if (promptInput) { promptInput.value = ''; } dialog.close(passkeySecret); resolve({ secret: passkeySecret, poolId: selectedPasskey.poolId || 'passkey-manage' }); } else { btnPasskey!.disabled = false; btnPasskey!.textContent = passkeyButtonTitle || '🛡️ Touch ID / Security Key'; } } catch (err) { console.warn('Passkey assertion in dialog failed/cancelled:', err); btnPasskey!.disabled = false; btnPasskey!.textContent = passkeyButtonTitle || '🛡️ Touch ID / Security Key'; } }; if (btnPasskey) { btnPasskey.addEventListener('click', onPasskeyClick); } /** user actively clicks Cancel */ const onCancel = () => { removeEventListeners(); if (promptInput) { promptInput.value = ''; } // Clear input for next time dialog.close(undefined); // Pass back null for cancel resolve(undefined); }; /** * Handle dialog.close() without button click (e.g., Esc key) * * NOTE: this does not trigger when dialog.close() is executed. This * is only firing when esc is pressed. */ const onClose = () => { removeEventListeners(); if (promptInput) { promptInput.value = ''; } // Clear input for next time dialog.close(undefined); resolve(undefined); }; // wire up okButton.addEventListener('click', onOK); cancelButton.addEventListener('click', onCancel); dialog.addEventListener('close', onClose); // e.g. escape key // show it if (!dialog.open) { dialog.showModal(); } delay(1000).then(() => { dialogBody.scrollTo({ top: 0, behavior: 'smooth' }); }); }); } catch (error) { console.error(`${lc} ${extractErrorMsg(error)}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * Gets {@link UIThemeInfo} if it exists in the db ({@link dbName}) and store * ({@link storeName}) at the given {@link key}. * * Used with libs\web-gib\src\api\commands\ui\update-css-variables.mts * * @see {@link putUIInfoInStore} * @see {@link UI_THEME_INFO_KEY} * @see {@link putUIInfoInStore} * @see {@link getGlobalDbName} * @see {@link getGlobalStoreName} */ export async function getExistingUIInfo({ dbName, storeName, key = UI_THEME_INFO_KEY, }: { dbName: string, storeName: string, key?: string, }): Promise { const lc = `[${getExistingUIInfo.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: 4c9d7c7f1e8bfcc917d405b842438825)`); } key ??= UI_THEME_INFO_KEY; let existingUIInfo: UIThemeInfo | undefined = undefined; const existingUIInfoAsString = await storageGet({ dbName, storeName, key, }); if (existingUIInfoAsString) { try { existingUIInfo = JSON.parse(existingUIInfoAsString) as UIThemeInfo; if (!existingUIInfo.cssVariableOverrides) { throw new Error(`invalid existing UI info (E: 2fb408362178ce79a57da313d6680d25)`); } } catch (error) { existingUIInfo = undefined; } } return existingUIInfo; } catch (error) { console.error(`${lc} ${extractErrorMsg(error)}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } } /** * Complementing {@link getExistingUIInfo}, this puts {@link newUIInfo} in the * db ({@link dbName}) and into the store ({@link storeName}) at the given * {@link key}. * * Used with libs\web-gib\src\api\commands\ui\update-css-variables.mts * * @see {@link getExistingUIInfo} * @see {@link UI_THEME_INFO_KEY} * @see {@link putUIInfoInStore} * @see {@link getGlobalDbName} * @see {@link getGlobalStoreName} */ export async function putUIInfoInStore({ dbName, storeName, key = UI_THEME_INFO_KEY, newUIInfo, }: { dbName: string, storeName: string, key?: string, newUIInfo: UIThemeInfo, }): Promise { const lc = `[${putUIInfoInStore.name}]`; try { if (logalot) { console.log(`${lc} starting... (I: 79c9ee707e426fd8c861c4ff4cf16826)`); } await storagePut({ dbName, storeName, key, value: JSON.stringify(newUIInfo), }); } catch (error) { console.error(`${lc} ${extractErrorMsg(error)}`); throw error; } finally { if (logalot) { console.log(`${lc} complete.`); } } };