M src/domain/entries.rs => src/domain/entries.rs +15 -0
@@ 50,6 50,21 @@ where Persistence: RussetEntryPersistenceLayer {
.filter(|entry| entry.as_ref().map_or_else(|_| true, |entry| !entry.tombstone))
.collect::<Vec<Result<Entry>>>()
}
+
+ pub async fn set_userentries(
+ &self,
+ entry_ids: &Vec<EntryId>,
+ user_id: &UserId,
+ user_entry: &UserEntry,
+ ) -> Result<()> {
+ for entry_id in entry_ids {
+ let _ = self.persistence
+ .get_entry_and_set_userentry(entry_id, user_id, user_entry)
+ .await?;
+ }
+ Ok(())
+ }
+
}
fn convert_entry(entry: PersistenceEntry, user_entry: Option<UserEntry>, tz: Tz) -> Entry {
M src/http/mod.rs => src/http/mod.rs +3 -55
@@ 1,17 1,11 @@
-use axum::extract::{ Form, State };
use axum::middleware::map_response;
-use axum::response::{ Html, Redirect, Response };
+use axum::response::{ Redirect, Response };
use axum::Router;
use axum::routing::{ any, get, post };
-use crate::domain::model::{ Entry, Feed };
use crate::domain::RussetDomainService;
use crate::http::session::AuthenticatedUser;
-use crate::model::{ FeedId, Pagination };
-use crate::persistence::model::User;
use crate::persistence::RussetPersistenceLayer;
-use sailfish::TemplateOnce;
use serde::Deserialize;
-use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::Semaphore;
use tower::limit::GlobalConcurrencyLimitLayer;
@@ 20,6 14,7 @@ use tower_http::compression::CompressionLayer;
mod entry;
mod feed;
mod login;
+mod root;
mod session;
mod static_routes;
mod subscribe;
@@ 36,7 31,7 @@ where Persistence: RussetPersistenceLayer {
.layer(GlobalConcurrencyLimitLayer::with_semaphore(login_limit_sempahore))
.route("/login", get(login::login_page))
.route("/styles.css", get(static_routes::styles))
- .route("/", get(home))
+ .route("/", get(root::root).post(root::edit_userentries))
.route("/entry/:id", get(entry::mark_read_redirect))
.route("/feed/:id", get(feed::feed_page).post(feed::unsubscribe))
.route("/subscribe", get(subscribe::subscribe_page).post(subscribe::subscribe))
@@ 67,56 62,9 @@ where Persistence: RussetPersistenceLayer {
fn clone(&self) -> Self { AppState { domain_service: self.domain_service.clone() } }
}
-// Home (root) page template
-#[derive(TemplateOnce)]
-#[template(path = "home.stpl")]
-struct HomePageTemplate<'a> {
- user: Option<&'a User>,
- entries: &'a [Entry],
- feeds: &'a HashMap<FeedId, Feed>,
- page_num: usize,
- page_title: &'a str,
- relative_root: &'a str,
-}
#[derive(Debug, Deserialize)]
struct PageQuery {
page_num: Option<usize>,
page_size: Option<usize>,
}
-#[tracing::instrument]
-async fn home<Persistence>(
- State(state): State<AppState<Persistence>>,
- user: AuthenticatedUser<Persistence>,
- Form(pagination): Form<PageQuery>,
-) -> Html<String>
-where Persistence: RussetPersistenceLayer {
- let page_num = pagination.page_num.unwrap_or(0);
- let page_size = pagination.page_size.unwrap_or(100);
- let pagination = Pagination { page_num, page_size };
- let entries = state.domain_service
- .get_subscribed_entries(&user.user.id, &pagination)
- .await
- .into_iter()
- .filter_map(|entry| entry.ok())
- .collect::<Vec<Entry>>();
- let feeds = state.domain_service
- .feeds_for_user(&user.user.id)
- .await
- .into_iter()
- .filter_map(|feed| feed.ok())
- .map(|feed| (feed.id.clone(), feed))
- .collect::<HashMap<FeedId, Feed>>();
- Html(
- HomePageTemplate {
- user: Some(&user.user),
- entries: entries.as_slice(),
- feeds: &feeds,
- page_num: pagination.page_num,
- page_title: "Entries",
- relative_root: "",
- }
- .render_once()
- .unwrap()
- )
-}
A src/http/root.rs => src/http/root.rs +132 -0
@@ 0,0 1,132 @@
+use axum::extract::{ Form, State };
+use axum::http::StatusCode;
+use axum::response::{ Html, Redirect };
+use crate::domain::model::{ Entry, Feed };
+use crate::http::{ AppState, PageQuery };
+use crate::http::session::AuthenticatedUser;
+use crate::model::{ EntryId, FeedId, Pagination, Timestamp };
+use crate::persistence::model::{ User, UserEntry };
+use crate::persistence::RussetPersistenceLayer;
+use crate::Result;
+use sailfish::TemplateOnce;
+use std::collections::HashMap;
+use std::time::SystemTime;
+use tracing::error;
+
+// Root (home/entries) page template
+#[derive(TemplateOnce)]
+#[template(path = "root.stpl")]
+struct RootPageTemplate<'a> {
+ user: Option<&'a User>,
+ entries: &'a [Entry],
+ feeds: &'a HashMap<FeedId, Feed>,
+ page_num: usize,
+ page_title: &'a str,
+ relative_root: &'a str,
+}
+#[tracing::instrument]
+pub async fn root<Persistence>(
+ State(state): State<AppState<Persistence>>,
+ user: AuthenticatedUser<Persistence>,
+ Form(pagination): Form<PageQuery>,
+) -> Html<String>
+where Persistence: RussetPersistenceLayer {
+ let page_num = pagination.page_num.unwrap_or(0);
+ let page_size = pagination.page_size.unwrap_or(100);
+ let pagination = Pagination { page_num, page_size };
+ let entries = state.domain_service
+ .get_subscribed_entries(&user.user.id, &pagination)
+ .await
+ .into_iter()
+ .filter_map(|entry| entry.ok())
+ .collect::<Vec<Entry>>();
+ let feeds = state.domain_service
+ .feeds_for_user(&user.user.id)
+ .await
+ .into_iter()
+ .filter_map(|feed| feed.ok())
+ .map(|feed| (feed.id.clone(), feed))
+ .collect::<HashMap<FeedId, Feed>>();
+ Html(
+ RootPageTemplate {
+ user: Some(&user.user),
+ entries: entries.as_slice(),
+ feeds: &feeds,
+ page_num: pagination.page_num,
+ page_title: "Entries",
+ relative_root: "",
+ }
+ .render_once()
+ .unwrap()
+ )
+}
+
+#[derive(Debug)]
+enum Action {
+ MarkRead,
+ Delete,
+}
+#[derive(Debug)]
+pub struct EditUserEntriesRequest {
+ action: Action,
+ select_all: bool,
+ selected_ids: Vec<EntryId>,
+}
+impl EditUserEntriesRequest {
+ fn from_raw_entries(entries: &Vec<(String, String)>) -> Result<EditUserEntriesRequest> {
+ let mut action: Option<Action> = None;
+ let mut select_all = false;
+ let mut selected_ids: Vec<EntryId> = Vec::new();
+ for (key, value) in entries {
+ match key.as_str() {
+ "action" => {
+ if action.is_some() {
+ return Err(format!("Multiple actions: at least {action:?} and {value:?}").into())
+ }
+ action = Some(match value.as_str() {
+ "mark_read" => Action::MarkRead,
+ "delete" => Action::Delete,
+ _ => return Err(format!("").into()),
+ });
+ },
+ "select-all" => select_all = true,
+ key if key.starts_with("select-") => {
+ let suffix = key.strip_prefix("select-").unwrap();
+ let id = ulid::Ulid::from_string(suffix)?;
+ selected_ids.push(EntryId(id));
+ },
+ _ => return Err(format!("Bad key: {key:?}").into()),
+ }
+ }
+ let action = action.ok_or(Into::<crate::Err>::into("No action"))?;
+ Ok(EditUserEntriesRequest{
+ action,
+ select_all,
+ selected_ids,
+ })
+ }
+}
+pub async fn edit_userentries<Persistence>(
+ State(state): State<AppState<Persistence>>,
+ user: AuthenticatedUser<Persistence>,
+ Form(request): Form<Vec<(String, String)>>,
+) -> std::result::Result<Redirect, StatusCode>
+where Persistence: RussetPersistenceLayer {
+ let request = EditUserEntriesRequest::from_raw_entries(&request)
+ .map_err(|err| {
+ error!("{err:?}");
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+ let time = Some(Timestamp::new(SystemTime::now()));
+ let user_entry = match request.action {
+ Action::MarkRead => UserEntry { read: time, tombstone: None },
+ Action::Delete => UserEntry { read: time.clone(), tombstone: time },
+ };
+ state.domain_service.set_userentries(&request.selected_ids, &user.user.id, &user_entry)
+ .await
+ .map_err(|err| {
+ error!("{err:?}");
+ StatusCode::INTERNAL_SERVER_ERROR
+ })?;
+ Ok(Redirect::to("/"))
+}
R templates/home.stpl => templates/root.stpl +51 -43
@@ 1,45 1,53 @@
<% include!("head.stpl"); %>
- <form><table>
- <thead class="alt"><tr>
- <th scope="col">
- <label for="select-all">All</label>
- <input type="checkbox" name="select-all" />
- </th>
- <th scope="col">Title</th>
- <th scope="col">Date</th>
- <th scope="col">Feed</th>
- </tr></thead>
- <tbody><%
-for (i, entry) in entries.iter().enumerate() {
- let mut classes = vec![];
- if i % 2 == 1 { classes.push("alt") };
- if !entry.read { classes.push("unread") };
- let classes = if classes.len() > 0 {
- format!(" class=\"{}\"", classes.join(" "))
- } else {
- "".to_string()
- };
-%>
- <tr<%- classes %>>
- <td class="center"><input type="checkbox" name="select-<%= entry.id.to_string() %>" /></td>
- <td><a href="<%- relative_root %>entry/<%- entry.id.to_string() %>"><%= entry.title %></a></td>
- <td class="center"><%= entry.article_date %></td>
- <td class="center"><a href="<%- relative_root %>feed/<%= entry.feed_id.to_string() %>"><%=
-match feeds.get(&entry.feed_id) {
- Some(feed) => feed.title.clone(),
- None => "Unknown Feed".to_string(),
-}
-%></a></td>
- </tr><%
-}
-%>
- </tbody>
- </table></form>
- <div>Page:
- <% if page_num > 1 { %><a href="<%- relative_root %>?page_num=0">1</a>…<% } %>
- <% if page_num > 0 { %><a href="<%- relative_root %>?page_num=<%- page_num - 1 %>"><%- page_num %></a><% } %>
- <%- page_num + 1 %>
- <a href="<%- relative_root %>?page_num=<%- page_num + 1 %>"><%- page_num + 2 %></a>
- </div>
- <div><a href="<%- relative_root %>/subscribe">Subscribe</a></div>
+ <form action="<%- relative_root %>/" method="post">
+ <table>
+ <thead class="alt"><tr>
+ <th scope="col">
+ <label for="select-all">All</label>
+ <input type="checkbox" name="select-all" />
+ </th>
+ <th scope="col">Title</th>
+ <th scope="col">Date</th>
+ <th scope="col">Feed</th>
+ </tr></thead>
+ <tbody><%
+ for (i, entry) in entries.iter().enumerate() {
+ let mut classes = vec![];
+ if i % 2 == 1 { classes.push("alt") };
+ if !entry.read { classes.push("unread") };
+ let classes = if classes.len() > 0 {
+ format!(" class=\"{}\"", classes.join(" "))
+ } else {
+ "".to_string()
+ };
+ %>
+ <tr<%- classes %>>
+ <td class="center"><input type="checkbox" name="select-<%= entry.id.to_string() %>" /></td>
+ <td><a href="<%- relative_root %>entry/<%- entry.id.to_string() %>"><%= entry.title %></a></td>
+ <td class="center"><%= entry.article_date %></td>
+ <td class="center"><a href="<%- relative_root %>feed/<%= entry.feed_id.to_string() %>"><%=
+ match feeds.get(&entry.feed_id) {
+ Some(feed) => feed.title.clone(),
+ None => "Unknown Feed".to_string(),
+ }
+ %></a></td>
+ </tr><%
+ }
+ %>
+ </tbody>
+ </table>
+ <div>Page:
+ <% if page_num > 1 { %><a href="<%- relative_root %>?page_num=0">1</a>…<% } %>
+ <% if page_num > 0 { %><a href="<%- relative_root %>?page_num=<%- page_num - 1 %>"><%- page_num %></a><% } %>
+ <%- page_num + 1 %>
+ <a href="<%- relative_root %>?page_num=<%- page_num + 1 %>"><%- page_num + 2 %></a>
+ </div>
+ <div class="dialog" style="display: flex; justify-content: center;">
+ <div class="controls">
+ <button name="action" value="mark_read">Mark Read</button>
+ <button name="action" value="delete">Delete</button>
+ <button name="action" value="subscribe" formaction="<%- relative_root %>/subscribe" formmethod="get">Subscribe</button>
+ </div>
+ </div>
+ </form>
<% include!("foot.stpl"); %>