Merge branch 'release/0.0.1'

This commit is contained in:
Sergey Yarkov 2022-08-18 21:43:28 +03:00
commit 87ec4296dc
211 changed files with 19861 additions and 11596 deletions

14
.eslintrc.json Normal file
View File

@ -0,0 +1,14 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:solid/typescript"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint", "solid"],
"rules": {}
}

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
*.css linguist-detectable=false
*.scss linguist-detectable=false

3
.gitignore vendored
View File

@ -10,6 +10,9 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.idea
.vscode
# environment variables
.env

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "content"]
path = content
url = https://github.com/sergeyyarkov/content

8
.prettierrc.json Normal file
View File

@ -0,0 +1,8 @@
{
"printWidth": 150,
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"useTabs": true
}

View File

@ -1,4 +0,0 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored
View File

@ -1,11 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View File

@ -1,7 +1,22 @@
# yarkov.tech
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Netlify Status](https://api.netlify.com/api/v1/badges/ccd0460a-9725-4e35-8263-1608c4186d9c/deploy-status)](https://app.netlify.com/sites/jade-froyo-876dfe/deploys)
Personal website created with Astro.
### Features
- [x] HTML layout
- [x] Adaptive & Responsive layout
- [x] Search articles by title or description
- [x] Dark/Light Theme
- [x] Multilang web-site
- [x] Form handling via [Netlify Forms](https://www.netlify.com/products/forms/)
- [x] Comment system via [utterances](https://utteranc.es/)
- [x] RSS Feed for articles
- [ ] SEO optimized
## License
This project is open source and available under the [MIT License](LICENSE).

View File

@ -1,4 +1,102 @@
import { defineConfig } from 'astro/config';
import solid from "@astrojs/solid-js";
// import image from "@astrojs/image";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
// import compress from "astro-compress";
import { defineConfig } from "astro/config";
import { h } from "hastscript";
import { toString } from "hast-util-to-string";
import { escape } from "html-escaper";
import remarkGfm from "remark-gfm";
import remarkSmartypants from "remark-smartypants";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
const createAnchorLabel = (heading) => {
const node = h("span.anchor-label", escape(heading));
node.properties["is:raw"] = true;
return node;
};
// https://astro.build/config
export default defineConfig({});
export default defineConfig({
site: "https://yarkov.tech",
integrations: [
solid(),
sitemap({
i18n: {
defaultLocale: "ru",
locales: {
ru: "ru-RU",
en: "en-US",
},
},
}),
// image(),
mdx({
remarkPlugins: [remarkGfm, remarkSmartypants],
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
properties: {
class: "anchor-link",
},
behavior: "after",
group: ({ tagName }) =>
h(`div.heading-wrapper.level-${tagName}`, {
tabIndex: -1,
}),
content: (heading) => [
h(
`span.anchor-icon`,
undefined,
h(
"svg",
{
width: 18,
height: 18,
version: 1.1,
viewBox: "0 0 16 16",
xlmns: "http://www.w3.org/2000/svg",
ariaHidden: "true",
"data-filter": "none",
},
h("path", {
fillRule: "evenodd",
fill: "currentcolor",
d: "M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z",
})
)
),
createAnchorLabel(toString(heading)),
],
},
],
],
}),
],
trailingSlash: "always",
markdown: {
syntaxHighlight: "prism",
},
vite: {
ssr: {
external: ["svgo"],
},
build: {
minify: "esbuild",
rollupOptions: {
output: {
entryFileNames: `[name].[hash].js`,
chunkFileNames: `chunks/[name].[hash].js`,
assetFileNames: `assets/[hash].[ext]`,
},
},
},
},
experimental: {
integrations: true,
},
});

1
content Submodule

@ -0,0 +1 @@
Subproject commit 702becf6781d2b2a5496b185f2bf3bf18b06cec5

