Compare commits

..

No commits in common. "develop" and "v0.0.1" have entirely different histories.

201 changed files with 13418 additions and 27213 deletions

View File

@ -1,14 +0,0 @@
node_modules
dist
.astro
.env
.env.example
.git
.gitignore
dockerfile
.dockerignore
.vscode
.prettierrc
codegen.ts
schema.graphql
README.md

View File

@ -1,4 +0,0 @@
ASTRO_TELEMETRY_DISABLED=1
DIRECTUS_URL=
DIRECTUS_URL_GRAPHQL=
DIRECTUS_TOKEN=

14
.eslintrc.json Normal file
View File

@ -0,0 +1,14 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:solid/typescript"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": ["@typescript-eslint", "solid"],
"rules": {}
}

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
*.css linguist-detectable=false
*.scss linguist-detectable=false

View File

@ -1,9 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "friday"
target-branch: "develop"
open-pull-requests-limit: 10

10
.gitignore vendored
View File

@ -1,7 +1,5 @@
# build output # build output
dist/ dist/
# generated types
.astro/
# dependencies # dependencies
node_modules/ node_modules/
@ -12,13 +10,13 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
.idea
.vscode
# environment variables # environment variables
.env .env
.env.production .env.production
# macOS-specific files # macOS-specific files
.DS_Store .DS_Store
# Local Netlify folder
.netlify

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "content"]
path = content
url = https://github.com/sergeyyarkov/content

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
# Expose Astro dependencies for `pnpm` users
shamefully-hoist=true

View File

@ -1,5 +1,5 @@
{ {
"printWidth": 120, "printWidth": 150,
"semi": true, "semi": true,
"singleQuote": false, "singleQuote": false,
"tabWidth": 2, "tabWidth": 2,

View File

@ -1,4 +0,0 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
.vscode/launch.json vendored
View File

@ -1,11 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

View File

@ -1,5 +0,0 @@
{
"githubPullRequests.ignoredPullRequestBranches": ["develop"],
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
}

View File

@ -1,22 +0,0 @@
ARG NODE_IMAGE=node:24.0.0-alpine
FROM ${NODE_IMAGE} AS build
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --omit=dev
FROM ${NODE_IMAGE} AS production
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/package*.json .
EXPOSE 9966
CMD [ "node", "./dist/server/entry.mjs"]

View File

@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.

View File

@ -1,22 +1,21 @@
# yarkov.tech # yarkov.tech
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![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)
Personal website created with Astro. Personal website created with Astro.
### Features ### Features
- [x] HTML layout
- [x] Adaptive & Responsive layout - [x] Adaptive & Responsive layout
- [x] Search articles by title or description - [x] Search articles by title or description
- [x] Dark/Light Theme - [x] Dark/Light Theme
- [x] Multilang web-site - [x] Multilang web-site
- [x] Table of contents - [x] Form handling via [Netlify Forms](https://www.netlify.com/products/forms/)
- [x] Content management via [Directus](https://directus.io/) - [x] Comment system via [utterances](https://utteranc.es/)
- [x] Form handling
- [x] Comment system via [Remark42](https://github.com/umputun/remark42)
- [x] RSS Feed for articles - [x] RSS Feed for articles
- [x] SEO optimized - [ ] SEO optimized
- [x] Views counter
## License ## License

View File

@ -1,38 +1,102 @@
import { defineConfig, envField } from "astro/config"; 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";
import remarkGfm from "remark-gfm";
import remarkSmartypants from "remark-smartypants";
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
/** const createAnchorLabel = (heading) => {
* Integrations const node = h("span.anchor-label", escape(heading));
*/ node.properties["is:raw"] = true;
import node from "@astrojs/node"; return node;
import solidJs from "@astrojs/solid-js"; };
import icon from "astro-icon";
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
site: "https://yarkov.tech", site: "https://yarkov.tech",
compressHTML: true, integrations: [
output: "server", solid(),
prefetch: true, sitemap({
server: { i18n: {
port: 9966, defaultLocale: "ru",
host: true, locales: {
allowedHosts: ["showcase.yarkov.tech"], ru: "ru-RU",
en: "en-US",
},
},
}),
// image(),
mdx({
remarkPlugins: [remarkGfm, remarkSmartypants],
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
properties: {
class: "anchor-link",
},
behavior: "after",
group: ({ tagName }) =>
h(`div.heading-wrapper.level-${tagName}`, {
tabIndex: -1,
}),
content: (heading) => [
h(
`span.anchor-icon`,
undefined,
h(
"svg",
{
width: 18,
height: 18,
version: 1.1,
viewBox: "0 0 16 16",
xlmns: "http://www.w3.org/2000/svg",
ariaHidden: "true",
"data-filter": "none",
},
h("path", {
fillRule: "evenodd",
fill: "currentcolor",
d: "M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z",
})
)
),
createAnchorLabel(toString(heading)),
],
},
],
],
}),
],
trailingSlash: "always",
markdown: {
syntaxHighlight: "prism",
}, },
adapter: node({ vite: {
mode: "standalone", ssr: {
}), external: ["svgo"],
env: { },
schema: { build: {
DIRECTUS_URL: envField.string({ context: "server", access: "secret", default: "" }), minify: "esbuild",
DIRECTUS_URL_GRAPHQL: envField.string({ rollupOptions: {
context: "server", output: {
access: "secret", entryFileNames: `[name].[hash].js`,
default: "http://directus:8055/graphql", chunkFileNames: `chunks/[name].[hash].js`,
}), assetFileNames: `assets/[hash].[ext]`,
DIRECTUS_TOKEN: envField.string({ context: "server", access: "secret", default: "" }), },
},
}, },
}, },
integrations: [icon(), solidJs()], experimental: {
trailingSlash: "ignore", integrations: true,
scopedStyleStrategy: "where", },
}); });

View File

@ -1,31 +0,0 @@
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: [
{
[`${process.env.DIRECTUS_URL}/graphql`]: {
headers: {
Authorization: `Bearer ${process.env.DIRECTUS_TOKEN}`,
},
},
},
],
documents: ["src/**/*.ts"],
ignoreNoDocuments: true,
generates: {
"./src/graphql/": {
preset: "client",
config: {
documentMode: "string",
},
},
"./schema.graphql": {
plugins: ["schema-ast"],
config: {
includeDirectives: true,
},
},
},
};
export default config;

View File

@ -1,17 +0,0 @@
services:
yarkov-tech-node:
container_name: yarkov-tech-node
restart: unless-stopped
image: sergeyyarkov/yarkovtech:latest
networks:
- nginx_net
env_file: .env
build:
context: ./
dockerfile: Dockerfile
ports:
- 9966
networks:
nginx_net:
external: true

1
content Submodule

@ -0,0 +1 @@
Subproject commit 702becf6781d2b2a5496b185f2bf3bf18b06cec5

3
netlify.toml Normal file
View File

@ -0,0 +1,3 @@
[build]
command = "npm run build"
publish = "dist"

