From 77fd282e29c04fe53169e138d26355a764c71dc0 Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Fri, 8 May 2026 17:31:17 +0200 Subject: [PATCH] refactor: unify edit mode Format+Fichiers with add/partage HTMX fragment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Edit mode now uses the same fichiers-fragment.php as add and partage, instead of duplicating the format checkboxes + new-file upload + website URL fieldsets. - Edit-only elements (existing files list, cover replace) stay in a separate #edit-existing-files-block below the shared fragment. - Removed .zip/.tar/.gz from the main TFE upload accept in both fichiers-fragment.php and fieldset-files.php. Archives go only in the Annexes file input. - Removed admin/format-website-fragment.php dependency from edit (no longer needed — the shared fragment handles website too). fix: jury repop crash + hx-preserve on file inputs, remove zip/tar from tfe accept - Jury fieldset add-mode repopulation now handles both scalar (legacy) and array (new dynamic multi-row) values for jury_promoteur and jury_promoteur_ulb_name. htmlspecialchars() was choking on array value. - All file inputs in fichiers-fragment.php wrapped in hx-preserve containers so HTMX swaps don't wipe user-selected files when toggling formats or the annexes checkbox. - Removed .zip/.tar/.gz from main TFE file accept — archives only via annexes input (which already had multiple + correct accept). - Edit mode now reuses the same fichiers-fragment.php fragment. fix: file inputs re-initialize after HTMX swap via inline script - Exposed window.XamxamInitFileUploads from file-upload-queue.js IIFE so HTMX fragments can trigger re-binding without a global listener. - fichiers-fragment.php emits at the end of the #format-fichiers-block fragment. - Removed hx-preserve wrappers — they prevented re-render after format/annexes toggles changed visible inputs. - This also fixes .zip removal from TFE accept and jury repopulation array crash from the previous commit. refactor: simplify file-upload-queue.js, remove file-preview.js - file-upload-queue.js rewritten from ~250 lines to ~120 lines: no more DataTransfer machinery, no IIFE wrapper, uses .onchange instead of addEventListener for simpler HTMX re-init. - window.XamxamInitFileUploads is the function itself (not an IIFE export). - Merged file-preview.js functionality into file-upload-queue.js (single-file .data-preview handling). Deleted file-preview.js. - fichiers-fragment.php inline script calls XamxamInitFileUploads() after every HTMX swap (same as before). debug: add console.log to file-upload-queue.js for file input behavior Adds logging at key points to diagnose why only one file is displayed: - XamxamInitFileUploads called - TFE queue picker init (id, multiple attribute state) - onchange event (files count, names) - fileArray post-concat length - Single-file preview bindings (id, multiple attribute) Remove after debug session. --- app/public/assets/css/admin.css | 2 - app/public/assets/css/apropos.css | 3 +- app/public/assets/css/common.css | 6 + app/public/assets/css/form.css | 2 - app/public/assets/css/tfe.css | 1 - app/public/assets/js/file-preview.js | 94 ----- app/public/assets/js/file-upload-queue.js | 336 ++++++------------ app/public/partage/fichiers-fragment.php | 11 +- app/templates/admin/acces.php | 29 ++ app/templates/partials/form/jury-fieldset.php | 26 +- 10 files changed, 173 insertions(+), 337 deletions(-) delete mode 100644 app/public/assets/js/file-preview.js diff --git a/app/public/assets/css/admin.css b/app/public/assets/css/admin.css index d68267c..f14e2a0 100644 --- a/app/public/assets/css/admin.css +++ b/app/public/assets/css/admin.css @@ -56,9 +56,7 @@ min-height: 0; overflow-y: auto; padding: var(--space-l) var(--space-l) var(--space-2xl); - max-width: 1100px; width: 100%; - margin-inline: auto; } .admin-body main > h1, diff --git a/app/public/assets/css/apropos.css b/app/public/assets/css/apropos.css index f71b938..b1beb6c 100644 --- a/app/public/assets/css/apropos.css +++ b/app/public/assets/css/apropos.css @@ -23,8 +23,7 @@ display: grid; grid-template-columns: 180px 1fr; gap: var(--space-2xl); - max-width: 860px; - margin: 0 auto; + width: 100%; align-items: start; } diff --git a/app/public/assets/css/common.css b/app/public/assets/css/common.css index 3055591..93ffced 100644 --- a/app/public/assets/css/common.css +++ b/app/public/assets/css/common.css @@ -292,6 +292,12 @@ header { main { flex: 1; min-height: 0; + overflow-wrap: anywhere; +} + +main * { + overflow-wrap: anywhere; + word-break: break-word; } /* ============================================================ diff --git a/app/public/assets/css/form.css b/app/public/assets/css/form.css index a7845dd..c544689 100644 --- a/app/public/assets/css/form.css +++ b/app/public/assets/css/form.css @@ -416,10 +416,8 @@ } .student-body main { - max-width: 720px; padding: var(--space-l) var(--space-l) var(--space-2xl); width: 100%; - margin-inline: auto; } /* ── Share-link error page ──────────────────────────────────────────────── */ diff --git a/app/public/assets/css/tfe.css b/app/public/assets/css/tfe.css index 1e35e4e..7f1df7c 100644 --- a/app/public/assets/css/tfe.css +++ b/app/public/assets/css/tfe.css @@ -17,7 +17,6 @@ grid-template-columns: 1fr 1.4fr; gap: var(--space-xl); width: 100%; - max-width: 1200px; align-items: start; } diff --git a/app/public/assets/js/file-preview.js b/app/public/assets/js/file-preview.js deleted file mode 100644 index f9326ea..0000000 --- a/app/public/assets/js/file-preview.js +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Live file-input preview. - * For every found on the page, - * renders a list of selected files with thumbnails (images) or file-type icons - * (PDFs, videos, archives…) and the filename + size. - */ -(() => { - const ICON = { - pdf: '📄', - video: '🎬', - zip: '🗜️', - vtt: '💬', - image: '🖼️', - other: '📎', - }; - - function iconFor(file) { - const t = file.type; - if (t.startsWith('image/')) return ICON.image; - if (t === 'application/pdf') return ICON.pdf; - if (t.startsWith('video/')) return ICON.video; - if (t === 'application/zip' || t === 'application/x-zip-compressed') return ICON.zip; - if (file.name.endsWith('.vtt')) return ICON.vtt; - return ICON.other; - } - - function humanSize(bytes) { - if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(2)} GB`; - if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(2)} MB`; - if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${bytes} B`; - } - - function renderPreview(input, container) { - container.innerHTML = ''; - const files = Array.from(input.files); - if (!files.length) return; - - files.forEach((file) => { - const item = document.createElement('div'); - item.className = 'fp-item'; - - if (file.type.startsWith('image/')) { - const img = document.createElement('img'); - img.className = 'fp-thumb'; - img.alt = file.name; - const reader = new FileReader(); - reader.onload = (e) => { img.src = e.target.result; }; - reader.readAsDataURL(file); - item.appendChild(img); - } else { - const icon = document.createElement('span'); - icon.className = 'fp-icon'; - icon.textContent = iconFor(file); - item.appendChild(icon); - } - - const meta = document.createElement('span'); - meta.className = 'fp-meta'; - meta.innerHTML = - '' + escHtml(file.name) + '' + - '' + humanSize(file.size) + ''; - item.appendChild(meta); - - container.appendChild(item); - }); - } - - function escHtml(str) { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"'); - } - - function init() { - document.querySelectorAll('input[type="file"][data-preview]').forEach((input) => { - const containerId = input.getAttribute('data-preview'); - const container = document.getElementById(containerId); - if (!container) return; - - input.addEventListener('change', () => { - renderPreview(input, container); - }); - }); - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } -})(); diff --git a/app/public/assets/js/file-upload-queue.js b/app/public/assets/js/file-upload-queue.js index 34b35d6..1d67293 100644 --- a/app/public/assets/js/file-upload-queue.js +++ b/app/public/assets/js/file-upload-queue.js @@ -1,274 +1,148 @@ /** * file-upload-queue.js * - * Powers two UI features: + * Renders visual file queues for: + * 1. #tfe-files-input — multi-file upload with drag-to-reorder (SortableJS) + * and per-file label inputs. Injects hidden file_labels[] / file_orders[]. + * 2. input[data-preview] — single-file previews (couverture, note_intention, etc.) + * 3. #existing-files-sortable — edit-mode sortable list * - * 1. TFE multi-file upload queue (#tfe-file-queue) - * - Renders each selected file as a sortable row with icon, name, size - * and an optional label input. - * - Drag-to-reorder via SortableJS. - * - Injects hidden `file_labels[]` and `file_orders[]` inputs so PHP - * receives per-file label and intended sort-order data. - * - Works for both the add/partage form (pure new uploads) and the edit - * form (new uploads only; existing-file sort is handled server-side). - * - * 2. Legacy single-file previews (data-preview="CONTAINER_ID") - * - Backward-compatible with cover-image and banner inputs. + * Exposes window.XamxamInitFileUploads() so HTMX fragments can re-bind + * after swap without a global event listener. */ -(() => { - /* ── Helpers ──────────────────────────────────────────────────────────── */ - - const ICONS = { - pdf: '📄', - video: '🎬', - audio: '🔊', - zip: '🗜️', - vtt: '💬', - image: '🖼️', - other: '📎', +window.XamxamInitFileUploads = function () { + console.log('[file-upload-queue] XamxamInitFileUploads called'); + var ICON = { + pdf: '\uD83D\uDCC4', video: '\uD83C\uDFAC', audio: '\uD83D\uDD0A', + zip: '\uD83D\uDDDC\uFE0F', vtt: '\uD83D\uDCAC', image: '\uD83D\uDDBC\uFE0F', other: '\uD83D\uDCCE' }; function iconFor(file) { - const t = file.type || ''; - const n = file.name.toLowerCase(); - if (t.startsWith('image/')) return ICONS.image; - if (t === 'application/pdf' || n.endsWith('.pdf')) return ICONS.pdf; - if (t.startsWith('video/') || /\.(mp4|webm|mov|ogv)$/.test(n)) return ICONS.video; - if (t.startsWith('audio/') || /\.(mp3|ogg|oga|wav|flac|aac|m4a)$/.test(n)) return ICONS.audio; - if (t === 'application/zip' || /\.(zip|tar|gz|tgz)$/.test(n)) return ICONS.zip; - if (n.endsWith('.vtt')) return ICONS.vtt; - return ICONS.other; + var t = file.type || '', n = file.name.toLowerCase(); + if (/^image\//.test(t)) return ICON.image; + if (t === 'application/pdf' || /\.pdf$/.test(n)) return ICON.pdf; + if (/^video\//.test(t) || /\.(mp4|webm|mov|ogv)$/.test(n)) return ICON.video; + if (/^audio\//.test(t) || /\.(mp3|ogg|oga|wav|flac|aac|m4a)$/.test(n)) return ICON.audio; + if (/\.(zip|tar|gz|tgz)$/.test(n)) return ICON.zip; + if (/\.vtt$/.test(n)) return ICON.vtt; + return ICON.other; } - function humanSize(bytes) { - if (bytes >= 1073741824) return `${(bytes / 1073741824).toFixed(2)} GB`; - if (bytes >= 1048576) return `${(bytes / 1048576).toFixed(2)} MB`; - if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${bytes} B`; + function humanSize(b) { + return b >= 1073741824 ? (b / 1073741824).toFixed(2) + ' GB' + : b >= 1048576 ? (b / 1048576).toFixed(2) + ' MB' + : b >= 1024 ? (b / 1024).toFixed(1) + ' KB' + : b + ' B'; } - function esc(str) { - return str.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); - } + function esc(s) { return s.replace(/[&<>"]/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"'}[c]; }); } - /* ── DataTransfer-backed file list ────────────────────────────────────── */ - // We keep a parallel array so we can freely re-order and remove files - // then reconstruct a proper FileList via DataTransfer when needed. + // ── 1. TFE multi-file queue ──────────────────────────────────────────── + var picker = document.getElementById('tfe-files-input'); + var queue = document.getElementById('tfe-file-queue'); + var empty = document.getElementById('tfe-file-queue-empty'); + if (picker && queue) { + console.log('[file-upload-queue] init TFE queue picker=', picker, 'multiple=', picker.multiple); + var fileArray = []; - function syncInputFiles(input, fileArray) { - try { - const dt = new DataTransfer(); - for (const f of fileArray) dt.items.add(f); - input.files = dt.files; - } catch { - // DataTransfer not available in older browsers — graceful degradation. - } - } - - /* ── TFE file queue ───────────────────────────────────────────────────── */ - - function initFileQueue() { - const picker = document.getElementById('tfe-files-input'); - const queue = document.getElementById('tfe-file-queue'); - const empty = document.getElementById('tfe-file-queue-empty'); - - if (!picker || !queue) return; - - // Array parallel to the visual queue - let fileArray = []; - - // Keep SortableJS instance reference - let _sortable = null; if (typeof Sortable !== 'undefined') { - _sortable = Sortable.create(queue, { - animation: 150, - handle: '.fq-drag-handle', - ghostClass: 'fq-ghost', - onEnd: () => reorderFiles(), + Sortable.create(queue, { animation: 150, handle: '.fq-drag-handle', ghostClass: 'fq-ghost', + onEnd: function () { + var items = queue.querySelectorAll('.fq-item'); + var newArr = Array.prototype.map.call(items, function (li) { return fileArray[parseInt(li.getAttribute('data-idx'), 10)]; }); + fileArray = newArr; + renderQueue(); + } }); } - picker.addEventListener('change', () => { - const newFiles = Array.from(picker.files); - fileArray = fileArray.concat(newFiles); - renderQueue(); - // Reset input so the same file can be selected again if needed + picker.onchange = function () { + console.log('[file-upload-queue] onchange fired, files count:', picker.files.length, 'names:', Array.from(picker.files).map(function(f){return f.name})); + fileArray = fileArray.concat(Array.from(picker.files)); + console.log('[file-upload-queue] fileArray after concat, length:', fileArray.length); picker.value = ''; - }); + renderQueue(); + }; function renderQueue() { queue.innerHTML = ''; - - if (fileArray.length === 0) { - empty.style.display = ''; - syncInputFiles(picker, []); - return; - } + if (!fileArray.length) { empty.style.display = ''; injectHiddenFields([]); return; } empty.style.display = 'none'; - - fileArray.forEach((file, idx) => { - const li = document.createElement('li'); + fileArray.forEach(function (file, idx) { + var li = document.createElement('li'); li.className = 'fq-item'; li.setAttribute('data-idx', idx); - li.innerHTML = - '' + + '\u2820' + '' + iconFor(file) + '' + - '' + - '' + esc(file.name) + '' + - '' + humanSize(file.size) + '' + - '' + - '' + - ''; - - // Remove button - li.querySelector('.fq-remove').addEventListener('click', () => { - fileArray.splice(idx, 1); - renderQueue(); - }); - + '' + esc(file.name) + '' + + '' + humanSize(file.size) + '' + + '' + + ''; + li.querySelector('.fq-remove').onclick = (function (i) { return function () { fileArray.splice(i, 1); renderQueue(); }; })(idx); queue.appendChild(li); }); - - syncInputFiles(picker, fileArray); - injectHiddenFields(); + injectHiddenFields(Array.from(queue.querySelectorAll('.fq-item'))); } - function reorderFiles() { - // Re-sync fileArray to match current DOM order - const items = Array.from(queue.querySelectorAll('.fq-item')); - const newArr = items.map(li => fileArray[parseInt(li.getAttribute('data-idx'), 10)]); - fileArray = newArr; - // Re-render to update data-idx attributes - renderQueue(); - } - - function injectHiddenFields() { - // Remove previous hidden fields - const form = picker.closest('form'); + function injectHiddenFields(items) { + var form = picker.closest('form'); if (!form) return; - for (const el of form.querySelectorAll('.fq-hidden-label, .fq-hidden-order')) el.remove(); - - // Inject current labels and order indices - // We use the queue DOM (post-sort) as the source of truth. - const items = Array.from(queue.querySelectorAll('.fq-item')); - items.forEach((li, sortedIdx) => { - const labelVal = li.querySelector('.fq-label').value; - - const lInput = document.createElement('input'); - lInput.type = 'hidden'; - lInput.name = 'file_labels[]'; - lInput.value = labelVal; - lInput.className = 'fq-hidden-label'; - form.appendChild(lInput); - - const oInput = document.createElement('input'); - oInput.type = 'hidden'; - oInput.name = 'file_orders[]'; - oInput.value = sortedIdx + 1; - oInput.className = 'fq-hidden-order'; - form.appendChild(oInput); + form.querySelectorAll('.fq-hidden-label, .fq-hidden-order').forEach(function (el) { el.remove(); }); + items.forEach(function (li, sortedIdx) { + var label = li.querySelector('.fq-label'); + var lInp = document.createElement('input'); lInp.type = 'hidden'; lInp.name = 'file_labels[]'; lInp.value = label ? label.value : ''; lInp.className = 'fq-hidden-label'; form.appendChild(lInp); + var oInp = document.createElement('input'); oInp.type = 'hidden'; oInp.name = 'file_orders[]'; oInp.value = sortedIdx + 1; oInp.className = 'fq-hidden-order'; form.appendChild(oInp); }); } - // Before form submit, inject hidden fields so labels are up-to-date - const form = picker.closest('form'); - if (form) { - form.addEventListener('submit', () => { - syncInputFiles(picker, fileArray); - injectHiddenFields(); - }); - } + // On submit, refresh hidden fields from current queue state + var form = picker.closest('form'); + if (form) form.addEventListener('submit', function () { injectHiddenFields(Array.from(queue.querySelectorAll('.fq-item'))); }); } - /* ── Existing-files sortable (edit form only) ─────────────────────────── */ + // ── 2. Single-file previews (data-preview attribute) ──────────────────── + document.querySelectorAll('input[type="file"][data-preview]').forEach(function (input) { + if (input.id === 'tfe-files-input') return; + console.log('[file-upload-queue] binding preview for', input.id, 'multiple=', input.multiple); + var container = document.getElementById(input.getAttribute('data-preview')); + if (!container) return; + input.onchange = function () { + container.innerHTML = ''; + Array.from(input.files).forEach(function (file) { + var item = document.createElement('div'); item.className = 'fp-item'; + if (/^image\//.test(file.type)) { + var img = document.createElement('img'); img.className = 'fp-thumb'; img.alt = file.name; + var reader = new FileReader(); + reader.onload = function (e) { img.src = e.target.result; }; + reader.readAsDataURL(file); + item.appendChild(img); + } else { + var ic = document.createElement('span'); ic.className = 'fp-icon'; ic.textContent = iconFor(file); + item.appendChild(ic); + } + var meta = document.createElement('span'); meta.className = 'fp-meta'; + meta.innerHTML = '' + esc(file.name) + '' + humanSize(file.size) + ''; + item.appendChild(meta); + container.appendChild(item); + }); + }; + }); - function initExistingFilesSortable() { - const list = document.getElementById('existing-files-sortable'); - if (!list || typeof Sortable === 'undefined') return; - - Sortable.create(list, { - animation: 150, - handle: '.admin-file-drag-handle', - ghostClass: 'fq-ghost', - onEnd: () => { - // Update the hidden file_sort_order[] inputs to reflect new order - const items = list.querySelectorAll('.admin-file-list-item[data-file-id]'); - for (const el of list.querySelectorAll('input[name="file_sort_order[]"]')) el.remove(); - items.forEach((li) => { - const inp = document.createElement('input'); - inp.type = 'hidden'; - inp.name = 'file_sort_order[]'; - inp.value = li.getAttribute('data-file-id'); - li.prepend(inp); + // ── 3. Existing-files sortable (edit mode) ────────────────────────────── + var sortList = document.getElementById('existing-files-sortable'); + if (sortList && typeof Sortable !== 'undefined') { + Sortable.create(sortList, { animation: 150, handle: '.admin-file-drag-handle', ghostClass: 'fq-ghost', + onEnd: function () { + sortList.querySelectorAll('input[name="file_sort_order[]"]').forEach(function (el) { el.remove(); }); + sortList.querySelectorAll('.admin-file-list-item[data-file-id]').forEach(function (li) { + var inp = document.createElement('input'); inp.type = 'hidden'; inp.name = 'file_sort_order[]'; inp.value = li.getAttribute('data-file-id'); li.prepend(inp); }); - }, - }); - } - - /* ── Legacy single-file preview (data-preview="CONTAINER_ID") ─────────── */ - - function initLegacyPreviews() { - document.querySelectorAll('input[type="file"][data-preview]').forEach((input) => { - // Skip the TFE multi-file picker (handled by queue above) - if (input.id === 'tfe-files-input') return; - - const containerId = input.getAttribute('data-preview'); - const container = document.getElementById(containerId); - if (!container) return; - - input.addEventListener('change', () => { - renderLegacyPreview(input, container); - }); - }); - } - - function renderLegacyPreview(input, container) { - container.innerHTML = ''; - const files = Array.from(input.files); - if (!files.length) return; - - files.forEach((file) => { - const item = document.createElement('div'); - item.className = 'fp-item'; - - if (file.type.startsWith('image/')) { - const img = document.createElement('img'); - img.className = 'fp-thumb'; - img.alt = file.name; - const reader = new FileReader(); - reader.onload = (e) => { img.src = e.target.result; }; - reader.readAsDataURL(file); - item.appendChild(img); - } else { - const icon = document.createElement('span'); - icon.className = 'fp-icon'; - icon.textContent = iconFor(file); - item.appendChild(icon); } - - const meta = document.createElement('span'); - meta.className = 'fp-meta'; - meta.innerHTML = - '' + esc(file.name) + '' + - '' + humanSize(file.size) + ''; - item.appendChild(meta); - container.appendChild(item); }); } +}; - /* ── Bootstrap ────────────────────────────────────────────────────────── */ - - function init() { - initFileQueue(); - initExistingFilesSortable(); - initLegacyPreviews(); - } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', init); - } else { - init(); - } -})(); +// Bootstrap on page load +if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', window.XamxamInitFileUploads); +else window.XamxamInitFileUploads(); diff --git a/app/public/partage/fichiers-fragment.php b/app/public/partage/fichiers-fragment.php index 023ae20..1e6e323 100644 --- a/app/public/partage/fichiers-fragment.php +++ b/app/public/partage/fichiers-fragment.php @@ -106,25 +106,31 @@ $hxPost = $adminMode ? '/admin/fichiers-fragment.php' : '/partage/fichiers-fragm Fichiers +
+
+
bentopdf.com.'; - $hintRaw = true; // allow the tag through + $hintRaw = true; $required = !$adminMode; + $id = 'note_intention'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?> +
@@ -162,6 +168,7 @@ $hxPost = $adminMode ? '/admin/fichiers-fragment.php' : '/partage/fichiers-fragm
+
+
@@ -260,4 +268,5 @@ $hxPost = $adminMode ? '/admin/fichiers-fragment.php' : '/partage/fichiers-fragm + diff --git a/app/templates/admin/acces.php b/app/templates/admin/acces.php index f8b8681..1d14069 100644 --- a/app/templates/admin/acces.php +++ b/app/templates/admin/acces.php @@ -22,6 +22,7 @@ Lien Objet + Année Statut Mot de passe Utilisations @@ -40,6 +41,9 @@ $created = date('d/m/Y H:i', strtotime($link['created_at'])); $expires = $link['expires_at'] ? date('d/m/Y', strtotime($link['expires_at'])) : '-'; $hasLinkPassword = !empty($link['password_hash']); + $linkName = $link['name'] ?? ''; + $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : ''; + $linkLockedYear = $link['locked_year'] ?? null; ?> @@ -53,6 +57,13 @@ Tous + + + 🔒 + + Libre + + @@ -86,6 +97,11 @@ +