mirror of
https://github.com/sergeyyarkov/yarkov.tech.git
synced 2026-10-11 10:29:21 +03:00
Compare commits
No commits in common. "develop" and "v1.2.7" have entirely different histories.
@ -1,14 +0,0 @@
|
|||||||
node_modules
|
|
||||||
dist
|
|
||||||
.astro
|
|
||||||
.env
|
|
||||||
.env.example
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
dockerfile
|
|
||||||
.dockerignore
|
|
||||||
.vscode
|
|
||||||
.prettierrc
|
|
||||||
codegen.ts
|
|
||||||
schema.graphql
|
|
||||||
README.md
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
ASTRO_TELEMETRY_DISABLED=1
|
IS_SITE_UNDER_CONSTRUCTION=false
|
||||||
DIRECTUS_URL=
|
REDIS_HOST=localhost
|
||||||
DIRECTUS_URL_GRAPHQL=
|
REDIS_PORT=6379
|
||||||
DIRECTUS_TOKEN=
|
REDIS_PASSWORD=12345
|
||||||
14
.eslintrc.json
Normal file
14
.eslintrc.json
Normal 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
2
.gitattributes
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*.css linguist-detectable=false
|
||||||
|
*.scss linguist-detectable=false
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,7 +1,5 @@
|
|||||||
# build output
|
# build output
|
||||||
dist/
|
dist/
|
||||||
# generated types
|
|
||||||
.astro/
|
|
||||||
|
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
@ -12,13 +10,11 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
# environment variables
|
# environment variables
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
|
|
||||||
# macOS-specific files
|
# macOS-specific files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Local Netlify folder
|
# Local Netlify folder
|
||||||
.netlify
|
.netlify
|
||||||
|
|||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
[submodule "content"]
|
||||||
|
path = content
|
||||||
|
url = git@github.com:sergeyyarkov/content.git
|
||||||
2
.npmrc
Normal file
2
.npmrc
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Expose Astro dependencies for `pnpm` users
|
||||||
|
shamefully-hoist=true
|
||||||
2
.prettierignore
Normal file
2
.prettierignore
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
**/*.mdx
|
||||||
|
**/*.md
|
||||||
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["astro-build.astro-vscode"],
|
|
||||||
"unwantedRecommendations": []
|
|
||||||
}
|
|
||||||
11
.vscode/launch.json
vendored
11
.vscode/launch.json
vendored
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"command": "./node_modules/.bin/astro dev",
|
|
||||||
"name": "Development server",
|
|
||||||
"request": "launch",
|
|
||||||
"type": "node-terminal"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
8
.vscode/settings.json
vendored
8
.vscode/settings.json
vendored
@ -1,5 +1,7 @@
|
|||||||
{
|
{
|
||||||
"githubPullRequests.ignoredPullRequestBranches": ["develop"],
|
"files.eol": "\n",
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"prettier.documentSelectors": ["**/*.astro"],
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true
|
"[astro]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
Dockerfile
22
Dockerfile
@ -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"]
|
|
||||||
@ -1,6 +1,7 @@
|
|||||||
# yarkov.tech
|
# yarkov.tech
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
|
[](https://app.netlify.com/sites/yarkov/deploys)
|
||||||
|
|
||||||
Personal website created with Astro.
|
Personal website created with Astro.
|
||||||
|
|
||||||
@ -10,10 +11,9 @@ Personal website created with Astro.
|
|||||||
- [x] Search articles by title or description
|
- [x] Search articles by title or description
|
||||||
- [x] Dark/Light Theme
|
- [x] Dark/Light Theme
|
||||||
- [x] Multilang web-site
|
- [x] Multilang web-site
|
||||||
- [x] Table of contents
|
- [x] Prefetch pages via [Quicklink](https://getquick.link/)
|
||||||
- [x] Content management via [Directus](https://directus.io/)
|
- [x] Form handling via [Netlify Forms](https://www.netlify.com/products/forms/)
|
||||||
- [x] Form handling
|
- [x] Comment system via [utterances](https://utteranc.es/)
|
||||||
- [x] Comment system via [Remark42](https://github.com/umputun/remark42)
|
|
||||||
- [x] RSS Feed for articles
|
- [x] RSS Feed for articles
|
||||||
- [x] SEO optimized
|
- [x] SEO optimized
|
||||||
- [x] Views counter
|
- [x] Views counter
|
||||||
|
|||||||
@ -1,38 +0,0 @@
|
|||||||
import { defineConfig, envField } from "astro/config";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Integrations
|
|
||||||
*/
|
|
||||||
import node from "@astrojs/node";
|
|
||||||
import solidJs from "@astrojs/solid-js";
|
|
||||||
import icon from "astro-icon";
|
|
||||||
|
|
||||||
// https://astro.build/config
|
|
||||||
export default defineConfig({
|
|
||||||
site: "https://yarkov.tech",
|
|
||||||
compressHTML: true,
|
|
||||||
output: "server",
|
|
||||||
prefetch: true,
|
|
||||||
server: {
|
|
||||||
port: 9966,
|
|
||||||
host: true,
|
|
||||||
allowedHosts: ["showcase.yarkov.tech"],
|
|
||||||
},
|
|
||||||
adapter: node({
|
|
||||||
mode: "standalone",
|
|
||||||
}),
|
|
||||||
env: {
|
|
||||||
schema: {
|
|
||||||
DIRECTUS_URL: envField.string({ context: "server", access: "secret", default: "" }),
|
|
||||||
DIRECTUS_URL_GRAPHQL: envField.string({
|
|
||||||
context: "server",
|
|
||||||
access: "secret",
|
|
||||||
default: "http://directus:8055/graphql",
|
|
||||||
}),
|
|
||||||
DIRECTUS_TOKEN: envField.string({ context: "server", access: "secret", default: "" }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
integrations: [icon(), solidJs()],
|
|
||||||
trailingSlash: "ignore",
|
|
||||||
scopedStyleStrategy: "where",
|
|
||||||
});
|
|
||||||
104
astro.config.ts
Normal file
104
astro.config.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
/**
|
||||||
|
* Integrations
|
||||||
|
*/
|
||||||
|
import solid from "@astrojs/solid-js";
|
||||||
|
import mdx from "@astrojs/mdx";
|
||||||
|
import sitemap from "@astrojs/sitemap";
|
||||||
|
import image from "@astrojs/image";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remark plugins
|
||||||
|
*/
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import remarkSmartypants from "remark-smartypants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rehype plugins
|
||||||
|
*/
|
||||||
|
import rehypeSlug from "rehype-slug";
|
||||||
|
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other required modules
|
||||||
|
*/
|
||||||
|
import { defineConfig } from "astro/config";
|
||||||
|
import { h } from "hastscript";
|
||||||
|
import { toString } from "hast-util-to-string";
|
||||||
|
import { escape } from "html-escaper";
|
||||||
|
import { DEFAULT_LANGUAGE } from "./src/config";
|
||||||
|
import languages from "./src/i18n/languages";
|
||||||
|
|
||||||
|
const createAnchorLabel = (heading: string) => {
|
||||||
|
const node = h("span.anchor-label", escape(heading));
|
||||||
|
if (node.properties) node.properties["is:raw"] = true;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({
|
||||||
|
site: "https://yarkov.tech",
|
||||||
|
integrations: [
|
||||||
|
solid(),
|
||||||
|
sitemap({
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: DEFAULT_LANGUAGE,
|
||||||
|
locales: Object.fromEntries(Object.keys(languages).map((key) => [key, key])),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
image({
|
||||||
|
serviceEntryPoint: "@astrojs/image/sharp",
|
||||||
|
}),
|
||||||
|
mdx({
|
||||||
|
remarkPlugins: [remarkGfm, remarkSmartypants],
|
||||||
|
rehypePlugins: [
|
||||||
|
rehypeSlug,
|
||||||
|
[
|
||||||
|
rehypeAutolinkHeadings,
|
||||||
|
{
|
||||||
|
properties: {
|
||||||
|
class: "anchor-link",
|
||||||
|
},
|
||||||
|
behavior: "after",
|
||||||
|
group: ({ tagName }) =>
|
||||||
|
h(`div.heading-wrapper.level-${tagName}`, {
|
||||||
|
tabIndex: -1,
|
||||||
|
}),
|
||||||
|
content: (heading) => [
|
||||||
|
h(
|
||||||
|
`span.anchor-icon`,
|
||||||
|
undefined,
|
||||||
|
h(
|
||||||
|
"svg",
|
||||||
|
{
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
version: 1.1,
|
||||||
|
viewBox: "0 0 16 16",
|
||||||
|
xlmns: "http://www.w3.org/2000/svg",
|
||||||
|
ariaHidden: "true",
|
||||||
|
"data-filter": "none",
|
||||||
|
},
|
||||||
|
h("path", {
|
||||||
|
fillRule: "evenodd",
|
||||||
|
fill: "currentcolor",
|
||||||
|
d: "M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
createAnchorLabel(toString(heading)),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
trailingSlash: "ignore",
|
||||||
|
markdown: {
|
||||||
|
syntaxHighlight: "prism",
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
ssr: {
|
||||||
|
external: ["svgo"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
31
codegen.ts
31
codegen.ts
@ -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;
|
|
||||||
17
compose.yml
17
compose.yml
@ -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
1
content
Submodule
@ -0,0 +1 @@
|
|||||||
|
Subproject commit 72aaaa2a262d8322bd449363e7b75b326a3a54be
|
||||||
10
netlify.toml
Normal file
10
netlify.toml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
[build]
|
||||||
|
command = "npm run build"
|
||||||
|
publish = "dist"
|
||||||
|
functions = "netlify/functions"
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/api/*" # simplify all calls to serverless functions
|
||||||
|
to = "/.netlify/functions/:splat" # all function calls will go to this path
|
||||||
|
status = 200 # ok code
|
||||||
|
force = true # ensure to always redirect
|
||||||
35
netlify/functions/views.ts
Normal file
35
netlify/functions/views.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Handler } from "@netlify/functions";
|
||||||
|
import { redisClient } from "../../redis";
|
||||||
|
|
||||||
|
const getViewsBySlug = async (slug: string): Promise<number> => {
|
||||||
|
let views = 1;
|
||||||
|
const data = await redisClient.get(`views:${slug}`);
|
||||||
|
if (data) views = Number.parseInt(data, 10);
|
||||||
|
if (Number.isNaN(views)) views = 1;
|
||||||
|
return views;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handler: Handler = async (event) => {
|
||||||
|
const slug = event.queryStringParameters?.slug;
|
||||||
|
|
||||||
|
switch (event.httpMethod) {
|
||||||
|
case "GET": {
|
||||||
|
if (!slug) return { statusCode: 400, body: "Bad request." };
|
||||||
|
const views = await getViewsBySlug(slug);
|
||||||
|
return { statusCode: 200, body: views.toString() };
|
||||||
|
}
|
||||||
|
case "POST": {
|
||||||
|
if (!slug) return { statusCode: 400, body: "Bad request." };
|
||||||
|
const views = await redisClient.incr(`views:${slug}`);
|
||||||
|
return { statusCode: 200, body: views.toString() };
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return {
|
||||||
|
statusCode: 500,
|
||||||
|
body: "Unrecognized HTTP Method, must be one of GET/POST",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { handler };
|
||||||
30119
package-lock.json
generated
30119
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
82
package.json
82
package.json
@ -1,53 +1,55 @@
|
|||||||
{
|
{
|
||||||
"name": "yarkov.tech",
|
"name": "yarkov.tech",
|
||||||
"private": false,
|
"version": "1.2.7",
|
||||||
"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 ./src/\"**/*.{js,jsx,ts,tsx,json,astro}\"",
|
||||||
"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.11.6",
|
||||||
"@graphql-codegen/cli": "^6.1.0",
|
"@astrojs/mdx": "^0.11.6",
|
||||||
"@graphql-codegen/schema-ast": "^5.0.0",
|
"@astrojs/rss": "^1.0.3",
|
||||||
"@parcel/watcher": "^2.5.1",
|
"@astrojs/sitemap": "^1.0.0",
|
||||||
"@types/html-escaper": "^3.0.4",
|
"@astrojs/solid-js": "^1.2.3",
|
||||||
"@types/mime-types": "^2.1.4",
|
"@types/html-escaper": "^3.0.0",
|
||||||
"@types/remark-prism": "^1.3.7",
|
"@types/mdx": "^2.0.3",
|
||||||
"dotenv": "^17.2.3",
|
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
||||||
"sass": "^1.68.0",
|
"@typescript-eslint/parser": "^5.43.0",
|
||||||
"typescript": "^5.9.3"
|
"astro": "^1.6.10",
|
||||||
|
"astro-icon": "^0.8.0",
|
||||||
|
"eslint": "^8.27.0",
|
||||||
|
"eslint-plugin-solid": "^0.8.0",
|
||||||
|
"hast-util-to-string": "^2.0.0",
|
||||||
|
"hastscript": "^7.1.0",
|
||||||
|
"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.7.0",
|
||||||
|
"rehype-autolink-headings": "^6.1.1",
|
||||||
|
"rehype-slug": "^5.1.0",
|
||||||
|
"remark-gfm": "^3.0.1",
|
||||||
|
"remark-smartypants": "^2.0.0",
|
||||||
|
"sass": "^1.56.1",
|
||||||
|
"solid-js": "^1.6.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@netlify/functions": "^1.3.0",
|
||||||
|
"ioredis": "^5.2.4",
|
||||||
|
"quicklink": "^2.3.0",
|
||||||
|
"sharp": "^0.31.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
User-agent: *
|
User-agent: *
|
||||||
Allow: /
|
Allow: /
|
||||||
|
|
||||||
Sitemap: https://yarkov.tech/sitemap.xml
|
Sitemap: https://yarkov.tech/sitemap-index.xml
|
||||||
BIN
public/static/webserial-demo-3.gif
Normal file
BIN
public/static/webserial-demo-3.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
9
redis.ts
Normal file
9
redis.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import Redis from "ioredis";
|
||||||
|
|
||||||
|
const redisClient = new Redis({
|
||||||
|
host: process.env.REDIS_HOST,
|
||||||
|
port: Number.parseInt(process.env.REDIS_PORT || "6379", 10),
|
||||||
|
password: process.env.REDIS_PASSWORD,
|
||||||
|
});
|
||||||
|
|
||||||
|
export { redisClient };
|
||||||
2570
schema.graphql
2570
schema.graphql
File diff suppressed because it is too large
Load Diff
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"globalTitle": "Sergey Yarkov",
|
|
||||||
"articles": {
|
|
||||||
"recentLimit": 5
|
|
||||||
},
|
|
||||||
"isSiteUnderConstruction": false,
|
|
||||||
"contacts": {
|
|
||||||
"email": "serzh.yarkov@gmail.com",
|
|
||||||
"telegram": "https://t.me/sergeyyarkov",
|
|
||||||
"github": "https://github.com/sergeyyarkov"
|
|
||||||
},
|
|
||||||
"ymCounter": 90034656,
|
|
||||||
"name": "yarkov.tech",
|
|
||||||
"author": "Sergey Yarkov"
|
|
||||||
}
|
|
||||||
@ -1,42 +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(),
|
|
||||||
subject: z.string(),
|
|
||||||
message: z.string(),
|
|
||||||
}),
|
|
||||||
handler: async (data, ctx) => {
|
|
||||||
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.",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
@ -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} />
|
|
||||||
30
src/components/AlternateLangLink/AlternateLangLink.astro
Normal file
30
src/components/AlternateLangLink/AlternateLangLink.astro
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
/**
|
||||||
|
* This components is used in article layout to display links in alternative lanugages.
|
||||||
|
*/
|
||||||
|
import { DEFAULT_LANGUAGE } from "@root/config";
|
||||||
|
import { getLanguageFromURL } from "@root/utils";
|
||||||
|
|
||||||
|
export interface Props extends astroHTML.JSX.AnchorHTMLAttributes {
|
||||||
|
lang: LanguageKeys;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pathname } = Astro.url;
|
||||||
|
const siteLang = getLanguageFromURL(pathname);
|
||||||
|
const { lang, ...attrs } = Astro.props as Props;
|
||||||
|
let href: string;
|
||||||
|
if (lang === DEFAULT_LANGUAGE) {
|
||||||
|
href = `/${pathname.split("/").slice(2).join("/")}`;
|
||||||
|
} else {
|
||||||
|
href = `/${lang}/`;
|
||||||
|
if (siteLang !== DEFAULT_LANGUAGE) {
|
||||||
|
href += `${pathname.split("/").slice(2).join("/")}`;
|
||||||
|
} else {
|
||||||
|
href += `${pathname.split("/").slice(1).join("/")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<a href={href} {...attrs}>
|
||||||
|
<slot />
|
||||||
|
</a>
|
||||||
1
src/components/AlternateLangLink/index.ts
Normal file
1
src/components/AlternateLangLink/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./AlternateLangLink.astro";
|
||||||
@ -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}
|
|
||||||
@ -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>
|
|
||||||
29
src/components/ArticleComments/ArticleComments.astro
Normal file
29
src/components/ArticleComments/ArticleComments.astro
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
import UIString from "../UIString";
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<h2><UIString key={"articlePost.comments"} /></h2>
|
||||||
|
<div id="comments"></div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $commentsWrapper = document.getElementById("comments");
|
||||||
|
const $commentsScript = document.createElement("script");
|
||||||
|
|
||||||
|
$commentsScript.async = true;
|
||||||
|
$commentsScript.src = "https://utteranc.es/client.js";
|
||||||
|
$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", window.theme === "dark" ? "dark-blue" : "github-light");
|
||||||
|
} else {
|
||||||
|
$commentsScript.setAttribute("theme", "github-light");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($commentsWrapper) $commentsWrapper.appendChild($commentsScript);
|
||||||
|
</script>
|
||||||
1
src/components/ArticleComments/index.ts
Normal file
1
src/components/ArticleComments/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ArticleComments.astro";
|
||||||
@ -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>
|
|
||||||
5
src/components/ArticleContent/ArticleContent.astro
Normal file
5
src/components/ArticleContent/ArticleContent.astro
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
import "./ArticleContent.scss";
|
||||||
|
---
|
||||||
|
|
||||||
|
<div itemprop="articleBody" class="article-content"><slot /></div>
|
||||||
221
src/components/ArticleContent/ArticleContent.scss
Normal file
221
src/components/ArticleContent/ArticleContent.scss
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
.article-content {
|
||||||
|
& h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin-top: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& p,
|
||||||
|
em,
|
||||||
|
q {
|
||||||
|
line-height: var(--line-height-base);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
& p {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& ul,
|
||||||
|
ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
line-height: var(--line-height-base);
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
& li {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
line-height: var(--line-height-base);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding-left: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& q {
|
||||||
|
font-style: italic;
|
||||||
|
quotes: "« " " »";
|
||||||
|
}
|
||||||
|
|
||||||
|
& a {
|
||||||
|
color: var(--font-color);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--color-blue-500);
|
||||||
|
text-underline-offset: 2.2px;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
&:hover {
|
||||||
|
color: var(--color-blue-500);
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& figure {
|
||||||
|
margin: 0 0 1em;
|
||||||
|
& figcaption {
|
||||||
|
color: var(--color-gray-400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& hr {
|
||||||
|
background-color: var(--border-color);
|
||||||
|
margin: 40px 0;
|
||||||
|
border: none;
|
||||||
|
height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& pre {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
& :not(pre) > code {
|
||||||
|
-webkit-font-smoothing: subpixel-antialiased;
|
||||||
|
background: var(--background-inline-code);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: "IBM Plex Mono", monospace;
|
||||||
|
padding: 0.2rem 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& figcaption {
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 1em;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0.7rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& table {
|
||||||
|
display: table;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
min-width: 100%;
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
margin: 5px 0;
|
||||||
|
& thead th {
|
||||||
|
background-color: var(--background-card);
|
||||||
|
border-top-left-radius: var(--border-radius-md);
|
||||||
|
border-top-right-radius: var(--border-radius-md);
|
||||||
|
}
|
||||||
|
& td,
|
||||||
|
th {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
& tbody tr:last-child td {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& li {
|
||||||
|
list-style: disc;
|
||||||
|
& li {
|
||||||
|
list-style-type: circle;
|
||||||
|
}
|
||||||
|
&::marker {
|
||||||
|
color: var(--color-blue-500);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: var(--line-height-none);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& blockquote {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
font-style: italic;
|
||||||
|
border-left: 0.25rem solid var(--border-color);
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .heading-wrapper {
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
margin: 3rem 0 1rem 0;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
|
||||||
|
& h2 {
|
||||||
|
font-size: var(--font-size-2xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
& h3 {
|
||||||
|
font-size: var(--font-size-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
& h4 {
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
& h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .heading-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-inline-start: 1px;
|
||||||
|
& .anchor-link {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
text-decoration: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0;
|
||||||
|
& .anchor-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: -25px;
|
||||||
|
top: -13px;
|
||||||
|
}
|
||||||
|
& svg {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& .anchor-label {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
&:hover {
|
||||||
|
& .anchor-link {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& .languages-available {
|
||||||
|
margin: 25px 0;
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 800px) {
|
||||||
|
.article-content {
|
||||||
|
& .heading-wrapper {
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
& .anchor-link {
|
||||||
|
& .anchor-icon {
|
||||||
|
left: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ArticleContent/index.ts
Normal file
1
src/components/ArticleContent/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ArticleContent.astro";
|
||||||
@ -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>
|
|
||||||
@ -1,41 +1,34 @@
|
|||||||
|
/* 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 ? `/${props.data.lang}` : "";
|
||||||
|
|
||||||
const href = createRelativeArticleUrl({ pubDate, slug, articleLang }, URL_BLOG_PREFIX);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article class="article-item">
|
||||||
itemprop="blogPosts"
|
<a
|
||||||
itemscope
|
href={`${hrefLangPrefix}/blog/${new Date(props.data.published_at).toLocaleDateString("en-CA")}/${
|
||||||
itemtype="http://schema.org/BlogPosting"
|
props.data.slug
|
||||||
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;
|
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
export { default } from "./ArticleItem";
|
export * from "./ArticleItem";
|
||||||
|
|||||||
@ -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> {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))}
|
|
||||||
</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>
|
|
||||||
90
src/components/ArticlePost/ArticlePost.astro
Normal file
90
src/components/ArticlePost/ArticlePost.astro
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
import { Picture } from "@astrojs/image/components";
|
||||||
|
import { Icon } from "astro-icon";
|
||||||
|
import { createDateFormatter } from "@root/utils";
|
||||||
|
import { isURL } from "@utils/string";
|
||||||
|
import ArticleComments from "@components/ArticleComments";
|
||||||
|
import TranslatedLink from "../TranslatedLink";
|
||||||
|
import "./ArticlePost.scss";
|
||||||
|
import { useTranslation } from "@root/i18n/utils";
|
||||||
|
|
||||||
|
export type Props = {
|
||||||
|
data: ArticleType;
|
||||||
|
lang: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data, lang } = Astro.props as Props;
|
||||||
|
const { slug, title, description, coverImage, tags, author, published_at } = data;
|
||||||
|
const t = useTranslation(Astro);
|
||||||
|
---
|
||||||
|
|
||||||
|
<article itemscope itemtype="http://schema.org/BlogPosting" class="article">
|
||||||
|
<header>
|
||||||
|
<div>
|
||||||
|
<div class="tags">
|
||||||
|
<ul itemprop="keywords">
|
||||||
|
{
|
||||||
|
tags.map((tag) => (
|
||||||
|
<li>
|
||||||
|
<TranslatedLink rel="tag" to={`/articles/?tags=${tag.toLocaleLowerCase()}`}>{`${tag}${
|
||||||
|
tags.at(-1) === tag ? "" : ","
|
||||||
|
}`}</TranslatedLink>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="views">
|
||||||
|
<Icon name="eye" width="21" height="21" />
|
||||||
|
<span><span id="views">---</span> {t("article.views")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="heading">
|
||||||
|
<h1 itemprop="headline">{title}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="published">
|
||||||
|
<time itemprop="datePublished" datetime={published_at}
|
||||||
|
>{createDateFormatter(lang).format(new Date(published_at))}
|
||||||
|
</time>
|
||||||
|
<span itemprop="author">{author}</span>
|
||||||
|
</div>
|
||||||
|
{
|
||||||
|
coverImage && (
|
||||||
|
<div class="cover">
|
||||||
|
<figure>
|
||||||
|
<Picture
|
||||||
|
itemprop="image"
|
||||||
|
src={isURL(coverImage) ? coverImage : `../../../content/assets/images/${coverImage}`}
|
||||||
|
sizes="(max-width: 450px) 450px, 700px"
|
||||||
|
widths={[450, 700]}
|
||||||
|
formats={["webp"]}
|
||||||
|
aspectRatio={"16:9"}
|
||||||
|
alt={title}
|
||||||
|
/>
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<div class="description">
|
||||||
|
<p>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<slot />
|
||||||
|
<ArticleComments />
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<script define:vars={{ slug }}>
|
||||||
|
(async () => {
|
||||||
|
const response = await fetch(`/api/views?slug=${slug}`, { method: "POST" });
|
||||||
|
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const views = await response.text();
|
||||||
|
const $views = document.getElementById("views");
|
||||||
|
|
||||||
|
if (isNaN(parseInt(views, 10))) return;
|
||||||
|
if ($views) $views.textContent = views;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
92
src/components/ArticlePost/ArticlePost.scss
Normal file
92
src/components/ArticlePost/ArticlePost.scss
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
.article {
|
||||||
|
& header {
|
||||||
|
& div:nth-child(1) {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
& .views {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
& span {
|
||||||
|
margin-left: 3.5px;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--font-color-grayer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& .heading {
|
||||||
|
margin-top: 1.125rem;
|
||||||
|
|
||||||
|
& h1 {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 2.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& p {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: var(--line-height-base);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .tags {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
text-transform: uppercase;
|
||||||
|
& ul {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
column-gap: 7px;
|
||||||
|
row-gap: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .published {
|
||||||
|
margin-bottom: 1.25em;
|
||||||
|
color: var(--font-color-gray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .cover {
|
||||||
|
margin: 20px 0;
|
||||||
|
& figure {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& .description p {
|
||||||
|
line-height: var(--line-height-base);
|
||||||
|
font-size: var(--font-size-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
& footer {
|
||||||
|
margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
& img {
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
|
||||||
|
&:not([src*=".svg"]) {
|
||||||
|
filter: var(--image-filter);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 560px) {
|
||||||
|
.article {
|
||||||
|
& header {
|
||||||
|
& div:nth-child(1) {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
& .tags {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& .heading h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ArticlePost/index.ts
Normal file
1
src/components/ArticlePost/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ArticlePost.astro";
|
||||||
@ -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} />
|
|
||||||
@ -1,4 +1,7 @@
|
|||||||
.articles {
|
.articles {
|
||||||
|
&-tags {
|
||||||
|
margin-bottom: 45px;
|
||||||
|
}
|
||||||
&-list {
|
&-list {
|
||||||
&__wrapper {
|
&__wrapper {
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
|
|||||||
@ -1,93 +1,97 @@
|
|||||||
import type { Component } from "solid-js";
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
import { createSignal, createEffect, For } from "solid-js";
|
import { createSignal, createEffect, For } from "solid-js";
|
||||||
import { search, selectedTags, 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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find articles by title
|
||||||
|
*/
|
||||||
|
if (search()) {
|
||||||
|
filtered = findArticlesBySearch(search().trim(), filtered);
|
||||||
setArticles(filtered);
|
setArticles(filtered);
|
||||||
setCount(Object.values(filtered).flat().length);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find articles by selected tags
|
||||||
|
*/
|
||||||
|
if (selectedTags().length !== 0) {
|
||||||
|
filtered = findArticlesByTags(selectedTags(), filtered);
|
||||||
|
setArticles(filtered);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set filtered variable as default value
|
||||||
|
*/
|
||||||
|
if (!search() && selectedTags().length === 0) {
|
||||||
|
filtered = props.data.articles;
|
||||||
|
setArticles(filtered);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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>
|
|
||||||
@ -1,69 +1,55 @@
|
|||||||
---
|
---
|
||||||
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;
|
canonicalUrl?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
|
||||||
const { title, description } = Astro.props;
|
|
||||||
const isDev = import.meta.env.DEV;
|
|
||||||
const t = useTranslation(Astro);
|
const t = useTranslation(Astro);
|
||||||
|
const { title = SITE_METADATA.title, description, canonicalUrl } = Astro.props as Props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<!-- Fonts -->
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel='preconnect' href='https://fonts.googleapis.com' />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin />
|
|
||||||
<link
|
<link
|
||||||
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'
|
as="style"
|
||||||
onload={`this.rel='stylesheet'`}
|
onload="this.rel='stylesheet'"
|
||||||
/>
|
/>
|
||||||
|
{canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
|
||||||
<!-- 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="generator" content={Astro.generator} />
|
||||||
<meta name='viewport' content='width=device-width, initial-scale=1' />
|
<meta name="description" content={description || t("site.description")} />
|
||||||
<title>{title || SITE_METADATA.title}</title>
|
<meta name="theme-color" content={SITE_METADATA.themeColor} />
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<!-- Metadata -->
|
|
||||||
<meta name='title' content={title || SITE_METADATA.title} />
|
|
||||||
<meta name='generator' content={Astro.generator} />
|
|
||||||
<meta name='description' content={description || t("site.description")} />
|
|
||||||
<meta name='theme-color' content={SITE_METADATA.themeColor} />
|
|
||||||
<meta name='msapplication-TileColor' content='#da532c' />
|
|
||||||
|
|
||||||
<!-- Favicons -->
|
|
||||||
<link rel='apple-touch-icon' sizes='180x180' href='/apple-touch-icon.png' />
|
|
||||||
<link
|
<link
|
||||||
rel='icon'
|
rel="icon"
|
||||||
type='image/png'
|
type="image/png"
|
||||||
sizes='32x32'
|
sizes="32x32"
|
||||||
href='/favicon-light-32x32.png'
|
href="/favicon-light-32x32.png"
|
||||||
media='(prefers-color-scheme: light)'
|
media="(prefers-color-scheme: light)"
|
||||||
/>
|
/>
|
||||||
<link
|
<link
|
||||||
rel='icon'
|
rel="icon"
|
||||||
type='image/png'
|
type="image/png"
|
||||||
sizes='16x16'
|
sizes="16x16"
|
||||||
href='/favicon-light-16x16.png'
|
href="/favicon-light-16x16.png"
|
||||||
media='(prefers-color-scheme: light)'
|
media="(prefers-color-scheme: light)"
|
||||||
/>
|
/>
|
||||||
<link rel='icon' type='image/png' sizes='32x32' href='/favicon-dark-32x32.png' media='(prefers-color-scheme: dark)' />
|
<link 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="icon" type="image/png" sizes="16x16" href="/favicon-dark-16x16.png" media="(prefers-color-scheme: dark)" />
|
||||||
<link rel='manifest' href='/site.webmanifest' />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<link rel='mask-icon' href='/safari-pinned-tab.svg' color='#5bbad5' />
|
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
|
||||||
<link rel='sitemap' href='/sitemap-index.xml' />
|
<meta name="msapplication-TileColor" content="#da532c" />
|
||||||
<slot />
|
<slot />
|
||||||
<script is:inline src='/assets/js/detect-theme.js'></script>
|
<script is:inline src="/assets/js/detect-theme.js"></script>
|
||||||
{!isDev && <YaMetrika />}
|
<script src="../quicklink.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@ -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>
|
|
||||||
🚧 This web site is under development 🚧
|
|
||||||
</UnderConstruction>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
<Header />
|
|
||||||
<SiteContent>
|
|
||||||
<slot />
|
|
||||||
</SiteContent>
|
|
||||||
<Footer />
|
|
||||||
</body>
|
|
||||||
25
src/components/BaseLayout/BaseLayout.astro
Normal file
25
src/components/BaseLayout/BaseLayout.astro
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
import { IS_SITE_UNDER_CONSTRUCTION } from "@root/config";
|
||||||
|
import Header from "@components/Header";
|
||||||
|
import Footer from "@components/Footer";
|
||||||
|
import SiteContent from "@components/SiteContent";
|
||||||
|
import HeaderBanner from "@components/HeaderBanner";
|
||||||
|
import UIString from "@components/UIString";
|
||||||
|
---
|
||||||
|
|
||||||
|
<body data-is-site-under-construction={IS_SITE_UNDER_CONSTRUCTION && "true"}>
|
||||||
|
{
|
||||||
|
IS_SITE_UNDER_CONSTRUCTION && (
|
||||||
|
<HeaderBanner>
|
||||||
|
🚧
|
||||||
|
<UIString key={"site.underConstruction"} />
|
||||||
|
🚧
|
||||||
|
</HeaderBanner>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
<Header />
|
||||||
|
<SiteContent>
|
||||||
|
<slot />
|
||||||
|
</SiteContent>
|
||||||
|
<Footer />
|
||||||
|
</body>
|
||||||
1
src/components/BaseLayout/index.ts
Normal file
1
src/components/BaseLayout/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./BaseLayout.astro";
|
||||||
@ -1,6 +1,7 @@
|
|||||||
---
|
---
|
||||||
export type Props = astroHTML.JSX.ButtonHTMLAttributes;
|
export type Props = astroHTML.JSX.ButtonHTMLAttributes;
|
||||||
const attrs = Astro.props;
|
|
||||||
|
const attrs = Astro.props as Props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<button {...attrs}>
|
<button {...attrs}>
|
||||||
@ -14,6 +15,7 @@ const attrs = Astro.props;
|
|||||||
font-family: var(--font-family-base);
|
font-family: var(--font-family-base);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
|
||||||
&[disabled] {
|
&[disabled] {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
1
src/components/Button/index.ts
Normal file
1
src/components/Button/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./Button.astro";
|
||||||
@ -1,74 +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>
|
|
||||||
<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;
|
|
||||||
& .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>
|
|
||||||
65
src/components/ContactForm/ContactForm.astro
Normal file
65
src/components/ContactForm/ContactForm.astro
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
---
|
||||||
|
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) {
|
||||||
|
form .flex {
|
||||||
|
flex-direction: column;
|
||||||
|
row-gap: 7px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
46
src/components/ContactForm/ContactForm.scss
Normal file
46
src/components/ContactForm/ContactForm.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ContactForm/index.ts
Normal file
1
src/components/ContactForm/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ContactForm.astro";
|
||||||
@ -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>
|
|
||||||
24
src/components/ContactsList/ContactsList.astro
Normal file
24
src/components/ContactsList/ContactsList.astro
Normal 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>
|
||||||
15
src/components/ContactsList/ContactsList.scss
Normal file
15
src/components/ContactsList/ContactsList.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ContactsList/index.ts
Normal file
1
src/components/ContactsList/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ContactsList.astro";
|
||||||
59
src/components/Counter/Counter.tsx
Normal file
59
src/components/Counter/Counter.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import type { Component } from "solid-js";
|
||||||
|
|
||||||
|
import { createSignal, createEffect } from "solid-js";
|
||||||
|
|
||||||
|
const Counter: Component<{ tick?: number }> = (props) => {
|
||||||
|
const [isRunned, setIsRunned] = createSignal<boolean>(false);
|
||||||
|
const [count, setCount] = createSignal<number>(0);
|
||||||
|
|
||||||
|
const run = () => setIsRunned(true);
|
||||||
|
const stop = () => setIsRunned(false);
|
||||||
|
const inc = () => setCount(count() + 1);
|
||||||
|
const dec = () => setCount(count() - 1);
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setInterval(() => {
|
||||||
|
if (isRunned()) {
|
||||||
|
setCount(count() + 1);
|
||||||
|
}
|
||||||
|
}, props.tick || 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
margin: "2rem 0",
|
||||||
|
display: "flex",
|
||||||
|
"flex-direction": "column",
|
||||||
|
"align-items": "center",
|
||||||
|
"row-gap": "15px",
|
||||||
|
border: "1px solid var(--border-color)",
|
||||||
|
"border-radius": "var(--border-radius-md)",
|
||||||
|
padding: "15px 5px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{count()}</span>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
"column-gap": "3px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button class="btn" disabled={isRunned()} onClick={run}>
|
||||||
|
Run
|
||||||
|
</button>
|
||||||
|
<button class="btn" disabled={isRunned()} onClick={inc}>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
<button class="btn" disabled={isRunned()} onClick={dec}>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<button class="btn" disabled={!isRunned()} onClick={stop}>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Counter;
|
||||||
1
src/components/Counter/index.ts
Normal file
1
src/components/Counter/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./Counter";
|
||||||
@ -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>
|
|
||||||
© {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>
|
|
||||||
58
src/components/Footer/Footer.astro
Normal file
58
src/components/Footer/Footer.astro
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from "astro-icon";
|
||||||
|
---
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="container">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="https://astro.build/" target="_blank">Astro</a>
|
||||||
|
<Icon name="astro" width="20" height="20" />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="https://www.netlify.com/" target="_blank">Netlify</a>
|
||||||
|
<Icon name="netlify" width="20" height="20" />
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="/rss.xml" target="_blank">RSS</a>
|
||||||
|
<Icon name="rss" width="20" height="20" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p>© Sergey Yarkov, 2022</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
footer {
|
||||||
|
user-select: none;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-top: 5rem;
|
||||||
|
& .container {
|
||||||
|
padding: 25px 0 10px 0;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
row-gap: 15px;
|
||||||
|
}
|
||||||
|
& ul {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 25px;
|
||||||
|
& li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 7px;
|
||||||
|
& a {
|
||||||
|
color: var(--font-color);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
& p {
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
src/components/Footer/index.ts
Normal file
1
src/components/Footer/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./Footer.astro";
|
||||||
@ -1,29 +1,32 @@
|
|||||||
---
|
---
|
||||||
import { IS_SITE_UNDER_CONSTRUCTION, SITE_METADATA } from "@root/constants";
|
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 ThemeSwitcher from "@components/ThemeSwticher";
|
||||||
import LanguageSelect from "@components/LanguageSelect.astro";
|
import UIString from "@components/UIString";
|
||||||
import NavToggle from "@components/NavToggle";
|
import TranslatedLink from "@components/TranslatedLink";
|
||||||
import UIString from "@components/UIString.astro";
|
import LanguageSelect from "@components/LanguageSelect";
|
||||||
import TranslatedLink from "@components/TranslatedLink.astro";
|
import NavToggle from "./NavToggle";
|
||||||
import HeaderContacts from "./HeaderContacts.astro";
|
|
||||||
|
const { name, nav, contacts } = SITE_METADATA;
|
||||||
|
const { pathname } = Astro.url;
|
||||||
---
|
---
|
||||||
|
|
||||||
<header {...IS_SITE_UNDER_CONSTRUCTION ? { style: "top: 45px;" } : {}}>
|
<header {...(IS_SITE_UNDER_CONSTRUCTION ? { style: 'top: 45px;' } : {})}>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
<TranslatedLink to="/">
|
<TranslatedLink to={"/"}>
|
||||||
{SITE_METADATA.name}
|
{name}
|
||||||
</TranslatedLink>
|
</TranslatedLink>
|
||||||
</div>
|
</div>
|
||||||
<nav itemscope itemtype="http://schema.org/SiteNavigationElement">
|
<nav>
|
||||||
<ul>
|
<ul>
|
||||||
{
|
{
|
||||||
SITE_METADATA.nav.map((link) => (
|
nav.map((link) => (
|
||||||
<li>
|
<li>
|
||||||
<TranslatedLink
|
<TranslatedLink
|
||||||
itemprop={"url"}
|
class={pathname.includes(removeSlashes(link.url)) && pathname.split("/").length < 5 && "active"}
|
||||||
to={link.url}
|
to={link.url}
|
||||||
class:list={[{ active: Astro.url.pathname.includes(link.url) }]}
|
|
||||||
>
|
>
|
||||||
<UIString key={link.key} />
|
<UIString key={link.key} />
|
||||||
</TranslatedLink>
|
</TranslatedLink>
|
||||||
@ -31,7 +34,20 @@ import HeaderContacts from "./HeaderContacts.astro";
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
<HeaderContacts />
|
<div class="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 settings">
|
<div class="flex settings">
|
||||||
<LanguageSelect />
|
<LanguageSelect />
|
||||||
<ThemeSwitcher client:visible />
|
<ThemeSwitcher client:visible />
|
||||||
@ -53,15 +69,17 @@ import HeaderContacts from "./HeaderContacts.astro";
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
& .container {
|
& .container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .logo a {
|
& .logo a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
color: var(--font-color);
|
color: var(--font-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
& a:hover,
|
& a:hover,
|
||||||
a.active {
|
a.active {
|
||||||
color: var(--color-blue-500);
|
color: var(--color-blue-500);
|
||||||
@ -69,6 +87,7 @@ import HeaderContacts from "./HeaderContacts.astro";
|
|||||||
text-decoration-thickness: 2px;
|
text-decoration-thickness: 2px;
|
||||||
text-underline-offset: 4px;
|
text-underline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
& nav {
|
& nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -87,11 +106,25 @@ import HeaderContacts from "./HeaderContacts.astro";
|
|||||||
text-transform: lowercase;
|
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 {
|
& .settings {
|
||||||
column-gap: 13.5px;
|
column-gap: 13.5px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 768px) {
|
@media screen and (max-width: 768px) {
|
||||||
header {
|
header {
|
||||||
padding: 10px 0;
|
padding: 10px 0;
|
||||||
@ -99,6 +132,7 @@ import HeaderContacts from "./HeaderContacts.astro";
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body[data-is-mobile-open="true"] {
|
body[data-is-mobile-open="true"] {
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
@ -123,7 +157,7 @@ import HeaderContacts from "./HeaderContacts.astro";
|
|||||||
display: block;
|
display: block;
|
||||||
& ul {
|
& ul {
|
||||||
display: block;
|
display: block;
|
||||||
text-align: left;
|
text-align: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
& li {
|
& li {
|
||||||
margin-bottom: 30px;
|
margin-bottom: 30px;
|
||||||
@ -132,6 +166,11 @@ import HeaderContacts from "./HeaderContacts.astro";
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
& .contacts {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-top: 2rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
1
src/components/Header/index.ts
Normal file
1
src/components/Header/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./Header.astro";
|
||||||
1
src/components/HeaderBanner/index.ts
Normal file
1
src/components/HeaderBanner/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./HeaderBanner.astro";
|
||||||
@ -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>
|
|
||||||
@ -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, SUPPORTED_LANGUAGES } 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);
|
||||||
---
|
---
|
||||||
@ -11,7 +12,7 @@ const lang = getLanguageFromURL(Astro.url.pathname);
|
|||||||
<select id="languageSelect" value={lang} aria-label="Select language">
|
<select id="languageSelect" value={lang} aria-label="Select language">
|
||||||
{
|
{
|
||||||
SUPPORTED_LANGUAGES.map((code) => (
|
SUPPORTED_LANGUAGES.map((code) => (
|
||||||
<option value={code} selected={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;
|
||||||
@ -91,3 +64,31 @@ const lang = getLanguageFromURL(Astro.url.pathname);
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<script define:vars={{ lang, DEFAULT_LANGUAGE }}>
|
||||||
|
const $select = document.getElementById("languageSelect");
|
||||||
|
|
||||||
|
const changeLanguageHandler = (e) => {
|
||||||
|
const newLang = e.target.value;
|
||||||
|
const pathname = window.location.pathname;
|
||||||
|
let url;
|
||||||
|
|
||||||
|
if (lang !== DEFAULT_LANGUAGE) {
|
||||||
|
url = pathname.split("/").slice(2).join("/");
|
||||||
|
} else {
|
||||||
|
url = pathname.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newLang !== DEFAULT_LANGUAGE) url = `${newLang}/${url}`;
|
||||||
|
|
||||||
|
/* Move user to articles page with new language */
|
||||||
|
if (window.location.pathname.includes("/blog/")) {
|
||||||
|
window.location.pathname = `${newLang === DEFAULT_LANGUAGE ? "" : newLang + "/"}` + "articles/";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.pathname = url;
|
||||||
|
};
|
||||||
|
|
||||||
|
$select.addEventListener("change", changeLanguageHandler);
|
||||||
|
</script>
|
||||||
1
src/components/LanguageSelect/index.ts
Normal file
1
src/components/LanguageSelect/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./LanguageSelect.astro";
|
||||||
@ -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))}`} />
|
|
||||||
)}
|
|
||||||
@ -3,6 +3,7 @@ export interface Props {
|
|||||||
heading: string;
|
heading: string;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { heading, description } = Astro.props as Props;
|
const { heading, description } = Astro.props as Props;
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -16,13 +17,14 @@ const { heading, description } = Astro.props as Props;
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
div {
|
div {
|
||||||
margin-bottom: 2.5rem;
|
margin-bottom: 2.5rem;
|
||||||
text-align: center;
|
|
||||||
& h1 {
|
& h1 {
|
||||||
font-size: var(--font-size-4xl);
|
font-size: var(--font-size-4xl);
|
||||||
|
text-align: center;
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
& p {
|
& p {
|
||||||
font-size: var(--font-size-lg);
|
font-size: var(--font-size-lg);
|
||||||
|
text-align: center;
|
||||||
max-width: 650px;
|
max-width: 650px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
1
src/components/PageHeading/index.ts
Normal file
1
src/components/PageHeading/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./PageHeading.astro";
|
||||||
@ -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>
|
|
||||||
53
src/components/ProjectItem/ProjectItem.astro
Normal file
53
src/components/ProjectItem/ProjectItem.astro
Normal 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>
|
||||||
35
src/components/ProjectItem/ProjectItem.scss
Normal file
35
src/components/ProjectItem/ProjectItem.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/components/ProjectItem/index.ts
Normal file
1
src/components/ProjectItem/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ProjectItem.astro";
|
||||||
@ -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>
|
|
||||||
7
src/components/ProjectsList/ProjectsList.astro
Normal file
7
src/components/ProjectsList/ProjectsList.astro
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
import "./ProjectsList.scss";
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="projects-list">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
5
src/components/ProjectsList/ProjectsList.scss
Normal file
5
src/components/ProjectsList/ProjectsList.scss
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.projects-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
1
src/components/ProjectsList/index.ts
Normal file
1
src/components/ProjectsList/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./ProjectsList.astro";
|
||||||
@ -1,68 +0,0 @@
|
|||||||
---
|
|
||||||
import { getLanguageFromURL } from "@i18n/utils";
|
|
||||||
import * as utils from "@utils/index";
|
|
||||||
import ArticleItem from "@components/ArticleItem";
|
|
||||||
import EmptyList from "@components/EmptyList.astro";
|
|
||||||
import UIString from "@components/UIString.astro";
|
|
||||||
import TranslatedLink from "@components/TranslatedLink.astro";
|
|
||||||
import { getRecentArticleList, getSiteSettings } from "../queries";
|
|
||||||
|
|
||||||
const pageLang = getLanguageFromURL(Astro.url.pathname);
|
|
||||||
const siteSettings = await getSiteSettings();
|
|
||||||
const articles = await getRecentArticleList(siteSettings?.recent_articles_limit || 5);
|
|
||||||
const filteredArticles = utils.filterArticlesByPageLang(articles, pageLang);
|
|
||||||
const isEmpty = articles.length === 0;
|
|
||||||
---
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div>
|
|
||||||
<h2>
|
|
||||||
<UIString key={"recentArticles.heading"} />
|
|
||||||
</h2>
|
|
||||||
<TranslatedLink to="/articles/">
|
|
||||||
<UIString key={"recentArticles.all"} />
|
|
||||||
</TranslatedLink>
|
|
||||||
</div>
|
|
||||||
<div itemscope itemtype="http://schema.org/Blog">
|
|
||||||
{
|
|
||||||
!isEmpty && !!filteredArticles ? (
|
|
||||||
filteredArticles.map(
|
|
||||||
(a) =>
|
|
||||||
a && (
|
|
||||||
<ArticleItem
|
|
||||||
slug={a.slug}
|
|
||||||
title={a.title}
|
|
||||||
languages_code={a.languages_code}
|
|
||||||
pub_date={a.pub_date}
|
|
||||||
pageLang={pageLang}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<EmptyList />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
section {
|
|
||||||
& > div:nth-child(1) {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
column-gap: 15px;
|
|
||||||
row-gap: 5px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
justify-content: space-between;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
padding-bottom: 10px;
|
|
||||||
& h2 {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
& > div:nth-child(2) {
|
|
||||||
margin-top: 10px;
|
|
||||||
max-width: calc(var(--content-width) + (var(--content-padding) * 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
import UIString from "@components/UIString.astro";
|
import UIString from "@components/UIString";
|
||||||
---
|
---
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
56
src/components/RecentArticles/RecentArticles.astro
Normal file
56
src/components/RecentArticles/RecentArticles.astro
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
import { ArticleItem } from "@components/ArticleItem";
|
||||||
|
import UIString from "@components/UIString";
|
||||||
|
import { getLanguageFromURL } from "@utils/url";
|
||||||
|
import { sortArtcilesByDate } from "@utils/articles";
|
||||||
|
import EmptyList from "./EmptyList.astro";
|
||||||
|
import TranslatedLink from "@components/TranslatedLink";
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
articles: ArticleType[];
|
||||||
|
length?: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { articles, length = 5 } = Astro.props as Props;
|
||||||
|
|
||||||
|
const lang = getLanguageFromURL(Astro.url.pathname);
|
||||||
|
const recent = sortArtcilesByDate(articles);
|
||||||
|
const isEmpty = recent.length === 0;
|
||||||
|
---
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div>
|
||||||
|
<h2><UIString key={"recentArticles.heading"} /></h2>
|
||||||
|
<TranslatedLink to="/articles/">
|
||||||
|
<UIString key={"recentArticles.all"} />
|
||||||
|
</TranslatedLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{!isEmpty ? recent.slice(0, length).map((article) => <ArticleItem lang={lang} data={article} />) : <EmptyList />}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
section {
|
||||||
|
& > div:nth-child(1) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
column-gap: 15px;
|
||||||
|
row-gap: 5px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
|
||||||
|
& h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& > div:nth-child(2) {
|
||||||
|
margin-top: 10px;
|
||||||
|
max-width: calc(var(--content-width) + (var(--content-padding) * 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
src/components/RecentArticles/index.ts
Normal file
1
src/components/RecentArticles/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./RecentArticles.astro";
|
||||||
@ -1,11 +1,14 @@
|
|||||||
import type { Component, JSX } from "solid-js";
|
import type { Component, JSX } from "solid-js";
|
||||||
|
|
||||||
import { createEffect } from "solid-js";
|
import { createEffect } from "solid-js";
|
||||||
import { search, setSearch, count } from "@stores/searchStore";
|
import { search, setSearch } from "@stores/searchStore";
|
||||||
import * as utils from "@utils/history";
|
import * as utils from "@utils/history";
|
||||||
import "./SearchInput.scss";
|
import "./SearchInput.scss";
|
||||||
|
|
||||||
type UiStringsType = { "input.search": string };
|
type SearchInputProps = {
|
||||||
type SearchInputProps = { i18n: UiStringsType };
|
count: number;
|
||||||
|
placeholder?: string | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const SearchInput: Component<SearchInputProps> = (props) => {
|
const SearchInput: Component<SearchInputProps> = (props) => {
|
||||||
/**
|
/**
|
||||||
@ -13,6 +16,7 @@ const SearchInput: Component<SearchInputProps> = (props) => {
|
|||||||
*/
|
*/
|
||||||
const resetSearch = () => {
|
const resetSearch = () => {
|
||||||
const url = utils.deleteParamFromUrl("search");
|
const url = utils.deleteParamFromUrl("search");
|
||||||
|
|
||||||
setSearch("");
|
setSearch("");
|
||||||
history.replaceState(null, "", url);
|
history.replaceState(null, "", url);
|
||||||
};
|
};
|
||||||
@ -29,6 +33,7 @@ const SearchInput: Component<SearchInputProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = utils.setParamToUrl("search", value.trim());
|
const url = utils.setParamToUrl("search", value.trim());
|
||||||
|
|
||||||
history.replaceState(null, "", `${location.pathname}?${url}`);
|
history.replaceState(null, "", `${location.pathname}?${url}`);
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
};
|
};
|
||||||
@ -49,25 +54,18 @@ const SearchInput: Component<SearchInputProps> = (props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="search-input">
|
<div class="search-input">
|
||||||
<svg
|
<svg width="25" height="24" viewBox="0 0 25 24" xmlns="http://www.w3.org/2000/svg" data-filter="none">
|
||||||
width="25"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 25 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
data-filter="none"
|
|
||||||
>
|
|
||||||
<path d="M10.6026 3.60001C11.8798 3.59993 13.1316 3.94283 14.2172 4.5901C15.3028 5.23736 16.179 6.16331 16.7472 7.26368C17.3154 8.36405 17.5531 9.59517 17.4334 10.8184C17.3137 12.0417 16.8415 13.2085 16.0699 14.1876L21.2427 19.164C21.4085 19.3248 21.5053 19.5399 21.5135 19.7662C21.5218 19.9926 21.4411 20.2137 21.2874 20.3853C21.1337 20.5569 20.9184 20.6665 20.6845 20.6921C20.4505 20.7178 20.2151 20.6576 20.0253 20.5236L19.9205 20.436L14.7476 15.4596C13.8802 16.0922 12.8687 16.5172 11.7968 16.6994C10.7248 16.8815 9.62324 16.8157 8.58298 16.5074C7.54272 16.199 6.59366 15.6569 5.81422 14.9259C5.03478 14.1949 4.44733 13.296 4.10042 12.3034C3.7535 11.3108 3.65708 10.2531 3.81911 9.21757C3.98114 8.18204 4.39698 7.19847 5.03226 6.34812C5.66755 5.49778 6.50404 4.80506 7.47263 4.32721C8.44122 3.84936 9.51411 3.60009 10.6026 3.60001V3.60001ZM10.6026 5.40001C9.27934 5.40001 8.01025 5.90572 7.07454 6.80589C6.13884 7.70607 5.61316 8.92697 5.61316 10.2C5.61316 11.473 6.13884 12.6939 7.07454 13.5941C8.01025 14.4943 9.27934 15 10.6026 15C11.9259 15 13.195 14.4943 14.1307 13.5941C15.0664 12.6939 15.5921 11.473 15.5921 10.2C15.5921 8.92697 15.0664 7.70607 14.1307 6.80589C13.195 5.90572 11.9259 5.40001 10.6026 5.40001Z" />
|
<path d="M10.6026 3.60001C11.8798 3.59993 13.1316 3.94283 14.2172 4.5901C15.3028 5.23736 16.179 6.16331 16.7472 7.26368C17.3154 8.36405 17.5531 9.59517 17.4334 10.8184C17.3137 12.0417 16.8415 13.2085 16.0699 14.1876L21.2427 19.164C21.4085 19.3248 21.5053 19.5399 21.5135 19.7662C21.5218 19.9926 21.4411 20.2137 21.2874 20.3853C21.1337 20.5569 20.9184 20.6665 20.6845 20.6921C20.4505 20.7178 20.2151 20.6576 20.0253 20.5236L19.9205 20.436L14.7476 15.4596C13.8802 16.0922 12.8687 16.5172 11.7968 16.6994C10.7248 16.8815 9.62324 16.8157 8.58298 16.5074C7.54272 16.199 6.59366 15.6569 5.81422 14.9259C5.03478 14.1949 4.44733 13.296 4.10042 12.3034C3.7535 11.3108 3.65708 10.2531 3.81911 9.21757C3.98114 8.18204 4.39698 7.19847 5.03226 6.34812C5.66755 5.49778 6.50404 4.80506 7.47263 4.32721C8.44122 3.84936 9.51411 3.60009 10.6026 3.60001V3.60001ZM10.6026 5.40001C9.27934 5.40001 8.01025 5.90572 7.07454 6.80589C6.13884 7.70607 5.61316 8.92697 5.61316 10.2C5.61316 11.473 6.13884 12.6939 7.07454 13.5941C8.01025 14.4943 9.27934 15 10.6026 15C11.9259 15 13.195 14.4943 14.1307 13.5941C15.0664 12.6939 15.5921 11.473 15.5921 10.2C15.5921 8.92697 15.0664 7.70607 14.1307 6.80589C13.195 5.90572 11.9259 5.40001 10.6026 5.40001Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
onInput={onSearch}
|
onInput={onSearch}
|
||||||
value={search()}
|
value={search()}
|
||||||
type="search"
|
type="search"
|
||||||
name="search"
|
placeholder={props.placeholder || "Search article by title or description..."}
|
||||||
placeholder={props.i18n["input.search"] || "Search article by title..."}
|
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<span>{count()}</span>
|
<span>{props.count}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -8,6 +8,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 5rem;
|
margin-top: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
main {
|
main {
|
||||||
margin-top: 3rem;
|
margin-top: 3rem;
|
||||||
1
src/components/SiteContent/index.ts
Normal file
1
src/components/SiteContent/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default } from "./SiteContent.astro";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user