27635
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,53 +1,47 @@
{ {
"name": "yarkov.tech", "name": "yarkov.tech",
"private": false, "version": "0.0.1",
"version": "3.0.1", "private": true,
"description": "Personal website created with Astro.", "description": "Personal website created with Astro.",
"author": { "author": {
"name": "Sergey Yarkov", "name": "Sergey Yarkov",
"email": "email@yarkov.tech", "email": "serzh.yarkov@gmail.com",
"url": "https://yarkov.tech" "url": "https://yarkov.tech"
}, },
"license": "MIT",
"type": "module", "type": "module",
"license": "MIT",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev --host",
"build": "astro check && astro build", "start": "astro dev",
"start": "node --env-file=.env ./dist/server/entry.mjs", "build": "astro build",
"preview": "astro preview", "preview": "astro preview --host",
"astro": "astro", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json}\"",
"telemetry-disable": "astro telemetry disable", "lint": "eslint '**/*.{js,mjs,ts,tsx}'"
"codegen": "graphql-codegen --require dotenv/config --config codegen.ts --watch"
},
"dependencies": {
"@astrojs/check": "^0.9.5",
"@astrojs/node": "^9.5.0",
"@astrojs/rss": "^4.0.13",
"@astrojs/solid-js": "^5.1.3",
"@iconify-json/mdi": "^1.2.3",
"@types/markdown-it": "^14.1.2",
"astro": "^5.15.8",
"astro-icon": "^1.1.5",
"graphql": "^16.12.0",
"hast-util-select": "^6.0.4",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"markdown-it-prism": "^3.0.1",
"markdown-it-table-of-contents": "^1.1.0",
"mime-types": "^2.1.35",
"sharp": "^0.32.6",
"solid-js": "^1.7.12"
}, },
"devDependencies": { "devDependencies": {
"@0no-co/graphqlsp": "^1.15.1", "@astrojs/image": "^0.3.4",
"@graphql-codegen/cli": "^6.1.0", "@astrojs/mdx": "^0.10.0",
"@graphql-codegen/schema-ast": "^5.0.0", "@astrojs/rss": "^1.0.0",
"@parcel/watcher": "^2.5.1", "@astrojs/sitemap": "^1.0.0",
"@types/html-escaper": "^3.0.4", "@astrojs/solid-js": "^1.0.0",
"@types/mime-types": "^2.1.4", "@types/mdx": "^2.0.2",
"@types/remark-prism": "^1.3.7", "@typescript-eslint/eslint-plugin": "^5.33.1",
"dotenv": "^17.2.3", "@typescript-eslint/parser": "^5.33.1",
"sass": "^1.68.0", "astro": "^1.0.6",
"typescript": "^5.9.3" "astro-compress": "^1.0.1",
"astro-icon": "^0.7.3",
"eslint": "^8.22.0",
"eslint-plugin-solid": "^0.7.1",
"hast-util-to-string": "^2.0.0",
"hastscript": "^7.0.2",
"html-escaper": "^3.0.3",
"prettier": "^2.7.1",
"prettier-plugin-astro": "^0.5.0",
"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"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +0,0 @@
{
"globalTitle": "Sergey Yarkov",
"articles": {
"recentLimit": 5
},
"isSiteUnderConstruction": false,
"contacts": {
"email": "email@yarkov.tech",
"telegram": "https://t.me/sergeyyarkov",
"github": "https://github.com/sergeyyarkov"
},
"ymCounter": 90034656,
"name": "yarkov.tech",
"author": "Sergey Yarkov"
}

View File

@ -1,45 +0,0 @@
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro/zod";
import { createAppeal, getLatestAppealsByClientIp } from "../queries";
const CREATE_APPEAL_LIMIT_HRS = 1;
const APPEALS_LAST_HRS_LIMIT = 2;
export const server = {
appeal: defineAction({
accept: "form",
input: z.object({
name: z.string(),
email: z.string().email(),
website_url: z.string().optional(), // honeypot field
subject: z.string(),
message: z.string(),
}),
handler: async (data, ctx) => {
if (data.website_url && data.website_url.trim() !== "") return true;
const currentDate = new Date();
const latestAppeals = await getLatestAppealsByClientIp(ctx.clientAddress, 5);
const appealsLastHrs = latestAppeals.filter((a) => {
const hourDiff = (currentDate.getTime() - new Date(a.date_created).getTime()) / (1000 * 60 * 60);
return hourDiff <= CREATE_APPEAL_LIMIT_HRS;
});
if (appealsLastHrs.length >= APPEALS_LAST_HRS_LIMIT) {
throw new ActionError({
code: "TOO_MANY_REQUESTS",
message: "Appeal create limit exceeded.",
});
}
const appeal = await createAppeal({ ...data, client_ip: ctx.clientAddress });
if (appeal) return true;
throw new ActionError({
code: "INTERNAL_SERVER_ERROR",
message: "Unable to create appeal.",
});
},
}),
};

View File

@ -1,12 +0,0 @@
---
import { getLanguageFromURL } from "@i18n/utils";
import { getAboutMe } from "../queries/global";
import { markdownToHTML } from "../utils/markdown";
import ArticleContent from "@components/ArticleContent.astro";
const pageLang = getLanguageFromURL(Astro.url.pathname);
const data = await getAboutMe(pageLang);
const aboutMeHtmlContent = await markdownToHTML(data?.about || "");
---
<ArticleContent set:html={aboutMeHtmlContent} />

View File

@ -1,19 +0,0 @@
---
import { getSiteSettings } from '@/src/queries';
const siteSettings = await getSiteSettings();
---
{siteSettings ? <script type="text/javascript" define:vars={{ counter: siteSettings.ym_counter }} is:inline>
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
var z = null;m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)})
(window, document, "script", "https://mc.yandex.ru/metrika/tag.js", "ym");
ym(counter, "init", {
clickmap: true,
trackLinks: true,
accurateTrackBounce: true,
});
</script> : null}

View File

@ -1,53 +0,0 @@
---
import UIString from "./UIString.astro";
interface Props {
url: string;
id: string;
lang: LanguageKeys;
}
const { url, id, lang } = Astro.props as Props;
---
<footer itemscope itemtype="http://schema.org/UserComments">
<h2><UIString key={"articlePost.comments"} />:</h2>
<div id="remark42"></div>
</footer>
<script is:inline define:vars={{ url, id, lang }}>
window.remark_config = {
host: "https://remark.yarkov.tech",
site_id: "remark",
theme: window.theme,
show_email_subscription: true,
url,
locale: lang,
no_footer: true,
};
var e = document.getElementById("remark42");
if ("IntersectionObserver" in window) {
var t = new IntersectionObserver((e) => {
e.forEach((e) => {
if (e.isIntersecting) {
((e, n) => {
for (var o = 0; o < e.length; o++) {
var r = n.createElement("script"),
c = ".js",
d = n.head || n.body;
("noModule" in r ? ((r.type = "module"), (c = ".mjs")) : (r.async = !0),
(r.defer = !0),
(r.src = remark_config.host + "/web/" + e[o] + c),
d.appendChild(r));
}
})(remark_config.components || ["embed"], document);
t.unobserve(e.target);
}
});
});
t.observe(e);
}
window.addEventListener("onthemetoggled", (e) => window.REMARK42.changeTheme(e.detail));
</script>

View File

@ -0,0 +1,29 @@
---
import { UTTERANCES_CONFIG } from "@root/config";
import UIString from "../UIString";
---
<footer>
<h2><UIString key={"articlePost.comments"} /></h2>
<div id="comments"></div>
</footer>
<script define:vars={{ UTTERANCES_CONFIG }}>
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("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);
}
if ($commentsWrapper) $commentsWrapper.appendChild($commentsScript);
</script>

View File

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

View File

