Compare commits
2 Commits
fb6ca6c245
...
d9d17bb971
Author | SHA1 | Date | |
---|---|---|---|
d9d17bb971 | |||
11cc9f6d0a |
@ -25,6 +25,7 @@ anyhow = "1.0.86"
|
|||||||
rayon = "1.10.0"
|
rayon = "1.10.0"
|
||||||
syntect = "5.2.0"
|
syntect = "5.2.0"
|
||||||
indoc = "2.0.5"
|
indoc = "2.0.5"
|
||||||
|
askama_escape = "0.10.3"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
rustflags = ["-Z", "threads=8"]
|
rustflags = ["-Z", "threads=8"]
|
||||||
|
@ -1,10 +1,5 @@
|
|||||||
---
|
---
|
||||||
title: Portfolio - Michal Vanko
|
title: Portfolio - Michal Vanko
|
||||||
work_history_prelude: >-
|
|
||||||
I've started learning web development when I was 14 years old. My very first
|
|
||||||
website was a presentation site for my own _Counter-Strike_ clan.
|
|
||||||
|
|
||||||
Then I had an opportunity to create a registration system for marathon runners for Europe's oldest marathon event. That basically started off my career as a web developer. I've worked on some projects while I was studying in high school and university. After that, I've started to work full-time as a web developer and gained more experience in developing real-time web applications.
|
|
||||||
work_history:
|
work_history:
|
||||||
- description: >-
|
- description: >-
|
||||||
_sudolabs_ is a company focused on building products and software
|
_sudolabs_ is a company focused on building products and software
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
use chrono::{DateTime, Utc};
|
|
||||||
use tracing::debug;
|
|
||||||
|
|
||||||
// This filter does not have extra arguments
|
|
||||||
pub fn pretty_date(date_time: &DateTime<Utc>) -> ::askama::Result<String> {
|
|
||||||
let formatted = format!("{}", date_time.format("%e %B %Y"));
|
|
||||||
Ok(formatted)
|
|
||||||
}
|
|
||||||
|
|
||||||
// This filter does not have extra arguments
|
|
||||||
pub fn description_filter(body: &str) -> ::askama::Result<String> {
|
|
||||||
let description = body
|
|
||||||
.lines()
|
|
||||||
.filter(|line| line.starts_with("<p>"))
|
|
||||||
.take(2)
|
|
||||||
.collect::<Vec<&str>>()
|
|
||||||
.join("\n");
|
|
||||||
debug!(description);
|
|
||||||
Ok(description)
|
|
||||||
}
|
|
170
src/filters/markdown.rs
Normal file
170
src/filters/markdown.rs
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use image::image_dimensions;
|
||||||
|
use indoc::formatdoc;
|
||||||
|
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
|
||||||
|
use syntect::{highlighting::ThemeSet, html::highlighted_html_for_string, parsing::SyntaxSet};
|
||||||
|
use tracing::{debug, error};
|
||||||
|
|
||||||
|
use crate::picture_generator::{
|
||||||
|
picture_markup_generator::generate_picture_markup, resolutions::get_max_resolution,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const MAX_BLOG_IMAGE_RESOLUTION: (u32, u32) = (1280, 860);
|
||||||
|
|
||||||
|
enum TextKind {
|
||||||
|
Text,
|
||||||
|
Heading(Option<String>),
|
||||||
|
Code(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
// pub fn parse_markdown(markdown: &str) -> ::askama::Result<String>
|
||||||
|
pub fn parse_markdown(markdown: &str) -> ::askama::Result<String> {
|
||||||
|
let mut options = Options::empty();
|
||||||
|
options.insert(Options::ENABLE_TABLES);
|
||||||
|
options.insert(Options::ENABLE_FOOTNOTES);
|
||||||
|
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||||
|
options.insert(Options::ENABLE_TASKLISTS);
|
||||||
|
options.insert(Options::ENABLE_SMART_PUNCTUATION);
|
||||||
|
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
|
||||||
|
|
||||||
|
let mut text_kind = TextKind::Text;
|
||||||
|
let syntax_set = SyntaxSet::load_defaults_newlines();
|
||||||
|
let theme_set = ThemeSet::load_defaults();
|
||||||
|
let theme = theme_set.themes.get("InspiredGitHub").unwrap();
|
||||||
|
let mut heading_ended: Option<bool> = None;
|
||||||
|
|
||||||
|
let parser = Parser::new_ext(markdown, options).map(|event| match event {
|
||||||
|
/*
|
||||||
|
Parsing images considers `alt` attribute as inner `Text` event
|
||||||
|
Therefore the `[alt]` is rendered in html as subtitle
|
||||||
|
and the `[](url "title")` `title` is rendered as `alt` attribute
|
||||||
|
*/
|
||||||
|
Event::Start(Tag::Image {
|
||||||
|
link_type: _,
|
||||||
|
dest_url,
|
||||||
|
title,
|
||||||
|
id: _,
|
||||||
|
}) => {
|
||||||
|
if !dest_url.starts_with("/") {
|
||||||
|
return Event::Html(
|
||||||
|
formatdoc!(
|
||||||
|
r#"<img
|
||||||
|
alt="{title}"
|
||||||
|
src="{dest_url}"
|
||||||
|
/>"#
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dev_only_img_path =
|
||||||
|
Path::new("static/").join(dest_url.strip_prefix("/").unwrap_or(&dest_url));
|
||||||
|
let img_dimensions = image_dimensions(&dev_only_img_path).unwrap();
|
||||||
|
|
||||||
|
let (max_width, max_height) = get_max_resolution(
|
||||||
|
img_dimensions,
|
||||||
|
MAX_BLOG_IMAGE_RESOLUTION.0,
|
||||||
|
MAX_BLOG_IMAGE_RESOLUTION.1,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Place image into the content with scaled reso to a boundary
|
||||||
|
let picture_markup = generate_picture_markup(
|
||||||
|
&dest_url, max_width, max_height, &title, None,
|
||||||
|
)
|
||||||
|
.unwrap_or(formatdoc!(
|
||||||
|
r#"
|
||||||
|
<img
|
||||||
|
alt="{alt}"
|
||||||
|
src="{src}"
|
||||||
|
/>"#,
|
||||||
|
alt = title,
|
||||||
|
src = dest_url,
|
||||||
|
));
|
||||||
|
Event::Html(
|
||||||
|
formatdoc!(
|
||||||
|
r#"<figure>
|
||||||
|
{picture_markup}
|
||||||
|
<figcaption>
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
|
||||||
|
text_kind = TextKind::Code(lang.to_string());
|
||||||
|
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang)))
|
||||||
|
}
|
||||||
|
Event::Text(text) => match &text_kind {
|
||||||
|
TextKind::Code(lang) => {
|
||||||
|
// TODO Check https://github.com/trishume/syntect/pull/535 for typescript support
|
||||||
|
let lang = if ["ts".to_string(), "typescript".to_string()].contains(lang) {
|
||||||
|
"javascript"
|
||||||
|
} else {
|
||||||
|
lang
|
||||||
|
};
|
||||||
|
let syntax_reference = syntax_set
|
||||||
|
.find_syntax_by_token(lang)
|
||||||
|
.unwrap_or(syntax_set.find_syntax_plain_text());
|
||||||
|
let highlighted =
|
||||||
|
highlighted_html_for_string(&text, &syntax_set, syntax_reference, theme)
|
||||||
|
.unwrap();
|
||||||
|
Event::Html(highlighted.into())
|
||||||
|
}
|
||||||
|
TextKind::Heading(provided_id) => {
|
||||||
|
let heading_id = provided_id.clone().unwrap_or({
|
||||||
|
text.to_lowercase()
|
||||||
|
.replace(|c: char| !c.is_alphanumeric(), "-")
|
||||||
|
});
|
||||||
|
debug!("heading_id: {}", heading_id.clone());
|
||||||
|
match heading_ended {
|
||||||
|
None => {
|
||||||
|
error!("Heading should have set state");
|
||||||
|
panic!("Heading should have set state");
|
||||||
|
}
|
||||||
|
Some(true) => Event::Html(text),
|
||||||
|
Some(false) => {
|
||||||
|
heading_ended = Some(true);
|
||||||
|
Event::Html(
|
||||||
|
formatdoc!(
|
||||||
|
r##"id="{heading_id}">
|
||||||
|
{text}"##
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Event::Text(text),
|
||||||
|
},
|
||||||
|
Event::Start(Tag::Heading {
|
||||||
|
level,
|
||||||
|
id,
|
||||||
|
classes: _,
|
||||||
|
attrs: _,
|
||||||
|
}) => {
|
||||||
|
let id_str = id.map(|id| id.to_string());
|
||||||
|
debug!("heading_start: {:?}, level: {}", &id_str, level);
|
||||||
|
text_kind = TextKind::Heading(id_str);
|
||||||
|
heading_ended = Some(false);
|
||||||
|
Event::Html(format!("<{level} ").into())
|
||||||
|
}
|
||||||
|
Event::Start(_) => event,
|
||||||
|
Event::End(TagEnd::Image) => Event::Html("</figcaption></figure>".into()),
|
||||||
|
Event::End(TagEnd::CodeBlock) => {
|
||||||
|
text_kind = TextKind::Text;
|
||||||
|
Event::End(TagEnd::CodeBlock)
|
||||||
|
}
|
||||||
|
Event::End(TagEnd::Heading(heading_level)) => {
|
||||||
|
text_kind = TextKind::Text;
|
||||||
|
heading_ended = None;
|
||||||
|
Event::End(TagEnd::Heading(heading_level))
|
||||||
|
}
|
||||||
|
_ => event,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write to String buffer
|
||||||
|
let mut html = String::new();
|
||||||
|
pulldown_cmark::html::push_html(&mut html, parser);
|
||||||
|
Ok(html)
|
||||||
|
}
|
6
src/filters/mod.rs
Normal file
6
src/filters/mod.rs
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
mod markdown;
|
||||||
|
mod pretty_date;
|
||||||
|
mod truncate_md;
|
||||||
|
pub use markdown::parse_markdown;
|
||||||
|
pub use pretty_date::pretty_date;
|
||||||
|
pub use truncate_md::truncate_md;
|
7
src/filters/pretty_date.rs
Normal file
7
src/filters/pretty_date.rs
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
// This filter does not have extra arguments
|
||||||
|
pub fn pretty_date(date_time: &DateTime<Utc>) -> ::askama::Result<String> {
|
||||||
|
let formatted = format!("{}", date_time.format("%e %B %Y"));
|
||||||
|
Ok(formatted)
|
||||||
|
}
|
18
src/filters/truncate_md.rs
Normal file
18
src/filters/truncate_md.rs
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
// This filter does not have extra arguments
|
||||||
|
|
||||||
|
const FORBIDDEN_LINES: [&str; 5] = [" ", "#", "-", "!", "<"];
|
||||||
|
|
||||||
|
pub fn truncate_md(body: &str, rows: usize) -> ::askama::Result<String> {
|
||||||
|
let description = body
|
||||||
|
.lines()
|
||||||
|
.filter(|line| {
|
||||||
|
!FORBIDDEN_LINES
|
||||||
|
.iter()
|
||||||
|
.any(|forbidden| line.starts_with(forbidden))
|
||||||
|
&& !line.is_empty()
|
||||||
|
})
|
||||||
|
.take(rows)
|
||||||
|
.collect::<Vec<&str>>()
|
||||||
|
.join("\n");
|
||||||
|
Ok(description)
|
||||||
|
}
|
@ -55,6 +55,5 @@ async fn main() {
|
|||||||
// - fotos
|
// - fotos
|
||||||
// THINK deploy to alula? rather then katelyn? can be change whenever
|
// THINK deploy to alula? rather then katelyn? can be change whenever
|
||||||
//
|
//
|
||||||
// TODO 404 page
|
|
||||||
// TODO view page transitions
|
// TODO view page transitions
|
||||||
// TODO cookbook
|
// TODO cookbook
|
||||||
|
@ -29,7 +29,7 @@ pub async fn render_blog_post(
|
|||||||
OriginalUri(original_uri): OriginalUri,
|
OriginalUri(original_uri): OriginalUri,
|
||||||
) -> Result<BlogPostTemplate, StatusCode> {
|
) -> Result<BlogPostTemplate, StatusCode> {
|
||||||
let path = format!("{}/{}.md", BLOG_POST_PATH, post_id);
|
let path = format!("{}/{}.md", BLOG_POST_PATH, post_id);
|
||||||
let parse_post = parse_post::<BlogPostMetadata>(&path, true);
|
let parse_post = parse_post::<BlogPostMetadata>(&path);
|
||||||
let parsed = parse_post.await?;
|
let parsed = parse_post.await?;
|
||||||
let segment = if original_uri.to_string().starts_with("/blog") {
|
let segment = if original_uri.to_string().starts_with("/blog") {
|
||||||
"blog"
|
"blog"
|
||||||
|
@ -5,6 +5,7 @@ pub mod broadcast_list;
|
|||||||
pub mod contact;
|
pub mod contact;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod not_found;
|
pub mod not_found;
|
||||||
|
pub mod portfolio;
|
||||||
pub mod post_list;
|
pub mod post_list;
|
||||||
pub mod project_list;
|
pub mod project_list;
|
||||||
pub mod showcase;
|
pub mod showcase;
|
||||||
|
46
src/pages/portfolio.rs
Normal file
46
src/pages/portfolio.rs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
components::site_header::HeaderProps,
|
||||||
|
filters,
|
||||||
|
post_utils::{
|
||||||
|
post_listing::get_post_list,
|
||||||
|
post_parser::{parse_post, ParseResult},
|
||||||
|
},
|
||||||
|
projects::project_model::ProjectMetadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct PortfolioPageModel {
|
||||||
|
pub title: String,
|
||||||
|
// TODO work_history
|
||||||
|
// TODO education
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "portfolio.html")]
|
||||||
|
pub struct PortfolioTemplate {
|
||||||
|
pub title: String,
|
||||||
|
pub body: String,
|
||||||
|
pub project_list: Vec<ParseResult<ProjectMetadata>>,
|
||||||
|
pub header_props: HeaderProps,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn render_portfolio() -> Result<PortfolioTemplate, StatusCode> {
|
||||||
|
let portfolio = parse_post::<PortfolioPageModel>("_pages/portfolio.md").await?;
|
||||||
|
|
||||||
|
let mut project_list = get_post_list::<ProjectMetadata>("_projects").await?;
|
||||||
|
|
||||||
|
project_list.sort_by_key(|post| post.slug.to_string());
|
||||||
|
project_list.retain(|project| project.metadata.displayed);
|
||||||
|
project_list.reverse();
|
||||||
|
|
||||||
|
Ok(PortfolioTemplate {
|
||||||
|
title: "Portfolio".to_owned(),
|
||||||
|
body: portfolio.body,
|
||||||
|
header_props: HeaderProps::default(),
|
||||||
|
project_list,
|
||||||
|
})
|
||||||
|
}
|
@ -3,6 +3,7 @@ use axum::http::StatusCode;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
components::site_header::HeaderProps,
|
components::site_header::HeaderProps,
|
||||||
|
filters,
|
||||||
post_utils::{post_listing::get_post_list, post_parser::ParseResult},
|
post_utils::{post_listing::get_post_list, post_parser::ParseResult},
|
||||||
projects::project_model::ProjectMetadata,
|
projects::project_model::ProjectMetadata,
|
||||||
};
|
};
|
||||||
|
@ -13,7 +13,6 @@ pub fn generate_image_with_src(
|
|||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
suffix: &str,
|
suffix: &str,
|
||||||
generate_image: bool,
|
|
||||||
) -> Result<String, anyhow::Error> {
|
) -> Result<String, anyhow::Error> {
|
||||||
let path_to_generated = get_generated_file_name(orig_img_path);
|
let path_to_generated = get_generated_file_name(orig_img_path);
|
||||||
let file_stem = path_to_generated.file_stem().unwrap().to_str().unwrap();
|
let file_stem = path_to_generated.file_stem().unwrap().to_str().unwrap();
|
||||||
@ -29,7 +28,6 @@ pub fn generate_image_with_src(
|
|||||||
let path_to_generated_arc = Arc::new(path_to_generated);
|
let path_to_generated_arc = Arc::new(path_to_generated);
|
||||||
let path_to_generated_clone = Arc::clone(&path_to_generated_arc);
|
let path_to_generated_clone = Arc::clone(&path_to_generated_arc);
|
||||||
|
|
||||||
if generate_image {
|
|
||||||
rayon::spawn(move || {
|
rayon::spawn(move || {
|
||||||
let orig_img = ImageReader::open(&disk_img_path)
|
let orig_img = ImageReader::open(&disk_img_path)
|
||||||
.with_context(|| format!("Failed to read instrs from {:?}", &disk_img_path))
|
.with_context(|| format!("Failed to read instrs from {:?}", &disk_img_path))
|
||||||
@ -49,7 +47,7 @@ pub fn generate_image_with_src(
|
|||||||
tracing::error!("Error: {}", e);
|
tracing::error!("Error: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
let path_to_generated = Arc::clone(&path_to_generated_arc);
|
let path_to_generated = Arc::clone(&path_to_generated_arc);
|
||||||
|
|
||||||
let image_path = get_image_path(
|
let image_path = get_image_path(
|
||||||
|
@ -20,7 +20,6 @@ pub fn generate_picture_markup(
|
|||||||
height: u32,
|
height: u32,
|
||||||
alt_text: &str,
|
alt_text: &str,
|
||||||
class_name: Option<&str>,
|
class_name: Option<&str>,
|
||||||
generate_image: bool,
|
|
||||||
) -> Result<String, anyhow::Error> {
|
) -> Result<String, anyhow::Error> {
|
||||||
let exported_formats = get_export_formats(orig_img_path);
|
let exported_formats = get_export_formats(orig_img_path);
|
||||||
let class_attr = if let Some(class) = class_name {
|
let class_attr = if let Some(class) = class_name {
|
||||||
@ -55,7 +54,6 @@ pub fn generate_picture_markup(
|
|||||||
let exported_formats_arc = Arc::new(exported_formats);
|
let exported_formats_arc = Arc::new(exported_formats);
|
||||||
let exported_formats_clone = Arc::clone(&exported_formats_arc);
|
let exported_formats_clone = Arc::clone(&exported_formats_arc);
|
||||||
|
|
||||||
if generate_image {
|
|
||||||
rayon::spawn(move || {
|
rayon::spawn(move || {
|
||||||
let orig_img = ImageReader::open(&disk_img_path)
|
let orig_img = ImageReader::open(&disk_img_path)
|
||||||
.with_context(|| format!("Failed to read instrs from {:?}", &disk_img_path))
|
.with_context(|| format!("Failed to read instrs from {:?}", &disk_img_path))
|
||||||
@ -66,14 +64,12 @@ pub fn generate_picture_markup(
|
|||||||
let resolutions = resolutions_clone.as_ref();
|
let resolutions = resolutions_clone.as_ref();
|
||||||
let exported_formats = exported_formats_clone.as_ref();
|
let exported_formats = exported_formats_clone.as_ref();
|
||||||
|
|
||||||
let result =
|
let result = generate_images(&orig_img, path_to_generated, resolutions, exported_formats)
|
||||||
generate_images(&orig_img, path_to_generated, resolutions, exported_formats)
|
|
||||||
.with_context(|| "Failed to generate images".to_string());
|
.with_context(|| "Failed to generate images".to_string());
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
tracing::error!("Error: {}", e);
|
tracing::error!("Error: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
let exported_formats = Arc::clone(&exported_formats_arc);
|
let exported_formats = Arc::clone(&exported_formats_arc);
|
||||||
let path_to_generated = Arc::clone(&path_to_generated_arc);
|
let path_to_generated = Arc::clone(&path_to_generated_arc);
|
||||||
@ -314,14 +310,7 @@ fn test_generate_picture_markup() {
|
|||||||
</picture>"#,
|
</picture>"#,
|
||||||
};
|
};
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
generate_picture_markup(
|
generate_picture_markup(orig_img_path, width, height, "Testing image alt", None,)
|
||||||
orig_img_path,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
"Testing image alt",
|
|
||||||
None,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
.expect("picture markup has to be generated"),
|
.expect("picture markup has to be generated"),
|
||||||
result
|
result
|
||||||
);
|
);
|
||||||
|
@ -21,7 +21,7 @@ pub async fn get_post_list<'de, Metadata: DeserializeOwned>(
|
|||||||
let file_path = file.path();
|
let file_path = file.path();
|
||||||
let file_path_str = file_path.to_str().unwrap();
|
let file_path_str = file_path.to_str().unwrap();
|
||||||
info!(":{}", file_path_str);
|
info!(":{}", file_path_str);
|
||||||
let post = parse_post::<Metadata>(file_path_str, false).await?;
|
let post = parse_post::<Metadata>(file_path_str).await?;
|
||||||
posts.push(post);
|
posts.push(post);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,22 +1,10 @@
|
|||||||
use core::panic;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use gray_matter::{engine::YAML, Matter};
|
use gray_matter::{engine::YAML, Matter};
|
||||||
use image::image_dimensions;
|
|
||||||
use indoc::formatdoc;
|
|
||||||
use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
|
|
||||||
use serde::{de::DeserializeOwned, Deserialize, Deserializer};
|
use serde::{de::DeserializeOwned, Deserialize, Deserializer};
|
||||||
use syntect::{highlighting::ThemeSet, html::highlighted_html_for_string, parsing::SyntaxSet};
|
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tracing::{debug, error};
|
|
||||||
|
|
||||||
use crate::picture_generator::{
|
|
||||||
picture_markup_generator::generate_picture_markup, resolutions::get_max_resolution,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const MAX_BLOG_IMAGE_RESOLUTION: (u32, u32) = (1280, 860);
|
|
||||||
|
|
||||||
pub fn deserialize_date<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
|
pub fn deserialize_date<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
|
||||||
where
|
where
|
||||||
@ -41,7 +29,6 @@ pub struct ParseResult<Metadata> {
|
|||||||
|
|
||||||
pub async fn parse_post<'de, Metadata: DeserializeOwned>(
|
pub async fn parse_post<'de, Metadata: DeserializeOwned>(
|
||||||
path: &str,
|
path: &str,
|
||||||
generate_images: bool,
|
|
||||||
) -> Result<ParseResult<Metadata>, StatusCode> {
|
) -> Result<ParseResult<Metadata>, StatusCode> {
|
||||||
let file_contents = fs::read_to_string(path)
|
let file_contents = fs::read_to_string(path)
|
||||||
.await
|
.await
|
||||||
@ -56,8 +43,6 @@ pub async fn parse_post<'de, Metadata: DeserializeOwned>(
|
|||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let body = parse_html(&metadata.content, generate_images);
|
|
||||||
|
|
||||||
let filename = Path::new(path)
|
let filename = Path::new(path)
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?
|
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
@ -66,173 +51,8 @@ pub async fn parse_post<'de, Metadata: DeserializeOwned>(
|
|||||||
.to_owned();
|
.to_owned();
|
||||||
|
|
||||||
Ok(ParseResult {
|
Ok(ParseResult {
|
||||||
body,
|
body: metadata.content,
|
||||||
metadata: metadata.data,
|
metadata: metadata.data,
|
||||||
slug: filename,
|
slug: filename,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TextKind {
|
|
||||||
Text,
|
|
||||||
Heading(Option<String>),
|
|
||||||
Code(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_html(markdown: &str, generate_images: bool) -> String {
|
|
||||||
let mut options = Options::empty();
|
|
||||||
options.insert(Options::ENABLE_TABLES);
|
|
||||||
options.insert(Options::ENABLE_FOOTNOTES);
|
|
||||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
|
||||||
options.insert(Options::ENABLE_TASKLISTS);
|
|
||||||
options.insert(Options::ENABLE_SMART_PUNCTUATION);
|
|
||||||
options.insert(Options::ENABLE_HEADING_ATTRIBUTES);
|
|
||||||
|
|
||||||
let mut text_kind = TextKind::Text;
|
|
||||||
let syntax_set = SyntaxSet::load_defaults_newlines();
|
|
||||||
let theme_set = ThemeSet::load_defaults();
|
|
||||||
let theme = theme_set.themes.get("InspiredGitHub").unwrap();
|
|
||||||
let mut heading_ended: Option<bool> = None;
|
|
||||||
|
|
||||||
let parser = Parser::new_ext(markdown, options).map(|event| match event {
|
|
||||||
/*
|
|
||||||
Parsing images considers `alt` attribute as inner `Text` event
|
|
||||||
Therefore the `[alt]` is rendered in html as subtitle
|
|
||||||
and the `[](url "title")` `title` is rendered as `alt` attribute
|
|
||||||
*/
|
|
||||||
Event::Start(Tag::Image {
|
|
||||||
link_type,
|
|
||||||
dest_url,
|
|
||||||
title,
|
|
||||||
id,
|
|
||||||
}) => {
|
|
||||||
if !dest_url.starts_with("/") {
|
|
||||||
return Event::Html(
|
|
||||||
formatdoc!(
|
|
||||||
r#"<img
|
|
||||||
alt="{title}"
|
|
||||||
src="{dest_url}"
|
|
||||||
/>"#
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let dev_only_img_path =
|
|
||||||
Path::new("static/").join(dest_url.strip_prefix("/").unwrap_or(&dest_url));
|
|
||||||
let img_dimensions = image_dimensions(&dev_only_img_path).unwrap();
|
|
||||||
|
|
||||||
let (max_width, max_height) = get_max_resolution(
|
|
||||||
img_dimensions,
|
|
||||||
MAX_BLOG_IMAGE_RESOLUTION.0,
|
|
||||||
MAX_BLOG_IMAGE_RESOLUTION.1,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Place image into the content with scaled reso to a boundary
|
|
||||||
let picture_markup = generate_picture_markup(
|
|
||||||
&dest_url,
|
|
||||||
max_width,
|
|
||||||
max_height,
|
|
||||||
&title,
|
|
||||||
None,
|
|
||||||
generate_images,
|
|
||||||
)
|
|
||||||
.unwrap_or(formatdoc!(
|
|
||||||
r#"
|
|
||||||
<img
|
|
||||||
alt="{alt}"
|
|
||||||
src="{src}"
|
|
||||||
/>"#,
|
|
||||||
alt = title,
|
|
||||||
src = dest_url,
|
|
||||||
));
|
|
||||||
debug!(
|
|
||||||
"Image link_type: {:?} url: {} title: {} id: {}",
|
|
||||||
link_type, dest_url, title, id
|
|
||||||
);
|
|
||||||
Event::Html(
|
|
||||||
formatdoc!(
|
|
||||||
r#"<figure>
|
|
||||||
{picture_markup}
|
|
||||||
<figcaption>
|
|
||||||
"#,
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang))) => {
|
|
||||||
text_kind = TextKind::Code(lang.to_string());
|
|
||||||
Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(lang)))
|
|
||||||
}
|
|
||||||
Event::Text(text) => match &text_kind {
|
|
||||||
TextKind::Code(lang) => {
|
|
||||||
// TODO Check https://github.com/trishume/syntect/pull/535 for typescript support
|
|
||||||
let lang = if ["ts".to_string(), "typescript".to_string()].contains(lang) {
|
|
||||||
"javascript"
|
|
||||||
} else {
|
|
||||||
lang
|
|
||||||
};
|
|
||||||
let syntax_reference = syntax_set
|
|
||||||
.find_syntax_by_token(lang)
|
|
||||||
.unwrap_or(syntax_set.find_syntax_plain_text());
|
|
||||||
let highlighted =
|
|
||||||
highlighted_html_for_string(&text, &syntax_set, syntax_reference, theme)
|
|
||||||
.unwrap();
|
|
||||||
Event::Html(highlighted.into())
|
|
||||||
}
|
|
||||||
TextKind::Heading(provided_id) => {
|
|
||||||
let heading_id = provided_id.clone().unwrap_or({
|
|
||||||
text.to_lowercase()
|
|
||||||
.replace(|c: char| !c.is_alphanumeric(), "-")
|
|
||||||
});
|
|
||||||
debug!("heading_id: {}", heading_id.clone());
|
|
||||||
match heading_ended {
|
|
||||||
None => {
|
|
||||||
error!("Heading should have set state");
|
|
||||||
panic!("Heading should have set state");
|
|
||||||
}
|
|
||||||
Some(true) => Event::Html(text),
|
|
||||||
Some(false) => {
|
|
||||||
heading_ended = Some(true);
|
|
||||||
Event::Html(
|
|
||||||
formatdoc!(
|
|
||||||
r##"id="{heading_id}">
|
|
||||||
{text}"##
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => Event::Text(text),
|
|
||||||
},
|
|
||||||
Event::Start(Tag::Heading {
|
|
||||||
level,
|
|
||||||
id,
|
|
||||||
classes: _,
|
|
||||||
attrs: _,
|
|
||||||
}) => {
|
|
||||||
let id_str = id.map(|id| id.to_string());
|
|
||||||
debug!("heading_start: {:?}, level: {}", &id_str, level);
|
|
||||||
text_kind = TextKind::Heading(id_str);
|
|
||||||
heading_ended = Some(false);
|
|
||||||
Event::Html(format!("<{level} ").into())
|
|
||||||
}
|
|
||||||
Event::Start(_) => event,
|
|
||||||
Event::End(TagEnd::Image) => Event::Html("</figcaption></figure>".into()),
|
|
||||||
Event::End(TagEnd::CodeBlock) => {
|
|
||||||
text_kind = TextKind::Text;
|
|
||||||
Event::End(TagEnd::CodeBlock)
|
|
||||||
}
|
|
||||||
Event::End(TagEnd::Heading(heading_level)) => {
|
|
||||||
text_kind = TextKind::Text;
|
|
||||||
heading_ended = None;
|
|
||||||
Event::End(TagEnd::Heading(heading_level))
|
|
||||||
}
|
|
||||||
_ => event,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Write to String buffer
|
|
||||||
let mut html = String::new();
|
|
||||||
pulldown_cmark::html::push_html(&mut html, parser);
|
|
||||||
html
|
|
||||||
}
|
|
||||||
|
@ -4,7 +4,8 @@ use crate::{
|
|||||||
admin::render_admin, blog_post_list::render_blog_post_list,
|
admin::render_admin, blog_post_list::render_blog_post_list,
|
||||||
blog_post_page::render_blog_post, broadcast_list::render_broadcast_post_list,
|
blog_post_page::render_blog_post, broadcast_list::render_broadcast_post_list,
|
||||||
contact::render_contact, index::render_index, not_found::render_not_found,
|
contact::render_contact, index::render_index, not_found::render_not_found,
|
||||||
project_list::render_projects_list, showcase::egg_fetcher::render_egg_fetcher,
|
portfolio::render_portfolio, project_list::render_projects_list,
|
||||||
|
showcase::egg_fetcher::render_egg_fetcher,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use axum::{extract::MatchedPath, http::Request, routing::get, Router};
|
use axum::{extract::MatchedPath, http::Request, routing::get, Router};
|
||||||
@ -23,6 +24,7 @@ pub fn get_router() -> Router {
|
|||||||
.route("/contact", get(render_contact))
|
.route("/contact", get(render_contact))
|
||||||
.route("/showcase", get(render_projects_list))
|
.route("/showcase", get(render_projects_list))
|
||||||
.route("/showcase/:project_slug", get(render_egg_fetcher))
|
.route("/showcase/:project_slug", get(render_egg_fetcher))
|
||||||
|
.route("/portfolio", get(render_portfolio))
|
||||||
.route("/admin", get(render_admin))
|
.route("/admin", get(render_admin))
|
||||||
.route("/feed.xml", get(render_rss_feed))
|
.route("/feed.xml", get(render_rss_feed))
|
||||||
.layer(
|
.layer(
|
||||||
|
@ -64,11 +64,6 @@ collections:
|
|||||||
fields:
|
fields:
|
||||||
- { label: Title, name: title, widget: string }
|
- { label: Title, name: title, widget: string }
|
||||||
- { label: Body, name: body, widget: markdown }
|
- { label: Body, name: body, widget: markdown }
|
||||||
- {
|
|
||||||
label: Work history prelude,
|
|
||||||
name: work_history_prelude,
|
|
||||||
widget: markdown,
|
|
||||||
}
|
|
||||||
- label: Work history
|
- label: Work history
|
||||||
name: work_history
|
name: work_history
|
||||||
widget: list
|
widget: list
|
||||||
@ -85,37 +80,7 @@ collections:
|
|||||||
- { label: City, name: city, widget: string, required: false }
|
- { label: City, name: city, widget: string, required: false }
|
||||||
- { label: Country, name: country, widget: string, required: false }
|
- { label: Country, name: country, widget: string, required: false }
|
||||||
- { label: Displayed, name: displayed, widget: boolean, default: true }
|
- { label: Displayed, name: displayed, widget: boolean, default: true }
|
||||||
- label: Projects
|
- { label: Thumbnail, name: thumbnail, widget: image, required: false }
|
||||||
name: projects
|
|
||||||
widget: list
|
|
||||||
fields:
|
|
||||||
- { label: Project name, name: name, widget: string }
|
|
||||||
- {
|
|
||||||
label: Displayed,
|
|
||||||
name: displayed,
|
|
||||||
widget: boolean,
|
|
||||||
default: true,
|
|
||||||
}
|
|
||||||
- { label: Description, name: body, widget: markdown }
|
|
||||||
- {
|
|
||||||
label: Cover image,
|
|
||||||
name: cover_image,
|
|
||||||
widget: image,
|
|
||||||
required: false,
|
|
||||||
}
|
|
||||||
- label: Presentations
|
|
||||||
name: presentations
|
|
||||||
widget: list
|
|
||||||
fields:
|
|
||||||
- { label: Name, name: name, widget: string }
|
|
||||||
- {
|
|
||||||
label: Displayed,
|
|
||||||
name: displayed,
|
|
||||||
widget: boolean,
|
|
||||||
default: true,
|
|
||||||
}
|
|
||||||
- { label: Description, name: description, widget: markdown }
|
|
||||||
- { label: Link, name: link, widget: string }
|
|
||||||
- label: Education
|
- label: Education
|
||||||
name: education
|
name: education
|
||||||
widget: list
|
widget: list
|
||||||
@ -128,6 +93,7 @@ collections:
|
|||||||
default: true,
|
default: true,
|
||||||
}
|
}
|
||||||
- { label: Description, name: description, widget: markdown }
|
- { label: Description, name: description, widget: markdown }
|
||||||
|
- { label: Thumbnail, name: thumbnail, widget: image, required: false }
|
||||||
- name: 'projects' # Used in routes, e.g., /admin/collections/blog
|
- name: 'projects' # Used in routes, e.g., /admin/collections/blog
|
||||||
label: 'Showcase projects' # Used in the UI
|
label: 'Showcase projects' # Used in the UI
|
||||||
folder: '_projects/' # The path to the folder where the documents are stored
|
folder: '_projects/' # The path to the folder where the documents are stored
|
||||||
|
@ -662,6 +662,11 @@ video {
|
|||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx-3 {
|
||||||
|
margin-left: 0.75rem;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.mx-4 {
|
.mx-4 {
|
||||||
margin-left: 1rem;
|
margin-left: 1rem;
|
||||||
margin-right: 1rem;
|
margin-right: 1rem;
|
||||||
@ -702,11 +707,6 @@ video {
|
|||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mx-3 {
|
|
||||||
margin-left: 0.75rem;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-1 {
|
.mb-1 {
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
@ -1027,6 +1027,11 @@ video {
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-9xl {
|
||||||
|
font-size: 8rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.text-base {
|
.text-base {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
line-height: 1.5rem;
|
line-height: 1.5rem;
|
||||||
@ -1047,16 +1052,6 @@ video {
|
|||||||
line-height: 1.75rem;
|
line-height: 1.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-6xl {
|
|
||||||
font-size: 3.75rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-9xl {
|
|
||||||
font-size: 8rem;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.font-bold {
|
.font-bold {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
<meta property="og:url" content="https://michalvanko.dev/{{segment}}/{{slug}}" />
|
<meta property="og:url" content="https://michalvanko.dev/{{segment}}/{{slug}}" />
|
||||||
{% match thumbnail %}
|
{% match thumbnail %}
|
||||||
{% when Some with (img) %}
|
{% when Some with (img) %}
|
||||||
{% let src = crate::picture_generator::image_src_generator::generate_image_with_src(img, 1200, 630, "_og", true).unwrap_or("thumbnail not found".to_string())|safe %}
|
{% let src = crate::picture_generator::image_src_generator::generate_image_with_src(img, 1200, 630, "_og").unwrap_or("thumbnail not found".to_string())|safe %}
|
||||||
<meta property="og:image" content="https://michalvanko.dev{{src}}" />
|
<meta property="og:image" content="https://michalvanko.dev{{src}}" />
|
||||||
{% when None %}
|
{% when None %}
|
||||||
<meta property="og:image" content="https://michalvanko.dev/images/m-logo.svg" />
|
<meta property="og:image" content="https://michalvanko.dev/images/m-logo.svg" />
|
||||||
@ -28,7 +28,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="article-body">
|
<section class="article-body">
|
||||||
{{body|escape("none")}}
|
{{body|parse_markdown|safe}}
|
||||||
</section>
|
</section>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<aside class="row-span-3 self-center float-start sm:float-none mr-3 mb-3 sm:ml-0 sm:mb-0">
|
<aside class="row-span-3 self-center float-start sm:float-none mr-3 mb-3 sm:ml-0 sm:mb-0">
|
||||||
{% match post.metadata.thumbnail %}
|
{% match post.metadata.thumbnail %}
|
||||||
{% when Some with (orig_path) %}
|
{% when Some with (orig_path) %}
|
||||||
{{ crate::picture_generator::picture_markup_generator::generate_picture_markup(orig_path, 180, 240, "Article thumbnail", None, true).unwrap_or("thumbnail not found".to_string())|safe }}
|
{{ crate::picture_generator::picture_markup_generator::generate_picture_markup(orig_path, 180, 240, "Article thumbnail", None).unwrap_or("thumbnail not found".to_string())|safe }}
|
||||||
{% when None %}
|
{% when None %}
|
||||||
<div>
|
<div>
|
||||||
{% include "components/blog_post_default_thumbnail.html" %}
|
{% include "components/blog_post_default_thumbnail.html" %}
|
||||||
@ -14,7 +14,7 @@
|
|||||||
<a rel="prefetch" href="/{{segment}}/{{post.slug}}" class="text-blue-950 visited:text-purple-700 no-underline">{{post.metadata.title}}</a>
|
<a rel="prefetch" href="/{{segment}}/{{post.slug}}" class="text-blue-950 visited:text-purple-700 no-underline">{{post.metadata.title}}</a>
|
||||||
</h3>
|
</h3>
|
||||||
</header>
|
</header>
|
||||||
<section class="text-base leading-5 text-slate-800 md:text-xl text-justify">{{post.body|description_filter|safe}}</section>
|
<section class="text-base leading-5 text-slate-800 md:text-xl text-justify">{{post.body|truncate_md(2)|parse_markdown|safe}}</section>
|
||||||
<footer class="text-sm md:text-base lg:text-lg mt-3 sm:mt-0 clear-both sm:clear-none">
|
<footer class="text-sm md:text-base lg:text-lg mt-3 sm:mt-0 clear-both sm:clear-none">
|
||||||
<ul class="inline-block">
|
<ul class="inline-block">
|
||||||
{% for tag in post.metadata.tags %}
|
{% for tag in post.metadata.tags %}
|
||||||
|
@ -11,14 +11,14 @@
|
|||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
</h2>
|
</h2>
|
||||||
<section class="description text-slate-800 my-2 md:text-xl text-justify">
|
<section class="description text-slate-800 my-2 md:text-xl text-justify">
|
||||||
{{project.body|safe}}
|
{{project.body|parse_markdown|safe}}
|
||||||
</section>
|
</section>
|
||||||
</header>
|
</header>
|
||||||
<!-- <hr class="border-blue-950 my-5"> -->
|
<!-- <hr class="border-blue-950 my-5"> -->
|
||||||
|
|
||||||
{% match project.metadata.cover_image %}
|
{% match project.metadata.cover_image %}
|
||||||
{% when Some with (source) %}
|
{% when Some with (source) %}
|
||||||
{% let picture = crate::picture_generator::picture_markup_generator::generate_picture_markup(source, 420, 236, "Project cover", Some("max-h-[236px]"), true).unwrap_or("cover not found".to_string()) %}
|
{% let picture = crate::picture_generator::picture_markup_generator::generate_picture_markup(source, 420, 236, "Project cover", Some("max-h-[236px]")).unwrap_or("cover not found".to_string()) %}
|
||||||
<figure class="mx-4 my-2 flex justify-center">
|
<figure class="mx-4 my-2 flex justify-center">
|
||||||
{% match project.metadata.link %}
|
{% match project.metadata.link %}
|
||||||
{% when Some with (href) %}
|
{% when Some with (href) %}
|
||||||
@ -28,7 +28,6 @@
|
|||||||
{% when None %}
|
{% when None %}
|
||||||
{{picture|safe}}
|
{{picture|safe}}
|
||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
<!-- TODO <figure> generate_image -->
|
|
||||||
</figure>
|
</figure>
|
||||||
{% when None %}
|
{% when None %}
|
||||||
{% endmatch %}
|
{% endmatch %}
|
||||||
|
@ -9,6 +9,6 @@
|
|||||||
</header>
|
</header>
|
||||||
{% let alt_text = format!("{svg} thumbnail") %}
|
{% let alt_text = format!("{svg} thumbnail") %}
|
||||||
|
|
||||||
{{ crate::picture_generator::picture_markup_generator::generate_picture_markup(img, 360, 128, alt_text, Some("h-auto mx-auto rounded-sm"), true).unwrap_or("thumbnail not found".to_string())|safe }}
|
{{ crate::picture_generator::picture_markup_generator::generate_picture_markup(img, 360, 128, alt_text, Some("h-auto mx-auto rounded-sm")).unwrap_or("thumbnail not found".to_string())|safe }}
|
||||||
</a>
|
</a>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
38
templates/portfolio.html
Normal file
38
templates/portfolio.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block og_meta %}
|
||||||
|
<meta property="og:title" content="{{title}} @michalvankodev" />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://michalvanko.dev/showcase" />
|
||||||
|
<meta property="og:image" content="https://michalvanko.dev/images/m-logo.svg" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<section id="portfolio-body">
|
||||||
|
|
||||||
|
{{ body }}
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="project-list-container" class="max-w-maxindex mx-auto">
|
||||||
|
<section id="project-list">
|
||||||
|
{% if project_list.len() == 0 %}
|
||||||
|
<p class="no-posts">You've found void in the space.</p>
|
||||||
|
{% else %}
|
||||||
|
<h1 class="m-5 text-4xl text-blue-950 font-extrabold md:text-6xl">
|
||||||
|
Showcase
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<ul class="m-6 grid grid-flow-row gap-6 md:grid-cols-2 md:grid-rows-[masonry] md:justify-stretch md:items-stretch xl:grid-cols-3">
|
||||||
|
{% for project in project_list %}
|
||||||
|
<li>
|
||||||
|
{% include "components/project_preview_card.html" %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
</section> <!-- /#project-list -->
|
||||||
|
|
||||||
|
</section> <!-- /#project-list-container -->
|
||||||
|
{% endblock %}
|
Loading…
Reference in New Issue
Block a user