feat: page Mes liens /liens/{slug} avec gestion et réordonnancement
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS profile_links (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_email TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL DEFAULT '',
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT now()
|
||||||
|
);
|
||||||
+5
-1
@@ -44,8 +44,12 @@ RewriteRule ^flux/?$ /index.php?action=flux [L,QSA]
|
|||||||
RewriteRule ^feed/add/?$ /index.php?action=add_feed [L,QSA]
|
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
|
# Profil public auteur + page liens
|
||||||
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 ^link/add/?$ /index.php?action=add_link [L,QSA]
|
||||||
|
RewriteRule ^link/delete/?$ /index.php?action=delete_link [L,QSA]
|
||||||
|
RewriteRule ^link/reorder/?$ /index.php?action=reorder_links [L,QSA]
|
||||||
|
|
||||||
# Pages statiques
|
# Pages statiques
|
||||||
RewriteRule ^about/?$ /index.php?action=about [L,QSA]
|
RewriteRule ^about/?$ /index.php?action=about [L,QSA]
|
||||||
|
|||||||
@@ -1286,6 +1286,101 @@ footer.mt-5 { margin-top: 0 !important; }
|
|||||||
font-size: .9375rem;
|
font-size: .9375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Page "Mes liens" ───────────────────── */
|
||||||
|
|
||||||
|
.liens-page {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2.5rem 1rem 4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liens-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liens-avatar {
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--vl-accent);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liens-name {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liens-bio {
|
||||||
|
font-size: .9rem;
|
||||||
|
color: var(--vl-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liens-list {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.liens-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: .2rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: .875rem 1.25rem;
|
||||||
|
border-radius: var(--vl-radius);
|
||||||
|
border: 1.5px solid var(--vl-border);
|
||||||
|
background: var(--vl-surface);
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--vl-text);
|
||||||
|
font-weight: 600;
|
||||||
|
transition: border-color .15s, background .15s, transform .1s;
|
||||||
|
box-shadow: var(--vl-shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liens-item:hover {
|
||||||
|
border-color: var(--vl-accent);
|
||||||
|
background: var(--vl-accent-soft);
|
||||||
|
color: var(--vl-accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: var(--vl-shadow-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.liens-item-title { font-size: 1rem; }
|
||||||
|
|
||||||
|
.liens-item-desc {
|
||||||
|
font-size: .8rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--vl-muted);
|
||||||
|
}
|
||||||
|
.liens-item:hover .liens-item-desc { color: var(--vl-accent); opacity: .8; }
|
||||||
|
|
||||||
|
.liens-footer {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--vl-muted);
|
||||||
|
}
|
||||||
|
.liens-footer a { color: inherit; }
|
||||||
|
.liens-footer a:hover { color: var(--vl-accent); }
|
||||||
|
|
||||||
/* ─── Agrégateur de flux ─────────────────── */
|
/* ─── Agrégateur de flux ─────────────────── */
|
||||||
|
|
||||||
.flux-list {
|
.flux-list {
|
||||||
|
|||||||
+98
-1
@@ -22,7 +22,7 @@ $action = $_GET['action'] ?? 'list';
|
|||||||
$uuid = $_GET['uuid'] ?? '';
|
$uuid = $_GET['uuid'] ?? '';
|
||||||
$slug = $_GET['slug'] ?? '';
|
$slug = $_GET['slug'] ?? '';
|
||||||
|
|
||||||
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed'];
|
$_noindexActions = ['create', 'edit', 'admin', 'categories', 'diff', 'add_files', 'import_image', 'import_image_step2', 'sources', 'profile', 'delete_file', 'delete_external_link', 'rename_category', 'delete_category', 'toggle_private_category', 'admin_save_site', 'not_found', 'add_feed', 'delete_feed', 'add_link', 'delete_link', 'reorder_links'];
|
||||||
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
|
$metaRobots = in_array($action, $_noindexActions, true) ? 'noindex, nofollow' : null;
|
||||||
unset($_noindexActions);
|
unset($_noindexActions);
|
||||||
|
|
||||||
@@ -881,6 +881,92 @@ switch ($action) {
|
|||||||
include BASE_PATH . '/templates/author_profile.php';
|
include BASE_PATH . '/templates/author_profile.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'liens':
|
||||||
|
$liensSlug = trim($_GET['slug'] ?? '');
|
||||||
|
$liensRow = profileBySlug($liensSlug);
|
||||||
|
if (!$liensRow) {
|
||||||
|
http_response_code(404);
|
||||||
|
$content = '<div class="container py-5"><p class="text-muted">Page introuvable.</p></div>';
|
||||||
|
$title = 'Page introuvable';
|
||||||
|
include BASE_PATH . '/templates/layout.php';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$_lName = $liensRow['display_name'] ?? '';
|
||||||
|
$_lBio = $liensRow['bio'] ?? '';
|
||||||
|
$_lSlug = $liensRow['profile_slug'] ?? '';
|
||||||
|
$_lInitials = mb_strtoupper(mb_substr($_lName, 0, 1, 'UTF-8'), 'UTF-8');
|
||||||
|
$profileLinks = [];
|
||||||
|
$pdo = dbPdo();
|
||||||
|
if ($pdo) {
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
'SELECT id, url, title, description FROM profile_links
|
||||||
|
WHERE user_email = :e ORDER BY position, id'
|
||||||
|
);
|
||||||
|
$st->execute([':e' => $liensRow['email']]);
|
||||||
|
$profileLinks = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
include BASE_PATH . '/templates/liens.php';
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'add_link':
|
||||||
|
requireAuth();
|
||||||
|
$linkUrl = filter_var(trim($_POST['link_url'] ?? ''), FILTER_VALIDATE_URL) ?: '';
|
||||||
|
$linkTitle = trim($_POST['link_title'] ?? '');
|
||||||
|
$linkDesc = trim($_POST['link_desc'] ?? '');
|
||||||
|
if ($linkUrl !== '') {
|
||||||
|
$pdo = dbPdo();
|
||||||
|
if ($pdo) {
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
'INSERT INTO profile_links (user_email, url, title, description, position)
|
||||||
|
VALUES (:e, :u, :t, :d,
|
||||||
|
COALESCE((SELECT MAX(position)+1 FROM profile_links WHERE user_email = :e), 0))'
|
||||||
|
);
|
||||||
|
$st->execute([':e' => currentUserEmail(), ':u' => $linkUrl, ':t' => $linkTitle, ':d' => $linkDesc]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Location: /profile#links');
|
||||||
|
exit;
|
||||||
|
|
||||||
|
case 'delete_link':
|
||||||
|
requireAuth();
|
||||||
|
$linkId = (int)($_POST['link_id'] ?? 0);
|
||||||
|
if ($linkId > 0) {
|
||||||
|
$pdo = dbPdo();
|
||||||
|
if ($pdo) {
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare('DELETE FROM profile_links WHERE id = :id AND user_email = :e');
|
||||||
|
$st->execute([':id' => $linkId, ':e' => currentUserEmail()]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Location: /profile#links');
|
||||||
|
exit;
|
||||||
|
|
||||||
|
case 'reorder_links':
|
||||||
|
requireAuth();
|
||||||
|
$order = $_POST['order'] ?? [];
|
||||||
|
if (is_array($order)) {
|
||||||
|
$pdo = dbPdo();
|
||||||
|
if ($pdo) {
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare('UPDATE profile_links SET position = :p WHERE id = :id AND user_email = :e');
|
||||||
|
foreach (array_values($order) as $pos => $id) {
|
||||||
|
$st->execute([':p' => $pos, ':id' => (int)$id, ':e' => currentUserEmail()]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Location: /profile#links');
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'flux':
|
case 'flux':
|
||||||
require_once BASE_PATH . '/src/FeedFetcher.php';
|
require_once BASE_PATH . '/src/FeedFetcher.php';
|
||||||
$fetcher = new FeedFetcher(BASE_PATH . '/data/_cache/feeds');
|
$fetcher = new FeedFetcher(BASE_PATH . '/data/_cache/feeds');
|
||||||
@@ -1878,6 +1964,17 @@ switch ($action) {
|
|||||||
if ($profileCurrentUrl === '' && $profileCurrentSlug !== '') {
|
if ($profileCurrentUrl === '' && $profileCurrentSlug !== '') {
|
||||||
$profileCurrentUrl = rtrim(APP_URL, '/') . '/profil/' . rawurlencode($profileCurrentSlug);
|
$profileCurrentUrl = rtrim(APP_URL, '/') . '/profil/' . rawurlencode($profileCurrentSlug);
|
||||||
}
|
}
|
||||||
|
// Liens de la page "Mes liens"
|
||||||
|
$profileLinks = [];
|
||||||
|
$pdo = dbPdo();
|
||||||
|
if ($pdo) {
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare('SELECT id, url, title, description FROM profile_links WHERE user_email = :e ORDER BY position, id');
|
||||||
|
$st->execute([':e' => currentUserEmail()]);
|
||||||
|
$profileLinks = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
// Feeds RSS de l'utilisateur
|
// Feeds RSS de l'utilisateur
|
||||||
$profileFeeds = [];
|
$profileFeeds = [];
|
||||||
$pdo = dbPdo();
|
$pdo = dbPdo();
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php ob_start(); ?>
|
||||||
|
|
||||||
|
<div class="liens-page">
|
||||||
|
|
||||||
|
<div class="liens-header">
|
||||||
|
<div class="liens-avatar"><?= htmlspecialchars($_lInitials) ?></div>
|
||||||
|
<h1 class="liens-name"><?= htmlspecialchars($_lName) ?></h1>
|
||||||
|
<?php if ($_lBio !== ''): ?>
|
||||||
|
<p class="liens-bio"><?= nl2br(htmlspecialchars($_lBio)) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="liens-list">
|
||||||
|
<?php foreach ($profileLinks as $_link): ?>
|
||||||
|
<a href="<?= htmlspecialchars($_link['url']) ?>" target="_blank" rel="noopener" class="liens-item">
|
||||||
|
<span class="liens-item-title"><?= htmlspecialchars($_link['title'] ?: $_link['url']) ?></span>
|
||||||
|
<?php if ($_link['description'] !== ''): ?>
|
||||||
|
<span class="liens-item-desc"><?= htmlspecialchars($_link['description']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</a>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($_lSlug !== ''): ?>
|
||||||
|
<div class="liens-footer">
|
||||||
|
<a href="/profil/<?= rawurlencode($_lSlug) ?>"><?= htmlspecialchars($_lName) ?> sur <?= htmlspecialchars(siteTitle()) ?></a>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$content = ob_get_clean();
|
||||||
|
$title = 'Liens de ' . htmlspecialchars($_lName) . ' — ' . siteTitle();
|
||||||
|
$seoTitle = $title;
|
||||||
|
$canonical = rtrim(APP_URL, '/') . '/liens/' . rawurlencode($_lSlug);
|
||||||
|
$ogUrl = $canonical;
|
||||||
|
$mainClass = 'container-fluid';
|
||||||
|
include __DIR__ . '/layout.php';
|
||||||
@@ -67,6 +67,97 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Flux RSS -->
|
<!-- Flux RSS -->
|
||||||
|
<!-- Mes liens -->
|
||||||
|
<div class="mt-4" id="links">
|
||||||
|
<div class="d-flex align-items-center gap-3 mb-3">
|
||||||
|
<h2 class="h6 text-muted mb-0">Mes liens</h2>
|
||||||
|
<?php if (($profileCurrentSlug ?? '') !== ''): ?>
|
||||||
|
<a href="/liens/<?= rawurlencode($profileCurrentSlug) ?>" class="small" target="_blank">↗ voir la page publique</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 align-items-start">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<?php if (!empty($profileLinks)): ?>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<ul class="list-group list-group-flush" id="links-sortable">
|
||||||
|
<?php foreach ($profileLinks as $_link): ?>
|
||||||
|
<li class="list-group-item d-flex align-items-center gap-2 py-2" data-id="<?= (int)$_link['id'] ?>">
|
||||||
|
<span class="drag-handle text-muted me-1" style="cursor:grab">⠿</span>
|
||||||
|
<div class="flex-grow-1 min-w-0">
|
||||||
|
<div class="fw-semibold small text-truncate"><?= htmlspecialchars($_link['title'] ?: $_link['url']) ?></div>
|
||||||
|
<div class="text-muted small text-truncate"><?= htmlspecialchars($_link['url']) ?></div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/link/delete" class="flex-shrink-0">
|
||||||
|
<input type="hidden" name="link_id" value="<?= (int)$_link['id'] ?>">
|
||||||
|
<button class="btn btn-sm btn-outline-danger py-0" data-confirm="Supprimer ce lien ?">✕</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/link/reorder" id="reorder-form" class="d-none">
|
||||||
|
<?php foreach ($profileLinks as $__i => $_link): ?>
|
||||||
|
<input type="hidden" name="order[]" value="<?= (int)$_link['id'] ?>">
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</form>
|
||||||
|
<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; ?>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header small fw-semibold">Ajouter un lien</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/link/add">
|
||||||
|
<div class="mb-2">
|
||||||
|
<input type="url" name="link_url" class="form-control form-control-sm"
|
||||||
|
placeholder="https://…" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<input type="text" name="link_title" class="form-control form-control-sm"
|
||||||
|
placeholder="Titre" maxlength="100">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" name="link_desc" class="form-control form-control-sm"
|
||||||
|
placeholder="Description courte (optionnel)" maxlength="200">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm w-100">Ajouter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4" id="feeds">
|
<div class="mt-4" id="feeds">
|
||||||
<h2 class="h6 text-muted mb-3">Flux RSS</h2>
|
<h2 class="h6 text-muted mb-3">Flux RSS</h2>
|
||||||
<div class="row g-3 align-items-start">
|
<div class="row g-3 align-items-start">
|
||||||
|
|||||||
Reference in New Issue
Block a user