UNPKG

@doyosi/laravel

Version:

Complete JavaScript plugins collection for Laravel applications - AJAX, forms, UI components, and more

1,304 lines (1,143 loc) 76.3 kB
/*! * @doyosi/laravel-js v1.0.5 * JavaScript plugins for Laravel applications * (c) 2025 Karyazilim * Released under MIT License */ /** * AjaxDivBox - Ajax-based grid/list view with card-like div rendering. * * @param {Object} options - Configuration options. * @see constructor for all available options. * * @example * import AjaxDivBox from './AjaxDivBox.js'; * const grid = new AjaxDivBox({ * url: '/api/items', * container: '#ajax-list', * templateId: 'box-template', * filterSelector: '#filters', * pagination: '#pagination-container' * }); * grid.init(); // Fetch initial data * grid.on('rendered', ({ data }) => console.log('Loaded', data.length, 'items')); */ class AjaxDivBox { /** * @param {Object} options * @param {string} options.url - API endpoint for data. * @param {string|Element} options.container - The container to render the items into. * @param {string} [options.templateId='box-template'] - The ID of the script template for rendering items. * @param {string} [options.metaKey='meta'] - The key in the API response for pagination metadata. * @param {string} [options.dataKey='data'] - The key in the API response for the array of items. * @param {string} [options.fetcher='axios'|'fetch'] - The HTTP client to use. Auto-detects Axios. * @param {function|null} [options.onBox=null] - A custom function to render an item, overrides templateId. * @param {string|Element|null} [options.pagination=null] - The container for pagination links. * @param {string|Element|null} [options.filterSelector=null] - The form or div containing filter inputs. * @param {string|Element|null} [options.loadingIndicator='.loading-list'] - Selector for the loading element. * @param {string|Element|null} [options.nothingFoundBlock='.nothing-found-list'] - Element to show when no results are found. * @param {string|Element|null} [options.errorBlock='.list-render-error'] - Element to show on fetch error. * @param {function|null} [options.additionalParams=null] - Function that returns an object of extra query parameters. */ constructor({ url, container, templateId = 'box-template', metaKey = 'meta', dataKey = 'data', fetcher = window.axios ? 'axios' : 'fetch', onBox = null, pagination = null, filterSelector = null, loadingIndicator = '.loading-list', nothingFoundBlock = '.nothing-found-list', errorBlock = '.list-render-error', additionalParams = null, }) { this.url = url; this.config = { templateId, metaKey, dataKey, fetcher, onBox, additionalParams }; this.filters = {}; this._handlers = {}; this.debounceTimer = null; // --- UPDATE: Centralized DOM element querying --- // Query all elements once and store them. const getElement = (selector) => { if (selector instanceof HTMLElement) return selector; if (typeof selector === 'string') return document.querySelector(selector); return null; }; this.elements = { container: getElement(container), pagination: getElement(pagination), filters: getElement(filterSelector), loader: getElement(loadingIndicator), nothingFound: getElement(nothingFoundBlock), error: getElement(errorBlock), }; if (!this.elements.container) { throw new Error('AjaxDivBox: The main container element is required and was not found.'); } // --- UPDATE: Event listeners are bound once in the constructor --- this._bindFilterEvents(); if (this.config.autoInit !== false) { this.init(); } } /** * Registers an event handler. * @param {'start'|'rendered'|'error'|'pageChange'} event - The event name. * @param {Function} fn - The callback function. * @returns {this} */ on(event, fn) { if (!this._handlers[event]) this._handlers[event] = []; this._handlers[event].push(fn); return this; } /** * Emits an event to all registered handlers. * @private */ _emit(event, payload) { (this._handlers[event] || []).forEach(fn => fn(payload)); } /** * Initializes the component by fetching the first page of data. * This should be called after instantiation. */ init() { if (this.elements.filters) { this._updateFilters(); } return this.fetchData(1); } /** * Refreshes the data using the current filters and page. */ refresh() { // UPDATE: More descriptive name than init() for a refresh action. const currentPage = this.lastMeta?.current_page || 1; return this.fetchData(currentPage); } /** * Binds change/input events to filter elements. * @private */ _bindFilterEvents() { if (!this.elements.filters) return; this.elements.filters.addEventListener('input', e => { const target = e.target; if (target.matches('input, select')) { // UPDATE: Simplified debounce logic. clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { this._updateFilters(); this.fetchData(1); }, 300); } }); } /** * Reads the current values from the filter inputs. * @private */ _updateFilters() { this.filters = {}; if (!this.elements.filters) return; const formData = new FormData(this.elements.filters.tagName === 'FORM' ? this.elements.filters : undefined); if (this.elements.filters.tagName !== 'FORM') { this.elements.filters.querySelectorAll('input, select').forEach(input => { if (input.name) formData.append(input.name, input.value); }); } for (const [key, value] of formData.entries()) { if (value) this.filters[key] = value; } } /** * Constructs the query string from current filters and additional params. * @private */ _buildQueryString(page = 1) { let params = { ...this.filters, page }; if (typeof this.config.additionalParams === 'function') { params = { ...params, ...this.config.additionalParams() }; } return new URLSearchParams(Object.entries(params).filter(([, v]) => v !== '' && v != null)).toString(); } /** * Manages the visibility of elements based on the current state. * @private * @param {'loading'|'content'|'error'|'empty'} state - The state to display. * @param {string} [errorMessage] - An optional error message. */ _setState(state, errorMessage = 'An error occurred.') { const { container, loader, nothingFound, error } = this.elements; const all = [container, loader, nothingFound, error]; all.forEach(el => el?.classList.add('hidden')); if (state === 'loading' && loader) loader.classList.remove('hidden'); else if (state === 'content' && container) container.classList.remove('hidden'); else if (state === 'empty' && nothingFound) nothingFound.classList.remove('hidden'); else if (state === 'error' && error) { error.classList.remove('hidden'); const errorTextField = error.querySelector('.list-render-error-text') || error; errorTextField.textContent = errorMessage; } } /** * Fetches data from the API endpoint. * @param {number} [page=1] - The page number to fetch. */ async fetchData(page = 1) { this._setState('loading'); this._emit("start", { page }); const endpoint = `${this.url.split('?')[0]}?${this._buildQueryString(page)}`; try { const response = this.config.fetcher === 'axios' ? (await window.axios.get(endpoint)).data : await (await fetch(endpoint, { headers: { 'Accept': 'application/json' } })).json(); if (response.ok === false) throw response; // Handle API-level errors const meta = response[this.config.metaKey] || {}; this.lastMeta = meta; // Cache for refresh // Check for pre-rendered HTML in the response if (response.html !== undefined && response.html !== null) { // If API returns pre-rendered HTML, use it directly this.elements.container.innerHTML = response.html; this._renderPagination(meta); const hasContent = response.html.trim().length > 0; this._setState(hasContent ? 'content' : 'empty'); this._emit("rendered", { html: response.html, meta, page }); } else { // Otherwise, use the standard template rendering const data = response[this.config.dataKey] || []; this._renderBoxes(data); this._renderPagination(meta); this._setState(data.length > 0 ? 'content' : 'empty'); this._emit("rendered", { data, meta, page }); } } catch (err) { console.error('AjaxDivBox fetch error:', err); const message = err?.message || 'Failed to load data.'; this._setState('error', message); this._emit("error", { error: err, message }); } } /** * Renders the items into the container. * @private */ _renderBoxes(data) { if (!this.elements.container) return; this.elements.container.innerHTML = ''; // Clear previous content const fragment = document.createDocumentFragment(); data.forEach(item => { const itemHtml = this._renderTemplate(item); const tempDiv = document.createElement('div'); tempDiv.innerHTML = itemHtml; // Append all children from the temp div to the fragment while (tempDiv.firstChild) { fragment.appendChild(tempDiv.firstChild); } }); this.elements.container.appendChild(fragment); } /** * Renders a single item using either the custom onBox function or the template. * @private */ _renderTemplate(item) { // UPDATE: Prioritize per-item HTML if it exists. if (item.html !== undefined && item.html !== null) { return item.html; } if (typeof this.config.onBox === 'function') { return this.config.onBox(item); } const tpl = document.getElementById(this.config.templateId); if (!tpl) { console.error(`Template with id "${this.config.templateId}" not found.`); return ''; } let html = tpl.innerHTML; // Replace nested object properties (e.g., data.user.name) html = html.replace(/data\.([a-zA-Z0-9_]+\.[a-zA-Z0-9_]+)/g, (match, path) => { const keys = path.split('.'); let value = item; for (const key of keys) { value = value?.[key]; if (value === undefined) break; } return value ?? ''; }); // Replace simple properties (e.g., data.name) html = html.replace(/data\.([a-zA-Z0-9_]+)/g, (_, key) => item[key] ?? ''); return html; } /** * Renders pagination links based on metadata. * @private */ _renderPagination(meta) { const pagEl = this.elements.pagination; if (!pagEl) return; pagEl.innerHTML = ''; if (!meta?.links || meta.last_page <= 1) { pagEl.classList.add('hidden'); return; } pagEl.classList.remove('hidden'); const fragment = document.createDocumentFragment(); meta.links.forEach(link => { const pageNum = new URL(link.url || '', window.location.origin).searchParams.get('page'); if (link.label.includes('...')) { const span = document.createElement('span'); span.className = 'btn btn-disabled join-item'; span.textContent = '...'; fragment.appendChild(span); return; } const btn = document.createElement('button'); btn.className = `join-item btn ${link.active ? 'btn-active' : ''}`; btn.disabled = !link.url || link.active; btn.innerHTML = link.label.replace(/&laquo;|&raquo;/g, ''); if (link.url) { btn.onclick = (e) => { e.preventDefault(); this._emit("pageChange", { page: parseInt(pageNum), label: link.label }); this.fetchData(pageNum); }; } fragment.appendChild(btn); }); const joinDiv = document.createElement('div'); joinDiv.className = "join"; joinDiv.appendChild(fragment); pagEl.appendChild(joinDiv); } } class AjaxTable { constructor({ url, container, templateId = 'row-template', metaKey = 'meta', dataKey = 'data', fetcher = window.axios ? 'axios' : 'fetch', onRow = null, pagination = null, filterSelector = null, loadingIndicator = '.loading-table', nothingFoundBlock = '.nothing-found-table', errorBlock = '.table-render-error', additionalParams = null, autoInit = true, debounceTime = 300 }) { this.url = url; this.config = { templateId, metaKey, dataKey, fetcher, onRow, additionalParams, debounceTime }; this.filters = {}; this._handlers = {}; this.debounceTimer = null; const getEl = (sel) => typeof sel === 'string' ? document.querySelector(sel) : sel; this.elements = { container: getEl(container), table: getEl(container)?.querySelector('table'), tbody: getEl(container)?.querySelector('tbody'), pagination: getEl(pagination), filters: getEl(filterSelector), loader: getEl(loadingIndicator), nothingFound: getEl(nothingFoundBlock), error: getEl(errorBlock), }; if (!this.elements.table || !this.elements.tbody) { throw new Error('AjaxTable: Table or tbody element not found.'); } this._bindFilterEvents(); if (autoInit) this.init(); } on(event, fn) { if (!this._handlers[event]) this._handlers[event] = []; this._handlers[event].push(fn); return this; } _emit(event, payload) { (this._handlers[event] || []).forEach(fn => fn(payload)); } init() { if (this.elements.filters) this._updateFilters(); return this.fetchData(1); } refresh() { const currentPage = this.lastMeta?.current_page || 1; return this.fetchData(currentPage); } _bindFilterEvents() { if (!this.elements.filters) return; const handler = () => { clearTimeout(this.debounceTimer); this.debounceTimer = setTimeout(() => { this._updateFilters(); this.fetchData(1); }, this.config.debounceTime); }; this.elements.filters.querySelectorAll('input, select').forEach(input => { const type = input.tagName.toLowerCase(); if (type === 'input') { input.addEventListener('input', handler); } else { input.addEventListener('change', handler); } }); } _updateFilters() { this.filters = {}; const formData = new FormData(this.elements.filters.tagName === 'FORM' ? this.elements.filters : undefined); if (this.elements.filters.tagName !== 'FORM') { this.elements.filters.querySelectorAll('input, select').forEach(input => { if (input.name) formData.append(input.name, input.value); }); } for (const [key, value] of formData.entries()) { if (value) this.filters[key] = value; } } _buildQueryString(page = 1) { let params = { ...this.filters, page }; if (typeof this.config.additionalParams === 'function') { params = { ...params, ...this.config.additionalParams() }; } return new URLSearchParams(Object.entries(params).filter(([, v]) => v != null && v !== '')).toString(); } _setState(state, errorMessage = 'An error occurred.') { const { container, loader, nothingFound, error } = this.elements; const all = [container, loader, nothingFound, error]; all.forEach(el => el?.classList.add('hidden')); if (state === 'loading' && loader) loader.classList.remove('hidden'); else if (state === 'content' && container) container.classList.remove('hidden'); else if (state === 'empty' && nothingFound) nothingFound.classList.remove('hidden'); else if (state === 'error' && error) { error.classList.remove('hidden'); const errorTextField = error.querySelector('.table-render-error-text') || error; errorTextField.textContent = errorMessage; } } async fetchData(page = 1) { this._setState('loading'); this._emit('start', { page }); const pagEl = this.elements.pagination; if (pagEl) { pagEl.innerHTML = ''; pagEl.classList.add('hidden'); } const endpoint = `${this.url.split('?')[0]}?${this._buildQueryString(page)}`; try { const response = this.config.fetcher === 'axios' ? (await window.axios.get(endpoint)).data : await (await fetch(endpoint)).json(); const meta = response[this.config.metaKey] || {}; this.lastMeta = meta; const data = response[this.config.dataKey] || []; this._renderRows(data); this._renderPagination(meta); this._setState(data.length > 0 ? 'content' : 'empty'); this._emit('rendered', { data, meta, page }); } catch (err) { console.error('AjaxTable fetch error:', err); const msg = err?.message || 'Failed to load data.'; this._setState('error', msg); this._emit('error', { error: err, message: msg }); } } _renderRows(data) { if (!this.elements.tbody) return; this.elements.tbody.innerHTML = ''; const fragment = document.createDocumentFragment(); data.forEach(row => { const tr = document.createElement('tr'); tr.innerHTML = this._renderTemplate(row); fragment.appendChild(tr.children.length === 1 && tr.children[0].tagName === 'TR' ? tr.children[0] : tr); }); this.elements.tbody.appendChild(fragment); } _renderTemplate(row) { if (typeof this.config.onRow === 'function') return this.config.onRow(row); if (row.html) return row.html; const tpl = document.getElementById(this.config.templateId); if (!tpl) return ''; let html = tpl.innerHTML; html = html.replace(/data\.([a-zA-Z0-9_]+\.[a-zA-Z0-9_]+)/g, (_, path) => { const keys = path.split('.'); let value = row; for (const key of keys) { value = value?.[key]; if (value === undefined) break; } return value ?? ''; }); html = html.replace(/data\.([a-zA-Z0-9_]+)/g, (_, key) => row[key] ?? ''); return html; } _renderPagination(meta) { const pagEl = this.elements.pagination; if (!pagEl || !meta?.links || meta.last_page <= 1) { pagEl?.classList.add('hidden'); return; } pagEl.classList.remove('hidden'); pagEl.innerHTML = ''; const fragment = document.createDocumentFragment(); meta.links.forEach(link => { const pageNum = new URL(link.url || '', window.location.origin).searchParams.get('page'); const btn = document.createElement('button'); btn.className = `join-item btn ${link.active ? 'btn-active' : ''}`; btn.disabled = !link.url || link.active; btn.innerHTML = link.label.replace(/&laquo;|&raquo;/g, ''); if (link.url) { btn.onclick = e => { e.preventDefault(); this._emit('pageChange', { page: parseInt(pageNum), label: link.label }); this.fetchData(pageNum); }; } fragment.appendChild(link.label.includes('...') ? Object.assign(document.createElement('span'), { className: 'btn btn-disabled join-item', textContent: '...' }) : btn); }); const joinDiv = document.createElement('div'); joinDiv.className = 'join'; joinDiv.appendChild(fragment); pagEl.appendChild(joinDiv); } } class CodeInput { constructor(selector, hiddenName) { this.inputs = Array.from(document.querySelectorAll(`${selector}[data-id="${hiddenName}"]`)); this.hidden = document.querySelector(`input[type=hidden][name="${hiddenName}"]`); this._bindEvents(); this._updateHidden(); } _bindEvents() { this.inputs.forEach((inpt, idx) => { inpt.setAttribute('maxlength', 1); inpt.addEventListener('input', e => this._onInput(e, idx)); inpt.addEventListener('keydown', e => this._onKeyDown(e, idx)); inpt.addEventListener('paste', e => this._onPaste(e, idx)); }); } _onInput(e, idx) { const val = e.target.value.replace(/[^0-9]/g, '').charAt(0); e.target.value = val; this._updateHidden(); if (val && this.inputs[idx + 1]) { this.inputs[idx + 1].focus(); } } _onKeyDown(e, idx) { if (e.key === 'Backspace' && !e.target.value && this.inputs[idx - 1]) { this.inputs[idx - 1].focus(); } } _onPaste(e, idx) { e.preventDefault(); const paste = e.clipboardData.getData('text').trim().replace(/\s+/g, ''); const vals = paste.split('').slice(0, this.inputs.length - idx); vals.forEach((ch, i) => { this.inputs[idx + i].value = ch; }); this.inputs[Math.min(this.inputs.length - 1, idx + vals.length - 1)].focus(); this._updateHidden(); } _updateHidden() { const code = this.inputs.map(i => i.value || '').join(''); if (this.hidden) this.hidden.value = code; } } // DeleteContent.js class DeleteContent { /** * @param {string|NodeList|HTMLElement[]} selector Buttons selector, e.g. ".delete-btn" or a NodeList * @param {Object} options * @param {string} [options.confirmText] Modal header text * @param {string} [options.message] Confirmation message * @param {string} [options.successText] Success modal header * @param {string} [options.successMessage] Success description * @param {string} [options.errorText] Error modal header * @param {string} [options.errorMessage] Error description * @param {Function} [options.onDelete] Callback after delete: (success, data, button) => {} */ constructor(selector, options = {}) { this.options = Object.assign({ confirmText: "Are you sure?", message: "This action cannot be undone!", successText: "Deleted!", successMessage: "Your item has been successfully deleted.", errorText: "Error!", errorMessage: "Delete failed.", successTimeout: null, errorTimeout: null, }, options); this.buttons = typeof selector === 'string' ? document.querySelectorAll(selector) : selector; this._initModals(); this._bindEvents(); } // Initializes modals (creates them if they don't exist in the DOM) _initModals() { // Confirm Modal if (!document.getElementById('delete_modal')) { document.body.insertAdjacentHTML('beforeend', ` <dialog id="delete_modal" class="modal"> <div class="modal-box bg-red-800/90 text-white"> <h3 class="font-bold text-lg text-white flex items-center gap-2 justify-center"> <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg> <span id="delete_modal_title">${this.options.confirmText}</span> </h3> <p class="py-1 text-sm text-center" id="delete_modal_message">${this.options.message}</p> <div class="modal-action flex gap-2 justify-center"> <button class="btn btn-sm shadow-none" type="button" id="cancelDeleteBtn">Cancel</button> <button class="btn btn-sm btn-error shadow-none" type="button" id="confirmDeleteBtn">Delete</button> </div> </div> </dialog>`); } // Success Modal if (!document.getElementById('success_modal')) { document.body.insertAdjacentHTML('beforeend', ` <dialog id="success_modal" class="modal"> <div class="modal-box bg-green-800/90 text-white"> <div class="flex items-center justify-between gap-1 border-b border-gray-200 py-2"> <h3 class="text-lg font-bold"> <span id="success_modal_title">${this.options.successText}</span> </h3> <form method="dialog"> <button class="btn btn-xs btn-ghost rounded-full btn-circle" type="submit"> <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg></button> </form> </div> <p class="py-4" id="success_modal_message">${this.options.successMessage}</p> </div> </dialog>`); } // Error Modal if (!document.getElementById('error_modal')) { document.body.insertAdjacentHTML('beforeend', ` <dialog id="error_modal" class="modal"> <div class="modal-box bg-red-800/90 text-white"> <div class="flex items-center justify-between gap-1 border-b border-gray-200 py-2"> <h3 class="text-lg font-bold"> <span id="error_modal_title">${this.options.errorText}</span> </h3> <form method="dialog"> <button class="btn btn-xs btn-ghost rounded-full btn-circle" type="submit"> <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg></button> </form> </div> <p class="py-4" id="error_modal_message">${this.options.errorMessage}</p> </div> </dialog>`); } // Cache modal elements for later use this.deleteModal = document.getElementById('delete_modal'); this.successModal = document.getElementById('success_modal'); this.errorModal = document.getElementById('error_modal'); this.confirmBtn = document.getElementById('confirmDeleteBtn'); this.cancelBtn = document.getElementById('cancelDeleteBtn'); } _bindEvents() { // For keeping track of which button was clicked this.currentBtn = null; // Open the confirmation modal on delete button click this.buttons.forEach(btn => { btn.addEventListener('click', (e) => { e.preventDefault(); this.currentBtn = btn; document.getElementById('delete_modal_title').textContent = this.options.confirmText; document.getElementById('delete_modal_message').textContent = this.options.message; this.confirmBtn.disabled = false; this.cancelBtn.disabled = false; this.deleteModal.showModal(); }); }); // Cancel delete this.cancelBtn.addEventListener('click', () => { this.deleteModal.close(); this.currentBtn = null; }); // Confirm delete this.confirmBtn.addEventListener('click', async () => { if (!this.currentBtn) return; const url = this.currentBtn.dataset.href; // Disable both buttons to prevent multiple clicks this.confirmBtn.disabled = true; this.cancelBtn.disabled = true; try { let resp; if (window.axios) { resp = await axios.delete(url, { headers: {'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json'} }); if (resp.status === 200 || resp.status === 204) { this._showSuccess(); this._afterDelete(true, resp.data); } else throw resp; } else { const res = await fetch(url, { method: 'DELETE', headers: {'X-Requested-With': 'XMLHttpRequest', 'Accept': 'application/json'} }); if (res.ok) { this._showSuccess(); let data = await res.json().catch(() => ({})); this._afterDelete(true, data); } else { throw res; } } } catch (err) { this._showError(err); this._afterDelete(false, err); } finally { this.deleteModal.close(); this.currentBtn = null; // Always re-enable buttons when modal re-opens setTimeout(() => { this.confirmBtn.disabled = false; this.cancelBtn.disabled = false; }, 300); } }); } // Shows the success modal _showSuccess() { document.getElementById('success_modal_title').textContent = this.options.successText; document.getElementById('success_modal_message').textContent = this.options.successMessage; this.successModal.showModal(); // Only auto-close if timeout is set and > 0 if (this.options.successTimeout && this.options.successTimeout > 0) { setTimeout(() => { this.successModal.close(); }, this.options.successTimeout); } } _showError(err) { document.getElementById('error_modal_title').textContent = this.options.errorText; let msg = this.options.errorMessage; if (err && err.response && err.response.data && err.response.data.message) msg = err.response.data.message; document.getElementById('error_modal_message').textContent = msg; this.errorModal.showModal(); // Only auto-close if timeout is set and > 0 if (this.options.errorTimeout && this.options.errorTimeout > 0) { setTimeout(() => { this.errorModal.close(); }, this.options.errorTimeout); } } /** * Hook that fires after delete request completes * @param {boolean} success Was the delete successful? * @param {object} data Response data or error * @param {HTMLElement} button The button that triggered the delete */ _afterDelete(success, data) { if (typeof this.options.onDelete === 'function') { this.options.onDelete(success, data, this.currentBtn); } } } /** * EditContent.js * * Easily manage "edit" form workflows for CRUD apps. * - Fills form via AJAX (fetch, axios supported) * - Switches form to edit mode, changes button/title, reveals cancel button * - Handles cancel back to add mode * - Emits events/hooks for integration * * Example usage: * import EditContent from './EditContent'; * const edit = new EditContent({ * form: '#lineForm', * editButtonSelector: '.btn-edit-line', * cancelButtonSelector: '.edit-cancel', * submitButtonSelector: '#lineButton', * addTitle: 'Add New Line', * editTitle: 'Edit Line', * addButtonText: 'Save', * editButtonText: 'Edit', * onEditStart: (data) => {}, * onEditEnd: () => {}, * }); */ class EditContent { /** * @param {Object} opts * @param {string|HTMLElement} opts.form - Form selector or element * @param {string} opts.editButtonSelector - Selector for edit buttons * @param {string} opts.cancelButtonSelector - Selector for cancel button * @param {string} [opts.submitButtonSelector] - Selector for submit button (default: first [type=submit] in form) * @param {string} [opts.addTitle] - Default title in add mode * @param {string} [opts.editTitle] - Title in edit mode * @param {string} [opts.addButtonText] - Default button text in add mode * @param {string} [opts.editButtonText] - Button text in edit mode * @param {Function} [opts.onEditStart] - Hook after edit mode, receives loaded data * @param {Function} [opts.onEditEnd] - Hook after returning to add mode */ constructor(opts = {}) { // Config/DOM this.form = typeof opts.form === 'string' ? document.querySelector(opts.form) : opts.form; this.editButtonSelector = opts.editButtonSelector; this.cancelButton = document.querySelector(opts.cancelButtonSelector); this.submitButton = opts.submitButtonSelector ? document.querySelector(opts.submitButtonSelector) : this.form.querySelector('[type="submit"]'); this.buttonText = this.submitButton?.querySelector('.button-text') || this.submitButton; this.header = this.form.closest('.pcard')?.querySelector('.pcard-title'); this.addTitle = opts.addTitle || this.header?.dataset.addTitle || this.header?.textContent || 'Add'; this.editTitle = opts.editTitle || this.header?.dataset.editTitle || 'Edit'; this.addButtonText = opts.addButtonText || this.buttonText?.dataset.addTitle || this.buttonText?.textContent || 'Save'; this.editButtonText = opts.editButtonText || this.buttonText?.dataset.editTitle || 'Edit'; this.onEditStart = opts.onEditStart; this.onEditEnd = opts.onEditEnd; this.editMode = false; this._bindEditButtons(); this._bindCancelButton(); } /** Listen to all edit buttons for click */ _bindEditButtons() { document.body.addEventListener('click', async (e) => { const btn = e.target.closest(this.editButtonSelector); if (!btn) return; e.preventDefault(); const url = btn.dataset.href; if (!url) return; try { let res, data; if (window.axios) { res = await axios.get(url, { headers: { 'Accept': 'application/json' } }); data = res.data.data || res.data; } else { res = await fetch(url, { headers: { 'Accept': 'application/json' } }); data = (await res.json()).data || {}; } this._fillForm(data, { action: url, method: 'POST', title: this.editTitle, buttonText: this.editButtonText, }); if (this.cancelButton) this.cancelButton.classList.remove('hidden'); this.editMode = true; if (typeof this.onEditStart === 'function') this.onEditStart(data); } catch (err) { console.error(err); window.Toast ? new Toast({ message: "Cannot fetch item.", type: "error" }) : alert('Fetch error'); } }); } /** Cancel edit, reset form to add mode */ _bindCancelButton() { if (!this.cancelButton) return; this.cancelButton.addEventListener('click', () => this.resetToAddMode()); } /** * Fill the form with data, and update action/title/button text */ _fillForm(data = {}, config = {}) { ['input', 'textarea', 'select'].forEach(tag => { this.form.querySelectorAll(tag).forEach(input => { if (!input.name) return; if (input.type === "file") return; // Match title[en], description[tr] etc. let match = input.name.match(/^(\w+)\[(\w+)\]$/); if (match) { let [_, parent, locale] = match; // e.g. parent='title', locale='en' -> look for data.titles?.en let pluralKey = parent + 's'; // title -> titles if (data[pluralKey] && data[pluralKey][locale] !== undefined) { input.value = data[pluralKey][locale]; } // Fallback to e.g. data.title?.en else if (data[parent] && typeof data[parent] === 'object' && data[parent][locale] !== undefined) { input.value = data[parent][locale]; } } else if (data[input.name] !== undefined) { console.log(input.name, data[input.name]); input.value = data[input.name]; } }); }); if (config.action) this.form.action = config.action; if (config.method) this.form.setAttribute('data-method', config.method); if (this.header && config.title) this.header.textContent = config.title; if (this.buttonText && config.buttonText) this.buttonText.textContent = config.buttonText; this.form.setAttribute('data-edit-mode', this.editMode ? 'true' : 'false'); } /** Public: reset form and UI back to add mode */ resetToAddMode(defaults = {}) { this.form.reset(); this._fillForm(defaults, { action: this.form.dataset.action, method: 'POST', title: this.addTitle, buttonText: this.addButtonText, }); if (this.cancelButton) this.cancelButton.classList.add('hidden'); this.editMode = false; if (typeof this.onEditEnd === 'function') this.onEditEnd(); } } class FormSubmit { constructor(config = {}) { this.config = Object.assign({ method: 'axios', httpMethod: 'POST', formSelector: null, submitButtonSelector: null, action: null, successMessage: 'Operation completed successfully!', errorMessage: 'An error occurred.', useToast: typeof Toast !== 'undefined', disableOnSuccess: false, redirectUrl: null, beforeSubmit: null, afterSubmit: null, getUrl: null, buttonOnly: false, }, config); this.handlers = { success: [], error: [], beforeSubmit: [], afterSubmit: [] }; this.form = this._getFormElement(this.config.formSelector); this.submitButton = this._getSubmitButton(); this.isButtonOnly = !this.form || this.config.buttonOnly; if (!this.isButtonOnly && !this.form) throw new Error('Form element not found.'); if (!this.submitButton) throw new Error('Submit button not found.'); this.originalButtonText = this.submitButton.querySelector('.button-text')?.textContent || this.submitButton.textContent; this._setupEventListeners(); } on(event, fn) { if (this.handlers[event]) this.handlers[event].push(fn); return this; } _emit(event, payload) { (this.handlers[event] || []).forEach(fn => fn(payload)); } _getFormElement(sel) { if (!sel) return null; if (typeof sel === 'string') return document.querySelector(sel); if (sel instanceof HTMLFormElement) return sel; return null; } _getSubmitButton() { const sel = this.config.submitButtonSelector; if (!sel && this.form) return this.form.querySelector('[type="submit"]'); if (sel instanceof HTMLElement) return sel; if (typeof sel === 'string') return document.querySelector(sel); return null; } _setupEventListeners() { this.submitButton.addEventListener('click', e => { e.preventDefault(); this._clearErrors(); if (this.isButtonOnly) this._handleSubmit(); else this.form.dispatchEvent(new Event('submit', { cancelable: true })); }); if (this.form && !this.isButtonOnly) { this.form.addEventListener('submit', e => { e.preventDefault(); this._handleSubmit(); }); } } async _handleSubmit() { let formData = new FormData(this.isButtonOnly ? undefined : this.form); if (this.isButtonOnly && this.submitButton.dataset.data) { try { const buttonData = JSON.parse(this.submitButton.dataset.data); Object.entries(buttonData).forEach(([k, v]) => formData.append(k, v)); } catch (e) { console.warn('Invalid JSON in button data attribute:', e); } } const method = this.config.httpMethod.toUpperCase(); if (method !== 'POST' && !formData.has('_method')) formData.append('_method', method); const action = typeof this.config.getUrl === 'function' ? this.config.getUrl(this.submitButton) : (this.config.action || this.form?.action || window.location.href); try { if (typeof this.config.beforeSubmit === 'function') { const result = await this.config.beforeSubmit(formData, this.form, this.submitButton); if (result instanceof FormData) formData = result; else if (result === false) return; } this._emit('beforeSubmit', { formData, form: this.form, button: this.submitButton }); } catch (error) { console.error('Error in beforeSubmit hook:', error); return; } this._setLoading(true); let response, success = false; try { if (this.config.method === 'axios' && window.axios) { const resp = await axios({ url: action, method: method.toLowerCase(), data: formData }); response = resp.data ?? resp; } else if (this.config.method === 'xhr') { response = await this._sendWithXHR(action, formData, method); } else { response = await this._sendWithFetch(action, formData, method); } success = true; await this._handleSuccess(response); if (!this.config.disableOnSuccess) this._setLoading(false); } catch (error) { response = error; await this._handleError(error); this._setLoading(false); } try { if (typeof this.config.afterSubmit === 'function') { await this.config.afterSubmit(response, success, this.form, this.submitButton); } this._emit('afterSubmit', { response, success, form: this.form, button: this.submitButton }); } catch (error) { console.error('Error in afterSubmit hook:', error); } } async _sendWithFetch(url, fd, method) { const res = await fetch(url, { method, body: fd, headers: { 'Accept': 'application/json' } }); const data = await res.json().catch(() => ({})); if (!res.ok) throw { response: { data, status: res.status } }; return data; } _sendWithXHR(url, fd, method) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open(method, url); xhr.setRequestHeader('Accept', 'application/json'); xhr.onload = () => { try { const json = JSON.parse(xhr.responseText); (xhr.status >= 200 && xhr.status < 300) ? resolve(json) : reject({ response: { data: json, status: xhr.status } }); } catch (e) { reject({ message: 'Invalid JSON', error: e }); } }; xhr.onerror = () => reject(new Error('Network error')); xhr.send(fd); }); } _setLoading(loading) { if (!this.submitButton) return; const btnText = this.submitButton.querySelector('.button-text'); this.submitButton.disabled = loading; const loadingText = this.submitButton.dataset.loadingText || 'Loading…'; if (loading) { if (btnText) btnText.textContent = loadingText; else this.submitButton.textContent = loadingText; } else { const defaultText = btnText?.dataset.default || this.originalButtonText; if (btnText) btnText.textContent = defaultText; else this.submitButton.textContent = this.originalButtonText; } } async _handleSuccess(res) { const msg = res?.message || res?.data?.message || this.config.successMessage; const redirect = res?.redirect || res?.data?.redirect || this.config.redirectUrl; this._showMessage(msg, 'success'); this._emit('success', res); if (redirect) setTimeout(() => window.location.href = redirect, 1000); } async _handleError(err) { console.error(err); const d = err.response?.data || err; const msg = d?.message || this.config.errorMessage; if (d?.errors && this.form) { Object.entries(d.errors).forEach(([name, messages]) => { const inputName = name.includes('.') ? name.replace('.', '[') + ']' : name; const el = this.form.querySelector(`.form-error[data-input="${inputName}"]`); if (el) { el.textContent = Array.isArray(messages) ? messages.join(' ') : messages; el.classList.remove('hidden'); } }); } this._showMessage(msg, 'error'); this._emit('error', err); } _clearErrors() { if (!this.form) return; this.form.querySelectorAll('.form-error[data-input]').forEach(span => { span.classList.add('hidden'); span.textContent = ''; }); } _showMessage(msg, type) { if (this.config.useToast && window.Toast) { new Toast({ message: msg, type, duration: 4000, position: 'top-center-full' }); } else { alert(msg); } } /** PUBLIC API **/ reset() { if (!this.form) { console.warn('FormSubmit: Cannot reset without form.'); return; } this.form.reset(); this._clearErrors(); this._setLoading(false); } submit() { this._handleSubmit(); } getFormData() { if (!this.form) { console.warn('FormSubmit: No form available.'); return new FormData(); } return new FormData(this.form); } setFieldValue(name, value) { const el = this.form?.querySelector(`[name="${name}"]`); if (el) el.value = value; } getFieldValue(name) { const el = this.form?.querySelector(`[name="${name}"]`); return el ? el.value : null; } } // ImageInput.js // Usage: new ImageInput('#myFileInput') OR new ImageInput(document.querySelector('input[type="file"]')); /** * ImageInput - auto updates preview image on file select, restores on ESC/cancel. * @param {string|HTMLElement} selector - file input selector or element */ class ImageInput { /** * @param {string|HTMLElement|NodeList} input - Selector, Element, or NodeList of inputs */ constructor(input) { // Allow single or multiple if (typeof input === "string") { this.inputs = document.querySelectorAll(input); } else if (input instanceof HTMLElement) { this.inputs = [input]; } else if (input instanceof NodeList) { this.inputs = Array.from(input); } else { throw new Error('ImageInput: Invalid input selector/element.'); } this.inputs.forEach(fileInput => this.init(fileInput)); } init(fileInput) { const imgSel = fileInput.closest('.form-group')?.querySelector('[data-img-preview]') || (fileInput.dataset.imgPreview && document.querySelector(fileInput.dataset.imgPreview)); if (!imgSel) return; const img = imgSel; const originalSrc = img.getAttribute('data-src') || img.src; // File change: show preview fileInput.addEventListener('change', (e) => { if (fileInput.files && fil