feat: with base app

This commit is contained in:
Kasper Juul Hermansen 2023-03-05 22:56:04 +01:00
parent f8f0a832e9
commit c3f8679863
Signed by: kjuulh
GPG Key ID: 57B6E1465221F912
17 changed files with 3578 additions and 192 deletions

2196
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -11,12 +11,10 @@ console_error_panic_hook = "0.1"
console_log = "0.2" console_log = "0.2"
cfg-if = "1" cfg-if = "1"
lazy_static = "1" lazy_static = "1"
leptos = { path = "../../leptos", default-features = false, features = [ leptos = { version = "*", default-features = false, features = ["serde"] }
"serde", leptos_meta = { version = "*", default-features = false }
] } leptos_axum = { version = "*", default-features = false, optional = true }
leptos_meta = { path = "../../meta", default-features = false } leptos_router = { version = "*", default-features = false }
leptos_axum = { path = "../../integrations/axum", default-features = false, optional = true }
leptos_router = { path = "../../router", default-features = false }
log = "0.4" log = "0.4"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
simple_logger = "4" simple_logger = "4"
@ -26,6 +24,8 @@ tower = { version = "0.4.13", optional = true }
tower-http = { version = "0.3.4", features = ["fs"], optional = true } tower-http = { version = "0.3.4", features = ["fs"], optional = true }
tokio = { version = "1", features = ["time"], optional = true } tokio = { version = "1", features = ["time"], optional = true }
wasm-bindgen = "0.2" wasm-bindgen = "0.2"
chrono = { version = "0.4.23", features = ["serde"] }
uuid = { version = "1.3.0", features = ["v4", "wasm-bindgen", "js", "serde"] }
[features] [features]
hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"] hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate"]
@ -49,7 +49,7 @@ site-root = "target/site"
# Defaults to pkg # Defaults to pkg
site-pkg-dir = "pkg" site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css # [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "style/main.scss" style-file = "style/output.css"
# Assets source dir. All files found here will be copied and synchronized to site-root. # Assets source dir. All files found here will be copied and synchronized to site-root.
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir. # The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
# #

View File

@ -7,3 +7,18 @@ install_crate = "cargo-all-features"
command = "cargo" command = "cargo"
args = ["+nightly", "check-all-features"] args = ["+nightly", "check-all-features"]
install_crate = "cargo-all-features" install_crate = "cargo-all-features"
[tasks.watch_tailwind]
command = "npx"
args = [
"tailwindcss",
"-i",
"./input.css",
"-o",
"./style/output.css",
"--watch",
]
[tasks.build_tailwind]
command = "npx"
args = ["tailwindcss", "-i", "./input.css", "-o", "./style/output.css"]

