e8520e485f
- Add Modal.confirmDialog() (Promise<boolean>, built on openPanel so it inherits the overlay, animation, focus-trap and Escape handling) and use it to replace native confirm() in the photos batch-delete and lightbox single-delete flows. - The lightbox now reflects the real favorite state when an item opens (previously the star always started empty) and toggles favorites through the favorites module so its cache stays in sync. - Add photos.delete_* i18n keys (English; other locales fall back to en). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
490 lines
18 KiB
JavaScript
490 lines
18 KiB
JavaScript
import { i18n } from '../core/i18n.js';
|
|
import { replaceIconsInElement } from '../core/icons.js';
|
|
|
|
/**
|
|
* Modal System for OxiCloud
|
|
* Provides modern, styled modals to replace browser prompts/alerts
|
|
*/
|
|
|
|
const Modal = {
|
|
// Modal element references
|
|
/** @private @type {HTMLElement | null} */
|
|
overlay: null,
|
|
/** @private @type {HTMLElement | null} */
|
|
icon: null,
|
|
/** @private @type {HTMLElement | null} */
|
|
title: null,
|
|
/** @private @type {HTMLElement | null} */
|
|
label: null,
|
|
/** @private @type {HTMLInputElement | null} */
|
|
input: null,
|
|
/** @private @type {HTMLButtonElement | null} */
|
|
cancelBtn: null,
|
|
/** @private @type {HTMLButtonElement | null} */
|
|
confirmBtn: null,
|
|
/** @private @type {HTMLButtonElement | null} */
|
|
closeBtn: null,
|
|
|
|
// Current callback
|
|
/** @private @type {Function | null} */
|
|
onConfirm: null,
|
|
/** @private @type {Function | null} */
|
|
onCancel: null,
|
|
|
|
/** @private @type {((value: string) => Promise<void>) | null} */
|
|
_action: null,
|
|
|
|
/** @private @type {HTMLElement | null} */
|
|
errorEl: null,
|
|
|
|
// Rename mode: select only name without extension
|
|
_selectNameOnly: false,
|
|
|
|
// Panel mode — openPanel() sets this; skips input-focus logic
|
|
/** @private */
|
|
_panelMode: false,
|
|
|
|
// Saved modal-body innerHTML to restore when a panel closes
|
|
/** @private */
|
|
_savedBodyHTML: '',
|
|
|
|
// Element focused before the modal opened — focus returns here on close.
|
|
/** @private @type {HTMLElement|null} */
|
|
_previousFocus: null,
|
|
|
|
/**
|
|
* Initialize modal system
|
|
*/
|
|
init() {
|
|
this.overlay = document.getElementById('input-modal');
|
|
if (!this.overlay) {
|
|
console.warn('Modal overlay not found');
|
|
return;
|
|
}
|
|
|
|
this.icon = document.getElementById('modal-icon');
|
|
this.title = document.getElementById('modal-title');
|
|
this.label = document.getElementById('modal-label');
|
|
this.input = /** @type {HTMLInputElement} */ (document.getElementById('modal-input'));
|
|
this.cancelBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-cancel-btn'));
|
|
this.confirmBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-confirm-btn'));
|
|
this.closeBtn = /** @type {HTMLButtonElement} */ (document.getElementById('modal-close-btn'));
|
|
|
|
// Event listeners
|
|
this.errorEl = document.getElementById('modal-error');
|
|
|
|
this.cancelBtn?.addEventListener('click', () => this.close(false));
|
|
this.closeBtn?.addEventListener('click', () => this.close(false));
|
|
this.confirmBtn?.addEventListener('click', () => this.confirm());
|
|
|
|
// Close on overlay click
|
|
this.overlay.addEventListener('click', (e) => {
|
|
if (e.target === this.overlay) {
|
|
this.close(false);
|
|
}
|
|
});
|
|
|
|
// Trap Tab focus within the dialog while it is open.
|
|
this.overlay.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Tab') this._trapFocus(e);
|
|
});
|
|
|
|
// Clear inline error as soon as the user starts typing
|
|
this.input?.addEventListener('input', () => this.clearError());
|
|
|
|
// Handle Enter and Escape keys
|
|
this.input?.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
this.confirm();
|
|
} else if (e.key === 'Escape') {
|
|
this.close(false);
|
|
}
|
|
});
|
|
|
|
// Escape in panel mode (input isn't focused so the above handler won't fire)
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && this._panelMode && !this.overlay?.classList.contains('hidden')) {
|
|
this.close(false);
|
|
}
|
|
});
|
|
},
|
|
|
|
/** @param {string} message */
|
|
showError(message) {
|
|
this.input?.classList.add('modal-input--error');
|
|
if (this.errorEl) {
|
|
this.errorEl.textContent = message;
|
|
this.errorEl.classList.remove('hidden');
|
|
}
|
|
},
|
|
|
|
clearError() {
|
|
this.input?.classList.remove('modal-input--error');
|
|
if (this.errorEl) {
|
|
this.errorEl.textContent = '';
|
|
this.errorEl.classList.add('hidden');
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Show input modal (replacement for prompt())
|
|
* @param {Object} options - Modal configuration
|
|
* @param {string} [options.title] - Modal title
|
|
* @param {string} [options.label] - Input label
|
|
* @param {string} [options.placeholder] - Input placeholder
|
|
* @param {string} [options.value] - Initial input value
|
|
* @param {string} [options.icon] - Font Awesome icon class (e.g., 'fa-folder-plus')
|
|
* @param {string} [options.confirmText] - Confirm button text
|
|
* @param {string} [options.cancelText] - Cancel button text
|
|
* @param {(value: string) => Promise<void>} [options.action] - Async action called on confirm.
|
|
* Throw an Error to keep the modal open and display the error message inline.
|
|
* When omitted the modal resolves immediately with the input value (legacy behaviour).
|
|
* @returns {Promise<string|null>} - Resolves with input value or null if cancelled
|
|
*/
|
|
prompt(options = {}) {
|
|
return new Promise((resolve) => {
|
|
const {
|
|
title = 'Input',
|
|
label = '',
|
|
placeholder = '',
|
|
value = '',
|
|
icon = 'fa-keyboard',
|
|
confirmText = null,
|
|
cancelText = null,
|
|
action = null
|
|
} = options;
|
|
|
|
// Set modal content - update the icon
|
|
const iconContainer = document.querySelector('.modal-icon');
|
|
if (iconContainer) {
|
|
// Replace contents with a fresh <i> that icons.js will convert
|
|
iconContainer.innerHTML = `<i id="modal-icon" class="fas ${icon}"></i>`;
|
|
this.icon = document.getElementById('modal-icon');
|
|
// Let icons.js convert it to SVG
|
|
if (replaceIconsInElement) {
|
|
replaceIconsInElement(iconContainer);
|
|
this.icon = document.getElementById('modal-icon');
|
|
}
|
|
}
|
|
this.title.textContent = title;
|
|
this.label.textContent = label;
|
|
this.input.placeholder = placeholder;
|
|
this.input.value = value;
|
|
|
|
if (confirmText) {
|
|
this.confirmBtn.textContent = confirmText;
|
|
} else {
|
|
this.confirmBtn.textContent = i18n.t('actions.confirm');
|
|
}
|
|
|
|
if (cancelText) {
|
|
this.cancelBtn.textContent = cancelText;
|
|
} else {
|
|
this.cancelBtn.textContent = i18n.t('actions.cancel');
|
|
}
|
|
|
|
this._action = action;
|
|
this.clearError();
|
|
|
|
// Set callbacks
|
|
this.onConfirm = () => {
|
|
const inputValue = this.input.value.trim();
|
|
resolve(inputValue || null);
|
|
};
|
|
this.onCancel = () => resolve(null);
|
|
|
|
// Show modal
|
|
this.open();
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Show modal for creating new folder
|
|
* @param {(value: string) => Promise<void>} [action]
|
|
* @returns {Promise<string|null>}
|
|
*/
|
|
promptNewFolder(action = null) {
|
|
return this.prompt({
|
|
title: i18n.t('dialogs.new_folder_title'),
|
|
label: i18n.t('dialogs.folder_name'),
|
|
placeholder: i18n.t('dialogs.folder_placeholder'),
|
|
icon: 'fa-folder-plus',
|
|
confirmText: i18n.t('actions.create'),
|
|
action
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Show modal for renaming
|
|
* @param {string} currentName - Current name of file/folder
|
|
* @param {boolean} isFolder - Whether it's a folder
|
|
* @param {(value: string) => Promise<void>} [action]
|
|
* @returns {Promise<string|null>}
|
|
*/
|
|
promptRename(currentName, isFolder = false, action = null) {
|
|
this._selectNameOnly = !isFolder;
|
|
|
|
return this.prompt({
|
|
title: i18n.t('dialogs.rename_title'),
|
|
label: i18n.t('dialogs.new_name'),
|
|
placeholder: '',
|
|
value: currentName,
|
|
icon: isFolder ? 'fa-folder' : 'fa-file',
|
|
confirmText: i18n.t('actions.rename'),
|
|
action
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Keep Tab focus cycling within the dialog container (focus trap).
|
|
* @private
|
|
* @param {KeyboardEvent} e
|
|
*/
|
|
_trapFocus(e) {
|
|
const container = this.overlay?.querySelector('.modal-container');
|
|
if (!container) return;
|
|
const focusables = /** @type {NodeListOf<HTMLElement>} */ (
|
|
container.querySelectorAll(
|
|
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
)
|
|
);
|
|
if (!focusables.length) return;
|
|
const first = focusables[0];
|
|
const last = focusables[focusables.length - 1];
|
|
if (e.shiftKey && document.activeElement === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
} else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Open the modal
|
|
*/
|
|
open() {
|
|
if (!this.overlay) return;
|
|
|
|
this._previousFocus = /** @type {HTMLElement|null} */ (document.activeElement);
|
|
this.confirmBtn.disabled = false;
|
|
|
|
// Show overlay
|
|
this.overlay.classList.remove('hidden');
|
|
|
|
// Trigger animation
|
|
requestAnimationFrame(() => {
|
|
this.overlay.classList.add('active');
|
|
});
|
|
|
|
// Focus input after animation
|
|
setTimeout(() => {
|
|
this.input.focus();
|
|
|
|
if (this._selectNameOnly) {
|
|
// Select only the filename, excluding the extension
|
|
const value = this.input.value;
|
|
const lastDot = value.lastIndexOf('.');
|
|
if (lastDot > 0) {
|
|
this.input.setSelectionRange(0, lastDot);
|
|
} else {
|
|
this.input.select();
|
|
}
|
|
this._selectNameOnly = false;
|
|
} else {
|
|
this.input.select();
|
|
}
|
|
}, 100);
|
|
},
|
|
|
|
/**
|
|
* Close the modal
|
|
* @param {boolean} confirmed - Whether the action was confirmed
|
|
*/
|
|
close(confirmed = false) {
|
|
if (!this.overlay) return;
|
|
|
|
this.clearError();
|
|
this._action = null;
|
|
this.overlay.classList.remove('active');
|
|
|
|
const wasPanel = this._panelMode;
|
|
|
|
setTimeout(() => {
|
|
this.overlay.classList.add('hidden');
|
|
|
|
if (!confirmed && this.onCancel) {
|
|
this.onCancel();
|
|
}
|
|
|
|
// Clear callbacks
|
|
this.onConfirm = null;
|
|
this.onCancel = null;
|
|
|
|
// Restore original modal-body content after a panel closes
|
|
if (wasPanel) {
|
|
const bodyEl = this.overlay?.querySelector('.modal-body');
|
|
if (bodyEl) bodyEl.innerHTML = this._savedBodyHTML;
|
|
this.overlay?.querySelector('.modal-container')?.classList.remove('modal-container--panel');
|
|
this._panelMode = false;
|
|
this._savedBodyHTML = '';
|
|
}
|
|
|
|
// Return focus to whatever was focused before the modal opened.
|
|
this._previousFocus?.focus?.();
|
|
this._previousFocus = null;
|
|
}, 200);
|
|
},
|
|
|
|
/**
|
|
* Confirm the action. When an async action is set, the modal stays open
|
|
* until it resolves — closing only on success, showing the error inline on failure.
|
|
*/
|
|
async confirm() {
|
|
// Panel mode: delegate entirely to the caller-supplied onConfirm
|
|
if (this._panelMode) {
|
|
if (this.onConfirm) this.onConfirm();
|
|
this.close(true);
|
|
return;
|
|
}
|
|
|
|
if (!this._action) {
|
|
if (this.onConfirm) this.onConfirm();
|
|
this.close(true);
|
|
return;
|
|
}
|
|
|
|
const inputValue = this.input.value.trim();
|
|
if (!inputValue) return;
|
|
|
|
this.clearError();
|
|
this.confirmBtn.disabled = true;
|
|
|
|
try {
|
|
await this._action(inputValue);
|
|
if (this.onConfirm) this.onConfirm();
|
|
this.close(true);
|
|
} catch (e) {
|
|
this.showError(/** @type {Error} */ (e).message || 'An error occurred');
|
|
this.confirmBtn.disabled = false;
|
|
this.input.focus();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Open the modal with fully custom body content (panel mode).
|
|
*
|
|
* The caller supplies a pre-built HTMLElement as `content`; it is injected
|
|
* into `.modal-body`, replacing the default label/input/error elements for
|
|
* the lifetime of this panel. The overlay, header, animation, footer
|
|
* buttons, click-outside, and Escape handling all come from Modal.
|
|
*
|
|
* Original `.modal-body` innerHTML is restored automatically when the
|
|
* panel closes.
|
|
*
|
|
* @param {Object} options
|
|
* @param {string} options.title
|
|
* @param {string} [options.icon] - Font Awesome class, default 'fa-share-alt'
|
|
* @param {HTMLElement} options.content - DOM node to inject into .modal-body
|
|
* @param {string} [options.confirmText] - Confirm button label
|
|
* @param {string} [options.cancelText] - Cancel button label
|
|
* @param {boolean} [options.confirmDisabled] - Initial disabled state of the confirm button
|
|
* @param {() => void} [options.onConfirm] - Called when Confirm is clicked
|
|
* @param {() => void} [options.onCancel] - Called when Cancel / close is triggered
|
|
*/
|
|
openPanel({ title, icon = 'fa-share-alt', content, confirmText = null, cancelText = null, confirmDisabled = false, onConfirm = null, onCancel = null }) {
|
|
if (!this.overlay) return;
|
|
|
|
this._panelMode = true;
|
|
|
|
// ── Header ──────────────────────────────────────────────────────────
|
|
const iconContainer = this.overlay.querySelector('.modal-icon');
|
|
if (iconContainer) {
|
|
iconContainer.innerHTML = `<i class="fas ${icon}"></i>`;
|
|
if (replaceIconsInElement) replaceIconsInElement(iconContainer);
|
|
}
|
|
if (this.title) this.title.textContent = title;
|
|
|
|
// ── Body swap ───────────────────────────────────────────────────────
|
|
const bodyEl = this.overlay.querySelector('.modal-body');
|
|
if (bodyEl) {
|
|
this._savedBodyHTML = bodyEl.innerHTML;
|
|
bodyEl.replaceChildren(content);
|
|
}
|
|
|
|
// ── Container size modifier ──────────────────────────────────────────
|
|
this.overlay.querySelector('.modal-container')?.classList.add('modal-container--panel');
|
|
|
|
// ── Footer buttons ──────────────────────────────────────────────────
|
|
if (this.confirmBtn) {
|
|
this.confirmBtn.textContent = confirmText ?? i18n.t('actions.apply', 'Apply');
|
|
this.confirmBtn.disabled = confirmDisabled;
|
|
}
|
|
if (this.cancelBtn) {
|
|
this.cancelBtn.textContent = cancelText ?? i18n.t('actions.cancel');
|
|
}
|
|
|
|
// ── Callbacks ───────────────────────────────────────────────────────
|
|
this.onConfirm = onConfirm;
|
|
this.onCancel = onCancel;
|
|
this._action = null;
|
|
this.clearError();
|
|
|
|
// ── Show overlay (same animation as prompt, no input focus) ─────────
|
|
this.overlay.classList.remove('hidden');
|
|
requestAnimationFrame(() => {
|
|
this.overlay.classList.add('active');
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Confirmation dialog (replacement for window.confirm()).
|
|
* Built on openPanel, so it inherits the overlay, animation, focus-trap,
|
|
* Escape and click-outside handling.
|
|
* @param {Object} options
|
|
* @param {string} options.title
|
|
* @param {string} options.message
|
|
* @param {string} [options.confirmText]
|
|
* @param {string} [options.cancelText]
|
|
* @param {string} [options.icon] - Font Awesome class, default 'fa-circle-question'
|
|
* @returns {Promise<boolean>} true if confirmed, false otherwise
|
|
*/
|
|
confirmDialog({ title, message, confirmText = null, cancelText = null, icon = 'fa-circle-question' }) {
|
|
return new Promise((resolve) => {
|
|
if (!this.overlay) {
|
|
resolve(false);
|
|
return;
|
|
}
|
|
const content = document.createElement('p');
|
|
content.className = 'modal-confirm-message';
|
|
content.textContent = message;
|
|
|
|
let settled = false;
|
|
const done = (/** @type {boolean} */ value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(value);
|
|
};
|
|
|
|
this.openPanel({
|
|
title,
|
|
icon,
|
|
content,
|
|
confirmText: confirmText ?? i18n.t('actions.confirm'),
|
|
cancelText: cancelText ?? i18n.t('actions.cancel'),
|
|
onConfirm: () => done(true),
|
|
onCancel: () => done(false)
|
|
});
|
|
});
|
|
}
|
|
};
|
|
|
|
// Initialize when DOM is ready
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
Modal.init();
|
|
});
|
|
|
|
// Export for use in other modules
|
|
export { Modal };
|