Implement entry and feed pagination
This commit is contained in:
parent
0607b46283
commit
ec394fc170
1
.gitignore
vendored
1
.gitignore
vendored
@ -3,5 +3,6 @@
|
|||||||
.env
|
.env
|
||||||
/static/js/*
|
/static/js/*
|
||||||
/static/css/*
|
/static/css/*
|
||||||
|
/static/img/*
|
||||||
.frontend-built
|
.frontend-built
|
||||||
/content
|
/content
|
||||||
|
41
Cargo.lock
generated
41
Cargo.lock
generated
@ -158,6 +158,7 @@ dependencies = [
|
|||||||
"bitflags 1.3.2",
|
"bitflags 1.3.2",
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
"headers",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"hyper",
|
"hyper",
|
||||||
@ -197,6 +198,12 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.21.0"
|
version = "0.21.0"
|
||||||
@ -382,6 +389,7 @@ dependencies = [
|
|||||||
"dotenvy",
|
"dotenvy",
|
||||||
"feed-rs",
|
"feed-rs",
|
||||||
"futures",
|
"futures",
|
||||||
|
"http",
|
||||||
"maud",
|
"maud",
|
||||||
"notify",
|
"notify",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@ -929,6 +937,31 @@ dependencies = [
|
|||||||
"hashbrown 0.12.3",
|
"hashbrown 0.12.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "headers"
|
||||||
|
version = "0.3.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f3e372db8e5c0d213e0cd0b9be18be2aca3d44cf2fe30a9d46a65581cd454584"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.13.1",
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"bytes",
|
||||||
|
"headers-core",
|
||||||
|
"http",
|
||||||
|
"httpdate",
|
||||||
|
"mime",
|
||||||
|
"sha1",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "headers-core"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.4.1"
|
version = "0.4.1"
|
||||||
@ -2088,7 +2121,7 @@ version = "0.11.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "13293b639a097af28fc8a90f22add145a9c954e49d77da06263d58cf44d5fb91"
|
checksum = "13293b639a097af28fc8a90f22add145a9c954e49d77da06263d58cf44d5fb91"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.21.0",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@ -2278,7 +2311,7 @@ version = "3.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f02d8aa6e3c385bf084924f660ce2a3a6bd333ba55b35e8590b321f35d88513"
|
checksum = "9f02d8aa6e3c385bf084924f660ce2a3a6bd333ba55b35e8590b321f35d88513"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.21.0",
|
||||||
"chrono",
|
"chrono",
|
||||||
"hex",
|
"hex",
|
||||||
"indexmap 1.9.3",
|
"indexmap 1.9.3",
|
||||||
@ -2519,7 +2552,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "8264c59b28b6858796acfcedc660aa4c9075cc6e4ec8eb03cdca2a3e725726db"
|
checksum = "8264c59b28b6858796acfcedc660aa4c9075cc6e4ec8eb03cdca2a3e725726db"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64 0.21.0",
|
||||||
"bitflags 2.3.3",
|
"bitflags 2.3.3",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"bytes",
|
"bytes",
|
||||||
@ -2563,7 +2596,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "1cab6147b81ca9213a7578f1b4c9d24c449a53953cd2222a7b5d7cd29a5c3139"
|
checksum = "1cab6147b81ca9213a7578f1b4c9d24c449a53953cd2222a7b5d7cd29a5c3139"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64 0.21.0",
|
||||||
"bitflags 2.3.3",
|
"bitflags 2.3.3",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
@ -14,7 +14,7 @@ path = "src/lib.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
ansi-to-html = "0.1"
|
ansi-to-html = "0.1"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
axum = { version = "0.6", features = ["form", "multipart"] }
|
axum = { version = "0.6", features = ["form", "headers", "multipart"] }
|
||||||
bytes = "1.4"
|
bytes = "1.4"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
clap = { version = "4.3", features = ["derive", "env"] }
|
clap = { version = "4.3", features = ["derive", "env"] }
|
||||||
@ -50,3 +50,4 @@ uuid = { version = "1.3", features = ["serde"] }
|
|||||||
url = "2.4"
|
url = "2.4"
|
||||||
validator = { version = "0.16", features = ["derive"] }
|
validator = { version = "0.16", features = ["derive"] }
|
||||||
ammonia = "3.3.0"
|
ammonia = "3.3.0"
|
||||||
|
http = "0.2.9"
|
||||||
|
@ -6,6 +6,27 @@ html {
|
|||||||
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.htmx-indicator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-request .list-loading {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.htmx-request.list-loading {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-loading {
|
||||||
|
margin: 24px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
img.loading {
|
||||||
|
filter: invert(100%);
|
||||||
|
max-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
|
|
||||||
header.header nav {
|
header.header nav {
|
||||||
|
33
frontend/img/three-dots.svg
Executable file
33
frontend/img/three-dots.svg
Executable file
@ -0,0 +1,33 @@
|
|||||||
|
<!-- By Sam Herbert (@sherb), for everyone. More @ http://goo.gl/7AJzbL -->
|
||||||
|
<svg width="120" height="30" viewBox="0 0 120 30" xmlns="http://www.w3.org/2000/svg" fill="#fff">
|
||||||
|
<circle cx="15" cy="15" r="15">
|
||||||
|
<animate attributeName="r" from="15" to="15"
|
||||||
|
begin="0s" dur="0.8s"
|
||||||
|
values="15;9;15" calcMode="linear"
|
||||||
|
repeatCount="indefinite" />
|
||||||
|
<animate attributeName="fill-opacity" from="1" to="1"
|
||||||
|
begin="0s" dur="0.8s"
|
||||||
|
values="1;.5;1" calcMode="linear"
|
||||||
|
repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<circle cx="60" cy="15" r="9" fill-opacity="0.3">
|
||||||
|
<animate attributeName="r" from="9" to="9"
|
||||||
|
begin="0s" dur="0.8s"
|
||||||
|
values="9;15;9" calcMode="linear"
|
||||||
|
repeatCount="indefinite" />
|
||||||
|
<animate attributeName="fill-opacity" from="0.5" to="0.5"
|
||||||
|
begin="0s" dur="0.8s"
|
||||||
|
values=".5;1;.5" calcMode="linear"
|
||||||
|
repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
<circle cx="105" cy="15" r="15">
|
||||||
|
<animate attributeName="r" from="15" to="15"
|
||||||
|
begin="0s" dur="0.8s"
|
||||||
|
values="15;9;15" calcMode="linear"
|
||||||
|
repeatCount="indefinite" />
|
||||||
|
<animate attributeName="fill-opacity" from="1" to="1"
|
||||||
|
begin="0s" dur="0.8s"
|
||||||
|
values="1;.5;1" calcMode="linear"
|
||||||
|
repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
@ -1,7 +1,6 @@
|
|||||||
import htmx from 'htmx.org';
|
import htmx from 'htmx.org';
|
||||||
import 'htmx.org/dist/ext/sse';
|
|
||||||
|
|
||||||
// import CSS so it gets named with a content hash that busts caches
|
// import assets so they get named with a content hash that busts caches
|
||||||
import '../css/styles.css';
|
import '../css/styles.css';
|
||||||
|
|
||||||
import './localTimeController';
|
import './localTimeController';
|
||||||
@ -13,3 +12,6 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.htmx = htmx;
|
window.htmx = htmx;
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/first
|
||||||
|
import 'htmx.org/dist/ext/sse';
|
||||||
|
8
justfile
8
justfile
@ -7,15 +7,18 @@ install-frontend:
|
|||||||
bun install --cwd frontend
|
bun install --cwd frontend
|
||||||
|
|
||||||
clean-frontend:
|
clean-frontend:
|
||||||
rm -rf ./static/js/* ./static/css/*
|
rm -rf ./static/js/* ./static/css/* ./static/img/*
|
||||||
|
|
||||||
build-frontend: clean-frontend
|
build-frontend: clean-frontend
|
||||||
bun build frontend/js/index.ts \
|
bun build frontend/js/index.ts \
|
||||||
--outdir ./static \
|
--outdir ./static \
|
||||||
--root ./frontend \
|
--root ./frontend \
|
||||||
--entry-naming [dir]/[name]-[hash].[ext] \
|
--entry-naming [dir]/[name]-[hash].[ext] \
|
||||||
|
--chunk-naming [dir]/[name]-[hash].[ext] \
|
||||||
--asset-naming [dir]/[name]-[hash].[ext] \
|
--asset-naming [dir]/[name]-[hash].[ext] \
|
||||||
--minify
|
--minify
|
||||||
|
mkdir -p static/img
|
||||||
|
cp frontend/img/* static/img/
|
||||||
touch ./static/js/manifest.txt # create empty manifest to be overwritten by build.rs
|
touch ./static/js/manifest.txt # create empty manifest to be overwritten by build.rs
|
||||||
touch ./static/css/manifest.txt # create empty manifest to be overwritten by build.rs
|
touch ./static/css/manifest.txt # create empty manifest to be overwritten by build.rs
|
||||||
touch .frontend-built # trigger build.rs to run
|
touch .frontend-built # trigger build.rs to run
|
||||||
@ -25,7 +28,10 @@ build-dev-frontend: clean-frontend
|
|||||||
--outdir ./static \
|
--outdir ./static \
|
||||||
--root ./frontend \
|
--root ./frontend \
|
||||||
--entry-naming [dir]/[name]-[hash].[ext] \
|
--entry-naming [dir]/[name]-[hash].[ext] \
|
||||||
|
--chunk-naming [dir]/[name]-[hash].[ext] \
|
||||||
--asset-naming [dir]/[name]-[hash].[ext]
|
--asset-naming [dir]/[name]-[hash].[ext]
|
||||||
|
mkdir -p static/img
|
||||||
|
cp frontend/img/* static/img/
|
||||||
touch ./static/js/manifest.txt # create empty manifest needed so binary compiles
|
touch ./static/js/manifest.txt # create empty manifest needed so binary compiles
|
||||||
touch ./static/css/manifest.txt # create empty manifest needed so binary compiles
|
touch ./static/css/manifest.txt # create empty manifest needed so binary compiles
|
||||||
# in development mode, frontend changes do not trigger a rebuild of the backend
|
# in development mode, frontend changes do not trigger a rebuild of the backend
|
||||||
|
@ -84,7 +84,7 @@ impl CrawlScheduler {
|
|||||||
let mut options = GetFeedsOptions::default();
|
let mut options = GetFeedsOptions::default();
|
||||||
loop {
|
loop {
|
||||||
info!("fetching feeds before: {:?}", options.before);
|
info!("fetching feeds before: {:?}", options.before);
|
||||||
let feeds = match Feed::get_all(&self.pool, options.clone()).await {
|
let feeds = match Feed::get_all(&self.pool, &options).await {
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
return Err(CrawlSchedulerError::FetchFeedsError(err.to_string()));
|
return Err(CrawlSchedulerError::FetchFeedsError(err.to_string()));
|
||||||
}
|
}
|
||||||
|
25
src/api_response.rs
Normal file
25
src/api_response.rs
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
use axum::{
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
Json,
|
||||||
|
};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Wrapper type for API responses that allows endpoints to return either JSON or HTML in the same
|
||||||
|
/// route.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum ApiResponse<T> {
|
||||||
|
Json(T),
|
||||||
|
Html(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> IntoResponse for ApiResponse<T>
|
||||||
|
where
|
||||||
|
T: Serialize,
|
||||||
|
{
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
match self {
|
||||||
|
ApiResponse::Json(json) => Json(json).into_response(),
|
||||||
|
ApiResponse::Html(html) => Html(html).into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -6,7 +6,10 @@ use lib::actors::feed_crawler::FeedCrawlerHandle;
|
|||||||
use lib::domain_locks::DomainLocks;
|
use lib::domain_locks::DomainLocks;
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
@ -1,9 +1,27 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::extract::Query;
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::TypedHeader;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::api_response::ApiResponse;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::models::entry::Entry;
|
use crate::headers::Accept;
|
||||||
|
use crate::models::entry::{Entry, GetEntriesOptions};
|
||||||
|
use crate::partials::entry_list::entry_list;
|
||||||
|
|
||||||
pub async fn get(State(pool): State<PgPool>) -> Result<Json<Vec<Entry>>, Error> {
|
pub async fn get(
|
||||||
Ok(Json(Entry::get_all(&pool, Default::default()).await?))
|
Query(options): Query<GetEntriesOptions>,
|
||||||
|
accept: Option<TypedHeader<Accept>>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||||
|
let entries = Entry::get_all(&pool, &options).await.map_err(Error::from)?;
|
||||||
|
if let Some(TypedHeader(accept)) = accept {
|
||||||
|
if accept == Accept::ApplicationJson {
|
||||||
|
return Ok::<ApiResponse<Vec<Entry>>, Error>(ApiResponse::Json(entries));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ApiResponse::Html(
|
||||||
|
entry_list(entries, &options).into_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,27 @@
|
|||||||
use axum::{extract::State, Json};
|
use axum::TypedHeader;
|
||||||
|
use axum::extract::Query;
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use axum::extract::State;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::api_response::ApiResponse;
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
use crate::models::feed::Feed;
|
use crate::headers::Accept;
|
||||||
|
use crate::models::feed::{Feed, GetFeedsOptions};
|
||||||
|
use crate::partials::feed_list::feed_list;
|
||||||
|
|
||||||
pub async fn get(State(pool): State<PgPool>) -> Result<Json<Vec<Feed>>, Error> {
|
pub async fn get(
|
||||||
// TODO: pagination
|
Query(options): Query<GetFeedsOptions>,
|
||||||
Ok(Json(Feed::get_all(&pool, Default::default()).await?))
|
accept: Option<TypedHeader<Accept>>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<impl IntoResponse, impl IntoResponse> {
|
||||||
|
let feeds = Feed::get_all(&pool, &options).await.map_err(Error::from)?;
|
||||||
|
if let Some(TypedHeader(accept)) = accept {
|
||||||
|
if accept == Accept::ApplicationJson {
|
||||||
|
return Ok::<ApiResponse<Vec<Feed>>, Error>(ApiResponse::Json(feeds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ApiResponse::Html(
|
||||||
|
feed_list(feeds, &options).into_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
15
src/handlers/entries.rs
Normal file
15
src/handlers/entries.rs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
use axum::extract::{Query, State};
|
||||||
|
use maud::Markup;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::error::Result;
|
||||||
|
use crate::models::entry::{Entry, GetEntriesOptions};
|
||||||
|
use crate::partials::entry_list::entry_list;
|
||||||
|
|
||||||
|
pub async fn get(
|
||||||
|
Query(options): Query<GetEntriesOptions>,
|
||||||
|
State(pool): State<PgPool>,
|
||||||
|
) -> Result<Markup> {
|
||||||
|
let entries = Entry::get_all(&pool, &options).await?;
|
||||||
|
Ok(entry_list(entries, &options))
|
||||||
|
}
|
@ -20,10 +20,12 @@ pub async fn get(
|
|||||||
let entry = Entry::get(&pool, id.as_uuid()).await?;
|
let entry = Entry::get(&pool, id.as_uuid()).await?;
|
||||||
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".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 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.render(html! {
|
Ok(layout.with_subtitle(&title).render(html! {
|
||||||
article {
|
article {
|
||||||
h2 class="title" { a href=(entry.url) { (title) } }
|
h2 class="title" { a href=(entry.url) { (title) } }
|
||||||
div {
|
div {
|
||||||
|
@ -16,7 +16,7 @@ use tokio_stream::StreamExt;
|
|||||||
use crate::actors::crawl_scheduler::{CrawlSchedulerHandle, CrawlSchedulerHandleMessage};
|
use crate::actors::crawl_scheduler::{CrawlSchedulerHandle, CrawlSchedulerHandleMessage};
|
||||||
use crate::actors::feed_crawler::FeedCrawlerHandleMessage;
|
use crate::actors::feed_crawler::FeedCrawlerHandleMessage;
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::models::entry::Entry;
|
use crate::models::entry::{Entry, GetEntriesOptions};
|
||||||
use crate::models::feed::{CreateFeed, Feed};
|
use crate::models::feed::{CreateFeed, Feed};
|
||||||
use crate::partials::add_feed_form::add_feed_form;
|
use crate::partials::add_feed_form::add_feed_form;
|
||||||
use crate::partials::entry_link::entry_link;
|
use crate::partials::entry_link::entry_link;
|
||||||
@ -30,11 +30,16 @@ pub async fn get(
|
|||||||
layout: Layout,
|
layout: Layout,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let feed = Feed::get(&pool, id.as_uuid()).await?;
|
let feed = Feed::get(&pool, id.as_uuid()).await?;
|
||||||
let entries = Entry::get_all_for_feed(&pool, feed.feed_id, Default::default()).await?;
|
let options = GetEntriesOptions {
|
||||||
|
feed_id: Some(feed.feed_id),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let title = feed.title.unwrap_or_else(|| "Untitled Feed".to_string());
|
||||||
|
let entries = Entry::get_all(&pool, &options).await?;
|
||||||
let delete_url = format!("/feed/{}/delete", id);
|
let delete_url = format!("/feed/{}/delete", id);
|
||||||
Ok(layout.render(html! {
|
Ok(layout.with_subtitle(&title).render(html! {
|
||||||
header class="feed-header" {
|
header class="feed-header" {
|
||||||
h2 { (feed.title.unwrap_or_else(|| "Untitled Feed".to_string())) }
|
h2 { (title) }
|
||||||
button class="edit-feed" { "✏️ Edit feed" }
|
button class="edit-feed" { "✏️ Edit feed" }
|
||||||
form action=(delete_url) method="post" {
|
form action=(delete_url) method="post" {
|
||||||
button type="submit" class="remove-feed" data-controller="remove-feed" { "❌ Remove feed" }
|
button type="submit" class="remove-feed" data-controller="remove-feed" { "❌ Remove feed" }
|
||||||
@ -43,7 +48,7 @@ pub async fn get(
|
|||||||
@if let Some(description) = feed.description {
|
@if let Some(description) = feed.description {
|
||||||
p { (description) }
|
p { (description) }
|
||||||
}
|
}
|
||||||
(entry_list(entries))
|
(entry_list(entries, &options))
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -178,7 +183,7 @@ pub async fn stream(
|
|||||||
entry,
|
entry,
|
||||||
)))) => Ok(Event::default().data(
|
)))) => Ok(Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li { "Crawled entry: " (entry_link(entry)) }
|
li { "Crawled entry: " (entry_link(&entry)) }
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
)),
|
)),
|
||||||
|
@ -7,16 +7,18 @@ use crate::error::Result;
|
|||||||
use crate::models::feed::{Feed, GetFeedsOptions};
|
use crate::models::feed::{Feed, GetFeedsOptions};
|
||||||
use crate::partials::add_feed_form::add_feed_form;
|
use crate::partials::add_feed_form::add_feed_form;
|
||||||
use crate::partials::feed_list::feed_list;
|
use crate::partials::feed_list::feed_list;
|
||||||
use crate::partials::opml_import_form::opml_import_form;
|
|
||||||
use crate::partials::layout::Layout;
|
use crate::partials::layout::Layout;
|
||||||
|
use crate::partials::opml_import_form::opml_import_form;
|
||||||
|
|
||||||
pub async fn get(State(pool): State<PgPool>, layout: Layout) -> Result<Response> {
|
pub async fn get(State(pool): State<PgPool>, layout: Layout) -> Result<Response> {
|
||||||
let options = GetFeedsOptions::default();
|
let options = GetFeedsOptions::default();
|
||||||
let feeds = Feed::get_all(&pool, options.clone()).await?;
|
let feeds = Feed::get_all(&pool, &options).await?;
|
||||||
Ok(layout.render(html! {
|
Ok(layout.with_subtitle("feeds").render(html! {
|
||||||
h2 { "Feeds" }
|
h2 { "Feeds" }
|
||||||
div class="feeds" {
|
div class="feeds" {
|
||||||
(feed_list(feeds, options))
|
ul id="feeds" {
|
||||||
|
(feed_list(feeds, &options))
|
||||||
|
}
|
||||||
div class="add-feed" {
|
div class="add-feed" {
|
||||||
h3 { "Add Feed" }
|
h3 { "Add Feed" }
|
||||||
(add_feed_form())
|
(add_feed_form())
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::response::Response;
|
use axum::response::Response;
|
||||||
|
use maud::html;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
@ -7,6 +8,11 @@ use crate::models::entry::Entry;
|
|||||||
use crate::partials::{layout::Layout, entry_list::entry_list};
|
use crate::partials::{layout::Layout, entry_list::entry_list};
|
||||||
|
|
||||||
pub async fn get(State(pool): State<PgPool>, layout: Layout) -> Result<Response> {
|
pub async fn get(State(pool): State<PgPool>, layout: Layout) -> Result<Response> {
|
||||||
let entries = Entry::get_all(&pool, Default::default()).await?;
|
let options = Default::default();
|
||||||
Ok(layout.render(entry_list(entries)))
|
let entries = Entry::get_all(&pool, &options).await?;
|
||||||
|
Ok(layout.render(html! {
|
||||||
|
ul class="entries" {
|
||||||
|
(entry_list(entries, &options))
|
||||||
|
}
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
@ -76,7 +76,7 @@ pub async fn stream(
|
|||||||
))) => Ok::<Event, String>(
|
))) => Ok::<Event, String>(
|
||||||
Event::default().data(
|
Event::default().data(
|
||||||
html! {
|
html! {
|
||||||
li { "Crawled entry: " (entry_link(entry)) }
|
li { "Crawled entry: " (entry_link(&entry)) }
|
||||||
}
|
}
|
||||||
.into_string(),
|
.into_string(),
|
||||||
),
|
),
|
||||||
|
@ -22,7 +22,7 @@ use crate::partials::layout::Layout;
|
|||||||
|
|
||||||
pub async fn get(layout: Layout) -> Result<Response> {
|
pub async fn get(layout: Layout) -> Result<Response> {
|
||||||
let mem_buf = MEM_LOG.lock().unwrap();
|
let mem_buf = MEM_LOG.lock().unwrap();
|
||||||
Ok(layout.render(html! {
|
Ok(layout.with_subtitle("log").render(html! {
|
||||||
pre id="log" hx-sse="connect:/log/stream swap:message" hx-swap="beforeend" {
|
pre id="log" hx-sse="connect:/log/stream swap:message" hx-swap="beforeend" {
|
||||||
(PreEscaped(convert_escaped(from_utf8(mem_buf.as_slices().0).unwrap()).unwrap()))
|
(PreEscaped(convert_escaped(from_utf8(mem_buf.as_slices().0).unwrap()).unwrap()))
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
|
pub mod entries;
|
||||||
pub mod entry;
|
pub mod entry;
|
||||||
pub mod home;
|
pub mod home;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
|
58
src/headers.rs
Normal file
58
src/headers.rs
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
use axum::{
|
||||||
|
headers::{self, Header},
|
||||||
|
http::{HeaderName, HeaderValue},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Typed header implementation for the `Accept` header.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Accept {
|
||||||
|
TextHtml,
|
||||||
|
ApplicationJson,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Accept {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Accept::TextHtml => write!(f, "text/html"),
|
||||||
|
Accept::ApplicationJson => write!(f, "application/json"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Header for Accept {
|
||||||
|
fn name() -> &'static HeaderName {
|
||||||
|
&http::header::ACCEPT
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode<'i, I>(values: &mut I) -> Result<Self, headers::Error>
|
||||||
|
where
|
||||||
|
I: Iterator<Item = &'i HeaderValue>,
|
||||||
|
{
|
||||||
|
let value = values.next().ok_or_else(headers::Error::invalid)?;
|
||||||
|
|
||||||
|
match value.to_str().map_err(|_| headers::Error::invalid())? {
|
||||||
|
"text/html" => Ok(Accept::TextHtml),
|
||||||
|
"application/json" => Ok(Accept::ApplicationJson),
|
||||||
|
_ => Err(headers::Error::invalid()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn encode<E>(&self, values: &mut E)
|
||||||
|
where
|
||||||
|
E: Extend<HeaderValue>,
|
||||||
|
{
|
||||||
|
values.extend(std::iter::once(self.into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Accept> for HeaderValue {
|
||||||
|
fn from(value: Accept) -> Self {
|
||||||
|
HeaderValue::from(&value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&Accept> for HeaderValue {
|
||||||
|
fn from(value: &Accept) -> Self {
|
||||||
|
HeaderValue::from_str(value.to_string().as_str()).unwrap()
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,10 @@
|
|||||||
pub mod actors;
|
pub mod actors;
|
||||||
|
pub mod api_response;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod domain_locks;
|
pub mod domain_locks;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod handlers;
|
pub mod handlers;
|
||||||
|
pub mod headers;
|
||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod partials;
|
pub mod partials;
|
||||||
@ -11,5 +13,5 @@ pub mod utils;
|
|||||||
pub mod uuid;
|
pub mod uuid;
|
||||||
|
|
||||||
pub const USER_AGENT: &str = "crawlnicle/0.1.0";
|
pub const USER_AGENT: &str = "crawlnicle/0.1.0";
|
||||||
pub const JS_BUNDLES: &str = include_str!("../static/js/manifest.txt");
|
pub const JS_MANIFEST: &str = include_str!("../static/js/manifest.txt");
|
||||||
pub const CSS_BUNDLES: &str = include_str!("../static/css/manifest.txt");
|
pub const CSS_MANIFEST: &str = include_str!("../static/css/manifest.txt");
|
||||||
|
@ -89,6 +89,7 @@ async fn main() -> Result<()> {
|
|||||||
.route("/feed/:id", get(handlers::feed::get))
|
.route("/feed/:id", get(handlers::feed::get))
|
||||||
.route("/feed/:id/stream", get(handlers::feed::stream))
|
.route("/feed/:id/stream", get(handlers::feed::stream))
|
||||||
.route("/feed/:id/delete", post(handlers::feed::delete))
|
.route("/feed/:id/delete", post(handlers::feed::delete))
|
||||||
|
.route("/entries", get(handlers::entries::get))
|
||||||
.route("/entry/:id", get(handlers::entry::get))
|
.route("/entry/:id", get(handlers::entry::get))
|
||||||
.route("/log", get(handlers::log::get))
|
.route("/log", get(handlers::log::get))
|
||||||
.route("/log/stream", get(handlers::log::stream))
|
.route("/log/stream", get(handlers::log::stream))
|
||||||
|
@ -6,7 +6,7 @@ use validator::{Validate, ValidationErrors};
|
|||||||
|
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
|
|
||||||
const DEFAULT_ENTRIES_PAGE_SIZE: i64 = 50;
|
pub const DEFAULT_ENTRIES_PAGE_SIZE: i64 = 50;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct Entry {
|
pub struct Entry {
|
||||||
@ -35,9 +35,11 @@ pub struct CreateEntry {
|
|||||||
pub published_at: DateTime<Utc>,
|
pub published_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default, Deserialize)]
|
||||||
pub struct GetEntriesOptions {
|
pub struct GetEntriesOptions {
|
||||||
|
pub feed_id: Option<Uuid>,
|
||||||
pub published_before: Option<DateTime<Utc>>,
|
pub published_before: Option<DateTime<Utc>>,
|
||||||
|
pub id_before: Option<Uuid>,
|
||||||
pub limit: Option<i64>,
|
pub limit: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,71 +56,103 @@ impl Entry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_all(pool: &PgPool, options: GetEntriesOptions) -> sqlx::Result<Vec<Entry>> {
|
pub async fn get_all(pool: &PgPool, options: &GetEntriesOptions) -> sqlx::Result<Vec<Entry>> {
|
||||||
if let Some(published_before) = options.published_before {
|
if let Some(feed_id) = options.feed_id {
|
||||||
sqlx::query_as!(
|
if let Some(published_before) = options.published_before {
|
||||||
Entry,
|
if let Some(id_before) = options.id_before {
|
||||||
"select * from entry
|
sqlx::query_as!(
|
||||||
where deleted_at is null
|
Entry,
|
||||||
and published_at < $1
|
"select * from entry
|
||||||
order by published_at desc
|
where deleted_at is null
|
||||||
limit $2
|
and feed_id = $1
|
||||||
",
|
and (published_at, entry_id) < ($2, $3)
|
||||||
published_before,
|
order by published_at desc, entry_id desc
|
||||||
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
limit $4
|
||||||
)
|
",
|
||||||
.fetch_all(pool)
|
feed_id,
|
||||||
.await
|
published_before,
|
||||||
|
id_before,
|
||||||
|
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Entry,
|
||||||
|
"select * from entry
|
||||||
|
where deleted_at is null
|
||||||
|
and feed_id = $1
|
||||||
|
and published_at < $2
|
||||||
|
order by published_at desc
|
||||||
|
limit $3
|
||||||
|
",
|
||||||
|
feed_id,
|
||||||
|
published_before,
|
||||||
|
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sqlx::query_as!(
|
||||||
|
Entry,
|
||||||
|
"select * from entry
|
||||||
|
where deleted_at is null
|
||||||
|
and feed_id = $1
|
||||||
|
order by published_at desc
|
||||||
|
limit $2
|
||||||
|
",
|
||||||
|
feed_id,
|
||||||
|
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
sqlx::query_as!(
|
if let Some(published_before) = options.published_before {
|
||||||
Entry,
|
if let Some(id_before) = options.id_before {
|
||||||
"select * from entry
|
sqlx::query_as!(
|
||||||
where deleted_at is null
|
Entry,
|
||||||
order by published_at desc
|
"select * from entry
|
||||||
limit $1
|
where deleted_at is null
|
||||||
",
|
and (published_at, entry_id) < ($1, $2)
|
||||||
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
order by published_at desc, entry_id desc
|
||||||
)
|
limit $3
|
||||||
.fetch_all(pool)
|
",
|
||||||
.await
|
published_before,
|
||||||
}
|
id_before,
|
||||||
}
|
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||||
|
)
|
||||||
pub async fn get_all_for_feed(
|
.fetch_all(pool)
|
||||||
pool: &PgPool,
|
.await
|
||||||
feed_id: Uuid,
|
} else {
|
||||||
options: GetEntriesOptions,
|
sqlx::query_as!(
|
||||||
) -> sqlx::Result<Vec<Entry>> {
|
Entry,
|
||||||
if let Some(published_before) = options.published_before {
|
"select * from entry
|
||||||
sqlx::query_as!(
|
where deleted_at is null
|
||||||
Entry,
|
and published_at < $1
|
||||||
"select * from entry
|
order by published_at desc
|
||||||
where deleted_at is null
|
limit $2
|
||||||
and feed_id = $1
|
",
|
||||||
and published_at < $2
|
published_before,
|
||||||
order by published_at desc
|
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||||
limit $3
|
)
|
||||||
",
|
.fetch_all(pool)
|
||||||
feed_id,
|
.await
|
||||||
published_before,
|
}
|
||||||
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
} else {
|
||||||
)
|
sqlx::query_as!(
|
||||||
.fetch_all(pool)
|
Entry,
|
||||||
.await
|
"select * from entry
|
||||||
} else {
|
where deleted_at is null
|
||||||
sqlx::query_as!(
|
order by published_at desc
|
||||||
Entry,
|
limit $1
|
||||||
"select * from entry
|
",
|
||||||
where deleted_at is null
|
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
||||||
and feed_id = $1
|
)
|
||||||
order by published_at desc
|
.fetch_all(pool)
|
||||||
limit $2
|
.await
|
||||||
",
|
}
|
||||||
feed_id,
|
|
||||||
options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE)
|
|
||||||
)
|
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,7 +109,7 @@ pub struct UpdateFeed {
|
|||||||
pub last_entry_published_at: Option<Option<DateTime<Utc>>>,
|
pub last_entry_published_at: Option<Option<DateTime<Utc>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub enum GetFeedsSort {
|
pub enum GetFeedsSort {
|
||||||
Title,
|
Title,
|
||||||
CreatedAt,
|
CreatedAt,
|
||||||
@ -117,11 +117,12 @@ pub enum GetFeedsSort {
|
|||||||
LastEntryPublishedAt,
|
LastEntryPublishedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone, Deserialize)]
|
||||||
pub struct GetFeedsOptions {
|
pub struct GetFeedsOptions {
|
||||||
pub sort: Option<GetFeedsSort>,
|
pub sort: Option<GetFeedsSort>,
|
||||||
pub before: Option<DateTime<Utc>>,
|
pub before: Option<DateTime<Utc>>,
|
||||||
pub after_title: Option<String>,
|
pub after_title: Option<String>,
|
||||||
|
pub before_id: Option<Uuid>,
|
||||||
pub limit: Option<i64>,
|
pub limit: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -159,11 +160,11 @@ impl Feed {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_all(pool: &PgPool, options: GetFeedsOptions) -> sqlx::Result<Vec<Feed>> {
|
pub async fn get_all(pool: &PgPool, options: &GetFeedsOptions) -> sqlx::Result<Vec<Feed>> {
|
||||||
// TODO: make sure there are indices for all of these sort options
|
// TODO: make sure there are indices for all of these sort options
|
||||||
match options.sort.unwrap_or(GetFeedsSort::CreatedAt) {
|
match options.sort.as_ref().unwrap_or(&GetFeedsSort::CreatedAt) {
|
||||||
GetFeedsSort::Title => {
|
GetFeedsSort::Title => {
|
||||||
if let Some(after_title) = options.after_title {
|
if let Some(after_title) = &options.after_title {
|
||||||
sqlx::query_as!(
|
sqlx::query_as!(
|
||||||
Feed,
|
Feed,
|
||||||
r#"select
|
r#"select
|
||||||
@ -183,11 +184,12 @@ impl Feed {
|
|||||||
deleted_at
|
deleted_at
|
||||||
from feed
|
from feed
|
||||||
where deleted_at is null
|
where deleted_at is null
|
||||||
and title > $1
|
and (title, feed_id) > ($1, $2)
|
||||||
order by title asc
|
order by title asc, feed_id asc
|
||||||
limit $2
|
limit $3
|
||||||
"#,
|
"#,
|
||||||
after_title,
|
after_title,
|
||||||
|
options.before_id.unwrap_or(Uuid::nil()),
|
||||||
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@ -212,7 +214,7 @@ impl Feed {
|
|||||||
deleted_at
|
deleted_at
|
||||||
from feed
|
from feed
|
||||||
where deleted_at is null
|
where deleted_at is null
|
||||||
order by title asc
|
order by title asc, feed_id asc
|
||||||
limit $1
|
limit $1
|
||||||
"#,
|
"#,
|
||||||
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
||||||
@ -243,11 +245,12 @@ impl Feed {
|
|||||||
deleted_at
|
deleted_at
|
||||||
from feed
|
from feed
|
||||||
where deleted_at is null
|
where deleted_at is null
|
||||||
and created_at < $1
|
and (created_at, feed_id) < ($1, $2)
|
||||||
order by created_at desc
|
order by created_at desc, feed_id desc
|
||||||
limit $2
|
limit $3
|
||||||
"#,
|
"#,
|
||||||
created_before,
|
created_before,
|
||||||
|
options.before_id.unwrap_or(Uuid::nil()),
|
||||||
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@ -272,7 +275,7 @@ impl Feed {
|
|||||||
deleted_at
|
deleted_at
|
||||||
from feed
|
from feed
|
||||||
where deleted_at is null
|
where deleted_at is null
|
||||||
order by created_at desc
|
order by created_at desc, feed_id desc
|
||||||
limit $1
|
limit $1
|
||||||
"#,
|
"#,
|
||||||
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
||||||
@ -303,11 +306,12 @@ impl Feed {
|
|||||||
deleted_at
|
deleted_at
|
||||||
from feed
|
from feed
|
||||||
where deleted_at is null
|
where deleted_at is null
|
||||||
and last_crawled_at < $1
|
and (last_crawled_at, feed_id) < ($1, $2)
|
||||||
order by last_crawled_at desc
|
order by last_crawled_at desc, feed_id desc
|
||||||
limit $2
|
limit $3
|
||||||
"#,
|
"#,
|
||||||
crawled_before,
|
crawled_before,
|
||||||
|
options.before_id.unwrap_or(Uuid::nil()),
|
||||||
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@ -332,7 +336,7 @@ impl Feed {
|
|||||||
deleted_at
|
deleted_at
|
||||||
from feed
|
from feed
|
||||||
where deleted_at is null
|
where deleted_at is null
|
||||||
order by last_crawled_at desc
|
order by last_crawled_at desc, feed_id desc
|
||||||
limit $1
|
limit $1
|
||||||
"#,
|
"#,
|
||||||
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
||||||
@ -363,11 +367,12 @@ impl Feed {
|
|||||||
deleted_at
|
deleted_at
|
||||||
from feed
|
from feed
|
||||||
where deleted_at is null
|
where deleted_at is null
|
||||||
and last_entry_published_at < $1
|
and (last_entry_published_at, feed_id) < ($1, $2)
|
||||||
order by last_entry_published_at desc
|
order by last_entry_published_at desc, feed_id desc
|
||||||
limit $2
|
limit $3
|
||||||
"#,
|
"#,
|
||||||
published_before,
|
published_before,
|
||||||
|
options.before_id.unwrap_or(Uuid::nil()),
|
||||||
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
||||||
)
|
)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@ -392,7 +397,7 @@ impl Feed {
|
|||||||
deleted_at
|
deleted_at
|
||||||
from feed
|
from feed
|
||||||
where deleted_at is null
|
where deleted_at is null
|
||||||
order by last_entry_published_at desc
|
order by last_entry_published_at desc, feed_id desc
|
||||||
limit $1
|
limit $1
|
||||||
"#,
|
"#,
|
||||||
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE),
|
||||||
|
@ -4,8 +4,8 @@ use crate::models::entry::Entry;
|
|||||||
use crate::utils::get_domain;
|
use crate::utils::get_domain;
|
||||||
use crate::uuid::Base62Uuid;
|
use crate::uuid::Base62Uuid;
|
||||||
|
|
||||||
pub fn entry_link(entry: Entry) -> Markup {
|
pub fn entry_link(entry: &Entry) -> Markup {
|
||||||
let title = entry.title.unwrap_or_else(|| "Untitled".to_string());
|
let title = entry.title.as_ref().map(|s| s.clone()).unwrap_or_else(|| "Untitled".to_string());
|
||||||
let url = format!("/entry/{}", Base62Uuid::from(entry.entry_id));
|
let url = format!("/entry/{}", Base62Uuid::from(entry.entry_id));
|
||||||
let domain = get_domain(&entry.url).unwrap_or_default();
|
let domain = get_domain(&entry.url).unwrap_or_default();
|
||||||
html! {
|
html! {
|
||||||
|
@ -1,12 +1,47 @@
|
|||||||
use maud::{html, Markup};
|
use maud::{html, Markup};
|
||||||
|
|
||||||
use crate::models::entry::Entry;
|
use crate::models::entry::{Entry, GetEntriesOptions, DEFAULT_ENTRIES_PAGE_SIZE};
|
||||||
use crate::partials::entry_link::entry_link;
|
use crate::partials::entry_link::entry_link;
|
||||||
|
|
||||||
pub fn entry_list(entries: Vec<Entry>) -> Markup {
|
pub fn entry_list(entries: Vec<Entry>, options: &GetEntriesOptions) -> Markup {
|
||||||
|
let len = entries.len() as i64;
|
||||||
|
if len == 0 {
|
||||||
|
return html! { p { "No entries found." } };
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut more_query = None;
|
||||||
|
if len == options.limit.unwrap_or(DEFAULT_ENTRIES_PAGE_SIZE) {
|
||||||
|
let last_entry = entries.last().unwrap();
|
||||||
|
if let Some(feed_id) = options.feed_id {
|
||||||
|
more_query = Some(format!(
|
||||||
|
"/api/v1/entries?feed_id={}&published_before={}&id_before={}",
|
||||||
|
feed_id,
|
||||||
|
last_entry.published_at,
|
||||||
|
last_entry.entry_id
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
more_query = Some(format!(
|
||||||
|
"/api/v1/entries?published_before={}&id_before={}",
|
||||||
|
last_entry.published_at,
|
||||||
|
last_entry.entry_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
ul class="entries" {
|
@for (i, entry) in entries.iter().enumerate() {
|
||||||
@for entry in entries {
|
@if i == entries.len() - 1 {
|
||||||
|
@if let Some(ref more_query) = more_query {
|
||||||
|
li class="entry" hx-get=(more_query) hx-trigger="revealed" hx-swap="afterend" {
|
||||||
|
(entry_link(entry))
|
||||||
|
div class="htmx-indicator list-loading" {
|
||||||
|
img class="loading" src="/static/img/three-dots.svg" alt="Loading...";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
li class="entry" { (entry_link(entry)) }
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
li class="entry" { (entry_link(entry)) }
|
li class="entry" { (entry_link(entry)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,22 +3,37 @@ use maud::{html, Markup};
|
|||||||
use crate::models::feed::{Feed, GetFeedsOptions, DEFAULT_FEEDS_PAGE_SIZE};
|
use crate::models::feed::{Feed, GetFeedsOptions, DEFAULT_FEEDS_PAGE_SIZE};
|
||||||
use crate::partials::feed_link::feed_link;
|
use crate::partials::feed_link::feed_link;
|
||||||
|
|
||||||
pub fn feed_list(feeds: Vec<Feed>, options: GetFeedsOptions) -> Markup {
|
pub fn feed_list(feeds: Vec<Feed>, options: &GetFeedsOptions) -> Markup {
|
||||||
let len = feeds.len() as i64;
|
let len = feeds.len() as i64;
|
||||||
|
if len == 0 {
|
||||||
|
return html! { p { "No feeds found." } };
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut more_query = None;
|
||||||
|
if len == options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE) {
|
||||||
|
let last_feed = feeds.last().unwrap();
|
||||||
|
more_query = Some(format!(
|
||||||
|
"/api/v1/feeds?sort=CreatedAt&before={}&id_before={}",
|
||||||
|
last_feed.created_at,
|
||||||
|
last_feed.feed_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
div class="feeds-list" {
|
@for (i, feed) in feeds.iter().enumerate() {
|
||||||
@if len == 0 {
|
@if i == feeds.len() - 1 {
|
||||||
p id="no-feeds" { "No feeds found." }
|
@if let Some(ref more_query) = more_query {
|
||||||
} else {
|
li class="feed" hx-get=(more_query) hx-trigger="revealed" hx-swap="afterend" {
|
||||||
ul id="feeds" {
|
(feed_link(feed, false))
|
||||||
@for feed in feeds {
|
div class="htmx-indicator list-loading" {
|
||||||
li { (feed_link(&feed, false)) }
|
img class="loading" src="/static/img/three-dots.svg" alt="Loading...";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} @else {
|
||||||
|
li class="feed" { (feed_link(feed, false)) }
|
||||||
}
|
}
|
||||||
}
|
} @else {
|
||||||
// TODO: pagination
|
li class="feed" { (feed_link(feed, false)) }
|
||||||
@if len == options.limit.unwrap_or(DEFAULT_FEEDS_PAGE_SIZE) {
|
|
||||||
button id="load-more-feeds" { "Load More" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,10 +14,12 @@ use maud::{html, Markup, DOCTYPE};
|
|||||||
use crate::partials::header::header;
|
use crate::partials::header::header;
|
||||||
use crate::{config::Config, partials::footer::footer};
|
use crate::{config::Config, partials::footer::footer};
|
||||||
#[cfg(not(debug_assertions))]
|
#[cfg(not(debug_assertions))]
|
||||||
use crate::{CSS_BUNDLES, JS_BUNDLES};
|
use crate::{CSS_MANIFEST, JS_MANIFEST};
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
pub struct Layout {
|
pub struct Layout {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
pub subtitle: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@ -34,6 +36,7 @@ where
|
|||||||
.map_err(|err| err.into_response())?;
|
.map_err(|err| err.into_response())?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
title: config.title,
|
title: config.title,
|
||||||
|
..Default::default()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -44,7 +47,7 @@ where
|
|||||||
// In release mode, this work is done ahead of time in build.rs and saved to static/js/manifest.txt
|
// In release mode, this work is done ahead of time in build.rs and saved to static/js/manifest.txt
|
||||||
// and static/css/manifest.txt. The contents of those files are then compiled into the server
|
// and static/css/manifest.txt. The contents of those files are then compiled into the server
|
||||||
// binary so that rendering the Layout does not need to do any filesystem operations.
|
// binary so that rendering the Layout does not need to do any filesystem operations.
|
||||||
fn get_bundles(asset_type: &str) -> Vec<String> {
|
fn get_manifest(asset_type: &str) -> Vec<String> {
|
||||||
let root_dir = Path::new("./");
|
let root_dir = Path::new("./");
|
||||||
let dir = root_dir.join(format!("static/{}", asset_type));
|
let dir = root_dir.join(format!("static/{}", asset_type));
|
||||||
|
|
||||||
@ -68,38 +71,56 @@ fn get_bundles(asset_type: &str) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
fn js_bundles() -> Vec<String> {
|
fn js_manifest() -> Vec<String> {
|
||||||
get_bundles("js")
|
get_manifest("js")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(debug_assertions))]
|
#[cfg(not(debug_assertions))]
|
||||||
fn js_bundles() -> Lines<'static> {
|
fn js_manifest() -> Lines<'static> {
|
||||||
JS_BUNDLES.lines()
|
JS_MANIFEST.lines()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
fn css_bundles() -> Vec<String> {
|
fn css_manifest() -> Vec<String> {
|
||||||
get_bundles("css")
|
get_manifest("css")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(debug_assertions))]
|
#[cfg(not(debug_assertions))]
|
||||||
fn css_bundles() -> Lines<'static> {
|
fn css_manifest() -> Lines<'static> {
|
||||||
CSS_BUNDLES.lines()
|
CSS_MANIFEST.lines()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Layout {
|
impl Layout {
|
||||||
|
pub fn with_title(mut self, title: &str) -> Self {
|
||||||
|
self.title = title.to_string();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_subtitle(mut self, subtitle: &str) -> Self {
|
||||||
|
self.subtitle = Some(subtitle.to_string());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_title(&self) -> String {
|
||||||
|
if let Some(subtitle) = &self.subtitle {
|
||||||
|
format!("{} - {}", self.title, subtitle)
|
||||||
|
} else {
|
||||||
|
self.title.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render(self, template: Markup) -> Response {
|
pub fn render(self, template: Markup) -> Response {
|
||||||
let with_layout = html! {
|
let with_layout = html! {
|
||||||
(DOCTYPE)
|
(DOCTYPE)
|
||||||
html lang="en" {
|
html lang="en" {
|
||||||
head {
|
head {
|
||||||
meta charset="utf-8";
|
meta charset="utf-8";
|
||||||
title { (self.title) }
|
title { (self.full_title()) }
|
||||||
@for js_bundle in js_bundles() {
|
@for js_file in js_manifest() {
|
||||||
script type="module" src=(js_bundle) {}
|
script type="module" src=(js_file) {}
|
||||||
}
|
}
|
||||||
@for css_bundle in css_bundles() {
|
@for css_file in css_manifest() {
|
||||||
link rel="stylesheet" href=(css_bundle) {}
|
link rel="stylesheet" href=(css_file) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
body hx-booster="true" {
|
body hx-booster="true" {
|
||||||
|
Loading…
Reference in New Issue
Block a user