mirror of
https://github.com/sergeyyarkov/yarkov.tech.git
synced 2026-10-11 10:29:21 +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*
|
||||
pnpm-debug.log*
|
||||
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.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
|
||||
|
||||
[](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.
|
||||
|
||||
@ -15,7 +15,7 @@ Personal website created with Astro.
|
||||
- [x] Form handling via [Netlify Forms](https://www.netlify.com/products/forms/)
|
||||
- [x] Comment system via [utterances](https://utteranc.es/)
|
||||
- [x] RSS Feed for articles
|
||||
- [ ] SEO optimized
|
||||
- [x] SEO optimized
|
||||
|
||||
## 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 { h } from "hastscript";
|
||||
import { toString } from "hast-util-to-string";
|
||||
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 remarkSmartypants from "remark-smartypants";
|
||||
|
||||
/**
|
||||
* Rehype plugins
|
||||
*/
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||
|
||||
@ -27,12 +39,13 @@ export default defineConfig({
|
||||
i18n: {
|
||||
defaultLocale: "ru",
|
||||
locales: {
|
||||
ru: "ru-RU",
|
||||
en: "en-US",
|
||||
ru: "ru",
|
||||
en: "en",
|
||||
},
|
||||
},
|
||||
}),
|
||||
// image(),
|
||||
image(),
|
||||
compress(),
|
||||
mdx({
|
||||
remarkPlugins: [remarkGfm, remarkSmartypants],
|
||||
rehypePlugins: [
|
||||
@ -77,7 +90,7 @@ export default defineConfig({
|
||||
],
|
||||
}),
|
||||
],
|
||||
trailingSlash: "always",
|
||||
trailingSlash: "ignore",
|
||||
markdown: {
|
||||
syntaxHighlight: "prism",
|
||||
},
|
||||
@ -86,7 +99,6 @@ export default defineConfig({
|
||||
external: ["svgo"],
|
||||
},
|
||||
build: {
|
||||
minify: "esbuild",
|
||||
rollupOptions: {
|
||||
output: {
|
||||
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",
|
||||
"version": "0.0.1",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Personal website created with Astro.",
|
||||
"author": {
|
||||
@ -15,33 +15,35 @@
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"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}'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/image": "^0.3.4",
|
||||
"@astrojs/mdx": "^0.10.0",
|
||||
"@astrojs/image": "^0.3.6",
|
||||
"@astrojs/mdx": "^0.10.2",
|
||||
"@astrojs/rss": "^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",
|
||||
"@typescript-eslint/eslint-plugin": "^5.33.1",
|
||||
"@typescript-eslint/parser": "^5.33.1",
|
||||
"astro": "^1.0.6",
|
||||
"astro-compress": "^1.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.35.1",
|
||||
"@typescript-eslint/parser": "^5.35.1",
|
||||
"astro": "^1.1.1",
|
||||
"astro-compress": "^1.0.5",
|
||||
"astro-icon": "^0.7.3",
|
||||
"eslint": "^8.22.0",
|
||||
"eslint": "^8.23.0",
|
||||
"eslint-plugin-solid": "^0.7.1",
|
||||
"hast-util-to-string": "^2.0.0",
|
||||
"hastscript": "^7.0.2",
|
||||
"html-escaper": "^3.0.3",
|
||||
"mime-types": "^2.1.35",
|
||||
"netlify-plugin-minify-html": "^0.3.1",
|
||||
"prettier": "^2.7.1",
|
||||
"prettier-plugin-astro": "^0.5.0",
|
||||
"prettier-plugin-astro": "^0.5.3",
|
||||
"rehype-autolink-headings": "^6.1.1",
|
||||
"rehype-slug": "^5.0.1",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"remark-smartypants": "^2.0.0",
|
||||
"sass": "^1.54.4",
|
||||
"solid-js": "^1.4.8"
|
||||
"sass": "^1.54.5",
|
||||
"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: *
|
||||
Disallow: /
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://yarkov.tech/sitemap-index.xml
|
||||
@ -1,5 +1,4 @@
|
||||
---
|
||||
import { UTTERANCES_CONFIG } from "@root/config";
|
||||
import UIString from "../UIString";
|
||||
---
|
||||
|
||||
@ -8,21 +7,22 @@ import UIString from "../UIString";
|
||||
<div id="comments"></div>
|
||||
</footer>
|
||||
|
||||
<script define:vars={{ UTTERANCES_CONFIG }}>
|
||||
<script>
|
||||
const $commentsWrapper = document.getElementById("comments");
|
||||
const $commentsScript = document.createElement("script");
|
||||
|
||||
$commentsScript.async = true;
|
||||
$commentsScript.src = "https://utteranc.es/client.js";
|
||||
$commentsScript.setAttribute("repo", UTTERANCES_CONFIG.repo);
|
||||
$commentsScript.setAttribute("issue-term", UTTERANCES_CONFIG.issueTerm);
|
||||
$commentsScript.setAttribute("repo", "sergeyyarkov/comments");
|
||||
$commentsScript.setAttribute("issue-term", "title");
|
||||
$commentsScript.setAttribute("label", "Comment");
|
||||
$commentsScript.setAttribute("id", "utterances");
|
||||
$commentsScript.setAttribute("crossorigin", "anonymous");
|
||||
|
||||
if (window.theme) {
|
||||
$commentsScript.setAttribute("theme", theme === "dark" ? "dark-blue" : "github-light");
|
||||
} else {
|
||||
$commentsScript.setAttribute("theme", UTTERANCES_CONFIG.theme);
|
||||
$commentsScript.setAttribute("theme", "github-light");
|
||||
}
|
||||
|
||||
if ($commentsWrapper) $commentsWrapper.appendChild($commentsScript);
|
||||
|
||||
@ -32,7 +32,7 @@
|
||||
|
||||
& q {
|
||||
font-style: italic;
|
||||
quotes: "«" "»";
|
||||
quotes: "« " " »";
|
||||
}
|
||||
|
||||
& a {
|
||||
|
||||
@ -14,7 +14,7 @@ export const ArticleItem: Component<ArticleItemProps> = (props) => {
|
||||
const hrefLangPrefix = props.data.lang && props.data.lang !== DEFAULT_LANGUAGE ? "/en" : "";
|
||||
return (
|
||||
<article class="article-item">
|
||||
<a href={`/blog/${new Date(props.data.published_at).toLocaleDateString("en-CA")}${hrefLangPrefix}/${props.data.slug}/`}>
|
||||
<a href={`${hrefLangPrefix}/blog/${new Date(props.data.published_at).toLocaleDateString("en-CA")}/${props.data.slug}/`}>
|
||||
<div class="flex">
|
||||
<h3>{props.data.title}</h3>
|
||||
{isNonDefaultLanguage && <sup>{props.data.lang.toLocaleUpperCase()}</sup>}
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
---
|
||||
// import { Image } from "@astrojs/image/components";
|
||||
// import * as utils from "@utils/string";
|
||||
import { Image, Picture } from "@astrojs/image/components";
|
||||
import { createDateFormatter } from "@root/utils";
|
||||
import ArticleComments from "@components/ArticleComments";
|
||||
import "./ArticlePost.scss";
|
||||
@ -39,13 +38,15 @@ const { title, coverImage, tags, author, published_at } = data;
|
||||
coverImage && (
|
||||
<div>
|
||||
<figure>
|
||||
{coverImage && <img src={coverImage} width='700' height='400' alt={title} />}
|
||||
{/* Render remote or local image */}
|
||||
{/* utils.isURL(coverImage) ? (
|
||||
<Image itemprop="image" src={coverImage} width={700} height={400} aspectRatio={"16:9"} format={"webp"} alt={data.title} />
|
||||
) : (
|
||||
<Image itemprop="image" src={cover.default.src} width={700} height={400} aspectRatio={"16:9"} format={"webp"} alt={data.title} />
|
||||
)*/}
|
||||
<Picture
|
||||
itemprop="image"
|
||||
src={coverImage}
|
||||
sizes="(max-width: 500px) 500px, 700px"
|
||||
widths={[500, 700]}
|
||||
formats={["webp"]}
|
||||
aspectRatio={"16:9"}
|
||||
alt={title}
|
||||
/>
|
||||
</figure>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -5,10 +5,11 @@ import { useTranslation } from "@i18n/utils";
|
||||
export interface Props {
|
||||
title?: string | undefined;
|
||||
description?: string | undefined;
|
||||
canonicalUrl?: string | undefined;
|
||||
}
|
||||
|
||||
const t = useTranslation(Astro);
|
||||
const { title = SITE_METADATA.title, description } = Astro.props as Props;
|
||||
const { title = SITE_METADATA.title, description, canonicalUrl } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<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
|
||||
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 name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={title} />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="description" content={description || t("site.description")} />
|
||||
<meta name="theme-color" content={SITE_METADATA.themeColor} />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
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";
|
||||
import "./BaseLayout.scss";
|
||||
---
|
||||
|
||||
<body>
|
||||
<body data-is-site-under-construction={IS_SITE_UNDER_CONSTRUCTION && "true"}>
|
||||
{
|
||||
IS_SITE_UNDER_CONSTRUCTION && (
|
||||
<HeaderBanner>
|
||||
@ -18,8 +18,8 @@ import "./BaseLayout.scss";
|
||||
)
|
||||
}
|
||||
<Header />
|
||||
<main class="site-content">
|
||||
<SiteContent>
|
||||
<slot />
|
||||
</main>
|
||||
</SiteContent>
|
||||
<Footer />
|
||||
</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 { Icon } from 'astro-icon';
|
||||
import './ContactsList.scss';
|
||||
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}
|
||||
<div class="contacts-list">
|
||||
<a href={"mailto:" + 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 href={githubUrl} target='_blank'
|
||||
<a href={githubUrl} target="_blank"
|
||||
>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 href={telegramUrl} target='_blank'
|
||||
<a href={telegramUrl} target="_blank"
|
||||
>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>
|
||||
</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 { createSignal, createEffect } from "solid-js";
|
||||
import Button from "../Button";
|
||||
import "./Counter.scss";
|
||||
|
||||
const Counter: Component<{ tick?: number }> = (props) => {
|
||||
const [isRunned, setIsRunned] = createSignal<boolean>(false);
|
||||
@ -22,21 +20,37 @@ const Counter: Component<{ tick?: number }> = (props) => {
|
||||
});
|
||||
|
||||
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>
|
||||
<div>
|
||||
<Button disabled={isRunned()} onClick={run}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
"column-gap": "3px",
|
||||
}}
|
||||
>
|
||||
<button class="btn" disabled={isRunned()} onClick={run}>
|
||||
Run
|
||||
</Button>
|
||||
<Button disabled={isRunned()} onClick={inc}>
|
||||
</button>
|
||||
<button class="btn" disabled={isRunned()} onClick={inc}>
|
||||
+
|
||||
</Button>
|
||||
<Button disabled={isRunned()} onClick={dec}>
|
||||
</button>
|
||||
<button class="btn" disabled={isRunned()} onClick={dec}>
|
||||
-
|
||||
</Button>
|
||||
<Button disabled={!isRunned()} onClick={stop}>
|
||||
</button>
|
||||
<button class="btn" disabled={!isRunned()} onClick={stop}>
|
||||
Stop
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,24 +1,58 @@
|
||||
---
|
||||
import './Footer.scss';
|
||||
import { Icon } from 'astro-icon'
|
||||
import { Icon } from "astro-icon";
|
||||
---
|
||||
|
||||
<footer class='footer'>
|
||||
<div class='container'>
|
||||
<ul class='footer__links'>
|
||||
<footer>
|
||||
<div class="container">
|
||||
<ul>
|
||||
<li>
|
||||
<a href='https://astro.build/' target='_blank'>Astro</a>
|
||||
<Icon name='astro' width='20' height='20' />
|
||||
<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' />
|
||||
<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' />
|
||||
<a href="/rss.xml" target="_blank">RSS</a>
|
||||
<Icon name="rss" width="20" height="20" />
|
||||
</li>
|
||||
</ul>
|
||||
<p class='footer__copy'>© Sergey Yarkov, 2022</p>
|
||||
<p>© Sergey Yarkov, 2022</p>
|
||||
</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);
|
||||
}
|
||||
}
|
||||
}
|
||||
& 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 { IS_SITE_UNDER_CONSTRUCTION, SITE_METADATA } from "@root/config";
|
||||
import { removeSlashes } from "@utils/string";
|
||||
import ThemeSwitcher from "@components/ThemeSwticher";
|
||||
import UIString from "@components/UIString";
|
||||
import TranslatedLink from "@components/TranslatedLink";
|
||||
import LanguageSelect from "@components/LanguageSelect";
|
||||
import NavToggle from "./NavToggle";
|
||||
import "./Header.scss";
|
||||
|
||||
const { name, nav, contacts } = SITE_METADATA;
|
||||
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="header__logo">
|
||||
<div class="logo">
|
||||
<TranslatedLink to={"/"}>
|
||||
{name}
|
||||
</TranslatedLink>
|
||||
</div>
|
||||
<nav class="header__nav">
|
||||
<ul class="header__nav-links">
|
||||
<nav>
|
||||
<ul>
|
||||
{
|
||||
nav.map((link) => (
|
||||
<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} />
|
||||
</TranslatedLink>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
<div class="header__nav-contacts">
|
||||
<div class="contacts">
|
||||
<a href={"mailto:" + contacts.email}>
|
||||
Email
|
||||
<Icon name="email" width="18" height="18" />
|
||||
@ -45,7 +45,7 @@ const { pathname } = Astro.url;
|
||||
<Icon name="telegram" width="18" height="18" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex header__nav-settings">
|
||||
<div class="flex settings">
|
||||
<LanguageSelect />
|
||||
<ThemeSwitcher client:visible />
|
||||
</div>
|
||||
@ -53,3 +53,123 @@ const { pathname } = Astro.url;
|
||||
<NavToggle client:visible />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style lang="scss">
|
||||
header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--background-header-color);
|
||||
padding-top: 13.5px;
|
||||
padding-bottom: 13.5px;
|
||||
user-select: none;
|
||||
z-index: 9999;
|
||||
& .container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
& .logo a {
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--font-color);
|
||||
}
|
||||
|
||||
& a:hover,
|
||||
a.active {
|
||||
color: var(--color-blue-500);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 2px;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
& nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
& ul {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
column-gap: 20px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-right: 35px;
|
||||
& li a {
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
color: var(--font-color);
|
||||
text-decoration: none;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
}
|
||||
& .contacts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
column-gap: 10px;
|
||||
margin-right: 30px;
|
||||
& a {
|
||||
text-decoration: none;
|
||||
font-size: 0;
|
||||
&:hover {
|
||||
opacity: var(--opacity-hover);
|
||||
}
|
||||
}
|
||||
}
|
||||
& .settings {
|
||||
column-gap: 13.5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
header {
|
||||
padding: 10px 0;
|
||||
& nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
body[data-is-mobile-open="true"] {
|
||||
overflow-y: hidden;
|
||||
touch-action: none;
|
||||
& header {
|
||||
padding: 15px 0;
|
||||
background-color: var(--background-header-color);
|
||||
position: fixed;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
& .container {
|
||||
flex-direction: column;
|
||||
row-gap: 3rem;
|
||||
}
|
||||
& .logo {
|
||||
align-self: flex-start;
|
||||
}
|
||||
& nav {
|
||||
display: block;
|
||||
& ul {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
& li {
|
||||
margin-bottom: 30px;
|
||||
& a {
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
}
|
||||
}
|
||||
& .contacts {
|
||||
margin-right: 0;
|
||||
margin-top: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,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;
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
|
||||
<style lang="scss" is:global>
|
||||
.header.header-banner {
|
||||
top: 45px;
|
||||
}
|
||||
|
||||
.banner-wrapper {
|
||||
<style lang="scss">
|
||||
div {
|
||||
padding: 12px 0;
|
||||
min-height: 45px;
|
||||
text-align: center;
|
||||
@ -17,18 +13,4 @@
|
||||
top: 0;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
body[data-is-mobile-open="true"] {
|
||||
& .header.header-banner {
|
||||
margin-top: 45px;
|
||||
& .language-select {
|
||||
bottom: 145px;
|
||||
}
|
||||
& .theme-switcher {
|
||||
bottom: 80px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -42,6 +42,27 @@ const lang = getLanguageFromURL(Astro.url.pathname);
|
||||
border-radius: var(--border-radius-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
body[data-is-mobile-open="true"] {
|
||||
.language-select {
|
||||
position: absolute;
|
||||
bottom: 115px;
|
||||
right: 2.2rem;
|
||||
& svg,
|
||||
& select {
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
body[data-is-site-under-construction] {
|
||||
.language-select {
|
||||
bottom: 145px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script define:vars={{ lang, DEFAULT_LANGUAGE }}>
|
||||
|
||||
@ -1,6 +1,4 @@
|
||||
---
|
||||
import "./PageHeading.scss";
|
||||
|
||||
export interface Props {
|
||||
heading: string;
|
||||
description?: string | undefined;
|
||||
@ -10,8 +8,25 @@ const { heading, description } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<div class="page-heading">
|
||||
<h1 style={!description && "margin-bottom: 2.5rem;"}>
|
||||
<h1>
|
||||
{heading}
|
||||
</h1>
|
||||
{description && <p>{description}</p>}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
margin-bottom: 2.5rem;
|
||||
& h1 {
|
||||
font-size: var(--font-size-4xl);
|
||||
text-align: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
& p {
|
||||
font-size: var(--font-size-lg);
|
||||
text-align: center;
|
||||
max-width: 650px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,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'>
|
||||
<slot></slot>
|
||||
<div class="projects-list">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@ -48,7 +48,6 @@ const SearchInput: Component<SearchInputProps> = (props) => {
|
||||
resetSearch();
|
||||
return;
|
||||
}
|
||||
console.log(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 {
|
||||
& :global(p) {
|
||||
font-size: var(--font-size-lg);
|
||||
max-width: 570px;
|
||||
max-width: 520px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
@ -16,3 +16,16 @@
|
||||
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 { createSignal, onMount, Switch, Match } from "solid-js";
|
||||
@ -50,7 +51,12 @@ const ThemeSwitcher: Component = () => {
|
||||
});
|
||||
|
||||
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
|
||||
children={
|
||||
<>
|
||||
|
||||
@ -1,14 +1,7 @@
|
||||
export const DEFAULT_LANGUAGE: LanguageKeys = "ru";
|
||||
|
||||
/* Show banner about website development */
|
||||
export const IS_SITE_UNDER_CONSTRUCTION = true;
|
||||
|
||||
export const UTTERANCES_CONFIG: UtterancesConfigType = {
|
||||
repo: "sergeyyarkov/comments",
|
||||
issueTerm: "title",
|
||||
label: "Comments",
|
||||
theme: "github-light",
|
||||
};
|
||||
export const IS_SITE_UNDER_CONSTRUCTION = import.meta.env.IS_SITE_UNDER_CONSTRUCTION === "true" ? true : false;
|
||||
|
||||
export const SITE_METADATA: SiteMetadataType = {
|
||||
title: "Sergey Yarkov",
|
||||
@ -32,8 +25,8 @@ export const SITE_METADATA: SiteMetadataType = {
|
||||
name: "yarkov.tech",
|
||||
year: "2022",
|
||||
description: {
|
||||
ru: "Мой персональный веб-сайт где находятся информация обо мне и список различных статей, туториалов...",
|
||||
en: "My personal website where you can find information about me and a list of various articles, tutorials...",
|
||||
ru: "Мой персональный веб-сайт где находятся информация обо мне и список различных статей и туториалов...",
|
||||
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",
|
||||
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",
|
||||
},
|
||||
sourceUrl: "https://github.com/sergeyyarkov/devthread.ru",
|
||||
demoUrl: "https://devthread.netlify.app",
|
||||
demoUrl: "https://devthread.yarkov.tech",
|
||||
articleUrl: undefined,
|
||||
},
|
||||
{
|
||||
@ -60,7 +53,7 @@ export const SITE_METADATA: SiteMetadataType = {
|
||||
en: "Learning Management System (LMS) to publish your courses, lectures, lessons",
|
||||
},
|
||||
sourceUrl: "https://github.com/sergeyyarkov/educt",
|
||||
demoUrl: "https://client-educt-lms.netlify.app",
|
||||
demoUrl: "https://educt.yarkov.tech",
|
||||
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 {
|
||||
"site.title": "yarkov.tech",
|
||||
"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...",
|
||||
"article.availableOn": "Available on",
|
||||
"articles.empty": "No articles found...",
|
||||
"articlePost.comments": "Comments",
|
||||
"recentArticles.empty": "There is nothing here yet...",
|
||||
@ -13,18 +14,18 @@ export default {
|
||||
"pageTitle.contacts": "Contacts",
|
||||
/* Page descriptions */
|
||||
"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.",
|
||||
/* Navigation */
|
||||
"navigation.articles": "Articles",
|
||||
"navigation.projects": "Projects",
|
||||
"navigation.about": "About",
|
||||
"navigation.about": "About me",
|
||||
"navigation.contacts": "Contacts",
|
||||
/* Splash page */
|
||||
/* Home page */
|
||||
"splash.headline": "Hello, I'am Sergey",
|
||||
"splash.subheadline": "Fullstack web-developer & freelancer",
|
||||
"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.contact": "write me",
|
||||
/* Recent articles */
|
||||
@ -34,8 +35,8 @@ export default {
|
||||
"404.heading": "404",
|
||||
"404.description": "The page you are trying to find does not exist.",
|
||||
/* Buttons */
|
||||
"btn.send": "Submit",
|
||||
"btn.sending": "Submitting...",
|
||||
"btn.submit": "Submit",
|
||||
"btn.submiting": "Submitting...",
|
||||
"btn.sourceCode": "Source",
|
||||
"btn.demo": "Demo",
|
||||
"btn.article": "Article",
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
export default {
|
||||
"site.title": "yarkov.tech",
|
||||
"site.description":
|
||||
"Здесь я публикую статьи и туториалы на различные IT темы, а также рассказываю о своих проектах которые я сделал за последние несколько лет.",
|
||||
"Здесь я публикую статьи и туториалы на разные IT темы, а также рассказываю о своих проектах которые я сделал за последние несколько лет.",
|
||||
"site.underConstruction": "Этот веб-сайт находится в разработке...",
|
||||
"article.availableOn": "Доступно на",
|
||||
"articles.empty": "Не найдено ни одной статьи...",
|
||||
"articlePost.comments": "Комментарии",
|
||||
"recentArticles.empty": "Пока что здесь ничего нет...",
|
||||
@ -20,11 +21,11 @@ export default {
|
||||
"navigation.projects": "Проекты",
|
||||
"navigation.about": "Обо мне",
|
||||
"navigation.contacts": "Контакты",
|
||||
/* Splash page */
|
||||
/* Home page */
|
||||
"splash.headline": "Hello, I'am Sergey",
|
||||
"splash.subheadline": "Fullstack web-developer & freelancer",
|
||||
"splash.description":
|
||||
"Я занимаюсь разработкой веб-сайтов и веб-приложений разной сложности, а также публикую различные статьи и туториалы на разные IT темы.",
|
||||
"Я занимаюсь разработкой веб-сайтов и веб-приложений разной сложности, а также публикую статьи и туториалы на разные IT темы.",
|
||||
"splash.about": "обо мне",
|
||||
"splash.contact": "написать мне",
|
||||
/* Recent articles */
|
||||
@ -34,8 +35,8 @@ export default {
|
||||
"404.heading": "404",
|
||||
"404.description": "Страницу которую вы пытаетесь найти не существует.",
|
||||
/* Buttons */
|
||||
"btn.send": "Отправить",
|
||||
"btn.sending": "Отправка...",
|
||||
"btn.submit": "Отправить",
|
||||
"btn.submitting": "Отправка...",
|
||||
"btn.sourceCode": "Исходник",
|
||||
"btn.demo": "Демо",
|
||||
"btn.article": "Статья",
|
||||
|
||||
@ -12,40 +12,14 @@ export function formatArticlesByLangs(data: Record<string, MDXInstance<ArticleTy
|
||||
const splitted = module.file.split("/");
|
||||
const lang = splitted.at(-2);
|
||||
const name = splitted.at(-3);
|
||||
|
||||
if (!modules[name]) modules[name] = {};
|
||||
modules[name][lang] = module;
|
||||
if (!module.frontmatter.draft) modules[name][lang] = module;
|
||||
}
|
||||
|
||||
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> {
|
||||
return Object.keys(languages);
|
||||
}
|
||||
|
||||
@ -1,24 +1,42 @@
|
||||
---
|
||||
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 BaseLayout from "@components/BaseLayout";
|
||||
import ArticleContent from "@components/ArticleContent";
|
||||
import ArticlePost from "@components/ArticlePost";
|
||||
import { SITE_METADATA } from "@root/config";
|
||||
import { isURL } from '@utils/string'
|
||||
import "@styles/global.scss";
|
||||
|
||||
export interface Props extends BaseHeadProps {
|
||||
lang?: string | undefined;
|
||||
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}>
|
||||
<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>
|
||||
</BaseHead>
|
||||
|
||||
|
||||
<BaseLayout>
|
||||
<div class="container">
|
||||
<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 BaseHead from "@components/BaseHead.astro";
|
||||
import BaseLayout from "@components/BaseLayout";
|
||||
import PageHeading from "@components/PageHeading";
|
||||
import "@styles/global.scss";
|
||||
|
||||
export interface Props extends BaseHeadProps {
|
||||
lang?: string | undefined;
|
||||
showPageHeading?: boolean | undefined;
|
||||
noIndex?: boolean;
|
||||
noIndex?: boolean | undefined;
|
||||
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}>
|
||||
<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" />}
|
||||
</BaseHead>
|
||||
<BaseLayout>
|
||||
<div class="container">
|
||||
{showPageHeading && <PageHeading heading={title} description={description} />}
|
||||
<slot />
|
||||
</div>
|
||||
</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;
|
||||
items.push({
|
||||
link:
|
||||
`${import.meta.env.SITE}blog/` +
|
||||
`${import.meta.env.SITE}` +
|
||||
`${lang !== DEFAULT_LANGUAGE ? `${lang}/` : ""}` +
|
||||
"blog/" +
|
||||
new Date(published_at).toLocaleDateString("en-CA") +
|
||||
`${lang !== DEFAULT_LANGUAGE ? "/" + lang : ""}` +
|
||||
`/${slug}/`,
|
||||
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 {
|
||||
cursor: pointer;
|
||||
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-filter: invert(100%);
|
||||
|
||||
& .header svg,
|
||||
& .site-content svg:not([data-filter="none"]) {
|
||||
& body > header svg,
|
||||
& body > main svg:not([data-filter="none"]) {
|
||||
filter: var(--icon-filter);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
---
|
||||
import { SITE_METADATA } from "@root/config";
|
||||
import PageLayout from "@layouts/default.astro";
|
||||
import PageHeading from "@components/PageHeading";
|
||||
import SkillsList from "@components/SkillsList";
|
||||
import SkillsBlock from "@components/SkillsBlock";
|
||||
|
||||
@ -15,6 +16,7 @@ const { skills } = SITE_METADATA;
|
||||
---
|
||||
|
||||
<PageLayout noIndex lang={lang} title={t("pageTitle.about")}>
|
||||
<PageHeading heading={t("pageTitle.about")} />
|
||||
<section>
|
||||
<ArticleContent>
|
||||
<Content />
|
||||
|
||||
@ -1,18 +1,22 @@
|
||||
---
|
||||
import PageLayout from "@layouts/default.astro";
|
||||
import PageHeading from "@components/PageHeading";
|
||||
import ArticlesList from "@components/ArticlesList";
|
||||
import { transformArticles, formatToArticleBlocks } from "@utils/articles";
|
||||
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 data = getTranslatedArticles(Astro);
|
||||
const data = ArticleService.getTranslatedArticles(Astro);
|
||||
const tags = new Set(data.map((article) => article.frontmatter.tags).flat());
|
||||
const lang = getLanguageFromURL(Astro.url.pathname);
|
||||
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>
|
||||
<ArticlesList
|
||||
client:load
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
---
|
||||
import PageLayout from "@layouts/default.astro";
|
||||
import PageHeading from "@components/PageHeading";
|
||||
import ContactForm from "@components/ContactForm";
|
||||
import ContactsList from "@components/ContactsList";
|
||||
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")}>
|
||||
<PageHeading heading={t("pageTitle.contacts")} description={t("pageDescription.contacts")} />
|
||||
<section>
|
||||
<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
|
||||
/>
|
||||
<ContactForm />
|
||||
<ContactsList />
|
||||
</section>
|
||||
</PageLayout>
|
||||
|
||||
@ -4,16 +4,18 @@ import PageLayout from "@layouts/default.astro";
|
||||
import RecentArticles from "@components/RecentArticles";
|
||||
import { Splash, SplashHeading, SplashLinks, Link } from "@components/Splash";
|
||||
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";
|
||||
|
||||
const data = getTranslatedArticles(Astro);
|
||||
const data = ArticleService.getTranslatedArticles(Astro);
|
||||
const t = useTranslation(Astro);
|
||||
const lang = getLanguageFromURL(Astro.url.pathname);
|
||||
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>
|
||||
<SplashHeading>
|
||||
<hgroup>
|
||||
|
||||
@ -7,7 +7,7 @@ const lang = getLanguageFromURL(Astro.url.pathname);
|
||||
const t = useTranslation(Astro);
|
||||
---
|
||||
|
||||
<PageLayout lang={lang} title="404" showPageHeading={false}>
|
||||
<PageLayout lang={lang} title="404">
|
||||
<div class="container">
|
||||
<h1 style="font-size:var(--font-size-5xl);">404</h1>
|
||||
<p>{t("404.description")}</p>
|
||||
|
||||
@ -3,6 +3,7 @@ import { SITE_METADATA } from "@root/config";
|
||||
import PageLayout from "@layouts/default.astro";
|
||||
import ProjectsList from "@components/ProjectsList";
|
||||
import ProjectItem from "@components/ProjectItem";
|
||||
import PageHeading from "@components/PageHeading";
|
||||
import { getLanguageFromURL } from "@root/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")}>
|
||||
<PageHeading heading={t("pageTitle.projects")} description={t("pageDescription.projects")} />
|
||||
<section>
|
||||
<ProjectsList>
|
||||
{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 UIDictionaryKeys = keyof typeof uiKeys;
|
||||
|
||||
type UtterancesConfigType = {
|
||||
repo: string;
|
||||
label?: string;
|
||||
issueTerm: string;
|
||||
theme: string;
|
||||
};
|
||||
|
||||
type SiteMetadataType = {
|
||||
title: string;
|
||||
name: string;
|
||||
@ -46,6 +39,7 @@ declare global {
|
||||
tags: Array<string>;
|
||||
published_at: string;
|
||||
lang?: string;
|
||||
draft?: boolean;
|
||||
};
|
||||
|
||||
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 => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete(param);
|
||||
|
||||
@ -9,9 +9,3 @@ export function isEmail(email: string): boolean {
|
||||
export function isURL(url: string): boolean {
|
||||
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 languages from "@i18n/languages";
|
||||
|
||||
export const encode = (data: object) => {
|
||||
return Object.keys(data)
|
||||
@ -6,7 +7,8 @@ export const encode = (data: object) => {
|
||||
.join("&");
|
||||
};
|
||||
|
||||
export const getLanguageFromURL = (pathname: string) => {
|
||||
const langCodeMatch = pathname.match(/\/([a-z]{1}-?[a-z]{1})\//);
|
||||
return langCodeMatch ? langCodeMatch[1] : DEFAULT_LANGUAGE;
|
||||
export const getLanguageFromURL = (pathname: string): LanguageKeys => {
|
||||
const lang = pathname.split("/")[1] as LanguageKeys;
|
||||
if (Object.hasOwn(languages, lang)) return lang;
|
||||
return DEFAULT_LANGUAGE;
|
||||
};
|
||||
|
||||
@ -19,7 +19,8 @@
|
||||
"@stores/*": ["./src/stores/*"],
|
||||
"@hooks/*": ["./src/hooks/*"],
|
||||
"@i18n/*": ["./src/i18n/*"],
|
||||
"@images/*": ["./src/images/*"]
|
||||
"@images/*": ["./src/images/*"],
|
||||
"@services/*": ["./src/services/*"]
|
||||
},
|
||||
"outDir": "./dist",
|
||||
"typeRoots": ["./node_modules/@types", "./src/types"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user