--- /dev/null
+<!doctype html>
+<html lang="en">
+
+<head>
+ <title>River Admin</title>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="stylesheet" href="https://classless.de/classless.css">
+ <link rel="stylesheet" href="./style.css">
+ <script src="https://unpkg.com/htmx.org@1.9.10"></script>
+</head>
+
+<body>
+ <nav>
+ <ul>
+ <li class="float-right"><a href="/admin.html">Admin Panel</a></li>
+ <li><a href="/">River View</a></li>
+ <li><a href="/reader.html">Reader View</a></li>
+ </ul>
+ </nav>
+
+ <!-- Authentication Section -->
+ <div id="auth-section" class="auth-container">
+ <header>
+ <h1>Admin Authentication</h1>
+ <p>Enter your admin key to access the admin panel</p>
+ </header>
+ <main>
+ <form id="auth-form" class="auth-form">
+ <div class="form-group">
+ <label for="admin-key">Admin Key:</label>
+ <input type="password" id="admin-key" name="admin-key" required>
+ </div>
+ <button type="submit">Login</button>
+ </form>
+ </main>
+ </div>
+
+ <!-- Admin Panel Section (hidden by default) -->
+ <div id="admin-panel" class="admin-panel" style="display: none;">
+ <header>
+ <h1>River Admin Panel</h1>
+ </header>
+
+ <main class="admin-content">
+ <div id="notifications"></div>
+ <!-- Add Feed Section -->
+ <section class="add-feed-section">
+ <h2>Add New Feed</h2>
+ <form id="add-feed-form" hx-post="/feeds" hx-target="#notifications"
+ hx-on::after-request="this.reset()">
+ <div class="form-row">
+ <div class="form-group">
+ <label for="feed-url">Feed URL:</label>
+ <input type="url" id="feed-url" name="url" required
+ placeholder="https://example.com/feed.xml">
+ </div>
+ <div class="form-group">
+ <label for="feed-title">Feed Title:</label>
+ <input type="text" id="feed-title" name="title" required placeholder="Feed Title">
+ </div>
+ <div class="form-group">
+ <label for="feed-favicon">Favicon URL (optional): <img id="favicon-preview"
+ class="hidden feed-favicon" /></label>
+ <input type="url" id="feed-favicon" name="favicon"
+ placeholder="https://example.com/favicon.ico">
+ </div>
+ <div class="form-group">
+ <button type="submit">Add Feed</button>
+ </div>
+ </div>
+ </form>
+ <div id="add-feed-result" class="result-message"></div>
+ </section>
+
+ <!-- Feeds Management Section -->
+ <section class="feeds-section">
+ <h2>Manage Feeds</h2>
+ <div id="feeds-list" hx-get="/admin/feeds" hx-trigger="load" hx-target="this">
+ Loading feeds...
+ </div>
+ </section>
+ </main>
+ </div>
+
+ <footer>
+ <hr>
+ <p>
+ Another project by <a href="https://xangelo">xangelo</a>
+ </p>
+ </footer>
+</body>
+
+<script>
+ let adminKey = localStorage.getItem('adminKey') || '';
+ // Authentication handling
+ document.getElementById('auth-form').addEventListener('submit', function (e) {
+ e.preventDefault();
+ const key = document.getElementById('admin-key').value;
+ if (key) {
+ adminKey = key;
+ localStorage.setItem('adminKey', key);
+ showAdminPanel();
+ }
+ });
+
+ // Check if already authenticated
+ window.addEventListener('load', function () {
+ const storedKey = localStorage.getItem('adminKey');
+ if (storedKey) {
+ adminKey = storedKey;
+ document.getElementById('admin-key').value = storedKey;
+ showAdminPanel();
+ }
+ });
+
+ function showAdminPanel() {
+ document.getElementById('auth-section').style.display = 'none';
+ document.getElementById('admin-panel').style.display = 'block';
+ // Trigger feeds list reload
+ htmx.trigger('#feeds-list', 'load');
+ }
+
+ // Add admin key to all HTMX requests
+ document.body.addEventListener('htmx:configRequest', function (evt) {
+ evt.detail.headers['x-secret'] = adminKey;
+ });
+
+ document.getElementById('feed-url').addEventListener('change', function (evt) {
+ if (evt.target.value) {
+ const url = new URL(evt.target.value);
+
+ const favicon = `https://${url.hostname}/favicon.ico`;
+ document.getElementById('feed-favicon').value = favicon;
+ document.getElementById('favicon-preview').src = favicon;
+ document.getElementById('favicon-preview').classList.remove('hidden');
+ }
+ });
+
+ document.getElementById('feed-favicon').addEventListener('change', function (evt) {
+ if (evt.target.value) {
+ document.getElementById('favicon-preview').src = evt.target.value;
+ document.getElementById('favicon-preview').classList.remove('hidden');
+ }
+ else {
+ document.getElementById('favicon-preview').src = '';
+ document.getElementById('favicon-preview').classList.add('hidden');
+ }
+ });
+
+ // Handle visibility checkbox changes
+ document.body.addEventListener('htmx:configRequest', function (evt) {
+ // If this is a visibility update request, ensure we send the correct data
+ if (evt.detail.path && evt.detail.path.includes('/visibility')) {
+ const checkbox = evt.detail.elt;
+ if (checkbox && checkbox.type === 'checkbox') {
+ const fieldName = checkbox.name;
+ const isChecked = checkbox.checked;
+
+ // Clear the existing parameters and set our own
+ evt.detail.parameters = {};
+ evt.detail.parameters[fieldName] = isChecked ? 'on' : 'off';
+ }
+ }
+ });
+
+</script>
+
+</html>
\ No newline at end of file
<!doctype html>
<html lang="en">
- <head>
- <title>River</title>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <link rel="stylesheet" href="https://classless.de/classless.css">
- <link rel="stylesheet" href="./style.css">
- <script src="https://unpkg.com/htmx.org@1.9.10"></script>
- </head>
- <body>
- <nav>
- <ul>
- <li><a href="/">River View</a></li>
- <li><a href="/reader.html">Reader View</a></li>
- </ul>
- </nav>
- <header>
- <h1>River of News</h1>
- <p>
- <strong>An auto-updating list of news</strong>
- </p>
- </header>
- <main hx-trigger="load, every 5m" hx-get="/river"></main>
- <footer>
- <hr>
- <p>
- Another project by <a href="https://xangelo">xangelo</a>
- </p>
- </footer>
- </body>
+
+<head>
+ <title>River</title>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <link rel="stylesheet" href="https://classless.de/classless.css">
+ <link rel="stylesheet" href="./style.css">
+ <script src="https://unpkg.com/htmx.org@1.9.10"></script>
+</head>
+
+<body>
+ <nav>
+ <ul>
+ <li class="float-right"><a href="/admin.html">Admin Panel</a></li>
+ <li><a href="/">River View</a></li>
+ <li><a href="/reader.html">Reader View</a></li>
+ </ul>
+ </nav>
+ <header>
+ <h1>River of News</h1>
+ <p>
+ <strong>An auto-updating list of news</strong>
+ </p>
+ </header>
+ <main hx-trigger="load, every 5m" hx-get="/river"></main>
+ <footer>
+ <hr>
+ <p>
+ Another project by <a href="https://xangelo">xangelo</a>
+ </p>
+ </footer>
+</body>
<script>
const keyName = 'lastCheck';
const headerName = 'last-check';
document.body.addEventListener('htmx:configRequest', evt => {
const val = localStorage.getItem(keyName);
event.detail.headers[headerName] = val;
- if(val) {
+ if (val) {
event.detail.headers['entry-count'] = document.querySelectorAll('.item').length;
}
});
document.body.addEventListener('htmx:afterRequest', evt => {
localStorage.setItem(keyName, evt.detail.xhr.getResponseHeader(headerName));
});
- </script>
-</html>
+</script>
+
+</html>
\ No newline at end of file
<body>
<nav>
<ul>
+ <li class="float-right"><a href="/admin.html">Admin Panel</a></li>
<li><a href="/">River View</a></li>
<li><a href="/reader.html">Reader View</a></li>
</ul>
+.hidden {
+ display: none !important;
+}
+
+.feed-favicon {
+ width: 16px;
+ height: 16px;
+ margin-right: 0.5rem;
+ vertical-align: middle;
+ display: inline-block;
+}
+
+.full-width {
+ width: 100%;
+}
.item {
.header {
}
.header::before {
- content: '';
+ content: "";
border: solid 0 #aaa;
border-top-width: 1px;
width: 100%;
}
}
+.notification {
+ padding: 1rem;
+ margin-bottom: 1rem;
+ border-radius: 0.5rem;
+ background-color: var(--clight);
+}
+.notification.success {
+ background-color: var(--cemphbg);
+}
+.notification.error {
+ background-color: var(--cmed);
+}
+.notification.info {
+ background-color: var(--clight);
+}
+
+.visibility-controls {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.visibility-controls label {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.9rem;
+ cursor: pointer;
+}
+
+.visibility-controls input[type="checkbox"] {
+ margin: 0;
+ cursor: pointer;
+}
+
@media (prefers-color-scheme: dark) {
- :root {
- /* foreground | background color */
- --cfg: #cecbc4; --cbg: #252220;
- --cdark: #999; --clight: #333;
- --cmed: #566;
- --clink: #1ad;
- --cemph: #0b9; --cemphbg: #0b91;
- }
+ :root {
+ /* foreground | background color */
+ --cfg: #cecbc4;
+ --cbg: #252220;
+ --cdark: #999;
+ --clight: #333;
+ --cmed: #566;
+ --clink: #1ad;
+ --cemph: #0b9;
+ --cemphbg: #0b91;
+ }
}
import express from 'express';
import knex from 'knex';
import { join } from 'path';
+import bodyParser from 'body-parser';
import Parser from 'rss-parser';
import { FeedEntrySchema, FeedSchema, FeedSchemaWithUnread, FeedWithEntrySchema } from './types';
-import { renderFeedItem, renderFeedItemHeader, renderReaderAppFeedEntries, renderReaderAppFeedEntry, renderReaderAppFeedList } from './views';
+import { renderAdminNotifications, renderFeedItem, renderFeedItemHeader, renderReaderAppFeedEntries, renderReaderAppFeedEntry, renderReaderAppFeedList, renderSimpleAdminFeedsList } from './views';
import { createHash } from 'crypto';
const db = knex({
}
app.use(express.static(join(__dirname, 'public')));
+app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.json());
app.use((req, res, next) => {
.from('feed_entry')
.join('feeds', 'feeds.id', '=', 'feed_entry.feed_id')
.where('feed_entry.created_at', '>', createdAt)
+ .where('feeds.show_in_river', true)
.limit(100)
.orderBy('feed_entry.pub_date', 'desc');
}).join("\n"));
});
-app.post('/feeds', async (req, res) => {
- if (req.body.key !== process.env.ADMIN_KEY) {
- res.sendStatus(400).end();
- return;
+function isAdmin(req: express.Request, res: express.Response, next: express.NextFunction) {
+ if (req.header('x-secret') === process.env.ADMIN_KEY) {
+ next();
+ }
+ else {
+ res.sendStatus(401).end();
}
+}
+
+app.post('/feeds', isAdmin, async (req, res) => {
const now = new Date();
const feed = {
url: req.body.url,
};
const rows = await db('feeds').insert(feed).onConflict().ignore().returning('*');
-
- res.json(rows);
+ if (rows.length > 0) {
+ res.send(renderAdminNotifications(`Feed ${feed.title} added successfully!`, 'success'));
+ }
+ else {
+ res.send(renderAdminNotifications(`Feed ${feed.title} already exists!`, 'error'));
+ }
});
app.get('/feeds', async (req, res) => {
});
app.post('/feed_entry/:feed_entry_id', async (req, res) => {
- const authSecret = req.header('x-secret');
+ const item: FeedWithEntrySchema[] = await db('feed_entry').update({
+ is_read: true
+ }).where({
+ id: req.params.feed_entry_id
+ }).returning('*');
+ res.sendStatus(204).end();
+});
- if (authSecret === process.env.ADMIN_KEY) {
- const item: FeedWithEntrySchema[] = await db('feed_entry').update({
- is_read: true
- }).where({
- id: req.params.feed_entry_id
- }).returning('*');
+app.delete('/feeds/:feed_id', isAdmin, async (req, res) => {
+ await db('feeds').delete().where({ id: req.params.feed_id });
+ await db('feed_entry').delete().where({ feed_id: req.params.feed_id });
- res.send(renderReaderAppFeedEntry(item.pop()));
- }
- else {
- res.sendStatus(204).end();
+ const feedList: FeedSchema[] = await db.select('*').from('feeds').orderBy('created_at');
+ if (req.accepts('html')) {
+ res.send(renderSimpleAdminFeedsList(feedList));
+ } else {
+ res.json(feedList);
}
});
-app.delete('/feeds/:feed_id', async (req, res) => {
- if (req.query.key !== process.env.ADMIN_KEY) {
- res.sendStatus(400).end();
+// Update feed visibility settings
+app.patch('/feeds/:feed_id/visibility', isAdmin, async (req, res) => {
+ const feedId = req.params.feed_id;
+
+ // Determine which field is being updated based on what's in the request body
+ let field: string;
+ let boolValue: boolean;
+
+ if (req.body.show_in_river !== undefined) {
+ field = 'show_in_river';
+ boolValue = req.body.show_in_river === 'on';
+ } else if (req.body.show_in_reader !== undefined) {
+ field = 'show_in_reader';
+ boolValue = req.body.show_in_reader === 'on';
+ } else {
+ res.status(400).json({ error: 'No valid field provided' });
return;
}
- await db('feeds').delete().where({ id: req.params.feed_id });
- await db('feed_entry').delete().where({ feed_id: req.params.feed_id });
+ try {
+ await db('feeds').update({ [field]: boolValue }).where({ id: feedId });
+ res.send(renderAdminNotifications(`Feed ${feedId} visibility updated successfully!`, 'success'));
+ } catch (error) {
+ console.error('Error updating feed visibility:', error);
+ res.send(renderAdminNotifications(`Failed to update feed visibility: ${error}`, 'error'));
+ }
+});
- res.json(await db.select('*').from('feeds').orderBy('created_at'));
+// Admin endpoint for feeds list
+app.get('/admin/feeds', isAdmin, async (req, res) => {
+ const feedList: FeedSchema[] = await db.select('*').from('feeds').orderBy('created_at');
+
+ if (req.accepts('html')) {
+ res.send(renderSimpleAdminFeedsList(feedList));
+ } else {
+ res.json(feedList);
+ }
});
// Start the application
import { FeedSchema, FeedSchemaWithUnread, FeedWithEntrySchema } from "./types";
export function renderFeedItemHeader(entry: FeedWithEntrySchema): string {
-return `
+ return `
<h2 class="header">
<img src="${entry.favicon}" alt="Favicon for ${entry.feed_title}">
[
export function renderReaderAppFeedListItem(feed: FeedSchemaWithUnread, autoload: boolean = false): string {
- return `<a href="/feeds/${feed.id}" hx-get="feeds/${feed.id}" hx-trigger="click${autoload? ', load': ''}" hx-target=".feed-entries" class="feed" data-id="${feed.id}">
+ return `<a href="/feeds/${feed.id}" hx-get="feeds/${feed.id}" hx-trigger="click${autoload ? ', load' : ''}" hx-target=".feed-entries" class="feed" data-id="${feed.id}">
${feed.title} <span class="unread-count" id="unread-${feed.id}">(${feed.total - feed.read})</span>
</a>`;
}
export function renderReaderAppFeedEntry(entry: FeedWithEntrySchema): string {
- const meta = JSON.parse(entry.meta);
- const date = new Date(entry.pub_date * 1000);
- return `
+ const meta = JSON.parse(entry.meta);
+ const date = new Date(entry.pub_date * 1000);
+ return `
<div class="feed-entry ${entry.is_read ? '' : 'unread'}" hx-trigger="click" hx-post="/feed_entry/${entry.id}" hx-swap="outerHTML">
<div class="row">
<a href="${entry.link}" class="col">${entry.title}</a>
export function renderPagination(baseLink: string, currentPage: number, totalItems: number, itemsPerPage: number = 100): string {
let pageLinks: string[] = ['<p class="align-right">'];
- const pages = Math.ceil(totalItems/itemsPerPage);
- for(let i = 1; i <= pages; ++i) {
- if(i === (currentPage + 1)) {
+ const pages = Math.ceil(totalItems / itemsPerPage);
+ for (let i = 1; i <= pages; ++i) {
+ if (i === (currentPage + 1)) {
pageLinks.push(i.toString());
}
else {
${renderPagination(`/feeds/${feed.id}`, page, feed.total)}
`;
}
+
+export function renderAdminFeedsList(list: FeedSchemaWithUnread[]): string {
+ if (list.length === 0) {
+ return '<div class="no-feeds">No feeds found. Add your first feed above!</div>';
+ }
+
+ const tableRows = list.map(feed => {
+ const createdDate = new Date(feed.created_at);
+ const updatedDate = new Date(feed.updated_at);
+ const faviconImg = feed.favicon ? `<img src="${feed.favicon}" alt="" class="feed-favicon">` : '';
+
+ return `
+ <tr>
+ <td>
+ ${faviconImg}
+ <strong>${feed.title}</strong>
+ </td>
+ <td>
+ <div class="feed-url" title="${feed.url}">${feed.url}</div>
+ </td>
+ <td class="feed-stats">
+ ${feed.total - feed.read} unread / ${feed.total} total
+ </td>
+ <td>
+ <small title="${createdDate}">${fuzzyTime(createdDate.getTime())}</small>
+ </td>
+ <td>
+ <small title="${updatedDate}">${fuzzyTime(updatedDate.getTime())}</small>
+ </td>
+ <td>
+ <button class="delete-feed-btn" data-feed-id="${feed.id}" data-feed-title="${feed.title}">
+ Delete
+ </button>
+ </td>
+ </tr>
+ `;
+ }).join('');
+
+ return `
+ <table class="feeds-table">
+ <thead>
+ <tr>
+ <th>Feed Title</th>
+ <th>URL</th>
+ <th>Articles</th>
+ <th>Created</th>
+ <th>Last Updated</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${tableRows}
+ </tbody>
+ </table>
+ `;
+}
+
+export function renderAdminNotifications(message: string, type: 'success' | 'error' | 'info' = 'info'): string {
+ return `<div class="notification ${type}">${message}</div>`;
+}
+
+export function renderSimpleAdminFeedsList(list: FeedSchema[]): string {
+ if (list.length === 0) {
+ return '<div class="no-feeds">No feeds found. Add your first feed above!</div>';
+ }
+
+ const tableRows = list.map(feed => {
+ const createdDate = new Date(feed.created_at);
+ const updatedDate = new Date(feed.updated_at);
+ const faviconImg = feed.favicon ? `<img src="${feed.favicon}" alt="" class="feed-favicon">` : '';
+
+ return `
+ <tr>
+ <td>
+ ${faviconImg} <strong>${feed.title}</strong> <br>
+ <small title="${feed.url}">${feed.url}</small>
+ </td>
+ <td>
+ <small title="${createdDate}">${fuzzyTime(createdDate.getTime())}</small>
+ </td>
+ <td>
+ <div class="visibility-controls">
+ <label>
+ <input type="checkbox"
+ class="visibility-checkbox"
+ data-feed-id="${feed.id}"
+ data-field="show_in_river"
+ ${feed.show_in_river ? 'checked' : ''}
+ hx-patch="/feeds/${feed.id}/visibility"
+ hx-trigger="change"
+ hx-target="#notifications"
+ hx-include="this"
+ name="show_in_river">
+ River View
+ </label>
+ <br>
+ <label>
+ <input type="checkbox"
+ class="visibility-checkbox"
+ data-feed-id="${feed.id}"
+ data-field="show_in_reader"
+ ${feed.show_in_reader ? 'checked' : ''}
+ hx-patch="/feeds/${feed.id}/visibility"
+ hx-trigger="change"
+ hx-target="#notifications"
+ hx-include="this"
+ name="show_in_reader">
+ Reader View
+ </label>
+ </div>
+ </td>
+ <td>
+ <button class="delete-feed-btn" hx-delete="/feeds/${feed.id}" hx-target="closest table">
+ Delete
+ </button>
+ </td>
+ </tr>
+ `;
+ }).join('');
+
+ return `
+ <table class="full-width">
+ <thead>
+ <tr>
+ <th>Feed</th>
+ <th>Created</th>
+ <th>Visibility</th>
+ <th>Actions</th>
+ </tr>
+ </thead>
+ <tbody>
+ ${tableRows}
+ </tbody>
+ </table>
+ `;
+}