remove old svelte web source

This commit is contained in:
2024-09-30 21:13:20 +02:00
parent f42ebc7044
commit 1ce8ccfdd5
149 changed files with 20 additions and 10198 deletions

View File

@@ -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>

View 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>,
}

View 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
View File

@@ -0,0 +1,3 @@
pub mod blog_post_model;
pub mod featured_blog_posts;
pub mod tag_list;

View 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
View File

@@ -0,0 +1 @@
pub mod site_header;

View 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
View 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
View 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
View File

@@ -1,3 +0,0 @@
/// <reference types="@sveltejs/kit" />
/// <reference types="svelte" />
/// <reference types="vite/client" />

View File

@@ -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
}

View File

@@ -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-'))
}

View File

@@ -1,11 +0,0 @@
declare global {
interface Window {
onMountScripts?: Array<() => void>
}
}
export function runOnMountScripts() {
window.onMountScripts?.forEach((fn) => {
fn()
})
}

View File

@@ -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,
})

View File

@@ -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>

View File

@@ -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',
})

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',
})

View File

@@ -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>

View File

@@ -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'],
})

View File

@@ -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>

View File

@@ -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',
})

View File

@@ -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)}
>&lt;</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)}
>&gt;</a
>
</li>
{/if}
</ul>

View File

@@ -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])
})
})
})

View File

@@ -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}`
}

View File

@@ -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(',')
}

View File

@@ -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]),
})
}

View File

@@ -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>`
},
}

View File

@@ -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,
})
})
})

View File

@@ -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,
}
}

View File

@@ -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)
})
})
})

View File

@@ -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,
}
}
}

View File

@@ -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,
})

View File

@@ -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,
})

View File

@@ -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)
)}`,
})

View File

@@ -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)

View File

@@ -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
View 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
View File

@@ -0,0 +1,9 @@
use askama::Template;
#[derive(Template)]
#[template(path = "admin.html")]
pub struct AdminPageTemplate {}
pub async fn render_admin() -> AdminPageTemplate {
AdminPageTemplate {}
}

View 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,
})
}

View 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
View 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
View 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
View 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
View 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,
})
}

View File

@@ -1,4 +0,0 @@
/** @type {import('@sveltejs/kit').ParamMatcher} */
export function match(param: string) {
return !['tags', 'page'].some((keyword) => param.startsWith(keyword))
}

View 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,
}
}
}

View 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(())
}

View 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;

View 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
);
}

View 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
View File

@@ -0,0 +1,2 @@
pub mod post_listing;
pub mod post_parser;

View 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)
}

View 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
}

View 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
View File

@@ -0,0 +1,2 @@
pub mod featured_projects;
pub mod project_model;

View 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
View 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,
)
}),
)
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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&nbsp;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>

View File

@@ -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

View File

@@ -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" />

View File

@@ -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

View File

@@ -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

View File

@@ -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" />

View File

@@ -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" />

View File

@@ -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

View File

@@ -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

View File

@@ -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" />

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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',
}
)

View File

@@ -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',
},
})

View File

@@ -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

View File

@@ -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>

View File

@@ -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-->

View File

@@ -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',
})

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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 &#128663;.</p>
</section>

View File

@@ -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',
})

View File

@@ -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>

View File

@@ -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',
})

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

Some files were not shown because too many files have changed in this diff Show More