mirror of
https://github.com/sergeyyarkov/yarkov.tech.git
synced 2026-10-11 18:39:14 +03:00
Merge branch 'release/1.0.0'
This commit is contained in:
commit
264eabdce2
4
.gitignore
vendored
4
.gitignore
vendored
@ -10,10 +10,6 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
.idea
|
|
||||||
.vscode
|
|
||||||
|
|
||||||
|
|
||||||
# environment variables
|
# environment variables
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
|
|||||||
7
.vscode/settings.json
vendored
Normal file
7
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files.eol": "\n",
|
||||||
|
"prettier.documentSelectors": ["**/*.astro"],
|
||||||
|
"[astro]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
# yarkov.tech
|
# yarkov.tech
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://app.netlify.com/sites/jade-froyo-876dfe/deploys)
|
[](https://app.netlify.com/sites/yarkov/deploys)
|
||||||
|
|
||||||
Personal website created with Astro.
|
Personal website created with Astro.
|
||||||
|
|
||||||
@ -15,7 +15,7 @@ Personal website created with Astro.
|
|||||||
- [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 [utterances](https://utteranc.es/)
|
- [x] Comment system via [utterances](https://utteranc.es/)
|
||||||
- [x] RSS Feed for articles
|
- [x] RSS Feed for articles
|
||||||
- [ ] SEO optimized
|
- [x] SEO optimized
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,26 @@
|
|||||||
import solid from "@astrojs/solid-js";
|
|
||||||
// import image from "@astrojs/image";
|
|
||||||
import mdx from "@astrojs/mdx";
|
|
||||||
import sitemap from "@astrojs/sitemap";
|
|
||||||
// import compress from "astro-compress";
|
|
||||||
import { defineConfig } from "astro/config";
|
import { defineConfig } from "astro/config";
|
||||||
import { h } from "hastscript";
|
import { h } from "hastscript";
|
||||||
import { toString } from "hast-util-to-string";
|
import { toString } from "hast-util-to-string";
|
||||||
import { escape } from "html-escaper";
|
import { escape } from "html-escaper";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integrations
|
||||||
|
*/
|
||||||
|
import solid from "@astrojs/solid-js";
|
||||||
|
import mdx from "@astrojs/mdx";
|
||||||
|
import sitemap from "@astrojs/sitemap";
|
||||||
|
import image from "@astrojs/image";
|
||||||
|
import compress from "astro-compress";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remark plugins
|
||||||
|
*/
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import remarkSmartypants from "remark-smartypants";
|
import remarkSmartypants from "remark-smartypants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rehype plugins
|
||||||
|
*/
|
||||||
import rehypeSlug from "rehype-slug";
|
import rehypeSlug from "rehype-slug";
|
||||||
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||||
|
|
||||||
@ -27,12 +39,13 @@ export default defineConfig({
|
|||||||
i18n: {
|
i18n: {
|
||||||
defaultLocale: "ru",
|
defaultLocale: "ru",
|
||||||
locales: {
|
locales: {
|
||||||
ru: "ru-RU",
|
ru: "ru",
|
||||||
en: "en-US",
|
en: "en",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// image(),
|
image(),
|
||||||
|
compress(),
|
||||||
mdx({
|
mdx({
|
||||||
remarkPlugins: [remarkGfm, remarkSmartypants],
|
remarkPlugins: [remarkGfm, remarkSmartypants],
|
||||||
rehypePlugins: [
|
rehypePlugins: [
|
||||||
@ -77,7 +90,7 @@ export default defineConfig({
|
|||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
trailingSlash: "always",
|
trailingSlash: "ignore",
|
||||||
markdown: {
|
markdown: {
|
||||||
syntaxHighlight: "prism",
|
syntaxHighlight: "prism",
|
||||||
},
|
},
|
||||||
@ -86,7 +99,6 @@ export default defineConfig({
|
|||||||
external: ["svgo"],
|
external: ["svgo"],
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
minify: "esbuild",
|
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: `[name].[hash].js`,
|
entryFileNames: `[name].[hash].js`,
|
||||||
|
|||||||
2
content
2
content
@ -1 +1 @@
|
|||||||
Subproject commit 702becf6781d2b2a5496b185f2bf3bf18b06cec5
|
Subproject commit 50ba0addfe113ad1d531d62fe0b516e1cdfa589c
|
||||||
939
package-lock.json
generated
939
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "yarkov.tech",
|
"name": "yarkov.tech",
|
||||||
"version": "0.0.1",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Personal website created with Astro.",
|
"description": "Personal website created with Astro.",
|
||||||
"author": {
|
"author": {
|
||||||
@ -15,33 +15,35 @@
|
|||||||
"start": "astro dev",
|
"start": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview --host",
|
"preview": "astro preview --host",
|
||||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json}\"",
|
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,md,mdx}\"",
|
||||||
"lint": "eslint '**/*.{js,mjs,ts,tsx}'"
|
"lint": "eslint '**/*.{js,mjs,ts,tsx}'"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@astrojs/image": "^0.3.4",
|
"@astrojs/image": "^0.3.6",
|
||||||
"@astrojs/mdx": "^0.10.0",
|
"@astrojs/mdx": "^0.10.2",
|
||||||
"@astrojs/rss": "^1.0.0",
|
"@astrojs/rss": "^1.0.0",
|
||||||
"@astrojs/sitemap": "^1.0.0",
|
"@astrojs/sitemap": "^1.0.0",
|
||||||
"@astrojs/solid-js": "^1.0.0",
|
"@astrojs/solid-js": "^1.1.0",
|
||||||
"@types/mdx": "^2.0.2",
|
"@types/mdx": "^2.0.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.33.1",
|
"@typescript-eslint/eslint-plugin": "^5.35.1",
|
||||||
"@typescript-eslint/parser": "^5.33.1",
|
"@typescript-eslint/parser": "^5.35.1",
|
||||||
"astro": "^1.0.6",
|
"astro": "^1.1.1",
|
||||||
"astro-compress": "^1.0.1",
|
"astro-compress": "^1.0.5",
|
||||||
"astro-icon": "^0.7.3",
|
"astro-icon": "^0.7.3",
|
||||||
"eslint": "^8.22.0",
|
"eslint": "^8.23.0",
|
||||||
"eslint-plugin-solid": "^0.7.1",
|
"eslint-plugin-solid": "^0.7.1",
|
||||||
"hast-util-to-string": "^2.0.0",
|
"hast-util-to-string": "^2.0.0",
|
||||||
"hastscript": "^7.0.2",
|
"hastscript": "^7.0.2",
|
||||||
"html-escaper": "^3.0.3",
|
"html-escaper": "^3.0.3",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
|
"netlify-plugin-minify-html": "^0.3.1",
|
||||||
"prettier": "^2.7.1",
|
"prettier": "^2.7.1",
|
||||||
"prettier-plugin-astro": "^0.5.0",
|
"prettier-plugin-astro": "^0.5.3",
|
||||||
"rehype-autolink-headings": "^6.1.1",
|
"rehype-autolink-headings": "^6.1.1",
|
||||||
"rehype-slug": "^5.0.1",
|
"rehype-slug": "^5.0.1",
|
||||||
"remark-gfm": "^3.0.1",
|
"remark-gfm": "^3.0.1",
|
||||||
"remark-smartypants": "^2.0.0",
|
"remark-smartypants": "^2.0.0",
|
||||||
"sass": "^1.54.4",
|
"sass": "^1.54.5",
|
||||||
"solid-js": "^1.4.8"
|
"solid-js": "^1.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 59 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB |
@ -1,6 +1,4 @@
|
|||||||
# Disallow all at the time of development
|
|
||||||
|
|
||||||
User-agent: *
|
User-agent: *
|
||||||
Disallow: /
|
Allow: /
|
||||||
|
|
||||||
Sitemap: https://yarkov.tech/sitemap-index.xml
|
Sitemap: https://yarkov.tech/sitemap-index.xml
|
||||||
@ -1,5 +1,4 @@
|
|||||||
---
|
---
|
||||||
import { UTTERANCES_CONFIG } from "@root/config";
|
|
||||||
import UIString from "../UIString";
|
import UIString from "../UIString";
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -8,21 +7,22 @@ import UIString from "../UIString";
|
|||||||
<div id="comments"></div>
|
<div id="comments"></div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script define:vars={{ UTTERANCES_CONFIG }}>
|
<script>
|
||||||
const $commentsWrapper = document.getElementById("comments");
|
const $commentsWrapper = document.getElementById("comments");
|
||||||
const $commentsScript = document.createElement("script");
|
const $commentsScript = document.createElement("script");
|
||||||
|
|
||||||
$commentsScript.async = true;
|
$commentsScript.async = true;
|
||||||
$commentsScript.src = "https://utteranc.es/client.js";
|
$commentsScript.src = "https://utteranc.es/client.js";
|
||||||
$commentsScript.setAttribute("repo", UTTERANCES_CONFIG.repo);
|
$commentsScript.setAttribute("repo", "sergeyyarkov/comments");
|
||||||
$commentsScript.setAttribute("issue-term", UTTERANCES_CONFIG.issueTerm);
|
$commentsScript.setAttribute("issue-term", "title");
|
||||||
|
$commentsScript.setAttribute("label", "Comment");
|
||||||
$commentsScript.setAttribute("id", "utterances");
|
$commentsScript.setAttribute("id", "utterances");
|
||||||
$commentsScript.setAttribute("crossorigin", "anonymous");
|
$commentsScript.setAttribute("crossorigin", "anonymous");
|
||||||
|
|
||||||
if (window.theme) {
|
if (window.theme) {
|
||||||
$commentsScript.setAttribute("theme", theme === "dark" ? "dark-blue" : "github-light");
|
$commentsScript.setAttribute("theme", theme === "dark" ? "dark-blue" : "github-light");
|
||||||
} else {
|
} else {
|
||||||
$commentsScript.setAttribute("theme", UTTERANCES_CONFIG.theme);
|
$commentsScript.setAttribute("theme", "github-light");
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($commentsWrapper) $commentsWrapper.appendChild($commentsScript);
|
if ($commentsWrapper) $commentsWrapper.appendChild($commentsScript);
|
||||||
|
|||||||
@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
& q {
|
& q {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
quotes: "«" "»";
|
quotes: "« " " »";
|
||||||
}
|
}
|
||||||
|
|
||||||
& a {
|
& a {
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export const ArticleItem: Component<ArticleItemProps> = (props) => {
|
|||||||
const hrefLangPrefix = props.data.lang && props.data.lang !== DEFAULT_LANGUAGE ? "/en" : "";
|
const hrefLangPrefix = props.data.lang && props.data.lang !== DEFAULT_LANGUAGE ? "/en" : "";
|
||||||
return (
|
return (
|
||||||
<article class="article-item">
|
<article class="article-item">
|
||||||
<a href={`/blog/${new Date(props.data.published_at).toLocaleDateString("en-CA")}${hrefLangPrefix}/${props.data.slug}/`}>
|
<a href={`${hrefLangPrefix}/blog/${new Date(props.data.published_at).toLocaleDateString("en-CA")}/${props.data.slug}/`}>
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
<h3>{props.data.title}</h3>
|
<h3>{props.data.title}</h3>
|
||||||
{isNonDefaultLanguage && <sup>{props.data.lang.toLocaleUpperCase()}</sup>}
|
{isNonDefaultLanguage && <sup>{props.data.lang.toLocaleUpperCase()}</sup>}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
// import { Image } from "@astrojs/image/components";
|
import { Image, Picture } from "@astrojs/image/components";
|
||||||
// import * as utils from "@utils/string";
|
|
||||||
import { createDateFormatter } from "@root/utils";
|
import { createDateFormatter } from "@root/utils";
|
||||||
import ArticleComments from "@components/ArticleComments";
|
import ArticleComments from "@components/ArticleComments";
|
||||||
import "./ArticlePost.scss";
|
import "./ArticlePost.scss";
|
||||||
@ -39,13 +38,15 @@ const { title, coverImage, tags, author, published_at } = data;
|
|||||||
coverImage && (
|
coverImage && (
|
||||||
<div>
|
<div>
|
||||||
<figure>
|
<figure>
|
||||||
{coverImage && <img src={coverImage} width='700' height='400' alt={title} />}
|
<Picture
|
||||||
{/* Render remote or local image */}
|
itemprop="image"
|
||||||
{/* utils.isURL(coverImage) ? (
|
src={coverImage}
|
||||||
<Image itemprop="image" src={coverImage} width={700} height={400} aspectRatio={"16:9"} format={"webp"} alt={data.title} />
|
sizes="(max-width: 500px) 500px, 700px"
|
||||||
) : (
|
widths={[500, 700]}
|
||||||
<Image itemprop="image" src={cover.default.src} width={700} height={400} aspectRatio={"16:9"} format={"webp"} alt={data.title} />
|
formats={["webp"]}
|
||||||
)*/}
|
aspectRatio={"16:9"}
|
||||||
|
alt={title}
|
||||||
|
/>
|
||||||
</figure>
|
</figure>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -5,10 +5,11 @@ import { useTranslation } from "@i18n/utils";
|
|||||||
export interface Props {
|
export interface Props {
|
||||||
title?: string | undefined;
|
title?: string | undefined;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
|
canonicalUrl?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = useTranslation(Astro);
|
const t = useTranslation(Astro);
|
||||||
const { title = SITE_METADATA.title, description } = Astro.props as Props;
|
const { title = SITE_METADATA.title, description, canonicalUrl } = Astro.props as Props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
@ -16,13 +17,16 @@ const { title = SITE_METADATA.title, description } = Astro.props as Props;
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono&display=swap"
|
||||||
rel="preload" as="style" onload="this.rel='stylesheet'"
|
rel="preload"
|
||||||
|
as="style"
|
||||||
|
onload="this.rel='stylesheet'"
|
||||||
/>
|
/>
|
||||||
|
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
|
||||||
<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}</title>
|
||||||
<meta name="title" content={title} />
|
<meta name="title" content={title} />
|
||||||
|
<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} />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
|
|||||||
@ -2,12 +2,12 @@
|
|||||||
import { IS_SITE_UNDER_CONSTRUCTION } from "@root/config";
|
import { IS_SITE_UNDER_CONSTRUCTION } from "@root/config";
|
||||||
import Header from "@components/Header";
|
import Header from "@components/Header";
|
||||||
import Footer from "@components/Footer";
|
import Footer from "@components/Footer";
|
||||||
|
import SiteContent from "@components/SiteContent";
|
||||||
import HeaderBanner from "@components/HeaderBanner";
|
import HeaderBanner from "@components/HeaderBanner";
|
||||||
import UIString from "@components/UIString";
|
import UIString from "@components/UIString";
|
||||||
import "./BaseLayout.scss";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<body>
|
<body data-is-site-under-construction={IS_SITE_UNDER_CONSTRUCTION && "true"}>
|
||||||
{
|
{
|
||||||
IS_SITE_UNDER_CONSTRUCTION && (
|
IS_SITE_UNDER_CONSTRUCTION && (
|
||||||
<HeaderBanner>
|
<HeaderBanner>
|
||||||
@ -18,8 +18,8 @@ import "./BaseLayout.scss";
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
<Header />
|
<Header />
|
||||||
<main class="site-content">
|
<SiteContent>
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</SiteContent>
|
||||||
<Footer />
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -1,11 +0,0 @@
|
|||||||
.site-content {
|
|
||||||
flex: 1 0 auto;
|
|
||||||
width: 100%;
|
|
||||||
margin-top: 5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
|
||||||
.site-content {
|
|
||||||
margin-top: 3rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
27
src/components/Button/Button.astro
Normal file
27
src/components/Button/Button.astro
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
export type Props = astroHTML.JSX.ButtonHTMLAttributes;
|
||||||
|
|
||||||
|
const attrs = Astro.props as Props;
|
||||||
|
---
|
||||||
|
|
||||||
|
<button {...attrs}>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
button {
|
||||||
|
display: inline-flex;
|
||||||
|
column-gap: 7px;
|
||||||
|
font-family: var(--font-family-base);
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
&:hover {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,28 +0,0 @@
|
|||||||
.btn {
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
column-gap: 7px;
|
|
||||||
align-items: center;
|
|
||||||
font-family: var(--font-family-base);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: var(--font-size-md);
|
|
||||||
color: var(--font-color);
|
|
||||||
padding: 5px 16px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
background: none;
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
&:hover {
|
|
||||||
border: 1px solid var(--border-color-hover);
|
|
||||||
}
|
|
||||||
&[disabled] {
|
|
||||||
opacity: 0.5;
|
|
||||||
&:hover {
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&[data-loading="true"] {
|
|
||||||
user-select: none;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import type { ParentComponent, JSX } from "solid-js";
|
|
||||||
|
|
||||||
import { splitProps } from "solid-js";
|
|
||||||
import Spinner from "@components/Spinner";
|
|
||||||
import "./Button.scss";
|
|
||||||
|
|
||||||
type ButtonProps = {
|
|
||||||
isLoading?: boolean | undefined;
|
|
||||||
loadingText?: string | undefined;
|
|
||||||
} & JSX.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
||||||
|
|
||||||
const Button: ParentComponent<ButtonProps> = (props) => {
|
|
||||||
const [local, attrs] = splitProps(props, ["children", "isLoading", "loadingText"]);
|
|
||||||
return (
|
|
||||||
<button disabled={local.isLoading} class={`btn`} data-loading={local.isLoading} {...attrs}>
|
|
||||||
{local.isLoading && <Spinner />}
|
|
||||||
{local.isLoading ? local.loadingText || "Loading..." : local.children}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Button;
|
|
||||||
@ -1 +1 @@
|
|||||||
export { default } from "./Button";
|
export { default } from "./Button.astro";
|
||||||
|
|||||||
58
src/components/ContactForm/ContactForm.astro
Normal file
58
src/components/ContactForm/ContactForm.astro
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
import Button from "@components/Button";
|
||||||
|
import { useTranslation } from "@i18n/utils";
|
||||||
|
|
||||||
|
const t = useTranslation(Astro);
|
||||||
|
---
|
||||||
|
|
||||||
|
<form name="contact" method="post" netlify-honeypot="bot-field" data-netlify="true">
|
||||||
|
<div hidden>
|
||||||
|
Don't fill this out if you're human
|
||||||
|
<input name="bot-field" />
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<div>
|
||||||
|
<input name="name" placeholder={t("input.nameField")} type="text" maxlength="20" autocomplete="off" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input name="email" placeholder="example@email.com" autocomplete="off" type="email" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input name="subject" placeholder={t("input.subjectField")} type="text" autocomplete="off" required />
|
||||||
|
<textarea name="message" placeholder={t("input.messageField")} id="message" autocomplete="off" cols={30} rows={10} required></textarea>
|
||||||
|
<div class="footer">
|
||||||
|
<Button type="submit">{t("btn.submit")}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 7px;
|
||||||
|
margin-bottom: 3.125rem;
|
||||||
|
|
||||||
|
& .flex {
|
||||||
|
column-gap: 10px;
|
||||||
|
& div {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& input.error-input,
|
||||||
|
& textarea.error-input {
|
||||||
|
border: 1px solid var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
& .footer {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 560px) {
|
||||||
|
.contact-form .flex {
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,140 +0,0 @@
|
|||||||
import type { Component, JSX } from "solid-js";
|
|
||||||
|
|
||||||
import { createSignal } from "solid-js";
|
|
||||||
import { createStore } from "solid-js/store";
|
|
||||||
import { useForm } from "@hooks/index";
|
|
||||||
import { encode } from "@utils/url";
|
|
||||||
import Button from "@components/Button";
|
|
||||||
import validations from "./validations";
|
|
||||||
import Submitted from "./Submitted";
|
|
||||||
import Error from "./Error";
|
|
||||||
import "./ContactForm.scss";
|
|
||||||
|
|
||||||
type UiStringsType = {
|
|
||||||
"form.contactSuccess": string;
|
|
||||||
"form.contactError": string;
|
|
||||||
"input.nameField": string;
|
|
||||||
"input.subjectField": string;
|
|
||||||
"input.messageField": string;
|
|
||||||
"btn.send": string;
|
|
||||||
"btn.sending": string;
|
|
||||||
"error.invalidEmail": string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ErrorMessage: Component<{ message: string }> = (props) => <span class="error-message">{props.message}</span>;
|
|
||||||
|
|
||||||
const ContactForm: Component<{ i18n: UiStringsType }> = (props) => {
|
|
||||||
const { formSubmit, validate, errors } = useForm({ errorClass: "error-input" });
|
|
||||||
const [isSubmitted, setIsSubmitted] = createSignal<boolean>(false);
|
|
||||||
const [isLoading, setIsLoading] = createSignal<boolean>(false);
|
|
||||||
const [isError, setIsError] = createSignal<boolean>(false);
|
|
||||||
const [state, setState] = createStore<ContactFieldsType>({ name: "", email: "", subject: "", message: "", "bot-field": "" });
|
|
||||||
|
|
||||||
const { validateEmail } = validations;
|
|
||||||
|
|
||||||
const reset = (form: HTMLFormElement) => {
|
|
||||||
for (const key in state) {
|
|
||||||
form.elements[key].value = "";
|
|
||||||
setState({ [key]: "" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendMessage = async (form: HTMLFormElement) => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
fetch("/", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
||||||
body: encode({
|
|
||||||
"form-name": "contact",
|
|
||||||
...state,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
setIsError(true);
|
|
||||||
console.error(error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
setIsSubmitted(true);
|
|
||||||
reset(form);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onChange: JSX.EventHandler<HTMLInputElement, InputEvent> = (e) => {
|
|
||||||
const key = e.currentTarget.name;
|
|
||||||
const value = e.currentTarget.value;
|
|
||||||
|
|
||||||
if (!Object.hasOwn(state, key)) return;
|
|
||||||
setState({ [key]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form use:formSubmit={sendMessage} class="contact-form" name="contact" method="post" data-netlify="true">
|
|
||||||
<div hidden>
|
|
||||||
Don't fill this out if you're human
|
|
||||||
<input onInput={onChange} name="bot-field" />
|
|
||||||
</div>
|
|
||||||
<div class="flex">
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
onInput={onChange}
|
|
||||||
use:validate
|
|
||||||
name="name"
|
|
||||||
placeholder={props.i18n["input.nameField"]}
|
|
||||||
type="text"
|
|
||||||
maxLength="20"
|
|
||||||
autocomplete="off"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{errors.name && <ErrorMessage message={errors.name} />}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
onInput={onChange}
|
|
||||||
use:validate={[validateEmail(props.i18n["error.invalidEmail"])]}
|
|
||||||
name="email"
|
|
||||||
placeholder="example@email.com"
|
|
||||||
autocomplete="off"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{errors.email && <ErrorMessage message={errors.email} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
value={state.subject}
|
|
||||||
onInput={onChange}
|
|
||||||
use:validate
|
|
||||||
name="subject"
|
|
||||||
placeholder={props.i18n["input.subjectField"]}
|
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{errors.subject && <ErrorMessage message={errors.subject} />}
|
|
||||||
<textarea
|
|
||||||
onInput={onChange}
|
|
||||||
use:validate
|
|
||||||
name="message"
|
|
||||||
placeholder={props.i18n["input.messageField"]}
|
|
||||||
id="message"
|
|
||||||
autocomplete="off"
|
|
||||||
cols={30}
|
|
||||||
rows={10}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{errors.message && <ErrorMessage message={errors.message} />}
|
|
||||||
<div class="form-footer">
|
|
||||||
{!isError() && !isSubmitted() && (
|
|
||||||
<Button isLoading={isLoading()} loadingText={props.i18n["btn.sending"]} type="submit">
|
|
||||||
{props.i18n["btn.send"]}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{!isError() && isSubmitted() && <Submitted text={props.i18n["form.contactSuccess"]} />}
|
|
||||||
{isError() && isSubmitted() && <Error text={props.i18n["form.contactError"]} />}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContactForm;
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import type { Component } from "solid-js";
|
|
||||||
|
|
||||||
const Submitted: Component<{ text: string }> = (props) => {
|
|
||||||
return <div class="form-result">❌ {props.text}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Submitted;
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import type { Component } from "solid-js";
|
|
||||||
|
|
||||||
const Submitted: Component<{ text: string }> = (props) => {
|
|
||||||
return <div class="form-result">✔️ {props.text}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Submitted;
|
|
||||||
@ -1 +1 @@
|
|||||||
export { default } from "./ContactForm";
|
export { default } from "./ContactForm.astro";
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
import { isEmail } from "@utils/string";
|
|
||||||
|
|
||||||
export default {
|
|
||||||
validateEmail: (error: string) => {
|
|
||||||
return (field: HTMLInputElement) => (isEmail(field.value) ? false : error);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,24 +1,24 @@
|
|||||||
---
|
---
|
||||||
import { SITE_METADATA } from '@root/config';
|
import { SITE_METADATA } from "@root/config";
|
||||||
import { Icon } from 'astro-icon';
|
import { Icon } from "astro-icon";
|
||||||
import './ContactsList.scss';
|
import "./ContactsList.scss";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
contacts: { email, githubUrl, telegramUrl },
|
contacts: { email, githubUrl, telegramUrl },
|
||||||
} = SITE_METADATA;
|
} = SITE_METADATA;
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class='contacts-list'>
|
<div class="contacts-list">
|
||||||
<a href={'mailto:' + email}
|
<a href={"mailto:" + email}
|
||||||
>Email
|
>Email
|
||||||
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
|
<Icon name="arrow-up-right" width="15" height="15" data-filter="none" />
|
||||||
</a>
|
</a>
|
||||||
<a href={githubUrl} target='_blank'
|
<a href={githubUrl} target="_blank"
|
||||||
>Github
|
>Github
|
||||||
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
|
<Icon name="arrow-up-right" width="15" height="15" data-filter="none" />
|
||||||
</a>
|
</a>
|
||||||
<a href={telegramUrl} target='_blank'
|
<a href={telegramUrl} target="_blank"
|
||||||
>Telegram
|
>Telegram
|
||||||
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
|
<Icon name="arrow-up-right" width="15" height="15" data-filter="none" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
.counter {
|
|
||||||
margin: 2rem 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
row-gap: 15px;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: var(--border-radius-md);
|
|
||||||
padding: 15px 5px;
|
|
||||||
& div {
|
|
||||||
display: flex;
|
|
||||||
column-gap: 3px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +1,6 @@
|
|||||||
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 Button from "../Button";
|
|
||||||
import "./Counter.scss";
|
|
||||||
|
|
||||||
const Counter: Component<{ tick?: number }> = (props) => {
|
const Counter: Component<{ tick?: number }> = (props) => {
|
||||||
const [isRunned, setIsRunned] = createSignal<boolean>(false);
|
const [isRunned, setIsRunned] = createSignal<boolean>(false);
|
||||||
@ -22,21 +20,37 @@ const Counter: Component<{ tick?: number }> = (props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="counter">
|
<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>
|
<span>{count()}</span>
|
||||||
<div>
|
<div
|
||||||
<Button disabled={isRunned()} onClick={run}>
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
"column-gap": "3px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button class="btn" disabled={isRunned()} onClick={run}>
|
||||||
Run
|
Run
|
||||||
</Button>
|
</button>
|
||||||
<Button disabled={isRunned()} onClick={inc}>
|
<button class="btn" disabled={isRunned()} onClick={inc}>
|
||||||
+
|
+
|
||||||
</Button>
|
</button>
|
||||||
<Button disabled={isRunned()} onClick={dec}>
|
<button class="btn" disabled={isRunned()} onClick={dec}>
|
||||||
-
|
-
|
||||||
</Button>
|
</button>
|
||||||
<Button disabled={!isRunned()} onClick={stop}>
|
<button class="btn" disabled={!isRunned()} onClick={stop}>
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,24 +1,58 @@
|
|||||||
---
|
---
|
||||||
import './Footer.scss';
|
import { Icon } from "astro-icon";
|
||||||
import { Icon } from 'astro-icon'
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<footer class='footer'>
|
<footer>
|
||||||
<div class='container'>
|
<div class="container">
|
||||||
<ul class='footer__links'>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<a href='https://astro.build/' target='_blank'>Astro</a>
|
<a href="https://astro.build/" target="_blank">Astro</a>
|
||||||
<Icon name='astro' width='20' height='20' />
|
<Icon name="astro" width="20" height="20" />
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href='https://www.netlify.com/' target='_blank'>Netlify</a>
|
<a href="https://www.netlify.com/" target="_blank">Netlify</a>
|
||||||
<Icon name='netlify' width='20' height='20' />
|
<Icon name="netlify" width="20" height="20" />
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href='/rss.xml' target='_blank'>RSS</a>
|
<a href="/rss.xml" target="_blank">RSS</a>
|
||||||
<Icon name='rss' width='20' height='20' />
|
<Icon name="rss" width="20" height="20" />
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p class='footer__copy'>© Sergey Yarkov, 2022</p>
|
<p>© Sergey Yarkov, 2022</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& p {
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
.footer {
|
|
||||||
& .container {
|
|
||||||
margin-top: 5rem;
|
|
||||||
padding: 25px 0 10px 0;
|
|
||||||
font-weight: 500;
|
|
||||||
border-top: 1px solid var(--border-color);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
row-gap: 15px;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
&__links {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
column-gap: 25px;
|
|
||||||
list-style: none;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
& li {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
column-gap: 7px;
|
|
||||||
& a {
|
|
||||||
color: var(--font-color);
|
|
||||||
font-size: var(--font-size-sm);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&__copy {
|
|
||||||
text-align: center;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,37 +1,37 @@
|
|||||||
---
|
---
|
||||||
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/config";
|
||||||
|
import { removeSlashes } from "@utils/string";
|
||||||
import ThemeSwitcher from "@components/ThemeSwticher";
|
import ThemeSwitcher from "@components/ThemeSwticher";
|
||||||
import UIString from "@components/UIString";
|
import UIString from "@components/UIString";
|
||||||
import TranslatedLink from "@components/TranslatedLink";
|
import TranslatedLink from "@components/TranslatedLink";
|
||||||
import LanguageSelect from "@components/LanguageSelect";
|
import LanguageSelect from "@components/LanguageSelect";
|
||||||
import NavToggle from "./NavToggle";
|
import NavToggle from "./NavToggle";
|
||||||
import "./Header.scss";
|
|
||||||
|
|
||||||
const { name, nav, contacts } = SITE_METADATA;
|
const { name, nav, contacts } = SITE_METADATA;
|
||||||
const { pathname } = Astro.url;
|
const { pathname } = Astro.url;
|
||||||
---
|
---
|
||||||
|
|
||||||
<header class={`header${IS_SITE_UNDER_CONSTRUCTION ? " header-banner" : ""}`}>
|
<header {...(IS_SITE_UNDER_CONSTRUCTION ? { style: 'top: 45px;' } : {})}>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header__logo">
|
<div class="logo">
|
||||||
<TranslatedLink to={"/"}>
|
<TranslatedLink to={"/"}>
|
||||||
{name}
|
{name}
|
||||||
</TranslatedLink>
|
</TranslatedLink>
|
||||||
</div>
|
</div>
|
||||||
<nav class="header__nav">
|
<nav>
|
||||||
<ul class="header__nav-links">
|
<ul>
|
||||||
{
|
{
|
||||||
nav.map((link) => (
|
nav.map((link) => (
|
||||||
<li>
|
<li>
|
||||||
<TranslatedLink class={pathname.includes(link.url) && "active"} to={link.url}>
|
<TranslatedLink class={pathname.includes(removeSlashes(link.url)) && pathname.split("/").length < 5 && "active"} to={link.url}>
|
||||||
<UIString key={link.key} />
|
<UIString key={link.key} />
|
||||||
</TranslatedLink>
|
</TranslatedLink>
|
||||||
</li>
|
</li>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="header__nav-contacts">
|
<div class="contacts">
|
||||||
<a href={"mailto:" + contacts.email}>
|
<a href={"mailto:" + contacts.email}>
|
||||||
Email
|
Email
|
||||||
<Icon name="email" width="18" height="18" />
|
<Icon name="email" width="18" height="18" />
|
||||||
@ -45,7 +45,7 @@ const { pathname } = Astro.url;
|
|||||||
<Icon name="telegram" width="18" height="18" />
|
<Icon name="telegram" width="18" height="18" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex header__nav-settings">
|
<div class="flex settings">
|
||||||
<LanguageSelect />
|
<LanguageSelect />
|
||||||
<ThemeSwitcher client:visible />
|
<ThemeSwitcher client:visible />
|
||||||
</div>
|
</div>
|
||||||
@ -53,3 +53,123 @@ const { pathname } = Astro.url;
|
|||||||
<NavToggle client:visible />
|
<NavToggle client:visible />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
background-color: var(--background-header-color);
|
||||||
|
padding-top: 13.5px;
|
||||||
|
padding-bottom: 13.5px;
|
||||||
|
user-select: none;
|
||||||
|
z-index: 9999;
|
||||||
|
& .container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .logo a {
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
color: var(--font-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
& a:hover,
|
||||||
|
a.active {
|
||||||
|
color: var(--color-blue-500);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
text-underline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
& ul {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
column-gap: 20px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin-right: 35px;
|
||||||
|
& li a {
|
||||||
|
display: inline-block;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--font-color);
|
||||||
|
text-decoration: none;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& .contacts {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 10px;
|
||||||
|
margin-right: 30px;
|
||||||
|
& a {
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0;
|
||||||
|
&:hover {
|
||||||
|
opacity: var(--opacity-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& .settings {
|
||||||
|
column-gap: 13.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
header {
|
||||||
|
padding: 10px 0;
|
||||||
|
& nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body[data-is-mobile-open="true"] {
|
||||||
|
overflow-y: hidden;
|
||||||
|
touch-action: none;
|
||||||
|
& header {
|
||||||
|
padding: 15px 0;
|
||||||
|
background-color: var(--background-header-color);
|
||||||
|
position: fixed;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 9999;
|
||||||
|
& .container {
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 3rem;
|
||||||
|
}
|
||||||
|
& .logo {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
& nav {
|
||||||
|
display: block;
|
||||||
|
& ul {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
& li {
|
||||||
|
margin-bottom: 30px;
|
||||||
|
& a {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& .contacts {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-top: 2rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,144 +0,0 @@
|
|||||||
.header {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
background-color: var(--background-header-color);
|
|
||||||
padding-top: 13.5px;
|
|
||||||
padding-bottom: 13.5px;
|
|
||||||
user-select: none;
|
|
||||||
z-index: 9999;
|
|
||||||
& .container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
&__logo a {
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
color: var(--font-color);
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-thickness: 2px;
|
|
||||||
text-underline-offset: 5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&__nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
& ul {
|
|
||||||
display: flex;
|
|
||||||
list-style: none;
|
|
||||||
column-gap: 20px;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin-right: 35px;
|
|
||||||
& li a {
|
|
||||||
display: inline-block;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--font-color);
|
|
||||||
text-decoration: none;
|
|
||||||
text-transform: lowercase;
|
|
||||||
&:hover,
|
|
||||||
&.active {
|
|
||||||
color: var(--color-blue-500);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-decoration-thickness: 2px;
|
|
||||||
text-underline-offset: 4px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&-contacts {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
column-gap: 10px;
|
|
||||||
margin-right: 30px;
|
|
||||||
& a {
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0;
|
|
||||||
&:hover {
|
|
||||||
opacity: var(--opacity-hover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&-settings {
|
|
||||||
column-gap: 13.5px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
|
||||||
.header {
|
|
||||||
padding: 10px 0;
|
|
||||||
&__nav {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body[data-is-mobile-open="true"] {
|
|
||||||
overflow-y: hidden;
|
|
||||||
touch-action: none;
|
|
||||||
& .header {
|
|
||||||
padding: 15px 0;
|
|
||||||
background-color: var(--background-header-color);
|
|
||||||
position: fixed;
|
|
||||||
right: 0;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
bottom: 0;
|
|
||||||
height: 100%;
|
|
||||||
z-index: 9999;
|
|
||||||
& .container {
|
|
||||||
flex-direction: column;
|
|
||||||
row-gap: 3rem;
|
|
||||||
}
|
|
||||||
&__logo {
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
&__nav {
|
|
||||||
display: block;
|
|
||||||
&-links {
|
|
||||||
display: block;
|
|
||||||
text-align: center;
|
|
||||||
margin: 0;
|
|
||||||
& li {
|
|
||||||
margin-bottom: 30px;
|
|
||||||
& a {
|
|
||||||
font-size: var(--font-size-xl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&-contacts {
|
|
||||||
margin-right: 0;
|
|
||||||
margin-top: 2rem;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& .nav-toggle {
|
|
||||||
position: absolute;
|
|
||||||
right: var(--content-padding);
|
|
||||||
}
|
|
||||||
& .theme-switcher {
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
bottom: 45px;
|
|
||||||
right: 2.2rem;
|
|
||||||
& svg {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& .language-select {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 115px;
|
|
||||||
right: 2.2rem;
|
|
||||||
& svg,
|
|
||||||
& select {
|
|
||||||
height: 36px;
|
|
||||||
width: 36px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -13,3 +13,10 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-is-mobile-open="true"] {
|
||||||
|
.nav-toggle {
|
||||||
|
position: absolute;
|
||||||
|
right: var(--content-padding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,13 +1,9 @@
|
|||||||
<div class="banner-wrapper">
|
<div>
|
||||||
<span><slot /></span>
|
<span><slot /></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss" is:global>
|
<style lang="scss">
|
||||||
.header.header-banner {
|
div {
|
||||||
top: 45px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.banner-wrapper {
|
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
min-height: 45px;
|
min-height: 45px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -17,18 +13,4 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 99999;
|
z-index: 99999;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
|
||||||
body[data-is-mobile-open="true"] {
|
|
||||||
& .header.header-banner {
|
|
||||||
margin-top: 45px;
|
|
||||||
& .language-select {
|
|
||||||
bottom: 145px;
|
|
||||||
}
|
|
||||||
& .theme-switcher {
|
|
||||||
bottom: 80px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -42,6 +42,27 @@ const lang = getLanguageFromURL(Astro.url.pathname);
|
|||||||
border-radius: var(--border-radius-md);
|
border-radius: var(--border-radius-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-is-mobile-open="true"] {
|
||||||
|
.language-select {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 115px;
|
||||||
|
right: 2.2rem;
|
||||||
|
& svg,
|
||||||
|
& select {
|
||||||
|
height: 36px;
|
||||||
|
width: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
body[data-is-site-under-construction] {
|
||||||
|
.language-select {
|
||||||
|
bottom: 145px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script define:vars={{ lang, DEFAULT_LANGUAGE }}>
|
<script define:vars={{ lang, DEFAULT_LANGUAGE }}>
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
---
|
---
|
||||||
import "./PageHeading.scss";
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
heading: string;
|
heading: string;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
@ -10,8 +8,25 @@ const { heading, description } = Astro.props as Props;
|
|||||||
---
|
---
|
||||||
|
|
||||||
<div class="page-heading">
|
<div class="page-heading">
|
||||||
<h1 style={!description && "margin-bottom: 2.5rem;"}>
|
<h1>
|
||||||
{heading}
|
{heading}
|
||||||
</h1>
|
</h1>
|
||||||
{description && <p>{description}</p>}
|
{description && <p>{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
div {
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
& h1 {
|
||||||
|
font-size: var(--font-size-4xl);
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
& p {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 650px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
.page-heading {
|
|
||||||
& h1 {
|
|
||||||
font-size: var(--font-size-4xl);
|
|
||||||
text-align: center;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
& p {
|
|
||||||
font-size: var(--font-size-lg);
|
|
||||||
text-align: center;
|
|
||||||
max-width: 650px;
|
|
||||||
margin: 0 auto;
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import './ProjectsList.scss';
|
import "./ProjectsList.scss";
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class='projects-list'>
|
<div class="projects-list">
|
||||||
<slot></slot>
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -48,7 +48,6 @@ const SearchInput: Component<SearchInputProps> = (props) => {
|
|||||||
resetSearch();
|
resetSearch();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(param);
|
|
||||||
|
|
||||||
setSearch(param);
|
setSearch(param);
|
||||||
});
|
});
|
||||||
|
|||||||
17
src/components/SiteContent/SiteContent.astro
Normal file
17
src/components/SiteContent/SiteContent.astro
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<main>
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
flex: 1 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
main {
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
src/components/SiteContent/index.ts
Normal file
1
src/components/SiteContent/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./SiteContent.astro";
|
||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import './SkillsList.scss';
|
import "./SkillsList.scss";
|
||||||
---
|
---
|
||||||
|
|
||||||
<div class='skills-list'><slot></slot></div>
|
<div class="skills-list"><slot /></div>
|
||||||
|
|||||||
@ -1,26 +0,0 @@
|
|||||||
.spinner {
|
|
||||||
--spinner-size: 1em;
|
|
||||||
display: inline-block;
|
|
||||||
border-top: 1px solid var(--font-color);
|
|
||||||
border-right: 1px solid var(--font-color);
|
|
||||||
border-bottom-style: solid;
|
|
||||||
border-left-style: solid;
|
|
||||||
border-radius: 99999px;
|
|
||||||
border-bottom-width: 1px;
|
|
||||||
border-left-width: 1px;
|
|
||||||
border-bottom-color: transparent;
|
|
||||||
border-left-color: transparent;
|
|
||||||
animation: 0.45s linear 0s infinite normal none running spinner;
|
|
||||||
width: var(--spinner-size);
|
|
||||||
height: var(--spinner-size);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spinner {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import type { Component, JSX } from "solid-js";
|
|
||||||
|
|
||||||
import "./Spinner.scss";
|
|
||||||
|
|
||||||
type SpinnerProps = JSX.HTMLAttributes<HTMLDivElement>;
|
|
||||||
|
|
||||||
const Spinner: Component<SpinnerProps> = (props) => {
|
|
||||||
return <div class="spinner" {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Spinner;
|
|
||||||
@ -1 +0,0 @@
|
|||||||
export { default } from "./Spinner";
|
|
||||||
@ -6,7 +6,7 @@
|
|||||||
header {
|
header {
|
||||||
& :global(p) {
|
& :global(p) {
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
max-width: 570px;
|
max-width: 520px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -16,3 +16,16 @@
|
|||||||
transition: all 0.1s linear 0s;
|
transition: all 0.1s linear 0s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body[data-is-mobile-open="true"] {
|
||||||
|
.theme-switcher {
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 45px;
|
||||||
|
right: 2.2rem;
|
||||||
|
& svg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { IS_SITE_UNDER_CONSTRUCTION } from "@root/config";
|
||||||
import type { Component, JSX } from "solid-js";
|
import type { Component, JSX } from "solid-js";
|
||||||
|
|
||||||
import { createSignal, onMount, Switch, Match } from "solid-js";
|
import { createSignal, onMount, Switch, Match } from "solid-js";
|
||||||
@ -50,7 +51,12 @@ const ThemeSwitcher: Component = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button onClick={toggleTheme} class={isReady() ? "theme-switcher ready" : "theme-switcher"} aria-label={theme()}>
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
class={isReady() ? "theme-switcher ready" : "theme-switcher"}
|
||||||
|
aria-label={theme()}
|
||||||
|
{...(IS_SITE_UNDER_CONSTRUCTION ? { style: "bottom:80px;" } : {})}
|
||||||
|
>
|
||||||
<Switch
|
<Switch
|
||||||
children={
|
children={
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -1,14 +1,7 @@
|
|||||||
export const DEFAULT_LANGUAGE: LanguageKeys = "ru";
|
export const DEFAULT_LANGUAGE: LanguageKeys = "ru";
|
||||||
|
|
||||||
/* Show banner about website development */
|
/* Show banner about website development */
|
||||||
export const IS_SITE_UNDER_CONSTRUCTION = true;
|
export const IS_SITE_UNDER_CONSTRUCTION = import.meta.env.IS_SITE_UNDER_CONSTRUCTION === "true" ? true : false;
|
||||||
|
|
||||||
export const UTTERANCES_CONFIG: UtterancesConfigType = {
|
|
||||||
repo: "sergeyyarkov/comments",
|
|
||||||
issueTerm: "title",
|
|
||||||
label: "Comments",
|
|
||||||
theme: "github-light",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SITE_METADATA: SiteMetadataType = {
|
export const SITE_METADATA: SiteMetadataType = {
|
||||||
title: "Sergey Yarkov",
|
title: "Sergey Yarkov",
|
||||||
@ -32,8 +25,8 @@ export const SITE_METADATA: SiteMetadataType = {
|
|||||||
name: "yarkov.tech",
|
name: "yarkov.tech",
|
||||||
year: "2022",
|
year: "2022",
|
||||||
description: {
|
description: {
|
||||||
ru: "Мой персональный веб-сайт где находятся информация обо мне и список различных статей, туториалов...",
|
ru: "Мой персональный веб-сайт где находятся информация обо мне и список различных статей и туториалов...",
|
||||||
en: "My personal website where you can find information about me and a list of various articles, tutorials...",
|
en: "My personal website where you can find information about me and a list of various articles and tutorials...",
|
||||||
},
|
},
|
||||||
sourceUrl: "https://github.com/sergeyyarkov/yarkov.tech",
|
sourceUrl: "https://github.com/sergeyyarkov/yarkov.tech",
|
||||||
demoUrl: "https://yarkov.tech",
|
demoUrl: "https://yarkov.tech",
|
||||||
@ -48,7 +41,7 @@ export const SITE_METADATA: SiteMetadataType = {
|
|||||||
en: "Static website for publishing various articles, developed with Gatsby.js and NetlifyCMS",
|
en: "Static website for publishing various articles, developed with Gatsby.js and NetlifyCMS",
|
||||||
},
|
},
|
||||||
sourceUrl: "https://github.com/sergeyyarkov/devthread.ru",
|
sourceUrl: "https://github.com/sergeyyarkov/devthread.ru",
|
||||||
demoUrl: "https://devthread.netlify.app",
|
demoUrl: "https://devthread.yarkov.tech",
|
||||||
articleUrl: undefined,
|
articleUrl: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -60,7 +53,7 @@ export const SITE_METADATA: SiteMetadataType = {
|
|||||||
en: "Learning Management System (LMS) to publish your courses, lectures, lessons",
|
en: "Learning Management System (LMS) to publish your courses, lectures, lessons",
|
||||||
},
|
},
|
||||||
sourceUrl: "https://github.com/sergeyyarkov/educt",
|
sourceUrl: "https://github.com/sergeyyarkov/educt",
|
||||||
demoUrl: "https://client-educt-lms.netlify.app",
|
demoUrl: "https://educt.yarkov.tech",
|
||||||
articleUrl: undefined,
|
articleUrl: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
3
src/env.d.ts
vendored
Normal file
3
src/env.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly IS_SITE_UNDER_CONSTRUCTION: string;
|
||||||
|
}
|
||||||
@ -1 +0,0 @@
|
|||||||
export * from "./useForm";
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
import type { Accessor } from "solid-js";
|
|
||||||
|
|
||||||
import { createStore, SetStoreFunction } from "solid-js/store";
|
|
||||||
|
|
||||||
type HTMLElementsType = HTMLInputElement | HTMLTextAreaElement;
|
|
||||||
type CustomValidatorType = (element: HTMLElementsType) => string | false;
|
|
||||||
type FieldConfigType = { element: HTMLElementsType; validators: Array<CustomValidatorType> };
|
|
||||||
type FormSubmitFnType = (ref: HTMLFormElement, accessor: Accessor<(form: HTMLFormElement) => unknown>) => void;
|
|
||||||
type ValidateFnType = (ref: HTMLElementsType, accessor: Accessor<Array<CustomValidatorType>>) => void;
|
|
||||||
type СheckValidFnType = (config: FieldConfigType, setErrors: SetStoreFunction<Record<string, unknown>>, errorClass: string) => void;
|
|
||||||
|
|
||||||
const checkValid: СheckValidFnType = (fieldConfig, setErrors, errorClass) => {
|
|
||||||
const { element, validators = [] } = fieldConfig;
|
|
||||||
|
|
||||||
element.setCustomValidity("");
|
|
||||||
element.checkValidity();
|
|
||||||
let message = element.validationMessage;
|
|
||||||
|
|
||||||
/* Run custom validators */
|
|
||||||
if (Array.isArray(validators)) {
|
|
||||||
if (!message) {
|
|
||||||
for (const validator of validators) {
|
|
||||||
if (typeof validator === "function") {
|
|
||||||
const text = validator(element);
|
|
||||||
if (text) {
|
|
||||||
element.setCustomValidity(text);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
message = element.validationMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Set errors if message exists and class to input element */
|
|
||||||
if (message) {
|
|
||||||
errorClass && element.classList.toggle(errorClass, true);
|
|
||||||
setErrors({ [element.name]: message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useForm = ({ errorClass }) => {
|
|
||||||
const [errors, setErrors] = createStore<{ [key: string]: string | undefined }>({});
|
|
||||||
const fields = {};
|
|
||||||
|
|
||||||
const validate: ValidateFnType = (ref, accessor) => {
|
|
||||||
const validators = accessor();
|
|
||||||
let config: FieldConfigType;
|
|
||||||
fields[ref.name] = config = { element: ref, validators };
|
|
||||||
ref.onblur = () => checkValid(config, setErrors, errorClass);
|
|
||||||
ref.oninput = () => {
|
|
||||||
if (!errors[ref.name]) return;
|
|
||||||
setErrors({ [ref.name]: undefined });
|
|
||||||
errorClass && ref.classList.toggle(errorClass, false);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const formSubmit: FormSubmitFnType = (ref, accessor) => {
|
|
||||||
const callback = accessor();
|
|
||||||
ref.setAttribute("novalidate", "");
|
|
||||||
ref.onsubmit = (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
let isError = false;
|
|
||||||
|
|
||||||
/* Validate all fields of form */
|
|
||||||
for (const key in fields) {
|
|
||||||
const field = fields[key] as FieldConfigType;
|
|
||||||
checkValid(field, setErrors, errorClass);
|
|
||||||
if (!isError && field.element.validationMessage) {
|
|
||||||
field.element.focus();
|
|
||||||
isError = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof callback === "function") !isError && callback(ref);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return { validate, formSubmit, errors };
|
|
||||||
};
|
|
||||||
@ -1,8 +1,9 @@
|
|||||||
export default {
|
export default {
|
||||||
"site.title": "yarkov.tech",
|
"site.title": "yarkov.tech",
|
||||||
"site.description":
|
"site.description":
|
||||||
"Here I publish articles and tutorials on various IT topics, as well as talk about my projects that I have done over the past few years.",
|
"Here I publish articles and tutorials on various IT topics, and I also talk about my projects that I have done over the past few years.",
|
||||||
"site.underConstruction": "This web-site is under development...",
|
"site.underConstruction": "This web-site is under development...",
|
||||||
|
"article.availableOn": "Available on",
|
||||||
"articles.empty": "No articles found...",
|
"articles.empty": "No articles found...",
|
||||||
"articlePost.comments": "Comments",
|
"articlePost.comments": "Comments",
|
||||||
"recentArticles.empty": "There is nothing here yet...",
|
"recentArticles.empty": "There is nothing here yet...",
|
||||||
@ -13,18 +14,18 @@ export default {
|
|||||||
"pageTitle.contacts": "Contacts",
|
"pageTitle.contacts": "Contacts",
|
||||||
/* Page descriptions */
|
/* Page descriptions */
|
||||||
"pageDescription.articles": "Technical articles, tutorials and various reference materials related to IT topics can be found here.",
|
"pageDescription.articles": "Technical articles, tutorials and various reference materials related to IT topics can be found here.",
|
||||||
"pageDescription.projects": "A few of my main projects that have been developed and made available to the public.",
|
"pageDescription.projects": "A few of my main projects that have been developed and published in open access.",
|
||||||
"pageDescription.contacts": "Use the feedback form or write to me on social networks.",
|
"pageDescription.contacts": "Use the feedback form or write to me on social networks.",
|
||||||
/* Navigation */
|
/* Navigation */
|
||||||
"navigation.articles": "Articles",
|
"navigation.articles": "Articles",
|
||||||
"navigation.projects": "Projects",
|
"navigation.projects": "Projects",
|
||||||
"navigation.about": "About",
|
"navigation.about": "About me",
|
||||||
"navigation.contacts": "Contacts",
|
"navigation.contacts": "Contacts",
|
||||||
/* Splash page */
|
/* Home page */
|
||||||
"splash.headline": "Hello, I'am Sergey",
|
"splash.headline": "Hello, I'am Sergey",
|
||||||
"splash.subheadline": "Fullstack web-developer & freelancer",
|
"splash.subheadline": "Fullstack web-developer & freelancer",
|
||||||
"splash.description":
|
"splash.description":
|
||||||
"I develop websites and web applications of varying complexity, as well as publish various articles and tutorials on various IT topics.",
|
"I develop websites and web applications of varying complexity, and also publish articles and tutorials on various IT topics.",
|
||||||
"splash.about": "about me",
|
"splash.about": "about me",
|
||||||
"splash.contact": "write me",
|
"splash.contact": "write me",
|
||||||
/* Recent articles */
|
/* Recent articles */
|
||||||
@ -34,8 +35,8 @@ export default {
|
|||||||
"404.heading": "404",
|
"404.heading": "404",
|
||||||
"404.description": "The page you are trying to find does not exist.",
|
"404.description": "The page you are trying to find does not exist.",
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
"btn.send": "Submit",
|
"btn.submit": "Submit",
|
||||||
"btn.sending": "Submitting...",
|
"btn.submiting": "Submitting...",
|
||||||
"btn.sourceCode": "Source",
|
"btn.sourceCode": "Source",
|
||||||
"btn.demo": "Demo",
|
"btn.demo": "Demo",
|
||||||
"btn.article": "Article",
|
"btn.article": "Article",
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
export default {
|
export default {
|
||||||
"site.title": "yarkov.tech",
|
"site.title": "yarkov.tech",
|
||||||
"site.description":
|
"site.description":
|
||||||
"Здесь я публикую статьи и туториалы на различные IT темы, а также рассказываю о своих проектах которые я сделал за последние несколько лет.",
|
"Здесь я публикую статьи и туториалы на разные IT темы, а также рассказываю о своих проектах которые я сделал за последние несколько лет.",
|
||||||
"site.underConstruction": "Этот веб-сайт находится в разработке...",
|
"site.underConstruction": "Этот веб-сайт находится в разработке...",
|
||||||
|
"article.availableOn": "Доступно на",
|
||||||
"articles.empty": "Не найдено ни одной статьи...",
|
"articles.empty": "Не найдено ни одной статьи...",
|
||||||
"articlePost.comments": "Комментарии",
|
"articlePost.comments": "Комментарии",
|
||||||
"recentArticles.empty": "Пока что здесь ничего нет...",
|
"recentArticles.empty": "Пока что здесь ничего нет...",
|
||||||
@ -20,11 +21,11 @@ export default {
|
|||||||
"navigation.projects": "Проекты",
|
"navigation.projects": "Проекты",
|
||||||
"navigation.about": "Обо мне",
|
"navigation.about": "Обо мне",
|
||||||
"navigation.contacts": "Контакты",
|
"navigation.contacts": "Контакты",
|
||||||
/* Splash page */
|
/* Home page */
|
||||||
"splash.headline": "Hello, I'am Sergey",
|
"splash.headline": "Hello, I'am Sergey",
|
||||||
"splash.subheadline": "Fullstack web-developer & freelancer",
|
"splash.subheadline": "Fullstack web-developer & freelancer",
|
||||||
"splash.description":
|
"splash.description":
|
||||||
"Я занимаюсь разработкой веб-сайтов и веб-приложений разной сложности, а также публикую различные статьи и туториалы на разные IT темы.",
|
"Я занимаюсь разработкой веб-сайтов и веб-приложений разной сложности, а также публикую статьи и туториалы на разные IT темы.",
|
||||||
"splash.about": "обо мне",
|
"splash.about": "обо мне",
|
||||||
"splash.contact": "написать мне",
|
"splash.contact": "написать мне",
|
||||||
/* Recent articles */
|
/* Recent articles */
|
||||||
@ -34,8 +35,8 @@ export default {
|
|||||||
"404.heading": "404",
|
"404.heading": "404",
|
||||||
"404.description": "Страницу которую вы пытаетесь найти не существует.",
|
"404.description": "Страницу которую вы пытаетесь найти не существует.",
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
"btn.send": "Отправить",
|
"btn.submit": "Отправить",
|
||||||
"btn.sending": "Отправка...",
|
"btn.submitting": "Отправка...",
|
||||||
"btn.sourceCode": "Исходник",
|
"btn.sourceCode": "Исходник",
|
||||||
"btn.demo": "Демо",
|
"btn.demo": "Демо",
|
||||||
"btn.article": "Статья",
|
"btn.article": "Статья",
|
||||||
|
|||||||
@ -12,40 +12,14 @@ export function formatArticlesByLangs(data: Record<string, MDXInstance<ArticleTy
|
|||||||
const splitted = module.file.split("/");
|
const splitted = module.file.split("/");
|
||||||
const lang = splitted.at(-2);
|
const lang = splitted.at(-2);
|
||||||
const name = splitted.at(-3);
|
const name = splitted.at(-3);
|
||||||
|
|
||||||
if (!modules[name]) modules[name] = {};
|
if (!modules[name]) modules[name] = {};
|
||||||
modules[name][lang] = module;
|
if (!module.frontmatter.draft) modules[name][lang] = module;
|
||||||
}
|
}
|
||||||
|
|
||||||
return modules;
|
return modules;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTranslatedArticles(Astro: Readonly<AstroGlobal>): MDXInstance<ArticleType>[] {
|
|
||||||
const data = import.meta.glob<MDXInstance<ArticleType>>("../../content/articles/**/*.mdx", { eager: true });
|
|
||||||
const modules = formatArticlesByLangs(data);
|
|
||||||
const siteLang = utils.getLanguageFromURL(Astro.url.pathname);
|
|
||||||
const articles: MDXInstance<ArticleType>[] = [];
|
|
||||||
|
|
||||||
for (const key of Object.keys(modules)) {
|
|
||||||
const langs = Object.keys(modules[key]);
|
|
||||||
for (const articleLang of langs) {
|
|
||||||
const isNonDefaultLanguage = !langs.includes(siteLang) && langs.length === 1;
|
|
||||||
const article = modules[key];
|
|
||||||
|
|
||||||
if (articleLang === siteLang) {
|
|
||||||
articles.push(article[siteLang]);
|
|
||||||
article[siteLang].frontmatter.lang = siteLang;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNonDefaultLanguage) {
|
|
||||||
articles.push(article[articleLang]);
|
|
||||||
article[articleLang].frontmatter.lang = articleLang;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return articles;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getLanguageKeys(): Array<string> {
|
export function getLanguageKeys(): Array<string> {
|
||||||
return Object.keys(languages);
|
return Object.keys(languages);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,42 @@
|
|||||||
---
|
---
|
||||||
import type { Props as BaseHeadProps } from "@root/components/BaseHead.astro";
|
import type { Props as BaseHeadProps } from "@root/components/BaseHead.astro";
|
||||||
|
import { extname } from "node:path";
|
||||||
|
import mime from "mime-types";
|
||||||
|
|
||||||
import BaseHead from "@components/BaseHead.astro";
|
import BaseHead from "@components/BaseHead.astro";
|
||||||
import BaseLayout from "@components/BaseLayout";
|
import BaseLayout from "@components/BaseLayout";
|
||||||
import ArticleContent from "@components/ArticleContent";
|
import ArticleContent from "@components/ArticleContent";
|
||||||
import ArticlePost from "@components/ArticlePost";
|
import ArticlePost from "@components/ArticlePost";
|
||||||
|
import { SITE_METADATA } from "@root/config";
|
||||||
|
import { isURL } from '@utils/string'
|
||||||
import "@styles/global.scss";
|
import "@styles/global.scss";
|
||||||
|
|
||||||
export interface Props extends BaseHeadProps {
|
export interface Props extends BaseHeadProps {
|
||||||
lang?: string | undefined;
|
lang?: string | undefined;
|
||||||
content: ArticleType;
|
content: ArticleType;
|
||||||
|
canonicalUrl?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { lang = "ru", content } = Astro.props as Props;
|
const { lang = "ru", content, canonicalUrl } = Astro.props as Props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang={lang}>
|
<html lang={lang}>
|
||||||
<BaseHead title={content.title} description={content.description}>
|
<BaseHead title={content.title} description={content.description} canonicalUrl={canonicalUrl}>
|
||||||
|
<meta property="og:title" content={content.title} />
|
||||||
|
<meta property="og:site_name" content={SITE_METADATA.name} />
|
||||||
|
<meta property="og:description" content={content.description} />
|
||||||
|
<meta property="og:type" content="article" />
|
||||||
|
<meta property="og:url" content={canonicalUrl} />
|
||||||
|
{content.coverImage && (
|
||||||
|
<meta property="og:image" content={isURL(content.coverImage) ? content.coverImage : `${Astro.url.origin}${content.coverImage}`} />
|
||||||
|
<meta property="og:image:width" content="700" />
|
||||||
|
<meta property="og:image:height" content="400" />
|
||||||
|
<meta property="og:image:type" content={`${mime.lookup(extname(content.coverImage))}`} />
|
||||||
|
)}
|
||||||
<script is:inline defer src="/assets/js/load-code-highlight.js"></script>
|
<script is:inline defer src="/assets/js/load-code-highlight.js"></script>
|
||||||
</BaseHead>
|
</BaseHead>
|
||||||
|
|
||||||
|
|
||||||
<BaseLayout>
|
<BaseLayout>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<ArticlePost lang={lang} data={content}>
|
<ArticlePost lang={lang} data={content}>
|
||||||
|
|||||||
@ -4,25 +4,23 @@ import type { Props as BaseHeadProps } from "@components/BaseHead.astro";
|
|||||||
import { SITE_METADATA } from "@root/config";
|
import { SITE_METADATA } from "@root/config";
|
||||||
import BaseHead from "@components/BaseHead.astro";
|
import BaseHead from "@components/BaseHead.astro";
|
||||||
import BaseLayout from "@components/BaseLayout";
|
import BaseLayout from "@components/BaseLayout";
|
||||||
import PageHeading from "@components/PageHeading";
|
|
||||||
import "@styles/global.scss";
|
import "@styles/global.scss";
|
||||||
|
|
||||||
export interface Props extends BaseHeadProps {
|
export interface Props extends BaseHeadProps {
|
||||||
lang?: string | undefined;
|
lang?: string | undefined;
|
||||||
showPageHeading?: boolean | undefined;
|
noIndex?: boolean | undefined;
|
||||||
noIndex?: boolean;
|
canonicalUrl?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description, lang = "ru", showPageHeading = true, noIndex = false } = Astro.props as Props;
|
const { title, description, lang = "ru", noIndex = false, canonicalUrl } = Astro.props as Props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang={lang}>
|
<html lang={lang}>
|
||||||
<BaseHead title={title ? `${title} | ${SITE_METADATA.title}` : SITE_METADATA.title} description={description}>
|
<BaseHead title={title ? `${title} | ${SITE_METADATA.title}` : SITE_METADATA.title} description={description} canonicalUrl={canonicalUrl}>
|
||||||
{noIndex && <meta name="robots" content="noindex" />}
|
{noIndex && <meta name="robots" content="noindex" />}
|
||||||
</BaseHead>
|
</BaseHead>
|
||||||
<BaseLayout>
|
<BaseLayout>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{showPageHeading && <PageHeading heading={title} description={description} />}
|
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
59
src/pages/[...slug].astro
Normal file
59
src/pages/[...slug].astro
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
import type { MDXInstance } from "astro";
|
||||||
|
|
||||||
|
import ArticleLayout from "@layouts/article.astro";
|
||||||
|
import { getLanguageFromURL } from "@root/utils";
|
||||||
|
import { DEFAULT_LANGUAGE } from "@root/config";
|
||||||
|
import { formatArticlesByLangs, useTranslation } from "@i18n/utils";
|
||||||
|
import languages from "@i18n/languages";
|
||||||
|
|
||||||
|
const t = useTranslation(Astro);
|
||||||
|
const siteLang = getLanguageFromURL(Astro.url.pathname);
|
||||||
|
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
article: MDXInstance<ArticleType>;
|
||||||
|
alternateLangs: LanguageKeys[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStaticPaths() {
|
||||||
|
const modules = import.meta.glob<MDXInstance<ArticleType>>("../../content/articles/**/*.mdx", { eager: true });
|
||||||
|
const entries = Object.keys(modules).map((key) => [key, key.split("/").slice(6)[0]]);
|
||||||
|
const formatted = formatArticlesByLangs(modules);
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map(([key, lang]) => {
|
||||||
|
const article = modules[key];
|
||||||
|
const dirName = key.split("/").at(-3);
|
||||||
|
const published_at = new Date(article.frontmatter.published_at).toLocaleDateString("en-CA");
|
||||||
|
const slug = `${lang !== DEFAULT_LANGUAGE ? `${lang}/` : ""}blog/${published_at}/${article.frontmatter.slug}`;
|
||||||
|
|
||||||
|
return { params: { slug }, props: { article, alternateLangs: Object.keys(formatted[dirName]) } };
|
||||||
|
})
|
||||||
|
.filter(({ props: { article } }) => !article.frontmatter.draft);
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
article: { Content, frontmatter },
|
||||||
|
alternateLangs,
|
||||||
|
} = Astro.props as Props;
|
||||||
|
|
||||||
|
const { pathname } = Astro.url;
|
||||||
|
---
|
||||||
|
|
||||||
|
<ArticleLayout lang={siteLang} content={frontmatter} canonicalUrl={canonicalUrl.href}>
|
||||||
|
{
|
||||||
|
alternateLangs.length > 1 && (
|
||||||
|
<div class="languages-available">
|
||||||
|
{t("article.availableOn")}:
|
||||||
|
{alternateLangs
|
||||||
|
.filter((lang) => lang !== siteLang)
|
||||||
|
.map((lang) => (
|
||||||
|
<a href={DEFAULT_LANGUAGE === lang ? `/${pathname.split("/").slice(2).join("/")}` : `/${lang}${pathname}`}>{languages[lang]}</a>
|
||||||
|
))
|
||||||
|
.reduce((prev, curr) => [prev, ", ", curr])}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<Content />
|
||||||
|
</ArticleLayout>
|
||||||
@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
import type { MDXInstance } from "astro";
|
|
||||||
|
|
||||||
import ArticleLayout from "@layouts/article.astro";
|
|
||||||
import { getLanguageFromURL } from "@root/utils";
|
|
||||||
import { DEFAULT_LANGUAGE } from "@root/config";
|
|
||||||
|
|
||||||
const lang = getLanguageFromURL(Astro.url.pathname);
|
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
|
||||||
const modules = import.meta.glob<MDXInstance<ArticleType>>("../../../content/articles/**/*.mdx", { eager: true });
|
|
||||||
const entries = Object.keys(modules).map((key) => [key, key.split("/").slice(7)[0]]);
|
|
||||||
|
|
||||||
return entries.map(([key, lang]) => {
|
|
||||||
const article = modules[key];
|
|
||||||
const published_at = new Date(article.frontmatter.published_at).toLocaleDateString("en-CA");
|
|
||||||
const slug = `${published_at}/${lang !== DEFAULT_LANGUAGE ? lang + "/" : ""}${article.frontmatter.slug}`;
|
|
||||||
|
|
||||||
return { params: { slug }, props: article };
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const { Content, frontmatter } = Astro.props as MDXInstance<ArticleType>;
|
|
||||||
---
|
|
||||||
|
|
||||||
<ArticleLayout lang={lang} content={frontmatter}>
|
|
||||||
<Content />
|
|
||||||
</ArticleLayout>
|
|
||||||
@ -14,9 +14,10 @@ for (const key of Object.keys(articles)) {
|
|||||||
const { title, description, slug, published_at, coverImage } = article[lang].frontmatter;
|
const { title, description, slug, published_at, coverImage } = article[lang].frontmatter;
|
||||||
items.push({
|
items.push({
|
||||||
link:
|
link:
|
||||||
`${import.meta.env.SITE}blog/` +
|
`${import.meta.env.SITE}` +
|
||||||
|
`${lang !== DEFAULT_LANGUAGE ? `${lang}/` : ""}` +
|
||||||
|
"blog/" +
|
||||||
new Date(published_at).toLocaleDateString("en-CA") +
|
new Date(published_at).toLocaleDateString("en-CA") +
|
||||||
`${lang !== DEFAULT_LANGUAGE ? "/" + lang : ""}` +
|
|
||||||
`/${slug}/`,
|
`/${slug}/`,
|
||||||
title,
|
title,
|
||||||
description: `${description}${coverImage !== undefined ? `<img src="${coverImage}" alt="${title}">` : ""}`,
|
description: `${description}${coverImage !== undefined ? `<img src="${coverImage}" alt="${title}">` : ""}`,
|
||||||
|
|||||||
31
src/services/ArticleService.ts
Normal file
31
src/services/ArticleService.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import type { AstroGlobal, MDXInstance } from "astro";
|
||||||
|
import { formatArticlesByLangs } from "@i18n/utils";
|
||||||
|
import { getLanguageFromURL } from "@utils/url";
|
||||||
|
|
||||||
|
class ArticleService {
|
||||||
|
public getTranslatedArticles(Astro: Readonly<AstroGlobal>): MDXInstance<ArticleType>[] {
|
||||||
|
const data = import.meta.glob<MDXInstance<ArticleType>>("../../content/articles/**/*.mdx", { eager: true });
|
||||||
|
const modules = formatArticlesByLangs(data);
|
||||||
|
const siteLang = getLanguageFromURL(Astro.url.pathname);
|
||||||
|
const articles: MDXInstance<ArticleType>[] = [];
|
||||||
|
const push = (article: MDXInstance<ArticleType>, lang: LanguageKeys) => {
|
||||||
|
articles.push(article);
|
||||||
|
article.frontmatter.lang = lang;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const key of Object.keys(modules)) {
|
||||||
|
const langs = Object.keys(modules[key]) as LanguageKeys[];
|
||||||
|
for (const lang of langs) {
|
||||||
|
const isNonDefaultLanguage = !langs.includes(siteLang) && langs.length === 1;
|
||||||
|
const article = modules[key];
|
||||||
|
|
||||||
|
if (lang === siteLang) push(article[siteLang], siteLang);
|
||||||
|
if (isNonDefaultLanguage) push(article[lang], lang);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return articles.filter((article) => !article.frontmatter.draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new ArticleService();
|
||||||
1
src/services/index.ts
Normal file
1
src/services/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as ArticleService } from "./ArticleService";
|
||||||
@ -130,6 +130,13 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
|
cursor: pointer;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: 5px 16px;
|
||||||
|
color: var(--font-color);
|
||||||
|
&:hover {
|
||||||
|
border: 1px solid var(--border-color-hover);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -138,8 +138,8 @@
|
|||||||
--icon-secondary: var(--color-gray-100);
|
--icon-secondary: var(--color-gray-100);
|
||||||
--icon-filter: invert(100%);
|
--icon-filter: invert(100%);
|
||||||
|
|
||||||
& .header svg,
|
& body > header svg,
|
||||||
& .site-content svg:not([data-filter="none"]) {
|
& body > main svg:not([data-filter="none"]) {
|
||||||
filter: var(--icon-filter);
|
filter: var(--icon-filter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
import { SITE_METADATA } from "@root/config";
|
import { SITE_METADATA } from "@root/config";
|
||||||
import PageLayout from "@layouts/default.astro";
|
import PageLayout from "@layouts/default.astro";
|
||||||
|
import PageHeading from "@components/PageHeading";
|
||||||
import SkillsList from "@components/SkillsList";
|
import SkillsList from "@components/SkillsList";
|
||||||
import SkillsBlock from "@components/SkillsBlock";
|
import SkillsBlock from "@components/SkillsBlock";
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ const { skills } = SITE_METADATA;
|
|||||||
---
|
---
|
||||||
|
|
||||||
<PageLayout noIndex lang={lang} title={t("pageTitle.about")}>
|
<PageLayout noIndex lang={lang} title={t("pageTitle.about")}>
|
||||||
|
<PageHeading heading={t("pageTitle.about")} />
|
||||||
<section>
|
<section>
|
||||||
<ArticleContent>
|
<ArticleContent>
|
||||||
<Content />
|
<Content />
|
||||||
|
|||||||
@ -1,18 +1,22 @@
|
|||||||
---
|
---
|
||||||
import PageLayout from "@layouts/default.astro";
|
import PageLayout from "@layouts/default.astro";
|
||||||
|
import PageHeading from "@components/PageHeading";
|
||||||
import ArticlesList from "@components/ArticlesList";
|
import ArticlesList from "@components/ArticlesList";
|
||||||
import { transformArticles, formatToArticleBlocks } from "@utils/articles";
|
import { transformArticles, formatToArticleBlocks } from "@utils/articles";
|
||||||
import { getLanguageFromURL } from "@root/utils";
|
import { getLanguageFromURL } from "@root/utils";
|
||||||
import { useTranslation, getTranslatedArticles } from "@i18n/utils";
|
import { useTranslation } from "@i18n/utils";
|
||||||
|
import { ArticleService } from "@services/index";
|
||||||
|
|
||||||
const t = useTranslation(Astro);
|
const t = useTranslation(Astro);
|
||||||
const data = getTranslatedArticles(Astro);
|
const data = ArticleService.getTranslatedArticles(Astro);
|
||||||
const tags = new Set(data.map((article) => article.frontmatter.tags).flat());
|
const tags = new Set(data.map((article) => article.frontmatter.tags).flat());
|
||||||
const lang = getLanguageFromURL(Astro.url.pathname);
|
const lang = getLanguageFromURL(Astro.url.pathname);
|
||||||
const articles = transformArticles(data);
|
const articles = transformArticles(data);
|
||||||
|
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
|
||||||
---
|
---
|
||||||
|
|
||||||
<PageLayout lang={lang} title={t("pageTitle.articles")} description={t("pageDescription.articles")}>
|
<PageLayout lang={lang} title={t("pageTitle.articles")} description={t("pageDescription.articles")} canonicalUrl={canonicalUrl.href}>
|
||||||
|
<PageHeading heading={t("pageTitle.articles")} description={t("pageDescription.articles")} />
|
||||||
<section>
|
<section>
|
||||||
<ArticlesList
|
<ArticlesList
|
||||||
client:load
|
client:load
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
import PageLayout from "@layouts/default.astro";
|
import PageLayout from "@layouts/default.astro";
|
||||||
|
import PageHeading from "@components/PageHeading";
|
||||||
import ContactForm from "@components/ContactForm";
|
import ContactForm from "@components/ContactForm";
|
||||||
import ContactsList from "@components/ContactsList";
|
import ContactsList from "@components/ContactsList";
|
||||||
import { getLanguageFromURL } from "@root/utils";
|
import { getLanguageFromURL } from "@root/utils";
|
||||||
@ -10,20 +11,9 @@ const lang = getLanguageFromURL(Astro.url.pathname);
|
|||||||
---
|
---
|
||||||
|
|
||||||
<PageLayout noIndex lang={lang} title={t("pageTitle.contacts")} description={t("pageDescription.contacts")}>
|
<PageLayout noIndex lang={lang} title={t("pageTitle.contacts")} description={t("pageDescription.contacts")}>
|
||||||
|
<PageHeading heading={t("pageTitle.contacts")} description={t("pageDescription.contacts")} />
|
||||||
<section>
|
<section>
|
||||||
<ContactForm
|
<ContactForm />
|
||||||
i18n={{
|
|
||||||
"input.nameField": t("input.nameField"),
|
|
||||||
"input.subjectField": t("input.subjectField"),
|
|
||||||
"input.messageField": t("input.messageField"),
|
|
||||||
"btn.send": t("btn.send"),
|
|
||||||
"btn.sending": t("btn.sending"),
|
|
||||||
"error.invalidEmail": t("error.invalidEmail"),
|
|
||||||
"form.contactSuccess": t("form.contactSuccess"),
|
|
||||||
"form.contactError": t("form.contactError"),
|
|
||||||
}}
|
|
||||||
client:load
|
|
||||||
/>
|
|
||||||
<ContactsList />
|
<ContactsList />
|
||||||
</section>
|
</section>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@ -4,16 +4,18 @@ import PageLayout from "@layouts/default.astro";
|
|||||||
import RecentArticles from "@components/RecentArticles";
|
import RecentArticles from "@components/RecentArticles";
|
||||||
import { Splash, SplashHeading, SplashLinks, Link } from "@components/Splash";
|
import { Splash, SplashHeading, SplashLinks, Link } from "@components/Splash";
|
||||||
import { getLanguageFromURL } from "@root/utils";
|
import { getLanguageFromURL } from "@root/utils";
|
||||||
import { getTranslatedArticles, useTranslation } from "@i18n/utils";
|
import { useTranslation } from "@i18n/utils";
|
||||||
|
import { ArticleService } from "@services/index";
|
||||||
import UIString from "@components/UIString";
|
import UIString from "@components/UIString";
|
||||||
|
|
||||||
const data = getTranslatedArticles(Astro);
|
const data = ArticleService.getTranslatedArticles(Astro);
|
||||||
const t = useTranslation(Astro);
|
const t = useTranslation(Astro);
|
||||||
const lang = getLanguageFromURL(Astro.url.pathname);
|
const lang = getLanguageFromURL(Astro.url.pathname);
|
||||||
const articles = utils.transformArticles(data);
|
const articles = utils.transformArticles(data);
|
||||||
|
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
|
||||||
---
|
---
|
||||||
|
|
||||||
<PageLayout lang={lang} description={t("site.description")} showPageHeading={false}>
|
<PageLayout lang={lang} description={t("site.description")} canonicalUrl={canonicalUrl.href}>
|
||||||
<Splash>
|
<Splash>
|
||||||
<SplashHeading>
|
<SplashHeading>
|
||||||
<hgroup>
|
<hgroup>
|
||||||
|
|||||||
@ -7,7 +7,7 @@ const lang = getLanguageFromURL(Astro.url.pathname);
|
|||||||
const t = useTranslation(Astro);
|
const t = useTranslation(Astro);
|
||||||
---
|
---
|
||||||
|
|
||||||
<PageLayout lang={lang} title="404" showPageHeading={false}>
|
<PageLayout lang={lang} title="404">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 style="font-size:var(--font-size-5xl);">404</h1>
|
<h1 style="font-size:var(--font-size-5xl);">404</h1>
|
||||||
<p>{t("404.description")}</p>
|
<p>{t("404.description")}</p>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { SITE_METADATA } from "@root/config";
|
|||||||
import PageLayout from "@layouts/default.astro";
|
import PageLayout from "@layouts/default.astro";
|
||||||
import ProjectsList from "@components/ProjectsList";
|
import ProjectsList from "@components/ProjectsList";
|
||||||
import ProjectItem from "@components/ProjectItem";
|
import ProjectItem from "@components/ProjectItem";
|
||||||
|
import PageHeading from "@components/PageHeading";
|
||||||
import { getLanguageFromURL } from "@root/utils";
|
import { getLanguageFromURL } from "@root/utils";
|
||||||
import { useTranslation } from "@i18n/utils";
|
import { useTranslation } from "@i18n/utils";
|
||||||
|
|
||||||
@ -12,6 +13,7 @@ const { projects } = SITE_METADATA;
|
|||||||
---
|
---
|
||||||
|
|
||||||
<PageLayout noIndex lang={lang} title={t("pageTitle.projects")} description={t("pageDescription.projects")}>
|
<PageLayout noIndex lang={lang} title={t("pageTitle.projects")} description={t("pageDescription.projects")}>
|
||||||
|
<PageHeading heading={t("pageTitle.projects")} description={t("pageDescription.projects")} />
|
||||||
<section>
|
<section>
|
||||||
<ProjectsList>
|
<ProjectsList>
|
||||||
{projects.map((project) => <ProjectItem data={project} />)}
|
{projects.map((project) => <ProjectItem data={project} />)}
|
||||||
|
|||||||
8
src/types/main.d.ts
vendored
8
src/types/main.d.ts
vendored
@ -15,13 +15,6 @@ declare global {
|
|||||||
type LanguageKeys = keyof typeof languages;
|
type LanguageKeys = keyof typeof languages;
|
||||||
type UIDictionaryKeys = keyof typeof uiKeys;
|
type UIDictionaryKeys = keyof typeof uiKeys;
|
||||||
|
|
||||||
type UtterancesConfigType = {
|
|
||||||
repo: string;
|
|
||||||
label?: string;
|
|
||||||
issueTerm: string;
|
|
||||||
theme: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SiteMetadataType = {
|
type SiteMetadataType = {
|
||||||
title: string;
|
title: string;
|
||||||
name: string;
|
name: string;
|
||||||
@ -46,6 +39,7 @@ declare global {
|
|||||||
tags: Array<string>;
|
tags: Array<string>;
|
||||||
published_at: string;
|
published_at: string;
|
||||||
lang?: string;
|
lang?: string;
|
||||||
|
draft?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProjectType = {
|
type ProjectType = {
|
||||||
|
|||||||
@ -1,10 +1,3 @@
|
|||||||
export const extendHistoryState = (state: object, url?: URL | string | undefined): void => {
|
|
||||||
const { history } = window;
|
|
||||||
const updated = Object.assign({}, history.state, state);
|
|
||||||
|
|
||||||
history.replaceState(updated, "", url);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteParamFromUrl = (param: string): string => {
|
export const deleteParamFromUrl = (param: string): string => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
params.delete(param);
|
params.delete(param);
|
||||||
|
|||||||
@ -9,9 +9,3 @@ export function isEmail(email: string): boolean {
|
|||||||
export function isURL(url: string): boolean {
|
export function isURL(url: string): boolean {
|
||||||
return new RegExp(/(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\\/~+#-]*[\w@?^=%&\\/~+#-])/).test(url);
|
return new RegExp(/(http|ftp|https):\/\/([\w_-]+(?:(?:\.[\w_-]+)+))([\w.,@?^=%&:\\/~+#-]*[\w@?^=%&\\/~+#-])/).test(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeLangTag(tag: string) {
|
|
||||||
if (!tag.includes("-")) return tag.toLowerCase();
|
|
||||||
const [lang, region] = tag.split("-");
|
|
||||||
return lang.toLowerCase() + "-" + region.toUpperCase();
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { DEFAULT_LANGUAGE } from "@root/config";
|
import { DEFAULT_LANGUAGE } from "@root/config";
|
||||||
|
import languages from "@i18n/languages";
|
||||||
|
|
||||||
export const encode = (data: object) => {
|
export const encode = (data: object) => {
|
||||||
return Object.keys(data)
|
return Object.keys(data)
|
||||||
@ -6,7 +7,8 @@ export const encode = (data: object) => {
|
|||||||
.join("&");
|
.join("&");
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLanguageFromURL = (pathname: string) => {
|
export const getLanguageFromURL = (pathname: string): LanguageKeys => {
|
||||||
const langCodeMatch = pathname.match(/\/([a-z]{1}-?[a-z]{1})\//);
|
const lang = pathname.split("/")[1] as LanguageKeys;
|
||||||
return langCodeMatch ? langCodeMatch[1] : DEFAULT_LANGUAGE;
|
if (Object.hasOwn(languages, lang)) return lang;
|
||||||
|
return DEFAULT_LANGUAGE;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -19,7 +19,8 @@
|
|||||||
"@stores/*": ["./src/stores/*"],
|
"@stores/*": ["./src/stores/*"],
|
||||||
"@hooks/*": ["./src/hooks/*"],
|
"@hooks/*": ["./src/hooks/*"],
|
||||||
"@i18n/*": ["./src/i18n/*"],
|
"@i18n/*": ["./src/i18n/*"],
|
||||||
"@images/*": ["./src/images/*"]
|
"@images/*": ["./src/images/*"],
|
||||||
|
"@services/*": ["./src/services/*"]
|
||||||
},
|
},
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"typeRoots": ["./node_modules/@types", "./src/types"]
|
"typeRoots": ["./node_modules/@types", "./src/types"]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user