feat: initial commit

This commit is contained in:
Alex Wellnitz 2023-09-02 22:58:42 +02:00
commit bb0950208e
70 changed files with 22649 additions and 0 deletions

6
.eslintignore Normal file
View File

@ -0,0 +1,6 @@
.husky
.vscode
node_modules
public
dist
.yarn

23
.eslintrc.js Normal file
View File

@ -0,0 +1,23 @@
module.exports = {
env: {
node: true,
es2022: true,
browser: true,
},
extends: ["eslint:recommended", "plugin:astro/recommended"],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
overrides: [
{
files: ["*.astro"],
parser: "astro-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser",
extraFileExtensions: [".astro"],
},
rules: {},
},
],
};

35
.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# build output
dist/
.output/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# ignore .astro directory
.astro
# ignore Jampack cache files
.jampack/
# yarn
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
.pnp.*

4
.husky/pre-commit Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx lint-staged

4
.markdownlint.json Normal file
View File

@ -0,0 +1,4 @@
{
"MD033": false,
"MD013": false
}

2
.npmrc Normal file
View File

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

13
.prettierignore Normal file
View File

@ -0,0 +1,13 @@
# Ignore everything
/*
# Except these files & folders
!/src
!/public
!/.github
!tsconfig.json
!astro.config.mjs
!package.json
!.prettierrc
!.eslintrc.js
!README.md

11
.prettierrc Normal file
View File

@ -0,0 +1,11 @@
{
"arrowParens": "avoid",
"semi": true,
"tabWidth": 2,
"printWidth": 80,
"singleQuote": false,
"jsxSingleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"endOfLine": "lf"
}

4
.vscode/extensions.json vendored Normal file
View File

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

11
.vscode/launch.json vendored Normal file
View File

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

View File

@ -0,0 +1,159 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" class="theme--agnostic" fill="none" width="1000" height="330">
<style>
.gauge-base {
opacity: 0.1
}
.gauge-arc {
fill: none;
animation-delay: 250ms;
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: 100px 60px;
animation: load-gauge 1s ease forwards
}
.guage-text {
font-size: 40px;
font-family: monospace;
text-align: center
}
.guage-red {
color: #ff4e42;
fill: #ff4e42;
stroke: #ff4e42
}
.guage-orange {
color: #ffa400;
fill: #ffa400;
stroke: #ffa400
}
.guage-green {
color: #0cce6b;
fill: #0cce6b;
stroke: #0cce6b
}
.theme--agnostic .guage-undefined {
color: #5c5c5c;
fill: #5c5c5c;
stroke: #5c5c5c
}
.theme--light .guage-undefined {
color: #1e1e1e;
fill: #1e1e1e;
stroke: #1e1e1e
}
.theme--dark .guage-undefined {
color: #f5f5f5;
fill: #f5f5f5;
stroke: #f5f5f5
}
.guage-title {
stroke: none;
font-size: 26px;
line-height: 26px;
font-family: Roboto, Halvetica, Arial, sans-serif
}
.metric.guage-title {
font-family: 'Courier New', Courier, monospace
}
.theme--agnostic .guage-title {
color: #737373;
fill: #737373
}
.theme--light .guage-title {
color: #212121;
fill: #212121
}
.theme--dark .guage-title {
color: #f5f5f5;
fill: #f5f5f5
}
@keyframes load-gauge {
from {
stroke-dasharray: 0 352.858
}
}
.lh-gauge--pwa__disc {
fill: #e0e0e0
}
.lh-gauge--pwa__logo {
position: relative;
fill: #b0b0b0
}
.lh-gauge--pwa__invisible {
display: none
}
.lh-gauge--pwa__visible {
display: inline
}
.guage-invisible {
display: none
}
.lh-gauge--pwa__logo--primary-color {
fill: #304ffe
}
.theme--agnostic .lh-gauge--pwa__logo--secondary-color {
fill: #787878
}
.theme--light .lh-gauge--pwa__logo--secondary-color {
fill: #3d3d3d
}
.theme--dark .lh-gauge--pwa__logo--secondary-color {
fill: #d8b6b6
}
.theme--light #svg_2 {
stroke: #00000022
}
.theme--agnostic #svg_2 {
stroke: #616161
}
.theme--light #svg_2 {
stroke: #00000022
}
.theme--dark #svg_2 {
stroke: #f5f5f566
}
</style>
<svg class="guage-div guage-perf guage-green" viewBox="0 0 200 200" width="200" height="200" x="100" y="0">
<circle class="gauge-base" r="56" cx="100" cy="60" stroke-width="8"/>
<circle class="gauge-arc guage-arc-1" r="56" cx="100" cy="60" stroke-width="8" style="stroke-dasharray: 351.858, 351.858;"/>
<text class="guage-text" x="100px" y="60px" alignment-baseline="central" dominant-baseline="central" text-anchor="middle">100</text>
<text class="guage-title" x="100px" y="160px" alignment-baseline="central" dominant-baseline="central" text-anchor="middle">Performance</text>
</svg>,<svg class="guage-div guage-perf guage-green" viewBox="0 0 200 200" width="200" height="200" x="300" y="0">
<circle class="gauge-base" r="56" cx="100" cy="60" stroke-width="8"/>
<circle class="gauge-arc guage-arc-1" r="56" cx="100" cy="60" stroke-width="8" style="stroke-dasharray: 351.858, 351.858;"/>
<text class="guage-text" x="100px" y="60px" alignment-baseline="central" dominant-baseline="central" text-anchor="middle">100</text>
<text class="guage-title" x="100px" y="160px" alignment-baseline="central" dominant-baseline="central" text-anchor="middle">Accessibility</text>
</svg>,<svg class="guage-div guage-perf guage-green" viewBox="0 0 200 200" width="200" height="200" x="500" y="0">
<circle class="gauge-base" r="56" cx="100" cy="60" stroke-width="8"/>
<circle class="gauge-arc guage-arc-1" r="56" cx="100" cy="60" stroke-width="8" style="stroke-dasharray: 351.858, 351.858;"/>
<text class="guage-text" x="100px" y="60px" alignment-baseline="central" dominant-baseline="central" text-anchor="middle">100</text>
<text class="guage-title" x="100px" y="160px" alignment-baseline="central" dominant-baseline="central" text-anchor="middle">Best Practices</text>
</svg>,<svg class="guage-div guage-perf guage-green" viewBox="0 0 200 200" width="200" height="200" x="700" y="0">
<circle class="gauge-base" r="56" cx="100" cy="60" stroke-width="8"/>
<circle class="gauge-arc guage-arc-1" r="56" cx="100" cy="60" stroke-width="8" style="stroke-dasharray: 351.858, 351.858;"/>
<text class="guage-text" x="100px" y="60px" alignment-baseline="central" dominant-baseline="central" text-anchor="middle">100</text>
<text class="guage-title" x="100px" y="160px" alignment-baseline="central" dominant-baseline="central" text-anchor="middle">SEO</text>
</svg>
<svg width="604" height="76" x="200" y="250">
<g>
<rect fill="none" id="canvas_background" height="80" width="604" y="-1" x="-1"/>
<g display="none" overflow="visible" y="0" x="0" height="100%" width="100%" id="canvasGrid">
<rect fill="url(#gridpattern)" stroke-width="0" y="0" x="0" height="100%" width="100%"/>
</g>
</g>
<g>
<rect fill-opacity="0" stroke-width="2" rx="40" id="svg_2" height="72" width="600" y="1" x="0" fill="#000000"/>
<rect stroke="#000" rx="8" id="svg_3" height="14" width="48" y="30" x="35" stroke-opacity="null" stroke-width="0" fill="#ff4e42"/>
<rect stroke="#000" rx="6" id="svg_4" height="14" width="48" y="30" x="220" stroke-opacity="null" stroke-width="0" fill="#ffa400"/>
<rect stroke="#000" rx="6" id="svg_5" height="14" width="48" y="30" x="410" stroke-opacity="null" stroke-width="0" fill="#0cce6b"/>
<text class="metric guage-title" xml:space="preserve" text-anchor="start" font-size="26" id="svg_6" y="45" x="100" stroke-opacity="null" stroke-width="0" stroke="#000">0-49</text>
<text class="metric guage-title" xml:space="preserve" text-anchor="start" font-size="26" id="svg_7" y="45" x="280" stroke-opacity="null" stroke-width="0" stroke="#000">50-89</text>
<text class="metric guage-title" xml:space="preserve" text-anchor="start" font-size="26" id="svg_8" y="45" x="470" stroke-opacity="null" stroke-width="0" stroke="#000">90-100</text>
</g>
</svg>
</svg>

After

Width:  |  Height:  |  Size: 6.1 KiB

25
Dockerfile Normal file
View File

@ -0,0 +1,25 @@
FROM node:lts AS base
WORKDIR /app
# By copying only the package.json and package-lock.json here, we ensure that the following `-deps` steps are independent of the source code.
# Therefore, the `-deps` steps will be skipped if only the source code changes.
COPY package.json package-lock.json ./
FROM base AS prod-deps
RUN npm install --production
FROM base AS build-deps
RUN npm install --production=false
FROM build-deps AS build
COPY . .
RUN npm run build
FROM base AS runtime
COPY --from=prod-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD node ./dist/server/entry.mjs

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 Sat Naing
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

153
README.md Normal file
View File

@ -0,0 +1,153 @@
# AstroPaper 📄
![AstroPaper](public/astropaper-og.jpg)
![Typescript](https://img.shields.io/badge/TypeScript-007ACC?style=for-the-badge&logo=typescript&logoColor=white)
![GitHub](https://img.shields.io/github/license/satnaing/astro-paper?color=%232F3741&style=for-the-badge)
[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?logo=conventionalcommits&logoColor=white&style=for-the-badge)](https://conventionalcommits.org)
[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg?style=for-the-badge)](http://commitizen.github.io/cz-cli/)
AstroPaper is a minimal, responsive, accessible and SEO-friendly Astro blog theme. This theme is designed and crafted based on [my personal blog](https://satnaing.dev/blog).
This theme follows best practices and provides accessibility out of the box. Light and dark mode are supported by default. Moreover, additional color schemes can also be configured.
This theme is self-documented \_ which means articles/posts in this theme can also be considered as documentations. Read [the blog posts](https://astro-paper.pages.dev/posts/) or check [the README Documentation Section](#-documentation) for more info.
## 🔥 Features
- [x] type-safe markdown
- [x] super fast performance
- [x] accessible (Keyboard/VoiceOver)
- [x] responsive (mobile ~ desktops)
- [x] SEO-friendly
- [x] light & dark mode
- [x] fuzzy search
- [x] draft posts & pagination
- [x] sitemap & rss feed
- [x] followed best practices
- [x] highly customizable
- [x] dynamic OG image generation for blog posts [#15](https://github.com/satnaing/astro-paper/pull/15) ([Blog Post](https://astro-paper.pages.dev/posts/dynamic-og-image-generation-in-astropaper-blog-posts/))
_Note: I've tested screen-reader accessibility of AstroPaper using **VoiceOver** on Mac and **TalkBack** on Android. I couldn't test all other screen-readers out there. However, accessibility enhancements in AstroPaper should be working fine on others as well._
## ✅ Lighthouse Score
<p align="center">
<a href="https://pagespeed.web.dev/report?url=https%3A%2F%2Fastro-paper.pages.dev%2F&form_factor=desktop">
<img width="710" alt="AstroPaper Lighthouse Score" src="AstroPaper-lighthouse-score.svg">
<a>
</p>
## 🚀 Project Structure
Inside of AstroPaper, you'll see the following folders and files:
```bash
/
├── public/
│ ├── assets/
│ │ └── logo.svg
│ │ └── logo.png
│ └── favicon.svg
│ └── astropaper-og.jpg
│ └── robots.txt
│ └── toggle-theme.js
├── src/
│ ├── assets/
│ │ └── socialIcons.ts
│ ├── components/
│ ├── content/
│ │ | blog/
│ │ | └── some-blog-posts.md
│ │ └── _schemas.ts
│ │ └── config.ts
│ ├── layouts/
│ └── pages/
│ └── styles/
│ └── utils/
│ └── config.ts
│ └── types.ts
└── package.json
```
Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
Any static assets, like images, can be placed in the `public/` directory.
All blog posts are stored in `src/content/blog` directory.
## 📖 Documentation
Documentation can be read in two formats\_ _markdown_ & _blog post_.
- Configuration - [markdown](src/content/blog/how-to-configure-astropaper-theme.md) | [blog post](https://astro-paper.pages.dev/posts/how-to-configure-astropaper-theme/)
- Add Posts - [markdown](src/content/blog/adding-new-post.md) | [blog post](https://astro-paper.pages.dev/posts/adding-new-posts-in-astropaper-theme/)
- Customize Color Schemes - [markdown](src/content/blog/customizing-astropaper-theme-color-schemes.md) | [blog post](https://astro-paper.pages.dev/posts/customizing-astropaper-theme-color-schemes/)
- Predefined Color Schemes - [markdown](src/content/blog/predefined-color-schemes.md) | [blog post](https://astro-paper.pages.dev/posts/predefined-color-schemes/)
> For AstroPaper v1, check out [this branch](https://github.com/satnaing/astro-paper/tree/astro-paper-v1) and this [live URL](https://astro-paper-v1.astro-paper.pages.dev/)
## 💻 Tech Stack
**Main Framework** - [Astro](https://astro.build/)
**Type Checking** - [TypeScript](https://www.typescriptlang.org/)
**Component Framework** - [ReactJS](https://reactjs.org/)
**Styling** - [TailwindCSS](https://tailwindcss.com/)
**UI/UX** - [Figma](https://figma.com)
**Fuzzy Search** - [FuseJS](https://fusejs.io/)
**Icons** - [Boxicons](https://boxicons.com/) | [Tablers](https://tabler-icons.io/)
**Code Formatting** - [Prettier](https://prettier.io/)
**Deployment** - [Cloudflare Pages](https://pages.cloudflare.com/)
**Illustration in About Page** - [https://freesvgillustration.com](https://freesvgillustration.com/)
**Linting** - [ESLint](https://eslint.org)
## 👨🏻‍💻 Running Locally
The easiest way to run this project locally is to run the following command in your desired directory.
```bash
# npm 6.x
npm create astro@latest --template satnaing/astro-paper
# npm 7+, extra double-dash is needed:
npm create astro@latest -- --template satnaing/astro-paper
# yarn
yarn create astro --template satnaing/astro-paper
```
## Google Site Verification (optional)
You can easily add your [Google Site Verification HTML tag](https://support.google.com/webmasters/answer/9008080#meta_tag_verification&zippy=%2Chtml-tag) in AstroPaper using environment variable. This step is optional. If you don't add the following env variable, the google-site-verification tag won't appear in the html `<head>` section.
```bash
# in your environment variable file (.env)
PUBLIC_GOOGLE_SITE_VERIFICATION=your-google-site-verification-value
```
## 🧞 Commands
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :--------------------- | :------------------------------------------------------------------------------------------------------------------------------- |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:3000` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run format:check` | Check code format with Prettier |
| `npm run format` | Format codes with Prettier |
| `npm run sync` | Generates TypeScript types for all Astro modules. [Learn more](https://docs.astro.build/en/reference/cli-reference/#astro-sync). |
| `npm run cz` | Commit code changes with commitizen |
| `npm run lint` | Lint with ESLint |
## ✨ Feedback & Suggestions
If you have any suggestions/feedback, you can contact me via [my email](mailto:contact@satnaing.dev). Alternatively, feel free to open an issue if you find bugs or want to request new features.
## 📜 License
Licensed under the MIT License, Copyright © 2023
---
Made with 🤍 by [Sat Naing](https://satnaing.dev) 👨🏻‍💻

41
astro.config.mjs Normal file
View File

@ -0,0 +1,41 @@
import { defineConfig } from "astro/config";
import tailwind from "@astrojs/tailwind";
import react from "@astrojs/react";
import remarkToc from "remark-toc";
import remarkCollapse from "remark-collapse";
import sitemap from "@astrojs/sitemap";
// https://astro.build/config
export default defineConfig({
site: "https://astro-paper.pages.dev/", // replace this with your deployed domain
integrations: [
tailwind({
config: {
applyBaseStyles: false,
},
}),
react(),
sitemap(),
],
markdown: {
remarkPlugins: [
remarkToc,
[
remarkCollapse,
{
test: "Table of contents",
},
],
],
shikiConfig: {
theme: "one-dark-pro",
wrap: true,
},
extendDefaultPlugins: true,
},
vite: {
optimizeDeps: {
exclude: ["@resvg/resvg-js"],
},
},
});

18716
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

59
package.json Normal file
View File

@ -0,0 +1,59 @@
{
"name": "alexohneander-astro",
"version": "2.3.0",
"scripts": {
"dev": "astro check --watch & astro dev",
"start": "astro dev",
"build": "astro build && jampack ./dist",
"preview": "astro preview",
"sync": "astro sync",
"astro": "astro",
"format:check": "prettier --plugin-search-dir=. --check .",
"format": "prettier --plugin-search-dir=. --write .",
"cz": "cz",
"prepare": "husky install",
"lint": "eslint ."
},
"dependencies": {
"@astrojs/rss": "^2.4.1",
"@resvg/resvg-js": "^2.4.1",
"astro": "^2.4.5",
"fuse.js": "^6.6.2",
"github-slugger": "^2.0.0",
"remark-collapse": "^0.1.2",
"remark-toc": "^8.0.1",
"satori": "^0.8.1",
"tailwindcss": "^3.3.2"
},
"devDependencies": {
"@astrojs/react": "^2.1.3",
"@astrojs/sitemap": "^1.3.1",
"@astrojs/tailwind": "^3.1.2",
"@divriots/jampack": "^0.11.2",
"@tailwindcss/typography": "^0.5.9",
"@types/github-slugger": "^1.3.0",
"@types/react": "^18.2.6",
"@typescript-eslint/parser": "^5.59.5",
"astro-eslint-parser": "^0.14.0",
"commitizen": "^4.3.0",
"cz-conventional-changelog": "^3.3.0",
"eslint": "^8.40.0",
"eslint-plugin-astro": "^0.27.0",
"husky": "^8.0.3",
"lint-staged": "^13.2.2",
"prettier": "^2.8.8",
"prettier-plugin-tailwindcss": "^0.2.8",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
},
"lint-staged": {
"*.{js,jsx,ts,tsx,md,mdx,json}": [
"prettier --plugin-search-dir=. --write"
]
}
}

361
public/assets/dev.svg Normal file
View File

@ -0,0 +1,361 @@
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg" width="865.76" height="682.89" viewBox="0 0 865.76 682.89">
<defs>
<style xmlns="http://www.w3.org/1999/xhtml">*, body, html { -webkit-font-smoothing: antialiased; }
img, svg { max-width: 100%; }
</style>
</defs>
<path d="M391.82,532.2c-15.44,2.82-87.85,18.09-73.28,55a33.24,33.24,0,0,0,9.74,13.13c18.18,15.25,83.33,52.58,272.06,32.22,10.69-1.15,21.42-1.86,32.17-2.06,49.73-.92,206-9.34,202-78.54,0,0-2.07-38.74-95.7-26.87l-71.21-4.43s-160.55-12.38-268.7,10.11C396.57,531.29,394.2,531.77,391.82,532.2Z" fill="#787878" data-primary="true"/>
<path d="M391.82,532.2c-15.44,2.82-87.85,18.09-73.28,55a33.24,33.24,0,0,0,9.74,13.13c18.18,15.25,83.33,52.58,272.06,32.22,10.69-1.15,21.42-1.86,32.17-2.06,49.73-.92,206-9.34,202-78.54,0,0-2.07-38.74-95.7-26.87l-71.21-4.43s-160.55-12.38-268.7,10.11C396.57,531.29,394.2,531.77,391.82,532.2Z" fill="#fff" opacity="0.7"/>
<path d="M503.08,522.3C179.26,552.79,133.91,359.63,133.91,359.63c-24.79-67.13-3.45-111,27.66-152.68a303.36,303.36,0,0,1,117.77-94.5c74.9-34.06,126.36-41,126.36-41S622.58,15.68,735.64,183.7c0,0,108,135.55,37.54,221.14,0,0-35.14,47.34-127.63,82.89l-69.3,20.46A387.7,387.7,0,0,1,503.08,522.3Z" fill="#787878" data-primary="true"/>
<path d="M503.08,522.3C179.26,552.79,133.91,359.63,133.91,359.63c-24.79-67.13-3.45-111,27.66-152.68a303.36,303.36,0,0,1,117.77-94.5c74.9-34.06,126.36-41,126.36-41S622.58,15.68,735.64,183.7c0,0,108,135.55,37.54,221.14,0,0-35.14,47.34-127.63,82.89l-69.3,20.46A387.7,387.7,0,0,1,503.08,522.3Z" fill="#fff" opacity="0.7"/>
<rect x="104.67" y="206.46" width="463.2" height="348.88" fill="#fff"/>
<rect x="108.43" y="206.46" width="459.44" height="35.42" fill="#e6e6e6"/>
<rect x="128.82" y="259.06" width="104.13" height="104.13" fill="#787878" opacity="0.29" data-primary="true"/>
<rect x="713.86" y="369.62" width="5.37" height="37.57" fill="#999"/>
<polygon points="664.89 442.18 664.89 554.44 672.53 554.44 676.93 436.58 664.89 442.18" fill="#ccc"/>
<polygon points="711.71 420.08 711.71 537.08 719.36 537.08 723.52 414.71 711.71 420.08" fill="#ccc"/>
<polygon points="668.23 434.1 733.18 405.05 703.86 399.96 670.01 385.44 668.23 434.1" fill="#ccc"/>
<path d="M656.14,446.25l77-35.83v-5.37L668.23,434.1S660.68,442.36,656.14,446.25Z" fill="#b3b3b3"/>
<path d="M693.46,271.94H734a4.55,4.55,0,0,1,4.55,4.55v67.37a0,0,0,0,1,0,0H693.46a0,0,0,0,1,0,0V271.94A0,0,0,0,1,693.46,271.94Z" fill="#999"/>
<rect x="241.54" y="44.36" width="325.8" height="139.55" fill="#787878" data-primary="true"/>
<rect x="263.01" y="83.01" width="100.91" height="65.48" fill="#fff" opacity="0.3"/>
<g opacity="0.3">
<path d="M297.36,131.59a1.07,1.07,0,0,1-.76-.32l-14.79-14.76a1.08,1.08,0,0,1,0-1.5l14.79-15.56a1.07,1.07,0,0,1,1.56,1.47l-14.07,14.81,14.05,14a1.07,1.07,0,0,1,0,1.52A1.09,1.09,0,0,1,297.36,131.59Z" fill="#fff"/>
</g>
<g opacity="0.3">
<path d="M328.73,132.66a1.06,1.06,0,0,1-.76-.31,1.07,1.07,0,0,1,0-1.52l14-14L328,102a1.08,1.08,0,1,1,1.56-1.48l14.78,15.56a1.06,1.06,0,0,1,0,1.5l-14.78,14.77A1.07,1.07,0,0,1,328.73,132.66Z" fill="#fff"/>
</g>
<g opacity="0.3">
<path d="M305.56,131.59a1.08,1.08,0,0,1-1-1.56l14.34-28.18a1.08,1.08,0,1,1,1.92,1L306.51,131A1.07,1.07,0,0,1,305.56,131.59Z" fill="#fff"/>
</g>
<path d="M524.39,119.51H454.62a1.08,1.08,0,0,1,0-2.15h69.77a1.08,1.08,0,1,1,0,2.15Z" fill="#fff"/>
<path d="M540.5,132.39H454.62a1.08,1.08,0,0,1,0-2.15H540.5a1.08,1.08,0,0,1,0,2.15Z" fill="#fff"/>
<rect x="460.52" y="153.86" width="65.48" height="16.1" rx="7.5" fill="#fff" opacity="0.3"/>
<path d="M567.33,44.36V183.91H241.54s54.75-59.1,144.51-74c4.1-.68,8.24-1.12,12.38-1.4C426.41,106.6,557.79,95.18,567.33,44.36Z" fill="#fff" opacity="0.3"/>
<rect x="31.14" y="128.09" width="187.86" height="213.62" fill="#787878" data-primary="true"/>
<rect x="31.14" y="128.09" width="187.86" height="34.35" fill="#fff" opacity="0.3"/>
<rect x="46.17" y="173.18" width="57.97" height="57.97" fill="#282728" data-secondary="true"/>
<circle cx="164.78" cy="145.27" r="3.76" fill="#fff" opacity="0.3"/>
<circle cx="184.11" cy="145.27" r="3.76" fill="#fff" opacity="0.3"/>
<circle cx="203.43" cy="145.27" r="3.76" fill="#fff" opacity="0.3"/>
<path d="M170.69,192.5H117a1.07,1.07,0,1,1,0-2.14h53.67a1.07,1.07,0,0,1,0,2.14Z" fill="#fff"/>
<path d="M186.25,205.38h-68.7a1.07,1.07,0,0,1,0-2.14h68.7a1.07,1.07,0,1,1,0,2.14Z" fill="#fff"/>
<path d="M203.43,218.27H117.55a1.08,1.08,0,0,1,0-2.15h85.88a1.08,1.08,0,0,1,0,2.15Z" fill="#fff"/>
<path d="M168,287H84.28a1.08,1.08,0,1,1,0-2.15H168a1.08,1.08,0,0,1,0,2.15Z" fill="#fff"/>
<path d="M194.84,299.85H57.44a1.08,1.08,0,1,1,0-2.15h137.4a1.08,1.08,0,1,1,0,2.15Z" fill="#fff"/>
<path d="M168.54,312.73H83.74a1.08,1.08,0,1,1,0-2.15h84.8a1.08,1.08,0,1,1,0,2.15Z" fill="#fff"/>
<rect x="83.74" y="248.32" width="78.36" height="16.1" fill="#fff" opacity="0.3"/>
<rect x="256.57" y="259.06" width="66.55" height="17.18" fill="#787878" opacity="0.29" data-primary="true"/>
<path d="M308.78,293.79H256.57a1.08,1.08,0,1,1,0-2.15h52.21a1.08,1.08,0,1,1,0,2.15Z" fill="#e6e6e6"/>
<path d="M325.8,306.67H256.57a1.07,1.07,0,1,1,0-2.14H325.8a1.07,1.07,0,1,1,0,2.14Z" fill="#e6e6e6"/>
<path d="M339.76,319.55H256.57a1.07,1.07,0,1,1,0-2.14h83.19a1.07,1.07,0,0,1,0,2.14Z" fill="#e6e6e6"/>
<path d="M379.48,332.44H256.57a1.08,1.08,0,1,1,0-2.15H379.48a1.08,1.08,0,0,1,0,2.15Z" fill="#e6e6e6"/>
<rect x="256.57" y="348.15" width="154.58" height="15.03" fill="#787878" opacity="0.29" data-primary="true"/>
<path d="M252.45,400.29h-122a1.08,1.08,0,0,1,0-2.15h122a1.08,1.08,0,1,1,0,2.15Z" fill="#e6e6e6"/>
<path d="M353.18,400.29H268.91a1.08,1.08,0,0,1,0-2.15h84.27a1.08,1.08,0,0,1,0,2.15Z" fill="#e6e6e6"/>
<path d="M417.59,400.29H388.06a1.08,1.08,0,0,1,0-2.15h29.53a1.08,1.08,0,0,1,0,2.15Z" fill="#e6e6e6"/>
<rect x="256.57" y="396.53" width="5.37" height="5.37" fill="#787878" opacity="0.29" data-primary="true"/>
<rect x="360.69" y="396.53" width="5.37" height="5.37" fill="#787878" opacity="0.29" data-primary="true"/>
<rect x="373.57" y="396.53" width="5.37" height="5.37" fill="#ccc"/>
<path d="M223.29,429.16H131a1.08,1.08,0,0,1,0-2.15h92.32a1.08,1.08,0,0,1,0,2.15Z" fill="#e6e6e6"/>
<path d="M289.84,455.37H129.9a1.08,1.08,0,1,1,0-2.15H289.84a1.08,1.08,0,1,1,0,2.15Z" fill="#e6e6e6"/>
<path d="M325.27,429.16H255a1.08,1.08,0,1,1,0-2.15h70.31a1.08,1.08,0,0,1,0,2.15Z" fill="#e6e6e6"/>
<path d="M349.42,455.37h-36a1.08,1.08,0,0,1,0-2.15h36a1.08,1.08,0,0,1,0,2.15Z" fill="#e6e6e6"/>
<rect x="227.58" y="425.4" width="5.37" height="5.37" fill="#787878" opacity="0.29" data-primary="true"/>
<rect x="240.46" y="425.4" width="5.37" height="5.37" fill="#ccc"/>
<rect x="290.92" y="451.61" width="5.37" height="5.37" fill="#787878" opacity="0.29" data-primary="true"/>
<rect x="303.8" y="451.61" width="5.37" height="5.37" fill="#ccc"/>
<path d="M355.32,512.93H298.43a1.08,1.08,0,0,1,0-2.15h56.89a1.08,1.08,0,1,1,0,2.15Z" fill="#e6e6e6"/>
<path d="M416,512.93H388.06a1.08,1.08,0,0,1,0-2.15H416a1.08,1.08,0,1,1,0,2.15Z" fill="#e6e6e6"/>
<rect x="361.77" y="509.17" width="5.37" height="5.37" fill="#ccc"/>
<rect x="374.65" y="509.17" width="5.37" height="5.37" fill="#787878" opacity="0.29" data-primary="true"/>
<path d="M416,455.37H375.72a1.08,1.08,0,0,1,0-2.15H416a1.08,1.08,0,1,1,0,2.15Z" fill="#e6e6e6"/>
<rect x="353.18" y="451.61" width="5.37" height="5.37" fill="#ccc"/>
<rect x="366.06" y="451.61" width="5.37" height="5.37" fill="#787878" opacity="0.29" data-primary="true"/>
<path d="M205,485H131a1.08,1.08,0,0,1,0-2.15H205a1.08,1.08,0,0,1,0,2.15Z" fill="#e6e6e6"/>
<path d="M349.42,485h-52.6a1.08,1.08,0,0,1,0-2.15h52.6a1.08,1.08,0,0,1,0,2.15Z" fill="#e6e6e6"/>
<path d="M416,485H363.38a1.08,1.08,0,1,1,0-2.15H416a1.08,1.08,0,1,1,0,2.15Z" fill="#e6e6e6"/>
<rect x="207.19" y="481.26" width="5.37" height="5.37" fill="#ccc"/>
<rect x="220.07" y="481.26" width="5.37" height="5.37" fill="#787878" opacity="0.29" data-primary="true"/>
<rect x="231.88" y="481.26" width="5.37" height="5.37" fill="#ccc"/>
<path d="M256.57,512.93H131a1.08,1.08,0,0,1,0-2.15h125.6a1.08,1.08,0,0,1,0,2.15Z" fill="#e6e6e6"/>
<rect x="258.71" y="509.17" width="5.37" height="5.37" fill="#ccc"/>
<rect x="271.59" y="509.17" width="5.37" height="5.37" fill="#787878" opacity="0.29" data-primary="true"/>
<rect x="283.4" y="509.17" width="5.37" height="5.37" fill="#ccc"/>
<rect x="244.76" y="481.26" width="5.37" height="5.37" fill="#787878" opacity="0.29" data-primary="true"/>
<rect x="259.79" y="481.26" width="5.37" height="5.37" fill="#ccc"/>
<rect x="271.59" y="481.26" width="5.37" height="5.37" fill="#787878" opacity="0.29" data-primary="true"/>
<rect x="284.48" y="481.26" width="5.37" height="5.37" fill="#ccc"/>
<path d="M417.59,429.16H358a1.08,1.08,0,1,1,0-2.15h59.58a1.08,1.08,0,0,1,0,2.15Z" fill="#e6e6e6"/>
<rect x="330.63" y="425.4" width="5.37" height="5.37" fill="#787878" opacity="0.29" data-primary="true"/>
<rect x="343.52" y="425.4" width="5.37" height="5.37" fill="#ccc"/>
<rect x="51.53" y="436.18" width="103.05" height="64.41" fill="#787878" data-primary="true"/>
<g opacity="0.3">
<path d="M88.5,485.36a1.06,1.06,0,0,1-.74-.3l-15.5-14.83a1.06,1.06,0,0,1,0-1.54l15.49-15a1.07,1.07,0,0,1,1.52,0,1.08,1.08,0,0,1,0,1.52l-14.7,14.25,14.69,14.06a1.07,1.07,0,0,1,0,1.52A1.1,1.1,0,0,1,88.5,485.36Z" fill="#fff"/>
</g>
<g opacity="0.3">
<path d="M119.16,485.36a1.07,1.07,0,0,1-.74-1.84l14.69-14.26L118.42,455.2a1.07,1.07,0,0,1,1.48-1.55l15.5,14.83a1.07,1.07,0,0,1,.33.77,1.08,1.08,0,0,1-.32.78l-15.5,15A1.08,1.08,0,0,1,119.16,485.36Z" fill="#fff"/>
</g>
<g opacity="0.3">
<path d="M96.62,483.41a1.11,1.11,0,0,1-.5-.12,1.07,1.07,0,0,1-.45-1.45l14-26.83a1.08,1.08,0,1,1,1.91,1l-14,26.83A1.06,1.06,0,0,1,96.62,483.41Z" fill="#fff"/>
</g>
<rect x="434.76" y="367.48" width="11.81" height="208.25" fill="#999"/>
<rect x="441.2" y="367.48" width="5.37" height="208.25" opacity="0.1"/>
<rect x="471.26" y="368.01" width="11.81" height="172.29" fill="#999"/>
<rect x="477.7" y="368.01" width="5.37" height="172.29" opacity="0.1"/>
<rect x="728.89" y="367.48" width="11.81" height="208.25" fill="#999"/>
<rect x="735.33" y="367.48" width="5.37" height="208.25" opacity="0.1"/>
<rect x="758.95" y="354.06" width="11.81" height="186.25" fill="#999"/>
<rect x="765.39" y="354.06" width="5.37" height="186.25" opacity="0.1"/>
<path d="M688.1,271.94h40.53a4.55,4.55,0,0,1,4.55,4.55v67.37a0,0,0,0,1,0,0H688.1a0,0,0,0,1,0,0V271.94A0,0,0,0,1,688.1,271.94Z" fill="#b3b3b3"/>
<polygon points="421.88 364.26 477.27 336.37 786.88 336.37 750.36 364.26 421.88 364.26" fill="#ccc"/>
<path d="M542.11,559.63l-32.5,25.42S496,597.2,507.76,604.71c0,0,17.17,10.74,31.13-7.51l19.37-31.64Z" fill="#787878" data-primary="true"/>
<path d="M505.61,596.12c8,8.68,20.58,6.87,28.45-1,3.7-3.79,7-8.33,10.52-12.3,3.08-3.62,7.51-8.79,10.65-12.28-2.8,3.74-7.06,9.09-10,12.81-3.41,4.12-6.73,8.65-10.42,12.54-8.21,8.11-21.45,9.88-29.19.26Z" opacity="0.2"/>
<path d="M512.32,583.74c6.45-.09,13.31,2.42,17.35,7.63a15.61,15.61,0,0,1,2.79,5.84c-.26-.47-.51-1-.74-1.43a8.51,8.51,0,0,0-.81-1.37c-4-6.39-11.44-9.4-18.59-10.67Z" opacity="0.2"/>
<path d="M519.56,580c4.83-.65,11.72.93,12.9,6.4-2.62-4.61-8.1-5.41-12.9-6.4Z" opacity="0.2"/>
<path d="M523.86,575.73c4.82-.65,11.72.93,12.89,6.39-2.61-4.6-8.1-5.4-12.89-6.39Z" opacity="0.2"/>
<path d="M532.45,569.29c4.82-.65,11.72.93,12.89,6.39-2.61-4.61-8.1-5.4-12.89-6.39Z" opacity="0.2"/>
<path d="M550.16,544.06l-8,15.57s-3.32,4,1.25,6.48a8.52,8.52,0,0,0,4.06,1h7.9a3.61,3.61,0,0,0,2.94-1.51L568,551.93S554.41,546.7,550.16,544.06Z" fill="#f9b499"/>
<polygon points="548.32 510.23 551.84 520.86 557.18 505.66 548.32 510.23" fill="#f9b499"/>
<path d="M710.77,332.4c0,8.24-5.5,8.24-5.5,8.24l-15,5.37c-6.68,2.23-9.44,1.89-10.5,1.46a1.62,1.62,0,0,0-1.36.1.24.24,0,0,1-.08.06,11.71,11.71,0,0,1-3.82,1.75h0c-2.89.58-2.48-2.31-2.48-2.31a12.77,12.77,0,0,0,.2-1.54,9.91,9.91,0,0,0-5.8-9.37,26.59,26.59,0,0,0-4.77-1.68,6.38,6.38,0,0,0-3.07,0l-8.33,1.91H608.39L570,335.27a98.24,98.24,0,0,1,12.58-65.48,86.2,86.2,0,0,1,8.82-12.47c.72-.84,1.14-1.27,1.14-1.27l2.37-3.43s32,2.14,32.37,1.07,22.15-14,22.15-14l30.76,13a10.63,10.63,0,0,1,3.59,2.59c6.62,6.85,11.81,23.17,11.81,23.17l14,46.16A30.89,30.89,0,0,1,710.77,332.4Z" fill="#787878" data-primary="true"/>
<path d="M675.8,305s-30.74,5-53.75.22c-.59-.12-1.17-.27-1.75-.43A88.92,88.92,0,0,0,592.56,302l-22.06-.18-1.09,7.3h36.87s12,.39,21.7,3.61c0,0,9.66,3.22,29,1.07l21.82-2.66Z" fill="#282728" data-secondary="true"/>
<path d="M683.8,255.21c-20.39,2.6-56.89,14.58-56.89,14.58-8.59-6.44-35.49-12.47-35.49-12.47.72-.84,1.14-1.27,1.14-1.27l2.37-3.43s32,2.14,32.37,1.07,22.15-14,22.15-14l30.76,13A10.63,10.63,0,0,1,683.8,255.21Z" opacity="0.2"/>
<path d="M620.1,254.32a12.38,12.38,0,0,1-1.24.26c-7.26,1.28-14.75-1.87-20.74-8a43,43,0,0,1-10.73-19.86c-4.59-18.58,2.63-36.33,16.12-39.66s28.13,9,32.72,27.59S633.6,251,620.1,254.32Z" fill="#f9b499"/>
<ellipse cx="639.26" cy="215.05" rx="1.61" ry="3.22" fill="none" stroke="red" stroke-miterlimit="10" stroke-width="0.75"/>
<path d="M651.6,210.75s17.18,9.45-10.73,18.14a15.44,15.44,0,0,1-.54-3.65,15.8,15.8,0,0,1,.54-4.54l-8.59-4.36s-4.67.85-7.17-4.52c0,0-8.93,0-7.86-6.44,0,0-6.44,4.3-8.59-1.07,0,0-9.69,6.88-19.33-4.62,0,0-4.28,7.84-3,21,0,0-6.34,5.08-9.93.38a6.63,6.63,0,0,1-1.28-3.77,5.58,5.58,0,0,1,3.22-5.49s-7.77-2.89-7.56-9.28a10.2,10.2,0,0,1,1.41-4.67s1.61-4,7.63-2.31h0a19.17,19.17,0,0,1,3.1,1.24s-8.21-17.26,3.4-28.49c0,0,19.14-19.82,26.66,4.87,0,0,6.55-10.14,17-7.62h0a15.76,15.76,0,0,1,2.25.72s7.51,2,6.44,14.1c0,0,9.66-8.28,18.25,1.38,0,0,6.44,7.89,0,16.28C657,198.05,660.19,205.38,651.6,210.75Z" fill="#282728" data-secondary="true"/>
<path d="M590.41,197.6s-3.22-10.46,6.44-16.91c0,0,6.93-4.51,16.49,1.46a23.89,23.89,0,0,1,2.73,2.07,16.44,16.44,0,0,0,10.59,4.11s11-.47,12.6,12c0,0-12.35-10.72-21.47-5.54,0,0-4.83-15.22-17.72-10.93C600.07,183.91,592.56,186.06,590.41,197.6Z" opacity="0.2"/>
<path d="M579.79,195.56c-5.23.93-9,7-9,7a10.2,10.2,0,0,1,1.41-4.67S573.77,193.84,579.79,195.56Z" opacity="0.2"/>
<path d="M651.6,210.75s17.18,9.45-10.73,18.14a7.4,7.4,0,0,1-.54-3.65,7.26,7.26,0,0,1,.54-2.07l4.29-6.83h0c3.22-1.93,3.22-6.66,3.22-6.66a10.45,10.45,0,0,0,4.63-3.63,8.74,8.74,0,0,0,1.09-8.24c-2.23-5.68-8.94-4.09-8.94-4.09,2.15-16-7.52-10.85-7.52-10.85-1-14.52-7.22-17.15-7.61-17.3a15.76,15.76,0,0,1,2.25.72s7.51,2,6.44,14.1c0,0,9.66-8.28,18.25,1.38,0,0,6.44,7.89,0,16.28C657,198.05,660.19,205.38,651.6,210.75Z" opacity="0.2"/>
<path d="M659.11,241.88l-26.83,22.77c-4.3,3.45-6.44.85-6.44.85l-1.19-1.87-5.79-9c20.4-4.65,18.67-31.41,18.67-31.41h3.34a17.63,17.63,0,0,0,2.14,11.2c3.91-1.57,9-1.42,11.4-1.23a14.8,14.8,0,0,1,2.89.49C665.93,236,659.11,241.88,659.11,241.88Z" fill="#f9b499"/>
<path d="M659.11,241.88l-26.83,22.77c-4.3,3.45-6.44.85-6.44.85l-1.19-1.87c11.69-5.09,23-18.83,23-18.83,4.06-6-2.64-9.53-4.26-10.28a.16.16,0,0,1,0-.29c3.86-1.42,8.68-1.27,11-1.09a14.8,14.8,0,0,1,2.89.49C665.93,236,659.11,241.88,659.11,241.88Z" fill="#f7a48b"/>
<path d="M618.86,254.58l3.07,4.81s18.66-10.53,15-26.36C637,233,635.5,251.18,618.86,254.58Z" fill="#f7a48b"/>
<path d="M599,253.69a55.57,55.57,0,0,1,18.79,6.51" fill="none" stroke="red" stroke-miterlimit="10" stroke-width="0.75"/>
<path d="M710.77,332.4c0,8.24-5.5,8.24-5.5,8.24l-15,5.37c-6.68,2.23-9.44,1.89-10.5,1.46a1.62,1.62,0,0,0-1.36.1c.36-.24,1.68-1.46-.08-5l-5.63-8.42a1.13,1.13,0,0,1,.39-1.6,1.07,1.07,0,0,1,.55-.14,1.12,1.12,0,0,1,.91.46l7.14,9.93s1.07,4.29,15-2.15C696.69,340.64,708.75,336,710.77,332.4Z" opacity="0.2"/>
<path d="M674.48,349.38h0c-2.89.58-2.48-2.31-2.48-2.31a12.77,12.77,0,0,0,.2-1.54,9.91,9.91,0,0,0-5.8-9.37,26.59,26.59,0,0,0-4.77-1.68s8.22-3.51,12.51,7.22C674.14,341.71,675.46,346.35,674.48,349.38Z" opacity="0.2"/>
<path d="M677.36,323.46s-14,5.89-18.8,11l-8.33,1.91H608.39L570,335.27a98.24,98.24,0,0,1,12.58-65.48c.3,0,8.89,2.15,17.47,32.21,0,0,8.53,31.81,22,30.4h23.5s21.08.73,21.08-13.23V297.7s.65-8.42,4.09-3.13L681.66,317S683.8,321.32,677.36,323.46Z" opacity="0.2"/>
<path d="M680.58,258c-8.42,6.71-12.77,17.28-12.88,27.91-.1-1.33-.27-2.68-.25-4,0-9.45,4.89-19.05,13.13-23.89Z" opacity="0.2"/>
<path d="M640.87,324h0a.54.54,0,0,1-.52-.55l1.07-32.21c0-21.29,5.35-36.51,5.4-36.66a.54.54,0,0,1,.69-.32.53.53,0,0,1,.32.68c0,.15-5.33,15.2-5.33,36.32l-1.08,32.22A.54.54,0,0,1,640.87,324Z" fill="#282728" data-secondary="true"/>
<path d="M614,327.22h0a.54.54,0,0,1-.52-.55l1.08-31.13a208.17,208.17,0,0,1,2.69-33.9.53.53,0,0,1,.62-.43.54.54,0,0,1,.43.63,208.45,208.45,0,0,0-2.67,33.71l-1.07,31.15A.55.55,0,0,1,614,327.22Z" fill="#282728" data-secondary="true"/>
<g opacity="0.2">
<path d="M640.87,324h0a.54.54,0,0,1-.52-.55l1.07-32.21c0-21.29,5.35-36.51,5.4-36.66a.54.54,0,0,1,.69-.32.53.53,0,0,1,.32.68c0,.15-5.33,15.2-5.33,36.32l-1.08,32.22A.54.54,0,0,1,640.87,324Z"/>
<path d="M614,327.22h0a.54.54,0,0,1-.52-.55l1.08-31.13a208.17,208.17,0,0,1,2.69-33.9.53.53,0,0,1,.62-.43.54.54,0,0,1,.43.63,208.45,208.45,0,0,0-2.67,33.71l-1.07,31.15A.55.55,0,0,1,614,327.22Z"/>
</g>
<path d="M706.34,371.77c0,.17,0,8.76-2.14,25.76l-.34,2.43a58.67,58.67,0,0,0-.52,7.27c-.06,3.84-2.56,11.15-21.3,8.21.49-.83.94-1.65,1.36-2.49l.35-.68c1.57-3,3.69-7.58,4.35-11.52,0,0-59-5.36-78.37-12.88,0,0-28.3-11.81-38.84-8.05l-8.39,7s-7.51-2.15,3.22-15Z" fill="#787878" data-primary="true"/>
<path d="M688.1,400.75s13.8.92,13.29,7a4,4,0,0,1-1.61,2.85c-1.84,1.41-6.25,3.29-16.15,1.89A63,63,0,0,0,688.1,400.75Z" opacity="0.2"/>
<path d="M564.14,385.44s-6.33-4,6.75-5.62Z" opacity="0.2"/>
<path d="M688.1,400.75a53.84,53.84,0,0,1-4.35,11.52l-.35.68c-.23.43-.44.81-.63,1.15s-.47.89-.73,1.34c-13.3,23.42-41.56,44.44-53.06,52.37a12.19,12.19,0,0,0-5.27,9.22c-2.38,36.84-19.34,68.64-19.34,68.64-3.61,5.17-9,7.66-15,8.46-19.1,2.58-45.12-11.68-45.12-11.68l14-39.72c11.81-44,23.61-56.89,23.61-56.89l26.54-55.12,1.37-2.85C629.06,395.39,688.1,400.75,688.1,400.75Z" fill="#282728" data-secondary="true"/>
<path d="M688.1,400.75a53.84,53.84,0,0,1-4.35,11.52l-.35.68c-.23.43-.44.81-.63,1.15s-.47.89-.73,1.34c-13.3,23.42-41.56,44.44-53.06,52.37a12.19,12.19,0,0,0-5.27,9.22c-2.38,36.84-19.34,68.64-19.34,68.64-3.61,5.17-9,7.66-15,8.46,20-25.72,31.27-83,31.27-83-.89-13.33,38.47-47.86,38.47-47.86,15-15-4.29-19.33-4.29-19.33-17.27-2.72-39.92-10.84-46.46-13.25l1.37-2.85C629.06,395.39,688.1,400.75,688.1,400.75Z" opacity="0.2"/>
<path d="M589.37,430.17l-33.31,13.52,13,26s-5.54,16.19-11.4,35.76c0,0-13.42,9.12-27.37,8.05,0,0-11.81-30.06-18.25-61.19,0,0-8.59-18.25,6.44-30.06L562,387.05l8.93-7.23s6.64-4.83,38.84,8.05Z" fill="#282728" data-secondary="true"/>
<path d="M547.62,532.88,526.35,549.3a27.56,27.56,0,0,1-6.81,3.93c-3.21,1.25-7.85,4-6.84,9.17a8.45,8.45,0,0,0,5.07,6.09c2.55,1.06,6.59,1.78,12.53.37l11.81-9.23,7.47-14.54-5.33-2.64Z" fill="#787878" data-primary="true"/>
<path d="M547.62,532.88,526.35,549.3a27.56,27.56,0,0,1-6.81,3.93c-3.21,1.25-7.85,4-6.84,9.17a8.45,8.45,0,0,0,5.07,6.09c2.55,1.06,6.59,1.78,12.53.37l11.81-9.23,7.47-14.54-5.33-2.64Z" opacity="0.2"/>
<path d="M589.37,430.17l-33.31,13.52,13,26a370.36,370.36,0,0,1-11.89,36s-12.93,8.88-26.88,7.81c39.72-3.22,20.78-67.22,20.78-67.22-4.38-9.78,2.19-12.65,6.18-13.48a53.87,53.87,0,0,0,7.5-2.12l10.06-3.7a29.52,29.52,0,0,0,16.91-15.53,27.2,27.2,0,0,0,2.23-8.31,14.25,14.25,0,0,0-10.3-15c-8.42-2.48-16.86-1.84-21.7-1.1l8.93-7.23s6.64-4.83,38.84,8.05Z" opacity="0.2"/>
<path d="M512.56,561c8,4.49,17.84,3.92,26,.25,1.19-.51,2.33-1.14,3.54-1.66-1.08.73-2.18,1.45-3.3,2.13-8,4.53-18.78,5.1-26.25-.72Z" opacity="0.2"/>
<path d="M524.65,550.52c4.14,1.84,12.18,6.84,12.1,11.9-.72-2.81-3.3-4.59-5.35-6.46s-4.5-3.57-6.75-5.44Z" opacity="0.2"/>
<path d="M530.3,546.25c3.1.44,5.52,3.24,6.45,6.1-2.23-2.05-4-4.27-6.45-6.1Z" opacity="0.2"/>
<path d="M534.32,543.14a9.55,9.55,0,0,1,6.62,6.16c-2.17-2.19-4.35-4-6.62-6.16Z" opacity="0.2"/>
<path d="M538.89,539.61a11.81,11.81,0,0,1,5.36,6.61,29.31,29.31,0,0,1-5.36-6.61Z" opacity="0.2"/>
<rect x="421.88" y="364.26" width="328.48" height="7.51" fill="#b3b3b3"/>
<polygon points="750.36 364.26 750.36 371.77 786.86 342.79 786.88 336.37 750.36 364.26" fill="#999"/>
<path d="M507.76,344.93h98.07l-7.33-63.74a5.61,5.61,0,0,0-5.57-5h-90a3,3,0,0,0-2.93,3.31Z" fill="#787878" data-primary="true"/>
<path d="M605.83,344.93H507.76L500,279.54a3,3,0,0,1,2.95-3.31h90a5.61,5.61,0,0,1,5.56,5Z" fill="#fff" opacity="0.3"/>
<polygon points="583.53 276.23 507.76 341.71 506.12 329.04 567.52 276.23 583.53 276.23" fill="#fff" opacity="0.3"/>
<path d="M517.07,344.93l79.55-67.31a6,6,0,0,1,1.88,3.57l.38,3.34-71.09,60.4Z" fill="#fff" opacity="0.3"/>
<rect x="507.76" y="344.93" width="94.46" height="6.44" fill="#787878" data-primary="true"/>
<rect x="602.22" y="344.93" width="29.49" height="6.44" fill="#787878" data-primary="true"/>
<rect x="602.22" y="344.93" width="29.49" height="6.44" opacity="0.2"/>
<polygon points="419.73 353.52 466.38 353.52 499.97 333.94 459.85 333.94 419.73 353.52" fill="#fff"/>
<rect x="419.73" y="353.52" width="46.65" height="4.65" fill="#e6e6e6"/>
<polygon points="499.97 333.94 499.97 339.8 466.38 358.17 466.38 353.52 499.97 333.94" fill="#ccc"/>
<polygon points="499.97 333.94 499.97 339.8 466.38 358.17 466.38 353.52 499.97 333.94" opacity="0.1"/>
<path d="M658.56,334.46s-13.47,1.87-20.95,12.08c0,0-10.05,9.15-.18,7.53,0,0,.47,4.68,8.39,1.53,0,0,1.37,3.31,10-1.53,0,0,8.64-4.84,16.16-7C672,347.08,675.17,334.71,658.56,334.46Z" fill="#f9b499"/>
<path d="M646,343.86a40.12,40.12,0,0,1-8.55,10.21A40.49,40.49,0,0,1,646,343.86Z" fill="#f7a48b"/>
<path d="M645.82,355.6a24.61,24.61,0,0,1,6.85-7.82,24.71,24.71,0,0,1-6.85,7.82Z" fill="#f7a48b"/>
<ellipse cx="638.72" cy="215.58" rx="6.44" ry="8.05" fill="#f9b499"/>
<path d="M640.87,228.89s12.2-4.93,24.24-3.72a26.56,26.56,0,0,1,17.33,9.17c4.85,5.6,11.54,15.1,4.38,18.3a8.59,8.59,0,0,1-7.29-.33c-5-2.49-17.91-6.91-47.79,12.65l27.37-23.08s6.49-6.48-3.5-8.53a13.52,13.52,0,0,0-2.62-.25,46.27,46.27,0,0,0-10,1.27S640.87,230.93,640.87,228.89Z" fill="#787878" data-primary="true"/>
<path d="M640.87,228.89s12.2-4.93,24.24-3.72a26.56,26.56,0,0,1,17.33,9.17c4.85,5.6,11.54,15.1,4.38,18.3a8.59,8.59,0,0,1-7.29-.33c-5-2.49-17.91-6.91-47.79,12.65l27.37-23.08s6.49-6.48-3.5-8.53a13.52,13.52,0,0,0-2.62-.25,46.27,46.27,0,0,0-10,1.27S640.87,230.93,640.87,228.89Z" fill="#fff" opacity="0.3"/>
<path d="M674.14,234.37c-5.73,6.95-13.48,12.06-21.25,16.49-1.15.58-2.28,1.2-3.44,1.76,8.36-5.92,17-11.41,24.69-18.25Z" fill="#fff" opacity="0.3"/>
<path d="M683.8,238.66C679,244,671.85,246.84,664.89,248c6.47-2.57,13.26-5.24,18.91-9.35Z" fill="#fff" opacity="0.3"/>
<path d="M625.84,265.5c-4.44-2.67-21.36-6.8-27.08-8.15a23.81,23.81,0,0,0-3.37-.5c-4.81-.45-3.9-3.16-3.9-3.16,0-4.29,6.63-7.09,6.63-7.09,6,6.11,13.48,9.26,20.74,8Z" fill="#787878" data-primary="true"/>
<path d="M625.84,265.5c-4.44-2.67-21.36-6.8-27.08-8.15a23.81,23.81,0,0,0-3.37-.5c-4.81-.45-3.9-3.16-3.9-3.16,0-4.29,6.63-7.09,6.63-7.09,6,6.11,13.48,9.26,20.74,8Z" fill="#fff" opacity="0.3"/>
<circle cx="551.23" cy="311.12" r="8.05" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
public/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

10
public/assets/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
public/astropaper-og.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

13
public/favicon.svg Normal file
View File

@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 36 36">
<path fill="#000" d="M22.25 4h-8.5a1 1 0 0 0-.96.73l-5.54 19.4a.5.5 0 0 0 .62.62l5.05-1.44a2 2 0 0 0 1.38-1.4l3.22-11.66a.5.5 0 0 1 .96 0l3.22 11.67a2 2 0 0 0 1.38 1.39l5.05 1.44a.5.5 0 0 0 .62-.62l-5.54-19.4a1 1 0 0 0-.96-.73Z"/>
<path fill="url(#gradient)" d="M18 28a7.63 7.63 0 0 1-5-2c-1.4 2.1-.35 4.35.6 5.55.14.17.41.07.47-.15.44-1.8 2.93-1.22 2.93.6 0 2.28.87 3.4 1.72 3.81.34.16.59-.2.49-.56-.31-1.05-.29-2.46 1.29-3.25 3-1.5 3.17-4.83 2.5-6-.67.67-2.6 2-5 2Z"/>
<defs>
<linearGradient id="gradient" x1="16" x2="16" y1="32" y2="24" gradientUnits="userSpaceOnUse">
<stop stop-color="#000"/>
<stop offset="1" stop-color="#000" stop-opacity="0"/>
</linearGradient>
</defs>
<style>
@media (prefers-color-scheme:dark){:root{filter:invert(100%)}}
</style>
</svg>

After

Width:  |  Height:  |  Size: 873 B

5
public/robots.txt Normal file
View File

@ -0,0 +1,5 @@
User-agent: Googlebot
Disallow: /nogooglebot/
User-agent: *
Allow: /

52
public/toggle-theme.js Normal file
View File

@ -0,0 +1,52 @@
const primaryColorScheme = ""; // "light" | "dark"
// Get theme data from local storage
const currentTheme = localStorage.getItem("theme");
function getPreferTheme() {
// return theme value in local storage if it is set
if (currentTheme) return currentTheme;
// return primary color scheme if it is set
if (primaryColorScheme) return primaryColorScheme;
// return user device's prefer color scheme
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
let themeValue = getPreferTheme();
function setPreference() {
localStorage.setItem("theme", themeValue);
reflectPreference();
}
function reflectPreference() {
document.firstElementChild.setAttribute("data-theme", themeValue);
document.querySelector("#theme-btn")?.setAttribute("aria-label", themeValue);
}
// set early so no page flashes / CSS is made aware
reflectPreference();
window.onload = () => {
// set on load so screen readers can get the latest value on the button
reflectPreference();
// now this script can find and listen for clicks on the control
document.querySelector("#theme-btn")?.addEventListener("click", () => {
themeValue = themeValue === "light" ? "dark" : "light";
setPreference();
});
};
// sync with system changes
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", ({ matches: isDark }) => {
themeValue = isDark ? "dark" : "light";
setPreference();
});

213
src/assets/socialIcons.ts Normal file
View File

@ -0,0 +1,213 @@
import type { SocialIcons } from "../types";
const socialIcons: SocialIcons = {
Github: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path
d="M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5"
></path>
</svg>`,
Facebook: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path
d="M7 10v4h3v7h4v-7h3l1 -4h-4v-2a1 1 0 0 1 1 -1h3v-4h-3a5 5 0 0 0 -5 5v2h-3"
></path>
</svg>`,
Instagram: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<rect x="4" y="4" width="16" height="16" rx="4"></rect>
<circle cx="12" cy="12" r="3"></circle>
<line x1="16.5" y1="7.5" x2="16.5" y2="7.501"></line>
</svg>`,
LinkedIn: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<rect x="4" y="4" width="16" height="16" rx="2"></rect>
<line x1="8" y1="11" x2="8" y2="16"></line>
<line x1="8" y1="8" x2="8" y2="8.01"></line>
<line x1="12" y1="16" x2="12" y2="11"></line>
<path d="M16 16v-3a2 2 0 0 0 -4 0"></path>
</svg>`,
Mail: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<rect x="3" y="5" width="18" height="14" rx="2"></rect>
<polyline points="3 7 12 13 21 7"></polyline>
</svg>`,
Twitter: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M22 4.01c-1 .49 -1.98 .689 -3 .99c-1.121 -1.265 -2.783 -1.335 -4.38 -.737s-2.643 2.06 -2.62 3.737v1c-3.245 .083 -6.135 -1.395 -8 -4c0 0 -4.182 7.433 4 11c-1.872 1.247 -3.739 2.088 -6 2c3.308 1.803 6.913 2.423 10.034 1.517c3.58 -1.04 6.522 -3.723 7.651 -7.742a13.84 13.84 0 0 0 .497 -3.753c-.002 -.249 1.51 -2.772 1.818 -4.013z"></path>
</svg>`,
Twitch: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 2H3v16h5v4l4-4h5l4-4V2zm-10 9V7m5 4V7"></path>
</svg>`,
YouTube: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22.54 6.42a2.78 2.78 0 0 0-1.94-2C18.88 4 12 4 12 4s-6.88 0-8.6.46a2.78 2.78 0 0 0-1.94 2A29 29 0 0 0 1 11.75a29 29 0 0 0 .46 5.33A2.78 2.78 0 0 0 3.4 19c1.72.46 8.6.46 8.6.46s6.88 0 8.6-.46a2.78 2.78 0 0 0 1.94-2 29 29 0 0 0 .46-5.25 29 29 0 0 0-.46-5.33z"></path>
<polygon points="9.75 15.02 15.5 11.75 9.75 8.48 9.75 15.02"></polygon>
</svg>`,
WhatsApp: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 21l1.65 -3.8a9 9 0 1 1 3.4 2.9l-5.05 .9"></path>
<path d="M9 10a0.5 .5 0 0 0 1 0v-1a0.5 .5 0 0 0 -1 0v1a5 5 0 0 0 5 5h1a0.5 .5 0 0 0 0 -1h-1a0.5 .5 0 0 0 0 1"></path>
</svg>`,
Snapchat: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M16.882 7.842a4.882 4.882 0 0 0 -9.764 0c0 4.273 -.213 6.409 -4.118 8.118c2 .882 2 .882 3 3c3 0 4 2 6 2s3 -2 6 -2c1 -2.118 1 -2.118 3 -3c-3.906 -1.709 -4.118 -3.845 -4.118 -8.118zm-13.882 8.119c4 -2.118 4 -4.118 1 -7.118m17 7.118c-4 -2.118 -4 -4.118 -1 -7.118"></path>
</svg>`,
Pinterest: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<line x1="8" y1="20" x2="12" y2="11"></line>
<path d="M10.7 14c.437 1.263 1.43 2 2.55 2c2.071 0 3.75 -1.554 3.75 -4a5 5 0 1 0 -9.7 1.7"></path>
<circle cx="12" cy="12" r="9"></circle>
</svg>`,
TikTok: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M9 12a4 4 0 1 0 4 4v-12a5 5 0 0 0 5 5"></path>
</svg>`,
CodePen: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 15l9 6l9 -6l-9 -6l-9 6"></path>
<path d="M3 9l9 6l9 -6l-9 -6l-9 6"></path>
<line x1="3" y1="9" x2="3" y2="15"></line>
<line x1="21" y1="9" x2="21" y2="15"></line>
<line x1="12" y1="3" x2="12" y2="9"></line>
<line x1="12" y1="15" x2="12" y2="21"></line>
</svg>`,
Discord: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<circle cx="9" cy="12" r="1"></circle>
<circle cx="15" cy="12" r="1"></circle>
<path d="M7.5 7.5c3.5 -1 5.5 -1 9 0"></path>
<path d="M7 16.5c3.5 1 6.5 1 10 0"></path>
<path d="M15.5 17c0 1 1.5 3 2 3c1.5 0 2.833 -1.667 3.5 -3c.667 -1.667 .5 -5.833 -1.5 -11.5c-1.457 -1.015 -3 -1.34 -4.5 -1.5l-1 2.5"></path>
<path d="M8.5 17c0 1 -1.356 3 -1.832 3c-1.429 0 -2.698 -1.667 -3.333 -3c-.635 -1.667 -.476 -5.833 1.428 -11.5c1.388 -1.015 2.782 -1.34 4.237 -1.5l1 2.5"></path>
</svg>`,
GitLab: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M21 14l-9 7l-9 -7l3 -11l3 7h6l3 -7z"></path>
</svg>`,
Reddit: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 8c2.648 0 5.028 .826 6.675 2.14a2.5 2.5 0 0 1 2.326 4.36c0 3.59 -4.03 6.5 -9 6.5c-4.875 0 -8.845 -2.8 -9 -6.294l-1 -.206a2.5 2.5 0 0 1 2.326 -4.36c1.646 -1.313 4.026 -2.14 6.674 -2.14z"></path>
<path d="M12 8l1 -5l6 1"></path>
<circle cx="19" cy="4" r="1"></circle>
<circle cx="9" cy="13" r=".5" fill="currentColor"></circle>
<circle cx="15" cy="13" r=".5" fill="currentColor"></circle>
<path d="M10 17c.667 .333 1.333 .5 2 .5s1.333 -.167 2 -.5"></path>
</svg>`,
Skype: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 3a9 9 0 0 1 8.603 11.65a4.5 4.5 0 0 1 -5.953 5.953a9 9 0 0 1 -11.253 -11.253a4.5 4.5 0 0 1 5.953 -5.954a8.987 8.987 0 0 1 2.65 -.396z"></path>
<path d="M8 14.5c.5 2 2.358 2.5 4 2.5c2.905 0 4 -1.187 4 -2.5c0 -1.503 -1.927 -2.5 -4 -2.5s-4 -.997 -4 -2.5c0 -1.313 1.095 -2.5 4 -2.5c1.642 0 3.5 .5 4 2.5"></path>
</svg>`,
Steam: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M16.5 5a4.5 4.5 0 1 1 -.653 8.953l-4.347 3.009l0 .038a3 3 0 0 1 -2.824 2.995l-.176 .005a3 3 0 0 1 -2.94 -2.402l-2.56 -1.098v-3.5l3.51 1.755a2.989 2.989 0 0 1 2.834 -.635l2.727 -3.818a4.5 4.5 0 0 1 4.429 -5.302z"></path>
<circle fill="currentColor" cx="16.5" cy="9.5" r="1"></circle>
</svg>`,
Telegram: `<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-tabler"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M15 10l-4 4l6 6l4 -16l-18 7l4 2l2 6l3 -4"></path>
</svg>`,
Mastodon: `<svg class="icon-tabler" viewBox="-10 -5 1034 1034" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
<path fill="currentColor"
d="M499 112q-93 1 -166 11q-81 11 -128 33l-14 8q-16 10 -32 25q-22 21 -38 47q-21 33 -32 73q-14 47 -14 103v37q0 77 1 119q3 113 18 188q19 95 62 154q50 67 134 89q109 29 210 24q46 -3 88 -12q30 -7 55 -17l19 -8l-4 -75l-22 6q-28 6 -57 10q-41 6 -78 4q-53 -1 -80 -7
q-43 -8 -67 -30q-29 -25 -35 -72q-2 -14 -2 -29l25 6q31 6 65 10q48 7 93 9q42 2 92 -2q32 -2 88 -9t107 -30q49 -23 81.5 -54.5t38.5 -63.5q9 -45 13 -109q4 -46 5 -97v-41q0 -56 -14 -103q-11 -40 -32 -73q-16 -26 -38 -47q-15 -15 -32 -25q-12 -8 -14 -8
q-46 -22 -127 -33q-74 -10 -166 -11h-3zM367 267q73 0 109 56l24 39l24 -39q36 -56 109 -56q63 0 101 43t38 117v239h-95v-232q0 -74 -61 -74q-69 0 -69 88v127h-94v-127q0 -88 -69 -88q-61 0 -61 74v232h-95v-239q0 -74 38 -117t101 -43z" />
</svg>`,
};
export default socialIcons;

View File

@ -0,0 +1,60 @@
---
// Remove current url path and remove trailing slash if exists
const currentUrlPath = Astro.url.pathname.replace(/\/+$/, "");
// Get url array from path
// eg: /tags/tailwindcss => ['tags', 'tailwindcss']
const breadcrumbList = currentUrlPath.split("/").slice(1);
// if breadcrumb is Home > Posts > 1 <etc>
// replace Posts with Posts (page number)
breadcrumbList[0] === "posts" &&
breadcrumbList.splice(0, 2, `Posts (page ${breadcrumbList[1] || 1})`);
---
<nav class="breadcrumb" aria-label="breadcrumb">
<ul>
<li>
<a href="/">Home</a>
<span aria-hidden="true">&#62;</span>
</li>
{
breadcrumbList.map((breadcrumb, index) =>
index + 1 === breadcrumbList.length ? (
<li>
<span
class={`${index > 0 ? "lowercase" : "capitalize"}`}
aria-current="page"
>
{/* make the last part lowercase in Home > Tags > some-tag */}
{breadcrumb}
</span>
</li>
) : (
<li>
<a href={`/${breadcrumb}`}>{breadcrumb}</a>
<span aria-hidden="true">&#62;</span>
</li>
)
)
}
</ul>
</nav>
<style>
.breadcrumb {
@apply mx-auto mb-1 mt-8 w-full max-w-3xl px-4;
}
.breadcrumb ul li {
@apply inline;
}
.breadcrumb ul li a {
@apply capitalize opacity-70;
}
.breadcrumb ul li span {
@apply opacity-70;
}
.breadcrumb ul li:not(:last-child) a {
@apply hover:opacity-100;
}
</style>

