feat: with base app

This commit is contained in:
2023-03-05 22:56:04 +01:00
parent f8f0a832e9
commit c3f8679863
17 changed files with 3578 additions and 192 deletions

101
src/api/events.rs Normal file
View File

@@ -0,0 +1,101 @@
use lazy_static::lazy_static;
use leptos::*;
use serde::{Deserialize, Serialize};
use crate::models::{Event, EventOverview, Image};
lazy_static! {
static ref EVENTS: Vec<Event> = vec![
Event {
cover_image: Some(Image {
id: uuid::Uuid::new_v4(),
url: "https://images.unsplash.com/photo-1513104890138-7c749659a591?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=400&q=80".into(),
alt: "some-alt".into(),
metadata: None,
}),
id: uuid::Uuid::new_v4(),
name: "Pizza".into(),
description: Some("Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint consectetur cupidatat.".into()),
time: chrono::Utc::now()
.checked_add_days(chrono::Days::new(1))
.unwrap(),
recipe_id: None,
images: vec![],
metadata: None,
},
Event {
cover_image: Some(Image {
id: uuid::Uuid::new_v4(),
url: "https://images.unsplash.com/photo-1513104890138-7c749659a591?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=400&q=80".into(),
alt: "some-alt".into(),
metadata: None,
}),
id: uuid::Uuid::new_v4(),
name: "Kød boller".into(),
description: Some("Lorem ipsum dolor sit amet, officia excepteur ex fugiat reprehenderit enim labore culpa sint ad nisi Lorem pariatur mollit ex esse exercitation amet. Nisi anim cupidatat excepteur officia. Reprehenderit nostrud nostrud ipsum Lorem est aliquip amet voluptate voluptate dolor minim nulla est proident. Nostrud officia pariatur ut officia. Sit irure elit esse ea nulla sunt ex occaecat reprehenderit commodo officia dolor Lorem duis laboris cupidatat officia voluptate. Culpa proident adipisicing id nulla nisi laboris ex in Lorem sunt duis officia eiusmod. Aliqua reprehenderit commodo ex non excepteur duis sunt velit enim. Voluptate laboris sint cupidatat ullamco ut ea consectetur et est culpa et culpa duis.".into()),
time: chrono::Utc::now()
.checked_add_days(chrono::Days::new(4))
.unwrap(),
recipe_id: None,
images: vec![],
metadata: None,
},
Event {
cover_image: Some(Image {
id: uuid::Uuid::new_v4(),
url: "https://images.unsplash.com/photo-1513104890138-7c749659a591?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=400&q=80".into(),
alt: "some-alt".into(),
metadata: None,
}),
id: uuid::Uuid::new_v4(),
name: "Pizza".into(),
description: Some("description".into()),
time: chrono::Utc::now()
.checked_sub_days(chrono::Days::new(2))
.unwrap(),
recipe_id: None,
images: vec![],
metadata: None,
},
];
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpcomingEventsOverview {
pub events: Vec<EventOverview>,
}
#[server(GetUpcomingEvents, "/api")]
pub async fn get_upcoming_events() -> Result<UpcomingEventsOverview, ServerFnError> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
get_upcoming_events_fn().await
}
async fn get_upcoming_events_fn() -> Result<UpcomingEventsOverview, ServerFnError> {
let current_time = chrono::Utc::now();
let mut events: Vec<EventOverview> = EVENTS
.iter()
.filter(|data| data.time > current_time)
.map(|data| data.clone().into())
.collect();
events.sort_by(|a, b| a.time.cmp(&b.time));
Ok(UpcomingEventsOverview { events })
}
#[server(GetFullEvent, "/api")]
pub async fn get_full_event(event_id: uuid::Uuid) -> Result<Option<Event>, ServerFnError> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
get_full_event_fn(event_id).await
}
async fn get_full_event_fn(event_id: uuid::Uuid) -> Result<Option<Event>, ServerFnError> {
let event = EVENTS
.iter()
.find(|data| data.id == event_id)
.map(|d| d.clone());
Ok(event)
}

9
src/api/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
pub mod events;
use leptos::*;
#[cfg(feature = "ssr")]
pub fn register() {
events::GetUpcomingEvents::register();
events::GetFullEvent::register();
}

View File

