Compare commits

..

1 commit
main ... svelte

Author SHA1 Message Date
38065f6d7d migrate to svelte5 partially 2024-12-11 12:52:12 +01:00
194 changed files with 4258 additions and 8895 deletions

13
.eslintignore Normal file
View file

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

20
.eslintrc.cjs Normal file
View file

@ -0,0 +1,20 @@
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,4 +1,8 @@
.astro
.wrangler
dist
node_modules/
.DS_Store
node_modules
/build
/.svelte-kit
/package
.env
.env.*
!.env.example

1
.npmrc Normal file
View file

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

14
.prettierignore Normal file
View file

@ -0,0 +1,14 @@
.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,11 +1,6 @@
{
"plugins": ["prettier-plugin-astro"],
"overrides": [
{
"files": "*.astro",
"options": {
"parser": "astro"
}
}
]
"useTabs": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}

View file

@ -1,64 +1,26 @@
# daichendt.one
# Alex' small website
Personal website built with Astro, TailwindCSS, and MDX.
Link to the [website](https://daichendt.one)
## 🚀 Getting Started
Powered by Svelte & SvelteKit
### Prerequisites
## Developing
- [Node.js](https://nodejs.org/) (v20 or higher)
- [pnpm](https://pnpm.io/) (v8 or higher)
Once you've created a project and installed dependencies with `pnpm install`, start a development server:
### Installation
1. Clone the repository:
```bash
git clone https://github.com/AlexDaichendt/site
cd site
```
2. Install dependencies:
```bash
pnpm install
```
### Development
Start the development server:
```bash
pnpm dev
# or start the server and open the app in a new browser tab
pnpm dev -- --open
```
### Building for Production
## Building
To create a production version of your app:
Build the project:
```bash
pnpm build
```
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.
You can preview the production build with `pnpm run preview`.

View file

@ -1,38 +0,0 @@
// @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,
},
}),
});

View file

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

21
mdsvex.config.js Normal file
View file

@ -0,0 +1,21 @@
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';
const config = defineConfig({
layout: {
blog: './src/lib/layouts/blog.svelte',
},
extensions: ['.svelte.md', '.md', '.svx'],
smartypants: {
dashes: 'oldschool',
},
remarkPlugins: [remarkGFM, remarkEmoji],
rehypePlugins: [rehypeSlug, [rehypeAutolinkHeadings, { behaviour: 'append' }]],
});
export default config;

View file

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

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

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

View file

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

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

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

@ -1,27 +0,0 @@
{
"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,50 +1,59 @@
{
"name": "daichendt.one-astro",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"db:generate": "drizzle-kit generate",
"db:migrate:local": "wrangler d1 migrations apply cv-verification --local",
"db:migrate:prod": "wrangler d1 migrations apply cv-verification --remote",
"db:migrate:preview": "wrangler d1 migrations apply --env preview d1-demo-preview-db --remote",
"db:studio:local": "LOCAL_DB_PATH=$(find .wrangler/state/v3/d1/miniflare-D1DatabaseObject -type f -name '*.sqlite' -print -quit) drizzle-kit studio",
"db:studio:preview": "source .drizzle.env && DB_ID='yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' drizzle-kit studio",
"db:studio:prod": "source .drizzle.env && DB_ID='ae0b9867-26f2-4a5b-8aa6-805a86792662' drizzle-kit studio"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/cloudflare": "^12.6.0",
"@astrojs/mdx": "^4.3.0",
"@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.4.1",
"@astrojs/tailwind": "^6.0.2",
"@cloudflare/workers-types": "^4.20241230.0",
"@fontsource/fira-sans": "^5.1.1",
"@fontsource/ubuntu": "^5.1.0",
"@iconify-json/mdi": "^1.2.1",
"@iconify-json/simple-icons": "^1.2.14",
"astro": "^5.10.0",
"astro-icon": "^1.1.4",
"drizzle-orm": "^0.38.3",
"mdast-util-to-string": "^4.0.0",
"nanoid": "^5.0.9",
"reading-time": "^1.5.0",
"sharp": "^0.33.5",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2"
},
"devDependencies": {
"@tailwind-plugin/expose-colors": "^1.1.8",
"@types/node": "^22.10.3",
"better-sqlite3": "^11.7.0",
"cross-env": "^7.0.3",
"drizzle-kit": "^0.30.1",
"prettier": "^3.4.2",
"prettier-plugin-astro": "^0.14.1",
"remark-emoji": "^5.0.1"
}
"name": "daichendt.one",
"version": "1.0.0",
"license": "MIT",
"author": {
"email": "me@daichendt.one",
"name": "Alex Daichendt"
},
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"package": "svelte-kit package",
"preview": "vite preview",
"postinstall": "svelte-kit sync",
"check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check --plugin-search-dir=. . && eslint .",
"format": "prettier --write --plugin-search-dir=. ."
},
"devDependencies": {
"@iconify/svelte": "^4.1.0",
"@sveltejs/adapter-cloudflare": "^4.8.0",
"@sveltejs/kit": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^8.17.0",
"@typescript-eslint/parser": "^8.17.0",
"autoprefixer": "^10.4.20",
"browserslist": "^4.24.2",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"fontaine": "^0.5.0",
"mdi-svelte": "^1.1.2",
"mdsvex": "^0.12.3",
"postcss": "^8.4.49",
"postcss-load-config": "^6.0.1",
"postcss-normalize": "^13.0.1",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.2",
"rehype-autolink-headings": "^7.1.0",
"rehype-slug": "^6.0.0",
"remark-emoji": "^5.0.1",
"remark-gfm": "^4.0.0",
"svelte": "^5.8.1",
"svelte-check": "^4.1.1",
"svelte-preprocess": "^6.0.3",
"tailwindcss": "^3.4.16",
"tslib": "^2.8.1",
"typescript": "^5.7.2",
"vite": "^6.0.3",
"vite-imagetools": "^7.0.5"
},
"dependencies": {
"@fontsource/ubuntu-mono": "^5.1.0",
"@mdi/js": "^7.4.47",
"@sveltejs/vite-plugin-svelte": "^5.0.1",
"imagetools-core": "^7.0.2"
},
"browserslist": "last 2 versions"
}