32
src/components/Card.tsx Normal file
View File

@ -0,0 +1,32 @@
import Datetime from "./Datetime";
import type { BlogFrontmatter } from "@content/_schemas";
export interface Props {
href?: string;
frontmatter: BlogFrontmatter;
secHeading?: boolean;
}
export default function Card({ href, frontmatter, secHeading = true }: Props) {
const { title, pubDatetime, description } = frontmatter;
return (
<li className="my-6">
<a
href={href}
className="inline-block text-lg font-medium text-skin-accent decoration-dashed underline-offset-4 focus-visible:no-underline focus-visible:underline-offset-0"
>
{secHeading ? (
<h2 className="text-lg font-medium decoration-dashed hover:underline">
{title}
</h2>
) : (
<h3 className="text-lg font-medium decoration-dashed hover:underline">
{title}
</h3>
)}
</a>
<Datetime datetime={pubDatetime} />
<p>{description}</p>
</li>
);
}

View File

@ -0,0 +1,52 @@
import { LOCALE } from "@config";
export interface Props {
datetime: string | Date;
size?: "sm" | "lg";
className?: string;
}
export default function Datetime({ datetime, size = "sm", className }: Props) {
return (
<div className={`flex items-center space-x-2 opacity-80 ${className}`}>
<svg
xmlns="http://www.w3.org/2000/svg"
className={`${
size === "sm" ? "scale-90" : "scale-100"
} inline-block h-6 w-6 fill-skin-base`}
aria-hidden="true"
>
<path d="M7 11h2v2H7zm0 4h2v2H7zm4-4h2v2h-2zm0 4h2v2h-2zm4-4h2v2h-2zm0 4h2v2h-2z"></path>
<path d="M5 22h14c1.103 0 2-.897 2-2V6c0-1.103-.897-2-2-2h-2V2h-2v2H9V2H7v2H5c-1.103 0-2 .897-2 2v14c0 1.103.897 2 2 2zM19 8l.001 12H5V8h14z"></path>
</svg>
<span className="sr-only">Posted on:</span>
<span className={`italic ${size === "sm" ? "text-sm" : "text-base"}`}>
<FormattedDatetime datetime={datetime} />
</span>
</div>
);
}
const FormattedDatetime = ({ datetime }: { datetime: string | Date }) => {
const myDatetime = new Date(datetime);
const date = myDatetime.toLocaleDateString(LOCALE, {
year: "numeric",
month: "long",
day: "numeric",
});
const time = myDatetime.toLocaleTimeString(LOCALE, {
hour: "2-digit",
minute: "2-digit",
});
return (
<>
{date}
<span aria-hidden="true"> | </span>
<span className="sr-only">&nbsp;at&nbsp;</span>
{time}
</>
);
};