27313
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,24 +1,47 @@
{
"name": "yarkov.tech",
"version": "0.0.1",
"private": true,
"description": "Personal website created with Astro.",
"author": {
"name": "Sergey Yarkov",
"email": "serzh.yarkov@gmail.com",
"url": "https://yarkov.tech"
},
"license": "MIT",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview"
},
"devDependencies": {
"astro": "^1.0.0-beta.63",
"prettier": "^2.7.1",
"prettier-plugin-astro": "^0.1.1",
"sass": "^1.53.0"
}
"name": "yarkov.tech",
"version": "0.0.1",
"private": true,
"description": "Personal website created with Astro.",
"author": {
"name": "Sergey Yarkov",
"email": "serzh.yarkov@gmail.com",
"url": "https://yarkov.tech"
},
"type": "module",
"license": "MIT",
"scripts": {
"dev": "astro dev --host",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview --host",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json}\"",
"lint": "eslint '**/*.{js,mjs,ts,tsx}'"
},
"devDependencies": {
"@astrojs/image": "^0.3.4",
"@astrojs/mdx": "^0.10.0",
"@astrojs/rss": "^1.0.0",
"@astrojs/sitemap": "^1.0.0",
"@astrojs/solid-js": "^1.0.0",
"@types/mdx": "^2.0.2",
"@typescript-eslint/eslint-plugin": "^5.33.1",
"@typescript-eslint/parser": "^5.33.1",
"astro": "^1.0.6",
"astro-compress": "^1.0.1",
"astro-icon": "^0.7.3",
"eslint": "^8.22.0",
"eslint-plugin-solid": "^0.7.1",
"hast-util-to-string": "^2.0.0",
"hastscript": "^7.0.2",
"html-escaper": "^3.0.3",
"prettier": "^2.7.1",
"prettier-plugin-astro": "^0.5.0",
"rehype-autolink-headings": "^6.1.1",
"rehype-slug": "^5.0.1",
"remark-gfm": "^3.0.1",
"remark-smartypants": "^2.0.0",
"sass": "^1.54.4",
"solid-js": "^1.4.8"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,3 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.2256 8.52039C10.2256 9.10839 10.3929 9.34289 10.8304 9.34289C11.8055 9.34289 12.4264 8.10039 12.4264 6.03399C12.4264 2.87559 10.1248 1.36359 7.25127 1.36359C4.29517 1.36359 1.60646 3.34599 1.60646 7.09239C1.60646 10.6708 3.95846 12.6196 7.57046 12.6196C8.79686 12.6196 9.62006 12.4852 10.8794 12.0652L11.1496 13.1901C9.90637 13.594 8.57777 13.7109 7.55367 13.7109C2.81607 13.7109 0.278564 11.1069 0.278564 7.09169C0.278564 3.04289 3.21927 0.287689 7.26807 0.287689C11.4849 0.287689 13.7186 2.80769 13.7186 5.89889C13.7186 8.51969 12.8961 10.5189 10.3089 10.5189C9.13216 10.5189 8.36007 10.0485 8.25926 9.00619C7.95686 10.1654 7.15047 10.5189 6.05777 10.5189C4.59617 10.5189 3.36976 9.39259 3.36976 7.12529C3.36976 4.84049 4.44566 3.42929 6.37767 3.42929C7.40247 3.42929 8.04086 3.83249 8.32506 4.47089L8.81297 3.58049H10.2242V8.52039H10.2256ZM8.15986 6.30279C8.15986 5.37949 7.47037 4.99239 6.89917 4.99239C6.27757 4.99239 5.58946 5.49569 5.58946 6.97479C5.58946 8.15079 6.11026 8.80599 6.89917 8.80599C7.45357 8.80599 8.15986 8.45319 8.15986 7.47879V6.30279Z" fill="#010409"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,3 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.99453 1.04315C3.61348 1.04179 0.875 3.77889 0.875 7.15722C0.875 9.8287 2.58809 12.0996 4.97383 12.9336C5.29512 13.0142 5.2459 12.7859 5.2459 12.6301V11.5705C3.39062 11.7879 3.31543 10.5601 3.19102 10.3551C2.93945 9.92577 2.34473 9.81639 2.52246 9.61132C2.94492 9.39393 3.37559 9.666 3.87461 10.4029C4.23555 10.9375 4.93965 10.8473 5.29648 10.7584C5.37441 10.4371 5.54121 10.15 5.7709 9.92714C3.84863 9.58261 3.04746 8.40956 3.04746 7.01503C3.04746 6.33827 3.27031 5.7162 3.70781 5.21444C3.42891 4.38729 3.73379 3.67909 3.7748 3.57382C4.56914 3.50272 5.39492 4.14257 5.45918 4.19315C5.91035 4.07147 6.42578 4.00722 7.00273 4.00722C7.58242 4.00722 8.09922 4.07421 8.55449 4.19725C8.70898 4.07968 9.47461 3.53007 10.2129 3.59706C10.2525 3.70233 10.5506 4.39413 10.2881 5.21034C10.7311 5.71347 10.9566 6.341 10.9566 7.01913C10.9566 8.4164 10.15 9.59081 8.22227 9.92987C8.38738 10.0923 8.51849 10.2859 8.60792 10.4995C8.69736 10.7131 8.74333 10.9424 8.74316 11.174V12.7121C8.7541 12.8351 8.74316 12.9568 8.94824 12.9568C11.3695 12.1406 13.1127 9.85331 13.1127 7.15858C13.1127 3.77889 10.3729 1.04315 6.99453 1.04315Z" fill="#010409"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,3 +0,0 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.0806 2.13178C12.0806 2.13178 13.2138 1.6899 13.1194 2.76303C13.0879 3.20491 12.8046 4.75147 12.5843 6.4243L11.8288 11.3796C11.8288 11.3796 11.7659 12.1056 11.1992 12.2318C10.6326 12.3581 9.78273 11.7899 9.62532 11.6637C9.49941 11.569 7.26448 10.1487 6.47751 9.45431C6.25715 9.26493 6.00533 8.88617 6.50898 8.44429L9.8142 5.28805C10.1919 4.90929 10.5697 4.02554 8.99576 5.09867L4.58882 8.09712C4.58882 8.09712 4.08517 8.41274 3.14084 8.12868L1.09475 7.49743C1.09475 7.49743 0.33927 7.02399 1.62988 6.55053C4.7777 5.06709 8.64952 3.55208 12.0806 2.13178Z" fill="#010409"/>
</svg>

Before

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -0,0 +1,22 @@
/**
* This script is used to determine the theme of the site.
* The theme value is taken from localstorage, otherwise from the operating system.
* It is very important to include this script before loading the CSS
* and body markup to prevent FOUC (Flash of unstyled content).
*/
window.theme = (() => {
const local = localStorage.getItem("theme");
if (local && (local === "dark" || local === "light")) return local;
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
return isDark ? "dark" : "light";
})();
document.documentElement.setAttribute("data-theme", window.theme);
/* Sync with OS theme */
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", ({ matches: isDark }) => {
window.theme = isDark ? "dark" : "light";
localStorage.setItem("theme", window.theme);
document.documentElement.setAttribute("data-theme", window.theme);
});

View File

@ -0,0 +1,48 @@
/**
* This script will load the properly code highlithing theme based on user theme
* It is important to insert it after detecting a user theme
* and assigning the `window.theme` property
*/
if (window.theme) {
/* the {theme} in the path will be dynamically changed */
const PATH = `/assets/css/code-highlight-{theme}.min.css`;
const $head = document.getElementsByTagName("head")[0];
const getPath = (theme) => PATH.replace(`{theme}`, theme);
const createLink = (theme, media) => {
const $link = document.createElement("link");
$link.rel = "stylesheet";
$link.type = "text/css";
$link.href = getPath(theme);
$link.media = media;
return $link;
};
/* init links to load them first */
const $light = createLink("light", "none");
const $dark = createLink("dark", "none");
const toggleMedia = (theme) => {
switch (theme) {
case "light":
$light.media = "all";
$dark.media = "none";
break;
case "dark":
$light.media = "none";
$dark.media = "all";
break;
default:
break;
}
};
toggleMedia(window.theme);
$head.appendChild($light);
$head.appendChild($dark);
window.addEventListener("onthemetoggled", ({ detail: newTheme }) => toggleMedia(newTheme));
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", ({ matches: isDark }) => {
return isDark ? toggleMedia("dark") : toggleMedia("light");
});
}

9
public/browserconfig.xml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 617 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 955 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/mstile-150x150.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

6
public/robots.txt Normal file
View File

@ -0,0 +1,6 @@
# Disallow all at the time of development
User-agent: *
Disallow: /
Sitemap: https://yarkov.tech/sitemap-index.xml

View File

