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.
This commit is contained in:
@@ -15,6 +15,9 @@ No framework, no build step. PHP + SQLite for caching, Bootstrap 5 for the UI.
|
|||||||
`fetched_at`), newest first.
|
`fetched_at`), newest first.
|
||||||
- Filters by **source** (`youtube` / `reddit` / `rt`) and **time range**
|
- Filters by **source** (`youtube` / `reddit` / `rt`) and **time range**
|
||||||
(all time, 1h, 2h, 6h, 24h, 7d).
|
(all time, 1h, 2h, 6h, 24h, 7d).
|
||||||
|
- Lets you **manually configure the subreddit and YouTube channel
|
||||||
|
selection** from the browser (`manage.php`, password protected): add
|
||||||
|
channels by `@handle`, disable or remove feeds — no config editing needed.
|
||||||
- Paginates with a **"Load more stories"** button (AJAX, 20 per page).
|
- Paginates with a **"Load more stories"** button (AJAX, 20 per page).
|
||||||
- Caches everything in **SQLite** so page loads never wait on the network; feeds
|
- Caches everything in **SQLite** so page loads never wait on the network; feeds
|
||||||
are refreshed on demand when stale (default TTL 30 min).
|
are refreshed on demand when stale (default TTL 30 min).
|
||||||
@@ -80,7 +83,9 @@ rm composer-setup.php
|
|||||||
### 3. Configure
|
### 3. Configure
|
||||||
|
|
||||||
- **Feeds** — edit `config/feeds.php` (see *Configuration* for the format and the
|
- **Feeds** — edit `config/feeds.php` (see *Configuration* for the format and the
|
||||||
YouTube `@handle` → `channel_id` note).
|
YouTube `@handle` → `channel_id` note), or skip this entirely and manage the
|
||||||
|
selection from the browser on `manage.php` (see *Managing feeds*; set its
|
||||||
|
password in `config/app.php`).
|
||||||
- **Runtime** — optionally tune `config/app.php` (page size, TTLs, ranges,
|
- **Runtime** — optionally tune `config/app.php` (page size, TTLs, ranges,
|
||||||
timeouts, user agent).
|
timeouts, user agent).
|
||||||
|
|
||||||
@@ -223,6 +228,8 @@ button when `has_more` is false.
|
|||||||
### Caching & refresh semantics
|
### Caching & refresh semantics
|
||||||
|
|
||||||
- `items` and `feeds` live in `data/feeds.sqlite` (WAL mode, outside the web root).
|
- `items` and `feeds` live in `data/feeds.sqlite` (WAL mode, outside the web root).
|
||||||
|
- Only **enabled** feeds are refreshed and served — disabling one from the
|
||||||
|
manage page stops both without deleting its cached items.
|
||||||
- A feed is **stale** when `now - last_attempt_at >= cache_ttl`, or, if its last
|
- A feed is **stale** when `now - last_attempt_at >= cache_ttl`, or, if its last
|
||||||
attempt errored, when `now - last_attempt_at >= error_retry_ttl + jitter`
|
attempt errored, when `now - last_attempt_at >= error_retry_ttl + jitter`
|
||||||
(jitter derived from `crc32(url)` so rate-limited feeds don't all retry at once).
|
(jitter derived from `crc32(url)` so rate-limited feeds don't all retry at once).
|
||||||
@@ -239,23 +246,29 @@ button when `has_more` is false.
|
|||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── index.php Page entry point: refresh, filter, render page 1
|
├── index.php Page entry point: refresh, filter, render page 1
|
||||||
|
├── manage.php Manual feed configuration (password protected)
|
||||||
├── api/items.php JSON endpoint for "Load more" (returns rendered HTML)
|
├── api/items.php JSON endpoint for "Load more" (returns rendered HTML)
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── app.php Runtime settings (see Configuration)
|
│ ├── app.php Runtime settings (see Configuration)
|
||||||
│ └── feeds.php The feed list: [{source, label, url}, ...]
|
│ └── feeds.php The default feed list: [{source, label, url}, ...]
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── bootstrap.php Wires config + classes together, returns the container
|
│ ├── bootstrap.php Wires config + classes together, returns the container
|
||||||
│ ├── Database.php PDO SQLite connection + schema migration
|
│ ├── Database.php PDO SQLite connection + schema migration
|
||||||
│ ├── FeedFetcher.php HTTP + parse + normalize a single feed
|
│ ├── FeedFetcher.php HTTP + parse + normalize a single feed
|
||||||
│ ├── FeedRepository.php All SQL: upsert/query/count, staleness, attempt state
|
│ ├── FeedRepository.php All SQL: upsert/query/count, staleness, feed management
|
||||||
│ ├── FeedService.php Orchestration: refreshStale(), page(), sources()
|
│ ├── FeedService.php Orchestration: refreshStale(), page(), sources()
|
||||||
|
│ ├── FeedManager.php Manual selection: addReddit()/addYouTube()/remove()/setEnabled()
|
||||||
|
│ ├── ChannelResolver.php YouTube @handle / channel URL → channel_id → Atom feed URL
|
||||||
|
│ ├── ManageAuth.php manage.php gate: password, CSRF, flash messages
|
||||||
│ └── View.php Escaping + render() + labels/colors/time formatting
|
│ └── View.php Escaping + render() + labels/colors/time formatting
|
||||||
├── templates/
|
├── templates/
|
||||||
│ ├── layout.php Full page shell, filters, grid, empty state, Load more
|
│ ├── layout.php Full page shell, filters, grid, empty state, Load more
|
||||||
|
│ ├── manage.php Feed management page (login, add forms, feed list)
|
||||||
│ └── cards.php One feed card partial (used by page + API)
|
│ └── cards.php One feed card partial (used by page + API)
|
||||||
├── assets/
|
├── assets/
|
||||||
│ ├── app.js Load-more, relative timestamps, filter propagation
|
│ ├── app.js Load-more, relative timestamps, filter propagation
|
||||||
│ └── style.css Design tokens + card/pill/header styling
|
│ ├── manage.js Confirm dialogs on the manage page
|
||||||
|
│ └── style.css Design tokens + card/pill/header/manage styling
|
||||||
├── data/ SQLite database (gitignored, created at runtime)
|
├── data/ SQLite database (gitignored, created at runtime)
|
||||||
└── vendor/ Composer deps (gitignored)
|
└── vendor/ Composer deps (gitignored)
|
||||||
```
|
```
|
||||||
@@ -266,10 +279,13 @@ button when `has_more` is false.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `App\Database` | Creates the SQLite file/dir, applies schema, sets WAL + busy timeout | `connect()` |
|
| `App\Database` | Creates the SQLite file/dir, applies schema, sets WAL + busy timeout | `connect()` |
|
||||||
| `App\FeedFetcher` | Fetch one feed (with host throttle + 5xx retry), parse with laminas, extract media fields, normalize to a plain array | `fetch()` |
|
| `App\FeedFetcher` | Fetch one feed (with host throttle + 5xx retry), parse with laminas, extract media fields, normalize to a plain array | `fetch()` |
|
||||||
| `App\FeedRepository` | Every SQL statement; returns arrays/DTO-less rows | `syncFeeds()`, `staleFeeds()`, `upsertItems()`, `recordAttempt()`, `getItems()`, `countItems()`, `feedsStatus()` |
|
| `App\FeedRepository` | Every SQL statement; returns arrays/DTO-less rows | `syncFeeds()`, `enabledFeeds()`, `staleFeeds()`, `upsertItems()`, `recordAttempt()`, `getItems()`, `countItems()`, `addFeed()`, `setFeedEnabled()`, `deleteFeed()`, `feedsStatus()` |
|
||||||
| `App\FeedService` | Business flow: refresh policy + paginated reads | `refreshStale()`, `page()`, `sources()`, `feedsStatus()` |
|
| `App\FeedService` | Business flow: refresh policy + paginated reads | `refreshStale()`, `page()`, `sources()`, `feedsStatus()` |
|
||||||
|
| `App\FeedManager` | Manual selection changes; validates new feeds with one immediate fetch (404 rejects the entry) | `addReddit()`, `addYouTube()`, `remove()`, `setEnabled()` |
|
||||||
|
| `App\ChannelResolver` | YouTube input (`@handle`, `UC…` ID or channel URL) → Atom feed URL; resolves handles once via the channel page (youtube.com only) | `resolve()` |
|
||||||
|
| `App\ManageAuth` | Session gate for `manage.php`: password login, CSRF tokens, flash messages | `login()`, `authed()`, `csrfToken()`, `checkCsrf()` |
|
||||||
| `App\View` | Output escaping, template rendering, source labels/colors, ranges, `timeAgo()` | `e()`, `render()`, `sourceLabel()`, `sourceColor()`, `rangeLabel()`, `timeAgo()` |
|
| `App\View` | Output escaping, template rendering, source labels/colors, ranges, `timeAgo()` | `e()`, `render()`, `sourceLabel()`, `sourceColor()`, `rangeLabel()`, `timeAgo()` |
|
||||||
| `src/bootstrap.php` | Builds and returns `['config','feeds','pdo','repository','fetcher','service']` | — |
|
| `src/bootstrap.php` | Builds and returns `['config','feeds','pdo','repository','fetcher','resolver','manager','service']` | — |
|
||||||
|
|
||||||
`FeedFetcher` notes: laminas does not expose the YouTube `media:` extension, so
|
`FeedFetcher` notes: laminas does not expose the YouTube `media:` extension, so
|
||||||
`extractExtras()` does a second DOM/XPath pass over the raw XML to pull
|
`extractExtras()` does a second DOM/XPath pass over the raw XML to pull
|
||||||
@@ -298,14 +314,19 @@ escapes again on output).
|
|||||||
Indexes: `idx_items_published(published_at DESC)`,
|
Indexes: `idx_items_published(published_at DESC)`,
|
||||||
`idx_items_source(source, published_at DESC)`.
|
`idx_items_source(source, published_at DESC)`.
|
||||||
|
|
||||||
`feeds` — one row per configured feed + health state:
|
`feeds` — one row per feed (config or manually added) + selection/health state:
|
||||||
|
|
||||||
| column | notes |
|
| column | notes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `url` | primary key |
|
| `url` | primary key |
|
||||||
| `source`, `label` | from `config/feeds.php` |
|
| `source`, `label` | from `config/feeds.php` or the manage page |
|
||||||
| `last_attempt_at`, `last_success_at` | ISO-8601 UTC |
|
| `last_attempt_at`, `last_success_at` | ISO-8601 UTC |
|
||||||
| `last_error` | last failure message, `NULL` on success |
|
| `last_error` | last failure message, `NULL` on success |
|
||||||
|
| `origin` | `config` (from `config/feeds.php`) or `manual` (added via manage.php) |
|
||||||
|
| `enabled` | `1` = selected; disabled feeds are neither refreshed nor shown |
|
||||||
|
|
||||||
|
Only items whose `feed_url` belongs to an enabled feed are served — disabling or
|
||||||
|
removing a feed hides its cached stories without deleting them.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -325,6 +346,7 @@ Indexes: `idx_items_published(published_at DESC)`,
|
|||||||
| `ranges` | `1h,2h,6h,24h,7d` | time-filter windows (key → seconds) |
|
| `ranges` | `1h,2h,6h,24h,7d` | time-filter windows (key → seconds) |
|
||||||
| `request_timeout` / `connect_timeout` | `15` / `10` | Guzzle timeouts |
|
| `request_timeout` / `connect_timeout` | `15` / `10` | Guzzle timeouts |
|
||||||
| `user_agent` | `RSSFeed/1.0 (...)` | sent to providers (matters for Reddit) |
|
| `user_agent` | `RSSFeed/1.0 (...)` | sent to providers (matters for Reddit) |
|
||||||
|
| `manage_password` | `rssfeed-admin` | password for `manage.php`; plain or `password_hash()` value, empty = management disabled |
|
||||||
|
|
||||||
`config/feeds.php` returns a list of feeds:
|
`config/feeds.php` returns a list of feeds:
|
||||||
|
|
||||||
@@ -343,12 +365,52 @@ Resolve a handle once (fetch `https://www.youtube.com/@handle` and read
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Managing feeds (manual configuration)
|
||||||
|
|
||||||
|
`manage.php` (linked as **Manage** in the dashboard header) configures the
|
||||||
|
subreddit/YouTube selection from the browser. It is password protected:
|
||||||
|
|
||||||
|
- Set `manage_password` in `config/app.php`. It accepts a plain password (fine
|
||||||
|
for a LAN/dev box) or a `password_hash()` value for better hygiene:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php -r 'echo password_hash("your secret", PASSWORD_DEFAULT), PHP_EOL;'
|
||||||
|
```
|
||||||
|
|
||||||
|
- An empty value disables the page (it shows a setup notice instead).
|
||||||
|
|
||||||
|
What you can do there:
|
||||||
|
|
||||||
|
| Action | Input | Behavior |
|
||||||
|
|---|---|---|
|
||||||
|
| Add subreddit | `homelab`, `r/homelab`, or any `reddit.com/r/…` URL | Normalized to `https://www.reddit.com/r/<name>/.rss`, labeled `r/<name>` |
|
||||||
|
| Add YouTube channel | `@handle`, `UC…` channel ID, or any channel URL | Handle resolved to `channel_id` once, stored as the Atom feed URL; the label becomes the real channel title |
|
||||||
|
| Disable / enable | toggle per feed | Disabled feeds are neither refreshed nor shown (their cached stories are kept and reappear when re-enabled) |
|
||||||
|
| Remove | manual feeds | Deleted from the selection; re-add anytime |
|
||||||
|
| Remove | config feeds | Hidden (`enabled = 0`) — `config/feeds.php` re-registers them on every sync, so they cannot be deleted from the UI |
|
||||||
|
|
||||||
|
Semantics worth knowing:
|
||||||
|
|
||||||
|
- Adding a feed **fetches it immediately** (bypassing the per-refresh Reddit
|
||||||
|
host cap): an `HTTP 404` rejects the entry ("not found, nothing was added"),
|
||||||
|
while a rate-limited (`429`) add is kept and fills in on a later refresh.
|
||||||
|
- Re-adding an existing feed URL **re-enables** it.
|
||||||
|
- Handle resolution only ever requests `youtube.com` pages — user input can
|
||||||
|
never point the resolver at arbitrary URLs.
|
||||||
|
- All POST actions are CSRF-protected (session token) and use POST-then-redirect.
|
||||||
|
- `config/feeds.php` remains the default set and keeps working as before; the
|
||||||
|
manage page layers manual feeds and on/off state on top of it (persisted in
|
||||||
|
the `feeds` table, so they survive restarts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Routes & query parameters
|
## Routes & query parameters
|
||||||
|
|
||||||
| Route | Params | Returns |
|
| Route | Params | Returns |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `index.php` (`/RSSFeed/`) | `source` (optional), `range` (optional) | full HTML page, page 1 |
|
| `index.php` (`/RSSFeed/`) | `source` (optional), `range` (optional) | full HTML page, page 1 |
|
||||||
| `api/items.php` | `page` (default 1), `source`, `range` | JSON `{ html, page, page_size, count, total, has_more, range, refresh }` |
|
| `api/items.php` | `page` (default 1), `source`, `range` | JSON `{ html, page, page_size, count, total, has_more, range, refresh }` |
|
||||||
|
| `manage.php` | — (POST: `login`, `logout`, `add_reddit`, `add_youtube`, `toggle`, `remove`) | management page (password gated) |
|
||||||
|
|
||||||
- `source` must match a `source` in `config/feeds.php`, else ignored.
|
- `source` must match a `source` in `config/feeds.php`, else ignored.
|
||||||
- `range` must be a key in `ranges`, else ignored.
|
- `range` must be a key in `ranges`, else ignored.
|
||||||
@@ -358,9 +420,10 @@ Resolve a handle once (fetch `https://www.youtube.com/@handle` and read
|
|||||||
|
|
||||||
## Adding feeds and sources
|
## Adding feeds and sources
|
||||||
|
|
||||||
**New feed (existing source):** add an entry to `config/feeds.php`. `syncFeeds()`
|
**New feed (existing source):** either add it on the **manage page** (no code
|
||||||
registers it automatically on the next request; it will be fetched on the next
|
editing, see *Managing feeds*) or add an entry to `config/feeds.php`.
|
||||||
refresh.
|
`syncFeeds()` registers config entries automatically on the next request; they
|
||||||
|
will be fetched on the next refresh.
|
||||||
|
|
||||||
**New source type** (e.g. `hackernews`): add feeds with `'source' => 'hackernews'`,
|
**New source type** (e.g. `hackernews`): add feeds with `'source' => 'hackernews'`,
|
||||||
then teach the UI its identity in `src/View.php`:
|
then teach the UI its identity in `src/View.php`:
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
document.querySelectorAll('form[data-confirm]').forEach(function (form) {
|
||||||
|
form.addEventListener('submit', function (event) {
|
||||||
|
if (!window.confirm(form.dataset.confirm)) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -419,6 +419,254 @@ main {
|
|||||||
color: #7a5a12;
|
color: #7a5a12;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alert-ok-soft {
|
||||||
|
border: 1px solid #b7ecd2;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #ecfbf3;
|
||||||
|
color: #14624a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Manage page ---------- */
|
||||||
|
|
||||||
|
.manage-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.38rem 0.85rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-link:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 8px 18px -12px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-title {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-main {
|
||||||
|
max-width: 920px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-panel {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 18px;
|
||||||
|
padding: 1.25rem 1.4rem;
|
||||||
|
box-shadow: 0 1px 2px rgba(22, 11, 46, 0.04), 0 14px 34px -26px rgba(22, 11, 46, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-notice {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 3rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-login {
|
||||||
|
max-width: 460px;
|
||||||
|
margin: 3rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-heading {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-subheading {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-compact {
|
||||||
|
padding: 0.5rem 1.35rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-group {
|
||||||
|
margin-bottom: 1.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-group-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: 1.02rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-group-title .pill-dot {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--src);
|
||||||
|
}
|
||||||
|
|
||||||
|
.manage-group-title .count {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
background: rgba(22, 11, 46, 0.06);
|
||||||
|
padding: 0.12rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-rows {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.6rem 0.9rem;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-left: 4px solid var(--src);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 0.7rem 1rem;
|
||||||
|
transition: box-shadow 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-row:hover {
|
||||||
|
box-shadow: 0 12px 26px -18px rgba(22, 11, 46, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-row.is-off {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-row-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.35rem 0.6rem;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-row-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.origin-badge {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
background: rgba(22, 11, 46, 0.06);
|
||||||
|
padding: 0.16rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-row-url {
|
||||||
|
font-size: 0.66rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
text-decoration: none;
|
||||||
|
max-width: 36ch;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-row-url:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.32rem;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-dot {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-ok {
|
||||||
|
color: var(--rt);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-err {
|
||||||
|
color: var(--youtube);
|
||||||
|
}
|
||||||
|
|
||||||
|
.health-idle {
|
||||||
|
color: var(--ink-soft);
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feed-row-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.75);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.3rem 0.85rem;
|
||||||
|
transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini:hover,
|
||||||
|
.btn-mini:focus {
|
||||||
|
color: var(--ink);
|
||||||
|
border-color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini-on {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: rgba(106, 61, 255, 0.45);
|
||||||
|
background: rgba(106, 61, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini-on:hover,
|
||||||
|
.btn-mini-on:focus {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(106, 61, 255, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-mini-danger:hover,
|
||||||
|
.btn-mini-danger:focus {
|
||||||
|
color: var(--youtube);
|
||||||
|
border-color: rgba(255, 31, 75, 0.5);
|
||||||
|
background: rgba(255, 31, 75, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Focus & motion ---------- */
|
/* ---------- Focus & motion ---------- */
|
||||||
|
|
||||||
a:focus-visible,
|
a:focus-visible,
|
||||||
|
|||||||
@@ -35,4 +35,9 @@ return [
|
|||||||
|
|
||||||
// Identify the client to feed providers (important for Reddit).
|
// Identify the client to feed providers (important for Reddit).
|
||||||
'user_agent' => 'RSSFeed/1.0 (+https://localhost; contact: admin@localhost)',
|
'user_agent' => 'RSSFeed/1.0 (+https://localhost; contact: admin@localhost)',
|
||||||
|
|
||||||
|
// Password for manage.php (manual subreddit/channel configuration).
|
||||||
|
// Accepts a plain password or a password_hash() value; empty disables the
|
||||||
|
// manage page entirely. Change this before exposing the app publicly.
|
||||||
|
'manage_password' => 'rssfeed-admin',
|
||||||
];
|
];
|
||||||
|
|||||||
+71
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual feed configuration: sign in, then add/remove/toggle subreddits and
|
||||||
|
* YouTube channels. POST actions redirect (PRG); results surface as flash.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @var array<string, mixed> $app */
|
||||||
|
$app = require __DIR__ . '/src/bootstrap.php';
|
||||||
|
|
||||||
|
$config = $app['config'];
|
||||||
|
$manager = $app['manager'];
|
||||||
|
$service = $app['service'];
|
||||||
|
|
||||||
|
$auth = new App\ManageAuth($config);
|
||||||
|
|
||||||
|
$redirect = static function (): never {
|
||||||
|
header('Location: manage.php', true, 303);
|
||||||
|
exit;
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$token = is_string($_POST['csrf'] ?? null) ? $_POST['csrf'] : '';
|
||||||
|
$action = is_string($_POST['action'] ?? null) ? $_POST['action'] : '';
|
||||||
|
$input = is_string($_POST['input'] ?? null) ? trim($_POST['input']) : '';
|
||||||
|
$url = is_string($_POST['url'] ?? null) ? trim($_POST['url']) : '';
|
||||||
|
$enabled = is_string($_POST['enabled'] ?? null) && $_POST['enabled'] === '1';
|
||||||
|
|
||||||
|
if (!$auth->enabled() || !$auth->checkCsrf($token)) {
|
||||||
|
$auth->flash('err', 'Session expired — please try again.');
|
||||||
|
$redirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'logout') {
|
||||||
|
$auth->logout();
|
||||||
|
$auth->flash('ok', 'Signed out.');
|
||||||
|
$redirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'login') {
|
||||||
|
if (!$auth->login($input)) {
|
||||||
|
$auth->flash('err', 'Wrong password.');
|
||||||
|
}
|
||||||
|
$redirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $auth->authed() ? match ($action) {
|
||||||
|
'add_reddit' => $manager->addReddit($input),
|
||||||
|
'add_youtube' => $manager->addYouTube($input),
|
||||||
|
'toggle' => $manager->setEnabled($url, $enabled),
|
||||||
|
'remove' => $manager->remove($url),
|
||||||
|
default => null,
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
if ($result === null) {
|
||||||
|
$auth->flash('err', 'Please sign in first.');
|
||||||
|
} else {
|
||||||
|
$auth->flash($result['ok'] ? 'ok' : 'err', $result['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$redirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
echo App\View::render('manage', [
|
||||||
|
'config' => $config,
|
||||||
|
'auth' => $auth,
|
||||||
|
'feeds' => $auth->authed() ? $service->feedsStatus() : [],
|
||||||
|
'flash' => $auth->takeFlash(),
|
||||||
|
]);
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves user-supplied YouTube input (channel ID, @handle or channel URL)
|
||||||
|
* to the channel's Atom feed URL. YouTube serves that feed only by
|
||||||
|
* channel_id, so handles are resolved once via the channel page.
|
||||||
|
*/
|
||||||
|
final class ChannelResolver
|
||||||
|
{
|
||||||
|
private Client $http;
|
||||||
|
|
||||||
|
public function __construct(private array $options = [])
|
||||||
|
{
|
||||||
|
$this->http = new Client([
|
||||||
|
'timeout' => $options['request_timeout'] ?? 15,
|
||||||
|
'connect_timeout' => $options['connect_timeout'] ?? 10,
|
||||||
|
'http_errors' => false,
|
||||||
|
'allow_redirects' => true,
|
||||||
|
'headers' => [
|
||||||
|
'User-Agent' => $options['user_agent'] ?? 'RSSFeed/1.0',
|
||||||
|
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||||
|
'Accept-Language' => 'en-US,en;q=0.9',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{url: string, label: string}
|
||||||
|
*/
|
||||||
|
public function resolve(string $input): array
|
||||||
|
{
|
||||||
|
$input = trim($input);
|
||||||
|
|
||||||
|
if ($input === '') {
|
||||||
|
throw new \RuntimeException('Enter a channel ID, @handle or channel URL.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('~^(?:https?://)?(?:[a-z0-9-]+\.)*youtube\.com/~i', $input) && parse_url($input, PHP_URL_HOST) === false) {
|
||||||
|
$input = 'https://' . $input;
|
||||||
|
}
|
||||||
|
|
||||||
|
$channelId = $this->channelIdFromInput($input);
|
||||||
|
|
||||||
|
if ($channelId === null) {
|
||||||
|
$channelId = $this->resolveFromChannelPage($input);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The label starts as the handle/ID; FeedManager replaces it with the
|
||||||
|
// real channel title after the first fetch of the feed.
|
||||||
|
return [
|
||||||
|
'url' => 'https://www.youtube.com/feeds/videos.xml?channel_id=' . $channelId,
|
||||||
|
'label' => $channelId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a channel ID directly from raw input or a YouTube URL.
|
||||||
|
*/
|
||||||
|
private function channelIdFromInput(string $input): ?string
|
||||||
|
{
|
||||||
|
if (preg_match('~^(UC[A-Za-z0-9_-]{22})$~', $input, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = strtolower((string) parse_url($input, PHP_URL_HOST));
|
||||||
|
if ($host === '' || (!str_ends_with($host, 'youtube.com'))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('~[?&]channel_id=(UC[A-Za-z0-9_-]{22})~', $input, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('~/channel/(UC[A-Za-z0-9_-]{22})~', $input, $m)) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve @handle and /c/name style inputs by fetching the channel page
|
||||||
|
* (or the given channel URL) once and extracting its channel ID.
|
||||||
|
*/
|
||||||
|
private function resolveFromChannelPage(string $input): string
|
||||||
|
{
|
||||||
|
$pageUrl = $this->channelPageUrl($input);
|
||||||
|
|
||||||
|
$response = $this->http->get($pageUrl);
|
||||||
|
$status = $response->getStatusCode();
|
||||||
|
|
||||||
|
if ($status !== 200) {
|
||||||
|
throw new \RuntimeException(sprintf('YouTube returned HTTP %d for %s', $status, $pageUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
$html = (string) $response->getBody();
|
||||||
|
$channelId = null;
|
||||||
|
|
||||||
|
if (preg_match('~itemprop="identifier"\s+content="(UC[A-Za-z0-9_-]{22})"~', $html, $m)) {
|
||||||
|
$channelId = $m[1];
|
||||||
|
} elseif (preg_match('~"(?:externalChannelId|channelId)":"(UC[A-Za-z0-9_-]{22})"~', $html, $m)) {
|
||||||
|
$channelId = $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($channelId === null) {
|
||||||
|
throw new \RuntimeException(sprintf('Could not find a channel ID for %s', $pageUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $channelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function channelPageUrl(string $input): string
|
||||||
|
{
|
||||||
|
if (preg_match('~^@([A-Za-z0-9._-]{3,30})/?$~', $input, $m)) {
|
||||||
|
return 'https://www.youtube.com/@' . $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
$host = strtolower((string) parse_url($input, PHP_URL_HOST));
|
||||||
|
if ($host !== '' && str_ends_with($host, 'youtube.com')) {
|
||||||
|
$path = (string) parse_url($input, PHP_URL_PATH);
|
||||||
|
|
||||||
|
if (preg_match('~^/@([A-Za-z0-9._%-]{3,30})/?$~', rawurldecode($path), $m)) {
|
||||||
|
return 'https://www.youtube.com/@' . $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('~^/(c|user)/([A-Za-z0-9._-]{3,30})/?$~', $path, $m)) {
|
||||||
|
return 'https://www.youtube.com/' . $m[1] . '/' . $m[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new \RuntimeException(sprintf('"%s" is not a channel ID, @handle or YouTube channel URL.', $input));
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-1
@@ -58,8 +58,26 @@ final class Database
|
|||||||
label TEXT,
|
label TEXT,
|
||||||
last_attempt_at TEXT,
|
last_attempt_at TEXT,
|
||||||
last_success_at TEXT,
|
last_success_at TEXT,
|
||||||
last_error TEXT
|
last_error TEXT,
|
||||||
|
origin TEXT NOT NULL DEFAULT 'config',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1
|
||||||
)
|
)
|
||||||
SQL);
|
SQL);
|
||||||
|
|
||||||
|
// SQLite has no "ADD COLUMN IF NOT EXISTS": migrate pre-existing tables.
|
||||||
|
self::addColumn($pdo, 'feeds', 'origin', "ALTER TABLE feeds ADD COLUMN origin TEXT NOT NULL DEFAULT 'config'");
|
||||||
|
self::addColumn($pdo, 'feeds', 'enabled', 'ALTER TABLE feeds ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function addColumn(PDO $pdo, string $table, string $column, string $sql): void
|
||||||
|
{
|
||||||
|
$existing = [];
|
||||||
|
foreach ($pdo->query(sprintf('PRAGMA table_info(%s)', $table)) as $row) {
|
||||||
|
$existing[strtolower((string) $row['name'])] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($existing[strtolower($column)])) {
|
||||||
|
$pdo->exec($sql);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual feed selection: add, remove and toggle feeds, validating new
|
||||||
|
* entries with one immediate fetch so typos are rejected right away.
|
||||||
|
*/
|
||||||
|
final class FeedManager
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private FeedRepository $repository,
|
||||||
|
private FeedFetcher $fetcher,
|
||||||
|
private ChannelResolver $resolver,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok: bool, message: string}
|
||||||
|
*/
|
||||||
|
public function addReddit(string $input): array
|
||||||
|
{
|
||||||
|
$name = self::subredditName($input);
|
||||||
|
|
||||||
|
if ($name === null) {
|
||||||
|
return ['ok' => false, 'message' => sprintf('"%s" is not a subreddit name or reddit.com URL.', trim($input))];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reddit feed titles are verbose ("Linux, GNU/Linux, free software…");
|
||||||
|
// the clean r/name form matches how config/feeds.php labels them.
|
||||||
|
return $this->addFeed('reddit', 'r/' . $name, 'https://www.reddit.com/r/' . $name . '/.rss', trim($input), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok: bool, message: string}
|
||||||
|
*/
|
||||||
|
public function addYouTube(string $input): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$resolved = $this->resolver->resolve($input);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['ok' => false, 'message' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->addFeed('youtube', $resolved['label'], $resolved['url'], trim($input), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a feed. Manual feeds are deleted; config feeds can only be
|
||||||
|
* disabled here because config/feeds.php re-registers them on every sync.
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, message: string}
|
||||||
|
*/
|
||||||
|
public function remove(string $url): array
|
||||||
|
{
|
||||||
|
$feed = $this->repository->findFeed($url);
|
||||||
|
|
||||||
|
if ($feed === null) {
|
||||||
|
return ['ok' => false, 'message' => 'That feed is not registered.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = (string) ($feed['label'] ?? $url);
|
||||||
|
|
||||||
|
if ((string) $feed['origin'] === 'config') {
|
||||||
|
$this->repository->setFeedEnabled($url, false);
|
||||||
|
|
||||||
|
return ['ok' => true, 'message' => sprintf('Disabled "%s" (it is defined in config/feeds.php, so it is hidden rather than deleted).', $label)];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->repository->deleteFeed($url);
|
||||||
|
|
||||||
|
return ['ok' => true, 'message' => sprintf('Removed "%s" (cached stories stay hidden until it is added again).', $label)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{ok: bool, message: string}
|
||||||
|
*/
|
||||||
|
public function setEnabled(string $url, bool $enabled): array
|
||||||
|
{
|
||||||
|
$feed = $this->repository->findFeed($url);
|
||||||
|
|
||||||
|
if ($feed === null) {
|
||||||
|
return ['ok' => false, 'message' => 'That feed is not registered.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->repository->setFeedEnabled($url, $enabled);
|
||||||
|
|
||||||
|
return ['ok' => true, 'message' => sprintf('%s "%s".', $enabled ? 'Enabled' : 'Disabled', (string) ($feed['label'] ?? $url))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the feed, then fetch it once directly (bypassing the refresh
|
||||||
|
* host cap, since the user asked for this feed explicitly): a 404 rejects
|
||||||
|
* the entry, anything else keeps it and lets the normal refresh retry.
|
||||||
|
*
|
||||||
|
* @return array{ok: bool, message: string}
|
||||||
|
*/
|
||||||
|
private function addFeed(string $source, string $label, string $url, string $displayInput, bool $labelFromTitle): array
|
||||||
|
{
|
||||||
|
$this->repository->addFeed($source, $label, $url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->fetcher->fetch(['source' => $source, 'label' => $label, 'url' => $url]);
|
||||||
|
|
||||||
|
$title = trim((string) ($result['title'] ?? ''));
|
||||||
|
if ($labelFromTitle && $title !== '') {
|
||||||
|
$label = $title;
|
||||||
|
$this->repository->setFeedLabel($url, $title);
|
||||||
|
|
||||||
|
// Cache the new items under the real name, not the input handle/ID.
|
||||||
|
foreach ($result['items'] as &$item) {
|
||||||
|
$item['feed_label'] = $title;
|
||||||
|
}
|
||||||
|
unset($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
$newItems = $this->repository->upsertItems($result['items']);
|
||||||
|
$this->repository->recordAttempt($url, true);
|
||||||
|
|
||||||
|
return ['ok' => true, 'message' => sprintf('Added "%s" — %d stories cached.', $label, $newItems)];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$error = $e->getMessage();
|
||||||
|
$this->repository->recordAttempt($url, false, $error);
|
||||||
|
|
||||||
|
if (str_contains($error, 'HTTP 404')) {
|
||||||
|
$this->repository->deleteFeed($url);
|
||||||
|
|
||||||
|
return ['ok' => false, 'message' => sprintf('"%s" was not found (HTTP 404); nothing was added.', $displayInput)];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['ok' => true, 'message' => sprintf('Added "%s" — not fetched yet (%s); it will fill in on a later refresh.', $label, $error)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize "name", "r/name" or any reddit.com/r/name URL to the
|
||||||
|
* subreddit name, or null when the input cannot be a subreddit.
|
||||||
|
*/
|
||||||
|
private static function subredditName(string $input): ?string
|
||||||
|
{
|
||||||
|
$input = trim($input);
|
||||||
|
|
||||||
|
if ($input === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('~^(?:https?://)?(?:[a-z0-9-]+\.)*reddit\.com/r/([A-Za-z0-9_]{2,21})(?:[/?#]|$)~i', $input, $m)) {
|
||||||
|
return strtolower($m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('~^(?:r/)?([A-Za-z0-9_]{2,21})/?$~i', $input, $m)) {
|
||||||
|
return strtolower($m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
-9
@@ -13,16 +13,21 @@ final class FeedRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep the feeds table in sync with the configured feed list.
|
* Keep the feeds table in sync with the configured feed list. Manual feeds
|
||||||
|
* and the enabled flag of existing rows are never touched, so a disabled
|
||||||
|
* config feed stays disabled across syncs.
|
||||||
*
|
*
|
||||||
* @param array<int, array<string, mixed>> $feeds
|
* @param array<int, array<string, mixed>> $feeds
|
||||||
*/
|
*/
|
||||||
public function syncFeeds(array $feeds): void
|
public function syncFeeds(array $feeds): void
|
||||||
{
|
{
|
||||||
$stmt = $this->pdo->prepare(<<<'SQL'
|
$stmt = $this->pdo->prepare(<<<'SQL'
|
||||||
INSERT INTO feeds (url, source, label)
|
INSERT INTO feeds (url, source, label, origin)
|
||||||
VALUES (:url, :source, :label)
|
VALUES (:url, :source, :label, 'config')
|
||||||
ON CONFLICT(url) DO UPDATE SET source = excluded.source, label = excluded.label
|
ON CONFLICT(url) DO UPDATE SET
|
||||||
|
source = excluded.source,
|
||||||
|
label = excluded.label,
|
||||||
|
origin = 'config'
|
||||||
SQL);
|
SQL);
|
||||||
|
|
||||||
foreach ($feeds as $feed) {
|
foreach ($feeds as $feed) {
|
||||||
@@ -34,6 +39,67 @@ final class FeedRepository
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feeds currently selected for the dashboard, in the normalized shape the
|
||||||
|
* refresher and sources() expect (insert order: config feeds, then manual).
|
||||||
|
*
|
||||||
|
* @return array<int, array{url: string, source: string, label: string}>
|
||||||
|
*/
|
||||||
|
public function enabledFeeds(): array
|
||||||
|
{
|
||||||
|
return $this->pdo->query(
|
||||||
|
'SELECT url, COALESCE(label, url) AS label, source FROM feeds WHERE enabled = 1 ORDER BY rowid'
|
||||||
|
)->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a feed manually, or re-enable it when its URL already exists.
|
||||||
|
*/
|
||||||
|
public function addFeed(string $source, string $label, string $url, string $origin = 'manual'): void
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare(<<<'SQL'
|
||||||
|
INSERT INTO feeds (url, source, label, origin, enabled)
|
||||||
|
VALUES (:url, :source, :label, :origin, 1)
|
||||||
|
ON CONFLICT(url) DO UPDATE SET
|
||||||
|
source = excluded.source,
|
||||||
|
label = excluded.label,
|
||||||
|
origin = excluded.origin,
|
||||||
|
enabled = 1
|
||||||
|
SQL);
|
||||||
|
|
||||||
|
$stmt->execute([':url' => $url, ':source' => $source, ':label' => $label, ':origin' => $origin]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFeedEnabled(string $url, bool $enabled): void
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('UPDATE feeds SET enabled = :enabled WHERE url = :url');
|
||||||
|
$stmt->execute([':enabled' => $enabled ? 1 : 0, ':url' => $url]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setFeedLabel(string $url, string $label): void
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('UPDATE feeds SET label = :label WHERE url = :url');
|
||||||
|
$stmt->execute([':label' => $label, ':url' => $url]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{url: string, source: string, label: string, origin: string, enabled: int}|null
|
||||||
|
*/
|
||||||
|
public function findFeed(string $url): ?array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('SELECT url, source, label, origin, enabled FROM feeds WHERE url = :url');
|
||||||
|
$stmt->execute([':url' => $url]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
|
||||||
|
return $row === false ? null : $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteFeed(string $url): void
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('DELETE FROM feeds WHERE url = :url');
|
||||||
|
$stmt->execute([':url' => $url]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the subset of feeds whose last attempt is missing or older than TTL.
|
* Return the subset of feeds whose last attempt is missing or older than TTL.
|
||||||
* Feeds that previously failed use a shorter retry interval.
|
* Feeds that previously failed use a shorter retry interval.
|
||||||
@@ -142,13 +208,15 @@ final class FeedRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the shared WHERE clause for item queries.
|
* Build the shared WHERE clause for item queries. Items only ever surface
|
||||||
|
* from feeds that are currently enabled, so deselecting a feed hides its
|
||||||
|
* cached stories without deleting them.
|
||||||
*
|
*
|
||||||
* @param array<string, string> $params
|
* @param array<string, string> $params
|
||||||
*/
|
*/
|
||||||
private function itemFilter(?string $source, ?string $since, array &$params): string
|
private function itemFilter(?string $source, ?string $since, array &$params): string
|
||||||
{
|
{
|
||||||
$where = [];
|
$where = ['feed_url IN (SELECT url FROM feeds WHERE enabled = 1)'];
|
||||||
|
|
||||||
if ($source !== null) {
|
if ($source !== null) {
|
||||||
$where[] = 'source = :source';
|
$where[] = 'source = :source';
|
||||||
@@ -160,7 +228,7 @@ final class FeedRepository
|
|||||||
$params[':since'] = $since;
|
$params[':since'] = $since;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $where === [] ? '' : ' WHERE ' . implode(' AND ', $where);
|
return ' WHERE ' . implode(' AND ', $where);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -198,10 +266,15 @@ final class FeedRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int, array{url: string, label: string, source: string, last_success_at: ?string, last_error: ?string}>
|
* Every registered feed (enabled and disabled) plus its health state,
|
||||||
|
* grouped by source order, for the manage page.
|
||||||
|
*
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
public function feedsStatus(): array
|
public function feedsStatus(): array
|
||||||
{
|
{
|
||||||
return $this->pdo->query('SELECT url, label, source, last_success_at, last_error FROM feeds ORDER BY label')->fetchAll();
|
return $this->pdo->query(
|
||||||
|
'SELECT url, label, source, origin, enabled, last_success_at, last_error FROM feeds ORDER BY source, rowid'
|
||||||
|
)->fetchAll();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-2
@@ -39,8 +39,9 @@ final class FeedService
|
|||||||
];
|
];
|
||||||
|
|
||||||
$hostCounts = [];
|
$hostCounts = [];
|
||||||
|
$activeFeeds = $this->repository->enabledFeeds();
|
||||||
|
|
||||||
foreach ($this->repository->staleFeeds($this->feeds, $this->ttlSeconds, $this->errorRetryTtlSeconds) as $feed) {
|
foreach ($this->repository->staleFeeds($activeFeeds, $this->ttlSeconds, $this->errorRetryTtlSeconds) as $feed) {
|
||||||
$url = (string) $feed['url'];
|
$url = (string) $feed['url'];
|
||||||
$host = (string) parse_url($url, PHP_URL_HOST);
|
$host = (string) parse_url($url, PHP_URL_HOST);
|
||||||
|
|
||||||
@@ -100,12 +101,14 @@ final class FeedService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* Sources that currently have at least one enabled feed.
|
||||||
|
*
|
||||||
* @return array<int, string>
|
* @return array<int, string>
|
||||||
*/
|
*/
|
||||||
public function sources(): array
|
public function sources(): array
|
||||||
{
|
{
|
||||||
$sources = [];
|
$sources = [];
|
||||||
foreach ($this->feeds as $feed) {
|
foreach ($this->repository->enabledFeeds() as $feed) {
|
||||||
$sources[(string) $feed['source']] = true;
|
$sources[(string) $feed['source']] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session gate for manage.php: password sign-in, CSRF tokens and flash
|
||||||
|
* messages. The dashboard itself stays session-less.
|
||||||
|
*/
|
||||||
|
final class ManageAuth
|
||||||
|
{
|
||||||
|
public function __construct(private array $config)
|
||||||
|
{
|
||||||
|
if (session_status() === PHP_SESSION_NONE) {
|
||||||
|
session_start([
|
||||||
|
'cookie_httponly' => true,
|
||||||
|
'cookie_samesite' => 'Lax',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
return trim((string) ($this->config['manage_password'] ?? '')) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authed(): bool
|
||||||
|
{
|
||||||
|
return $this->enabled() && ($_SESSION['manage_authed'] ?? false) === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(string $password): bool
|
||||||
|
{
|
||||||
|
if (!$this->enabled() || !$this->matches($password)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
session_regenerate_id(true);
|
||||||
|
$_SESSION['manage_authed'] = true;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(): void
|
||||||
|
{
|
||||||
|
$_SESSION['manage_authed'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function csrfToken(): string
|
||||||
|
{
|
||||||
|
if (!is_string($_SESSION['manage_csrf'] ?? null)) {
|
||||||
|
$_SESSION['manage_csrf'] = bin2hex(random_bytes(16));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $_SESSION['manage_csrf'];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkCsrf(string $token): bool
|
||||||
|
{
|
||||||
|
return $token !== '' && hash_equals($this->csrfToken(), $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function flash(string $type, string $text): void
|
||||||
|
{
|
||||||
|
$_SESSION['manage_flash'] = ['type' => $type, 'text' => $text];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{type: string, text: string}|null
|
||||||
|
*/
|
||||||
|
public function takeFlash(): ?array
|
||||||
|
{
|
||||||
|
$flash = $_SESSION['manage_flash'] ?? null;
|
||||||
|
|
||||||
|
if (is_array($flash) && isset($flash['type'], $flash['text'])) {
|
||||||
|
unset($_SESSION['manage_flash']);
|
||||||
|
|
||||||
|
return ['type' => (string) $flash['type'], 'text' => (string) $flash['text']];
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($_SESSION['manage_flash']);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accepts either a password_hash() value or a plain password.
|
||||||
|
*/
|
||||||
|
private function matches(string $password): bool
|
||||||
|
{
|
||||||
|
$stored = trim((string) ($this->config['manage_password'] ?? ''));
|
||||||
|
|
||||||
|
if (str_starts_with($stored, '$2') || str_starts_with($stored, '$argon2')) {
|
||||||
|
return password_verify($password, $stored);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hash_equals($stored, $password);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ $feeds = require dirname(__DIR__) . '/config/feeds.php';
|
|||||||
$pdo = App\Database::connect($config['db_path']);
|
$pdo = App\Database::connect($config['db_path']);
|
||||||
$repository = new App\FeedRepository($pdo);
|
$repository = new App\FeedRepository($pdo);
|
||||||
$fetcher = new App\FeedFetcher($config);
|
$fetcher = new App\FeedFetcher($config);
|
||||||
|
$resolver = new App\ChannelResolver($config);
|
||||||
|
$manager = new App\FeedManager($repository, $fetcher, $resolver);
|
||||||
$service = new App\FeedService(
|
$service = new App\FeedService(
|
||||||
$repository,
|
$repository,
|
||||||
$fetcher,
|
$fetcher,
|
||||||
@@ -26,5 +28,7 @@ return [
|
|||||||
'pdo' => $pdo,
|
'pdo' => $pdo,
|
||||||
'repository' => $repository,
|
'repository' => $repository,
|
||||||
'fetcher' => $fetcher,
|
'fetcher' => $fetcher,
|
||||||
|
'resolver' => $resolver,
|
||||||
|
'manager' => $manager,
|
||||||
'service' => $service,
|
'service' => $service,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -59,11 +59,14 @@ $total = (int) $page['total'];
|
|||||||
<span class="brand-mark" aria-hidden="true"></span>
|
<span class="brand-mark" aria-hidden="true"></span>
|
||||||
<span class="brand-text">RSS <em>Feed</em> Dashboard</span>
|
<span class="brand-text">RSS <em>Feed</em> Dashboard</span>
|
||||||
</a>
|
</a>
|
||||||
<div class="header-meta d-none d-md-flex align-items-center">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<span class="pulse" aria-hidden="true"></span>
|
<div class="header-meta d-none d-md-flex align-items-center">
|
||||||
<span class="mono"><?= $total ?> stories</span>
|
<span class="pulse" aria-hidden="true"></span>
|
||||||
<span class="sep">/</span>
|
<span class="mono"><?= $total ?> stories</span>
|
||||||
<span class="mono">YouTube · Reddit · RT</span>
|
<span class="sep">/</span>
|
||||||
|
<span class="mono">YouTube · Reddit · RT</span>
|
||||||
|
</div>
|
||||||
|
<a class="manage-link" href="manage.php">Manage</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="spectrum-rail" aria-hidden="true"></div>
|
<div class="spectrum-rail" aria-hidden="true"></div>
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<?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="?">← 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>
|
||||||
Reference in New Issue
Block a user