Updating hackernews example

This commit is contained in:
Greg Johnston 2022-12-12 13:40:05 -05:00
parent 8a0e56aff5
commit 6b6c54e8ff
9 changed files with 114 additions and 136 deletions

View File

@ -0,0 +1,7 @@
// This file is auto-generated. Changing it will have no effect on leptos. Change these by changing RenderOptions and rerunning
RenderOptions {
pkg-path "/pkg/leptos_hackernews"
environment "PROD"
socket-address "127.0.0.1:3000"
reload-port 3001
}

View File

@ -14,11 +14,10 @@ console_log = "0.2"
console_error_panic_hook = "0.1"
futures = "0.3"
cfg-if = "1"
leptos = { path = "../../../leptos/leptos", default-features = false, features = [
"serde",
] }
leptos_meta = { path = "../../../leptos/meta", default-features = false }
leptos_router = { path = "../../../leptos/router", default-features = false }
leptos = { path = "../../leptos", default-features = false, features = ["serde"] }
leptos_meta = { path = "../../meta", default-features = false }
leptos_actix = { path = "../../integrations/actix", default-features = false, optional = true }
leptos_router = { path = "../../router", default-features = false }
log = "0.4"
simple_logger = "2"
serde = { version = "1", features = ["derive"] }
@ -26,6 +25,8 @@ serde_json = "1"
gloo-net = { version = "0.2", features = ["http"] }
reqwest = { version = "0.11", features = ["json"] }
# openssl = { version = "0.10", features = ["v110"] }
wasm-bindgen = "0.2"
web-sys = { version = "0.3", features = ["AbortController", "AbortSignal"] }
[features]
default = ["csr"]
@ -34,11 +35,12 @@ 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",
]
[package.metadata.cargo-all-features]
denylist = ["actix-files", "actix-web"]
denylist = ["actix-files", "actix-web", "leptos_actix"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]]

View File

