Add WIP blog

This commit is contained in:
Tanguy Gérôme 2025-05-30 00:49:42 +03:00
parent d2702ae39f
commit f2357759f1
Signed by: tanguy
GPG key ID: 10B2947233740B88
8 changed files with 195 additions and 11 deletions

View file

@ -5,7 +5,7 @@ partial_translations: (partial translations)
resume: Resume
blog: Blog
welcome_blog: Welcome to my blog!
blog_latest_posts: Latests posts
gallery: Gallery

View file

@ -5,7 +5,7 @@ partial_translations: (keskeneräisiä käännöksiä)
resume: Ansioluettelo
blog: Blogi
welcome_blog: Tervetuloa blogiini!
blog_latest_posts: Viimeisimmät postaukset
gallery: Galleria

View file

@ -5,7 +5,7 @@ partial_translations: (traductions partielles)
resume: CV
blog: Blog
welcome_blog: Bienvenue sur mon blog!
blog_latest_posts: Dernier billets
gallery: Gallerie

View file

@ -50,6 +50,10 @@ pub fn App() -> impl IntoView {
<I18nRoute<Locale, _, _> view=Outlet>
<Route path=path!("/") view=HomePage/>
<Route path=path!("/resume") view=Resume/>
<ParentRoute path=path!("/blog") view=crate::blog::Blog>
<Route path=path!(":slug") view=crate::blog::BlogPost/>
<Route path=path!("") view=|| view! {}/>
</ParentRoute>
<ParentRoute path=path!("/gallery") view=crate::gallery::Gallery>
<Route path=path!(":slug") view=crate::gallery::GalleryEntry/>
<Route path=path!("") view=|| view! {}/>
@ -92,10 +96,9 @@ pub fn Header() -> impl IntoView {
<h1><span>tanguy</span><span>.gerome</span><span>.fi</span></h1>
<div class="links">
<A href="/">{t!(i18n, home)}</A>
<A href="/resume">{t!(i18n, resume)}</A>
// <A href="/blog">{t!(i18n, blog)}</A>
<A href="/blog">{t!(i18n, blog)}</A>
<A href="/gallery">{t!(i18n, gallery)}</A>
// <A href="/resume">{t!(i18n, resume)}</A>
<A href="/resume">{t!(i18n, resume)}</A>
</div>
</div>
<picture>

113
src/blog.rs Normal file
View file

@ -0,0 +1,113 @@
use leptos::{
Params,
prelude::*
};
use serde::{Deserialize, Serialize};
use leptos_router::{
components::Outlet, hooks::use_params, params::Params
};
use crate::{i18n::*, services::directus::Asset};
#[derive(Clone, Debug, Serialize, Deserialize, Default)]
pub struct BlogPostTranslations {
title: String,
content: String
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BlogPost {
slug: String,
translations: Option<BlogPostTranslations>,
illustration: Asset,
}
#[server(GetBlog, "/api", "GetJson")]
pub async fn get_blog_posts(locale: String) -> Result<Vec<BlogPost>, ServerFnError> {
crate::services::directus::get_client()
.get_all_items_in_collection::<BlogPost>(&locale, "blog_posts").await
.map_err(|e| ServerFnError::ServerError(e.to_string()))
}
#[component]
pub fn Blog() -> impl IntoView {
let i18n = use_i18n();
let blog_posts = Resource::new(move || i18n.get_locale(), move |locale| get_blog_posts(locale.to_string()));
view! {
<Outlet/>
<main class="main-width blog-wrapper">
<h2>{t!(i18n, blog_latest_posts)}:</h2>
<Suspense fallback=move || view! { <div>"Loading..."</div> }>
{move || blog_posts.get()
.and_then(|blog_posts| blog_posts.ok())
.and_then(|blog_posts| {
view! {
<div class="blog-entries">
{
blog_posts.into_iter().map(|post| {
view! {
<a class="blog-thumbnail" href={format!("/blog/{}", post.slug)}>
<img src=format!("https://directus.gerome.fi/assets/{}?width=600&height=400&fit=cover&format=auto&quality=80&withoutEnlargement=true", post.illustration.id)/>
<p class="blog-title">{post.translations.unwrap_or_default().title}</p>
</a>
}
}).collect::<Vec<_>>()
}
</div>
}.into()
})
}
</Suspense>
</main>
}
}
#[derive(Params, PartialEq)]
struct BlogPostParams {
slug: Option<String>,
}
#[component]
pub fn BlogPost() -> impl IntoView {
let i18n = use_i18n();
let params = use_params::<BlogPostParams>();
let slug = move || {
params .read()
.as_ref()
.ok()
.and_then(|params| params.slug.clone())
.unwrap_or_default()
};
let blog_posts = Resource::new(move || i18n.get_locale(), move |locale| get_blog_posts(locale.to_string()));
view! {
<Suspense fallback=move || view! { <main class="main-width blog-entry-wrapper"><div>"Loading..."</div></main> }>
{move || blog_posts.get()
.and_then(|blog_posts| blog_posts.ok())
.and_then(|blog_posts| blog_posts.iter()
.find(|post| post.slug == slug())
.map(|post| post.clone()))
.and_then(|post| {
let translations = post.translations.unwrap_or_default();
let html = markdown::to_html(&translations.content);
view! {
<div class="blog-open-entry">
<div class="blog-illustration">
<img src=format!("https://directus.gerome.fi/assets/{}?width=1600&height=1000&fit=inside&format=auto&quality=90&withoutEnlargement=true", post.illustration.id)/>
<h2 class="blog-title">{translations.title}</h2>
</div>
<main class="main-width blog-entry-wrapper">
<div class="markdown" inner_html=html/>
</main>
</div>
}.into()
})
}
</Suspense>
}
}

View file

@ -4,6 +4,7 @@ pub mod app;
pub mod services;
pub mod blog;
pub mod gallery;
#[cfg(feature = "hydrate")]

View file

@ -58,7 +58,7 @@ impl DirectusClient {
}
pub async fn get_all_items_in_collection<T: for<'a> Deserialize<'a>>(&self, locale: &str, collection: &str) -> Result<Vec<T>> {
let response = self.reqwest_client.get(format!("{}/items/{}?fields=*,*.*,translations.*", self.base_url, collection))
let response = self.reqwest_client.get(format!("{}/items/{}?fields=*,*.*,translations.*&sort=-date_created", self.base_url, collection))
.header("Authorization", format!("Bearer {}", self.access_token))
.send().await?;
let json = &response.json::<serde_json::Value>().await?;

View file

@ -233,8 +233,8 @@ body {
}
}
.gallery-entries {
width: fit-content;
.gallery-entries, .blog-entries {
width: 100%;
display: flex;
flex-direction: row;
flex-wrap: wrap;
@ -243,10 +243,77 @@ body {
gap: 16px;
}
.blog-open-entry {
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
align-items: center;
.blog-illustration {
position: relative;
img {
max-height: 60vh;
width: 100vw;
max-width: 100vw;
object-fit: cover;
}
.blog-title {
display: block;
width: 100%;
position: absolute;
bottom: 0px;
padding: 16px;
padding-top: 64px;
margin: 0;
background: linear-gradient(rgba(0, 0, 0, 0.0), rgba(0, 0, 0, 1.0));
color: white;
}
}
}
.blog-wrapper {
max-width: 1250px!important;
width: fit-content;
}
.blog-thumbnail {
position: relative;
img {
height: 100%;
width: 100%;
max-height: 300px;
max-width: 500px;
object-fit: contain;
}
.blog-title {
text-align: center;
display: block;
width: 100%;
position: absolute;
bottom: 0px;
padding: 16px;
padding-top: 64px;
margin: 0;
background: linear-gradient(rgba(0, 0, 0, 0.0), rgba(0, 0, 0, 1.0));
color: white;
}
}
.gallery-wrapper {
max-width: 1000px!important;
width: fit-content;
}
.gallery-thumbnail {
img {
height: 400px;
width: 400px;
height: 300px;
width: 300px;
object-fit: contain;
@media all and (max-width: 1000px) {