Add last/next crawl times to feeds, improve local time rendering
This commit is contained in:
parent
457aafbfe3
commit
2fab68241e
@ -1,20 +1,77 @@
|
|||||||
|
// This file is used to convert UTC timestamps from the server to human-readable local time in the browser.
|
||||||
|
//
|
||||||
|
// Usage: Add a time element with a `datetime` attribute and a `data-local-time` attribute to the HTML.
|
||||||
|
//
|
||||||
|
// `data-local-time` can be either 'relative' or 'date'.
|
||||||
|
// 'relative' will show the time in a human-readable format, e.g. "2 hours from now".
|
||||||
|
// 'date' will show the date in a human-readable format, e.g. "January 1, 2022".
|
||||||
|
// Any other value will be ignored and time will be shown as-is from the server as a UTC timestamp.
|
||||||
|
|
||||||
|
function pluralize(count: number, singular: string, plural: string): string {
|
||||||
|
return count === 1 ? singular : plural;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(utcTime: Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
const diffInSeconds = (utcTime.getTime() - now.getTime()) / 1000;
|
||||||
|
|
||||||
|
if (diffInSeconds < -86400) {
|
||||||
|
const days = Math.round(Math.abs(diffInSeconds) / 86400);
|
||||||
|
return `${days} ${pluralize(days, 'day', 'days')} ago`;
|
||||||
|
} else if (diffInSeconds < -3600) {
|
||||||
|
const hours = Math.round(Math.abs(diffInSeconds) / 3600);
|
||||||
|
return `${hours} ${pluralize(Math.abs(hours), 'hour', 'hours')} ago`;
|
||||||
|
} else if (diffInSeconds < -60) {
|
||||||
|
const minutes = Math.round(Math.abs(diffInSeconds) / 60);
|
||||||
|
return `${minutes} ${pluralize(minutes, 'minute', 'minutes')} ago`;
|
||||||
|
} else if (diffInSeconds < 0) {
|
||||||
|
const seconds = Math.abs(diffInSeconds);
|
||||||
|
return `${seconds} ${pluralize(seconds, 'second', 'seconds')} ago`;
|
||||||
|
} else if (diffInSeconds < 60) {
|
||||||
|
return `${diffInSeconds} ${pluralize(
|
||||||
|
diffInSeconds,
|
||||||
|
'second',
|
||||||
|
'seconds'
|
||||||
|
)} from now`;
|
||||||
|
} else if (diffInSeconds < 3600) {
|
||||||
|
const minutes = Math.round(diffInSeconds / 60);
|
||||||
|
return `${minutes} ${pluralize(minutes, 'minute', 'minutes')} from now`;
|
||||||
|
} else if (diffInSeconds < 86400) {
|
||||||
|
const hours = Math.round(diffInSeconds / 3600);
|
||||||
|
return `${hours} ${pluralize(hours, 'hour', 'hours')} from now`;
|
||||||
|
} else {
|
||||||
|
const days = Math.round(diffInSeconds / 86400);
|
||||||
|
return `${days} ${pluralize(days, 'day', 'days')} from now`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function convertTimeElements(): void {
|
function convertTimeElements(): void {
|
||||||
const timeElements = document.querySelectorAll('time.local-time');
|
const timeElements = document.querySelectorAll('time[data-local-time]');
|
||||||
timeElements.forEach((element) => {
|
timeElements.forEach((element) => {
|
||||||
const utcString = element.getAttribute('datetime');
|
const utcString = element.getAttribute('datetime');
|
||||||
if (utcString !== null) {
|
if (utcString !== null) {
|
||||||
const utcTime = new Date(utcString);
|
const utcTime = new Date(utcString);
|
||||||
element.textContent = utcTime.toLocaleDateString(
|
const localTimeType = element.getAttribute('data-local-time');
|
||||||
window.navigator.language,
|
if (localTimeType === 'relative') {
|
||||||
{
|
element.textContent = formatRelativeTime(utcTime);
|
||||||
year: 'numeric',
|
} else if (localTimeType === 'date') {
|
||||||
month: 'long',
|
element.textContent = utcTime.toLocaleDateString(
|
||||||
day: 'numeric',
|
window.navigator.language,
|
||||||
}
|
{
|
||||||
);
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
'Unrecognized data-local-time attribute value on time[data-local-time] element. Local time cannot be displayed.',
|
||||||
|
element
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error(
|
console.error(
|
||||||
'Missing datetime attribute on time.local-time element',
|
'Missing datetime attribute on time[data-local-time] element',
|
||||||
element
|
element
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ use crate::error::Result;
|
|||||||
use crate::htmx::HXTarget;
|
use crate::htmx::HXTarget;
|
||||||
use crate::models::entry::Entry;
|
use crate::models::entry::Entry;
|
||||||
use crate::partials::layout::Layout;
|
use crate::partials::layout::Layout;
|
||||||
|
use crate::partials::time::date_time;
|
||||||
use crate::uuid::Base62Uuid;
|
use crate::uuid::Base62Uuid;
|
||||||
|
|
||||||
pub async fn get(
|
pub async fn get(
|
||||||
@ -24,9 +25,6 @@ pub async fn get(
|
|||||||
let content_dir = std::path::Path::new(&config.content_dir);
|
let content_dir = std::path::Path::new(&config.content_dir);
|
||||||
let content_path = content_dir.join(format!("{}.html", entry.entry_id));
|
let content_path = content_dir.join(format!("{}.html", entry.entry_id));
|
||||||
let title = entry.title.unwrap_or_else(|| "Untitled Entry".to_string());
|
let title = entry.title.unwrap_or_else(|| "Untitled Entry".to_string());
|
||||||
let published_at = entry
|
|
||||||
.published_at
|
|
||||||
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
|
||||||
let content = fs::read_to_string(content_path).unwrap_or_else(|_| "No content".to_string());
|
let content = fs::read_to_string(content_path).unwrap_or_else(|_| "No content".to_string());
|
||||||
Ok(layout
|
Ok(layout
|
||||||
.with_subtitle(&title)
|
.with_subtitle(&title)
|
||||||
@ -41,9 +39,7 @@ pub async fn get(
|
|||||||
div {
|
div {
|
||||||
span class="text-sm text-gray-600" {
|
span class="text-sm text-gray-600" {
|
||||||
strong { "Published: " }
|
strong { "Published: " }
|
||||||
time datetime=(published_at) class="local-time" {
|
(date_time(entry.published_at))
|
||||||
(published_at)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(PreEscaped(content))
|
(PreEscaped(content))
|
||||||
|
@ -3,7 +3,6 @@ use std::{collections::HashMap, net::SocketAddr, path::Path, sync::Arc};
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::{
|
use axum::{
|
||||||
error_handling::HandleErrorLayer,
|
error_handling::HandleErrorLayer,
|
||||||
response::Response,
|
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
BoxError, Router,
|
BoxError, Router,
|
||||||
};
|
};
|
||||||
|
@ -127,6 +127,12 @@ pub struct GetFeedsOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Feed {
|
impl Feed {
|
||||||
|
pub fn next_crawl_time(&self) -> Option<DateTime<Utc>> {
|
||||||
|
self.last_crawled_at.map(|last_crawled_at| {
|
||||||
|
last_crawled_at + chrono::Duration::minutes(self.crawl_interval_minutes as i64)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get(db: impl Executor<'_, Database = Postgres>, feed_id: Uuid) -> Result<Feed> {
|
pub async fn get(db: impl Executor<'_, Database = Postgres>, feed_id: Uuid) -> Result<Feed> {
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Feed,
|
Feed,
|
||||||
|
@ -2,12 +2,14 @@ use maud::{html, Markup};
|
|||||||
|
|
||||||
use crate::models::feed::Feed;
|
use crate::models::feed::Feed;
|
||||||
use crate::partials::link::{link, LinkProps};
|
use crate::partials::link::{link, LinkProps};
|
||||||
|
use crate::partials::time::relative_time;
|
||||||
use crate::uuid::Base62Uuid;
|
use crate::uuid::Base62Uuid;
|
||||||
|
|
||||||
pub struct FeedLink<'a> {
|
pub struct FeedLink<'a> {
|
||||||
pub feed: &'a Feed,
|
pub feed: &'a Feed,
|
||||||
pub pending_crawl: bool,
|
pub pending_crawl: bool,
|
||||||
pub reset_htmx_target: bool,
|
pub reset_htmx_target: bool,
|
||||||
|
pub show_next_crawl_time: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeedLink<'_> {
|
impl FeedLink<'_> {
|
||||||
@ -16,6 +18,7 @@ impl FeedLink<'_> {
|
|||||||
feed,
|
feed,
|
||||||
pending_crawl: false,
|
pending_crawl: false,
|
||||||
reset_htmx_target: false,
|
reset_htmx_target: false,
|
||||||
|
show_next_crawl_time: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,6 +32,11 @@ impl FeedLink<'_> {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn show_next_crawl_time(&mut self) -> &mut Self {
|
||||||
|
self.show_next_crawl_time = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render(&self) -> Markup {
|
pub fn render(&self) -> Markup {
|
||||||
let title = self.feed.title.clone().unwrap_or_else(|| {
|
let title = self.feed.title.clone().unwrap_or_else(|| {
|
||||||
if self.pending_crawl {
|
if self.pending_crawl {
|
||||||
@ -39,7 +47,21 @@ impl FeedLink<'_> {
|
|||||||
});
|
});
|
||||||
let feed_url = format!("/feed/{}", Base62Uuid::from(self.feed.feed_id));
|
let feed_url = format!("/feed/{}", Base62Uuid::from(self.feed.feed_id));
|
||||||
html! {
|
html! {
|
||||||
(link(LinkProps { destination: &feed_url, title: &title, reset_htmx_target: self.reset_htmx_target }))
|
div class="flex flex-row gap-4 items-baseline" {
|
||||||
|
(link(LinkProps { destination: &feed_url, title: &title, reset_htmx_target: self.reset_htmx_target }))
|
||||||
|
@if let Some(last_crawl) = self.feed.last_crawled_at {
|
||||||
|
span class="text-sm text-gray-600" {
|
||||||
|
span class="font-semibold" { "last crawl: " }
|
||||||
|
(relative_time(last_crawl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@if let Some(next_crawl) = self.feed.next_crawl_time() {
|
||||||
|
span class="text-sm text-gray-600" {
|
||||||
|
span class="font-semibold" { "next crawl: " }
|
||||||
|
(relative_time(next_crawl))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,4 +13,5 @@ pub mod login_form;
|
|||||||
pub mod opml_import_form;
|
pub mod opml_import_form;
|
||||||
pub mod register_form;
|
pub mod register_form;
|
||||||
pub mod reset_password_form;
|
pub mod reset_password_form;
|
||||||
|
pub mod time;
|
||||||
pub mod user_name;
|
pub mod user_name;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
use maud::{html, Markup, PreEscaped};
|
use maud::{html, Markup};
|
||||||
|
|
||||||
pub fn opml_import_form() -> Markup {
|
pub fn opml_import_form() -> Markup {
|
||||||
html! {
|
html! {
|
||||||
|
59
src/partials/time.rs
Normal file
59
src/partials/time.rs
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use maud::{html, Markup};
|
||||||
|
|
||||||
|
use crate::utils::FormattedUtcTimestamp;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum LocalTimeType {
|
||||||
|
Date,
|
||||||
|
Relative,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for LocalTimeType {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
LocalTimeType::Date => write!(f, "date"),
|
||||||
|
LocalTimeType::Relative => write!(f, "relative"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct TimeProps {
|
||||||
|
pub timestamp: DateTime<Utc>,
|
||||||
|
pub local_time_type: Option<LocalTimeType>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn time(
|
||||||
|
TimeProps {
|
||||||
|
timestamp,
|
||||||
|
local_time_type,
|
||||||
|
}: TimeProps,
|
||||||
|
) -> Markup {
|
||||||
|
let time = FormattedUtcTimestamp::from(timestamp);
|
||||||
|
html! {
|
||||||
|
time
|
||||||
|
datetime=(time.rfc3339)
|
||||||
|
data-local-time=(local_time_type.unwrap_or(LocalTimeType::Date))
|
||||||
|
title=(time.human_readable)
|
||||||
|
{
|
||||||
|
(time.rfc3339)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn date_time(timestamp: DateTime<Utc>) -> Markup {
|
||||||
|
time(TimeProps {
|
||||||
|
timestamp,
|
||||||
|
local_time_type: Some(LocalTimeType::Date),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn relative_time(timestamp: DateTime<Utc>) -> Markup {
|
||||||
|
time(TimeProps {
|
||||||
|
timestamp,
|
||||||
|
local_time_type: Some(LocalTimeType::Relative),
|
||||||
|
})
|
||||||
|
}
|
15
src/utils.rs
15
src/utils.rs
@ -1,3 +1,4 @@
|
|||||||
|
use chrono::{DateTime, SecondsFormat, Utc};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
pub fn get_domain(url: &str) -> Option<String> {
|
pub fn get_domain(url: &str) -> Option<String> {
|
||||||
@ -12,3 +13,17 @@ pub fn get_domain(url: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct FormattedUtcTimestamp {
|
||||||
|
pub rfc3339: String,
|
||||||
|
pub human_readable: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<DateTime<Utc>> for FormattedUtcTimestamp {
|
||||||
|
fn from(dt: DateTime<Utc>) -> Self {
|
||||||
|
FormattedUtcTimestamp {
|
||||||
|
rfc3339: dt.to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||||
|
human_readable: dt.format("%Y-%m-%d %H:%M:%S %Z").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user