hackernews islands example

This commit is contained in:
Greg Johnston 2024-06-23 17:55:16 -04:00
parent ae14644806
commit 5b7f5e3db3
9 changed files with 190 additions and 255 deletions

View file

@ -11,16 +11,11 @@ codegen-units = 1
lto = true lto = true
[dependencies] [dependencies]
console_log = "1.0"
console_error_panic_hook = "0.1" console_error_panic_hook = "0.1"
leptos = { path = "../../leptos", features = ["experimental-islands"] } leptos = { path = "../../leptos", features = ["experimental-islands"] }
leptos_axum = { path = "../../integrations/axum", optional = true, features = [ leptos_axum = { path = "../../integrations/axum", optional = true }
"experimental-islands",
] }
leptos_meta = { path = "../../meta" } leptos_meta = { path = "../../meta" }
leptos_router = { path = "../../router" } leptos_router = { path = "../../router" }
log = "0.4"
simple_logger = "4.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
tracing = "0.1" tracing = "0.1"
gloo-net = { version = "0.6", features = ["http"] } gloo-net = { version = "0.6", features = ["http"] }
@ -46,8 +41,8 @@ mime_guess = { version = "2.0.4", optional = true }
[features] [features]
default = [] default = []
csr = ["leptos/csr", "leptos_meta/csr", "leptos_router/csr"] csr = ["leptos/csr"]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] hydrate = ["leptos/hydrate"]
ssr = [ ssr = [
"dep:axum", "dep:axum",
"dep:tower", "dep:tower",

View file

@ -1,27 +1,30 @@
#![allow(unused)] #[cfg(feature = "ssr")]
use serde::de::DeserializeOwned;
use leptos::Serializable;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(feature = "ssr")]
pub fn story(path: &str) -> String { pub fn story(path: &str) -> String {
format!("https://node-hnapi.herokuapp.com/{path}") format!("https://node-hnapi.herokuapp.com/{path}")
} }
#[cfg(feature = "ssr")]
pub fn user(path: &str) -> String { pub fn user(path: &str) -> String {
format!("https://hacker-news.firebaseio.com/v0/user/{path}.json") format!("https://hacker-news.firebaseio.com/v0/user/{path}.json")
} }
lazy_static::lazy_static! {
static ref CLIENT: reqwest::Client = reqwest::Client::new();
}
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
pub async fn fetch_api<T>(path: &str) -> Option<T> pub async fn fetch_api<T>(path: &str) -> Option<T>
where where
T: Serializable, T: Serialize + DeserializeOwned,
{ {
let json = CLIENT.get(path).send().await.ok()?.text().await.ok()?; use leptos::logging;
T::de(&json).map_err(|e| log::error!("{e}")).ok() reqwest::get(path)
.await
.map_err(|e| logging::error!("{e}"))
.ok()?
.json()
.await
.ok()
} }
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)] #[derive(Debug, Deserialize, Serialize, PartialEq, Eq, Clone)]

View file

@ -1,28 +0,0 @@
use leptos::{view, Errors, For, IntoView, RwSignal, SignalGet, View};
// A basic function to display errors served by the error boundaries. Feel free to do more complicated things
// here than just displaying them
pub fn error_template(errors: Option<RwSignal<Errors>>) -> View {
let Some(errors) = errors else {
panic!("No Errors found and we expected errors!");
};
view! {
<h1>"Errors"</h1>
<For
// a function that returns the items we're iterating over; a signal is fine
each=move || errors.get()
// a unique key for each item as a reference
key=|(key, _)| key.clone()
// renders each item to a view
children= move | (_, error)| {
let error_string = error.to_string();
view! {
<p>"Error: " {error_string}</p>
}
}
/>
}
.into_view()
}

View file

