diff --git a/.gitignore b/.gitignore index 8a837a7..e3f8b3c 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,7 @@ Cargo.lock # env file containing API keys .env + +# wasm related +/frontend/dist/ +/frontend/index.html diff --git a/Cargo.toml b/Cargo.toml index 559a131..4ab4c70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,6 @@ +workspace = { members = ["backend", "config", "frontend", "types"] } + [package] name = "pulse" version = "0.1.0" edition = "2021" - -# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html - -[dependencies] -cached = {version = "0.51.4", features = ["async"]} -dotenv = "0.15.0" -regex = "1.10.5" -reqwest = "0.12.4" -scraper = "0.19.0" -serde_json = "1.0.117" -tokio = { version = "1.38.0", features = ["full"] } -url = "2.5.1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..92a3a00 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "backend" +version = "0.1.0" +edition = "2021" +build = "build.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +types = { path = "../types" } +config = { path = "../config" } +actix-files = "0.6.6" +actix-web = "4.8.0" +cached = { version = "0.51.4", features = ["async"] } +rand = "0.8.5" +regex = "1.10.5" +reqwest = "0.12.5" +scraper = "0.19.0" +serde_json = "1.0.117" +url = "2.5.2" +yew = "0.21.0" diff --git a/backend/build.rs b/backend/build.rs new file mode 100644 index 0000000..7767b1b --- /dev/null +++ b/backend/build.rs @@ -0,0 +1,9 @@ +use std::fs; +use std::path::Path; + +fn main() { + let dist_dir = Path::new("../frontend/dist"); + if !dist_dir.exists() || fs::read_dir(dist_dir).map_or(true, |entries| entries.count() == 0) { + println!("cargo:warning=Unable to serve frontened"); + } +} diff --git a/backend/src/fetching.rs b/backend/src/fetching.rs new file mode 100644 index 0000000..9d997a6 --- /dev/null +++ b/backend/src/fetching.rs @@ -0,0 +1,4 @@ +pub mod github; +pub mod goodreads; +pub mod lastfm; +pub mod letterboxd; diff --git a/backend/src/fetching/github.rs b/backend/src/fetching/github.rs new file mode 100644 index 0000000..0cc1b23 --- /dev/null +++ b/backend/src/fetching/github.rs @@ -0,0 +1,53 @@ +use cached::proc_macro::once; +use reqwest::{self, header}; +use types::Commit; + +// 15 min +#[once(result = true, time = 900, sync_writes = true)] +pub async fn fetch_newest( + username: &str, + n: u32, +) -> Result, Box> { + println!("Fetching data from github api..."); + let url = format!("https://api.github.com/users/{username}/events"); + + let client = reqwest::Client::new(); + let response = client + .get(&url) + .header(header::USER_AGENT, "feframe") + .send() + .await? + .text() + .await?; + + let json: serde_json::Value = serde_json::from_str(&response) + .map_err(|err| Box::new(err) as Box)?; + + let json_array = match json.as_array() { + Some(json_array) => json_array.clone(), + None => return Ok(Vec::new()), + }; + + let push_events: Vec<_> = json_array + .iter() + .filter(|&event| event["type"] == "PushEvent") + .cloned() + .collect(); + + Ok(push_events + .iter() + .filter_map(|event| { + let commit = &event["payload"]["commits"][0]; + let repository_name = event["repo"]["name"].as_str()?.to_string(); + let repository_link = format!("https://github.com/{repository_name}"); + + Some(Commit { + message: commit["message"].as_str()?.to_string(), + url: format!("{repository_link}/commit/{}", commit["sha"].as_str()?), + repository_name, + repository_link, + }) + }) + .take(n as usize) + .collect()) +} diff --git a/src/dynamic_content/goodreads.rs b/backend/src/fetching/goodreads.rs similarity index 57% rename from src/dynamic_content/goodreads.rs rename to backend/src/fetching/goodreads.rs index f768aa9..83d8d79 100644 --- a/src/dynamic_content/goodreads.rs +++ b/backend/src/fetching/goodreads.rs @@ -1,16 +1,8 @@ -use super::ApiRefresh; use cached::proc_macro::once; use regex::Regex; use scraper::{Html, Selector}; -use url::{ParseError, Url}; - -#[derive(Clone)] -pub struct Book { - pub title: String, - pub author: String, - pub title_url: Url, - pub author_url: Url, -} +use std::collections::HashMap; +use types::Book; fn clean_text(input: &str) -> String { let trimmed = input.trim().replace(['\n', '\r'], ""); @@ -18,10 +10,6 @@ fn clean_text(input: &str) -> String { re.replace_all(&trimmed, " ").to_string() } -fn create_goodreads_url(path: &str) -> Result { - Url::parse(&format!("https://www.goodreads.com/{path}")) -} - fn swap_name_order(full_name: &str) -> Result { let (last, first) = full_name .split_once(',') @@ -31,20 +19,15 @@ fn swap_name_order(full_name: &str) -> Result { Ok(format!("{first} {last}")) } -impl ApiRefresh for Book { - type Content = Book; - - async fn fetch_newest(n: u32) -> Result, Box> { - fetch_newest_books(n).await - } -} - // 1 day -#[once(result = true, time = 86400)] -async fn fetch_newest_books(n: u32) -> Result, Box> { - let shelf = std::env::var("GOODREADS_SHELF")?; +#[once(result = true, time = 86400, sync_writes = true)] +pub async fn fetch_newest( + shelf: &str, + n: u32, +) -> Result, Box> { + println!("Parsing goodreads shelf html..."); let html = Html::parse_document( - &reqwest::get(&shelf) + &reqwest::get(shelf) .await .map_err(|err| Box::new(err) as Box)? .text() @@ -52,10 +35,20 @@ async fn fetch_newest_books(n: u32) -> Result, Box)?, ); + let ratings = HashMap::from([ + ("did not like it", 1), + ("it was ok", 2), + ("liked it", 3), + ("really liked it", 4), + ("it was amazing", 5), + ]); + let row_selector = Selector::parse(r"tr.bookalike.review").unwrap(); let title_selector = Selector::parse(r"td.field.title a").unwrap(); let author_selector = Selector::parse(r"td.field.author a").unwrap(); + let rating_selector = Selector::parse(r"td.field.rating span").unwrap(); + let cover_selector = Selector::parse(r"td.field.cover img").unwrap(); Ok(html .select(&row_selector) @@ -64,14 +57,20 @@ async fn fetch_newest_books(n: u32) -> Result, Box>().concat()), author: swap_name_order(&author_element.text().collect::>().concat()) .ok()?, - title_url: create_goodreads_url(title_href).ok()?, - author_url: create_goodreads_url(author_href).ok()?, + rating: ("★").repeat(*ratings.get(rating)?), + title_url: format!("https://www.goodreads.com{title_href}"), + author_url: format!("https://www.goodreads.com{author_href}"), + cover_url: cover_url.to_string(), }) }) .take(n as usize) diff --git a/src/dynamic_content/lastfm.rs b/backend/src/fetching/lastfm.rs similarity index 56% rename from src/dynamic_content/lastfm.rs rename to backend/src/fetching/lastfm.rs index 776eef7..0a8d363 100644 --- a/src/dynamic_content/lastfm.rs +++ b/backend/src/fetching/lastfm.rs @@ -1,30 +1,14 @@ -use super::ApiRefresh; use cached::proc_macro::once; -use url::Url; - -#[derive(Clone)] -pub struct Song { - pub title: String, - pub artist_name: String, - pub album_name: String, - pub album_image: Url, - pub url: Url, -} - -impl ApiRefresh for Song { - type Content = Song; - - async fn fetch_newest(n: u32) -> Result, Box> { - fetch_newest_songs(n).await - } -} +use types::Song; // 20 min -#[once(result = true, time = 1200)] -async fn fetch_newest_songs(n: u32) -> Result, Box> { - let key = std::env::var("LASTFM_KEY")?; - let username = std::env::var("LASTFM_USERNAME")?; - +#[once(result = true, time = 1200, sync_writes = true)] +pub async fn fetch_newest( + username: &str, + key: &str, + n: u32, +) -> Result, Box> { + println!("Fetching data from lastfm api..."); let url = format!("https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user={username}&api_key={key}&format=json"); let response = reqwest::get(&url) @@ -49,9 +33,13 @@ async fn fetch_newest_songs(n: u32) -> Result, Box Result> { + let img_selector = + Selector::parse("div.react-component.poster.film-poster img.image[src]").unwrap(); + + Ok(html + .select(&img_selector) + .next() + .ok_or("Image source not found in HTML")? + .attr("src") + .ok_or("Image source attribute not found in HTML")? + .to_string()) +} + +fn parse_release_year(html: &Html) -> Result> { + let img_selector = Selector::parse("div.react-component.poster.film-poster").unwrap(); + + Ok(html + .select(&img_selector) + .next() + .ok_or("Image source not found in HTML")? + .attr("data-film-release-year") + .ok_or("Image source attribute not found in HTML")? + .to_string()) +} + +// 1 day +#[once(result = true, time = 86400, sync_writes = true)] +pub async fn fetch_newest( + username: &str, + n: u32, +) -> Result, Box> { + println!("Parsing letterboxd profile html..."); + let url = format!("https://letterboxd.com/{username}/films/by/rated-date/"); + let html = Html::parse_document( + &reqwest::get(&url) + .await + .map_err(|err| Box::new(err) as Box)? + .text() + .await + .map_err(|err| Box::new(err) as Box)?, + ); + + let row_selector = Selector::parse("li.poster-container").unwrap(); + + let div_selector = Selector::parse("div.really-lazy-load").unwrap(); + let rating_selector = Selector::parse("span.rating").unwrap(); + let img_selector = Selector::parse("img.image").unwrap(); + + let movie_iter = html + .select(&row_selector) + .filter_map(|row| { + let title = row + .select(&img_selector) + .next()? + .value() + .attr("alt")? + .to_string(); + + let rating = row + .select(&rating_selector) + .next() + .map(|r| r.inner_html()) + .filter(|r| !r.is_empty())?; + + let div_val = row.select(&div_selector).next()?.value(); + + let link = div_val.attr("data-target-link")?; + + let slug = div_val.attr("data-film-slug")?; + + Some(Movie { + title, + rating, + release_year: String::new(), + url: format! {"https://letterboxd.com{link}"}, + poster_url: slug.to_string(), // icky, just store the slug in here for now xd + }) + }) + .take(n as usize); + + // no async closures :( + let mut movies = Vec::new(); + for mut movie in movie_iter { + let html = Html::parse_document( + &reqwest::get(format!( + "https://letterboxd.com/ajax/poster/film/{}/std/70x105/", + movie.poster_url // aka slug + )) + .await + .map_err(|err| Box::new(err) as Box)? + .text() + .await + .map_err(|err| Box::new(err) as Box)?, + ); + + movie.poster_url = parse_image(&html)?; + movie.release_year = parse_release_year(&html)?; + + movies.push(movie); + } + + Ok(movies) +} diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..642b5fc --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,52 @@ +use actix_web::{web, App, HttpServer, Responder}; +use config::{ENDPOINT, ENV}; + +mod fetching; + +async fn github() -> impl Responder { + web::Json( + fetching::github::fetch_newest(ENV.username.github, 10) + .await + .unwrap(), + ) +} + +async fn lastfm() -> impl Responder { + web::Json( + fetching::lastfm::fetch_newest(ENV.username.lastfm, ENV.key.lastfm, 10) + .await + .unwrap(), + ) +} + +async fn goodreads() -> impl Responder { + web::Json( + fetching::goodreads::fetch_newest(ENV.link.goodreads, 10) + .await + .unwrap(), + ) +} + +async fn letterboxd() -> impl Responder { + web::Json( + fetching::letterboxd::fetch_newest(ENV.username.letterboxd, 4) + .await + .unwrap(), + ) +} + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + println!("Server opened on {}", ENDPOINT.base); + HttpServer::new(|| { + App::new() + .route(ENDPOINT.github, web::get().to(github)) + .route(ENDPOINT.lastfm, web::get().to(lastfm)) + .route(ENDPOINT.goodreads, web::get().to(goodreads)) + .route(ENDPOINT.letterboxd, web::get().to(letterboxd)) + .service(actix_files::Files::new("/", "../frontend/dist").index_file("index.html")) + }) + .bind(ENDPOINT.base)? + .run() + .await +} diff --git a/config/Cargo.toml b/config/Cargo.toml new file mode 100644 index 0000000..cd4f221 --- /dev/null +++ b/config/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "config" +version = "0.1.0" +edition = "2021" +build = "build.rs" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +dotenv_codegen = "0.15.0" +types = { path = "../types" } +url = "2.5.2" + +[build-dependencies] +url = "2.5.1" +dotenv_codegen = "0.15.0" diff --git a/config/build.rs b/config/build.rs new file mode 100644 index 0000000..475cb4a --- /dev/null +++ b/config/build.rs @@ -0,0 +1,6 @@ +use dotenv_codegen::dotenv; +use url::Url; + +fn main() { + let _ = Url::parse(dotenv!("GOODREADS_SHELF")).expect("Goodreads shelf link is invalid"); +} diff --git a/config/src/lib.rs b/config/src/lib.rs new file mode 100644 index 0000000..408962f --- /dev/null +++ b/config/src/lib.rs @@ -0,0 +1,51 @@ +use dotenv_codegen::dotenv; + +pub const ENV: Env = Env { + username: Username { + github: dotenv!("GH_USERNAME"), + lastfm: dotenv!("LASTFM_USERNAME"), + letterboxd: dotenv!("LETTERBOXD_USERNAME"), + }, + link: Link { + goodreads: dotenv!("GOODREADS_SHELF"), + }, + key: Key { + lastfm: dotenv!("LASTFM_KEY"), + }, +}; + +pub const ENDPOINT: Endpoint = Endpoint { + base: "127.0.0.1:8080", + github: "/api/github", + lastfm: "/api/lastfm", + letterboxd: "/api/letterboxd", + goodreads: "/api/goodreads", +}; + +pub struct Endpoint<'a> { + pub base: &'a str, + pub github: &'a str, + pub lastfm: &'a str, + pub letterboxd: &'a str, + pub goodreads: &'a str, +} + +pub struct Env<'a> { + pub username: Username<'a>, + pub link: Link<'a>, + pub key: Key<'a>, +} + +pub struct Username<'a> { + pub github: &'a str, + pub lastfm: &'a str, + pub letterboxd: &'a str, +} + +pub struct Link<'a> { + pub goodreads: &'a str, +} + +pub struct Key<'a> { + pub lastfm: &'a str, +} diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml new file mode 100644 index 0000000..c6dc985 --- /dev/null +++ b/frontend/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "frontend" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +types = { path = "../types" } +config = { path = "../config" } +stylist = { version = "0.13.0", features = ["yew"] } +yew = { version = "0.21.0", features = ["csr"]} +wasm-bindgen-futures = "0.4.42" +url = "2.5.2" +reqwest = { version = "0.12.5", features = ["json", "blocking"] } +serde_json = "1.0.117" +tokio = "1.38.0" +chrono = "0.4.38" +chrono-tz = "0.9.0" diff --git a/frontend/src/components.rs b/frontend/src/components.rs new file mode 100644 index 0000000..e5d1456 --- /dev/null +++ b/frontend/src/components.rs @@ -0,0 +1,5 @@ +pub mod github; +pub mod goodreads; +pub mod lastfm; +pub mod letterboxd; +pub mod profile; diff --git a/frontend/src/components/assets/me.jpg b/frontend/src/components/assets/me.jpg new file mode 100644 index 0000000..ad91445 Binary files /dev/null and b/frontend/src/components/assets/me.jpg differ diff --git a/frontend/src/components/github.rs b/frontend/src/components/github.rs new file mode 100644 index 0000000..db0fa3d --- /dev/null +++ b/frontend/src/components/github.rs @@ -0,0 +1,6 @@ +mod button; +mod card; +mod row; +mod scroller; + +pub use card::Card; diff --git a/frontend/src/components/github/button.rs b/frontend/src/components/github/button.rs new file mode 100644 index 0000000..11d1435 --- /dev/null +++ b/frontend/src/components/github/button.rs @@ -0,0 +1,32 @@ +use stylist::yew::styled_component; +use yew::prelude::*; + +#[styled_component] +pub fn Button() -> Html { + let icon_svg = html! { + + + + + + + + + + + + }; + + let onclick = Callback::noop(); + + html! { + + } +} diff --git a/frontend/src/components/github/card.rs b/frontend/src/components/github/card.rs new file mode 100644 index 0000000..992faa3 --- /dev/null +++ b/frontend/src/components/github/card.rs @@ -0,0 +1,51 @@ +use super::button::Button; +use super::scroller::Scroller; +use stylist::yew::styled_component; +use yew::prelude::*; + +#[styled_component] +pub fn Card() -> Html { + let title = "Recent Commits".to_string(); + html! { +
+
+
+
+
+ +
+
+
+ } +} diff --git a/frontend/src/components/github/row.rs b/frontend/src/components/github/row.rs new file mode 100644 index 0000000..a5fa14f --- /dev/null +++ b/frontend/src/components/github/row.rs @@ -0,0 +1,66 @@ +use stylist::yew::styled_component; +use yew::prelude::*; + +use types::Commit; + +#[styled_component] +pub fn Row(props: &Commit) -> Html { + let Commit { + message, + url, + repository_name, + repository_link, + } = props; + + let repo_svg = html! { + + + + + }; + + html! { +
+
+ + { message } + +
+ + { repository_name } + + { repo_svg.clone() } +
+
+
+ } +} diff --git a/frontend/src/components/github/scroller.rs b/frontend/src/components/github/scroller.rs new file mode 100644 index 0000000..7115d38 --- /dev/null +++ b/frontend/src/components/github/scroller.rs @@ -0,0 +1,60 @@ +use super::row::Row; +use config::ENDPOINT; +use reqwest; +use stylist::yew::styled_component; +use types::Commit; +use yew::prelude::*; + +#[styled_component] +pub fn Scroller() -> Html { + #[allow(clippy::redundant_closure)] + let commits = use_state(|| std::vec::Vec::new()); + { + let commits = commits.clone(); + use_effect_with((), move |()| { + let commits = commits.clone(); + wasm_bindgen_futures::spawn_local(async move { + let response = reqwest::get(format!("http://{}{}", ENDPOINT.base, ENDPOINT.github)) + .await + .unwrap(); + let fetched_commits: Vec = response.json().await.unwrap(); + commits.set(fetched_commits); + }); + || () + }); + } + + html! { +
+ { commits.iter().map(|commit| html! { }).collect::>() } +
+ } +} diff --git a/frontend/src/components/goodreads.rs b/frontend/src/components/goodreads.rs new file mode 100644 index 0000000..db0fa3d --- /dev/null +++ b/frontend/src/components/goodreads.rs @@ -0,0 +1,6 @@ +mod button; +mod card; +mod row; +mod scroller; + +pub use card::Card; diff --git a/frontend/src/components/goodreads/button.rs b/frontend/src/components/goodreads/button.rs new file mode 100644 index 0000000..de71b93 --- /dev/null +++ b/frontend/src/components/goodreads/button.rs @@ -0,0 +1,24 @@ +use stylist::yew::styled_component; +use yew::prelude::*; + +#[styled_component] +pub fn Button() -> Html { + let icon_svg = html! { + + + + }; + + let onclick = Callback::noop(); + + html! { + + } +} diff --git a/frontend/src/components/goodreads/card.rs b/frontend/src/components/goodreads/card.rs new file mode 100644 index 0000000..606a01f --- /dev/null +++ b/frontend/src/components/goodreads/card.rs @@ -0,0 +1,51 @@ +use super::button::Button; +use super::scroller::Scroller; +use stylist::yew::styled_component; +use yew::prelude::*; + +#[styled_component] +pub fn Card() -> Html { + let title = "Recently Read".to_string(); + html! { +
+
+
+
+
+ +
+
+
+ } +} diff --git a/frontend/src/components/goodreads/row.rs b/frontend/src/components/goodreads/row.rs new file mode 100644 index 0000000..a818ebb --- /dev/null +++ b/frontend/src/components/goodreads/row.rs @@ -0,0 +1,72 @@ +use stylist::yew::styled_component; +use yew::prelude::*; + +use types::Book; + +#[styled_component] +pub fn Row(props: &Book) -> Html { + let Book { + title, + author, + rating, + title_url, + author_url, + cover_url, + } = props; + + html! { + + } +} diff --git a/frontend/src/components/goodreads/scroller.rs b/frontend/src/components/goodreads/scroller.rs new file mode 100644 index 0000000..49d8bbe --- /dev/null +++ b/frontend/src/components/goodreads/scroller.rs @@ -0,0 +1,60 @@ +use super::row::Row; +use config::ENDPOINT; +use stylist::yew::styled_component; +use types::Book; +use yew::prelude::*; + +#[styled_component] +pub fn Scroller() -> Html { + #[allow(clippy::redundant_closure)] + let books = use_state(|| std::vec::Vec::new()); + { + let books = books.clone(); + use_effect_with((), move |()| { + let books = books.clone(); + wasm_bindgen_futures::spawn_local(async move { + let response = + reqwest::get(format!("http://{}{}", ENDPOINT.base, ENDPOINT.goodreads)) + .await + .unwrap(); + let fetched_books: Vec = response.json().await.unwrap(); + books.set(fetched_books); + }); + || () + }); + } + + html! { +
+ { books.iter().map(|book| html! { }).collect::>() } +
+ } +} diff --git a/frontend/src/components/lastfm.rs b/frontend/src/components/lastfm.rs new file mode 100644 index 0000000..db0fa3d --- /dev/null +++ b/frontend/src/components/lastfm.rs @@ -0,0 +1,6 @@ +mod button; +mod card; +mod row; +mod scroller; + +pub use card::Card; diff --git a/frontend/src/components/lastfm/button.rs b/frontend/src/components/lastfm/button.rs new file mode 100644 index 0000000..1a8a084 --- /dev/null +++ b/frontend/src/components/lastfm/button.rs @@ -0,0 +1,24 @@ +use stylist::yew::styled_component; +use yew::prelude::*; + +#[styled_component] +pub fn Button() -> Html { + let icon_svg = html! { + + + + }; + + let onclick = Callback::noop(); + + html! { + + } +} diff --git a/frontend/src/components/lastfm/card.rs b/frontend/src/components/lastfm/card.rs new file mode 100644 index 0000000..515ae9d --- /dev/null +++ b/frontend/src/components/lastfm/card.rs @@ -0,0 +1,51 @@ +use super::button::Button; +use super::scroller::Scroller; +use stylist::yew::styled_component; +use yew::prelude::*; + +#[styled_component] +pub fn Card() -> Html { + let title = "Scrobbles".to_string(); + html! { +
+
+
+
+
+ +
+
+
+ } +} diff --git a/frontend/src/components/lastfm/row.rs b/frontend/src/components/lastfm/row.rs new file mode 100644 index 0000000..1cd3a18 --- /dev/null +++ b/frontend/src/components/lastfm/row.rs @@ -0,0 +1,82 @@ +use stylist::yew::styled_component; +use yew::prelude::*; + +use types::Song; + +#[styled_component] +pub fn Row(props: &Song) -> Html { + let Song { + title, + artist_name, + album_name: _, + album_image, + url, + } = props; + + let play_svg = html! { + + + + + + + + + + + }; + + html! { + + } +} diff --git a/frontend/src/components/lastfm/scroller.rs b/frontend/src/components/lastfm/scroller.rs new file mode 100644 index 0000000..76ad8fc --- /dev/null +++ b/frontend/src/components/lastfm/scroller.rs @@ -0,0 +1,59 @@ +use super::row::Row; +use config::ENDPOINT; +use stylist::yew::styled_component; +use types::Song; +use yew::prelude::*; + +#[styled_component] +pub fn Scroller() -> Html { + #[allow(clippy::redundant_closure)] + let songs = use_state(|| std::vec::Vec::new()); + { + let songs = songs.clone(); + use_effect_with((), move |()| { + let songs = songs.clone(); + wasm_bindgen_futures::spawn_local(async move { + let response = reqwest::get(format!("http://{}{}", ENDPOINT.base, ENDPOINT.lastfm)) + .await + .unwrap(); + let fetched_songs: Vec = response.json().await.unwrap(); + songs.set(fetched_songs); + }); + || () + }); + } + + html! { +
+ { songs.iter().map(|commit| html! { }).collect::>() } +
+ } +} diff --git a/frontend/src/components/letterboxd.rs b/frontend/src/components/letterboxd.rs new file mode 100644 index 0000000..db0fa3d --- /dev/null +++ b/frontend/src/components/letterboxd.rs @@ -0,0 +1,6 @@ +mod button; +mod card; +mod row; +mod scroller; + +pub use card::Card; diff --git a/frontend/src/components/letterboxd/button.rs b/frontend/src/components/letterboxd/button.rs new file mode 100644 index 0000000..9d54e24 --- /dev/null +++ b/frontend/src/components/letterboxd/button.rs @@ -0,0 +1,51 @@ +use stylist::yew::styled_component; +use yew::prelude::*; + +#[styled_component] +pub fn Button() -> Html { + let icon_svg = html! { + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + }; + + let onclick = Callback::noop(); + + html! { + + } +} diff --git a/frontend/src/components/letterboxd/card.rs b/frontend/src/components/letterboxd/card.rs new file mode 100644 index 0000000..96cbf92 --- /dev/null +++ b/frontend/src/components/letterboxd/card.rs @@ -0,0 +1,51 @@ +use super::button::Button; +use super::scroller::Scroller; +use stylist::yew::styled_component; +use yew::prelude::*; + +#[styled_component] +pub fn Card() -> Html { + let title = "Recently Watched".to_string(); + html! { +
+
+
+
+
+ +
+
+
+ } +} diff --git a/frontend/src/components/letterboxd/row.rs b/frontend/src/components/letterboxd/row.rs new file mode 100644 index 0000000..ec21e86 --- /dev/null +++ b/frontend/src/components/letterboxd/row.rs @@ -0,0 +1,71 @@ +use stylist::yew::styled_component; +use yew::prelude::*; + +use types::Movie; + +#[styled_component] +pub fn Row(props: &Movie) -> Html { + let Movie { + title, + rating, + release_year, + url, + poster_url, + } = props; + + html! { + + } +} diff --git a/frontend/src/components/letterboxd/scroller.rs b/frontend/src/components/letterboxd/scroller.rs new file mode 100644 index 0000000..5c20c4d --- /dev/null +++ b/frontend/src/components/letterboxd/scroller.rs @@ -0,0 +1,60 @@ +use super::row::Row; +use config::ENDPOINT; +use stylist::yew::styled_component; +use types::Movie; +use yew::prelude::*; + +#[styled_component] +pub fn Scroller() -> Html { + #[allow(clippy::redundant_closure)] + let movies = use_state(|| std::vec::Vec::new()); + { + let movies = movies.clone(); + use_effect_with((), move |()| { + let movies = movies.clone(); + wasm_bindgen_futures::spawn_local(async move { + let response = + reqwest::get(format!("http://{}{}", ENDPOINT.base, ENDPOINT.letterboxd)) + .await + .unwrap(); + let fetched_movies: Vec = response.json().await.unwrap(); + movies.set(fetched_movies); + }); + || () + }); + } + + html! { +
+ { movies.iter().map(|movie| html! { }).collect::>() } +
+ } +} diff --git a/frontend/src/components/profile.rs b/frontend/src/components/profile.rs new file mode 100644 index 0000000..ac8b117 --- /dev/null +++ b/frontend/src/components/profile.rs @@ -0,0 +1,4 @@ +mod card; +mod row; + +pub use card::Card; diff --git a/frontend/src/components/profile/card.rs b/frontend/src/components/profile/card.rs new file mode 100644 index 0000000..82d4216 --- /dev/null +++ b/frontend/src/components/profile/card.rs @@ -0,0 +1,117 @@ +use chrono::{Datelike, Local, TimeZone, Utc}; +use chrono_tz::US::Pacific; +use stylist::yew::styled_component; +use yew::prelude::*; + +fn age() -> i32 { + let now = Local::now(); + let birthday = Local.with_ymd_and_hms(2003, 2, 24, 0, 0, 0).unwrap(); + let age = now.year() - birthday.year(); + + if now.month() < birthday.month() + || (now.month() == birthday.month() && now.day() < birthday.day()) + { + age - 1 + } else { + age + } +} + +#[styled_component] +pub fn Card() -> Html { + let cake_svg = html! { + + + + }; + + let clock_svg = html! { + + + + }; + + html! { +
+
+
+ Profile Image +
+ { "Wyatt" } +
+
+ { "he/him" } +
+
+
+
+
+ { cake_svg } + { age() } +
+
+ { clock_svg } + { Utc::now().with_timezone(&Pacific).format("%H:%M %p") } +
+
+
+
+
+ } +} diff --git a/frontend/src/components/profile/row.rs b/frontend/src/components/profile/row.rs new file mode 100644 index 0000000..a818ebb --- /dev/null +++ b/frontend/src/components/profile/row.rs @@ -0,0 +1,72 @@ +use stylist::yew::styled_component; +use yew::prelude::*; + +use types::Book; + +#[styled_component] +pub fn Row(props: &Book) -> Html { + let Book { + title, + author, + rating, + title_url, + author_url, + cover_url, + } = props; + + html! { + + } +} diff --git a/frontend/src/main.rs b/frontend/src/main.rs new file mode 100644 index 0000000..dab8175 --- /dev/null +++ b/frontend/src/main.rs @@ -0,0 +1,80 @@ +mod components; + +use stylist::yew::{styled_component, Global}; +use yew::prelude::*; + +#[styled_component] +pub fn App() -> Html { + html! { +
+ // Global Styles can be applied with component. + +
+
+ +
+
+ + +
+
+ + +
+
+
+ } +} + +fn main() { + yew::Renderer::::new().render(); +} diff --git a/src/dynamic_content.rs b/src/dynamic_content.rs deleted file mode 100644 index 7091dc3..0000000 --- a/src/dynamic_content.rs +++ /dev/null @@ -1,17 +0,0 @@ -mod github; -mod goodreads; -mod lastfm; -mod letterboxd; - -pub use github::Commit; -pub use goodreads::Book; -pub use lastfm::Song; -pub use letterboxd::Movie; - -pub trait ApiRefresh { - type Content; - - async fn fetch_newest( - n: u32, - ) -> Result, Box>; -} diff --git a/src/dynamic_content/github.rs b/src/dynamic_content/github.rs deleted file mode 100644 index 477fe6f..0000000 --- a/src/dynamic_content/github.rs +++ /dev/null @@ -1,66 +0,0 @@ -use super::ApiRefresh; -use cached::proc_macro::once; -use reqwest::{self, header}; -use url::Url; - -#[derive(Clone)] -pub struct Commit { - pub message: String, - pub url: Url, - pub repository_name: String, - pub repository_link: Url, -} - -impl ApiRefresh for Commit { - type Content = Commit; - - async fn fetch_newest(n: u32) -> Result, Box> { - fetch_newest_commits(n).await - } -} - -// 15 min -#[once(result = true, time = 900)] -async fn fetch_newest_commits(n: u32) -> Result, Box> { - let username = std::env::var("GH_USERNAME")?; - - let url = format!("https://api.github.com/users/{username}/events"); - - let client = reqwest::Client::new(); - let response = client - .get(&url) - .header(header::USER_AGENT, "pulse") - .send() - .await? - .text() - .await?; - - let json: serde_json::Value = serde_json::from_str(&response) - .map_err(|err| Box::new(err) as Box)?; - - let events = match json.as_array() { - Some(events) => events.clone(), - None => return Ok(Vec::new()), - }; - - let commits: Vec<_> = events - .iter() - .filter(|&event| event["type"] == "PushEvent") - .cloned() - .collect(); - - Ok(commits - .iter() - .filter_map(|commit| { - Some(Commit { - message: commit["payload"]["commits"][0]["message"] - .as_str()? - .to_string(), - url: Url::parse(commit["payload"]["commits"][0]["url"].as_str()?).ok()?, - repository_name: commit["repo"]["name"].as_str()?.to_string(), - repository_link: Url::parse(commit["repo"]["url"].as_str()?).ok()?, - }) - }) - .take(n as usize) - .collect()) -} diff --git a/src/dynamic_content/letterboxd.rs b/src/dynamic_content/letterboxd.rs deleted file mode 100644 index 6bf13a0..0000000 --- a/src/dynamic_content/letterboxd.rs +++ /dev/null @@ -1,73 +0,0 @@ -use super::ApiRefresh; -use cached::proc_macro::once; -use scraper::{Html, Selector}; -use url::Url; - -#[derive(Clone)] -pub struct Movie { - pub title: String, - pub rating: String, - pub url: Url, -} - -impl ApiRefresh for Movie { - type Content = Movie; - - async fn fetch_newest( - n: u32, - ) -> Result, Box> { - fetch_newest_movies(n).await - } -} - -// 1 day -#[once(result = true, time = 86400)] -async fn fetch_newest_movies(n: u32) -> Result, Box> { - let username = std::env::var("LETTERBOXD_USERNAME")?; - let url = format!("https://letterboxd.com/{username}/films/by/rated-date/"); - let html = Html::parse_document( - &reqwest::get(&url) - .await - .map_err(|err| Box::new(err) as Box)? - .text() - .await - .map_err(|err| Box::new(err) as Box)?, - ); - - let row_selector = Selector::parse("li.poster-container").unwrap(); - - let div_selector = Selector::parse("div.really-lazy-load").unwrap(); - let rating_selector = Selector::parse("span.rating").unwrap(); - let img_selector = Selector::parse("img.image").unwrap(); - - Ok(html - .select(&row_selector) - .filter_map(|row| { - let title = row - .select(&img_selector) - .next()? - .value() - .attr("alt")? - .to_string(); - - let rating = row - .select(&rating_selector) - .next() - .map(|r| r.inner_html()) - .filter(|r| !r.is_empty())?; - - let link = row - .select(&div_selector) - .next()? - .value() - .attr("data-target-link")?; - - Some(Movie { - title, - rating, - url: Url::parse(format! {"https://letterboxd.com{link}"}.as_str()).ok()?, - }) - }) - .take(n as usize) - .collect()) -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 5bdce85..0000000 --- a/src/main.rs +++ /dev/null @@ -1,23 +0,0 @@ -use dynamic_content::{ApiRefresh, Book, Commit, Movie, Song}; - -mod dynamic_content; - -#[allow(dead_code)] -async fn use_content() { - let commit = &Commit::fetch_newest(1).await.unwrap()[0]; - let book = &Book::fetch_newest(1).await.unwrap()[0]; - let song = &Song::fetch_newest(1).await.unwrap()[0]; - let movie = &Movie::fetch_newest(1).await.unwrap()[0]; - - println!( - "{}, {}, {}, {}", - commit.repository_name, book.title, song.title, movie.title - ); -} - -#[tokio::main] -async fn main() { - dotenv::dotenv().ok(); - - println!("Hello, world!"); -} diff --git a/tests/endpoint.rs b/tests/endpoint.rs new file mode 100644 index 0000000..8337712 --- /dev/null +++ b/tests/endpoint.rs @@ -0,0 +1 @@ +// diff --git a/tests/env_tests.rs b/tests/env_tests.rs deleted file mode 100644 index 2c4df6a..0000000 --- a/tests/env_tests.rs +++ /dev/null @@ -1,23 +0,0 @@ -mod env_tests { - #[test] - fn test_required_env_vrs() { - dotenv::dotenv().ok(); - let required_vars = [ - "LASTFM_USERNAME", - "LASTFM_KEY", - "GH_USERNAME", - "GOODREADS_SHELF", - "LETTERBOXD_USERNAME", - ]; - - required_vars - .iter() - .map(|&var_name| { - assert!( - std::env::var(var_name).is_ok(), - "Environment variable {var_name} is not defined" - ); - }) - .for_each(drop); - } -} diff --git a/types/Cargo.toml b/types/Cargo.toml new file mode 100644 index 0000000..da47d6e --- /dev/null +++ b/types/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "types" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +serde = "1.0.203" +serde_json = "1.0.117" +url = "2.5.2" +yew = "0.21.0" diff --git a/types/src/lib.rs b/types/src/lib.rs new file mode 100644 index 0000000..114d97d --- /dev/null +++ b/types/src/lib.rs @@ -0,0 +1,38 @@ +use serde::{Deserialize, Serialize}; +use yew::Properties; + +#[derive(Clone, Properties, PartialEq, Serialize, Deserialize)] +pub struct Commit { + pub message: String, + pub url: String, + pub repository_name: String, + pub repository_link: String, +} + +#[derive(Clone, Properties, PartialEq, Serialize, Deserialize)] +pub struct Movie { + pub title: String, + pub rating: String, + pub release_year: String, + pub url: String, + pub poster_url: String, +} + +#[derive(Clone, Properties, PartialEq, Serialize, Deserialize)] +pub struct Book { + pub title: String, + pub author: String, + pub rating: String, + pub title_url: String, + pub author_url: String, + pub cover_url: String, +} + +#[derive(Clone, Properties, PartialEq, Serialize, Deserialize)] +pub struct Song { + pub title: String, + pub artist_name: String, + pub album_name: String, + pub album_image: String, + pub url: String, +}