3
input.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

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::*;
use leptos_meta::*; use leptos_meta::*;
use leptos_router::*; use leptos_router::*;
use serde::{Deserialize, Serialize};
use thiserror::Error; use crate::pages::home::*;
#[component] #[component]
pub fn App(cx: Scope) -> impl IntoView { pub fn App(cx: Scope) -> impl IntoView {
@ -12,178 +11,19 @@ pub fn App(cx: Scope) -> impl IntoView {
view! { cx, view! { cx,
<Stylesheet id="leptos" href="/pkg/ssr_modes.css" /> <Stylesheet id="leptos" href="/pkg/ssr_modes.css" />
<Title text="Welcome to Leptos"/> <Title text="Bitebuds" />
<Router> <Router>
<main> <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> <Routes>
// Well load the home page with out-of-order streaming and <Suspense/>
<Route path="" view=|cx| view! { cx, <HomePage /> }/> <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> </Routes>
</div>
</main> </main>
</div>
</Router> </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)] #![feature(result_flattening)]
pub mod api;
pub mod app; pub mod app;
mod components;
pub mod fallback; pub mod fallback;
mod models;
mod pages;
use cfg_if::cfg_if; use cfg_if::cfg_if;
cfg_if! { cfg_if! {

View File

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

View File

@ -1,3 +0,0 @@
body {
font-family: sans-serif;
}

904
style/output.css Normal file
View File

@ -0,0 +1,904 @@
/*
! tailwindcss v3.2.7 | MIT License | https://tailwindcss.com
*/
/*
1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
*/
*,
::before,
::after {
box-sizing: border-box;
/* 1 */
border-width: 0;
/* 2 */
border-style: solid;
/* 2 */
border-color: #e5e7eb;
/* 2 */
}
::before,
::after {
--tw-content: '';
}
/*
1. Use a consistent sensible line-height in all browsers.
2. Prevent adjustments of font size after orientation changes in iOS.
3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default.
*/
html {
line-height: 1.5;
/* 1 */
-webkit-text-size-adjust: 100%;
/* 2 */
-moz-tab-size: 4;
/* 3 */
-o-tab-size: 4;
tab-size: 4;
/* 3 */
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */
font-feature-settings: normal;
/* 5 */
}
/*
1. Remove the margin in all browsers.
2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
*/
body {
margin: 0;
/* 1 */
line-height: inherit;
/* 2 */
}
/*
1. Add the correct height in Firefox.
2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
3. Ensure horizontal rules are visible by default.
*/
hr {
height: 0;
/* 1 */
color: inherit;
/* 2 */
border-top-width: 1px;
/* 3 */
}
/*
Add the correct text decoration in Chrome, Edge, and Safari.
*/
abbr:where([title]) {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
/*
Remove the default font size and weight for headings.
*/
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
/*
Reset links to optimize for opt-in styling instead of opt-out.
*/
a {
color: inherit;
text-decoration: inherit;
}
/*
Add the correct font weight in Edge and Safari.
*/
b,
strong {
font-weight: bolder;
}
/*
1. Use the user's configured `mono` font family by default.
2. Correct the odd `em` font sizing in all browsers.
*/
code,
kbd,
samp,
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */
font-size: 1em;
/* 2 */
}
/*
Add the correct font size in all browsers.
*/
small {
font-size: 80%;
}
/*
Prevent `sub` and `sup` elements from affecting the line height in all browsers.
*/
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
/*
1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
3. Remove gaps between table borders by default.
*/
table {
text-indent: 0;
/* 1 */
border-color: inherit;
/* 2 */
border-collapse: collapse;
/* 3 */
}
/*
1. Change the font styles in all browsers.
2. Remove the margin in Firefox and Safari.
3. Remove default padding in all browsers.
*/
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
/* 1 */
font-size: 100%;
/* 1 */
font-weight: inherit;
/* 1 */
line-height: inherit;
/* 1 */
color: inherit;
/* 1 */
margin: 0;
/* 2 */
padding: 0;
/* 3 */
}
/*
Remove the inheritance of text transform in Edge and Firefox.
*/
button,
select {
text-transform: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Remove default button styles.
*/
button,
[type='button'],
[type='reset'],
[type='submit'] {
-webkit-appearance: button;
/* 1 */
background-color: transparent;
/* 2 */
background-image: none;
/* 2 */
}
/*
Use the modern Firefox focus style for all focusable elements.
*/
:-moz-focusring {
outline: auto;
}
/*
Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
*/
:-moz-ui-invalid {
box-shadow: none;
}
/*
Add the correct vertical alignment in Chrome and Firefox.
*/
progress {
vertical-align: baseline;
}
/*
Correct the cursor style of increment and decrement buttons in Safari.
*/
::-webkit-inner-spin-button,
::-webkit-outer-spin-button {
height: auto;
}
/*
1. Correct the odd appearance in Chrome and Safari.
2. Correct the outline style in Safari.
*/
[type='search'] {
-webkit-appearance: textfield;
/* 1 */
outline-offset: -2px;
/* 2 */
}
/*
Remove the inner padding in Chrome and Safari on macOS.
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
/*
1. Correct the inability to style clickable types in iOS and Safari.
2. Change font properties to `inherit` in Safari.
*/
::-webkit-file-upload-button {
-webkit-appearance: button;
/* 1 */
font: inherit;
/* 2 */
}
/*
Add the correct display in Chrome and Safari.
*/
summary {
display: list-item;
}
/*
Removes the default spacing and border for appropriate elements.
*/
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
fieldset {
margin: 0;
padding: 0;
}
legend {
padding: 0;
}
ol,
ul,
menu {
list-style: none;
margin: 0;
padding: 0;
}
/*
Prevent resizing textareas horizontally by default.
*/
textarea {
resize: vertical;
}
/*
1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
2. Set the default placeholder color to the user's configured gray 400 color.
*/
input::-moz-placeholder, textarea::-moz-placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
input::placeholder,
textarea::placeholder {
opacity: 1;
/* 1 */
color: #9ca3af;
/* 2 */
}
/*
Set the default cursor for buttons.
*/
button,
[role="button"] {
cursor: pointer;
}
/*
Make sure disabled buttons don't get the pointer cursor.
*/
:disabled {
cursor: default;
}
/*
1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
This can trigger a poorly considered lint error in some tools but is included by design.
*/
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
/* 1 */
vertical-align: middle;
/* 2 */
}
/*
Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
*/
img,
video {
max-width: 100%;
height: auto;
}
/* Make elements with the HTML hidden attribute stay hidden by default */
[hidden] {
display: none;
}
*, ::before, ::after {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
::backdrop {
--tw-border-spacing-x: 0;
--tw-border-spacing-y: 0;
--tw-translate-x: 0;
--tw-translate-y: 0;
--tw-rotate: 0;
--tw-skew-x: 0;
--tw-skew-y: 0;
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-pan-x: ;
--tw-pan-y: ;
--tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity;
--tw-ordinal: ;
--tw-slashed-zero: ;
--tw-numeric-figure: ;
--tw-numeric-spacing: ;
--tw-numeric-fraction: ;
--tw-ring-inset: ;
--tw-ring-offset-width: 0px;
--tw-ring-offset-color: #fff;
--tw-ring-color: rgb(59 130 246 / 0.5);
--tw-ring-offset-shadow: 0 0 #0000;
--tw-ring-shadow: 0 0 #0000;
--tw-shadow: 0 0 #0000;
--tw-shadow-colored: 0 0 #0000;
--tw-blur: ;
--tw-brightness: ;
--tw-contrast: ;
--tw-grayscale: ;
--tw-hue-rotate: ;
--tw-invert: ;
--tw-saturate: ;
--tw-sepia: ;
--tw-drop-shadow: ;
--tw-backdrop-blur: ;
--tw-backdrop-brightness: ;
--tw-backdrop-contrast: ;
--tw-backdrop-grayscale: ;
--tw-backdrop-hue-rotate: ;
--tw-backdrop-invert: ;
--tw-backdrop-opacity: ;
--tw-backdrop-saturate: ;
--tw-backdrop-sepia: ;
}
.static {
position: static;
}
.absolute {
position: absolute;
}
.relative {
position: relative;
}
.left-\[3px\] {
left: 3px;
}
.top-3 {
top: 0.75rem;
}
.z-0 {
z-index: 0;
}
.z-10 {
z-index: 10;
}
.col-start-1 {
grid-column-start: 1;
}
.col-start-2 {
grid-column-start: 2;
}
.mx-4 {
margin-left: 1rem;
margin-right: 1rem;
}
.my-6 {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.mt-2\.5 {
margin-top: 0.625rem;
}
.mt-3 {
margin-top: 0.75rem;
}
.block {
display: block;
}
.inline-block {
display: inline-block;
}
.flex {
display: flex;
}
.grid {
display: grid;
}
.list-item {
display: list-item;
}
.hidden {
display: none;
}
.h-0 {
height: 0px;
}
.h-0\.5 {
height: 0.125rem;
}
.h-2 {
height: 0.5rem;
}
.h-3 {
height: 0.75rem;
}
.h-full {
height: 100%;
}
.max-h-\[150px\] {
max-height: 150px;
}
.max-h-\[250px\] {
max-height: 250px;
}
.min-h-\[150px\] {
min-height: 150px;
}
.w-0 {
width: 0px;
}
.w-0\.5 {
width: 0.125rem;
}
.w-2 {
width: 0.5rem;
}
.w-20 {
width: 5rem;
}
.w-full {
width: 100%;
}
.max-w-full {
max-width: 100%;
}
.flex-grow {
flex-grow: 1;
}
.list-decimal {
list-style-type: decimal;
}
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-cols-\[1fr\2c 4fr\] {
grid-template-columns: 1fr 4fr;
}
.grid-cols-\[30\%\2c 70\%\] {
grid-template-columns: 30% 70%;
}
.grid-cols-\[5\%\2c 90\%\2c 5\%\] {
grid-template-columns: 5% 90% 5%;
}
.flex-col {
flex-direction: column;
}
.content-start {
align-content: flex-start;
}
.justify-start {
justify-content: flex-start;
}
.gap-4 {
gap: 1rem;
}
.space-x-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-x-3 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.75rem * var(--tw-space-x-reverse));
margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse)));
}
.space-y-2 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.5rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.5rem * var(--tw-space-y-reverse));
}
.space-y-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(1rem * var(--tw-space-y-reverse));
}
.place-self-center {
place-self: center;
}
.self-center {
align-self: center;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rounded-full {
border-radius: 9999px;
}
.rounded-b-\[4rem\] {
border-bottom-right-radius: 4rem;
border-bottom-left-radius: 4rem;
}
.rounded-t-\[1rem\] {
border-top-left-radius: 1rem;
border-top-right-radius: 1rem;
}
.bg-gray-200 {
--tw-bg-opacity: 1;
background-color: rgb(229 231 235 / var(--tw-bg-opacity));
}
.bg-gray-300 {
--tw-bg-opacity: 1;
background-color: rgb(209 213 219 / var(--tw-bg-opacity));
}
.bg-orange-600 {
--tw-bg-opacity: 1;
background-color: rgb(234 88 12 / var(--tw-bg-opacity));
}
.object-cover {
-o-object-fit: cover;
object-fit: cover;
}
.px-10 {
padding-left: 2.5rem;
padding-right: 2.5rem;
}
.pb-10 {
padding-bottom: 2.5rem;
}
.pt-2 {
padding-top: 0.5rem;
}
.pt-4 {
padding-top: 1rem;
}
.pt-6 {
padding-top: 1.5rem;
}
.pt-8 {
padding-top: 2rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem;
}
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.font-medium {
font-weight: 500;
}
.font-normal {
font-weight: 400;
}
.font-semibold {
font-weight: 600;
}
.tracking-wide {
letter-spacing: 0.025em;
}
.text-gray-500 {
--tw-text-opacity: 1;
color: rgb(107 114 128 / var(--tw-text-opacity));
}
.text-gray-700 {
--tw-text-opacity: 1;
color: rgb(55 65 81 / var(--tw-text-opacity));
}
.text-orange-600 {
--tw-text-opacity: 1;
color: rgb(234 88 12 / var(--tw-text-opacity));
}
.filter {
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
}
.transition-all {
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.hover\:bg-gray-300:hover {
--tw-bg-opacity: 1;
background-color: rgb(209 213 219 / var(--tw-bg-opacity));
}
@media (min-width: 640px) {
.sm\:block {
display: block;
}
.sm\:grid {
display: grid;
}
.sm\:hidden {
display: none;
}
.sm\:max-h-full {
max-height: 100%;
}
.sm\:grid-cols-\[10\%\2c 80\%\2c 10\%\] {
grid-template-columns: 10% 80% 10%;
}
.sm\:space-x-6 > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(1.5rem * var(--tw-space-x-reverse));
margin-left: calc(1.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.sm\:space-y-0 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0px * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0px * var(--tw-space-y-reverse));
}
.sm\:px-6 {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.sm\:pb-6 {
padding-bottom: 1.5rem;
}
}
@media (min-width: 1024px) {
.lg\:grid-cols-\[25\%\2c 50\%\2c 25\%\] {
grid-template-columns: 25% 50% 25%;
}
}

10
tailwind.config.js Normal file
View File

@ -0,0 +1,10 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: {
files: ["*.html", "./src/**/*.rs"],
},
theme: {
extend: {}
},
plugins: []
}