Add ability to save posts

This commit is contained in:
David Hamilton 2021-12-30 16:05:56 -07:00
parent 3facaefb53
commit 08e48bbfed
11 changed files with 185 additions and 4 deletions

View File

@ -4,6 +4,7 @@
// Reference local files
mod post;
mod saved;
mod search;
mod settings;
mod subreddit;
@ -198,6 +199,11 @@ async fn main() {
app.at("/user/:name/comments/:id/:title").get(|r| post::item(r).boxed());
app.at("/user/:name/comments/:id/:title/:comment_id").get(|r| post::item(r).boxed());
// Saved posts
app.at("/saved").get(|r| saved::get(r).boxed());
app.at("/saved/:id/save").post(|r| saved::save(r).boxed());
app.at("/saved/:id/unsave").post(|r| saved::unsave(r).boxed());
// Configure settings
app.at("/settings").get(|r| settings::get(r).boxed()).post(|r| settings::set(r).boxed());
app.at("/settings/restore").get(|r| settings::restore(r).boxed());

View File

@ -4,7 +4,7 @@ use crate::esc;
use crate::server::RequestExt;
use crate::subreddit::{can_access_quarantine, quarantine};
use crate::utils::{
error, format_num, format_url, get_filters, param, rewrite_urls, setting, template, time, val, Author, Awards, Comment, Flags, Flair, FlairPart, Media, Post, Preferences,
error, format_num, format_url, get_filters, get_saved_posts, param, rewrite_urls, setting, template, time, val, Author, Awards, Comment, Flags, Flair, FlairPart, Media, Post, Preferences,
};
use hyper::{Body, Request, Response};
@ -19,6 +19,7 @@ struct PostTemplate {
post: Post,
sort: String,
prefs: Preferences,
saved: Vec<String>,
single_thread: bool,
url: String,
}
@ -57,6 +58,7 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
// Parse the JSON into Post and Comment structs
let post = parse_post(&response[0]).await;
let comments = parse_comments(&response[1], &post.permalink, &post.author.name, highlighted_comment, &get_filters(&req));
let saved = get_saved_posts(&req);
let url = req.uri().to_string();
// Use the Post and Comment structs to generate a website to show users
@ -65,6 +67,7 @@ pub async fn item(req: Request<Body>) -> Result<Response<Body>, String> {
post,
sort,
prefs: Preferences::new(req),
saved,
single_thread,
url,
})

102
src/saved.rs Normal file
View File

