--- /dev/null
+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');
+ });
+}
+
<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>
--- /dev/null
+<!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>
}
}
+.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 */
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',
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,
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)
});
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) => {
favicon: string;
} & KnexTimestamps;
+export type FeedSchemaWithUnread = FeedSchema & {
+ unread: number
+}
+
export type FeedEntrySchema<T = any> = {
id: string;
feed_id: number;
link: string;
pub_date: number;
author: string;
+ is_read: boolean;
meta: T;
} & KnexTimestamps;
import { fuzzyTime } from "./time";
-import { FeedWithEntrySchema } from "./types";
+import { FeedSchema, FeedSchemaWithUnread, FeedWithEntrySchema } from "./types";
export function renderFeedItemHeader(entry: FeedWithEntrySchema): string {
return `
</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");
+}