remove old svelte web source
31
src/app.html
@@ -1,31 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#333333" />
|
||||
|
||||
<meta name="description" content="Personal website of @michalvankodev" />
|
||||
<meta name="keywords" content="personal, blog, webdev, tech, programming" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS feed for latest posts" href="https://michalvanko.dev/feed.xml" />
|
||||
<link rel="alternate" title="JSON feed for latest posts" type="application/json" href="https://michalvanko.dev/feed.json" />
|
||||
|
||||
<link rel="stylesheet" href="/print.css" media="print" />
|
||||
<link rel="stylesheet" href="/fonts.css" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="stylesheet" href="/prism.css" />
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/m-logo.svg" />
|
||||
<link rel="icon" type="image/png" href="/m-logo-192.png" />
|
||||
<!-- This contains the contents of the <svelte:head> component, if
|
||||
the current page has one -->
|
||||
%sveltekit.head%
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: contents">
|
||||
%sveltekit.body%
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
17
src/blog_posts/blog_post_model.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::post_utils::post_parser::deserialize_date;
|
||||
|
||||
pub const BLOG_POST_PATH: &str = "../_posts/blog";
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct BlogPostMetadata {
|
||||
pub title: String,
|
||||
pub segments: Vec<String>,
|
||||
pub published: bool,
|
||||
#[serde(deserialize_with = "deserialize_date")]
|
||||
pub date: DateTime<Utc>,
|
||||
pub thumbnail: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
}
|
15
src/blog_posts/featured_blog_posts.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use crate::post_utils::{post_listing::get_post_list, post_parser::ParseResult};
|
||||
|
||||
use super::blog_post_model::{BlogPostMetadata, BLOG_POST_PATH};
|
||||
|
||||
pub async fn get_featured_blog_posts() -> Result<Vec<ParseResult<BlogPostMetadata>>, StatusCode> {
|
||||
let mut post_list = get_post_list::<BlogPostMetadata>(BLOG_POST_PATH).await?;
|
||||
post_list.retain(|post| post.metadata.segments.contains(&"featured".to_string()));
|
||||
post_list.retain(|post| post.metadata.published);
|
||||
post_list.sort_by_key(|post| post.metadata.date);
|
||||
post_list.reverse();
|
||||
|
||||
Ok(post_list)
|
||||
}
|
3
src/blog_posts/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod blog_post_model;
|
||||
pub mod featured_blog_posts;
|
||||
pub mod tag_list;
|
38
src/blog_posts/tag_list.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use axum::http::StatusCode;
|
||||
use std::collections::HashMap;
|
||||
use tracing::debug;
|
||||
|
||||
use crate::{
|
||||
blog_posts::blog_post_model::{BlogPostMetadata, BLOG_POST_PATH},
|
||||
post_utils::post_listing::get_post_list,
|
||||
};
|
||||
|
||||
pub async fn get_popular_blog_tags() -> Result<Vec<String>, StatusCode> {
|
||||
const TAGS_LENGTH: usize = 7;
|
||||
|
||||
let post_list = get_post_list::<BlogPostMetadata>(BLOG_POST_PATH).await?;
|
||||
let tags_sum = post_list
|
||||
.into_iter()
|
||||
.flat_map(|post| post.metadata.tags)
|
||||
.fold(HashMap::new(), |mut acc, tag| {
|
||||
*acc.entry(tag).or_insert(0) += 1;
|
||||
acc
|
||||
});
|
||||
|
||||
let mut sorted_tags_by_count: Vec<_> = tags_sum.into_iter().collect();
|
||||
sorted_tags_by_count.sort_by_key(|&(_, count)| std::cmp::Reverse(count));
|
||||
|
||||
// Log the counts
|
||||
for (tag, count) in &sorted_tags_by_count {
|
||||
debug!("Tag: {}, Count: {}", tag, count);
|
||||
}
|
||||
|
||||
let popular_blog_tags = sorted_tags_by_count
|
||||
.into_iter()
|
||||
.map(|tag_count| tag_count.0)
|
||||
.filter(|tag| tag != "dev")
|
||||
.take(TAGS_LENGTH)
|
||||
.collect::<Vec<String>>();
|
||||
|
||||
Ok(popular_blog_tags)
|
||||
}
|
1
src/components/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod site_header;
|
17
src/components/site_header.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
pub struct Link {
|
||||
pub href: String,
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct HeaderProps {
|
||||
pub back_link: Option<Link>,
|
||||
}
|
||||
|
||||
impl HeaderProps {
|
||||
pub fn with_back_link(link: Link) -> Self {
|
||||
Self {
|
||||
back_link: Some(link),
|
||||
}
|
||||
}
|
||||
}
|
53
src/feed.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use chrono::Utc;
|
||||
use rss::{ChannelBuilder, GuidBuilder, Item, ItemBuilder};
|
||||
|
||||
use crate::blog_posts::blog_post_model::{BlogPostMetadata, BLOG_POST_PATH};
|
||||
use crate::post_utils::post_listing::get_post_list;
|
||||
|
||||
pub async fn render_rss_feed() -> Result<impl IntoResponse, StatusCode> {
|
||||
let mut post_list = get_post_list::<BlogPostMetadata>(BLOG_POST_PATH)
|
||||
.await
|
||||
.unwrap_or(vec![]);
|
||||
post_list.sort_by_key(|post| post.metadata.date);
|
||||
post_list.reverse();
|
||||
|
||||
let last_build_date = Utc::now().to_rfc2822();
|
||||
let publish_date = post_list.last().map_or_else(
|
||||
|| last_build_date.clone(),
|
||||
|post| post.metadata.date.to_rfc2822(),
|
||||
);
|
||||
|
||||
let post_items = post_list
|
||||
.into_iter()
|
||||
.map(|post| {
|
||||
ItemBuilder::default()
|
||||
.title(Some(post.metadata.title))
|
||||
.link(Some(format!("https://michalvanko.dev/blog/{}", post.slug)))
|
||||
// TODO Description should be just a preview
|
||||
.description(None)
|
||||
.guid(Some(
|
||||
GuidBuilder::default()
|
||||
.value(format!("https://michalvanko.dev/blog/{}", post.slug))
|
||||
.build(),
|
||||
))
|
||||
.pub_date(Some(post.metadata.date.to_rfc2822()))
|
||||
.build()
|
||||
})
|
||||
.collect::<Vec<Item>>();
|
||||
|
||||
let feed_builder = ChannelBuilder::default()
|
||||
.title("michalvanko.dev latest posts".to_string())
|
||||
.link("https://michalvanko.dev".to_string())
|
||||
.description("Latest posts published on michalvanko.dev blog site".to_string())
|
||||
.language(Some("en".to_string()))
|
||||
.webmaster(Some("michalvankosk@gmail.com".to_string()))
|
||||
.pub_date(Some(publish_date))
|
||||
.last_build_date(Some(last_build_date))
|
||||
.items(post_items)
|
||||
.build();
|
||||
|
||||
let response = feed_builder.to_string();
|
||||
return Ok(([(header::CONTENT_TYPE, "application/xml")], response));
|
||||
}
|
20
src/filters.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
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)
|
||||
}
|
3
src/global.d.ts
vendored
@@ -1,3 +0,0 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
/// <reference types="svelte" />
|
||||
/// <reference types="vite/client" />
|
@@ -1,38 +0,0 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import fm from 'front-matter'
|
||||
import { readFile } from 'fs'
|
||||
import { parseField } from '$lib/markdown/parse-markdown'
|
||||
import { promisify } from 'util'
|
||||
|
||||
export interface ArticleAttributes {
|
||||
slug: string
|
||||
layout: string
|
||||
segments: string[]
|
||||
title: string
|
||||
published: boolean
|
||||
date: string
|
||||
thumbnail: string
|
||||
tags: string[]
|
||||
body: string
|
||||
}
|
||||
|
||||
export async function getArticleContent(slug: string) {
|
||||
let postSource: string
|
||||
try {
|
||||
postSource = await promisify(readFile)(`_posts/blog/${slug}.md`, 'utf-8')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} catch (e: any) {
|
||||
if (e.code === 'ENOENT') {
|
||||
throw error(404, 'Post not found \n' + e.toString())
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
const parsedPost = fm<ArticleAttributes>(postSource)
|
||||
|
||||
const post = parseField<ArticleAttributes>('body')({
|
||||
...parsedPost.attributes,
|
||||
body: parsedPost.body,
|
||||
})
|
||||
return post
|
||||
}
|
@@ -1,58 +0,0 @@
|
||||
import { readdir, readFile } from 'fs'
|
||||
import { promisify } from 'util'
|
||||
import { basename } from 'path'
|
||||
import { pipe, prop, sortBy, reverse, filter } from 'ramda'
|
||||
import fm from 'front-matter'
|
||||
import marked from 'marked'
|
||||
import {
|
||||
filterAndCount,
|
||||
type PaginationQuery,
|
||||
} from '$lib/pagination/pagination'
|
||||
import type { ArticleAttributes } from './articleContent'
|
||||
|
||||
export interface ArticlePreviewAttributes extends ArticleAttributes {
|
||||
preview: string
|
||||
}
|
||||
|
||||
const { NODE_ENV } = process.env
|
||||
export async function getBlogListing(paginationQuery: PaginationQuery) {
|
||||
const files = await promisify(readdir)(`_posts/blog/`, 'utf-8')
|
||||
const filteredFiles = filterDevelopmentFiles(files)
|
||||
|
||||
const contents = await Promise.all(
|
||||
filteredFiles.map(async (file) => {
|
||||
const fileContent = await promisify(readFile)(
|
||||
`_posts/blog/${file}`,
|
||||
'utf-8'
|
||||
)
|
||||
const parsedAttributes = fm<ArticleAttributes>(fileContent)
|
||||
|
||||
const lineOfTextRegExp = /^(?:\w|\[).+/gm
|
||||
const lines = parsedAttributes.body
|
||||
.match(lineOfTextRegExp)
|
||||
.slice(0, 2)
|
||||
.join('\n')
|
||||
|
||||
const preview = marked(lines)
|
||||
return {
|
||||
...parsedAttributes.attributes,
|
||||
preview,
|
||||
slug: basename(file, '.md'),
|
||||
}
|
||||
})
|
||||
)
|
||||
const filteredContents = pipe(
|
||||
sortBy<ArticlePreviewAttributes>(prop('date')),
|
||||
(items) => reverse(items),
|
||||
filter<(typeof contents)[0]>((article) => article.published),
|
||||
filterAndCount(paginationQuery)
|
||||
)(contents)
|
||||
|
||||
return filteredContents
|
||||
}
|
||||
|
||||
function filterDevelopmentFiles(files: string[]) {
|
||||
return NODE_ENV !== 'production'
|
||||
? files
|
||||
: files.filter((file) => !file.startsWith('dev-'))
|
||||
}
|
@@ -1,11 +0,0 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
onMountScripts?: Array<() => void>
|
||||
}
|
||||
}
|
||||
export function runOnMountScripts() {
|
||||
window.onMountScripts?.forEach((fn) => {
|
||||
fn()
|
||||
})
|
||||
}
|
||||
|
@@ -1,169 +0,0 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css'
|
||||
import { radialGradient, rgba, transparentize } from 'polished'
|
||||
import { sprinkles } from '$lib/styles/sprinkles.css'
|
||||
import {
|
||||
breakpoints,
|
||||
colors,
|
||||
mediaAt,
|
||||
menuBackground,
|
||||
transparent,
|
||||
vars,
|
||||
} from '$lib/styles/vars.css'
|
||||
|
||||
export const siteFooterClass = style([
|
||||
sprinkles({
|
||||
fontSize: { mobile: 'base', desktop: 'sm' },
|
||||
paddingX: '2x',
|
||||
paddingTop: '1x',
|
||||
color: 'menuLink',
|
||||
}),
|
||||
|
||||
radialGradient({
|
||||
colorStops: [
|
||||
`${menuBackground} 56%`,
|
||||
`${transparentize(0.4, menuBackground)} 100%`,
|
||||
],
|
||||
extent: '160% 100% at 100% 100%',
|
||||
fallback: transparent,
|
||||
}),
|
||||
{
|
||||
'@media': {
|
||||
[mediaAt(breakpoints.m)]: radialGradient({
|
||||
colorStops: [
|
||||
`${menuBackground} 48%`,
|
||||
`${transparentize(1, menuBackground)} 100%`,
|
||||
],
|
||||
extent: '140% 100% at 100% 100%',
|
||||
fallback: transparent,
|
||||
}),
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
export const headerClass = sprinkles({
|
||||
fontWeight: 'bold',
|
||||
fontSize: 'base',
|
||||
color: 'menuLink',
|
||||
margin: 'none',
|
||||
lineHeight: '3x',
|
||||
marginBottom: '1x',
|
||||
})
|
||||
|
||||
export const sectionListsClass = style([
|
||||
sprinkles({
|
||||
display: 'grid',
|
||||
justifyItems: { mobile: 'center', desktop: 'start' },
|
||||
textAlign: { mobile: 'center', desktop: 'start' },
|
||||
maxWidth: 'max',
|
||||
columnGap: '3x',
|
||||
margin: 'auto',
|
||||
}),
|
||||
{
|
||||
'@media': {
|
||||
[mediaAt(breakpoints.l)]: {
|
||||
gridTemplateColumns: 'auto auto auto',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
export const sectionListSectionClass = sprinkles({
|
||||
marginY: '3x',
|
||||
})
|
||||
|
||||
export const listUlClass = sprinkles({
|
||||
listStyle: 'none',
|
||||
padding: 'none',
|
||||
margin: 'none',
|
||||
})
|
||||
|
||||
export const listLiClass = sprinkles({
|
||||
marginLeft: '1x',
|
||||
})
|
||||
|
||||
export const nestedListLiClass = style([
|
||||
listLiClass,
|
||||
sprinkles({
|
||||
fontSize: 'sm',
|
||||
}),
|
||||
])
|
||||
|
||||
export const socialLinkLabelClass = sprinkles({
|
||||
paddingX: '1x',
|
||||
})
|
||||
|
||||
export const svgClass = style({
|
||||
fill: vars.color.menuLink,
|
||||
height: '1em',
|
||||
width: '1em',
|
||||
})
|
||||
|
||||
export const strokeSvgClass = style([
|
||||
svgClass,
|
||||
{
|
||||
stroke: vars.color.menuLink,
|
||||
strokeWidth: '2px',
|
||||
},
|
||||
])
|
||||
|
||||
export const socialLinkClass = sprinkles({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: {
|
||||
mobile: 'center',
|
||||
desktop: 'start',
|
||||
},
|
||||
})
|
||||
|
||||
export const bottomLineClass = sprinkles({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginX: 'auto',
|
||||
paddingBottom: '1x',
|
||||
marginTop: '2x',
|
||||
maxWidth: 'max',
|
||||
})
|
||||
|
||||
export const dateClass = sprinkles({
|
||||
fontSize: 'xs',
|
||||
whiteSpace: 'nowrap',
|
||||
})
|
||||
|
||||
export const boldClass = sprinkles({
|
||||
fontWeight: 'bold',
|
||||
})
|
||||
|
||||
export const hrClass = style([
|
||||
sprinkles({
|
||||
marginY: '2x',
|
||||
marginX: '1x',
|
||||
}),
|
||||
{
|
||||
color: rgba(colors.midnightBlue, 0.14),
|
||||
borderWidth: '1px 0 0',
|
||||
},
|
||||
])
|
||||
|
||||
export const licenceText = sprinkles({
|
||||
textAlign: 'center',
|
||||
width: 'parent',
|
||||
fontSize: 'xs',
|
||||
})
|
||||
|
||||
export const latestPostsClass = style({})
|
||||
|
||||
globalStyle(`${siteFooterClass} a`, {
|
||||
color: vars.color.menuLink,
|
||||
})
|
||||
|
||||
globalStyle(`${headerClass} a:link, ${headerClass} a:visited`, {
|
||||
color: vars.color.menuLink,
|
||||
})
|
||||
|
||||
globalStyle(`${siteFooterClass} a:hover`, {
|
||||
color: vars.color.menuLinkHover,
|
||||
})
|
||||
|
||||
globalStyle(`${latestPostsClass} li a:visited:not(:hover)`, {
|
||||
color: vars.color.linkVisited,
|
||||
})
|
@@ -1,199 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { format } from 'date-fns'
|
||||
import type { ArticlePreviewAttributes } from '$lib/articleContent/articleContentListing'
|
||||
import SvgIcon from './SvgIcon.svelte'
|
||||
import {
|
||||
boldClass,
|
||||
bottomLineClass,
|
||||
dateClass,
|
||||
headerClass,
|
||||
hrClass,
|
||||
latestPostsClass,
|
||||
listLiClass,
|
||||
listUlClass,
|
||||
nestedListLiClass,
|
||||
sectionListsClass,
|
||||
sectionListSectionClass,
|
||||
siteFooterClass,
|
||||
socialLinkClass,
|
||||
socialLinkLabelClass,
|
||||
strokeSvgClass,
|
||||
svgClass,
|
||||
licenceText,
|
||||
} from './Footer.css'
|
||||
|
||||
export let latestPosts: ArticlePreviewAttributes[]
|
||||
</script>
|
||||
|
||||
<footer class="site-footer navigation-theme {siteFooterClass}">
|
||||
<div class="lists {sectionListsClass}">
|
||||
<section class="site-map {sectionListSectionClass}">
|
||||
<ul class={listUlClass}>
|
||||
<li class={listLiClass}>
|
||||
<a href="/">Introduction</a>
|
||||
</li>
|
||||
<li class={listLiClass}>
|
||||
<a href="/portfolio">Portfolio</a>
|
||||
<ul class={listUlClass}>
|
||||
<li class={nestedListLiClass}>
|
||||
<a href="/portfolio#personal-information">About</a>
|
||||
</li>
|
||||
<li class={nestedListLiClass}>
|
||||
<a href="/portfolio#skills">Skills</a>
|
||||
</li>
|
||||
<li class={nestedListLiClass}>
|
||||
<a href="/portfolio#work-history">Work History</a>
|
||||
</li>
|
||||
<li class={nestedListLiClass}>
|
||||
<a href="/portfolio#projects">Projects</a>
|
||||
</li>
|
||||
<li class={nestedListLiClass}>
|
||||
<a href="/portfolio#education">Education</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="latest-posts {sectionListSectionClass} {latestPostsClass}">
|
||||
<h3 class={headerClass}>
|
||||
<a href="/blog">Latest posts</a>
|
||||
</h3>
|
||||
<ul class={listUlClass}>
|
||||
{#each latestPosts as post}
|
||||
<li class={listLiClass}>
|
||||
<a rel="prefetch" href="/{post.segments[0]}/{post.slug}">
|
||||
<span>{post.title}</span>
|
||||
<time class="date {dateClass}" datetime={post.date}>
|
||||
- {format(new Date(post.date), 'do MMM, yyyy')}
|
||||
</time>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<hr class={hrClass} />
|
||||
<section class="subscribe {boldClass}">
|
||||
<a href="/feed.xml" rel="external" title="RSS feed" class="rss">
|
||||
Subscribe
|
||||
<SvgIcon name="rss" className={svgClass} />
|
||||
</a>
|
||||
<a
|
||||
href="/feed.json"
|
||||
rel="external"
|
||||
title="JSON feed"
|
||||
class="json-feed"
|
||||
aria-label="Subscribe with JSON feed"
|
||||
>
|
||||
<SvgIcon name="json-feed" className={svgClass} />
|
||||
</a>
|
||||
</section>
|
||||
</section>
|
||||
<section class="socials {sectionListSectionClass}">
|
||||
<h3 class={headerClass}>Contact</h3>
|
||||
<ul class="social-links {listUlClass}">
|
||||
<li class="email {listLiClass}">
|
||||
<a
|
||||
class={socialLinkClass}
|
||||
href="mailto: michalvankosk@gmail.com"
|
||||
title="E-mail address"
|
||||
>
|
||||
<SvgIcon name="mail" className={svgClass} />
|
||||
<span class={socialLinkLabelClass}>michalvankosk@gmail.com</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="twitter {listLiClass}">
|
||||
<a
|
||||
class={socialLinkClass}
|
||||
href="https://twitter.com/michalvankodev"
|
||||
title="Twitter profile"
|
||||
>
|
||||
<SvgIcon name="twitter" className={strokeSvgClass} />
|
||||
<span class={socialLinkLabelClass}>Twitter</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="github {listLiClass}">
|
||||
<a
|
||||
class={socialLinkClass}
|
||||
href="https://github.com/michalvankodev"
|
||||
title="Github profile"
|
||||
>
|
||||
<SvgIcon name="github" className={strokeSvgClass} />
|
||||
<span class={socialLinkLabelClass}>Github</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="linkedin {listLiClass}">
|
||||
<a
|
||||
class={socialLinkClass}
|
||||
href="https://www.linkedin.com/in/michal-vanko-dev/"
|
||||
title="LinkedIn profile"
|
||||
>
|
||||
<SvgIcon name="linkedin" className={svgClass} />
|
||||
<span class={socialLinkLabelClass}>LinkedIn</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="twitch {listLiClass}">
|
||||
<a
|
||||
class={socialLinkClass}
|
||||
href="https://twitch.tv/michalvankodev"
|
||||
title="Twitch profile"
|
||||
>
|
||||
<SvgIcon name="twitch" className={svgClass} />
|
||||
<span class={socialLinkLabelClass}>Twitch</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="instagram {listLiClass}">
|
||||
<a
|
||||
class={socialLinkClass}
|
||||
href="https://www.instagram.com/michalvankodev/"
|
||||
title="Instagram profile"
|
||||
>
|
||||
<SvgIcon name="instagram" className={svgClass} />
|
||||
<span class={socialLinkLabelClass}>Instagram</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<footer class={bottomLineClass}>
|
||||
<p
|
||||
class={licenceText}
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dct="http://purl.org/dc/terms/"
|
||||
>
|
||||
<a
|
||||
property="dct:title"
|
||||
rel="cc:attributionURL"
|
||||
href="https://michalvanko.dev/">michalvanko.dev</a
|
||||
>
|
||||
by
|
||||
<a
|
||||
rel="cc:attributionURL dct:creator"
|
||||
property="cc:attributionName"
|
||||
href="https://michalvanko.dev/">Michal Vanko</a
|
||||
>
|
||||
is licensed under
|
||||
<a
|
||||
href="http://creativecommons.org/licenses/by-nc-nd/4.0/?ref=chooser-v1"
|
||||
target="_blank"
|
||||
rel="license noopener noreferrer"
|
||||
style="display:inline-block;"
|
||||
>CC BY-NC-ND 4.0<img
|
||||
style="height:22px!important;margin-left:3px;vertical-align:text-bottom;"
|
||||
src="https://mirrors.creativecommons.org/presskit/icons/cc.svg?ref=chooser-v1"
|
||||
alt="cc"
|
||||
/><img
|
||||
style="height:22px!important;margin-left:3px;vertical-align:text-bottom;"
|
||||
src="https://mirrors.creativecommons.org/presskit/icons/by.svg?ref=chooser-v1"
|
||||
alt="by"
|
||||
/><img
|
||||
style="height:22px!important;margin-left:3px;vertical-align:text-bottom;"
|
||||
src="https://mirrors.creativecommons.org/presskit/icons/nc.svg?ref=chooser-v1"
|
||||
alt="nc"
|
||||
/><img
|
||||
style="height:22px!important;margin-left:3px;vertical-align:text-bottom;"
|
||||
src="https://mirrors.creativecommons.org/presskit/icons/nd.svg?ref=chooser-v1"
|
||||
alt="nd"
|
||||
/></a
|
||||
>
|
||||
</p>
|
||||
</footer>
|
||||
</footer>
|
@@ -1,85 +0,0 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css'
|
||||
import { radialGradient, transparentize } from 'polished'
|
||||
import { menuBackground, transparent, vars } from '$lib/styles/vars.css'
|
||||
import { sprinkles } from '$lib/styles/sprinkles.css'
|
||||
|
||||
export const navigationClass = style([
|
||||
sprinkles({
|
||||
paddingTop: '1x',
|
||||
paddingBottom: '2x',
|
||||
paddingX: '1x',
|
||||
color: 'menu',
|
||||
textShadow: 'menuLinkShadow',
|
||||
}),
|
||||
radialGradient({
|
||||
colorStops: [
|
||||
`${menuBackground} 0%`,
|
||||
`${transparentize(1, menuBackground)} 100%`,
|
||||
],
|
||||
extent: '120% 100% at 0% 0%',
|
||||
fallback: transparent,
|
||||
}),
|
||||
])
|
||||
|
||||
export const navigationContentClass = sprinkles({
|
||||
display: 'flex',
|
||||
maxWidth: 'max',
|
||||
marginY: 'none',
|
||||
marginX: 'auto',
|
||||
})
|
||||
|
||||
export const navigationLinksClass = sprinkles({
|
||||
listStyle: 'none',
|
||||
margin: 'none',
|
||||
padding: 'none',
|
||||
display: 'flex',
|
||||
flex: '1',
|
||||
flexWrap: 'wrap',
|
||||
})
|
||||
|
||||
export const logoSectionClass = sprinkles({
|
||||
lineHeight: 'none',
|
||||
})
|
||||
|
||||
export const logoLinkClass = sprinkles({
|
||||
padding: 'none',
|
||||
display: 'block',
|
||||
})
|
||||
|
||||
globalStyle(`${navigationClass} a:not(${logoLinkClass})`, {
|
||||
color: vars.color.menuLink,
|
||||
padding: vars.space['1x'],
|
||||
})
|
||||
|
||||
globalStyle(`${navigationClass} a:hover`, {
|
||||
color: vars.color.menuLinkHover,
|
||||
})
|
||||
|
||||
export const logoImgClass = style({
|
||||
height: vars.space['3x'],
|
||||
})
|
||||
|
||||
export const selectedClass = sprinkles({
|
||||
textShadow: 'menuActiveLinkShadow',
|
||||
})
|
||||
|
||||
export const portfolioPageNavigation = style({
|
||||
position: 'sticky',
|
||||
top: '0px',
|
||||
zIndex: 1,
|
||||
width: '100%',
|
||||
fontSize: vars.fontSize.sm,
|
||||
padding: vars.space['1x'],
|
||||
background: vars.color.background,
|
||||
boxShadow: `0px 0.5em 0.5em ${vars.color.background}`,
|
||||
})
|
||||
|
||||
export const portfolioPageNavigationLinksClass = sprinkles({
|
||||
maxWidth: 'l',
|
||||
marginX: 'auto',
|
||||
marginY: 'none',
|
||||
})
|
||||
|
||||
export const portfolioPageNavigationLinkClass = sprinkles({
|
||||
padding: '1x',
|
||||
})
|
@@ -1,94 +0,0 @@
|
||||
<script>
|
||||
import classNames from 'classnames'
|
||||
import {
|
||||
logoImgClass,
|
||||
logoLinkClass,
|
||||
logoSectionClass,
|
||||
navigationClass,
|
||||
navigationContentClass,
|
||||
navigationLinksClass,
|
||||
portfolioPageNavigation,
|
||||
portfolioPageNavigationLinkClass,
|
||||
portfolioPageNavigationLinksClass,
|
||||
selectedClass,
|
||||
} from './Nav.css'
|
||||
import { page } from '$app/stores'
|
||||
|
||||
$: segment = $page.url.pathname
|
||||
|
||||
let links = [
|
||||
{
|
||||
label: 'Introduction',
|
||||
url: '/',
|
||||
},
|
||||
{
|
||||
label: 'Blog',
|
||||
url: '/blog',
|
||||
},
|
||||
{
|
||||
label: 'Broadcasts',
|
||||
url: '/broadcasts',
|
||||
},
|
||||
// {
|
||||
// label: "Dev's Cookery",
|
||||
// url: '/cookery',
|
||||
// },
|
||||
{
|
||||
label: 'Portfolio',
|
||||
url: '/portfolio',
|
||||
},
|
||||
]
|
||||
</script>
|
||||
|
||||
<nav class={navigationClass}>
|
||||
<section class={navigationContentClass}>
|
||||
<ul class={navigationLinksClass}>
|
||||
{#each links as link}
|
||||
<li>
|
||||
<a
|
||||
rel="prefetch"
|
||||
class={classNames({ [selectedClass]: segment === link.url })}
|
||||
href={link.url}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<aside class="logo-section {logoSectionClass}">
|
||||
<a class="logo {logoLinkClass}" href=".">
|
||||
<img
|
||||
class={logoImgClass}
|
||||
src="/m-logo.svg"
|
||||
alt="m logo"
|
||||
width="44px"
|
||||
height="44px"
|
||||
/>
|
||||
</a>
|
||||
</aside>
|
||||
</section>
|
||||
</nav>
|
||||
|
||||
{#if segment === '/portfolio'}
|
||||
<section class="page-navigation {portfolioPageNavigation}">
|
||||
<div class={portfolioPageNavigationLinksClass}>
|
||||
<a
|
||||
class={portfolioPageNavigationLinkClass}
|
||||
href="/portfolio#personal-information">About</a
|
||||
>
|
||||
<a class={portfolioPageNavigationLinkClass} href="/portfolio#skills"
|
||||
>Skills</a
|
||||
>
|
||||
<a class={portfolioPageNavigationLinkClass} href="/portfolio#work-history"
|
||||
>Work History</a
|
||||
>
|
||||
<a class={portfolioPageNavigationLinkClass} href="/portfolio#projects"
|
||||
>Projects</a
|
||||
>
|
||||
<a class={portfolioPageNavigationLinkClass} href="/portfolio#education"
|
||||
>Education</a
|
||||
>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
@@ -1,9 +0,0 @@
|
||||
<script lang="ts">
|
||||
import svgSprite from '../../svg/build/icons-sprite.svg'
|
||||
export let className: string
|
||||
export let name: string
|
||||
</script>
|
||||
|
||||
<svg aria-hidden="true" class={className}>
|
||||
<use xlink:href={`${svgSprite}#${name}`} />
|
||||
</svg>
|
@@ -1,18 +0,0 @@
|
||||
<script lang="ts">
|
||||
interface ArticleDetails {
|
||||
title: string
|
||||
slug: string
|
||||
preview: string
|
||||
}
|
||||
export let segment: string
|
||||
export let article: ArticleDetails
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<header>
|
||||
<h2>
|
||||
<a rel="prefetch" href={`/${segment}/${article.slug}`}>{article.title}</a>
|
||||
</h2>
|
||||
</header>
|
||||
{@html article.preview}
|
||||
</article>
|
@@ -1,30 +0,0 @@
|
||||
import { sprinkles } from '$lib/styles/sprinkles.css'
|
||||
|
||||
export const tagsListClass = sprinkles({
|
||||
listStyle: 'none',
|
||||
margin: 'none',
|
||||
padding: 'none',
|
||||
display: 'inline',
|
||||
})
|
||||
|
||||
export const tagsListLiClass = sprinkles({
|
||||
display: 'inline',
|
||||
fontStyle: 'italic',
|
||||
})
|
||||
|
||||
export const publishedClass = sprinkles({
|
||||
whiteSpace: 'nowrap',
|
||||
fontStyle: 'italic',
|
||||
})
|
||||
|
||||
export const publishedLabelClass = sprinkles({
|
||||
color: 'tintedText',
|
||||
})
|
||||
|
||||
export const footerClass = sprinkles({
|
||||
display: 'flex',
|
||||
fontSize: 'sm',
|
||||
justifyContent: 'space-between',
|
||||
paddingTop: '1x',
|
||||
marginTop: '2x',
|
||||
})
|
@@ -1,37 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { horizontalBorderTopClass } from '$lib/styles/scoops.css'
|
||||
|
||||
import { format } from 'date-fns'
|
||||
import type { ArticleContent } from '$lib/content/articleContentListing'
|
||||
import {
|
||||
footerClass,
|
||||
publishedClass,
|
||||
publishedLabelClass,
|
||||
tagsListClass,
|
||||
tagsListLiClass,
|
||||
} from './ArticlePreviewFooter.css'
|
||||
|
||||
export let segment: string
|
||||
export let article: ArticleContent
|
||||
</script>
|
||||
|
||||
<footer class="{footerClass} {horizontalBorderTopClass}">
|
||||
<div class="article-tags">
|
||||
{#if article.tags.length > 0}
|
||||
<span class="lighten">Tags:</span>
|
||||
<ul class={tagsListClass}>
|
||||
{#each article.tags as tag}
|
||||
<li class={tagsListLiClass}>
|
||||
<a href="/{segment}/tags/{tag}">{tag}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="created-at">
|
||||
<span class={publishedLabelClass}>Published on</span>
|
||||
<time datetime={article.date} class={publishedClass}>
|
||||
{format(new Date(article.date), "do MMMM',' y")}
|
||||
</time>
|
||||
</div>
|
||||
</footer>
|
@@ -1,20 +0,0 @@
|
||||
import { globalStyle } from '@vanilla-extract/css'
|
||||
import { vars } from '$lib/styles/vars.css'
|
||||
import { sprinkles } from '$lib/styles/sprinkles.css'
|
||||
|
||||
export const postListClass = sprinkles({
|
||||
padding: 'none',
|
||||
lineHeight: '3x',
|
||||
listStyle: 'none',
|
||||
})
|
||||
|
||||
export const seeAllClass = sprinkles({
|
||||
textAlign: 'end',
|
||||
width: 'parent',
|
||||
maxWidth: 'max',
|
||||
margin: 'auto',
|
||||
})
|
||||
|
||||
globalStyle(`${postListClass} > li:not(:last-child)`, {
|
||||
marginBottom: vars.space['4x'],
|
||||
})
|
@@ -1,41 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ArticleFooter from '$lib/components/articles/ArticlePreviewFooter/ArticlePreviewFooter.svelte'
|
||||
import Paginator from '$lib/components/paginator/Paginator.svelte'
|
||||
import { postListClass } from './ArticlePreviewList.css'
|
||||
import ArticlePreviewCard from '$lib/components/articles/ArticlePreviewCard/ArticlePreviewCard.svelte'
|
||||
import type { PaginationResult } from '$lib/pagination/pagination'
|
||||
import type { ArticleContent } from '$lib/content/articleContentListing'
|
||||
|
||||
export let page: number
|
||||
export let pageSize: number
|
||||
export let filters: Record<string, string>
|
||||
export let posts: PaginationResult<ArticleContent>
|
||||
export let segment: string
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<Paginator
|
||||
{segment}
|
||||
{page}
|
||||
{pageSize}
|
||||
{filters}
|
||||
totalCount={posts.totalCount}
|
||||
/>
|
||||
</header>
|
||||
<ul class="post-list {postListClass}">
|
||||
{#each posts.items as article (article.slug)}
|
||||
<li>
|
||||
<ArticlePreviewCard {article} {segment} />
|
||||
<ArticleFooter {article} {segment} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<footer>
|
||||
<Paginator
|
||||
{segment}
|
||||
{page}
|
||||
{pageSize}
|
||||
{filters}
|
||||
totalCount={posts.totalCount}
|
||||
/>
|
||||
</footer>
|
@@ -1,21 +0,0 @@
|
||||
import { sprinkles } from '$lib/styles/sprinkles.css'
|
||||
|
||||
export const listClass = sprinkles({
|
||||
listStyle: 'none',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
})
|
||||
|
||||
export const listItemClass = sprinkles({
|
||||
paddingX: '1x',
|
||||
})
|
||||
|
||||
export const activePage = sprinkles({
|
||||
//fontStyle: 'italic',
|
||||
fontWeight: 'bold',
|
||||
paddingX: '2x',
|
||||
})
|
||||
|
||||
export const pageLinkClass = sprinkles({
|
||||
paddingX: '1x',
|
||||
})
|
@@ -1,50 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
activePage,
|
||||
listClass,
|
||||
listItemClass,
|
||||
pageLinkClass,
|
||||
} from './Paginator.css'
|
||||
|
||||
import { getPaginatorPages, createHref } from './paginatorUtils'
|
||||
|
||||
export const Divider = 'divider'
|
||||
|
||||
export let segment: string
|
||||
export let page: number
|
||||
export let pageSize: number
|
||||
export let totalCount: number
|
||||
export let filters: Record<string, string>
|
||||
|
||||
$: paginatorPages = getPaginatorPages({ page, pageSize, totalCount })
|
||||
</script>
|
||||
|
||||
<ul class={listClass}>
|
||||
{#if page !== 1}
|
||||
<li class="{listItemClass} ">
|
||||
<a class={pageLinkClass} href={createHref(segment, filters, page - 1)}
|
||||
><</a
|
||||
>
|
||||
</li>
|
||||
{/if}
|
||||
{#each paginatorPages as pageNumber}
|
||||
{#if pageNumber === Divider}
|
||||
<li class={listItemClass}>...</li>
|
||||
{:else if page === pageNumber}
|
||||
<li class="{listItemClass} {activePage}">{pageNumber}</li>
|
||||
{:else}
|
||||
<li class="{listItemClass} ">
|
||||
<a class={pageLinkClass} href={createHref(segment, filters, pageNumber)}
|
||||
>{pageNumber}</a
|
||||
>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if page !== paginatorPages.length}
|
||||
<li class="{listItemClass} ">
|
||||
<a class={pageLinkClass} href={createHref(segment, filters, page + 1)}
|
||||
>></a
|
||||
>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
@@ -1,52 +0,0 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { Divider, getPaginatorPages } from './paginatorUtils'
|
||||
|
||||
describe('Paginator component', () => {
|
||||
describe('Paginator generates feasable pages to display', () => {
|
||||
test('Page: 1/5', () => {
|
||||
expect(
|
||||
getPaginatorPages({ page: 1, totalCount: 5, pageSize: 1 })
|
||||
).toEqual([1, 2, 3, 4, 5])
|
||||
})
|
||||
test('Page: 4/7', () => {
|
||||
expect(
|
||||
getPaginatorPages({ page: 4, totalCount: 7, pageSize: 1 })
|
||||
).toEqual([1, 2, 3, 4, 5, 6, 7])
|
||||
})
|
||||
test('Page: 4/8', () => {
|
||||
expect(
|
||||
getPaginatorPages({ page: 4, totalCount: 8, pageSize: 1 })
|
||||
).toEqual([1, 2, 3, 4, 5, 6, Divider, 8])
|
||||
})
|
||||
test('Page: 1/10', () => {
|
||||
expect(
|
||||
getPaginatorPages({ page: 1, totalCount: 10, pageSize: 1 })
|
||||
).toEqual([1, 2, 3, 4, 5, 6, Divider, 10])
|
||||
})
|
||||
test('Page: 2/10', () => {
|
||||
expect(
|
||||
getPaginatorPages({ page: 2, totalCount: 10, pageSize: 1 })
|
||||
).toEqual([1, 2, 3, 4, 5, 6, Divider, 10])
|
||||
})
|
||||
test('Page: 5/10', () => {
|
||||
expect(
|
||||
getPaginatorPages({ page: 5, totalCount: 10, pageSize: 1 })
|
||||
).toEqual([1, Divider, 3, 4, 5, 6, 7, Divider, 10])
|
||||
})
|
||||
test('Page: 7/10', () => {
|
||||
expect(
|
||||
getPaginatorPages({ page: 7, totalCount: 10, pageSize: 1 })
|
||||
).toEqual([1, Divider, 5, 6, 7, 8, 9, 10])
|
||||
})
|
||||
test('Page: 8/10', () => {
|
||||
expect(
|
||||
getPaginatorPages({ page: 8, totalCount: 10, pageSize: 1 })
|
||||
).toEqual([1, Divider, 5, 6, 7, 8, 9, 10])
|
||||
})
|
||||
test('Page: 10/10', () => {
|
||||
expect(
|
||||
getPaginatorPages({ page: 10, totalCount: 10, pageSize: 1 })
|
||||
).toEqual([1, Divider, 5, 6, 7, 8, 9, 10])
|
||||
})
|
||||
})
|
||||
})
|
@@ -1,51 +0,0 @@
|
||||
import { toParams } from '$lib/pagination/dropTakeParams'
|
||||
import { last, range } from 'ramda'
|
||||
|
||||
export const Divider = 'divider'
|
||||
|
||||
export function getPaginatorPages({
|
||||
page,
|
||||
pageSize,
|
||||
totalCount,
|
||||
}: {
|
||||
page: number
|
||||
pageSize: number
|
||||
totalCount: number
|
||||
}): (number | typeof Divider)[] {
|
||||
const maxLinksLength = 7
|
||||
const linksAroundActive = 2
|
||||
const totalPages = Math.ceil(totalCount / pageSize)
|
||||
const shownPages = range(1, totalPages + 1).reduce<
|
||||
(number | typeof Divider)[]
|
||||
>((acc, link) => {
|
||||
const isFirst = link === 1
|
||||
const isLast = link === totalPages
|
||||
const isPageOnStart = page <= 3 && link < maxLinksLength
|
||||
const isPageOnEnd =
|
||||
page >= totalPages - 3 && link > totalPages - maxLinksLength + 1
|
||||
|
||||
if ([isFirst, isLast, isPageOnStart, isPageOnEnd].some((value) => value)) {
|
||||
return [...acc, link]
|
||||
}
|
||||
|
||||
if (link < page - linksAroundActive || link > page + linksAroundActive) {
|
||||
if (last(acc) === Divider) {
|
||||
return acc
|
||||
}
|
||||
return [...acc, Divider]
|
||||
}
|
||||
|
||||
return [...acc, link]
|
||||
}, [])
|
||||
|
||||
return shownPages
|
||||
}
|
||||
|
||||
export function createHref(
|
||||
href: string,
|
||||
filters: Record<string, string>,
|
||||
pageNumber: number
|
||||
) {
|
||||
const filtersPath = toParams(filters)
|
||||
return `/${href}/${filtersPath ? filtersPath + '/' : ''}page/${pageNumber}`
|
||||
}
|
@@ -1,42 +0,0 @@
|
||||
import { map, multiply } from 'ramda'
|
||||
|
||||
export interface ImageOptions {
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the URL for resource with specified parameters for Netlify Large Media trasformation
|
||||
*
|
||||
* @see https://docs.netlify.com/large-media/transform-images/
|
||||
*/
|
||||
export function getNFResize(href: string, { width, height }: ImageOptions) {
|
||||
return `${href}?nf_resize=fit${height ? `&h=${height}` : ''}${
|
||||
width ? `&w=${width}` : ''
|
||||
}`
|
||||
}
|
||||
|
||||
export const PIXEL_DENSITIES = [1, 1.5, 2, 3, 4]
|
||||
|
||||
function multiplyImageOptions(
|
||||
multiplier,
|
||||
imageOptions: ImageOptions
|
||||
): ImageOptions {
|
||||
return map(multiply(multiplier), imageOptions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate `srcset` attribute for all `PIXEL_DENSITIES` to serve images in appropriate quality
|
||||
* for each device with specific density
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/srcset
|
||||
*/
|
||||
export function generateSrcSet(href: string, imageOptions: ImageOptions) {
|
||||
return PIXEL_DENSITIES.map(
|
||||
(density) =>
|
||||
`${getNFResize(
|
||||
href,
|
||||
multiplyImageOptions(density, imageOptions)
|
||||
)} ${density}x`
|
||||
).join(',')
|
||||
}
|
@@ -1,11 +0,0 @@
|
||||
import marked from 'marked'
|
||||
import { renderer } from './renderer-extension'
|
||||
|
||||
marked.use({ renderer })
|
||||
|
||||
export function parseField<T>(field: keyof T) {
|
||||
return (item: T): T => ({
|
||||
...item,
|
||||
[field]: marked(item[field]),
|
||||
})
|
||||
}
|
@@ -1,49 +0,0 @@
|
||||
import { generateSrcSet, getNFResize } from '$lib/large-media'
|
||||
import Prism from 'prismjs'
|
||||
import loadLanguages from 'prismjs/components/index.js'
|
||||
|
||||
loadLanguages(['bash', 'markdown', 'json', 'yaml', 'typescript'])
|
||||
|
||||
export const renderer = {
|
||||
heading(text: string, level: string) {
|
||||
const escapedText = text.toLowerCase().replace(/[^\w]+/g, '-')
|
||||
|
||||
return `
|
||||
<h${level}>
|
||||
<a name="${escapedText}" class="anchor" href="#${escapedText}">
|
||||
<span class="header-link"></span>
|
||||
</a>
|
||||
${text}
|
||||
</h${level}>
|
||||
`
|
||||
},
|
||||
image(href: string, title: string, text: string) {
|
||||
const figcaption = title ? `<figcaption>${title}</figcaption>` : ''
|
||||
const isLocal = !href.startsWith('http')
|
||||
const src = isLocal ? getNFResize(href, { height: 800, width: 800 }) : href
|
||||
const srcset = isLocal
|
||||
? `srcset="${generateSrcSet(href, { width: 800, height: 800 })}"`
|
||||
: ''
|
||||
|
||||
return `
|
||||
<figure>
|
||||
<img
|
||||
alt="${text}"
|
||||
${srcset}
|
||||
src="${src}"
|
||||
/>
|
||||
${figcaption}
|
||||
</figure>
|
||||
`
|
||||
},
|
||||
code(source: string, lang?: string) {
|
||||
// When lang is not specified it is usually an empty string which has to be handled
|
||||
const usedLang = !lang ? 'shell' : lang
|
||||
const highlightedSource = Prism.highlight(
|
||||
source,
|
||||
Prism.languages[usedLang],
|
||||
usedLang
|
||||
)
|
||||
return `<pre class='language-${usedLang}'><code class='language-${usedLang}'>${highlightedSource}</code></pre>`
|
||||
},
|
||||
}
|
@@ -1,11 +0,0 @@
|
||||
import { describe, test, expect } from 'vitest'
|
||||
import { getDropTakeFromPageParams } from './dropTakeParams'
|
||||
|
||||
describe('convert search params', () => {
|
||||
test('should convert from page size and page to offset and limit', () => {
|
||||
expect(getDropTakeFromPageParams(7, 2)).toEqual({
|
||||
offset: 7,
|
||||
limit: 7,
|
||||
})
|
||||
})
|
||||
})
|
@@ -1,42 +0,0 @@
|
||||
import { init, splitEvery } from 'ramda'
|
||||
|
||||
export function parseParams(params: string) {
|
||||
let splittedParams = params.split('/')
|
||||
if (splittedParams.length % 2 !== 0) {
|
||||
splittedParams = init(splittedParams)
|
||||
}
|
||||
const splits = splitEvery(2, splittedParams)
|
||||
return Object.fromEntries(splits)
|
||||
}
|
||||
|
||||
export function toParams(records: Record<string, string>) {
|
||||
return Object.entries(records)
|
||||
.map(([key, value]) => `${key}/${value}`)
|
||||
.join('/')
|
||||
}
|
||||
|
||||
export interface PaginationParams {
|
||||
pageSize: number
|
||||
page: number
|
||||
filters?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface DropTakeParams {
|
||||
offset: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert svelte `load` params into a `offset` and `limit` so they can be used to fetch endpoints with pagination queries
|
||||
*/
|
||||
export function getDropTakeFromPageParams(
|
||||
pageSize: number,
|
||||
page: number
|
||||
): DropTakeParams {
|
||||
const offset = pageSize * (page - 1)
|
||||
const limit = pageSize
|
||||
return {
|
||||
offset,
|
||||
limit,
|
||||
}
|
||||
}
|
@@ -1,98 +0,0 @@
|
||||
import { range } from 'ramda'
|
||||
import { describe, expect, test } from 'vitest'
|
||||
import { filterByPropContains, dropAndTake, filterAndCount } from './pagination'
|
||||
|
||||
describe('pagination', () => {
|
||||
test('does not drop any items by default', () => {
|
||||
const items = range(0, 100)
|
||||
expect(dropAndTake({})(items)).toHaveLength(100)
|
||||
})
|
||||
|
||||
test('limits out exact number of items', () => {
|
||||
const items = range(0, 100)
|
||||
expect(dropAndTake({ limit: 10 })(items)).toHaveLength(10)
|
||||
expect(dropAndTake({ limit: 10 })(items)[0]).toBe(0)
|
||||
expect(dropAndTake({ limit: 10 })(items)[9]).toBe(9)
|
||||
})
|
||||
|
||||
test('offset is skipping a number of items from the front', () => {
|
||||
const items = range(0, 100)
|
||||
expect(dropAndTake({ offset: 10 })(items)).toHaveLength(90)
|
||||
expect(dropAndTake({ offset: 10 })(items)[0]).toBe(10)
|
||||
})
|
||||
|
||||
test('is able to combine limit and offset', () => {
|
||||
const items = range(0, 100)
|
||||
expect(dropAndTake({ offset: 10, limit: 10 })(items)).toHaveLength(10)
|
||||
expect(dropAndTake({ offset: 10, limit: 10 })(items)[0]).toBe(10)
|
||||
expect(dropAndTake({ offset: 10, limit: 10 })(items)[9]).toBe(19)
|
||||
})
|
||||
|
||||
test('is able to filter by a field', () => {
|
||||
const items = [
|
||||
{
|
||||
id: 1,
|
||||
prop: ['yes'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
prop: ['yes', 'no'],
|
||||
},
|
||||
]
|
||||
|
||||
expect(filterByPropContains({ prop: 'no' })(items)).toHaveLength(1)
|
||||
expect(filterByPropContains({ prop: 'no' })(items)[0].id).toBe(2)
|
||||
|
||||
expect(filterByPropContains({ prop: 'yes' })(items)[0].id).toBe(1)
|
||||
expect(filterByPropContains({ prop: 'yes' })(items)).toHaveLength(2)
|
||||
})
|
||||
|
||||
describe('is able to combine limit and offset while filtering by field', () => {
|
||||
const items = [
|
||||
{
|
||||
id: 1,
|
||||
prop: ['yes'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
prop: ['yes', 'no'],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
prop: ['yes', 'no'],
|
||||
},
|
||||
]
|
||||
|
||||
test('combine all parameters', () => {
|
||||
const result = filterAndCount({
|
||||
offset: 1,
|
||||
limit: 1,
|
||||
filters: { prop: 'no' },
|
||||
})(items)
|
||||
expect(result.totalCount).toBe(2)
|
||||
expect(result.items[0].id).toBe(3)
|
||||
})
|
||||
|
||||
test('with 0 offset', () => {
|
||||
const result = filterAndCount({
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
filters: { prop: 'no' },
|
||||
})(items)
|
||||
expect(result.totalCount).toBe(2)
|
||||
expect(result.items[0].id).toBe(2)
|
||||
})
|
||||
|
||||
test('without filter', () => {
|
||||
const result = filterAndCount({ offset: 1, limit: 1 })(items)
|
||||
expect(result.totalCount).toBe(3)
|
||||
expect(result.items[0].id).toBe(2)
|
||||
})
|
||||
|
||||
test('without any params', () => {
|
||||
const result = filterAndCount({})(items)
|
||||
expect(result.totalCount).toBe(3)
|
||||
expect(result.items.length).toEqual(result.totalCount)
|
||||
})
|
||||
})
|
||||
})
|
@@ -1,48 +0,0 @@
|
||||
import { identity, drop, take, pipe } from 'ramda'
|
||||
|
||||
export interface PaginationQuery {
|
||||
offset?: number
|
||||
limit?: number
|
||||
filters?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface PaginationResult<ItemType> {
|
||||
items: ItemType[]
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
export function dropAndTake<Item>({ offset = 0, limit = Infinity }) {
|
||||
return pipe(drop<Item>(offset), take<Item>(limit)) as (
|
||||
items: Item[]
|
||||
) => Item[]
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function filterByPropContains<Item extends Record<string, any>>(
|
||||
filters: Record<string, string>
|
||||
) {
|
||||
return function (items: Item[]) {
|
||||
return items.filter((item) => {
|
||||
return Object.entries(filters).every(([fieldName, value]) =>
|
||||
item[fieldName].includes(value)
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function filterAndCount<Item extends Record<string, any>>({
|
||||
filters,
|
||||
...dropTakeParams
|
||||
}: PaginationQuery) {
|
||||
return function (items: Item[]) {
|
||||
const filterFunction = filters
|
||||
? filterByPropContains<Item>(filters)
|
||||
: identity
|
||||
const filteredItems = filterFunction(items)
|
||||
return {
|
||||
items: dropAndTake<Item>(dropTakeParams)(filteredItems),
|
||||
totalCount: filteredItems.length,
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,27 +0,0 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css'
|
||||
import { vars } from '$lib/styles/vars.css'
|
||||
|
||||
export const contentClass = style({})
|
||||
|
||||
globalStyle(`${contentClass} ul, ${contentClass} ol`, {
|
||||
lineHeight: vars.lineHeight['2x'],
|
||||
})
|
||||
|
||||
globalStyle(`${contentClass} li`, {
|
||||
marginBottom: vars.space['2x'],
|
||||
})
|
||||
|
||||
globalStyle(`${contentClass} img`, {
|
||||
maxHeight: vars.height.image,
|
||||
})
|
||||
|
||||
globalStyle(`${contentClass} img:only-child`, {
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
})
|
||||
|
||||
globalStyle(`${contentClass} .video-embed`, {
|
||||
margin: '0 auto',
|
||||
maxWidth: vars.width.image,
|
||||
aspectRatio: vars.aspectRatio.monitor,
|
||||
})
|
@@ -1,155 +0,0 @@
|
||||
import { globalStyle } from '@vanilla-extract/css'
|
||||
import { breakpoints, colors, vars } from './vars.css'
|
||||
|
||||
globalStyle('html', {
|
||||
scrollBehavior: 'smooth',
|
||||
})
|
||||
|
||||
globalStyle('body', {
|
||||
margin: 0,
|
||||
fontFamily:
|
||||
'cantarell, roboto, -apple-system, blinkmacsystemfont, segoe ui, oxygen, ubuntu, fira sans, droid sans, helvetica neue, sans-serif',
|
||||
fontSize: '16px',
|
||||
lineHeight: 1.65,
|
||||
color: vars.color.articleText,
|
||||
background: vars.color.background,
|
||||
minHeight: '100vh',
|
||||
'@media': {
|
||||
[`screen and (min-width: ${breakpoints.s}px)`]: {
|
||||
fontSize: '18px',
|
||||
},
|
||||
[`screen and (min-width: ${breakpoints.m}px)`]: {
|
||||
fontSize: '24px',
|
||||
},
|
||||
print: {
|
||||
fontSize: '12px',
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
globalStyle('h1, h2, h3, h4, h5, h6', {
|
||||
marginTop: vars.space['2x'],
|
||||
marginBottom: vars.space['1x'],
|
||||
marginLeft: vars.space.none,
|
||||
marginRight: vars.space.none,
|
||||
lineHeight: vars.lineHeight['1x'],
|
||||
color: vars.color.header,
|
||||
fontWeight: 500,
|
||||
letterSpacing: '-0.01em',
|
||||
})
|
||||
|
||||
globalStyle('h1', {
|
||||
fontSize: vars.fontSize['5x'],
|
||||
fontWeight: 800,
|
||||
})
|
||||
|
||||
globalStyle('h2', {
|
||||
fontSize: vars.fontSize['4x'],
|
||||
fontWeight: 700,
|
||||
})
|
||||
|
||||
globalStyle('h3', {
|
||||
fontSize: vars.fontSize['3x'],
|
||||
})
|
||||
|
||||
globalStyle('h4', {
|
||||
fontSize: vars.space['2x'],
|
||||
})
|
||||
|
||||
globalStyle('a', {
|
||||
textDecoration: 'none',
|
||||
transition: 'color 0.2s',
|
||||
})
|
||||
|
||||
globalStyle('a:link', {
|
||||
color: vars.color.link,
|
||||
})
|
||||
|
||||
globalStyle('a:hover', {
|
||||
color: vars.color.linkHover,
|
||||
textDecoration: 'underline',
|
||||
})
|
||||
|
||||
globalStyle('a:visited', {
|
||||
color: vars.color.linkVisited,
|
||||
})
|
||||
|
||||
globalStyle('a:visited:hover', {
|
||||
color: vars.color.linkVisitedHover,
|
||||
})
|
||||
|
||||
globalStyle('main pre, main pre[class*="language-"], main :not(pre) > code', {
|
||||
fontFamily: 'menlo, inconsolata, monospace',
|
||||
backgroundColor: vars.color.codeBackground,
|
||||
paddingTop: vars.space['1x'],
|
||||
paddingBottom: vars.space['1x'],
|
||||
paddingLeft: vars.space['1x'],
|
||||
paddingRight: vars.space['1x'],
|
||||
color: vars.color.code,
|
||||
lineHeight: vars.lineHeight['0x'],
|
||||
boxShadow: vars.boxShadow.codeBoxShadow,
|
||||
borderRadius: 3,
|
||||
})
|
||||
|
||||
globalStyle('main code, main code[class*="language-"]', {
|
||||
fontSize: vars.fontSize.sm,
|
||||
'@media': {
|
||||
[`screen and (min-width: ${breakpoints.m}px)`]: {
|
||||
fontSize: vars.fontSize.xs,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
globalStyle('code', {
|
||||
whiteSpace: 'pre-line',
|
||||
})
|
||||
|
||||
globalStyle('pre code', {
|
||||
whiteSpace: 'pre',
|
||||
})
|
||||
|
||||
globalStyle('figure', {
|
||||
marginTop: vars.space['2x'],
|
||||
marginBottom: vars.space['2x'],
|
||||
marginLeft: vars.space['1x'],
|
||||
marginRight: vars.space['1x'],
|
||||
textAlign: 'center',
|
||||
})
|
||||
|
||||
globalStyle('figcaption', {
|
||||
fontSize: vars.fontSize.xs,
|
||||
fontStyle: 'italic',
|
||||
})
|
||||
|
||||
globalStyle('blockquote', {
|
||||
lineHeight: vars.lineHeight['2x'],
|
||||
margin: vars.space['0x'],
|
||||
paddingLeft: vars.space['2x'],
|
||||
paddingRight: vars.space['0x'],
|
||||
paddingTop: vars.space['1x'],
|
||||
paddingBottom: vars.space['2x'],
|
||||
background: vars.color.quoteBackground,
|
||||
borderRadius: 3,
|
||||
borderLeft: `2px solid ${colors.tearkiss}`,
|
||||
boxShadow: vars.boxShadow.contentBoxShadow,
|
||||
fontSize: vars.fontSize.sm,
|
||||
overflow: 'auto',
|
||||
})
|
||||
|
||||
globalStyle('blockquote p', {
|
||||
marginTop: vars.space['0x'],
|
||||
marginBottom: vars.space['0x'],
|
||||
})
|
||||
|
||||
globalStyle('p', {
|
||||
marginTop: vars.space['1x'],
|
||||
marginBottom: vars.space['1x'],
|
||||
})
|
||||
|
||||
globalStyle('b, strong', {
|
||||
fontWeight: 600,
|
||||
})
|
||||
|
||||
globalStyle('::selection', {
|
||||
background: vars.color.selection,
|
||||
})
|
@@ -1,10 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css'
|
||||
import { desaturate, transparentize } from 'polished'
|
||||
import { colors } from './vars.css'
|
||||
|
||||
export const horizontalBorderTopClass = style({
|
||||
borderTop: `1px solid ${transparentize(
|
||||
0.6,
|
||||
desaturate(0.5, colors.tearkiss)
|
||||
)}`,
|
||||
})
|
@@ -1,80 +0,0 @@
|
||||
import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'
|
||||
import { breakpoints, vars } from './vars.css'
|
||||
|
||||
const responsiveProperties = defineProperties({
|
||||
conditions: {
|
||||
mobile: {},
|
||||
tablet: { '@media': `screen and (min-width: ${breakpoints.m}px)` },
|
||||
desktop: { '@media': `screen and (min-width: ${breakpoints.l}px)` },
|
||||
},
|
||||
defaultCondition: 'mobile',
|
||||
properties: {
|
||||
display: ['none', 'flex', 'block', 'inline', 'inline-block', 'grid'],
|
||||
position: ['relative', 'absolute', 'fixed'],
|
||||
flexDirection: ['row', 'column'],
|
||||
flexWrap: ['wrap', 'nowrap'],
|
||||
flexShrink: [0],
|
||||
flexGrow: [0, 1],
|
||||
justifyContent: [
|
||||
'stretch',
|
||||
'start',
|
||||
'center',
|
||||
'end',
|
||||
'space-around',
|
||||
'space-between',
|
||||
],
|
||||
justifyItems: [
|
||||
'stretch',
|
||||
'start',
|
||||
'center',
|
||||
'end',
|
||||
'space-around',
|
||||
'space-between',
|
||||
],
|
||||
alignItems: ['stretch', 'flex-start', 'center', 'flex-end'],
|
||||
flex: ['1'],
|
||||
gap: vars.space,
|
||||
textAlign: ['center', 'justify', 'start', 'end'],
|
||||
textShadow: vars.textShadow,
|
||||
paddingTop: vars.space,
|
||||
paddingBottom: vars.space,
|
||||
paddingLeft: vars.space,
|
||||
paddingRight: vars.space,
|
||||
marginTop: vars.space,
|
||||
marginBottom: vars.space,
|
||||
marginRight: vars.space,
|
||||
marginLeft: vars.space,
|
||||
columnGap: vars.space,
|
||||
fontSize: vars.fontSize,
|
||||
fontFamily: vars.fontFamily,
|
||||
fontWeight: vars.fontWeight,
|
||||
fontStyle: ['italic', 'normal'],
|
||||
lineHeight: vars.lineHeight,
|
||||
whiteSpace: ['normal', 'nowrap'],
|
||||
width: vars.width,
|
||||
maxWidth: vars.width,
|
||||
height: vars.height,
|
||||
listStyle: ['none'],
|
||||
overflow: ['auto'],
|
||||
aspectRatio: vars.aspectRatio,
|
||||
},
|
||||
shorthands: {
|
||||
padding: ['paddingTop', 'paddingBottom', 'paddingLeft', 'paddingRight'],
|
||||
paddingX: ['paddingLeft', 'paddingRight'],
|
||||
paddingY: ['paddingTop', 'paddingBottom'],
|
||||
placeItems: ['justifyContent', 'alignItems'],
|
||||
typeSize: ['fontSize', 'lineHeight'],
|
||||
margin: ['marginTop', 'marginBottom', 'marginLeft', 'marginRight'],
|
||||
marginX: ['marginLeft', 'marginRight'],
|
||||
marginY: ['marginTop', 'marginBottom'],
|
||||
},
|
||||
})
|
||||
|
||||
const colorProperties = defineProperties({
|
||||
properties: {
|
||||
color: vars.color,
|
||||
background: vars.color,
|
||||
},
|
||||
})
|
||||
|
||||
export const sprinkles = createSprinkles(responsiveProperties, colorProperties)
|
@@ -1,154 +0,0 @@
|
||||
import { createGlobalTheme } from '@vanilla-extract/css'
|
||||
import {
|
||||
darken,
|
||||
desaturate,
|
||||
lighten,
|
||||
mix,
|
||||
modularScale,
|
||||
saturate,
|
||||
tint,
|
||||
transparentize,
|
||||
} from 'polished'
|
||||
|
||||
export const colors = {
|
||||
tearkiss: '#42a6f0',
|
||||
pinky: '#fea6eb',
|
||||
lightCyan: '#d8f6ff',
|
||||
midnightBlue: '#171664',
|
||||
frenchViolet: '#7332c3',
|
||||
}
|
||||
|
||||
export const menuBackground = transparentize(0.6, colors.tearkiss)
|
||||
export const twitchEmbedBackground = transparentize(0.6, colors.pinky)
|
||||
export const background = tint(0.7, colors.lightCyan)
|
||||
export const codeBackground = tint(0.2, background)
|
||||
export const quoteBackground = darken(0.02, background)
|
||||
export const transparent = transparentize(1, '#ffffff')
|
||||
const articleText = desaturate(0.16, colors.midnightBlue)
|
||||
|
||||
export enum breakpoints {
|
||||
s = 400,
|
||||
m = 700,
|
||||
image = 800,
|
||||
l = 1000,
|
||||
max = 1140,
|
||||
}
|
||||
|
||||
export function mediaAt(breakpoint: breakpoints) {
|
||||
return `screen and (min-width: ${breakpoint}px)`
|
||||
}
|
||||
|
||||
const createScale =
|
||||
(base: number, ratio: number, unit = 'em') =>
|
||||
(steps: number) =>
|
||||
`${modularScale(steps, base, ratio)}${unit}`
|
||||
|
||||
const spaceScale = createScale(0.2, 2)
|
||||
const fontSizeScale = createScale(1, 1.125)
|
||||
const lineHeightScale = createScale(1.05, 1.125)
|
||||
// const borderRadiusScale = createScale(1.5, 4)
|
||||
|
||||
export const vars = createGlobalTheme(':root', {
|
||||
space: {
|
||||
none: '0',
|
||||
auto: 'auto',
|
||||
'0x': spaceScale(0),
|
||||
'1x': spaceScale(1),
|
||||
'2x': spaceScale(2),
|
||||
'3x': spaceScale(3),
|
||||
'4x': spaceScale(4),
|
||||
'5x': spaceScale(5),
|
||||
'6x': spaceScale(6),
|
||||
'7x': spaceScale(7),
|
||||
'8x': spaceScale(8),
|
||||
},
|
||||
color: {
|
||||
articleText,
|
||||
tintedText: tint(0.25, articleText),
|
||||
selection: tint(0.4, colors.pinky),
|
||||
link: saturate(0.2, mix(0.66, colors.tearkiss, colors.midnightBlue)),
|
||||
linkHover: colors.tearkiss,
|
||||
linkVisited: colors.frenchViolet,
|
||||
linkVisitedHover: lighten(0.1, colors.frenchViolet),
|
||||
code: lighten(0.15, articleText),
|
||||
|
||||
menu: colors.midnightBlue,
|
||||
menuLink: colors.midnightBlue,
|
||||
menuLinkHover: lighten(0.15, colors.midnightBlue),
|
||||
|
||||
header: lighten(0.1, colors.midnightBlue),
|
||||
background,
|
||||
codeBackground,
|
||||
quoteBackground,
|
||||
menuBackground,
|
||||
},
|
||||
fontFamily: {
|
||||
body: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"',
|
||||
},
|
||||
fontSize: {
|
||||
xs: fontSizeScale(-2),
|
||||
sm: fontSizeScale(-1),
|
||||
base: fontSizeScale(0),
|
||||
xl: fontSizeScale(1),
|
||||
'2x': fontSizeScale(2),
|
||||
'3x': fontSizeScale(3),
|
||||
'4x': fontSizeScale(5),
|
||||
'5x': fontSizeScale(7),
|
||||
'6x': fontSizeScale(9),
|
||||
},
|
||||
lineHeight: {
|
||||
none: '0',
|
||||
'0x': lineHeightScale(0),
|
||||
'1x': lineHeightScale(1),
|
||||
'2x': lineHeightScale(2),
|
||||
'3x': lineHeightScale(3),
|
||||
'4x': lineHeightScale(4),
|
||||
'5x': lineHeightScale(5),
|
||||
},
|
||||
fontWeight: {
|
||||
thin: 'thin',
|
||||
normal: 'normal',
|
||||
bold: 'bold',
|
||||
},
|
||||
textShadow: {
|
||||
menuLinkShadow: `0.02em 0.02em 0.03em ${transparentize(
|
||||
0.7,
|
||||
colors.midnightBlue
|
||||
)}`,
|
||||
menuActiveLinkShadow: `0.01em 0.01em 0.05em ${transparentize(
|
||||
0.1,
|
||||
colors.midnightBlue
|
||||
)}`,
|
||||
},
|
||||
boxShadow: {
|
||||
contentBoxShadow: `0px 0px 2px 1px ${transparentize(
|
||||
0.5,
|
||||
desaturate(0.5, colors.tearkiss)
|
||||
)}`,
|
||||
codeBoxShadow: `inset 0px 0px 2px 1px ${transparentize(
|
||||
0.8,
|
||||
desaturate(0.5, colors.tearkiss)
|
||||
)}`,
|
||||
},
|
||||
width: {
|
||||
auto: 'auto',
|
||||
s: '400px',
|
||||
m: '700px',
|
||||
image: '800px',
|
||||
l: '1000px',
|
||||
max: '1140px',
|
||||
full: '100vw',
|
||||
parent: '100%',
|
||||
layoutMax: '42rem',
|
||||
headerFooterMax: '52rem',
|
||||
additionalBlockMax: '46rem',
|
||||
},
|
||||
height: {
|
||||
full: '100hw',
|
||||
parent: '100%',
|
||||
image: '640px',
|
||||
},
|
||||
aspectRatio: {
|
||||
monitor: '16 / 9',
|
||||
},
|
||||
})
|
64
src/main.rs
Normal file
@@ -0,0 +1,64 @@
|
||||
use axum::{self};
|
||||
use tower_http::services::ServeDir;
|
||||
use tower_livereload::LiveReloadLayer;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
mod blog_posts;
|
||||
mod components;
|
||||
mod feed;
|
||||
mod filters;
|
||||
mod pages;
|
||||
mod picture_generator;
|
||||
mod post_utils;
|
||||
mod projects;
|
||||
mod router;
|
||||
// mod template;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
|
||||
// axum logs rejections from built-in extractors with the `axum::rejection`
|
||||
// target, at `TRACE` level. `axum::rejection=trace` enables showing those events
|
||||
"axum_server=debug,tower_http=debug,axum::rejection=trace".into()
|
||||
}),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
// build our application with a single route
|
||||
let app = router::get_router()
|
||||
.nest_service("/styles", ServeDir::new("styles"))
|
||||
.nest_service("/images", ServeDir::new("../static/images"))
|
||||
.nest_service("/fonts", ServeDir::new("../static/fonts"))
|
||||
.nest_service("/generated_images", ServeDir::new("generated_images"))
|
||||
.nest_service("/svg", ServeDir::new("../static/svg"))
|
||||
// TODO manifest logos have bad link, #directory-swap
|
||||
.nest_service(
|
||||
"/config.yml",
|
||||
ServeDir::new("../static/resources/config.yml"),
|
||||
);
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
let app = app.layer(LiveReloadLayer::new());
|
||||
|
||||
// run our app with hyper, listening globally on port 3080
|
||||
let port = std::option_env!("PORT").unwrap_or("3080");
|
||||
let addr = format!("0.0.0.0:{}", port);
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||
info!("axum_server listening on http://{}", &addr);
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
// TODO Socials
|
||||
// - fotos
|
||||
// background gradient color
|
||||
// TODO Change DNS system
|
||||
// THINK deploy to alula? rather then katelyn? can be change whenever
|
||||
// TODO after release
|
||||
// OG tags
|
||||
// Remove old web completely
|
||||
// Restructure repository
|
||||
// - projects page
|
9
src/pages/admin.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use askama::Template;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "admin.html")]
|
||||
pub struct AdminPageTemplate {}
|
||||
|
||||
pub async fn render_admin() -> AdminPageTemplate {
|
||||
AdminPageTemplate {}
|
||||
}
|
74
src/pages/blog_post_list.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use askama::Template;
|
||||
use axum::{extract::Path, http::StatusCode};
|
||||
use tokio::try_join;
|
||||
|
||||
use crate::{
|
||||
blog_posts::{
|
||||
blog_post_model::{BlogPostMetadata, BLOG_POST_PATH},
|
||||
tag_list::get_popular_blog_tags,
|
||||
},
|
||||
components::site_header::{HeaderProps, Link},
|
||||
filters,
|
||||
post_utils::{post_listing::get_post_list, post_parser::ParseResult},
|
||||
projects::{featured_projects::get_featured_projects, project_model::ProjectMetadata},
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "blog_post_list.html")]
|
||||
pub struct PostListTemplate {
|
||||
pub title: String,
|
||||
pub posts: Vec<ParseResult<BlogPostMetadata>>,
|
||||
pub tag: Option<String>,
|
||||
pub header_props: HeaderProps,
|
||||
pub blog_tags: Vec<String>,
|
||||
pub featured_projects: Vec<ParseResult<ProjectMetadata>>,
|
||||
}
|
||||
|
||||
pub async fn render_blog_post_list(
|
||||
tag: Option<Path<String>>,
|
||||
) -> Result<PostListTemplate, StatusCode> {
|
||||
// I will forget what happens here in a week. But essentially it's pattern matching and shadowing
|
||||
let tag = tag.map(|Path(tag)| tag);
|
||||
|
||||
let (blog_tags, featured_projects, mut post_list) = try_join!(
|
||||
get_popular_blog_tags(),
|
||||
get_featured_projects(),
|
||||
get_post_list::<BlogPostMetadata>(BLOG_POST_PATH)
|
||||
)?;
|
||||
|
||||
post_list.sort_by_key(|post| post.metadata.date);
|
||||
post_list.retain(|post| post.metadata.published);
|
||||
post_list.reverse();
|
||||
|
||||
let posts = match &tag {
|
||||
Some(tag) => post_list
|
||||
.into_iter()
|
||||
.filter(|post| {
|
||||
post.metadata
|
||||
.tags
|
||||
.iter()
|
||||
.map(|post_tag| post_tag.to_lowercase())
|
||||
.collect::<String>()
|
||||
.contains(&tag.to_lowercase())
|
||||
})
|
||||
.collect(),
|
||||
None => post_list,
|
||||
};
|
||||
|
||||
let header_props = match tag {
|
||||
Some(_) => HeaderProps::with_back_link(Link {
|
||||
href: "/blog".to_string(),
|
||||
label: "All posts".to_string(),
|
||||
}),
|
||||
None => HeaderProps::default(),
|
||||
};
|
||||
|
||||
Ok(PostListTemplate {
|
||||
title: "Blog posts".to_owned(),
|
||||
posts,
|
||||
tag,
|
||||
header_props,
|
||||
blog_tags,
|
||||
featured_projects,
|
||||
})
|
||||
}
|
37
src/pages/blog_post_page.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use askama::Template;
|
||||
use axum::{extract::Path, http::StatusCode};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::{
|
||||
blog_posts::blog_post_model::BlogPostMetadata, components::site_header::Link, filters,
|
||||
post_utils::post_parser::parse_post,
|
||||
};
|
||||
|
||||
use crate::components::site_header::HeaderProps;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "blog_post.html")]
|
||||
pub struct BlogPostTemplate {
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
pub date: DateTime<Utc>,
|
||||
pub tags: Vec<String>,
|
||||
pub header_props: HeaderProps,
|
||||
}
|
||||
|
||||
pub async fn render_blog_post(Path(post_id): Path<String>) -> Result<BlogPostTemplate, StatusCode> {
|
||||
let path = format!("../_posts/blog/{}.md", post_id);
|
||||
let parse_post = parse_post::<BlogPostMetadata>(&path, true);
|
||||
let parsed = parse_post.await?;
|
||||
|
||||
Ok(BlogPostTemplate {
|
||||
title: parsed.metadata.title,
|
||||
date: parsed.metadata.date,
|
||||
tags: parsed.metadata.tags,
|
||||
body: parsed.body,
|
||||
header_props: HeaderProps::with_back_link(Link {
|
||||
href: "/blog".to_string(),
|
||||
label: "All posts".to_string(),
|
||||
}),
|
||||
})
|
||||
}
|
78
src/pages/contact.rs
Normal file
@@ -0,0 +1,78 @@
|
||||
use askama::Template;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use crate::components::site_header::HeaderProps;
|
||||
|
||||
pub struct ContactLink {
|
||||
pub href: String,
|
||||
pub title: String,
|
||||
pub label: String,
|
||||
pub svg: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "contact.html")]
|
||||
pub struct ContactPageTemplate {
|
||||
pub title: String,
|
||||
pub header_props: HeaderProps,
|
||||
pub links: Vec<ContactLink>,
|
||||
}
|
||||
|
||||
pub async fn render_contact() -> Result<ContactPageTemplate, StatusCode> {
|
||||
let links = vec![
|
||||
ContactLink {
|
||||
href: "mailto: michalvankosk@gmail.com".to_string(),
|
||||
label: "michalvankosk@gmail.com".to_string(),
|
||||
title: "E-mail address".to_string(),
|
||||
svg: "mail".to_string(),
|
||||
},
|
||||
ContactLink {
|
||||
href: "https://twitch.tv/michalvankodev".to_string(),
|
||||
label: "Twitch".to_string(),
|
||||
title: "Twitch channel".to_string(),
|
||||
svg: "twitch".to_string(),
|
||||
},
|
||||
ContactLink {
|
||||
href: "https://tiktok.com/@michalvankodev".to_string(),
|
||||
label: "TikTok".to_string(),
|
||||
title: "TikTok channel".to_string(),
|
||||
svg: "tiktok".to_string(),
|
||||
},
|
||||
ContactLink {
|
||||
href: "https://www.youtube.com/@michalvankodev".to_string(),
|
||||
label: "YouTube".to_string(),
|
||||
title: "YouTube channel".to_string(),
|
||||
svg: "youtube".to_string(),
|
||||
},
|
||||
ContactLink {
|
||||
href: "https://instagram.com/michalvankodev".to_string(),
|
||||
label: "Instagram".to_string(),
|
||||
title: "Instagram profile".to_string(),
|
||||
svg: "instagram".to_string(),
|
||||
},
|
||||
ContactLink {
|
||||
href: "https://instagram.com/michalvankodev".to_string(),
|
||||
label: "GitHub".to_string(),
|
||||
title: "Github profile".to_string(),
|
||||
svg: "github".to_string(),
|
||||
},
|
||||
ContactLink {
|
||||
href: "https://www.linkedin.com/in/michal-vanko-dev/".to_string(),
|
||||
label: "LinkedIn".to_string(),
|
||||
title: "LinkedIn profile".to_string(),
|
||||
svg: "linkedin".to_string(),
|
||||
},
|
||||
ContactLink {
|
||||
href: "https://discord.gg/2cGg7kwZEh".to_string(),
|
||||
label: "Discord".to_string(),
|
||||
title: "Discord channel".to_string(),
|
||||
svg: "discord".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
Ok(ContactPageTemplate {
|
||||
title: "Contact".to_owned(),
|
||||
header_props: HeaderProps::default(),
|
||||
links,
|
||||
})
|
||||
}
|
38
src/pages/index.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use askama::Template;
|
||||
use axum::http::StatusCode;
|
||||
use tokio::try_join;
|
||||
|
||||
use crate::{
|
||||
blog_posts::{
|
||||
blog_post_model::BlogPostMetadata, featured_blog_posts::get_featured_blog_posts,
|
||||
tag_list::get_popular_blog_tags,
|
||||
},
|
||||
components::site_header::HeaderProps,
|
||||
filters,
|
||||
post_utils::post_parser::ParseResult,
|
||||
projects::{featured_projects::get_featured_projects, project_model::ProjectMetadata},
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "index.html")]
|
||||
pub struct IndexTemplate {
|
||||
header_props: HeaderProps,
|
||||
blog_tags: Vec<String>,
|
||||
featured_blog_posts: Vec<ParseResult<BlogPostMetadata>>,
|
||||
featured_projects: Vec<ParseResult<ProjectMetadata>>,
|
||||
}
|
||||
|
||||
pub async fn render_index() -> Result<IndexTemplate, StatusCode> {
|
||||
let (blog_tags, featured_blog_posts, featured_projects) = try_join!(
|
||||
get_popular_blog_tags(),
|
||||
get_featured_blog_posts(),
|
||||
get_featured_projects()
|
||||
)?;
|
||||
|
||||
Ok(IndexTemplate {
|
||||
header_props: HeaderProps::default(),
|
||||
blog_tags,
|
||||
featured_blog_posts,
|
||||
featured_projects,
|
||||
})
|
||||
}
|
6
src/pages/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod admin;
|
||||
pub mod blog_post_list;
|
||||
pub mod blog_post_page;
|
||||
pub mod contact;
|
||||
pub mod index;
|
||||
pub mod project_list;
|
30
src/pages/project_list.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use askama::Template;
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use crate::{
|
||||
components::site_header::HeaderProps,
|
||||
post_utils::{post_listing::get_post_list, post_parser::ParseResult},
|
||||
projects::project_model::ProjectMetadata,
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "project_list.html")]
|
||||
pub struct ProjectListTemplate {
|
||||
pub title: String,
|
||||
pub project_list: Vec<ParseResult<ProjectMetadata>>,
|
||||
pub header_props: HeaderProps,
|
||||
}
|
||||
|
||||
pub async fn render_projects_list() -> Result<ProjectListTemplate, StatusCode> {
|
||||
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(ProjectListTemplate {
|
||||
title: "Showcase".to_owned(),
|
||||
header_props: HeaderProps::default(),
|
||||
project_list,
|
||||
})
|
||||
}
|
@@ -1,4 +0,0 @@
|
||||
/** @type {import('@sveltejs/kit').ParamMatcher} */
|
||||
export function match(param: string) {
|
||||
return !['tags', 'page'].some((keyword) => param.startsWith(keyword))
|
||||
}
|
36
src/picture_generator/export_format.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use image::ImageFormat;
|
||||
|
||||
#[derive(Debug, PartialEq, Clone, Copy)]
|
||||
pub enum ExportFormat {
|
||||
Jpeg,
|
||||
Avif,
|
||||
Svg,
|
||||
Png,
|
||||
}
|
||||
|
||||
impl ExportFormat {
|
||||
pub fn get_extension(&self) -> &str {
|
||||
match self {
|
||||
ExportFormat::Jpeg => "jpg",
|
||||
ExportFormat::Avif => "avif",
|
||||
ExportFormat::Svg => "svg",
|
||||
ExportFormat::Png => "png",
|
||||
}
|
||||
}
|
||||
pub fn get_type(&self) -> &str {
|
||||
match self {
|
||||
ExportFormat::Jpeg => "image/jpeg",
|
||||
ExportFormat::Avif => "image/avif",
|
||||
ExportFormat::Svg => "image/svg+xml",
|
||||
ExportFormat::Png => "image/png",
|
||||
}
|
||||
}
|
||||
pub fn get_image_format(&self) -> ImageFormat {
|
||||
match self {
|
||||
ExportFormat::Jpeg => ImageFormat::Jpeg,
|
||||
ExportFormat::Avif => ImageFormat::Avif,
|
||||
ExportFormat::Svg => ImageFormat::Jpeg, // TODO what now?
|
||||
ExportFormat::Png => ImageFormat::Png,
|
||||
}
|
||||
}
|
||||
}
|
49
src/picture_generator/image_generator.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use std::{fs::create_dir_all, path::Path};
|
||||
|
||||
use image::{imageops::FilterType, DynamicImage};
|
||||
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
|
||||
use tracing::{debug, error};
|
||||
|
||||
use super::export_format::ExportFormat;
|
||||
|
||||
pub fn generate_images(
|
||||
image: &DynamicImage,
|
||||
path_to_generated: &Path,
|
||||
resolutions: &[(u32, u32, f32)],
|
||||
formats: &[ExportFormat],
|
||||
) -> Result<(), anyhow::Error> {
|
||||
formats.par_iter().for_each(|format| {
|
||||
resolutions.par_iter().for_each(|resolution| {
|
||||
let (width, height, _) = *resolution;
|
||||
// let image = image.clone();
|
||||
let resized = image.resize_to_fill(width, height, FilterType::Triangle);
|
||||
let file_name = path_to_generated.file_name().unwrap().to_str().unwrap();
|
||||
let save_path = Path::new("./")
|
||||
.join(path_to_generated.strip_prefix("/").unwrap())
|
||||
.with_file_name(format!("{file_name}_{width}x{height}"))
|
||||
.with_extension(format.get_extension());
|
||||
|
||||
if save_path.exists() {
|
||||
debug!("Skip generating {save_path:?} - Already exists");
|
||||
return;
|
||||
}
|
||||
|
||||
let parent_dir = save_path.parent().unwrap();
|
||||
if !parent_dir.exists() {
|
||||
create_dir_all(parent_dir).unwrap();
|
||||
}
|
||||
|
||||
let result = resized.save_with_format(&save_path, format.get_image_format());
|
||||
match result {
|
||||
Err(err) => {
|
||||
error!("Failed to generate {:?} - {:?}", &save_path, err);
|
||||
}
|
||||
_ => {
|
||||
debug!("Generated image {:?}", &save_path);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
27
src/picture_generator/mod.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
/*!
|
||||
This is going to be an attempt for creating HTML markup for serving and generating images
|
||||
for the most common PIXEL_DENSITIES.
|
||||
It should create `<picture>` elements with following features:
|
||||
|
||||
- least amount of needed arguments
|
||||
- for each pixel density it should have a definition in `srcset`
|
||||
- create a `avif` type for the image for each pixel_density
|
||||
- create an image in the original format for each pixel_density
|
||||
- support case of `svg` therefore not doing any of the pixel_density logic
|
||||
|
||||
These features might be considered later:
|
||||
- support case for art direction (different pictures for different screen sizes)
|
||||
|
||||
|
||||
TODO: figure wether `height` or `width` have to be known ahead of time
|
||||
|
||||
## Usage
|
||||
|
||||
It can be used from the rust code
|
||||
It should be used from the templates as well
|
||||
*/
|
||||
|
||||
pub mod export_format;
|
||||
pub mod image_generator;
|
||||
pub mod picture_markup_generator;
|
||||
pub mod resolutions;
|
329
src/picture_generator/picture_markup_generator.rs
Normal file
@@ -0,0 +1,329 @@
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use image::{image_dimensions, ImageReader};
|
||||
use indoc::formatdoc;
|
||||
|
||||
use super::{
|
||||
export_format::ExportFormat, image_generator::generate_images,
|
||||
resolutions::get_max_resolution_with_crop,
|
||||
};
|
||||
|
||||
pub const PIXEL_DENSITIES: [f32; 5] = [1., 1.5, 2., 3., 4.];
|
||||
|
||||
pub fn generate_picture_markup(
|
||||
orig_img_path: &str,
|
||||
width: u32,
|
||||
height: u32,
|
||||
alt_text: &str,
|
||||
class_name: Option<&str>,
|
||||
generate_image: bool,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let exported_formats = get_export_formats(orig_img_path);
|
||||
let class_attr = if let Some(class) = class_name {
|
||||
format!(r#"class="{class}""#)
|
||||
} else {
|
||||
"".to_string()
|
||||
};
|
||||
|
||||
if exported_formats.is_empty() {
|
||||
return Ok(formatdoc!(
|
||||
r#"<img
|
||||
src="{orig_img_path}"
|
||||
width="{width}"
|
||||
height="{height}"
|
||||
{class_attr}
|
||||
alt="{alt_text}"
|
||||
>"#
|
||||
));
|
||||
}
|
||||
let path_to_generated = get_generated_file_name(orig_img_path);
|
||||
|
||||
// TODO This should get removed when we move the project structure #directory-swap
|
||||
let disk_img_path =
|
||||
Path::new("../static/").join(orig_img_path.strip_prefix("/").unwrap_or(orig_img_path));
|
||||
|
||||
let orig_img_dimensions = image_dimensions(&disk_img_path)?;
|
||||
let resolutions = get_resolutions(orig_img_dimensions, width, height);
|
||||
|
||||
let path_to_generated_arc = Arc::new(path_to_generated);
|
||||
let path_to_generated_clone = Arc::clone(&path_to_generated_arc);
|
||||
let resolutions_arc = Arc::new(resolutions);
|
||||
let resolutions_clone = Arc::clone(&resolutions_arc);
|
||||
let exported_formats_arc = Arc::new(exported_formats);
|
||||
let exported_formats_clone = Arc::clone(&exported_formats_arc);
|
||||
|
||||
if generate_image {
|
||||
rayon::spawn(move || {
|
||||
let orig_img = ImageReader::open(&disk_img_path)
|
||||
.with_context(|| format!("Failed to read instrs from {:?}", &disk_img_path))
|
||||
.unwrap()
|
||||
.decode()
|
||||
.unwrap();
|
||||
let path_to_generated = path_to_generated_clone.as_ref();
|
||||
let resolutions = resolutions_clone.as_ref();
|
||||
let exported_formats = exported_formats_clone.as_ref();
|
||||
|
||||
let result =
|
||||
generate_images(&orig_img, path_to_generated, resolutions, exported_formats)
|
||||
.with_context(|| "Failed to generate images".to_string());
|
||||
if let Err(e) = result {
|
||||
tracing::error!("Error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let exported_formats = Arc::clone(&exported_formats_arc);
|
||||
let path_to_generated = Arc::clone(&path_to_generated_arc);
|
||||
let resolutions = Arc::clone(&resolutions_arc);
|
||||
|
||||
let source_tags = exported_formats
|
||||
.iter()
|
||||
.map(|format| {
|
||||
let srcset = generate_srcset(&path_to_generated, format, &resolutions);
|
||||
let format_type = format.get_type();
|
||||
formatdoc!(
|
||||
r#"<source
|
||||
srcset="{srcset}"
|
||||
type="{format_type}"
|
||||
>"#
|
||||
)
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n");
|
||||
|
||||
let image_path = get_image_path(
|
||||
&path_to_generated,
|
||||
resolutions.first().expect("Should this error ever happen?"),
|
||||
exported_formats.last().expect("Can this one ever happen?"),
|
||||
);
|
||||
let image_tag = formatdoc!(
|
||||
r#"<img
|
||||
src="{image_path}"
|
||||
width="{width}"
|
||||
height="{height}"
|
||||
alt="{alt_text}"
|
||||
{class_attr}
|
||||
>"#
|
||||
);
|
||||
|
||||
let result = formatdoc!(
|
||||
r#"<picture>
|
||||
{source_tags}
|
||||
{image_tag}
|
||||
</picture>"#
|
||||
);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn get_image_path(path: &Path, resolution: &(u32, u32, f32), format: &ExportFormat) -> String {
|
||||
let path_name = path.to_str().expect("Image has to have a valid path");
|
||||
let (width, height, _) = resolution;
|
||||
let extension = format.get_extension();
|
||||
|
||||
format!("{path_name}_{width}x{height}.{extension}")
|
||||
}
|
||||
|
||||
fn get_resolutions(
|
||||
(orig_width, orig_height): (u32, u32),
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Vec<(u32, u32, f32)> {
|
||||
let mut resolutions: Vec<(u32, u32, f32)> = vec![];
|
||||
for pixel_density in PIXEL_DENSITIES {
|
||||
let (density_width, density_height) = (
|
||||
(pixel_density * width as f32) as u32,
|
||||
(pixel_density * height as f32) as u32,
|
||||
);
|
||||
|
||||
// The equal sign `=` was added just to prevent next occurence of the loop
|
||||
// In case of the `orig_width` and `orig_height` being the same as `width` and `height`
|
||||
// See test case #1
|
||||
if density_width >= orig_width || density_height >= orig_height {
|
||||
let (max_width, max_height) =
|
||||
get_max_resolution_with_crop((orig_width, orig_height), width, height);
|
||||
resolutions.push((max_width, max_height, pixel_density));
|
||||
break;
|
||||
}
|
||||
resolutions.push((density_width, density_height, pixel_density));
|
||||
}
|
||||
resolutions
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_resolutions() {
|
||||
assert_eq!(
|
||||
get_resolutions((320, 200), 320, 200),
|
||||
vec![(320, 200, 1.)],
|
||||
"Only original size fits"
|
||||
);
|
||||
assert_eq!(
|
||||
get_resolutions((500, 400), 320, 200),
|
||||
vec![(320, 200, 1.), (480, 300, 1.5), (500, 312, 2.)],
|
||||
"Should only create sizes that fits and fill the max possible for the last one - width"
|
||||
);
|
||||
assert_eq!(
|
||||
get_resolutions((400, 600), 300, 200),
|
||||
vec![(300, 200, 1.), (400, 266, 1.5)],
|
||||
"Should only create sizes that fits and fill the max possible for the last one - height"
|
||||
);
|
||||
assert_eq!(
|
||||
get_resolutions((1200, 900), 320, 200),
|
||||
vec![
|
||||
(320, 200, 1.),
|
||||
(480, 300, 1.5),
|
||||
(640, 400, 2.),
|
||||
(960, 600, 3.),
|
||||
(1200, 750, 4.)
|
||||
],
|
||||
"Should create all possible sizes, with the last one maxed"
|
||||
);
|
||||
}
|
||||
|
||||
fn strip_prefixes(path: &Path) -> &Path {
|
||||
// Loop to remove all leading "../" components
|
||||
let mut parent_path = path
|
||||
.parent()
|
||||
.expect("there should be a parent route to an image");
|
||||
while let Ok(stripped) = parent_path
|
||||
.strip_prefix("..")
|
||||
.or(parent_path.strip_prefix("."))
|
||||
{
|
||||
parent_path = stripped;
|
||||
}
|
||||
parent_path
|
||||
}
|
||||
|
||||
fn get_generated_file_name(orig_img_path: &str) -> PathBuf {
|
||||
let path = Path::new(&orig_img_path);
|
||||
// let parent = path
|
||||
// .parent()
|
||||
// .expect("There should be a parent route to an image")
|
||||
// .strip_prefix(".")
|
||||
// .unwrap();
|
||||
let parent = strip_prefixes(path);
|
||||
let file_name = path
|
||||
.file_stem()
|
||||
.expect("There should be a name for every img");
|
||||
let result = Path::new("/generated_images/")
|
||||
.join(
|
||||
parent
|
||||
.strip_prefix("/")
|
||||
.unwrap_or(Path::new("/generated_images/")),
|
||||
)
|
||||
.join(file_name);
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_generated_paths() {
|
||||
let orig_img_path = "/images/uploads/img_name.jpg";
|
||||
assert_eq!(
|
||||
get_generated_file_name(orig_img_path)
|
||||
.to_str()
|
||||
.unwrap_or(""),
|
||||
"/generated_images/images/uploads/img_name"
|
||||
);
|
||||
}
|
||||
|
||||
fn generate_srcset(path: &Path, format: &ExportFormat, resolutions: &[(u32, u32, f32)]) -> String {
|
||||
resolutions
|
||||
.iter()
|
||||
.map(|resolution| {
|
||||
let extension = format.get_extension();
|
||||
let (width, height, density) = resolution;
|
||||
let path_name = path.to_str().expect("Path to an image has to be valid");
|
||||
let formatted_density = if density.fract() == 0.0 {
|
||||
format!("{}", density) // Convert to integer if there is no decimal part
|
||||
} else {
|
||||
format!("{:.1}", density) // Format to 1 decimal place if there is a fractional part
|
||||
};
|
||||
format!("{path_name}_{width}x{height}.{extension} {formatted_density}x")
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn get_export_formats(orig_img_path: &str) -> Vec<ExportFormat> {
|
||||
let path = Path::new(&orig_img_path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str());
|
||||
|
||||
match path {
|
||||
// THINK: Do we want to enable avif? It's very expensive to encode
|
||||
// Some("jpg" | "jpeg") => vec![ExportFormat::Avif, ExportFormat::Jpeg],
|
||||
// Some("png") => vec![ExportFormat::Avif, ExportFormat::Png],
|
||||
Some("jpg" | "jpeg") => vec![ExportFormat::Jpeg],
|
||||
Some("png") => vec![ExportFormat::Png],
|
||||
Some(_) | None => vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_export_formats() {
|
||||
assert_eq!(
|
||||
get_export_formats("/images/uploads/img_name.jpg"),
|
||||
vec![ExportFormat::Avif, ExportFormat::Jpeg]
|
||||
)
|
||||
}
|
||||
#[test]
|
||||
fn test_generate_srcset() {
|
||||
let orig_img_path =
|
||||
<PathBuf as std::str::FromStr>::from_str("/generated_images/images/uploads/img_name")
|
||||
.unwrap();
|
||||
let export_format = ExportFormat::Avif;
|
||||
let resolutions = vec![
|
||||
(320, 200, 1.),
|
||||
(480, 300, 1.5),
|
||||
(640, 400, 2.),
|
||||
(960, 600, 3.),
|
||||
(1200, 750, 4.),
|
||||
];
|
||||
let result = "/generated_images/images/uploads/img_name_320x200.avif 1x, /generated_images/images/uploads/img_name_480x300.avif 1.5x, /generated_images/images/uploads/img_name_640x400.avif 2x, /generated_images/images/uploads/img_name_960x600.avif 3x, /generated_images/images/uploads/img_name_1200x750.avif 4x";
|
||||
assert_eq!(
|
||||
generate_srcset(&orig_img_path, &export_format, &resolutions),
|
||||
result
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_picture_markup() {
|
||||
use indoc::indoc;
|
||||
let width = 300;
|
||||
let height = 200;
|
||||
let orig_img_path = "/images/uploads/2020-03-23_20-24-06_393.jpg";
|
||||
let result = indoc! {
|
||||
r#"<picture>
|
||||
<source
|
||||
srcset="/generated_images/images/uploads/2020-03-23_20-24-06_393_300x200.avif 1x, /generated_images/images/uploads/2020-03-23_20-24-06_393_450x300.avif 1.5x, /generated_images/images/uploads/2020-03-23_20-24-06_393_600x400.avif 2x, /generated_images/images/uploads/2020-03-23_20-24-06_393_900x600.avif 3x, /generated_images/images/uploads/2020-03-23_20-24-06_393_1200x800.avif 4x"
|
||||
type="image/avif"
|
||||
>
|
||||
<source
|
||||
srcset="/generated_images/images/uploads/2020-03-23_20-24-06_393_300x200.jpg 1x, /generated_images/images/uploads/2020-03-23_20-24-06_393_450x300.jpg 1.5x, /generated_images/images/uploads/2020-03-23_20-24-06_393_600x400.jpg 2x, /generated_images/images/uploads/2020-03-23_20-24-06_393_900x600.jpg 3x, /generated_images/images/uploads/2020-03-23_20-24-06_393_1200x800.jpg 4x"
|
||||
type="image/jpeg"
|
||||
>
|
||||
<img
|
||||
src="/generated_images/images/uploads/2020-03-23_20-24-06_393_300x200.jpg"
|
||||
width="300"
|
||||
height="200"
|
||||
alt="Testing image alt"
|
||||
>
|
||||
</picture>"#,
|
||||
};
|
||||
assert_eq!(
|
||||
generate_picture_markup(
|
||||
orig_img_path,
|
||||
width,
|
||||
height,
|
||||
"Testing image alt",
|
||||
None,
|
||||
false
|
||||
)
|
||||
.expect("picture markup has to be generated"),
|
||||
result
|
||||
);
|
||||
}
|
91
src/picture_generator/resolutions.rs
Normal file
@@ -0,0 +1,91 @@
|
||||
pub fn get_max_resolution_with_crop(
|
||||
(orig_width, orig_height): (u32, u32),
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> (u32, u32) {
|
||||
let width_scale = orig_width as f32 / width as f32;
|
||||
let height_scale = orig_height as f32 / height as f32;
|
||||
|
||||
let scale = width_scale.min(height_scale);
|
||||
(
|
||||
(width as f32 * scale) as u32,
|
||||
(height as f32 * scale) as u32,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_max_resolution_with_crop() {
|
||||
assert_eq!(
|
||||
get_max_resolution_with_crop((320, 200), 320, 200),
|
||||
(320, 200),
|
||||
"Original size fits"
|
||||
);
|
||||
// THINK: Real curious if this is what I want to do. Rather than use CSS to `object-cover` original image size
|
||||
assert_eq!(
|
||||
get_max_resolution_with_crop((200, 200), 300, 200),
|
||||
(200, 133),
|
||||
"Image has to be smaller"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
get_max_resolution_with_crop((1000, 1000), 200, 100),
|
||||
(1000, 500),
|
||||
"width is maxed"
|
||||
);
|
||||
assert_eq!(
|
||||
get_max_resolution_with_crop((1000, 1000), 100, 200),
|
||||
(500, 1000),
|
||||
"height is maxed"
|
||||
);
|
||||
assert_eq!(
|
||||
get_max_resolution_with_crop((300, 200), 600, 500),
|
||||
(240, 200),
|
||||
"image has to be scaled down"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn get_max_resolution(
|
||||
(orig_width, orig_height): (u32, u32),
|
||||
max_width: u32,
|
||||
max_height: u32,
|
||||
) -> (u32, u32) {
|
||||
// If the original dimensions are within the max dimensions, return them as is
|
||||
if orig_width <= max_width && orig_height <= max_height {
|
||||
return (orig_width, orig_height);
|
||||
}
|
||||
|
||||
let width_scale = max_width as f32 / orig_width as f32;
|
||||
let height_scale = max_height as f32 / orig_height as f32;
|
||||
|
||||
// Determine the scaling factor to ensure the image fits within the bounds
|
||||
let scale = width_scale.min(height_scale);
|
||||
|
||||
(
|
||||
(orig_width as f32 * scale).round() as u32,
|
||||
(orig_height as f32 * scale).round() as u32,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_max_resolution() {
|
||||
assert_eq!(
|
||||
get_max_resolution((999, 675), 1000, 800),
|
||||
(999, 675),
|
||||
"Original size fits"
|
||||
);
|
||||
assert_eq!(
|
||||
get_max_resolution((1100, 400), 1000, 800),
|
||||
(1000, 364),
|
||||
"Image should be resized to fit within max dimensions"
|
||||
);
|
||||
assert_eq!(
|
||||
get_max_resolution((1100, 1200), 1000, 800),
|
||||
(733, 800),
|
||||
"Image should be resized to fit within max dimensions"
|
||||
);
|
||||
assert_eq!(
|
||||
get_max_resolution((1100, 800), 1000, 800),
|
||||
(1000, 727),
|
||||
"Image should be resized to fit within max dimensions"
|
||||
);
|
||||
}
|
2
src/post_utils/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod post_listing;
|
||||
pub mod post_parser;
|
37
src/post_utils/post_listing.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use axum::http::StatusCode;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tokio::fs::read_dir;
|
||||
use tracing::info;
|
||||
|
||||
use super::post_parser::{parse_post, ParseResult};
|
||||
|
||||
pub async fn get_post_list<'de, Metadata: DeserializeOwned>(
|
||||
path: &str,
|
||||
) -> Result<Vec<ParseResult<Metadata>>, StatusCode> {
|
||||
// let path = "../_posts/blog/";
|
||||
let mut dir = read_dir(path)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
let mut posts: Vec<ParseResult<Metadata>> = Vec::new();
|
||||
|
||||
while let Some(file) = dir
|
||||
.next_entry()
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
{
|
||||
let file_path = file.path();
|
||||
let file_path_str = file_path.to_str().unwrap();
|
||||
info!(":{}", file_path_str);
|
||||
let post = parse_post::<Metadata>(file_path_str, false).await?;
|
||||
posts.push(post);
|
||||
}
|
||||
|
||||
if std::env::var("TARGET")
|
||||
.unwrap_or_else(|_| "DEV".to_owned())
|
||||
.eq("PROD")
|
||||
{
|
||||
posts.retain(|post| !post.slug.starts_with("dev"))
|
||||
}
|
||||
|
||||
Ok(posts)
|
||||
}
|
237
src/post_utils/post_parser.rs
Normal file
@@ -0,0 +1,237 @@
|
||||
use core::panic;
|
||||
use std::path::Path;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use chrono::{DateTime, Utc};
|
||||
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 syntect::{highlighting::ThemeSet, html::highlighted_html_for_string, parsing::SyntaxSet};
|
||||
use tokio::fs;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
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>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let date_str = String::deserialize(deserializer)?;
|
||||
match DateTime::parse_from_rfc3339(&date_str) {
|
||||
Ok(datetime) => Ok(datetime.with_timezone(&Utc)),
|
||||
Err(err) => Err(serde::de::Error::custom(format!(
|
||||
"Error parsing date: {}",
|
||||
err
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ParseResult<Metadata> {
|
||||
pub body: String,
|
||||
pub metadata: Metadata,
|
||||
pub slug: String,
|
||||
}
|
||||
|
||||
pub async fn parse_post<'de, Metadata: DeserializeOwned>(
|
||||
path: &str,
|
||||
generate_images: bool,
|
||||
) -> Result<ParseResult<Metadata>, StatusCode> {
|
||||
let file_contents = fs::read_to_string(path)
|
||||
.await
|
||||
// TODO Proper reasoning for an error
|
||||
.map_err(|_| StatusCode::NOT_FOUND)?;
|
||||
|
||||
let matter = Matter::<YAML>::new();
|
||||
let metadata = matter
|
||||
.parse_with_struct::<Metadata>(&file_contents)
|
||||
.ok_or_else(|| {
|
||||
tracing::error!("Failed to parse metadata");
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
let body = parse_html(&metadata.content, generate_images);
|
||||
|
||||
let filename = Path::new(path)
|
||||
.file_stem()
|
||||
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.to_str()
|
||||
.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.to_owned();
|
||||
|
||||
Ok(ParseResult {
|
||||
body,
|
||||
metadata: metadata.data,
|
||||
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
|
||||
}
|
16
src/projects/featured_projects.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
use axum::http::StatusCode;
|
||||
|
||||
use crate::post_utils::{post_listing::get_post_list, post_parser::ParseResult};
|
||||
|
||||
use super::project_model::ProjectMetadata;
|
||||
|
||||
pub async fn get_featured_projects() -> Result<Vec<ParseResult<ProjectMetadata>>, StatusCode> {
|
||||
let project_list = get_post_list::<ProjectMetadata>("../_projects").await?;
|
||||
|
||||
let featured_projects = project_list
|
||||
.into_iter()
|
||||
.filter(|post| post.metadata.featured)
|
||||
.collect();
|
||||
|
||||
Ok(featured_projects)
|
||||
}
|
2
src/projects/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod featured_projects;
|
||||
pub mod project_model;
|
23
src/projects/project_model.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct ProjectMetadata {
|
||||
pub title: String,
|
||||
pub classification: String,
|
||||
pub displayed: bool,
|
||||
pub cover_image: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub featured: bool,
|
||||
pub link: Option<String>,
|
||||
}
|
||||
|
||||
pub fn translate_classification(classification: &str) -> &str {
|
||||
match classification {
|
||||
"webapp" => "Web application",
|
||||
"website" => "Web site",
|
||||
"presentation" => "Presentation",
|
||||
"videogame" => "Video game",
|
||||
"embedded" => "Embedded system",
|
||||
any => any,
|
||||
}
|
||||
}
|
40
src/router.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use crate::{
|
||||
feed::render_rss_feed,
|
||||
pages::{
|
||||
admin::render_admin, blog_post_list::render_blog_post_list,
|
||||
blog_post_page::render_blog_post, contact::render_contact, index::render_index,
|
||||
project_list::render_projects_list,
|
||||
},
|
||||
};
|
||||
use axum::{extract::MatchedPath, http::Request, routing::get, Router};
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::info_span;
|
||||
|
||||
pub fn get_router() -> Router {
|
||||
Router::new()
|
||||
.route("/", get(render_index))
|
||||
.route("/blog", get(render_blog_post_list))
|
||||
.route("/blog/tags/:tag", get(render_blog_post_list))
|
||||
.route("/blog/:post_id", get(render_blog_post))
|
||||
.route("/contact", get(render_contact))
|
||||
.route("/showcase", get(render_projects_list))
|
||||
.route("/admin", get(render_admin))
|
||||
.route("/feed.xml", get(render_rss_feed))
|
||||
.layer(
|
||||
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
|
||||
// Log the matched route's path (with placeholders not filled in).
|
||||
// Use request.uri() or OriginalUri if you want the real path.
|
||||
let matched_path = request
|
||||
.extensions()
|
||||
.get::<MatchedPath>()
|
||||
.map(MatchedPath::as_str);
|
||||
|
||||
info_span!(
|
||||
"http_request",
|
||||
method = ?request.method(),
|
||||
matched_path,
|
||||
some_other_field = tracing::field::Empty,
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { LayoutData } from './$types'
|
||||
import Nav from '$lib/components/Nav.svelte'
|
||||
import Footer from '$lib/components/Footer.svelte'
|
||||
import 'modern-normalize/modern-normalize.css'
|
||||
import '$lib/styles/global.css'
|
||||
import { mainContentClass } from './layout.css'
|
||||
|
||||
export let data: LayoutData
|
||||
export let latestPosts = data.latestPosts
|
||||
</script>
|
||||
|
||||
<div class="app-content">
|
||||
<Nav />
|
||||
|
||||
<main class={mainContentClass}>
|
||||
<slot />
|
||||
</main>
|
||||
<Footer {latestPosts} />
|
||||
</div>
|
@@ -1,11 +0,0 @@
|
||||
import type { LayoutLoad } from './$types'
|
||||
export const prerender = true
|
||||
|
||||
export const load = (async ({ fetch }) => {
|
||||
const blogPostsResponse = await fetch(`/articles/pageSize/5.json`)
|
||||
const blogPostsContent = await blogPostsResponse.json()
|
||||
|
||||
return {
|
||||
latestPosts: blogPostsContent.posts.items,
|
||||
}
|
||||
}) satisfies LayoutLoad
|
@@ -1,83 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { generateSrcSet, getNFResize } from '$lib/large-media'
|
||||
import {
|
||||
citeOwnerClass,
|
||||
mottoClass,
|
||||
profilePicClass,
|
||||
profilePicImgClass,
|
||||
twitchAsideClass,
|
||||
twitchEmbedClass,
|
||||
twitchIframeClass,
|
||||
twitchStreamPromoClass,
|
||||
welcomeNoteClass,
|
||||
} from './page.css'
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Introduction @michalvankodev</title>
|
||||
</svelte:head>
|
||||
|
||||
<header class="index-header">
|
||||
<figure class="profile-pic {profilePicClass}">
|
||||
<picture>
|
||||
<img
|
||||
alt="Portrait"
|
||||
class="{profilePicImgClass}"
|
||||
srcset={generateSrcSet('/images/profile-portugal-landscape.jpg', {
|
||||
width: 800,
|
||||
})}
|
||||
src={getNFResize('/images/profile-portugal-landscape.jpg', {
|
||||
width: 800,
|
||||
})}
|
||||
/>
|
||||
</picture>
|
||||
</figure>
|
||||
|
||||
<p class="motto {mottoClass}">
|
||||
<cite>“Let your ambition carry you.”</cite>
|
||||
<span class="cite-owner {citeOwnerClass}">- La Flame</span>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<p class={welcomeNoteClass}>
|
||||
Hey, welcome to my personal website. My name is
|
||||
<strong>Michal Vanko</strong>
|
||||
and I'm a
|
||||
<em> <a href="https://en.wikipedia.org/wiki/Programmer">programmer</a> </em>
|
||||
. I'll try to share some stories and opinions about things that I'm interested
|
||||
in.
|
||||
</p>
|
||||
|
||||
<section class="twitch-stream-promo {twitchStreamPromoClass}">
|
||||
<h2>Follow my twitch stream</h2>
|
||||
<div class="twitch-embed {twitchEmbedClass}">
|
||||
<div class="twitch-video {twitchIframeClass}">
|
||||
<iframe
|
||||
title="My twitch channel"
|
||||
src="https://player.twitch.tv/?channel=michalvankodev&parent=michalvanko.dev&parent=localhost&autoplay=false"
|
||||
loading="lazy"
|
||||
frameborder="0"
|
||||
scrolling="no"
|
||||
allowfullscreen
|
||||
height="100%"
|
||||
width="100%"
|
||||
class="embed {twitchIframeClass}"
|
||||
/>
|
||||
</div>
|
||||
<aside class={twitchAsideClass}>
|
||||
Come hang out and chat with me <strong>every Tuesday and Thursday</strong>
|
||||
afternoon central Europe time. I stream working on my side-projects and talking
|
||||
anything about the developer lifestyle.
|
||||
</aside>
|
||||
<!-- <div class="twitch-chat">
|
||||
<iframe
|
||||
title="Twitch chat"
|
||||
frameborder="0"
|
||||
scrolling="no"
|
||||
src="https://www.twitch.tv/embed/michalvankodev/chat?parent=michalvanko.dev&parent=localhost"
|
||||
height="100%"
|
||||
width="100%"
|
||||
/>
|
||||
</div> -->
|
||||
</div>
|
||||
</section>
|
@@ -1,23 +0,0 @@
|
||||
import {
|
||||
getDropTakeFromPageParams,
|
||||
parseParams,
|
||||
} from '$lib/pagination/dropTakeParams'
|
||||
import { json } from '@sveltejs/kit'
|
||||
import { getBlogListing } from '$lib/articleContent/articleContentListing'
|
||||
import type { RequestHandler } from './$types'
|
||||
|
||||
export const prerender = true
|
||||
export const GET = (async ({ params }) => {
|
||||
const handledParams = params.params === 'index' ? '' : params.params
|
||||
const { page = 1, pageSize = 7, ...filters } = parseParams(handledParams)
|
||||
const paginationParams = getDropTakeFromPageParams(
|
||||
Number(pageSize),
|
||||
Number(page)
|
||||
)
|
||||
const paginationQuery = { ...paginationParams, filters }
|
||||
const filteredContents = await getBlogListing(paginationQuery)
|
||||
|
||||
return json({
|
||||
posts: filteredContents,
|
||||
})
|
||||
}) satisfies RequestHandler
|
@@ -1,32 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import ArticlePreviewList from '$lib/components/articles/ArticlePreviewList/ArticlePreviewList.svelte'
|
||||
import { seeAllClass } from '$lib/components/articles/ArticlePreviewList/ArticlePreviewList.css'
|
||||
|
||||
export let data: PageData
|
||||
$: ({ posts, filters } = data)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>My blog @michalvankodev</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if posts.items.length === 0}
|
||||
<p class="no-posts">You've found void in the space.</p>
|
||||
{:else}
|
||||
<h1>
|
||||
{#if filters.tags}
|
||||
<em>{filters.tags}</em>
|
||||
{:else}
|
||||
Blog
|
||||
{/if}
|
||||
posts
|
||||
</h1>
|
||||
{#if filters.tags}
|
||||
<div class={seeAllClass}>
|
||||
<a href="/blog">See all posts</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<ArticlePreviewList {...data} segment="blog" />
|
@@ -1,17 +0,0 @@
|
||||
import { parseParams } from '$lib/pagination/dropTakeParams'
|
||||
import type { PageLoad } from './$types'
|
||||
import type { ArticlePreviewAttributes } from '$lib/articleContent/articleContentListing'
|
||||
import type { PaginationResult } from '$lib/pagination/pagination'
|
||||
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
const { page = 1, pageSize = 7, ...filters } = parseParams(params.params)
|
||||
const articleResponse = await fetch(
|
||||
`/articles/segments/blog${params.params ? `/${params.params}` : ''}.json`
|
||||
).then((r) => r.json())
|
||||
return {
|
||||
posts: articleResponse.posts as PaginationResult<ArticlePreviewAttributes >,
|
||||
page: Number(page),
|
||||
pageSize,
|
||||
filters,
|
||||
}
|
||||
}) satisfies PageLoad
|
@@ -1,9 +0,0 @@
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { getArticleContent } from '$lib/articleContent/articleContent'
|
||||
|
||||
export const prerender = true
|
||||
|
||||
export const load = (async ({ params: { slug } }) => {
|
||||
const post = await getArticleContent(slug);
|
||||
return post
|
||||
}) satisfies PageServerLoad
|
@@ -1,24 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ArticleFooter from '$lib/components/articles/ArticlePreviewFooter/ArticlePreviewFooter.svelte'
|
||||
import type { PageData } from './$types'
|
||||
import { contentClass } from '$lib/styles/article/article.css'
|
||||
import { onMount } from 'svelte'
|
||||
import { runOnMountScripts } from '$lib/articleContent/onMountScripts'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
onMount(() => {
|
||||
runOnMountScripts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{data.title}</h1>
|
||||
|
||||
<article class="content {contentClass}">
|
||||
{@html data.body}
|
||||
</article>
|
||||
<ArticleFooter article={data} segment="blog" />
|
@@ -1,30 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types'
|
||||
import ArticlePreviewList from '$lib/components/articles/ArticlePreviewList/ArticlePreviewList.svelte'
|
||||
import { seeAllClass } from '$lib/components/articles/ArticlePreviewList/ArticlePreviewList.css'
|
||||
|
||||
export let data: PageData
|
||||
$: ({ posts, filters } = data)
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Broadcasts @michalvankodev</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if posts.items.length === 0}
|
||||
<p class="no-posts">You've found void in the space.</p>
|
||||
{:else}
|
||||
<h1>
|
||||
{#if filters.tags}
|
||||
<em>{filters.tags}</em>
|
||||
{/if}
|
||||
Broadcasts
|
||||
</h1>
|
||||
{#if filters.tags}
|
||||
<div class={seeAllClass}>
|
||||
<a href="/broadcasts">See all broadcasts</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<ArticlePreviewList {...data} segment="broadcasts" />
|
@@ -1,18 +0,0 @@
|
||||
import { parseParams } from '$lib/pagination/dropTakeParams'
|
||||
import type { PageLoad } from './$types'
|
||||
import type { ArticlePreviewAttributes } from '$lib/articleContent/articleContentListing'
|
||||
import type { PaginationResult } from '$lib/pagination/pagination'
|
||||
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
const { page = 1, pageSize = 7, ...filters } = parseParams(params.params)
|
||||
const articleResponse = await fetch(
|
||||
`/articles/segments/broadcasts${params.params ? `/${params.params}` : ''}.json`
|
||||
).then((r) => r.json())
|
||||
|
||||
return {
|
||||
posts: articleResponse.posts as PaginationResult<ArticlePreviewAttributes>,
|
||||
page: Number(page),
|
||||
pageSize,
|
||||
filters,
|
||||
}
|
||||
}) satisfies PageLoad
|
@@ -1,9 +0,0 @@
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { getArticleContent } from '$lib/articleContent/articleContent'
|
||||
|
||||
export const prerender = true
|
||||
|
||||
export const load = (async ({ params: { slug } }) => {
|
||||
const post = await getArticleContent(slug);
|
||||
return post
|
||||
}) satisfies PageServerLoad
|
@@ -1,24 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ArticleFooter from '$lib/components/articles/ArticlePreviewFooter/ArticlePreviewFooter.svelte'
|
||||
import type { PageData } from './$types'
|
||||
import { contentClass } from '$lib/styles/article/article.css'
|
||||
import { onMount } from 'svelte'
|
||||
import { runOnMountScripts } from '$lib/articleContent/onMountScripts'
|
||||
|
||||
export let data: PageData
|
||||
|
||||
onMount(() => {
|
||||
runOnMountScripts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1>{data.title}</h1>
|
||||
|
||||
<article class="content {contentClass}">
|
||||
{@html data.body}
|
||||
</article>
|
||||
<ArticleFooter article={data} segment="broadcasts" />
|
@@ -1,13 +0,0 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { getFeed } from '../feed'
|
||||
|
||||
export const prerender = true
|
||||
export const GET = (async ({ setHeaders }) => {
|
||||
const feed = await getFeed()
|
||||
|
||||
setHeaders({
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'max-age=86400',
|
||||
})
|
||||
return new Response(feed.json1())
|
||||
}) satisfies RequestHandler
|
@@ -1,41 +0,0 @@
|
||||
import { getBlogListing } from '$lib/articleContent/articleContentListing'
|
||||
import { Feed } from 'feed'
|
||||
|
||||
export async function getFeed() {
|
||||
const feed = new Feed({
|
||||
title: 'michalvanko.dev latest posts',
|
||||
id: 'https://michalvanko.dev',
|
||||
link: 'https://michalvanko.dev',
|
||||
description: 'Latest posts published on michalvanko.dev',
|
||||
copyright: 'All rights reserved 2020, Michal Vanko',
|
||||
generator: 'sapper with Feed for node.js',
|
||||
updated: new Date(),
|
||||
image: 'https://michalvanko.dev/eye.png',
|
||||
favicon: 'https://michalvanko.dev/m-favicon-192x192.png',
|
||||
language: 'en',
|
||||
author: {
|
||||
name: 'Michal Vanko',
|
||||
email: 'michalvankosk@gmail.com',
|
||||
link: 'https://michalvanko.dev',
|
||||
},
|
||||
feedLinks: {
|
||||
json: 'https://michalvanko.dev/feed.json',
|
||||
rss: 'https://michalvanko.dev/feed.xml',
|
||||
},
|
||||
})
|
||||
|
||||
const blogListing = await getBlogListing({})
|
||||
blogListing.items.forEach((post) => {
|
||||
feed.addItem({
|
||||
title: post.title,
|
||||
id: `https://michalvanko.dev/blog/${post.slug}`,
|
||||
link: `https://michalvanko.dev/blog/${post.slug}`,
|
||||
description: post.preview,
|
||||
date: new Date(post.date),
|
||||
image: post.thumbnail
|
||||
? `https://michalvanko.dev/${post.thumbnail}`
|
||||
: undefined,
|
||||
})
|
||||
})
|
||||
return feed
|
||||
}
|
@@ -1,13 +0,0 @@
|
||||
import type { RequestHandler } from '@sveltejs/kit'
|
||||
import { getFeed } from '../feed'
|
||||
|
||||
export const prerender = true
|
||||
export const GET = (async ({ setHeaders }) => {
|
||||
const feed = await getFeed()
|
||||
|
||||
setHeaders({
|
||||
'Content-Type': 'application/xml',
|
||||
'Cache-Control': 'max-age=86400',
|
||||
})
|
||||
return new Response(feed.rss2())
|
||||
}) satisfies RequestHandler
|
@@ -1,74 +0,0 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css'
|
||||
import { vars } from '$lib/styles/vars.css'
|
||||
import { sprinkles } from '$lib/styles/sprinkles.css'
|
||||
|
||||
export const appContentClass = style([
|
||||
sprinkles({
|
||||
display: 'grid',
|
||||
}),
|
||||
{
|
||||
gridTemplateRows: 'auto 1fr auto',
|
||||
gridTemplateColumns: '100%',
|
||||
},
|
||||
])
|
||||
|
||||
export const mainContentClass = sprinkles({
|
||||
position: 'relative',
|
||||
padding: {
|
||||
mobile: '3x',
|
||||
desktop: 'none',
|
||||
},
|
||||
})
|
||||
|
||||
// Layout global styles
|
||||
// atomic design needs to get rid off these global selectors LOL
|
||||
// There should be written markdown renderer for each type of output
|
||||
// where every component gets the layout atomic class
|
||||
// TODO Create atomic classes for maxWidhts and use them everywhere in the content
|
||||
|
||||
globalStyle(
|
||||
`${mainContentClass} h1, ${mainContentClass} h2, ${mainContentClass} h3, ${mainContentClass} h4, ${mainContentClass} h5, ${mainContentClass} h6, ${mainContentClass} p, ${mainContentClass} ul, ${mainContentClass} ol, ${mainContentClass} figure, ${mainContentClass} img, ${mainContentClass} blockquote, ${mainContentClass} iframe:not(.embed), ${mainContentClass} footer, ${mainContentClass} table`,
|
||||
{
|
||||
maxWidth: vars.width.layoutMax,
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
}
|
||||
)
|
||||
|
||||
globalStyle(`${mainContentClass} h1, ${mainContentClass} footer`, {
|
||||
maxWidth: vars.width.headerFooterMax,
|
||||
})
|
||||
|
||||
globalStyle(`${mainContentClass} h2`, {
|
||||
maxWidth: vars.width.additionalBlockMax,
|
||||
})
|
||||
|
||||
globalStyle(`${mainContentClass} iframe:not(.embed)`, {
|
||||
maxWidth: vars.width.additionalBlockMax,
|
||||
display: 'block',
|
||||
})
|
||||
|
||||
globalStyle(`${mainContentClass} img`, {
|
||||
maxWidth: vars.width.parent,
|
||||
borderRadius: 5,
|
||||
boxShadow: vars.boxShadow.contentBoxShadow,
|
||||
})
|
||||
|
||||
globalStyle(`${mainContentClass} table`, {
|
||||
maxWidth: vars.width.image,
|
||||
fontSize: vars.fontSize.sm,
|
||||
lineHeight: vars.lineHeight['3x'],
|
||||
})
|
||||
|
||||
globalStyle(`${mainContentClass} figure`, {
|
||||
maxWidth: vars.width.image,
|
||||
})
|
||||
|
||||
globalStyle(
|
||||
`${mainContentClass} pre, ${mainContentClass} pre[class*="language-"]`,
|
||||
{
|
||||
maxWidth: vars.width.additionalBlockMax,
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
}
|
||||
)
|
@@ -1,92 +0,0 @@
|
||||
import { sprinkles } from '$lib/styles/sprinkles.css'
|
||||
import {
|
||||
transparent,
|
||||
twitchEmbedBackground,
|
||||
mediaAt,
|
||||
breakpoints,
|
||||
} from '$lib/styles/vars.css'
|
||||
import { style } from '@vanilla-extract/css'
|
||||
import { radialGradient, transparentize } from 'polished'
|
||||
|
||||
export const profilePicClass = sprinkles({
|
||||
textAlign: 'center',
|
||||
marginX: 'auto',
|
||||
marginY: 'none',
|
||||
})
|
||||
|
||||
export const profilePicImgClass = style({
|
||||
aspectRatio: "auto 800 / 709",
|
||||
maxHeight: '66vh'
|
||||
})
|
||||
|
||||
export const mottoClass = sprinkles({
|
||||
textAlign: 'center',
|
||||
marginX: 'auto',
|
||||
marginY: '2x',
|
||||
fontSize: '2x',
|
||||
})
|
||||
|
||||
export const welcomeNoteClass = sprinkles({
|
||||
textAlign: 'center',
|
||||
marginX: 'auto',
|
||||
})
|
||||
|
||||
export const citeOwnerClass = sprinkles({
|
||||
whiteSpace: 'nowrap',
|
||||
})
|
||||
|
||||
export const twitchStreamPromoClass = style([
|
||||
radialGradient({
|
||||
colorStops: [
|
||||
`${twitchEmbedBackground} 0%`,
|
||||
`${transparentize(1, twitchEmbedBackground)} 60%`,
|
||||
],
|
||||
extent: '90% 40% at 50% 70%',
|
||||
fallback: transparent,
|
||||
}),
|
||||
{
|
||||
'@media': {
|
||||
[mediaAt(breakpoints.l)]: radialGradient({
|
||||
colorStops: [
|
||||
`${twitchEmbedBackground} 0%`,
|
||||
`${transparentize(1, twitchEmbedBackground)} 100%`,
|
||||
],
|
||||
extent: '180% 50% at 100% 50%',
|
||||
fallback: transparent,
|
||||
}),
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
export const twitchIframeClass = sprinkles({
|
||||
flexGrow: 1,
|
||||
maxWidth: 'image',
|
||||
aspectRatio: 'monitor',
|
||||
width: {
|
||||
mobile: 'parent',
|
||||
desktop: 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
export const twitchEmbedClass = sprinkles({
|
||||
display: 'flex',
|
||||
padding: '3x',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: {
|
||||
mobile: 'column',
|
||||
desktop: 'row',
|
||||
},
|
||||
gap: '4x',
|
||||
width: {
|
||||
mobile: 'parent',
|
||||
desktop: 'auto',
|
||||
},
|
||||
})
|
||||
|
||||
export const twitchAsideClass = sprinkles({
|
||||
width: {
|
||||
mobile: 'parent',
|
||||
desktop: 's',
|
||||
},
|
||||
})
|
@@ -1,85 +0,0 @@
|
||||
import { readFile } from 'fs'
|
||||
import { promisify } from 'util'
|
||||
import fm from 'front-matter'
|
||||
// TODO Switch marked for unified
|
||||
import marked from 'marked'
|
||||
import { parseField } from '$lib/markdown/parse-markdown'
|
||||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const prerender = true
|
||||
|
||||
export interface RecordAttributes {
|
||||
name: string
|
||||
description: string
|
||||
displayed: boolean
|
||||
}
|
||||
|
||||
export interface ProjectAttributes extends RecordAttributes {
|
||||
image: {
|
||||
source: string
|
||||
image_description: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkAttributes extends RecordAttributes {
|
||||
address: {
|
||||
name: string
|
||||
location: string
|
||||
zipcode: string
|
||||
city: string
|
||||
country: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface PresentationAttributes extends RecordAttributes {
|
||||
link: string
|
||||
}
|
||||
|
||||
export interface PortfolioAttributes {
|
||||
title: string
|
||||
work_history: WorkAttributes[]
|
||||
work_history_prelude: string
|
||||
projects: ProjectAttributes[]
|
||||
education: RecordAttributes[]
|
||||
presentations: PresentationAttributes[]
|
||||
}
|
||||
|
||||
export type PortfolioContent = {
|
||||
title: string
|
||||
workHistory: WorkAttributes[]
|
||||
workHistoryPrelude: string
|
||||
projects: ProjectAttributes[]
|
||||
education: RecordAttributes[]
|
||||
presentations: PresentationAttributes[]
|
||||
body: string
|
||||
}
|
||||
|
||||
export const load = (async () => {
|
||||
const pageSource = await promisify(readFile)('_pages/portfolio.md', 'utf-8')
|
||||
|
||||
const parsed = fm<PortfolioAttributes>(pageSource)
|
||||
const workHistory = (parsed.attributes.work_history || [])
|
||||
.filter((workHistory) => workHistory.displayed)
|
||||
.map(parseField('description'))
|
||||
const projects = (parsed.attributes.projects || [])
|
||||
.filter((project) => project.displayed)
|
||||
.map(parseField('description'))
|
||||
const education = (parsed.attributes.education || [])
|
||||
.filter((education) => education.displayed)
|
||||
.map(parseField('description'))
|
||||
const presentations = (parsed.attributes.presentations || []).filter(
|
||||
(education) => education.displayed
|
||||
)
|
||||
|
||||
const response: PortfolioContent = {
|
||||
title: parsed.attributes.title,
|
||||
body: marked(parsed.body),
|
||||
workHistoryPrelude: marked(parsed.attributes.work_history_prelude),
|
||||
workHistory,
|
||||
projects,
|
||||
education,
|
||||
presentations,
|
||||
}
|
||||
|
||||
return response
|
||||
}) satisfies PageServerLoad
|
@@ -1,81 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Work from './components/work.svelte'
|
||||
import Project from './components/project.svelte'
|
||||
import Presentation from './components/presentation.svelte'
|
||||
import type { PageData } from './$types'
|
||||
|
||||
import { listClass, listItemClass, nameTagClass } from './page.css'
|
||||
|
||||
export let data: PageData
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.title}</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="name-tag {nameTagClass}">Michal Vanko</h1>
|
||||
|
||||
<h2 class="name-tag {nameTagClass}">
|
||||
Software Architect and Engineering Manager
|
||||
</h2>
|
||||
|
||||
<section id="personal-information">
|
||||
{@html data.body}
|
||||
</section>
|
||||
|
||||
<section id="work-history">
|
||||
<h2>Work experience</h2>
|
||||
<section class="work-history-prelude">
|
||||
{@html data.workHistoryPrelude}
|
||||
</section>
|
||||
<ul class={listClass}>
|
||||
{#each data.workHistory as work}
|
||||
<li class={listItemClass}>
|
||||
<Work {work} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="projects">
|
||||
<h2>Projects</h2>
|
||||
<ul class={listClass}>
|
||||
{#each data.projects as project}
|
||||
<li class={listItemClass}>
|
||||
<Project {project} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="presentations">
|
||||
<h2>Presentations</h2>
|
||||
<ul class="">
|
||||
{#each data.presentations as presentation}
|
||||
<li class="">
|
||||
<Presentation {presentation} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="education">
|
||||
<h2>Education</h2>
|
||||
<ul class={listClass}>
|
||||
{#each data.education as work}
|
||||
<li class={listItemClass}>
|
||||
<Work {work} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
:global([id])::before {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 5em;
|
||||
margin-top: -5em;
|
||||
visibility: hidden;
|
||||
}
|
||||
</style>
|
@@ -1,39 +0,0 @@
|
||||
<section id="personal">
|
||||
<h3>Personal Information</h3>
|
||||
<p>I was born on 26th of May in Košice, Slovakia and I still live here.</p>
|
||||
<h4>Hobbies:</h4>
|
||||
<p>
|
||||
I enjoy playing basketball with my friends. I also like to play other team
|
||||
sports like football and hockey. I also play squash and table tennis. Once
|
||||
I've won a competition in squash at my university. During summer I love
|
||||
water skiing and swimming in a nearby lake.
|
||||
<br />
|
||||
I am very passionate about music. I've also tried some software for composing
|
||||
music but I am not really hooked into that yet. From time to time I enjoy playing
|
||||
board games with my friends.
|
||||
</p>
|
||||
<h4>Interests:</h4>
|
||||
<p>
|
||||
I like to explore new technologies and I'm passionate about <em
|
||||
>Open Source movement</em
|
||||
>,
|
||||
<em>Internet of Things</em> applications and
|
||||
<em>Linux desktop evolution</em>.
|
||||
<br />
|
||||
I am interested in modern software architecture and
|
||||
<em>reactive programming</em>.
|
||||
<br />
|
||||
I've attended various <strong>tech conferences and hackathons</strong>. I
|
||||
like them for all of the fascinating ideas that might be invented.
|
||||
<br />
|
||||
I've given presentations on various topics related to
|
||||
<em>web development</em>. You can
|
||||
<a href="#presentations">take a look at some of them here</a>.
|
||||
<br />
|
||||
I enjoy <strong>teaching and explaining</strong> how various technologies
|
||||
and techniques work to my colleagues for their better understanding.
|
||||
<br />
|
||||
I take advantage of <strong>test driven development</strong>.
|
||||
</p>
|
||||
</section>
|
||||
<!--/personal-->
|
@@ -1,18 +0,0 @@
|
||||
import { sprinkles } from '$lib/styles/sprinkles.css'
|
||||
|
||||
export const presentationFrameClass = sprinkles({
|
||||
width: 'image',
|
||||
height: 'image',
|
||||
})
|
||||
|
||||
export const presentationPreviewLinksClass = sprinkles({
|
||||
fontSize: 'sm',
|
||||
})
|
||||
|
||||
export const presentationDescriptionClass = sprinkles({
|
||||
paddingLeft: '1x',
|
||||
})
|
||||
|
||||
export const presentationNameClass = sprinkles({
|
||||
fontSize: 'base',
|
||||
})
|
@@ -1,41 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { PresentationAttributes } from 'src/routes/portfolio/index.json'
|
||||
import {
|
||||
presentationDescriptionClass,
|
||||
presentationFrameClass,
|
||||
presentationNameClass,
|
||||
presentationPreviewLinksClass,
|
||||
} from './presentation.css'
|
||||
|
||||
export let presentation: PresentationAttributes
|
||||
export let previewVisible = false
|
||||
|
||||
function togglePreviewVisible() {
|
||||
previewVisible = !previewVisible
|
||||
}
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<a href={presentation.link} target="_blank">
|
||||
<h3 class={presentationNameClass}>{presentation.name}</h3>
|
||||
</a>
|
||||
|
||||
<section class="description {presentationDescriptionClass}">
|
||||
{@html presentation.description}
|
||||
</section>
|
||||
|
||||
<section class="preview">
|
||||
<div class={presentationPreviewLinksClass}>
|
||||
<a href="#presentations" on:click|preventDefault={togglePreviewVisible}
|
||||
>{previewVisible ? 'Close' : 'Open'} preview</a
|
||||
>
|
||||
</div>
|
||||
{#if previewVisible}
|
||||
<iframe
|
||||
class={presentationFrameClass}
|
||||
src={presentation.link}
|
||||
title="Presentation of {presentation.name}"
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
</article>
|
@@ -1,10 +0,0 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css'
|
||||
|
||||
export const projectScopeClass = style({})
|
||||
|
||||
globalStyle(`${projectScopeClass} img`, {
|
||||
float: 'right',
|
||||
width: '25%',
|
||||
})
|
||||
|
||||
// We need to get rid off the global selectors LOL
|
@@ -1,21 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { ProjectAttributes } from '../../routes/portfolio/index.json'
|
||||
import { projectScopeClass } from './project.css'
|
||||
|
||||
export let project: ProjectAttributes
|
||||
</script>
|
||||
|
||||
<article class="project {projectScopeClass}">
|
||||
<h3>{project.name}</h3>
|
||||
<section class="description">
|
||||
{#if project.image}
|
||||
<img
|
||||
src={project.image.source}
|
||||
class="project-image"
|
||||
alt={project.image.image_description}
|
||||
/>
|
||||
{/if}
|
||||
{@html project.description}
|
||||
</section>
|
||||
<aside />
|
||||
</article>
|
@@ -1,17 +0,0 @@
|
||||
<section id="skills">
|
||||
<h3>Skills</h3>
|
||||
<p>Slovak is my mother tongue and I've learned English as my second language. I speak English on advanced level.</p>
|
||||
<p>
|
||||
I'm an experienced <em>Linux Desktop</em> user. I prefer to use open source libraries and technologies while I develop solutions.
|
||||
<br>
|
||||
I'm in good command of Office Tools and I've experience with image manipulation programs like <em>GIMP</em> and <em>Inkscape</em>.
|
||||
<br>
|
||||
I can also compose music and sounds in <em>digital audio workstation</em>.
|
||||
</p>
|
||||
<p>I'm passionate about <em>software architecture</em>. My goal is to be able to design suitable solution for any kind of product. From small <em>presentation sites</em>, <em>IOT devices</em>, to large <em>enterprise applications running on cloud</em>.</p>
|
||||
<p>
|
||||
I do <em>public speaking</em> and I am not afraid to share my knowledge and passion about technology.
|
||||
</p>
|
||||
<p>I'm advanced user of source code management tools <em>git</em> and <em>svn</em>.</p>
|
||||
<p>I've a driving licence for category B 🚗.</p>
|
||||
</section>
|
@@ -1,15 +0,0 @@
|
||||
import { sprinkles } from '$lib/styles/sprinkles.css'
|
||||
|
||||
export const workFooterClass = sprinkles({
|
||||
marginTop: '1x',
|
||||
paddingTop: '1x',
|
||||
fontSize: 'sm',
|
||||
lineHeight: '1x',
|
||||
})
|
||||
|
||||
export const workAddressNameClass = sprinkles({
|
||||
fontStyle: 'italic',
|
||||
fontWeight: 'normal',
|
||||
margin: 'none',
|
||||
fontSize: 'base',
|
||||
})
|
@@ -1,27 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { WorkAttributes } from '../../routes/portfolio/index.json'
|
||||
import { workAddressNameClass, workFooterClass } from './work.css'
|
||||
import { horizontalBorderTopClass } from '$lib/styles/scoops.css'
|
||||
export let work: WorkAttributes
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<h3>{work.name}</h3>
|
||||
<section class="description">
|
||||
{@html work.description}
|
||||
</section>
|
||||
|
||||
{#if work.address}
|
||||
<footer class="{workFooterClass} {horizontalBorderTopClass}">
|
||||
<h4 class={workAddressNameClass}>{work.address.name}</h4>
|
||||
<address>
|
||||
<div>
|
||||
{work.address.location},
|
||||
{work.address.zipcode}
|
||||
{work.address.city},
|
||||
{work.address.country}
|
||||
</div>
|
||||
</address>
|
||||
</footer>
|
||||
{/if}
|
||||
</article>
|
@@ -1,19 +0,0 @@
|
||||
import { sprinkles } from '$lib/styles/sprinkles.css'
|
||||
|
||||
export const linkableSectionClass = sprinkles({
|
||||
marginTop: '4x',
|
||||
})
|
||||
|
||||
export const nameTagClass = sprinkles({
|
||||
textAlign: 'center',
|
||||
})
|
||||
|
||||
export const listClass = sprinkles({
|
||||
listStyle: 'none',
|
||||
padding: 'none',
|
||||
})
|
||||
|
||||
export const listItemClass = sprinkles({
|
||||
marginX: 'none',
|
||||
marginY: '4x',
|
||||
})
|
@@ -1,20 +0,0 @@
|
||||
<h1>Egg-fetcher</h1>
|
||||
|
||||
<p>As mentioned in <a href="/blog/2022-06-26-our-attempt-at-rusty-game-jam-weekly-25-2022">Weekly #25-2022</a>, I've attended the <a href="https://itch.io/jam/rusty-jam-2">Rusty game jam #2</a> where we had a week to <strong>create a game with Rust</strong>.</p>
|
||||
|
||||
<p>I've teamed up with <a href="https://github.com/silen-z/">@silen-z</a>. We haven't been able to finish the game. We didn't even make the mechanics we were thinking of. But I'd like to show <strong>the incomplete version of Egg-fetcher</strong> anyway. As we built it with Rust and <a href="https://bevyengine.org/">bevy engine</a> we were able to <a href="https://github.com/septum/rusty_jam_bevy_template">reuse a template</a> that had a configured WASM build. Therefore is very easy to just present the game in the browser.</p>
|
||||
|
||||
<iframe title="Egg fetcher game" src="/egg-fetcher/index.html" width="800" height="600"></iframe>
|
||||
|
||||
<p><strong>The only functional controls are arrows</strong>. We have built a collision system where the chickens should move out of the way of the player and his pet. The player is not able to move through the fences and so on. We wanted to <strong>create a puzzle</strong> where you would have to play fetch with your pet dog to control the chickens and simultaneously control player movement.</p>
|
||||
|
||||
<p>This was only my 3rd attempt at a Rust codebase and therefore I got pretty much always stuck at some problem with borrow-checker or lifetimes.
|
||||
I learned many things and I'd like to continue with Rust and use it more in my side-projects.</p>
|
||||
|
||||
<style>
|
||||
iframe {
|
||||
height: 720px;
|
||||
width: 1280px !important;
|
||||
max-width: 1280px !important;
|
||||
}
|
||||
</style>
|
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><style>.cls-1{fill:#4d4d4d;}</style></defs><symbol id="github" viewBox="0 0 24 24"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"/></symbol><symbol id="instagram" viewBox="0 0 56.7 56.7">
|
||||
<g>
|
||||
<path d="M28.2,16.7c-7,0-12.8,5.7-12.8,12.8s5.7,12.8,12.8,12.8S41,36.5,41,29.5S35.2,16.7,28.2,16.7z M28.2,37.7
|
||||
c-4.5,0-8.2-3.7-8.2-8.2s3.7-8.2,8.2-8.2s8.2,3.7,8.2,8.2S32.7,37.7,28.2,37.7z"/>
|
||||
<circle cx="41.5" cy="16.4" r="2.9"/>
|
||||
<path d="M49,8.9c-2.6-2.7-6.3-4.1-10.5-4.1H17.9c-8.7,0-14.5,5.8-14.5,14.5v20.5c0,4.3,1.4,8,4.2,10.7c2.7,2.6,6.3,3.9,10.4,3.9
|
||||
h20.4c4.3,0,7.9-1.4,10.5-3.9c2.7-2.6,4.1-6.3,4.1-10.6V19.3C53,15.1,51.6,11.5,49,8.9z M48.6,39.9c0,3.1-1.1,5.6-2.9,7.3
|
||||
s-4.3,2.6-7.3,2.6H18c-3,0-5.5-0.9-7.3-2.6C8.9,45.4,8,42.9,8,39.8V19.3c0-3,0.9-5.5,2.7-7.3c1.7-1.7,4.3-2.6,7.3-2.6h20.6
|
||||
c3,0,5.5,0.9,7.3,2.7c1.7,1.8,2.7,4.3,2.7,7.2V39.9L48.6,39.9z"/>
|
||||
</g>
|
||||
</symbol><symbol id="json-feed" viewBox="0 0 140 140">
|
||||
|
||||
|
||||
<g id="Page-1">
|
||||
<g transform="translate(-4,-14)" id="icon">
|
||||
<rect style="fill:none;stroke-width:0" height="0" width="140" y="1000014" x="-74" id="background"/>
|
||||
<g transform="rotate(-45,91.330213,37.830213)" id="elements">
|
||||
<path d="m 7,64.983598 v 0.206832 c 8.251953,0 10.917969,3.300781 10.917969,13.266601 v 15.742188 c 0,16.376951 8.061523,23.613281 26.342773,23.613281 h 5.585938 v -11.87012 h -2.856446 c -10.346679,0 -14.02832,-3.55469 -14.02832,-13.457028 v -18.47168 c 0,-9.521485 -4.25293,-14.726563 -12.568359,-15.488281 V 56.748047 C 28.962891,55.922852 32.961914,51.162109 32.961914,41.958008 V 25.390625 c 0,-10.092773 3.554688,-13.583984 14.02832,-13.583984 H 49.84668 V 0 H 44.260742 C 25.916016,0 17.917969,6.9824219 17.917969,22.978516 v 13.647461 c 0,9.96582 -2.729492,13.330078 -10.917969,13.330078 v 0.06035 C 3.0909841,50.273695 0,53.525879 0,57.5 c 0,3.974121 3.0909841,7.226305 7,7.483598 z"/>
|
||||
<g transform="translate(42,50)" id="dots-@-15pt">
|
||||
<circle r="7.5" cy="7.5" cx="7.5" id="Oval-1"/>
|
||||
<circle r="7.5" cy="7.5" cx="32.5" id="Oval-2"/>
|
||||
<circle r="7.5" cy="7.5" cx="57.5" id="Oval-3"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</symbol><symbol id="linkedin" viewBox="0 0 32 32"><title/><path class="cls-1" d="M9,11H4a1,1,0,0,0-1,1V27.9a1,1,0,0,0,1,1H9a1,1,0,0,0,1-1V12A1,1,0,0,0,9,11ZM8,26.9H5V13H8Z"/><path class="cls-1" d="M6.5,3.5A3.5,3.5,0,1,0,10,7,3.5,3.5,0,0,0,6.5,3.5Zm0,5A1.5,1.5,0,1,1,8,7,1.5,1.5,0,0,1,6.5,8.5Z"/><path class="cls-1" d="M23,11a8.28,8.28,0,0,0-4,1,1,1,0,0,0-1-1H13a1,1,0,0,0-1,1V28a1,1,0,0,0,1,1h5a1,1,0,0,0,1-1V20c0-1,.56-2,1.5-2S22,19,22,20v8a1,1,0,0,0,1,1h5a1,1,0,0,0,1-1V18C29,16.18,27.9,11,23,11Zm4,16H24V20a3.74,3.74,0,0,0-3.5-4A3.74,3.74,0,0,0,17,20v7H14V13h3v1a1,1,0,0,0,1.79.61A5.38,5.38,0,0,1,23,13c3.83,0,4,4.95,4,5Z"/></symbol><symbol id="mail" viewBox="0 0 32 32"><g data-name="Layer 2" id="Layer_2"><path d="M24,6H8a5,5,0,0,0-5,5V21a5,5,0,0,0,5,5H24a5,5,0,0,0,5-5V11A5,5,0,0,0,24,6ZM8,8H24a3,3,0,0,1,2.7,1.72L16.6,17.3a1,1,0,0,1-1.2,0L5.3,9.72A3,3,0,0,1,8,8ZM24,24H8a3,3,0,0,1-3-3V12l9.2,6.9a3,3,0,0,0,3.6,0L27,12v9A3,3,0,0,1,24,24Z"/></g></symbol><symbol id="rss" viewBox="0 0 512 512"><g><path d="M119.9,336.1c-30.8,0-55.9,25.1-55.9,55.8c0,30.8,25.1,55.6,55.9,55.6c30.9,0,55.9-24.9,55.9-55.6 C175.8,361.2,150.8,336.1,119.9,336.1z"/><path d="M64,192v79.9c48,0,94.1,14.2,128,48.1c33.9,33.9,48,79.9,48,128h80C320,308.1,204,192,64,192z"/><path d="M64,64v79.9c171,0,303.9,133,303.9,304.1H448C448,236.3,276,64,64,64z"/></g></symbol><symbol id="twitch" viewBox="0 0 30 30"><g id="_x7C___x7C_"><path clip-rule="evenodd" d="M21,26h-6l-3,4H8v-4H1V5.132L3,0h26v18L21,26z M26,17V3H5v19h6v4l4-4 h6L26,17z" fill-rule="evenodd" id="Dialog"/><rect clip-rule="evenodd" fill-rule="evenodd" height="8" id="_x7C_" width="3" x="18" y="8"/><rect clip-rule="evenodd" fill-rule="evenodd" height="8" id="_x7C__1_" width="3" x="12" y="8"/></g></symbol><symbol id="twitter" viewBox="0 0 64 64"><title/><path d="M19.55,55.08c-7.37,0-13.37-1.58-16.54-3.24A1,1,0,0,1,3.43,50a38.37,38.37,0,0,0,15.86-4.44c-4.41-1.19-8.9-4.34-9.79-8.41a1,1,0,0,1,1.27-1.17,4.33,4.33,0,0,0,1.26.12A15.68,15.68,0,0,1,4.59,23.44a1,1,0,0,1,1.7-.76l0,0q.72.6,1.49,1.13a16.6,16.6,0,0,1-.6-12.94,1,1,0,0,1,1.69-.28C16,18.9,26.08,22.7,31.2,22.53a12.11,12.11,0,0,1-.2-2.2A12.35,12.35,0,0,1,43.34,8a14.33,14.33,0,0,1,8.93,3.42,19.86,19.86,0,0,0,2-.57A23.11,23.11,0,0,0,58,9.23a1,1,0,0,1,1.32,1.42,40.24,40.24,0,0,1-3.8,4.69A37.34,37.34,0,0,0,60.12,14a1,1,0,0,1,1.21,1.51,26.09,26.09,0,0,1-4.91,5c-.15,4.75-3.85,26.26-21.48,32.28l-.11,0A52.51,52.51,0,0,1,19.55,55.08ZM7.67,51.51a48.65,48.65,0,0,0,26.64-.63h0C51.31,45,54.55,23,54.42,20a1,1,0,0,1,.4-.85A23.91,23.91,0,0,0,57.39,17c-1.55.44-3.11.74-3.52.33a1,1,0,0,1-.23-.36,9.72,9.72,0,0,0-.49-1.08,1,1,0,0,1,.31-1.27,20.16,20.16,0,0,0,1.86-2l-.42.14a22.27,22.27,0,0,1-2.77.76,1,1,0,0,1-1-.35C49.93,11.67,46.33,10,43.34,10A10.31,10.31,0,0,0,33.4,23.14a1,1,0,0,1-.79,1.26c-5,.88-15.9-2.55-24.07-11.18-1.24,5,.65,10.69,3.47,13a1,1,0,0,1-1,1.68,26.14,26.14,0,0,1-4.08-2.29c.93,4.33,4,7.93,8.66,10.08a1,1,0,0,1-.09,1.85,12.93,12.93,0,0,1-3.48.5c1.63,3.1,6.15,5.52,9.87,5.91a1,1,0,0,1,.61,1.7C20.32,47.83,14,50.45,7.67,51.51ZM5.58,23.4h0Z"/></symbol></svg>
|
Before Width: | Height: | Size: 5.6 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M392.8 1.2c-17-4.9-34.7 5-39.6 22l-128 448c-4.9 17 5 34.7 22 39.6s34.7-5 39.6-22l128-448c4.9-17-5-34.7-22-39.6zm80.6 120.1c-12.5 12.5-12.5 32.8 0 45.3L562.7 256l-89.4 89.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l112-112c12.5-12.5 12.5-32.8 0-45.3l-112-112c-12.5-12.5-32.8-12.5-45.3 0zm-306.7 0c-12.5-12.5-32.8-12.5-45.3 0l-112 112c-12.5 12.5-12.5 32.8 0 45.3l112 112c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256l89.4-89.4c12.5-12.5 12.5-32.8 0-45.3z"/></svg>
|
Before Width: | Height: | Size: 693 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M524.5 69.8a1.5 1.5 0 0 0 -.8-.7A485.1 485.1 0 0 0 404.1 32a1.8 1.8 0 0 0 -1.9 .9 337.5 337.5 0 0 0 -14.9 30.6 447.8 447.8 0 0 0 -134.4 0 309.5 309.5 0 0 0 -15.1-30.6 1.9 1.9 0 0 0 -1.9-.9A483.7 483.7 0 0 0 116.1 69.1a1.7 1.7 0 0 0 -.8 .7C39.1 183.7 18.2 294.7 28.4 404.4a2 2 0 0 0 .8 1.4A487.7 487.7 0 0 0 176 479.9a1.9 1.9 0 0 0 2.1-.7A348.2 348.2 0 0 0 208.1 430.4a1.9 1.9 0 0 0 -1-2.6 321.2 321.2 0 0 1 -45.9-21.9 1.9 1.9 0 0 1 -.2-3.1c3.1-2.3 6.2-4.7 9.1-7.1a1.8 1.8 0 0 1 1.9-.3c96.2 43.9 200.4 43.9 295.5 0a1.8 1.8 0 0 1 1.9 .2c2.9 2.4 6 4.9 9.1 7.2a1.9 1.9 0 0 1 -.2 3.1 301.4 301.4 0 0 1 -45.9 21.8 1.9 1.9 0 0 0 -1 2.6 391.1 391.1 0 0 0 30 48.8 1.9 1.9 0 0 0 2.1 .7A486 486 0 0 0 610.7 405.7a1.9 1.9 0 0 0 .8-1.4C623.7 277.6 590.9 167.5 524.5 69.8zM222.5 337.6c-29 0-52.8-26.6-52.8-59.2S193.1 219.1 222.5 219.1c29.7 0 53.3 26.8 52.8 59.2C275.3 311 251.9 337.6 222.5 337.6zm195.4 0c-29 0-52.8-26.6-52.8-59.2S388.4 219.1 417.9 219.1c29.7 0 53.3 26.8 52.8 59.2C470.7 311 447.5 337.6 417.9 337.6z"/></svg>
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M192 64C86 64 0 150 0 256S86 448 192 448H448c106 0 192-86 192-192s-86-192-192-192H192zM496 168a40 40 0 1 1 0 80 40 40 0 1 1 0-80zM392 304a40 40 0 1 1 80 0 40 40 0 1 1 -80 0zM168 200c0-13.3 10.7-24 24-24s24 10.7 24 24v32h32c13.3 0 24 10.7 24 24s-10.7 24-24 24H216v32c0 13.3-10.7 24-24 24s-24-10.7-24-24V280H136c-13.3 0-24-10.7-24-24s10.7-24 24-24h32V200z"/></svg>
|
Before Width: | Height: | Size: 584 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M194.4 211.7a53.3 53.3 0 1 0 59.3 88.7 53.3 53.3 0 1 0 -59.3-88.7zm142.3-68.4c-5.2-5.2-11.5-9.3-18.4-12c-18.1-7.1-57.6-6.8-83.1-6.5c-4.1 0-7.9 .1-11.2 .1c-3.3 0-7.2 0-11.4-.1c-25.5-.3-64.8-.7-82.9 6.5c-6.9 2.7-13.1 6.8-18.4 12s-9.3 11.5-12 18.4c-7.1 18.1-6.7 57.7-6.5 83.2c0 4.1 .1 7.9 .1 11.1s0 7-.1 11.1c-.2 25.5-.6 65.1 6.5 83.2c2.7 6.9 6.8 13.1 12 18.4s11.5 9.3 18.4 12c18.1 7.1 57.6 6.8 83.1 6.5c4.1 0 7.9-.1 11.2-.1c3.3 0 7.2 0 11.4 .1c25.5 .3 64.8 .7 82.9-6.5c6.9-2.7 13.1-6.8 18.4-12s9.3-11.5 12-18.4c7.2-18 6.8-57.4 6.5-83c0-4.2-.1-8.1-.1-11.4s0-7.1 .1-11.4c.3-25.5 .7-64.9-6.5-83l0 0c-2.7-6.9-6.8-13.1-12-18.4zm-67.1 44.5A82 82 0 1 1 178.4 324.2a82 82 0 1 1 91.1-136.4zm29.2-1.3c-3.1-2.1-5.6-5.1-7.1-8.6s-1.8-7.3-1.1-11.1s2.6-7.1 5.2-9.8s6.1-4.5 9.8-5.2s7.6-.4 11.1 1.1s6.5 3.9 8.6 7s3.2 6.8 3.2 10.6c0 2.5-.5 5-1.4 7.3s-2.4 4.4-4.1 6.2s-3.9 3.2-6.2 4.2s-4.8 1.5-7.3 1.5l0 0c-3.8 0-7.5-1.1-10.6-3.2zM448 96c0-35.3-28.7-64-64-64H64C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V96zM357 389c-18.7 18.7-41.4 24.6-67 25.9c-26.4 1.5-105.6 1.5-132 0c-25.6-1.3-48.3-7.2-67-25.9s-24.6-41.4-25.8-67c-1.5-26.4-1.5-105.6 0-132c1.3-25.6 7.1-48.3 25.8-67s41.5-24.6 67-25.8c26.4-1.5 105.6-1.5 132 0c25.6 1.3 48.3 7.1 67 25.8s24.6 41.4 25.8 67c1.5 26.3 1.5 105.4 0 131.9c-1.3 25.6-7.1 48.3-25.8 67z"/></svg>
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,56 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
inkscape:version="1.0 (4035a4fb49, 2020-05-01)"
|
||||
sodipodi:docname="json_feed_icon.svg"
|
||||
id="svg17"
|
||||
version="1.1"
|
||||
viewBox="0 0 140 140"
|
||||
height="140px"
|
||||
width="140px">
|
||||
|
||||
<defs
|
||||
id="defs6" />
|
||||
<g
|
||||
id="Page-1">
|
||||
<g
|
||||
transform="translate(-4,-14)"
|
||||
id="icon">
|
||||
<rect
|
||||
style="fill:none;stroke-width:0"
|
||||
height="0"
|
||||
width="140"
|
||||
y="1000014"
|
||||
x="-74"
|
||||
id="background" />
|
||||
<g
|
||||
transform="rotate(-45,91.330213,37.830213)"
|
||||
id="elements">
|
||||
<path
|
||||
d="m 7,64.983598 v 0.206832 c 8.251953,0 10.917969,3.300781 10.917969,13.266601 v 15.742188 c 0,16.376951 8.061523,23.613281 26.342773,23.613281 h 5.585938 v -11.87012 h -2.856446 c -10.346679,0 -14.02832,-3.55469 -14.02832,-13.457028 v -18.47168 c 0,-9.521485 -4.25293,-14.726563 -12.568359,-15.488281 V 56.748047 C 28.962891,55.922852 32.961914,51.162109 32.961914,41.958008 V 25.390625 c 0,-10.092773 3.554688,-13.583984 14.02832,-13.583984 H 49.84668 V 0 H 44.260742 C 25.916016,0 17.917969,6.9824219 17.917969,22.978516 v 13.647461 c 0,9.96582 -2.729492,13.330078 -10.917969,13.330078 v 0.06035 C 3.0909841,50.273695 0,53.525879 0,57.5 c 0,3.974121 3.0909841,7.226305 7,7.483598 z" />
|
||||
<g
|
||||
transform="translate(42,50)"
|
||||
id="dots-@-15pt">
|
||||
<circle
|
||||
r="7.5"
|
||||
cy="7.5"
|
||||
cx="7.5"
|
||||
id="Oval-1" />
|
||||
<circle
|
||||
r="7.5"
|
||||
cy="7.5"
|
||||
cx="32.5"
|
||||
id="Oval-2" />
|
||||
<circle
|
||||
r="7.5"
|
||||
cy="7.5"
|
||||
cx="57.5"
|
||||
id="Oval-3" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M416 32H31.9C14.3 32 0 46.5 0 64.3v383.4C0 465.5 14.3 480 31.9 480H416c17.6 0 32-14.5 32-32.3V64.3c0-17.8-14.4-32.3-32-32.3zM135.4 416H69V202.2h66.5V416zm-33.2-243c-21.3 0-38.5-17.3-38.5-38.5S80.9 96 102.2 96c21.2 0 38.5 17.3 38.5 38.5 0 21.3-17.2 38.5-38.5 38.5zm282.1 243h-66.4V312c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9V416h-66.4V202.2h63.7v29.2h.9c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9V416z"/></svg>
|
Before Width: | Height: | Size: 655 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M48 64C21.5 64 0 85.5 0 112c0 15.1 7.1 29.3 19.2 38.4L236.8 313.6c11.4 8.5 27 8.5 38.4 0L492.8 150.4c12.1-9.1 19.2-23.3 19.2-38.4c0-26.5-21.5-48-48-48H48zM0 176V384c0 35.3 28.7 64 64 64H448c35.3 0 64-28.7 64-64V176L294.4 339.2c-22.8 17.1-54 17.1-76.8 0L0 176z"/></svg>
|
Before Width: | Height: | Size: 490 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M192 96a48 48 0 1 0 0-96 48 48 0 1 0 0 96zm-8 384V352h16V480c0 17.7 14.3 32 32 32s32-14.3 32-32V192h56 64 16c17.7 0 32-14.3 32-32s-14.3-32-32-32H384V64H576V256H384V224H320v48c0 26.5 21.5 48 48 48H592c26.5 0 48-21.5 48-48V48c0-26.5-21.5-48-48-48H368c-26.5 0-48 21.5-48 48v80H243.1 177.1c-33.7 0-64.9 17.7-82.3 46.6l-58.3 97c-9.1 15.1-4.2 34.8 10.9 43.9s34.8 4.2 43.9-10.9L120 256.9V480c0 17.7 14.3 32 32 32s32-14.3 32-32z"/></svg>
|
Before Width: | Height: | Size: 651 B |