@ -0,0 +1,102 @@
use std::collections::HashMap;
// CRATES
use crate::server::{ RequestExt, ResponseExt };
use crate::utils::{get_saved_posts, redirect, template, Post, Preferences};
use askama::Template;
use cookie::Cookie;
use hyper::{Body, Request, Response};
use time::{Duration, OffsetDateTime};
// STRUCTS
#[derive(Template)]
#[template(path = "saved.html")]
struct SavedTemplate {
posts: Vec<Post>,
prefs: Preferences,
saved: Vec<String>,
url: String,
}
// FUNCTIONS
pub async fn get(req: Request<Body>) -> Result<Response<Body>, String> {
let saved = get_saved_posts(&req);
let full_names: Vec<String> = saved.iter().map(|id| format!("t3_{}", id)).collect();
let path = format!("/api/info.json?id={}", full_names.join(","));
let posts;
match Post::fetch(&path, false).await {
Ok((post_results, _after)) => posts = post_results,
Err(_) => posts = vec![],
}
// let posts = vec![];
let url = req.uri().to_string();
template(SavedTemplate{
posts,
prefs: Preferences::new(req),
saved,
url,
})
}
pub async fn save(req: Request<Body>) -> Result<Response<Body>, String> {
// Get existing cookie
let mut saved_posts = get_saved_posts(&req);
let query = req.uri().query().unwrap_or_default().as_bytes();
let form = url::form_urlencoded::parse(query).collect::<HashMap<_, _>>();
let path = match form.get("redirect") {
Some(value) => format!("{}", value.replace("%26", "&").replace("%23", "#")),
None => "saved".to_string(),
};
let mut response = redirect(path);
match req.param("id") {
Some(id) => {
saved_posts.push(id);
response.insert_cookie(
Cookie::build(String::from("saved_posts"), saved_posts.join(","))
.path("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.finish(),
);
Ok(response)
},
None => Ok(response),
}
}
pub async fn unsave(req: Request<Body>) -> Result<Response<Body>, String> {
// Get existing cookie
let mut saved_posts = get_saved_posts(&req);
let query = req.uri().query().unwrap_or_default().as_bytes();
let form = url::form_urlencoded::parse(query).collect::<HashMap<_, _>>();
let path = match form.get("redirect") {
Some(value) => format!("{}", value.replace("%26", "&").replace("%23", "#")),
None => "saved".to_string(),
};
let mut response = redirect(path);
match req.param("id") {
Some(id) => {
if let Some(index) = saved_posts.iter().position(|el| el == &id) {
saved_posts.remove(index);
}
response.insert_cookie(
Cookie::build(String::from("saved_posts"), saved_posts.join(","))
.path("/")
.http_only(true)
.expires(OffsetDateTime::now_utc() + Duration::weeks(52))
.finish(),
);
Ok(response)
},
None => Ok(response),
}
}

View File

@ -1,5 +1,5 @@
// CRATES
use crate::utils::{catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, setting, template, val, Post, Preferences};
use crate::utils::{catch_random, error, filter_posts, format_num, format_url, get_filters, get_saved_posts, param, redirect, setting, template, val, Post, Preferences};
use crate::{
client::json,
subreddit::{can_access_quarantine, quarantine},
@ -36,6 +36,7 @@ struct SearchTemplate {
sub: String,
params: SearchParams,
prefs: Preferences,
saved: Vec<String>,
url: String,
/// Whether the subreddit itself is filtered.
is_filtered: bool,
@ -69,6 +70,7 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
let sort = param(&path, "sort").unwrap_or_else(|| "relevance".to_string());
let filters = get_filters(&req);
let saved = get_saved_posts(&req);
// If search is not restricted to this subreddit, show other subreddits in search results
let subreddits = if param(&path, "restrict_sr").is_none() {
@ -97,6 +99,7 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
typed,
},
prefs: Preferences::new(req),
saved,
url,
is_filtered: true,
all_posts_filtered: false,
@ -120,6 +123,7 @@ pub async fn find(req: Request<Body>) -> Result<Response<Body>, String> {
typed,
},
prefs: Preferences::new(req),
saved,
url,
is_filtered: false,
all_posts_filtered,

View File

@ -1,7 +1,7 @@
// CRATES
use crate::esc;
use crate::utils::{
catch_random, error, filter_posts, format_num, format_url, get_filters, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, Subreddit,
catch_random, error, filter_posts, format_num, format_url, get_filters, get_saved_posts, param, redirect, rewrite_urls, setting, template, val, Post, Preferences, Subreddit,
};
use crate::{client::json, server::ResponseExt, RequestExt};
use askama::Template;
@ -18,6 +18,7 @@ struct SubredditTemplate {
sort: (String, String),
ends: (String, String),
prefs: Preferences,
saved: Vec<String>,
url: String,
redirect_url: String,
/// Whether the subreddit itself is filtered.
@ -99,6 +100,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
let url = String::from(req.uri().path_and_query().map_or("", |val| val.as_str()));
let redirect_url = url[1..].replace('?', "%3F").replace('&', "%26");
let filters = get_filters(&req);
let saved = get_saved_posts(&req);
// If all requested subs are filtered, we don't need to fetch posts.
if sub_name.split('+').all(|s| filters.contains(s)) {
@ -108,6 +110,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
sort: (sort, param(&path, "t").unwrap_or_default()),
ends: (param(&path, "after").unwrap_or_default(), "".to_string()),
prefs: Preferences::new(req),
saved,
url,
redirect_url,
is_filtered: true,
@ -124,6 +127,7 @@ pub async fn community(req: Request<Body>) -> Result<Response<Body>, String> {
sort: (sort, param(&path, "t").unwrap_or_default()),
ends: (param(&path, "after").unwrap_or_default(), after),
prefs: Preferences::new(req),
saved,
url,
redirect_url,
is_filtered: false,

View File

@ -2,7 +2,7 @@
use crate::client::json;
use crate::esc;
use crate::server::RequestExt;
use crate::utils::{error, filter_posts, format_url, get_filters, param, template, Post, Preferences, User};
use crate::utils::{error, filter_posts, format_url, get_filters, get_saved_posts, param, template, Post, Preferences, User};
use askama::Template;
use hyper::{Body, Request, Response};
use time::{OffsetDateTime, macros::format_description};
@ -18,6 +18,7 @@ struct UserTemplate {
/// "overview", "comments", or "submitted"
listing: String,
prefs: Preferences,
saved: Vec<String>,
url: String,
redirect_url: String,
/// Whether the user themself is filtered.
@ -47,6 +48,7 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
let user = user(&username).await.unwrap_or_default();
let filters = get_filters(&req);
let saved = get_saved_posts(&req);
if filters.contains(&["u_", &username].concat()) {
template(UserTemplate {
user,
@ -55,6 +57,7 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
ends: (param(&path, "after").unwrap_or_default(), "".to_string()),
listing,
prefs: Preferences::new(req),
saved,
url,
redirect_url,
is_filtered: true,
@ -73,6 +76,7 @@ pub async fn profile(req: Request<Body>) -> Result<Response<Body>, String> {
ends: (param(&path, "after").unwrap_or_default(), after),
listing,
prefs: Preferences::new(req),
saved,
url,
redirect_url,
is_filtered: false,

View File

@ -711,6 +711,13 @@ pub async fn error(req: Request<Body>, msg: String) -> Result<Response<Body>, St
Ok(Response::builder().status(404).header("content-type", "text/html").body(body.into()).unwrap_or_default())
}
pub fn get_saved_posts(req: &Request<Body>) -> Vec<String> {
match req.cookie("saved_posts") {
Some(cookie) => cookie.value().split(',').map(String::from).collect(),
None => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::format_num;

View File

@ -548,10 +548,13 @@ button.submit {
align-items: center;
}
button.save { color: var(--text); }
select:hover { background: var(--foreground); }
input[type="submit"]:hover { color: var(--accent); }
button.submit:hover > svg { stroke: var(--accent); }
button.save:hover > svg { stroke: var(--accent); }
#timeframe {
margin: 0 2px;

View File

@ -117,6 +117,7 @@
<li><a href="/{{ post.id }}">permalink</a></li>
<li><a href="https://reddit.com/{{ post.id }}" rel="nofollow">reddit</a></li>
</ul>
{% call utils::save_unsave(post, saved) %}
<p>{{ post.upvote_ratio }}% Upvoted</p>
</div>
</div>

23
templates/saved.html Normal file
View File

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% import "utils.html" as utils %}
{% block title %}
Saved
{% endblock %}
{% block body %}
<main>
<div id="column_one">
{% for post in posts %}
{% if !(post.flags.nsfw && prefs.show_nsfw != "on") %}
<hr class="sep" />
{% call utils::post_in_list(post) %}
{% endif %}
{% endfor %}
{% if prefs.use_hls == "on" %}
<script src="/hls.min.js"></script>
<script src="/playHLSVideo.js"></script>
{% endif %}
</div>
</main>
{% endblock body %}

View File

@ -46,6 +46,7 @@
<a href="/">Home</a>
<a href="/r/popular">Popular</a>
<a href="/r/all">All</a>
<a href="/saved">Saved</a>
<p>REDDIT FEEDS</p>
{% for sub in prefs.subscriptions %}
<a href="/r/{{ sub }}" {% if sub == current %}class="selected"{% endif %}>{{ sub }}</a>
@ -142,6 +143,29 @@
</div>
<div class="post_footer">
<a href="{{ post.permalink }}" class="post_comments" title="{{ post.comments.1 }} comments">{{ post.comments.0 }} comments</a>
{% call save_unsave(post, saved) %}
</div>
</div>
{%- endmacro %}
{% macro save_unsave(post, saved) -%}
{% if saved.contains(post.id) %}
<form action="/saved/{{ post.id }}/unsave?redirect={{ url }}" method="POST">
<button class="save">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-star-fill" viewBox="0 0 16 16">
<title>Unsave</title>
<path d="M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z"/>
</svg>
</button>
</form>
{% else %}
<form action="/saved/{{ post.id }}/save?redirect={{ url }}" method="POST">
<button class="save">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-star" viewBox="0 0 16 16">
<title>Save</title>
<path d="M2.866 14.85c-.078.444.36.791.746.593l4.39-2.256 4.389 2.256c.386.198.824-.149.746-.592l-.83-4.73 3.522-3.356c.33-.314.16-.888-.282-.95l-4.898-.696L8.465.792a.513.513 0 0 0-.927 0L5.354 5.12l-4.898.696c-.441.062-.612.636-.283.95l3.523 3.356-.83 4.73zm4.905-2.767-3.686 1.894.694-3.957a.565.565 0 0 0-.163-.505L1.71 6.745l4.052-.576a.525.525 0 0 0 .393-.288L8 2.223l1.847 3.658a.525.525 0 0 0 .393.288l4.052.575-2.906 2.77a.565.565 0 0 0-.163.506l.694 3.957-3.686-1.894a.503.503 0 0 0-.461 0z"/>
</svg>
</button>
</form>
{% endif %}
{%- endmacro %}