5908
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 742 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 779 KiB

View file

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

3
src/app.css Normal file
View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

15
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,15 @@
/// <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[];
// }
}

16
src/app.html Normal file
View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 779 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

View file

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

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

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

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

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

View file

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

View file

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

View file

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

Before

Width:  |  Height:  |  Size: 20 KiB

View file

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

View file

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

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

View file

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

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

View file

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

View file

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

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

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

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

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

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

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

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

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

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

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

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

View file

@ -1,196 +0,0 @@
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,
// },
];

View file

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

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

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 KiB

View file

@ -1,40 +0,0 @@
---
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,77 +0,0 @@
export const publications = [
{
authors: [
"Alexander Daichendt",
"Florian Wiedner",
"Jonas Andre",
"Georg Carle",
],
title:
"Applicability of Hardware-Supported Containers in Low-Latency Networking",
conference:
"20th International Conference on Network and Service Management (CNSM 2024)",
location: "Prague, Czech Republic",
date: "Oct. 2024",
links: {
pdf: "http://www.net.in.tum.de/fileadmin/bibtex/publications/papers/wiedner_2024_cnsm.pdf",
homepage: "https://tumi8.github.io/applicability-hwsupported-containers",
bibtex: "https://www.net.in.tum.de/publications/bibtex/Wied24CNSM.bib",
},
},
{
authors: [
"Florian Wiedner",
"Max Helm",
"Alexander Daichendt",
"Jonas Andre",
"Georg Carle",
],
title:
"Performance evaluation of containers for low-latency packet processing in virtualized network environments",
journal: "Performance Evaluation",
volume: "166",
date: "Nov. 2024",
pages: "102442",
links: {
doi: "https://doi.org/10.1016/j.peva.2024.102442",
pdf: "http://www.net.in.tum.de/fileadmin/bibtex/publications/papers/wiedner-helm-2024-peva.pdf",
homepage: "https://wiednerf.github.io/container-in-low-latency/",
bibtex: "/publications/bibtex/WiedHelm24Container.bib",
},
},
{
authors: [
"Florian Wiedner",
"Alexander Daichendt",
"Jonas Andre",
"Georg Carle",
],
title: "Control Groups Added Latency in NFVs: An Update Needed?",
conference:
"2023 IEEE Conference on Network Function Virtualization and Software Defined Networks (NFV-SDN)",
date: "Nov. 2023",
links: {
pdf: "https://www.net.in.tum.de/fileadmin/bibtex/publications/papers/wiedner_nfvsdn2023.pdf",
homepage: "https://wiednerf.github.io/cgroups-nfv/",
bibtex: "/publications/bibtex/wiedner2023containercgroups.bib",
},
},
{
authors: [
"Florian Wiedner",
"Max Helm",
"Alexander Daichendt",
"Jonas Andre",
"Georg Carle",
],
title:
"Containing Low Tail-Latencies in Packet Processing Using Lightweight Virtualization",
conference: "2023 35rd International Teletraffic Congress (ITC-35)",
date: "Oct. 2023",
links: {
pdf: "https://www.net.in.tum.de/fileadmin/bibtex/publications/papers/wiedner_itc35.pdf",
homepage: "https://wiednerf.github.io/containerized-low-latency/",
bibtex: "/publications/bibtex/wiedner2023container.bib",
},
},
];