@ -0,0 +1,23 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="265.000000pt" height="265.000000pt" viewBox="0 0 265.000000 265.000000"
preserveAspectRatio="xMidYMid meet">
<metadata>
Created by potrace 1.14, written by Peter Selinger 2001-2017
</metadata>
<g transform="translate(0.000000,265.000000) scale(0.100000,-0.100000)"
fill="#000000" stroke="none">
<path d="M443 2635 c-203 -44 -385 -228 -428 -432 -21 -100 -21 -1656 0 -1756
43 -206 226 -389 432 -432 100 -21 1656 -21 1756 0 206 43 389 226 432 432 21
100 21 1656 0 1756 -43 206 -226 389 -432 432 -97 21 -1666 20 -1760 0z m1165
-1207 l-3 -43 -51 -3 -51 -3 96 -269 c53 -148 99 -271 102 -274 3 -4 47 110
97 251 50 142 94 266 97 276 6 15 1 17 -44 17 l-51 0 0 45 0 45 145 0 145 0 0
-44 0 -45 -42 -3 -42 -3 -171 -440 c-177 -455 -191 -488 -233 -543 -100 -131
-332 -116 -358 23 -17 90 58 155 140 121 47 -20 67 -77 42 -126 -20 -41 -20
-40 7 -40 57 0 111 60 156 173 l24 58 -151 387 -150 387 -41 3 -41 3 0 45 0
44 191 0 190 0 -3 -42z m656 -582 c34 -14 66 -65 66 -105 0 -39 -28 -89 -59
-105 -85 -44 -181 11 -181 104 0 90 86 143 174 106z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

19
public/site.webmanifest Normal file
View File

@ -0,0 +1,19 @@
{
"name": "yarkov.tech",
"short_name": "yarkov.tech",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-256x256.png",
"sizes": "256x256",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@ -1,11 +0,0 @@
{
"sitename": "yarkov.tech",
"author": {
"name": "Sergey Yarkov"
},
"contacts": {
"email": "serzh.yarkov@gmail.com",
"github": "sergeyyarkov",
"telegram": "sergeyyarkov"
}
}

View File

@ -0,0 +1,29 @@
---
import { UTTERANCES_CONFIG } from "@root/config";
import UIString from "../UIString";
---
<footer>
<h2><UIString key={"articlePost.comments"} /></h2>
<div id="comments"></div>
</footer>
<script define:vars={{ UTTERANCES_CONFIG }}>
const $commentsWrapper = document.getElementById("comments");
const $commentsScript = document.createElement("script");
$commentsScript.async = true;
$commentsScript.src = "https://utteranc.es/client.js";
$commentsScript.setAttribute("repo", UTTERANCES_CONFIG.repo);
$commentsScript.setAttribute("issue-term", UTTERANCES_CONFIG.issueTerm);
$commentsScript.setAttribute("id", "utterances");
$commentsScript.setAttribute("crossorigin", "anonymous");
if (window.theme) {
$commentsScript.setAttribute("theme", theme === "dark" ? "dark-blue" : "github-light");
} else {
$commentsScript.setAttribute("theme", UTTERANCES_CONFIG.theme);
}
if ($commentsWrapper) $commentsWrapper.appendChild($commentsScript);
</script>

View File

@ -0,0 +1 @@
export { default } from "./ArticleComments.astro";

View File

@ -0,0 +1,5 @@
---
import "./ArticleContent.scss";
---
<div itemprop="articleBody" class="article-content"><slot /></div>

View File

@ -0,0 +1,214 @@
.article-content {
& p,
& em,
& q {
line-height: var(--line-height-base);
font-size: var(--font-size-lg);
}
& h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 3rem;
margin-bottom: 1rem;
padding-bottom: 5px;
}
& ul {
padding-left: 1.5rem;
line-height: var(--line-height-base);
margin-bottom: 1.5rem;
margin-top: 0;
& li {
font-size: var(--font-size-lg);
line-height: var(--line-height-base);
margin-bottom: 0.5rem;
padding-left: 0.25rem;
}
}
& q {
font-style: italic;
quotes: "«" "»";
}
& a {
color: var(--font-color);
text-decoration: underline;
text-decoration-color: var(--color-blue-500);
text-underline-offset: 2.2px;
text-decoration-thickness: 2px;
font-weight: 500;
&:hover {
color: var(--color-blue-500);
text-decoration-thickness: 2px;
}
}
& figure {
margin: 0 0 1em;
}
& hr {
background-color: var(--border-color);
margin: 40px 0;
border: none;
height: 1px;
}
& pre {
font-size: var(--font-size-sm);
border-radius: var(--border-radius-md);
}
& :not(pre) > code {
-webkit-font-smoothing: subpixel-antialiased;
background: var(--background-inline-code);
border-radius: var(--border-radius-lg);
font-size: 1rem;
font-weight: 500;
font-family: "IBM Plex Mono", monospace;
padding: 0.2rem 0.3rem;
}
& figcaption {
font-style: italic;
font-size: 1em;
text-align: center;
margin: 0.7rem 0;
}
& .table-wrapper {
overflow-x: auto;
margin-bottom: 1.25rem;
}
& table {
display: table;
border-radius: var(--border-radius-md);
min-width: 100%;
font-size: var(--font-size-md);
border: 1px solid var(--border-color);
border-collapse: separate;
border-spacing: 0;
margin: 5px 0;
& thead th {
background-color: var(--background-card);
border-top-left-radius: var(--border-radius-md);
border-top-right-radius: var(--border-radius-md);
}
& td,
th {
border-bottom: 1px solid var(--border-color);
padding: 0.5rem;
text-align: left;
}
& tbody tr:last-child td {
border: none;
}
}
& li {
list-style: disc;
& li {
list-style-type: circle;
}
&::marker {
color: var(--color-blue-500);
font-size: 1.3rem;
line-height: var(--line-height-none);
}
}
& blockquote {
margin: 1.5rem 0;
font-style: italic;
border-left: 0.25rem solid var(--border-color);
padding-left: 1rem;
}
& .heading-wrapper {
border-bottom: 1px solid var(--border-color);
margin: 3rem 0 1rem 0;
padding-bottom: 0.35rem;
& h2 {
font-size: var(--font-size-2xl);
}
& h3 {
font-size: var(--font-size-xl);
}
& h4 {
font-size: var(--font-size-lg);
}
& h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
}
}
& .heading-wrapper {
display: flex;
align-items: center;
flex-direction: row-reverse;
justify-content: flex-end;
margin-inline-start: 1px;
& .anchor-link {
position: relative;
display: inline-flex;
text-decoration: none;
justify-content: center;
align-items: center;
opacity: 0;
& .anchor-icon {
position: absolute;
left: -25px;
top: -13px;
}
& svg {
vertical-align: middle;
}
}
& .anchor-label {
position: absolute;
width: 0;
height: 0;
padding: 0;
overflow: hidden;
white-space: nowrap;
}
&:hover {
& .anchor-link {
opacity: 1;
}
}
}
& .languages-available {
margin: 25px 0;
font-size: var(--font-size-lg);
}
}
@media screen and (max-width: 800px) {
.article-content {
& .heading-wrapper {
flex-direction: row;
justify-content: flex-start;
& .anchor-link {
& .anchor-icon {
left: 5px;
}
}
}
}
}

