add reader view
authorxangelo <me@xangelo.ca>
Thu, 8 Feb 2024 21:01:29 +0000 (16:01 -0500)
committerxangelo <me@xangelo.ca>
Thu, 8 Feb 2024 21:01:29 +0000 (16:01 -0500)
migrations/20240208181752_reader_app_is_read.ts [new file with mode: 0644]
src/public/index.html
src/public/reader.html [new file with mode: 0644]
src/public/style.css
src/server.ts
src/types.ts
src/views.ts

diff --git a/migrations/20240208181752_reader_app_is_read.ts b/migrations/20240208181752_reader_app_is_read.ts
new file mode 100644 (file)
index 0000000..28fd1ac
--- /dev/null
@@ -0,0 +1,16 @@
+import type { Knex } from "knex";
+
+
+export async function up(knex: Knex): Promise<void> {
+  return knex.schema.alterTable('feed_entry', t => {
+    t.boolean('is_read');
+  });
+}
+
+
+export async function down(knex: Knex): Promise<void> {
+  return knex.schema.alterTable('feed_entry', t => {
+    t.dropColumn('is_read');
+  });
+}
+
index 9076bdd524f0cdc8280a62c534c1e38219eabd78..93b2d5fe9f3be9bee7122175e597afdaf6d597ea 100644 (file)
@@ -9,6 +9,12 @@
     <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>
diff --git a/src/public/reader.html b/src/public/reader.html
new file mode 100644 (file)
index 0000000..cfb6cc5
--- /dev/null
@@ -0,0 +1,49 @@
+<!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 class="rss-reader row">
+      <div class="sidebar col-3" hx-get="/feeds" hx-trigger="load"></div>
+      <div class="feed-entries col"></div>
+    </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) {
+      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>
index 50bf413cc1c20b6f1277fe87e4ab1f50f8ac3d8c..88541c3f662581216a04400f71efb69add4e0b7f 100644 (file)
@@ -44,6 +44,26 @@ footer {
   }
 }
 