@ -1,271 +0,0 @@
---
import { useTranslation } from "../i18n/utils";
const t = useTranslation(Astro);
const tocTitle = t("article.tableOfContents");
---
<div itemprop='articleBody' class='article-content'><slot /></div>
<script is:inline define:vars={{ tocTitle }}>
var $toc = document.querySelector(".toc");
if ($toc) {
$toc.querySelector("summary").textContent = tocTitle;
document.querySelector("body")?.addEventListener("click", () => $toc?.removeAttribute("open"));
}
</script>
<style lang='scss' is:global>
.article-content {
padding-top: 20px;
& h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 3rem;
margin-bottom: 1rem;
padding-bottom: 5px;
}
& p,
em,
q {
line-height: var(--line-height-base);
font-size: var(--font-size-lg);
}
& p {
margin: 1.5rem 0;
}
& ul,
ol {
padding-left: 1.5rem;
line-height: var(--line-height-base);
margin: 1.5rem 0;
& li {
font-size: var(--font-size-lg);
line-height: var(--line-height-base);
margin-bottom: 0.5rem;
padding-left: 0.25rem;
}
}
& q {
font-style: italic;
quotes: "« " " »";
}
& figure {
margin: 0 0 1em;
& figcaption {
color: var(--color-gray-400);
}
}
& hr {
background-color: var(--border-color);
margin: 40px 0;
border: none;
height: 1px;
}
& pre {
font-size: var(--font-size-sm);
border-radius: var(--border-radius-md);
}
& :not(pre) > code {
-webkit-font-smoothing: subpixel-antialiased;
background: var(--background-inline-code);
border-radius: var(--border-radius-lg);
font-size: 1rem;
font-weight: 500;
font-family: "IBM Plex Mono", monospace;
padding: 0.2rem 0.3rem;
}
& figcaption {
font-style: italic;
font-size: 1em;
text-align: center;
margin: 0.7rem 0;
}
& .table-wrapper {
overflow-x: auto;
margin-bottom: 1.25rem;
}
& table {
display: table;
border-radius: var(--border-radius-md);
min-width: 100%;
font-size: var(--font-size-md);
border: 1px solid var(--border-color);
border-collapse: separate;
border-spacing: 0;
margin: 5px 0;
& thead th {
background-color: var(--background-card);
border-top-left-radius: var(--border-radius-md);
border-top-right-radius: var(--border-radius-md);
}
& td,
th {
border-bottom: 1px solid var(--border-color);
padding: 0.5rem;
text-align: left;
}
& tbody tr:last-child td {
border: none;
}
}
& ul {
& li {
list-style: disc;
& li {
list-style-type: circle;
}
&::marker {
color: var(--color-blue-500);
font-size: 1.3rem;
line-height: var(--line-height-none);
}
}
}
& blockquote {
margin: 1.5rem 0;
font-style: italic;
border-left: 0.25rem solid var(--border-color);
padding-left: 1rem;
}
& .heading-wrapper {
border-bottom: 1px solid var(--border-color);
margin: 3rem 0 1rem 0;
padding-bottom: 0.35rem;
& h2 {
font-size: var(--font-size-2xl);
}
& h3 {
font-size: var(--font-size-xl);
}
& h4 {
font-size: var(--font-size-lg);
}
& h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
}
}
& .heading-wrapper {
display: flex;
align-items: center;
flex-direction: row-reverse;
justify-content: flex-end;
margin-inline-start: 1px;
& .anchor-link {
position: relative;
display: inline-flex;
text-decoration: none;
justify-content: center;
align-items: center;
opacity: 0;
& .anchor-icon {
position: absolute;
left: -25px;
top: -13px;
}
& svg {
vertical-align: middle;
}
}
& .anchor-label {
position: absolute;
width: 0;
height: 0;
padding: 0;
overflow: hidden;
white-space: nowrap;
}
&:hover {
& .anchor-link {
opacity: 1;
}
}
}
& .languages-available {
margin: 25px 0;
font-size: var(--font-size-lg);
& a:not(:last-child)::after {
content: ",";
}
}
.toc {
cursor: pointer;
display: block;
position: absolute;
& summary {
font-size: var(--font-size-lg);
}
& nav {
background-color: var(--background-card);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
margin-top: 10px;
padding: 10px 16px;
& ul {
margin: 6px 0;
padding-left: 0;
margin-left: 0;
& a {
display: block;
// margin-bottom: 10px;
color: var(--font-color);
text-decoration: none;
font-size: var(--font-size-md);
&:hover {
color: var(--color-blue-500);
}
}
& li {
list-style: none;
padding: 0;
}
& li ul {
margin-left: 15px;
}
}
}
}
}
@media screen and (max-width: 800px) {
.article-content {
& .heading-wrapper {
flex-direction: row;
justify-content: flex-start;
& .anchor-link {
& .anchor-icon {
left: 5px;
}
}
}
}
}
</style>

View File

@ -0,0 +1,5 @@
---
import "./ArticleContent.scss";
---
<div itemprop="articleBody" class="article-content"><slot /></div>

View File

@ -0,0 +1,214 @@
.article-content {
& p,
& em,
& q {
line-height: var(--line-height-base);
font-size: var(--font-size-lg);
}
& h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 3rem;
margin-bottom: 1rem;
padding-bottom: 5px;
}
& ul {
padding-left: 1.5rem;
line-height: var(--line-height-base);
margin-bottom: 1.5rem;
margin-top: 0;
& li {
font-size: var(--font-size-lg);
line-height: var(--line-height-base);
margin-bottom: 0.5rem;
padding-left: 0.25rem;
}
}
& q {
font-style: italic;
quotes: "«" "»";
}
& a {
color: var(--font-color);
text-decoration: underline;
text-decoration-color: var(--color-blue-500);
text-underline-offset: 2.2px;
text-decoration-thickness: 2px;
font-weight: 500;
&:hover {
color: var(--color-blue-500);
text-decoration-thickness: 2px;
}
}
& figure {
margin: 0 0 1em;
}
& hr {
background-color: var(--border-color);
margin: 40px 0;
border: none;
height: 1px;
}
& pre {
font-size: var(--font-size-sm);
border-radius: var(--border-radius-md);
}
& :not(pre) > code {
-webkit-font-smoothing: subpixel-antialiased;
background: var(--background-inline-code);
border-radius: var(--border-radius-lg);
font-size: 1rem;
font-weight: 500;
font-family: "IBM Plex Mono", monospace;
padding: 0.2rem 0.3rem;
}
& figcaption {
font-style: italic;
font-size: 1em;
text-align: center;
margin: 0.7rem 0;
}
& .table-wrapper {
overflow-x: auto;
margin-bottom: 1.25rem;
}
& table {
display: table;
border-radius: var(--border-radius-md);
min-width: 100%;
font-size: var(--font-size-md);
border: 1px solid var(--border-color);
border-collapse: separate;
border-spacing: 0;
margin: 5px 0;
& thead th {
background-color: var(--background-card);
border-top-left-radius: var(--border-radius-md);
border-top-right-radius: var(--border-radius-md);
}
& td,
th {
border-bottom: 1px solid var(--border-color);
padding: 0.5rem;
text-align: left;
}
& tbody tr:last-child td {
border: none;
}
}
& li {
list-style: disc;
& li {
list-style-type: circle;
}
&::marker {
color: var(--color-blue-500);
font-size: 1.3rem;
line-height: var(--line-height-none);
}
}
& blockquote {
margin: 1.5rem 0;
font-style: italic;
border-left: 0.25rem solid var(--border-color);
padding-left: 1rem;
}
& .heading-wrapper {
border-bottom: 1px solid var(--border-color);
margin: 3rem 0 1rem 0;
padding-bottom: 0.35rem;
& h2 {
font-size: var(--font-size-2xl);
}
& h3 {
font-size: var(--font-size-xl);
}
& h4 {
font-size: var(--font-size-lg);
}
& h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
}
}
& .heading-wrapper {
display: flex;
align-items: center;
flex-direction: row-reverse;
justify-content: flex-end;
margin-inline-start: 1px;
& .anchor-link {
position: relative;
display: inline-flex;
text-decoration: none;
justify-content: center;
align-items: center;
opacity: 0;
& .anchor-icon {
position: absolute;
left: -25px;
top: -13px;
}
& svg {
vertical-align: middle;
}
}
& .anchor-label {
position: absolute;
width: 0;
height: 0;
padding: 0;
overflow: hidden;
white-space: nowrap;
}
&:hover {
& .anchor-link {
opacity: 1;
}
}
}
& .languages-available {
margin: 25px 0;
font-size: var(--font-size-lg);
}
}
@media screen and (max-width: 800px) {
.article-content {
& .heading-wrapper {
flex-direction: row;
justify-content: flex-start;
& .anchor-link {
& .anchor-icon {
left: 5px;
}
}
}
}
}

View File

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

View File

@ -1,31 +0,0 @@
---
import { Image } from "astro:assets";
export interface Props {
src: string;
alt?: string;
}
const { src, alt } = Astro.props as Props;
---
<div class="cover">
<Image
itemprop="image"
src={src}
width={700}
height={400}
sizes="(max-width: 450px) 450px, 700px"
format="webp"
alt={alt ? alt : ""}
/>
</div>
<style lang="scss">
.cover {
margin: 20px 0;
& figure {
margin: 0;
}
}
</style>

View File

@ -1,41 +1,28 @@
/* eslint-disable solid/reactivity */
import type { Component } from "solid-js"; import type { Component } from "solid-js";
import { createRelativeArticleUrl, createDateFormatter } from "@root/utils"; import { createDateFormatter } from "@utils/date";
import { DEFAULT_LANGUAGE, URL_BLOG_PREFIX } from "@root/constants";
import "./ArticleItem.scss"; import "./ArticleItem.scss";
import { Article_Translations } from "@/src/graphql/graphql"; import { DEFAULT_LANGUAGE } from "@root/config";
export type ArticleItemProps = { export type ArticleItemProps = {
title: Article_Translations["title"]; data: ArticleType;
pub_date: Article_Translations["pub_date"]; lang: string;
slug: Article_Translations["slug"];
languages_code: Article_Translations["languages_code"];
pageLang: string;
}; };
const ArticleItem: Component<ArticleItemProps> = (props) => { export const ArticleItem: Component<ArticleItemProps> = (props) => {
const { title, pub_date: pubDate, slug, languages_code, pageLang } = props; const isNonDefaultLanguage = props.data.lang && props.data.lang !== props.lang;
const articleLang = languages_code?.code.split("-")[0] || DEFAULT_LANGUAGE; const hrefLangPrefix = props.data.lang && props.data.lang !== DEFAULT_LANGUAGE ? "/en" : "";
const href = createRelativeArticleUrl({ pubDate, slug, articleLang }, URL_BLOG_PREFIX);
return ( return (
<article <article class="article-item">
itemprop="blogPosts" <a href={`/blog/${new Date(props.data.published_at).toLocaleDateString("en-CA")}${hrefLangPrefix}/${props.data.slug}/`}>
itemscope
itemtype="http://schema.org/BlogPosting"
class="article-item"
>
<a href={href}>
<div class="flex"> <div class="flex">
<h3 itemprop="headline">{title}</h3> <h3>{props.data.title}</h3>
{articleLang !== pageLang && <sup>{articleLang.toLocaleUpperCase()}</sup>} {isNonDefaultLanguage && <sup>{props.data.lang.toLocaleUpperCase()}</sup>}
</div> </div>
<p> <p>
<time datetime={pubDate}>{createDateFormatter(pageLang).format(new Date(pubDate))}</time> <time datetime={props.data.published_at.toString()}>{createDateFormatter(props.lang).format(new Date(props.data.published_at))}</time>
</p> </p>
</a> </a>
</article> </article>
); );
}; };
export default ArticleItem;

