feat: agrégateur RSS /flux + gestion feeds dans /profile
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS rss_feeds (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_email TEXT NOT NULL,
|
||||||
|
feed_url TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMP DEFAULT now(),
|
||||||
|
UNIQUE (user_email, feed_url)
|
||||||
|
);
|
||||||
@@ -40,6 +40,9 @@ RewriteRule ^admin/?$ /index.php?action=admin [L,QSA]
|
|||||||
RewriteRule ^categories/?$ /index.php?action=categories [L,QSA]
|
RewriteRule ^categories/?$ /index.php?action=categories [L,QSA]
|
||||||
RewriteRule ^profile/?$ /index.php?action=profile [L,QSA]
|
RewriteRule ^profile/?$ /index.php?action=profile [L,QSA]
|
||||||
RewriteRule ^search/?$ /index.php?action=search [L,QSA]
|
RewriteRule ^search/?$ /index.php?action=search [L,QSA]
|
||||||
|
RewriteRule ^flux/?$ /index.php?action=flux [L,QSA]
|
||||||
|
RewriteRule ^feed/add/?$ /index.php?action=add_feed [L,QSA]
|
||||||
|
RewriteRule ^feed/delete/?$ /index.php?action=delete_feed [L,QSA]
|
||||||
|
|
||||||
# Profil public auteur
|
# Profil public auteur
|
||||||
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]
|
||||||
|
|||||||
@@ -1283,3 +1283,57 @@ footer.mt-5 { margin-top: 0 !important; }
|
|||||||
color: var(--vl-muted);
|
color: var(--vl-muted);
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Agrégateur de flux ─────────────────── */
|
||||||
|
|
||||||
|
.flux-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
max-width: 52rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flux-item {
|
||||||
|
border-left: 3px solid var(--vl-border);
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flux-item-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--vl-muted);
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flux-author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vl-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.flux-author:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.flux-feed-name::before { content: '·'; margin-right: .5rem; }
|
||||||
|
|
||||||
|
.flux-date::before { content: '·'; margin-right: .5rem; }
|
||||||
|
|
||||||
|
.flux-item-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flux-item-title a {
|
||||||
|
color: var(--vl-text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.flux-item-title a:hover { color: var(--vl-accent); }
|
||||||
|
|
||||||
|
.flux-item-summary {
|
||||||
|
font-size: .875rem;
|
||||||
|
color: var(--vl-muted);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|||||||
+86
-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'];
|
$_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'];
|
||||||
$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,44 @@ switch ($action) {
|
|||||||
include BASE_PATH . '/templates/author_profile.php';
|
include BASE_PATH . '/templates/author_profile.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'flux':
|
||||||
|
require_once BASE_PATH . '/src/FeedFetcher.php';
|
||||||
|
$fetcher = new FeedFetcher(BASE_PATH . '/data/_cache/feeds');
|
||||||
|
$fluxItems = [];
|
||||||
|
$pdo = dbPdo();
|
||||||
|
if ($pdo) {
|
||||||
|
try {
|
||||||
|
$st = $pdo->query(
|
||||||
|
'SELECT f.user_email, f.feed_url, f.label,
|
||||||
|
p.display_name, p.profile_slug
|
||||||
|
FROM rss_feeds f
|
||||||
|
LEFT JOIN user_profiles p ON p.email = f.user_email
|
||||||
|
ORDER BY f.created_at'
|
||||||
|
);
|
||||||
|
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $_row) {
|
||||||
|
$data = $fetcher->get($_row['feed_url']);
|
||||||
|
if (!$data) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$feedTitle = $_row['label'] !== '' ? $_row['label'] : $data['feed_title'];
|
||||||
|
$authorName = $_row['display_name'] ?? '';
|
||||||
|
$authorSlug = $_row['profile_slug'] ?? '';
|
||||||
|
foreach ($data['items'] as $_item) {
|
||||||
|
$fluxItems[] = array_merge($_item, [
|
||||||
|
'feed_title' => $feedTitle,
|
||||||
|
'feed_url' => $_row['feed_url'],
|
||||||
|
'author_name' => $authorName,
|
||||||
|
'author_slug' => $authorSlug,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($fluxItems, static fn ($a, $b) => $b['date'] <=> $a['date']);
|
||||||
|
include BASE_PATH . '/templates/flux.php';
|
||||||
|
break;
|
||||||
|
|
||||||
case 'about':
|
case 'about':
|
||||||
include BASE_PATH . '/templates/about.php';
|
include BASE_PATH . '/templates/about.php';
|
||||||
break;
|
break;
|
||||||
@@ -1840,9 +1878,56 @@ switch ($action) {
|
|||||||
if ($profileCurrentUrl === '' && $profileCurrentSlug !== '') {
|
if ($profileCurrentUrl === '' && $profileCurrentSlug !== '') {
|
||||||
$profileCurrentUrl = rtrim(APP_URL, '/') . '/profil/' . rawurlencode($profileCurrentSlug);
|
$profileCurrentUrl = rtrim(APP_URL, '/') . '/profil/' . rawurlencode($profileCurrentSlug);
|
||||||
}
|
}
|
||||||
|
// Feeds RSS de l'utilisateur
|
||||||
|
$profileFeeds = [];
|
||||||
|
$pdo = dbPdo();
|
||||||
|
if ($pdo) {
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare('SELECT id, feed_url, label FROM rss_feeds WHERE user_email = :e ORDER BY created_at');
|
||||||
|
$st->execute([':e' => currentUserEmail()]);
|
||||||
|
$profileFeeds = $st->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
include BASE_PATH . '/templates/profile.php';
|
include BASE_PATH . '/templates/profile.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'add_feed':
|
||||||
|
requireAuth();
|
||||||
|
$feedUrl = filter_var(trim($_POST['feed_url'] ?? ''), FILTER_VALIDATE_URL) ?: '';
|
||||||
|
$feedLabel = trim($_POST['feed_label'] ?? '');
|
||||||
|
if ($feedUrl !== '') {
|
||||||
|
$pdo = dbPdo();
|
||||||
|
if ($pdo) {
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare(
|
||||||
|
'INSERT INTO rss_feeds (user_email, feed_url, label) VALUES (:e, :u, :l)
|
||||||
|
ON CONFLICT (user_email, feed_url) DO UPDATE SET label = :l'
|
||||||
|
);
|
||||||
|
$st->execute([':e' => currentUserEmail(), ':u' => $feedUrl, ':l' => $feedLabel]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Location: /profile#feeds');
|
||||||
|
exit;
|
||||||
|
|
||||||
|
case 'delete_feed':
|
||||||
|
requireAuth();
|
||||||
|
$feedId = (int)($_POST['feed_id'] ?? 0);
|
||||||
|
if ($feedId > 0) {
|
||||||
|
$pdo = dbPdo();
|
||||||
|
if ($pdo) {
|
||||||
|
try {
|
||||||
|
$st = $pdo->prepare('DELETE FROM rss_feeds WHERE id = :id AND user_email = :e');
|
||||||
|
$st->execute([':id' => $feedId, ':e' => currentUserEmail()]);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header('Location: /profile#feeds');
|
||||||
|
exit;
|
||||||
|
|
||||||
case 'search_files':
|
case 'search_files':
|
||||||
requireAuth();
|
requireAuth();
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
class FeedFetcher
|
||||||
|
{
|
||||||
|
private const MIN_TTL = 900; // 15 min
|
||||||
|
private const MAX_TTL = 86400; // 24 h
|
||||||
|
|
||||||
|
public function __construct(private string $cacheDir)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne les items du feed (depuis le cache si valide, sinon refetch).
|
||||||
|
* @return array{items: array, feed_title: string, fetched_at: int, ttl: int}|null
|
||||||
|
*/
|
||||||
|
public function get(string $url): ?array
|
||||||
|
{
|
||||||
|
$cached = $this->cacheRead($url);
|
||||||
|
if ($cached !== null && time() < (int)$cached['fetched_at'] + (int)$cached['ttl']) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
return $this->fetch($url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Force le refetch et met le cache à jour. */
|
||||||
|
public function fetch(string $url): ?array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
CURLOPT_MAXREDIRS => 5,
|
||||||
|
CURLOPT_TIMEOUT => 10,
|
||||||
|
CURLOPT_USERAGENT => 'varlog/1.0 FeedFetcher (+' . (defined('APP_URL') ? APP_URL : '') . ')',
|
||||||
|
CURLOPT_HEADER => true,
|
||||||
|
]);
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$hSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($raw === false || !is_int($httpCode) || $httpCode < 200 || $httpCode >= 400) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headers = substr((string)$raw, 0, $hSize);
|
||||||
|
$body = substr((string)$raw, $hSize);
|
||||||
|
|
||||||
|
libxml_use_internal_errors(true);
|
||||||
|
$xml = simplexml_load_string($body);
|
||||||
|
libxml_clear_errors();
|
||||||
|
if ($xml === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isAtom = ($xml->getName() === 'feed');
|
||||||
|
$items = $isAtom ? $this->parseAtom($xml) : $this->parseRss($xml);
|
||||||
|
$feedTitle = $isAtom
|
||||||
|
? (string)($xml->title ?? '')
|
||||||
|
: (string)($xml->channel->title ?? '');
|
||||||
|
|
||||||
|
$ttl = $this->resolveTtl($xml, $isAtom, $headers);
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'feed_title' => $feedTitle,
|
||||||
|
'fetched_at' => time(),
|
||||||
|
'ttl' => $ttl,
|
||||||
|
'items' => $items,
|
||||||
|
];
|
||||||
|
$this->cacheWrite($url, $data);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
private function parseRss(\SimpleXMLElement $xml): array
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
foreach ($xml->channel->item ?? [] as $item) {
|
||||||
|
$date = (string)($item->pubDate ?? '');
|
||||||
|
$items[] = [
|
||||||
|
'title' => trim((string)($item->title ?? '')),
|
||||||
|
'url' => trim((string)($item->link ?? '')),
|
||||||
|
'summary' => $this->cleanSummary((string)($item->description ?? '')),
|
||||||
|
'date' => $date !== '' ? (int)strtotime($date) : 0,
|
||||||
|
'author' => trim((string)($item->author ?? '')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $this->sortItems($items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseAtom(\SimpleXMLElement $xml): array
|
||||||
|
{
|
||||||
|
$ns = $xml->getNamespaces(true);
|
||||||
|
$items = [];
|
||||||
|
foreach ($xml->entry ?? [] as $entry) {
|
||||||
|
$url = '';
|
||||||
|
foreach ($entry->link ?? [] as $link) {
|
||||||
|
$rel = (string)($link['rel'] ?? 'alternate');
|
||||||
|
if ($rel === 'alternate' || $rel === '') {
|
||||||
|
$url = (string)($link['href'] ?? '');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$date = (string)($entry->published ?? $entry->updated ?? '');
|
||||||
|
$author = (string)($entry->author->name ?? '');
|
||||||
|
$summary = (string)($entry->summary ?? $entry->content ?? '');
|
||||||
|
$items[] = [
|
||||||
|
'title' => trim((string)($entry->title ?? '')),
|
||||||
|
'url' => trim($url),
|
||||||
|
'summary' => $this->cleanSummary($summary),
|
||||||
|
'date' => $date !== '' ? (int)strtotime($date) : 0,
|
||||||
|
'author' => trim($author),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $this->sortItems($items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanSummary(string $html): string
|
||||||
|
{
|
||||||
|
$text = strip_tags($html);
|
||||||
|
$text = preg_replace('/\s+/', ' ', $text) ?? $text;
|
||||||
|
return mb_strimwidth(trim($text), 0, 200, '…');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sortItems(array $items): array
|
||||||
|
{
|
||||||
|
usort($items, static fn ($a, $b) => $b['date'] <=> $a['date']);
|
||||||
|
return $items;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTtl(\SimpleXMLElement $xml, bool $isAtom, string $headers): int
|
||||||
|
{
|
||||||
|
// 1. TTL déclaré dans le flux RSS (<ttl> en minutes)
|
||||||
|
if (!$isAtom) {
|
||||||
|
$rssttl = (int)($xml->channel->ttl ?? 0);
|
||||||
|
if ($rssttl > 0) {
|
||||||
|
return $this->clampTtl($rssttl * 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Cache-Control: max-age depuis les headers HTTP
|
||||||
|
if (preg_match('/max-age=(\d+)/i', $headers, $m)) {
|
||||||
|
return $this->clampTtl((int)$m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Valeur par défaut : 1 heure
|
||||||
|
return 3600;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function clampTtl(int $seconds): int
|
||||||
|
{
|
||||||
|
return max(self::MIN_TTL, min(self::MAX_TTL, $seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ //
|
||||||
|
|
||||||
|
private function cachePath(string $url): string
|
||||||
|
{
|
||||||
|
return $this->cacheDir . '/' . md5($url) . '.json';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cacheRead(string $url): ?array
|
||||||
|
{
|
||||||
|
$path = $this->cachePath($url);
|
||||||
|
if (!file_exists($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$data = json_decode((string)file_get_contents($path), true);
|
||||||
|
return is_array($data) ? $data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cacheWrite(string $url, array $data): void
|
||||||
|
{
|
||||||
|
if (!is_dir($this->cacheDir)) {
|
||||||
|
mkdir($this->cacheDir, 0755, true);
|
||||||
|
}
|
||||||
|
file_put_contents(
|
||||||
|
$this->cachePath($url),
|
||||||
|
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php ob_start(); ?>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-3 mb-5">
|
||||||
|
<h1 class="h4 mb-0">Flux agrégés</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (empty($fluxItems)): ?>
|
||||||
|
<p class="text-muted">Aucun article disponible pour l'instant.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="flux-list">
|
||||||
|
<?php foreach ($fluxItems as $_item):
|
||||||
|
$_date = $_item['date'] > 0 ? date('d/m/Y', $_item['date']) : '';
|
||||||
|
$_authorName = $_item['author_name'] ?? '';
|
||||||
|
$_authorSlug = $_item['author_slug'] ?? '';
|
||||||
|
?>
|
||||||
|
<article class="flux-item">
|
||||||
|
<div class="flux-item-meta">
|
||||||
|
<?php if ($_authorSlug !== ''): ?>
|
||||||
|
<a href="/profil/<?= rawurlencode($_authorSlug) ?>" class="flux-author"><?= htmlspecialchars($_authorName) ?></a>
|
||||||
|
<?php elseif ($_authorName !== ''): ?>
|
||||||
|
<span class="flux-author"><?= htmlspecialchars($_authorName) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($_item['feed_title'] !== ''): ?>
|
||||||
|
<span class="flux-feed-name"><?= htmlspecialchars($_item['feed_title']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if ($_date !== ''): ?>
|
||||||
|
<span class="flux-date"><?= $_date ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<h2 class="flux-item-title">
|
||||||
|
<a href="<?= htmlspecialchars($_item['url']) ?>" target="_blank" rel="noopener">
|
||||||
|
<?= htmlspecialchars($_item['title']) ?> ↗
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
<?php if ($_item['summary'] !== ''): ?>
|
||||||
|
<p class="flux-item-summary"><?= htmlspecialchars($_item['summary']) ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php
|
||||||
|
$content = ob_get_clean();
|
||||||
|
$title = 'Flux — ' . siteTitle();
|
||||||
|
$canonical = rtrim(APP_URL, '/') . '/flux';
|
||||||
|
$mainClass = 'container-fluid';
|
||||||
|
include __DIR__ . '/layout.php';
|
||||||
+48
-6
@@ -1,7 +1,9 @@
|
|||||||
<?php ob_start(); ?>
|
<?php ob_start(); ?>
|
||||||
|
|
||||||
<div class="d-flex align-items-center gap-3 mb-4">
|
<form method="post" action="/profile">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
|
||||||
<h1 class="h4 mb-0">Mon profil</h1>
|
<h1 class="h4 mb-0">Mon profil</h1>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">Enregistrer</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php if ($profileSuccess): ?>
|
<?php if ($profileSuccess): ?>
|
||||||
@@ -10,8 +12,6 @@
|
|||||||
<?php if ($profileError !== ''): ?>
|
<?php if ($profileError !== ''): ?>
|
||||||
<div class="alert alert-danger py-2 small mb-3"><?= htmlspecialchars($profileError) ?></div>
|
<div class="alert alert-danger py-2 small mb-3"><?= htmlspecialchars($profileError) ?></div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|
||||||
<form method="post" action="/profile">
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
|
|
||||||
<!-- Colonne gauche : identité -->
|
<!-- Colonne gauche : identité -->
|
||||||
@@ -36,9 +36,6 @@
|
|||||||
<label class="form-label fw-semibold text-muted">Email</label>
|
<label class="form-label fw-semibold text-muted">Email</label>
|
||||||
<input type="text" class="form-control" value="<?= htmlspecialchars(currentUserEmail() ?? '') ?>" disabled>
|
<input type="text" class="form-control" value="<?= htmlspecialchars(currentUserEmail() ?? '') ?>" disabled>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-auto">
|
|
||||||
<button type="submit" class="btn btn-primary w-100">Enregistrer</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,6 +66,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<!-- Flux RSS -->
|
||||||
|
<div class="mt-4" id="feeds">
|
||||||
|
<h2 class="h6 text-muted mb-3">Flux RSS</h2>
|
||||||
|
<div class="row g-3 align-items-start">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<?php if (!empty($profileFeeds)): ?>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<?php foreach ($profileFeeds as $_feed): ?>
|
||||||
|
<li class="list-group-item d-flex align-items-center gap-2 py-2">
|
||||||
|
<div class="flex-grow-1 min-w-0">
|
||||||
|
<div class="fw-semibold small text-truncate"><?= htmlspecialchars($_feed['label'] ?: $_feed['feed_url']) ?></div>
|
||||||
|
<div class="text-muted small text-truncate"><?= htmlspecialchars($_feed['feed_url']) ?></div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/feed/delete" class="flex-shrink-0">
|
||||||
|
<input type="hidden" name="feed_id" value="<?= (int)$_feed['id'] ?>">
|
||||||
|
<button class="btn btn-sm btn-outline-danger py-0" data-confirm="Supprimer ce flux ?">✕</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header small fw-semibold">Ajouter un flux</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="post" action="/feed/add">
|
||||||
|
<div class="mb-2">
|
||||||
|
<input type="url" name="feed_url" class="form-control form-control-sm"
|
||||||
|
placeholder="https://example.com/feed.xml" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" name="feed_label" class="form-control form-control-sm"
|
||||||
|
placeholder="Libellé (optionnel)" maxlength="100">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm w-100">Ajouter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<?php
|
<?php
|
||||||
$pdo = dbPdo();
|
$pdo = dbPdo();
|
||||||
$_profileRoles = [];
|
$_profileRoles = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user