View File

@ -0,0 +1,45 @@
---
import Hr from "./Hr.astro";
import Socials from "./Socials.astro";
const currentYear = new Date().getFullYear();
export interface Props {
noMarginTop?: boolean;
}
const { noMarginTop = false } = Astro.props;
---
<footer class={`${noMarginTop ? "" : "mt-auto"}`}>
<Hr noPadding />
<div class="footer-wrapper">
<Socials centered />
<div class="copyright-wrapper">
<span>Copyright &#169; {currentYear}</span>
<span class="separator">&nbsp;|&nbsp;</span>
<span>All rights reserved.</span>
</div>
</div>
</footer>
<style>
footer {
@apply w-full;
}
.footer-wrapper {
@apply flex flex-col items-center justify-between py-6 sm:flex-row-reverse sm:py-4;
}
.link-button {
@apply my-1 p-2 hover:rotate-6;
}
.link-button svg {
@apply scale-125;
}
.copyright-wrapper {
@apply my-2 flex flex-col items-center whitespace-nowrap sm:flex-row;
}
.separator {
@apply hidden sm:inline;
}
</style>

210
src/components/Header.astro Normal file
View File

@ -0,0 +1,210 @@
---
import { LOGO_IMAGE, SITE } from "@config";
import Hr from "./Hr.astro";
import LinkButton from "./LinkButton.astro";
import logoPNG from "/assets/logo.png";
import logoSVG from "/assets/logo.svg";
export interface Props {
activeNav?: "posts" | "tags" | "experience" | "search";
}
const { activeNav } = Astro.props;
---
<header>
<a id="skip-to-content" href="#main-content">Skip to content</a>
<div class="nav-container">
<div class="top-nav-wrap">
<a href="/" class="logo">
{
LOGO_IMAGE.enable ? (
<img
src={LOGO_IMAGE.svg ? logoSVG : logoPNG}
alt="AstroPaper Logo"
width={LOGO_IMAGE.width}
height={LOGO_IMAGE.height}
/>
) : (
SITE.title
)
}
</a>
<nav id="nav-menu">
<button
class="hamburger-menu focus-outline"
aria-label="Open Menu"
aria-expanded="false"
aria-controls="menu-items"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="menu-icon"
>
<line x1="7" y1="12" x2="21" y2="12" class="line"></line>
<line x1="3" y1="6" x2="21" y2="6" class="line"></line>
<line x1="12" y1="18" x2="21" y2="18" class="line"></line>
<line x1="18" y1="6" x2="6" y2="18" class="close"></line>
<line x1="6" y1="6" x2="18" y2="18" class="close"></line>
</svg>
</button>
<ul id="menu-items" class="display-none sm:flex">
<li>
<a href="/posts" class={activeNav === "posts" ? "active" : ""}>
Posts
</a>
</li>
<li>
<a href="/tags" class={activeNav === "tags" ? "active" : ""}>
Tags
</a>
</li>
<li>
<a href="/experience" class={activeNav === "experience" ? "active" : ""}>
Experience
</a>
</li>
<li>
<LinkButton
href="/search"
className={`focus-outline p-3 sm:p-1 ${
activeNav === "search" ? "active" : ""
}`}
ariaLabel="search"
title="Search"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="scale-125 sm:scale-100"
><path
d="M19.023 16.977a35.13 35.13 0 0 1-1.367-1.384c-.372-.378-.596-.653-.596-.653l-2.8-1.337A6.962 6.962 0 0 0 16 9c0-3.859-3.14-7-7-7S2 5.141 2 9s3.14 7 7 7c1.763 0 3.37-.66 4.603-1.739l1.337 2.8s.275.224.653.596c.387.363.896.854 1.384 1.367l1.358 1.392.604.646 2.121-2.121-.646-.604c-.379-.372-.885-.866-1.391-1.36zM9 14c-2.757 0-5-2.243-5-5s2.243-5 5-5 5 2.243 5 5-2.243 5-5 5z"
></path>
</svg>
</LinkButton>
</li>
<li>
{
SITE.lightAndDarkMode && (
<button
id="theme-btn"
class="focus-outline"
title="Toggles light & dark"
aria-label="auto"
aria-live="polite"
>
<svg xmlns="http://www.w3.org/2000/svg" id="moon-svg">
<path d="M20.742 13.045a8.088 8.088 0 0 1-2.077.271c-2.135 0-4.14-.83-5.646-2.336a8.025 8.025 0 0 1-2.064-7.723A1 1 0 0 0 9.73 2.034a10.014 10.014 0 0 0-4.489 2.582c-3.898 3.898-3.898 10.243 0 14.143a9.937 9.937 0 0 0 7.072 2.93 9.93 9.93 0 0 0 7.07-2.929 10.007 10.007 0 0 0 2.583-4.491 1.001 1.001 0 0 0-1.224-1.224zm-2.772 4.301a7.947 7.947 0 0 1-5.656 2.343 7.953 7.953 0 0 1-5.658-2.344c-3.118-3.119-3.118-8.195 0-11.314a7.923 7.923 0 0 1 2.06-1.483 10.027 10.027 0 0 0 2.89 7.848 9.972 9.972 0 0 0 7.848 2.891 8.036 8.036 0 0 1-1.484 2.059z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" id="sun-svg">
<path d="M6.993 12c0 2.761 2.246 5.007 5.007 5.007s5.007-2.246 5.007-5.007S14.761 6.993 12 6.993 6.993 9.239 6.993 12zM12 8.993c1.658 0 3.007 1.349 3.007 3.007S13.658 15.007 12 15.007 8.993 13.658 8.993 12 10.342 8.993 12 8.993zM10.998 19h2v3h-2zm0-17h2v3h-2zm-9 9h3v2h-3zm17 0h3v2h-3zM4.219 18.363l2.12-2.122 1.415 1.414-2.12 2.122zM16.24 6.344l2.122-2.122 1.414 1.414-2.122 2.122zM6.342 7.759 4.22 5.637l1.415-1.414 2.12 2.122zm13.434 10.605-1.414 1.414-2.122-2.122 1.414-1.414z" />
</svg>
</button>
)
}
</li>
</ul>
</nav>
</div>
</div>
<Hr />
</header>
<style>
#skip-to-content {
@apply absolute -top-full left-16 z-50 bg-skin-accent px-3 py-2 text-skin-inverted transition-all focus:top-4;
}
.nav-container {
@apply mx-auto flex max-w-3xl flex-col items-center justify-between sm:flex-row;
}
.top-nav-wrap {
@apply relative flex w-full items-start justify-between p-4 sm:items-center sm:py-8;
}
.logo {
@apply absolute py-1 text-xl font-semibold sm:static sm:text-2xl;
}
.hamburger-menu {
@apply self-end p-2 sm:hidden;
}
.hamburger-menu svg {
@apply h-6 w-6 scale-125 fill-skin-base;
}
nav {
@apply flex w-full flex-col items-center bg-skin-fill sm:ml-2 sm:flex-row sm:justify-end sm:space-x-4 sm:py-0;
}
nav ul {
@apply mt-4 grid w-44 grid-cols-2 grid-rows-4 gap-x-2 gap-y-2 sm:ml-0 sm:mt-0 sm:w-auto sm:gap-x-5 sm:gap-y-0;
}
nav ul li {
@apply col-span-2 flex items-center justify-center;
}
nav ul li a {
@apply w-full px-4 py-3 text-center font-medium hover:text-skin-accent sm:my-0 sm:px-2 sm:py-1;
}
nav ul li:nth-child(4) a {
@apply w-auto;
}
nav ul li:nth-child(4),
nav ul li:nth-child(5) {
@apply col-span-1;
}
nav a.active {
@apply underline decoration-wavy decoration-2 underline-offset-4;
}
nav a.active svg {
@apply fill-skin-accent;
}
nav button {
@apply p-1;
}
nav button svg {
@apply h-6 w-6 fill-skin-base hover:fill-skin-accent;
}
#theme-btn {
@apply p-3 sm:p-1;
}
#theme-btn svg {
@apply scale-125 hover:rotate-12 sm:scale-100;
}
.menu-icon line {
@apply transition-opacity duration-75 ease-in-out;
}
.menu-icon .close {
opacity: 0;
}
.menu-icon.is-active .line {
@apply opacity-0;
}
.menu-icon.is-active .close {
@apply opacity-100;
}
</style>
<script>
// Toggle menu
const menuBtn = document.querySelector(".hamburger-menu");
const menuIcon = document.querySelector(".menu-icon");
const menuItems = document.querySelector("#menu-items");
menuBtn?.addEventListener("click", () => {
const menuExpanded = menuBtn.getAttribute("aria-expanded") === "true";
menuIcon?.classList.toggle("is-active");
menuBtn.setAttribute("aria-expanded", menuExpanded ? "false" : "true");
menuBtn.setAttribute(
"aria-label",
menuExpanded ? "Open Menu" : "Close Menu"
);
menuItems?.classList.toggle("display-none");
});
</script>