@ -1,13 +1,31 @@
use leptos::prelude::*; use leptos::prelude::*;
use leptos_meta::*;
use leptos_router::*;
mod api; mod api;
pub mod error_template;
#[cfg(feature = "ssr")]
pub mod fallback;
mod routes; mod routes;
use leptos_meta::{provide_meta_context, Link, Meta, MetaTags, Stylesheet};
use leptos_router::{
components::{FlatRoutes, Route, Router},
ParamSegment, StaticSegment,
};
use routes::{nav::*, stories::*, story::*, users::*}; use routes::{nav::*, stories::*, story::*, users::*};
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<AutoReload options=options.clone() />
<HydrationScripts options islands=true/>
<MetaTags/>
</head>
<body>
<App/>
</body>
</html>
}
}
#[component] #[component]
pub fn App() -> impl IntoView { pub fn App() -> impl IntoView {
provide_meta_context(); provide_meta_context();
@ -16,66 +34,24 @@ pub fn App() -> impl IntoView {
<Stylesheet id="leptos" href="/pkg/hackernews.css"/> <Stylesheet id="leptos" href="/pkg/hackernews.css"/>
<Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/> <Link rel="shortcut icon" type_="image/ico" href="/favicon.ico"/>
<Meta name="description" content="Leptos implementation of a HackerNews demo."/> <Meta name="description" content="Leptos implementation of a HackerNews demo."/>
<Example/>
<Router> <Router>
<Nav /> <Nav />
<main> <main>
<Routes> <FlatRoutes fallback=|| "Not found.">
<Route path="users/:id" view=User ssr=SsrMode::InOrder/> <Route path=(StaticSegment("users"), ParamSegment("id")) view=User/>
<Route path="stories/:id" view=Story ssr=SsrMode::InOrder/> <Route path=(StaticSegment("stories"), ParamSegment("id")) view=Story/>
<Route path=":stories?" view=Stories ssr=SsrMode::InOrder/> <Route path=ParamSegment("stories") view=Stories/>
</Routes> // TODO allow optional params without duplication
<Route path=StaticSegment("") view=Stories/>
</FlatRoutes>
</main> </main>
</Router> </Router>
} }
} }
use leptos::prelude::*;
#[island]
pub fn CommonIsland() -> impl IntoView {
let val = RwSignal::new(0);
view! {
<div>
{move || format!("CommonIsland value is {}", val.get())}
<button on:click=move|_| val.update(|x| {*x += 1})>Click</button>
</div>
}
}
#[island]
pub fn OuterWorking(children: Children) -> impl IntoView {
let val = RwSignal::new(0);
view! {
<>
<div>
{move || format!("outer value is {}", val.get())}
<button on:click=move|_| val.update(|x| {*x += 1})>Click</button>
</div>
{children()}
</>
}
}
#[component]
pub fn Example() -> impl IntoView {
view! {
<OuterFailing/>
<OuterWorking>
<CommonIsland/>
</OuterWorking>
<CommonIsland/>
}
}
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen] #[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() { pub fn hydrate() {
#[cfg(debug_assertions)]
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
leptos::leptos_dom::HydrationCtx::stop_hydrating(); leptos::mount::hydrate_islands();
} }

View file