View File

@ -0,0 +1 @@
export { default } from "./ArticleContent.astro";

View File

@ -0,0 +1,43 @@
.article-item {
border-radius: var(--border-radius-md);
text-decoration: none;
& a {
display: flex;
justify-content: space-between;
align-items: center;
column-gap: 10px;
color: var(--font-color);
padding: 8px 0;
& sup {
margin-left: 5px;
font-size: 0.7em;
}
& h3 {
font-size: var(--font-size-md);
font-weight: normal;
margin: 0;
}
& p {
font-size: var(--font-size-xs);
font-family: var(--font-family-monospace);
color: var(--font-color-gray);
text-transform: uppercase;
text-align: right;
white-space: nowrap;
margin-bottom: 0;
}
}
}
@media screen and (max-width: 560px) {
.article-item {
margin-bottom: 10px;
& a {
flex-direction: column-reverse;
align-items: flex-start;
& p {
margin-bottom: 5px;
}
}
}
}

View File

@ -0,0 +1,28 @@
/* eslint-disable solid/reactivity */
import type { Component } from "solid-js";
import { createDateFormatter } from "@utils/date";
import "./ArticleItem.scss";
import { DEFAULT_LANGUAGE } from "@root/config";
export type ArticleItemProps = {
data: ArticleType;
lang: string;
};
export const ArticleItem: Component<ArticleItemProps> = (props) => {
const isNonDefaultLanguage = props.data.lang && props.data.lang !== props.lang;
const hrefLangPrefix = props.data.lang && props.data.lang !== DEFAULT_LANGUAGE ? "/en" : "";
return (
<article class="article-item">
<a href={`/blog/${new Date(props.data.published_at).toLocaleDateString("en-CA")}${hrefLangPrefix}/${props.data.slug}/`}>
<div class="flex">
<h3>{props.data.title}</h3>
{isNonDefaultLanguage && <sup>{props.data.lang.toLocaleUpperCase()}</sup>}
</div>
<p>
<time datetime={props.data.published_at.toString()}>{createDateFormatter(props.lang).format(new Date(props.data.published_at))}</time>
</p>
</a>
</article>
);
};

View File

@ -0,0 +1 @@
export * from "./ArticleItem";

View File

@ -0,0 +1,56 @@
---
// import { Image } from "@astrojs/image/components";
// import * as utils from "@utils/string";
import { createDateFormatter } from "@root/utils";
import ArticleComments from "@components/ArticleComments";
import "./ArticlePost.scss";
import TranslatedLink from "../TranslatedLink";
export type Props = {
data: ArticleType;
lang: string;
};
const { data, lang } = Astro.props as Props;
const { title, coverImage, tags, author, published_at } = data;
---
<article itemscope itemtype="http://schema.org/BlogPosting" class="article">
<header>
<div class="tags">
<ul itemprop="keywords">
{
tags.map((tag) => (
<li>
<TranslatedLink rel="tag" to={`/articles/?tags=${tag.toLocaleLowerCase()}`}>{`${tag}${tags.at(-1) === tag ? "" : ","}`}</TranslatedLink>
</li>
))
}
</ul>
</div>
<div class="heading">
<h1 itemprop="headline">{title}</h1>
</div>
<div class="published">
<time itemprop="datePublished" datetime={published_at}>{createDateFormatter(lang).format(new Date(published_at))}</time>
<span itemprop="author">{author}</span>
</div>
{
coverImage && (
<div>
<figure>
{coverImage && <img src={coverImage} width='700' height='400' alt={title} />}
{/* Render remote or local image */}
{/* utils.isURL(coverImage) ? (
<Image itemprop="image" src={coverImage} width={700} height={400} aspectRatio={"16:9"} format={"webp"} alt={data.title} />
) : (
<Image itemprop="image" src={cover.default.src} width={700} height={400} aspectRatio={"16:9"} format={"webp"} alt={data.title} />
)*/}
</figure>
</div>
)
}
</header>
<slot />
<ArticleComments />
</article>

View File

@ -0,0 +1,57 @@
.article {
& header {
& .heading {
margin-top: 1.125rem;
& h1 {
font-weight: 600;
font-size: 2.125rem;
}
& p {
font-size: 1.125rem;
line-height: var(--line-height-base);
}
}
& .tags {
font-size: var(--font-size-sm);
text-transform: uppercase;
& ul {
display: flex;
flex-wrap: wrap;
column-gap: 7px;
row-gap: 7px;
}
}
& .published {
margin-bottom: 1.25em;
color: var(--font-color-gray);
}
}
& img {
border-radius: var(--border-radius-md);
display: block;
margin: 0 auto;
max-width: 100%;
height: auto;
}
& figure {
margin: 0;
}
& footer {
margin-top: 5rem;
}
}
@media screen and (max-width: 560px) {
.article {
& header .heading h1 {
font-size: 2rem;
}
}
}

View File

@ -0,0 +1 @@
export { default } from "./ArticlePost.astro";

View File

@ -0,0 +1,21 @@
.articles {
&-tags {
margin-bottom: 45px;
}
&-list {
&__wrapper {
margin-top: 40px;
& h2 {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-color);
}
}
&__empty {
color: var(--font-color-gray);
text-align: center;
user-select: none;
padding: 3rem 0;
}
}
}

View File

