feat: filtres et suppression massive dans admin/articles, profil auteur amélioré

- Admin articles : filtres auteur/catégorie/statut par GET, compteur de résultats
- Admin articles : suppression massive avec checkboxes, confirmation JS, contrôle d'ownership
- Profil auteur : bio tronquée à 3 lignes avec bouton 'plus', limite à 6 articles affichés
- Profil auteur : bouton CTA pill 'Mes liens' vers /liens/{slug}
- Page liens : boutons pill colorés (palette auto par index), fond gris clair, grand avatar
This commit is contained in:
Cedric Abonnel
2026-05-13 00:57:02 +02:00
parent f3584159c1
commit a926e1825d
11 changed files with 350 additions and 51 deletions
+1 -1
View File
@@ -1 +1 @@
["scolaire"] ["scolaire","perso","Produits","Journal personnel","Vie pratique"]
+2
View File
@@ -45,6 +45,8 @@ RewriteRule ^feed/add/?$ /index.php?action=add_feed [L,QSA]
RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA] RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA]
# Profil public auteur + page liens # Profil public auteur + page liens
RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/article/cursor/([0-9a-f-]{36})/?$ /index.php?action=author_articles&slug=$1&cursor=$2 [L,QSA]
RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/article/?$ /index.php?action=author_articles&slug=$1 [L,QSA]
RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=author&slug=$1 [L,QSA] RewriteRule ^profil/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=author&slug=$1 [L,QSA]
RewriteRule ^liens/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=liens&slug=$1 [L,QSA] RewriteRule ^liens/([a-z0-9][a-z0-9-]*)/?$ /index.php?action=liens&slug=$1 [L,QSA]
RewriteRule ^link/add/?$ /index.php?action=add_link [L,QSA] RewriteRule ^link/add/?$ /index.php?action=add_link [L,QSA]
+27
View File
@@ -1479,3 +1479,30 @@ footer.mt-5 { margin-top: 0 !important; }
margin: 0; margin: 0;
line-height: 1.6; line-height: 1.6;
} }
/* Bouton flottant "+" — nouvel article */
.fab-new {
position: fixed;
bottom: 2rem;
right: 2rem;
z-index: 1050;
width: 3.25rem;
height: 3.25rem;
border-radius: 50%;
background: var(--vl-accent);
color: #fff;
font-size: 1.75rem;
line-height: 3.25rem;
text-align: center;
text-decoration: none;
box-shadow: 0 4px 16px rgba(79,70,229,.45);
transition: background 0.15s, box-shadow 0.15s, transform 0.15s;
user-select: none;
}
.fab-new:hover,
.fab-new:focus {
background: var(--vl-accent-dark);
color: #fff;
box-shadow: 0 6px 20px rgba(79,70,229,.55);
transform: scale(1.08);
}
+14
View File
@@ -0,0 +1,14 @@
(function(){
var bio = document.getElementById('author-bio');
var btn = document.getElementById('bio-toggle');
if (!bio || !btn) return;
requestAnimationFrame(function() {
if (bio.scrollHeight > bio.clientHeight + 2) { btn.hidden = false; }
});
btn.addEventListener('click', function() {
var exp = btn.getAttribute('aria-expanded') === 'true';
bio.classList.toggle('bio-clamped', exp);
btn.textContent = exp ? 'plus' : 'moins';
btn.setAttribute('aria-expanded', exp ? 'false' : 'true');
});
})();
+29
View File
@@ -0,0 +1,29 @@
(function() {
const list = document.getElementById('links-sortable');
if (!list) return;
let dragged = null;
list.querySelectorAll('li').forEach(li => {
li.setAttribute('draggable', true);
li.addEventListener('dragstart', () => { dragged = li; li.style.opacity = '.4'; });
li.addEventListener('dragend', () => { dragged = null; li.style.opacity = ''; saveOrder(); });
li.addEventListener('dragover', e => { e.preventDefault(); const after = getDragAfter(list, e.clientY); after ? list.insertBefore(dragged, after) : list.appendChild(dragged); });
});
function getDragAfter(container, y) {
return [...container.querySelectorAll('li:not([style*="opacity"])')].reduce((closest, el) => {
const box = el.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
return offset < 0 && offset > closest.offset ? { offset, element: el } : closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
function saveOrder() {
const form = document.getElementById('reorder-form');
if (!form) return;
form.querySelectorAll('input').forEach(i => i.remove());
list.querySelectorAll('li[data-id]').forEach(li => {
const inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'order[]'; inp.value = li.dataset.id;
form.appendChild(inp);
});
form.submit();
}
})();
+101
View File
@@ -881,6 +881,55 @@ switch ($action) {
include BASE_PATH . '/templates/author_profile.php'; include BASE_PATH . '/templates/author_profile.php';
break; break;
case 'author_articles':
$authorSlug = trim($_GET['slug'] ?? '');
$authorRow = profileBySlug($authorSlug);
if (!$authorRow) {
http_response_code(404);
$content = '<div class="container py-5"><p class="text-muted">Profil introuvable.</p></div>';
$title = 'Profil introuvable';
include BASE_PATH . '/templates/layout.php';
break;
}
$privateCats = $articles->getPrivateCategories();
$allCats = $articles->getCategories();
$authorArticles = array_values(array_filter(
$articles->getAll(publishedOnly: true),
static function (array $a) use ($authorRow, $privateCats): bool {
if (($a['author'] ?? '') !== $authorRow['email']) {
return false;
}
$cat = trim($a['category'] ?? '');
if ($cat !== '' && in_array($cat, $privateCats, true) && !isLoggedIn()) {
return false;
}
if (strtotime((string)($a['published_at'] ?? '')) > time() && !hasCapability('view_previews')) {
return false;
}
return true;
}
));
$perPage = postsPerPage();
$cursor = trim($_GET['cursor'] ?? '');
$offset = 0;
if ($cursor !== '') {
foreach ($authorArticles as $i => $a) {
if ($a['uuid'] === $cursor) {
$offset = $i + 1;
break;
}
}
}
$posts = array_slice($authorArticles, $offset, $perPage);
$nextCursor = count($posts) === $perPage ? end($posts)['uuid'] : null;
$prevCursor = null;
if ($offset > 0) {
$prevOffset = max(0, $offset - $perPage);
$prevCursor = $prevOffset > 0 ? $authorArticles[$prevOffset - 1]['uuid'] : '';
}
include BASE_PATH . '/templates/author_articles.php';
break;
case 'liens': case 'liens':
$liensSlug = trim($_GET['slug'] ?? ''); $liensSlug = trim($_GET['slug'] ?? '');
$liensRow = profileBySlug($liensSlug); $liensRow = profileBySlug($liensSlug);
@@ -1622,6 +1671,34 @@ switch ($action) {
$allArticles = array_values(array_filter($allArticles, fn ($a) => ($a['author'] ?? '') === $me)); $allArticles = array_values(array_filter($allArticles, fn ($a) => ($a['author'] ?? '') === $me));
} }
usort($allArticles, fn ($a, $b) => strcmp($b['updated_at'] ?? '', $a['updated_at'] ?? '')); usort($allArticles, fn ($a, $b) => strcmp($b['updated_at'] ?? '', $a['updated_at'] ?? ''));
$adminData['filter_authors'] = array_values(array_unique(array_filter(array_column($allArticles, 'author'))));
$adminData['filter_categories'] = array_values(array_unique(array_filter(array_column($allArticles, 'category'))));
sort($adminData['filter_authors']);
sort($adminData['filter_categories']);
$filterAuthor = trim($_GET['filter_author'] ?? '');
$filterCategory = trim($_GET['filter_category'] ?? '');
$filterStatus = trim($_GET['filter_status'] ?? '');
$adminData['filter_author'] = $filterAuthor;
$adminData['filter_category'] = $filterCategory;
$adminData['filter_status'] = $filterStatus;
$nowTs = time();
if ($filterAuthor !== '') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => ($a['author'] ?? '') === $filterAuthor));
}
if ($filterCategory !== '') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => trim($a['category'] ?? '') === $filterCategory));
}
if ($filterStatus === 'published') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) <= $nowTs));
} elseif ($filterStatus === 'draft') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => !$a['published']));
} elseif ($filterStatus === 'preview') {
$allArticles = array_values(array_filter($allArticles, fn ($a) => $a['published'] && strtotime((string)($a['published_at'] ?? '')) > $nowTs));
}
$adminData['articles'] = $allArticles; $adminData['articles'] = $allArticles;
} }
@@ -1706,6 +1783,30 @@ switch ($action) {
include BASE_PATH . '/templates/admin.php'; include BASE_PATH . '/templates/admin.php';
break; break;
case 'admin_bulk_delete':
requireAuth();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$uuids = $_POST['uuids'] ?? [];
if (is_array($uuids)) {
$me = currentUserEmail() ?? '';
foreach ($uuids as $uid) {
$uid = trim((string)$uid);
if ($uid === '') {
continue;
}
$art = $articles->getByUuid($uid);
if (!$art) {
continue;
}
if (isAdmin() || ($art['author'] ?? '') === $me) {
$articles->delete($uid);
}
}
}
}
header('Location: /admin/articles');
exit;
case 'admin_grant_role': case 'admin_grant_role':
requireAuth(); requireAuth();
if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') { if (!isAdmin() || $_SERVER['REQUEST_METHOD'] !== 'POST') {
+72
View File
@@ -105,12 +105,74 @@ function adminStatusBadge(array $a, int $now): string
<!-- ─────────────────────────── ARTICLES ─────────────────────────── --> <!-- ─────────────────────────── ARTICLES ─────────────────────────── -->
<?php elseif ($tab === 'articles'): ?> <?php elseif ($tab === 'articles'): ?>
<!-- Filtres -->
<form class="row g-2 align-items-center mb-3" method="get" action="/admin/articles">
<?php if (isAdmin() && !empty($adminData['filter_authors'])): ?>
<div class="col-auto">
<select name="filter_author" class="form-select form-select-sm">
<option value="">Tous les auteurs</option>
<?php foreach ($adminData['filter_authors'] as $_fa): ?>
<option value="<?= htmlspecialchars($_fa) ?>"
<?= ($adminData['filter_author'] ?? '') === $_fa ? 'selected' : '' ?>>
<?= htmlspecialchars($_fa) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<?php if (!empty($adminData['filter_categories'])): ?>
<div class="col-auto">
<select name="filter_category" class="form-select form-select-sm">
<option value="">Toutes les catégories</option>
<?php foreach ($adminData['filter_categories'] as $_fc): ?>
<option value="<?= htmlspecialchars($_fc) ?>"
<?= ($adminData['filter_category'] ?? '') === $_fc ? 'selected' : '' ?>>
<?= htmlspecialchars($_fc) ?>
</option>
<?php endforeach; ?>
</select>
</div>
<?php endif; ?>
<div class="col-auto">
<select name="filter_status" class="form-select form-select-sm">
<option value="">Tous les statuts</option>
<option value="published" <?= ($adminData['filter_status'] ?? '') === 'published' ? 'selected' : '' ?>>Publié</option>
<option value="draft" <?= ($adminData['filter_status'] ?? '') === 'draft' ? 'selected' : '' ?>>Brouillon</option>
<option value="preview" <?= ($adminData['filter_status'] ?? '') === 'preview' ? 'selected' : '' ?>>Avant-première</option>
</select>
</div>
<div class="col-auto d-flex gap-2">
<button type="submit" class="btn btn-secondary btn-sm">Filtrer</button>
<?php $hasFilter = ($adminData['filter_author'] ?? '') !== '' || ($adminData['filter_category'] ?? '') !== '' || ($adminData['filter_status'] ?? '') !== ''; ?>
<?php if ($hasFilter): ?>
<a href="/admin/articles" class="btn btn-link btn-sm p-0">Réinitialiser</a>
<?php endif; ?>
</div>
<?php if ($hasFilter): ?>
<div class="col-auto">
<span class="text-muted small"><?= count($adminData['articles']) ?> résultat(s)</span>
</div>
<?php endif; ?>
</form>
<?php if (empty($adminData['articles'])): ?> <?php if (empty($adminData['articles'])): ?>
<p class="text-muted">Aucun article.</p> <p class="text-muted">Aucun article.</p>
<?php else: ?> <?php else: ?>
<form method="post" action="/?action=admin_bulk_delete" id="bulk-form">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="check-all">
<label class="form-check-label small text-muted" for="check-all">Tout sélectionner</label>
</div>
<button type="submit" class="btn btn-danger btn-sm"
onclick="return document.querySelectorAll('.bulk-check:checked').length > 0 && confirm('Supprimer les articles sélectionnés ? Cette action est irréversible.')">
Supprimer la sélection
</button>
</div>
<table class="table table-sm table-hover align-middle"> <table class="table table-sm table-hover align-middle">
<thead> <thead>
<tr> <tr>
<th style="width:2rem"></th>
<th>Titre</th> <th>Titre</th>
<?php if (isAdmin()): ?><th>Auteur</th><?php endif; ?> <?php if (isAdmin()): ?><th>Auteur</th><?php endif; ?>
<th>Catégorie</th> <th>Catégorie</th>
@@ -122,6 +184,10 @@ function adminStatusBadge(array $a, int $now): string
<tbody> <tbody>
<?php foreach ($adminData['articles'] as $a): ?> <?php foreach ($adminData['articles'] as $a): ?>
<tr> <tr>
<td>
<input class="form-check-input bulk-check" type="checkbox"
name="uuids[]" value="<?= htmlspecialchars($a['uuid']) ?>">
</td>
<td> <td>
<a href="/post/<?= htmlspecialchars($a['slug'] ?? '') ?>"> <a href="/post/<?= htmlspecialchars($a['slug'] ?? '') ?>">
<?= htmlspecialchars($a['title']) ?> <?= htmlspecialchars($a['title']) ?>
@@ -143,6 +209,12 @@ function adminStatusBadge(array $a, int $now): string
<?php endforeach; ?> <?php endforeach; ?>
</tbody> </tbody>
</table> </table>
</form>
<script>
document.getElementById('check-all').addEventListener('change', function() {
document.querySelectorAll('.bulk-check').forEach(function(cb) { cb.checked = this.checked; }, this);
});
</script>
<?php endif; ?> <?php endif; ?>
<!-- ─────────────────────────── UTILISATEURS ─────────────────────────── --> <!-- ─────────────────────────── UTILISATEURS ─────────────────────────── -->
+97
View File
@@ -0,0 +1,97 @@
<?php
require_once BASE_PATH . '/src/Parsedown.php';
$Parsedown = new Parsedown();
$_apName = $authorRow['display_name'] ?? '';
$_apSlug = $authorRow['profile_slug'] ?? '';
$_base = '/profil/' . rawurlencode($_apSlug) . '/article';
ob_start();
?>
<div class="mb-4">
<a href="/profil/<?= rawurlencode($_apSlug) ?>" class="text-muted">← <?= htmlspecialchars($_apName) ?></a>
<h1 class="mt-1">Articles de <?= htmlspecialchars($_apName) ?></h1>
</div>
<?php if (empty($posts)): ?>
<p class="text-muted">Aucun article publié.</p>
<?php else: ?>
<div class="post-grid">
<?php foreach ($posts as $post):
$html = $Parsedown->text($post['content']);
$preview = mb_strimwidth(strip_tags($html), 0, 120, '…');
$category = trim((string)($post['category'] ?? ''));
$gradient = coverGradient($category !== '' ? $category : $post['uuid'], $allCats ?? []);
$postUrl = '/post/' . rawurlencode($post['slug']);
$coverFile = $post['cover'] ?? '';
$coverStyle = $coverFile !== ''
? 'background-image: url(\'/file?uuid=' . rawurlencode($post['uuid']) . '&name=' . rawurlencode($coverFile) . '\')'
: 'background: ' . $gradient;
$isAvantPremiere = $post['published'] && strtotime((string)($post['published_at'] ?? '')) > time();
$isLocked = $isAvantPremiere && !hasCapability('view_previews');
?>
<article class="card">
<?php if ($isAvantPremiere): ?>
<div class="premiere-ribbon">Avant-première</div>
<?php endif; ?>
<div class="card-cover" style="<?= $coverStyle ?>">
<?php if ($category !== ''): ?>
<span class="cover-category"><?= htmlspecialchars($category) ?></span>
<?php endif; ?>
</div>
<div class="card-body d-flex flex-column">
<h2 class="card-title">
<?php if ($isLocked): ?>
<?= htmlspecialchars($post['title']) ?>
<?php else: ?>
<a href="<?= htmlspecialchars($postUrl) ?>"><?= htmlspecialchars($post['title']) ?></a>
<?php endif; ?>
</h2>
<p class="card-text flex-grow-1"><?= htmlspecialchars($preview) ?></p>
<div class="post-entry-meta mt-auto">
<?php if ($isAvantPremiere): ?>
<span class="text-muted">Disponible le <?= htmlspecialchars(date('d/m/Y \à H\hi', strtotime((string)($post['published_at'] ?? '')))) ?></span>
<?php else: ?>
<span><?= htmlspecialchars(date('d/m/Y', strtotime((string)($post['published_at'] ?? $post['created_at'] ?? '')))) ?></span>
<?php endif; ?>
<?php if (function_exists('isAdmin') && isAdmin()): ?>
<a href="/edit/<?= htmlspecialchars($post['uuid']) ?>" class="post-entry-edit">modifier</a>
<?php endif; ?>
<?php if (!$isLocked): ?>
<a href="<?= htmlspecialchars($postUrl) ?>" class="post-entry-read">→ lire</a>
<?php endif; ?>
</div>
</div>
<?php if (!$isLocked): ?>
<a href="<?= htmlspecialchars($postUrl) ?>" class="stretched-link"></a>
<?php endif; ?>
</article>
<?php endforeach; ?>
</div>
<?php if ($prevCursor !== null || $nextCursor !== null): ?>
<nav class="pagination-nav mt-5" aria-label="Navigation">
<?php if ($prevCursor !== null): ?>
<?php $prevHref = $prevCursor === '' ? $_base : $_base . '/cursor/' . rawurlencode($prevCursor); ?>
<a class="pagination-btn" href="<?= htmlspecialchars($prevHref) ?>">← Plus récents</a>
<?php endif; ?>
<?php if ($nextCursor !== null): ?>
<a class="pagination-btn ms-auto" href="<?= htmlspecialchars($_base . '/cursor/' . rawurlencode($nextCursor)) ?>">Plus anciens →</a>
<?php endif; ?>
</nav>
<?php endif; ?>
<?php endif; ?>
<?php
$content = ob_get_clean();
$title = 'Articles de ' . htmlspecialchars($_apName) . ' — ' . siteTitle();
$seoTitle = 'Articles de ' . $_apName . ' — ' . siteTitle();
$canonical = rtrim(APP_URL, '/') . $_base;
$ogUrl = $canonical;
if (!empty($cursor)) {
$metaRobots = 'noindex, follow';
}
$mainClass = 'container-fluid';
include __DIR__ . '/layout.php';
+2 -16
View File
@@ -27,21 +27,7 @@ $_initials = mb_strtoupper(mb_substr($_apName, 0, 1, 'UTF-8'), 'UTF-8');
<p class="author-profile-bio bio-clamped" id="author-bio"><?= nl2br(htmlspecialchars($_apBio)) ?></p> <p class="author-profile-bio bio-clamped" id="author-bio"><?= nl2br(htmlspecialchars($_apBio)) ?></p>
<button class="bio-toggle" id="bio-toggle" hidden>plus</button> <button class="bio-toggle" id="bio-toggle" hidden>plus</button>
</div> </div>
<script> <script src="/assets/js/bio-toggle.js" defer></script>
(function(){
var bio = document.getElementById('author-bio');
var btn = document.getElementById('bio-toggle');
requestAnimationFrame(function() {
if (bio.scrollHeight > bio.clientHeight + 2) { btn.hidden = false; }
});
btn.addEventListener('click', function() {
var exp = btn.getAttribute('aria-expanded') === 'true';
bio.classList.toggle('bio-clamped', exp);
btn.textContent = exp ? 'plus' : 'moins';
btn.setAttribute('aria-expanded', exp ? 'false' : 'true');
});
})();
</script>
<?php endif; ?> <?php endif; ?>
</div> </div>
@@ -82,7 +68,7 @@ $_initials = mb_strtoupper(mb_substr($_apName, 0, 1, 'UTF-8'), 'UTF-8');
</div> </div>
<?php if (count($authorArticles) > 6): ?> <?php if (count($authorArticles) > 6): ?>
<p class="text-center mt-4"> <p class="text-center mt-4">
<a href="/" class="author-profile-link">Voir tous les articles →</a> <a href="/profil/<?= rawurlencode($_apSlug) ?>/article" class="author-profile-link">Voir tous les articles →</a>
</p> </p>
<?php endif; ?> <?php endif; ?>
<?php endif; ?> <?php endif; ?>
+4 -3
View File
@@ -55,9 +55,6 @@ ob_start();
<?php else: ?> <?php else: ?>
<span><?= htmlspecialchars(date('d/m/Y', strtotime((string)($post['published_at'] ?? $post['created_at'] ?? '')))) ?></span> <span><?= htmlspecialchars(date('d/m/Y', strtotime((string)($post['published_at'] ?? $post['created_at'] ?? '')))) ?></span>
<?php endif; ?> <?php endif; ?>
<?php if (function_exists('isAdmin') && isAdmin()): ?>
<a href="/edit/<?= htmlspecialchars($post['uuid']) ?>" class="post-entry-edit">modifier</a>
<?php endif; ?>
<?php if (!$isLocked): ?> <?php if (!$isLocked): ?>
<a href="<?= htmlspecialchars($postUrl) ?>" class="post-entry-read">→ lire</a> <a href="<?= htmlspecialchars($postUrl) ?>" class="post-entry-read">→ lire</a>
<?php endif; ?> <?php endif; ?>
@@ -125,6 +122,10 @@ if (!empty($_tagCats)):
</nav> </nav>
<?php endif; ?> <?php endif; ?>
<?php if (function_exists('isLoggedIn') && isLoggedIn()): ?>
<a href="/new" class="fab-new" title="Nouvel article" aria-label="Nouvel article">+</a>
<?php endif; ?>
<?php <?php
$content = ob_get_clean(); $content = ob_get_clean();
$title = siteTitle(); $title = siteTitle();
+1 -31
View File
@@ -100,37 +100,7 @@
<input type="hidden" name="order[]" value="<?= (int)$_link['id'] ?>"> <input type="hidden" name="order[]" value="<?= (int)$_link['id'] ?>">
<?php endforeach; ?> <?php endforeach; ?>
</form> </form>
<script> <script src="/assets/js/links-sortable.js" defer></script>
(function() {
const list = document.getElementById('links-sortable');
if (!list) return;
let dragged = null;
list.querySelectorAll('li').forEach(li => {
li.setAttribute('draggable', true);
li.addEventListener('dragstart', () => { dragged = li; li.style.opacity = '.4'; });
li.addEventListener('dragend', () => { dragged = null; li.style.opacity = ''; saveOrder(); });
li.addEventListener('dragover', e => { e.preventDefault(); const after = getDragAfter(list, e.clientY); after ? list.insertBefore(dragged, after) : list.appendChild(dragged); });
});
function getDragAfter(container, y) {
return [...container.querySelectorAll('li:not([style*="opacity"])')].reduce((closest, el) => {
const box = el.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
return offset < 0 && offset > closest.offset ? { offset, element: el } : closest;
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
function saveOrder() {
const form = document.getElementById('reorder-form');
if (!form) return;
form.querySelectorAll('input').forEach(i => i.remove());
list.querySelectorAll('li[data-id]').forEach(li => {
const inp = document.createElement('input');
inp.type = 'hidden'; inp.name = 'order[]'; inp.value = li.dataset.id;
form.appendChild(inp);
});
form.submit();
}
})();
</script>
<?php endif; ?> <?php endif; ?>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">