View File

@ -1 +1 @@
export { default } from "./ArticleItem"; export * from "./ArticleItem";

View File

@ -1,193 +0,0 @@
---
import { Icon } from "astro-icon/components";
import { createDateFormatter } from "@root/utils";
import ArticleCover from "@components/ArticleCover.astro";
import ArticleComments from "@components/ArticleComments.astro";
import TranslatedLink from "@components/TranslatedLink.astro";
import AvailableOnLangs from "@components/AvailableOnLangs.astro";
import { useTranslation, getLanguageFromURL } from "@i18n/utils";
import { DIRECTUS_URL } from "astro:env/server";
import { Article_TranslationsQuery } from "../graphql/graphql";
export type Props = {
article: Article_TranslationsQuery["article_translations"][0];
lang: string;
};
const { article } = Astro.props as Props;
const pageLang = getLanguageFromURL(Astro.url.pathname);
const canonicalUrl = new URL(Astro.url.pathname, Astro.site);
const {
cover_image,
title,
author,
description,
tags,
pub_date,
slug,
views,
article_id: articleTranslations,
} = article;
const t = useTranslation(Astro);
// const articleCodeLangs = articleTranslations?.translations?.map((t) => t?.languages_code?.code);
---
<article itemscope itemtype="http://schema.org/BlogPosting" class="article">
<header>
<div>
{
tags && (
<div class="tags">
<ul itemprop="keywords">
{tags.map((tag) => (
<li>
<TranslatedLink rel="tag" to={`/articles/?tags=${tag?.tag_id?.title}`}>
{tag?.tag_id?.title}
</TranslatedLink>
</li>
))}
</ul>
</div>
)
}
<div class="views">
<Icon name="eye" width="21" height="21" />
<span><span>{views}</span>&nbsp;{t("article.views")}</span>
</div>
</div>
<div class="heading">
<h1 itemprop="headline">{title}</h1>
</div>
<div class="published">
<time itemprop="datePublished" datetime={pub_date}>
{createDateFormatter(pageLang).format(new Date(pub_date))}&nbsp;
</time>
<span itemprop="author">{`${author?.first_name} ${author?.last_name}`}</span>
</div>
{cover_image && <ArticleCover src={`${DIRECTUS_URL}/assets/${cover_image.filename_disk}`} alt={title} />}
<div class="description">
<p>
{description}
</p>
</div>
<AvailableOnLangs articles={articleTranslations} />
</header>
<slot />
<ArticleComments lang={pageLang} url={canonicalUrl.href} id={slug} />
</article>
<!-- <script define:vars={{ articleCodeLangs }} is:inline>
console.log(navigator.language, articleCodeLangs);
</script> -->
<style lang="scss" is:global>
.article {
& header {
& div:nth-child(1) {
display: flex;
justify-content: space-between;
align-items: center;
& .views {
display: flex;
align-items: center;
& span {
margin-left: 3.5px;
font-size: var(--font-size-sm);
color: var(--font-color-grayer);
}
}
}
& .heading {
margin-top: 1.125rem;
& h1 {
font-weight: 600;
font-size: 2.125rem;
}
& p {
font-size: 1.125rem;
line-height: var(--line-height-base);
}
}
& .tags {
font-size: var(--font-size-sm);
text-transform: uppercase;
& ul {
display: flex;
flex-wrap: wrap;
column-gap: 7px;
row-gap: 7px;
& li:not(:last-child) a::after {
content: ",";
}
}
}
& .published {
margin-bottom: 1.25em;
color: var(--font-color-gray);
}
}
& a {
color: var(--font-color);
text-decoration: underline;
text-decoration-color: var(--color-blue-500);
text-underline-offset: 2.2px;
text-decoration-thickness: 2px;
font-weight: 500;
&:hover {
color: var(--color-blue-500);
text-decoration-thickness: 2px;
}
}
& .cover {
margin: 20px 0;
& figure {
margin: 0;
}
}
& .description p {
line-height: var(--line-height-base);
font-size: var(--font-size-lg);
}
& footer {
margin-top: 5rem;
}
& img {
border-radius: var(--border-radius-lg);
display: block;
margin: 0 auto;
max-width: 100%;
height: auto;
&:not([src*=".svg"]) {
filter: var(--image-filter);
}
}
}
@media screen and (max-width: 560px) {
.article {
& header {
& div:nth-child(1) {
flex-direction: column;
align-items: flex-start;
& .tags {
margin-bottom: 15px;
}
}
& .heading h1 {
font-size: 2rem;
}
}
}
}
</style>

View File

@ -0,0 +1,56 @@
---
// import { Image } from "@astrojs/image/components";
// import * as utils from "@utils/string";
import { createDateFormatter } from "@root/utils";
import ArticleComments from "@components/ArticleComments";
import "./ArticlePost.scss";
import TranslatedLink from "../TranslatedLink";
export type Props = {
data: ArticleType;
lang: string;
};
const { data, lang } = Astro.props as Props;
const { title, coverImage, tags, author, published_at } = data;
---
<article itemscope itemtype="http://schema.org/BlogPosting" class="article">
<header>
<div class="tags">
<ul itemprop="keywords">
{
tags.map((tag) => (
<li>
<TranslatedLink rel="tag" to={`/articles/?tags=${tag.toLocaleLowerCase()}`}>{`${tag}${tags.at(-1) === tag ? "" : ","}`}</TranslatedLink>
</li>
))
}
</ul>
</div>
<div class="heading">
<h1 itemprop="headline">{title}</h1>
</div>
<div class="published">
<time itemprop="datePublished" datetime={published_at}>{createDateFormatter(lang).format(new Date(published_at))}</time>
<span itemprop="author">{author}</span>
</div>
{
coverImage && (
<div>
<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} />
)*/}
</figure>
</div>
)
}
</header>
<slot />
<ArticleComments />
</article>

View File

@ -0,0 +1,57 @@
.article {
& header {
& .heading {
margin-top: 1.125rem;
& h1 {
font-weight: 600;
font-size: 2.125rem;
}
& p {
font-size: 1.125rem;
line-height: var(--line-height-base);
}
}
& .tags {
font-size: var(--font-size-sm);
text-transform: uppercase;
& ul {
display: flex;
flex-wrap: wrap;
column-gap: 7px;
row-gap: 7px;
}
}
& .published {
margin-bottom: 1.25em;
color: var(--font-color-gray);
}
}
& img {
border-radius: var(--border-radius-md);
display: block;
margin: 0 auto;
max-width: 100%;
height: auto;
}
& figure {
margin: 0;
}
& footer {
margin-top: 5rem;
}
}
@media screen and (max-width: 560px) {
.article {
& header .heading h1 {
font-size: 2rem;
}
}
}

View File

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

View File

@ -1,14 +0,0 @@
---
import { useTranslation, getLanguageFromURL } from "@i18n/utils";
import * as utils from "@utils/article";
import List from "@components/ArticlesList";
import { getArticleList } from "@root/queries/article";
const t = useTranslation(Astro);
const pageLang = getLanguageFromURL(Astro.url.pathname);
const data = await getArticleList();
const articles = utils.filterArticlesByPageLang(data, pageLang);
const articleBlocks = utils.formatToArticleBlocks(articles);
---
<List client:load i18n={{ "articles.empty": t("articles.empty") }} articles={articleBlocks} pageLang={pageLang} />

View File