@ -0,0 +1,98 @@
import type { Component } from "solid-js";
import { createSignal, createEffect, For } from "solid-js";
import { search, selectedTags } from "@stores/searchStore";
import { findArticlesBySearch, findArticlesByTags, sortArtcilesByDate } from "@utils/articles";
import { ArticleItem } from "@components/ArticleItem";
import SearchInput from "@components/SearchInput";
import TagsList from "@components/TagsList";
import "./ArticlesList.scss";
type UiStringsType = {
"input.search": string;
"articles.empty": string;
};
type ArticlesListProps = {
data: {
articles: ArticleBlockType;
tags: string[];
};
lang: string;
i18n: UiStringsType;
};
const ArticlesList: Component<ArticlesListProps> = (props) => {
// eslint-disable-next-line solid/reactivity
const [articles, setArticles] = createSignal<ArticleBlockType>(props.data.articles);
/**
* Sort articles by year in descending order
*/
const sortYears = (articles: ArticleBlockType) => {
return Object.keys(articles)
.map(Number)
.sort((a, b) => b - a);
};
const isEmpty = () => Object.keys(articles()).length === 0;
const getLength = () => Object.values(articles()).flat().length;
createEffect(() => {
let filtered = props.data.articles;
/**
* Find articles by title
*/
if (search()) {
filtered = findArticlesBySearch(search().trim(), filtered);
setArticles(filtered);
}
/**
* Find articles by selected tags
*/
if (selectedTags().length !== 0) {
filtered = findArticlesByTags(selectedTags(), filtered);
setArticles(filtered);
return;
}
/**
* Set filtered variable as default value
*/
if (!search() && selectedTags().length === 0) {
filtered = props.data.articles;
setArticles(filtered);
}
});
return (
<>
<SearchInput count={getLength()} placeholder={props.i18n["input.search"]} />
<TagsList data={props.data.tags} />
<div class="articles-list">
{isEmpty() ? (
<div class="articles-list__empty">
<p>{props.i18n["articles.empty"]}</p>
</div>
) : (
<For
each={sortYears(articles())}
children={(year) => {
return (
<div class="articles-list__wrapper">
<h2>{year}</h2>
<For each={sortArtcilesByDate(articles()[year])} children={(article) => <ArticleItem lang={props.lang} data={article} />} />
</div>
);
}}
/>
)}
</div>
</>
);
};
export default ArticlesList;

View File

@ -0,0 +1 @@
export { default } from "./ArticlesList";

View File

@ -0,0 +1,38 @@
---
import { SITE_METADATA } from "@root/config";
import { useTranslation } from "@i18n/utils";
export interface Props {
title?: string | undefined;
description?: string | undefined;
}
const t = useTranslation(Astro);
const { title = SITE_METADATA.title, description } = Astro.props as Props;
---
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono&display=swap"
rel="preload" as="style" onload="this.rel='stylesheet'"
/>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description || t("site.description")} />
<meta name="theme-color" content={SITE_METADATA.themeColor} />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-light-32x32.png" media="(prefers-color-scheme: light)" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-light-16x16.png" media="(prefers-color-scheme: light)" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-dark-32x32.png" media="(prefers-color-scheme: dark)" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-dark-16x16.png" media="(prefers-color-scheme: dark)" />
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
<meta name="msapplication-TileColor" content="#da532c" />
<slot />
<script is:inline src="/assets/js/detect-theme.js"></script>
</head>

View File

@ -0,0 +1,25 @@
---
import { IS_SITE_UNDER_CONSTRUCTION } from "@root/config";
import Header from "@components/Header";
import Footer from "@components/Footer";
import HeaderBanner from "@components/HeaderBanner";
import UIString from "@components/UIString";
import "./BaseLayout.scss";
---
<body>
{
IS_SITE_UNDER_CONSTRUCTION && (
<HeaderBanner>
&#128679;&nbsp;
<UIString key={"site.underConstruction"} />
&nbsp;&#128679;
</HeaderBanner>
)
}
<Header />
<main class="site-content">
<slot />
</main>
<Footer />
</body>

View File

@ -0,0 +1,11 @@
.site-content {
flex: 1 0 auto;
width: 100%;
margin-top: 5rem;
}
@media screen and (max-width: 768px) {
.site-content {
margin-top: 3rem;
}
}

View File

@ -0,0 +1 @@
export { default } from "./BaseLayout.astro";

View File

@ -0,0 +1,28 @@
.btn {
cursor: pointer;
display: inline-flex;
column-gap: 7px;
align-items: center;
font-family: var(--font-family-base);
font-weight: 500;
font-size: var(--font-size-md);
color: var(--font-color);
padding: 5px 16px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
background: none;
font-size: var(--font-size-sm);
&:hover {
border: 1px solid var(--border-color-hover);
}
&[disabled] {
opacity: 0.5;
&:hover {
border: 1px solid var(--border-color);
}
}
&[data-loading="true"] {
user-select: none;
cursor: not-allowed;
}
}

View File