12
src/components/Hr.astro Normal file
View File

@ -0,0 +1,12 @@
---
export interface Props {
noPadding?: boolean;
ariaHidden?: boolean;
}
const { noPadding = false, ariaHidden = true } = Astro.props;
---
<div class={`max-w-3xl mx-auto ${noPadding ? "px-0" : "px-4"}`}>
<hr class="border-skin-line" aria-hidden={ariaHidden} />
</div>

View File

@ -0,0 +1,28 @@
---
export interface Props {
href: string;
className?: string;
ariaLabel?: string;
title?: string;
disabled?: boolean;
}
const { href, className, ariaLabel, title, disabled = false } = Astro.props;
---
<a
href={disabled ? "#" : href}
tabindex={disabled ? "-1" : "0"}
class={`group inline-block ${className}`}
aria-label={ariaLabel}
title={title}
aria-disabled={disabled}
>
<slot />
</a>
<style>
a {
@apply hover:text-skin-accent;
}
</style>

122
src/components/Search.tsx Normal file
View File

@ -0,0 +1,122 @@
import Fuse from "fuse.js";
import { useEffect, useRef, useState, useMemo } from "react";
import Card from "@components/Card";
import slugify from "@utils/slugify";
import type { BlogFrontmatter } from "@content/_schemas";
export type SearchItem = {
title: string;
description: string;
data: BlogFrontmatter;
};
interface Props {
searchList: SearchItem[];
}
interface SearchResult {
item: SearchItem;
refIndex: number;
}
export default function SearchBar({ searchList }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputVal, setInputVal] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[] | null>(
null
);
const handleChange = (e: React.FormEvent<HTMLInputElement>) => {
setInputVal(e.currentTarget.value);
};
const fuse = useMemo(
() =>
new Fuse(searchList, {
keys: ["title", "description"],
includeMatches: true,
minMatchCharLength: 2,
threshold: 0.5,
}),
[searchList]
);
useEffect(() => {
// if URL has search query,
// insert that search query in input field
const searchUrl = new URLSearchParams(window.location.search);
const searchStr = searchUrl.get("q");
if (searchStr) setInputVal(searchStr);
// put focus cursor at the end of the string
setTimeout(function () {
inputRef.current!.selectionStart = inputRef.current!.selectionEnd =
searchStr?.length || 0;
}, 50);
}, []);
useEffect(() => {
// Add search result only if
// input value is more than one character
let inputResult = inputVal.length > 1 ? fuse.search(inputVal) : [];
setSearchResults(inputResult);
// Update search string in URL
if (inputVal.length > 0) {
const searchParams = new URLSearchParams(window.location.search);
searchParams.set("q", inputVal);
const newRelativePathQuery =
window.location.pathname + "?" + searchParams.toString();
history.replaceState(null, "", newRelativePathQuery);
} else {
history.replaceState(null, "", window.location.pathname);
}
}, [inputVal]);
return (
<>
<label className="relative block">
<span className="absolute inset-y-0 left-0 flex items-center pl-2 opacity-75">
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M19.023 16.977a35.13 35.13 0 0 1-1.367-1.384c-.372-.378-.596-.653-.596-.653l-2.8-1.337A6.962 6.962 0 0 0 16 9c0-3.859-3.14-7-7-7S2 5.141 2 9s3.14 7 7 7c1.763 0 3.37-.66 4.603-1.739l1.337 2.8s.275.224.653.596c.387.363.896.854 1.384 1.367l1.358 1.392.604.646 2.121-2.121-.646-.604c-.379-.372-.885-.866-1.391-1.36zM9 14c-2.757 0-5-2.243-5-5s2.243-5 5-5 5 2.243 5 5-2.243 5-5 5z"></path>
</svg>
</span>
<input
className="block w-full rounded border border-skin-fill
border-opacity-40 bg-skin-fill py-3 pl-10
pr-3 placeholder:italic placeholder:text-opacity-75
focus:border-skin-accent focus:outline-none"
placeholder="Search for anything..."
type="text"
name="search"
value={inputVal}
onChange={handleChange}
autoComplete="off"
autoFocus
ref={inputRef}
/>
</label>
{inputVal.length > 1 && (
<div className="mt-8">
Found {searchResults?.length}
{searchResults?.length && searchResults?.length === 1
? " result"
: " results"}{" "}
for '{inputVal}'
</div>
)}
<ul>
{searchResults &&
searchResults.map(({ item, refIndex }) => (
<Card
href={`/posts/${slugify(item.data)}`}
frontmatter={item.data}
key={`${refIndex}-${slugify(item.data)}`}
/>
))}
</ul>
</>
);
}

