Update the UI (#10)

* Move the emoji

* Convert to a grid layout

* Ellipsize the feed urls

* Add link to source on GitHub

* Always be updating the relative time of the feed

* Make "recent" within the last 8 hours

* Sort the feeds alphabetically
This commit is contained in:
Carter McBride 2024-06-21 16:05:49 -06:00 committed by GitHub
parent 567b29995f
commit 5f27f8a16e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 217 additions and 89 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -5,34 +5,132 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" /> <meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>📰 Carter's RSS Feeds</title> <title>Carter's RSS Feeds</title>
<link rel="icon" href="news-emoji.svg" sizes="any" type="image/svg+xml"> <link rel="icon" href="news-emoji.svg" sizes="any" type="image/svg+xml">
<link rel="stylesheet" href="style.css" /> <link rel="stylesheet" href="style.css" />
<link rel="manifest" href="manifest.json" /> <link rel="manifest" href="manifest.json" />
<script type="module" defer>
/**
* from @feelinglovelynow/get-relative-time
* MIT License
* Copyright (c) 2023 Feeling Lovely Now
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
/**
* Simple function that recieves a `Date` object that is in the future or is in the past and gives back the relative time using `Intl.RelativeTimeFormat('en', { numeric: 'auto' })`
* Examples: `[ "last year", "6 months ago", "4 weeks ago", "7 days ago", "now", "in 1 minute", "in 6 hours", "tomorrow", "in 3 days", "in 4 weeks", "next month", "in 2 months", "in 12 months", "next year" ]`
* @param { Date } date
* @throws { { id: 'fln__get-relative-time__invalid-date', message: 'Please pass getRelativeTime() a valid date object', _errorData: { date } } } - `IF (!(date instanceof Date) || date.toString() === 'Invalid Date')`
* @returns { string }
*/
function getRelativeTime(date) {
if (!(date instanceof Date) || date.toString() === 'Invalid Date') throw { id: 'fln__get-relative-time__invalid-date', message: 'Please pass getRelativeTime() a valid date object', _errorData: { date } }
else {
/** @type { string } */
let response
const timestamp = date.getTime()
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
const diffMilliSeconds = timestamp - ((new Date()).getTime())
const diffSeconds = Math.round(diffMilliSeconds / 1000)
const diffMinutes = Math.round(diffSeconds / 60)
const diffHours = Math.round(diffMinutes / 60)
const diffDays = Math.round(diffHours / 24)
if (!Boolean(diffDays) && !Boolean(diffHours) && !Boolean(diffMinutes)) response = rtf.format(diffSeconds, 'second')
else if (!Boolean(diffDays) && !Boolean(diffHours)) response = rtf.format(diffMinutes, 'minute')
else if (!Boolean(diffDays)) response = rtf.format(diffHours, 'hour')
else if (diffDays < -365) response = rtf.format(Math.round(diffDays / 365), 'year')
else if (diffDays < -33) response = rtf.format(Math.round(diffDays / 30), 'month')
else if (diffDays < -7) response = rtf.format(Math.round(diffDays / 7), 'week')
else if (diffDays > 365) response = rtf.format(Math.round(diffDays / 365), 'year')
else if (diffDays > 33) response = rtf.format(Math.round(diffDays / 30), 'month')
else if (diffDays > 7) response = rtf.format(Math.round(diffDays / 7), 'week')
else response = rtf.format(diffDays, 'day')
return response
}
}
/**
* @param { Date } date
* @returns { number }
*/
function getUpdateInterval(date) {
const now = new Date();
const diffMilliSeconds = date.getTime() - now.getTime();
const diffSeconds = Math.round(diffMilliSeconds / 1000);
if (diffSeconds < 60) return 1000;
if (diffSeconds < 3600) return 1000 * 60;
if (diffSeconds < 86400) return 1000 * 60 * 60;
return 1000 * 60 * 60 * 24;
}
class RelativeTime extends HTMLElement {
constructor() {
super();
const date = new Date(parseInt(this.getAttribute("data-time")));
this.textContent = getRelativeTime(date);
setInterval(() => {
requestAnimationFrame(() => {
this.textContent = getRelativeTime(date);
});
}, getUpdateInterval(date));
}
}
customElements.define("relative-time", RelativeTime);
</script>
</head> </head>
<body> <body>
<h1>Carter's RSS Feeds</h1> <header>
<h1>📰 Carter's RSS Feeds</h1>
{% for group, feeds in data %} </header>
<h2>{{ group }}</h2> <main>
{% for feed in feeds %} {% for group, feeds in data %}
<details> <section>
<summary> <h2>{{ group }}</h2>
<span class="feed-title">{{ feed.title }}</span> {% for feed in feeds %}
<span class="feed-url">({{ feed.feed }})</span> <details>
</summary> <summary>
<ul> <span class="feed-title">{{ feed.title }}</span>
{% for item in feed.items %} <span class="feed-url" title="{{feed.feed}}">({{ feed.feed }})</span>
<li class="{% if item.isRecent %}has-recent{% endif %}"> </summary>
<div><a class="article-title" href="{{ item.link }}" target="_blank" rel="noopener norefferer nofollow">{{ <ul>
item.title }}</a> {% for item in feed.items %}
<div class="article-timestamp">{{ item.timestamp | relative }}</div> <li class="{% if item.isRecent %}has-recent{% endif %}">
</li> <div>
<a class="article-title" href="{{ item.link }}" target="_blank" rel="noopener norefferer nofollow">{{
item.title | safe}}</a>
</div>
<div class="article-timestamp">
<relative-time data-time="{{item.timestamp}}">{{ item.timestamp | relative}}</relative-time>
</div>
</li>
{% endfor %}
</ul>
</details>
{% endfor %} {% endfor %}
</ul> </section>
</details> {% endfor %}
{% endfor %} {% endfor %} {% if errors | length > 0 %} </main>
{% if errors | length > 0 %}
<h2>Errors</h2> <h2>Errors</h2>
<p>There were errors trying to parse these feeds:</p> <p>There were errors trying to parse these feeds:</p>
<ul class="errors"> <ul class="errors">
@ -41,15 +139,18 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% endif %} {% endif %}
<footer>
<br /> <hr>
<hr /> <p>Last updated {{ now }}.</p>
<p>Last updated {{ now }}.</p> <p>
<p> <a href="https://github.com/carterworks/rss-reader">View on GitHub</a>
Powered by </p>
<a href="https://github.com/georgemandis/bubo-rss">Bubo Reader (v{{ info.version }})</a>, a project by <a <p>
href="https://george.mand.is">George Mandis</a>. ❤️ Powered by
</p> <a href="https://github.com/georgemandis/bubo-rss">Bubo Reader (v{{ info.version }})</a>, a project by <a
href="https://george.mand.is">George Mandis</a>. ❤️
</p>
</footer>
</body> </body>
</html> </html>

