Files
RSS-Feed/templates/manage.php
T
Harwinder Sekhon 41f07b6ad8 Add manual subreddit/YouTube channel configuration (manage.php)
Browser-based feed selection layered on top of config/feeds.php:

- manage.php (password + CSRF gated, session based): add subreddits
  (name / r/name / URL) and YouTube channels (@handle / UC id / URL,
  handle resolved to channel_id once, youtube.com only); enable/disable
  toggles; remove manual feeds; config feeds are hidden, not deleted.
- New feeds are validated with one immediate fetch (bypassing the
  per-refresh host cap): HTTP 404 rejects the entry, 429 keeps it for
  a later refresh. YouTube labels adopt the real channel title.
- feeds table gains origin (config|manual) and enabled columns, with
  a guarded ALTER migration for existing databases.
- Only enabled feeds are refreshed and served: getItems/countItems
  filter by feed_url in enabled feeds, so deselected feeds' cached
  stories are hidden, not deleted; re-adding a URL re-enables it.
- Dashboard header gets a Manage link; manage page styled with the
  existing design tokens; README documents the feature.
2026-10-05 18:58:38 -04:00

199 lines
11 KiB
PHP

<?php
declare(strict_types=1);
use App\View;
/** @var array<string, mixed> $config */
/** @var App\ManageAuth $auth */
/** @var array<int, array<string, mixed>> $feeds */
/** @var array{type: string, text: string}|null $flash */
$csrf = View::e($auth->csrfToken());
$feedGroups = [];
foreach ($feeds as $feed) {
$feedGroups[(string) $feed['source']][] = $feed;
}
$actionUrl = 'manage.php';
?>
<!doctype html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Manage feeds · RSS Feed Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link href="assets/style.css" rel="stylesheet">
</head>
<body class="app-body">
<header class="site-header">
<div class="container-xxl d-flex align-items-center justify-content-between gap-3 py-3">
<a class="brand" href="manage.php">
<span class="brand-mark" aria-hidden="true"></span>
<span class="brand-text">RSS <em>Feed</em> Dashboard</span>
</a>
<div class="d-flex align-items-center gap-3">
<span class="mono manage-title">Manage feeds</span>
<a class="manage-link" href="?">&larr; Dashboard</a>
</div>
</div>
<div class="spectrum-rail" aria-hidden="true"></div>
</header>
<main class="container-xxl py-4 manage-main">
<?php if (!$auth->enabled()): ?>
<div class="manage-panel manage-notice">
<h1 class="manage-heading">Management is disabled</h1>
<p class="mb-0">
Set <code class="mono">manage_password</code> in <code class="mono">config/app.php</code>
to enable this page.
</p>
</div>
<?php elseif (!$auth->authed()): ?>
<?php if ($flash !== null): ?>
<div class="alert alert-soft alert-dismissible fade show small" role="alert">
<?= View::e($flash['text']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<div class="manage-panel manage-login">
<h1 class="manage-heading">Sign in to manage feeds</h1>
<p class="text-body-secondary small">This page configures which subreddits and YouTube channels the dashboard follows.</p>
<form method="post" action="<?= View::e($actionUrl) ?>" class="d-flex gap-2">
<input type="hidden" name="csrf" value="<?= $csrf ?>">
<input type="hidden" name="action" value="login">
<input type="password" name="input" class="form-control mono" placeholder="Password" required autofocus autocomplete="current-password">
<button type="submit" class="btn btn-energy btn-compact">Sign in</button>
</form>
</div>
<?php else: ?>
<?php if ($flash !== null): ?>
<div class="alert alert-dismissible fade show small <?= $flash['type'] === 'ok' ? 'alert-ok-soft' : 'alert-soft' ?>" role="alert">
<?= View::e($flash['text']) ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php endif; ?>
<div class="row g-4 mb-4">
<div class="col-12 col-lg-6">
<div class="manage-panel">
<h2 class="manage-subheading">Add a subreddit</h2>
<form method="post" action="<?= View::e($actionUrl) ?>" class="d-flex gap-2">
<input type="hidden" name="csrf" value="<?= $csrf ?>">
<input type="hidden" name="action" value="add_reddit">
<input type="text" name="input" class="form-control mono" inputmode="url"
placeholder="r/homelab · homelab · reddit.com/r/homelab"
required maxlength="300" autocomplete="off">
<button type="submit" class="btn btn-energy btn-compact">Add</button>
</form>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="manage-panel">
<h2 class="manage-subheading">Add a YouTube channel</h2>
<form method="post" action="<?= View::e($actionUrl) ?>" class="d-flex gap-2">
<input type="hidden" name="csrf" value="<?= $csrf ?>">
<input type="hidden" name="action" value="add_youtube">
<input type="text" name="input" class="form-control mono" inputmode="url"
placeholder="@handle · UC… channel ID · channel URL"
required maxlength="300" autocomplete="off">
<button type="submit" class="btn btn-energy btn-compact">Add</button>
</form>
</div>
</div>
</div>
<?php if ($feedGroups === []): ?>
<div class="manage-panel manage-notice">
<p class="mb-0">No feeds registered yet — add one above, or edit <code class="mono">config/feeds.php</code>.</p>
</div>
<?php endif; ?>
<?php foreach ($feedGroups as $source => $groupFeeds): ?>
<section class="manage-group">
<h2 class="manage-group-title" style="--src: <?= View::e(View::sourceColor($source)) ?>">
<span class="pill-dot" aria-hidden="true"></span>
<?= View::e(View::sourceLabel($source)) ?>
<span class="mono count"><?= count($groupFeeds) ?></span>
</h2>
<div class="feed-rows">
<?php foreach ($groupFeeds as $feed): ?>
<?php
$url = (string) $feed['url'];
$label = (string) ($feed['label'] ?? $url);
$isEnabled = (int) $feed['enabled'] === 1;
$isConfig = (string) $feed['origin'] === 'config';
$lastError = $feed['last_error'] ?? null;
$lastSuccess = $feed['last_success_at'] ?? null;
$confirm = $isConfig
? sprintf('Hide "%s" from the dashboard? Its cached stories are kept.', $label)
: sprintf('Remove "%s"? Its cached stories stay hidden until it is added again.', $label);
?>
<div class="feed-row <?= $isEnabled ? '' : 'is-off' ?>" style="--src: <?= View::e(View::sourceColor($source)) ?>">
<div class="feed-row-main">
<span class="feed-row-label"><?= View::e($label) ?></span>
<span class="origin-badge mono"><?= $isConfig ? 'config' : 'manual' ?></span>
<a class="feed-row-url mono text-truncate" href="<?= View::e($url) ?>"
target="_blank" rel="noopener noreferrer"><?= View::e($url) ?></a>
<?php if (is_string($lastError) && $lastError !== ''): ?>
<span class="health health-err mono" title="<?= View::e($lastError) ?>">
<span class="health-dot" aria-hidden="true"></span>failed
</span>
<?php elseif (is_string($lastSuccess) && $lastSuccess !== ''): ?>
<span class="health health-ok mono" title="Last success: <?= View::e($lastSuccess) ?>">
<span class="health-dot" aria-hidden="true"></span><?= View::e(View::timeAgo($lastSuccess)) ?>
</span>
<?php else: ?>
<span class="health health-idle mono"><span class="health-dot" aria-hidden="true"></span>not fetched yet</span>
<?php endif; ?>
</div>
<div class="feed-row-actions">
<form method="post" action="<?= View::e($actionUrl) ?>">
<input type="hidden" name="csrf" value="<?= $csrf ?>">
<input type="hidden" name="action" value="toggle">
<input type="hidden" name="url" value="<?= View::e($url) ?>">
<input type="hidden" name="enabled" value="<?= $isEnabled ? '0' : '1' ?>">
<button type="submit" class="btn btn-mini <?= $isEnabled ? '' : 'btn-mini-on' ?>">
<?= $isEnabled ? 'Disable' : 'Enable' ?>
</button>
</form>
<form method="post" action="<?= View::e($actionUrl) ?>"
data-confirm="<?= View::e($confirm) ?>">
<input type="hidden" name="csrf" value="<?= $csrf ?>">
<input type="hidden" name="action" value="remove">
<input type="hidden" name="url" value="<?= View::e($url) ?>">
<button type="submit" class="btn btn-mini btn-mini-danger">
<?= $isConfig ? 'Hide' : 'Remove' ?>
</button>
</form>
</div>
</div>
<?php endforeach; ?>
</div>
</section>
<?php endforeach; ?>
<div class="d-flex justify-content-end mt-4">
<form method="post" action="<?= View::e($actionUrl) ?>">
<input type="hidden" name="csrf" value="<?= $csrf ?>">
<input type="hidden" name="action" value="logout">
<button type="submit" class="btn btn-mini">Sign out</button>
</form>
</div>
<?php endif; ?>
</main>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"
integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhykGkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="assets/manage.js" defer></script>
</body>
</html>