Ajout tags #7
17 changed files with 436 additions and 387 deletions
467
Cargo.lock
generated
467
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
20
Cargo.toml
20
Cargo.toml
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "portfolio"
|
||||
version = "0.2.3"
|
||||
version = "0.4.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
@ -10,10 +10,9 @@ crate-type = ["cdylib", "rlib"]
|
|||
|
||||
[dependencies]
|
||||
leptos = { version = "0.5", features = ["nightly"] }
|
||||
leptos_actix = { version = "0.5", optional = true }
|
||||
leptos_meta = { version = "0.5", features = ["nightly"] }
|
||||
leptos_router = { version = "0.5", features = ["nightly"] }
|
||||
gloo-net = { version = "0.4", features = ["http"] }
|
||||
gloo-net = { version = "0.5", features = ["http"] }
|
||||
log = "0.4"
|
||||
cfg-if = "1.0"
|
||||
serde = "1.0"
|
||||
|
@ -30,16 +29,20 @@ leptos_icons = { version = "0.1.0", features = [
|
|||
|
||||
# dependecies for client (enable when csr or hydrate set)
|
||||
wasm-bindgen = { version = "0.2", optional = true }
|
||||
console_log = { version = "1", optional = true }
|
||||
console_log = { version = "1.0", optional = true }
|
||||
console_error_panic_hook = { version = "0.1", optional = true }
|
||||
|
||||
# dependecies for server (enable when ssr set)
|
||||
actix-files = { version = "0.6", optional = true }
|
||||
actix-web = { version = "4", features = ["macros"], optional = true }
|
||||
actix-web = { version = "4.4", features = ["macros"], optional = true }
|
||||
leptos_actix = { version = "0.5", optional = true }
|
||||
futures = { version = "0.3", optional = true }
|
||||
simple_logger = { version = "4.2", optional = true }
|
||||
simple_logger = { version = "4.3", optional = true }
|
||||
pulldown-cmark = { version = "0.9", optional = true } # Markdown parser
|
||||
gray_matter = { version = "0.2", optional = true } # frontmatter parser
|
||||
serde_yaml = { version = "0.9", optional = true }
|
||||
anyhow = { version = "1.0", optional = true }
|
||||
thiserror = { version = "1.0", optional = true }
|
||||
|
||||
[features]
|
||||
default = ["csr"]
|
||||
|
@ -70,6 +73,9 @@ ssr = [
|
|||
"dep:simple_logger",
|
||||
"dep:pulldown-cmark",
|
||||
"dep:gray_matter",
|
||||
"dep:serde_yaml",
|
||||
"dep:anyhow",
|
||||
"dep:thiserror"
|
||||
]
|
||||
|
||||
[package.metadata.cargo-all-features]
|
||||
|
@ -94,7 +100,9 @@ opt-level = 'z'
|
|||
name = "portfolio"
|
||||
site-root = "target/site"
|
||||
site-pkg-dir = "pkg"
|
||||
style-file = "style/main.scss" # Important for Hot Reload to work properly (File must exist)
|
||||
tailwind-input-file = "style/portfolio.css"
|
||||
watch-additional-files = ["data_src"]
|
||||
assets-dir = "public"
|
||||
site-addr = "127.0.0.1:3000"
|
||||
reload-port = 3001
|
||||
|
|
|
@ -33,11 +33,11 @@ COPY --from=builder /app/target/site /app/site
|
|||
# Copy Cargo.toml if it’s needed at runtime
|
||||
COPY --from=builder /app/Cargo.toml /app/
|
||||
# Copy all data files
|
||||
COPY --from=builder /app/markdowns /app/markdowns
|
||||
COPY --from=builder /app/data_src /app/data_src
|
||||
WORKDIR /app
|
||||
|
||||
# Set any required env variables and
|
||||
ENV RUST_LOG="info"
|
||||
ENV RUST_LOG="warn"
|
||||
ENV APP_ENVIRONMENT="production"
|
||||
ENV LEPTOS_SITE_ADDR="0.0.0.0:8080"
|
||||
ENV LEPTOS_SITE_ROOT="site"
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
---
|
||||
image_path: "https://upload.wikimedia.org/wikipedia/commons/thumb/1/11/Test-Logo.svg/783px-Test-Logo.svg.png?20150906031702"
|
||||
slug: test_layout
|
||||
title: Testing layout
|
||||
date: 2023-11-26
|
||||
description: Testing the layout of the site.
|
||||
project_link: none
|
||||
draft: true
|
||||
tags:
|
||||
- test
|
||||
---
|
||||
|
||||
# Heading 1
|
|
@ -31,4 +31,4 @@ mod title;
|
|||
|
||||
pub use title::Title;
|
||||
|
||||
pub use mon_parcours::{MonParcours};
|
||||
pub use mon_parcours::MonParcours;
|
||||
|
|
|
@ -62,7 +62,7 @@ const GRADLE_TAG : Lang<'static> = Lang { lang: "Gradle", url: "https://gradle.o
|
|||
const BABYLONJS_TAG : Lang<'static> = Lang { lang: "BabylonJS", url: "https://www.babylonjs.com/" };
|
||||
const ROCKET_RS_TAG : Lang<'static> = Lang { lang: "Rocket", url: "https://rocket.rs/" };
|
||||
const ACTIX_WEB_TAG : Lang<'static> = Lang { lang: "Actix Web", url: "https://actix.rs/" };
|
||||
const LEPTOS_TAG : Lang<'static> = Lang { lang: "Leptos", url: "https://leptos.dev/" };
|
||||
const _LEPTOS_TAG : Lang<'static> = Lang { lang: "Leptos", url: "https://leptos.dev/" };
|
||||
|
||||
|
||||
#[component]
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
use leptos::*;
|
||||
use leptos_router::A;
|
||||
|
||||
#[component]
|
||||
pub fn Title(
|
||||
|
@ -8,7 +9,7 @@ pub fn Title(
|
|||
) -> impl IntoView {
|
||||
view! {
|
||||
<header>
|
||||
<a href=href>r"< Retour"</a>
|
||||
<A href=href>r"< Retour"</A>
|
||||
<h1>{title}</h1>
|
||||
<span></span>
|
||||
</header>
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
pub mod components;
|
||||
|
||||
pub mod models;
|
||||
|
||||
mod pages;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
mod utils;
|
||||
|
||||
use leptos::*;
|
||||
use leptos_meta::*;
|
||||
use leptos_router::*;
|
||||
|
|
|
@ -1,3 +1,50 @@
|
|||
mod post;
|
||||
|
||||
pub use post::Post;
|
||||
pub use post::Post;
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct Data {
|
||||
pub posts: Vec<Arc<Post>>,
|
||||
pub posts_by_slug: HashMap<String, Arc<Post>>,
|
||||
pub posts_by_tag: HashMap<String, Vec<Arc<Post>>>,
|
||||
}
|
||||
|
||||
impl Data {
|
||||
#[allow(dead_code)] // Use in main.rs
|
||||
pub fn new() -> anyhow::Result<Self> {
|
||||
let posts = crate::app::utils::data_src::get_all::<Post>("posts")?
|
||||
.into_iter()
|
||||
.map(Arc::new)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut posts_by_slug : HashMap<String, Arc<Post>> = HashMap::new();
|
||||
let mut posts_by_tag : HashMap<String, Vec<Arc<Post>>> = HashMap::new();
|
||||
|
||||
for post in &posts {
|
||||
posts_by_slug.insert(post.metadata.slug.clone(), post.clone());
|
||||
|
||||
for tag in &post.metadata.tags {
|
||||
let tag = tag.to_lowercase();
|
||||
let posts = posts_by_tag.entry(tag).or_default();
|
||||
posts.push(post.clone());
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Loaded {} posts", posts.len());
|
||||
log::info!("Found {} tags", posts_by_tag.len());
|
||||
|
||||
Ok(Self {
|
||||
posts,
|
||||
posts_by_slug,
|
||||
posts_by_tag,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
|
@ -1,34 +1,43 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct PostMetadata {
|
||||
pub slug: String,
|
||||
pub image_path: String,
|
||||
pub title: String,
|
||||
pub date: String,
|
||||
pub description: String,
|
||||
pub project_link: String,
|
||||
pub draft: bool
|
||||
pub draft: bool,
|
||||
pub tags: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug)]
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct Post {
|
||||
pub slug : String,
|
||||
pub metadata: PostMetadata,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "ssr")] {
|
||||
impl Post {
|
||||
fn from_content(content: String) -> Option<Self> {
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PostDeserializationError {
|
||||
#[error("Invalid front matter")]
|
||||
InvalidFrontMatter,
|
||||
#[error("Invalid markdown")]
|
||||
InvalidMarkdown,
|
||||
}
|
||||
|
||||
impl TryFrom<String> for Post {
|
||||
type Error = PostDeserializationError;
|
||||
|
||||
fn try_from(content: String) -> Result<Self, Self::Error> {
|
||||
use gray_matter::{Matter, engine::YAML};
|
||||
let matter = Matter::<YAML>::new();
|
||||
|
||||
let post_data = matter
|
||||
.parse_with_struct::<PostMetadata>(&content)?;
|
||||
.parse_with_struct::<PostMetadata>(&content)
|
||||
.ok_or_else(|| PostDeserializationError::InvalidFrontMatter)?;
|
||||
|
||||
let metadata = post_data.data;
|
||||
let slug = format!("{}_{}", metadata.date, metadata.title.to_lowercase().replace(' ', "_"));
|
||||
let content = post_data.content;
|
||||
|
||||
use pulldown_cmark::{Parser, Options, html};
|
||||
|
@ -36,49 +45,14 @@ cfg_if::cfg_if! {
|
|||
let mut html_output = String::new();
|
||||
html::push_html(&mut html_output, parser);
|
||||
|
||||
Some(Self {
|
||||
slug,
|
||||
metadata,
|
||||
content: html_output,
|
||||
})
|
||||
}
|
||||
|
||||
fn from_path(path: &std::path::Path) -> Option<Self> {
|
||||
let content = std::fs::read_to_string(path);
|
||||
let content = match content {
|
||||
Ok(content) => content,
|
||||
Err(e) => {
|
||||
eprintln!("{:?}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Self::from_content(content)
|
||||
}
|
||||
|
||||
pub fn get_all(folder: &str) -> Result<Vec<Self>, String> {
|
||||
use std::{path::Path, fs::read_dir};
|
||||
|
||||
let mut posts: Vec<Self> = Vec::new();
|
||||
|
||||
let folder_path = Path::new("markdowns").join(folder);
|
||||
let paths = read_dir(folder_path).map_err(|e| e.to_string())?;
|
||||
|
||||
for path_result in paths {
|
||||
let path = match path_result {
|
||||
Ok(path) => path.path(),
|
||||
Err(e) => {
|
||||
eprintln!("{:?}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(post) = Self::from_path(&path) {
|
||||
posts.push(post);
|
||||
}
|
||||
if html_output.is_empty() {
|
||||
return Err(PostDeserializationError::InvalidMarkdown);
|
||||
}
|
||||
|
||||
Ok(posts)
|
||||
Ok(Self {
|
||||
metadata: post_data.data,
|
||||
content: html_output,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,64 +9,113 @@ use crate::app::{
|
|||
};
|
||||
|
||||
#[server]
|
||||
pub async fn get_posts() -> Result<Vec<Post>, ServerFnError> {
|
||||
let posts = Post::get_all("posts")
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
|
||||
|
||||
Ok(posts)
|
||||
pub async fn get_posts(
|
||||
tag: Option<String>
|
||||
) -> Result<Vec<Post>, ServerFnError> {
|
||||
leptos_actix::extract(
|
||||
|data: actix_web::web::Data<crate::app::models::Data>| async move {
|
||||
let data = data.into_inner();
|
||||
let default = vec![];
|
||||
let posts = match tag {
|
||||
Some(tag) => data.posts_by_tag.get(&tag).unwrap_or(&default),
|
||||
None => &data.posts
|
||||
};
|
||||
posts.iter()
|
||||
.map(|post| {
|
||||
Post {
|
||||
metadata: post.metadata.clone(),
|
||||
content: post.content.clone(),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>, >()
|
||||
},
|
||||
).await
|
||||
}
|
||||
|
||||
#[server]
|
||||
pub async fn get_post(
|
||||
slug: String
|
||||
) -> Result<Post, ServerFnError> {
|
||||
let posts = Post::get_all("posts")
|
||||
.map_err(|e| ServerFnError::ServerError(e.to_string()))?;
|
||||
leptos_actix::extract(
|
||||
|data: actix_web::web::Data<crate::app::models::Data>| async move {
|
||||
let data = data.into_inner();
|
||||
data.posts_by_slug.get(&slug)
|
||||
.and_then(|post| Some(Post {
|
||||
metadata: post.metadata.clone(),
|
||||
content: post.content.clone(),
|
||||
}))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.and_then(|post| post.ok_or_else(|| ServerFnError::ServerError("Post not found".to_string())))
|
||||
}
|
||||
|
||||
let post = posts.into_iter().find(|post| post.slug == slug)
|
||||
.ok_or(ServerFnError::ServerError("Post not found".to_string()))?;
|
||||
#[component]
|
||||
pub fn PostTags(
|
||||
tags: Vec<String>
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div class="tags_list">
|
||||
{
|
||||
tags.into_iter().map(|tag| view! { <A class="tag" href=format!("/posts?tag={}", tag)>{tag}</A>}).collect_view()
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
Ok(post)
|
||||
#[component]
|
||||
pub fn PostListCard(
|
||||
post: Post
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div>
|
||||
<img src={post.metadata.image_path.clone()} alt=format!("Image {}", post.metadata.title)/>
|
||||
|
||||
{
|
||||
if post.metadata.draft {
|
||||
Some(view!{
|
||||
<div class="warning">
|
||||
<Icon icon=Icon::from(IoIcon::IoConstruct)/>
|
||||
</div>
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
<div>
|
||||
<PostTags tags=post.metadata.tags.clone()/>
|
||||
<h2><A href=format!("/posts/{}", post.metadata.slug.clone())>{post.metadata.title.clone()}</A></h2>
|
||||
<p>{post.metadata.description.clone()}</p>
|
||||
<span>{post.metadata.date.clone()}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn PostList() -> impl IntoView {
|
||||
let posts = create_resource(|| (), |_| get_posts());
|
||||
let query = use_query_map();
|
||||
let tag = move || query.with(|query| query.get("tag").cloned());
|
||||
let posts = create_resource(move || tag(), move |_| get_posts(tag()));
|
||||
|
||||
let posts_view = move || {
|
||||
posts.and_then(|posts| {
|
||||
posts.iter()
|
||||
.map(|post| view! {
|
||||
<a href=format!("posts/{}", post.slug.clone())>
|
||||
<img src={post.metadata.image_path.clone()} alt=format!("Image {}", post.metadata.title)/>
|
||||
|
||||
{
|
||||
if post.metadata.draft {
|
||||
Some(view!{
|
||||
<div class="warning">
|
||||
<Icon icon=Icon::from(IoIcon::IoConstruct)/>
|
||||
</div>
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
<div>
|
||||
<h2>{post.metadata.title.clone()}</h2>
|
||||
<p>{post.metadata.description.clone()}</p>
|
||||
<span>{post.metadata.date.clone()}</span>
|
||||
</div>
|
||||
</a>
|
||||
})
|
||||
.map(|post| view! { <PostListCard post=post.clone() /> })
|
||||
.collect_view()
|
||||
})
|
||||
};
|
||||
|
||||
let title = move || match tag() {
|
||||
Some(tag) => view! { <Title href="/posts".to_string() title=format!("Posts for {}", tag)/> },
|
||||
None => view! { <Title href="/".to_string() title="Posts".to_string()/> }
|
||||
};
|
||||
|
||||
view! {
|
||||
<Suspense fallback=move || view! { <Loading title="Chargement des posts...".to_string() /> }>
|
||||
<main class="posts">
|
||||
<Title href="/".to_string() title="Posts".to_string()/>
|
||||
{ title }
|
||||
|
||||
<div class="posts__cards">{posts_view}</div>
|
||||
</main>
|
||||
|
@ -86,6 +135,7 @@ pub fn PostElement() -> impl IntoView {
|
|||
view! {
|
||||
<>
|
||||
<Title href="/posts".to_string() title=post.metadata.title.clone()/>
|
||||
<PostTags tags=post.metadata.tags.clone()/>
|
||||
{
|
||||
if post.metadata.draft {
|
||||
Some(view!{
|
||||
|
|
33
src/app/utils/data_src.rs
Normal file
33
src/app/utils/data_src.rs
Normal file
|
@ -0,0 +1,33 @@
|
|||
#[allow(dead_code)] // Use in main.rs
|
||||
pub fn get_all<T>(folder: &str) -> anyhow::Result<Vec<T>>
|
||||
where
|
||||
T: TryFrom<String>,
|
||||
{
|
||||
use std::{path::Path, fs::read_dir};
|
||||
|
||||
let mut datas: Vec<T> = Vec::new();
|
||||
|
||||
let folder_path = Path::new("data_src").join(folder);
|
||||
let paths = read_dir(folder_path)?;
|
||||
|
||||
for path_result in paths {
|
||||
let data : Result<T, String> = path_result
|
||||
.and_then(|path| std::fs::File::open(path.path()))
|
||||
.map_err(|e| format!("Open file Error: {:?}", e))
|
||||
.and_then(|mut file| {
|
||||
use std::io::Read;
|
||||
let mut content = String::new();
|
||||
file.read_to_string(&mut content)
|
||||
.map_err(|e| format!("Read file Error: {:?}", e))?;
|
||||
T::try_from(content)
|
||||
.map_err(|_e| "Parse file Error".to_string())
|
||||
});
|
||||
|
||||
match data {
|
||||
Ok(data) => datas.push(data),
|
||||
Err(e) => log::warn!("{}", e),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(datas)
|
||||
}
|
1
src/app/utils/mod.rs
Normal file
1
src/app/utils/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub(crate) mod data_src;
|
|
@ -12,10 +12,10 @@ cfg_if! {
|
|||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
logging::log!("hydrate mode - hydrating");
|
||||
logging::log!("Portfolio version : {}", env!("CARGO_PKG_VERSION"));
|
||||
log::info!("hydrate mode - hydrating");
|
||||
log::info!("Portfolio version : {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
leptos::mount_to_body(|| {
|
||||
mount_to_body(|| {
|
||||
view! { <App/> }
|
||||
});
|
||||
}
|
||||
|
@ -30,7 +30,8 @@ cfg_if! {
|
|||
_ = console_log::init_with_level(log::Level::Debug);
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
logging::log!("csr mode - mounting to body");
|
||||
log::info!("csr mode - mounting to body");
|
||||
log::info!("Portfolio version : {}", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
mount_to_body(|| {
|
||||
view! { <App /> }
|
||||
|
|
19
src/main.rs
19
src/main.rs
|
@ -8,9 +8,26 @@ cfg_if! {
|
|||
use leptos::*;
|
||||
use crate::app::*;
|
||||
use leptos_actix::{generate_route_list, LeptosRoutes};
|
||||
use simple_logger::SimpleLogger;
|
||||
|
||||
#[get("/style.css")]
|
||||
async fn css() -> impl Responder {
|
||||
actix_files::NamedFile::open_async("./style/output.css").await
|
||||
}
|
||||
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
// Configure logger
|
||||
SimpleLogger::new()
|
||||
.with_level(log::LevelFilter::Info)
|
||||
.env()
|
||||
.init()
|
||||
.unwrap();
|
||||
|
||||
// Load required data
|
||||
let data = web::Data::<models::Data>::new(models::Data::new().unwrap());
|
||||
|
||||
// Setting this to None means we'll be using cargo-leptos and its env vars.
|
||||
let conf = get_configuration(None).await.unwrap();
|
||||
|
||||
|
@ -24,8 +41,10 @@ cfg_if! {
|
|||
let site_root = &leptos_options.site_root;
|
||||
let routes = &routes;
|
||||
App::new()
|
||||
.app_data(web::Data::clone(&data)) // Must panic if data can't be loaded
|
||||
.route("/api/{tail:.*}", leptos_actix::handle_server_fns())
|
||||
.leptos_routes(leptos_options.to_owned(), routes.to_owned(), || view! { <App/> })
|
||||
.service(css)
|
||||
.service(Files::new("/", site_root))
|
||||
.wrap(middleware::Compress::default())
|
||||
})
|
||||
|
|
0
style/main.scss
Normal file
0
style/main.scss
Normal file
|
@ -42,13 +42,21 @@
|
|||
& ol li {
|
||||
@apply list-decimal list-inside;
|
||||
}
|
||||
|
||||
& blockquote {
|
||||
@apply border-l-4 border-primary/50 dark:border-dark_primary/50 pl-3 my-3;
|
||||
}
|
||||
|
||||
& .tags_list {
|
||||
@apply flex flex-row flex-wrap gap-2;
|
||||
}
|
||||
}
|
||||
|
||||
.posts {
|
||||
& > .posts__cards {
|
||||
@apply grid grid-cols-1 gap-5 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 m-5;
|
||||
|
||||
& > a {
|
||||
& > div {
|
||||
@apply rounded-xl overflow-hidden relative;
|
||||
@apply bg-primary/10 dark:bg-dark_primary/10;
|
||||
@apply shadow-md shadow-primary/5 dark:shadow-dark_primary/5;
|
||||
|
|
Loading…
Reference in a new issue