refactor: extract inline JS into app/ modules, remove dead overtype-webcomponent

- Remove overtype-webcomponent.min.js (zero references)
- Extract copyLogContent + fallbackCopy + HTMX tab-updater → app/admin-logs.js
  (removes duplicate from both system.php and parametres.php)
- Extract copyUrl → app/clipboard.js (shared by acces.php)
- Extract tag/language pill-search logic → app/pill-search.js
  Generalized with data-pill-search attributes, auto-inits via
  DOMContentLoaded + htmx:afterSwap
- Extract access-request form handler → app/access-request.js
  (was inline in templates/public/tfe.php)

Files created: admin-logs.js, clipboard.js, pill-search.js, access-request.js
Files modified: 9 templates/controllers to drop inline scripts and
  reference external JS files
This commit is contained in:
Pontoporeia
2026-05-11 19:37:31 +02:00
parent 04094d802d
commit b56d073210
31 changed files with 430 additions and 1724 deletions

View File

@@ -1455,6 +1455,19 @@
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: npqqxmut d463ff53 "fix: harden security based on pentest scan findings" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: npqqxmut d463ff53 "fix: harden security based on pentest scan findings" (rebased revision)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
- $linkName = $link['name'] ?? '';
- $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% diff from: somsyvxz 14a3cd10 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebase destination)
\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\ to: okvqnxxl ce3e339b "refactor: extract inline JS into app/ modules, remove dead overtype-webcomponent" (rebased revision)
$linkName = $link['name'] ?? '';
$linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
$linkLockedYear = $link['locked_year'] ?? null;
+%%%%%%% diff from: somsyvxz 249f7943 "Bulk bar anti-shift, tags icons, AP no-wrap, credits reorder" (rebased revision)
+\\\\\\\ to: okvqnxxl 4c0538fc "refactor: extract inline JS into app/ modules, remove dead overtype-webcomponent" (rebased revision)
++ $linkName = $link['name'] ?? '';
++ $linkExpiresVal = $link['expires_at'] ? date('Y-m-d\TH:i', strtotime($link['expires_at'])) : '';
?>
<tr class="admin-table-row" onclick="event.stopPropagation(); window.open('/partage/<?= urlencode($link['slug']) ?>', '_blank')" style="cursor:pointer">
@@ -1904,14 +1917,6 @@ document.getElementById('open-create-dialog').addEventListener('click', () => {
document.getElementById('create-dialog').showModal();
});
function copyUrl(id) {
const input = document.getElementById('url-' + id);
navigator.clipboard.writeText(input.value).then(() => {
const btn = event.target.closest('button');
if (btn) { const orig = btn.getAttribute('title') || ''; btn.setAttribute('title', '✓ Copié'); setTimeout(() => btn.setAttribute('title', orig), 1200); }
});
}
function openEditDialog(id, name, hasPassword, expiresVal) {
document.getElementById('edit-link-id').value = id;
document.getElementById('edit-name').value = name || '';

View File

@@ -390,7 +390,7 @@ document.addEventListener('htmx:afterSwap', function(evt) {
<script>
(function () {
var otScript = document.createElement('script');
otScript.src = '<?= App::assetV('/assets/js/overtype.min.js') ?>';
otScript.src = '<?= App::assetV('/assets/js/vendor/overtype.min.js') ?>';
document.head.appendChild(otScript);
})();
</script>

View File

@@ -13,7 +13,7 @@
<?php if (!empty($extraJsInline)): ?>
<script><?= $extraJsInline ?></script>
<?php endif; ?>
<script src="/assets/js/htmx.min.js"></script>
<script src="/assets/js/vendor/htmx.min.js"></script>
<script>
// Global HTMX debugging for settings checkboxes
document.body.addEventListener('htmx:sendError', function (e) {

View File

@@ -454,31 +454,6 @@
</main>
<script>
function copyLogContent(btn) {
var logOut = document.querySelector('#log-output');
if (!logOut) return;
var text = Array.from(logOut.querySelectorAll('.log-line'))
.map(function(el){ return el.textContent; }).join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function(){
btn.textContent = '\u2713 Copi\u00e9';
btn.classList.add('copied');
setTimeout(function(){ btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
});
} else {
fallbackCopy(text, btn);
}
}
function fallbackCopy(text, btn) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); btn.textContent = '\u2713 Copi\u00e9'; btn.classList.add('copied');
setTimeout(function(){ btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
} catch(e) {}
document.body.removeChild(ta);
}
// Focus the SMTP field that caused the probe error
(function () {
var form = document.querySelector('form[data-smtp-error-field]');
@@ -489,30 +464,8 @@ function fallbackCopy(text, btn) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.focus();
}());
// Update active tab class after each HTMX swap on #sys-tab-panel
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'sys-tab-panel') {
var rc = evt.detail.requestConfig;
var tab = null;
var qIdx = rc.path.indexOf('?');
if (qIdx !== -1) {
tab = new URLSearchParams(rc.path.substring(qIdx + 1)).get('tab');
}
if (!tab && rc.parameters && rc.parameters.tab) {
tab = rc.parameters.tab;
}
if (tab) {
document.querySelectorAll('.sys-tabs .sys-tab').forEach(function(a) {
var isActive = a.getAttribute('data-tab') === tab;
a.classList.toggle('active', isActive);
if (isActive) a.setAttribute('aria-current', 'page');
else a.removeAttribute('aria-current');
});
}
}
});
</script>
<script src="/assets/js/app/admin-logs.js"></script>
<!-- Enable maintenance confirm -->
<dialog id="enable-maintenance-dialog" class="admin-dialog admin-dialog--sm" aria-labelledby="enable-maint-title">

View File

@@ -111,55 +111,4 @@
</div><!-- #sys-tab-panel -->
</main>
<script>
function copyLogContent(btn) {
var logOut = document.querySelector('#log-output');
if (!logOut) return;
var text = Array.from(logOut.querySelectorAll('.log-line'))
.map(function(el){ return el.textContent; }).join('\n');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function(){
btn.textContent = '\u2713 Copi\u00e9';
btn.classList.add('copied');
setTimeout(function(){ btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
});
} else {
fallbackCopy(text, btn);
}
}
function fallbackCopy(text, btn) {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0';
document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); btn.textContent = '\u2713 Copi\u00e9'; btn.classList.add('copied');
setTimeout(function(){ btn.textContent = 'Copier'; btn.classList.remove('copied'); }, 2000);
} catch(e) {}
document.body.removeChild(ta);
}
// Update active tab class after each HTMX swap on #sys-tab-panel
document.body.addEventListener('htmx:afterSwap', function(evt) {
if (evt.detail.target && evt.detail.target.id === 'sys-tab-panel') {
var rc = evt.detail.requestConfig;
var tab = null;
// Tab clicks carry ?tab=… in the path
var qIdx = rc.path.indexOf('?');
if (qIdx !== -1) {
tab = new URLSearchParams(rc.path.substring(qIdx + 1)).get('tab');
}
// Line-count form sends tab via hx-vals in parameters
if (!tab && rc.parameters && rc.parameters.tab) {
tab = rc.parameters.tab;
}
if (tab) {
document.querySelectorAll('.sys-tabs .sys-tab').forEach(function(a) {
var isActive = a.getAttribute('data-tab') === tab;
a.classList.toggle('active', isActive);
if (isActive) a.setAttribute('aria-current', 'page');
else a.removeAttribute('aria-current');
});
}
}
});
</script>
<script src="/assets/js/app/admin-logs.js"></script>