View file

@ -1,17 +0,0 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const cvTable = sqliteTable("cv", {
uuid: text("uuid").primaryKey(),
company_name: text("company_name").notNull(),
created: integer("created", {
mode: "timestamp_ms",
}),
author: text("author").notNull(),
purpose: text("purpose").notNull(),
tooling: text("tooling").notNull(),
status: text("status", {
enum: ["active", "revoked"],
}).default("active"),
sha256: text("sha256"),
pgp_signature: text("pgp_signature"),
});

10
src/env.d.ts vendored
View file

@ -1,10 +0,0 @@
// src/env.d.ts
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />
type Runtime = import("@astrojs/cloudflare").Runtime<Env>;
declare namespace App {
interface Locals extends Runtime {}
}

View file

@ -1,76 +0,0 @@
---
import BaseHead from "../components/BaseHead.astro";
import Footer from "../components/nav/Footer.astro";
import { SITE_TITLE, SITE_DESCRIPTION } from "../consts";
import "@fontsource/ubuntu";
import "@fontsource/ubuntu/700.css";
import TopHeader from "../components/nav/TopHeader.astro";
import PageHeadline from "../components/PageHeadline.astro";
import MobileNavDrawer from "../components/nav/MobileNavDrawer.astro";
interface Props {
title?: string;
description?: string;
subtitle?: string;
className?: string;
}
const {
title = SITE_TITLE,
description = SITE_DESCRIPTION,
subtitle,
className = "max-w-2xl px-4 py-8",
} = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<BaseHead title={title} description={description} />
<script is:inline>
// Prevent FOUC for dark mode
if (
localStorage.getItem("color-theme") === "dark" ||
(!("color-theme" in localStorage) &&
window.matchMedia("(prefers-color-scheme: dark)").matches)
) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
</script>
</head>
<body
class="bg-white dark:bg-gray-900 text-black dark:text-white min-h-screen flex flex-col"
>
<div id="theme-overlay" class="theme-overlay"></div>
<MobileNavDrawer />
<TopHeader />
<main class="flex-1">
<div class={`${className} mx-auto`}>
<PageHeadline title={title} subtitle={subtitle} />
<slot />
</div>
</main>
<Footer />
<style>
.theme-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 9999;
display: none;
transition: clip-path 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
</style>
</body>
</html>

View file

@ -1,79 +0,0 @@
---
import type { CollectionEntry } from "astro:content";
import FormattedDate from "../components/FormattedDate.astro";
import BaseLayout from "./BaseLayout.astro";
import { Picture } from "astro:assets";
type Props = CollectionEntry<"blog">["data"] & {
readingTime: number;
};
const { title, description, pubDate, updatedDate, heroImage, readingTime } =
Astro.props;
---
<BaseLayout title={title} description={description}>
<article class="max-w-3xl mx-auto">
{
heroImage && (
<div class="mb-12">
<Picture
src={heroImage}
alt=""
width={752}
class="rounded-lg shadow-lg w-full object-cover aspect-[16/9] dark:shadow-gray-800/30 transition-transform hover:scale-[1.02]"
/>
</div>
)
}
<div class="prose dark:prose-invert max-w-none">
<div class="space-y-2 text-center mb-12">
<div class="space-y-2">
<time class="text-sm text-gray-600 dark:text-gray-400">
<FormattedDate date={pubDate} />
</time>
{
updatedDate && (
<div class="text-sm text-gray-500 dark:text-gray-400 italic">
Last updated on <FormattedDate date={updatedDate} />
</div>
)
}
</div>
<h1 class="font-bold tracking-tight text-gray-900 dark:text-white">
{title}
</h1>
<div
class="flex items-center justify-center space-x-4 text-sm text-gray-600 dark:text-gray-400"
>
<span class="flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4 mr-1"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{readingTime} min read
</span>
</div>
<hr class="w-32 mx-auto border-gray-200 dark:border-gray-800" />
</div>
<div class="mt-8 text-gray-800 dark:text-gray-200">
<slot />
</div>
</div>
</article>
</BaseLayout>

