delete all
@ -1,4 +0,0 @@
|
||||
IS_SITE_UNDER_CONSTRUCTION=false
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=12345
|
||||
@ -1,14 +0,0 @@
|
||||
{
|
||||
"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
@ -1,2 +0,0 @@
|
||||
*.css linguist-detectable=false
|
||||
*.scss linguist-detectable=false
|
||||
9
.github/dependabot.yml
vendored
@ -1,9 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "friday"
|
||||
target-branch: "develop"
|
||||
open-pull-requests-limit: 10
|
||||
20
.gitignore
vendored
@ -1,20 +0,0 @@
|
||||
# build output
|
||||
dist/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
# Local Netlify folder
|
||||
.netlify
|
||||
3
.gitmodules
vendored
@ -1,3 +0,0 @@
|
||||
[submodule "content"]
|
||||
path = content
|
||||
url = git@github.com:sergeyyarkov/content.git
|
||||
2
.npmrc
@ -1,2 +0,0 @@
|
||||
# Expose Astro dependencies for `pnpm` users
|
||||
shamefully-hoist=true
|
||||
@ -1,2 +0,0 @@
|
||||
# **/*.mdx
|
||||
# **/*.md
|
||||
@ -1,8 +0,0 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"useTabs": true
|
||||
}
|
||||
7
.vscode/settings.json
vendored
@ -1,7 +0,0 @@
|
||||
{
|
||||
"files.eol": "\n",
|
||||
"prettier.documentSelectors": ["**/*.astro"],
|
||||
"[astro]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
21
LICENSE
@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Sergey Yarkov
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
23
README.md
@ -1,23 +0,0 @@
|
||||
# yarkov.tech
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://app.netlify.com/sites/yarkov/deploys)
|
||||
|
||||
Personal website created with Astro.
|
||||
|
||||
<!-- ### Features
|
||||
|
||||
- [x] Adaptive & Responsive layout
|
||||
- [x] Search articles by title or description
|
||||
- [x] Dark/Light Theme
|
||||
- [x] Multilang web-site
|
||||
- [x] Prefetch pages via [Quicklink](https://getquick.link/)
|
||||
- [x] Form handling via [Netlify Forms](https://www.netlify.com/products/forms/)
|
||||
- [x] Comment system via [Disqus](https://disqus.com/)
|
||||
- [x] RSS Feed for articles
|
||||
- [x] SEO optimized
|
||||
- [x] Views counter -->
|
||||
|
||||
## License
|
||||
|
||||
This project is open source and available under the [MIT License](LICENSE).
|
||||
107
astro.config.ts
@ -1,107 +0,0 @@
|
||||
/**
|
||||
* Integrations
|
||||
*/
|
||||
import solid from "@astrojs/solid-js";
|
||||
import mdx from "@astrojs/mdx";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
import image from "@astrojs/image";
|
||||
|
||||
/**
|
||||
* Remark plugins
|
||||
*/
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkSmartypants from "remark-smartypants";
|
||||
|
||||
/**
|
||||
* Rehype plugins
|
||||
*/
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||
|
||||
/**
|
||||
* Other required modules
|
||||
*/
|
||||
import { defineConfig } from "astro/config";
|
||||
import { h } from "hastscript";
|
||||
import { toString } from "hast-util-to-string";
|
||||
import { escape } from "html-escaper";
|
||||
import { DEFAULT_LANGUAGE } from "./src/config";
|
||||
import languages from "./src/i18n/languages";
|
||||
|
||||
const createAnchorLabel = (heading: string) => {
|
||||
const node = h("span.anchor-label", escape(heading));
|
||||
if (node.properties) node.properties["is:raw"] = true;
|
||||
return node;
|
||||
};
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: "https://yarkov.tech/",
|
||||
build: {
|
||||
assets: "assets",
|
||||
},
|
||||
integrations: [
|
||||
solid(),
|
||||
sitemap({
|
||||
i18n: {
|
||||
defaultLocale: DEFAULT_LANGUAGE,
|
||||
locales: Object.fromEntries(Object.keys(languages).map((key) => [key, key])),
|
||||
},
|
||||
}),
|
||||
image({
|
||||
serviceEntryPoint: "@astrojs/image/sharp",
|
||||
}),
|
||||
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: "ignore",
|
||||
markdown: {
|
||||
syntaxHighlight: "prism",
|
||||
},
|
||||
vite: {
|
||||
ssr: {
|
||||
external: ["svgo"],
|
||||
},
|
||||
},
|
||||
});
|
||||
1
content
@ -1 +0,0 @@
|
||||
Subproject commit 756bcd1f73ec5b2281be40a1d5c2aa814b4273fc
|
||||
10
netlify.toml
@ -1,10 +0,0 @@
|
||||
[build]
|
||||
command = "npm run build"
|
||||
publish = "dist"
|
||||
functions = "netlify/functions"
|
||||
|
||||
[[redirects]]
|
||||
from = "/api/*" # simplify all calls to serverless functions
|
||||
to = "/.netlify/functions/:splat" # all function calls will go to this path
|
||||
status = 200 # ok code
|
||||
force = true # ensure to always redirect
|
||||
@ -1,35 +0,0 @@
|
||||
import { Handler } from "@netlify/functions";
|
||||
import { redisClient } from "../../redis";
|
||||
|
||||
const getViewsBySlug = async (slug: string): Promise<number> => {
|
||||
let views = 1;
|
||||
const data = await redisClient.get(`views:${slug}`);
|
||||
if (data) views = Number.parseInt(data, 10);
|
||||
if (Number.isNaN(views)) views = 1;
|
||||
return views;
|
||||
};
|
||||
|
||||
const handler: Handler = async (event) => {
|
||||
const slug = event.queryStringParameters?.slug;
|
||||
|
||||
switch (event.httpMethod) {
|
||||
case "GET": {
|
||||
if (!slug) return { statusCode: 400, body: "Bad request." };
|
||||
const views = await getViewsBySlug(slug);
|
||||
return { statusCode: 200, body: views.toString() };
|
||||
}
|
||||
case "POST": {
|
||||
if (!slug) return { statusCode: 400, body: "Bad request." };
|
||||
const views = await redisClient.incr(`views:${slug}`);
|
||||
return { statusCode: 200, body: views.toString() };
|
||||
}
|
||||
default: {
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: "Unrecognized HTTP Method, must be one of GET/POST",
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export { handler };
|
||||
16151
package-lock.json
generated
55
package.json
@ -1,55 +0,0 @@
|
||||
{
|
||||
"name": "yarkov.tech",
|
||||
"version": "1.2.7",
|
||||
"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 ./src/\"**/*.{js,jsx,ts,tsx,json,astro}\"",
|
||||
"lint": "eslint '**/*.{js,mjs,ts,tsx}'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/image": "^0.14.0",
|
||||
"@astrojs/mdx": "^0.16.0",
|
||||
"@astrojs/rss": "^2.1.0",
|
||||
"@astrojs/sitemap": "^1.0.1",
|
||||
"@astrojs/solid-js": "^2.0.2",
|
||||
"@types/html-escaper": "^3.0.0",
|
||||
"@types/mdx": "^2.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.50.0",
|
||||
"@typescript-eslint/parser": "^5.50.0",
|
||||
"astro": "^2.0.6",
|
||||
"astro-icon": "^0.8.0",
|
||||
"eslint": "^8.33.0",
|
||||
"eslint-plugin-solid": "^0.9.4",
|
||||
"hast-util-to-string": "^2.0.0",
|
||||
"hastscript": "^7.2.0",
|
||||
"html-escaper": "^3.0.3",
|
||||
"mime-types": "^2.1.35",
|
||||
"netlify-plugin-minify-html": "^0.3.1",
|
||||
"prettier": "^2.8.3",
|
||||
"prettier-plugin-astro": "^0.8.0",
|
||||
"rehype-autolink-headings": "^6.1.1",
|
||||
"rehype-slug": "^5.1.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-smartypants": "^2.0.0",
|
||||
"sass": "^1.58.0",
|
||||
"solid-js": "^1.6.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@netlify/functions": "^1.4.0",
|
||||
"ioredis": "^5.3.0",
|
||||
"quicklink": "^2.3.0",
|
||||
"sharp": "^0.31.3"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
@ -1,22 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/mstile-150x150.png"/>
|
||||
<TileColor>#da532c</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
|
Before Width: | Height: | Size: 617 B |
|
Before Width: | Height: | Size: 938 B |
|
Before Width: | Height: | Size: 635 B |
|
Before Width: | Height: | Size: 955 B |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
@ -1,4 +0,0 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://yarkov.tech/sitemap-index.xml
|
||||
@ -1,23 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@ -1,19 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 2.7 MiB |
9
redis.ts
@ -1,9 +0,0 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
const redisClient = new Redis({
|
||||
host: process.env.REDIS_HOST,
|
||||
port: Number.parseInt(process.env.REDIS_PORT || "6379", 10),
|
||||
password: process.env.REDIS_PASSWORD,
|
||||
});
|
||||
|
||||
export { redisClient };
|
||||
@ -1,13 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd content/
|
||||
git add .
|
||||
git commit -m "Update content."
|
||||
git push origin main
|
||||
cd ../
|
||||
git add content/
|
||||
git commit -m "Update content"
|
||||
git checkout main
|
||||
git merge develop
|
||||
git checkout develop
|
||||
git push origin --all
|
||||
@ -1,50 +0,0 @@
|
||||
---
|
||||
/**
|
||||
* This components is used in article layout to display links in alternative lanugages.
|
||||
*/
|
||||
import { DEFAULT_LANGUAGE } from "@root/config";
|
||||
import { getLanguageFromURL } from "@root/utils";
|
||||
|
||||
export interface Props extends astroHTML.JSX.AnchorHTMLAttributes {
|
||||
lang: LanguageKeys;
|
||||
|
||||
/**
|
||||
* Custom article slug for alternate language
|
||||
*/
|
||||
slug?: string;
|
||||
}
|
||||
|
||||
let { pathname } = Astro.url;
|
||||
const siteLang = getLanguageFromURL(pathname);
|
||||
const { lang, slug, ...attrs } = Astro.props as Props;
|
||||
|
||||
/**
|
||||
* Articles have different slug for different languages, so I have to modify it according to user's language.
|
||||
*/
|
||||
const mofifyArticleSlug = (slug) =>
|
||||
lang === DEFAULT_LANGUAGE
|
||||
? pathname.replace(pathname.split("/").at(4), slug)
|
||||
: pathname.replace(pathname.split("/").at(3), slug);
|
||||
|
||||
let href: string;
|
||||
|
||||
if (slug && pathname.includes("/blog/")) {
|
||||
pathname = mofifyArticleSlug(slug);
|
||||
}
|
||||
|
||||
/* Someday I'll refactor this */
|
||||
if (lang === DEFAULT_LANGUAGE) {
|
||||
href = `/${pathname.split("/").slice(2).join("/")}`;
|
||||
} else {
|
||||
href = `/${lang}/`;
|
||||
if (siteLang !== DEFAULT_LANGUAGE) {
|
||||
href += `${pathname.split("/").slice(2).join("/")}`;
|
||||
} else {
|
||||
href += `${pathname.split("/").slice(1).join("/")}`;
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<a href={href} {...attrs}>
|
||||
<slot />
|
||||
</a>
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./AlternateLangLink.astro";
|
||||
@ -1,43 +0,0 @@
|
||||
---
|
||||
interface Props {
|
||||
url: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
const { url, id } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<footer itemscope itemtype="http://schema.org/UserComments">
|
||||
<div id="disqus_thread"></div>
|
||||
</footer>
|
||||
|
||||
<script define:vars={{ url, id }}>
|
||||
var disqus_config = function () {
|
||||
this.page.url = url;
|
||||
this.page.identifier = id;
|
||||
};
|
||||
|
||||
var e = document.getElementById("disqus_thread");
|
||||
if ("IntersectionObserver" in window) {
|
||||
var t = new IntersectionObserver((e) => {
|
||||
e.forEach((e) => {
|
||||
e.isIntersecting &&
|
||||
((function () {
|
||||
var e = document,
|
||||
t = e.createElement("script");
|
||||
(t.src = "https://yarkov-tech.disqus.com/embed.js"),
|
||||
t.setAttribute("data-timestamp", +new Date()),
|
||||
(e.head || e.body).appendChild(t);
|
||||
})(),
|
||||
t.unobserve(e.target));
|
||||
});
|
||||
});
|
||||
t.observe(e);
|
||||
}
|
||||
|
||||
window.addEventListener(
|
||||
"onthemetoggled",
|
||||
() =>
|
||||
document.readyState == "complete" && setTimeout(() => DISQUS.reset({ reload: true, config: disqus_config }), 100)
|
||||
);
|
||||
</script>
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./ArticleComments.astro";
|
||||
@ -1,5 +0,0 @@
|
||||
---
|
||||
import "./ArticleContent.scss";
|
||||
---
|
||||
|
||||
<div itemprop="articleBody" class="article-content"><slot /></div>
|
||||
@ -1,221 +0,0 @@
|
||||
.article-content {
|
||||
& h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
& p,
|
||||
em,
|
||||
q {
|
||||
line-height: var(--line-height-base);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
& p {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
& ul,
|
||||
ol {
|
||||
padding-left: 1.5rem;
|
||||
line-height: var(--line-height-base);
|
||||
margin: 1.5rem 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;
|
||||
& figcaption {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
}
|
||||
|
||||
& 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 +0,0 @@
|
||||
export { default } from "./ArticleContent.astro";
|
||||
@ -1,43 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
/* eslint-disable solid/reactivity */
|
||||
import { 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 ? `/${props.data.lang}` : "";
|
||||
const href = `${hrefLangPrefix}/blog/${new Date(props.data.published_at).toISOString().split("T")[0]}/${
|
||||
props.data.slug
|
||||
}/`;
|
||||
|
||||
return (
|
||||
<article itemprop="blogPosts" itemscope itemtype="http://schema.org/BlogPosting" class="article-item">
|
||||
<a href={href}>
|
||||
<div class="flex">
|
||||
<h3 itemprop="headline">{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 +0,0 @@
|
||||
export * from "./ArticleItem";
|
||||
@ -1,92 +0,0 @@
|
||||
---
|
||||
import { Picture } from "@astrojs/image/components";
|
||||
import { Icon } from "astro-icon";
|
||||
import { createDateFormatter } from "@root/utils";
|
||||
import { isURL } from "@utils/string";
|
||||
import ArticleComments from "@components/ArticleComments";
|
||||
import TranslatedLink from "../TranslatedLink";
|
||||
import "./ArticlePost.scss";
|
||||
import { useTranslation } from "@root/i18n/utils";
|
||||
|
||||
export type Props = {
|
||||
data: ArticleType;
|
||||
lang: string;
|
||||
};
|
||||
|
||||
const { data, lang } = Astro.props as Props;
|
||||
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
|
||||
const { slug, title, description, coverImage, tags, author, published_at } = data;
|
||||
const t = useTranslation(Astro);
|
||||
---
|
||||
|
||||
<article itemscope itemtype="http://schema.org/BlogPosting" class="article">
|
||||
<header>
|
||||
<div>
|
||||
<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="views">
|
||||
<Icon name="eye" width="21" height="21" />
|
||||
<span><span id="views">---</span> {t("article.views")}</span>
|
||||
</div>
|
||||
</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 class="cover">
|
||||
<figure>
|
||||
<Picture
|
||||
itemprop="image"
|
||||
src={isURL(coverImage) ? coverImage : `../../../content/assets/images/${coverImage}`}
|
||||
sizes="(max-width: 450px) 450px, 700px"
|
||||
widths={[450, 700]}
|
||||
formats={["webp"]}
|
||||
aspectRatio={"16:9"}
|
||||
alt={title}
|
||||
background={"chocolate"}
|
||||
/>
|
||||
</figure>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="description">
|
||||
<p>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<slot />
|
||||
<ArticleComments url={canonicalUrl.href} id={data.slug} />
|
||||
</article>
|
||||
|
||||
<script define:vars={{ slug }}>
|
||||
(async () => {
|
||||
const response = await fetch(`/api/views?slug=${slug}`, { method: "POST" });
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const views = await response.text();
|
||||
const $views = document.getElementById("views");
|
||||
|
||||
if (isNaN(parseInt(views, 10))) return;
|
||||
if ($views) $views.textContent = views;
|
||||
})();
|
||||
</script>
|
||||
@ -1,92 +0,0 @@
|
||||
.article {
|
||||
& header {
|
||||
& div:nth-child(1) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
& .views {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& span {
|
||||
margin-left: 3.5px;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--font-color-grayer);
|
||||
}
|
||||
}
|
||||
}
|
||||
& .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);
|
||||
}
|
||||
}
|
||||
|
||||
& .cover {
|
||||
margin: 20px 0;
|
||||
& figure {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& .description p {
|
||||
line-height: var(--line-height-base);
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
& footer {
|
||||
margin-top: 5rem;
|
||||
}
|
||||
|
||||
& img {
|
||||
border-radius: var(--border-radius-lg);
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
|
||||
&:not([src*=".svg"]) {
|
||||
filter: var(--image-filter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 560px) {
|
||||
.article {
|
||||
& header {
|
||||
& div:nth-child(1) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
& .tags {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
& .heading h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./ArticlePost.astro";
|
||||
@ -1,21 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
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 itemscope itemtype="http://schema.org/Blog" 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 +0,0 @@
|
||||
export { default } from "./ArticlesList";
|
||||
@ -1,55 +0,0 @@
|
||||
---
|
||||
import { SITE_METADATA } from "@root/config";
|
||||
import { useTranslation } from "@i18n/utils";
|
||||
|
||||
export interface Props {
|
||||
title?: string | undefined;
|
||||
description?: string | undefined;
|
||||
canonicalUrl?: string | undefined;
|
||||
}
|
||||
|
||||
const t = useTranslation(Astro);
|
||||
const { title = SITE_METADATA.title, description, canonicalUrl } = 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'"
|
||||
/>
|
||||
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={title} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<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>
|
||||
<script src="../quicklink.js"></script>
|
||||
</head>
|
||||
@ -1,25 +0,0 @@
|
||||
---
|
||||
import { IS_SITE_UNDER_CONSTRUCTION } from "@root/config";
|
||||
import Header from "@components/Header";
|
||||
import Footer from "@components/Footer";
|
||||
import SiteContent from "@components/SiteContent";
|
||||
import HeaderBanner from "@components/HeaderBanner";
|
||||
import UIString from "@components/UIString";
|
||||
---
|
||||
|
||||
<body data-is-site-under-construction={IS_SITE_UNDER_CONSTRUCTION && "true"}>
|
||||
{
|
||||
IS_SITE_UNDER_CONSTRUCTION && (
|
||||
<HeaderBanner>
|
||||
🚧
|
||||
<UIString key={"site.underConstruction"} />
|
||||
🚧
|
||||
</HeaderBanner>
|
||||
)
|
||||
}
|
||||
<Header />
|
||||
<SiteContent>
|
||||
<slot />
|
||||
</SiteContent>
|
||||
<Footer />
|
||||
</body>
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./BaseLayout.astro";
|
||||
@ -1,27 +0,0 @@
|
||||
---
|
||||
export type Props = astroHTML.JSX.ButtonHTMLAttributes;
|
||||
|
||||
const attrs = Astro.props as Props;
|
||||
---
|
||||
|
||||
<button {...attrs}>
|
||||
<slot />
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
button {
|
||||
display: inline-flex;
|
||||
column-gap: 7px;
|
||||
font-family: var(--font-family-base);
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
&:hover {
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./Button.astro";
|
||||
@ -1,65 +0,0 @@
|
||||
---
|
||||
import Button from "@components/Button";
|
||||
import { useTranslation } from "@i18n/utils";
|
||||
|
||||
const t = useTranslation(Astro);
|
||||
---
|
||||
|
||||
<form name="contact" method="post" netlify-honeypot="bot-field" data-netlify="true">
|
||||
<div hidden>
|
||||
Don't fill this out if you're human
|
||||
<input name="bot-field" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div>
|
||||
<input name="name" placeholder={t("input.nameField")} type="text" maxlength="20" autocomplete="off" required />
|
||||
</div>
|
||||
<div>
|
||||
<input name="email" placeholder="example@email.com" autocomplete="off" type="email" required />
|
||||
</div>
|
||||
</div>
|
||||
<input name="subject" placeholder={t("input.subjectField")} type="text" autocomplete="off" required />
|
||||
<textarea
|
||||
name="message"
|
||||
placeholder={t("input.messageField")}
|
||||
id="message"
|
||||
autocomplete="off"
|
||||
cols={30}
|
||||
rows={10}
|
||||
required></textarea>
|
||||
<div class="footer">
|
||||
<Button type="submit">{t("btn.submit")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style lang="scss">
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
row-gap: 7px;
|
||||
margin-bottom: 3.125rem;
|
||||
|
||||
& .flex {
|
||||
column-gap: 10px;
|
||||
& div {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
& input.error-input,
|
||||
& textarea.error-input {
|
||||
border: 1px solid var(--color-error);
|
||||
}
|
||||
|
||||
& .footer {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 560px) {
|
||||
form .flex {
|
||||
flex-direction: column;
|
||||
row-gap: 7px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,46 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./ContactForm.astro";
|
||||
@ -1,24 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@ -1,15 +0,0 @@
|
||||
.contacts-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: 40px;
|
||||
row-gap: 30px;
|
||||
|
||||
& a {
|
||||
display: flex;
|
||||
column-gap: 5px;
|
||||
}
|
||||
|
||||
& svg {
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./ContactsList.astro";
|
||||
@ -1,59 +0,0 @@
|
||||
import type { Component } from "solid-js";
|
||||
|
||||
import { createSignal, createEffect } from "solid-js";
|
||||
|
||||
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
|
||||
style={{
|
||||
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",
|
||||
}}
|
||||
>
|
||||
<span>{count()}</span>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"column-gap": "3px",
|
||||
}}
|
||||
>
|
||||
<button class="btn" disabled={isRunned()} onClick={run}>
|
||||
Run
|
||||
</button>
|
||||
<button class="btn" disabled={isRunned()} onClick={inc}>
|
||||
+
|
||||
</button>
|
||||
<button class="btn" disabled={isRunned()} onClick={dec}>
|
||||
-
|
||||
</button>
|
||||
<button class="btn" disabled={!isRunned()} onClick={stop}>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Counter;
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./Counter";
|
||||
@ -1,58 +0,0 @@
|
||||
---
|
||||
import { Icon } from "astro-icon";
|
||||
---
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<ul>
|
||||
<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>
|
||||
<small>© Sergey Yarkov, 2022</small>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
footer {
|
||||
user-select: none;
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
margin-top: 5rem;
|
||||
& .container {
|
||||
padding: 25px 0 10px 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
row-gap: 15px;
|
||||
}
|
||||
& ul {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 25px;
|
||||
& li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 7px;
|
||||
& a {
|
||||
color: var(--font-color);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
}
|
||||
}
|
||||
& small {
|
||||
text-align: center;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./Footer.astro";
|
||||
@ -1,179 +0,0 @@
|
||||
---
|
||||
import { Icon } from "astro-icon";
|
||||
import { IS_SITE_UNDER_CONSTRUCTION, SITE_METADATA } from "@root/config";
|
||||
import { removeSlashes } from "@utils/string";
|
||||
import ThemeSwitcher from "@components/ThemeSwticher";
|
||||
import UIString from "@components/UIString";
|
||||
import TranslatedLink from "@components/TranslatedLink";
|
||||
import LanguageSelect from "@components/LanguageSelect";
|
||||
import NavToggle from "./NavToggle";
|
||||
|
||||
const { name, nav, contacts } = SITE_METADATA;
|
||||
const { pathname } = Astro.url;
|
||||
---
|
||||
|
||||
<header {...(IS_SITE_UNDER_CONSTRUCTION ? { style: 'top: 45px;' } : {})}>
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<TranslatedLink to={"/"}>
|
||||
{name}
|
||||
</TranslatedLink>
|
||||
</div>
|
||||
<nav itemscope itemtype="http://schema.org/SiteNavigationElement">
|
||||
<ul>
|
||||
{
|
||||
nav.map((link) => (
|
||||
<li>
|
||||
<TranslatedLink
|
||||
itemprop="url"
|
||||
class={pathname.includes(removeSlashes(link.url)) && pathname.split("/").length < 5 && "active"}
|
||||
to={link.url}
|
||||
>
|
||||
<UIString key={link.key} />
|
||||
</TranslatedLink>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
<div class="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 settings">
|
||||
<LanguageSelect />
|
||||
<ThemeSwitcher client:visible />
|
||||
</div>
|
||||
</nav>
|
||||
<NavToggle client:visible />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
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);
|
||||
}
|
||||
|
||||
& a:hover,
|
||||
a.active {
|
||||
color: var(--color-blue-500);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
& 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;
|
||||
}
|
||||
}
|
||||
& .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;
|
||||
& ul {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,22 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
body[data-is-mobile-open="true"] {
|
||||
.nav-toggle {
|
||||
position: absolute;
|
||||
right: var(--content-padding);
|
||||
}
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
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 +0,0 @@
|
||||
export { default } from "./NavToggle";
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./Header.astro";
|
||||
@ -1,16 +0,0 @@
|
||||
<div>
|
||||
<span><slot /></span>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
padding: 12px 0;
|
||||
min-height: 45px;
|
||||
text-align: center;
|
||||
background: var(--color-black);
|
||||
color: var(--color-white);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 99999;
|
||||
}
|
||||
</style>
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./HeaderBanner.astro";
|
||||
@ -1,94 +0,0 @@
|
||||
---
|
||||
import { Icon } from "astro-icon";
|
||||
import { DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES } 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">
|
||||
{
|
||||
SUPPORTED_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;
|
||||
}
|
||||
|
||||
body[data-is-mobile-open="true"] {
|
||||
.language-select {
|
||||
position: absolute;
|
||||
bottom: 115px;
|
||||
right: 2.2rem;
|
||||
& svg,
|
||||
& select {
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
body[data-is-site-under-construction] {
|
||||
.language-select {
|
||||
bottom: 145px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</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;
|
||||
let url;
|
||||
|
||||
if (lang !== DEFAULT_LANGUAGE) {
|
||||
url = pathname.split("/").slice(2).join("/");
|
||||
} else {
|
||||
url = pathname.slice(1);
|
||||
}
|
||||
|
||||
if (newLang !== DEFAULT_LANGUAGE) url = `${newLang}/${url}`;
|
||||
|
||||
/* 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 +0,0 @@
|
||||
export { default } from "./LanguageSelect.astro";
|
||||
@ -1,32 +0,0 @@
|
||||
---
|
||||
export interface Props {
|
||||
heading: string;
|
||||
description?: string | undefined;
|
||||
}
|
||||
|
||||
const { heading, description } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<div class="page-heading">
|
||||
<h1>
|
||||
{heading}
|
||||
</h1>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
margin-bottom: 2.5rem;
|
||||
& 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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./PageHeading.astro";
|
||||
@ -1,53 +0,0 @@
|
||||
---
|
||||
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>
|
||||
@ -1,35 +0,0 @@
|
||||
.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 +0,0 @@
|
||||
export { default } from "./ProjectItem.astro";
|
||||
@ -1,7 +0,0 @@
|
||||
---
|
||||
import "./ProjectsList.scss";
|
||||
---
|
||||
|
||||
<div class="projects-list">
|
||||
<slot />
|
||||
</div>
|
||||
@ -1,5 +0,0 @@
|
||||
.projects-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./ProjectsList.astro";
|
||||
@ -1,18 +0,0 @@
|
||||
---
|
||||
import UIString from "@components/UIString";
|
||||
---
|
||||
|
||||
<div>
|
||||
<p><UIString key={"recentArticles.empty"} /></p>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
text-align: center;
|
||||
margin: 2.5rem 0;
|
||||
& p {
|
||||
color: var(--font-color-gray);
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,56 +0,0 @@
|
||||
---
|
||||
import { ArticleItem } from "@components/ArticleItem";
|
||||
import UIString from "@components/UIString";
|
||||
import { getLanguageFromURL } from "@utils/url";
|
||||
import { sortArtcilesByDate } from "@utils/articles";
|
||||
import EmptyList from "./EmptyList.astro";
|
||||
import TranslatedLink from "@components/TranslatedLink";
|
||||
|
||||
export interface Props {
|
||||
articles: ArticleType[];
|
||||
length?: number | undefined;
|
||||
}
|
||||
|
||||
const { articles, length = 5 } = Astro.props as Props;
|
||||
|
||||
const lang = getLanguageFromURL(Astro.url.pathname);
|
||||
const recent = sortArtcilesByDate(articles);
|
||||
const isEmpty = recent.length === 0;
|
||||
---
|
||||
|
||||
<section>
|
||||
<div>
|
||||
<h2><UIString key={"recentArticles.heading"} /></h2>
|
||||
<TranslatedLink to="/articles/">
|
||||
<UIString key={"recentArticles.all"} />
|
||||
</TranslatedLink>
|
||||
</div>
|
||||
|
||||
<div itemscope itemtype="http://schema.org/Blog">
|
||||
{!isEmpty ? recent.slice(0, length).map((article) => <ArticleItem lang={lang} data={article} />) : <EmptyList />}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
& > div:nth-child(1) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 15px;
|
||||
row-gap: 5px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 10px;
|
||||
|
||||
& h2 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
& > div:nth-child(2) {
|
||||
margin-top: 10px;
|
||||
max-width: calc(var(--content-width) + (var(--content-padding) * 2));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./RecentArticles.astro";
|
||||
@ -1,27 +0,0 @@
|
||||
.search-input {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 15px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
& svg {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
fill: var(--font-color-grayer);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
& input {
|
||||
padding-left: 40px;
|
||||
color: var(--font-color-gray);
|
||||
background-color: var(--background-color-hover);
|
||||
}
|
||||
& span {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 500;
|
||||
color: var(--font-color-gray);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
@ -1,73 +0,0 @@
|
||||
import type { Component, JSX } from "solid-js";
|
||||
|
||||
import { createEffect } from "solid-js";
|
||||
import { search, setSearch } from "@stores/searchStore";
|
||||
import * as utils from "@utils/history";
|
||||
import "./SearchInput.scss";
|
||||
|
||||
type SearchInputProps = {
|
||||
count: number;
|
||||
placeholder?: string | undefined;
|
||||
};
|
||||
|
||||
const SearchInput: Component<SearchInputProps> = (props) => {
|
||||
/**
|
||||
* Reset state and delete `search` param from url
|
||||
*/
|
||||
const resetSearch = () => {
|
||||
const url = utils.deleteParamFromUrl("search");
|
||||
|
||||
setSearch("");
|
||||
history.replaceState(null, "", url);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update state and set url param
|
||||
*/
|
||||
const onSearch: JSX.EventHandler<HTMLInputElement, InputEvent> = (e) => {
|
||||
const value = e.currentTarget.value;
|
||||
|
||||
if (value.trim().length === 0) {
|
||||
resetSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = utils.setParamToUrl("search", value.trim());
|
||||
|
||||
history.replaceState(null, "", `${location.pathname}?${url}`);
|
||||
setSearch(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the signal state by reading the `search` parameter from the URL.
|
||||
*/
|
||||
createEffect(() => {
|
||||
const param = new URLSearchParams(location.search).get("search");
|
||||
|
||||
if (!param) {
|
||||
resetSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
setSearch(param);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="search-input">
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" xmlns="http://www.w3.org/2000/svg" data-filter="none">
|
||||
<path d="M10.6026 3.60001C11.8798 3.59993 13.1316 3.94283 14.2172 4.5901C15.3028 5.23736 16.179 6.16331 16.7472 7.26368C17.3154 8.36405 17.5531 9.59517 17.4334 10.8184C17.3137 12.0417 16.8415 13.2085 16.0699 14.1876L21.2427 19.164C21.4085 19.3248 21.5053 19.5399 21.5135 19.7662C21.5218 19.9926 21.4411 20.2137 21.2874 20.3853C21.1337 20.5569 20.9184 20.6665 20.6845 20.6921C20.4505 20.7178 20.2151 20.6576 20.0253 20.5236L19.9205 20.436L14.7476 15.4596C13.8802 16.0922 12.8687 16.5172 11.7968 16.6994C10.7248 16.8815 9.62324 16.8157 8.58298 16.5074C7.54272 16.199 6.59366 15.6569 5.81422 14.9259C5.03478 14.1949 4.44733 13.296 4.10042 12.3034C3.7535 11.3108 3.65708 10.2531 3.81911 9.21757C3.98114 8.18204 4.39698 7.19847 5.03226 6.34812C5.66755 5.49778 6.50404 4.80506 7.47263 4.32721C8.44122 3.84936 9.51411 3.60009 10.6026 3.60001V3.60001ZM10.6026 5.40001C9.27934 5.40001 8.01025 5.90572 7.07454 6.80589C6.13884 7.70607 5.61316 8.92697 5.61316 10.2C5.61316 11.473 6.13884 12.6939 7.07454 13.5941C8.01025 14.4943 9.27934 15 10.6026 15C11.9259 15 13.195 14.4943 14.1307 13.5941C15.0664 12.6939 15.5921 11.473 15.5921 10.2C15.5921 8.92697 15.0664 7.70607 14.1307 6.80589C13.195 5.90572 11.9259 5.40001 10.6026 5.40001Z" />
|
||||
</svg>
|
||||
<input
|
||||
onInput={onSearch}
|
||||
value={search()}
|
||||
type="search"
|
||||
placeholder={props.placeholder || "Search article by title or description..."}
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span>{props.count}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchInput;
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./SearchInput";
|
||||
@ -1,17 +0,0 @@
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
flex: 1 0 auto;
|
||||
width: 100%;
|
||||
margin-top: 5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
main {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./SiteContent.astro";
|
||||
@ -1,33 +0,0 @@
|
||||
---
|
||||
import { DEFAULT_LANGUAGE } from "@root/config";
|
||||
import { getLanguageFromURL } from "@root/utils";
|
||||
import { Icon } from "astro-icon";
|
||||
import "./SkillsBlock.scss";
|
||||
|
||||
export interface Props {
|
||||
data: SkillsType;
|
||||
}
|
||||
|
||||
const lang = getLanguageFromURL(Astro.url.pathname);
|
||||
const {
|
||||
data: { title, list },
|
||||
} = Astro.props as Props;
|
||||
---
|
||||
|
||||
<div class="skills-block">
|
||||
<div>
|
||||
<h3>{title[lang] || title[DEFAULT_LANGUAGE]}</h3>
|
||||
</div>
|
||||
<div>
|
||||
<ul>
|
||||
{
|
||||
list.map((entry) => (
|
||||
<li>
|
||||
{entry.icon && <Icon name={entry.icon} width="20" height="20" />}
|
||||
{entry.name}
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,36 +0,0 @@
|
||||
.skills-block {
|
||||
& h3 {
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--font-color-grayer);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
& ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
& li {
|
||||
font-weight: 400;
|
||||
margin-bottom: 10px;
|
||||
font-size: var(--font-size-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.skills-block {
|
||||
& h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
& ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./SkillsBlock.astro";
|
||||
@ -1,5 +0,0 @@
|
||||
---
|
||||
import "./SkillsList.scss";
|
||||
---
|
||||
|
||||
<div class="skills-list"><slot /></div>
|
||||
@ -1,14 +0,0 @@
|
||||
.skills-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
column-gap: 40px;
|
||||
row-gap: 30px;
|
||||
margin-top: 5rem;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
.skills-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export { default } from "./SkillsList.astro";
|
||||