Rewrite + Searching

This commit is contained in:
spikecodes 2020-12-31 15:54:13 -08:00
parent c7282520cd
commit a6dc7ee043
17 changed files with 342 additions and 262 deletions

21
Cargo.lock generated
View File

@ -272,9 +272,9 @@ dependencies = [
[[package]] [[package]]
name = "addr2line" name = "addr2line"
version = "0.14.0" version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c0929d69e78dd9bf5408269919fcbcaeb2e35e5d43e5815517cdc6a8e11a423" checksum = "a55f82cfe485775d02112886f4169bde0c5894d75e79ead7eafe7e40a25e45f7"
dependencies = [ dependencies = [
"gimli", "gimli",
] ]
@ -755,11 +755,11 @@ dependencies = [
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.1.15" version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc587bc0ec293155d5bfa6b9891ec18a1e330c234f896ea47fbada4cadbe47e6" checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
dependencies = [ dependencies = [
"cfg-if 0.1.10", "cfg-if 1.0.0",
"libc", "libc",
"wasi 0.9.0+wasi-snapshot-preview1", "wasi 0.9.0+wasi-snapshot-preview1",
] ]
@ -1006,7 +1006,7 @@ checksum = "1482821306169ec4d07f6aca392a4681f66c75c9918aa49641a2595db64053cb"
[[package]] [[package]]
name = "libreddit" name = "libreddit"
version = "0.2.3" version = "0.2.4"
dependencies = [ dependencies = [
"actix-web", "actix-web",
"askama", "askama",
@ -1016,6 +1016,7 @@ dependencies = [
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"url",
] ]
[[package]] [[package]]
@ -1624,9 +1625,9 @@ checksum = "c111b5bd5695e56cffe5129854aa230b39c93a305372fdbb2668ca2394eea9f8"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.5.1" version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae524f056d7d770e174287294f562e95044c68e88dec909a00d2094805db9d75" checksum = "1a55ca5f3b68e41c979bf8c46a6f1da892ca4db8f94023ce0bd32407573b1ac0"
[[package]] [[package]]
name = "socket2" name = "socket2"
@ -1647,9 +1648,9 @@ checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
[[package]] [[package]]
name = "standback" name = "standback"
version = "0.2.13" version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf906c8b8fc3f6ecd1046e01da1d8ddec83e48c8b08b84dcc02b585a6bedf5a8" checksum = "c66a8cff4fa24853fdf6b51f75c6d7f8206d7c75cab4e467bcd7f25c2b1febe0"
dependencies = [ dependencies = [
"version_check 0.9.2", "version_check 0.9.2",
] ]

View File

@ -3,7 +3,7 @@ name = "libreddit"
description = " Alternative private front-end to Reddit" description = " Alternative private front-end to Reddit"
license = "AGPL-3.0" license = "AGPL-3.0"
repository = "https://github.com/spikecodes/libreddit" repository = "https://github.com/spikecodes/libreddit"
version = "0.2.3" version = "0.2.4"
authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"] authors = ["spikecodes <19519553+spikecodes@users.noreply.github.com>"]
edition = "2018" edition = "2018"
@ -19,4 +19,5 @@ askama = "0.8.0"
serde = "1.0.117" serde = "1.0.117"
serde_json = "1.0" serde_json = "1.0"
chrono = "0.4.19" chrono = "0.4.19"
async-recursion = "0.3.1" async-recursion = "0.3.1"
url = "2.2.0"

View File

@ -18,7 +18,6 @@ Like [Invidious](https://github.com/iv-org/invidious) but for Reddit. Browse the
- [About](#about) - [About](#about)
- [Elsewhere](#elsewhere) - [Elsewhere](#elsewhere)
- [Info](#info) - [Info](#info)
- [In Progress](#in-progress)
- [Teddit Comparison](#how-does-it-compare-to-teddit) - [Teddit Comparison](#how-does-it-compare-to-teddit)
- [Comparison](#comparison) - [Comparison](#comparison)
- [Speed](#speed) - [Speed](#speed)
@ -66,9 +65,6 @@ Libreddit hopes to provide an easier way to browse Reddit, without the ads, trac
Libreddit currently implements most of Reddit's functionalities but still lacks a few features that are being worked on below. Libreddit currently implements most of Reddit's functionalities but still lacks a few features that are being worked on below.
### In Progress
- Searching
### How does it compare to Teddit? ### How does it compare to Teddit?
Teddit is another awesome open source project designed to provide an alternative frontend to Reddit. There is no connection between the two and you're welcome to use whichever one you favor. Competition fosters innovation and Teddit's release has motivated me to build Libreddit into an even more polished product. Teddit is another awesome open source project designed to provide an alternative frontend to Reddit. There is no connection between the two and you're welcome to use whichever one you favor. Competition fosters innovation and Teddit's release has motivated me to build Libreddit into an even more polished product.
@ -76,7 +72,6 @@ Teddit is another awesome open source project designed to provide an alternative
If you are looking to compare, the biggest differences I have noticed are: If you are looking to compare, the biggest differences I have noticed are:
- Libreddit is themed around Reddit's redesign whereas Teddit appears to stick much closer to Reddit's old design. This may suit some users better as design is always subjective. - Libreddit is themed around Reddit's redesign whereas Teddit appears to stick much closer to Reddit's old design. This may suit some users better as design is always subjective.
- Libreddit is written in [Rust](https://www.rust-lang.org) for speed and memory safety. It uses [Actix Web](https://actix.rs), which was [benchmarked as the fastest web server for single queries](https://www.techempower.com/benchmarks/#hw=ph&test=db). - Libreddit is written in [Rust](https://www.rust-lang.org) for speed and memory safety. It uses [Actix Web](https://actix.rs), which was [benchmarked as the fastest web server for single queries](https://www.techempower.com/benchmarks/#hw=ph&test=db).
- Unlike Teddit (at the time of writing this), Libreddit does not require a Reddit API key to host.
## Comparison ## Comparison

View File

@ -2,9 +2,9 @@
use actix_web::{get, middleware::NormalizePath, web, App, HttpResponse, HttpServer}; use actix_web::{get, middleware::NormalizePath, web, App, HttpResponse, HttpServer};
// Reference local files // Reference local files
mod popular;
mod post; mod post;
mod proxy; mod proxy;
mod search;
mod subreddit; mod subreddit;
mod user; mod user;
mod utils; mod utils;
@ -42,6 +42,7 @@ async fn main() -> std::io::Result<()> {
HttpServer::new(|| { HttpServer::new(|| {
App::new() App::new()
// .default_service(web::get().to(subreddit::page))
// TRAILING SLASH MIDDLEWARE // TRAILING SLASH MIDDLEWARE
.wrap(NormalizePath::default()) .wrap(NormalizePath::default())
// GENERAL SERVICES // GENERAL SERVICES
@ -50,17 +51,22 @@ async fn main() -> std::io::Result<()> {
.route("/robots.txt/", web::get().to(robots)) .route("/robots.txt/", web::get().to(robots))
// PROXY SERVICE // PROXY SERVICE
.route("/proxy/{url:.*}/", web::get().to(proxy::handler)) .route("/proxy/{url:.*}/", web::get().to(proxy::handler))
// SEARCH SERVICES
.route("/search/", web::get().to(search::page))
.route("r/{sub}/search/", web::get().to(search::page))
// USER SERVICES // USER SERVICES
.route("/u/{username}/", web::get().to(user::page)) .route("/u/{username}/", web::get().to(user::profile))
.route("/user/{username}/", web::get().to(user::page)) .route("/user/{username}/", web::get().to(user::profile))
// SUBREDDIT SERVICES // SUBREDDIT SERVICES
.route("/r/{sub}/", web::get().to(subreddit::page)) .route("/r/{sub}/", web::get().to(subreddit::page))
.route("/r/{sub}/{sort}/", web::get().to(subreddit::page))
// POPULAR SERVICES // POPULAR SERVICES
.route("/", web::get().to(popular::page)) .route("/", web::get().to(subreddit::page))
.route("/{sort:best|hot|new|top|rising}/", web::get().to(subreddit::page))
// POST SERVICES // POST SERVICES
.route("/{id:.{5,6}}/", web::get().to(post::short)) .route("/{id:.{5,6}}/", web::get().to(post::item))
.route("/r/{sub}/comments/{id}/{title}/", web::get().to(post::page)) .route("/r/{sub}/comments/{id}/{title}/", web::get().to(post::item))
.route("/r/{sub}/comments/{id}/{title}/{comment_id}/", web::get().to(post::comment)) .route("/r/{sub}/comments/{id}/{title}/{comment_id}/", web::get().to(post::item))
}) })
.bind(address.clone()) .bind(address.clone())
.expect(format!("Cannot bind to the address: {}", address).as_str()) .expect(format!("Cannot bind to the address: {}", address).as_str())

View File

@ -1,57 +0,0 @@
// CRATES
use crate::utils::{fetch_posts, ErrorTemplate, Params, Post};
use actix_web::{http::StatusCode, web, HttpResponse, Result};
use askama::Template;
// STRUCTS
#[derive(Template)]
#[template(path = "popular.html", escape = "none")]
struct PopularTemplate {
posts: Vec<Post>,
sort: (String, String),
ends: (String, String),
}
// RENDER
async fn render(sort: Option<String>, t: Option<String>, ends: (Option<String>, Option<String>)) -> Result<HttpResponse> {
let sorting = sort.unwrap_or("hot".to_string());
let before = ends.1.clone().unwrap_or(String::new()); // If there is an after, there must be a before
let timeframe = match &t { Some(val) => format!("&t={}", val), None => String::new() };
// Build the Reddit JSON API url
let url = match ends.0 {
Some(val) => format!("r/popular/{}.json?before={}&count=25{}", sorting, val, timeframe),
None => match ends.1 {
Some(val) => format!("r/popular/{}.json?after={}&count=25{}", sorting, val, timeframe),
None => format!("r/popular/{}.json?{}", sorting, timeframe),
},
};
let items_result = fetch_posts(url, String::new()).await;
if items_result.is_err() {
let s = ErrorTemplate {
message: items_result.err().unwrap().to_string(),
}
.render()
.unwrap();
Ok(HttpResponse::Ok().status(StatusCode::NOT_FOUND).content_type("text/html").body(s))
} else {
let items = items_result.unwrap();
let s = PopularTemplate {
posts: items.0,
sort: (sorting, t.unwrap_or(String::new())),
ends: (before, items.1),
}
.render()
.unwrap();
Ok(HttpResponse::Ok().content_type("text/html").body(s))
}
}
// SERVICES
pub async fn page(params: web::Query<Params>) -> Result<HttpResponse> {
render(params.sort.clone(), params.t.clone(), (params.before.clone(), params.after.clone())).await
}

View File

@ -1,6 +1,6 @@
// CRATES // CRATES
use crate::utils::{format_num, format_url, request, val, Comment, ErrorTemplate, Flair, Flags, Params, Post}; use crate::utils::{format_num, format_url, param, request, val, Comment, ErrorTemplate, Flags, Flair, Post};
use actix_web::{http::StatusCode, web, HttpResponse, Result}; use actix_web::{http::StatusCode, HttpRequest, HttpResponse, Result};
use async_recursion::async_recursion; use async_recursion::async_recursion;
@ -16,22 +16,17 @@ struct PostTemplate {
sort: String, sort: String,
} }
async fn render(id: String, sort: Option<String>, comment_id: Option<String>) -> Result<HttpResponse> { pub async fn item(req: HttpRequest) -> Result<HttpResponse> {
let path = format!("{}.json?{}&raw_json=1", req.path(), req.query_string());
let sort = param(&path, "sort").await;
let id = req.match_info().get("id").unwrap_or("").to_string();
// Log the post ID being fetched in debug mode // Log the post ID being fetched in debug mode
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
dbg!(&id); dbg!(&id);
// Handling sort paramater
let sorting: String = sort.unwrap_or("confidence".to_string());
// Build the Reddit JSON API url
let url: String = match comment_id {
None => format!("{}.json?sort={}&raw_json=1", id, sorting),
Some(val) => format!("{}.json?sort={}&comment={}&raw_json=1", id, sorting, val),
};
// Send a request to the url, receive JSON in response // Send a request to the url, receive JSON in response
let req = request(url).await; let req = request(path.clone()).await;
// If the Reddit API returns an error, exit and send error page to user // If the Reddit API returns an error, exit and send error page to user
if req.is_err() { if req.is_err() {
@ -41,37 +36,18 @@ async fn render(id: String, sort: Option<String>, comment_id: Option<String>) ->
.render() .render()
.unwrap(); .unwrap();
return Ok(HttpResponse::Ok().status(StatusCode::NOT_FOUND).content_type("text/html").body(s)); return Ok(HttpResponse::Ok().status(StatusCode::NOT_FOUND).content_type("text/html").body(s));
} else {
// Otherwise, grab the JSON output from the request
let res = req.unwrap();
// Parse the JSON into Post and Comment structs
let post = parse_post(res[0].clone()).await.unwrap();
let comments = parse_comments(res[1].clone()).await.unwrap();
// Use the Post and Comment structs to generate a website to show users
let s = PostTemplate { comments, post, sort }.render().unwrap();
Ok(HttpResponse::Ok().content_type("text/html").body(s))
} }
// Otherwise, grab the JSON output from the request
let res = req.unwrap();
// Parse the JSON into Post and Comment structs
let post = parse_post(res[0].clone()).await;
let comments = parse_comments(res[1].clone()).await;
// Use the Post and Comment structs to generate a website to show users
let s = PostTemplate {
comments: comments.unwrap(),
post: post.unwrap(),
sort: sorting,
}
.render()
.unwrap();
Ok(HttpResponse::Ok().content_type("text/html").body(s))
}
// SERVICES
pub async fn short(web::Path(id): web::Path<String>, params: web::Query<Params>) -> Result<HttpResponse> {
render(id, params.sort.clone(), None).await
}
pub async fn comment(web::Path((_sub, id, _title, comment_id)): web::Path<(String, String, String, String)>, params: web::Query<Params>) -> Result<HttpResponse> {
render(id, params.sort.clone(), Some(comment_id)).await
}
pub async fn page(web::Path((_sub, id)): web::Path<(String, String)>, params: web::Query<Params>) -> Result<HttpResponse> {
render(id, params.sort.clone(), None).await
} }
// UTILITIES // UTILITIES
@ -111,7 +87,7 @@ async fn parse_post(json: serde_json::Value) -> Result<Post, &'static str> {
let post = Post { let post = Post {
title: val(post_data, "title").await, title: val(post_data, "title").await,
community: val(post_data, "subreddit").await, community: val(post_data, "subreddit").await,
body: val(post_data,"selftext_html").await, body: val(post_data, "selftext_html").await,
author: val(post_data, "author").await, author: val(post_data, "author").await,
author_flair: Flair( author_flair: Flair(
val(post_data, "author_flair_text").await, val(post_data, "author_flair_text").await,
@ -132,7 +108,7 @@ async fn parse_post(json: serde_json::Value) -> Result<Post, &'static str> {
), ),
flags: Flags { flags: Flags {
nsfw: post_data["data"]["over_18"].as_bool().unwrap_or(false), nsfw: post_data["data"]["over_18"].as_bool().unwrap_or(false),
stickied: post_data["data"]["stickied"].as_bool().unwrap_or(false) stickied: post_data["data"]["stickied"].as_bool().unwrap_or(false),
}, },
media: media.1, media: media.1,
time: Utc.timestamp(unix_time, 0).format("%b %e %Y %H:%M UTC").to_string(), time: Utc.timestamp(unix_time, 0).format("%b %e %Y %H:%M UTC").to_string(),
@ -157,7 +133,7 @@ async fn parse_comments(json: serde_json::Value) -> Result<Vec<Comment>, &'stati
} }
let score = comment["data"]["score"].as_i64().unwrap_or(0); let score = comment["data"]["score"].as_i64().unwrap_or(0);
let body = val(comment, "body_html").await; let body = val(comment, "body_html").await;
let replies: Vec<Comment> = if comment["data"]["replies"].is_object() { let replies: Vec<Comment> = if comment["data"]["replies"].is_object() {
parse_comments(comment["data"]["replies"].clone()).await.unwrap_or(Vec::new()) parse_comments(comment["data"]["replies"].clone()).await.unwrap_or(Vec::new())

52
src/search.rs Normal file
View File

@ -0,0 +1,52 @@
// CRATES
use crate::utils::{fetch_posts, param, ErrorTemplate, Post};
use actix_web::{http::StatusCode, HttpRequest, HttpResponse, Result};
use askama::Template;
// STRUCTS
#[derive(Template)]
#[allow(dead_code)]
#[template(path = "search.html", escape = "none")]
struct SearchTemplate {
posts: Vec<Post>,
query: String,
sub: String,
sort: (String, String),
ends: (String, String),
}
// SERVICES
pub async fn page(req: HttpRequest) -> Result<HttpResponse> {
let path = format!("{}.json?{}", req.path(), req.query_string());
let q = param(&path, "q").await;
let sort = if param(&path, "sort").await.is_empty() {
"relevance".to_string()
} else {
param(&path, "sort").await
};
let sub = req.match_info().get("sub").unwrap_or("").to_string();
let posts = fetch_posts(path.clone(), String::new()).await;
if posts.is_err() {
let s = ErrorTemplate {
message: posts.err().unwrap().to_string(),
}
.render()
.unwrap();
Ok(HttpResponse::Ok().status(StatusCode::NOT_FOUND).content_type("text/html").body(s))
} else {
let items = posts.unwrap();
let s = SearchTemplate {
posts: items.0,
query: q,
sub: sub,
sort: (sort, param(&path, "t").await),
ends: (param(&path, "after").await, items.1),
}
.render()
.unwrap();
Ok(HttpResponse::Ok().content_type("text/html").body(s))
}
}

View File

@ -1,6 +1,6 @@
// CRATES // CRATES
use crate::utils::{fetch_posts, format_num, format_url, request, val, ErrorTemplate, Params, Post, Subreddit}; use crate::utils::{fetch_posts, format_num, format_url, param, request, val, ErrorTemplate, Post, Subreddit};
use actix_web::{http::StatusCode, web, HttpResponse, Result}; use actix_web::{http::StatusCode, HttpRequest, HttpResponse, Result};
use askama::Template; use askama::Template;
use std::convert::TryInto; use std::convert::TryInto;
@ -15,49 +15,35 @@ struct SubredditTemplate {
} }
// SERVICES // SERVICES
#[allow(dead_code)] // web::Path(sub): web::Path<String>, params: web::Query<Params>
pub async fn page(web::Path(sub): web::Path<String>, params: web::Query<Params>) -> Result<HttpResponse> { pub async fn page(req: HttpRequest) -> Result<HttpResponse> {
render(sub, params.sort.clone(), params.t.clone(), (params.before.clone(), params.after.clone())).await let path = format!("{}.json?{}", req.path(), req.query_string());
} let sub = req.match_info().get("sub").unwrap_or("popular").to_string();
let sort = req.match_info().get("sort").unwrap_or("hot").to_string();
pub async fn render(sub_name: String, sort: Option<String>, t: Option<String>, ends: (Option<String>, Option<String>)) -> Result<HttpResponse> { let sub_result = if !&sub.contains("+") && sub != "popular" {
let sorting = sort.unwrap_or("hot".to_string()); subreddit(&sub).await
let before = ends.1.clone().unwrap_or(String::new()); // If there is an after, there must be a before
let timeframe = match &t { Some(val) => format!("&t={}", val), None => String::new() };
// Build the Reddit JSON API url
let url = match ends.0 {
Some(val) => format!("r/{}/{}.json?before={}&count=25{}", sub_name, sorting, val, timeframe),
None => match ends.1 {
Some(val) => format!("r/{}/{}.json?after={}&count=25{}", sub_name, sorting, val, timeframe),
None => format!("r/{}/{}.json?{}", sub_name, sorting, timeframe),
},
};
let sub_result = if !&sub_name.contains("+") {
subreddit(&sub_name).await
} else { } else {
Ok(Subreddit::default()) Ok(Subreddit::default())
}; };
let items_result = fetch_posts(url, String::new()).await; let posts = fetch_posts(path.clone(), String::new()).await;
if sub_result.is_err() || items_result.is_err() { if posts.is_err() {
let s = ErrorTemplate { let s = ErrorTemplate {
message: sub_result.err().unwrap().to_string(), message: posts.err().unwrap().to_string(),
} }
.render() .render()
.unwrap(); .unwrap();
Ok(HttpResponse::Ok().status(StatusCode::NOT_FOUND).content_type("text/html").body(s)) Ok(HttpResponse::Ok().status(StatusCode::NOT_FOUND).content_type("text/html").body(s))
} else { } else {
let sub = sub_result.unwrap(); let sub = sub_result.unwrap_or(Subreddit::default());
let items = items_result.unwrap(); let items = posts.unwrap();
let s = SubredditTemplate { let s = SubredditTemplate {
sub: sub, sub: sub,
posts: items.0, posts: items.0,
sort: (sorting, t.unwrap_or(String::new())), sort: (sort, param(&path, "t").await),
ends: (before, items.1), ends: (param(&path, "after").await, items.1),
} }
.render() .render()
.unwrap(); .unwrap();

View File

@ -1,6 +1,6 @@
// CRATES // CRATES
use crate::utils::{fetch_posts, format_url, nested_val, request, ErrorTemplate, Params, Post, User}; use crate::utils::{fetch_posts, format_url, nested_val, param, request, ErrorTemplate, Post, User};
use actix_web::{http::StatusCode, web, HttpResponse, Result}; use actix_web::{http::StatusCode, HttpRequest, HttpResponse, Result};
use askama::Template; use askama::Template;
use chrono::{TimeZone, Utc}; use chrono::{TimeZone, Utc};
@ -14,25 +14,19 @@ struct UserTemplate {
ends: (String, String), ends: (String, String),
} }
async fn render(username: String, sort: Option<String>, t: Option<String>, ends: (Option<String>, Option<String>)) -> Result<HttpResponse> { pub async fn profile(req: HttpRequest) -> Result<HttpResponse> {
let sorting = sort.unwrap_or("new".to_string()); // Build the Reddit JSON API path
let path = format!("{}.json?{}&raw_json=1", req.path(), req.query_string());
let before = ends.1.clone().unwrap_or(String::new()); // If there is an after, there must be a before // Retrieve other variables from Libreddit request
let sort = param(&path, "sort").await;
let timeframe = match &t { Some(val) => format!("&t={}", val), None => String::new() }; let username = req.match_info().get("username").unwrap_or("").to_string();
// Build the Reddit JSON API url
let url = match ends.0 {
Some(val) => format!("user/{}/.json?sort={}&before={}&count=25&raw_json=1{}", username, sorting, val, timeframe),
None => match ends.1 {
Some(val) => format!("user/{}/.json?sort={}&after={}&count=25&raw_json=1{}", username, sorting, val, timeframe),
None => format!("user/{}/.json?sort={}&raw_json=1{}", username, sorting, timeframe),
},
};
// Request user profile data and user posts/comments from Reddit
let user = user(&username).await; let user = user(&username).await;
let posts = fetch_posts(url, "Comment".to_string()).await; let posts = fetch_posts(path.clone(), "Comment".to_string()).await;
// If there is an error show error page
if user.is_err() || posts.is_err() { if user.is_err() || posts.is_err() {
let s = ErrorTemplate { let s = ErrorTemplate {
message: user.err().unwrap().to_string(), message: user.err().unwrap().to_string(),
@ -42,12 +36,12 @@ async fn render(username: String, sort: Option<String>, t: Option<String>, ends:
Ok(HttpResponse::Ok().status(StatusCode::NOT_FOUND).content_type("text/html").body(s)) Ok(HttpResponse::Ok().status(StatusCode::NOT_FOUND).content_type("text/html").body(s))
} else { } else {
let posts_unwrapped = posts.unwrap(); let posts_unwrapped = posts.unwrap();
let s = UserTemplate { let s = UserTemplate {
user: user.unwrap(), user: user.unwrap(),
posts: posts_unwrapped.0, posts: posts_unwrapped.0,
sort: (sorting, t.unwrap_or(String::new())), sort: (sort, param(&path, "t").await),
ends: (before, posts_unwrapped.1) ends: (param(&path, "after").await, posts_unwrapped.1),
} }
.render() .render()
.unwrap(); .unwrap();
@ -56,9 +50,9 @@ async fn render(username: String, sort: Option<String>, t: Option<String>, ends:
} }
// SERVICES // SERVICES
pub async fn page(web::Path(username): web::Path<String>, params: web::Query<Params>) -> Result<HttpResponse> { // pub async fn page(web::Path(username): web::Path<String>, params: web::Query<Params>) -> Result<HttpResponse> {
render(username, params.sort.clone(), params.t.clone(), (params.before.clone(), params.after.clone())).await // render(username, params.sort.clone(), params.t.clone(), (params.before.clone(), params.after.clone())).await
} // }
// USER // USER
async fn user(name: &String) -> Result<User, &'static str> { async fn user(name: &String) -> Result<User, &'static str> {

View File

@ -3,6 +3,7 @@
// //
use chrono::{TimeZone, Utc}; use chrono::{TimeZone, Utc};
use serde_json::{from_str, Value}; use serde_json::{from_str, Value};
use url::Url;
// use surf::{client, get, middleware::Redirect}; // use surf::{client, get, middleware::Redirect};
#[cfg(feature = "proxy")] #[cfg(feature = "proxy")]
@ -16,7 +17,7 @@ pub struct Flair(pub String, pub String, pub String);
// Post flags with nsfw and stickied // Post flags with nsfw and stickied
pub struct Flags { pub struct Flags {
pub nsfw: bool, pub nsfw: bool,
pub stickied: bool pub stickied: bool,
} }
// Post containing content, metadata and media // Post containing content, metadata and media
@ -72,6 +73,7 @@ pub struct Subreddit {
#[derive(serde::Deserialize)] #[derive(serde::Deserialize)]
pub struct Params { pub struct Params {
pub t: Option<String>, pub t: Option<String>,
pub q: Option<String>,
pub sort: Option<String>, pub sort: Option<String>,
pub after: Option<String>, pub after: Option<String>,
pub before: Option<String>, pub before: Option<String>,
@ -88,6 +90,13 @@ pub struct ErrorTemplate {
// FORMATTING // FORMATTING
// //
// Grab a query param from a url
pub async fn param(path: &String, value: &str) -> String {
let url = Url::parse(format!("https://reddit.com/{}", path).as_str()).unwrap();
let pairs: std::collections::HashMap<_, _> = url.query_pairs().into_owned().collect();
pairs.get(value).unwrap_or(&String::new()).to_owned()
}
// Direct urls to proxy if proxy is enabled // Direct urls to proxy if proxy is enabled
pub async fn format_url(url: String) -> String { pub async fn format_url(url: String) -> String {
if url.is_empty() { if url.is_empty() {
@ -127,9 +136,9 @@ pub async fn nested_val(j: &serde_json::Value, n: &str, k: &str) -> String {
} }
// Fetch posts of a user or subreddit // Fetch posts of a user or subreddit
pub async fn fetch_posts(url: String, fallback_title: String) -> Result<(Vec<Post>, String), &'static str> { pub async fn fetch_posts(path: String, fallback_title: String) -> Result<(Vec<Post>, String), &'static str> {
// Send a request to the url, receive JSON in response // Send a request to the url, receive JSON in response
let req = request(url.clone()).await; let req = request(path.clone()).await;
// If the Reddit API returns an error, exit this function // If the Reddit API returns an error, exit this function
if req.is_err() { if req.is_err() {
@ -178,14 +187,14 @@ pub async fn fetch_posts(url: String, fallback_title: String) -> Result<(Vec<Pos
), ),
flags: Flags { flags: Flags {
nsfw: post["data"]["over_18"].as_bool().unwrap_or(false), nsfw: post["data"]["over_18"].as_bool().unwrap_or(false),
stickied: post["data"]["stickied"].as_bool().unwrap_or(false) stickied: post["data"]["stickied"].as_bool().unwrap_or(false),
}, },
url: val(post, "permalink").await, url: val(post, "permalink").await,
time: Utc.timestamp(unix_time, 0).format("%b %e '%y").to_string(), time: Utc.timestamp(unix_time, 0).format("%b %e '%y").to_string(),
}); });
} }
dbg!(url); dbg!(path);
Ok((posts, res["data"]["after"].as_str().unwrap_or("").to_string())) Ok((posts, res["data"]["after"].as_str().unwrap_or("").to_string()))
} }
@ -195,8 +204,8 @@ pub async fn fetch_posts(url: String, fallback_title: String) -> Result<(Vec<Pos
// //
// Make a request to a Reddit API and parse the JSON response // Make a request to a Reddit API and parse the JSON response
pub async fn request(mut url: String) -> Result<serde_json::Value, &'static str> { pub async fn request(path: String) -> Result<serde_json::Value, &'static str> {
url = format!("https://www.reddit.com/{}", url); let url = format!("https://www.reddit.com/{}", path);
// --- actix-web::client --- // --- actix-web::client ---
// let client = actix_web::client::Client::default(); // let client = actix_web::client::Client::default();

View File

@ -10,6 +10,11 @@
--black-contrast: 0 1px 3px rgba(0,0,0,0.5); --black-contrast: 0 1px 3px rgba(0,0,0,0.5);
} }
::selection {
color: var(--background);
background: var(--accent);
}
* { * {
transition: 0.2s all; transition: 0.2s all;
margin: 0; margin: 0;
@ -25,10 +30,12 @@ body {
nav { nav {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center;
color: var(--accent); color: var(--accent);
background: var(--outside); background: var(--outside);
padding: 15px; padding: 5px 15px;
font-size: 20px; font-size: 20px;
height: 40px;
} }
nav #lib, nav #github, nav #version { color: white; } nav #lib, nav #github, nav #version { color: white; }
@ -47,6 +54,10 @@ footer {
justify-content: center; justify-content: center;
} }
footer > a {
margin-right: 5px;
}
button { button {
background: none; background: none;
border: none; border: none;
@ -76,7 +87,6 @@ aside {
max-width: 350px; max-width: 350px;
} }
/* User & Subreddit */ /* User & Subreddit */
#user, #subreddit, #sidebar { #user, #subreddit, #sidebar {
@ -130,53 +140,112 @@ aside {
font-size: 15px; font-size: 15px;
} }
/* Sorting */ /* Sorting and Search */
#sort, #timeframe { select {
background: var(--outside); background: var(--outside);
box-shadow: var(--black-contrast); }
select, #search {
border: 0; border: 0;
padding: 0 15px; padding: 0 15px;
margin-bottom: 20px;
height: 40px; height: 40px;
font-size: 15px; font-size: 15px;
border-radius: 5px 0 0 5px;
appearance: none; appearance: none;
border-radius: 5px 0px 0px 5px;
} }
#searchbox {
display: flex;
box-shadow: var(--black-contrast);
}
#searchbox > *, #sort_submit {
background: var(--highlighted);
height: 40px;
}
#search {
border-right: 2px var(--outside) solid;
min-width: 0;
flex-grow: 1;
}
#inside {
display: flex;
font-size: 15px;
align-items: center;
border-right: 2px var(--outside) solid;
height: 40px;
padding: 0 10px;
}
#restrict_sr { margin-right: 5px; }
input[type="submit"] {
border: 0;
border-radius: 0px 5px 5px 0px;
}
select:hover { background: var(--foreground); }
input[type="submit"]:hover { color: var(--accent); }
#timeframe { #timeframe {
border-radius: 0; border-radius: 5px 0px 0px 5px;
border-left: 4px solid var(--highlighted); margin-left: 10px;
} }
#sort_submit { #search_sort {
background: var(--highlighted); background: var(--highlighted);
border: 0; border-radius: 5px;
font-size: 15px; overflow: hidden;
height: 40px;
border-radius: 0 5px 5px 0;
} }
#sort:hover, #timeframe:hover { background: var(--foreground); } #search_sort > #search {
#sort_submit:hover { color: var(--accent); } border: 0;
background: transparent;
}
#sort > div, footer > a { #search_sort > :not(:first-child), #search_sort > #sort_options {
margin: 0;
border-radius: 0;
border-right: 0;
border-left: 2px solid var(--background);
box-shadow: none;
background: transparent;
}
#sort_options {
height: 40px;
}
#sort, #search_sort {
display: flex;
align-items: center;
margin-bottom: 20px;
}
#sort_options, footer > a {
border-radius: 5px;
box-shadow: var(--black-contrast); box-shadow: var(--black-contrast);
background: var(--outside); background: var(--outside);
display: flex;
overflow: hidden;
}
#sort_options > a, footer > a {
color: lightgrey; color: lightgrey;
border-radius: 5px;
margin-right: 5px;
padding: 10px 20px; padding: 10px 20px;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
} }
#sort > div.selected { #sort_options > a.selected {
background: var(--accent); background: var(--accent);
color: black; color: black;
} }
#sort > div:hover { #sort_options > a:not(.selected):hover {
background: var(--foreground); background: var(--foreground);
} }
@ -243,6 +312,7 @@ aside {
.post_title { .post_title {
font-size: 18px; font-size: 18px;
line-height: 1.5;
} }
.post_right { .post_right {
@ -283,7 +353,6 @@ aside {
background: var(--accent); background: var(--accent);
color: black; color: black;
padding: 5px; padding: 5px;
margin-right: 5px;
border-radius: 5px; border-radius: 5px;
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: bold;
@ -494,9 +563,8 @@ td, th {
} }
@media screen and (max-width: 800px) { @media screen and (max-width: 800px) {
main { main { flex-direction: column-reverse; }
flex-direction: column-reverse; nav { flex-direction: column; }
}
aside { aside {
margin: 20px 0 0 0; margin: 20px 0 0 0;

View File

@ -11,14 +11,13 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
{% endblock %} {% endblock %}
</head> </head>
<body style="visibility: hidden;"> <body style="visibility: hidden;">
{% block navbar %}
<nav> <nav>
<a href="/"><span id="lib">lib</span>reddit. <span id="version">v{{ env!("CARGO_PKG_VERSION") }}</span></a> <a href="/"><span id="lib">lib</span>reddit. <span id="version">v{{ env!("CARGO_PKG_VERSION") }}</span></a>
{% block search %}{% endblock %}
<a id="github" href="https://github.com/spikecodes/libreddit">GITHUB</a> <a id="github" href="https://github.com/spikecodes/libreddit">GITHUB</a>
</nav> </nav>
{% endblock %}
{% block body %} {% block body %}
<main> <main>

View File

@ -1,5 +1,13 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import "utils.html" as utils %}
{% block title %}{{ post.title }} - r/{{ post.community }}{% endblock %} {% block title %}{{ post.title }} - r/{{ post.community }}{% endblock %}
{% block search %}
{% call utils::search(["/r/", post.community.as_str()].concat(), "") %}
{% endblock %}
{% block root %}/r/{{ post.community }}{% endblock %}{% block location %}r/{{ post.community }}{% endblock %}
{% block head %} {% block head %}
{% call super() %} {% call super() %}
<meta name="author" content="u/{{ post.author }}"> <meta name="author" content="u/{{ post.author }}">
@ -56,13 +64,9 @@
<div class="post_body">{{ post.body }}</div> <div class="post_body">{{ post.body }}</div>
</div> </div>
</div> </div>
<form> <form id="sort">
<select id="sort" name="sort"> <select name="sort">
<option value="confidence" {% if sort == "confidence" %}selected{% endif %}>Best</option> {% call utils::options(sort, ["confidence", "top", "new", "controversial", "old"], "") %}
<option value="top" {% if sort == "top" %}selected{% endif %}>Top</option>
<option value="new" {% if sort == "new" %}selected{% endif %}>New</option>
<option value="controversial" {% if sort == "controversial" %}selected{% endif %}>Controversial</option>
<option value="old" {% if sort == "old" %}selected{% endif %}>Old</option>
</select><input id="sort_submit" type="submit" value="&rarr;"> </select><input id="sort_submit" type="submit" value="&rarr;">
</form> </form>

View File

@ -1,22 +1,24 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import "utils.html" as utils %}
{% block content %} {% block content %}
<div id="column_one"> <div id="column_one">
<form> <form id="search_sort">
<select id="sort" name="sort"> <input id="search" type="text" name="q" placeholder="Search" value="{{ query }}">
<option value="best" {% if sort.0 == "best" %}selected{% endif %}>Best</option> {% if sub != "" %}
<option value="hot" {% if sort.0 == "hot" %}selected{% endif %}>Hot</option> <div id="inside">
<option value="new" {% if sort.0 == "new" %}selected{% endif %}>New</option> <input type="checkbox" name="restrict_sr" id="restrict_sr" checked="checked" data-com.bitwarden.browser.user-edited="yes">
<option value="top" {% if sort.0 == "top" %}selected{% endif %}>Top</option> <label for="restrict_sr">in r/{{ sub }}</label>
</select>{% if sort.0 == "top" %}<select id="timeframe" name="t"> </div>
<option value="hour" {% if sort.1 == "hour" %}selected{% endif %}>Hour</option> {% endif %}
<option value="day" {% if sort.1 == "day" || sort.1 == "" %}selected{% endif %}>Day</option> <select id="sort_options" name="sort">
<option value="week" {% if sort.1 == "week" %}selected{% endif %}>Week</option> {% call utils::options(sort.0, ["relevance", "hot", "top", "new", "comments"], "") %}
<option value="month" {% if sort.1 == "month" %}selected{% endif %}>Month</option> </select>{% if sort.0 != "new" %}<select id="timeframe" name="t">
<option value="year" {% if sort.1 == "year" %}selected{% endif %}>Year</option> {% call utils::options(sort.1, ["hour", "day", "week", "month", "year", "all"], "all") %}
<option value="all" {% if sort.1 == "all" %}selected{% endif %}>All</option>
</select>{% endif %}<input id="sort_submit" type="submit" value="&rarr;"> </select>{% endif %}<input id="sort_submit" type="submit" value="&rarr;">
</form> </form>
{% for post in posts %} {% for post in posts %}
{% if post.title != "Comment" %}
<div class="post"> <div class="post">
<div class="post_left"> <div class="post_left">
<p class="post_score">{{ post.score }}</p> <p class="post_score">{{ post.score }}</p>
@ -40,6 +42,21 @@
</div> </div>
<img class="post_thumbnail" src="{{ post.media }}"> <img class="post_thumbnail" src="{{ post.media }}">
</div><br> </div><br>
{% else %}
<div class="comment">
<div class="comment_left">
<p class="comment_score">{{ post.score }}</p>
<div class="line"></div>
</div>
<details class="comment_right" open>
<summary class="comment_data">
<a class="comment_link" href="{{ post.url }}">COMMENT</a>
<span class="datetime">{{ post.time }}</span>
</summary>
<p class="comment_body">{{ post.body }}</p>
</details>
</div><br>
{% endif %}
{% endfor %} {% endfor %}
<footer> <footer>

View File

@ -1,25 +1,27 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import "utils.html" as utils %}
{% if sub.name != "" %}
{% block title %}r/{{ sub.name }}: {{ sub.description }}{% endblock %} {% block title %}r/{{ sub.name }}: {{ sub.description }}{% endblock %}
{% endif %}
{% block search %}
{% call utils::search(["/r/", sub.name.as_str()].concat(), "") %}
{% endblock %}
{% block body %} {% block body %}
<main style="max-width: 1000px;"> <main>
<div id="column_one"> <div id="column_one">
<form> <form id="sort">
<select id="sort" name="sort"> <div id="sort_options">
<option value="hot" {% if sort.0 == "hot" %}selected{% endif %}>Hot</option> {% if sub.name.is_empty() %}
<option value="new" {% if sort.0 == "new" %}selected{% endif %}>New</option> {% call utils::sort("", ["hot", "new", "top", "rising"], sort.0) %}
<option value="top" {% if sort.0 == "top" %}selected{% endif %}>Top</option> {% else %}
</select>{% if sort.0 == "top" %}<select id="timeframe" name="t"> {% call utils::sort(["/r/", sub.name.as_str()].concat(), ["hot", "new", "top", "rising"], sort.0) %}
<option value="hour" {% if sort.1 == "hour" %}selected{% endif %}>Hour</option> {% endif %}
<option value="day" {% if sort.1 == "day" || sort.1 == "" %}selected{% endif %}>Day</option> </div>
<option value="week" {% if sort.1 == "week" %}selected{% endif %}>Week</option> {% if sort.0 == "top" %}<select id="timeframe" name="t">
<option value="month" {% if sort.1 == "month" %}selected{% endif %}>Month</option> {% call utils::options(sort.1, ["hour", "day", "week", "month", "year", "all"], "day") %}
<option value="year" {% if sort.1 == "year" %}selected{% endif %}>Year</option> <input id="sort_submit" type="submit" value="&rarr;">
<option value="all" {% if sort.1 == "all" %}selected{% endif %}>All</option> </select>{% endif %}
</select>{% endif %}<input id="sort_submit" type="submit" value="&rarr;">
</form> </form>
{% for post in posts %} {% for post in posts %}
<div class="post {% if post.flags.stickied %}stickied{% endif %}"> <div class="post {% if post.flags.stickied %}stickied{% endif %}">

View File

@ -1,20 +1,19 @@
{% extends "base.html" %} {% extends "base.html" %}
{% import "utils.html" as utils %}
{% block search %}
{% call utils::search("".to_owned(), "", "") %}
{% endblock %}
{% block title %}Libreddit: u/{{ user.name }}{% endblock %} {% block title %}Libreddit: u/{{ user.name }}{% endblock %}
{% block body %} {% block body %}
<main style="max-width: 1000px;"> <main style="max-width: 1000px;">
<div id="column_one"> <div id="column_one">
<form> <form id="sort">
<select id="sort" name="sort"> <select name="sort">
<option value="hot" {% if sort.0 == "hot" %}selected{% endif %}>Hot</option> {% call utils::options(sort.0, ["hot", "new", "top"], "") %}
<option value="new" {% if sort.0 == "new" %}selected{% endif %}>New</option>
<option value="top" {% if sort.0 == "top" %}selected{% endif %}>Top</option>
</select>{% if sort.0 == "top" %}<select id="timeframe" name="t"> </select>{% if sort.0 == "top" %}<select id="timeframe" name="t">
<option value="hour" {% if sort.1 == "hour" %}selected{% endif %}>Hour</option> {% call utils::options(sort.1, ["hour", "day", "week", "month", "year", "all"], "all") %}
<option value="day" {% if sort.1 == "day" %}selected{% endif %}>Day</option>
<option value="week" {% if sort.1 == "week" %}selected{% endif %}>Week</option>
<option value="month" {% if sort.1 == "month" %}selected{% endif %}>Month</option>
<option value="year" {% if sort.1 == "year" %}selected{% endif %}>Year</option>
<option value="all" {% if sort.1 == "all" || sort.1 == "" %}selected{% endif %}>All</option>
</select>{% endif %}<input id="sort_submit" type="submit" value="&rarr;"> </select>{% endif %}<input id="sort_submit" type="submit" value="&rarr;">
</form> </form>
{% for post in posts %} {% for post in posts %}

28
templates/utils.html Normal file
View File

@ -0,0 +1,28 @@
{% macro options(current, values, default) -%}
{% for value in values %}
<option value="{{ value }}" {% if current == value || (current == "" && value == default) %}selected{% endif %}>
{{ format!("{}{}", value.get(0..1).unwrap().to_uppercase(), value.get(1..).unwrap()) }}
</option>
{% endfor %}
{%- endmacro %}
{% macro sort(root, methods, selected) -%}
{% for method in methods %}
<a {% if method == selected %}class="selected"{% endif %} href="{{ root }}/{{ method }}">
{{ format!("{}{}", method.get(0..1).unwrap().to_uppercase(), method.get(1..).unwrap()) }}
</a>
{% endfor %}
{%- endmacro %}
{% macro search(root, search) -%}
<form action="{% if root != "/r/" %}{{ root }}{% endif %}/search/" id="searchbox">
<input id="search" type="text" name="q" placeholder="Search" value="{{ search }}">
{% if root != "/r/" %}
<div id="inside">
<input type="checkbox" name="restrict_sr" id="restrict_sr" checked="checked" data-com.bitwarden.browser.user-edited="yes">
<label for="restrict_sr">in {{ root }}</label>
</div>
{% endif %}
<input type="submit" value="&rarr;">
</form>
{%- endmacro %}