34
src/components/Socials.astro Executable file
View File

@ -0,0 +1,34 @@
---
import { SOCIALS } from "@config";
import LinkButton from "./LinkButton.astro";
import socialIcons from "@assets/socialIcons";
export interface Props {
centered?: boolean;
}
const { centered = false } = Astro.props;
---
<div class={`social-icons ${centered ? "flex" : ""}`}>
{
SOCIALS.filter(social => social.active).map(social => (
<LinkButton
href={social.href}
className="link-button"
title={social.linkTitle}
>
<Fragment set:html={socialIcons[social.name]} />
</LinkButton>
))
}
</div>
<style>
.social-icons {
@apply flex-wrap justify-center gap-1;
}
.link-button {
@apply p-2 hover:rotate-6 sm:p-1;
}
</style>

37
src/components/Tag.astro Normal file
View File

@ -0,0 +1,37 @@
---
export interface Props {
name: string;
size?: "sm" | "lg";
}
const { name, size = "sm" } = Astro.props;
---
<li
class={`inline-block ${
size === "sm" ? "my-1 underline-offset-4" : "my-3 mx-1 underline-offset-8"
}`}
>
<a
href={`/tags/${name.toLowerCase()}`}
class={`${size === "sm" ? "text-sm" : "text-lg"} pr-2 group`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
class={`${size === "sm" ? " scale-75" : "scale-110"}`}
><path
d="M16.018 3.815 15.232 8h-4.966l.716-3.815-1.964-.37L8.232 8H4v2h3.857l-.751 4H3v2h3.731l-.714 3.805 1.965.369L8.766 16h4.966l-.714 3.805 1.965.369.783-4.174H20v-2h-3.859l.751-4H21V8h-3.733l.716-3.815-1.965-.37zM14.106 14H9.141l.751-4h4.966l-.752 4z"
></path>
</svg>
&nbsp;<span>{name.toLowerCase()}</span>
</a>
</li>
<style>
a {
@apply relative underline decoration-dashed hover:-top-0.5 hover:text-skin-accent focus-visible:p-1;
}
a svg {
@apply -mr-5 h-6 w-6 scale-95 text-skin-base opacity-80 group-hover:fill-skin-accent;
}
</style>

144
src/config.ts Normal file
View File

@ -0,0 +1,144 @@
import type { Site, SocialObjects } from "./types";
export const SITE: Site = {
website: "https://astro-paper.pages.dev/",
author: "Alex Wellnitz",
desc: "A minimal, responsive and SEO-friendly Astro blog theme.",
title: "Alexohneander",
subtitle: "Engineering Chaos",
ogImage: "astropaper-og.jpg",
lightAndDarkMode: true,
postPerPage: 3,
};
export const LOCALE = ["en-EN"]; // set to [] to use the environment default
export const LOGO_IMAGE = {
enable: false,
svg: true,
width: 216,
height: 46,
};
export const SOCIALS: SocialObjects = [
{
name: "Github",
href: "https://github.com/alexohneander",
linkTitle: ` ${SITE.title} on Github`,
active: true,
},
{
name: "Facebook",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Facebook`,
active: false,
},
{
name: "Instagram",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Instagram`,
active: false,
},
{
name: "LinkedIn",
href: "https://www.linkedin.com/in/alex-wellnitz/",
linkTitle: `${SITE.title} on LinkedIn`,
active: true,
},
{
name: "Mail",
href: "mailto:moin@wellnitz-alex.de",
linkTitle: `Send an email to ${SITE.title}`,
active: true,
},
{
name: "Twitter",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Twitter`,
active: false,
},
{
name: "Twitch",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Twitch`,
active: false,
},
{
name: "YouTube",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on YouTube`,
active: false,
},
{
name: "WhatsApp",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on WhatsApp`,
active: false,
},
{
name: "Snapchat",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Snapchat`,
active: false,
},
{
name: "Pinterest",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Pinterest`,
active: false,
},
{
name: "TikTok",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on TikTok`,
active: false,
},
{
name: "CodePen",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on CodePen`,
active: false,
},
{
name: "Discord",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Discord`,
active: false,
},
{
name: "GitLab",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on GitLab`,
active: false,
},
{
name: "Reddit",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Reddit`,
active: false,
},
{
name: "Skype",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Skype`,
active: false,
},
{
name: "Steam",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Steam`,
active: false,
},
{
name: "Telegram",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Telegram`,
active: false,
},
{
name: "Mastodon",
href: "https://github.com/satnaing/astro-paper",
linkTitle: `${SITE.title} on Mastodon`,
active: false,
},
];

18
src/content/_schemas.ts Normal file
View File

@ -0,0 +1,18 @@
import { z } from "astro:content";
export const blogSchema = z
.object({
author: z.string().optional(),
pubDatetime: z.date(),
title: z.string(),
postSlug: z.string().optional(),
featured: z.boolean().optional(),
draft: z.boolean().optional(),
tags: z.array(z.string()).default(["others"]),
ogImage: z.string().optional(),
description: z.string(),
canonicalURL: z.string().optional(),
})
.strict();
export type BlogFrontmatter = z.infer<typeof blogSchema>;

View File

@ -0,0 +1,132 @@
---
author: Alex Wellnitz
pubDatetime: 2021-03-03T19:20:27+02:00
title: Backup MySQL Databases in Kubernetes
postSlug: backup-mysql-databases-in-kubernetes
featured: true
draft: false
tags:
- docker
- kubernetes
- cronjob
- bash
- backup
ogImage: ""
description:
In this post, we will show you how to create a MySQL server backup using Kubernetes CronJobs.
---
In this post, we will show you how to create a MySQL server backup using Kubernetes CronJobs.
In our case, we do not have a managed MySQL server. But we want to backup it to our NAS, so that we have a backup in case of emergency.
For this we first build a container that can execute our tasks, because we will certainly need several tasks to backup our cluster.
## CronJob Agent Container
First, we'll show you our Dockerfile so you know what we need.
```Dockerfile
FROM alpine:3.10
# Update
RUN apk --update add --no-cache bash nodejs-current yarn curl busybox-extras vim rsync git mysql-client openssh-client
RUN curl -LO https://storage.googleapis.com/kubernetes-release/release/v1.18.0/bin/linux/amd64/kubectl && chmod +x ./kubectl && mv ./kubectl /usr/local/bin/kubectl
# Scripts
RUN mkdir /srv/jobs
COPY jobs/* /srv/jobs/
# Backup Folder
RUN mkdir /var/backup
RUN mkdir /var/backup/mysql
```
## Backup Script
And now our backup script which the container executes.
Our script is quite simple, we get all tables with the mysql client, export them as sql file, pack them in a zip file and send them in a 8 hours interval to our NAS.
```bash
#!/bin/bash
############# SET VARIABLES #############
# Env Variables
BACKUPSERVER="8.8.8.8" # Backup Server Ip
BACKUPDIR=/var/backup/mysql
BACKUPREMOTEDIR="/mnt/backup/kubernetes/"
HOST="mariadb.default"
NOW="$(date +"%Y-%m-%d")"
STARTTIME=$(date +"%s")
USER=mysqlUser
PASS=mysqlPassword
############# BUILD ENVIROMENT #############
# Check if temp Backup Directory is empty
mkdir $BACKUPDIR
if [ "$(ls -A $BACKUPDIR)" ]; then
echo "Take action $BACKUPDIR is not Empty"
rm -f $BACKUPDIR/*.gz
rm -f $BACKUPDIR/*.mysql
else
echo "$BACKUPDIR is Empty"
fi
############# BACKUP SQL DATABASES #############
for DB in $(mysql -u$USER -p$PASS -h $HOST -e 'show databases' -s --skip-column-names); do
mysqldump -u$USER -p$PASS -h $HOST --lock-tables=false $DB > "$BACKUPDIR/$DB.sql";
done
############# ZIP BACKUP #############
cd $BACKUPDIR
tar -zcvf backup-${NOW}.tar.gz *.sql
############# MOVE BACKUP TO REMOTE #############
rsync -avz $BACKUPDIR/backup-${NOW}.tar.gz root@$BACKUPSERVER:$BACKUPREMOTEDIR
# done
```
## Kubernetes CronJob Deployment
Finally we show you the kubernetes deployment for our agent.
In the deployment, our agent is defined as a CronJob that runs every 8 hours.
In addition, we have added an SSH key as a Conifg map so that this can write to the NAS and a certain security is given.
```yaml
apiVersion: batch/v1beta1
kind: CronJob
metadata:
name: backup-mariadb
namespace: default
spec:
schedule: "0 8 * * *"
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 1
jobTemplate:
spec:
template:
spec:
containers:
- name: cronjob-agent
image: xxx/cronjob-agent
command: ["bash", "/srv/jobs/backup-mariadb.sh"]
volumeMounts:
- mountPath: /root/.ssh/id_rsa.pub
name: cronjob-default-config
subPath: id_rsa.pub
- mountPath: /root/.ssh/id_rsa
name: cronjob-default-config
subPath: id_rsa
readOnly: true
- mountPath: /root/.ssh/config
name: cronjob-default-config
subPath: config
volumes:
- name: cronjob-default-config
configMap:
name: cronjob-default-config
defaultMode: 256
restartPolicy: Never
```

View File

@ -0,0 +1,104 @@
---
author: Alex Wellnitz
pubDatetime: 2022-01-21T19:20:27+02:00
title: Baremetal CNI Setup with Cilium
postSlug: baremetal-cni-setup-with-cilium
featured: true
draft: false
tags:
- kubernetes
- cni
- baremetal
ogImage: ""
description:
In a freshly set up Kubernetes cluster, we need a so-called CNI. This CNI is not always present after installation.
---
In a freshly set up Kubernetes cluster, we need a so-called CNI. This CNI is not always present after installation.
## What is a Container Network Interface (CNI)?
CNI is a network framework that allows the dynamic configuration of networking resources through a group of Go-written specifications and libraries. The specification mentioned for the plugin outlines an interface that would configure the network, provisioning the IP addresses, and mantain multi-host connectivity.
In the Kubernetes context, the CNI seamlessly integrates with the kubelet to allow automatic network configuration between pods using an underlay or overlay network. An underlay network is defined at the physical level of the networking layer composed of routers and switches. In contrast, the overlay network uses a virtual interface like VxLAN to encapsulate the network traffic.
Once the network configuration type is specified, the runtime defines a network for containers to join and calls the CNI plugin to add the interface into the container namespace and allocate the linked subnetwork and routes by making calls to IPAM (IP Address Management) plugin.
In addition to Kubernetes networking, CNI also supports Kubernetes-based platforms like OpenShift to provide a unified container communication across the cluster through software-defined networking (SDN) approach.
### What is Cilium?
Cilium is an open-source, highly scalable Kubernetes CNI solution developed by Linux kernel developers. Cilium secures network connectivity between Kubernetes services by adding high-level application rules utilizing eBPF filtering technology. Cilium is deployed as a daemon `cilium-agent` on each node of the Kubernetes cluster to manage operations and translates the network definitions to eBPF programs.
The communication between pods happens over an overlay network or utilizing a routing protocol. Both IPv4 and IPv6 addresses are supported for cases. Overlay network implementation utilizes VXLAN tunneling for packet encapsulation while native routing happens through unencapsulated BGP protocol.
Cilium can be used with multiple Kubernetes clusters and can provide multi CNI features, a high level of inspection,pod-to-pod connectivity across all clusters.
Its network and application layer awareness manages packet inspection, and the application protocol packets are using.
Cilium also has support for Kubernetes Network Policies through HTTP request filters. The policy configuration can be written into a YAML or JSON file and offers both ingress and egress enforcements. Admins can accept or reject requests based on the request method or path header while integrating policies with service mesh like Istio.
### Preparation
For the installation we need the CLI from Cilium.
We can install this with the following commands:
**Mac OSx**
```bash
curl -L --remote-name-all https://github.com/cilium/cilium-cli/releases/latest/download/cilium-darwin-amd64.tar.gz{,.sha256sum}
shasum -a 256 -c cilium-darwin-amd64.tar.gz.sha256sum
sudo tar xzvfC cilium-darwin-amd64.tar.gz /usr/local/bin
rm cilium-darwin-amd64.tar.gz{,.sha256sum}
```
**Linux**
```bash
curl -L --remote-name-all https://github.com/cilium/cilium-cli/releases/latest/download/cilium-linux-amd64.tar.gz{,.sha256sum}
sha256sum --check cilium-linux-amd64.tar.gz.sha256sum
sudo tar xzvfC cilium-linux-amd64.tar.gz /usr/local/bin
rm cilium-linux-amd64.tar.gz{,.sha256sum}
```
### Install Cilium
You can install Cilium on any Kubernetes cluster. These are the generic instructions on how to install Cilium into any Kubernetes cluster. The installer will attempt to automatically pick the best configuration options for you.
#### Requirements
- Kubernetes must be configured to use CNI
- Linux kernel >= 4.9.17
#### Install
Install Cilium into the Kubernetes cluster pointed to by your current kubectl context:
```bash
cilium install
```
If the installation fails for some reason, run `cilium status` to retrieve the overall status of the Cilium deployment and inspect the logs of whatever pods are failing to be deployed.
### Validate the Installation
To validate that Cilium has been properly installed, you can run
```bash
$ cilium status --wait
/¯¯\
/¯¯\__/¯¯\ Cilium: OK
\__/¯¯\__/ Operator: OK
/¯¯\__/¯¯\ Hubble: disabled
\__/¯¯\__/ ClusterMesh: disabled
\__/
DaemonSet cilium Desired: 2, Ready: 2/2, Available: 2/2
Deployment cilium-operator Desired: 2, Ready: 2/2, Available: 2/2
Containers: cilium-operator Running: 2
cilium Running: 2
Image versions cilium quay.io/cilium/cilium:v1.9.5: 2
cilium-operator quay.io/cilium/operator-generic:v1.9.5: 2
```
Run the following command to validate that your cluster has proper network connectivity:
```bash
$ cilium connectivity test
Monitor aggregation detected, will skip some flow validation steps
✨ [k8s-cluster] Creating namespace for connectivity check...
(...)
---------------------------------------------------------------------------------------------------------------------
📋 Test Report
---------------------------------------------------------------------------------------------------------------------
✅ 69/69 tests successful (0 warnings)
```
Congratulations! You have a fully functional Kubernetes cluster with Cilium. 🎉

View File

@ -0,0 +1,240 @@
---
author: Alex Wellnitz
pubDatetime: 2021-05-06T19:20:27+02:00
title: Site to Site VPN for Google Kubernetes Engine
postSlug: baremetal-cni-setup-with-cilium
featured: false
draft: false
tags:
- kubernetes
- openvpn
- google
ogImage: ""
description:
In this tutorial I will try to explain you briefly and concisely how you can set up a site-to-site VPN for the Google Cloud Network.
---
In this tutorial I will try to explain you briefly and concisely how you can set up a site-to-site VPN for the Google Cloud Network.
### Prerequisites
We need 2 virtual machines. The first one on the side of our office and the other one on the side of Google.
#### Setup OpenVPN Clients
##### Site-to-Site Client Office Side
We need to install OpenVPN, we do it as follows:
```bash
apt install openvpn -y
```
After that we add our OpenVPN configuration under this path `/etc/openvpn/s2s.conf`.
*s2s.conf*
```
# Use a dynamic tun device.
# For Linux 2.2 or non-Linux OSes,
# you may want to use an explicit
# unit number such as "tun1".
# OpenVPN also supports virtual
# ethernet "tap" devices.
dev tun
# Our OpenVPN peer is the Google gateway.
remote IP_GOOGLE_VPN_CLIENT
ifconfig 4.1.0.2 4.1.0.1
route 10.156.0.0 255.255.240.0 # Google Cloud VM Network
route 10.24.0.0 255.252.0.0 # Google Kubernetes Pod Network
push "route 192.168.10.0 255.255.255.0" # Office Network
# Our pre-shared static key
#secret static.key
# Cipher to use
cipher AES-256-CBC
port 1195
user nobody
group nogroup
# Uncomment this section for a more reliable detection when a system
# loses its connection. For example, dial-ups or laptops that
# travel to other locations.
ping 15
ping-restart 45
ping-timer-rem
persist-tun
persist-key
# Verbosity level.
# 0 -- quiet except for fatal errors.
# 1 -- mostly quiet, but display non-fatal network errors.
# 3 -- medium output, good for normal operation.
# 9 -- verbose, good for troubleshooting
verb 3
log /etc/openvpn/s2s.log
```
We also have to enable the IPv4 forward function in the kernel, so we go to `/etc/sysctl.conf` and comment out the following line:
```
net.ipv4.ip_forward=1
```
We can then start our OpenVPN client with this command:
```bash
systemctl start openvpn@s2s
```
On the Office side we have to open the port for the OpenVPN client that the other side can connect.
##### Site-to-Site Client Google Side
When setting up the OpenVPN client on Google's site, we need to consider the following settings when creating it. When we create the machine, we need to enable this option in the network settings:
![Google Cloud Network Settings](https://i.imgur.com/OXEkhxo.png)
Also on this side we have to install the OpenVPN client again and then add this config under the path `/etc/openvpn/s2s.conf`:
```
# Use a dynamic tun device.
# For Linux 2.2 or non-Linux OSes,
# you may want to use an explicit
# unit number such as "tun1".
# OpenVPN also supports virtual
# ethernet "tap" devices.
dev tun
# Our OpenVPN peer is the Office gateway.
remote IP_OFFICE_VPN_CLIENT
ifconfig 4.1.0.2 4.1.0.1
route 192.168.10.0 255.255.255.0 # Office Network
push "route 10.156.0.0 255.255.240.0" # Google Cloud VM Network
push "route 10.24.0.0 255.252.0.0" # Google Kubernetes Pod Network
# Our pre-shared static key
#secret static.key
# Cipher to use
cipher AES-256-CBC
port 1195
user nobody
group nogroup
# Uncomment this section for a more reliable detection when a system
# loses its connection. For example, dial-ups or laptops that
# travel to other locations.
ping 15
ping-restart 45
ping-timer-rem
persist-tun
persist-key
# Verbosity level.
# 0 -- quiet except for fatal errors.
# 1 -- mostly quiet, but display non-fatal network errors.
# 3 -- medium output, good for normal operation.
# 9 -- verbose, good for troubleshooting
verb 3
log /etc/openvpn/s2s.log
```
We also have to enable the IPv4 forward function in the kernel, so we go to `/etc/sysctl.conf` and comment out the following line:
```
net.ipv4.ip_forward=1
```
##### Connection test
Now that both clients are basically configured we can test the connection. Both clients have to be started with systemctl. After that we look at the logs with `tail -f /etc/openvpn/s2s-log` and wait for this message:
```
Wed May 5 08:28:01 2021 /sbin/ip route add 10.28.0.0/20 via 4.1.0.1
Wed May 5 08:28:01 2021 TCP/UDP: Preserving recently used remote address: [AF_INET]0.0.0.0:1195
Wed May 5 08:28:01 2021 Socket Buffers: R=[212992->212992] S=[212992->212992]
Wed May 5 08:28:01 2021 UDP link local (bound): [AF_INET][undef]:1195
Wed May 5 08:28:01 2021 UDP link remote: [AF_INET]0.0.0.0:1195
Wed May 5 08:28:01 2021 GID set to nogroup
Wed May 5 08:28:01 2021 UID set to nobody
Wed May 5 08:28:11 2021 Peer Connection Initiated with [AF_INET]0.0.0.0:1195
Wed May 5 08:28:12 2021 WARNING: this configuration may cache passwords in memory -- use the auth-nocache option to prevent this
Wed May 5 08:28:12 2021 Initialization Sequence Completed
```
If we can't establish a connection, we need to check if the ports are opened on both sides.
#### Routing Google Cloud Network
After our clients have finished installing and configuring, we need to set the routes on Google. I will not map the Office side, as this is always different. But you have to route the networks for the Google network there as well.
To set the route on Google we go to the network settings and then to Routes. Here you have to specify your office network so that the clients in the Google network know what to do.
![Google Cloud Network Route](https://i.imgur.com/6Q2Drf4.png)
#### IP-Masquerade-Agent
IP masquerading is a form of network address translation (NAT) used to perform many-to-one IP address translations, which allows multiple clients to access a destination using a single IP address. A GKE cluster uses IP masquerading so that destinations outside of the cluster only receive packets from node IP addresses instead of Pod IP addresses. This is useful in environments that expect to only receive packets from node IP addresses.
You have to edit the ip-masq-agent and this configuration is responsible for letting the pods inside the nodes, reach other parts of the GCP VPC Network, more specifically the VPN. So, it allows pods to communicate with the devices that are accessible through the VPN.
First of all we're gonna be working inside the kube-system namespace, and we're gonna put the configmap that configures our ip-masq-agent, put this in a config file:
```yaml
nonMasqueradeCIDRs:
- 10.24.0.0/14 # The IPv4 CIDR the cluster is using for Pods (required)
- 10.156.0.0/20 # The IPv4 CIDR of the subnetwork the cluster is using for Nodes (optional, works without but I guess its better with it)
masqLinkLocal: false
resyncInterval: 60s
```
and run `kubectl create configmap ip-masq-agent --from-file config --namespace kube-system`
afterwards, configure the ip-masq-agent, put this in a `ip-masq-agent.yml` file:
```yaml
apiVersion: extensions/v1beta1
kind: DaemonSet
metadata:
name: ip-masq-agent
namespace: kube-system
spec:
template:
metadata:
labels:
k8s-app: ip-masq-agent
spec:
hostNetwork: true
containers:
- name: ip-masq-agent
image: gcr.io/google-containers/ip-masq-agent-amd64:v2.4.1
args:
- --masq-chain=IP-MASQ
# To non-masquerade reserved IP ranges by default, uncomment the line below.
# - --nomasq-all-reserved-ranges
securityContext:
privileged: true
volumeMounts:
- name: config
mountPath: /etc/config
volumes:
- name: config
configMap:
# Note this ConfigMap must be created in the same namespace as the daemon pods - this spec uses kube-system
name: ip-masq-agent
optional: true
items:
# The daemon looks for its config in a YAML file at /etc/config/ip-masq-agent
- key: config
path: ip-masq-agent
tolerations:
- effect: NoSchedule
operator: Exists
- effect: NoExecute
operator: Exists
- key: "CriticalAddonsOnly"
operator: "Exists"
```
and run `kubectl -n kube-system apply -f ip-masq-agent.yml`.
Now our site-to-site VPN should be set up. You should now test if you can ping the pods and if all other services work as you expect them to.

View File

@ -0,0 +1,43 @@
---
author: Alex Wellnitz
pubDatetime: 2022-09-15T15:00:00+02:00
title: Why Docker isn't always a good idea Part 1
postSlug: why-docker-isnt-always-a-good-idea
featured: false
draft: false
tags:
- docker
- network
- haproxy
ogImage: ""
description:
To briefly explain the situation.
We have a **HAProxy** running on a Debian server as a Docker container. This is the entrance node to a **Docker Swarm** cluster.
---
To briefly explain the situation:
We have a **HAProxy** running on a Debian server as a Docker container. This is the entrance node to a **Docker Swarm** cluster.
Now, in the last few days, there have been several small outages of the websites running in the **Docker Swarm** cluster. After getting an overview, we noticed that no new connections can be established.
As soon as we restarted the **HAProxy**, everything went back to normal. After that I did some research on TCP connections and found out that there is a socket limit.
In Linux we have a limit of sockets that can be opened at the same time. At this point, I unfortunately did not understand that this limit refers to a client connection. So we note that a client can establish a maximum of **65535** socket connections to a server.
This limit refers to a range of ports that you release. We had about 35k sockets available on our server (**HAProxy**). Now the pages are always down when this limit is reached. Thinking back for a moment, we should never get to that limit as it relates to a client. But the problem with us was that Docker's softlayer network didn't route the client address cleanly through the NAT, so everything was coming from one client.
After stopping the Docker container and installing **HAProxy** natively on the server, we were able to cross that boundary as well.
## Assumption
Because we NAT all the requests through the Docker network, the source address is always the same. This is how we reach the socket limit. If we omit the NAT and use HAProxy natively, we do not reach this limit, because the source address is no longer always the same.
![Network Address Translation](https://upload.wikimedia.org/wikipedia/commons/thumb/c/c7/NAT_Concept-en.svg/1920px-NAT_Concept-en.svg.png "Network Address Translation")
## Conclusion
With this setup, we get overhead into the system that we don't need. We have an extra abstraction layer, every request has to go through the Docker network and we reach the socket limit. All these points fall away when we use it natively.
If we use a lot of micro services it is important that we use something like Docker, because then we can share the kernel and it makes the deployment much easier.
But if we have only one application that is very important, it is better to keep it simple.

View File

@ -0,0 +1,131 @@
---
author: Alex Wellnitz
pubDatetime: 2022-09-19T09:20:27+02:00
title: Writing Backup Scripts with Borg
postSlug: writing-backup-scripts-with-borg
featured: false
draft: false
tags:
- borg
- linux
- backup
ogImage: ""
description:
Since we all know that the first rule is "no backup, no pity", I'll show you how you can use Borg to back up your important data in an encrypted way with relative ease.
---
Since we all know that the first rule is "no backup, no pity", I'll show you how you can use Borg to back up your important data in an encrypted way with relative ease.
If you do not want to use a second computer, but an external hard drive, you can adjust this later in the script and ignore the points in the instructions for the second computer.
### Requirements
- 2 Linux Computers
- Borg
- SSH
- Storage
- More than 5 brain cells
### Installation
First we need to install borg on both computers so that we can back up on one and save on the other.
```bash
sudo apt install borgbackup
```
Then we create a Borg repository. We can either use an external target or a local path.
**External Target:**
```bash
borg init --encryption=repokey ssh://user@192.168.2.42:22/mnt/backup/borg
```
**Local Path:**
```bash
borg init --encryption=repokey /path/to/backup_folder
```
If you are using an external destination, I recommend that you store your SSH key on the destination.
This way you don't have to enter a password and is simply nicer from my point of view.
Once you have created everything and prepared the script with your parameters, I recommend that you run the script as a CronJob so that you no longer have to remember to back up your things yourself.
**crontab example:**
```bash
#Minute Hour Day Month Day(Week) command
#(0-59) (0-23) (1-31) (1-12) (1-7;1=Mo)
00 2 * * * /srv/scripts/borgBackup.sh
```
### Automated script
```bash
#!/bin/sh
# VARS
BACKUPSERVER="192.168.2.42"
BACKUPDIR="/mnt/backup/borg"
# Here you can either use your external destination or the local path.
# External target
export BORG_REPO="user@$BACKUPSERVER:$BACKUPDIR"
# Local path
# export BORG_REPO=/path/to/backup_folder
# Your repository password must be stored here.
export BORG_PASSPHRASE='S0m3th1ngV3ryC0mpl1c4t3d'
info() { printf "\n%s %s\n\n" "$( date )" "$*" >&2; }
trap 'echo $( date ) Backup interrupted >&2; exit 2' INT TERM
info "Start backup"
#Here the backup is created, adjust it the way you would like to have it.
borg create \
--stats \
--compression lz4 \
::'BackupName-{now}' \
/etc/nginx \
/home/user
backup_exit=$?
info "Deleting old backups"
# Automatic deletion of old backups
borg prune \
--prefix 'BackupName-' \
--keep-daily 7 \
--keep-weekly 4 \
--keep-monthly 6
prune_exit=$?
# Information on whether the backup worked.
global_exit=$(( backup_exit > prune_exit ? backup_exit : prune_exit ))
if [ ${global_exit} -eq 0 ]; then
info "Backup and Prune finished successfully"
elif [ ${global_exit} -eq 1 ]; then
info "Backup and/or Prune finished with warnings"
else
info "Backup and/or Prune finished with errors"
fi
exit ${global_exit}
```
### Get your data from the backup
First, we create a temporary directory in which we can mount the backup.
```bash
mkdir /tmp/borg-backup
```
Once our mount point is created, we can mount our backup repo.
At this point you must remember that you can use an external destination or a local path.
```bash
borg mount ssh://user@192.168.2.42/mnt/backup/borg /tmp/borg-backup
```
Once our repo is mounted, we can change into the directory and restore files via **rsync** or **cp**.
### Conclusion
I hope you could understand everything and now secure your shit sensibly. Because without a backup we are all lost!

8
src/content/config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineCollection } from "astro:content";
import { blogSchema } from "./_schemas";
const blog = defineCollection({
schema: blogSchema,
});
export const collections = { blog };

2
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View File

@ -0,0 +1,28 @@
---
import { SITE } from "@config";
import Breadcrumbs from "@components/Breadcrumbs.astro";
import Footer from "@components/Footer.astro";
import Header from "@components/Header.astro";
import Layout from "./Layout.astro";
export interface Props {
frontmatter: {
title: string;
description?: string;
};
}
const { frontmatter } = Astro.props;
---
<Layout title={`${frontmatter.title} | ${SITE.title}`}>
<Header activeNav="experience" />
<Breadcrumbs />
<main id="main-content">
<section id="about" class="prose mb-28 max-w-3xl prose-img:border-0">
<h1 class="text-2xl tracking-wider sm:text-3xl">{frontmatter.title}</h1>
<slot />
</section>
</main>
<Footer />
</Layout>

85
src/layouts/Layout.astro Normal file
View File

@ -0,0 +1,85 @@
---
import { SITE } from "@config";
import "@styles/base.css";
const googleSiteVerification = import.meta.env.PUBLIC_GOOGLE_SITE_VERIFICATION;
export interface Props {
title?: string;
subtitle?: string;
author?: string;
description?: string;
ogImage?: string;
canonicalURL?: string;
}
const {
title = SITE.title,
subtitle = SITE.subtitle,
author = SITE.author,
description = SITE.desc,
ogImage = SITE.ogImage,
canonicalURL = new URL(Astro.url.pathname, Astro.site).href,
} = Astro.props;
const socialImageURL = new URL(
ogImage ? ogImage : SITE.ogImage,
Astro.url.origin
).href;
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="canonical" href={canonicalURL} />
<meta name="generator" content={Astro.generator} />
<!-- General Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<meta name="author" content={author} />
<link rel="sitemap" href="/sitemap-index.xml" />
<!-- Open Graph / Facebook -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:image" content={socialImageURL} />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalURL} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={socialImageURL} />
<!-- Google Font -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400;1,600&display=swap"
rel="stylesheet"
/>
{
// If PUBLIC_GOOGLE_SITE_VERIFICATION is set in the environment variable,
// include google-site-verification tag in the heading
// Learn more: https://support.google.com/webmasters/answer/9008080#meta_tag_verification&zippy=%2Chtml-tag
googleSiteVerification && (
<meta
name="google-site-verification"
content={googleSiteVerification}
/>
)
}
<script is:inline src="/toggle-theme.js"></script>
</head>
<body>
<slot />
</body>
</html>

29
src/layouts/Main.astro Normal file
View File

@ -0,0 +1,29 @@
---
import Breadcrumbs from "@components/Breadcrumbs.astro";
export interface Props {
pageTitle: string;
pageDesc?: string;
}
const { pageTitle, pageDesc } = Astro.props;
---
<Breadcrumbs />
<main id="main-content">
<h1>{pageTitle}</h1>
<p>{pageDesc}</p>
<slot />
</main>
<style>
#main-content {
@apply mx-auto w-full max-w-3xl px-4 pb-12;
}
#main-content h1 {
@apply text-2xl font-semibold sm:text-3xl;
}
#main-content p {
@apply mb-6 mt-2 italic;
}
</style>