View file

@ -25,8 +25,8 @@
}, },
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@feelinglovelynow/get-relative-time": "^1.1.2",
"chalk": "^5.2.0", "chalk": "^5.2.0",
"javascript-time-ago": "^2.5.10",
"node-fetch": "^3.3.1", "node-fetch": "^3.3.1",
"nunjucks": "^3.2.4", "nunjucks": "^3.2.4",
"rss-parser": "^3.13.0" "rss-parser": "^3.13.0"

View file

@ -1,16 +1,18 @@
{ {
"name": "Carter's RSS Feeds", "name": "Carter's RSS Feeds",
"short_name": "Carter's RSS Feeds", "short_name": "Carter's RSS Feeds",
"start_url": ".", "start_url": ".",
"scope": ".", "scope": ".",
"display": "standalone", "display": "standalone",
"background_color": "#FCF5E4", "background_color": "#FCF5E4",
"theme_color": "#FCF5E4", "theme_color": "#FCF5E4",
"description": "Updates from RSS feeds that Carter likes", "description": "Updates from RSS feeds that Carter likes",
"id": "/", "id": "/",
"icons": [{ "icons": [
"sizes": "any", {
"src": "news-emoji.svg", "sizes": "any",
"type": "image/svg+xml" "src": "news-emoji.svg",
}] "type": "image/svg+xml"
}
]
} }

View file