@ -0,0 +1,22 @@
import type { ParentComponent, JSX } from "solid-js";
import { splitProps } from "solid-js";
import Spinner from "@components/Spinner";
import "./Button.scss";
type ButtonProps = {
isLoading?: boolean | undefined;
loadingText?: string | undefined;
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>;
const Button: ParentComponent<ButtonProps> = (props) => {
const [local, attrs] = splitProps(props, ["children", "isLoading", "loadingText"]);
return (
<button disabled={local.isLoading} class={`btn`} data-loading={local.isLoading} {...attrs}>
{local.isLoading && <Spinner />}
{local.isLoading ? local.loadingText || "Loading..." : local.children}
</button>
);
};
export default Button;

View File

@ -0,0 +1 @@
export { default } from "./Button";

View File

@ -0,0 +1,46 @@
.contact-form {
display: flex;
flex-direction: column;
row-gap: 7px;
margin-bottom: 3.125rem;
& .form-footer {
text-align: center;
margin-top: 15px;
}
& .form-result {
font-style: italic;
color: var(--font-color-gray);
}
& .error-message {
margin: 2px 0 3px 0;
display: block;
color: var(--color-error);
font-size: var(--font-size-sm);
user-select: none;
&::before {
content: "*";
}
}
& .flex {
column-gap: 10px;
& div {
width: 100%;
}
}
& input.error-input,
& textarea.error-input {
border: 1px solid var(--color-error);
}
}
@media screen and (max-width: 560px) {
.contact-form .flex {
flex-direction: column;
row-gap: 7px;
}
}

View File

@ -0,0 +1,140 @@
import type { Component, JSX } from "solid-js";
import { createSignal } from "solid-js";
import { createStore } from "solid-js/store";
import { useForm } from "@hooks/index";
import { encode } from "@utils/url";
import Button from "@components/Button";
import validations from "./validations";
import Submitted from "./Submitted";
import Error from "./Error";
import "./ContactForm.scss";
type UiStringsType = {
"form.contactSuccess": string;
"form.contactError": string;
"input.nameField": string;
"input.subjectField": string;
"input.messageField": string;
"btn.send": string;
"btn.sending": string;
"error.invalidEmail": string;
};
const ErrorMessage: Component<{ message: string }> = (props) => <span class="error-message">{props.message}</span>;
const ContactForm: Component<{ i18n: UiStringsType }> = (props) => {
const { formSubmit, validate, errors } = useForm({ errorClass: "error-input" });
const [isSubmitted, setIsSubmitted] = createSignal<boolean>(false);
const [isLoading, setIsLoading] = createSignal<boolean>(false);
const [isError, setIsError] = createSignal<boolean>(false);
const [state, setState] = createStore<ContactFieldsType>({ name: "", email: "", subject: "", message: "", "bot-field": "" });
const { validateEmail } = validations;
const reset = (form: HTMLFormElement) => {
for (const key in state) {
form.elements[key].value = "";
setState({ [key]: "" });
}
};
const sendMessage = async (form: HTMLFormElement) => {
try {
setIsLoading(true);
fetch("/", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: encode({
"form-name": "contact",
...state,
}),
});
} catch (error) {
setIsError(true);
console.error(error);
} finally {
setIsLoading(false);
setIsSubmitted(true);
reset(form);
}
};
const onChange: JSX.EventHandler<HTMLInputElement, InputEvent> = (e) => {
const key = e.currentTarget.name;
const value = e.currentTarget.value;
if (!Object.hasOwn(state, key)) return;
setState({ [key]: value });
};
return (
<form use:formSubmit={sendMessage} class="contact-form" name="contact" method="post" data-netlify="true">
<div hidden>
Don't fill this out if you're human
<input onInput={onChange} name="bot-field" />
</div>
<div class="flex">
<div>
<input
onInput={onChange}
use:validate
name="name"
placeholder={props.i18n["input.nameField"]}
type="text"
maxLength="20"
autocomplete="off"
required
/>
{errors.name && <ErrorMessage message={errors.name} />}
</div>
<div>
<input
onInput={onChange}
use:validate={[validateEmail(props.i18n["error.invalidEmail"])]}
name="email"
placeholder="example@email.com"
autocomplete="off"
type="text"
required
/>
{errors.email && <ErrorMessage message={errors.email} />}
</div>
</div>
<input
value={state.subject}
onInput={onChange}
use:validate
name="subject"
placeholder={props.i18n["input.subjectField"]}
type="text"
autocomplete="off"
required
/>
{errors.subject && <ErrorMessage message={errors.subject} />}
<textarea
onInput={onChange}
use:validate
name="message"
placeholder={props.i18n["input.messageField"]}
id="message"
autocomplete="off"
cols={30}
rows={10}
required
/>
{errors.message && <ErrorMessage message={errors.message} />}
<div class="form-footer">
{!isError() && !isSubmitted() && (
<Button isLoading={isLoading()} loadingText={props.i18n["btn.sending"]} type="submit">
{props.i18n["btn.send"]}
</Button>
)}
{!isError() && isSubmitted() && <Submitted text={props.i18n["form.contactSuccess"]} />}
{isError() && isSubmitted() && <Error text={props.i18n["form.contactError"]} />}
</div>
</form>
);
};
export default ContactForm;

View File

@ -0,0 +1,7 @@
import type { Component } from "solid-js";
const Submitted: Component<{ text: string }> = (props) => {
return <div class="form-result">&#10060; {props.text}</div>;
};
export default Submitted;

View File

@ -0,0 +1,7 @@
import type { Component } from "solid-js";
const Submitted: Component<{ text: string }> = (props) => {
return <div class="form-result">&#10004;&#65039; {props.text}</div>;
};
export default Submitted;

View File

@ -0,0 +1 @@
export { default } from "./ContactForm";

View File

@ -0,0 +1,7 @@
import { isEmail } from "@utils/string";
export default {
validateEmail: (error: string) => {
return (field: HTMLInputElement) => (isEmail(field.value) ? false : error);
},
};

View File

@ -0,0 +1,24 @@
---
import { SITE_METADATA } from '@root/config';
import { Icon } from 'astro-icon';
import './ContactsList.scss';
const {
contacts: { email, githubUrl, telegramUrl },
} = SITE_METADATA;
---
<div class='contacts-list'>
<a href={'mailto:' + email}
>Email
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
</a>
<a href={githubUrl} target='_blank'
>Github
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
</a>
<a href={telegramUrl} target='_blank'
>Telegram
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
</a>
</div>

View File

@ -0,0 +1,15 @@
.contacts-list {
display: flex;
flex-wrap: wrap;
column-gap: 40px;
row-gap: 30px;
& a {
display: flex;
column-gap: 5px;
}
& svg {
filter: none;
}
}

View File

@ -0,0 +1 @@
export { default } from "./ContactsList.astro";

View File

@ -0,0 +1,14 @@
.counter {
margin: 2rem 0;
display: flex;
flex-direction: column;
align-items: center;
row-gap: 15px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
padding: 15px 5px;
& div {
display: flex;
column-gap: 3px;
}
}

View File

@ -0,0 +1,45 @@
import type { Component } from "solid-js";
import { createSignal, createEffect } from "solid-js";
import Button from "../Button";
import "./Counter.scss";
const Counter: Component<{ tick?: number }> = (props) => {
const [isRunned, setIsRunned] = createSignal<boolean>(false);
const [count, setCount] = createSignal<number>(0);
const run = () => setIsRunned(true);
const stop = () => setIsRunned(false);
const inc = () => setCount(count() + 1);
const dec = () => setCount(count() - 1);
createEffect(() => {
setInterval(() => {
if (isRunned()) {
setCount(count() + 1);
}
}, props.tick || 200);
});
return (
<div class="counter">
<span>{count()}</span>
<div>
<Button disabled={isRunned()} onClick={run}>
Run
</Button>
<Button disabled={isRunned()} onClick={inc}>
+
</Button>
<Button disabled={isRunned()} onClick={dec}>
-
</Button>
<Button disabled={!isRunned()} onClick={stop}>
Stop
</Button>
</div>
</div>
);
};
export default Counter;

View File

@ -0,0 +1 @@
export { default } from "./Counter";