View File

@ -0,0 +1,62 @@
---
import Layout from "@layouts/Layout.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Tag from "@components/Tag.astro";
import Datetime from "@components/Datetime";
import type { CollectionEntry } from "astro:content";
import { slugifyStr } from "@utils/slugify";
export interface Props {
post: CollectionEntry<"blog">;
}
const { post } = Astro.props;
const { title, author, description, ogImage, canonicalURL, pubDatetime, tags } = post.data;
const { Content } = await post.render();
const ogUrl = new URL(ogImage ? ogImage : `${title}.png`, Astro.url.origin)
.href;
---
<Layout title={title} author={author} description={description} ogImage={ogUrl} canonicalURL={canonicalURL}>
<Header />
<div class="mx-auto flex w-full max-w-3xl justify-start px-2">
<button
class="focus-outline mb-2 mt-8 flex hover:opacity-75"
onclick="history.back()"
>
<svg xmlns="http://www.w3.org/2000/svg"
><path
d="M13.293 6.293 7.586 12l5.707 5.707 1.414-1.414L10.414 12l4.293-4.293z"
></path>
</svg><span>Go back</span>
</button>
</div>
<main id="main-content">
<h1 class="post-title">{title}</h1>
<Datetime datetime={pubDatetime} size="lg" className="my-2" />
<article id="article" role="article" class="prose mx-auto mt-8 max-w-3xl">
<Content />
</article>
<ul class="tags-container">
{tags.map(tag => <Tag name={slugifyStr(tag)} />)}
</ul>
</main>
<Footer />
</Layout>
<style>
main {
@apply mx-auto w-full max-w-3xl px-4 pb-12;
}
.post-title {
@apply text-2xl font-semibold text-skin-accent;
}
.tags-container {
@apply my-8;
}
</style>