@ -1,4 +1,7 @@
.articles { .articles {
&-tags {
margin-bottom: 45px;
}
&-list { &-list {
&__wrapper { &__wrapper {
margin-top: 40px; margin-top: 40px;

View File

@ -1,93 +1,94 @@
import type { Component } from "solid-js"; import type { Component } from "solid-js";
import { createSignal, createEffect, For } from "solid-js"; import { createSignal, createEffect, For } from "solid-js";
import { search, selectedTags, setCount } from "@stores/searchStore"; import { search, selectedTags } from "@stores/searchStore";
import ArticleItem from "@components/ArticleItem"; import { findArticlesBySearch, findArticlesByTags, sortArtcilesByDate } from "@utils/articles";
import { ArticleQuery } from "@/src/graphql/graphql"; import { ArticleItem } from "@components/ArticleItem";
import SearchInput from "@components/SearchInput";
import TagsList from "@components/TagsList";
import "./ArticlesList.scss"; import "./ArticlesList.scss";
type UiStringsType = { "articles.empty": string }; type UiStringsType = {
type Articles = ArticleQuery["article"][0]["translations"]; "input.search": string;
type ArticlesBlockType = Record<string, Articles>; "articles.empty": string;
};
type ArticlesListProps = { type ArticlesListProps = {
articles: ArticlesBlockType; data: {
pageLang: string; articles: ArticleBlockType;
tags: string[];
};
lang: string;
i18n: UiStringsType; i18n: UiStringsType;
}; };
const ArticlesList: Component<ArticlesListProps> = (props) => { const ArticlesList: Component<ArticlesListProps> = (props) => {
const [articles, setArticles] = createSignal<ArticlesBlockType>(props.articles); // eslint-disable-next-line solid/reactivity
const [articles, setArticles] = createSignal<ArticleBlockType>(props.data.articles);
const isEmpty = () => Object.keys(articles()).length === 0; /**
* Sort articles by year in descending order
const sortByYears = (articles: ArticlesBlockType) => { */
const sortYears = (articles: ArticleBlockType) => {
return Object.keys(articles) return Object.keys(articles)
.map(Number) .map(Number)
.sort((a, b) => b - a); .sort((a, b) => b - a);
}; };
const searchArticles = ({ const isEmpty = () => Object.keys(articles()).length === 0;
params: { search, tags }, const getLength = () => Object.values(articles()).flat().length;
}: {
params: { search: string; tags?: string[] };
}): ArticlesBlockType => {
const filter = (cb: (articles: Articles) => Articles) => {
return Object.fromEntries(
Object.keys(props.articles)
.map((year) => [year, cb(props.articles[year])])
.filter((articles) => articles[1]?.length !== 0)
);
};
return filter((articles) => {
let filtered = articles?.filter(
(a) => a && a.title.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())
);
if (tags && tags.length > 0) {
filtered = filtered?.filter((a) =>
tags.some((t) => {
return a?.tags?.map((t) => t?.tag_id?.title).includes(t);
})
);
}
return filtered;
});
};
createEffect(() => { createEffect(() => {
const filtered = searchArticles({ params: { search: search(), tags: selectedTags() } }); let filtered = props.data.articles;
setArticles(filtered);
setCount(Object.values(filtered).flat().length); /**
* Find articles by title
*/
if (search()) {
filtered = findArticlesBySearch(search().trim(), filtered);
setArticles(filtered);
}
/**
* Find articles by selected tags
*/
if (selectedTags().length !== 0) {
filtered = findArticlesByTags(selectedTags(), filtered);
setArticles(filtered);
return;
}
/**
* Set filtered variable as default value
*/
if (!search() && selectedTags().length === 0) {
filtered = props.data.articles;
setArticles(filtered);
}
}); });
return ( return (
<> <>
<div itemscope itemtype="http://schema.org/Blog" class="articles-list"> <SearchInput count={getLength()} placeholder={props.i18n["input.search"]} />
{!isEmpty() ? ( <TagsList data={props.data.tags} />
<For each={sortByYears(articles())}> <div class="articles-list">
{(year) => ( {isEmpty() ? (
<div class="articles-list__wrapper">
<h2>{year}</h2>
<For each={articles()[year]}>
{(a) =>
a && (
<ArticleItem
slug={a.slug}
title={a.title}
languages_code={a.languages_code}
pub_date={a.pub_date}
pageLang={props.pageLang}
/>
)
}
</For>
</div>
)}
</For>
) : (
<div class="articles-list__empty"> <div class="articles-list__empty">
<p>{props.i18n["articles.empty"]}</p> <p>{props.i18n["articles.empty"]}</p>
</div> </div>
) : (
<For
each={sortYears(articles())}
children={(year) => {
return (
<div class="articles-list__wrapper">
<h2>{year}</h2>
<For each={sortArtcilesByDate(articles()[year])} children={(article) => <ArticleItem lang={props.lang} data={article} />} />
</div>
);
}}
/>
)} )}
</div> </div>
</> </>

View File

@ -1,43 +0,0 @@
---
import { getLanguageFromURL, useTranslation } from "@root/i18n/utils";
import { createRelativeArticleUrl } from "@root/utils";
import { Article_TranslationsQuery } from "../graphql/graphql";
export interface Props {
articles: Article_TranslationsQuery["article_translations"][0]["article_id"];
}
const pageLang = getLanguageFromURL(Astro.url.pathname);
const t = useTranslation(Astro);
const { articles } = Astro.props;
---
{
articles && articles.translations && articles.translations?.length !== 1 && (
<div>
{t("article.availableOn")}:{" "}
{articles.translations
.filter((a) => a && a.languages_code?.code.split("-")[0] !== pageLang)
.map(
(a) =>
a && (
<a
href={`${createRelativeArticleUrl({ slug: a.slug, articleLang: `${a.languages_code?.code.split("-")[0]}`, pubDate: a.pub_date })}`}
>{`${a.languages_code?.name}`}</a>
)
)}
</div>
)
}
<style lang="scss">
div {
margin: 10px 0;
font-size: var(--font-size-lg);
& a {
&:not(:last-child)::after {
content: ",";
}
}
}
</style>

View File

@ -1,69 +1,38 @@
--- ---
import "@styles/global.scss"; import { SITE_METADATA } from "@root/config";
import { SITE_METADATA } from "@root/constants"; import { useTranslation } from "@i18n/utils";
import YaMetrika from "./Analytics/YaMetrika.astro";
import { useTranslation } from "@root/i18n/utils";
export interface Props { export interface Props {
title?: string; title?: string | undefined;
description?: string; description?: string | undefined;
image?: string;
} }
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { title, description } = Astro.props;
const isDev = import.meta.env.DEV;
const t = useTranslation(Astro); const t = useTranslation(Astro);
const { title = SITE_METADATA.title, description } = Astro.props as Props;
--- ---
<head> <head>
<!-- Fonts --> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel='preconnect' href='https://fonts.googleapis.com' /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin />
<link <link
href='https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono&display=swap' href="https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono&display=swap"
rel='preload' rel="preload" as="style" onload="this.rel='stylesheet'"
as='style'
onload={`this.rel='stylesheet'`}
/> />
<!-- Canonical URL --> <meta charset="utf-8" />
<link rel='canonical' href={canonicalURL.href} /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<!-- Base metadata --> <meta name="title" content={title} />
<meta charset='utf-8' /> <meta name="description" content={description || t("site.description")} />
<meta name='viewport' content='width=device-width, initial-scale=1' /> <meta name="theme-color" content={SITE_METADATA.themeColor} />
<title>{title || SITE_METADATA.title}</title> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-light-32x32.png" media="(prefers-color-scheme: light)" />
<!-- Metadata --> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-light-16x16.png" media="(prefers-color-scheme: light)" />
<meta name='title' content={title || SITE_METADATA.title} /> <link rel="icon" type="image/png" sizes="32x32" href="/favicon-dark-32x32.png" media="(prefers-color-scheme: dark)" />
<meta name='generator' content={Astro.generator} /> <link rel="icon" type="image/png" sizes="16x16" href="/favicon-dark-16x16.png" media="(prefers-color-scheme: dark)" />
<meta name='description' content={description || t("site.description")} /> <link rel="manifest" href="/site.webmanifest" />
<meta name='theme-color' content={SITE_METADATA.themeColor} /> <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
<meta name='msapplication-TileColor' content='#da532c' /> <meta name="msapplication-TileColor" content="#da532c" />
<!-- Favicons -->
<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
<link
rel='icon'
type='image/png'
sizes='32x32'
href='/favicon-light-32x32.png'
media='(prefers-color-scheme: light)'
/>
<link
rel='icon'
type='image/png'
sizes='16x16'
href='/favicon-light-16x16.png'
media='(prefers-color-scheme: light)'
/>
<link rel='icon' type='image/png' sizes='32x32' href='/favicon-dark-32x32.png' media='(prefers-color-scheme: dark)' />
<link rel='icon' type='image/png' sizes='16x16' href='/favicon-dark-16x16.png' media='(prefers-color-scheme: dark)' />
<link rel='manifest' href='/site.webmanifest' />
<link rel='mask-icon' href='/safari-pinned-tab.svg' color='#5bbad5' />
<link rel='sitemap' href='/sitemap-index.xml' />
<slot /> <slot />
<script is:inline src='/assets/js/detect-theme.js'></script> <script is:inline src="/assets/js/detect-theme.js"></script>
{!isDev && <YaMetrika />}
</head> </head>

View File

@ -1,22 +0,0 @@
---
import { IS_SITE_UNDER_CONSTRUCTION } from "@root/constants";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import SiteContent from "@components/SiteContent.astro";
import UnderConstruction from "./UnderConstruction.astro";
---
<body>
{
IS_SITE_UNDER_CONSTRUCTION && (
<UnderConstruction>
&#128679;&nbsp; This web site is under development &nbsp;&#128679;
</UnderConstruction>
)
}
<Header />
<SiteContent>
<slot />
</SiteContent>
<Footer />
</body>

View File

@ -0,0 +1,25 @@
---
import { IS_SITE_UNDER_CONSTRUCTION } from "@root/config";
import Header from "@components/Header";
import Footer from "@components/Footer";
import HeaderBanner from "@components/HeaderBanner";
import UIString from "@components/UIString";
import "./BaseLayout.scss";
---
<body>
{
IS_SITE_UNDER_CONSTRUCTION && (
<HeaderBanner>
&#128679;&nbsp;
<UIString key={"site.underConstruction"} />
&nbsp;&#128679;
</HeaderBanner>
)
}
<Header />
<main class="site-content">
<slot />
</main>
<Footer />
</body>

View File

@ -0,0 +1,11 @@
.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 @@
export { default } from "./BaseLayout.astro";

View File

@ -1,25 +0,0 @@
---
export type Props = astroHTML.JSX.ButtonHTMLAttributes;
const attrs = Astro.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

@ -0,0 +1,28 @@
.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

@ -0,0 +1,22 @@
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

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

View File

@ -1,85 +0,0 @@
---
import Button from "@components/Button.astro";
import UIString from "./UIString.astro";
import { useTranslation } from "@i18n/utils";
import { actions } from "astro:actions";
const t = useTranslation(Astro);
const formResult = Astro.getActionResult(actions.appeal);
---
<>
<form name="contact" action={actions.appeal} method="post">
<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" type="email" autocomplete="off" required />
</div>
<div class="hp-field" aria-hidden="true">
<label for="website_url">Оставьте это поле пустым:</label>
<input type="text" autocomplete="off" id="website_url" name="website_url" tabindex="-1" autocomplete="off" />
</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">
{
formResult?.error && (
<span>
<UIString key={formResult.error.status === 429 ? "submitted.limit" : "submitted.error"} />
</span>
)
}
<Button type={"submit"}>
<UIString key={"btn.submit"} />
</Button>
</div>
</form>
</>
<style lang="scss">
form {
display: flex;
flex-direction: column;
row-gap: 7px;
margin-bottom: 3.125rem;
& .hp-field {
position: absolute;
left: -9999px;
top: -9999px;
opacity: 0;
pointer-events: none;
}
& .flex {
column-gap: 10px;
& div {
width: 100%;
}
}
& input.error-input,
& textarea.error-input {
border: 1px solid var(--color-error);
}
& .footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
}
@media screen and (max-width: 560px) {
form .flex {
flex-direction: column;
row-gap: 7px;
}
}
</style>

View File

@ -0,0 +1,46 @@
.contact-form {
display: flex;
flex-direction: column;
row-gap: 7px;
margin-bottom: 3.125rem;
& .form-footer {
text-align: center;
margin-top: 15px;
}
& .form-result {
font-style: italic;
color: var(--font-color-gray);
}
& .error-message {
margin: 2px 0 3px 0;
display: block;
color: var(--color-error);
font-size: var(--font-size-sm);
user-select: none;
&::before {
content: "*";
}
}
& .flex {
column-gap: 10px;
& div {
width: 100%;
}
}
& input.error-input,
& textarea.error-input {
border: 1px solid var(--color-error);
}
}
@media screen and (max-width: 560px) {
.contact-form .flex {
flex-direction: column;
row-gap: 7px;
}
}

View File

@ -0,0 +1,140 @@
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

@ -0,0 +1,7 @@
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

@ -0,0 +1,7 @@
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

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

View File

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

View File

@ -1,45 +0,0 @@
---
import { Icon } from "astro-icon/components";
import { getContacts } from "../queries";
const contacts = await getContacts();
---
<div>
{
contacts && (
<>
<a href={"mailto:" + contacts.email}>
Email
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
</a>
<a href={contacts.github} target='_blank'>
Github
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
</a>
<a href={contacts.telegram} target='_blank'>
Telegram
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
</a>
</>
)
}
</div>
<style lang='scss'>
div {
display: flex;
flex-wrap: wrap;
column-gap: 40px;
row-gap: 30px;
& a {
display: flex;
column-gap: 5px;
}
& svg {
filter: none;
}
}
</style>

View File

@ -0,0 +1,24 @@
---
import { SITE_METADATA } from '@root/config';
import { Icon } from 'astro-icon';
import './ContactsList.scss';
const {
contacts: { email, githubUrl, telegramUrl },
} = SITE_METADATA;
---
<div class='contacts-list'>
<a href={'mailto:' + email}
>Email
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
</a>
<a href={githubUrl} target='_blank'
>Github
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
</a>
<a href={telegramUrl} target='_blank'
>Telegram
<Icon name='arrow-up-right' width='15' height='15' data-filter='none' />
</a>
</div>

View File

@ -0,0 +1,15 @@
.contacts-list {
display: flex;
flex-wrap: wrap;
column-gap: 40px;
row-gap: 30px;
& a {
display: flex;
column-gap: 5px;
}
& svg {
filter: none;
}
}

View File

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

View File

@ -0,0 +1,14 @@
.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

@ -0,0 +1,45 @@
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);
const [count, setCount] = createSignal<number>(0);
const run = () => setIsRunned(true);
const stop = () => setIsRunned(false);
const inc = () => setCount(count() + 1);
const dec = () => setCount(count() - 1);
createEffect(() => {
setInterval(() => {
if (isRunned()) {
setCount(count() + 1);
}
}, props.tick || 200);
});
return (
<div class="counter">
<span>{count()}</span>
<div>
<Button disabled={isRunned()} onClick={run}>
Run
</Button>
<Button disabled={isRunned()} onClick={inc}>
+
</Button>
<Button disabled={isRunned()} onClick={dec}>
-
</Button>
<Button disabled={!isRunned()} onClick={stop}>
Stop
</Button>
</div>
</div>
);
};
export default Counter;

View File

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

View File

@ -1,52 +0,0 @@
---
import { Icon } from "astro-icon/components";
import TranslatedLink from "./TranslatedLink.astro";
import UIString from "./UIString.astro";
import { getLanguageFromURL } from "../i18n/utils";
const pageLang = getLanguageFromURL(Astro.url.pathname);
---
<footer>
<div class="container">
<div>
<i>
<UIString key={"footer.developed"} />: <TranslatedLink to="/contacts/"> Sergey Yarkov </TranslatedLink>
&copy; {new Date().getFullYear()}
</i>
</div>
<div>
<a href=`/${pageLang}/rss.xml` target="_blank"><UIString key={"footer.rssFeed"} /></a>
<span>
<Icon name="rss" width="20" height="20" />
</span>
</div>
</div>
</footer>
<style lang="scss">
footer {
font-weight: 500;
margin-top: 5rem;
color: var(--color-gray-400);
font-size: var(--font-size-sm);
& .container {
padding-top: 15px;
padding-bottom: 15px;
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
column-gap: 20px;
row-gap: 5px;
& div:last-child {
display: flex;
align-items: center;
& a {
margin-right: 0.25rem;
}
}
}
}
</style>

View File

@ -0,0 +1,24 @@
---
import './Footer.scss';
import { Icon } from 'astro-icon'
---
<footer class='footer'>
<div class='container'>
<ul class='footer__links'>
<li>
<a href='https://astro.build/' target='_blank'>Astro</a>
<Icon name='astro' width='20' height='20' />
</li>
<li>
<a href='https://www.netlify.com/' target='_blank'>Netlify</a>
<Icon name='netlify' width='20' height='20' />
</li>
<li>
<a href='/rss.xml' target='_blank'>RSS</a>
<Icon name='rss' width='20' height='20' />
</li>
</ul>
<p class='footer__copy'>© Sergey Yarkov, 2022</p>
</div>
</footer>

View File

@ -0,0 +1,34 @@
.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

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

View File

@ -1,139 +0,0 @@
---
import { IS_SITE_UNDER_CONSTRUCTION, SITE_METADATA } from "@root/constants";
import ThemeSwitcher from "@components/ThemeSwticher";
import LanguageSelect from "@components/LanguageSelect.astro";
import NavToggle from "@components/NavToggle";
import UIString from "@components/UIString.astro";
import TranslatedLink from "@components/TranslatedLink.astro";
import HeaderContacts from "./HeaderContacts.astro";
---
<header {...IS_SITE_UNDER_CONSTRUCTION ? { style: "top: 45px;" } : {}}>
<div class="container">
<div class="logo">
<TranslatedLink to="/">
{SITE_METADATA.name}
</TranslatedLink>
</div>
<nav itemscope itemtype="http://schema.org/SiteNavigationElement">
<ul>
{
SITE_METADATA.nav.map((link) => (
<li>
<TranslatedLink
itemprop={"url"}
to={link.url}
class:list={[{ active: Astro.url.pathname.includes(link.url) }]}
>
<UIString key={link.key} />
</TranslatedLink>
</li>
))
}
</ul>
<HeaderContacts />
<div class="flex settings">
<LanguageSelect />
<ThemeSwitcher client:visible />
</div>
</nav>
<NavToggle client:visible />
</div>
</header>
<style lang="scss">
header {
position: sticky;
top: 0;
border-bottom: 1px solid var(--border-color);
background-color: var(--background-header-color);
padding-top: 13.5px;
padding-bottom: 13.5px;
user-select: none;
z-index: 9999;
& .container {
display: flex;
align-items: flex-start;
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;
}
}
& .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: left;
margin: 0;
& li {
margin-bottom: 30px;
& a {
font-size: var(--font-size-xl);
}
}
}
}
}
}
}
</style>

View File

@ -0,0 +1,55 @@
---
import { Icon } from "astro-icon";
import { IS_SITE_UNDER_CONSTRUCTION, SITE_METADATA } from "@root/config";
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" : ""}`}>
<div class="container">
<div class="header__logo">
<TranslatedLink to={"/"}>
{name}
</TranslatedLink>
</div>
<nav class="header__nav">
<ul class="header__nav-links">
{
nav.map((link) => (
<li>
<TranslatedLink class={pathname.includes(link.url) && "active"} to={link.url}>
<UIString key={link.key} />
</TranslatedLink>
</li>
))
}
</ul>
<div class="header__nav-contacts">
<a href={"mailto:" + contacts.email}>
Email
<Icon name="email" width="18" height="18" />
</a>
<a href={contacts.githubUrl}>
Github
<Icon name="github_filled" width="18" height="18" />
</a>
<a href={contacts.telegramUrl}>
Telegram
<Icon name="telegram" width="18" height="18" />
</a>
</div>
<div class="flex header__nav-settings">
<LanguageSelect />
<ThemeSwitcher client:visible />
</div>
</nav>
<NavToggle client:visible />
</div>
</header>

View File

@ -0,0 +1,144 @@
.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,10 +13,3 @@
display: none; display: none;
} }
} }
body[data-is-mobile-open="true"] {
.nav-toggle {
position: absolute;
right: var(--content-padding);
}
}

