Merge branch 'release/0.0.1'
14
.eslintrc.json
Normal 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
@ -0,0 +1,2 @@
|
|||||||
|
*.css linguist-detectable=false
|
||||||
|
*.scss linguist-detectable=false
|
||||||
3
.gitignore
vendored
@ -10,6 +10,9 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
|
||||||
# environment variables
|
# environment variables
|
||||||
.env
|
.env
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[submodule "content"]
|
||||||
|
path = content
|
||||||
|
url = https://github.com/sergeyyarkov/content
|
||||||
8
.prettierrc.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 150,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"useTabs": true
|
||||||
|
}
|
||||||
4
.vscode/extensions.json
vendored
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["astro-build.astro-vscode"],
|
|
||||||
"unwantedRecommendations": []
|
|
||||||
}
|
|
||||||
11
.vscode/launch.json
vendored
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"command": "./node_modules/.bin/astro dev",
|
|
||||||
"name": "Development server",
|
|
||||||
"request": "launch",
|
|
||||||
"type": "node-terminal"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
15
README.md
@ -1,7 +1,22 @@
|
|||||||
# yarkov.tech
|
# yarkov.tech
|
||||||
|
|
||||||
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://app.netlify.com/sites/jade-froyo-876dfe/deploys)
|
||||||
|
|
||||||
Personal website created with Astro.
|
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
|
## License
|
||||||
|
|
||||||
This project is open source and available under the [MIT License](LICENSE).
|
This project is open source and available under the [MIT License](LICENSE).
|
||||||
|
|||||||
102
astro.config.mjs
@ -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
|
// 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
|
||||||
7293
package-lock.json
generated
33
package.json
@ -8,17 +8,40 @@
|
|||||||
"email": "serzh.yarkov@gmail.com",
|
"email": "serzh.yarkov@gmail.com",
|
||||||
"url": "https://yarkov.tech"
|
"url": "https://yarkov.tech"
|
||||||
},
|
},
|
||||||
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev --host",
|
||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview"
|
"preview": "astro preview --host",
|
||||||
|
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json}\"",
|
||||||
|
"lint": "eslint '**/*.{js,mjs,ts,tsx}'"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"astro": "^1.0.0-beta.63",
|
"@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": "^2.7.1",
|
||||||
"prettier-plugin-astro": "^0.1.1",
|
"prettier-plugin-astro": "^0.5.0",
|
||||||
"sass": "^1.53.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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/android-chrome-256x256.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
1
public/assets/css/code-highlight-dark.min.css
vendored
Normal file
1
public/assets/css/code-highlight-light.min.css
vendored
Normal 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 |
@ -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 |
@ -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 |
BIN
public/assets/images/image1.webp
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
public/assets/images/image2.webp
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
public/assets/images/image3.webp
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
public/assets/images/image4.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
public/assets/images/image5.webp
Normal file
|
After Width: | Height: | Size: 31 KiB |
22
public/assets/js/detect-theme.js
Normal 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);
|
||||||
|
});
|
||||||
48
public/assets/js/load-code-highlight.js
Normal 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
@ -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>
|
||||||
BIN
public/favicon-dark-16x16.png
Normal file
|
After Width: | Height: | Size: 617 B |
BIN
public/favicon-dark-32x32.png
Normal file
|
After Width: | Height: | Size: 938 B |
BIN
public/favicon-light-16x16.png
Normal file
|
After Width: | Height: | Size: 635 B |
BIN
public/favicon-light-32x32.png
Normal file
|
After Width: | Height: | Size: 955 B |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 15 KiB |
BIN
public/mstile-150x150.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
6
public/robots.txt
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Disallow all at the time of development
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Disallow: /
|
||||||
|
|
||||||
|
Sitemap: https://yarkov.tech/sitemap-index.xml
|
||||||
23
public/safari-pinned-tab.svg
Normal 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
@ -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"
|
||||||
|
}
|
||||||
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"sitename": "yarkov.tech",
|
|
||||||
"author": {
|
|
||||||
"name": "Sergey Yarkov"
|
|
||||||
},
|
|
||||||
"contacts": {
|
|
||||||
"email": "serzh.yarkov@gmail.com",
|
|
||||||
"github": "sergeyyarkov",
|
|
||||||
"telegram": "sergeyyarkov"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
29
src/components/ArticleComments/ArticleComments.astro
Normal 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>
|
||||||
1
src/components/ArticleComments/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ArticleComments.astro";
|
||||||
5
src/components/ArticleContent/ArticleContent.astro
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import "./ArticleContent.scss";
|
||||||
|
---
|
||||||
|
|
||||||
|
<div itemprop="articleBody" class="article-content"><slot /></div>
|
||||||
214
src/components/ArticleContent/ArticleContent.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ArticleContent/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ArticleContent.astro";
|
||||||
43
src/components/ArticleItem/ArticleItem.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/components/ArticleItem/ArticleItem.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/components/ArticleItem/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./ArticleItem";
|
||||||
56
src/components/ArticlePost/ArticlePost.astro
Normal 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>
|
||||||
57
src/components/ArticlePost/ArticlePost.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ArticlePost/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ArticlePost.astro";
|
||||||
21
src/components/ArticlesList/ArticlesList.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/components/ArticlesList/ArticlesList.tsx
Normal 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;
|
||||||
1
src/components/ArticlesList/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ArticlesList";
|
||||||
38
src/components/BaseHead.astro
Normal 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>
|
||||||
25
src/components/BaseLayout/BaseLayout.astro
Normal 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>
|
||||||
|
🚧
|
||||||
|
<UIString key={"site.underConstruction"} />
|
||||||
|
🚧
|
||||||
|
</HeaderBanner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<Header />
|
||||||
|
<main class="site-content">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
</body>
|
||||||
11
src/components/BaseLayout/BaseLayout.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/BaseLayout/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./BaseLayout.astro";
|
||||||
28
src/components/Button/Button.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/components/Button/Button.tsx
Normal 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;
|
||||||
1
src/components/Button/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./Button";
|
||||||
46
src/components/ContactForm/ContactForm.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/components/ContactForm/ContactForm.tsx
Normal 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;
|
||||||
7
src/components/ContactForm/Error.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
const Submitted: Component<{ text: string }> = (props) => {
|
||||||
|
return <div class="form-result">❌ {props.text}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Submitted;
|
||||||
7
src/components/ContactForm/Submitted.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
const Submitted: Component<{ text: string }> = (props) => {
|
||||||
|
return <div class="form-result">✔️ {props.text}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Submitted;
|
||||||
1
src/components/ContactForm/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ContactForm";
|
||||||
7
src/components/ContactForm/validations.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { isEmail } from "@utils/string";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
validateEmail: (error: string) => {
|
||||||
|
return (field: HTMLInputElement) => (isEmail(field.value) ? false : error);
|
||||||
|
},
|
||||||
|
};
|
||||||
24
src/components/ContactsList/ContactsList.astro
Normal 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>
|
||||||
15
src/components/ContactsList/ContactsList.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ContactsList/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ContactsList.astro";
|
||||||
14
src/components/Counter/Counter.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/components/Counter/Counter.tsx
Normal 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;
|
||||||
1
src/components/Counter/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./Counter";
|
||||||
24
src/components/Footer/Footer.astro
Normal 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>
|
||||||
34
src/components/Footer/Footer.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/Footer/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./Footer.astro";
|
||||||
55
src/components/Header/Header.astro
Normal 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>
|
||||||
144
src/components/Header/Header.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/components/Header/NavToggle/NavToggle.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/components/Header/NavToggle/NavToggle.tsx
Normal 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;
|
||||||
1
src/components/Header/NavToggle/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./NavToggle";
|
||||||
1
src/components/Header/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./Header.astro";
|
||||||
34
src/components/HeaderBanner/HeaderBanner.astro
Normal 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>
|
||||||
1
src/components/HeaderBanner/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./HeaderBanner.astro";
|
||||||
65
src/components/LanguageSelect/LanguageSelect.astro
Normal 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>
|
||||||
1
src/components/LanguageSelect/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./LanguageSelect.astro";
|
||||||
17
src/components/PageHeading/PageHeading.astro
Normal 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>
|
||||||
14
src/components/PageHeading/PageHeading.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/PageHeading/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./PageHeading.astro";
|
||||||
53
src/components/ProjectItem/ProjectItem.astro
Normal 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>
|
||||||
35
src/components/ProjectItem/ProjectItem.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ProjectItem/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ProjectItem.astro";
|
||||||