View file

@ -0,0 +1,27 @@
<script lang="ts">
import type { ImageMetadata } from '$lib/utils/types';
export let metadata: ImageMetadata[];
export let sizes: string;
const fallback = metadata[metadata.length - 1];
const _metadata = metadata.slice(0, metadata.length - 1);
const srcset = _metadata
.map(({ href, width }) => `https://cats.daichendt.one/${href} ${width}w`)
.join(',');
</script>
{#if !fallback && !metadata}
No metadata supplied
{:else}
<img {srcset} class="image" alt="A cute kitty" {sizes} loading="lazy" />
{/if}
<style>
.image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>

View file

@ -0,0 +1,77 @@
<script lang="ts">
let copied = false;
function copyToClipboard(event: MouseEvent) {
// @ts-ignore
const target = event.target?.innerText.split('\n')[0];
navigator.clipboard.writeText(target);
// select the double clicked node
let sel = document.getSelection();
let range = new Range();
range.selectNode(event.target?.firstChild);
sel?.removeAllRanges();
sel?.addRange(range);
if (!copied) {
copied = true;
setTimeout(() => (copied = false), 3000);
}
}
</script>
<code on:dblclick={copyToClipboard}>
<span class="text">
<slot />
</span>
<div class:copied class="copyWrapper">
{#if copied}
Copied
{:else}
Double click to copy
{/if}
</div>
</code>
<style>
.copied {
background-color: yellowgreen !important;
min-width: 3rem !important;
}
.copyWrapper {
background-color: var(--special-color);
margin-bottom: 0.2rem;
padding: 0.2rem;
border-radius: 3px;
position: absolute;
min-width: 10rem;
visibility: hidden;
z-index: 1;
bottom: 100%;
left: 0;
margin-left: 0px;
}
code:hover .copyWrapper {
visibility: visible;
}
code {
position: relative;
display: inline-block;
background-color: var(--light-color);
border-radius: 5px;
box-shadow: 0 0 5px var(--shadow-color);
padding: 2px;
border: 1px solid var(--border-color);
line-break: anywhere;
}
:global([data-nu-scheme-is='dark'] body code:not([class*='language-'])) {
color: var(--bg-color);
}
:global(pre[class*='language-']) {
margin: 0.5em 1rem !important;
}
:global(code[class*='language-'], pre[class*='language-']) {
font-size: 0.9rem;
}
</style>

View file

@ -0,0 +1,9 @@
<hr />
<style>
hr {
background-color: var(--special-color);
border: none;
height: 1px;
}
</style>

View file

@ -0,0 +1,42 @@
<script context="module">
const year = new Date().getFullYear();
</script>
<script>
import Icon from '@iconify/svelte';
import Link from './Link.svelte';
</script>
<footer class="mt-16 p-8">
<!-- container class inherited from __layout-->
<div class="container">
<p class="flex items-center gap-1">
Copyright <Icon icon="material-symbols:copyright" class="inline-block" />
{year} Alexander Daichendt
</p>
<div class="flex md:justify-between md:flex-row flex-col gap-1">
<Link href="/cat">Meeeeeow</Link>
<Link href="/privacy">Privacy Policy</Link>
<Link href="/impressum">Impressum</Link>
<Link href="https://github.com/AlexDaichendt/site">Source</Link>
</div>
</div>
</footer>
<style>
footer {
background-color: var(--special-bg-color);
}
@media screen and (max-width: 500px) {
.footerLinks {
flex-direction: column;
}
}
:global(footer div a) {
color: var(--text-soft-color) !important;
}
:global(footer div a:hover) {
color: var(--light-color) !important;
}
</style>

View file

@ -0,0 +1,104 @@
<script>
import ThemeSwitcher from './ThemeSwitcher.svelte';
import { page } from '$app/stores';
const NAV_ITEMS = [
{ href: '/', label: 'Home' },
{ href: '/blog', label: 'Blog' },
{ href: '/publications', label: 'Publications' },
{ href: '/projects', label: 'Projects' },
{ href: '/contact', label: 'Contact' },
];
</script>
<header>
<div class="header">
<a href="/">
<h1>Alex Daichendt</h1>
</a>
<ThemeSwitcher />
</div>
<nav>
<ol class="navList">
{#each NAV_ITEMS as navItem}
<li
class="navItem {$page.url.pathname === navItem.href ||
(navItem.href === '/blog' && $page.url.pathname.includes('/blog'))
? 'active'
: ''}"
>
<a href={navItem.href}>{navItem.label}</a>
</li>
{/each}
</ol>
</nav>
</header>
<style>
header {
margin-bottom: 3rem;
}
.active {
font-weight: 600;
}
.navList {
padding: 0;
}
.navItem {
display: inline;
}
.navItem:not(:last-child)::after {
content: '·';
margin-right: 0.5rem;
}
.navItem a:hover {
color: var(--text-strong-color);
}
.navItem a:after {
content: '';
position: absolute;
bottom: -2px;
right: 50%;
width: 0%;
border-bottom: 3px solid var(--outline-color);
transition: 0.3s;
}
.navItem a:before {
content: '';
position: absolute;
bottom: -2px;
left: 50%;
width: 0%;
border-bottom: 3px solid var(--outline-color);
transition: 0.3s;
}
.navItem a:hover:after {
width: 50%;
}
.navItem a:hover:before {
width: 50%;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.header h1 {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
color: var(--special-color);
}
a {
text-decoration: none;
color: inherit;
font-size: 1.2rem;
position: relative;
}
ol {
list-style-type: none;
}
</style>

View file

@ -0,0 +1,168 @@
<script lang="ts">
/**
* the output of a vite-imagetools import, using the `meta` query for output
* format
*
* full type:
* [code](https://github.com/JonasKruckenberg/imagetools/blob/main/packages/core/src/output-formats.ts),
* [docs](https://github.com/JonasKruckenberg/imagetools/blob/main/docs/guide/getting-started.md#metadata)
*/
export let meta: { src: string; width: number; format: string }[];
// if there is only one, vite-imagetools won't wrap the object in an array
if (!(meta instanceof Array)) meta = [meta];
// all images by format
let sources = new Map<string, typeof meta>();
meta.map((m) => sources.set(m.format, []));
meta.map((m) => (sources.get(m.format) ?? []).push(m));
// fallback image: first resolution of last format
let image = (sources.get([...sources.keys()].slice(-1)[0]) ?? [])[0];
/**
* `source` attribute. default: width of the first resolution specified in the
* import.
*/
export let sizes = '100vw';
/** `img` attribute */
export let alt: string;
/** `img` attribute */
export let loading: 'lazy' | 'eager' = 'lazy';
</script>
<!--
@component
takes the output of a vite-imagetools import (using the `meta` output format)
and generates a `<picture>` with `<source>` tags and an `<img>`.
usage
- in `global.d.ts`
```typescript
declare module "*&imagetools" {
const out;
export default out;
}
```
- in svelte file
- typescript
```typescript
import Image from "$lib/Image.svelte";
import me from "$lib/assets/me.jpg?w=200;400&format=webp;png&meta&imagetools";
```
- html
```html
<span><Image meta="{me}" alt="me" /></span>
```
- it's not necessary to wrap it in a `<span>`, but i like to avoid unnested
`:global()` selectors in svelte css
- scss
```scss
span :global(img) {
border-radius: 50%;
}
```
example generated `<picture>`
```html
<picture>
<source
sizes="200px"
type="image/webp"
srcset="
/_app/assets/me-3cfc7c5f.webp 200w,
/_app/assets/me-ab564f98.webp 400w
"
/>
<source
sizes="200px"
type="image/png"
srcset="
/_app/assets/me-2bc09a6d.png 200w,
/_app/assets/me-6f16cc18.png 400w
"
/>
<img src="/_app/assets/me-2bc09a6d.png" alt="me" />
</picture>
```
notes
- from the documentation for
[`sizes`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-sizes),
> The selected source size affects the intrinsic size of the image (the
> images display size if no CSS styling is applied). If the srcset
> attribute is absent, or contains no values with a width descriptor, then
> the sizes attribute has no effect.
there are other things that may also affect the intrinsic (and separately,
display) size of the image, but this is all we set here.
- the `&imagetools` in the usage above is to make typescript happy. there are
other workarounds, if you'd prefer a differnet one
https://github.com/JonasKruckenberg/imagetools/issues/160
- it'd be nice if we could just use a plain `<img>` tag, but in my bit of
testing that didn't seem to allow for multiple formats. i was also tempted
to just use png, but in my bit of testing the webp file was only ~10% (!)
the size of the png.
assumptions
- this counts on vite-imagetools returning metadata objects in the same order
as the query values are specified
- e.g. for `?width=100;200&format=webp;png&meta` we expect the source with
`width=100` to come before the one with `width=200`, and likewise for
`webp` and `png`
- i don't think this is guaranteed, so hopefully it doesn't change. looks
like it depends on this bit of code
https://github.com/JonasKruckenberg/imagetools/blob/main/packages/core/src/lib/resolve-configs.ts#L17
references
- responsive images
- mdn https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images
- css-tricks https://css-tricks.com/a-guide-to-the-responsive-images-syntax-in-html/
- web
- html
- picture https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture
- source https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source
- img https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img
- js
- Map https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
- ts
- wildcard module declarations https://www.typescriptlang.org/docs/handbook/modules.html#wildcard-module-declarations
- docs
- vite-imagetools https://github.com/JonasKruckenberg/imagetools/tree/main/docs
- other
- how to generate srcset https://github.com/JonasKruckenberg/imagetools/blob/main/packages/core/src/output-formats.ts
- `Map` preserves insertion order https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
- you don't set elements on `Map` objects the way you do on regular objects
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
(this was really hard to figure out lol)
- vite-imagetools extensions (to make the import query string shorter)
- docs https://github.com/JonasKruckenberg/imagetools/blob/main/docs/guide/extending.md
- code (options) https://github.com/JonasKruckenberg/imagetools/blob/main/packages/vite/src/types.ts
-->
<picture>
{#each [...sources.entries()] as [format, meta]}
<source
{sizes}
type="image/{format}"
srcset={meta.map((m) => `${m.src} ${m.width}w`).join(', ')}
/>
{/each}
<img src={image.src} {alt} {loading} />
</picture>
<style>
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
</style>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import { mdiLinkVariant } from '@mdi/js';
import { mdiChevronRight } from '@mdi/js';
import Icon from 'mdi-svelte';
export let href: string;
export let disableIcon = false;
export let disablePrefetch = false;
// svelte-ignore unused-export-let
export let rel = '';
const internal = !href.startsWith('http');
// external props
let props: Record<string,string|boolean> = {
rel: "nofollow noreferrer noopener",
target: "_blank"
}
if (internal) {
// internal props
if (!disablePrefetch ){
props = {
"data-sveltekit-prefetch": ""
}
}
}
</script>
<a
{...$$props}
{...props}
{href}
>
<span class="text"><slot /></span>
{#if !disableIcon && !internal}
<Icon path={internal ? mdiChevronRight : mdiLinkVariant} size="1rem" />
{/if}
</a><style>
a {
color: var(--special-color);
text-decoration: none;
font-weight: 550;
}
a:hover {
background-color: var(--outline-color);
color: var(--dark-color)
}
.text {
text-decoration: underline;
word-wrap: break-word;
}
</style>

View file

@ -0,0 +1,11 @@
<script lang="ts">
export let id: string | undefined = undefined;
</script>
<li {id}><slot /></li>
<style>
li {
margin: 0.4rem 0;
}
</style>

View file

@ -0,0 +1,39 @@
<script lang="ts">
import { mdiChevronDoubleUp } from '@mdi/js';
import Icon from 'mdi-svelte';
let y: number = 0;
$: enabled = y > 100;
function onClick() {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
</script>
<svelte:window bind:scrollY={y} />
{#if enabled}
<button on:click={onClick}><Icon path={mdiChevronDoubleUp} /></button>
{/if}
<style>
button:focus {
box-shadow: 0 0 5px var(--special-shadow-color);
}
button {
border-radius: 35px;
width: 48px;
height: 48px;
position: fixed;
bottom: 32px;
right: 32px;
color: var(--light-color);
border: 1px solid var(--special-shadow-color);
background-color: var(--dark-color);
}
button:hover {
border: 1px solid var(--shadow-color);
color: var(--dark-color);
background-color: var(--light-color);
}
</style>

View file

@ -0,0 +1,25 @@
<script lang="ts">
import { page } from '$app/stores';
export let title = "Alex Daichendt's website";
export let keywords: string[] = [];
export let description: string = '';
let seo = $page.data?.seo;
if (seo) {
title = seo.title ? `${seo.title} - Alex Daichendt` : "Alex Daichendt's website";
description = seo.description;
keywords = seo.keywords || [];
}
</script>
<svelte:head
><title>{title}</title>
{#if description.length > 0}
<meta name="description" content={description} />
{/if}
<meta name="author" content="Alexander Daichendt" />
{#if keywords.length > 0}
<meta name="keywords" content={keywords.join(',')} />
{/if}
</svelte:head>

View file

@ -0,0 +1,51 @@
<script>
</script>
<div tabindex="-1">
<table id="table">
<slot />
</table>
</div>
<style>
div {
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0px 0px 2px var(--shadow-color);
border: 1px solid var(--outline-color);
overflow: auto;
}
table {
border-collapse: collapse;
width: 100%;
}
:global(#table thead th) {
padding-bottom: 0.25rem;
padding-left: 1rem;
padding-right: 1rem;
border: solid;
width: 350px;
margin: auto;
border-top: none;
border-left: none;
border-right: none;
border-bottom: none;
background: linear-gradient(var(--special-color), var(--special-color)) bottom
/* left or right or else */ no-repeat;
background-size: 50% 2px;
}
:global(#table tbody tr) {
border-bottom: 1px solid var(--outline-color);
}
:global(#table tbody tr:last-child) {
border-bottom: none;
}
:global(#table tbody td) {
padding: 0.5rem;
}
</style>

View file

@ -0,0 +1,138 @@
<script lang="js">
// @ts-nocheck
import { onMount } from 'svelte';
let checked = false;
onMount(() => {
const ROOT = document.querySelector(':root');
const DARK = 'dark';
const LIGHT = 'light';
const HIGH = 'more';
const LOW = 'no-preference';
const SCHEMES = [DARK, LIGHT];
const CONTRASTS = [HIGH, LOW];
function observeContext(data) {
if (data.find((record) => !record.attributeName.endsWith('-is'))) {
setScheme();
setContrast();
}
}
const schemeMedia = matchMedia('(prefers-color-scheme: dark)');
const contrastMedia = matchMedia('(prefers-contrast: more)');
let globalScheme = schemeMedia.matches ? DARK : LIGHT;
let globalContrast = contrastMedia.matches ? HIGH : LOW;
schemeMedia.addListener((_media) => {
globalScheme = _media.matches ? DARK : LIGHT;
setScheme();
});
contrastMedia.addListener((_media) => {
globalContrast = _media.matches ? HIGH : LOW;
setContrast();
});
function setScheme() {
const setting = ROOT.dataset.nuScheme;
ROOT.dataset.nuSchemeIs =
(setting !== 'auto' && SCHEMES.includes(setting) && setting) || globalScheme;
}
function setContrast() {
const setting = ROOT.dataset.nuContrast;
ROOT.dataset.nuContrastIs =
(setting !== 'auto' && CONTRASTS.includes(setting) && setting) || globalContrast;
}
const observer = new MutationObserver((data) => observeContext(data));
observer.observe(ROOT, {
characterData: false,
attributes: true,
childList: false,
subtree: false,
});
setScheme();
// adjust the theme selector
checked = globalScheme === DARK;
setContrast();
// Switch to dark scheme
// ROOT.dataset.nuContrast = 'more';
// Increase contrast
// ROOT.dataset.nuScheme = 'dark';
});
function toggleTheme() {
const root = document.querySelector(':root');
const theme = root.dataset['nuSchemeIs'];
if (theme === 'light') {
root.dataset['nuScheme'] = 'dark';
} else {
root.dataset['nuScheme'] = 'light';
}
}
</script>
<label class="switch">
<input aria-label="Nightmode" type="checkbox" bind:checked on:change={toggleTheme} />
<span class="slider round"></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: lightskyblue;
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(--special-mark-color);
}
input:checked + .slider:before {
background-color: white;
background: radial-gradient(circle at 19% 19%, transparent 41%, var(--outline-color) 43%);
}
input:focus + .slider {
box-shadow: 0 0 5px var(--special-shadow-color);
}
input:checked + .slider:before {
transform: translateX(26px);
}
/* Rounded sliders */
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
</style>

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