77
src/layouts/Posts.astro Normal file
View File

@ -0,0 +1,77 @@
---
import { SITE } from "@config";
import Layout from "@layouts/Layout.astro";
import Main from "@layouts/Main.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Card from "@components/Card";
import LinkButton from "@components/LinkButton.astro";
import slugify from "@utils/slugify";
import type { CollectionEntry } from "astro:content";
export interface Props {
pageNum: number;
totalPages: number;
posts: CollectionEntry<"blog">[];
}
const { pageNum, totalPages, posts } = Astro.props;
const prev = pageNum > 1 ? "" : "disabled";
const next = pageNum < totalPages ? "" : "disabled";
---
<Layout title={`Posts | ${SITE.title}`}>
<Header activeNav="posts" />
<Main pageTitle="Posts" pageDesc="All the articles I've posted.">
<ul>
{
posts.map(({ data }) => (
<Card href={`/posts/${slugify(data)}`} frontmatter={data} />
))
}
</ul>
</Main>
{
totalPages > 1 && (
<nav class="pagination-wrapper" aria-label="Pagination">
<LinkButton
disabled={prev === "disabled"}
href={`/posts${pageNum - 1 !== 1 ? "/" + (pageNum - 1) : ""}`}
className={`mr-4 select-none ${prev}`}
ariaLabel="Previous"
>
<svg xmlns="http://www.w3.org/2000/svg" class={`${prev}-svg`}>
<path d="M12.707 17.293 8.414 13H18v-2H8.414l4.293-4.293-1.414-1.414L4.586 12l6.707 6.707z" />
</svg>
Prev
</LinkButton>
<LinkButton
disabled={next === "disabled"}
href={`/posts/${pageNum + 1}`}
className={`ml-4 select-none ${next}`}
ariaLabel="Next"
>
Next
<svg xmlns="http://www.w3.org/2000/svg" class={`${next}-svg`}>
<path d="m11.293 17.293 1.414 1.414L19.414 12l-6.707-6.707-1.414 1.414L15.586 11H6v2h9.586z" />
</svg>
</LinkButton>
</nav>
)
}
<Footer noMarginTop={totalPages > 1} />
</Layout>
<style>
.pagination-wrapper {
@apply mb-8 mt-auto flex justify-center;
}
.disabled {
@apply pointer-events-none select-none opacity-50 hover:text-skin-base group-hover:fill-skin-base;
}
.disabled-svg {
@apply group-hover:!fill-skin-base;
}
</style>

42
src/pages/404.astro Normal file
View File

@ -0,0 +1,42 @@
---
import { SITE } from "@config";
import Layout from "@layouts/Layout.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import LinkButton from "@components/LinkButton.astro";
---
<Layout title={`404 Not Found | ${SITE.title}`}>
<Header />
<main id="main-content">
<div class="not-found-wrapper">
<h1 aria-label="404 Not Found">404</h1>
<span aria-hidden="true">¯\_(ツ)_/¯</span>
<p>Page Not Found</p>
<LinkButton
href="/"
className="my-6 underline decoration-dashed underline-offset-8 text-lg"
>
Go back home
</LinkButton>
</div>
</main>
<Footer />
</Layout>
<style>
#main-content {
@apply mx-auto flex max-w-3xl flex-1 items-center justify-center;
}
.not-found-wrapper {
@apply mb-14 flex flex-col items-center justify-center;
}
.not-found-wrapper h1 {
@apply text-9xl font-bold text-skin-accent;
}
.not-found-wrapper p {
@apply mt-4 text-2xl sm:text-3xl;
}
</style>

View File

@ -0,0 +1,18 @@
import { getCollection } from "astro:content";
import generateOgImage from "@utils/generateOgImage";
import type { APIRoute } from "astro";
export const get: APIRoute = async ({ params }) => ({
body: await generateOgImage(params.ogTitle),
});
const postImportResult = await getCollection("blog", ({ data }) => !data.draft);
const posts = Object.values(postImportResult);
export function getStaticPaths() {
return posts
.filter(({ data }) => !data.ogImage)
.map(({ data }) => ({
params: { ogTitle: data.title },
}));
}

65
src/pages/experience.md Normal file
View File

@ -0,0 +1,65 @@
---
layout: ../layouts/AboutLayout.astro
title: "Experience"
---
### DevOps Engineer, Materna SE
**since 2023**
As a key globally active IT service provider, Materna advise and assist you in all aspects of digitization and provide tailor-made technologies for agile, flexible and secure IT.
- **Infrastructure as Code (IaC)**:
- Develop and maintain infrastructure as code scripts using tools like Terraform, Ansible, or CloudFormation to automate the provisioning of infrastructure resources.
- **Continuous Integration (CI) and Continuous Deployment (CD)**:
- Implement and manage CI/CD pipelines using tools like Jenkins, Travis CI, or GitLab CI to automate the software delivery process.
- **Containerization and Orchestration**:
- Work with Docker containers and container orchestration platforms like Kubernetes to improve scalability and resource utilization.
- **Monitoring and Logging**:
- Set up monitoring and logging solutions (e.g., Prometheus, ELK Stack) to track application performance, identify issues, and troubleshoot problems proactively.
- **Collaboration and Communication**:
- Foster collaboration between development and operations teams, ensuring effective communication and knowledge sharing.
- **Infrastructure Optimization**:
- Analyze and optimize infrastructure costs, resource utilization, and performance to achieve cost-efficiency and scalability.
- **Troubleshooting and Support**:
- Respond to incidents, diagnose problems, and provide support to ensure system reliability and availability.
### DevOps Engineer, Apozin GmbH
**until 2023**
Apozin turns visions into a competitive advantage. Our team of pharmacists, PTA's, graphic designers, web designers, sales professionals, marketing specialists, and programmers realize holistic concepts that we constantly evolve and improve for our clients.
- Operation and design of Kubernetes clusters at multiple locations
- Design and implementation of backup strategies
- Deployment of various services (including HAProxy, MariaDB, MongoDB, Elasticsearch, NGINX)
- Design and operation of comprehensive monitoring solutions (Zabbix, Grafana, Prometheus, Graylog)
- Design and setup of build pipelines with Jenkins, Docker, and FluxCD
- Administration of various servers in different environments (Google Cloud, Hetzner, AWS, Digital Ocean, Hosting.de)
### Fullstack .Net Developer, prointernet
**until 2019**
Agency for internet and design founded in 1998, established in Kastellaun in the Hunsrück region, operating worldwide, and at home on the internet. A team of designers, developers, and consultants who love what they do.
- Development of web applications (C#, Dotnet, JS)
- Design of websites (Composite C1)
- Company Website
## Projects
### DevOps Engineer, Amamed
**until 2023**
Just right for your pharmacy! amamed is the only digital solution on the market that puts your pharmacy at the center and makes you fully equipped, secure, and flexible online.
- Provision of various services (including reverse proxies, databases, load balancers)
- Operation of Docker Swarm clusters
- Product Website
### DevOps Engineer, deineApotheke
**until 2021**
"deine Apotheke" supports the pharmacies in your neighborhood and paves the way for you to access pharmacy services: through our app, you can select your pharmacy and pre-order medications, even with a prescription.
- Provision of various services (including backend APIs, MariaDB clusters, NATs, Redis)
- Design and operation of Kubernetes clusters (3 locations)
- Management of automated pipelines via Bitbucket Pipelines (continuous integration)
- IT administration for 6 individuals (SysOps)

156
src/pages/index.astro Normal file
View File

@ -0,0 +1,156 @@
---
import { getCollection } from "astro:content";
import Layout from "@layouts/Layout.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import LinkButton from "@components/LinkButton.astro";
import Hr from "@components/Hr.astro";
import Card from "@components/Card";
import Socials from "@components/Socials.astro";
import getSortedPosts from "@utils/getSortedPosts";
import slugify from "@utils/slugify";
import { SOCIALS } from "@config";
const posts = await getCollection("blog");
const sortedPosts = getSortedPosts(posts);
const featuredPosts = sortedPosts.filter(({ data }) => data.featured);
const socialCount = SOCIALS.filter(social => social.active).length;
---
<Layout>
<Header />
<main id="main-content">
<section id="hero">
<h1>Engineering Chaos</h1>
<a
target="_blank"
href="/rss.xml"
class="rss-link"
aria-label="rss feed"
title="RSS Feed"
>
<svg xmlns="http://www.w3.org/2000/svg" class="rss-icon"
><path
d="M19 20.001C19 11.729 12.271 5 4 5v2c7.168 0 13 5.832 13 13.001h2z"
></path><path
d="M12 20.001h2C14 14.486 9.514 10 4 10v2c4.411 0 8 3.589 8 8.001z"
></path><circle cx="6" cy="18" r="2"></circle>
</svg>
</a>
<p>
I'm Alex, a DevOps architect and software developer. I currently hold the role of DevOps Engineer at Materna, where I assist developers in accelerating web performance and provide guidance on various topics such as web development, Kubernetes, network security, and more.
</p>
<!-- <p>
Read the blog posts or check
<LinkButton
className="hover:text-skin-accent underline underline-offset-4 decoration-dashed"
href="https://github.com/satnaing/astro-paper#readme"
>
README
</LinkButton> for more info.
</p> -->
{
// only display if at least one social link is enabled
socialCount > 0 && (
<div class="social-wrapper">
<div class="social-links">Social Links:</div>
<Socials />
</div>
)
}
</section>
<Hr />
{
featuredPosts.length > 0 && (
<>
<section id="featured">
<h2>Featured</h2>
<ul>
{featuredPosts.map(({ data }) => (
<Card
href={`/posts/${slugify(data)}`}
frontmatter={data}
secHeading={false}
/>
))}
</ul>
</section>
<Hr />
</>
)
}
<section id="recent-posts">
<h2>Recent Posts</h2>
<ul>
{
sortedPosts.map(
({ data }, index) =>
index < 4 && (
<Card
href={`/posts/${slugify(data)}`}
frontmatter={data}
secHeading={false}
/>
)
)
}
</ul>
<div class="all-posts-btn-wrapper">
<LinkButton href="/posts">
All Posts
<svg xmlns="http://www.w3.org/2000/svg"
><path
d="m11.293 17.293 1.414 1.414L19.414 12l-6.707-6.707-1.414 1.414L15.586 11H6v2h9.586z"
></path>
</svg>
</LinkButton>
</div>
</section>
</main>
<Footer />
</Layout>
<style>
/* ===== Hero Section ===== */
#hero {
@apply pb-6 pt-8;
}
#hero h1 {
@apply my-4 inline-block text-3xl font-bold sm:my-8 sm:text-5xl;
}
#hero .rss-link {
@apply mb-6;
}
#hero .rss-icon {
@apply mb-2 h-6 w-6 scale-110 fill-skin-accent sm:mb-3 sm:scale-125;
}
#hero p {
@apply my-2;
}
.social-wrapper {
@apply mt-4 flex flex-col sm:flex-row sm:items-center;
}
.social-links {
@apply mb-1 mr-2 whitespace-nowrap sm:mb-0;
}
/* ===== Featured & Recent Posts Sections ===== */
#featured,
#recent-posts {
@apply pb-6 pt-12;
}
#featured h2,
#recent-posts h2 {
@apply text-2xl font-semibold tracking-wide;
}
.all-posts-btn-wrapper {
@apply my-8 text-center;
}
</style>

