Merge branch 'release/1.0.0'

This commit is contained in:
Sergey Yarkov 2022-08-27 18:59:24 +03:00
commit 264eabdce2
80 changed files with 1350 additions and 1067 deletions

4
.gitignore vendored
View File

@ -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
View File

@ -0,0 +1,7 @@
{
"files.eol": "\n",
"prettier.documentSelectors": ["**/*.astro"],
"[astro]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}

View File

@ -1,7 +1,7 @@
# yarkov.tech
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Netlify Status](https://api.netlify.com/api/v1/badges/ccd0460a-9725-4e35-8263-1608c4186d9c/deploy-status)](https://app.netlify.com/sites/jade-froyo-876dfe/deploys)
[![Netlify Status](https://api.netlify.com/api/v1/badges/ccd0460a-9725-4e35-8263-1608c4186d9c/deploy-status)](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

View File

@ -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`,

@ -1 +1 @@
Subproject commit 702becf6781d2b2a5496b185f2bf3bf18b06cec5
Subproject commit 50ba0addfe113ad1d531d62fe0b516e1cdfa589c

939
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -1,6 +1,4 @@
# Disallow all at the time of development
User-agent: *
Disallow: /
Allow: /
Sitemap: https://yarkov.tech/sitemap-index.xml

View File

@ -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);

View File

@ -32,7 +32,7 @@
& q {
font-style: italic;
quotes: "«" "»";
quotes: "« " " »";
}
& a {

View File

@ -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>}

View File

@ -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>
)

View File

@ -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" />

View File

@ -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>

View File

@ -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;
}
}

View 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>

View File

@ -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;
}
}

View File

@ -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;

View File

@ -1 +1 @@
export { default } from "./Button";
export { default } from "./Button.astro";

View 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>

View File

@ -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;

View File

@ -1,7 +0,0 @@
import type { Component } from "solid-js";
const Submitted: Component<{ text: string }> = (props) => {
return <div class="form-result">&#10060; {props.text}</div>;
};
export default Submitted;

View File

@ -1,7 +0,0 @@
import type { Component } from "solid-js";
const Submitted: Component<{ text: string }> = (props) => {
return <div class="form-result">&#10004;&#65039; {props.text}</div>;
};
export default Submitted;

View File

@ -1 +1 @@
export { default } from "./ContactForm";
export { default } from "./ContactForm.astro";

View File

@ -1,7 +0,0 @@
import { isEmail } from "@utils/string";
export default {
validateEmail: (error: string) => {
return (field: HTMLInputElement) => (isEmail(field.value) ? false : error);
},
};

View File

@ -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>

View File

@ -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;
}
}

View File

@ -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>
);

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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>

View File

@ -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;
}
}
}
}
}

View File

@ -13,3 +13,10 @@
display: none;
}
}
body[data-is-mobile-open="true"] {
.nav-toggle {
position: absolute;
right: var(--content-padding);
}
}

View File

@ -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>

View File

@ -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 }}>

View File

@ -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>

View File

@ -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;
}
}

View File

@ -1,7 +1,7 @@
---
import './ProjectsList.scss';
import "./ProjectsList.scss";
---
<div class='projects-list'>
<slot></slot>
<div class="projects-list">
<slot />
</div>

View File

@ -48,7 +48,6 @@ const SearchInput: Component<SearchInputProps> = (props) => {
resetSearch();
return;
}
console.log(param);
setSearch(param);
});

View 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>

View File

@ -0,0 +1 @@
export { default } from "./SiteContent.astro";

View File

@ -1,5 +1,5 @@
---
import './SkillsList.scss';
import "./SkillsList.scss";
---
<div class='skills-list'><slot></slot></div>
<div class="skills-list"><slot /></div>

View File

@ -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);
}
}

View File

@ -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;

View File

@ -1 +0,0 @@
export { default } from "./Spinner";

View File

@ -6,7 +6,7 @@
header {
& :global(p) {
font-size: var(--font-size-lg);
max-width: 570px;
max-width: 520px;
line-height: 1.5;
}

View File

@ -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;
}
}
}

View File

@ -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={
<>

View File

@ -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
View File

@ -0,0 +1,3 @@
interface ImportMetaEnv {
readonly IS_SITE_UNDER_CONSTRUCTION: string;
}

View File

@ -1 +0,0 @@
export * from "./useForm";

View File

@ -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 };
};

View File

@ -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",

View File

@ -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": "Статья",

View File

@ -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);
}

View File

@ -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}>

View File

@ -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
View 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>

View File

@ -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>

View File

@ -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}">` : ""}`,

View 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
View File

@ -0,0 +1 @@
export { default as ArticleService } from "./ArticleService";

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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 />

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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
View File

@ -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 = {

View File

@ -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);

View File

@ -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();
}

View File

@ -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;
};

View File

@ -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"]