feat: admin panel to manage feeds
authorxangelo <me@xangelo.ca>
Thu, 12 Jun 2025 16:12:27 +0000 (12:12 -0400)
committerxangelo <me@xangelo.ca>
Thu, 12 Jun 2025 16:38:00 +0000 (12:38 -0400)
src/public/admin.html [new file with mode: 0644]
src/public/index.html
src/public/reader.html
src/public/style.css
src/server.ts
src/views.ts

diff --git a/src/public/admin.html b/src/public/admin.html
new file mode 100644 (file)
index 0000000..607e013
--- /dev/null
@@ -0,0 +1,169 @@
+<!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
index 93b2d5fe9f3be9bee7122175e597afdaf6d597ea..aa5e110fe0c6a9d54c207e5205413be29ac347c4 100644 (file)
@@ -1,46 +1,50 @@
 <!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
index e02043d3a9522b8d8ee84c5bfc2429d3d1d55a9f..b5a59e781b495348a16959869e2855a4fbccb836 100644 (file)
@@ -13,6 +13,7 @@
 <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>
index 88541c3f662581216a04400f71efb69add4e0b7f..ac52da18343f0f34a59bfde9382d0ebbbc74789e 100644 (file)
@@ -1,3 +1,18 @@
+.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 {
@@ -14,7 +29,7 @@
   }
 
   .header::before {
-    content: '';
+    content: "";
     border: solid 0 #aaa;
     border-top-width: 1px;
     width: 100%;
@@ -64,13 +79,51 @@ footer {
   }
 }
 
+.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;
+  }
 }
index 50a8844fe7f272be4708a641702b99e4a5ee5311..2e8c19afe97f0798974b4d6d44a17e65436f7abc 100644 (file)
@@ -2,9 +2,10 @@ import 'dotenv/config';
 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({
@@ -142,6 +143,7 @@ async function runMigrations() {
 }
 
 app.use(express.static(join(__dirname, 'public')));
+app.use(bodyParser.urlencoded({ extended: true }));
 app.use(express.json());
 
 app.use((req, res, next) => {
@@ -176,6 +178,7 @@ app.get('/river', async (req, res) => {
     .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');
 
@@ -208,11 +211,16 @@ ${group.map(renderFeedItem).join("\n")}
   }).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,
@@ -223,8 +231,12 @@ app.post('/feeds', async (req, res) => {
   };
 
   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) => {
@@ -277,32 +289,63 @@ group by fe.feed_id
 });
 
 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
index 486fda1bf56faad11f2add91fa5fdc8758db99e7..892af3d144af0b7702bfc2517fc219350e51cda0 100644 (file)
@@ -2,7 +2,7 @@ import { fuzzyTime } from "./time";
 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}">
     [
@@ -27,7 +27,7 @@ export function renderFeedItem(entry: FeedWithEntrySchema): string {
 
 
 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>`;
 
@@ -40,9 +40,9 @@ export function renderReaderAppFeedList(list: FeedSchemaWithUnread[]): string {
 }
 
 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>
@@ -60,9 +60,9 @@ export function renderReaderAppFeedEntry(entry: FeedWithEntrySchema): string {
 
 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 {
@@ -84,3 +84,139 @@ export function renderReaderAppFeedEntries(page: number, feed: FeedSchemaWithUnr
   ${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>
+  `;
+}