@ -1,10 +1,9 @@
#[cfg(feature = "ssr")] #[cfg(feature = "ssr")]
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
pub use axum::{routing::get, Router}; pub use axum::Router;
pub use hackernews_islands::fallback::file_and_error_handler;
use hackernews_islands::*; use hackernews_islands::*;
pub use leptos::get_configuration; pub use leptos::config::get_configuration;
pub use leptos_axum::{generate_route_list, LeptosRoutes}; pub use leptos_axum::{generate_route_list, LeptosRoutes};
use tower_http::compression::{ use tower_http::compression::{
predicate::{NotForContentType, SizeAbove}, predicate::{NotForContentType, SizeAbove},
@ -50,7 +49,6 @@ async fn main() {
pub fn main() { pub fn main() {
use hackernews_islands::*; use hackernews_islands::*;
use leptos::prelude::*; use leptos::prelude::*;
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once(); console_error_panic_hook::set_once();
mount_to_body(App); leptos::mount::mount_to_body(App);
} }

View file

@ -1,5 +1,5 @@
use leptos::{component, view, IntoView}; use leptos::prelude::*;
use leptos_router::*; use leptos_router::components::A;
#[component] #[component]
pub fn Nav() -> impl IntoView { pub fn Nav() -> impl IntoView {
@ -21,7 +21,7 @@ pub fn Nav() -> impl IntoView {
<A href="/job"> <A href="/job">
<strong>"Jobs"</strong> <strong>"Jobs"</strong>
</A> </A>
<a class="github" href="http://github.com/gbj/leptos" target="_blank" rel="noreferrer"> <a class="github" href="http://github.com/leptos-rs/leptos" target="_blank" rel="noreferrer">
"Built with Leptos" "Built with Leptos"
</a> </a>
</nav> </nav>

View file

@ -1,6 +1,9 @@
use crate::api; use crate::api;
use leptos::prelude::*; use leptos::{either::Either, prelude::*};
use leptos_router::*; use leptos_router::{
components::A,
hooks::{use_params_map, use_query_map},
};
fn category(from: &str) -> String { fn category(from: &str) -> String {
match from { match from {
@ -13,7 +16,7 @@ fn category(from: &str) -> String {
.to_string() .to_string()
} }
#[server(FetchStories, "/api")] #[server]
pub async fn fetch_stories( pub async fn fetch_stories(
story_type: String, story_type: String,
page: usize, page: usize,
@ -30,80 +33,84 @@ pub fn Stories() -> impl IntoView {
let params = use_params_map(); let params = use_params_map();
let page = move || { let page = move || {
query 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) .unwrap_or(1)
}; };
let story_type = move || { let story_type = move || {
params params
.with(|p| p.get("stories").cloned()) .read()
.get("stories")
.unwrap_or_else(|| "top".to_string()) .unwrap_or_else(|| "top".to_string())
}; };
let stories = create_resource( let stories = Resource::new(
move || (page(), story_type()), move || (page(), story_type()),
move |(page, story_type)| fetch_stories(category(&story_type), page), move |(page, story_type)| async move {
fetch_stories(category(&story_type), page).await.ok()
},
); );
let (pending, set_pending) = create_signal(false); let (pending, set_pending) = signal(false);
let hide_more_link = move || { let hide_more_link = move || match &*stories.read() {
pending.get() Some(Some(stories)) => stories.len() < 28,
|| stories _ => true
.map(|stories| { } || pending.get();
stories.as_ref().map(|s| s.len() < 28).unwrap_or_default()
})
.unwrap_or_default()
};
view! { view! {
<div class="news-view"> <div class="news-view">
<div class="news-list-nav"> <div class="news-list-nav">
<span> <span>
{move || if page() > 1 { {move || if page() > 1 {
view! { Either::Left(view! {
<a class="page-link" <a class="page-link"
href=move || format!("/{}?page={}", story_type(), page() - 1) href=move || format!("/{}?page={}", story_type(), page() - 1)
attr:aria_label="Previous Page" aria-label="Previous Page"
> >
"< prev" "< prev"
</a> </a>
}.into_any() })
} else { } else {
view! { Either::Right(view! {
<span class="page-link disabled" aria-hidden="true"> <span class="page-link disabled" aria-hidden="true">
"< prev" "< prev"
</span> </span>
}.into_any() })
}} }}
</span> </span>
<span>"page " {page}</span> <span>"page " {page}</span>
<span class="page-link" <Suspense>
class:disabled=hide_more_link <span class="page-link"
aria-hidden=hide_more_link class:disabled=hide_more_link
> aria-hidden=hide_more_link
<a href=move || format!("/{}?page={}", story_type(), page() + 1)
aria-label="Next Page"
> >
"more >" <a href=move || format!("/{}?page={}", story_type(), page() + 1)
</a> aria-label="Next Page"
</span> >
"more >"
</a>
</span>
</Suspense>
</div> </div>
<main class="news-list"> <main class="news-list">
<div> <div>
<Transition <Transition
fallback=|| () fallback=move || view! { <p>"Loading..."</p> }
set_pending set_pending
> >
{move || stories.get().map(|story| story.map(|stories| view! { <Show when=move || stories.read().as_ref().map(Option::is_none).unwrap_or(false)>
<ul> >
<For <p>"Error loading stories."</p>
each=move || stories.clone() </Show>
key=|story| story.id <ul>
let:story <For
> each=move || stories.get().unwrap_or_default().unwrap_or_default()
<Story story/> key=|story| story.id
</For> let:story
</ul> >
}))} <Story story/>
</For>
</ul>
</Transition> </Transition>
</div> </div>
</main> </main>
@ -118,26 +125,26 @@ fn Story(story: api::Story) -> impl IntoView {
<span class="score">{story.points}</span> <span class="score">{story.points}</span>
<span class="title"> <span class="title">
{if !story.url.starts_with("item?id=") { {if !story.url.starts_with("item?id=") {
view! { Either::Left(view! {
<span> <span>
<a href=story.url target="_blank" rel="noreferrer"> <a href=story.url target="_blank" rel="noreferrer">
{story.title.clone()} {story.title.clone()}
</a> </a>
<span class="host">"("{story.domain}")"</span> <span class="host">"("{story.domain}")"</span>
</span> </span>
}.into_view() })
} else { } else {
let title = story.title.clone(); 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> </span>
<br /> <br />
<span class="meta"> <span class="meta">
{if story.story_type != "job" { {if story.story_type != "job" {
view! { Either::Left(view! {
<span> <span>
{"by "} {"by "}
{story.user.map(|user| view ! { <A href=format!("/users/{user}")>{user.clone()}</A>})} {story.user.map(|user| view ! { <A href=format!("/users/{user}")>{user.clone()}</A>})}
{format!(" {} | ", story.time_ago)} {format!(" {} | ", story.time_ago)}
<A href=format!("/stories/{}", story.id)> <A href=format!("/stories/{}", story.id)>
{if story.comments_count.unwrap_or_default() > 0 { {if story.comments_count.unwrap_or_default() > 0 {
@ -147,10 +154,10 @@ fn Story(story: api::Story) -> impl IntoView {
}} }}
</A> </A>
</span> </span>
}.into_view() })
} else { } else {
let title = story.title.clone(); 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> </span>
{(story.story_type != "link").then(|| view! { {(story.story_type != "link").then(|| view! {

View file

@ -1,52 +1,37 @@
use crate::api; use crate::api;
use leptos::either::Either;
use leptos::prelude::*; use leptos::prelude::*;
use leptos_meta::*; use leptos_meta::Meta;
use leptos_router::*; use leptos_router::components::A;
use std::cell::RefCell; use leptos_router::hooks::use_params_map;
#[server(FetchStory, "/api")] #[server]
pub async fn fetch_story( pub async fn fetch_story(
id: String, id: String,
) -> Result<RefCell<Option<api::Story>>, ServerFnError> { ) -> Result<Option<api::Story>, ServerFnError> {
Ok(RefCell::new( Ok(api::fetch_api::<api::Story>(&api::story(&format!("item/{id}"))).await)
api::fetch_api::<api::Story>(&api::story(&format!("item/{id}"))).await,
))
} }
#[component] #[component]
pub fn Story() -> impl IntoView { pub fn Story() -> impl IntoView {
let params = use_params_map(); let params = use_params_map();
let story = create_resource( let story = Resource::new(
move || params.get().get("id").cloned().unwrap_or_default(), move || params.read().get("id").unwrap_or_default(),
move |id| async move { move |id| async move {
if id.is_empty() { if id.is_empty() {
Ok(RefCell::new(None)) Ok(None)
} else { } else {
fetch_story(id).await fetch_story(id).await
} }
}, },
); );
let meta_description = move || {
story
.map(|story| {
story
.as_ref()
.map(|story| {
story.borrow().as_ref().map(|story| story.title.clone())
})
.ok()
})
.flatten()
.flatten()
.unwrap_or_else(|| "Loading story...".to_string())
};
let story_view = move || { Suspense(SuspenseProps::builder().fallback(|| "Loading...").children(ToChildren::to_children(move || Suspend(async move {
story.map(|story| { match story.await.ok().flatten() {
story.as_ref().ok().and_then(|story| { None => Either::Left("Story not found."),
let story: Option<api::Story> = story.borrow_mut().take(); Some(story) => {
story.map(|story| { Either::Right(view! {
view! { <Meta name="description" content=story.title.clone()/>
<div class="item-view"> <div class="item-view">
<div class="item-view-header"> <div class="item-view-header">
<a href=story.url target="_blank"> <a href=story.url target="_blank">
@ -55,63 +40,36 @@ pub fn Story() -> impl IntoView {
<span class="host"> <span class="host">
"("{story.domain}")" "("{story.domain}")"
</span> </span>
{story.user.map(|user| view! { <p class="meta"> {story.user.map(|user| view! { <p class="meta">
{story.points} {story.points}
" points | by " " points | by "
<A href=format!("/users/{user}")>{user.clone()}</A> <A href=format!("/users/{user}")>{user.clone()}</A>
{format!(" {}", story.time_ago)} {format!(" {}", story.time_ago)}
</p>})} </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>
</div>
</div> </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">
{story.comments.unwrap_or_default().into_iter()
.map(|comment: api::Comment| view! { <Comment comment /> })
.collect_view()}
</ul>
</div>
</div>
}})})})
};
view! {
<Meta name="description" content=meta_description/>
<Suspense fallback=|| ()>
{story_view}
</Suspense>
}
}
#[island]
pub fn Toggle(children: Children) -> impl IntoView {
let (open, set_open) = create_signal(true);
view! {
<div class="toggle" class:open=open>
<a on:click=move |_| set_open.update(|n| *n = !*n)>
{move || if open.get() {
"[-]"
} else {
"[+] comments collapsed"
}}
</a>
</div>
<ul
class="comment-children"
style:display=move || if open.get() {
"block"
} else {
"none"
} }
> }
{children()} }))).build())
</ul>
}
} }
#[component] #[component]
@ -135,3 +93,29 @@ pub fn Comment(comment: api::Comment) -> impl IntoView {
</li> </li>
} }
} }
#[island]
pub fn Toggle(children: Children) -> impl IntoView {
let (open, set_open) = signal(true);
view! {
<div class="toggle" class:open=open>
<a on:click=move |_| set_open.update(|n| *n = !*n)>
{move || if open.get() {
"[-]"
} else {
"[+] comments collapsed"
}}
</a>
</div>
<ul
class="comment-children"
style:display=move || if open.get() {
"block"
} else {
"none"
}
>
{children()}
</ul>
}
}

View file

@ -1,20 +1,20 @@
#[allow(unused)] // User is unused in WASM build use crate::api;
use crate::api::{self, User}; use leptos::server::Resource;
use leptos::prelude::*; use leptos::{either::Either, prelude::*};
use leptos_router::*; use leptos_router::hooks::use_params_map;
#[server(FetchUser, "/api")] #[server]
pub async fn fetch_user( pub async fn fetch_user(
id: String, id: String,
) -> Result<Option<api::User>, ServerFnError> { ) -> Result<Option<api::User>, ServerFnError> {
Ok(api::fetch_api::<User>(&api::user(&id)).await) Ok(api::fetch_api::<api::User>(&api::user(&id)).await)
} }
#[component] #[component]
pub fn User() -> impl IntoView { pub fn User() -> impl IntoView {
let params = use_params_map(); let params = use_params_map();
let user = create_resource( let user = Resource::new(
move || params.get().get("id").cloned().unwrap_or_default(), move || params.read().get("id").unwrap_or_default(),
move |id| async move { move |id| async move {
if id.is_empty() { if id.is_empty() {
Ok(None) Ok(None)
@ -25,12 +25,12 @@ pub fn User() -> impl IntoView {
); );
view! { view! {
<div class="user-view"> <div class="user-view">
<Suspense fallback=|| ()> <Suspense fallback=|| view! { "Loading..." }>
{move || user.get().map(|user| user.map(|user| match user { {move || Suspend(async move { match user.await.ok().flatten() {
None => view! { <h1>"User not found."</h1> }.into_view(), None => Either::Left(view! { <h1>"User not found."</h1> }),
Some(user) => view! { Some(user) => Either::Right(view! {
<div> <div>
<h1>"User: " {&user.id}</h1> <h1>"User: " {user.id.clone()}</h1>
<ul class="meta"> <ul class="meta">
<li> <li>
<span class="label">"Created: "</span> {user.created} <span class="label">"Created: "</span> {user.created}
@ -46,8 +46,8 @@ pub fn User() -> impl IntoView {
<a href=format!("https://news.ycombinator.com/threads?id={}", user.id)>"comments"</a> <a href=format!("https://news.ycombinator.com/threads?id={}", user.id)>"comments"</a>
</p> </p>
</div> </div>
}.into_view() })
}))} }})}
</Suspense> </Suspense>
</div> </div>
} }