Compare commits

...
Sign in to create a new pull request.

31 commits
svelte ... main

Author SHA1 Message Date
46e485a668 feat: update landing page 2025-08-24 12:50:42 +02:00
7481d043f2 fix: keep script inline 2025-08-03 12:03:29 +02:00
a0f5b86aac add webvital and error track 2025-07-23 08:49:26 +02:00
1ef855c029 chore: add rybbit metrics 2025-07-22 18:18:18 +02:00
0515fdf9bd feat: improve seo 2025-07-05 08:43:31 +02:00
3e0875a0d7 feat: add datenschutz 2025-07-05 08:01:47 +02:00
b3d158d52c feat: add picture, improve layout 2025-07-04 17:05:28 +02:00
bf720b79ee fix: cleanup component structure 2025-06-30 17:58:41 +02:00
2abae63f4b feat: add new blog post 2025-06-21 09:08:03 +02:00
dea6f3d9a9 chore: update deps 2025-06-20 21:33:37 +02:00
a89ad736b2 update landing page text 2025-05-22 12:49:37 +02:00
c1a2c6bbef fix: bad overflow attribute 2025-02-13 22:04:24 +01:00
c7e3b2c2d6 feat: lots of css fixes 2025-01-03 20:32:29 +01:00
3bf65ae7ab feat: mobile nav with animations 2025-01-03 19:50:29 +01:00
a839560bff feat: update blog post 2025-01-03 10:01:20 +01:00
799cf0611c feat: add sha256 and pgp verification 2025-01-03 09:38:16 +01:00
a155a9b355 feat: add projects page 2025-01-02 23:09:39 +01:00
a331872c79 fix: headline sizes 2025-01-02 21:09:31 +01:00
a31e367ef5 fix: lighthouse issues 2025-01-02 00:08:25 +01:00
91182a834c feat: add blog post about cv verification 2025-01-01 23:34:12 +01:00
a7f19ff451 fix: uid collision handling 2025-01-01 23:33:39 +01:00
beb2f2b61e feat: switch to nanoid over uuid 2025-01-01 22:36:29 +01:00
ba54eda347 feat: add oslo entry 2025-01-01 17:38:47 +01:00
95b5afa0d5 feat: add cv verification creation tool 2025-01-01 16:54:08 +01:00
245ad3a625 fix: properly load ubuntu font 2025-01-01 15:28:23 +01:00
3955e219e6 feat: responsive verification table 2025-01-01 14:18:49 +01:00
194b4b0808 feat: cv verifier 2025-01-01 14:00:05 +01:00
4dd699f08c feat: add astro-check 2024-12-12 09:51:12 +01:00
6c7c305fe1 feat: add remark reading time 2024-12-11 22:25:46 +01:00
a4f12f33a3 feat: add probe 2024-12-11 16:44:55 +01:00
5e67b2bb0d migrate to astro 2024-12-11 12:57:13 +01:00
194 changed files with 11204 additions and 8119 deletions

View file

@ -1,13 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock

View file

@ -1,20 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': () => require('typescript')
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: true
}
};

12
.gitignore vendored
View file

@ -1,8 +1,4 @@
.DS_Store .astro
node_modules .wrangler
/build dist
/.svelte-kit node_modules/
/package
.env
.env.*
!.env.example

1
.npmrc
View file

@ -1 +0,0 @@
engine-strict=true

View file

@ -1,14 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
Link.svelte

View file

@ -1,6 +1,11 @@
{ {
"useTabs": true, "plugins": ["prettier-plugin-astro"],
"singleQuote": true, "overrides": [
"trailingComma": "all", {
"printWidth": 100 "files": "*.astro",
"options": {
"parser": "astro"
}
}
]
} }

View file

