upgrading hackernews example

This commit is contained in:
Greg Johnston 2024-05-19 19:58:03 -04:00
parent 32294d6cab
commit 4ffa3c46b6
8 changed files with 170 additions and 135 deletions

View File

@ -8,6 +8,8 @@ crate-type = ["cdylib", "rlib"]
[profile.release]
codegen-units = 1
opt-level = "z"
panic = "abort"
lto = true
[dependencies]
@ -15,7 +17,7 @@ actix-files = { version = "0.6", optional = true }
actix-web = { version = "4", optional = true, features = ["macros"] }
console_log = "1"
console_error_panic_hook = "0.1"
leptos = { path = "../../leptos" }
leptos = { path = "../../leptos", features = ["tracing"] }
leptos_meta = { path = "../../meta" }
leptos_actix = { path = "../../integrations/actix", optional = true }
leptos_router = { path = "../../router" }
@ -27,18 +29,15 @@ tracing = "0.1"
# openssl = { version = "0.10", features = ["v110"] }
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
send_wrapper = "0.6.0"
tracing-subscriber = "0.3"
tracing-subscriber-wasm = "0.1"
[features]
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
ssr = [
"dep:actix-files",
"dep:actix-web",
"dep:leptos_actix",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
]
csr = ["leptos/csr"]
hydrate = ["leptos/hydrate"]
ssr = ["dep:actix-files", "dep:actix-web", "dep:leptos_actix", "leptos/ssr"]
[profile.wasm-release]
inherits = "release"

View File

@ -1,4 +1,4 @@
use leptos::Serializable;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub fn story(path: &str) -> String {
@ -10,46 +10,52 @@ pub fn user(path: &str) -> String {
}
#[cfg(not(feature = "ssr"))]
pub async fn fetch_api<T>(path: &str) -> Option<T>
pub fn fetch_api<T>(
path: &str,
) -> impl std::future::Future<Output = Option<T>> + Send + '_
where
T: Serializable,
T: Serialize + DeserializeOwned,
{
let abort_controller = web_sys::AbortController::new().ok();
let abort_signal = abort_controller.as_ref().map(|a| a.signal());
use send_wrapper::SendWrapper;
// abort in-flight requests if, e.g., we've navigated away from this page
leptos::on_cleanup(move || {
if let Some(abort_controller) = abort_controller {
abort_controller.abort()
}
});
SendWrapper::new(async move {
use leptos::reactive_graph::owner::Owner;
let json = gloo_net::http::Request::get(path)
.abort_signal(abort_signal.as_ref())
.send()
.await
.map_err(|e| log::error!("{e}"))
.ok()?
.text()
.await
.ok()?;
let abort_controller =
SendWrapper::new(web_sys::AbortController::new().ok());
let abort_signal = abort_controller.as_ref().map(|a| a.signal());
T::de(&json).ok()
// abort in-flight requests if, e.g., we've navigated away from this page
Owner::on_cleanup(move || {
if let Some(abort_controller) = abort_controller.take() {
abort_controller.abort()
}
});
gloo_net::http::Request::get(path)
.abort_signal(abort_signal.as_ref())
.send()
.await
.map_err(|e| log::error!("{e}"))
.ok()?
.json()
.await
.ok()
})
}
#[cfg(feature = "ssr")]
pub async fn fetch_api<T>(path: &str) -> Option<T>
where
T: Serializable,
T: Serialize + DeserializeOwned,
{
let json = reqwest::get(path)
reqwest::get(path)
.await
.map_err(|e| log::error!("{e}"))
.ok()?
.text()
.json()
.await
.ok()?;
T::de(&json).map_err(|e| log::error!("{e}")).ok()
.ok()
}
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]

View File

@ -1,8 +1,11 @@
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
use leptos::prelude::*;
mod api;
mod routes;
use leptos_meta::{provide_meta_context, Link, Meta, Stylesheet};
use leptos_router::{
components::{FlatRoutes, Route, Router},
ParamSegment, StaticSegment,
};
use routes::{nav::*, stories::*, story::*, users::*};
#[component]
@ -15,18 +18,19 @@ pub fn App() -> impl IntoView {
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Meta name="description" content="Leptos implementation of a HackerNews demo."/>
// adding `set_is_routing` causes the router to wait for async data to load on new pages
<Router set_is_routing>
<Router> // TODO set_is_routing>
// shows a progress bar while async data are loading
<div class="routing-progress">
// TODO
/*<div class="routing-progress">
<RoutingProgress is_routing max_time=std::time::Duration::from_millis(250)/>
</div>
</div>*/
<Nav />
<main>
<Routes>
<Route path="users/:id" view=User/>
<Route path="stories/:id" view=Story/>
<Route path=":stories?" view=Stories/>
</Routes>
<FlatRoutes fallback=|| "Not found.">
<Route path=(StaticSegment("users"), ParamSegment("id")) view=User/>
<Route path=(StaticSegment("stories"), ParamSegment("id")) view=Story/>
<Route path=ParamSegment("stories") view=Stories/>
</FlatRoutes>
</main>
</Router>
}