@ -12,6 +12,17 @@ body {
color: var(--color-text); color: var(--color-text);
} }
main {
display: grid;
gap: 1em;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
section ul {
max-height: 250px;
overflow-y: auto;
}
.inline-icon { .inline-icon {
height: 1em; height: 1em;
vertical-align: text-bottom; vertical-align: text-bottom;
@ -19,6 +30,7 @@ body {
summary { summary {
cursor: pointer; cursor: pointer;
text-overflow: ellipsis;
} }
summary, summary,
@ -42,13 +54,18 @@ details:has(li.has-recent) {
} }
} }
details,
.errors li { .errors li {
word-break: break-all; word-break: break-all;
} }
.feed-url { .feed-url {
color: #aaa; color: #aaa;
white-space: nowrap;
display: inline-block;
text-overflow: ellipsis;
width: 100%;
overflow: hidden;
} }
.article-timestamp, .article-timestamp,

View file

@ -72,9 +72,20 @@ const finishBuild: () => void = async () => {
process.stdout.write("\nDone fetching everything!\n"); process.stdout.write("\nDone fetching everything!\n");
// sort all the categories and the feeds alphabetically
const sortedFeeds: Feeds = {};
const sortedKeys = Object.keys(contentFromAllFeeds).sort((a, b) =>
a.localeCompare(b),
);
for (const key of sortedKeys) {
sortedFeeds[key] = contentFromAllFeeds[key].sort((a, b) =>
a.title.localeCompare(b.title),
);
}
// generate the static HTML output from our template renderer // generate the static HTML output from our template renderer
const output = render({ const output = render({
data: contentFromAllFeeds, data: sortedFeeds,
errors: errors, errors: errors,
info: buboInfo, info: buboInfo,
}); });
@ -105,48 +116,48 @@ const processFeed =
feed: string; feed: string;
startTime: number; startTime: number;
}) => }) =>
async (response: Response): Promise<void> => { async (response: Response): Promise<void> => {
const body = await parseFeed(response); const body = await parseFeed(response);
//skip to the next one if this didn't work out //skip to the next one if this didn't work out
if (!body) return; if (!body) return;
try { try {
const contents: FeedItem = ( const contents: FeedItem = (
typeof body === "string" ? await parser.parseString(body) : body typeof body === "string" ? await parser.parseString(body) : body
) as FeedItem; ) as FeedItem;
contents.feed = feed; contents.feed = feed;
contents.title = getTitle(contents); contents.title = getTitle(contents);
contents.link = getLink(contents); contents.link = getLink(contents);
// try to normalize date attribute naming // try to normalize date attribute naming
for (const item of contents.items) { for (const item of contents.items) {
item.timestamp = getTimestamp(item); item.timestamp = getTimestamp(item);
item.title = getTitle(item); item.title = getTitle(item);
item.link = getLink(item); item.link = getLink(item);
const timestamp = new Date(Number.parseInt(item.timestamp)); const timestamp = new Date(Number.parseInt(item.timestamp));
const yesterday = new Date(); const eightHoursAgo = new Date();
yesterday.setDate(yesterday.getDate() - 1); eightHoursAgo.setHours(eightHoursAgo.getHours() - 8);
item.isRecent = timestamp > yesterday; item.isRecent = timestamp > eightHoursAgo;
}
contents.hasRecent = contents.items.some((item) => item.isRecent);
contentFromAllFeeds[group].push(contents as object);
process.stdout.write(
`${success("Successfully fetched:")} ${feed} - ${benchmark(startTime)}\n`,
);
} catch (err) {
process.stdout.write(
`${error("Error processing:")} ${feed} - ${benchmark(
startTime,
)}\n${err}\n`,
);
errors.push(`Error processing: ${feed}\n\t${err}`);
} }
contents.hasRecent = contents.items.some((item) => item.isRecent); finishBuild();
};
contentFromAllFeeds[group].push(contents as object);
process.stdout.write(
`${success("Successfully fetched:")} ${feed} - ${benchmark(startTime)}\n`,
);
} catch (err) {
process.stdout.write(
`${error("Error processing:")} ${feed} - ${benchmark(
startTime,
)}\n${err}\n`,
);
errors.push(`Error processing: ${feed}\n\t${err}`);
}
finishBuild();
};
// go through each group of feeds and process // go through each group of feeds and process
const processFeeds = () => { const processFeeds = () => {

View file

@ -7,18 +7,15 @@
import nunjucks from "nunjucks"; import nunjucks from "nunjucks";
const env: nunjucks.Environment = nunjucks.configure({ autoescape: true }); const env: nunjucks.Environment = nunjucks.configure({ autoescape: true });
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { getRelativeTime } from "@feelinglovelynow/get-relative-time";
import type { Feeds, JSONValue } from "./@types/bubo"; import type { Feeds, JSONValue } from "./@types/bubo";
import TimeAgo from "javascript-time-ago";
import en from "javascript-time-ago/locale/en";
TimeAgo.addDefaultLocale(en);
const timeFormatter = new TimeAgo("en-US");
/** /**
* Global filters for my Nunjucks templates * Global filters for my Nunjucks templates
*/ */
env.addFilter("relative", (dateString): string => { env.addFilter("relative", (dateString): string => {
const date: Date = new Date(Number.parseInt(dateString)); const date: Date = new Date(Number.parseInt(dateString));
return !Number.isNaN(date.getTime()) ? timeFormatter.format(date) : dateString; return !Number.isNaN(date.getTime()) ? getRelativeTime(date) : dateString;
}); });
env.addFilter("formatTime", (dateString): string => { env.addFilter("formatTime", (dateString): string => {