View File

@ -0,0 +1,58 @@
---
import { CollectionEntry, getCollection } from "astro:content";
import Posts from "@layouts/Posts.astro";
import PostDetails from "@layouts/PostDetails.astro";
import getSortedPosts from "@utils/getSortedPosts";
import getPageNumbers from "@utils/getPageNumbers";
import slugify from "@utils/slugify";
import { SITE } from "@config";
export interface Props {
post: CollectionEntry<"blog">;
}
export async function getStaticPaths() {
const posts = await getCollection("blog", ({ data }) => !data.draft);
const postResult = posts.map(post => ({
params: { slug: slugify(post.data) },
props: { post },
}));
const pagePaths = getPageNumbers(posts.length).map(pageNum => ({
params: { slug: String(pageNum) },
}));
return [...postResult, ...pagePaths];
}
const { slug } = Astro.params;
const { post } = Astro.props;
const posts = await getCollection("blog");
const sortedPosts = getSortedPosts(posts);
const totalPages = getPageNumbers(sortedPosts.length);
const currentPage =
slug && !isNaN(Number(slug)) && totalPages.includes(Number(slug))
? Number(slug)
: 0;
const lastPost = currentPage * SITE.postPerPage;
const startPost = lastPost - SITE.postPerPage;
const paginatedPosts = sortedPosts.slice(startPost, lastPost);
---
{
post ? (
<PostDetails post={post} />
) : (
<Posts
posts={paginatedPosts}
pageNum={currentPage}
totalPages={totalPages.length}
/>
)
}

View File

@ -0,0 +1,18 @@
---
import { SITE } from "@config";
import Posts from "@layouts/Posts.astro";
import getSortedPosts from "@utils/getSortedPosts";
import getPageNumbers from "@utils/getPageNumbers";
import { getCollection } from "astro:content";
const posts = await getCollection("blog");
const sortedPosts = getSortedPosts(posts);
const totalPages = getPageNumbers(sortedPosts.length);
const paginatedPosts = sortedPosts.slice(0, SITE.postPerPage);
---
<Posts posts={paginatedPosts} pageNum={1} totalPages={totalPages.length} />

21
src/pages/rss.xml.ts Normal file
View File

@ -0,0 +1,21 @@
import rss from "@astrojs/rss";
import { getCollection } from "astro:content";
import getSortedPosts from "@utils/getSortedPosts";
import slugify from "@utils/slugify";
import { SITE } from "@config";
export async function get() {
const posts = await getCollection("blog");
const sortedPosts = getSortedPosts(posts);
return rss({
title: SITE.title,
description: SITE.desc,
site: SITE.website,
items: sortedPosts.map(({ data }) => ({
link: `posts/${slugify(data)}`,
title: data.title,
description: data.description,
pubDate: new Date(data.pubDatetime),
})),
});
}

27
src/pages/search.astro Normal file
View File

@ -0,0 +1,27 @@
---
import { getCollection } from "astro:content";
import { SITE } from "@config";
import Layout from "@layouts/Layout.astro";
import Main from "@layouts/Main.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Search from "@components/Search";
// Retrieve all articles
const posts = await getCollection("blog", ({ data }) => !data.draft);
// List of items to search in
const searchList = posts.map(({ data }) => ({
title: data.title,
description: data.description,
data,
}));
---
<Layout title={`Search | ${SITE.title}`}>
<Header activeNav="search" />
<Main pageTitle="Search" pageDesc="Search any article ...">
<Search client:load searchList={searchList} />
</Main>
<Footer />
</Layout>

View File

@ -0,0 +1,56 @@
---
import { CollectionEntry, getCollection } from "astro:content";
import Layout from "@layouts/Layout.astro";
import Main from "@layouts/Main.astro";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Card from "@components/Card";
import getUniqueTags from "@utils/getUniqueTags";
import getPostsByTag from "@utils/getPostsByTag";
import slugify from "@utils/slugify";
import { SITE } from "@config";
import getSortedPosts from "@utils/getSortedPosts";
export interface Props {
post: CollectionEntry<"blog">;
tag: string;
}
export async function getStaticPaths() {
const posts = await getCollection("blog");
const tags = getUniqueTags(posts);
return tags.map(tag => {
return {
params: { tag },
props: { tag },
};
});
}
const { tag } = Astro.props;
const posts = await getCollection("blog", ({ data }) => !data.draft);
const tagPosts = getPostsByTag(posts, tag);
const sortTagsPost = getSortedPosts(tagPosts);
---
<Layout title={`Tag:${tag} | ${SITE.title}`}>
<Header activeNav="tags" />
<Main
pageTitle={`Tag:${tag}`}
pageDesc={`All the articles with the tag "${tag}".`}
>
<ul>
{
sortTagsPost.map(({ data }) => (
<Card href={`/posts/${slugify(data)}`} frontmatter={data} />
))
}
</ul>
</Main>
<Footer />
</Layout>

View File

@ -0,0 +1,24 @@
---
import { getCollection } from "astro:content";
import Header from "@components/Header.astro";
import Footer from "@components/Footer.astro";
import Layout from "@layouts/Layout.astro";
import Main from "@layouts/Main.astro";
import Tag from "@components/Tag.astro";
import getUniqueTags from "@utils/getUniqueTags";
import { SITE } from "@config";
const posts = await getCollection("blog");
let tags = getUniqueTags(posts);
---
<Layout title={`Tags | ${SITE.title}`}>
<Header activeNav="tags" />
<Main pageTitle="Tags" pageDesc="All the tags used in posts.">
<ul>
{tags.map(tag => <Tag name={tag} size="lg" />)}
</ul>
</Main>
<Footer />
</Layout>

131
src/styles/base.css Normal file
View File

@ -0,0 +1,131 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root,
html[data-theme="light"] {
--color-fill: 251, 254, 251;
--color-text-base: 40, 39, 40;
--color-accent: 0, 108, 172;
--color-card: 230, 230, 230;
--color-card-muted: 205, 205, 205;
--color-border: 236, 233, 233;
}
html[data-theme="dark"] {
--color-fill: 33, 39, 55;
--color-text-base: 234, 237, 243;
--color-accent: 255, 107, 1;
--color-card: 52, 63, 96;
--color-card-muted: 138, 51, 2;
--color-border: 171, 75, 8;
}
#sun-svg,
html[data-theme="dark"] #moon-svg {
display: none;
}
#moon-svg,
html[data-theme="dark"] #sun-svg {
display: block;
}
body {
@apply flex min-h-screen flex-col bg-skin-fill font-mono text-skin-base
selection:bg-skin-accent selection:bg-opacity-70 selection:text-skin-inverted;
}
section,
footer {
@apply mx-auto max-w-3xl px-4;
}
a {
@apply outline-2 outline-offset-1 outline-skin-fill
focus-visible:no-underline focus-visible:outline-dashed;
}
svg {
@apply inline-block h-6 w-6 fill-skin-base group-hover:fill-skin-accent;
}
svg.icon-tabler {
@apply inline-block h-6 w-6 scale-125 fill-transparent
stroke-current stroke-2 opacity-90 group-hover:fill-transparent
sm:scale-110;
}
.prose {
@apply prose-headings:!mb-3 prose-headings:!text-skin-base
prose-h3:italic prose-p:!text-skin-base
prose-a:!text-skin-base prose-a:!decoration-dashed prose-a:underline-offset-8
hover:prose-a:text-skin-accent prose-blockquote:!border-l-skin-accent
prose-blockquote:border-opacity-50 prose-blockquote:opacity-80
prose-figcaption:!text-skin-base prose-figcaption:opacity-70
prose-strong:!text-skin-base
prose-code:rounded prose-code:bg-skin-card
prose-code:bg-opacity-75 prose-code:p-1 prose-code:!text-skin-base
prose-code:before:!content-[''] prose-code:after:!content-['']
prose-pre:!text-skin-base prose-ol:!text-skin-base
prose-ul:overflow-x-clip prose-ul:!text-skin-base prose-li:marker:!text-skin-accent
prose-table:text-skin-base prose-th:border
prose-th:border-skin-line prose-td:border
prose-td:border-skin-line prose-img:mx-auto
prose-img:!mt-2 prose-img:border-2
prose-img:border-skin-line prose-hr:!border-skin-line;
}
.prose a {
@apply hover:!text-skin-accent;
}
.prose thead th:first-child,
tbody td:first-child,
tfoot td:first-child {
padding-left: 0.5714286em;
}
.prose h2#table-of-contents {
@apply mb-2;
}
.prose details {
@apply inline-block cursor-pointer select-none text-skin-base;
}
.prose summary {
@apply focus-outline;
}
.prose h2#table-of-contents + p {
@apply hidden;
}
/* ===== scrollbar ===== */
html {
overflow-y: scroll;
}
/* width */
::-webkit-scrollbar {
@apply w-3;
}
/* Track */
::-webkit-scrollbar-track {
@apply bg-skin-fill;
}
/* Handle */
::-webkit-scrollbar-thumb {
@apply bg-skin-card;
}
/* Handle on hover */
::-webkit-scrollbar-thumb:hover {
@apply bg-skin-card-muted;
}
code {
white-space: pre;
overflow: scroll;
}
}
@layer components {
.display-none {
@apply hidden;
}
.focus-outline {
@apply outline-2 outline-offset-1 outline-skin-fill focus-visible:no-underline focus-visible:outline-dashed;
}
}

43
src/types.ts Normal file
View File

@ -0,0 +1,43 @@
export type Site = {
website: string;
author: string;
desc: string;
title: string;
subtitle: string;
ogImage: string;
lightAndDarkMode: boolean;
postPerPage: number;
};
export type SocialObjects = {
name: SocialMedia;
href: string;
active: boolean;
linkTitle: string;
}[];
export type SocialIcons = {
[social in SocialMedia]: string;
};
export type SocialMedia =
| "Github"
| "Facebook"
| "Instagram"
| "LinkedIn"
| "Mail"
| "Twitter"
| "Twitch"
| "YouTube"
| "WhatsApp"
| "Snapchat"
| "Pinterest"
| "TikTok"
| "CodePen"
| "Discord"
| "GitLab"
| "Reddit"
| "Skype"
| "Steam"
| "Telegram"
| "Mastodon";

View File

@ -0,0 +1,155 @@
import satori, { SatoriOptions } from "satori";
import { SITE } from "@config";
import { writeFile } from "node:fs/promises";
import { Resvg } from "@resvg/resvg-js";
const fetchFonts = async () => {
// Regular Font
const fontFileRegular = await fetch(
"https://www.1001fonts.com/download/font/ibm-plex-mono.regular.ttf"
);
const fontRegular: ArrayBuffer = await fontFileRegular.arrayBuffer();
// Bold Font
const fontFileBold = await fetch(
"https://www.1001fonts.com/download/font/ibm-plex-mono.bold.ttf"
);
const fontBold: ArrayBuffer = await fontFileBold.arrayBuffer();
return { fontRegular, fontBold };
};
const { fontRegular, fontBold } = await fetchFonts();
const ogImage = (text: string) => {
return (
<div
style={{
background: "#fefbfb",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div
style={{
position: "absolute",
top: "-1px",
right: "-1px",
border: "4px solid #000",
background: "#ecebeb",
opacity: "0.9",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
margin: "2.5rem",
width: "88%",
height: "80%",
}}
/>
<div
style={{
border: "4px solid #000",
background: "#fefbfb",
borderRadius: "4px",
display: "flex",
justifyContent: "center",
margin: "2rem",
width: "88%",
height: "80%",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
margin: "20px",
width: "90%",
height: "90%",
}}
>
<p
style={{
fontSize: 72,
fontWeight: "bold",
maxHeight: "84%",
overflow: "hidden",
}}
>
{text}
</p>
<div
style={{
display: "flex",
justifyContent: "space-between",
width: "100%",
marginBottom: "8px",
fontSize: 28,
}}
>
<span>
by{" "}
<span
style={{
color: "transparent",
}}
>
"
</span>
<span style={{ overflow: "hidden", fontWeight: "bold" }}>
{SITE.author}
</span>
</span>
<span style={{ overflow: "hidden", fontWeight: "bold" }}>
{SITE.title}
</span>
</div>
</div>
</div>
</div>
);
};
const options: SatoriOptions = {
width: 1200,
height: 630,
embedFont: true,
fonts: [
{
name: "IBM Plex Mono",
data: fontRegular,
weight: 400,
style: "normal",
},
{
name: "IBM Plex Mono",
data: fontBold,
weight: 600,
style: "normal",
},
],
};
const generateOgImage = async (mytext = SITE.title) => {
const svg = await satori(ogImage(mytext), options);
// render png in production mode
if (import.meta.env.MODE === "production") {
const resvg = new Resvg(svg);
const pngData = resvg.render();
const pngBuffer = pngData.asPng();
console.info("Output PNG Image :", `${mytext}.png`);
await writeFile(`./dist/${mytext}.png`, pngBuffer);
}
return svg;
};
export default generateOgImage;

View File

@ -0,0 +1,14 @@
import { SITE } from "@config";
const getPageNumbers = (numberOfPosts: number) => {
const numberOfPages = numberOfPosts / Number(SITE.postPerPage);
let pageNumbers: number[] = [];
for (let i = 1; i <= Math.ceil(numberOfPages); i++) {
pageNumbers = [...pageNumbers, i];
}
return pageNumbers;
};
export default getPageNumbers;

View File

@ -0,0 +1,7 @@
import { slugifyAll } from "./slugify";
import type { CollectionEntry } from "astro:content";
const getPostsByTag = (posts: CollectionEntry<"blog">[], tag: string) =>
posts.filter(post => slugifyAll(post.data.tags).includes(tag));
export default getPostsByTag;

View File

@ -0,0 +1,12 @@
import type { CollectionEntry } from "astro:content";
const getSortedPosts = (posts: CollectionEntry<"blog">[]) =>
posts
.filter(({ data }) => !data.draft)
.sort(
(a, b) =>
Math.floor(new Date(b.data.pubDatetime).getTime() / 1000) -
Math.floor(new Date(a.data.pubDatetime).getTime() / 1000)
);
export default getSortedPosts;

View File

@ -0,0 +1,17 @@
import { slugifyStr } from "./slugify";
import type { CollectionEntry } from "astro:content";
const getUniqueTags = (posts: CollectionEntry<"blog">[]) => {
const filteredPosts = posts.filter(({ data }) => !data.draft);
const tags: string[] = filteredPosts
.flatMap(post => post.data.tags)
.map(tag => slugifyStr(tag))
.filter(
(value: string, index: number, self: string[]) =>
self.indexOf(value) === index
)
.sort((tagA: string, tagB: string) => tagA.localeCompare(tagB));
return tags;
};
export default getUniqueTags;

11
src/utils/slugify.ts Normal file
View File

@ -0,0 +1,11 @@
import { slug as slugger } from "github-slugger";
import type { BlogFrontmatter } from "@content/_schemas";
export const slugifyStr = (str: string) => slugger(str);
const slugify = (post: BlogFrontmatter) =>
post.postSlug ? slugger(post.postSlug) : slugger(post.title);
export const slugifyAll = (arr: string[]) => arr.map(str => slugifyStr(str));
export default slugify;

65
tailwind.config.cjs Normal file
View File

@ -0,0 +1,65 @@
function withOpacity(variableName) {
return ({ opacityValue }) => {
if (opacityValue !== undefined) {
return `rgba(var(${variableName}), ${opacityValue})`;
}
return `rgb(var(${variableName}))`;
};
}
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: {
// Remove the following screen breakpoint or add other breakpoints
// if one breakpoint is not enough for you
screens: {
sm: "640px",
},
// Uncomment the following extend
// if existing Tailwind color palette will be used
// extend: {
textColor: {
skin: {
base: withOpacity("--color-text-base"),
accent: withOpacity("--color-accent"),
inverted: withOpacity("--color-fill"),
},
},
backgroundColor: {
skin: {
fill: withOpacity("--color-fill"),
accent: withOpacity("--color-accent"),
inverted: withOpacity("--color-text-base"),
card: withOpacity("--color-card"),
"card-muted": withOpacity("--color-card-muted"),
},
},
outlineColor: {
skin: {
fill: withOpacity("--color-accent"),
},
},
borderColor: {
skin: {
line: withOpacity("--color-border"),
fill: withOpacity("--color-text-base"),
accent: withOpacity("--color-accent"),
},
},
fill: {
skin: {
base: withOpacity("--color-text-base"),
accent: withOpacity("--color-accent"),
},
transparent: "transparent",
},
fontFamily: {
mono: ["IBM Plex Mono", "monospace"],
},
// },
},
plugins: [require("@tailwindcss/typography")],
};

33
tsconfig.json Normal file
View File

@ -0,0 +1,33 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"baseUrl": "src",
"jsx": "react-jsx",
"paths": {
"@assets/*": [
"assets/*"
],
"@config": [
"config.ts"
],
"@components/*": [
"components/*"
],
"@content/*": [
"content/*"
],
"@layouts/*": [
"layouts/*"
],
"@pages/*": [
"pages/*"
],
"@styles/*": [
"styles/*"
],
"@utils/*": [
"utils/*"
]
}
}
}