View File

@ -1,27 +1,16 @@
import type { Component } from "solid-js"; import type { Component } from "solid-js";
import { createSignal, createEffect } from "solid-js"; import { createSignal, createEffect } from "solid-js";
import "./NavToggle.scss"; import "./NavToggle.scss";
const MenuIcon: Component = () => ( const MenuIcon: Component = () => (
<svg <svg fill="#000000" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="24px" height="24px">
fill="#000000"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 50 50"
width="24px"
height="24px"
>
<path d="M 0 7.5 L 0 12.5 L 50 12.5 L 50 7.5 Z M 0 22.5 L 0 27.5 L 50 27.5 L 50 22.5 Z M 0 37.5 L 0 42.5 L 50 42.5 L 50 37.5 Z" /> <path d="M 0 7.5 L 0 12.5 L 50 12.5 L 50 7.5 Z M 0 22.5 L 0 27.5 L 50 27.5 L 50 22.5 Z M 0 37.5 L 0 42.5 L 50 42.5 L 50 37.5 Z" />
</svg> </svg>
); );
const CloseIcon: Component = () => ( const CloseIcon: Component = () => (
<svg <svg fill="#000000" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" width="24px" height="24px">
fill="#000000"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 50 50"
width="24px"
height="24px"
>
<path d="M 9.15625 6.3125 L 6.3125 9.15625 L 22.15625 25 L 6.21875 40.96875 L 9.03125 43.78125 L 25 27.84375 L 40.9375 43.78125 L 43.78125 40.9375 L 27.84375 25 L 43.6875 9.15625 L 40.84375 6.3125 L 25 22.15625 Z" /> <path d="M 9.15625 6.3125 L 6.3125 9.15625 L 22.15625 25 L 6.21875 40.96875 L 9.03125 43.78125 L 25 27.84375 L 40.9375 43.78125 L 43.78125 40.9375 L 27.84375 25 L 43.6875 9.15625 L 40.84375 6.3125 L 25 22.15625 Z" />
</svg> </svg>
); );
@ -43,13 +32,7 @@ const NavToggle: Component = () => {
}); });
return ( return (
<button <button class="nav-toggle" onClick={toggleNav} type="button" aria-pressed={isNavShown()} aria-label="Menu">
class="nav-toggle"
onClick={toggleNav}
type="button"
aria-pressed={isNavShown()}
aria-label="Menu"
>
{isNavShown() ? <CloseIcon /> : <MenuIcon />} {isNavShown() ? <CloseIcon /> : <MenuIcon />}
</button> </button>
); );

