diff --git a/Cargo.lock b/Cargo.lock index 7f635f8..8aa65a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "ansi_term" version = "0.12.1" @@ -227,6 +236,21 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "chrono" +version = "0.4.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e3c5919066adf22df73762e50cffcde3a758f2a848b113b586d1f86728b673b" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-integer", + "num-traits", + "time 0.1.45", + "wasm-bindgen", + "winapi", +] + [[package]] name = "clap" version = "2.34.0" @@ -248,6 +272,16 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + [[package]] name = "cookie" version = "0.16.2" @@ -255,7 +289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" dependencies = [ "percent-encoding", - "time", + "time 0.3.20", "version_check", ] @@ -271,7 +305,7 @@ dependencies = [ "publicsuffix", "serde", "serde_json", - "time", + "time 0.3.20", "url", ] @@ -300,6 +334,50 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cxx" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a140f260e6f3f79013b8bfc65e7ce630c9ab4388c6a89c71e07226f49487b72" +dependencies = [ + "cc", + "cxxbridge-flags", + "cxxbridge-macro", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da6383f459341ea689374bf0a42979739dc421874f112ff26f829b8040b8e613" +dependencies = [ + "cc", + "codespan-reporting", + "once_cell", + "proc-macro2", + "quote", + "scratch", + "syn", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90201c1a650e95ccff1c8c0bb5a343213bdd317c6e600a93075bca2eff54ec97" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b75aed41bb2e6367cae39e6326ef817a851db13c13e4f3263714ca3cfb8de56" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "darling" version = "0.14.4" @@ -483,7 +561,7 @@ checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", ] [[package]] @@ -652,6 +730,30 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "iana-time-zone" +version = "0.1.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64c122667b287044802d6ce17ee2ddf13207ed924c712de9a66a5814d5b64765" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0703ae284fc167426161c2e3f1da3ea71d94b21bedbcc9494e92b28e334e3dca" +dependencies = [ + "cxx", + "cxx-build", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -761,6 +863,15 @@ version = "0.2.139" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" +[[package]] +name = "link-cplusplus" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecd207c9c713c34f95a097a5b029ac2ce6010530c7b49d7fea24d977dede04f5" +dependencies = [ + "cc", +] + [[package]] name = "linux-raw-sys" version = "0.1.4" @@ -849,7 +960,7 @@ checksum = "5b9d9a46eff5b4ff64b45a9e316a6d1e0bc719ef429cbec4dc630684212bfdf9" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.45.0", ] @@ -881,6 +992,25 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + [[package]] name = "num_cpus" version = "1.15.0" @@ -1267,6 +1397,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" +[[package]] +name = "scratch" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1792db035ce95be60c3f8853017b3999209281c24e2ba5bc8e59bf97a0c590c1" + [[package]] name = "security-framework" version = "2.8.2" @@ -1444,6 +1580,15 @@ dependencies = [ "windows-sys 0.42.0", ] +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" +dependencies = [ + "winapi-util", +] + [[package]] name = "textwrap" version = "0.11.0" @@ -1483,6 +1628,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b797afad3f312d1c66a56d11d0316f916356d11bd158fbc6ca6389ff6bf805a" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + [[package]] name = "time" version = "0.3.20" @@ -1761,7 +1917,9 @@ dependencies = [ "anyhow", "axum", "cached", + "chrono", "grass", + "lazy_static", "maud", "regex", "serde", @@ -1781,6 +1939,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -1855,9 +2019,9 @@ checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" [[package]] name = "wattpad" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2661f9c5a05a40a378a809fc77076644d8cdd6839b468d8ed3f3cffa887870f4" +checksum = "9826298a0db9284b994af09fef8a564840af0e9e2621440aef46a5bd066481ed" dependencies = [ "anyhow", "regex", @@ -1893,6 +2057,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index d91599c..43ccd9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,11 +9,13 @@ edition = "2021" anyhow = "1.0.69" axum = { version = "0.6.10", features = ["macros"] } cached = "0.42.0" +chrono = "0.4.24" grass = { version = "0.12.3", features = ["macro"] } +lazy_static = "1.4.0" maud = { git = "https://github.com/lambda-fairy/maud", features = ["axum"] } regex = "1.7.1" serde = { version = "1.0.152", features = ["derive"] } tokio = { version = "1.26.0", features = ["full"] } tracing = "0.1.37" tracing-subscriber = "0.3.16" -wattpad = "0.2.3" +wattpad = "0.2.4" diff --git a/css/index.scss b/css/index.scss index b192d3d..45597c3 100644 --- a/css/index.scss +++ b/css/index.scss @@ -42,15 +42,24 @@ a:hover { display: flex; align-items: center; overflow-x: scroll; - gap: 0.125em; + padding-top: 0.5em; + padding-bottom: 0.5em; + gap: 0.5em; * { margin: 0; - padding: 0.5em; text-align: center; white-space: nowrap; } + :first-child { + margin-left: 0.5em; + } + + :last-child { + margin-right: 0.5em; + } + .header-separator { padding: 0 !important; } @@ -58,6 +67,7 @@ a:hover { .story-page-container { display: flex; + flex-direction: column; align-items: center; justify-content: center; margin-top: 0.5em; @@ -75,6 +85,30 @@ a:hover { flex-direction: column; } } + + .story-buttons { + display: flex; + align-items: center; + justify-content: center; + column-gap: 0.25em; + margin-top: 0.75em; + } +} + +.tag-container { + display: flex; + flex-wrap: wrap; + column-gap: 0.25em; +} + +.index-content { + margin-top: 0.5em; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-left: 2.5vw; + margin-right: 2.5vw; } .story-content { @@ -82,3 +116,9 @@ a:hover { margin-right: 2.5vw; overflow-wrap: anywhere; } + +.not-found-page { + margin-top: 0.5em; + display: flex; + justify-content: center; +} diff --git a/scripts/StoryContent.js b/scripts/StoryContent.js new file mode 100644 index 0000000..d329429 --- /dev/null +++ b/scripts/StoryContent.js @@ -0,0 +1,39 @@ +const oldOnloadHandler = window.onload ?? (() => {}); + +window.onload = () => { + oldOnloadHandler(); + + // Scroll header to current entry + document.getElementById("selected-header")?.scrollIntoView({ + block: "center", + inline: "center" + }); + + // Sync history + const params = new URL(document.location.href).searchParams; + if (params.get("chapter")) { + localStorage.setItem( + `${params.get("id")}_progress`, + params.get("chapter") + ); + } + + const readingHistory = JSON.parse( + localStorage.getItem("readingHistory") ?? "[]" + ); + + readingHistory.forEach((id, idx) => { + if (id === params.get("id")) { + readingHistory.splice(idx, 1); + } + }); + + readingHistory.unshift(params.get("id")); + + //TODO: do pagination on the history page to make this un-necessary + if (readingHistory.length > 50) { + readingHistory.pop(); + } + + localStorage.setItem("readingHistory", JSON.stringify(readingHistory)); +}; diff --git a/scripts/StoryDetails.js b/scripts/StoryDetails.js new file mode 100644 index 0000000..7cbc5b9 --- /dev/null +++ b/scripts/StoryDetails.js @@ -0,0 +1,58 @@ +const oldOnloadHandler = window.onload ?? (() => {}); + +window.onload = () => { + oldOnloadHandler(); + // Sync to position button + const position = localStorage.getItem( + new URL(document.URL).searchParams.get("id") + "_progress" + ); + + if (position) { + const buttonsList = document.getElementById("detail-buttons"); + + const buttonEle = document.createElement("button"); + buttonEle.className = "favorite-story-button"; + buttonEle.textContent = "Sync to story position"; + buttonEle.onclick = () => { + const currURL = new URL(document.location.href); + currURL.searchParams.set("chapter", position); + window.location.href = currURL.toString(); + }; + buttonsList?.appendChild(buttonEle); + } + + // Favorite button + const button = document.getElementById("favorite"); + + if (!button) throw "how did your button not init what"; + + let savedStories = JSON.parse(localStorage.getItem("savedStories") ?? "[]"); + + function handler() { + if (button?.textContent === "Save Story") { + savedStories.push({ + id: new URL(document.URL).searchParams.get("id"), + name: document.getElementById("storyname")?.textContent, + coverURL: document.getElementById("storycover").src + }); + button.textContent = "Unsave Story"; + } else if (button?.textContent === "Unsave Story") { + savedStories = savedStories.filter( + (story) => + story.id !== new URL(document.URL).searchParams.get("id") + ); + button.textContent = "Save Story"; + } + localStorage.setItem("savedStories", JSON.stringify(savedStories)); + } + + if ( + savedStories.filter( + (story) => story.id === new URL(document.URL).searchParams.get("id") + ).length > 0 + ) { + button.textContent = "Unsave Story"; + } + + button.onclick = handler; +}; diff --git a/src/cached_wattpad.rs b/src/cached_wattpad.rs new file mode 100644 index 0000000..9e650cb --- /dev/null +++ b/src/cached_wattpad.rs @@ -0,0 +1,33 @@ +use crate::WATTPAD; +use anyhow::{Context, Result}; +use chrono::{DateTime, Local}; +use lazy_static::lazy_static; +use std::{collections::HashMap, sync::Mutex}; +use wattpad::{Part, Story}; + +lazy_static! { + static ref STORY_CACHE: Mutex)>> = + Mutex::new(HashMap::new()); +} + +pub async fn get_story(id: &str) -> Result { + let current_time = chrono::Local::now(); + if let Some(cache_item) = STORY_CACHE + .lock() + .expect("Failed to lock StoryCache hashmap (somehow)") + .get(id) + { + let time_diff = current_time.signed_duration_since(cache_item.1); + if time_diff.num_minutes() <= 5 { + tracing::debug!("Cache hit for story {}", id); + return Ok(cache_item.0.clone()); + } + } + tracing::debug!("Caching story {}", id); + let story = WATTPAD.get_story(id).await.context("Failed to get story")?; + STORY_CACHE + .lock() + .expect("Failed to lock StoryCache hashmap (somehow)") + .insert(id.to_string(), (story.clone(), current_time)); + Ok(story) +} diff --git a/src/components/header.rs b/src/components/header.rs index 26a1c8f..1b62bb9 100644 --- a/src/components/header.rs +++ b/src/components/header.rs @@ -18,7 +18,7 @@ impl Render for Header { div .header-separator { (prefix) } } @if header_part.path == self.1 { - div .selected-header { (header_part.name) } + div .selected-header #selected-header { (header_part.name) } } @else { a .unselected-header href=(header_part.path) { (header_part.name) } } diff --git a/src/main.rs b/src/main.rs index a94897e..3e674e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,33 +1,37 @@ +mod cached_wattpad; mod components; mod routes; -use routes::{index, story}; +use routes::{index, not_found, story}; -use axum::{routing::get, Router}; -use std::net::SocketAddr; +use axum::{extract::Extension, routing::get, Router}; +use lazy_static::lazy_static; +use maud::{html, Markup}; +use std::{net::SocketAddr, sync::Arc}; +use tokio::runtime::Runtime; use wattpad::Wattpad; -#[derive(Clone)] -pub struct State { - pub wattpad: Wattpad, +lazy_static! { + pub static ref TOKIO: Runtime = Runtime::new().unwrap(); + pub static ref WATTPAD: Wattpad = + TOKIO.block_on(async { Wattpad::new().await.expect("Failed to initialize Wattpad!") }); } -#[tokio::main] -async fn main() { +fn main() { tracing_subscriber::fmt::init(); + lazy_static::initialize(&WATTPAD); - let state = State { - wattpad: Wattpad::new().await.unwrap(), - }; let addr: SocketAddr = "127.0.0.1:3000".parse().unwrap(); let app = Router::new() .route("/", get(index::render)) .route("/story", get(story::render)) - .with_state(state); + .fallback(not_found::render); tracing::info!("Listening on {}", addr); - let _ = axum::Server::bind(&addr) - .serve(app.into_make_service()) - .await; + TOKIO.block_on(async { + let _ = axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await; + }) } diff --git a/src/routes/index.rs b/src/routes/index.rs index bc6878c..578ffb3 100644 --- a/src/routes/index.rs +++ b/src/routes/index.rs @@ -1,8 +1,14 @@ use crate::components::header::{Header, HeaderLink}; -use maud::{html, Markup}; +use cached::proc_macro::cached; +use maud::{html, Markup, PreEscaped}; +#[cached] pub async fn render() -> Markup { html! { + meta name="viewport" content="width=device-width, initial-scale=1.0"; + style {(PreEscaped( + grass::include!("./css/index.scss") + ))} ( Header(vec![ HeaderLink { @@ -10,9 +16,30 @@ pub async fn render() -> Markup { path: "/".to_string(), prefix: None, postfix: None - } + }, + HeaderLink { + name: "Search".to_string(), + path: "/search".to_string(), + prefix: None, + postfix: None + }, + HeaderLink { + name: "History".to_string(), + path: "/history".to_string(), + prefix: None, + postfix: None + }, + HeaderLink { + name: "About".to_string(), + path: "/about".to_string(), + prefix: None, + postfix: None + }, ], "/".to_string()) ) - h1 { "Hello, world!" } + div .index-content { + h1 { "Voltpad" } + div { "A blazingly fast (but for real this time) Wattpad frontend" } + } } } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index c018439..3ef05d6 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,2 +1,3 @@ pub mod index; +pub mod not_found; pub mod story; diff --git a/src/routes/not_found.rs b/src/routes/not_found.rs new file mode 100644 index 0000000..aeb528b --- /dev/null +++ b/src/routes/not_found.rs @@ -0,0 +1,48 @@ +use crate::components::header::{Header, HeaderLink}; +use axum::http::StatusCode; +use cached::proc_macro::cached; +use maud::{html, Markup, PreEscaped}; + +#[cached] +pub async fn render() -> (StatusCode, Markup) { + ( + StatusCode::NOT_FOUND, + html! { + meta name="viewport" content="width=device-width, initial-scale=1.0"; + style {(PreEscaped( + grass::include!("./css/index.scss") + ))} + ( + Header(vec![ + HeaderLink { + name: "Home".to_string(), + path: "/".to_string(), + prefix: None, + postfix: None + }, + HeaderLink { + name: "Search".to_string(), + path: "/search".to_string(), + prefix: None, + postfix: None + }, + HeaderLink { + name: "History".to_string(), + path: "/history".to_string(), + prefix: None, + postfix: None + }, + HeaderLink { + name: "About".to_string(), + path: "/about".to_string(), + prefix: None, + postfix: None + }, + ], "/404".to_string()) + ) + div .not-found-page { + img src="https://media.ruthenic.com/cat404.png"; + } + }, + ) +} diff --git a/src/routes/story.rs b/src/routes/story.rs index 700a08a..a19f3c3 100644 --- a/src/routes/story.rs +++ b/src/routes/story.rs @@ -1,6 +1,9 @@ +use std::sync::Arc; + +use crate::cached_wattpad::*; use crate::components::header::{Header, HeaderLink}; -use crate::State; -use axum::extract::{Query, State as AxumState}; +use crate::WATTPAD; +use axum::extract::{Extension, Query, State as AxumState}; use maud::{html, Markup, PreEscaped, DOCTYPE}; use regex::Regex; use serde::Deserialize; @@ -11,16 +14,14 @@ pub struct StoryParams { chapter: Option, } -pub async fn render( - AxumState(state): AxumState, - Query(params): Query, -) -> Markup { +pub async fn render(Query(params): Query) -> Markup { if let Some(id) = params.id { - let story = state.wattpad.get_story(id.as_str()).await; + //let story = state.wattpad.get_story(id.as_str()).await; + let story = get_story(id.as_str()).await; if let Ok(story) = story { if let Some(part_idx) = params.chapter { - let parts = story.get_parts().await.unwrap(); - if let Some(part) = parts.get(part_idx) { + let part = story.get_part(part_idx).await; + if let Ok(part) = part { let style_matching_regex = Regex::new("style=\"(.+?)\"").unwrap(); html! { (DOCTYPE) @@ -30,11 +31,14 @@ pub async fn render( style {(PreEscaped( grass::include!("./css/index.scss") ))} + script {(PreEscaped( + include_str!("../../scripts/StoryContent.js") + ))} } body { (Header({ - let mut story_parts: Vec = parts.iter().enumerate().map(|(idx, part)| HeaderLink { path: format!("/story?id={}&chapter={}", story.id, idx), name: part.title.clone(), prefix: None, postfix: None }).collect(); - story_parts.insert(0, HeaderLink { path: format!("/story?id={}", story.id), name: "Details".to_string(), prefix: Some("|".to_string()), postfix: Some("|".to_string()) }); + let mut story_parts: Vec = story._parts.iter().enumerate().map(|(idx, part)| HeaderLink { path: format!("/story?id={}&chapter={}", story.id, idx), name: part.title.clone(), prefix: None, postfix: None }).collect(); + story_parts.insert(0, HeaderLink { path: format!("/story?id={}", story.id), name: "Details".to_string(), prefix: None, postfix: Some("|".to_string()) }); story_parts.insert(0, HeaderLink { path: "/".to_string(), name: "Homepage".to_string(), prefix: None, postfix: None }); story_parts }, format!("/story?id={}&chapter={}", story.id, part_idx))) @@ -59,7 +63,6 @@ pub async fn render( } } } else { - let parts = story.get_parts().await.unwrap(); html! { (DOCTYPE) html { @@ -68,19 +71,22 @@ pub async fn render( style {(PreEscaped( grass::include!("./css/index.scss") ))} + script {(PreEscaped( + include_str!("../../scripts/StoryDetails.js") + ))} } body { (Header({ - let mut story_parts: Vec = parts.iter().enumerate().map(|(idx, part)| HeaderLink { path: format!("/story?id={}&chapter={}", story.id, idx), name: part.title.clone(), prefix: None, postfix: None }).collect(); - story_parts.insert(0, HeaderLink { path: format!("/story?id={}", story.id), name: "Details".to_string(), prefix: Some("|".to_string()), postfix: Some("|".to_string()) }); + let mut story_parts: Vec = story._parts.iter().enumerate().map(|(idx, part)| HeaderLink { path: format!("/story?id={}&chapter={}", story.id, idx), name: part.title.clone(), prefix: None, postfix: None }).collect(); + story_parts.insert(0, HeaderLink { path: format!("/story?id={}", story.id), name: "Details".to_string(), prefix: None, postfix: Some("|".to_string()) }); story_parts.insert(0, HeaderLink { path: "/".to_string(), name: "Homepage".to_string(), prefix: None, postfix: None }); story_parts }, format!("/story?id={}", story.id))) div .story-page-container { div .story-page { - img .story-cover src=(story.cover); + img .story-cover #storycover src=(story.cover); div .story-details { - h3 #story-name { + h3 #storyname { (story.title) } p { @@ -98,12 +104,15 @@ pub async fn render( div .tag-container { @for tag in story.tags { div .tag { - a href=(format!("/search?query={}&type=tag", tag)) { (tag) } + a href=(format!("/search?query={}&type=tag", tag)) { "#"(tag) } } } } } } + div .story-buttons #detail-buttons { + button #favorite { "Save Story" } + } } } }