@ -1,4 +1,3 @@
|
|||||||
IS_SITE_UNDER_CONSTRUCTION=false
|
REDIS_HOST=redis-1234.p124.eu-central-5-5.ec2.cloud.redislabs.com
|
||||||
REDIS_HOST=localhost
|
REDIS_PASSWORD=superpassword
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=1234
|
||||||
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
|
|
||||||
4
.gitignore
vendored
@ -1,5 +1,7 @@
|
|||||||
# build output
|
# build output
|
||||||
dist/
|
dist/
|
||||||
|
# generated types
|
||||||
|
.astro/
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
@ -10,11 +12,13 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
# environment variables
|
# environment variables
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
|
|
||||||
# macOS-specific files
|
# macOS-specific files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Local Netlify folder
|
# Local Netlify folder
|
||||||
.netlify
|
.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,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"printWidth": 120,
|
"printWidth": 100,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
4
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
||||||
11
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
.vscode/settings.json
vendored
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files.eol": "\n",
|
|
||||||
"prettier.documentSelectors": ["**/*.astro"],
|
|
||||||
"[astro]": {
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -11,6 +11,7 @@ Personal website created with Astro.
|
|||||||
- [x] Search articles by title or description
|
- [x] Search articles by title or description
|
||||||
- [x] Dark/Light Theme
|
- [x] Dark/Light Theme
|
||||||
- [x] Multilang web-site
|
- [x] Multilang web-site
|
||||||
|
- [x] Content management via [NetlifyCMS](https://www.netlifycms.org/)
|
||||||
- [x] Prefetch pages via [Quicklink](https://getquick.link/)
|
- [x] Prefetch pages via [Quicklink](https://getquick.link/)
|
||||||
- [x] Form handling via [Netlify Forms](https://www.netlify.com/products/forms/)
|
- [x] Form handling via [Netlify Forms](https://www.netlify.com/products/forms/)
|
||||||
- [x] Comment system via [Disqus](https://disqus.com/)
|
- [x] Comment system via [Disqus](https://disqus.com/)
|
||||||
|
|||||||
444
astro.config.mjs
Normal file
@ -0,0 +1,444 @@
|
|||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integrations
|
||||||
|
*/
|
||||||
|
import sitemap from "@astrojs/sitemap";
|
||||||
|
import solidJs from "@astrojs/solid-js";
|
||||||
|
import image from "@astrojs/image";
|
||||||
|
import netlifyCMS from "astro-netlify-cms";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rehype plugins
|
||||||
|
*/
|
||||||
|
import rehypeSlug from "rehype-slug";
|
||||||
|
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other required modules
|
||||||
|
*/
|
||||||
|
import { h } from "hastscript";
|
||||||
|
import { toString } from "hast-util-to-string";
|
||||||
|
import { escape } from "html-escaper";
|
||||||
|
import languages from "./src/i18n/languages";
|
||||||
|
import { DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES } from "./src/constants";
|
||||||
|
|
||||||
|
const createAnchorLabel = (heading) => {
|
||||||
|
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",
|
||||||
|
integrations: [
|
||||||
|
sitemap({
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: DEFAULT_LANGUAGE,
|
||||||
|
locales: Object.fromEntries(Object.keys(languages).map((key) => [key, key])),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
solidJs(),
|
||||||
|
netlifyCMS({
|
||||||
|
adminPath: "/cms",
|
||||||
|
config: {
|
||||||
|
editor: {
|
||||||
|
preview: false,
|
||||||
|
},
|
||||||
|
display_url: "https://yarkov.tech",
|
||||||
|
i18n: {
|
||||||
|
locales: SUPPORTED_LANGUAGES,
|
||||||
|
default_locale: DEFAULT_LANGUAGE,
|
||||||
|
structure: "multiple_folders",
|
||||||
|
},
|
||||||
|
locale: DEFAULT_LANGUAGE,
|
||||||
|
backend: {
|
||||||
|
name: "github",
|
||||||
|
repo: "sergeyyarkov/yarkov.tech",
|
||||||
|
branch: "version-2.0.0",
|
||||||
|
},
|
||||||
|
media_folder: "public/media",
|
||||||
|
public_folder: "/media",
|
||||||
|
collections: [
|
||||||
|
/**
|
||||||
|
* Articles
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
name: "articles",
|
||||||
|
label: "Публикации",
|
||||||
|
label_singular: "публикацию",
|
||||||
|
description:
|
||||||
|
"Технические статьи, туториалы и различные справочные материалы которые связаны с IT тематикой",
|
||||||
|
folder: "src/content/blog",
|
||||||
|
slug: "{{slug}}",
|
||||||
|
create: true,
|
||||||
|
delete: true,
|
||||||
|
i18n: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "coverImage",
|
||||||
|
widget: "image",
|
||||||
|
label: "Постер",
|
||||||
|
required: false,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "draft",
|
||||||
|
label: "В черновике",
|
||||||
|
widget: "boolean",
|
||||||
|
hint: "Опубликована ли статья",
|
||||||
|
i18n: true,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "author",
|
||||||
|
widget: "relation",
|
||||||
|
value_field: "{{firstName}} {{lastName}}",
|
||||||
|
search_fields: ["firstName", "lastName"],
|
||||||
|
collection: "authors",
|
||||||
|
label: "Автор",
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
widget: "string",
|
||||||
|
label: "Заголовок",
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
widget: "string",
|
||||||
|
label: "Описание",
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tags",
|
||||||
|
widget: "relation",
|
||||||
|
label: "Тэги",
|
||||||
|
collection: "tags",
|
||||||
|
multiple: true,
|
||||||
|
value_field: "title",
|
||||||
|
search_fields: ["title"],
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "pubDate",
|
||||||
|
widget: "datetime",
|
||||||
|
label: "Дата публикации",
|
||||||
|
picker_utc: true,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "updatedDate",
|
||||||
|
widget: "datetime",
|
||||||
|
label: "Дата обновления",
|
||||||
|
picker_utc: true,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "body",
|
||||||
|
widget: "markdown",
|
||||||
|
label: "Содержание",
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Tags
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
name: "tags",
|
||||||
|
label: "Тэги",
|
||||||
|
label_singular: "тэг",
|
||||||
|
description: "Используются для тегирования контента",
|
||||||
|
folder: "src/content/tags",
|
||||||
|
create: true,
|
||||||
|
delete: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
widget: "string",
|
||||||
|
label: "Название",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Authors
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
name: "authors",
|
||||||
|
label: "Авторы",
|
||||||
|
label_singular: "автора",
|
||||||
|
description: "На странице статьи отображается имя автора",
|
||||||
|
slug: "{{firstName}}-{{lastName}}",
|
||||||
|
folder: "src/content/authors",
|
||||||
|
create: true,
|
||||||
|
delete: true,
|
||||||
|
i18n: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "Имя",
|
||||||
|
name: "firstName",
|
||||||
|
widget: "string",
|
||||||
|
required: true,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Фамилия",
|
||||||
|
name: "lastName",
|
||||||
|
widget: "string",
|
||||||
|
required: true,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Об авторе",
|
||||||
|
name: "body",
|
||||||
|
widget: "markdown",
|
||||||
|
required: false,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Projects
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
name: "projects",
|
||||||
|
label: "Проекты",
|
||||||
|
label_singular: "проект",
|
||||||
|
description: "Основные проекты, которые были разработаны в выложены в открытый доступ",
|
||||||
|
folder: "src/content/projects",
|
||||||
|
create: true,
|
||||||
|
delete: true,
|
||||||
|
i18n: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "icon",
|
||||||
|
label: "Иконка",
|
||||||
|
widget: "string",
|
||||||
|
required: true,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
label: "Название",
|
||||||
|
widget: "string",
|
||||||
|
required: true,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "year",
|
||||||
|
label: "В каком году разработал",
|
||||||
|
widget: "number",
|
||||||
|
required: true,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "description",
|
||||||
|
label: "Описание",
|
||||||
|
widget: "string",
|
||||||
|
required: true,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sourceUrl",
|
||||||
|
label: "Ссылка на исходник",
|
||||||
|
widget: "string",
|
||||||
|
required: true,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "demoUrl",
|
||||||
|
label: "Ссылка на демонстрацию",
|
||||||
|
widget: "string",
|
||||||
|
required: false,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "articleUrl",
|
||||||
|
label: "Ссылка на статью",
|
||||||
|
widget: "string",
|
||||||
|
required: false,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Skills
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
name: "skills",
|
||||||
|
label: "Навыки",
|
||||||
|
label_singular: "навык",
|
||||||
|
folder: "src/content/skills",
|
||||||
|
create: true,
|
||||||
|
delete: true,
|
||||||
|
description: "Навыки отображаются на странице Обо мне",
|
||||||
|
i18n: true,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
label: "Категория",
|
||||||
|
widget: "string",
|
||||||
|
required: true,
|
||||||
|
i18n: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list",
|
||||||
|
label: "Записи",
|
||||||
|
required: true,
|
||||||
|
widget: "list",
|
||||||
|
allow_add: true,
|
||||||
|
min: 1,
|
||||||
|
i18n: true,
|
||||||
|
fields: [
|
||||||
|
{ name: "icon", label: "Иконка", widget: "string", i18n: true, required: false },
|
||||||
|
{ name: "title", label: "Название", widget: "string", i18n: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Settings
|
||||||
|
*/
|
||||||
|
{
|
||||||
|
name: "settings",
|
||||||
|
label: "Настройки",
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
name: "siteconfig",
|
||||||
|
label: "Настройки веб-сайта",
|
||||||
|
file: "siteconfig.json",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "Название сайта",
|
||||||
|
name: "name",
|
||||||
|
widget: "string",
|
||||||
|
hint: "Отображается в шапке.",
|
||||||
|
default: "yarkov.tech",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Автор веб-сайта",
|
||||||
|
name: "author",
|
||||||
|
widget: "string",
|
||||||
|
hint: "Отображается в подвале.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Глобальный заголовок",
|
||||||
|
name: "globalTitle",
|
||||||
|
widget: "string",
|
||||||
|
hint: "Отображется во вкладке браузера.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "В разработке",
|
||||||
|
name: "isSiteUnderConstruction",
|
||||||
|
widget: "boolean",
|
||||||
|
required: false,
|
||||||
|
default: false,
|
||||||
|
hint: "Отображает баннер на всех старницах о том, что веб-сайт находится в разработке",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Счетчик Яндекс.Метрики",
|
||||||
|
name: "ymCounter",
|
||||||
|
widget: "number",
|
||||||
|
hint: "Например: 90034656",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Статьи",
|
||||||
|
name: "articles",
|
||||||
|
widget: "object",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "recentLimit",
|
||||||
|
label: "Лимит на отображение",
|
||||||
|
widget: "number",
|
||||||
|
hint: "Кол-во публикаций на гл.странице",
|
||||||
|
default: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Контакты",
|
||||||
|
name: "contacts",
|
||||||
|
widget: "object",
|
||||||
|
required: false,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
label: "Telegram",
|
||||||
|
name: "telegram",
|
||||||
|
widget: "string",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Почта",
|
||||||
|
name: "email",
|
||||||
|
widget: "string",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Github",
|
||||||
|
name: "github",
|
||||||
|
widget: "string",
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
image({
|
||||||
|
serviceEntryPoint: "@astrojs/image/sharp",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
markdown: {
|
||||||
|
syntaxHighlight: "prism",
|
||||||
|
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",
|
||||||
|
});
|
||||||
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
|
|
||||||
17876
package-lock.json
generated
44
package.json
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "yarkov.tech",
|
"name": "yarkov.tech",
|
||||||
"version": "1.2.7",
|
"private": false,
|
||||||
"private": true,
|
"version": "2.0.0",
|
||||||
"description": "Personal website created with Astro.",
|
"description": "Personal website created with Astro.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Sergey Yarkov",
|
"name": "Sergey Yarkov",
|
||||||
@ -11,45 +11,35 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev --host",
|
"dev": "astro dev",
|
||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview --host",
|
"preview": "astro preview",
|
||||||
"format": "prettier --write ./src/\"**/*.{js,jsx,ts,tsx,json,astro}\"",
|
"astro": "astro"
|
||||||
"lint": "eslint '**/*.{js,mjs,ts,tsx}'"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"dependencies": {
|
||||||
"@astrojs/image": "^0.14.0",
|
"@astrojs/image": "^0.14.1",
|
||||||
"@astrojs/mdx": "^0.16.0",
|
|
||||||
"@astrojs/rss": "^2.1.0",
|
"@astrojs/rss": "^2.1.0",
|
||||||
"@astrojs/sitemap": "^1.0.1",
|
"@astrojs/sitemap": "^1.0.1",
|
||||||
"@astrojs/solid-js": "^2.0.2",
|
"@astrojs/solid-js": "^2.0.2",
|
||||||
"@types/html-escaper": "^3.0.0",
|
"@netlify/functions": "^1.4.0",
|
||||||
"@types/mdx": "^2.0.3",
|
"@sindresorhus/slugify": "^2.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.50.0",
|
"astro": "^2.0.10",
|
||||||
"@typescript-eslint/parser": "^5.50.0",
|
|
||||||
"astro": "^2.0.6",
|
|
||||||
"astro-icon": "^0.8.0",
|
"astro-icon": "^0.8.0",
|
||||||
"eslint": "^8.33.0",
|
"astro-netlify-cms": "^0.5.3",
|
||||||
"eslint-plugin-solid": "^0.9.4",
|
|
||||||
"hast-util-to-string": "^2.0.0",
|
"hast-util-to-string": "^2.0.0",
|
||||||
"hastscript": "^7.2.0",
|
"hastscript": "^7.2.0",
|
||||||
"html-escaper": "^3.0.3",
|
"html-escaper": "^3.0.3",
|
||||||
|
"ioredis": "^5.3.0",
|
||||||
"mime-types": "^2.1.35",
|
"mime-types": "^2.1.35",
|
||||||
"netlify-plugin-minify-html": "^0.3.1",
|
"quicklink": "^2.3.0",
|
||||||
"prettier": "^2.8.3",
|
|
||||||
"prettier-plugin-astro": "^0.8.0",
|
|
||||||
"rehype-autolink-headings": "^6.1.1",
|
"rehype-autolink-headings": "^6.1.1",
|
||||||
"rehype-slug": "^5.1.0",
|
"rehype-slug": "^5.1.0",
|
||||||
"remark-gfm": "^3.0.1",
|
"sharp": "^0.31.3",
|
||||||
"remark-smartypants": "^2.0.0",
|
|
||||||
"sass": "^1.58.0",
|
|
||||||
"solid-js": "^1.6.10"
|
"solid-js": "^1.6.10"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"devDependencies": {
|
||||||
"@netlify/functions": "^1.4.0",
|
"@types/mime-types": "^2.1.1",
|
||||||
"ioredis": "^5.3.0",
|
"sass": "^1.58.0"
|
||||||
"quicklink": "^2.3.0",
|
|
||||||
"sharp": "^0.31.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
public/media/arduino-uart-converter.png
Normal file
|
After Width: | Height: | Size: 690 KiB |
BIN
public/media/image1.jpg
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
public/media/image2.jpg
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
public/media/image3.jpg
Normal file
|
After Width: | Height: | Size: 408 KiB |
BIN
public/media/serial-port.jpg
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
public/media/uart-connect-settings.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
public/media/uart-connection.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/media/uart-packet.jpg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/media/webserial-connect.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
public/media/webserial-demo-1.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
public/media/webserial-demo-2.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 2.7 MiB After Width: | Height: | Size: 2.7 MiB |
@ -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
|
|
||||||
15
siteconfig.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"globalTitle": "Sergey Yarkov",
|
||||||
|
"articles": {
|
||||||
|
"recentLimit": 5
|
||||||
|
},
|
||||||
|
"isSiteUnderConstruction": false,
|
||||||
|
"contacts": {
|
||||||
|
"email": "serzh.yarkov@gmail.com",
|
||||||
|
"telegram": "https://t.me/sergeyyarkov",
|
||||||
|
"github": "https://github.com/sergeyyarkov"
|
||||||
|
},
|
||||||
|
"ymCounter": 90034656,
|
||||||
|
"name": "yarkov.tech",
|
||||||
|
"author": "Sergey Yarkov"
|
||||||
|
}
|
||||||
@ -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";
|
|
||||||
21
src/components/Analytics/YaMetrika.astro
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
export interface Props {
|
||||||
|
counter: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { counter } = Astro.props as Props
|
||||||
|
---
|
||||||
|
|
||||||
|
<script type="text/javascript" define:vars={{ counter }} is:inline>
|
||||||
|
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||||
|
var z = null;m[i].l=1*new Date();
|
||||||
|
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
|
||||||
|
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
|
||||||
|
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
|
||||||
|
|
||||||
|
ym(counter, "init", {
|
||||||
|
clickmap:true,
|
||||||
|
trackLinks:true,
|
||||||
|
accurateTrackBounce:true
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@ -38,6 +38,10 @@ const { url, id } = Astro.props as Props;
|
|||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
"onthemetoggled",
|
"onthemetoggled",
|
||||||
() =>
|
() =>
|
||||||
document.readyState == "complete" && setTimeout(() => DISQUS.reset({ reload: true, config: disqus_config }), 100)
|
document.readyState == "complete" &&
|
||||||
|
setTimeout(
|
||||||
|
() => "DISQUS" in window && DISQUS.reset({ reload: true, config: disqus_config }),
|
||||||
|
100
|
||||||
|
)
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@ -1 +0,0 @@
|
|||||||
export { default } from "./ArticleComments.astro";
|
|
||||||
228
src/components/ArticleContent.astro
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
<div itemprop="articleBody" class="article-content"><slot /></div>
|
||||||
|
|
||||||
|
<style lang="scss" is:global>
|
||||||
|
.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);
|
||||||
|
& a:not(:last-child)::after {
|
||||||
|
content: ",";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 800px) {
|
||||||
|
.article-content {
|
||||||
|
& .heading-wrapper {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
& .anchor-link {
|
||||||
|
& .anchor-icon {
|
||||||
|
left: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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";
|
|
||||||
34
src/components/ArticleCover.astro
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
import { Picture } from "@astrojs/image/components";
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { src, alt } = Astro.props as Props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="cover">
|
||||||
|
<figure>
|
||||||
|
<Picture
|
||||||
|
itemprop="image"
|
||||||
|
src={src}
|
||||||
|
sizes="(max-width: 450px) 450px, 700px"
|
||||||
|
widths={[450, 700]}
|
||||||
|
formats={["webp"]}
|
||||||
|
aspectRatio={"16:9"}
|
||||||
|
alt={alt ? alt : ""}
|
||||||
|
background={"chocolate"}
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.cover {
|
||||||
|
margin: 20px 0;
|
||||||
|
& figure {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,34 +1,42 @@
|
|||||||
/* eslint-disable solid/reactivity */
|
import type { Component } from "solid-js";
|
||||||
import { Component } from "solid-js";
|
import { createDateFormatter, createRelativeArticleUrl } from "@root/utils";
|
||||||
import { createDateFormatter } from "@utils/date";
|
import { URL_BLOG_PREFIX } from "@root/constants";
|
||||||
import "./ArticleItem.scss";
|
import "./ArticleItem.scss";
|
||||||
import { DEFAULT_LANGUAGE } from "@root/config";
|
|
||||||
|
|
||||||
export type ArticleItemProps = {
|
export type ArticleItemProps = {
|
||||||
data: ArticleType;
|
id: string;
|
||||||
lang: string;
|
title: string;
|
||||||
|
pubDate: Date;
|
||||||
|
pageLang: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ArticleItem: Component<ArticleItemProps> = (props) => {
|
const ArticleItem: Component<ArticleItemProps> = (props) => {
|
||||||
const isNonDefaultLanguage = props.data.lang && props.data.lang !== props.lang;
|
const articleLang = props.id.split("/")[0];
|
||||||
const hrefLangPrefix = props.data.lang && props.data.lang !== DEFAULT_LANGUAGE ? `/${props.data.lang}` : "";
|
const href = createRelativeArticleUrl(
|
||||||
const href = `${hrefLangPrefix}/blog/${new Date(props.data.published_at).toISOString().split("T")[0]}/${
|
{ id: props.id, title: props.title, pubDate: props.pubDate },
|
||||||
props.data.slug
|
URL_BLOG_PREFIX
|
||||||
}/`;
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article itemprop="blogPosts" itemscope itemtype="http://schema.org/BlogPosting" class="article-item">
|
<article
|
||||||
|
itemprop="blogPosts"
|
||||||
|
itemscope
|
||||||
|
itemtype="http://schema.org/BlogPosting"
|
||||||
|
class="article-item"
|
||||||
|
>
|
||||||
<a href={href}>
|
<a href={href}>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<h3 itemprop="headline">{props.data.title}</h3>
|
<h3 itemprop="headline">{props.title}</h3>
|
||||||
{isNonDefaultLanguage && <sup>{props.data.lang.toLocaleUpperCase()}</sup>}
|
{articleLang !== props.pageLang && <sup>{articleLang.toLocaleUpperCase()}</sup>}
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<time datetime={props.data.published_at.toString()}>
|
<time datetime={props.pubDate.toISOString()}>
|
||||||
{createDateFormatter(props.lang).format(new Date(props.data.published_at))}
|
{createDateFormatter(props.pageLang).format(props.pubDate)}
|
||||||
</time>
|
</time>
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default ArticleItem;
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export * from "./ArticleItem";
|
export { default } from "./ArticleItem";
|
||||||
|
|||||||
178
src/components/ArticlePost.astro
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
---
|
||||||
|
import type { CollectionEntry } from "astro:content";
|
||||||
|
import { Icon } from "astro-icon";
|
||||||
|
import slugify from "@sindresorhus/slugify";
|
||||||
|
import { createDateFormatter } from "@root/utils";
|
||||||
|
import ArticleCover from "@components/ArticleCover.astro";
|
||||||
|
import ArticleComments from "@components/ArticleComments.astro";
|
||||||
|
import TranslatedLink from "@components/TranslatedLink.astro";
|
||||||
|
import { useTranslation, getLanguageFromURL } from "@i18n/utils";
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
article: CollectionEntry<"blog">;
|
||||||
|
lang: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { article } = Astro.props as Props;
|
||||||
|
const pageLang = getLanguageFromURL(Astro.url.pathname);
|
||||||
|
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
|
||||||
|
const {
|
||||||
|
data: { coverImage, title, author, description, tags, pubDate },
|
||||||
|
} = article;
|
||||||
|
const slug = slugify(title, { lowercase: true });
|
||||||
|
const t = useTranslation(Astro);
|
||||||
|
---
|
||||||
|
|
||||||
|
<article itemscope itemtype="http://schema.org/BlogPosting" class="article">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
tags && (
|
||||||
|
<div class="tags">
|
||||||
|
<ul itemprop="keywords">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<li>
|
||||||
|
<TranslatedLink rel="tag" to={`/articles/?tags=${tag}`}>
|
||||||
|
{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={pubDate.toString()}>
|
||||||
|
{createDateFormatter(pageLang).format(pubDate)}
|
||||||
|
</time>
|
||||||
|
<span itemprop="author">{author}</span>
|
||||||
|
</div>
|
||||||
|
{coverImage && <ArticleCover src={coverImage} alt={title} />}
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<slot />
|
||||||
|
<ArticleComments url={canonicalUrl.href} id={slug} />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<script define:vars={{ slug, IS_DEV: import.meta.env.DEV }}>
|
||||||
|
(async () => {
|
||||||
|
if (IS_DEV) return;
|
||||||
|
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>
|
||||||
|
|
||||||
|
<style lang="scss" is:global>
|
||||||
|
.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;
|
||||||
|
& li:not(:last-child)::after {
|
||||||
|
content: ",";
|
||||||
|
color: var(--color-blue-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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,7 +1,4 @@
|
|||||||
.articles {
|
.articles {
|
||||||
&-tags {
|
|
||||||
margin-bottom: 45px;
|
|
||||||
}
|
|
||||||
&-list {
|
&-list {
|
||||||
&__wrapper {
|
&__wrapper {
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
|
|||||||
@ -1,97 +1,97 @@
|
|||||||
import type { Component } from "solid-js";
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
import { createSignal, createEffect, For } from "solid-js";
|
import { createSignal, createEffect, For } from "solid-js";
|
||||||
import { search, selectedTags } from "@stores/searchStore";
|
import { search, selectedTags, setCount } from "@stores/searchStore";
|
||||||
import { findArticlesBySearch, findArticlesByTags, sortArtcilesByDate } from "@utils/articles";
|
import SearchInput from "./SearchInput";
|
||||||
import { ArticleItem } from "@components/ArticleItem";
|
import TagsList from "./TagsList";
|
||||||
import SearchInput from "@components/SearchInput";
|
import ArticleItem from "@components/ArticleItem";
|
||||||
import TagsList from "@components/TagsList";
|
|
||||||
import "./ArticlesList.scss";
|
import "./ArticlesList.scss";
|
||||||
|
|
||||||
type UiStringsType = {
|
type UiStringsType = { "articles.empty": string; "input.search": string };
|
||||||
"input.search": string;
|
|
||||||
"articles.empty": string;
|
type ArticlesType = Array<{
|
||||||
};
|
id: string;
|
||||||
|
title: string;
|
||||||
|
tags: string[];
|
||||||
|
pubDate: Date;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type ArticlesBlockType = Record<string, ArticlesType>;
|
||||||
|
|
||||||
type ArticlesListProps = {
|
type ArticlesListProps = {
|
||||||
data: {
|
articles: ArticlesBlockType;
|
||||||
articles: ArticleBlockType;
|
|
||||||
tags: string[];
|
tags: string[];
|
||||||
};
|
pageLang: string;
|
||||||
lang: string;
|
|
||||||
i18n: UiStringsType;
|
i18n: UiStringsType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ArticlesList: Component<ArticlesListProps> = (props) => {
|
const ArticlesList: Component<ArticlesListProps> = (props) => {
|
||||||
// eslint-disable-next-line solid/reactivity
|
const [articles, setArticles] = createSignal<ArticlesBlockType>(props.articles);
|
||||||
const [articles, setArticles] = createSignal<ArticleBlockType>(props.data.articles);
|
|
||||||
|
|
||||||
/**
|
const isEmpty = () => Object.keys(articles()).length === 0;
|
||||||
* Sort articles by year in descending order
|
|
||||||
*/
|
const sortYears = (articles: ArticlesBlockType) => {
|
||||||
const sortYears = (articles: ArticleBlockType) => {
|
|
||||||
return Object.keys(articles)
|
return Object.keys(articles)
|
||||||
.map(Number)
|
.map(Number)
|
||||||
.sort((a, b) => b - a);
|
.sort((a, b) => b - a);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEmpty = () => Object.keys(articles()).length === 0;
|
const searchArticles = ({
|
||||||
const getLength = () => Object.values(articles()).flat().length;
|
params: { search, tags },
|
||||||
|
}: {
|
||||||
|
params: { search: string; tags?: string[] };
|
||||||
|
}): ArticlesBlockType => {
|
||||||
|
const filter = (cb: (articles: ArticlesType) => ArticlesType) => {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.keys(props.articles)
|
||||||
|
.map((year) => [year, cb(props.articles[year])])
|
||||||
|
.filter((articles) => articles[1].length !== 0)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return filter((articles) => {
|
||||||
|
let filtered = articles.filter((a) =>
|
||||||
|
a.title.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
|
||||||
|
);
|
||||||
|
if (tags && tags.length > 0) {
|
||||||
|
filtered = filtered.filter((a) => tags.some((t) => a.tags.includes(t)));
|
||||||
|
}
|
||||||
|
return filtered;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
let filtered = props.data.articles;
|
const filtered = searchArticles({ params: { search: search(), tags: selectedTags() } });
|
||||||
|
|
||||||
/**
|
|
||||||
* Find articles by title
|
|
||||||
*/
|
|
||||||
if (search()) {
|
|
||||||
filtered = findArticlesBySearch(search().trim(), filtered);
|
|
||||||
setArticles(filtered);
|
setArticles(filtered);
|
||||||
}
|
setCount(Object.values(filtered).flat().length);
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<SearchInput count={getLength()} placeholder={props.i18n["input.search"]} />
|
<SearchInput i18n={{ "input.search": props.i18n["input.search"] }} />
|
||||||
<TagsList data={props.data.tags} />
|
<TagsList tags={props.tags} />
|
||||||
<div itemscope itemtype="http://schema.org/Blog" class="articles-list">
|
<div itemscope itemtype="http://schema.org/Blog" class="articles-list">
|
||||||
{isEmpty() ? (
|
{!isEmpty() ? (
|
||||||
|
<For each={sortYears(articles())}>
|
||||||
|
{(year) => (
|
||||||
|
<div class="articles-list__wrapper">
|
||||||
|
<h2>{year}</h2>
|
||||||
|
<For each={articles()[year]}>
|
||||||
|
{({ id, title, pubDate }) => (
|
||||||
|
<ArticleItem
|
||||||
|
id={id}
|
||||||
|
title={title}
|
||||||
|
pubDate={pubDate}
|
||||||
|
pageLang={props.pageLang}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
) : (
|
||||||
<div class="articles-list__empty">
|
<div class="articles-list__empty">
|
||||||
<p>{props.i18n["articles.empty"]}</p>
|
<p>{props.i18n["articles.empty"]}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,14 +1,11 @@
|
|||||||
import type { Component, JSX } from "solid-js";
|
import type { Component, JSX } from "solid-js";
|
||||||
|
|
||||||
import { createEffect } from "solid-js";
|
import { createEffect } from "solid-js";
|
||||||
import { search, setSearch } from "@stores/searchStore";
|
import { search, setSearch, count } from "@stores/searchStore";
|
||||||
import * as utils from "@utils/history";
|
import * as utils from "@utils/history";
|
||||||
import "./SearchInput.scss";
|
import "./SearchInput.scss";
|
||||||
|
|
||||||
type SearchInputProps = {
|
type UiStringsType = { "input.search": string };
|
||||||
count: number;
|
type SearchInputProps = { i18n: UiStringsType };
|
||||||
placeholder?: string | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SearchInput: Component<SearchInputProps> = (props) => {
|
const SearchInput: Component<SearchInputProps> = (props) => {
|
||||||
/**
|
/**
|
||||||
@ -16,7 +13,6 @@ const SearchInput: Component<SearchInputProps> = (props) => {
|
|||||||
*/
|
*/
|
||||||
const resetSearch = () => {
|
const resetSearch = () => {
|
||||||
const url = utils.deleteParamFromUrl("search");
|
const url = utils.deleteParamFromUrl("search");
|
||||||
|
|
||||||
setSearch("");
|
setSearch("");
|
||||||
history.replaceState(null, "", url);
|
history.replaceState(null, "", url);
|
||||||
};
|
};
|
||||||
@ -33,7 +29,6 @@ const SearchInput: Component<SearchInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = utils.setParamToUrl("search", value.trim());
|
const url = utils.setParamToUrl("search", value.trim());
|
||||||
|
|
||||||
history.replaceState(null, "", `${location.pathname}?${url}`);
|
history.replaceState(null, "", `${location.pathname}?${url}`);
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
};
|
};
|
||||||
@ -54,18 +49,24 @@ const SearchInput: Component<SearchInputProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="search-input">
|
<div class="search-input">
|
||||||
<svg width="25" height="24" viewBox="0 0 25 24" xmlns="http://www.w3.org/2000/svg" data-filter="none">
|
<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" />
|
<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>
|
</svg>
|
||||||
<input
|
<input
|
||||||
onInput={onSearch}
|
onInput={onSearch}
|
||||||
value={search()}
|
value={search()}
|
||||||
type="search"
|
type="search"
|
||||||
placeholder={props.placeholder || "Search article by title or description..."}
|
placeholder={props.i18n["input.search"] || "Search article by title..."}
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<span>{props.count}</span>
|
<span>{count()}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -1,15 +1,12 @@
|
|||||||
import type { Component, JSX } from "solid-js";
|
import type { Component, JSX } from "solid-js";
|
||||||
|
|
||||||
import { For, createEffect } from "solid-js";
|
import { For, createEffect } from "solid-js";
|
||||||
import { selectedTags, setSelectedTags } from "@stores/searchStore";
|
import { selectedTags, setSelectedTags } from "@stores/searchStore";
|
||||||
import * as utils from "@utils/history";
|
import * as utils from "@utils/history";
|
||||||
|
|
||||||
type TagsListProps = {
|
type TagsListProps = { tags: string[] };
|
||||||
data: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const TagsList: Component<TagsListProps> = (props) => {
|
const TagsList: Component<TagsListProps> = (props) => {
|
||||||
const isTagSelected = (tag: string) => selectedTags().includes(tag.toLocaleLowerCase());
|
const isTagSelected = (tag: string) => selectedTags().includes(tag);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reset signal state and `tags` property in history state
|
* Reset signal state and `tags` property in history state
|
||||||
@ -21,9 +18,11 @@ const TagsList: Component<TagsListProps> = (props) => {
|
|||||||
history.replaceState(null, "", url);
|
history.replaceState(null, "", url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onSelect: JSX.EventHandler<HTMLLIElement, PointerEvent> = (e) => {
|
const onSelect: JSX.EventHandler<HTMLLIElement, MouseEvent> = (e) => {
|
||||||
const tag = e.target.getAttribute("data-tag");
|
const tag = e.target.getAttribute("data-tag");
|
||||||
|
|
||||||
|
if (!tag) return;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete tag from selected tags if already selected
|
* Delete tag from selected tags if already selected
|
||||||
* and update param in url
|
* and update param in url
|
||||||
@ -53,14 +52,13 @@ const TagsList: Component<TagsListProps> = (props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
let param: string | string[] = new URLSearchParams(location.search).get("tags");
|
let param: string | null | string[] = new URLSearchParams(location.search).get("tags");
|
||||||
|
|
||||||
if (!param) {
|
if (!param) {
|
||||||
resetSelected();
|
resetSelected();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* to array */
|
|
||||||
param = param.split(",");
|
param = param.split(",");
|
||||||
|
|
||||||
if (param.length === 0) {
|
if (param.length === 0) {
|
||||||
@ -68,25 +66,15 @@ const TagsList: Component<TagsListProps> = (props) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tags: string[] = [];
|
setSelectedTags(props.tags.filter((t) => param?.includes(t)));
|
||||||
|
|
||||||
for (const tag of props.data) {
|
|
||||||
const isTagExist = param.includes(tag.toLocaleLowerCase());
|
|
||||||
|
|
||||||
if (isTagExist) {
|
|
||||||
tags.push(tag.toLocaleLowerCase());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedTags(tags);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul class="list-links">
|
<ul class="list-links">
|
||||||
<For
|
<For
|
||||||
each={props.data}
|
each={props.tags}
|
||||||
children={(tag) => (
|
children={(tag) => (
|
||||||
<li onClick={onSelect} class="btn-link" data-tag={tag.toLocaleLowerCase()} data-active={isTagSelected(tag)}>
|
<li onClick={onSelect} class="btn-link" data-tag={tag} data-active={isTagSelected(tag)}>
|
||||||
{tag}
|
{tag}
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
@ -1,18 +1,24 @@
|
|||||||
---
|
---
|
||||||
import { SITE_METADATA } from "@root/config";
|
import "@styles/global.scss";
|
||||||
import { useTranslation } from "@i18n/utils";
|
import siteconfig from "siteconfig.json";
|
||||||
|
import { SITE_METADATA } from "@root/constants";
|
||||||
|
import YaMetrika from "./Analytics/YaMetrika.astro";
|
||||||
|
import { useTranslation } from "@root/i18n/utils";
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
title?: string | undefined;
|
title?: string;
|
||||||
description?: string | undefined;
|
description?: string;
|
||||||
canonicalUrl?: string | undefined;
|
image?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||||
|
const { title, description } = Astro.props;
|
||||||
|
const isDev = import.meta.env.DEV;
|
||||||
const t = useTranslation(Astro);
|
const t = useTranslation(Astro);
|
||||||
const { title = SITE_METADATA.title, description, canonicalUrl } = Astro.props as Props;
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
|
<!-- Fonts -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
@ -21,14 +27,23 @@ const { title = SITE_METADATA.title, description, canonicalUrl } = Astro.props a
|
|||||||
as="style"
|
as="style"
|
||||||
onload="this.rel='stylesheet'"
|
onload="this.rel='stylesheet'"
|
||||||
/>
|
/>
|
||||||
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
|
|
||||||
|
<!-- Canonical URL -->
|
||||||
|
<link rel="canonical" href={canonicalURL.href} />
|
||||||
|
|
||||||
|
<!-- Base metadata -->
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{title}</title>
|
<title>{title || SITE_METADATA.title}</title>
|
||||||
<meta name="title" content={title} />
|
|
||||||
|
<!-- Metadata -->
|
||||||
|
<meta name="title" content={title || SITE_METADATA.title} />
|
||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<meta name="description" content={description || t("site.description")} />
|
<meta name="description" content={description || t("site.description")} />
|
||||||
<meta name="theme-color" content={SITE_METADATA.themeColor} />
|
<meta name="theme-color" content={SITE_METADATA.themeColor} />
|
||||||
|
<meta name="msapplication-TileColor" content="#da532c" />
|
||||||
|
|
||||||
|
<!-- Favicons -->
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
@ -44,12 +59,24 @@ const { title = SITE_METADATA.title, description, canonicalUrl } = Astro.props a
|
|||||||
href="/favicon-light-16x16.png"
|
href="/favicon-light-16x16.png"
|
||||||
media="(prefers-color-scheme: light)"
|
media="(prefers-color-scheme: light)"
|
||||||
/>
|
/>
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-dark-32x32.png" media="(prefers-color-scheme: dark)" />
|
<link
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-dark-16x16.png" media="(prefers-color-scheme: dark)" />
|
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="manifest" href="/site.webmanifest" />
|
||||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
|
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
|
||||||
<meta name="msapplication-TileColor" content="#da532c" />
|
|
||||||
<slot />
|
<slot />
|
||||||
<script is:inline src="/assets/js/detect-theme.js"></script>
|
<script is:inline src="/assets/js/detect-theme.js"></script>
|
||||||
<script src="../quicklink.js"></script>
|
<script src="../quicklink.js"></script>
|
||||||
|
{!isDev && siteconfig?.ymCounter && <YaMetrika counter={siteconfig.ymCounter} />}
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
22
src/components/BaseLayout.astro
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
import { IS_SITE_UNDER_CONSTRUCTION } from "@root/constants";
|
||||||
|
import Header from "@components/Header.astro";
|
||||||
|
import Footer from "@components/Footer.astro";
|
||||||
|
import SiteContent from "@components/SiteContent.astro";
|
||||||
|
import UnderConstruction from "./UnderConstruction.astro";
|
||||||
|
---
|
||||||
|
|
||||||
|
<body>
|
||||||
|
{
|
||||||
|
IS_SITE_UNDER_CONSTRUCTION && (
|
||||||
|
<UnderConstruction>
|
||||||
|
🚧 This web site is under development 🚧
|
||||||
|
</UnderConstruction>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<Header />
|
||||||
|
<SiteContent>
|
||||||
|
<slot />
|
||||||
|
</SiteContent>
|
||||||
|
<Footer />
|
||||||
|
</body>
|
||||||
@ -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,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
export type Props = astroHTML.JSX.ButtonHTMLAttributes;
|
export type Props = astroHTML.JSX.ButtonHTMLAttributes;
|
||||||
|
|
||||||
const attrs = Astro.props as Props;
|
const attrs = Astro.props as Props;
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -15,7 +14,6 @@ const attrs = Astro.props as Props;
|
|||||||
font-family: var(--font-family-base);
|
font-family: var(--font-family-base);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
|
||||||
&[disabled] {
|
&[disabled] {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@ -1 +0,0 @@
|
|||||||
export { default } from "./Button.astro";
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import Button from "@components/Button";
|
import Button from "@components/Button.astro";
|
||||||
|
import UIString from "./UIString.astro";
|
||||||
import { useTranslation } from "@i18n/utils";
|
import { useTranslation } from "@i18n/utils";
|
||||||
|
|
||||||
const t = useTranslation(Astro);
|
const t = useTranslation(Astro);
|
||||||
@ -12,13 +13,32 @@ const t = useTranslation(Astro);
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<div>
|
<div>
|
||||||
<input name="name" placeholder={t("input.nameField")} type="text" maxlength="20" autocomplete="off" required />
|
<input
|
||||||
|
name="name"
|
||||||
|
placeholder={t("input.nameField")}
|
||||||
|
type="text"
|
||||||
|
maxlength="20"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<input name="email" placeholder="example@email.com" autocomplete="off" type="email" required />
|
<input
|
||||||
|
name="email"
|
||||||
|
placeholder="example@email.com"
|
||||||
|
autocomplete="off"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input name="subject" placeholder={t("input.subjectField")} type="text" autocomplete="off" required />
|
<input
|
||||||
|
name="subject"
|
||||||
|
placeholder={t("input.subjectField")}
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
required
|
||||||
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
name="message"
|
name="message"
|
||||||
placeholder={t("input.messageField")}
|
placeholder={t("input.messageField")}
|
||||||
@ -28,7 +48,9 @@ const t = useTranslation(Astro);
|
|||||||
rows={10}
|
rows={10}
|
||||||
required></textarea>
|
required></textarea>
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<Button type="submit">{t("btn.submit")}</Button>
|
<Button type="submit">
|
||||||
|
<UIString key={"btn.submit"} />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@ -38,24 +60,20 @@ const t = useTranslation(Astro);
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
row-gap: 7px;
|
row-gap: 7px;
|
||||||
margin-bottom: 3.125rem;
|
margin-bottom: 3.125rem;
|
||||||
|
|
||||||
& .flex {
|
& .flex {
|
||||||
column-gap: 10px;
|
column-gap: 10px;
|
||||||
& div {
|
& div {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
& input.error-input,
|
& input.error-input,
|
||||||
& textarea.error-input {
|
& textarea.error-input {
|
||||||
border: 1px solid var(--color-error);
|
border: 1px solid var(--color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
& .footer {
|
& .footer {
|
||||||
text-align: center;
|
text-align: right;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 560px) {
|
@media screen and (max-width: 560px) {
|
||||||
form .flex {
|
form .flex {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -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";
|
|
||||||
36
src/components/ContactsList.astro
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from "astro-icon";
|
||||||
|
---
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href={"mailto:" + "serzh.yarkov@gmail.com"}
|
||||||
|
>Email
|
||||||
|
<Icon name="arrow-up-right" width="15" height="15" data-filter="none" />
|
||||||
|
</a>
|
||||||
|
<a href={"https://github.com/sergeyyarkov"} target="_blank"
|
||||||
|
>Github
|
||||||
|
<Icon name="arrow-up-right" width="15" height="15" data-filter="none" />
|
||||||
|
</a>
|
||||||
|
<a href={"https://t.me/sergeyyarkov"} target="_blank"
|
||||||
|
>Telegram
|
||||||
|
<Icon name="arrow-up-right" width="15" height="15" data-filter="none" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
column-gap: 40px;
|
||||||
|
row-gap: 30px;
|
||||||
|
|
||||||
|
& a {
|
||||||
|
display: flex;
|
||||||
|
column-gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& svg {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import UIString from "@components/UIString";
|
import UIString from "@components/UIString.astro";
|
||||||
---
|
---
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
52
src/components/Footer.astro
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
import { SITE_METADATA } from "@root/constants";
|
||||||
|
import { Icon } from "astro-icon";
|
||||||
|
import TranslatedLink from "./TranslatedLink.astro";
|
||||||
|
import UIString from "./UIString.astro";
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="container">
|
||||||
|
<div>
|
||||||
|
<i>
|
||||||
|
<UIString key={"footer.developed"} />: <TranslatedLink to="/contacts/">
|
||||||
|
{SITE_METADATA.author}
|
||||||
|
</TranslatedLink>
|
||||||
|
© {new Date().getFullYear()}
|
||||||
|
</i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="/rss.xml" target="_blank"><UIString key={"footer.rssFeed"} /></a>
|
||||||
|
<span>
|
||||||
|
<Icon name="rss" width="20" height="20" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
footer {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 5rem;
|
||||||
|
color: var(--color-gray-400);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
& .container {
|
||||||
|
padding-top: 15px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
column-gap: 20px;
|
||||||
|
row-gap: 5px;
|
||||||
|
& div:last-child {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
& a {
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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,33 +1,29 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from "astro-icon";
|
import { Icon } from "astro-icon";
|
||||||
import { IS_SITE_UNDER_CONSTRUCTION, SITE_METADATA } from "@root/config";
|
import { IS_SITE_UNDER_CONSTRUCTION, SITE_METADATA } from "@root/constants";
|
||||||
import { removeSlashes } from "@utils/string";
|
|
||||||
import ThemeSwitcher from "@components/ThemeSwticher";
|
import ThemeSwitcher from "@components/ThemeSwticher";
|
||||||
import UIString from "@components/UIString";
|
import LanguageSelect from "@components/LanguageSelect.astro";
|
||||||
import TranslatedLink from "@components/TranslatedLink";
|
import NavToggle from "@components/NavToggle";
|
||||||
import LanguageSelect from "@components/LanguageSelect";
|
import UIString from "@components/UIString.astro";
|
||||||
import NavToggle from "./NavToggle";
|
import TranslatedLink from "@components/TranslatedLink.astro";
|
||||||
|
|
||||||
const { name, nav, contacts } = SITE_METADATA;
|
|
||||||
const { pathname } = Astro.url;
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<header {...(IS_SITE_UNDER_CONSTRUCTION ? { style: 'top: 45px;' } : {})}>
|
<header {...(IS_SITE_UNDER_CONSTRUCTION ? { style: 'top: 45px;' } : {})}>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<TranslatedLink to={"/"}>
|
<TranslatedLink to="/">
|
||||||
{name}
|
{SITE_METADATA.name}
|
||||||
</TranslatedLink>
|
</TranslatedLink>
|
||||||
</div>
|
</div>
|
||||||
<nav itemscope itemtype="http://schema.org/SiteNavigationElement">
|
<nav itemscope itemtype="http://schema.org/SiteNavigationElement">
|
||||||
<ul>
|
<ul>
|
||||||
{
|
{
|
||||||
nav.map((link) => (
|
SITE_METADATA.nav.map((link) => (
|
||||||
<li>
|
<li>
|
||||||
<TranslatedLink
|
<TranslatedLink
|
||||||
itemprop="url"
|
itemprop={"url"}
|
||||||
class={pathname.includes(removeSlashes(link.url)) && pathname.split("/").length < 5 && "active"}
|
|
||||||
to={link.url}
|
to={link.url}
|
||||||
|
class:list={[{ active: Astro.url.pathname.includes(link.url) }]}
|
||||||
>
|
>
|
||||||
<UIString key={link.key} />
|
<UIString key={link.key} />
|
||||||
</TranslatedLink>
|
</TranslatedLink>
|
||||||
@ -35,20 +31,24 @@ const { pathname } = Astro.url;
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
{
|
||||||
|
SITE_METADATA.contacts && (
|
||||||
<div class="contacts">
|
<div class="contacts">
|
||||||
<a href={"mailto:" + contacts.email}>
|
<a target="_blank" href={"mailto:" + SITE_METADATA.contacts.email}>
|
||||||
Email
|
Email
|
||||||
<Icon name="email" width="18" height="18" />
|
<Icon name="email" width="18" height="18" />
|
||||||
</a>
|
</a>
|
||||||
<a href={contacts.githubUrl}>
|
<a target="_blank" href={SITE_METADATA.contacts.github}>
|
||||||
Github
|
Github
|
||||||
<Icon name="github_filled" width="18" height="18" />
|
<Icon name="github_filled" width="18" height="18" />
|
||||||
</a>
|
</a>
|
||||||
<a href={contacts.telegramUrl}>
|
<a target="_blank" href={SITE_METADATA.contacts.telegram}>
|
||||||
Telegram
|
Telegram
|
||||||
<Icon name="telegram" width="18" height="18" />
|
<Icon name="telegram" width="18" height="18" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
<div class="flex settings">
|
<div class="flex settings">
|
||||||
<LanguageSelect />
|
<LanguageSelect />
|
||||||
<ThemeSwitcher client:visible />
|
<ThemeSwitcher client:visible />
|
||||||
@ -70,17 +70,15 @@ const { pathname } = Astro.url;
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
& .container {
|
& .container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .logo a {
|
& .logo a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
color: var(--font-color);
|
color: var(--font-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
& a:hover,
|
& a:hover,
|
||||||
a.active {
|
a.active {
|
||||||
color: var(--color-blue-500);
|
color: var(--color-blue-500);
|
||||||
@ -88,7 +86,6 @@ const { pathname } = Astro.url;
|
|||||||
text-decoration-thickness: 2px;
|
text-decoration-thickness: 2px;
|
||||||
text-underline-offset: 4px;
|
text-underline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
& nav {
|
& nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -125,7 +122,6 @@ const { pathname } = Astro.url;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
header {
|
header {
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
@ -133,7 +129,6 @@ const { pathname } = Astro.url;
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-is-mobile-open="true"] {
|
body[data-is-mobile-open="true"] {
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
@ -158,7 +153,7 @@ const { pathname } = Astro.url;
|
|||||||
display: block;
|
display: block;
|
||||||
& ul {
|
& ul {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
& li {
|
& li {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
@ -169,8 +164,14 @@ const { pathname } = Astro.url;
|
|||||||
}
|
}
|
||||||
& .contacts {
|
& .contacts {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
margin-top: 2rem;
|
margin-top: 2.5rem;
|
||||||
justify-content: center;
|
& a {
|
||||||
|
margin-right: 0.2rem;
|
||||||
|
}
|
||||||
|
& svg {
|
||||||
|
width: 21px;
|
||||||
|
height: 21px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
export { default } from "./Header.astro";
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { default } from "./HeaderBanner.astro";
|
|
||||||
@ -1,8 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { Icon } from "astro-icon";
|
import { Icon } from "astro-icon";
|
||||||
import { DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES } from "@root/config";
|
import { DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, URL_BLOG_PREFIX } from "@root/constants";
|
||||||
import languages from "@i18n/languages";
|
import { getLanguageFromURL } from "@i18n/utils";
|
||||||
import { getLanguageFromURL } from "@root/utils";
|
|
||||||
|
|
||||||
const lang = getLanguageFromURL(Astro.url.pathname);
|
const lang = getLanguageFromURL(Astro.url.pathname);
|
||||||
---
|
---
|
||||||
@ -12,7 +11,7 @@ const lang = getLanguageFromURL(Astro.url.pathname);
|
|||||||
<select id="languageSelect" value={lang} aria-label="Select language">
|
<select id="languageSelect" value={lang} aria-label="Select language">
|
||||||
{
|
{
|
||||||
SUPPORTED_LANGUAGES.map((code) => (
|
SUPPORTED_LANGUAGES.map((code) => (
|
||||||
<option value={code} selected={lang === code}>
|
<option value={code} selected={code === lang}>
|
||||||
<span>{code.toLocaleUpperCase()}</span>
|
<span>{code.toLocaleUpperCase()}</span>
|
||||||
</option>
|
</option>
|
||||||
))
|
))
|
||||||
@ -20,6 +19,35 @@ const lang = getLanguageFromURL(Astro.url.pathname);
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script define:vars={{ lang, DEFAULT_LANGUAGE, URL_BLOG_PREFIX }}>
|
||||||
|
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(`/${URL_BLOG_PREFIX}/`)) {
|
||||||
|
window.location.pathname =
|
||||||
|
`${newLang === DEFAULT_LANGUAGE ? "" : newLang + "/"}` + "articles/";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.pathname = url;
|
||||||
|
};
|
||||||
|
|
||||||
|
$select.addEventListener("change", changeLanguageHandler);
|
||||||
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
div {
|
div {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -64,31 +92,3 @@ const lang = getLanguageFromURL(Astro.url.pathname);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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,16 +1,27 @@
|
|||||||
import type { Component } from "solid-js";
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
import { createSignal, createEffect } from "solid-js";
|
import { createSignal, createEffect } from "solid-js";
|
||||||
import "./NavToggle.scss";
|
import "./NavToggle.scss";
|
||||||
|
|
||||||
const MenuIcon: Component = () => (
|
const MenuIcon: Component = () => (
|
||||||
<svg fill="#000000" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="24px" height="24px">
|
<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" />
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
const CloseIcon: Component = () => (
|
const CloseIcon: Component = () => (
|
||||||
<svg fill="#000000" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="24px" height="24px">
|
<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" />
|
<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>
|
</svg>
|
||||||
);
|
);
|
||||||
@ -32,7 +43,13 @@ const NavToggle: Component = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button class="nav-toggle" onClick={toggleNav} type="button" aria-pressed={isNavShown()} aria-label="Menu">
|
<button
|
||||||
|
class="nav-toggle"
|
||||||
|
onClick={toggleNav}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={isNavShown()}
|
||||||
|
aria-label="Menu"
|
||||||
|
>
|
||||||
{isNavShown() ? <CloseIcon /> : <MenuIcon />}
|
{isNavShown() ? <CloseIcon /> : <MenuIcon />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
33
src/components/OpenGraph.astro
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
import mime from 'mime-types'
|
||||||
|
import { extname } from 'node:path'
|
||||||
|
import { isURL } from "@utils/string";
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
title: string;
|
||||||
|
siteName: string;
|
||||||
|
description: string;
|
||||||
|
type: string;
|
||||||
|
url: string;
|
||||||
|
image?: {
|
||||||
|
path: string;
|
||||||
|
width: string;
|
||||||
|
height: string;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const {title, siteName, description, url, image} = Astro.props as Props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:site_name" content={siteName} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:url" content={url} />
|
||||||
|
{image && (
|
||||||
|
<meta property="og:image" content={isURL(image.path) ? image.path : `${Astro.url.origin}${image.path}`} />
|
||||||
|
<meta property="og:image:width" content="700" />
|
||||||
|
<meta property="og:image:height" content="400" />
|
||||||
|
<meta property="og:image:type" content={`${mime.lookup(extname(image.path))}`} />
|
||||||
|
)}
|
||||||
@ -3,7 +3,6 @@ export interface Props {
|
|||||||
heading: string;
|
heading: string;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { heading, description } = Astro.props as Props;
|
const { heading, description } = Astro.props as Props;
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -17,14 +16,13 @@ const { heading, description } = Astro.props as Props;
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
div {
|
div {
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 2.5rem;
|
||||||
|
text-align: center;
|
||||||
& h1 {
|
& h1 {
|
||||||
font-size: var(--font-size-4xl);
|
font-size: var(--font-size-4xl);
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
& p {
|
& p {
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
text-align: center;
|
|
||||||
max-width: 650px;
|
max-width: 650px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
export { default } from "./PageHeading.astro";
|
|
||||||
91
src/components/ProjectItem.astro
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
import { DEFAULT_LANGUAGE } from "@root/constants";
|
||||||
|
import { getLanguageFromURL } from "@i18n/utils";
|
||||||
|
import { Icon } from "astro-icon";
|
||||||
|
import type { CollectionEntry } from "astro:content";
|
||||||
|
// import UIString from "../UIString";
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
data: CollectionEntry<"projects">["data"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = Astro.props as Props;
|
||||||
|
// const lang = getLanguageFromURL(Astro.url.pathname);
|
||||||
|
const { icon, title, year, description, articleUrl, sourceUrl, demoUrl } = data;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="project-item">
|
||||||
|
<div class="project-item__heading">
|
||||||
|
<h2>
|
||||||
|
<Icon name={icon} width="20" height="20" />
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<span>{year}</span>
|
||||||
|
</div>
|
||||||
|
<div class="project-item__description">
|
||||||
|
<p>{description}</p>
|
||||||
|
</div>
|
||||||
|
<div class="project-item__links">
|
||||||
|
{
|
||||||
|
articleUrl && (
|
||||||
|
<a class="btn-link" href={articleUrl} target="_blank">
|
||||||
|
Статья
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
sourceUrl && (
|
||||||
|
<a class="btn-link" href={sourceUrl} target="_blank">
|
||||||
|
Исходник
|
||||||
|
<Icon name="link-out" width="15" height="15" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{
|
||||||
|
demoUrl && (
|
||||||
|
<a class="btn-link" href={demoUrl} target="_blank">
|
||||||
|
Демо
|
||||||
|
<Icon name="link-out" width="15" height="15" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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";
|
|
||||||
14
src/components/ProjectsList.astro
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
---
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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";
|
|
||||||
69
src/components/RecentArticles.astro
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
import type { CollectionEntry } from "astro:content";
|
||||||
|
import siteconfig from "siteconfig.json";
|
||||||
|
import { getLanguageFromURL } from "@i18n/utils";
|
||||||
|
import * as utils from "@utils/index";
|
||||||
|
import ArticleItem from "@components/ArticleItem";
|
||||||
|
import EmptyList from "@components/EmptyList.astro";
|
||||||
|
import UIString from "@components/UIString.astro";
|
||||||
|
import TranslatedLink from "@components/TranslatedLink.astro";
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
articles: CollectionEntry<"blog">[];
|
||||||
|
length?: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { articles, length = siteconfig.articles.recentLimit } = Astro.props as Props;
|
||||||
|
const pageLang = getLanguageFromURL(Astro.url.pathname);
|
||||||
|
const filtered = utils.removeDuplicates(articles.slice(0, length), pageLang);
|
||||||
|
const isEmpty = articles.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 ? (
|
||||||
|
filtered.map((article) => (
|
||||||
|
<ArticleItem
|
||||||
|
id={article.id}
|
||||||
|
title={article.data.title}
|
||||||
|
pubDate={article.data.pubDate}
|
||||||
|
pageLang={pageLang}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<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,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";
|
|
||||||
@ -8,7 +8,6 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 5rem;
|
margin-top: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
main {
|
main {
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
@ -1 +0,0 @@
|
|||||||
export { default } from "./SiteContent.astro";
|
|
||||||
69
src/components/SkillsBlock.astro
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from "astro-icon";
|
||||||
|
import type { CollectionEntry } from "astro:content";
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
data: CollectionEntry<"skills">["data"];
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { title, list },
|
||||||
|
} = Astro.props as Props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="skills-block">
|
||||||
|
<div>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ul>
|
||||||
|
{
|
||||||
|
list.map((entry) => (
|
||||||
|
<li>
|
||||||
|
{entry.icon && <Icon name={entry.icon} width="20" height="20" />}
|
||||||
|
{entry.title}
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -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>
|
|
||||||