@ -1,4 +1,4 @@
use leptos::Serializable;
use leptos::{on_cleanup, Scope, Serializable};
use serde::{Deserialize, Serialize};
pub fn story(path: &str) -> String {
@ -10,11 +10,15 @@ pub fn user(path: &str) -> String {
}
#[cfg(not(feature = "ssr"))]
pub async fn fetch_api<T>(path: &str) -> Option<T>
pub async fn fetch_api<T>(cx: Scope, path: &str) -> Option<T>
where
T: Serializable,
{
let abort_controller = web_sys::AbortController::new().ok();
let abort_signal = abort_controller.as_ref().map(|a| a.signal());
let json = gloo_net::http::Request::get(path)
.abort_signal(abort_signal.as_ref())
.send()
.await
.map_err(|e| log::error!("{e}"))
@ -22,11 +26,19 @@ where
.text()
.await
.ok()?;
// abort in-flight requests if the Scope is disposed
// i.e., if we've navigated away from this page
on_cleanup(cx, move || {
if let Some(abort_controller) = abort_controller {
abort_controller.abort()
}
});
T::from_json(&json).ok()
}
#[cfg(feature = "ssr")]
pub async fn fetch_api<T>(path: &str) -> Option<T>
pub async fn fetch_api<T>(cx: Scope, path: &str) -> Option<T>
where
T: Serializable,
{

View File

@ -1,5 +1,5 @@
use cfg_if::cfg_if;
use leptos::*;
use leptos::{component, Scope, IntoView, provide_context, view};
use leptos_meta::*;
use leptos_router::*;
mod api;
@ -10,13 +10,14 @@ use routes::story::*;
use routes::users::*;
#[component]
pub fn App(cx: Scope) -> Element {
pub fn App(cx: Scope) -> impl IntoView {
provide_context(cx, MetaContext::default());
view! {
cx,
<div>
<Stylesheet href="/static/style.css"/>
<>
<Stylesheet href="/style.css"/>
<Meta name="description" content="Leptos implementation of a HackerNews demo."/>
<Router>
<Nav />
<main>
@ -27,7 +28,7 @@ pub fn App(cx: Scope) -> Element {
</Routes>
</main>
</Router>
</div>
</>
}
}
@ -37,10 +38,10 @@ cfg_if! {
use wasm_bindgen::prelude::wasm_bindgen;
#[wasm_bindgen]
pub fn main() {
pub fn hydrate() {
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
leptos::hydrate(body().unwrap(), move |cx| {
leptos::mount_to_body(move |cx| {
view! { cx, <App/> }
});
}

View File

@ -3,109 +3,39 @@ use leptos::*;
// boilerplate to run in different modes
cfg_if! {
// server-only stuff
if #[cfg(feature = "ssr")] {
use actix_files::{Files, NamedFile};
use actix_files::{Files};
use actix_web::*;
use futures::StreamExt;
use leptos_meta::*;
use leptos_router::*;
use leptos_hackernews::*;
use std::{net::SocketAddr, env};
#[get("/static/style.css")]
#[get("/style.css")]
async fn css() -> impl Responder {
NamedFile::open_async("./style.css").await
}
// match every path — our router will handle actual dispatch
#[get("{tail:.*}")]
async fn render_app(req: HttpRequest) -> impl Responder {
let path = req.path();
let query = req.query_string();
let path = if query.is_empty() {
"http://leptos".to_string() + path
} else {
"http://leptos".to_string() + path + "?" + query
};
let app = move |cx| {
let integration = ServerIntegration { path: path.clone() };
provide_context(cx, RouterIntegrationContext::new(integration));
view! { cx, <App/> }
};
let head = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<script type="module">import init, { main } from '/pkg/leptos_hackernews.js'; init().then(main);</script>"#;
let tail = "</body></html>";
HttpResponse::Ok().content_type("text/html").streaming(
futures::stream::once(async { head.to_string() })
.chain(render_to_stream(move |cx| {
let app = app(cx);
let head = use_context::<MetaContext>(cx)
.map(|meta| meta.dehydrate())
.unwrap_or_default();
format!("{head}</head><body>{app}")
}))
.chain(futures::stream::once(async { tail.to_string() }))
.map(|html| Ok(web::Bytes::from(html)) as Result<web::Bytes>),
)
actix_files::NamedFile::open_async("./style.css").await
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let host = std::env::var("HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let port = std::env::var("PORT").unwrap_or_else(|_| "8080".to_string());
log::debug!("serving at {host}:{port}");
let addr = SocketAddr::from(([127,0,0,1],3000));
simple_logger::init_with_level(log::Level::Debug).expect("couldn't initialize logging");
// uncomment these lines (and .bind_openssl() below) to enable HTTPS, which is sometimes
// necessary for proper HTTP/2 streaming
// load TLS keys
// to create a self-signed temporary cert for testing:
// `openssl req -x509 -newkey rsa:4096 -nodes -keyout key.pem -out cert.pem -days 365 -subj '/CN=localhost'`
// let mut builder = SslAcceptor::mozilla_intermediate(SslMethod::tls()).unwrap();
// builder
// .set_private_key_file("key.pem", SslFiletype::PEM)
// .unwrap();
// builder.set_certificate_chain_file("cert.pem").unwrap();
HttpServer::new(|| {
HttpServer::new(move || {
let render_options: RenderOptions = RenderOptions::builder().pkg_path("/pkg/leptos_hackernews").reload_port(3001).socket_address(addr.clone()).environment(&env::var("RUST_ENV")).build();
render_options.write_to_file();
App::new()
.service(Files::new("/pkg", "./pkg"))
.service(css)
.service(
web::scope("/pkg")
.service(Files::new("", "./pkg"))
.wrap(middleware::Compress::default()),
)
.service(render_app)
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
.route("/{tail:.*}", leptos_actix::render_app_to_stream(render_options, |cx| view! { cx, <App/> }))
//.wrap(middleware::Compress::default())
})
.bind(("127.0.0.1", 8080))?
// replace .bind with .bind_openssl to use HTTPS
//.bind_openssl(&format!("{}:{}", host, port), builder)?
.bind(&addr)?
.run()
.await
}
}
// client-only stuff for Trunk
else {
use leptos_hackernews::*;
pub fn main() {
console_error_panic_hook::set_once();
_ = console_log::init_with_level(log::Level::Debug);
console_error_panic_hook::set_once();
mount_to_body(|cx| {
view! { cx, <App/> }
});
} else {
fn main() {
// no client-side main function
}
}
}

View File

@ -1,8 +1,8 @@
use leptos::*;
use leptos::{component, Scope, IntoView, view};
use leptos_router::*;
#[component]
pub fn Nav(cx: Scope) -> Element {
pub fn Nav(cx: Scope) -> impl IntoView {
view! { cx,
<header class="header">
<nav class="inner">

View File

@ -14,7 +14,7 @@ fn category(from: &str) -> &'static str {
}
#[component]
pub fn Stories(cx: Scope) -> Element {
pub fn Stories(cx: Scope) -> impl IntoView {
let query = use_query_map(cx);
let params = use_params_map(cx);
let page = move || {
@ -32,11 +32,13 @@ pub fn Stories(cx: Scope) -> Element {
move || (page(), story_type()),
move |(page, story_type)| async move {
let path = format!("{}?page={}", category(&story_type), page);
api::fetch_api::<Vec<api::Story>>(&api::story(&path)).await
api::fetch_api::<Vec<api::Story>>(cx, &api::story(&path)).await
},
);
let (pending, set_pending) = create_signal(cx, false);
let hide_more_link = move || stories.read().unwrap_or(None).unwrap_or_default().len() < 28;
let hide_more_link =
move || pending() || stories.read().unwrap_or(None).unwrap_or_default().len() < 28;
view! {
cx,
@ -52,14 +54,14 @@ pub fn Stories(cx: Scope) -> Element {
>
"< prev"
</a>
}
}.into_any()
} else {
view! {
cx,
<span class="page-link disabled" aria-hidden="true">
"< prev"
</span>
}
}.into_any()
}}
</span>
<span>"page " {page}</span>
@ -76,25 +78,30 @@ pub fn Stories(cx: Scope) -> Element {
</div>
<main class="news-list">
<div>
<Suspense fallback=view! { cx, <p>"Loading..."</p> }>
<Transition
fallback=move || view! { cx, <p>"Loading..."</p> }
set_pending
>
{move || match stories.read() {
None => None,
Some(None) => Some(view! { cx, <p>"Error loading stories."</p> }),
Some(None) => Some(view! { cx, <p>"Error loading stories."</p> }.into_any()),
Some(Some(stories)) => {
Some(view! { cx,
<ul>
<For each=move || stories.clone() key=|story| story.id>{
move |cx: Scope, story: &api::Story| {
<For
each=move || stories.clone()
key=|story| story.id
view=move |story: api::Story| {
view! { cx,
<Story story=story.clone() />
<Story story/>
}
}
}</For>
/>
</ul>
})
}.into_any())
}
}}
</Suspense>
</Transition>
</div>
</main>
</div>
@ -102,7 +109,7 @@ pub fn Stories(cx: Scope) -> Element {
}
#[component]
fn Story(cx: Scope, story: api::Story) -> Element {
fn Story(cx: Scope, story: api::Story) -> impl IntoView {
view! { cx,
<li class="news-item">
<span class="score">{story.points}</span>
@ -115,10 +122,10 @@ fn Story(cx: Scope, story: api::Story) -> Element {
</a>
<span class="host">"("{story.domain}")"</span>
</span>
}
}.into_view(cx)
} else {
let title = story.title.clone();
view! { cx, <A href=format!("/stories/{}", story.id)>{title.clone()}</A> }
view! { cx, <A href=format!("/stories/{}", story.id)>{title.clone()}</A> }.into_view(cx)
}}
</span>
<br />
@ -137,10 +144,10 @@ fn Story(cx: Scope, story: api::Story) -> Element {
}}
</A>
</span>
}
}.into_view(cx)
} else {
let title = story.title.clone();
view! { cx, <A href=format!("/item/{}", story.id)>{title.clone()}</A> }
view! { cx, <A href=format!("/item/{}", story.id)>{title.clone()}</A> }.into_view(cx)
}}
</span>
{(story.story_type != "link").then(|| view! { cx,

View File

@ -1,18 +1,27 @@
use crate::api;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
#[component]
pub fn Story(cx: Scope) -> Element {
pub fn Story(cx: Scope) -> impl IntoView {
let params = use_params_map(cx);
let story = create_resource(
cx,
move || params().get("id").cloned().unwrap_or_default(),
move |id| async move { api::fetch_api::<api::Story>(&api::story(&format!("item/{id}"))).await },
move |id| async move {
if id.is_empty() {
None
} else {
api::fetch_api::<api::Story>(cx, &api::story(&format!("item/{id}"))).await
}
},
);
let meta_description = move || story.read().and_then(|story| story.map(|story| story.title.clone())).unwrap_or_else(|| "Loading story...".to_string());
view! { cx,
<div>
<Meta name="description" content=meta_description/>
{move || story.read().map(|story| match story {
None => view! { cx, <div class="item-view">"Error loading this story."</div> },
Some(story) => view! { cx,
@ -40,9 +49,11 @@ pub fn Story(cx: Scope) -> Element {
}}
</p>
<ul class="comment-children">
<For each=move || story.comments.clone().unwrap_or_default() key=|comment| comment.id>
{move |cx, comment: &api::Comment| view! { cx, <Comment comment=comment.clone() /> }}
</For>
<For
each=move || story.comments.clone().unwrap_or_default()
key=|comment| comment.id
view=move |comment| view! { cx, <Comment comment /> }
/>
</ul>
</div>
</div>
@ -52,7 +63,7 @@ pub fn Story(cx: Scope) -> Element {
}
#[component]
pub fn Comment(cx: Scope, comment: api::Comment) -> Element {
pub fn Comment(cx: Scope, comment: api::Comment) -> impl IntoView {
let (open, set_open) = create_signal(cx, true);
view! { cx,
@ -81,9 +92,11 @@ pub fn Comment(cx: Scope, comment: api::Comment) -> Element {
let comments = comment.comments.clone();
move || view! { cx,
<ul class="comment-children">
<For each=move || comments.clone() key=|comment| comment.id>
{|cx, comment: &api::Comment| view! { cx, <Comment comment=comment.clone() /> }}
</For>
<For
each=move || comments.clone()
key=|comment| comment.id
view=move |comment: api::Comment| view! { cx, <Comment comment /> }
/>
</ul>
}
})}

View File

@ -3,17 +3,23 @@ use leptos::*;
use leptos_router::*;
#[component]
pub fn User(cx: Scope) -> Element {
pub fn User(cx: Scope) -> impl IntoView {
let params = use_params_map(cx);
let user = create_resource(
cx,
move || params().get("id").cloned().unwrap_or_default(),
move |id| async move { api::fetch_api::<User>(&api::user(&id)).await },
move |id| async move {
if id.is_empty() {
None
} else {
api::fetch_api::<User>(cx, &api::user(&id)).await
}
},
);
view! { cx,
<div class="user-view">
{move || user.read().map(|user| match user {
None => view! { cx, <h1>"User not found."</h1> },
None => view! { cx, <h1>"User not found."</h1> }.into_any(),
Some(user) => view! { cx,
<div>
<h1>"User: " {&user.id}</h1>
@ -32,7 +38,7 @@ pub fn User(cx: Scope) -> Element {
<a href=format!("https://news.ycombinator.com/threads?id={}", user.id)>"comments"</a>
</p>
</div>
}
}.into_any()
})}
</div>
}