View File

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

View File

@ -0,0 +1,34 @@
<div class="banner-wrapper">
<span><slot /></span>
</div>
<style lang="scss" is:global>
.header.header-banner {
top: 45px;
}
.banner-wrapper {
padding: 12px 0;
min-height: 45px;
text-align: center;
background: var(--color-black);
color: var(--color-white);
position: sticky;
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

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

View File

@ -1,54 +0,0 @@
---
import { Icon } from "astro-icon/components";
import { getContacts } from "../queries";
const contacts = await getContacts();
---
{
contacts && (
<div class="contacts">
<a target="_blank" href={"mailto:" + contacts.email}>
Email
<Icon name="email" width="18" height="18" />
</a>
<a target="_blank" href={contacts.github}>
Github
<Icon name="github_filled" width="18" height="18" />
</a>
<a target="_blank" href={contacts.telegram}>
Telegram
<Icon name="telegram" width="18" height="18" />
</a>
</div>
)
}
<style lang="scss">
.contacts {
display: flex;
align-items: center;
column-gap: 10px;
margin-right: 30px;
& a {
text-decoration: none;
font-size: 0;
&:hover {
opacity: var(--opacity-hover);
}
}
}
@media screen and (max-width: 768px) {
.contacts {
margin-right: 0;
margin-top: 2.5rem;
& a {
margin-right: 0.2rem;
}
& svg {
width: 21px;
height: 21px;
}
}
}
</style>

View File

@ -1,7 +1,8 @@
--- ---
import { Icon } from "astro-icon/components"; import { Icon } from "astro-icon";
import { DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES, URL_BLOG_PREFIX } from "@root/constants"; import { DEFAULT_LANGUAGE } from "@root/config";
import { getLanguageFromURL } from "@i18n/utils"; import languages from "@i18n/languages";
import { getLanguageFromURL } from "@root/utils";
const lang = getLanguageFromURL(Astro.url.pathname); const lang = getLanguageFromURL(Astro.url.pathname);
--- ---
@ -10,8 +11,8 @@ const lang = getLanguageFromURL(Astro.url.pathname);
<Icon name="translate" width="21" height="21" /> <Icon name="translate" width="21" height="21" />
<select id="languageSelect" value={lang} aria-label="Select language"> <select id="languageSelect" value={lang} aria-label="Select language">
{ {
SUPPORTED_LANGUAGES.map((code) => ( Object.entries(languages).map(([code]) => (
<option value={code} selected={code === lang}> <option value={code} selected={lang === code}>
<span>{code.toLocaleUpperCase()}</span> <span>{code.toLocaleUpperCase()}</span>
</option> </option>
)) ))
@ -19,34 +20,6 @@ const lang = getLanguageFromURL(Astro.url.pathname);
</select> </select>
</div> </div>
<script is:inline define:vars={{ lang, DEFAULT_LANGUAGE, URL_BLOG_PREFIX }}>
const $select = document.getElementById("languageSelect");
const changeLanguageHandler = (e) => {
const newLang = e.target.value;
const pathname = window.location.pathname;
let url;
if (lang !== DEFAULT_LANGUAGE) {
url = pathname.split("/").slice(2).join("/");
} else {
url = pathname.slice(1);
}
if (newLang !== DEFAULT_LANGUAGE) url = `${newLang}/${url}`;
/* Move user to articles page with new language */
if (window.location.pathname.includes(`/${URL_BLOG_PREFIX}/`)) {
window.location.pathname = `${newLang === DEFAULT_LANGUAGE ? "" : newLang + "/"}` + "articles/";
return;
}
window.location.pathname = url;
};
$select.addEventListener("change", changeLanguageHandler);
</script>
<style lang="scss"> <style lang="scss">
div { div {
position: relative; position: relative;
@ -69,25 +42,24 @@ const lang = getLanguageFromURL(Astro.url.pathname);
border-radius: var(--border-radius-md); border-radius: var(--border-radius-md);
cursor: pointer; cursor: pointer;
} }
body[data-is-mobile-open="true"] {
.language-select {
position: absolute;
bottom: 115px;
right: 2.2rem;
& svg,
& select {
height: 36px;
width: 36px;
}
}
}
@media screen and (max-width: 768px) {
body[data-is-site-under-construction] {
.language-select {
bottom: 145px;
}
}
}
</style> </style>
<script define:vars={{ lang, DEFAULT_LANGUAGE }}>
const $select = document.getElementById("languageSelect");
const changeLanguageHandler = (e) => {
const newLang = e.target.value;
const pathname = window.location.pathname;
const url = newLang === DEFAULT_LANGUAGE ? pathname.slice(3) : `${newLang}${pathname}`;
/* Move user to articles page with new language */
if (window.location.pathname.includes("/blog/")) {
window.location.pathname = `${newLang === DEFAULT_LANGUAGE ? "" : newLang + "/"}` + "articles/";
return;
}
window.location.pathname = url;
};
$select.addEventListener("change", changeLanguageHandler);
</script>

View File

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

View File

@ -1,33 +0,0 @@
---
import mime from 'mime-types'
import { extname } from 'node:path'
import { isURL } from "@utils/string";
export interface Props {
title: string;
siteName: string;
description: string;
type: string;
url: string;
image?: {
path: string;
width: string;
height: string;
type: string;
}
}
const {title, siteName, description, url, image} = Astro.props as Props;
---
<meta property="og:title" content={title} />
<meta property="og:site_name" content={siteName} />
<meta property="og:description" content={description} />
<meta property="og:type" content="article" />
<meta property="og:url" content={url} />
{image && (
<meta property="og:image" content={isURL(image.path) ? image.path : `${Astro.url.origin}${image.path}`} />
<meta property="og:image:width" content="700" />
<meta property="og:image:height" content="400" />
<meta property="og:image:type" content={`${mime.lookup(extname(image.path))}`} />
)}

View File

@ -1,30 +0,0 @@
---
export interface Props {
heading: string;
description?: string | undefined;
}
const { heading, description } = Astro.props as Props;
---
<div class="page-heading">
<h1>
{heading}
</h1>
{description && <p>{description}</p>}
</div>
<style lang="scss">
div {
margin-bottom: 2.5rem;
text-align: center;
& h1 {
font-size: var(--font-size-4xl);
margin-bottom: 15px;
}
& p {
font-size: var(--font-size-lg);
max-width: 650px;
margin: 0 auto;
}
}
</style>

View File

@ -0,0 +1,17 @@
---
import "./PageHeading.scss";
export interface Props {
heading: string;
description?: string | undefined;
}
const { heading, description } = Astro.props as Props;
---
<div class="page-heading">
<h1 style={!description && "margin-bottom: 2.5rem;"}>
{heading}
</h1>
{description && <p>{description}</p>}
</div>

View File

@ -0,0 +1,14 @@
.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

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

View File

@ -1,89 +0,0 @@
---
import { Icon } from "astro-icon/components";
import UIString from "@components/UIString.astro";
import { Project_TranslationsQuery } from "../graphql/graphql";
import { DIRECTUS_URL } from "astro:env/server";
export interface Props {
data: Project_TranslationsQuery["project_translations"][0];
}
const { data } = Astro.props as Props;
const { icon, title, year, description, article_url, source_url, demo_url } = data;
---
<div class="project-item">
<div class="project-item__heading">
<h2>
{icon && <img data-icon src={`${DIRECTUS_URL}/assets/${icon.filename_disk}`} />}
{title}
</h2>
<span>{year}</span>
</div>
<div class="project-item__description">
<p>{description}</p>
</div>
<div class="project-item__links">
{
article_url && (
<a class="btn-link" href={article_url} target="_blank">
<UIString key={"btn.article"} />
</a>
)
}
{
source_url && (
<a class="btn-link" href={source_url} target="_blank">
<UIString key={"btn.sourceCode"} />
<Icon name="mdi:link-variant" width="15" height="15" />
</a>
)
}
{
demo_url && (
<a class="btn-link" href={demo_url} target="_blank">
<UIString key={"btn.demo"} />
<Icon name="mdi:link-variant" width="15" height="15" />
</a>
)
}
</div>
</div>
<style lang="scss">
.project-item {
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
padding: 15px;
&__heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
& h2 {
display: flex;
align-items: center;
font-size: 1.2rem;
font-weight: 600;
column-gap: 10px;
margin: 0;
padding: 0;
}
& span {
font-size: var(--font-size-sm);
color: var(--font-color-gray);
}
}
&__links {
display: flex;
margin-top: auto;
column-gap: 5px;
}
}
</style>

View File

@ -0,0 +1,53 @@
---
import { DEFAULT_LANGUAGE } from "@root/config";
import { getLanguageFromURL } from "@root/utils";
import { Icon } from "astro-icon";
import UIString from "../UIString";
import "./ProjectItem.scss";
export interface Props {
data: ProjectType;
}
const { data } = Astro.props as Props;
const lang = getLanguageFromURL(Astro.url.pathname);
const { icon, name, year, description, articleUrl, sourceUrl, demoUrl } = data;
---
<div class="project-item">
<div class="project-item__heading">
<h2>
<Icon name={icon} width="20" height="20" />
{name}
</h2>
<span>{year}</span>
</div>
<div class="project-item__description">
<p>{description[lang] || description[DEFAULT_LANGUAGE]}</p>
</div>
<div class="project-item__links">
{
articleUrl && (
<a class="btn-link" href={articleUrl} target="_blank">
<UIString key={"btn.article"} />
</a>
)
}
{
sourceUrl && (
<a class="btn-link" href={sourceUrl} target="_blank">
<UIString key={"btn.sourceCode"} />
<Icon name="link-out" width="15" height="15" />
</a>
)
}
{
demoUrl && (
<a class="btn-link" href={demoUrl} target="_blank">
<UIString key={"btn.demo"} />
<Icon name="link-out" width="15" height="15" />
</a>
)
}
</div>
</div>

View File

@ -0,0 +1,35 @@
.project-item {
display: flex;
flex-direction: column;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
padding: 15px;
&__heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
& h2 {
display: flex;
align-items: center;
font-size: 1.2rem;
font-weight: 600;
column-gap: 10px;
margin: 0;
padding: 0;
}
& span {
font-size: var(--font-size-sm);
color: var(--font-color-gray);
}
}
&__links {
display: flex;
margin-top: auto;
column-gap: 5px;
}
}

View File

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

View File

@ -1,20 +0,0 @@
---
import ProjectItem from "@components/ProjectItem.astro";
import { getLanguageFromURL } from "../i18n/utils";
import { getProjectList } from "../queries";
const pageLang = getLanguageFromURL(Astro.url.pathname);
const projects = await getProjectList(pageLang);
---
<div>
{projects.map((data) => <ProjectItem data={data} />)}
</div>
<style>
div {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 10px;
}
</style>

Some files were not shown because too many files have changed in this diff Show More