+.rss-reader {
+  .sidebar {
+    padding-top: 1em;
+  }
+  .feed-entry {
+    padding: 0.5rem 1rem;
+    .excerpt {
+      margin-bottom: 2rem;
+    }
+  }
+  .feed-entry.unread {
+    border: var(--border);
+    border-width: 0 0 0 5px;
+  }
+
+  .feed-entry:hover {
+    background-color: var(--clight);
+  }
+}
+
 @media (prefers-color-scheme: dark) {
        :root {
                /* foreground   | background color */
index 3ac23717cfac5039140e866f1caa12e3acb72e8a..e82719a31b57187b5ad1f676704b595c4153bc78 100644 (file)
@@ -3,8 +3,9 @@ import express from 'express';
 import knex from 'knex';
 import { join } from 'path';
 import Parser from 'rss-parser';
-import { FeedEntrySchema, FeedSchema, FeedWithEntrySchema } from './types';
-import { renderFeedItem, renderFeedItemHeader } from './views';
+import { FeedEntrySchema, FeedSchema, FeedSchemaWithUnread, FeedWithEntrySchema } from './types';
+import { renderFeedItem, renderFeedItemHeader, renderReaderAppFeedEntries, renderReaderAppFeedEntry, renderReaderAppFeedList } from './views';
+import { createHash } from 'crypto';
 
 const db = knex({
   client: 'better-sqlite3',
@@ -72,8 +73,10 @@ async function queryFeeds() {
       const data = await parser.parseURL(feed.url);
 
       const items: FeedEntrySchema<any>[] = data.items.map(item => {
+        const id = createHash('sha256');
+        id.update(item.guid || item.id || Date.now()+Math.random());
         return {
-          id: item.guid || item.id,
+          id: id.digest('hex'),
           title: item.title,
           link: item.link,
           feed_id: feed.id,
@@ -81,6 +84,7 @@ async function queryFeeds() {
           author: item.creator || item.author,
           created_at: timestamp(now),
           updated_at: timestamp(now),
+          is_read: false,
           meta: {
             comment_link: item.comments,
             snippet: contentExtractor(feed, item)
@@ -201,7 +205,42 @@ app.post('/feeds', async (req, res) => {
 });
 
 app.get('/feeds', async (req, res) => {
-  res.json(await db.select('*').from('feeds').orderBy('created_at'));
+  const feedList: FeedSchemaWithUnread[] = await db.raw(`
+select f.*, count(fe.id) as unread 
+from feeds f 
+join feed_entry fe on fe.feed_id = f.id 
+where fe.is_read is false
+group by fe.feed_id
+`);
+
+  if(req.accepts('html')) {
+    res.send(renderReaderAppFeedList(feedList));
+    return;
+  }
+
+  res.json(feedList);
+});
+
+app.get('/feeds/:feed_id', async (req, res) => {
+  const feedEntries = await db.select('*').from('feed_entry').where({
+    feed_id: req.params.feed_id
+  }).orderBy('pub_date', 'desc').limit(100);
+
+  if(req.accepts('html')) {
+    res.send(renderReaderAppFeedEntries(feedEntries))
+    return;
+  }
+  res.json(feedEntries);
+});
+
+app.post('/feed_entry/:feed_entry_id', async (req, res) => {
+  const item: FeedWithEntrySchema[] = await db('feed_entry').update({
+    is_read: true
+  }).where({
+    id: req.params.feed_entry_id
+  }).returning('*');
+
+  res.send(renderReaderAppFeedEntry(item.pop()));
 });
 
 app.delete('/feeds/:feed_id', async (req, res) => {
index ca6cc5cab309963512d36475e05cebd8d2458ecc..121076ff00cfdb3161d22b2e8c1765cfee1e0ed5 100644 (file)
@@ -10,6 +10,10 @@ export type FeedSchema = {
   favicon: string;
 } & KnexTimestamps;
 
+export type FeedSchemaWithUnread = FeedSchema & {
+  unread: number
+}
+
 export type FeedEntrySchema<T = any> = {
   id: string;
   feed_id: number;
@@ -17,6 +21,7 @@ export type FeedEntrySchema<T = any> = {
   link: string;
   pub_date: number;
   author: string;
+  is_read: boolean;
   meta: T;
 } & KnexTimestamps;
 
index f456ad5bffedfbcd715ad365039387e064790bd0..2a88a8b618bba8e1464cac943d06a2c280dadb43 100644 (file)
@@ -1,5 +1,5 @@
 import { fuzzyTime } from "./time";
-import { FeedWithEntrySchema } from "./types";
+import { FeedSchema, FeedSchemaWithUnread, FeedWithEntrySchema } from "./types";
 
 export function renderFeedItemHeader(entry: FeedWithEntrySchema): string {
 return `
@@ -24,3 +24,40 @@ export function renderFeedItem(entry: FeedWithEntrySchema): string {
   </div>
   `
 }
+
+
+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}">
+${feed.title} <span class="unread-count" id="unread-${feed.id}">(${feed.unread})</span>
+    </a>`;
+
+}
+
+export function renderReaderAppFeedList(list: FeedSchemaWithUnread[]): string {
+  return list.map((feed, idx) => {
+    return renderReaderAppFeedListItem(feed, idx === 0);
+  }).join("<br>");
+}
+
+export function renderReaderAppFeedEntry(entry: FeedWithEntrySchema): string {
+    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>
+    <span class="col-4 text-right">
+      <small title="${date}">${fuzzyTime(entry.pub_date * 1000)}</small>
+    </span>
+  </div>
+  ${meta.snippet ? `<div class="excerpt">${meta.snippet}</div>` : ''}
+</small>
+</div>
+
+    `;
+
+}
+
+export function renderReaderAppFeedEntries(list: FeedWithEntrySchema[]): string {
+  return list.map(renderReaderAppFeedEntry).join("\n");
+}