@@ -1,9 +1,8 @@
use lazy_static::lazy_static;
use leptos::*;
use leptos_meta::*;
use leptos_router::*;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::pages::home::*;
#[component]
pub fn App(cx: Scope) -> impl IntoView {
@@ -11,179 +10,20 @@ pub fn App(cx: Scope) -> impl IntoView {
provide_meta_context(cx);
view! { cx,
<Stylesheet id="leptos" href="/pkg/ssr_modes.css"/>
<Title text="Welcome to Leptos"/>
<Stylesheet id="leptos" href="/pkg/ssr_modes.css" />
<Title text="Bitebuds" />
<Router>
<main>
<Routes>
// Well load the home page with out-of-order streaming and <Suspense/>
<Route path="" view=|cx| view! { cx, <HomePage/> }/>
// We'll load the posts with async rendering, so they can set
// the title and metadata *after* loading the data
<Route
path="/post/:id"
view=|cx| view! { cx, <Post/> }
ssr=SsrMode::Async
/>
<Route
path="/post_in_order/:id"
view=|cx| view! { cx, <Post/> }
ssr=SsrMode::InOrder
/>
</Routes>
<Router>
<div class="app grid lg:grid-cols-[25%,50%,25%] sm:grid-cols-[10%,80%,10%] grid-cols-[5%,90%,5%]">
<main class="main col-start-2">
<div class="pt-4">
<h1 class="font-semibold text-xl tracking-wide">"Bitebuds"</h1>
<Routes>
<Route path="" view=|cx| view! { cx, <HomePage /> }/>
</Routes>
</div>
</main>
</Router>
</div>
</Router>
}
}
#[component]
fn HomePage(cx: Scope) -> impl IntoView {
// load the posts
let posts =
create_resource(cx, || (), |_| async { list_post_metadata().await });
let posts_view = move || {
posts.with(cx, |posts| posts
.clone()
.map(|posts| {
posts.iter()
.map(|post| view! { cx, <li><a href=format!("/post/{}", post.id)>{&post.title}</a> "|" <a href=format!("/post_in_order/{}", post.id)>{&post.title}"(in order)"</a></li>})
.collect::<Vec<_>>()
})
)
};
view! { cx,
<h1>"My Great Blog"</h1>
<Suspense fallback=move || view! { cx, <p>"Loading posts..."</p> }>
<ul>{posts_view}</ul>
</Suspense>
}
}
#[derive(Params, Copy, Clone, Debug, PartialEq, Eq)]
pub struct PostParams {
id: usize,
}
#[component]
fn Post(cx: Scope) -> impl IntoView {
let query = use_params::<PostParams>(cx);
let id = move || {
query.with(|q| {
q.as_ref().map(|q| q.id).map_err(|_| PostError::InvalidId)
})
};
let post = create_resource(cx, id, |id| async move {
match id {
Err(e) => Err(e),
Ok(id) => get_post(id)
.await
.map(|data| data.ok_or(PostError::PostNotFound))
.map_err(|_| PostError::ServerError)
.flatten(),
}
});
let post_view = move || {
post.with(cx, |post| {
post.clone().map(|post| {
view! { cx,
// render content
<h1>{&post.title}</h1>
<p>{&post.content}</p>
// since we're using async rendering for this page,
// this metadata should be included in the actual HTML <head>
// when it's first served
<Title text=post.title/>
<Meta name="description" content=post.content/>
}
})
})
};
view! { cx,
<Suspense fallback=move || view! { cx, <p>"Loading post..."</p> }>
<ErrorBoundary fallback=|cx, errors| {
view! { cx,
<div class="error">
<h1>"Something went wrong."</h1>
<ul>
{move || errors.get()
.into_iter()
.map(|(_, error)| view! { cx, <li>{error.to_string()} </li> })
.collect::<Vec<_>>()
}
</ul>
</div>
}
}>
{post_view}
</ErrorBoundary>
</Suspense>
}
}
// Dummy API
lazy_static! {
static ref POSTS: Vec<Post> = vec![
Post {
id: 0,
title: "My first post".to_string(),
content: "This is my first post".to_string(),
},
Post {
id: 1,
title: "My second post".to_string(),
content: "This is my second post".to_string(),
},
Post {
id: 2,
title: "My third post".to_string(),
content: "This is my third post".to_string(),
},
];
}
#[derive(Error, Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum PostError {
#[error("Invalid post ID.")]
InvalidId,
#[error("Post not found.")]
PostNotFound,
#[error("Server error.")]
ServerError,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Post {
id: usize,
title: String,
content: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PostMetadata {
id: usize,
title: String,
}
#[server(ListPostMetadata, "/api")]
pub async fn list_post_metadata() -> Result<Vec<PostMetadata>, ServerFnError> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(POSTS
.iter()
.map(|data| PostMetadata {
id: data.id,
title: data.title.clone(),
})
.collect())
}
#[server(GetPost, "/api")]
pub async fn get_post(id: usize) -> Result<Option<Post>, ServerFnError> {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
Ok(POSTS.iter().find(|post| post.id == id).cloned())
}

197
src/components/day.rs Normal file
View File

@@ -0,0 +1,197 @@
use chrono::Datelike;
use leptos::*;
use crate::api::events::*;
use crate::models::{EventOverview, Image};
#[component]
pub fn Day(
cx: Scope,
event: EventOverview,
next: Option<bool>,
last: Option<bool>,
) -> impl IntoView {
let (expanded, set_expanded) = create_signal(cx, false);
let day = event.time.weekday().to_string();
let timestamp = event.time.format("%Y-%m-%d").to_string();
view! {
cx,
<div class="sm:grid grid-cols-[1fr,4fr] gap-4 space-y-4 sm:space-y-0">
<div class="relative">
{if !last.unwrap_or(false) {
view! {
cx,
<div class="bg-gray-300 absolute top-3 left-[3px] h-full w-0.5 hidden sm:block z-0"/>
}.into_view(cx)
} else {
view! {cx, <div></div>}.into_view(cx)
}}
<div class="col-start-1 flex space-x-2">
<div class={format!("hidden sm:block w-2 h-2 rounded-full mt-2.5 z-10 {}", if next.unwrap_or(false) {"bg-orange-600"} else { "bg-gray-300"})} />
<div class="inline-block">
<span class={format!("text-md font-medium {}", if next.unwrap_or(false) {"text-orange-600"} else {"text-gray-700"})}>
{day}
</span>
<p class="text-xs font-normal text-gray-500">
{timestamp}
</p>
</div>
</div>
</div>
<div class="col-start-2 transition-all sm:pb-6">
{move || if expanded() == true {
view! {
cx,
<DayContentExpanded event_id=event.id.clone()/>
}.into_view(cx)
} else {
view! {
cx,
<DayContentCollapsed event=event.clone() setter=set_expanded />
}.into_view(cx)
}}
</div>
</div>
<div class="divider block sm:hidden h-0.5 w-full bg-gray-300 my-6 rounded-full" />
}
}
#[component]
fn DayContentExpanded(cx: Scope, event_id: uuid::Uuid) -> impl IntoView {
let full_event = create_resource(cx, move || (), move |_| get_full_event(event_id));
let image = |cx: Scope, image: Option<Image>| {
if let Some(image) = image {
view! {
cx,
<img src={image.url} alt=image.alt class="object-cover max-h-[250px] " />
}.into_view(cx)
} else {
view! {cx, <div></div>}.into_view(cx)
}
};
let event_view = move || full_event.with(cx, |event| {
event.clone().map(|event| {event.map(|event| view! {
cx,
<article class="day-content space-x-3 min-h-[150px] flex flex-col">
{image(cx, event.cover_image)}
<div class="day-content__body space-y-2 pt-6">
<h2 class="font-semibold text-xl text-orange-600">{event.name}</h2>
{
event.description.map(|d| view! {cx,
<p class="font-normal sm:px-6 text">
{d}
</p>
})
}
{
event.recipe_id.map(|_r| {view! {cx,
<h3 class="font-medium text-lg pt-2 text-orange-600">"Recipe"</h3>
<ol class="px-10">
<li class="list-item list-decimal">
"Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint consectetur
cupidatat."
</li>
<li class="list-item list-decimal">
"Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint consectetur
cupidatat."
</li>
</ol>
<h3 class="font-medium text-lg pt-2 text-orange-600">"References"</h3>
<ul class="px-10">
<li class="list-item list-decimal">
<a href={r"https://google.com"}>
"Lorem ipsum dolor sit amet, qui minim labore adipisicing minim sint cillum sint consectetur cupidatat"</a>
</li>
</ul>
<h3 class="font-medium text-lg pt-2 text-orange-600">"Images"</h3>
<div class="day-content__images grid grid-cols-3 gap-4 mx-4 pt-2">
<img
src={r"https://images.unsplash.com/photo-1677856217391-838a585c8290?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2070&q=80"}
alt="no alt text in sight"
class="object-cover"
/>
<img
src={r"https://images.unsplash.com/photo-1677856217391-838a585c8290?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2070&q=80"}
alt="no alt text in sight"
class="object-cover"
/>
<img
src={r"https://images.unsplash.com/photo-1677856217391-838a585c8290?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2070&q=80"}
alt="no alt text in sight"
class="object-cover"
/>
</div>
}})
}
</div>
</article>
<div class="pb-10" />
})})
});
view! {
cx,
<Suspense fallback=move || view! {cx, <p>"Loading events..."</p>}>
{event_view}
</Suspense>
}
}
#[component]
fn DayContentCollapsed(
cx: Scope,
setter: WriteSignal<bool>,
event: EventOverview,
) -> impl IntoView {
let image = event.cover_image.clone();
view! {
cx,
<article class="day-content sm:grid grid-cols-[30%,70%] sm:space-x-6 flex flex-col">
{if let Some(image) = image {
view! {
cx,
<div class="content-start justify-start">
<img src={image.url} alt=image.alt class="object-cover place-self-center w-full max-w-full max-h-[150px] sm:max-h-full " />
</div>
}.into_view(cx)
} else {
view!{cx, <div></div>}.into_view(cx)
}}
<div class="day-content__body flex flex-col">
<h2 class="font-semibold text-lg text-orange-600">{event.name}</h2>
{if let Some(mut description) = event.description.clone() {
description.truncate(120);
view! {cx,
<p class="font-normal text-sm">
{
if description.len() == 120 {
format!("{description}...")
} else {
description
}
}
</p>
}.into_view(cx)
} else {
view! {cx, <div></div>}.into_view(cx)
}}
<div class="flex-grow" />
<button
on:click=move |_| setter.update(|value| *value = !*value)
class="transition-all h-3 w-20 bg-gray-200 hover:bg-gray-300 self-center rounded-b-[4rem] rounded-t-[1rem] mt-3"
/>
</div>
</article>
}
}

1
src/components/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod day;

View File

@@ -1,7 +1,11 @@
#![feature(result_flattening)]
pub mod api;
pub mod app;
mod components;
pub mod fallback;
mod models;
mod pages;
use cfg_if::cfg_if;
cfg_if! {

View File

@@ -1,12 +1,16 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main(){
async fn main() {
use axum::{
extract::{Extension, Path},
routing::{get, post},
Router,
};
use leptos::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use axum::{extract::{Extension, Path}, Router, routing::{get, post}};
use std::sync::Arc;
use ssr_modes_axum::fallback::file_and_error_handler;
use ssr_modes_axum::app::*;
use ssr_modes_axum::fallback::file_and_error_handler;
use std::sync::Arc;
let conf = get_configuration(None).await.unwrap();
let addr = conf.leptos_options.site_addr;
@@ -14,8 +18,7 @@ async fn main(){
// Generate the list of routes in your Leptos App
let routes = generate_route_list(|cx| view! { cx, <App/> }).await;
GetPost::register();
ListPostMetadata::register();
ssr_modes_axum::api::register();
let app = Router::new()
.route("/api/*fn_name", post(leptos_axum::handle_server_fns))

53
src/models.rs Normal file
View File

@@ -0,0 +1,53 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Metadata(HashMap<String, String>);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Recipe {
pub id: uuid::Uuid,
pub metadata: Option<Metadata>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Image {
pub id: uuid::Uuid,
pub url: String,
pub alt: String,
pub metadata: Option<Metadata>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Event {
pub id: uuid::Uuid,
pub cover_image: Option<Image>,
pub name: String,
pub description: Option<String>,
pub time: chrono::DateTime<chrono::Utc>,
pub recipe_id: Option<uuid::Uuid>,
pub images: Vec<Image>,
pub metadata: Option<Metadata>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EventOverview {
pub id: uuid::Uuid,
pub cover_image: Option<Image>,
pub name: String,
pub description: Option<String>,
pub time: chrono::DateTime<chrono::Utc>,
}
impl From<Event> for EventOverview {
fn from(value: Event) -> Self {
Self {
id: value.id,
cover_image: value.cover_image,
name: value.name,
description: value.description,
time: value.time,
}
}
}

52
src/pages/home.rs Normal file
View File

@@ -0,0 +1,52 @@
use leptos::*;
use crate::api;
use crate::components::day::{Day, DayProps};
#[component]
pub fn HomePage(cx: Scope) -> impl IntoView {
let events = create_resource(
cx,
|| (),
|_| async { api::events::get_upcoming_events().await },
);
let events_view = move || {
events.with(cx, |events| {
events.clone().map(|event_overview| {
event_overview
.events
.iter()
.enumerate()
.map(|(index, event)| {
view! {
cx,
<Day
event=event.clone()
next={Some(index == 0)}
last={
if event_overview.events.len() - 1 == index {
Some(true)
} else {
None
}
}
/>
}
})
.collect::<Vec<_>>()
})
})
};
view! {
cx,
<div class="space-y-4 pt-8">
<Suspense fallback=move || view! {cx, <p>"Loading events..."</p>}>
<ul class="days flex flex-col">
{events_view}
</ul>
</Suspense>
</div>
}
}

1
src/pages/mod.rs Normal file
View File

@@ -0,0 +1 @@
pub mod home;