View File

@ -0,0 +1,24 @@
---
import './Footer.scss';
import { Icon } from 'astro-icon'
---
<footer class='footer'>
<div class='container'>
<ul class='footer__links'>
<li>
<a href='https://astro.build/' target='_blank'>Astro</a>
<Icon name='astro' width='20' height='20' />
</li>
<li>
<a href='https://www.netlify.com/' target='_blank'>Netlify</a>
<Icon name='netlify' width='20' height='20' />
</li>
<li>
<a href='/rss.xml' target='_blank'>RSS</a>
<Icon name='rss' width='20' height='20' />
</li>
</ul>
<p class='footer__copy'>© Sergey Yarkov, 2022</p>
</div>
</footer>

View File

@ -0,0 +1,34 @@
.footer {
& .container {
margin-top: 5rem;
padding: 25px 0 10px 0;
font-weight: 500;
border-top: 1px solid var(--border-color);
display: flex;
flex-direction: column;
align-items: center;
row-gap: 15px;
user-select: none;
}
&__links {
display: flex;
align-items: center;
column-gap: 25px;
list-style: none;
margin: 0;
padding: 0;
& li {
display: flex;
align-items: center;
column-gap: 7px;
& a {
color: var(--font-color);
font-size: var(--font-size-sm);
}
}
}
&__copy {
text-align: center;
font-size: var(--font-size-xs);
}
}

View File

@ -0,0 +1 @@
export { default } from "./Footer.astro";

View File

@ -0,0 +1,55 @@
---
import { Icon } from "astro-icon";
import { IS_SITE_UNDER_CONSTRUCTION, SITE_METADATA } from "@root/config";
import ThemeSwitcher from "@components/ThemeSwticher";
import UIString from "@components/UIString";
import TranslatedLink from "@components/TranslatedLink";
import LanguageSelect from "@components/LanguageSelect";
import NavToggle from "./NavToggle";
import "./Header.scss";
const { name, nav, contacts } = SITE_METADATA;
const { pathname } = Astro.url;
---
<header class={`header${IS_SITE_UNDER_CONSTRUCTION ? " header-banner" : ""}`}>
<div class="container">
<div class="header__logo">
<TranslatedLink to={"/"}>
{name}
</TranslatedLink>
</div>
<nav class="header__nav">
<ul class="header__nav-links">
{
nav.map((link) => (
<li>
<TranslatedLink class={pathname.includes(link.url) && "active"} to={link.url}>
<UIString key={link.key} />
</TranslatedLink>
</li>
))
}
</ul>
<div class="header__nav-contacts">
<a href={"mailto:" + contacts.email}>
Email
<Icon name="email" width="18" height="18" />
</a>
<a href={contacts.githubUrl}>
Github
<Icon name="github_filled" width="18" height="18" />
</a>
<a href={contacts.telegramUrl}>
Telegram
<Icon name="telegram" width="18" height="18" />
</a>
</div>
<div class="flex header__nav-settings">
<LanguageSelect />
<ThemeSwitcher client:visible />
</div>
</nav>
<NavToggle client:visible />
</div>
</header>

View File

@ -0,0 +1,144 @@
.header {
position: sticky;
top: 0;
border-bottom: 1px solid var(--border-color);
background-color: var(--background-header-color);
padding-top: 13.5px;
padding-bottom: 13.5px;
user-select: none;
z-index: 9999;
& .container {
display: flex;
align-items: center;
justify-content: space-between;
}
&__logo a {
text-decoration: none;
font-weight: 600;
font-size: var(--font-size-lg);
color: var(--font-color);
&:hover {
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 5px;
}
}
&__nav {
display: flex;
align-items: center;
& ul {
display: flex;
list-style: none;
column-gap: 20px;
margin: 0;
padding: 0;
margin-right: 35px;
& li a {
display: inline-block;
font-weight: 500;
color: var(--font-color);
text-decoration: none;
text-transform: lowercase;
&:hover,
&.active {
color: var(--color-blue-500);
text-decoration: underline;
text-decoration-thickness: 2px;
text-underline-offset: 4px;
}
}
}
&-contacts {
display: flex;
align-items: center;
column-gap: 10px;
margin-right: 30px;
& a {
text-decoration: none;
font-size: 0;
&:hover {
opacity: var(--opacity-hover);
}
}
}
&-settings {
column-gap: 13.5px;
}
}
}
@media screen and (max-width: 768px) {
.header {
padding: 10px 0;
&__nav {
display: none;
}
}
body[data-is-mobile-open="true"] {
overflow-y: hidden;
touch-action: none;
& .header {
padding: 15px 0;
background-color: var(--background-header-color);
position: fixed;
right: 0;
left: 0;
top: 0;
bottom: 0;
height: 100%;
z-index: 9999;
& .container {
flex-direction: column;
row-gap: 3rem;
}
&__logo {
align-self: flex-start;
}
&__nav {
display: block;
&-links {
display: block;
text-align: center;
margin: 0;
& li {
margin-bottom: 30px;
& a {
font-size: var(--font-size-xl);
}
}
}
&-contacts {
margin-right: 0;
margin-top: 2rem;
justify-content: center;
}
}
& .nav-toggle {
position: absolute;
right: var(--content-padding);
}
& .theme-switcher {
display: block;
position: absolute;
bottom: 45px;
right: 2.2rem;
& svg {
width: 36px;
height: 36px;
}
}
& .language-select {
position: absolute;
bottom: 115px;
right: 2.2rem;
& svg,
& select {
height: 36px;
width: 36px;
}
}
}
}
}

View File

@ -0,0 +1,15 @@
.nav-toggle {
cursor: pointer;
border: none;
padding: 0;
&:hover {
border: none;
opacity: var(--opacity-hover);
}
}
@media screen and (min-width: 769px) {
.nav-toggle {
display: none;
}
}

View File