View File

@@ -31,7 +31,7 @@ $maxLanguages = $maxLanguages ?? 10;
$required = $required ?? false;
$langCount = count($selectedLanguages);
?>
<div id="<?= htmlspecialchars($id) ?>-search-container">
<div id="<?= htmlspecialchars($id) ?>-search-container" data-pill-search data-pill-name="<?= htmlspecialchars($name) ?>" data-pill-max="<?= (int)$maxLanguages ?>" data-pill-min="0" data-pill-required="0" data-pill-role="lang">
<span class="admin-row-label"><?= htmlspecialchars($label) ?><span id="language-autre-required"><?= $required ? ' <span class="asterisk">*</span>' : '' ?></span></span>
<div class="tag-search-wrapper">
<?php if ($hint): ?>
@@ -79,183 +79,24 @@ $langCount = count($selectedLanguages);
</div>
</div>
<!-- Inline script for the interactive behaviour (no external JS required) -->
<script>
(function() {
const container = document.getElementById(<?= json_encode($id . '-search-container') ?>);
if (!container || container._langSearchInit) return;
container._langSearchInit = true;
const pills = document.getElementById(<?= json_encode($id . '-pills') ?>);
const search = document.getElementById(<?= json_encode($id . '-search') ?>);
const dropdown = document.getElementById(<?= json_encode($id . '-suggestions') ?>);
const countEl = document.getElementById(<?= json_encode($id . '-count') ?>);
const counter = document.getElementById(<?= json_encode($id . '-counter') ?>);
const maxLanguages = <?= (int)$maxLanguages ?>;
const inputName = <?= json_encode($name) ?>;
let selectedIdx = -1;
function updateCount() {
const n = pills.querySelectorAll('.tag-pill').length;
if (countEl) countEl.textContent = n + '/' + maxLanguages;
if (counter) counter.style.display = (n > 0) ? '' : 'none';
// Toggle the checkbox-list asterisk: if any "autre" language pill
// is present, the checkbox list is no longer required.
const asteriskEl = document.getElementById('languages-required-asterisk');
if (asteriskEl) {
const checkboxes = document.querySelectorAll('#languages-fieldset input[type="checkbox"]:checked');
asteriskEl.innerHTML = (n === 0 && checkboxes.length === 0) ? ' <span class="asterisk">*</span>' : '';
}
// Show/hide search input based on max
const wrap = container.querySelector('.tag-search-input-wrap');
const maxMsg = container.querySelector('.tag-search-max-msg');
if (n >= maxLanguages) {
if (wrap) wrap.style.display = 'none';
if (maxMsg) maxMsg.style.display = '';
} else {
if (wrap) {
wrap.style.display = '';
if (search) search.style.display = '';
}
if (maxMsg) maxMsg.style.display = 'none';
}
}
// Lowercase, collapse spaces, trim, ucfirst for display
function normalizeLang(name) {
return name.trim().replace(/\s+/g, ' ').toLowerCase();
}
function ucfirst(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
// Check if language already exists in pills (case-insensitive)
function langAlreadyAdded(name) {
const norm = normalizeLang(name);
const existing = pills.querySelectorAll('.tag-pill-name');
for (const el of existing) {
if (normalizeLang(el.textContent) === norm) return true;
}
return false;
}
// Remove a pill
pills.addEventListener('click', function(e) {
const btn = e.target.closest('.tag-pill-remove');
if (!btn) return;
const pill = btn.closest('.tag-pill');
pill.remove();
updateCount();
// Re-enable search field visibility
const wrap = container.querySelector('.tag-search-input-wrap');
const searchInput = container.querySelector('.tag-search-input');
if (wrap && searchInput) {
wrap.style.display = '';
searchInput.style.display = '';
}
});
// Highlight a suggestion by index
function highlight(idx) {
const items = dropdown.querySelectorAll('.tag-search-item');
items.forEach(function(item, i) {
if (i === idx) {
item.classList.add('tag-search-item--highlight');
} else {
item.classList.remove('tag-search-item--highlight');
}
});
}
// Select a suggestion by button element
function selectLang(btn) {
const langName = normalizeLang(btn.getAttribute('data-tag-name') || '');
if (!langName) return;
if (langAlreadyAdded(langName)) return;
if (pills.querySelectorAll('.tag-pill').length >= maxLanguages) return;
const escapedName = htmlEscape(langName);
const pill = document.createElement('span');
pill.className = 'tag-pill';
pill.innerHTML = '<input type="hidden" name="' + inputName + '[]" value="' + escapedName + '">'
+ '<span class="tag-pill-name">' + escapedName + '</span>'
+ '<button type="button" class="tag-pill-remove" title="Retirer \u00AB\u00A0' + escapedName + '\u00A0\u00BB" aria-label="Retirer ' + escapedName + '">'
+ '<svg width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM112,168V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Zm48,0V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Z"></path></svg>'
+ '</button>';
pills.appendChild(pill);
updateCount();
search.value = '';
dropdown.innerHTML = '';
selectedIdx = -1;
search.focus();
}
// Click on suggestion
dropdown.addEventListener('click', function(e) {
console.log('[lang-search] dropdown click, target:', e.target.tagName, e.target.className);
const btn = e.target.closest('.tag-search-item');
if (!btn) { console.log('[lang-search] no .tag-search-item found in click path'); return; }
console.log('[lang-search] found btn:', btn.getAttribute('data-tag-name'), btn.className);
selectLang(btn);
});
// Keyboard navigation
search.addEventListener('keydown', function(e) {
const items = dropdown.querySelectorAll('.tag-search-item');
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (items.length === 0) return;
if (e.key === 'ArrowDown') {
selectedIdx = (selectedIdx + 1) % items.length;
} else {
selectedIdx = selectedIdx <= 0 ? items.length - 1 : selectedIdx - 1;
}
highlight(selectedIdx);
} else if (e.key === 'Enter') {
if (items.length > 0) {
e.preventDefault();
if (selectedIdx >= 0 && selectedIdx < items.length) {
selectLang(items[selectedIdx]);
} else {
selectLang(items[0]);
}
}
} else if (e.key === 'Escape') {
dropdown.innerHTML = '';
selectedIdx = -1;
}
});
// Hide dropdown on blur (after a tiny delay so click events fire)
search.addEventListener('blur', function() {
setTimeout(function() {
if (!dropdown.contains(document.activeElement)) {
console.log('[lang-search] blur: hiding dropdown');
dropdown.innerHTML = '';
selectedIdx = -1;
}
}, 150);
});
// Log HTMX responses
document.body.addEventListener('htmx:afterSwap', function(e) {
if (e.detail.target && e.detail.target.id === '<?= htmlspecialchars($id) ?>-suggestions') {
console.log('[lang-search] htmx:afterSwap, target:', e.detail.target.id, 'html length:', e.detail.target.innerHTML.length);
console.log('[lang-search] innerHTML:', e.detail.target.innerHTML);
}
});
function htmlEscape(str) {
const el = document.createElement('span');
el.textContent = str;
return el.innerHTML;
// Language-specific: toggle checkbox-list asterisk based on pills presence
(function () {
var container = document.getElementById(<?= json_encode($id . '-search-container') ?>);
if (!container) return;
var pills = container.querySelector('.tag-search-pills');
if (!pills) return;
function check() {
var asteriskEl = document.getElementById('languages-required-asterisk');
if (!asteriskEl) return;
var n = pills.querySelectorAll('.tag-pill').length;
var checkboxes = document.querySelectorAll('#languages-fieldset input[type="checkbox"]:checked');
asteriskEl.innerHTML = (n === 0 && checkboxes.length === 0) ? ' <span class="asterisk">*</span>' : '';
}
var observer = new MutationObserver(check);
observer.observe(pills, { childList: true });
check();
})();
</script>
<?php
unset($name, $label, $placeholder, $hint, $hxPost, $selectedLanguages, $id, $maxLanguages, $langCount, $required);

View File

@@ -34,7 +34,7 @@ $required = $required ?? false;
$tagCount = count($selectedTags);
$belowMin = $required && $tagCount < $minTags;
?>
<div id="<?= htmlspecialchars($id) ?>-search-container">
<div id="<?= htmlspecialchars($id) ?>-search-container" data-pill-search data-pill-name="<?= htmlspecialchars($name) ?>" data-pill-max="<?= (int)$maxTags ?>" data-pill-min="<?= (int)$minTags ?>" data-pill-required="<?= $required ? '1' : '0' ?>" data-pill-role="tag">
<span class="admin-row-label"><?= htmlspecialchars($label) ?><?= $required ? ' <span class="asterisk">*</span>' : '' ?></span>
<div class="tag-search-wrapper">
<?php if ($hint): ?>
@@ -81,171 +81,5 @@ $belowMin = $required && $tagCount < $minTags;
<div class="tag-search-suggestions" id="<?= htmlspecialchars($id) ?>-suggestions" role="listbox"></div>
</div>
</div>
<!-- Inline script for the interactive behaviour (no external JS required) -->
<script>
(function() {
const container = document.getElementById(<?= json_encode($id . '-search-container') ?>);
if (!container || container._tagSearchInit) return;
container._tagSearchInit = true;
const pills = document.getElementById(<?= json_encode($id . '-pills') ?>);
const search = document.getElementById(<?= json_encode($id . '-search') ?>);
const dropdown = document.getElementById(<?= json_encode($id . '-suggestions') ?>);
const countEl = document.getElementById(<?= json_encode($id . '-count') ?>);
const counter = document.getElementById(<?= json_encode($id . '-counter') ?>);
const maxTags = <?= (int)$maxTags ?>;
const minTags = <?= (int)$minTags ?>;
const required = <?= json_encode($required) ?>;
const inputName = <?= json_encode($name) ?>;
let selectedIdx = -1;
function updateCount() {
const n = pills.querySelectorAll('.tag-pill').length;
const suffix = required ? ' (min ' + minTags + ')' : '';
if (countEl) countEl.textContent = n + '/' + maxTags + suffix;
if (counter) counter.style.display = (n > 0 || required) ? '' : 'none';
if (countEl && required) {
if (n < minTags) {
countEl.style.color = 'var(--text-danger)';
} else {
countEl.style.color = 'var(--accent)';
}
}
// Show/hide search input based on max
const wrap = container.querySelector('.tag-search-input-wrap');
const maxMsg = container.querySelector('.tag-search-max-msg');
if (n >= maxTags) {
if (wrap) wrap.style.display = 'none';
if (maxMsg) maxMsg.style.display = '';
} else {
if (wrap) {
wrap.style.display = '';
if (search) search.style.display = '';
}
if (maxMsg) maxMsg.style.display = 'none';
}
}
// Lowercase, collapse spaces, trim
function normalizeTag(name) {
return name.trim().replace(/\s+/g, ' ').toLowerCase();
}
// Check if tag already exists in pills (case-insensitive)
function tagAlreadyAdded(name) {
const norm = normalizeTag(name);
const existing = pills.querySelectorAll('.tag-pill-name');
for (const el of existing) {
if (normalizeTag(el.textContent) === norm) return true;
}
return false;
}
// Remove a pill
pills.addEventListener('click', function(e) {
const btn = e.target.closest('.tag-pill-remove');
if (!btn) return;
const pill = btn.closest('.tag-pill');
pill.remove();
updateCount();
// Re-enable search field visibility
const wrap = container.querySelector('.tag-search-input-wrap');
const searchInput = container.querySelector('.tag-search-input');
if (wrap && searchInput) {
wrap.style.display = '';
searchInput.style.display = '';
}
});
// Highlight a suggestion by index
function highlight(idx) {
const items = dropdown.querySelectorAll('.tag-search-item');
items.forEach(function(item, i) {
if (i === idx) {
item.classList.add('tag-search-item--highlight');
} else {
item.classList.remove('tag-search-item--highlight');
}
});
}
// Select a suggestion by button element
function selectTag(btn) {
const tagName = normalizeTag(btn.getAttribute('data-tag-name') || '');
if (!tagName) return;
if (tagAlreadyAdded(tagName)) return;
if (pills.querySelectorAll('.tag-pill').length >= maxTags) return;
const escapedName = htmlEscape(tagName);
const pill = document.createElement('span');
pill.className = 'tag-pill';
pill.innerHTML = '<input type="hidden" name="' + inputName + '[]" value="' + escapedName + '">'
+ '<span class="tag-pill-name">' + escapedName + '</span>'
+ '<button type="button" class="tag-pill-remove" title="Retirer \u00AB\u00A0' + escapedName + '\u00A0\u00BB" aria-label="Retirer ' + escapedName + '">'
+ '<svg width="16" height="16" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM112,168V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Zm48,0V104a8,8,0,0,1,16,0v64a8,8,0,0,1-16,0Z"></path></svg>'
+ '</button>';
pills.appendChild(pill);
updateCount();
search.value = '';
dropdown.innerHTML = '';
selectedIdx = -1;
search.focus();
}
// Click on suggestion
dropdown.addEventListener('click', function(e) {
const btn = e.target.closest('.tag-search-item');
if (!btn) return;
selectTag(btn);
});
// Keyboard navigation
search.addEventListener('keydown', function(e) {
const items = dropdown.querySelectorAll('.tag-search-item');
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (items.length === 0) return;
if (e.key === 'ArrowDown') {
selectedIdx = (selectedIdx + 1) % items.length;
} else {
selectedIdx = selectedIdx <= 0 ? items.length - 1 : selectedIdx - 1;
}
highlight(selectedIdx);
} else if (e.key === 'Enter') {
if (items.length > 0) {
e.preventDefault();
if (selectedIdx >= 0 && selectedIdx < items.length) {
selectTag(items[selectedIdx]);
} else {
selectTag(items[0]);
}
}
} else if (e.key === 'Escape') {
dropdown.innerHTML = '';
selectedIdx = -1;
}
});
// Hide dropdown on blur (after a tiny delay so click events fire)
search.addEventListener('blur', function() {
setTimeout(function() {
if (!dropdown.contains(document.activeElement)) {
dropdown.innerHTML = '';
selectedIdx = -1;
}
}, 150);
});
function htmlEscape(str) {
const el = document.createElement('span');
el.textContent = str;
return el.innerHTML;
}
})();
</script>
<?php
unset($name, $label, $placeholder, $hint, $hxPost, $selectedTags, $id, $maxTags, $tagCount);

View File

@@ -6,7 +6,7 @@
<!-- Student popover -->
<div id="student-popover" class="student-popover" hidden aria-live="polite"></div>
<script src="/assets/js/htmx.min.js"></script>
<script src="/assets/js/vendor/htmx.min.js"></script>
<script>
(function () {
var popover = document.getElementById('student-popover');

View File

@@ -336,88 +336,7 @@
</form>
</div>
<script>
(function() {
const form = document.getElementById('access-request-form');
const emailInput = document.getElementById('access-email');
const justificationContainer = document.getElementById('justification-container');
const justificationInput = document.getElementById('access-justification');
const messageDiv = document.getElementById('access-request-message');
// Show/hide justification based on email domain
emailInput.addEventListener('input', function() {
const email = this.value.trim().toLowerCase();
const isErg = email.endsWith('@erg.school') || email.endsWith('@erg.be');
justificationContainer.style.display = isErg ? 'none' : 'block';
justificationInput.required = !isErg;
});
function showRetryPrompt(rejectedEmail, serverMessage) {
messageDiv.style.display = 'block';
messageDiv.className = 'tfe-access-message tfe-access-error';
messageDiv.innerHTML =
'<strong>Adresse e-mail introuvable sur le serveur de l\'ERG.</strong><br>' +
'<small>' + serverMessage.replace(/</g, '&lt;') + '</small><br><br>' +
'Corrigez votre adresse e-mail et réessayez.';
// Highlight the email field and let the user fix it
emailInput.value = rejectedEmail;
emailInput.classList.add('input-error');
emailInput.focus();
emailInput.select();
// Remove error highlight once they start typing
emailInput.addEventListener('input', function clearError() {
emailInput.classList.remove('input-error');
emailInput.removeEventListener('input', clearError);
});
}
// Form submission
form.addEventListener('submit', function(e) {
e.preventDefault();
const submitBtn = form.querySelector('button[type="submit"]');
submitBtn.disabled = true;
submitBtn.textContent = 'Envoi en cours...';
messageDiv.style.display = 'none';
const submittedEmail = emailInput.value.trim();
const formData = new FormData(form);
formData.append('thesis_id', '<?= $thesisId ?>');
fetch('/request-access', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
submitBtn.disabled = false;
submitBtn.textContent = 'Demander l\'accès';
if (data.status === 'recipient_rejected') {
showRetryPrompt(submittedEmail, data.message);
return;
}
messageDiv.style.display = 'block';
if (data.success) {
messageDiv.className = 'tfe-access-message tfe-access-success';
messageDiv.textContent = data.message;
form.reset();
} else {
messageDiv.className = 'tfe-access-message tfe-access-error';
messageDiv.textContent = data.message || 'Une erreur est survenue. Veuillez réessayer.';
}
})
.catch(error => {
submitBtn.disabled = false;
submitBtn.textContent = 'Demander l\'accès';
messageDiv.style.display = 'block';
messageDiv.className = 'tfe-access-message tfe-access-error';
messageDiv.textContent = 'Erreur de connexion. Veuillez réessayer.';
});
});
})();
</script>
<?php elseif (!empty($data["files"])): ?>
<?php
// Preload PeerTube instance URL once