diff --git a/css/includes/_includes.scss b/css/includes/_includes.scss index 2bae1c21c64..b4d0c75ba82 100644 --- a/css/includes/_includes.scss +++ b/css/includes/_includes.scss @@ -51,6 +51,7 @@ $is-dark: false !default; @import "components/form/form-destination"; @import "components/form/item-translations"; @import "components/form/helpdesk-home-config-for-empty-entity"; +@import "components/file-uploader"; @import "components/fuzzy"; @import "components/global-menu"; @import "components/illustration-picker"; diff --git a/css/includes/components/_file-uploader.scss b/css/includes/components/_file-uploader.scss new file mode 100644 index 00000000000..1982e190eb0 --- /dev/null +++ b/css/includes/components/_file-uploader.scss @@ -0,0 +1,119 @@ +/*! + * --------------------------------------------------------------------- + * + * GLPI - Gestionnaire Libre de Parc Informatique + * + * http://glpi-project.org + * + * @copyright 2015-2026 Teclib' and contributors. + * @licence https://www.gnu.org/licenses/gpl-3.0.html + * + * --------------------------------------------------------------------- + * + * LICENSE + * + * This file is part of GLPI. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * --------------------------------------------------------------------- + */ + +// Drop Zone +.file-uploader-dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 180px; + padding: 1.5rem; + border: 2px dashed var(--tblr-border-color); + border-radius: 0.5rem; + background-color: var(--tblr-bg-surface); + cursor: pointer; + transition: all 0.2s ease; + + &:hover, + &.dragging { + border-color: var(--tblr-primary); + background-color: rgb(var(--tblr-primary-rgb) / 5%); + + .file-uploader-dropzone-icon { + color: var(--tblr-primary); + transform: translateY(-4px); + } + } +} + +.file-uploader-dropzone-content { + text-align: center; +} + +.file-uploader-dropzone-icon { + font-size: 2.5rem; + color: var(--tblr-secondary); + margin-bottom: 0.75rem; + transition: all 0.2s ease; +} + +.file-uploader-dropzone-title { + font-size: 1rem; + font-weight: 500; + margin-bottom: 0.25rem; +} + +.file-uploader-dropzone-hint { + font-size: 0.8125rem; + margin-bottom: 0; +} + +// File Preview List +.file-uploader-preview { + max-height: 180px; + overflow-y: auto; +} + +.file-uploader-item { + background-color: var(--tblr-bg-surface); + + &:hover { + background-color: var(--tblr-bg-surface-secondary); + } + + .file-uploader-remove { + opacity: 0.5; + + &:hover { + opacity: 1; + } + } + + .min-width-0 { + min-width: 0; + } +} + +// Upload progress bar +.file-uploader-progress { + height: 4px; + background-color: var(--tblr-border-color); + border-radius: 2px; + overflow: hidden; + + .file-uploader-progress-bar { + height: 100%; + background-color: var(--tblr-primary); + transition: width 0.3s ease; + } +} diff --git a/css/includes/components/_kb.scss b/css/includes/components/_kb.scss index 07e12ac706f..70c388550cd 100644 --- a/css/includes/components/_kb.scss +++ b/css/includes/components/_kb.scss @@ -512,3 +512,91 @@ border-top: var(--tblr-border-width) solid var(--tblr-border-color); } } + +// Drop Zone for document upload +.kb-dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 180px; + padding: 1.5rem; + border: 2px dashed var(--tblr-border-color); + border-radius: 0.5rem; + background-color: var(--tblr-bg-surface); + cursor: pointer; + transition: all 0.2s ease; + + &:hover, + &.dragging { + border-color: var(--tblr-primary); + background-color: rgb(var(--tblr-primary-rgb) / 5%); + + .kb-dropzone-icon { + color: var(--tblr-primary); + transform: translateY(-4px); + } + } +} + +.kb-dropzone-content { + text-align: center; +} + +.kb-dropzone-icon { + font-size: 2.5rem; + color: var(--tblr-secondary); + margin-bottom: 0.75rem; + transition: all 0.2s ease; +} + +.kb-dropzone-title { + font-size: 1rem; + font-weight: 500; + margin-bottom: 0.25rem; +} + +.kb-dropzone-hint { + font-size: 0.8125rem; + margin-bottom: 0; +} + +// File Preview List +.kb-file-preview { + max-height: 180px; + overflow-y: auto; +} + +.kb-file-item { + background-color: var(--tblr-bg-surface); + + &:hover { + background-color: var(--tblr-bg-surface-secondary); + } + + .kb-file-remove { + opacity: 0.5; + + &:hover { + opacity: 1; + } + } + + .min-width-0 { + min-width: 0; + } +} + +// Upload progress bar +.kb-upload-progress { + height: 4px; + background-color: var(--tblr-border-color); + border-radius: 2px; + overflow: hidden; + + .kb-upload-progress-bar { + height: 100%; + background-color: var(--tblr-primary); + transition: width 0.3s ease; + } +} diff --git a/js/modules/FileUploader.js b/js/modules/FileUploader.js new file mode 100644 index 00000000000..45fa4ed234b --- /dev/null +++ b/js/modules/FileUploader.js @@ -0,0 +1,506 @@ +/** + * --------------------------------------------------------------------- + * + * GLPI - Gestionnaire Libre de Parc Informatique + * + * http://glpi-project.org + * + * @copyright 2015-2026 Teclib' and contributors. + * @licence https://www.gnu.org/licenses/gpl-3.0.html + * + * --------------------------------------------------------------------- + * + * LICENSE + * + * This file is part of GLPI. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * --------------------------------------------------------------------- + */ + +/* global _, getAjaxCsrfToken, glpi_toast_error, uniqid */ + +/** + * Generic file uploader with drag & drop support. + * + * Handles file selection, validation, immediate upload to the server, + * progress tracking and file list rendering. Does not handle document + * creation or any domain-specific logic. + * + * Emits a `file-uploader:change` CustomEvent on the container whenever + * the internal state changes (file added, removed, upload complete, error). + * + * Elements are discovered inside the container via data attributes: + * - data-glpi-file-uploader-dropzone + * - data-glpi-file-uploader-input + * - data-glpi-file-uploader-preview + * - data-glpi-file-uploader-list + */ +export class FileUploader +{ + /** @type {HTMLElement} */ + #container; + + /** @type {HTMLElement} */ + #dropZone; + + /** @type {HTMLInputElement} */ + #fileInput; + + /** @type {HTMLElement} */ + #previewContainer; + + /** @type {HTMLElement} */ + #listContainer; + + /** @type {{ file: File, status: string, result: Object|null, error: string|null, xhr: XMLHttpRequest|null }[]} */ + #fileEntries = []; + + /** @type {AbortController} */ + #abortController; + + /** + * @param {HTMLElement} container - Root element containing the uploader markup + * @param {Object} options + * @param {number} options.maxFileSize - Maximum file size in MB (default: CFG_GLPI.document_max_size || 50) + */ + constructor(container, options = {}) + { + this.#container = container; + this.#dropZone = container.querySelector('[data-glpi-file-uploader-dropzone]'); + this.#fileInput = container.querySelector('[data-glpi-file-uploader-input]'); + this.#previewContainer = container.querySelector('[data-glpi-file-uploader-preview]'); + this.#listContainer = container.querySelector('[data-glpi-file-uploader-list]'); + this.#abortController = new AbortController(); + + this.maxFileSize = (options.maxFileSize ?? CFG_GLPI?.document_max_size ?? 50) * 1024 * 1024; + + if (!this.#dropZone || !this.#fileInput) { + throw new Error('FileUploader: Required elements not found (dropzone or input)'); + } + + this.#bindEvents(); + } + + /** + * @returns {{ file: File, status: string, result: Object|null }[]} + */ + getSuccessfulEntries() + { + return this.#fileEntries.filter(e => e.status === 'success'); + } + + /** + * @returns {boolean} + */ + isUploading() + { + return this.#fileEntries.some(e => e.status === 'uploading' || e.status === 'pending'); + } + + /** + * @returns {boolean} + */ + hasSuccessfulUploads() + { + return this.#fileEntries.some(e => e.status === 'success'); + } + + reset() + { + for (const entry of this.#fileEntries) { + if (entry.xhr) { + entry.xhr.abort(); + } + } + + this.#fileEntries = []; + this.#renderFileList(); + this.#emitChange(); + } + + destroy() + { + this.reset(); + this.#abortController.abort(); + } + + #bindEvents() + { + const signal = this.#abortController.signal; + + // Drag & Drop + this.#dropZone.addEventListener('dragenter', (e) => this.#onDragEnter(e), { signal }); + this.#dropZone.addEventListener('dragover', (e) => this.#onDragOver(e), { signal }); + this.#dropZone.addEventListener('dragleave', (e) => this.#onDragLeave(e), { signal }); + this.#dropZone.addEventListener('drop', (e) => this.#onDrop(e), { signal }); + + // File input + this.#fileInput.addEventListener('change', (e) => { + this.#addFiles([...e.target.files]); + e.target.value = ''; + }, { signal }); + + // Delegate remove button clicks + if (this.#previewContainer) { + this.#previewContainer.addEventListener('click', (e) => { + const removeBtn = e.target.closest('.file-uploader-remove'); + if (removeBtn) { + const index = parseInt(removeBtn.dataset.index, 10); + this.#removeFile(index); + } + }, { signal }); + } + } + + #onDragEnter(e) + { + e.preventDefault(); + this.#dropZone.classList.add('dragging'); + } + + #onDragOver(e) + { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + } + + #onDragLeave(e) + { + if (!this.#dropZone.contains(e.relatedTarget)) { + this.#dropZone.classList.remove('dragging'); + } + } + + #onDrop(e) + { + e.preventDefault(); + this.#dropZone.classList.remove('dragging'); + this.#addFiles([...e.dataTransfer.files]); + } + + /** + * @param {File[]} newFiles + */ + #addFiles(newFiles) + { + const validFiles = newFiles.filter(file => this.#validateFile(file)); + const startIndex = this.#fileEntries.length; + + for (const file of validFiles) { + this.#fileEntries.push({ + file, + status: 'pending', + result: null, + error: null, + xhr: null, + }); + } + + this.#renderFileList(); + this.#emitChange(); + + for (let i = startIndex; i < this.#fileEntries.length; i++) { + this.#uploadSingleFile(i); + } + } + + /** + * @param {File} file + * @returns {boolean} + */ + #validateFile(file) + { + if (file.size > this.maxFileSize) { + glpi_toast_error(__('File %s exceeds maximum size').replace('%s', file.name)); + return false; + } + + if (file.size === 0) { + glpi_toast_error(__('File %s is empty').replace('%s', file.name)); + return false; + } + + return true; + } + + /** + * @param {number} index + */ + #removeFile(index) + { + const entry = this.#fileEntries[index]; + + if (entry?.xhr) { + entry.xhr.abort(); + } + + this.#fileEntries.splice(index, 1); + this.#renderFileList(); + this.#emitChange(); + } + + /** + * @param {number} index + */ + #uploadSingleFile(index) + { + const entry = this.#fileEntries[index]; + const file = entry.file; + + entry.status = 'uploading'; + + const xhr = new XMLHttpRequest(); + entry.xhr = xhr; + + const formData = new FormData(); + const uniquePrefix = uniqid('', true); + const uploadName = uniquePrefix + file.name; + const renamedFile = new File([file], uploadName, { type: file.type }); + + formData.append('name', 'filename'); + formData.append('filename[]', renamedFile); + + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percent = Math.round((e.loaded / e.total) * 100); + this.#updateFileProgress(index, percent); + } + }); + + xhr.addEventListener('load', () => { + entry.xhr = null; + + if (xhr.status >= 200 && xhr.status < 300) { + try { + const response = JSON.parse(xhr.responseText); + const fileData = response.filename?.[0]; + + if (fileData?.error) { + entry.status = 'error'; + entry.error = fileData.error; + this.#updateFileProgress(index, 0, 'error'); + this.#showFileError(index, fileData.error); + glpi_toast_error(`${file.name}: ${fileData.error}`); + } else if (fileData) { + entry.status = 'success'; + entry.result = fileData; + this.#updateFileProgress(index, 100, 'success'); + } else { + entry.status = 'error'; + entry.error = __('Invalid server response'); + this.#updateFileProgress(index, 0, 'error'); + this.#showFileError(index, entry.error); + } + } catch { + entry.status = 'error'; + entry.error = __('Invalid server response'); + this.#updateFileProgress(index, 0, 'error'); + this.#showFileError(index, entry.error); + } + } else { + entry.status = 'error'; + entry.error = __('Upload failed'); + this.#updateFileProgress(index, 0, 'error'); + this.#showFileError(index, entry.error); + glpi_toast_error(`${file.name}: ${__('Upload failed')}`); + } + + this.#emitChange(); + }); + + xhr.addEventListener('error', () => { + entry.xhr = null; + entry.status = 'error'; + entry.error = __('Upload failed'); + this.#updateFileProgress(index, 0, 'error'); + this.#showFileError(index, entry.error); + glpi_toast_error(`${file.name}: ${__('Upload failed')}`); + this.#emitChange(); + }); + + xhr.open('POST', `${CFG_GLPI.root_doc}/ajax/fileupload.php`); + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + xhr.setRequestHeader('X-Glpi-Csrf-Token', getAjaxCsrfToken()); + xhr.send(formData); + } + + #renderFileList() + { + if (!this.#previewContainer || !this.#listContainer) { + return; + } + + if (this.#fileEntries.length === 0) { + this.#previewContainer.classList.add('d-none'); + this.#listContainer.innerHTML = ''; + return; + } + + this.#previewContainer.classList.remove('d-none'); + this.#listContainer.innerHTML = this.#fileEntries.map((entry, index) => { + const file = entry.file; + const isError = entry.status === 'error'; + const isSuccess = entry.status === 'success'; + const errorClass = isError ? 'border-danger bg-danger bg-opacity-10' : ''; + const statusIcon = isSuccess + ? '' + : ''; + + return ` +
+ +
+
${_.escape(file.name)}
+ ${_.escape(this.#formatFileSize(file.size))} + ${isError ? `
${_.escape(entry.error)}
` : ''} +
+ ${statusIcon} + +
+ `; + }).join(''); + } + + /** + * @param {number} index + * @param {string} message + */ + #showFileError(index, message) + { + if (!this.#previewContainer) { + return; + } + + const fileItem = this.#previewContainer.querySelector(`[data-file-index="${CSS.escape(index)}"]`); + if (!fileItem || fileItem.querySelector('.text-danger')) { + return; + } + + const errorDiv = document.createElement('div'); + errorDiv.className = 'text-danger small mt-1'; + errorDiv.textContent = message; + fileItem.querySelector('.flex-grow-1').appendChild(errorDiv); + + fileItem.classList.add('border-danger', 'bg-danger', 'bg-opacity-10'); + } + + /** + * @param {number} index + * @param {number} percent + * @param {string} status + */ + #updateFileProgress(index, percent, status = 'uploading') + { + if (!this.#previewContainer) { + return; + } + + const fileItem = this.#previewContainer.querySelector(`[data-file-index="${CSS.escape(index)}"]`); + if (!fileItem) { + return; + } + + let progressBar = fileItem.querySelector('.file-uploader-progress'); + + if (!progressBar) { + progressBar = document.createElement('div'); + progressBar.className = 'file-uploader-progress mt-1'; + progressBar.innerHTML = '
'; + fileItem.querySelector('.flex-grow-1').appendChild(progressBar); + } + + const bar = progressBar.querySelector('.file-uploader-progress-bar'); + bar.style.width = `${percent}%`; + bar.classList.remove('bg-success', 'bg-danger'); + + if (status === 'success') { + bar.classList.add('bg-success'); + } else if (status === 'error') { + bar.classList.add('bg-danger'); + } + } + + #emitChange() + { + this.#container.dispatchEvent(new CustomEvent('file-uploader:change', { + bubbles: true, + detail: { + hasSuccessful: this.hasSuccessfulUploads(), + isUploading: this.isUploading(), + count: this.#fileEntries.length, + }, + })); + } + + /** + * @param {string} filename + * @returns {string} + */ + #getFileIcon(filename) + { + const ext = filename.split('.').pop()?.toLowerCase() || ''; + const iconMap = { + 'pdf': 'ti-file-type-pdf', + 'doc': 'ti-file-type-doc', + 'docx': 'ti-file-type-docx', + 'xls': 'ti-file-type-xls', + 'xlsx': 'ti-file-type-xls', + 'ppt': 'ti-file-type-ppt', + 'pptx': 'ti-file-type-ppt', + 'zip': 'ti-file-zip', + 'rar': 'ti-file-zip', + '7z': 'ti-file-zip', + 'tar': 'ti-file-zip', + 'gz': 'ti-file-zip', + 'jpg': 'ti-photo', + 'jpeg': 'ti-photo', + 'png': 'ti-photo', + 'gif': 'ti-photo', + 'svg': 'ti-photo', + 'webp': 'ti-photo', + 'txt': 'ti-file-text', + 'md': 'ti-markdown', + 'csv': 'ti-file-spreadsheet', + 'json': 'ti-file-code', + 'xml': 'ti-file-code', + 'html': 'ti-file-code', + 'css': 'ti-file-code', + 'js': 'ti-file-code', + }; + + return iconMap[ext] || 'ti-file'; + } + + /** + * @param {number} bytes + * @returns {string} + */ + #formatFileSize(bytes) + { + if (bytes === 0) { + return '0 B'; + } + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; + } +} diff --git a/js/modules/Knowbase/UploadController.js b/js/modules/Knowbase/UploadController.js new file mode 100644 index 00000000000..add452b66ce --- /dev/null +++ b/js/modules/Knowbase/UploadController.js @@ -0,0 +1,236 @@ +/** + * --------------------------------------------------------------------- + * + * GLPI - Gestionnaire Libre de Parc Informatique + * + * http://glpi-project.org + * + * @copyright 2015-2026 Teclib' and contributors. + * @licence https://www.gnu.org/licenses/gpl-3.0.html + * + * --------------------------------------------------------------------- + * + * LICENSE + * + * This file is part of GLPI. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * --------------------------------------------------------------------- + */ + +/* global getAjaxCsrfToken, bootstrap, glpi_toast_error, glpi_toast_info */ + +import { FileUploader } from '/js/modules/FileUploader.js'; + +/** + * KB-specific document upload controller. + * + * Wraps the generic FileUploader and adds document creation logic, + * modal lifecycle management, and page reload on success. + */ +export class DocumentUploadController +{ + /** @type {HTMLElement} */ + #container; + + /** @type {FileUploader} */ + #uploader; + + /** @type {HTMLElement|null} */ + #modal; + + /** @type {HTMLFormElement} */ + #form; + + /** @type {HTMLButtonElement} */ + #uploadBtn; + + /** + * @param {HTMLElement} container - Form container (tab pane) + * @param {HTMLElement|null} modal - Modal element (optional) + */ + constructor(container, modal = null) + { + this.#container = container; + this.#modal = modal; + this.#form = container.querySelector('form'); + this.#uploadBtn = container.querySelector('[data-glpi-kb-upload-submit]'); + + if (!this.#form) { + throw new Error('DocumentUploadController: form element not found'); + } + + this.#uploader = new FileUploader(container); + + this.#bindEvents(); + } + + #bindEvents() + { + // Update submit button state when uploader state changes + this.#container.addEventListener('file-uploader:change', () => { + this.#updateUploadButton(); + }); + + // Form submission + this.#form.addEventListener('submit', (e) => this.#onSubmit(e)); + + // Modal lifecycle + if (this.#modal) { + this.#modal.addEventListener('hide.bs.modal', () => { + if (document.activeElement && this.#modal.contains(document.activeElement)) { + document.activeElement.blur(); + } + }); + + this.#modal.addEventListener('hidden.bs.modal', () => { + this.#uploader.reset(); + const descField = this.#form.querySelector('#kb-document-description'); + if (descField) { + descField.value = ''; + } + }); + } + } + + #updateUploadButton() + { + if (!this.#uploadBtn) { + return; + } + + this.#uploadBtn.disabled = this.#uploader.isUploading() + || !this.#uploader.hasSuccessfulUploads(); + } + + /** + * @param {Event} e + */ + async #onSubmit(e) + { + e.preventDefault(); + + const successEntries = this.#uploader.getSuccessfulEntries(); + if (successEntries.length === 0) { + return; + } + + this.#setLoading(true); + + try { + await this.#createDocuments(successEntries); + this.#onSuccess(); + } catch (error) { + console.error('Document creation failed:', error); + glpi_toast_error(__('Upload failed: %s').replace('%s', error.message)); + } finally { + this.#setLoading(false); + } + } + + /** + * @param {boolean} loading + */ + #setLoading(loading) + { + if (!this.#uploadBtn) { + return; + } + + if (loading) { + this.#uploadBtn.disabled = true; + this.#uploadBtn.dataset.originalHtml = this.#uploadBtn.innerHTML; + this.#uploadBtn.innerHTML = `${__('Uploading...')}`; + } else { + this.#updateUploadButton(); + if (this.#uploadBtn.dataset.originalHtml) { + this.#uploadBtn.innerHTML = this.#uploadBtn.dataset.originalHtml; + } + } + } + + /** + * @param {{ file: File, status: string, result: Object|null }[]} successEntries + */ + async #createDocuments(successEntries) + { + const description = this.#form.querySelector('#kb-document-description')?.value || ''; + const itemtype = this.#form.querySelector('[name="itemtype"]')?.value; + const items_id = this.#form.querySelector('[name="items_id"]')?.value; + + for (const entry of successEntries) { + const uploadedFile = entry.result; + const formData = new FormData(); + const fullTempName = uploadedFile.name || ''; + const displayName = uploadedFile.display || ''; + const filePrefix = uploadedFile.prefix || ''; + const fileTag = uploadedFile.id || ''; + + formData.append('_filename[0]', fullTempName); + formData.append('_prefix_filename[0]', filePrefix); + formData.append('_tag_filename[0]', fileTag); + formData.append('name', displayName.replace(/\.[^.]+$/, '')); + formData.append('comment', description); + + if (itemtype && items_id) { + formData.append('itemtype', itemtype); + formData.append('items_id', items_id); + } + + formData.append('add', '1'); + + const response = await fetch( + `${CFG_GLPI.root_doc}/front/document.form.php`, + { + method: 'POST', + body: formData, + headers: { + 'X-Requested-With': 'XMLHttpRequest', + 'X-Glpi-Csrf-Token': getAjaxCsrfToken(), + }, + } + ); + + if (!response.ok) { + throw new Error(`Failed to create document for ${displayName}`); + } + } + } + + #onSuccess() + { + const count = this.#uploader.getSuccessfulEntries().length; + + if (this.#modal) { + const modalInstance = bootstrap.Modal.getInstance(this.#modal); + if (modalInstance) { + modalInstance.hide(); + } + } + + glpi_toast_info( + count === 1 + ? __('Document uploaded successfully') + : __('%d documents uploaded successfully').replace('%d', count) + ); + + this.#container.dispatchEvent(new CustomEvent('documents:uploaded', { + bubbles: true, + detail: { count: count } + })); + + window.location.reload(); + } +} diff --git a/templates/components/form/file_uploader.html.twig b/templates/components/form/file_uploader.html.twig new file mode 100644 index 00000000000..3d4c60497ed --- /dev/null +++ b/templates/components/form/file_uploader.html.twig @@ -0,0 +1,58 @@ +{# + # --------------------------------------------------------------------- + # + # GLPI - Gestionnaire Libre de Parc Informatique + # + # http://glpi-project.org + # + # @copyright 2015-2026 Teclib' and contributors. + # @licence https://www.gnu.org/licenses/gpl-3.0.html + # + # --------------------------------------------------------------------- + # + # LICENSE + # + # This file is part of GLPI. + # + # This program is free software: you can redistribute it and/or modify + # it under the terms of the GNU General Public License as published by + # the Free Software Foundation, either version 3 of the License, or + # (at your option) any later version. + # + # This program is distributed in the hope that it will be useful, + # but WITHOUT ANY WARRANTY; without even the implied warranty of + # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + # GNU General Public License for more details. + # + # You should have received a copy of the GNU General Public License + # along with this program. If not, see . + # + # --------------------------------------------------------------------- + #} + + + +
+
+
diff --git a/templates/pages/tools/kb/article.html.twig b/templates/pages/tools/kb/article.html.twig index 457b0117c38..638a576515e 100644 --- a/templates/pages/tools/kb/article.html.twig +++ b/templates/pages/tools/kb/article.html.twig @@ -278,14 +278,43 @@