View File

@ -50,7 +50,20 @@ async fn main() -> std::io::Result<()> {
fn main() {
use hackernews::App;
_ = console_log::init_with_level(log::Level::Debug);
use tracing_subscriber::fmt;
use tracing_subscriber_wasm::MakeConsoleWriter;
fmt()
.with_writer(
// To avoide trace events in the browser from showing their
// JS backtrace, which is very annoying, in my opinion
MakeConsoleWriter::default()
.map_trace_level_to(tracing::Level::DEBUG),
)
// For some reason, if we don't do this in the browser, we get
// a runtime error.
.without_time()
.init();
console_error_panic_hook::set_once();
leptos::mount_to_body(App)
leptos::mount::mount_to_body(App)
}

View File

@ -1,5 +1,5 @@
use leptos::{component, view, IntoView};
use leptos_router::*;
use leptos::prelude::*;
use leptos_router::components::A;
#[component]
pub fn Nav() -> impl IntoView {

View File

@ -1,6 +1,9 @@
use crate::api;
use leptos::*;
use leptos_router::*;
use leptos::{either::Either, prelude::*};
use leptos_router::{
components::A,
hooks::{use_params_map, use_query_map},
};
fn category(from: &str) -> &'static str {
match from {
@ -18,15 +21,19 @@ pub fn Stories() -> impl IntoView {
let params = use_params_map();
let page = move || {
query
.with(|q| q.get("page").and_then(|page| page.parse::<usize>().ok()))
.read()
.get("page")
.and_then(|page| page.parse::<usize>().ok())
.unwrap_or(1)
};
let story_type = move || {
params
.with(|p| p.get("stories").cloned())
.read()
.get("stories")
.map(ToOwned::to_owned)
.unwrap_or_else(|| "top".to_string())
};
let stories = create_resource(
let stories = Resource::new_serde(
move || (page(), story_type()),
move |(page, story_type)| async move {
let path = format!("{}?page={}", category(&story_type), page);
@ -36,8 +43,9 @@ pub fn Stories() -> impl IntoView {
let (pending, set_pending) = create_signal(false);
let hide_more_link = move || {
stories.get().unwrap_or(None).unwrap_or_default().len() < 28
|| pending.get()
Suspend(async move {
stories.await.unwrap_or_default().len() < 28 || pending.get()
})
};
view! {
@ -45,27 +53,28 @@ pub fn Stories() -> impl IntoView {
<div class="news-list-nav">
<span>
{move || if page() > 1 {
view! {
Either::Left(view! {
<a class="page-link"
href=move || format!("/{}?page={}", story_type(), page() - 1)
attr:aria_label="Previous Page"
aria-label="Previous Page"
>
"< prev"
</a>
}.into_any()
})
} else {
view! {
Either::Right(view! {
<span class="page-link disabled" aria-hidden="true">
"< prev"
</span>
}.into_any()
})
}}
</span>
<span>"page " {page}</span>
<Suspense>
<span class="page-link"
class:disabled=hide_more_link
aria-hidden=hide_more_link
// TODO support Suspense in attributes
/*class:disabled=Suspend(hide_more_link)
aria-hidden=Suspend(hide_more_link)*/
>
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
aria-label="Next Page"
@ -79,25 +88,19 @@ pub fn Stories() -> impl IntoView {
<div>
<Transition
fallback=move || view! { <p>"Loading..."</p> }
set_pending
// TODO set_pending on Transition
//set_pending
>
{move || match stories.get() {
None => None,
Some(None) => Some(view! { <p>"Error loading stories."</p> }.into_any()),
Some(Some(stories)) => {
Some(view! {
{move || Suspend(async move { match stories.await {
None => Either::Left(view! { <p>"Error loading stories."</p> }),
Some(stories) => {
Either::Right(view! {
<ul>
<For
each=move || stories.clone()
key=|story| story.id
let:story
>
<Story story/>
</For>
{stories.into_iter().map(|story| view! { <Story story/> }).collect::<Vec<_>>()}
</ul>
}.into_any())
})
}
}}
}})}
</Transition>
</div>
</main>
@ -112,23 +115,23 @@ fn Story(story: api::Story) -> impl IntoView {
<span class="score">{story.points}</span>
<span class="title">
{if !story.url.starts_with("item?id=") {
view! {
Either::Left(view! {
<span>
<a href=story.url target="_blank" rel="noreferrer">
{story.title.clone()}
</a>
<span class="host">"("{story.domain}")"</span>
</span>
}.into_view()
})
} else {
let title = story.title.clone();
view! { <A href=format!("/stories/{}", story.id)>{title.clone()}</A> }.into_view()
Either::Right(view! { <A href=format!("/stories/{}", story.id)>{title}</A> })
}}
</span>
<br />
<span class="meta">
{if story.story_type != "job" {
view! {
Either::Left(view! {
<span>
{"by "}
{story.user.map(|user| view ! { <A href=format!("/users/{user}")>{user.clone()}</A>})}
@ -141,10 +144,10 @@ fn Story(story: api::Story) -> impl IntoView {
}}
</A>
</span>
}.into_view()
})
} else {
let title = story.title.clone();
view! { <A href=format!("/item/{}", story.id)>{title.clone()}</A> }.into_view()
Either::Right(view! { <A href=format!("/item/{}", story.id)>{title}</A> })
}}
</span>
{(story.story_type != "link").then(|| view! {

View File

@ -1,13 +1,21 @@
use crate::api;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
use leptos::either::Either;
use leptos::prelude::*;
use leptos_meta::Meta;
use leptos_router::components::A;
use leptos_router::hooks::use_params_map;
#[component]
pub fn Story() -> impl IntoView {
let params = use_params_map();
let story = create_resource(
move || params.get().get("id").cloned().unwrap_or_default(),
let story = Resource::new_serde(
move || {
params
.get()
.get("id")
.map(ToOwned::to_owned)
.unwrap_or_default()
},
move |id| async move {
if id.is_empty() {
None
@ -17,19 +25,13 @@ pub fn Story() -> impl IntoView {
}
},
);
let meta_description = move || {
story
.get()
.and_then(|story| story.map(|story| story.title))
.unwrap_or_else(|| "Loading story...".to_string())
};
view! {
<Suspense fallback=|| view! { "Loading..." }>
<Meta name="description" content=meta_description/>
{move || story.get().map(|story| match story {
None => view! { <div class="item-view">"Error loading this story."</div> },
Some(story) => view! {
Suspense(SuspenseProps::builder().fallback(|| "Loading...").children(ToChildren::to_children(move || Suspend(async move {
match story.await {
None => Either::Left("Story not found."),
Some(story) => {
Either::Right(view! {
<Meta name="description" content=story.title.clone()/>
<div class="item-view">
<div class="item-view-header">
<a href=story.url target="_blank">
@ -46,27 +48,28 @@ pub fn Story() -> impl IntoView {
</p>})}
</div>
<div class="item-view-comments">
<p class="item-view-comments-header">
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
let:comment
>
<Comment comment />
</For>
</ul>
<p class="item-view-comments-header">
{if story.comments_count.unwrap_or_default() > 0 {
format!("{} comments", story.comments_count.unwrap_or_default())
} else {
"No comments yet.".into()
}}
</p>
<ul class="comment-children">
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
let:comment
>
<Comment comment />
</For>
</ul>
</div>
</div>
</div>
}})}
</Suspense>
}
})
}
}
}))).build())
}
#[component]
@ -113,7 +116,7 @@ pub fn Comment(comment: api::Comment) -> impl IntoView {
}
})}
</li>
}
}.into_any()
}
fn pluralize(n: usize) -> &'static str {

View File

@ -1,12 +1,19 @@
use crate::api::{self, User};
use leptos::*;
use leptos_router::*;
use leptos::server::Resource;
use leptos::{either::Either, prelude::*};
use leptos_router::{hooks::use_params_map, *};
#[component]
pub fn User() -> impl IntoView {
let params = use_params_map();
let user = create_resource(
move || params.get().get("id").cloned().unwrap_or_default(),
let user = Resource::new_serde(
move || {
params
.read()
.get("id")
.map(ToOwned::to_owned)
.unwrap_or_default()
},
move |id| async move {
if id.is_empty() {
None
@ -18,11 +25,11 @@ pub fn User() -> impl IntoView {
view! {
<div class="user-view">
<Suspense fallback=|| view! { "Loading..." }>
{move || user.get().map(|user| match user {
None => view! { <h1>"User not found."</h1> }.into_view(),
Some(user) => view! {
{move || Suspend(async move { match user.await {
None => Either::Left(view! { <h1>"User not found."</h1> }),
Some(user) => Either::Right(view! {
<div>
<h1>"User: " {&user.id}</h1>
<h1>"User: " {user.id.clone()}</h1>
<ul class="meta">
<li>
<span class="label">"Created: "</span> {user.created}
@ -38,8 +45,8 @@ pub fn User() -> impl IntoView {
<a href=format!("https://news.ycombinator.com/threads?id={}", user.id)>"comments"</a>
</p>
</div>
}.into_view()
})}
})
}})}
</Suspense>
</div>
}