@ -0,0 +1,41 @@
import type { Component } from "solid-js";
import { createSignal, createEffect } from "solid-js";
import "./NavToggle.scss";
const MenuIcon: Component = () => (
<svg fill="#000000" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="24px" height="24px">
<path d="M 0 7.5 L 0 12.5 L 50 12.5 L 50 7.5 Z M 0 22.5 L 0 27.5 L 50 27.5 L 50 22.5 Z M 0 37.5 L 0 42.5 L 50 42.5 L 50 37.5 Z" />
</svg>
);
const CloseIcon: Component = () => (
<svg fill="#000000" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="24px" height="24px">
<path d="M 9.15625 6.3125 L 6.3125 9.15625 L 22.15625 25 L 6.21875 40.96875 L 9.03125 43.78125 L 25 27.84375 L 40.9375 43.78125 L 43.78125 40.9375 L 27.84375 25 L 43.6875 9.15625 L 40.84375 6.3125 L 25 22.15625 Z" />
</svg>
);
const NavToggle: Component = () => {
const [isNavShown, setIsNavShown] = createSignal<boolean>(false);
const toggleNav = () => setIsNavShown(!isNavShown());
createEffect(() => {
const body = document.body;
if (isNavShown()) {
body.setAttribute("data-is-mobile-open", "true");
return;
}
body.removeAttribute("data-is-mobile-open");
});
return (
<button class="nav-toggle" onClick={toggleNav} type="button" aria-pressed={isNavShown()} aria-label="Menu">
{isNavShown() ? <CloseIcon /> : <MenuIcon />}
</button>
);
};
export default NavToggle;

View File

@ -0,0 +1 @@
export { default } from "./NavToggle";

View File

@ -0,0 +1 @@
export { default } from "./Header.astro";

View File

@ -0,0 +1,34 @@
<div class="banner-wrapper">
<span><slot /></span>
</div>
<style lang="scss" is:global>
.header.header-banner {
top: 45px;
}
.banner-wrapper {
padding: 12px 0;
min-height: 45px;
text-align: center;
background: var(--color-black);
color: var(--color-white);
position: sticky;
top: 0;
z-index: 99999;
}
@media screen and (max-width: 768px) {
body[data-is-mobile-open="true"] {
& .header.header-banner {
margin-top: 45px;
& .language-select {
bottom: 145px;
}
& .theme-switcher {
bottom: 80px;
}
}
}
}
</style>

View File

@ -0,0 +1 @@
export { default } from "./HeaderBanner.astro";

View File

@ -0,0 +1,65 @@
---
import { Icon } from "astro-icon";
import { DEFAULT_LANGUAGE } from "@root/config";
import languages from "@i18n/languages";
import { getLanguageFromURL } from "@root/utils";
const lang = getLanguageFromURL(Astro.url.pathname);
---
<div class="flex language-select">
<Icon name="translate" width="21" height="21" />
<select id="languageSelect" value={lang} aria-label="Select language">
{
Object.entries(languages).map(([code]) => (
<option value={code} selected={lang === code}>
<span>{code.toLocaleUpperCase()}</span>
</option>
))
}
</select>
</div>
<style lang="scss">
div {
position: relative;
&:hover {
opacity: var(--opacity-hover);
}
}
svg {
position: absolute;
right: 0;
}
select {
appearance: none;
width: 21px;
opacity: 0;
margin: 0;
border: none;
font-size: var(--font-size-md);
font-family: "IBM Plex Mono";
border-radius: var(--border-radius-md);
cursor: pointer;
}
</style>
<script define:vars={{ lang, DEFAULT_LANGUAGE }}>
const $select = document.getElementById("languageSelect");
const changeLanguageHandler = (e) => {
const newLang = e.target.value;
const pathname = window.location.pathname;
const url = newLang === DEFAULT_LANGUAGE ? pathname.slice(3) : `${newLang}${pathname}`;
/* Move user to articles page with new language */
if (window.location.pathname.includes("/blog/")) {
window.location.pathname = `${newLang === DEFAULT_LANGUAGE ? "" : newLang + "/"}` + "articles/";
return;
}
window.location.pathname = url;
};
$select.addEventListener("change", changeLanguageHandler);
</script>

View File

@ -0,0 +1 @@
export { default } from "./LanguageSelect.astro";

View File

@ -0,0 +1,17 @@
---
import "./PageHeading.scss";
export interface Props {
heading: string;
description?: string | undefined;
}
const { heading, description } = Astro.props as Props;
---
<div class="page-heading">
<h1 style={!description && "margin-bottom: 2.5rem;"}>
{heading}
</h1>
{description && <p>{description}</p>}
</div>

View File

@ -0,0 +1,14 @@
.page-heading {
& h1 {
font-size: var(--font-size-4xl);
text-align: center;
margin-bottom: 15px;
}
& p {
font-size: var(--font-size-lg);
text-align: center;
max-width: 650px;
margin: 0 auto;
margin-bottom: 2.5rem;
}
}

View File

@ -0,0 +1 @@
export { default } from "./PageHeading.astro";

View File

@ -0,0 +1,53 @@
---
import { DEFAULT_LANGUAGE } from "@root/config";
import { getLanguageFromURL } from "@root/utils";
import { Icon } from "astro-icon";
import UIString from "../UIString";
import "./ProjectItem.scss";
export interface Props {
data: ProjectType;
}
const { data } = Astro.props as Props;
const lang = getLanguageFromURL(Astro.url.pathname);
const { icon, name, year, description, articleUrl, sourceUrl, demoUrl } = data;
---
<div class="project-item">
<div class="project-item__heading">
<h2>
<Icon name={icon} width="20" height="20" />
{name}
</h2>
<span>{year}</span>
</div>
<div class="project-item__description">
<p>{description[lang] || description[DEFAULT_LANGUAGE]}</p>
</div>
<div class="project-item__links">
{
articleUrl && (
<a class="btn-link" href={articleUrl} target="_blank">
<UIString key={"btn.article"} />
</a>
)
}
{
sourceUrl && (
<a class="btn-link" href={sourceUrl} target="_blank">
<UIString key={"btn.sourceCode"} />
<Icon name="link-out" width="15" height="15" />
</a>
)
}
{
demoUrl && (
<a class="btn-link" href={demoUrl} target="_blank">
<UIString key={"btn.demo"} />
<Icon name="link-out" width="15" height="15" />
</a>
)
}
</div>
</div>

View File

@ -0,0 +1,35 @@
.project-item {
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
padding: 15px;
&__heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
& h2 {
display: flex;
align-items: center;
font-size: 1.2rem;
font-weight: 600;
column-gap: 10px;
margin: 0;
padding: 0;
}
& span {
font-size: var(--font-size-sm);
color: var(--font-color-gray);
}
}
&__links {
display: flex;
margin-top: auto;
column-gap: 5px;
}
}

View File

@ -0,0 +1 @@
export { default } from "./ProjectItem.astro";

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