@ -1,26 +1,64 @@
# Alex' small website # daichendt.one
Link to the [website](https://daichendt.one) Personal website built with Astro, TailwindCSS, and MDX.
Powered by Svelte & SvelteKit ## 🚀 Getting Started
## Developing ### Prerequisites
Once you've created a project and installed dependencies with `pnpm install`, start a development server: - [Node.js](https://nodejs.org/) (v20 or higher)
- [pnpm](https://pnpm.io/) (v8 or higher)
### Installation
1. Clone the repository:
```bash ```bash
pnpm dev git clone https://github.com/AlexDaichendt/site
cd site
# or start the server and open the app in a new browser tab
pnpm dev -- --open
``` ```
## Building 2. Install dependencies:
```bash
pnpm install
```
To create a production version of your app: ### Development
Start the development server:
```bash
pnpm dev
```
### Building for Production
Build the project:
```bash ```bash
pnpm build pnpm build
``` ```
You can preview the production build with `pnpm run preview`. Preview the production build:
```bash
pnpm preview
```
## 🛠 Tech Stack
- [Astro](https://astro.build)
- [TailwindCSS](https://tailwindcss.com)
- [MDX](https://mdxjs.com)
- [Sharp](https://sharp.pixelplumbing.com) for image optimization
- [Iconify](https://iconify.design) for icons
## 📦 Key Dependencies
- `@astrojs/mdx` - MDX integration
- `@astrojs/rss` - RSS feed support
- `@astrojs/sitemap` - Sitemap generation
- `@astrojs/tailwind` - TailwindCSS integration
- `@fontsource/ubuntu` - Ubuntu font
- `astro-icon` - Icon component
- `remark-emoji` - Emoji support in markdown
## 🙏 Credits
This theme is based on the [Bear Blog](https://github.com/HermanMartinus/bearblog/) theme.

38
astro.config.mjs Normal file
View file

@ -0,0 +1,38 @@
// @ts-check
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";
import { defineConfig } from "astro/config";
import remarkEmoji from "remark-emoji";
import tailwind from "@astrojs/tailwind";
import icon from "astro-icon";
import { remarkReadingTime } from "./src/remark/remark-reading-time";
import cloudflare from "@astrojs/cloudflare";
// https://astro.build/config
export default defineConfig({
prefetch: {
defaultStrategy: "hover",
prefetchAll: true,
},
site: "https://daichendt.one",
integrations: [
mdx({
remarkPlugins: [remarkEmoji, remarkReadingTime],
}),
sitemap(),
tailwind(),
icon(),
],
adapter: cloudflare({
imageService: "compile",
platformProxy: {
enabled: true,
},
}),
});

24
drizzle.config.ts Normal file
View file

@ -0,0 +1,24 @@
import type { Config } from "drizzle-kit";
const { LOCAL_DB_PATH, DB_ID, D1_TOKEN, CF_ACCOUNT_ID } = process.env;
// Use better-sqlite driver for local development
export default LOCAL_DB_PATH
? ({
schema: "./src/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: LOCAL_DB_PATH,
},
} satisfies Config)
: ({
schema: "./src/db/schema.ts",
out: "./migrations",
dialect: "sqlite",
driver: "d1-http",
dbCredentials: {
databaseId: DB_ID!,
token: D1_TOKEN!,
accountId: CF_ACCOUNT_ID!,
},
} satisfies Config);

View file

@ -1,22 +0,0 @@
import { defineMDSveXConfig as defineConfig } from 'mdsvex';
import remarkGFM from 'remark-gfm';
import remarkEmoji from 'remark-emoji';
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import remarkFootnotes from 'remark-footnotes';
const config = defineConfig({
layout: {
blog: './src/lib/layouts/blog.svelte',
},
extensions: ['.svelte.md', '.md', '.svx'],
smartypants: {
dashes: 'oldschool',
},
remarkPlugins: [remarkGFM, remarkEmoji, remarkFootnotes],
rehypePlugins: [rehypeSlug, [rehypeAutolinkHeadings, { behaviour: 'append' }]],
});
export default config;

View file

@ -0,0 +1,8 @@
CREATE TABLE `cv` (
`uuid` text PRIMARY KEY NOT NULL,
`company_name` text NOT NULL,
`created` integer,
`author` text NOT NULL,
`created_for` text NOT NULL,
`status` text DEFAULT 'pending'
);

View file

@ -0,0 +1,15 @@
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_cv` (
`uuid` text PRIMARY KEY NOT NULL,
`company_name` text NOT NULL,
`created` integer,
`author` text NOT NULL,
`purpose` text NOT NULL,
`tooling` text NOT NULL,
`status` text DEFAULT 'active'
);
--> statement-breakpoint
INSERT INTO `__new_cv`("uuid", "company_name", "created", "author", "purpose", "tooling", "status") SELECT "uuid", "company_name", "created", "author", "purpose", "tooling", "status" FROM `cv`;--> statement-breakpoint
DROP TABLE `cv`;--> statement-breakpoint
ALTER TABLE `__new_cv` RENAME TO `cv`;--> statement-breakpoint
PRAGMA foreign_keys=ON;

View file

@ -0,0 +1,2 @@
ALTER TABLE `cv` ADD `sha256` text;--> statement-breakpoint
ALTER TABLE `cv` ADD `pgp_signature` text;

View file

@ -0,0 +1,71 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ed238646-2f39-4026-8075-4ddf059cc6f7",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"cv": {
"name": "cv",
"columns": {
"uuid": {
"name": "uuid",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"company_name": {
"name": "company_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created": {
"name": "created",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_for": {
"name": "created_for",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'pending'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,80 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a68f3965-0dd6-46c6-af01-4f96061e8b11",
"prevId": "ed238646-2f39-4026-8075-4ddf059cc6f7",
"tables": {
"cv": {
"name": "cv",
"columns": {
"uuid": {
"name": "uuid",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"company_name": {
"name": "company_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created": {
"name": "created",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"purpose": {
"name": "purpose",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tooling": {
"name": "tooling",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'active'"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {
"\"cv\".\"created_for\"": "\"cv\".\"purpose\""
}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,92 @@
{
"version": "6",
"dialect": "sqlite",
"id": "5f7163dc-e8ca-4bd2-ab41-6b244d02aaf7",
"prevId": "a68f3965-0dd6-46c6-af01-4f96061e8b11",
"tables": {
"cv": {
"name": "cv",
"columns": {
"uuid": {
"name": "uuid",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"company_name": {
"name": "company_name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created": {
"name": "created",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"purpose": {
"name": "purpose",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"tooling": {
"name": "tooling",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'active'"
},
"sha256": {
"name": "sha256",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pgp_signature": {
"name": "pgp_signature",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View file

@ -0,0 +1,27 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1735731743025,
"tag": "0000_naive_tarantula",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1735735966188,
"tag": "0001_confused_wendell_rand",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1735887287143,
"tag": "0002_naive_revanche",
"breakpoints": true
}
]
}

View file

@ -1,58 +1,50 @@
{ {
"name": "daichendt.one", "name": "daichendt.one-astro",
"version": "1.0.0", "type": "module",
"license": "MIT", "version": "0.0.1",
"author": { "scripts": {
"email": "me@daichendt.one", "dev": "astro dev",
"name": "Alex Daichendt" "build": "astro build",
}, "preview": "astro preview",
"type": "module", "astro": "astro",
"scripts": { "db:generate": "drizzle-kit generate",
"dev": "vite dev", "db:migrate:local": "wrangler d1 migrations apply cv-verification --local",
"build": "vite build", "db:migrate:prod": "wrangler d1 migrations apply cv-verification --remote",
"package": "svelte-kit package", "db:migrate:preview": "wrangler d1 migrations apply --env preview d1-demo-preview-db --remote",
"preview": "vite preview", "db:studio:local": "LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) drizzle-kit studio",
"postinstall": "svelte-kit sync", "db:studio:preview": "source .drizzle.env && DB_ID='yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' drizzle-kit studio",
"check": "svelte-check --tsconfig ./tsconfig.json", "db:studio:prod": "source .drizzle.env && DB_ID='ae0b9867-26f2-4a5b-8aa6-805a86792662' drizzle-kit studio"
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", },
"lint": "prettier --check --plugin-search-dir=. . && eslint .", "dependencies": {
"format": "prettier --write --plugin-search-dir=. ." "@astrojs/check": "^0.9.4",
}, "@astrojs/cloudflare": "^12.6.0",
"devDependencies": { "@astrojs/mdx": "^4.3.0",
"@sveltejs/adapter-cloudflare": "^1.0.0", "@astrojs/rss": "^4.0.12",
"@sveltejs/kit": "^1.0.1", "@astrojs/sitemap": "^3.4.1",
"@typescript-eslint/eslint-plugin": "^5.47.1", "@astrojs/tailwind": "^6.0.2",
"@typescript-eslint/parser": "^5.47.1", "@cloudflare/workers-types": "^4.20241230.0",
"autoprefixer": "^10.4.13", "@fontsource/fira-sans": "^5.1.1",
"browserslist": "^4.21.4", "@fontsource/ubuntu": "^5.1.0",
"eslint": "^8.31.0", "@iconify-json/mdi": "^1.2.1",
"eslint-config-prettier": "^8.3.0", "@iconify-json/simple-icons": "^1.2.14",
"eslint-plugin-svelte3": "^4.0.0", "astro": "^5.10.0",
"fontaine": "^0.4.1", "astro-icon": "^1.1.4",
"mdi-svelte": "^1.1.2", "drizzle-orm": "^0.38.3",
"mdsvex": "^0.10.6", "mdast-util-to-string": "^4.0.0",
"postcss": "^8.4.20", "nanoid": "^5.0.9",
"postcss-load-config": "^4.0.1", "reading-time": "^1.5.0",
"postcss-normalize": "^10.0.1", "sharp": "^0.33.5",
"prettier": "^2.8.1", "tailwindcss": "^3.4.16",
"prettier-plugin-svelte": "^2.9.0", "typescript": "^5.7.2"
"rehype-autolink-headings": "^6.1.1", },
"rehype-slug": "^5.1.0", "devDependencies": {
"remark-emoji": "^3.0.2", "@tailwind-plugin/expose-colors": "^1.1.8",
"remark-footnotes": "2.0", "@types/node": "^22.10.3",
"remark-gfm": "^3.0.1", "better-sqlite3": "^11.7.0",
"svelte": "^3.55.0", "cross-env": "^7.0.3",
"svelte-check": "^3.0.1", "drizzle-kit": "^0.30.1",
"svelte-preprocess": "^5.0.0", "prettier": "^3.4.2",
"tslib": "^2.4.1", "prettier-plugin-astro": "^0.14.1",
"typescript": "^4.9.4", "remark-emoji": "^5.0.1"
"vite": "^4.0.3", }
"vite-imagetools": "^4.0.12"
},
"dependencies": {
"@fontsource/ubuntu-mono": "^4.5.11",
"@mdi/js": "^7.1.96",
"imagetools-core": "^3.2.3"
},
"browserslist": "last 2 versions"
} }

9148
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

38
public/robots.txt Normal file
View file

@ -0,0 +1,38 @@
User-agent: GPTBot
Disallow: /
User-agent: ChatGPT-User
Disallow: /
User-agent: Google-Extended
Disallow: /
User-agent: CCBot
Disallow: /
User-agent: anthropic-ai
Disallow: /
User-agent: Claude-Web
Disallow: /
User-agent: cohere-ai
Disallow: /
User-agent: Omgilibot
Disallow: /
User-agent: Omgili
Disallow: /
User-agent: FacebookBot
Disallow: /
User-agent: Amazonbot
Disallow: /
# Common AI scraper endpoints
Disallow: /*?*source=
Disallow: /*?*ref=
Disallow: /*?*ai=
Disallow: /*&*ai=

15
src/app.d.ts vendored
View file

@ -1,15 +0,0 @@
/// <reference types="@sveltejs/kit" />
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
// and what to do when importing types
declare namespace App {
// interface Locals {}
// interface Platform {}
// interface Session {}
// interface Stuff {
// title?: string;
// description?: string;
// keywords?: string[];
// }
}

View file

@ -1,16 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-nu-scheme-is="light" data-nu-contrast-is="no-preference">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
%sveltekit.head%
</head>
<body>
<div>%sveltekit.body%</div>
</body>
</html>

View file

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 551 KiB

After

Width:  |  Height:  |  Size: 551 KiB

Before After
Before After

BIN
src/assets/cv_backend.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

BIN
src/assets/cv_backend2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
src/assets/cv_demo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View file

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 268 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
src/assets/me.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

Before

Width:  |  Height:  |  Size: 410 KiB

After

Width:  |  Height:  |  Size: 410 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before After
Before After

View file

@ -0,0 +1,119 @@
---
import "../styles/global.css";
import ubuntuRegularWoff2 from "@fontsource/ubuntu/files/ubuntu-latin-400-normal.woff2?url";
import ubuntuBoldWoff2 from "@fontsource/ubuntu/files/ubuntu-latin-700-normal.woff2?url";
import "@fontsource/fira-sans";
interface Props {
title: string;
description: string;
image?: string;
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { title, description, image = "/blog-placeholder-1.jpg" } = Astro.props;
---
<!-- Global Metadata -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="generator" content={Astro.generator} />
<!-- Font preloads, keep them even if it throws a warning for not using them due to the system providing them -->
<link
rel="preload"
href={ubuntuRegularWoff2}
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href={ubuntuBoldWoff2}
as="font"
type="font/woff2"
crossorigin
/>
<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL} />
<!-- Primary Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={new URL(image, Astro.url)} />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={Astro.url} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={new URL(image, Astro.url)} />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Person",
"name": "Alexander Daichendt",
"email": "jsonld@daichendt.one",
"url": "https://daichendt.one/",
"image": "https://daichendt.one/files/alexdaichendt.jpg",
"sameAs": ["https://github.com/alexdaichendt"],
"jobTitle": "IT Consultant",
"worksFor": {
"@type": "Organization",
"name": "Freelance"
},
"alumniOf": {
"@type": "CollegeOrUniversity",
"name": "Technical University Munich"
},
"nationality": {
"@type": "Country",
"name": "Germany"
},
"description": "Privacy-first software engineer passionate about building efficient, user-friendly, and data-respectful web applications. Experienced in Rust, Node.js, and more, delivering scalable, standards-compliant solutions with a focus on usability.",
"hasCredential": {
"@context": "https://schema.org",
"@type": "EducationalOccupationalCredential",
"credentialCategory": "Master's degree",
"name": "Master of Science in Informatics",
"description": "Graduate degree awarded for completing the Master of Science program in Informatics at the Technical University of Munich.",
"educationalLevel": "Master's",
"recognizedBy": {
"@type": "CollegeOrUniversity",
"name": "Technical University of Munich",
"url": "https://www.tum.de"
},
"awardedBy": {
"@type": "CollegeOrUniversity",
"name": "Technical University of Munich",
"url": "https://www.tum.de"
},
"subjectOf": {
"@type": "EducationalProgram",
"name": "Informatics",
"url": "https://www.in.tum.de/en/for-prospective-students/masters-programs/informatics-msc/"
}
}
}
</script>
<script
src="https://rybbit.shiverpeak.xyz/api/script.js"
data-site-id="1"
data-web-vitals="true"
data-track-errors="true"
is:inline
defer></script>

View file

@ -0,0 +1,233 @@
---
import type { ImageMetadata } from "astro";
import { Icon } from "astro-icon/components";
interface Props {
images: {
src: ImageMetadata;
alt: string;
}[];
}
const { images } = Astro.props;
const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
const helpId = `${carouselId}-help`;
---
<section
id={carouselId}
class="relative overflow-hidden rounded-lg"
role="region"
aria-roledescription="carousel"
aria-label="Image carousel"
data-carousel-id={carouselId}
tabindex="0"
aria-describedby={helpId}
>
<!-- Slides -->
<div class="relative h-0 pb-[56.25%]">
{
images.map((image, i) => (
<img
src={image.src.src}
alt={image.alt}
class={`
absolute inset-0 w-full h-full object-cover object-top
transition-opacity duration-500 ease-in-out
${i === 0 ? "opacity-100" : "opacity-0"}
carousel-slide
`}
data-index={i}
/>
))
}
</div>
<!-- Navigation Buttons -->
<button
type="button"
class="absolute left-4 top-1/2 -translate-y-1/2
flex h-10 w-10 items-center justify-center
rounded-full bg-white/70 backdrop-blur-sm
text-gray-800 hover:bg-white focus:outline-none
focus-visible:ring-2 focus-visible:ring-indigo-600
transition-colors"
aria-label="Previous slide"
data-dir="prev"
>
<Icon name="mdi:chevron-left" class="h-5 w-5" />
<span class="sr-only">Previous</span>
</button>
<button
type="button"
class="absolute right-4 top-1/2 -translate-y-1/2
flex h-10 w-10 items-center justify-center
rounded-full bg-white/70 backdrop-blur-sm
text-gray-800 hover:bg-white focus:outline-none
focus-visible:ring-2 focus-visible:ring-indigo-600
transition-colors"
aria-label="Next slide"
data-dir="next"
>
<Icon name="mdi:chevron-right" class="h-5 w-5" />
<span class="sr-only">Next</span>
</button>
<!-- Theater toggle button (bottom-right) -->
<button
type="button"
class="absolute bottom-4 right-4 flex h-10 w-10 items-center justify-center
rounded-full bg-white/30 backdrop-blur-sm text-gray-800
hover:bg-white focus:outline-none focus-visible:ring-2
focus-visible:ring-indigo-600 transition-colors"
aria-label="Enter wide mode"
aria-pressed="false"
data-theater-toggle
>
<Icon name="mdi:arrow-expand-horizontal" class="h-5 w-5" />
</button>
<!-- Live region for screenreader announcements -->
<div
class="sr-only"
aria-live="polite"
aria-atomic="true"
data-live-announcer
>
</div>
<!-- Hidden helper text for screen readers -->
<p id={helpId} class="sr-only">
Carousel focused. Use Left and Right Arrow keys to change slides. Press T to
toggle wide mode. Press Escape to exit wide mode.
</p>
</section>
<script define:vars={{ carouselId }}>
document.addEventListener("DOMContentLoaded", () => {
const carousel = document.getElementById(carouselId);
if (!carousel) return;
const slides = carousel.querySelectorAll(".carousel-slide");
const prevBtn = carousel.querySelector('[data-dir="prev"]');
const nextBtn = carousel.querySelector('[data-dir="next"]');
const liveAnnouncer = carousel.querySelector("[data-live-announcer]");
const theaterBtn = carousel.querySelector("[data-theater-toggle]");
let current = 0;
const total = slides.length;
let autoRotateTimer;
let theater = false;
const updateTheaterButton = () => {
if (!theaterBtn) return;
theaterBtn.setAttribute("aria-pressed", theater ? "true" : "false");
theaterBtn.setAttribute(
"aria-label",
theater ? "Exit wide mode" : "Enter wide mode",
);
const icon = theaterBtn.querySelector("svg");
if (icon) {
icon.setAttribute("data-icon-state", theater ? "collapse" : "expand");
}
};
const applyTheaterState = () => {
const section = carousel.closest("[data-threecol]");
if (!section) return;
section.classList.toggle("theater-mode", theater);
updateTheaterButton();
};
const toggleTheater = () => {
theater = !theater;
applyTheaterState();
};
const exitTheater = () => {
if (!theater) return;
theater = false;
applyTheaterState();
};
theaterBtn?.addEventListener("click", (e) => {
e.preventDefault();
toggleTheater();
});
const showSlide = (index) => {
slides[current].classList.remove("opacity-100");
slides[current].classList.add("opacity-0");
current = index;
slides[current].classList.remove("opacity-0");
slides[current].classList.add("opacity-100");
if (liveAnnouncer) {
liveAnnouncer.textContent = `Slide ${current + 1} of ${total}`;
}
};
const goPrev = () => {
const prev = (current - 1 + total) % total;
showSlide(prev);
};
const goNext = () => {
const next = (current + 1) % total;
showSlide(next);
};
prevBtn?.addEventListener("click", goPrev);
nextBtn?.addEventListener("click", goNext);
// Key handling (now works when the section itself is focused)
carousel.addEventListener("keydown", (e) => {
const target = e.target;
const isTyping =
target instanceof HTMLElement &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable);
if (isTyping) return;
if (e.key === "ArrowLeft") {
e.preventDefault();
goPrev();
} else if (e.key === "ArrowRight") {
e.preventDefault();
goNext();
} else if (
(e.key === "t" || e.key === "T") &&
!e.altKey &&
!e.metaKey &&
!e.ctrlKey
) {
e.preventDefault();
toggleTheater();
} else if (e.key === "Escape") {
exitTheater();
}
});
const startAutoRotate = () => {
stopAutoRotate();
autoRotateTimer = window.setInterval(goNext, 5000);
};
const stopAutoRotate = () => {
if (autoRotateTimer) {
clearInterval(autoRotateTimer);
autoRotateTimer = undefined;
}
};
startAutoRotate();
carousel.addEventListener("mouseenter", stopAutoRotate);
carousel.addEventListener("mouseleave", startAutoRotate);
carousel.addEventListener("focusin", stopAutoRotate);
carousel.addEventListener("focusout", startAutoRotate);
});
</script>

View file

@ -0,0 +1,17 @@
---
interface Props {
date: Date;
}
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString('en-us', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
</time>

View file

@ -0,0 +1,56 @@
---
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"a">;
const { href, class: className, ...props } = Astro.props;
const pathname = Astro.url.pathname.replace(import.meta.env.BASE_URL, "");
const subpath = pathname.match(/[^\/]+/g);
const isActive = href === pathname || href === "/" + (subpath?.[0] || "");
---
<a
href={href}
class:list={[
className,
isActive ? "active" : "",
"p-2 hover:text-mytheme-800 hover:dark:text-mytheme-100 inline-block no-underline relative",
]}
{...props}
>
<slot />
</a>
<style>
/* Hover animation for non-active links */
a:not(.active)::after {
content: "";
position: absolute;
width: 0;
height: 4px;
bottom: 0;
left: 50%;
background-color: var(--tw-mytheme-400);
transition: all 0.3s ease-in-out;
transform: translateX(-50%);
}
a:not(.active):hover::after {
width: 100%;
}
/* Solid underline for active links */
a.active::after {
content: "";
position: absolute;
width: 100%;
height: 4px;
bottom: 0;
left: 0;
background-color: var(--tw-mytheme-400);
}
a.active {
font-weight: bold;
}
</style>

View file

@ -0,0 +1,275 @@
---
interface Props {
companyName?: string;
ownerName?: string;
address?: {
street?: string;
city?: string;
postalCode?: string;
country?: string;
};
contact?: {
phone?: string;
email: string;
website: string;
};
business?: {
vatId?: string;
registrationOffice?: string;
};
responsiblePerson?: {
name: string;
address: {
street: string;
city: string;
postalCode: string;
country: string;
};
};
}
const {
companyName,
ownerName,
address,
contact,
business,
responsiblePerson,
} = Astro.props;
---
<div class="max-w-4xl mx-auto space-y-12">
<section>
<h2
class="text-2xl font-bold text-gray-900 dark:text-white mb-6 border-b border-gray-200 dark:border-gray-700 pb-3"
>
Diensteanbieter
</h2>
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<dt class="font-semibold text-gray-900 dark:text-white">Firmenname:</dt>
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
{companyName}
</dd>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<dt class="font-semibold text-gray-900 dark:text-white">Inhaber:</dt>
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
{ownerName}
</dd>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<dt class="font-semibold text-gray-900 dark:text-white">Anschrift:</dt>
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
{address?.street}<br />
{address?.postalCode}
{address?.city}<br />
{address?.country}
</dd>
</div>
</div>
</section>
<!-- Contact Information -->
<section>
<h2
class="text-2xl font-bold text-gray-900 dark:text-white mb-6 border-b border-gray-200 dark:border-gray-700 pb-3"
>
Kontaktdaten
</h2>
<div class="space-y-4">
{
contact?.phone && (
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<dt class="font-semibold text-gray-900 dark:text-white">
Telefon:
</dt>
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
{contact?.phone}
</dd>
</div>
)
}
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<dt class="font-semibold text-gray-900 dark:text-white">E-Mail:</dt>
<dd class="md:col-span-2">
<a
href={`mailto:${contact?.email}`}
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline"
>
{contact?.email}
</a>
</dd>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<dt class="font-semibold text-gray-900 dark:text-white">Website:</dt>
<dd class="md:col-span-2">
<a
href={contact?.website}
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline"
>
{contact?.website}
</a>
</dd>
</div>
</div>
</section>
<!-- Business Registration -->
{
(business?.vatId || business?.registrationOffice) && (
<section>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6 border-b border-gray-200 dark:border-gray-700 pb-3">
Gewerbliche Angaben
</h2>
<div class="space-y-4">
{business?.vatId && (
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<dt class="font-semibold text-gray-900 dark:text-white">
Umsatzsteuer-ID:
</dt>
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
{business.vatId}
<br />
<span class="text-sm text-gray-500 dark:text-gray-400">
gemäß § 27a Umsatzsteuergesetz
</span>
</dd>
</div>
)}
{business.registrationOffice && (
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<dt class="font-semibold text-gray-900 dark:text-white">
Gewerbeanmeldung:
</dt>
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
{business?.registrationOffice}
</dd>
</div>
)}
</div>
</section>
)
}
<!-- Responsible for Content -->
<section>
<h2
class="text-2xl font-bold text-gray-900 dark:text-white mb-6 border-b border-gray-200 dark:border-gray-700 pb-3"
>
Verantwortlich für den Inhalt
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<dt class="font-semibold text-gray-900 dark:text-white">
Verantwortlich nach § 55 Abs. 2 RStV:
</dt>
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
{responsiblePerson?.name}<br />
{responsiblePerson?.address.street}<br />
{responsiblePerson?.address.postalCode}
{responsiblePerson?.address.city}<br />
{responsiblePerson?.address.country}
</dd>
</div>
</section>
<!-- EU Dispute Resolution -->
<section>
<h2
class="text-2xl font-bold text-gray-900 dark:text-white mb-6 border-b border-gray-200 dark:border-gray-700 pb-3"
>
EU-Streitschlichtung
</h2>
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<dt class="font-semibold text-gray-900 dark:text-white">
Online-Streitbeilegung:
</dt>
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
Die Europäische Kommission stellt eine Plattform zur
Online-Streitbeilegung (OS) bereit:<br />
<a
href="https://ec.europa.eu/consumers/odr/"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline"
>
https://ec.europa.eu/consumers/odr/
</a>
</dd>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<dt class="font-semibold text-gray-900 dark:text-white">
Verbraucherschlichtung:
</dt>
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren
vor einer Verbraucherschlichtungsstelle teilzunehmen.
</dd>
</div>
</div>
</section>
<!-- Disclaimer Section -->
<section>
<h2
class="text-2xl font-bold text-gray-900 dark:text-white mb-6 border-b border-gray-200 dark:border-gray-700 pb-3"
>
Haftungsausschluss
</h2>
<!-- Content Liability -->
<div class="mb-8">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Haftung für Inhalte
</h3>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">
Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf
diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8
bis 10 TMG sind wir als Diensteanbieter jedoch nicht unter der
Verpflichtung, übermittelte oder gespeicherte fremde Informationen zu
überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige
Tätigkeit hinweisen.
</p>
</div>
<!-- Links Liability -->
<div class="mb-8">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Haftung für Links
</h3>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">
Unser Angebot enthält Links zu externen Websites Dritter, auf deren
Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden
Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten
Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten
verantwortlich.
</p>
</div>
<!-- Copyright -->
<div class="mb-8">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Urheberrecht
</h3>
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen
Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung,
Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der
Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des
jeweiligen Autors bzw. Erstellers.
</p>
</div>
</section>
<!-- Footer Note -->
<div class="border-t border-gray-200 dark:border-gray-600 pt-8 mt-12">
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">
Letzte Aktualisierung: <span class="font-medium">
{new Date().toLocaleDateString("de-DE")}
</span>
</p>
</div>
</div>

5
src/components/Li.astro Normal file
View file

@ -0,0 +1,5 @@
<li class="pt-2 mr-4" {...Astro.props}>
<div class="self-center">
<slot />
</div>
</li>

27
src/components/Link.astro Normal file
View file

@ -0,0 +1,27 @@
---
interface Props {
href: string;
disablePrefetch?: boolean;
}
const { href, disablePrefetch = false } = Astro.props;
const internal = !href.startsWith("http");
let linkProps = internal
? !disablePrefetch
? { "data-astro-prefetch": `${!disablePrefetch}` }
: {}
: {
rel: "nofollow noreferrer noopener",
target: "_blank",
};
---
<a
{...linkProps}
{href}
class="text-special text-mytheme-700 dark:text-mytheme-300 font-medium hover:bg-outline hover:text-dark no-underline"
>
<span class="underline break-words"><slot /></span></a
>

168
src/components/Logo.astro Normal file
View file

@ -0,0 +1,168 @@
<!--?xml version="1.0" encoding="UTF-8" standalone="no"?-->
<svg
version="1.0"
width="3364pt"
height="832pt"
viewBox="0 0 3364 832"
preserveAspectRatio="xMidYMid"
id="svg44"
class="logo"
xmlns="http://www.w3.org/2000/svg"
>
<g
transform="matrix(0.1,0,0,-0.1,-312.98366,2490.1118)"
stroke="none"
id="g44"
class="logo-group"
>
<path
d="m 4485,24500 c -123,-9 -208,-30 -304,-75 -324,-154 -540,-444 -581,-785 -14,-114 -14,-5531 0,-5722 17,-236 62,-375 172,-535 135,-196 338,-342 568,-406 l 75,-21 2762,-4 c 2804,-3 3115,0 3238,33 93,25 255,108 336,173 216,172 354,409 373,642 3,36 6,1363 8,2950 2,2719 1,2889 -15,2960 -35,150 -87,259 -186,389 -108,142 -225,236 -386,312 -126,59 -224,79 -430,90 -214,10 -5485,9 -5630,-1 z m 5700,-411 c 205,-25 386,-151 475,-333 82,-165 75,114 75,-3041 v -2810 l -22,-82 c -51,-190 -161,-327 -328,-408 -141,-68 45,-64 -3039,-65 -2759,-1 -2782,-1 -2859,19 -116,30 -190,74 -282,166 -93,92 -145,184 -173,305 -16,70 -17,253 -15,2898 2,3087 -2,2873 59,2994 82,165 211,282 374,340 l 65,23 1650,6 c 2136,7 3902,2 4020,-12 z"
id="path1"></path>
<path
d="m 6386,23148 c -15,-23 -124,-291 -294,-723 -125,-316 -183,-470 -407,-1065 -106,-283 -274,-724 -373,-980 -216,-559 -431,-1137 -445,-1195 -3,-14 161,-22 474,-24 l 266,-1 36,78 c 33,73 85,196 226,546 33,83 66,155 73,162 10,10 105,13 385,15 205,2 375,0 378,-3 4,-3 9,-417 12,-920 4,-649 9,-915 17,-920 17,-12 1352,-9 1516,2 352,25 581,87 860,232 397,206 682,521 817,902 67,190 88,421 79,861 -6,309 -12,356 -71,560 -34,118 -138,328 -217,437 -91,126 -231,281 -312,347 -272,221 -475,310 -771,341 -94,9 -396,28 -402,25 -1,-1 29,-84 68,-186 38,-101 94,-249 123,-329 78,-211 86,-228 104,-235 9,-4 59,-16 111,-26 352,-70 592,-366 672,-827 44,-258 8,-542 -101,-793 -49,-114 -107,-197 -201,-290 -217,-216 -542,-354 -889,-380 -204,-15 -662,-8 -683,10 -15,12 -17,66 -17,587 0,476 2,575 14,586 9,9 59,19 137,26 67,7 125,14 127,17 13,13 -205,594 -235,628 -15,16 -58,17 -634,17 -339,0 -624,3 -633,6 -14,5 -14,10 0,42 8,21 48,125 89,232 90,239 188,495 220,575 25,63 115,294 167,430 17,44 70,179 118,300 48,121 95,245 105,275 10,30 24,68 32,84 26,50 32,38 193,-394 185,-497 310,-821 457,-1190 277,-695 326,-821 462,-1200 73,-201 151,-417 174,-480 l 43,-115 39,1 c 39,1 232,21 425,45 52,6 128,14 167,18 94,8 103,17 79,79 -10,26 -29,74 -41,107 -12,33 -34,89 -47,125 -14,36 -66,175 -116,310 -222,598 -313,841 -437,1160 -70,178 -118,305 -273,715 -183,488 -403,1049 -520,1330 l -29,70 -239,6 c -131,4 -380,7 -551,8 l -313,1 z"
id="path2"></path>
<path
d="m 18208,22228 c -1,-304 -1,-588 1,-630 2,-43 -1,-78 -6,-78 -5,0 -52,37 -105,83 -166,143 -340,214 -548,224 -189,9 -349,-28 -502,-116 -175,-100 -295,-230 -388,-416 -233,-467 -142,-1104 205,-1434 122,-116 277,-202 430,-237 110,-26 380,-27 475,-1 145,38 312,144 405,256 28,33 55,59 60,57 6,-2 11,-61 13,-144 l 3,-142 h 214 c 117,0 216,3 218,8 6,9 9,822 8,2100 l -1,1022 h -240 -240 z m -433,-855 c 108,-29 184,-72 267,-154 84,-82 131,-165 165,-289 25,-92 25,-348 -1,-440 -28,-103 -83,-197 -165,-283 -60,-63 -91,-86 -155,-117 -100,-47 -160,-60 -284,-60 -177,0 -285,43 -403,159 -84,84 -122,152 -162,289 -18,65 -22,101 -22,232 0,180 13,240 76,367 89,178 240,288 429,313 71,10 179,2 255,-17 z"
id="path3"></path>
<path
d="m 24805,21214 v -1566 l 242,4 c 133,1 244,6 248,10 4,4 9,303 12,665 5,607 6,663 24,724 78,270 377,420 654,327 143,-48 241,-169 271,-337 10,-59 13,-228 14,-735 v -658 l 239,4 c 132,2 243,7 248,12 13,13 16,1294 3,1421 -29,290 -199,563 -417,672 -124,62 -203,76 -391,70 -159,-5 -193,-11 -303,-58 -77,-32 -204,-118 -272,-184 -33,-31 -61,-54 -64,-52 -2,3 -4,285 -3,626 l 1,621 h -253 -253 z"
id="path4"></path>
<path
d="m 33730,22144 c 1,-350 0,-638 -3,-640 -2,-2 -40,29 -84,69 -94,87 -143,122 -240,170 -241,122 -556,121 -798,-1 -147,-73 -244,-153 -335,-274 -101,-134 -179,-306 -216,-478 -28,-128 -26,-412 4,-536 65,-275 193,-497 368,-640 119,-98 302,-181 446,-204 110,-18 353,-8 438,18 143,42 281,132 399,258 38,41 72,74 75,74 3,0 7,-69 8,-152 l 3,-153 216,-3 216,-2 7,297 c 7,316 6,2651 -1,2766 l -5,67 h -249 -250 z m -466,-759 c 240,-51 421,-234 470,-477 20,-95 20,-276 1,-381 -21,-120 -90,-253 -172,-333 -112,-109 -252,-164 -419,-164 -248,0 -435,112 -543,326 -94,186 -113,416 -51,616 47,150 173,304 302,367 117,58 274,75 412,46 z"
id="path5"></path>
<path
d="m 21683,22716 c -90,-28 -179,-110 -201,-184 -62,-206 93,-394 313,-380 93,6 174,47 243,122 60,66 76,110 69,181 -11,110 -103,225 -207,258 -61,20 -158,21 -217,3 z"
id="path6"></path>
<path
d="m 35036,22349 c -3,-34 -6,-174 -6,-310 v -249 h -215 -215 v -149 c 0,-81 3,-176 6,-210 l 7,-61 h 96 c 53,0 146,-3 208,-7 l 111,-6 5,-611 c 5,-674 6,-688 69,-816 45,-92 159,-203 261,-253 176,-86 389,-97 599,-31 126,40 122,32 126,238 4,144 2,176 -9,176 -8,0 -48,-7 -89,-16 -44,-10 -125,-17 -195,-18 -115,-1 -122,0 -169,28 -36,21 -54,40 -70,75 -21,45 -21,54 -21,633 0,322 2,589 5,591 3,3 124,8 270,12 146,4 268,10 272,14 7,6 13,389 6,396 -2,2 -118,6 -258,10 -140,3 -265,8 -277,11 l -23,4 v 305 305 h -244 -243 z"
id="path7"></path>
<path
d="m 13699,21840 c -235,-22 -415,-107 -585,-275 -88,-87 -107,-112 -147,-195 -48,-99 -93,-248 -79,-262 5,-5 109,-11 233,-15 252,-7 232,-12 267,74 60,151 154,218 339,245 162,23 331,-19 406,-101 75,-83 108,-165 122,-300 7,-70 7,-71 -20,-81 -14,-5 -119,-17 -233,-25 -685,-50 -847,-92 -1028,-270 -153,-151 -199,-363 -124,-581 70,-202 278,-372 525,-430 125,-29 322,-25 438,10 48,14 114,39 146,56 67,33 194,128 264,195 26,25 51,44 56,41 5,-3 12,-64 16,-136 7,-122 9,-131 29,-135 11,-3 104,-4 206,-3 l 185,3 6,130 c 10,207 10,1096 -1,1255 -14,213 -52,330 -156,477 -80,113 -217,215 -359,269 -122,45 -342,69 -506,54 z m 559,-1254 c 10,-10 12,-171 3,-219 -15,-84 -38,-124 -106,-193 -69,-70 -162,-122 -270,-150 -97,-25 -291,-26 -360,-1 -124,43 -209,151 -208,265 1,70 12,99 54,146 41,47 93,74 178,91 55,12 422,54 591,68 41,4 111,-1 118,-7 z"
id="path8"></path>
<path
d="m 19935,21840 c -95,-15 -223,-58 -315,-106 -160,-84 -272,-195 -349,-343 -42,-83 -109,-276 -98,-287 9,-9 397,-18 440,-10 21,4 37,11 37,16 0,6 14,40 32,77 70,148 162,204 378,228 75,9 224,-17 288,-49 90,-46 162,-163 188,-305 19,-108 18,-120 -11,-131 -13,-5 -102,-14 -197,-20 -392,-24 -678,-66 -821,-120 -133,-50 -272,-172 -332,-291 -55,-108 -71,-269 -39,-393 60,-232 269,-419 542,-482 89,-21 275,-20 376,1 159,33 302,111 428,233 78,75 72,82 91,-93 l 12,-110 95,-3 c 52,-2 150,0 218,3 l 122,7 v 679 c 0,412 -4,718 -11,777 -39,362 -287,629 -654,707 -93,20 -332,28 -420,15 z m 608,-1388 c -1,-113 -5,-139 -23,-177 -32,-67 -119,-147 -208,-194 -99,-51 -171,-70 -297,-77 -208,-13 -339,58 -391,210 -25,72 -15,135 32,203 61,89 147,118 419,143 72,7 164,16 205,20 41,4 118,7 170,6 l 95,-1 z"
id="path9"></path>
<path
d="m 28005,21826 c -195,-38 -360,-127 -514,-279 -97,-96 -150,-171 -215,-308 -88,-184 -124,-370 -113,-584 13,-246 87,-455 226,-643 116,-156 213,-236 385,-318 156,-74 223,-87 426,-88 151,-1 186,2 260,22 172,46 298,120 441,259 102,98 217,252 204,272 -3,6 -30,23 -58,38 -62,31 -278,123 -289,123 -5,0 -43,-34 -85,-76 -91,-91 -195,-165 -272,-196 -48,-19 -75,-22 -186,-22 -124,0 -134,1 -205,32 -136,58 -230,150 -301,294 -37,73 -76,209 -65,226 3,5 342,12 778,16 495,4 777,11 783,17 17,18 9,274 -13,380 -49,239 -186,470 -372,627 -92,76 -261,163 -380,193 -108,27 -331,35 -435,15 z m 355,-422 c 30,-9 74,-27 97,-41 62,-36 174,-161 215,-240 35,-67 66,-178 53,-191 -4,-4 -247,-8 -541,-10 -613,-3 -552,-16 -511,105 30,88 99,195 168,262 63,59 171,116 249,130 71,13 201,6 270,-15 z"
id="path10"></path>
<path
d="m 23315,21821 c -206,-33 -386,-126 -541,-279 -311,-306 -413,-835 -243,-1260 149,-376 443,-620 808,-673 118,-17 343,-7 446,21 278,72 542,305 655,577 23,54 29,82 23,92 -11,17 -106,35 -258,50 -55,6 -125,13 -156,17 l -57,6 -37,-58 c -111,-177 -272,-274 -452,-274 -211,0 -387,116 -487,322 -114,234 -118,492 -13,707 104,214 267,323 482,323 111,-1 199,-26 281,-81 64,-43 109,-97 170,-204 45,-79 21,-76 259,-33 247,45 255,47 255,60 0,25 -78,181 -128,256 -131,198 -320,341 -537,406 -79,24 -110,27 -255,30 -91,1 -187,-1 -215,-5 z"
id="path11"></path>
<path
d="m 30636,21814 c -155,-34 -251,-84 -411,-213 -55,-44 -103,-77 -107,-75 -5,3 -8,60 -8,128 v 123 l -216,6 c -119,4 -220,5 -224,2 -11,-7 -12,-578 -4,-1418 l 7,-719 236,4 236,3 5,660 c 5,601 7,665 24,715 87,266 307,410 565,372 159,-23 259,-86 327,-208 71,-127 69,-102 69,-862 v -682 h 195 c 107,0 211,3 232,6 l 36,6 6,67 c 4,36 7,356 8,711 2,689 0,733 -48,872 -39,117 -90,195 -188,293 -76,76 -107,99 -186,138 -52,26 -122,54 -155,63 -86,23 -310,28 -399,8 z"
id="path12"></path>
<path
d="m 21540,20721 v -1073 l 239,4 c 132,1 243,7 248,11 9,9 9,2104 0,2113 -3,3 -114,8 -246,11 l -241,6 z"
id="path13"></path>
<path
d="m 15358,20207 c -140,-53 -208,-147 -208,-289 0,-60 5,-84 29,-133 34,-70 88,-126 151,-154 67,-30 188,-29 257,3 60,29 120,85 159,152 24,41 28,58 28,128 1,69 -3,89 -27,138 -30,62 -74,106 -140,140 -53,27 -193,36 -249,15 z"
id="path14"></path>
<path
d="m 15901,18323 c -10,-20 96,-485 148,-653 38,-120 52,-140 96,-140 32,0 34,2 69,86 56,134 136,430 136,504 0,43 10,33 16,-16 7,-55 100,-379 135,-471 35,-89 53,-113 84,-113 30,0 38,12 74,100 23,57 152,530 188,688 5,20 1,22 -30,22 -49,0 -65,-8 -82,-41 -28,-53 -134,-509 -136,-586 -2,-34 -3,-33 -11,17 -28,165 -149,553 -185,592 -10,11 -27,18 -42,16 -28,-3 -43,-45 -172,-455 -41,-130 -59,-162 -59,-104 0,16 -11,71 -24,123 -13,51 -36,145 -51,208 -35,145 -61,217 -85,230 -28,15 -58,12 -69,-7 z"
id="path15"></path>
<path
d="m 17206,18317 c -9,-12 -22,-42 -31,-66 -8,-24 -20,-49 -25,-56 -15,-18 -250,-625 -250,-646 0,-15 7,-19 38,-19 20,0 43,4 50,9 8,4 31,47 51,93 61,136 52,130 178,134 59,2 119,4 132,6 21,3 28,-4 45,-47 77,-193 85,-205 159,-205 45,0 48,-18 -25,185 -80,223 -121,329 -198,505 -54,125 -92,157 -124,107 z m 81,-284 c 28,-87 51,-158 50,-159 -1,-1 -46,-4 -101,-8 l -99,-7 7,33 c 3,18 17,60 31,93 13,34 30,94 36,133 7,40 15,72 18,72 3,0 29,-71 58,-157 z"
id="path16"></path>
<path
d="m 20902,18327 c -13,-15 56,-228 173,-542 93,-249 96,-255 146,-255 46,0 70,44 164,300 30,80 79,213 111,295 71,189 74,202 42,210 -13,3 -36,1 -51,-4 -37,-14 -72,-91 -171,-376 -43,-121 -81,-229 -85,-240 -6,-14 -26,36 -78,190 -100,294 -137,393 -156,416 -19,22 -79,26 -95,6 z"
id="path17"></path>
<path
d="m 31314,18326 c -72,-17 -112,-50 -141,-114 -63,-135 2,-242 192,-317 146,-57 185,-89 185,-149 0,-55 -73,-108 -149,-109 -53,-1 -92,17 -142,66 -45,44 -70,57 -108,57 -25,0 -31,-4 -31,-21 0,-118 221,-243 352,-200 108,36 188,127 188,216 0,106 -62,172 -221,235 -101,40 -158,76 -179,113 -13,24 -13,30 0,54 19,34 85,73 123,73 42,0 86,-22 146,-73 62,-53 88,-59 97,-23 9,37 -23,93 -78,137 -70,55 -152,74 -234,55 z"
id="path18"></path>
<path
d="m 34121,18323 c -33,-28 -308,-750 -297,-780 7,-17 67,-17 88,0 8,6 30,52 47,101 47,131 35,122 143,118 53,-2 106,1 122,7 43,17 66,-4 104,-96 51,-123 64,-140 112,-148 23,-4 46,-3 50,1 5,5 -10,56 -32,114 -22,58 -58,152 -78,210 -135,376 -187,490 -226,490 -6,0 -21,-8 -33,-17 z m 32,-175 c 3,-13 26,-79 52,-147 25,-68 44,-125 43,-126 -7,-4 -203,-24 -206,-20 -3,2 16,64 42,138 25,74 46,144 46,156 0,28 16,27 23,-1 z"
id="path19"></path>
<path
d="m 34672,18327 c -12,-14 -22,-780 -10,-792 13,-13 79,-9 89,5 5,8 9,150 9,315 1,281 2,298 17,270 39,-72 204,-302 311,-435 97,-120 124,-147 156,-158 21,-7 42,-10 45,-6 7,7 10,773 3,792 -2,7 -23,12 -54,12 h -50 l 4,-304 c 3,-282 2,-303 -13,-290 -9,8 -68,90 -130,182 -152,226 -284,391 -326,408 -39,17 -38,17 -51,1 z"
id="path20"></path>
<path
d="m 13091,18311 c -102,-41 -148,-108 -139,-202 9,-88 77,-155 212,-210 179,-72 212,-109 171,-189 -16,-31 -89,-80 -120,-80 -44,0 -102,28 -150,72 -60,54 -93,70 -118,57 -21,-12 -22,-60 -2,-99 16,-31 123,-99 189,-120 66,-21 146,-8 210,35 94,62 121,120 106,228 -8,54 -14,66 -47,95 -40,35 -86,59 -191,98 -98,37 -159,81 -175,127 -5,13 8,28 54,62 91,67 119,64 232,-27 18,-15 48,-29 68,-30 30,-3 34,0 37,24 2,15 -4,40 -12,56 -16,31 -89,89 -139,110 -40,17 -135,14 -186,-7 z"
id="path21"></path>
<path
d="m 14575,18321 c -3,-2 -5,-176 -5,-386 0,-283 3,-384 12,-393 7,-7 31,-12 55,-12 h 43 v 129 c 0,71 3,142 6,159 l 6,30 149,4 c 81,1 152,8 158,13 5,6 11,26 13,45 l 3,35 -160,5 -160,5 -5,100 c -3,55 -3,112 -1,128 l 3,27 h 174 174 v 61 61 l -230,-3 c -127,-2 -233,-5 -235,-8 z"
id="path22"></path>
<path
d="m 15179,18318 c -14,-6 -19,-17 -19,-46 0,-21 5,-43 12,-50 8,-8 50,-12 125,-12 62,0 114,-1 115,-2 1,-2 4,-154 7,-338 l 6,-335 44,-3 c 28,-2 47,1 52,10 5,7 9,153 10,323 0,171 4,319 8,330 8,18 18,20 117,20 h 109 l 8,48 c 5,26 7,51 3,54 -8,9 -573,9 -597,1 z"
id="path23"></path>
<path
d="m 17775,18323 c -11,-3 -23,-8 -27,-12 -11,-10 -22,-513 -13,-653 l 7,-128 h 53 53 l 4,138 c 4,161 10,176 67,166 20,-3 43,-8 52,-10 9,-3 49,-53 88,-113 103,-155 142,-191 208,-191 22,0 24,3 18,28 -10,39 -59,122 -121,204 -30,39 -54,77 -54,84 0,6 17,24 38,40 83,64 121,128 122,204 0,56 -36,134 -81,177 -59,56 -98,66 -254,68 -77,1 -149,0 -160,-2 z m 291,-122 c 59,-27 84,-63 84,-124 0,-46 -3,-53 -49,-98 l -48,-49 -79,6 c -130,11 -124,4 -124,138 0,91 3,118 16,130 23,23 146,21 200,-3 z"
id="path24"></path>
<path
d="m 18610,18323 c -75,-2 -117,-8 -122,-16 -11,-18 -10,-749 2,-767 8,-12 37,-14 177,-12 92,1 192,5 221,8 l 52,6 v 58 57 l -167,-1 -168,-1 -3,100 c -5,159 -16,148 146,144 l 137,-4 3,53 c 4,61 20,55 -158,56 l -125,1 -9,47 c -6,29 -6,70 0,105 l 9,58 157,-4 158,-4 15,36 c 30,72 26,77 -69,77 -46,0 -96,1 -112,3 -16,2 -81,2 -144,0 z"
id="path25"></path>
<path
d="m 19553,18323 -83,-4 v -389 -389 l 37,-7 c 62,-12 263,4 324,25 106,36 178,98 228,199 45,88 44,266 -2,362 -31,67 -131,161 -192,182 -54,19 -182,27 -312,21 z m 249,-115 c 57,-17 131,-87 158,-150 19,-45 22,-64 18,-140 -5,-72 -10,-96 -35,-140 -53,-94 -156,-143 -290,-136 l -58,3 -3,279 c -2,217 1,281 10,288 20,12 156,10 200,-4 z"
id="path26"></path>
<path
d="m 20390,18321 -75,-6 -7,-340 c -4,-187 -5,-364 -2,-392 l 5,-53 142,1 c 198,1 306,11 318,29 9,14 6,77 -5,87 -3,3 -80,6 -173,7 l -168,1 -3,80 c -2,44 0,99 3,123 l 6,42 h 150 149 v 50 49 l -152,3 -153,3 -3,107 -3,108 173,-3 173,-2 8,48 c 5,26 6,51 2,55 -9,9 -287,11 -385,3 z"
id="path27"></path>
<path
d="m 21755,18319 c -3,-8 -6,-187 -7,-399 l -3,-385 175,-3 c 96,-1 202,1 235,5 l 60,8 v 53 53 l -175,2 -175,2 -3,70 c -2,39 0,94 3,124 l 7,54 126,-5 c 70,-2 135,-3 145,0 14,3 17,14 17,59 v 56 l -140,-6 c -167,-8 -162,-11 -158,120 l 3,86 170,1 170,1 9,48 c 4,26 5,51 2,54 -4,4 -107,9 -231,11 -185,4 -226,2 -230,-9 z"
id="path28"></path>
<path
d="m 22417,18143 c -9,-236 -9,-526 -1,-576 l 7,-37 h 205 c 113,1 219,4 234,7 27,5 28,8 28,59 0,63 26,56 -207,54 l -153,-1 -2,338 -3,338 -51,3 -51,3 z"
id="path29"></path>
<path
d="m 23275,18319 c -206,-27 -350,-252 -301,-470 42,-186 160,-301 326,-316 109,-10 242,52 321,150 77,96 101,195 79,317 -18,102 -45,153 -116,219 -90,85 -187,116 -309,100 z m 153,-124 c 51,-22 120,-91 144,-145 24,-56 27,-179 5,-232 -21,-49 -92,-125 -140,-150 -51,-25 -130,-33 -185,-18 -58,15 -124,77 -152,143 -33,75 -34,214 -3,274 25,46 78,93 138,123 49,24 140,26 193,5 z"
id="path30"></path>
<path
d="m 23939,18321 c -8,-2 -17,-9 -21,-15 -4,-6 -8,-180 -8,-387 0,-420 -6,-389 71,-389 h 39 v 134 c 0,73 3,147 6,164 l 6,30 122,4 c 108,3 127,7 173,31 107,55 146,184 89,295 -56,110 -112,134 -317,136 -81,1 -153,0 -160,-3 z m 302,-111 c 55,-15 82,-50 87,-115 6,-70 -11,-97 -74,-119 -58,-20 -209,-22 -221,-3 -9,15 -18,211 -9,232 4,12 24,15 93,15 48,0 104,-5 124,-10 z"
id="path31"></path>
<path
d="m 24710,18320 -95,-5 -3,-384 c -2,-301 1,-386 10,-393 16,-9 216,-10 351,-1 l 97,6 v 56 56 h -172 -173 l -3,100 c -5,157 -15,146 141,145 73,-1 142,-1 155,-1 20,1 22,6 22,57 v 57 l -147,-7 c -82,-3 -154,-2 -161,2 -9,6 -12,35 -10,108 l 3,99 h 155 c 212,1 197,-2 202,45 2,23 1,45 -2,49 -7,11 -250,18 -370,11 z"
id="path32"></path>
<path
d="m 25286,18323 c -3,-4 -6,-183 -6,-400 v -393 h 65 65 v 128 c 0,70 3,137 6,149 6,23 29,30 82,24 29,-3 40,-15 106,-115 41,-61 89,-126 106,-143 39,-40 130,-70 130,-43 0,12 -86,152 -146,237 -24,34 -44,66 -44,71 0,6 21,22 46,37 62,36 124,131 124,190 0,100 -78,218 -162,245 -43,14 -360,25 -372,13 z m 317,-114 c 21,-5 52,-23 69,-38 26,-23 32,-37 36,-85 l 5,-57 -47,-43 c -50,-47 -77,-56 -165,-56 -83,0 -84,2 -85,122 -1,117 6,155 32,161 33,9 117,7 155,-4 z"
id="path33"></path>
<path
d="m 27150,17929 v -402 l 50,5 c 28,3 54,8 58,12 9,10 15,676 6,739 l -7,47 h -53 -54 z"
id="path34"></path>
<path
d="m 27428,18324 c -5,-4 -8,-29 -8,-56 0,-46 1,-48 28,-49 77,-2 209,-10 220,-14 9,-3 12,-80 12,-340 v -335 h 54 54 l 7,318 c 3,174 10,328 14,342 7,24 10,25 113,28 101,3 105,4 117,28 6,14 11,36 11,49 v 25 h -257 c -142,0 -280,3 -308,6 -27,3 -53,3 -57,-2 z"
id="path35"></path>
<path
d="m 28780,18315 c -78,-22 -134,-59 -179,-116 -160,-202 -96,-521 127,-631 58,-29 76,-33 147,-33 72,0 86,3 142,34 101,55 179,144 153,176 -22,27 -47,22 -104,-21 -104,-77 -166,-96 -254,-74 -132,31 -213,189 -183,355 14,76 52,130 121,172 57,35 120,49 173,39 18,-3 69,-29 112,-56 44,-28 87,-50 95,-50 16,0 40,38 40,63 0,25 -76,91 -138,120 -78,36 -170,44 -252,22 z"
id="path36"></path>
<path
d="m 29632,18319 c -146,-29 -258,-136 -296,-284 -61,-232 88,-463 323,-501 90,-14 214,38 303,127 83,82 109,143 109,257 1,147 -29,222 -126,310 -84,77 -207,113 -313,91 z m 194,-142 c 118,-74 160,-177 129,-314 -18,-78 -51,-125 -120,-173 -124,-85 -283,-47 -361,86 -35,60 -46,200 -20,267 28,74 118,149 201,167 48,10 129,-6 171,-33 z"
id="path37"></path>
<path
d="m 30287,18323 c -4,-3 -7,-181 -7,-394 v -387 l 23,-6 c 12,-3 37,-6 55,-6 h 32 l 1,203 c 0,111 0,249 -1,306 -1,63 2,101 8,97 5,-3 28,-36 50,-73 22,-38 78,-120 124,-184 249,-349 244,-343 305,-355 22,-4 32,-2 36,9 6,15 9,290 8,619 l -1,167 -34,6 c -19,4 -44,4 -56,0 -21,-7 -21,-8 -18,-310 2,-167 2,-303 2,-302 -31,37 -76,104 -97,142 -47,86 -328,459 -353,469 -21,8 -69,8 -77,-1 z"
id="path38"></path>
<path
d="m 31861,18316 c -10,-11 -12,-81 -9,-288 l 3,-273 33,-68 c 27,-55 44,-74 85,-102 157,-102 376,-51 459,109 21,39 22,53 23,336 v 295 h -55 -55 v -275 c -1,-297 -4,-320 -56,-366 -84,-74 -248,-51 -294,41 -18,37 -20,61 -21,320 l -1,280 -50,3 c -35,2 -53,-2 -62,-12 z"
id="path39"></path>
<path
d="m 32712,18323 -22,-4 2,-392 3,-392 219,1 c 245,2 244,2 253,73 l 6,41 h -181 c -155,0 -181,2 -186,16 -3,9 -6,154 -6,324 0,359 2,350 -88,333 z"
id="path40"></path>
<path
d="m 13868,18301 c -71,-28 -113,-57 -162,-112 -100,-111 -117,-299 -41,-449 35,-70 106,-142 172,-175 50,-26 69,-30 138,-30 69,0 89,4 148,32 81,39 166,116 210,192 31,54 32,60 32,171 0,106 -2,120 -28,173 -38,76 -113,151 -184,185 -77,36 -213,42 -285,13 z m 240,-117 c 93,-46 152,-145 152,-253 0,-106 -69,-208 -180,-267 -49,-26 -164,-25 -209,1 -113,67 -167,224 -125,366 42,144 225,221 362,153 z"
id="path41"></path>
<path
d="m 33171,18311 c -17,-11 -19,-67 -3,-83 7,-7 51,-12 116,-12 57,-1 108,-5 113,-9 4,-5 10,-159 12,-343 l 3,-335 57,3 56,3 5,330 c 3,182 9,336 13,343 6,8 40,12 112,12 h 103 l 11,31 c 26,74 45,69 -286,69 -164,0 -304,-4 -312,-9 z"
id="path42"></path>
<path
d="m 35462,18273 3,-48 118,-6 c 65,-3 122,-10 126,-15 5,-5 11,-155 15,-333 5,-242 9,-327 19,-333 19,-12 72,-9 85,4 9,9 12,97 12,333 0,176 4,325 8,332 5,8 45,13 117,15 l 110,3 11,35 c 6,19 9,40 7,47 -4,10 -76,13 -320,13 h -314 z"
id="path43"></path>
<path
d="m 26404,17976 c -37,-37 -45,-83 -23,-145 14,-40 35,-51 99,-51 44,0 56,4 82,31 28,27 30,34 25,78 -8,67 -25,98 -60,111 -55,19 -87,12 -123,-24 z"
id="path44"></path>
</g>
</svg>
<style>
.logo {
width: 100%;
height: 64px;
max-width: 300px; /* Adjust as needed */
}
.logo-group path {
fill: #061e45; /* Dark blue for light mode */
transition: fill 0.3s ease;
}
/* If you're using a class-based dark mode approach */
:global(.dark) .logo-group path {
fill: #e5e7eb;
}
</style>

After

Width:  |  Height:  |  Size: 20 KiB

3
src/components/Ol.astro Normal file
View file

@ -0,0 +1,3 @@
<ol class="list-disc list-inside" {...Astro.props}>
<slot />
</ol>

View file

@ -0,0 +1,22 @@
---
interface Props {
title: string;
subtitle?: string;
}
const { title, subtitle } = Astro.props;
---
<div class="text-center mb-16">
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
{title}
</h1>
{
subtitle && (
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
{subtitle}
</p>
)
}
<div class="mt-8 w-24 h-1 bg-mytheme-400 mx-auto rounded-full"></div>
</div>

View file

@ -0,0 +1,10 @@
---
import { Picture as AstroPicture } from "astro:assets";
---
<AstroPicture
src={Astro.props.src}
alt={Astro.props.alt}
formats={["avif", "webp"]}
{...Astro.props}
/>

View file

@ -0,0 +1,99 @@
---
import { Icon } from "astro-icon/components";
import { type Project } from "../consts";
import Carousel from "./Carousel.astro";
import ThreeColumnSection from "./ThreeColumnSection.astro";
interface Props {
projects: Project[];
}
const { projects } = Astro.props;
---
{
projects.map((project) => (
<ThreeColumnSection maxWidth="8xl" py="py-8">
{/* ---------- LEFT: Project details ---------- */}
<div slot="left" class="max-w-xs justify-self-center lg:justify-self-end">
<article class="space-y-4">
{/* Title + optional company */}
<h3 class="text-2xl font-semibold text-slate-900 dark:text-slate-100">
{project.title}
{/* Duration */}
<p class="text-sm text-slate-500 dark:text-slate-400 flex items-center mt-1">
<Icon name="mdi:calendar" class="mr-2" />
{project.duration}
</p>
</h3>
{/* Description */}
<p class="text-base text-slate-800 dark:text-slate-200">
{project.description}
</p>
{/* Tech stack */}
<div class="flex flex-wrap gap-2 mt-2">
{project.tech_stack.map((tech) => (
<span class="px-2 py-0.5 text-xs font-medium bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-slate-100 rounded">
{tech}
</span>
))}
</div>
{/* Deliverables (optional) */}
{project.deliverables?.length && (
<ul class="list-disc list-inside text-sm text-slate-700 dark:text-slate-300 mt-3">
{project.deliverables.map((item) => (
<li class="mb-1">{item}</li>
))}
</ul>
)}
{/* Links */}
<div class="flex space-x-4 mt-4">
{project.live_url && (
<a
href={project.live_url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-sm font-medium
transition-colors duration-150
bg-slate-100 hover:bg-slate-200 focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-slate-500
dark:bg-slate-800 dark:hover:bg-slate-700 dark:focus-visible:outline-slate-400"
>
<Icon name="mdi:aspect-ratio" /> Live
</a>
)}
{project.repo_url && (
<a
href={project.repo_url}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-sm font-medium
transition-colors duration-150
bg-slate-100 hover:bg-slate-200 focus-visible:outline focus-visible:outline-2
focus-visible:outline-offset-2 focus-visible:outline-slate-500
dark:bg-slate-800 dark:hover:bg-slate-700 dark:focus-visible:outline-slate-400"
>
<Icon name="mdi:github" /> Repo
</a>
)}
</div>
</article>
</div>
{/* ---------- CENTER: Carousel ---------- */}
<div slot="center" class="w-full">
<div class="rounded-lg overflow-hidden shadow-lg">
<Carousel images={project.images} />
</div>
</div>
{/* ---------- RIGHT: Empty for now ---------- */}
<div slot="right" class="hidden lg:block" />
</ThreeColumnSection>
))
}

View file

@ -0,0 +1,139 @@
---
export interface Props {
bgClass?: string;
containerClass?: string;
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "4xl" | "6xl" | "8xl";
py?: string;
reverseOnMobile?: boolean;
}
const {
bgClass = "",
containerClass = "",
maxWidth = "8xl",
py = "py-8 md:py-12",
reverseOnMobile = false,
} = Astro.props;
const maxWidthClasses = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
"2xl": "max-w-2xl",
"4xl": "max-w-4xl",
"6xl": "max-w-6xl",
"8xl": "max-w-8xl",
};
---
<section class={`${bgClass} ${py}`} data-threecol>
<div
class={`threecol-row ${maxWidthClasses[maxWidth]} mx-auto flex flex-col lg:flex-row gap-8 px-4 ${containerClass}`}
>
<!-- Left Column -->
<div
class={`${reverseOnMobile ? "order-3 lg:order-1" : "order-1"} flex-col`}
data-left
>
<slot name="left" />
</div>
<!-- Center Column -->
<div class={`order-1 lg:order-2 flex-col`} data-center>
<slot name="center" />
</div>
<!-- Right Column -->
<div
class={`${reverseOnMobile ? "order-1 lg:order-3" : "order-3"} flex-col`}
data-right
>
<slot name="right" />
</div>
</div>
</section>
<style>
/* Base layout variables */
[data-threecol] .threecol-row {
--center-initial: 640px; /* initial center width at lg+ */
--transition-ease: cubic-bezier(0.4, 0, 0.2, 1);
}
/* Make sure columns can shrink properly */
[data-threecol] [data-left],
[data-threecol] [data-center],
[data-threecol] [data-right] {
min-width: 0;
}
/* Large-screen flex behavior (mobile stays stacked naturally) */
@media (min-width: 1024px) {
[data-threecol] .threecol-row {
align-items: stretch;
}
[data-threecol] [data-left] {
max-width: 33.33%;
flex: 1 1 33.33%;
/* optional: transition flex changes if you expect subtle movement */
transition: flex 0.55s var(--transition-ease);
}
[data-threecol] [data-center] {
flex: 0 0 var(--center-initial);
transition:
flex-basis 0.55s var(--transition-ease),
flex-grow 0.55s var(--transition-ease),
max-width 0.55s var(--transition-ease);
display: flex;
flex-direction: column;
}
[data-threecol] [data-right] {
flex: 1 1 0;
transition:
flex-basis 0.55s var(--transition-ease),
flex-grow 0.55s var(--transition-ease),
opacity 0.35s ease;
display: flex;
flex-direction: column;
}
/* THEATER MODE (expanded center) */
[data-threecol].theater-mode [data-center] {
flex: 1 1 0; /* allow it to fill remaining space */
max-width: 100%;
}
[data-threecol].theater-mode [data-right] {
flex: 0 0 0;
flex-grow: 0;
opacity: 0;
pointer-events: none;
}
/* Optional: subtle emphasis on the center content while expanding */
[data-threecol] .threecol-row :is([data-center]) .carousel-theater-target,
[data-threecol] .threecol-row :is([data-center]) > * {
transition:
box-shadow 0.55s var(--transition-ease),
transform 0.55s var(--transition-ease);
}
[data-threecol].theater-mode [data-center] .carousel-theater-target,
[data-threecol].theater-mode [data-center] > * {
/* Example visual polish (comment out if not desired) */
/* box-shadow: 0 6px 24px -4px rgba(0,0,0,.25); */
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
[data-threecol] [data-left],
[data-threecol] [data-center],
[data-threecol] [data-right] {
transition: none !important;
}
}
</style>

3
src/components/Ul.astro Normal file
View file

@ -0,0 +1,3 @@
<ul class="list-disc list-outside ml-8" {...Astro.props}>
<slot />
</ul>

View file

@ -0,0 +1,220 @@
---
---
<script>
const themeToggleBtns = document.querySelectorAll(
".theme-toggle",
) as NodeListOf<HTMLInputElement>;
const sliders = document.querySelectorAll(".slider");
// Function to animate theme transition
function animateThemeTransition(
toggleButton: HTMLElement,
isDarkMode: boolean,
) {
// Set animation to start from top right corner
const centerX = window.innerWidth - 20; // 20px from right edge
const centerY = 20; // 20px from top edge
// Calculate radius needed to cover entire screen from top right
const maxDistance = Math.sqrt(
Math.pow(centerX, 2) + Math.pow(window.innerHeight - centerY, 2),
);
// Create a temporary pseudo-element animation using CSS custom properties
const newThemeColor = isDarkMode ? "rgb(17, 24, 39)" : "rgb(255, 255, 255)";
// Apply the animation using CSS custom properties
document.documentElement.style.setProperty(
"--theme-transition-color",
newThemeColor,
);
document.documentElement.style.setProperty(
"--theme-transition-x",
`${centerX}px`,
);
document.documentElement.style.setProperty(
"--theme-transition-y",
`${centerY}px`,
);
document.documentElement.style.setProperty(
"--theme-transition-radius",
"0px",
);
// Add animation class but don't change theme colors yet
document.body.classList.add("theme-transitioning");
// Start animation
requestAnimationFrame(() => {
document.documentElement.style.setProperty(
"--theme-transition-radius",
`${maxDistance}px`,
);
});
// Switch theme colors halfway through animation when background has covered most content
setTimeout(() => {
if (isDarkMode) {
document.documentElement.classList.add("dark");
localStorage.setItem("color-theme", "dark");
} else {
document.documentElement.classList.remove("dark");
localStorage.setItem("color-theme", "light");
}
}, 300); // Half of the 600ms animation
// Clean up after full animation completes
setTimeout(() => {
// Remove animation class
document.body.classList.remove("theme-transitioning");
}, 600);
}
// Set initial state of toggle based on previous settings
if (
localStorage.getItem("color-theme") === "dark" ||
(!("color-theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
themeToggleBtns.forEach((btn) => (btn.checked = true));
document.documentElement.classList.add("dark");
} else {
themeToggleBtns.forEach((btn) => (btn.checked = false));
document.documentElement.classList.remove("dark");
}
// Remove no-transition class after initial load
window.addEventListener("load", () => {
setTimeout(() => {
sliders.forEach((slider) => slider.classList.remove("no-transition"));
}, 0);
});
themeToggleBtns.forEach((btn) => {
btn.addEventListener("change", function () {
// Prevent default theme switching - we'll handle it after animation
const currentIsDark = document.documentElement.classList.contains("dark");
const targetIsDark = !currentIsDark;
// Update toggle states immediately for visual feedback
themeToggleBtns.forEach(
(toggleBtn) => (toggleBtn.checked = targetIsDark),
);
// Find the closest switch element to get position
const switchElement = btn.closest(".switch") as HTMLElement;
// Animate the transition
animateThemeTransition(switchElement, targetIsDark);
});
});
</script>
<label class="switch" for="theme-toggle">
<span class="sr-only">Toggle dark mode</span>
<input
id="theme-toggle"
class="theme-toggle"
type="checkbox"
role="switch"
aria-checked="false"
/>
<span class="slider round no-transition" aria-hidden="true"></span>
</label>
<style>
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--tw-mytheme-200);
transition: 0.4s;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: gold;
transition: 0.4s;
background: radial-gradient(
yellow,
orange 63%,
transparent calc(63% + 3px) 100%
);
}
input:checked + .slider {
background-color: var(--tw-mytheme-600);
}
input:checked + .slider:before {
background-color: white;
background: radial-gradient(
circle at 19% 19%,
transparent 41%,
var(--tw-mytheme-50) 43%
);
}
input:focus + .slider {
box-shadow: 0 0 5px var(--tw-mytheme-700);
}
input:checked + .slider:before {
transform: translateX(26px);
}
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
.no-transition {
transition: none !important;
}
.no-transition:before {
transition: none !important;
}
/* Theme transition animation */
body.theme-transitioning::before {
content: "";
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: var(--theme-transition-color);
clip-path: circle(
var(--theme-transition-radius) at var(--theme-transition-x)
var(--theme-transition-y)
);
transition: clip-path 0.6s ease-in-out;
pointer-events: none;
z-index: -1;
}
</style>

View file

@ -0,0 +1,22 @@
---
import HeaderLink from "../HeaderLink.astro";
export const navItems = [
{ href: "/", label: "Home" },
{ href: "/blog", label: "Blog" },
{ href: "/projects", label: "Projects" },
{ href: "/publications", label: "Publications" },
{ href: "/contact", label: "Contact" },
];
---
<!-- Desktop Navigation -->
<nav class="hidden lg:block">
<div class="flex gap-4">
{
navItems.map((item) => (
<HeaderLink href={item.href}>{item.label}</HeaderLink>
))
}
</div>
</nav>

View file

@ -0,0 +1,39 @@
---
const today = new Date();
const lastUpdated = new Date(); // You can set this to a specific date or pull from your build process
---
<footer
class="bg-gray-100/60 dark:bg-mytheme-900 shadow-sm text-gray-600 dark:text-gray-400 px-4 py-8"
>
<div class="max-w-full lg:px-16 md:px-8 px-2 py-4">
<!-- Main horizontal layout -->
<div
class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4"
>
<!-- Copyright -->
<div class="text-center sm:text-left">
<p>
&copy; {today.getFullYear()} Alexander Daichendt. All rights reserved.
</p>
</div>
<!-- Legal links -->
<div class="flex items-center justify-center gap-4">
<a
href="/impressum"
class="text-gray-600 dark:text-gray-400 hover:text-accent hover:underline transition-colors duration-200"
>
Impressum
</a>
<span class="opacity-60">•</span>
<a
href="/datenschutz"
class="text-gray-600 dark:text-gray-400 hover:text-accent hover:underline transition-colors duration-200"
>
Datenschutz
</a>
</div>
</div>
</div>
</footer>

View file

@ -0,0 +1,40 @@
---
---
<!-- Mobile Navigation Button Only -->
<div class="lg:hidden">
<button
id="mobile-nav-toggle"
class="relative w-8 h-8 flex items-center justify-center flex-col z-50"
aria-label="Toggle navigation"
type="button"
>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
</div>
<style>
.hamburger-line {
@apply block w-5 h-0.5 bg-gray-600 dark:bg-gray-300 transition-all duration-300;
margin-bottom: 3px;
}
.hamburger-line:last-child {
margin-bottom: 0;
}
#mobile-nav-toggle.nav-open .hamburger-line:nth-child(1) {
transform: rotate(45deg) translate(2px, 2px);
}
#mobile-nav-toggle.nav-open .hamburger-line:nth-child(2) {
opacity: 0;
}
#mobile-nav-toggle.nav-open .hamburger-line:nth-child(3) {
transform: rotate(-45deg) translate(2px, -2px);
}
</style>

View file

@ -0,0 +1,130 @@
---
import { navItems } from "./DesktopNav.astro";
import HeaderLink from "../HeaderLink.astro";
import DarkModeToggle from "./DarkModeToggle.astro";
---
<!-- Mobile Navigation Drawer and Backdrop -->
<div class="lg:hidden">
<!-- Backdrop -->
<div
id="mobile-backdrop"
class="fixed inset-0 bg-black/25 z-40 opacity-0 pointer-events-none transition-opacity duration-300"
>
</div>
<!-- Mobile Drawer -->
<nav
id="mobile-drawer"
class="fixed top-0 right-0 h-full w-48 bg-white dark:bg-gray-800 shadow-xl transform translate-x-full transition-transform duration-300 ease-in-out z-50"
>
<!-- Close button in drawer -->
<div
class="flex justify-between p-4 border-b border-gray-200 dark:border-gray-700"
>
<DarkModeToggle />
<button
id="mobile-nav-close"
class="w-8 h-8 flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Close navigation"
>
</button>
</div>
<!-- Navigation Links -->
<div class="p-6">
<div class="flex flex-col space-y-4">
{
navItems.map((item) => (
<HeaderLink href={item.href}>{item.label}</HeaderLink>
))
}
</div>
</div>
</nav>
</div>
<script>
function initMobileNav() {
const toggle = document.getElementById("mobile-nav-toggle")!;
const closeBtn = document.getElementById("mobile-nav-close")!;
const drawer = document.getElementById("mobile-drawer")!;
const backdrop = document.getElementById("mobile-backdrop")!;
if (!toggle || !drawer || !backdrop) {
console.error("Missing elements, retrying in 100ms...");
setTimeout(initMobileNav, 100);
return;
}
let isOpen = false;
function openNav() {
console.log("Opening nav");
isOpen = true;
toggle.classList.add("nav-open");
backdrop.classList.remove("opacity-0", "pointer-events-none");
drawer.classList.remove("translate-x-full");
document.body.style.overflow = "hidden";
}
function closeNav() {
console.log("Closing nav");
isOpen = false;
toggle.classList.remove("nav-open");
backdrop.classList.add("opacity-0", "pointer-events-none");
drawer.classList.add("translate-x-full");
document.body.style.overflow = "";
}
// Toggle button click
toggle.addEventListener("click", (e) => {
e.preventDefault();
console.log("Toggle clicked, isOpen:", isOpen);
isOpen ? closeNav() : openNav();
});
// Close button click
if (closeBtn) {
closeBtn.addEventListener("click", (e) => {
e.preventDefault();
console.log("Close button clicked");
closeNav();
});
}
// Backdrop click
backdrop.addEventListener("click", () => {
console.log("Backdrop clicked");
closeNav();
});
// Navigation links
drawer.querySelectorAll("a").forEach((link) => {
link.addEventListener("click", () => {
console.log("Nav link clicked");
closeNav();
});
});
// Escape key
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && isOpen) {
console.log("Escape pressed");
closeNav();
}
});
console.log("Mobile nav initialized successfully");
}
// Try multiple initialization strategies
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initMobileNav);
} else {
initMobileNav();
}
// Also try with astro:page-load for Astro's client-side navigation
document.addEventListener("astro:page-load", initMobileNav);
</script>

View file

@ -0,0 +1,34 @@
---
import DarkModeToggle from "./DarkModeToggle.astro";
import DesktopNav from "./DesktopNav.astro";
import Logo from "../Logo.astro";
import MobileNav from "./MobileNav.astro";
---
<header
class="bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm border-b border-gray-200 dark:border-gray-700 z-20 relative"
>
<div class="max-w-full lg:px-16 md:px-8 px-2 py-4">
<div class="flex items-center justify-between">
<h2 class="font-bold text-xl mb-0 font-mono flex">
<a
href="/"
class="hover:text-mytheme-600 dark:hover:text-mytheme-400 transition-colors"
>
<Logo />
</a>
</h2>
<div class="flex-1 flex justify-center">
<div class="max-w-2xl w-full flex justify-end lg:justify-start">
<DesktopNav />
</div>
</div>
<div class="flex items-center gap-4">
<div class="hidden lg:block"><DarkModeToggle /></div>
<MobileNav />
</div>
</div>
</div>
</header>

View file

@ -0,0 +1,106 @@
<form
method="POST"
class="flex flex-col gap-4 max-w-lg mx-auto p-6 rounded-lg shadow-md
bg-white dark:bg-gray-800 transition-colors"
>
<h2 class="text-2xl font-bold mb-4 text-gray-800 dark:text-white">
Add CV Verification
</h2>
<div class="flex flex-col gap-2">
<label
for="company_name"
class="font-medium text-gray-700 dark:text-gray-200"
>
Company Name
</label>
<input
type="text"
id="company_name"
name="company_name"
required
class="border rounded-md p-2
bg-gray-50 dark:bg-gray-900
text-gray-900 dark:text-white
border-gray-300 dark:border-gray-600
focus:border-blue-500 dark:focus:border-blue-400
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
focus:outline-none
placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
</div>
<div class="flex flex-col gap-2">
<label for="author" class="font-medium text-gray-700 dark:text-gray-200">
Author
</label>
<input
type="text"
id="author"
name="author"
required
value="Alexander Daichendt"
class="border rounded-md p-2
bg-gray-50 dark:bg-gray-900
text-gray-900 dark:text-white
border-gray-300 dark:border-gray-600
focus:border-blue-500 dark:focus:border-blue-400
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
focus:outline-none
placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
</div>
<div class="flex flex-col gap-2">
<label for="purpose" class="font-medium text-gray-700 dark:text-gray-200">
Purpose
</label>
<textarea
id="purpose"
name="purpose"
required
rows="3"
class="border rounded-md p-2
bg-gray-50 dark:bg-gray-900
text-gray-900 dark:text-white
border-gray-300 dark:border-gray-600
focus:border-blue-500 dark:focus:border-blue-400
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
focus:outline-none
placeholder:text-gray-400 dark:placeholder:text-gray-500"
>Job Application
</textarea>
</div>
<div class="flex flex-col gap-2">
<label for="tooling" class="font-medium text-gray-700 dark:text-gray-200">
Tooling
</label>
<textarea
id="tooling"
name="tooling"
required
rows="3"
class="border rounded-md p-2
bg-gray-50 dark:bg-gray-900
text-gray-900 dark:text-white
border-gray-300 dark:border-gray-600
focus:border-blue-500 dark:focus:border-blue-400
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
focus:outline-none
placeholder:text-gray-400 dark:placeholder:text-gray-500"
>Typst</textarea
>
</div>
<button
type="submit"
class="bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600
text-white py-2 px-4 rounded-md
transition-colors duration-200
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
disabled:opacity-50 disabled:cursor-not-allowed"
>
Submit Verification
</button>
</form>

View file

@ -0,0 +1,101 @@
---
import { Icon } from "astro-icon/components";
const { uid } = Astro.props;
---
<div
class="max-w-lg mx-auto p-6 rounded-lg shadow-md bg-white dark:bg-gray-800 transition-colors mt-8"
>
<h2 class="text-2xl font-bold mb-4 text-gray-800 dark:text-white">
Generated UID
</h2>
<div class="relative">
<div class="flex items-center gap-2 mb-1">
<code
class="uid-code bg-gray-50 dark:bg-gray-900 px-3 py-2 rounded-md text-gray-900 dark:text-white flex-grow font-mono"
>
{uid}
</code>
<button
class="copy-button p-2 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white
bg-gray-50 dark:bg-gray-900 rounded-md
transition-colors duration-200
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
title="Copy to clipboard"
>
<Icon name="mdi:content-copy" />
</button>
</div>
</div>
<p class="text-gray-700 dark:text-gray-200 mb-4 mt-6">
Embed this uid in the CV typst document. After that, calculate the sha256sum
and enter it below along with your GPG signature
</p>
<form method="POST" class="flex flex-col gap-4">
<input type="hidden" name="uid" value={uid} />
<div class="flex flex-col gap-2">
<label for="sha256" class="font-medium text-gray-700 dark:text-gray-200">
SHA256 Hash
</label>
<input
type="text"
id="sha256"
name="sha256"
required
class="border rounded-md p-2
bg-gray-50 dark:bg-gray-900
text-gray-900 dark:text-white
border-gray-300 dark:border-gray-600
focus:border-blue-500 dark:focus:border-blue-400
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
focus:outline-none
placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
</div>
<div class="flex flex-col gap-2">
<label for="gpg" class="font-medium text-gray-700 dark:text-gray-200">
PGP Signature
</label>
<textarea
id="pgp_signature"
name="pgp_signature"
required
rows="4"
placeholder="Paste your PGP signature here"
class="border rounded-md p-2
bg-gray-50 dark:bg-gray-900
text-gray-900 dark:text-white
border-gray-300 dark:border-gray-600
focus:border-blue-500 dark:focus:border-blue-400
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
focus:outline-none
placeholder:text-gray-400 dark:placeholder:text-gray-500
resize-y"
></textarea>
</div>
<button
type="submit"
class="bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600
text-white py-2 px-4 rounded-md
transition-colors duration-200
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
disabled:opacity-50 disabled:cursor-not-allowed"
>
Submit Hash and Signature
</button>
</form>
</div>
<script>
const buttons = document.querySelectorAll(".copy-button");
buttons.forEach((button) => {
button.addEventListener("click", async () => {
const uid = document.querySelectorAll(".uid-code")[0]?.textContent;
if (uid) {
await navigator.clipboard.writeText(uid);
}
});
});
</script>

View file

@ -0,0 +1,201 @@
---
import type { InferSelectModel } from "drizzle-orm";
import type { cvTable } from "../../db/schema";
import { Icon } from "astro-icon/components";
interface Props {
cv: InferSelectModel<typeof cvTable>;
}
const { cv } = Astro.props;
---
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-6 mb-4">
<ul class="space-y-4 mb-0">
<li class="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-2">
<span
class="text-gray-500 dark:text-gray-400 sm:min-w-32 whitespace-nowrap"
>
ID:
</span>
<span class="text-gray-800 dark:text-gray-200 font-medium break-all">
{cv.uuid}
</span>
</li>
<li class="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-2">
<span
class="text-gray-500 dark:text-gray-400 sm:min-w-32 whitespace-nowrap"
>
Issued by:
</span>
<span class="text-gray-800 dark:text-gray-200 font-medium">
{cv.author}
</span>
</li>
<li class="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-2">
<span
class="text-gray-500 dark:text-gray-400 sm:min-w-32 whitespace-nowrap"
>
Issued to:
</span>
<span class="text-gray-800 dark:text-gray-200 font-medium">
{cv.company_name}
</span>
</li>
<li class="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-2">
<span
class="text-gray-500 dark:text-gray-400 sm:min-w-32 whitespace-nowrap"
>
Issue date:
</span>
<span class="text-gray-800 dark:text-gray-200 font-medium">
{cv.created?.toLocaleString()}
</span>
</li>
<li class="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-2">
<span
class="text-gray-500 dark:text-gray-400 sm:min-w-32 whitespace-nowrap"
>
Purpose:
</span>
<span class="text-gray-800 dark:text-gray-200 font-medium">
{cv.purpose}
</span>
</li>
<li class="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-2">
<span
class="text-gray-500 dark:text-gray-400 sm:min-w-32 whitespace-nowrap"
>
SHA256:
</span>
<span class="text-gray-800 dark:text-gray-200 font-medium break-all">
{cv.sha256}
</span>
</li>
</ul>
</div>
<div class="">
<pre
id="signature"
class="p-6 rounded-lg overflow-x-auto text-sm font-mono whitespace-pre-wrap mb-0">{cv.pgp_signature}</pre>
</div>
<div class="mt-8">
<details class="group bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<summary class="flex items-center justify-between p-4 cursor-pointer">
<h3
class="flex items-center text-xl font-semibold text-gray-900 dark:text-gray-100 mb-0"
>
<Icon name="mdi:information-outline" class="mr-2" />
Verification Instructions
</h3>
<span class="relative ml-4 flex-shrink-0">
<svg
class="h-5 w-5 text-gray-500 dark:text-gray-400 transform group-open:rotate-180 transition-transform"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"></path>
</svg>
</span>
</summary>
<div class="p-4 sm:p-6 pt-6 space-y-6">
<div>
<h4 class="text-xl font-bold mb-2 text-gray-800 dark:text-gray-200">
1. Verify SHA256 Hash
</h4>
<p class="text-gray-600 dark:text-gray-400 mb-2">
Compare the output of following command with the SHA256 hash shown
above.
</p>
<pre
class="bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 p-3 rounded text-sm font-mono">shasum -a 256 cv.pdf</pre>
</div>
<div>
<h4 class="text-xl font-bold mb-2 text-gray-800 dark:text-gray-200">
2. Verify PGP Signature
</h4>
<ol
class="list-decimal list-inside space-y-2 text-gray-600 dark:text-gray-400 mb-2"
>
<li>
Download Alexander Daichendt's public pgp key from <a
href="https://daichendt.one/pub.key"
class="text-blue-500 dark:text-blue-400 hover:underline">here</a
>
</li>
<li>
Import the key into your keyring with<br />
<code
class="bg-gray-100 dark:bg-gray-800 p-3 rounded text-sm font-mono sm:ml-6 mb-2 break-all"
>gpg --import ~/Downloads/pub.key</code
>
</li>
<li>
Download the above PGP signature by clicking <button
class="text-blue-500 dark:text-blue-400 hover:underline"
id="download-pgp"
>here
</button>
</li>
<li>
Run the following command:<br />
<code
class="bg-gray-100 dark:bg-gray-800 p-3 rounded text-sm font-mono sm:ml-6"
>gpg --verify signature.asc cv.pdf</code
>
</li>
</ol>
<p class="text-gray-600 dark:text-gray-400 mt-2">
If the verification is successful, GPG will indicate so.<br />
<code
class="bg-gray-100 dark:bg-gray-800 p-3 rounded text-xs font-mono mt-2 break-all"
>gpg: Good signature from &quot;Alexander Daichendt
&lt;alexander@daichendt.one&gt;&quot; [ultimate]</code
>
</p>
</div>
</div>
</details>
</div>
<style>
details summary::-webkit-details-marker {
display: none;
}
</style>
<script>
const pgpElements = document.querySelectorAll("#download-pgp");
pgpElements.forEach((pgpElement) => {
pgpElement.addEventListener("click", () => {
console.log("Downloading PGP signature");
const pgpSignature = document.getElementById("signature")?.innerText;
if (!pgpSignature) {
console.error("No PGP signature found");
return;
}
const blob = new Blob([pgpSignature], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "signature.asc";
a.click();
URL.revokeObjectURL(url);
});
});
</script>

View file

@ -0,0 +1,5 @@
<div class="text-center p-8 bg-red-50 dark:bg-red-950 rounded-lg shadow-sm">
<p class="text-red-600 dark:text-red-400 text-lg font-medium mb-0">
No CV found with this UUID.
</p>
</div>

View file

@ -0,0 +1,22 @@
<div class="flex items-center justify-center gap-2 mb-4">
<div class="bg-red-100 dark:bg-red-900 p-2 rounded-full">
<svg
class="w-6 h-6 text-red-600 dark:text-red-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"></path>
</svg>
</div>
<h2 class="text-2xl font-bold text-red-600 dark:text-red-400 mb-0">
Revoked CV
</h2>
</div>
<p class="text-red-600 dark:text-red-400 text-lg">
This CV has been revoked and is no longer valid.
</p>

View file

@ -0,0 +1,36 @@
---
import type { InferSelectModel } from "drizzle-orm";
import type { cvTable } from "../../db/schema";
interface Props {
cv: InferSelectModel<typeof cvTable>;
}
const { cv } = Astro.props;
---
<div class="flex items-center gap-2 mb-6">
<div class="bg-green-100 dark:bg-green-900 p-2 rounded-full">
<svg
class="w-6 h-6 text-green-600 dark:text-green-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"></path>
</svg>
</div>
<h2 class="text-3xl font-bold text-gray-800 dark:text-gray-100 mb-0">
Verified CV
</h2>
</div>
<p class="text-gray-600 dark:text-gray-300 mb-6">
This CV was issued and verified by
<span class="font-semibold">{cv.author}</span>, for{" "}
<span class="font-semibold">{cv.company_name}</span>
</p>

196
src/consts.ts Normal file
View file

@ -0,0 +1,196 @@
import type { ImageMetadata } from "astro";
import discretizeui_demo from "./assets/projects/discretizeui/demo.png";
import discretizeui_languages from "./assets/projects/discretizeui/languages.png";
import discretizeui_tooltip from "./assets/projects/discretizeui/tooltip.png";
import optimizer1 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_52_AM.png";
import optimizer2 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_53_AM.png";
import optimizer3 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_54_AM.png";
import optimizer4 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_55_AM.png";
import videovault_dashboard from "./assets/projects/videovault/dashboard.png";
import videovault_edit from "./assets/projects/videovault/edit.png";
import videovault_frontpage from "./assets/projects/videovault/frontpage.png";
import videovault_player from "./assets/projects/videovault/player.png";
export const SITE_TITLE = "Alex Daichendt";
export const SITE_DESCRIPTION =
"Alex Daichendt's personal website, blog, and portfolio.";
export interface Project {
title: string;
featured: boolean;
live_url?: string;
repo_url?: string;
description: string;
tech_stack: string[];
duration: string;
deliverables: string[];
company?: string;
images: {
src: ImageMetadata;
alt: string;
}[];
}
export const projects: Project[] = [
{
title: "VideoVault",
featured: true,
live_url: "https://videovault.shiverpeak.xyz/",
repo_url: "https://github.com/AlexDaichendt/VideoVault",
description:
"A private, self-hosted video vault for your personal use. Supports multiple video transcodes, search, and is blazingly fast thanks to a backend written in Rust and a zero-js (vanilla) frontend.",
tech_stack: [
"Rust",
"Axum",
"sqlx",
"Tera Templates",
"Tailwind CSS",
"Node.js",
"pnpm",
"cargo",
"ffmpeg",
],
duration: "2025 - Present",
deliverables: [
"HLS Streaming of videos",
"Video Scrubbing",
"Aspect Ratio Awareness",
"Multiple Video Transcodes",
"External Transcoding Workers",
"Responsive, lightweight design",
"User Accounts via local, LDAP or OIDC",
"Sharing with timestamps",
"Quick and simple deployment (Docker)",
],
images: [
{
alt: "Frontpage",
src: videovault_frontpage,
},
{
alt: "VideoVault Dashboard",
src: videovault_dashboard,
},
{
alt: "VideoVault Video Player",
src: videovault_player,
},
{
alt: "VideoVault Edit",
src: videovault_edit,
},
],
},
{
title: "Discretize: Gear Optimizer",
featured: true,
live_url: "https://optimizer.discretize.eu/",
repo_url: "https://github.com/discretize/discretize-gear-optimizer",
description:
"A gear optimizer for the popular MMORPG Guild Wars 2. The optimizer is used by thousands of players daily to find the best gear combinations for their characters.",
tech_stack: ["React", "Redux", "Rust", "Vite", "MaterialUI"],
duration: "2021 - Present",
deliverables: [
"User-friendly interface",
"Rust/WASM calculation core",
"Internationalization",
"Keyboard Navigation",
],
images: [
{
src: optimizer1,
alt: "Discretize Gear Optimizer Screenshot",
},
{
src: optimizer2,
alt: "Discretize Gear Optimizer Screenshot",
},
{
src: optimizer3,
alt: "Discretize Gear Optimizer Screenshot",
},
{
src: optimizer4,
alt: "Discretize Gear Optimizer Screenshot",
},
],
},
{
title: "@discretize/gw2-ui-new",
featured: false,
live_url: "https://discretize.github.io/discretize-ui/gw2-ui",
repo_url: "https://github.com/discretize/discretize-ui",
description: `A modern, lightweight React component library for Guild Wars 2 UI elements. Used by all Discretize applications.`,
tech_stack: ["React", "TypeScript", "CSS Modules", "Storybook"],
duration: "2023 Present",
deliverables: [
"Refactored all components to TypeScript",
"Replaced CSS-in-JS with CSS Modules",
"Better performance by caching, batching",
],
images: [
{
src: discretizeui_demo,
alt: "Production Demo of the component library",
},
{
src: discretizeui_tooltip,
alt: "Tooltip component",
},
{
src: discretizeui_languages,
alt: "Supports multiple languages",
},
],
},
// {
// title: "Discretize -- Rewritten Website",
// description:
// "Rewritten website for the Discretize community. Contains guides, builds, and other useful information for the popular MMORPG Guild Wars 2. Awaiting last few changes and content updates by players before deployment.",
// live_url: "https://next.discretize.eu/",
// repo_url: "https://github.com/discretize/discretize.eu-rewrite",
// tech_stack: ["Astro", "React", "TypeScript", "Tailwind CSS"],
// duration: "2022 - Present",
// complexity: 5,
// },
// {
// title: "Discretize -- CC Tool",
// description:
// "Allows players to create skill schedules with drag and drop. Used by high-end players to optimize and coordinate their gameplay.",
// live_url: "https://cc-tool.pages.dev/",
// repo_url: "https://github.com/discretize/cc-tool",
// tech_stack: ["Vite", "React", "TypeScript", "Tailwind CSS"],
// duration: "2024 - Present",
// complexity: 3,
// },
// {
// title: "Discretize -- Random Builds",
// description:
// "Generates random builds for the popular MMORPG Guild Wars 2. Meant as a way to force players out of their comfort zone and try new things.",
// live_url: "https://random-builds.discretize.eu/",
// tech_stack: ["Vite", "React", "TypeScript", "Tailwind CSS"],
// duration: "2022",
// complexity: 3,
// },
// {
// title: "Discretize -- Old Website",
// description:
// "Currently deployed website for the Discretize community. Contains guides, builds, and other useful information for the popular MMORPG Guild Wars 2. Inherited project from previous maintainer. Several hundred thousand monthly users.",
// live_url: "https://discretize.eu/",
// tech_stack: ["React", "Gatsby", "Material UI"],
// duration: "2019 - Present",
// complexity: 3,
// },
// {
// title: "Minecraft LandLord Spigot Plugin",
// live_url: "https://www.spigotmc.org/resources/landlord-2.44398/",
// repo_url: "https://github.com/LandlordPlugin/LandLord",
// description:
// "Landlord aims to keep the Minecraft experience simple and fluid for players while also protecting their land. The idea for this plugin is to protect player builds with minimal game-play interference, while also allowing them to tweak the protection details in a simple and user-friendly way. Handed over the project to a new group of maintainers in 2019.",
// tech_stack: ["Java"],
// duration: "2017 - 2019",
// complexity: 2,
// },
];

19
src/content.config.ts Normal file
View file

@ -0,0 +1,19 @@
import { glob } from "astro/loaders";
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
// Load Markdown and MDX files in the `src/content/blog/` directory.
loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
// Type-check frontmatter using a schema
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
// Transform string to Date object
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: image().optional(),
}),
});
export const collections = { blog };

View file

@ -1,5 +1,5 @@
--- ---
created: '2023-01-02' pubDate: '2023-01-02'
title: 'Generate cover letters with ChatGPT' title: 'Generate cover letters with ChatGPT'
description: 'With the help of ChatGPT it is fairly easy to generate custom tailored cover letters for job applications with your own CV' description: 'With the help of ChatGPT it is fairly easy to generate custom tailored cover letters for job applications with your own CV'
keywords: keywords:

View file

@ -0,0 +1,117 @@
---
pubDate: '2025-01-02'
title: 'Building a CV verification tool in Typst, Astro and Cloudflare D1'
description: "Technical details on how I built a CV verification tool in Typst, Astro and Cloudflare D1."
keywords:
- CV
- verification
- tool
- application
- job
---
In my CV in the bottom right corner, I have a QR code that links to this website. The called page displays information to whom this CV was issued, when and for what purpose. I can revoke a CV in my backend and have it display a message that the CV is no longer valid, for example, when the current sent CV is outdated. Furthermore, the site shows the sha256 hash and a PGP signature of the CV, which can be used to verify the integrity and authenticity of the CV.
While the recipient can still store and process the CV, they can only use it for its intended purpose since others can verify who it was issued to through the QR code. If a third party were to manipulate the CV, the hash would not match the one on the website, and the PGP signature would not be valid.
Pretty cool gimmick! This effectively binds the CV document to my personal website and domain. It provides me more control over the CV and its usage, and it is a nice touch to show off my technical skills.
![CV Demo](../../assets/cv_demo.png)
[QR code leads to this link](https://daichendt.one/cv?id=RtoiZRTN)
## How it works
On this astro page, I have a route `/admin` which allows me to create a new verification id. This endpoint is secured with Cloudflare access.
<div style={{
maxWidth: '500px',
width: '100%',
margin: '0 auto',
textAlign: 'center'
}}>
![Backend](../../assets/cv_backend.png)
</div>
The backend runs serverless with Astro on Cloudflare workers and stores the submitted data in a D1 (SQLite) database. When a request to the `/cv` route is made, the backend checks if the id is in the database and if it is still valid. Depending on that, it will display the data accordingly. Try it out!
For id generation, I am using nanoid with 8 characters. Initially, I utilized UUIDv4 but found it too long for a QR code. At about 80 characters in total, the generated QR code should be about 90px large according to [this tool](https://certifiedcalculator.com/qr-code-size-calculator/). 8 characters still provide a good amount of entropy such that it is unlikely to hit a collision if I were to apply daily for the next 100 years. Of course, collisions are handled in the backend.
A Typst document, which is the technology I use for my CV, renders at 96dpi, so the QR code should be about 2.3cm large according to [this tool](https://www.pixelto.net/px-to-cm-converter) to be optimally scannable even when printed out. With my much shorter nanoid and some additional url shortening, I managed to cut the required size to 1.5 cm.
The overall implementation is trivial and I will not spend time explaining the details. [This blogpost](https://snorre.io/blog/2024-05-06-likes-cloudflare-d1-astro-api-endpoints/) and [this blogpost](https://kevinkipp.com/blog/going-full-stack-on-astro-with-cloudflare-d1-and-drizzle/) explain the Astro + Cloudflare D1 setup in more detail.
On the Typst side, I am using following code to place the QR code in the bottom right corner of my CV:
```typst
#import "@preview/cades:0.3.0": qr-code
#let uuid = "REPLACEME"
#place(
bottom + right,
dx: 0cm,
dy: 0.5cm,
link("https://daichendt.one/cv?id=" + uuid)[
#qr-code("https://daichendt.one/cv?id=" + uuid, width: 1.5cm)
],
)
```
After entering the company name and submitting the shown form, a new uid is returned to the browser. Now I can create my CV with this custom uid by running my Typst build script which inserts the uid into the Typst document, compiles a pdf, creates a sha256 hash and a PGP signature of the pdf. The script looks like this:
```python
#!/usr/bin/env python3
import os
import shutil
import subprocess
import argparse
from pathlib import Path
CV_FILE = "cv.typ"
def main():
parser = argparse.ArgumentParser(description="Build CV")
parser.add_argument("-o", "--output", help="Output file", default="cv.pdf")
parser.add_argument("-u", "--uid", help="unique CV ID", required=True)
args = parser.parse_args()
print("Building CV")
# copy cv file to build directory
os.makedirs("build", exist_ok=True)
shutil.copy(CV_FILE, "build")
path = Path("build/cv.typ")
content = path.read_text().replace("REPLACE_ME", args.uid)
path.write_text(content)
# build cv
subprocess.run(["typst", "compile", "build/cv.typ", args.output], check=True)
print("CV built")
shutil.rmtree("build", ignore_errors=True)
# print sha256 hash of the output file
sha256 = subprocess.run(["sha256sum", args.output], stdout=subprocess.PIPE, check=True).stdout.decode().split()[0]
print(f"SHA256 hash of the output file: {sha256}")
# gpg --detach-sign --armor --output - cv.pdf
gpg_sig = subprocess.run(["gpg", "--detach-sign", "--armor", "--output", "-", args.output], stdout=subprocess.PIPE, check=True).stdout.decode()
print("GPG signature:")
print(gpg_sig)
if __name__ == "__main__":
main()
```
Finally, I enter the generated sha256 hash and the PGP signature into the second page of the create verification workflow.
<div style={{
maxWidth: '500px',
width: '100%',
margin: '0 auto',
textAlign: 'center'
}}>
![Backend Second Step](../../assets/cv_backend2.png)
</div>
And that's it, I have a CV verification tool. I deliberately kept it as simple as possible with a trivial workflow to keep maintenance and the time it takes to create a new CV as low as possible. I am happy with the result.

View file

@ -1,19 +1,19 @@
--- ---
created: '2024-01-15' pubDate: '2024-01-15'
title: 'Detecting System Management Interrupts (SMIs)' title: 'Detecting System Management Interrupts'
description: '' description: ''
keywords: keywords:
- SMI - SMI
--- ---
# System Management Interrutps (SMI) ## System Management Interrutps (SMIs)
- high priority interrupts caused by the hardware - high priority interrupts caused by the hardware
- transparent to the operating system - transparent to the operating system
- can be used by the mainboard for power management, thermal management, or other system-level functions independent of the OS - can be used by the mainboard for power management, thermal management, or other system-level functions independent of the OS
- can take a long time to execute, causing a CPU core to be blocked from other work - can take a long time to execute, causing a CPU core to be blocked from other work
## Detecting SMIs ### Detecting SMIs
- compile a kernel with hwlat tracing capabilities; usually, a typical Linux kernel has this enabled; if not, the config can be found in the appendix - compile a kernel with hwlat tracing capabilities; usually, a typical Linux kernel has this enabled; if not, the config can be found in the appendix
- after starting the machine with a trace-capable image - after starting the machine with a trace-capable image
@ -22,7 +22,7 @@ keywords:
- there now should be a process "hwlat" running that takes up 50% of one CPU - there now should be a process "hwlat" running that takes up 50% of one CPU
- output of the hwlat tracer available `cat /sys/kernel/debug/tracing/trace` or `cat /sys/kernel/debug/tracing/trace_pipep` - output of the hwlat tracer available `cat /sys/kernel/debug/tracing/trace` or `cat /sys/kernel/debug/tracing/trace_pipep`
## Example Output ### Example Output
``` ```
# tracer: hwlat # tracer: hwlat
@ -41,7 +41,7 @@ keywords:
- inner/outer: where the latency was detected, see next section - inner/outer: where the latency was detected, see next section
### How does it work? #### How does it work?
- this hwlat process is taking timestamps in a loop - this hwlat process is taking timestamps in a loop
- if distance between two timestamps is unreasonably large (bigger than ns), there was an SMI - if distance between two timestamps is unreasonably large (bigger than ns), there was an SMI
@ -62,18 +62,18 @@ keywords:
} }
``` ```
### Further options #### Further options
- by default, only 50% CPU time is used - by default, only 50% CPU time is used
- this can be increased by echoing into `echo 9999999 > /sys/kernel/debug/tracing/hwlat_detector/width`, where the value is smaller than the set window `cat /sys/kernel/debug/tracing/hwlat_detector/window` to avoid starving the system. - this can be increased by echoing into `echo 9999999 > /sys/kernel/debug/tracing/hwlat_detector/width`, where the value is smaller than the set window `cat /sys/kernel/debug/tracing/hwlat_detector/window` to avoid starving the system.
- from my experience, this, however, is not necessary to catch SMIs. The default option is "good enough". - from my experience, this, however, is not necessary to catch SMIs. The default option is "good enough".
## Firing an SMI manually ### Firing an SMI manually
- There is a nice small kernel module [here](https://github.com/jib218/kernel-module-smi-trigger) for manually triggering an SMI to verify the setup - There is a nice small kernel module [here](https://github.com/jib218/kernel-module-smi-trigger) for manually triggering an SMI to verify the setup
- follow the instructions in the readme to compile and load the module - follow the instructions in the readme to compile and load the module
## Hardware Registers for counting SMIs ### Hardware Registers for counting SMIs
- Intel: MSR0x34, can be read out with turbostat / perf - Intel: MSR0x34, can be read out with turbostat / perf
- AMD: ls\_msi\_rx, can be used with `perf stat -e ls_smi_rx -I 60000` - AMD: ls\_msi\_rx, can be used with `perf stat -e ls_smi_rx -I 60000`
@ -81,7 +81,7 @@ However, doesn't seem to count everything; counts seem incorrect
--- ---
## Sources, Appendix ### Sources, Appendix
- https://wiki.linuxfoundation.org/realtime/documentation/howto/tools/hwlat - https://wiki.linuxfoundation.org/realtime/documentation/howto/tools/hwlat
- https://www.kernel.org/doc/html/latest/trace/hwlat_detector.html - https://www.kernel.org/doc/html/latest/trace/hwlat_detector.html

View file

@ -0,0 +1,114 @@
---
pubDate: "2025-06-21"
title: "From Basement to Cloud: Migrating to Hetzner"
description: "This post explains how to build a highly available infrastructure using Hetzner, Terraform, and Ansible—integrating services like Traefik for reverse proxying, Authentik for centralized authentication, and automated backups for seamless recovery."
keywords:
- hetzner
- vps
- ansible
- terraform
- timetagger
- infrastructure
---
Now that I graduated and started freelancing, I require some software infrastructure with higher availability guarantees than what I have from my basement.
Typically, an ISP selling consumer-grade internet access guarantees uptime between 95% and 99%, which is ridiculously low. I've had several outages in the past, so I do not want to rely on my consumer-grade internet to host my infrastructure.
A popular cloud alternative is Hetzner, a medium-sized German cloud provider whose principal office is just a few kilometers from my former employer in Unterföhring (Munich).
Although they do not offer any SLA, which surprised me, people online have had positive experiences overall.
Aside from that, their pricing is very competitive: if something goes wrong with Hetzner, I can still migrate away to another provider -- hopefully with minimal effort, which is what this post is about.
[Hetzner](https://www.hetzner.com/)
What kind of services do I need?
- Timetagger (time tracking)
- VideoVault (video-on-demand platform I am currently developing)
- Forgejo (self-hosted Git platform)
- something for calendar, contacts, notes
Utilities:
- Authentik (single sign-on for all other services and potential client logins)
- Traefik (reverse proxy)
- Cloudflare-companion (DNS management)
- ntfy (push notifications)
## Terraforming
Requesting a VM with code from Hetzner is trivial. They provide an awesome [Terraform provider](https://registry.terraform.io/providers/hetznercloud/hcloud/latest/docs).
One interesting detail is that I am using the Cloudflare provider to set up DNS records for the three zones I manage.
This is useful because it allows me to CNAME onto these DNS records later.
In my tfvars, I simply define:
```terraform
cloudflare_dns_records = [{
zone_id = "zone1"
name = "hetzner-fsn1-vm-001.shiverpeak.xyz"
type = "A"
proxied = false
},
{
zone_id = "zone2"
name = "hetzner-fsn1-vm-001.daichendt.one"
type = "A"
proxied = false
}
]
```
Another noteworthy aspect is that I use the null resource to call Ansible playbooks to configure the VM.
There are two playbooks: the first prepares the machine, like setting up another user, adding SSH keys, etc. And the second one is the centerpiece of this post: it installs and configures the services I need.
## Ansible Playbooking
All my secrets are stored in a vault file in `ansible/vars/vault.yml`. The configuration is stored in `ansible/group_vars/all.yml`.
A separate role installs and configures every service.
### Traefiking
Traefik is a popular reverse proxy in the cloud-native ecosystem. I like it because I can keep the DNS and proxy configurations close to the application via Docker labels.
Although Caddy is also quite simple to use, arguably even simpler than Traefik, it only provides a centralized configuration file (maybe something has changed there now?).
Traefik will automatically create a TLS certificate with ACME - Let's Encrypt for every service configured with the label `traefik.http.routers.<service>.tls.certresolver=`.
The last step is to automatically configure DNS records for the services. Traefik does not have a native way to do this, but there is a handy project called [cloudflare-companion](https://github.com/tiredofit/docker-traefik-cloudflare-companion). After supplying it with an API key with access to all DNS zones, it will automatically create CNAME records on the previously defined A records.
Getting the config right was a little tricky since I needed 3 different zones (and I did not bother to read the README, mhm). The full config is [here](https://git.shiverpeak.xyz/shiverpeak.xyz/infrastructure/src/branch/main/ansible/roles/cloudflare-companion/tasks/main.yml).
Now, whenever a new service is added to the reverse proxy, the DNS records will be automatically created.
### Applicationing
All my applications are installed via Docker containers. Jeff Geerling provides a great [Ansible Role](https://github.com/geerlingguy/ansible-role-docker) for Docker, which I use to install Docker on the server.
The installation of the remaining applications went uneventful. I store each application's configuration and data in `/mnt/user/appdata/<service>`, the same way as it is managed in Unraid.
Locating every application's state inside one folder is a useful property for managing backups and restores; more on that later.
### Authenticating
Authentik is great! At work, I started to use Keycloak; however, I wanted to get my hands dirty with the newer, hipper kid on the block, Authentik.
I've got a couple Google Titan Security keys for essentially no money (3 EUR per), so I can use them for passwordless login. Passwordless login allows me to simply click the key and be logged in without needing to enter a password or even a username. Pretty cool stuff! Following [this Video](https://www.youtube.com/watch?v=aEpT2fYGwLw) explains how to set up a passwordless login with Authentik.
Another feature I required is invitations! I do not want anyone to sign up by themselves, but I would like to be able to send a signup link to selected individuals.
Although Authentik provides a [guide](https://docs.goauthentik.io/docs/users-sources/user/invitations), it is not actually enough information to get it to work.
The guide is missing a crucial step: Creating a new Invitation Stage and linking it to the imported flow. Only then will the yellow warning in the Invitation's Menu disappear, and an invitation link will be generated.
VideoVault and Forgejo can be easily connected to Authentik using the OIDC provider. With just a few mouse clicks, copying over the credentials to the respective application's configuration is all that is needed.
Timetagger was harder: it does not support OIDC and has its own authentication mechanism. Luckily, Authentik covers Proxy Authentication too, where it will act as a layer in front of the application, handling authentication and authorization and setting the respective headers, which will be interpreted by Timetagger to automatically sign in the user.
Getting this to work was a bit tricky. In the end, I stuck with the embedded outpost. The full setup involved:
- Creating a new Application with Proxy Provider (forward auth single app) in Authentik
- Creating a Traefik Middleware for forward Auth ([link](https://git.shiverpeak.xyz/shiverpeak.xyz/infrastructure/src/branch/main/ansible/roles/traefik/templates/dynamic-conf.yml.j2#L15-L30))
- Assigning the middleware to the application ([link](https://git.shiverpeak.xyz/shiverpeak.xyz/infrastructure/src/branch/main/ansible/roles/timetagger/tasks/main.yml#L100))
- Making sure all the URLs are correct ;)
Full ansible role for [Authentik](https://git.shiverpeak.xyz/shiverpeak.xyz/infrastructure/src/branch/main/ansible/roles/authentik).
### Backuping
The goal is to be able to destroy the infrastructure and recreate it from scratch, all automatically.
I need to store the state of each application and restore it when the infrastructure is recreated.
A simple backup mechanism, which utilizes Backblaze B2 buckets, creates a tar.gz archive of each application's state daily, uploads it to the bucket, and deletes old backups.
When an application is reinstalled and detects its state folder is empty or missing, it will download the latest backup from the bucket and extract it to the state folder.
Simple but useful.
## Final remarks
I will keep this post updated as I adjust and tweak the infrastructure.
Thanks for reading thus far.

View file

@ -0,0 +1,73 @@
---
pubDate: 2024-12-21
title: Linux on a Huawei MateBook X Pro 2024
description: A guide on what is needed to get Linux running on the Huawei MateBook X Pro 2024.
heroImage: ./images/matebook.jpg
---
**UPDATE 24/08/2024**:
Having used this machine for a year soon, I can not recommend it. None of my issues have been resolved, some even gotten worse. The laptop's battery life is poor, often barely reaching 3 hours while having a text editor open. I pretty much depend on plugging the laptop into the wall, all the time to not degrade the battery more than necessary. The webcam, fingerprint, S3 and Xe drivers are still all pretty much broken. Bluetooth also behaves a bit strangely where it sometimes requires multiple attempts to connect.
---
I recently bought a Huawei MateBook X Pro 2024. It is a beautiful laptop with a 3:2 aspect ratio display and a touchscreen. The laptop comes with Windows 11 preinstalled. However, I wanted to run Linux on it. Here is a guide on what is needed to get Linux running on the Huawei MateBook X Pro 2024.
Overall, the experience was okay, but not something I would recommend to an average user. There are a fair bit of quirks that need to be ironed out. Especially distros running older kernels will have a hard time. I am running CachyOS with the latest 6.13-rc1 kernel, more on that later.
[Here](https://linux-hardware.org/?probe=b6dcf78af5) is a linux-hardware.org probe of my laptop.
| Hardware | PCI/USB ID | Status |
| ----------- | ------------------------------------------- | ------------------ |
| CPU | | :white_check_mark: |
| Touchpad | ps/2:7853-7853-bltp7840-00-347d | :white_check_mark: |
| Touchscreen | | :white_check_mark: |
| Keyboard | ps/2:0001-0001-at-translated-set-2-keyboard | :white_check_mark: |
| WiFi | 8086:7e40 | :white_check_mark: |
| Bluetooth | 8087:0033 | :white_check_mark: |
| iGPU | 8086:7d55 | :neutral_face: |
| Audio | 8086:7e28 | :ok: |
| Webcam | 8086:7d19 | :x: |
| Fingerprint | | :x: |
## CPU
The CPU on my SKU is an Intel Meteor Lake Core Ultra 155H. It comes with 6 performance cores, each with 2 threads, 8 efficiency cores, one thread each, and 2 LPE cores. The p and e cores share 24MB of L3 cache. The LPE cores do not have L3 cache and share 2MB L2 cache, which makes them rather slow. Below you can find the output of `lstopo`:
![lstopo](../../assets/lstopo-matebook.png)
Since thread director is not yet supported in the Linux kernel, by default, the scheduler will assign processes to the performance cores--while on battery. A scheduler like bpfland helps, but that still leaves the first, CPU core 0, alive. Disabling the cores manually is also not a good solution as the core 0 can not be deactivated. There used to be a kernel config option, `CONFIG_BOOTPARAM_HOTPLUG_CPU0` which would allow the core to be disabled at runtime, but is no longer available[^1].
Luckily, Intel is developing a tool which utilizes cgroups to enable/disable cores at runtime and moves processes away. If you care about battery life, you might want to configure `intel-lpmd`[^2].
After installing the tool, it must be enabled with `sudo systemctl enable --now intel-lpmd`. Next, enter your p cores into the config file at `/etc/intel_lpmd/intel_lpmd_config.xml`, so if you are running with SMT enabled, it would be the string `0-11` to include the 6 p-cores with 2 threads each. When you are on battery, the tool will disable the p-cores and move processes away. You can verify that it is active with `sudo systemctl status intel_lpmd.service`. For additional battery-savings, you can also disable the e-cores as the L3 cache can then be powered down. I would not recommend it tho.
## Touchpad
The touchpad worked out of the box ever since I got the laptop. I did read that older kernels might not register it.
## Touchscreen, Keyboard, Wifi, Bluetooth
No problems, as far as I can tell, all work out of the box.
## iGPU
This is a big one. Theres a problem with the default i915 driver which causes the iGPU to never go into a low power state. This is a big problem as it drains the battery rather quickly. There is an experimental Intel Xe driver, which fixes this issue. It can be enabled by adding the kernel parameters `i915.force_probe=!7d55 xe.force_probe=7d55` to the kernel command line. The driver is already in mainline, so no need to compile it yourself. However, the driver is still experimental there are several bugs. The screen might flicker from time to time showing rectangular artifacts. The 6.12 or lower Xe driver was highly unstable and caused my system to hard lock every few minutes. The 6.13-rc1 driver is much more stable, asides from the artifacts.
## Audio
The audio works out of the box. But its not great. It seems like not all speakers are fully used. It is good enough for me tho.
## Webcam
The webcam is an ipu6-based camera. Support has been trickling in over the years, but it is unusable at the moment and the forseeable future.
## Fingerprint
The fingerprint sensor is not supported at the moment. It does not even show up anywhere. One of those ingenious Goodix sensors that are not supported by the fprintd library.
---
Sources:
[^1]: https://www.kernelconfig.io/search?q=CONFIG_BOOTPARAM_HOTPLUG_CPU0&kernelversion=6.12.4&arch=x86
[^2]: https://github.com/intel/intel-lpmd

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 647 KiB

View file

@ -1,15 +1,12 @@
--- ---
created: '2024-06-11' pubDate: '2024-06-11'
title: "Kagi.com" title: "Kagi.com"
description: "" description: "Thoughts on Kagi.com"
keywords: keywords:
- search engine - search engine
hidden: false hidden: false
heroImage: ./images/kagi_doggo_5.svg
--- ---
<script>
import fastgpt from "./images/fastgpt.png?default"
import Image from "$components/Image.svelte"
</script>
Kagi is a paid search engine providing excellent search that reminds me of what Google was like in the early 2000s. Furthermore, it provides search-enhancing features like specific filters, custom site rankings, and an LLM summary of the search results. Kagi is a paid search engine providing excellent search that reminds me of what Google was like in the early 2000s. Furthermore, it provides search-enhancing features like specific filters, custom site rankings, and an LLM summary of the search results.
In this post, I would like to share my thoughts on Kagi.com and explain why I think it is a great search engine despite recent criticism. In this post, I would like to share my thoughts on Kagi.com and explain why I think it is a great search engine despite recent criticism.
@ -21,19 +18,19 @@ Google has been overflooded by SEO spam: sites that do not contain any useful in
If, for some reason, a bad site appears in the search results, I can easily block it. More relevant sites like Wikipedia or StackOverflow can be promoted to the top of the search results. If, for some reason, a bad site appears in the search results, I can easily block it. More relevant sites like Wikipedia or StackOverflow can be promoted to the top of the search results.
## AI Summary ## AI Summary
Kagi's AI will summarize the search results by simply appending a `?` to the end of the search query. LLMs are prone to generating nonsense, but Kagi's AI adds citations with links to the original source. If the AI summary provided helpful information, it was accurate; if it did not, the results were still there. Kagi's AI will summarize the search results by simply appending a `?` to the end of the search query. LLMs are prone to generating nonsense, but Kagi's AI adds citations with links to the original source. If the AI summary provided helpful information, it was accurate; if it did not, the results were still there.
<Image meta={fastgpt} alt="Example search query"/> ![Example search query](../../assets/fastgpt.png)
## Privacy ## Privacy
By default, since the search engine requires registration and payment, Kagi could theoretically track the user's search history. However, I have no reason to believe that Kagi is doing this. Kagi repeatedly stated that they are a small company that aims to do things differently, i.e., not maximize profit over sustainability. That is also why they give free T-shirts to the first 20k users. Although I'm not convinced this is a wise business decision, I respect their commitment to their user base. By default, since the search engine requires registration and payment, Kagi could theoretically track the user's search history. However, I have no reason to believe that Kagi is doing this. Kagi repeatedly stated that they are a small company that aims to do things differently, i.e., not maximize profit over sustainability. That is also why they give free T-shirts to the first 20k users. Although I'm not convinced this is a wise business decision, I respect their commitment to their user base.
In recent criticism, Kagi's CEO Vlad has made questionable privacy statements. Mainly, he claimed that an Email address is not PII (Personally Identifiable Information) because the user could create single-use Email addresses. That statement is obviously regrettable, but the CEO has clarified and will be more careful in the future. Just because a CEO is more outspoken and engaging with the community (which does not happen often - if ever) and sometimes says woeful things does not mean that the company as a whole should be boycotted. It should be seen as a way to engage with the company and perhaps improve it. Kagi is the best we have right now, and I am happy to support them. In recent criticism, Kagi's CEO Vlad has made questionable privacy statements. Mainly, he claimed that an Email address is not PII (Personally Identifiable Information) because the user could create single-use Email addresses. That statement is obviously regrettable, but the CEO has clarified and will be more careful in the future. Just because a CEO is more outspoken and engaging with the community (which does not happen often - if ever) and sometimes says woeful things does not mean that the company as a whole should be boycotted. It should be seen as a way to engage with the company and perhaps improve it. Kagi is the best we have right now, and I am happy to support them.
This entire privacy discussion boils down to a big "trust me, bro" which I am willing to give Kagi - for now. This entire privacy discussion boils down to a big "trust me, bro" which I am willing to give Kagi - for now.
I pay for search; at least I know that Kagi does not have to sell my data to keep the lights on - unlike specific competitors. I pay for search; at least I know that Kagi does not have to sell my data to keep the lights on - unlike specific competitors.
## Conclusion ## Conclusion
Kagi is a great search engine that I can recommend to anyone who is tired of Google's SEO spam and wants to support a small company that is trying to do things differently. The search results are excellent, and the AI summaries are a nice addition. I am looking forward to seeing how Kagi will develop in the future. Kagi is a great search engine that I can recommend to anyone who is tired of Google's SEO spam and wants to support a small company that is trying to do things differently. The search results are excellent, and the AI summaries are a nice addition. I am looking forward to seeing how Kagi will develop in the future.

View file

@ -1,5 +1,5 @@
--- ---
created: '2024-08-25' pubDate: '2024-08-25'
title: "Kata Containers: Custom Kernel Module in Guest" title: "Kata Containers: Custom Kernel Module in Guest"
description: 'How to build a custom kernel module for a Kata Containers guest.' description: 'How to build a custom kernel module for a Kata Containers guest.'
keywords: keywords:
@ -16,8 +16,8 @@ hidden: false
Kata Containers is a lightweight container runtime that leverages hardware virtualization to provide strong isolation between containers. It is compatible with the Open Container Initiative (OCI) and the Container Runtime Interface (CRI). Kata Containers uses a lightweight VM to run each container, which provides an additional layer of isolation compared to traditional container runtimes like Docker or containerd. Kata Containers is a lightweight container runtime that leverages hardware virtualization to provide strong isolation between containers. It is compatible with the Open Container Initiative (OCI) and the Container Runtime Interface (CRI). Kata Containers uses a lightweight VM to run each container, which provides an additional layer of isolation compared to traditional container runtimes like Docker or containerd.
The official documentation is fairly lackluster here and there. For example, see [here](https://github.com/kata-containers/kata-containers/blob/main/docs/how-to/how-to-load-kernel-modules-with-kata.md). There is a lot of prerequisite knowledge assumed. The official documentation is fairly lackluster here and there. For example, see [here](https://github.com/kata-containers/kata-containers/blob/main/docs/how-to/how-to-load-kernel-modules-with-kata.md). There is a lot of prerequisite knowledge assumed.
Another tutorial is [here](https://vadosware.io/post/building-custom-kernels-for-kata-containers/), which sheds some light into the building process of a custom kernel image, but leaves out custom kernel modules. Another tutorial is [here](https://vadosware.io/post/building-custom-kernels-for-kata-containers/), which sheds some light into the building process of a custom kernel image, but leaves out custom kernel modules.
This article aims to provide a step-by-step guide on how to utilize a custom kernel module in a Kata Containers guest. In this example, we will include the igb_uio kernel module, which can be used with DPDK. This article aims to provide a step-by-step guide on how to utilize a custom kernel module in a Kata Containers guest. In this example, we will include the igb_uio kernel module, which can be used with DPDK.
@ -41,7 +41,7 @@ menuconfig IGB_UIO
depends on UIO depends on UIO
default y default y
EOF EOF
# overwrite Makefile to avoid building the module as .ko file # overwrite Makefile to avoid building the module as .ko file
echo "# SPDX-License-Identifier: GPL-2.0" > kata-linux-6.7-$KATA_CONFIG_VERSION/drivers/igb_uio/Makefile echo "# SPDX-License-Identifier: GPL-2.0" > kata-linux-6.7-$KATA_CONFIG_VERSION/drivers/igb_uio/Makefile
echo "obj-\$(CONFIG_IGB_UIO) += igb_uio.o" >> kata-linux-6.7-$KATA_CONFIG_VERSION/drivers/igb_uio/Makefile echo "obj-\$(CONFIG_IGB_UIO) += igb_uio.o" >> kata-linux-6.7-$KATA_CONFIG_VERSION/drivers/igb_uio/Makefile
@ -59,6 +59,6 @@ echo "CONFIG_IGB_UIO=y" >> kata-linux-6.7-$KATA_CONFIG_VERSION/.config
# build the kernel with the new module # build the kernel with the new module
bash build-kernel.sh -v 6.7 build bash build-kernel.sh -v 6.7 build
``` ```
Why Kata 3.2.0, an ancient version, you might ask? Unfortunately, we were unable to get newer version to work with SEV-SNP. Why Kata 3.2.0, an ancient version, you might ask? Unfortunately, we were unable to get newer version to work with SEV-SNP.

View file

@ -0,0 +1,40 @@
---
pubDate: '2025-01-01'
title: "Little Things I noticed in Oslo"
description: 'A collection of small things I noticed during my stay in Oslo.'
keywords:
- travel
- oslo
- norway
hidden: false
heroImage: ./images/oslo.jpg
---
When I wondered through Ekeberg in Oslo, Norway, I noticed a few things that I found interesting. As a German, some of these things came to me as a surprise, others make a lot of sense.
### 1. No underground power lines
Most of the power and utility lines are above ground. I would assume this is due to added cost burying them underground.
### 2. Rocky ground
The ground is very rocky. When walking through the forrest, there's barely any soil, mostly just huge rocks.
### 3. Big Mailboxes
I did not see a single mailbox that could not fit a package. They are all huge and can fit a package of 3-4 books easily. Meanwhile, in Germany, a mailbox can at most fit a single book. Is there some sort of regulation for this?
### 4. Great busses and trams, icky subways
How are your busses and trams so clean, new and modern, but the subways are old and dirty?
### 5. Degraded streets
Many streets in the suburbs are in bad shape. Something like that is not common in Germany. Have winters something to do with this?
### 6. Colorful plates
There seems to be multiple types of license plates. I saw a lot of green plates on larger cars. Probably related to company cars.
### 7. Lots of secondary apartments
It seems like that legislation is more permissive when it comes to renting out a basement or attic as a secondary apartment. I saw a lot of these in the suburbs. In Germany, there is so much red tape and law around renting that barely anyone bothers with it.
### 8. Old houses with chargers for EVs
There is this stark contrast between old wooden houses with a Tesla or some other modern EV parked in front and hooked up to a charger. It's funny.
### 9. A lappen is a lappen
Apparently, a lappen can mean driver's license, which is exactly the same in German. Never once I expected to find this informal colloquialism anywhere outside the DACH area.

View file

@ -1,5 +1,5 @@
--- ---
created: '2022-09-24' pubDate: '2022-09-24'
title: 'Securing a Caddy endpoint with LLDAP' title: 'Securing a Caddy endpoint with LLDAP'
description: '' description: ''
keywords: keywords:
@ -7,11 +7,6 @@ keywords:
- Caddy - Caddy
--- ---
<script>
import overview from "./lldap_overview.png?default"
import Image from "$components/Image.svelte"
</script>
For my small home network, I was looking around for a solution to synchronize user For my small home network, I was looking around for a solution to synchronize user
accounts across services. I host various services like a file server or smaller web accounts across services. I host various services like a file server or smaller web
applications that are accessed by my significant other and a couple of friends. In the applications that are accessed by my significant other and a couple of friends. In the
@ -46,7 +41,7 @@ bridge for networking so that I can resolve my other services with DNS. After th
navigate to http://IP:17170 and are presented with the administration panel, where we can navigate to http://IP:17170 and are presented with the administration panel, where we can
create users and groups. create users and groups.
<Image meta={overview} alt="LLDAP Userinterface"/> ![LLDAP Userinterface](../../assets/lldap_overview.png)
## Integration with Caddy ## Integration with Caddy

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