Compare commits
31 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46e485a668 | |||
| 7481d043f2 | |||
| a0f5b86aac | |||
| 1ef855c029 | |||
| 0515fdf9bd | |||
| 3e0875a0d7 | |||
| b3d158d52c | |||
| bf720b79ee | |||
| 2abae63f4b | |||
| dea6f3d9a9 | |||
| a89ad736b2 | |||
| c1a2c6bbef | |||
| c7e3b2c2d6 | |||
| 3bf65ae7ab | |||
| a839560bff | |||
| 799cf0611c | |||
| a155a9b355 | |||
| a331872c79 | |||
| a31e367ef5 | |||
| 91182a834c | |||
| a7f19ff451 | |||
| beb2f2b61e | |||
| ba54eda347 | |||
| 95b5afa0d5 | |||
| 245ad3a625 | |||
| 3955e219e6 | |||
| 194b4b0808 | |||
| 4dd699f08c | |||
| 6c7c305fe1 | |||
| a4f12f33a3 | |||
| 5e67b2bb0d |
|
|
@ -1,13 +0,0 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['svelte3', '@typescript-eslint'],
|
||||
ignorePatterns: ['*.cjs'],
|
||||
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
||||
settings: {
|
||||
'svelte3/typescript': () => require('typescript')
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: true
|
||||
}
|
||||
};
|
||||
12
.gitignore
vendored
|
|
@ -1,8 +1,4 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.astro
|
||||
.wrangler
|
||||
dist
|
||||
node_modules/
|
||||
|
|
|
|||
1
.npmrc
|
|
@ -1 +0,0 @@
|
|||
engine-strict=true
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
Link.svelte
|
||||
13
.prettierrc
|
|
@ -1,6 +1,11 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
"plugins": ["prettier-plugin-astro"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.astro",
|
||||
"options": {
|
||||
"parser": "astro"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
62
README.md
|
|
@ -1,26 +1,64 @@
|
|||
# Alex' small website
|
||||
# daichendt.one
|
||||
|
||||
Link to the [website](https://daichendt.one)
|
||||
Personal website built with Astro, TailwindCSS, and MDX.
|
||||
|
||||
Powered by Svelte & SvelteKit
|
||||
## 🚀 Getting Started
|
||||
|
||||
## Developing
|
||||
### Prerequisites
|
||||
|
||||
Once you've created a project and installed dependencies with `pnpm install`, start a development server:
|
||||
- [Node.js](https://nodejs.org/) (v20 or higher)
|
||||
- [pnpm](https://pnpm.io/) (v8 or higher)
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
pnpm dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
pnpm dev -- --open
|
||||
git clone https://github.com/AlexDaichendt/site
|
||||
cd site
|
||||
```
|
||||
|
||||
## Building
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
To create a production version of your app:
|
||||
### Development
|
||||
|
||||
Start the development server:
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Building for Production
|
||||
|
||||
Build the project:
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
You can preview the production build with `pnpm run preview`.
|
||||
Preview the production build:
|
||||
```bash
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
## 🛠 Tech Stack
|
||||
|
||||
- [Astro](https://astro.build)
|
||||
- [TailwindCSS](https://tailwindcss.com)
|
||||
- [MDX](https://mdxjs.com)
|
||||
- [Sharp](https://sharp.pixelplumbing.com) for image optimization
|
||||
- [Iconify](https://iconify.design) for icons
|
||||
|
||||
## 📦 Key Dependencies
|
||||
|
||||
- `@astrojs/mdx` - MDX integration
|
||||
- `@astrojs/rss` - RSS feed support
|
||||
- `@astrojs/sitemap` - Sitemap generation
|
||||
- `@astrojs/tailwind` - TailwindCSS integration
|
||||
- `@fontsource/ubuntu` - Ubuntu font
|
||||
- `astro-icon` - Icon component
|
||||
- `remark-emoji` - Emoji support in markdown
|
||||
|
||||
## 🙏 Credits
|
||||
|
||||
This theme is based on the [Bear Blog](https://github.com/HermanMartinus/bearblog/) theme.
|
||||
|
|
|
|||
38
astro.config.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// @ts-check
|
||||
import mdx from "@astrojs/mdx";
|
||||
import sitemap from "@astrojs/sitemap";
|
||||
import { defineConfig } from "astro/config";
|
||||
import remarkEmoji from "remark-emoji";
|
||||
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
|
||||
import icon from "astro-icon";
|
||||
import { remarkReadingTime } from "./src/remark/remark-reading-time";
|
||||
|
||||
import cloudflare from "@astrojs/cloudflare";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
prefetch: {
|
||||
defaultStrategy: "hover",
|
||||
prefetchAll: true,
|
||||
},
|
||||
|
||||
site: "https://daichendt.one",
|
||||
|
||||
integrations: [
|
||||
mdx({
|
||||
remarkPlugins: [remarkEmoji, remarkReadingTime],
|
||||
}),
|
||||
sitemap(),
|
||||
tailwind(),
|
||||
icon(),
|
||||
],
|
||||
|
||||
adapter: cloudflare({
|
||||
imageService: "compile",
|
||||
platformProxy: {
|
||||
enabled: true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
24
drizzle.config.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { Config } from "drizzle-kit";
|
||||
|
||||
const { LOCAL_DB_PATH, DB_ID, D1_TOKEN, CF_ACCOUNT_ID } = process.env;
|
||||
|
||||
// Use better-sqlite driver for local development
|
||||
export default LOCAL_DB_PATH
|
||||
? ({
|
||||
schema: "./src/db/schema.ts",
|
||||
dialect: "sqlite",
|
||||
dbCredentials: {
|
||||
url: LOCAL_DB_PATH,
|
||||
},
|
||||
} satisfies Config)
|
||||
: ({
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./migrations",
|
||||
dialect: "sqlite",
|
||||
driver: "d1-http",
|
||||
dbCredentials: {
|
||||
databaseId: DB_ID!,
|
||||
token: D1_TOKEN!,
|
||||
accountId: CF_ACCOUNT_ID!,
|
||||
},
|
||||
} satisfies Config);
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { defineMDSveXConfig as defineConfig } from 'mdsvex';
|
||||
import remarkGFM from 'remark-gfm';
|
||||
import remarkEmoji from 'remark-emoji';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
import remarkFootnotes from 'remark-footnotes';
|
||||
|
||||
const config = defineConfig({
|
||||
layout: {
|
||||
blog: './src/lib/layouts/blog.svelte',
|
||||
},
|
||||
extensions: ['.svelte.md', '.md', '.svx'],
|
||||
|
||||
smartypants: {
|
||||
dashes: 'oldschool',
|
||||
},
|
||||
|
||||
remarkPlugins: [remarkGFM, remarkEmoji, remarkFootnotes],
|
||||
rehypePlugins: [rehypeSlug, [rehypeAutolinkHeadings, { behaviour: 'append' }]],
|
||||
});
|
||||
|
||||
export default config;
|
||||
8
migrations/0000_naive_tarantula.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
CREATE TABLE `cv` (
|
||||
`uuid` text PRIMARY KEY NOT NULL,
|
||||
`company_name` text NOT NULL,
|
||||
`created` integer,
|
||||
`author` text NOT NULL,
|
||||
`created_for` text NOT NULL,
|
||||
`status` text DEFAULT 'pending'
|
||||
);
|
||||
15
migrations/0001_confused_wendell_rand.sql
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_cv` (
|
||||
`uuid` text PRIMARY KEY NOT NULL,
|
||||
`company_name` text NOT NULL,
|
||||
`created` integer,
|
||||
`author` text NOT NULL,
|
||||
`purpose` text NOT NULL,
|
||||
`tooling` text NOT NULL,
|
||||
`status` text DEFAULT 'active'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_cv`("uuid", "company_name", "created", "author", "purpose", "tooling", "status") SELECT "uuid", "company_name", "created", "author", "purpose", "tooling", "status" FROM `cv`;--> statement-breakpoint
|
||||
DROP TABLE `cv`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_cv` RENAME TO `cv`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;
|
||||
2
migrations/0002_naive_revanche.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE `cv` ADD `sha256` text;--> statement-breakpoint
|
||||
ALTER TABLE `cv` ADD `pgp_signature` text;
|
||||
71
migrations/meta/0000_snapshot.json
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "ed238646-2f39-4026-8075-4ddf059cc6f7",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"cv": {
|
||||
"name": "cv",
|
||||
"columns": {
|
||||
"uuid": {
|
||||
"name": "uuid",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"company_name": {
|
||||
"name": "company_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created": {
|
||||
"name": "created",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_for": {
|
||||
"name": "created_for",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
80
migrations/meta/0001_snapshot.json
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "a68f3965-0dd6-46c6-af01-4f96061e8b11",
|
||||
"prevId": "ed238646-2f39-4026-8075-4ddf059cc6f7",
|
||||
"tables": {
|
||||
"cv": {
|
||||
"name": "cv",
|
||||
"columns": {
|
||||
"uuid": {
|
||||
"name": "uuid",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"company_name": {
|
||||
"name": "company_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created": {
|
||||
"name": "created",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"purpose": {
|
||||
"name": "purpose",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tooling": {
|
||||
"name": "tooling",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {
|
||||
"\"cv\".\"created_for\"": "\"cv\".\"purpose\""
|
||||
}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
92
migrations/meta/0002_snapshot.json
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "5f7163dc-e8ca-4bd2-ab41-6b244d02aaf7",
|
||||
"prevId": "a68f3965-0dd6-46c6-af01-4f96061e8b11",
|
||||
"tables": {
|
||||
"cv": {
|
||||
"name": "cv",
|
||||
"columns": {
|
||||
"uuid": {
|
||||
"name": "uuid",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"company_name": {
|
||||
"name": "company_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created": {
|
||||
"name": "created",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"purpose": {
|
||||
"name": "purpose",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tooling": {
|
||||
"name": "tooling",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"sha256": {
|
||||
"name": "sha256",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pgp_signature": {
|
||||
"name": "pgp_signature",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
27
migrations/meta/_journal.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1735731743025,
|
||||
"tag": "0000_naive_tarantula",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1735735966188,
|
||||
"tag": "0001_confused_wendell_rand",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1735887287143,
|
||||
"tag": "0002_naive_revanche",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
104
package.json
|
|
@ -1,58 +1,50 @@
|
|||
{
|
||||
"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": {
|
||||
"@sveltejs/adapter-cloudflare": "^1.0.0",
|
||||
"@sveltejs/kit": "^1.0.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.47.1",
|
||||
"@typescript-eslint/parser": "^5.47.1",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"browserslist": "^4.21.4",
|
||||
"eslint": "^8.31.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"fontaine": "^0.4.1",
|
||||
"mdi-svelte": "^1.1.2",
|
||||
"mdsvex": "^0.10.6",
|
||||
"postcss": "^8.4.20",
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"postcss-normalize": "^10.0.1",
|
||||
"prettier": "^2.8.1",
|
||||
"prettier-plugin-svelte": "^2.9.0",
|
||||
"rehype-autolink-headings": "^6.1.1",
|
||||
"rehype-slug": "^5.1.0",
|
||||
"remark-emoji": "^3.0.2",
|
||||
"remark-footnotes": "2.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"svelte": "^3.55.0",
|
||||
"svelte-check": "^3.0.1",
|
||||
"svelte-preprocess": "^5.0.0",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^4.9.4",
|
||||
"vite": "^4.0.3",
|
||||
"vite-imagetools": "^4.0.12"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/ubuntu-mono": "^4.5.11",
|
||||
"@mdi/js": "^7.1.96",
|
||||
"imagetools-core": "^3.2.3"
|
||||
},
|
||||
"browserslist": "last 2 versions"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
9148
pnpm-lock.yaml
generated
BIN
public/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
public/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 223 KiB |
BIN
public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 742 B |
BIN
public/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
public/files/alexdaichendt.jpg
Normal file
|
After Width: | Height: | Size: 779 KiB |
38
public/robots.txt
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
User-agent: GPTBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: ChatGPT-User
|
||||
Disallow: /
|
||||
|
||||
User-agent: Google-Extended
|
||||
Disallow: /
|
||||
|
||||
User-agent: CCBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: anthropic-ai
|
||||
Disallow: /
|
||||
|
||||
User-agent: Claude-Web
|
||||
Disallow: /
|
||||
|
||||
User-agent: cohere-ai
|
||||
Disallow: /
|
||||
|
||||
User-agent: Omgilibot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Omgili
|
||||
Disallow: /
|
||||
|
||||
User-agent: FacebookBot
|
||||
Disallow: /
|
||||
|
||||
User-agent: Amazonbot
|
||||
Disallow: /
|
||||
|
||||
# Common AI scraper endpoints
|
||||
Disallow: /*?*source=
|
||||
Disallow: /*?*ref=
|
||||
Disallow: /*?*ai=
|
||||
Disallow: /*&*ai=
|
||||
15
src/app.d.ts
vendored
|
|
@ -1,15 +0,0 @@
|
|||
/// <reference types="@sveltejs/kit" />
|
||||
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
// and what to do when importing types
|
||||
declare namespace App {
|
||||
// interface Locals {}
|
||||
// interface Platform {}
|
||||
// interface Session {}
|
||||
// interface Stuff {
|
||||
// title?: string;
|
||||
// description?: string;
|
||||
// keywords?: string[];
|
||||
// }
|
||||
}
|
||||
16
src/app.html
|
|
@ -1,16 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" data-nu-scheme-is="light" data-nu-contrast-is="no-preference">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div>%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 551 KiB After Width: | Height: | Size: 551 KiB |
BIN
src/assets/cv_backend.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
src/assets/cv_backend2.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
src/assets/cv_demo.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 268 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
BIN
src/assets/lstopo-matebook.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
src/assets/me.jpg
Normal file
|
After Width: | Height: | Size: 779 KiB |
BIN
src/assets/projects/discretizeui/demo.png
Normal file
|
After Width: | Height: | Size: 373 KiB |
BIN
src/assets/projects/discretizeui/languages.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
src/assets/projects/discretizeui/tooltip.png
Normal file
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
BIN
src/assets/projects/videovault/dashboard.png
Normal file
|
After Width: | Height: | Size: 247 KiB |
BIN
src/assets/projects/videovault/edit.png
Normal file
|
After Width: | Height: | Size: 835 KiB |
BIN
src/assets/projects/videovault/frontpage.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
BIN
src/assets/projects/videovault/player.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 410 KiB After Width: | Height: | Size: 410 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
119
src/components/BaseHead.astro
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
import "../styles/global.css";
|
||||
import ubuntuRegularWoff2 from "@fontsource/ubuntu/files/ubuntu-latin-400-normal.woff2?url";
|
||||
import ubuntuBoldWoff2 from "@fontsource/ubuntu/files/ubuntu-latin-700-normal.woff2?url";
|
||||
import "@fontsource/fira-sans";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
|
||||
const { title, description, image = "/blog-placeholder-1.jpg" } = Astro.props;
|
||||
---
|
||||
|
||||
<!-- Global Metadata -->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- Font preloads, keep them even if it throws a warning for not using them due to the system providing them -->
|
||||
<link
|
||||
rel="preload"
|
||||
href={ubuntuRegularWoff2}
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href={ubuntuBoldWoff2}
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={title} />
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={Astro.url} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={new URL(image, Astro.url)} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content={Astro.url} />
|
||||
<meta property="twitter:title" content={title} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
<meta property="twitter:image" content={new URL(image, Astro.url)} />
|
||||
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"name": "Alexander Daichendt",
|
||||
"email": "jsonld@daichendt.one",
|
||||
"url": "https://daichendt.one/",
|
||||
"image": "https://daichendt.one/files/alexdaichendt.jpg",
|
||||
"sameAs": ["https://github.com/alexdaichendt"],
|
||||
"jobTitle": "IT Consultant",
|
||||
"worksFor": {
|
||||
"@type": "Organization",
|
||||
"name": "Freelance"
|
||||
},
|
||||
"alumniOf": {
|
||||
"@type": "CollegeOrUniversity",
|
||||
"name": "Technical University Munich"
|
||||
},
|
||||
"nationality": {
|
||||
"@type": "Country",
|
||||
"name": "Germany"
|
||||
},
|
||||
"description": "Privacy-first software engineer passionate about building efficient, user-friendly, and data-respectful web applications. Experienced in Rust, Node.js, and more, delivering scalable, standards-compliant solutions with a focus on usability.",
|
||||
"hasCredential": {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "EducationalOccupationalCredential",
|
||||
"credentialCategory": "Master's degree",
|
||||
"name": "Master of Science in Informatics",
|
||||
"description": "Graduate degree awarded for completing the Master of Science program in Informatics at the Technical University of Munich.",
|
||||
"educationalLevel": "Master's",
|
||||
"recognizedBy": {
|
||||
"@type": "CollegeOrUniversity",
|
||||
"name": "Technical University of Munich",
|
||||
"url": "https://www.tum.de"
|
||||
},
|
||||
"awardedBy": {
|
||||
"@type": "CollegeOrUniversity",
|
||||
"name": "Technical University of Munich",
|
||||
"url": "https://www.tum.de"
|
||||
},
|
||||
"subjectOf": {
|
||||
"@type": "EducationalProgram",
|
||||
"name": "Informatics",
|
||||
"url": "https://www.in.tum.de/en/for-prospective-students/masters-programs/informatics-msc/"
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script
|
||||
src="https://rybbit.shiverpeak.xyz/api/script.js"
|
||||
data-site-id="1"
|
||||
data-web-vitals="true"
|
||||
data-track-errors="true"
|
||||
is:inline
|
||||
defer></script>
|
||||
233
src/components/Carousel.astro
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
---
|
||||
import type { ImageMetadata } from "astro";
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
interface Props {
|
||||
images: {
|
||||
src: ImageMetadata;
|
||||
alt: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const { images } = Astro.props;
|
||||
|
||||
const carouselId = `carousel-${Math.random().toString(36).slice(2, 11)}`;
|
||||
const helpId = `${carouselId}-help`;
|
||||
---
|
||||
|
||||
<section
|
||||
id={carouselId}
|
||||
class="relative overflow-hidden rounded-lg"
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label="Image carousel"
|
||||
data-carousel-id={carouselId}
|
||||
tabindex="0"
|
||||
aria-describedby={helpId}
|
||||
>
|
||||
<!-- Slides -->
|
||||
<div class="relative h-0 pb-[56.25%]">
|
||||
{
|
||||
images.map((image, i) => (
|
||||
<img
|
||||
src={image.src.src}
|
||||
alt={image.alt}
|
||||
class={`
|
||||
absolute inset-0 w-full h-full object-cover object-top
|
||||
transition-opacity duration-500 ease-in-out
|
||||
${i === 0 ? "opacity-100" : "opacity-0"}
|
||||
carousel-slide
|
||||
`}
|
||||
data-index={i}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Navigation Buttons -->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2
|
||||
flex h-10 w-10 items-center justify-center
|
||||
rounded-full bg-white/70 backdrop-blur-sm
|
||||
text-gray-800 hover:bg-white focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-indigo-600
|
||||
transition-colors"
|
||||
aria-label="Previous slide"
|
||||
data-dir="prev"
|
||||
>
|
||||
<Icon name="mdi:chevron-left" class="h-5 w-5" />
|
||||
<span class="sr-only">Previous</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2
|
||||
flex h-10 w-10 items-center justify-center
|
||||
rounded-full bg-white/70 backdrop-blur-sm
|
||||
text-gray-800 hover:bg-white focus:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-indigo-600
|
||||
transition-colors"
|
||||
aria-label="Next slide"
|
||||
data-dir="next"
|
||||
>
|
||||
<Icon name="mdi:chevron-right" class="h-5 w-5" />
|
||||
<span class="sr-only">Next</span>
|
||||
</button>
|
||||
|
||||
<!-- Theater toggle button (bottom-right) -->
|
||||
<button
|
||||
type="button"
|
||||
class="absolute bottom-4 right-4 flex h-10 w-10 items-center justify-center
|
||||
rounded-full bg-white/30 backdrop-blur-sm text-gray-800
|
||||
hover:bg-white focus:outline-none focus-visible:ring-2
|
||||
focus-visible:ring-indigo-600 transition-colors"
|
||||
aria-label="Enter wide mode"
|
||||
aria-pressed="false"
|
||||
data-theater-toggle
|
||||
>
|
||||
<Icon name="mdi:arrow-expand-horizontal" class="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<!-- Live region for screen‑reader 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>
|
||||
17
src/components/FormattedDate.astro
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
interface Props {
|
||||
date: Date;
|
||||
}
|
||||
|
||||
const { date } = Astro.props;
|
||||
---
|
||||
|
||||
<time datetime={date.toISOString()}>
|
||||
{
|
||||
date.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
</time>
|
||||
56
src/components/HeaderLink.astro
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
import type { HTMLAttributes } from "astro/types";
|
||||
|
||||
type Props = HTMLAttributes<"a">;
|
||||
|
||||
const { href, class: className, ...props } = Astro.props;
|
||||
const pathname = Astro.url.pathname.replace(import.meta.env.BASE_URL, "");
|
||||
const subpath = pathname.match(/[^\/]+/g);
|
||||
const isActive = href === pathname || href === "/" + (subpath?.[0] || "");
|
||||
---
|
||||
|
||||
<a
|
||||
href={href}
|
||||
class:list={[
|
||||
className,
|
||||
isActive ? "active" : "",
|
||||
"p-2 hover:text-mytheme-800 hover:dark:text-mytheme-100 inline-block no-underline relative",
|
||||
]}
|
||||
{...props}
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
|
||||
<style>
|
||||
/* Hover animation for non-active links */
|
||||
a:not(.active)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 4px;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
background-color: var(--tw-mytheme-400);
|
||||
transition: all 0.3s ease-in-out;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
a:not(.active):hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Solid underline for active links */
|
||||
a.active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
background-color: var(--tw-mytheme-400);
|
||||
}
|
||||
|
||||
a.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
275
src/components/ImpressumContent.astro
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
---
|
||||
interface Props {
|
||||
companyName?: string;
|
||||
ownerName?: string;
|
||||
address?: {
|
||||
street?: string;
|
||||
city?: string;
|
||||
postalCode?: string;
|
||||
country?: string;
|
||||
};
|
||||
contact?: {
|
||||
phone?: string;
|
||||
email: string;
|
||||
website: string;
|
||||
};
|
||||
business?: {
|
||||
vatId?: string;
|
||||
registrationOffice?: string;
|
||||
};
|
||||
responsiblePerson?: {
|
||||
name: string;
|
||||
address: {
|
||||
street: string;
|
||||
city: string;
|
||||
postalCode: string;
|
||||
country: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const {
|
||||
companyName,
|
||||
ownerName,
|
||||
address,
|
||||
contact,
|
||||
business,
|
||||
responsiblePerson,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-12">
|
||||
<section>
|
||||
<h2
|
||||
class="text-2xl font-bold text-gray-900 dark:text-white mb-6 border-b border-gray-200 dark:border-gray-700 pb-3"
|
||||
>
|
||||
Diensteanbieter
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<dt class="font-semibold text-gray-900 dark:text-white">Firmenname:</dt>
|
||||
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
|
||||
{companyName}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<dt class="font-semibold text-gray-900 dark:text-white">Inhaber:</dt>
|
||||
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
|
||||
{ownerName}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<dt class="font-semibold text-gray-900 dark:text-white">Anschrift:</dt>
|
||||
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
|
||||
{address?.street}<br />
|
||||
{address?.postalCode}
|
||||
{address?.city}<br />
|
||||
{address?.country}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Contact Information -->
|
||||
<section>
|
||||
<h2
|
||||
class="text-2xl font-bold text-gray-900 dark:text-white mb-6 border-b border-gray-200 dark:border-gray-700 pb-3"
|
||||
>
|
||||
Kontaktdaten
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{
|
||||
contact?.phone && (
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<dt class="font-semibold text-gray-900 dark:text-white">
|
||||
Telefon:
|
||||
</dt>
|
||||
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
|
||||
{contact?.phone}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<dt class="font-semibold text-gray-900 dark:text-white">E-Mail:</dt>
|
||||
<dd class="md:col-span-2">
|
||||
<a
|
||||
href={`mailto:${contact?.email}`}
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
{contact?.email}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<dt class="font-semibold text-gray-900 dark:text-white">Website:</dt>
|
||||
<dd class="md:col-span-2">
|
||||
<a
|
||||
href={contact?.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
{contact?.website}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Business Registration -->
|
||||
{
|
||||
(business?.vatId || business?.registrationOffice) && (
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6 border-b border-gray-200 dark:border-gray-700 pb-3">
|
||||
Gewerbliche Angaben
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
{business?.vatId && (
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<dt class="font-semibold text-gray-900 dark:text-white">
|
||||
Umsatzsteuer-ID:
|
||||
</dt>
|
||||
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
|
||||
{business.vatId}
|
||||
<br />
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
gemäß § 27a Umsatzsteuergesetz
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{business.registrationOffice && (
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<dt class="font-semibold text-gray-900 dark:text-white">
|
||||
Gewerbeanmeldung:
|
||||
</dt>
|
||||
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
|
||||
{business?.registrationOffice}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Responsible for Content -->
|
||||
<section>
|
||||
<h2
|
||||
class="text-2xl font-bold text-gray-900 dark:text-white mb-6 border-b border-gray-200 dark:border-gray-700 pb-3"
|
||||
>
|
||||
Verantwortlich für den Inhalt
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<dt class="font-semibold text-gray-900 dark:text-white">
|
||||
Verantwortlich nach § 55 Abs. 2 RStV:
|
||||
</dt>
|
||||
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
|
||||
{responsiblePerson?.name}<br />
|
||||
{responsiblePerson?.address.street}<br />
|
||||
{responsiblePerson?.address.postalCode}
|
||||
{responsiblePerson?.address.city}<br />
|
||||
{responsiblePerson?.address.country}
|
||||
</dd>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- EU Dispute Resolution -->
|
||||
<section>
|
||||
<h2
|
||||
class="text-2xl font-bold text-gray-900 dark:text-white mb-6 border-b border-gray-200 dark:border-gray-700 pb-3"
|
||||
>
|
||||
EU-Streitschlichtung
|
||||
</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<dt class="font-semibold text-gray-900 dark:text-white">
|
||||
Online-Streitbeilegung:
|
||||
</dt>
|
||||
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
|
||||
Die Europäische Kommission stellt eine Plattform zur
|
||||
Online-Streitbeilegung (OS) bereit:<br />
|
||||
<a
|
||||
href="https://ec.europa.eu/consumers/odr/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 underline"
|
||||
>
|
||||
https://ec.europa.eu/consumers/odr/
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<dt class="font-semibold text-gray-900 dark:text-white">
|
||||
Verbraucherschlichtung:
|
||||
</dt>
|
||||
<dd class="md:col-span-2 text-gray-700 dark:text-gray-300">
|
||||
Wir sind nicht bereit oder verpflichtet, an Streitbeilegungsverfahren
|
||||
vor einer Verbraucherschlichtungsstelle teilzunehmen.
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Disclaimer Section -->
|
||||
<section>
|
||||
<h2
|
||||
class="text-2xl font-bold text-gray-900 dark:text-white mb-6 border-b border-gray-200 dark:border-gray-700 pb-3"
|
||||
>
|
||||
Haftungsausschluss
|
||||
</h2>
|
||||
|
||||
<!-- Content Liability -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Haftung für Inhalte
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
Als Diensteanbieter sind wir gemäß § 7 Abs.1 TMG für eigene Inhalte auf
|
||||
diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Nach §§ 8
|
||||
bis 10 TMG sind wir als Diensteanbieter jedoch nicht unter der
|
||||
Verpflichtung, übermittelte oder gespeicherte fremde Informationen zu
|
||||
überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige
|
||||
Tätigkeit hinweisen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Links Liability -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Haftung für Links
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
Unser Angebot enthält Links zu externen Websites Dritter, auf deren
|
||||
Inhalte wir keinen Einfluss haben. Deshalb können wir für diese fremden
|
||||
Inhalte auch keine Gewähr übernehmen. Für die Inhalte der verlinkten
|
||||
Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten
|
||||
verantwortlich.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Copyright -->
|
||||
<div class="mb-8">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Urheberrecht
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen
|
||||
Seiten unterliegen dem deutschen Urheberrecht. Die Vervielfältigung,
|
||||
Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der
|
||||
Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des
|
||||
jeweiligen Autors bzw. Erstellers.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer Note -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-600 pt-8 mt-12">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center">
|
||||
Letzte Aktualisierung: <span class="font-medium">
|
||||
{new Date().toLocaleDateString("de-DE")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
5
src/components/Li.astro
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<li class="pt-2 mr-4" {...Astro.props}>
|
||||
<div class="self-center">
|
||||
<slot />
|
||||
</div>
|
||||
</li>
|
||||
27
src/components/Link.astro
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
---
|
||||
interface Props {
|
||||
href: string;
|
||||
disablePrefetch?: boolean;
|
||||
}
|
||||
|
||||
const { href, disablePrefetch = false } = Astro.props;
|
||||
|
||||
const internal = !href.startsWith("http");
|
||||
|
||||
let linkProps = internal
|
||||
? !disablePrefetch
|
||||
? { "data-astro-prefetch": `${!disablePrefetch}` }
|
||||
: {}
|
||||
: {
|
||||
rel: "nofollow noreferrer noopener",
|
||||
target: "_blank",
|
||||
};
|
||||
---
|
||||
|
||||
<a
|
||||
{...linkProps}
|
||||
{href}
|
||||
class="text-special text-mytheme-700 dark:text-mytheme-300 font-medium hover:bg-outline hover:text-dark no-underline"
|
||||
>
|
||||
<span class="underline break-words"><slot /></span></a
|
||||
>
|
||||
168
src/components/Logo.astro
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<!--?xml version="1.0" encoding="UTF-8" standalone="no"?-->
|
||||
<svg
|
||||
version="1.0"
|
||||
width="3364pt"
|
||||
height="832pt"
|
||||
viewBox="0 0 3364 832"
|
||||
preserveAspectRatio="xMidYMid"
|
||||
id="svg44"
|
||||
class="logo"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
transform="matrix(0.1,0,0,-0.1,-312.98366,2490.1118)"
|
||||
stroke="none"
|
||||
id="g44"
|
||||
class="logo-group"
|
||||
>
|
||||
<path
|
||||
d="m 4485,24500 c -123,-9 -208,-30 -304,-75 -324,-154 -540,-444 -581,-785 -14,-114 -14,-5531 0,-5722 17,-236 62,-375 172,-535 135,-196 338,-342 568,-406 l 75,-21 2762,-4 c 2804,-3 3115,0 3238,33 93,25 255,108 336,173 216,172 354,409 373,642 3,36 6,1363 8,2950 2,2719 1,2889 -15,2960 -35,150 -87,259 -186,389 -108,142 -225,236 -386,312 -126,59 -224,79 -430,90 -214,10 -5485,9 -5630,-1 z m 5700,-411 c 205,-25 386,-151 475,-333 82,-165 75,114 75,-3041 v -2810 l -22,-82 c -51,-190 -161,-327 -328,-408 -141,-68 45,-64 -3039,-65 -2759,-1 -2782,-1 -2859,19 -116,30 -190,74 -282,166 -93,92 -145,184 -173,305 -16,70 -17,253 -15,2898 2,3087 -2,2873 59,2994 82,165 211,282 374,340 l 65,23 1650,6 c 2136,7 3902,2 4020,-12 z"
|
||||
id="path1"></path>
|
||||
<path
|
||||
d="m 6386,23148 c -15,-23 -124,-291 -294,-723 -125,-316 -183,-470 -407,-1065 -106,-283 -274,-724 -373,-980 -216,-559 -431,-1137 -445,-1195 -3,-14 161,-22 474,-24 l 266,-1 36,78 c 33,73 85,196 226,546 33,83 66,155 73,162 10,10 105,13 385,15 205,2 375,0 378,-3 4,-3 9,-417 12,-920 4,-649 9,-915 17,-920 17,-12 1352,-9 1516,2 352,25 581,87 860,232 397,206 682,521 817,902 67,190 88,421 79,861 -6,309 -12,356 -71,560 -34,118 -138,328 -217,437 -91,126 -231,281 -312,347 -272,221 -475,310 -771,341 -94,9 -396,28 -402,25 -1,-1 29,-84 68,-186 38,-101 94,-249 123,-329 78,-211 86,-228 104,-235 9,-4 59,-16 111,-26 352,-70 592,-366 672,-827 44,-258 8,-542 -101,-793 -49,-114 -107,-197 -201,-290 -217,-216 -542,-354 -889,-380 -204,-15 -662,-8 -683,10 -15,12 -17,66 -17,587 0,476 2,575 14,586 9,9 59,19 137,26 67,7 125,14 127,17 13,13 -205,594 -235,628 -15,16 -58,17 -634,17 -339,0 -624,3 -633,6 -14,5 -14,10 0,42 8,21 48,125 89,232 90,239 188,495 220,575 25,63 115,294 167,430 17,44 70,179 118,300 48,121 95,245 105,275 10,30 24,68 32,84 26,50 32,38 193,-394 185,-497 310,-821 457,-1190 277,-695 326,-821 462,-1200 73,-201 151,-417 174,-480 l 43,-115 39,1 c 39,1 232,21 425,45 52,6 128,14 167,18 94,8 103,17 79,79 -10,26 -29,74 -41,107 -12,33 -34,89 -47,125 -14,36 -66,175 -116,310 -222,598 -313,841 -437,1160 -70,178 -118,305 -273,715 -183,488 -403,1049 -520,1330 l -29,70 -239,6 c -131,4 -380,7 -551,8 l -313,1 z"
|
||||
id="path2"></path>
|
||||
<path
|
||||
d="m 18208,22228 c -1,-304 -1,-588 1,-630 2,-43 -1,-78 -6,-78 -5,0 -52,37 -105,83 -166,143 -340,214 -548,224 -189,9 -349,-28 -502,-116 -175,-100 -295,-230 -388,-416 -233,-467 -142,-1104 205,-1434 122,-116 277,-202 430,-237 110,-26 380,-27 475,-1 145,38 312,144 405,256 28,33 55,59 60,57 6,-2 11,-61 13,-144 l 3,-142 h 214 c 117,0 216,3 218,8 6,9 9,822 8,2100 l -1,1022 h -240 -240 z m -433,-855 c 108,-29 184,-72 267,-154 84,-82 131,-165 165,-289 25,-92 25,-348 -1,-440 -28,-103 -83,-197 -165,-283 -60,-63 -91,-86 -155,-117 -100,-47 -160,-60 -284,-60 -177,0 -285,43 -403,159 -84,84 -122,152 -162,289 -18,65 -22,101 -22,232 0,180 13,240 76,367 89,178 240,288 429,313 71,10 179,2 255,-17 z"
|
||||
id="path3"></path>
|
||||
<path
|
||||
d="m 24805,21214 v -1566 l 242,4 c 133,1 244,6 248,10 4,4 9,303 12,665 5,607 6,663 24,724 78,270 377,420 654,327 143,-48 241,-169 271,-337 10,-59 13,-228 14,-735 v -658 l 239,4 c 132,2 243,7 248,12 13,13 16,1294 3,1421 -29,290 -199,563 -417,672 -124,62 -203,76 -391,70 -159,-5 -193,-11 -303,-58 -77,-32 -204,-118 -272,-184 -33,-31 -61,-54 -64,-52 -2,3 -4,285 -3,626 l 1,621 h -253 -253 z"
|
||||
id="path4"></path>
|
||||
<path
|
||||
d="m 33730,22144 c 1,-350 0,-638 -3,-640 -2,-2 -40,29 -84,69 -94,87 -143,122 -240,170 -241,122 -556,121 -798,-1 -147,-73 -244,-153 -335,-274 -101,-134 -179,-306 -216,-478 -28,-128 -26,-412 4,-536 65,-275 193,-497 368,-640 119,-98 302,-181 446,-204 110,-18 353,-8 438,18 143,42 281,132 399,258 38,41 72,74 75,74 3,0 7,-69 8,-152 l 3,-153 216,-3 216,-2 7,297 c 7,316 6,2651 -1,2766 l -5,67 h -249 -250 z m -466,-759 c 240,-51 421,-234 470,-477 20,-95 20,-276 1,-381 -21,-120 -90,-253 -172,-333 -112,-109 -252,-164 -419,-164 -248,0 -435,112 -543,326 -94,186 -113,416 -51,616 47,150 173,304 302,367 117,58 274,75 412,46 z"
|
||||
id="path5"></path>
|
||||
<path
|
||||
d="m 21683,22716 c -90,-28 -179,-110 -201,-184 -62,-206 93,-394 313,-380 93,6 174,47 243,122 60,66 76,110 69,181 -11,110 -103,225 -207,258 -61,20 -158,21 -217,3 z"
|
||||
id="path6"></path>
|
||||
<path
|
||||
d="m 35036,22349 c -3,-34 -6,-174 -6,-310 v -249 h -215 -215 v -149 c 0,-81 3,-176 6,-210 l 7,-61 h 96 c 53,0 146,-3 208,-7 l 111,-6 5,-611 c 5,-674 6,-688 69,-816 45,-92 159,-203 261,-253 176,-86 389,-97 599,-31 126,40 122,32 126,238 4,144 2,176 -9,176 -8,0 -48,-7 -89,-16 -44,-10 -125,-17 -195,-18 -115,-1 -122,0 -169,28 -36,21 -54,40 -70,75 -21,45 -21,54 -21,633 0,322 2,589 5,591 3,3 124,8 270,12 146,4 268,10 272,14 7,6 13,389 6,396 -2,2 -118,6 -258,10 -140,3 -265,8 -277,11 l -23,4 v 305 305 h -244 -243 z"
|
||||
id="path7"></path>
|
||||
<path
|
||||
d="m 13699,21840 c -235,-22 -415,-107 -585,-275 -88,-87 -107,-112 -147,-195 -48,-99 -93,-248 -79,-262 5,-5 109,-11 233,-15 252,-7 232,-12 267,74 60,151 154,218 339,245 162,23 331,-19 406,-101 75,-83 108,-165 122,-300 7,-70 7,-71 -20,-81 -14,-5 -119,-17 -233,-25 -685,-50 -847,-92 -1028,-270 -153,-151 -199,-363 -124,-581 70,-202 278,-372 525,-430 125,-29 322,-25 438,10 48,14 114,39 146,56 67,33 194,128 264,195 26,25 51,44 56,41 5,-3 12,-64 16,-136 7,-122 9,-131 29,-135 11,-3 104,-4 206,-3 l 185,3 6,130 c 10,207 10,1096 -1,1255 -14,213 -52,330 -156,477 -80,113 -217,215 -359,269 -122,45 -342,69 -506,54 z m 559,-1254 c 10,-10 12,-171 3,-219 -15,-84 -38,-124 -106,-193 -69,-70 -162,-122 -270,-150 -97,-25 -291,-26 -360,-1 -124,43 -209,151 -208,265 1,70 12,99 54,146 41,47 93,74 178,91 55,12 422,54 591,68 41,4 111,-1 118,-7 z"
|
||||
id="path8"></path>
|
||||
<path
|
||||
d="m 19935,21840 c -95,-15 -223,-58 -315,-106 -160,-84 -272,-195 -349,-343 -42,-83 -109,-276 -98,-287 9,-9 397,-18 440,-10 21,4 37,11 37,16 0,6 14,40 32,77 70,148 162,204 378,228 75,9 224,-17 288,-49 90,-46 162,-163 188,-305 19,-108 18,-120 -11,-131 -13,-5 -102,-14 -197,-20 -392,-24 -678,-66 -821,-120 -133,-50 -272,-172 -332,-291 -55,-108 -71,-269 -39,-393 60,-232 269,-419 542,-482 89,-21 275,-20 376,1 159,33 302,111 428,233 78,75 72,82 91,-93 l 12,-110 95,-3 c 52,-2 150,0 218,3 l 122,7 v 679 c 0,412 -4,718 -11,777 -39,362 -287,629 -654,707 -93,20 -332,28 -420,15 z m 608,-1388 c -1,-113 -5,-139 -23,-177 -32,-67 -119,-147 -208,-194 -99,-51 -171,-70 -297,-77 -208,-13 -339,58 -391,210 -25,72 -15,135 32,203 61,89 147,118 419,143 72,7 164,16 205,20 41,4 118,7 170,6 l 95,-1 z"
|
||||
id="path9"></path>
|
||||
<path
|
||||
d="m 28005,21826 c -195,-38 -360,-127 -514,-279 -97,-96 -150,-171 -215,-308 -88,-184 -124,-370 -113,-584 13,-246 87,-455 226,-643 116,-156 213,-236 385,-318 156,-74 223,-87 426,-88 151,-1 186,2 260,22 172,46 298,120 441,259 102,98 217,252 204,272 -3,6 -30,23 -58,38 -62,31 -278,123 -289,123 -5,0 -43,-34 -85,-76 -91,-91 -195,-165 -272,-196 -48,-19 -75,-22 -186,-22 -124,0 -134,1 -205,32 -136,58 -230,150 -301,294 -37,73 -76,209 -65,226 3,5 342,12 778,16 495,4 777,11 783,17 17,18 9,274 -13,380 -49,239 -186,470 -372,627 -92,76 -261,163 -380,193 -108,27 -331,35 -435,15 z m 355,-422 c 30,-9 74,-27 97,-41 62,-36 174,-161 215,-240 35,-67 66,-178 53,-191 -4,-4 -247,-8 -541,-10 -613,-3 -552,-16 -511,105 30,88 99,195 168,262 63,59 171,116 249,130 71,13 201,6 270,-15 z"
|
||||
id="path10"></path>
|
||||
<path
|
||||
d="m 23315,21821 c -206,-33 -386,-126 -541,-279 -311,-306 -413,-835 -243,-1260 149,-376 443,-620 808,-673 118,-17 343,-7 446,21 278,72 542,305 655,577 23,54 29,82 23,92 -11,17 -106,35 -258,50 -55,6 -125,13 -156,17 l -57,6 -37,-58 c -111,-177 -272,-274 -452,-274 -211,0 -387,116 -487,322 -114,234 -118,492 -13,707 104,214 267,323 482,323 111,-1 199,-26 281,-81 64,-43 109,-97 170,-204 45,-79 21,-76 259,-33 247,45 255,47 255,60 0,25 -78,181 -128,256 -131,198 -320,341 -537,406 -79,24 -110,27 -255,30 -91,1 -187,-1 -215,-5 z"
|
||||
id="path11"></path>
|
||||
<path
|
||||
d="m 30636,21814 c -155,-34 -251,-84 -411,-213 -55,-44 -103,-77 -107,-75 -5,3 -8,60 -8,128 v 123 l -216,6 c -119,4 -220,5 -224,2 -11,-7 -12,-578 -4,-1418 l 7,-719 236,4 236,3 5,660 c 5,601 7,665 24,715 87,266 307,410 565,372 159,-23 259,-86 327,-208 71,-127 69,-102 69,-862 v -682 h 195 c 107,0 211,3 232,6 l 36,6 6,67 c 4,36 7,356 8,711 2,689 0,733 -48,872 -39,117 -90,195 -188,293 -76,76 -107,99 -186,138 -52,26 -122,54 -155,63 -86,23 -310,28 -399,8 z"
|
||||
id="path12"></path>
|
||||
<path
|
||||
d="m 21540,20721 v -1073 l 239,4 c 132,1 243,7 248,11 9,9 9,2104 0,2113 -3,3 -114,8 -246,11 l -241,6 z"
|
||||
id="path13"></path>
|
||||
<path
|
||||
d="m 15358,20207 c -140,-53 -208,-147 -208,-289 0,-60 5,-84 29,-133 34,-70 88,-126 151,-154 67,-30 188,-29 257,3 60,29 120,85 159,152 24,41 28,58 28,128 1,69 -3,89 -27,138 -30,62 -74,106 -140,140 -53,27 -193,36 -249,15 z"
|
||||
id="path14"></path>
|
||||
<path
|
||||
d="m 15901,18323 c -10,-20 96,-485 148,-653 38,-120 52,-140 96,-140 32,0 34,2 69,86 56,134 136,430 136,504 0,43 10,33 16,-16 7,-55 100,-379 135,-471 35,-89 53,-113 84,-113 30,0 38,12 74,100 23,57 152,530 188,688 5,20 1,22 -30,22 -49,0 -65,-8 -82,-41 -28,-53 -134,-509 -136,-586 -2,-34 -3,-33 -11,17 -28,165 -149,553 -185,592 -10,11 -27,18 -42,16 -28,-3 -43,-45 -172,-455 -41,-130 -59,-162 -59,-104 0,16 -11,71 -24,123 -13,51 -36,145 -51,208 -35,145 -61,217 -85,230 -28,15 -58,12 -69,-7 z"
|
||||
id="path15"></path>
|
||||
<path
|
||||
d="m 17206,18317 c -9,-12 -22,-42 -31,-66 -8,-24 -20,-49 -25,-56 -15,-18 -250,-625 -250,-646 0,-15 7,-19 38,-19 20,0 43,4 50,9 8,4 31,47 51,93 61,136 52,130 178,134 59,2 119,4 132,6 21,3 28,-4 45,-47 77,-193 85,-205 159,-205 45,0 48,-18 -25,185 -80,223 -121,329 -198,505 -54,125 -92,157 -124,107 z m 81,-284 c 28,-87 51,-158 50,-159 -1,-1 -46,-4 -101,-8 l -99,-7 7,33 c 3,18 17,60 31,93 13,34 30,94 36,133 7,40 15,72 18,72 3,0 29,-71 58,-157 z"
|
||||
id="path16"></path>
|
||||
<path
|
||||
d="m 20902,18327 c -13,-15 56,-228 173,-542 93,-249 96,-255 146,-255 46,0 70,44 164,300 30,80 79,213 111,295 71,189 74,202 42,210 -13,3 -36,1 -51,-4 -37,-14 -72,-91 -171,-376 -43,-121 -81,-229 -85,-240 -6,-14 -26,36 -78,190 -100,294 -137,393 -156,416 -19,22 -79,26 -95,6 z"
|
||||
id="path17"></path>
|
||||
<path
|
||||
d="m 31314,18326 c -72,-17 -112,-50 -141,-114 -63,-135 2,-242 192,-317 146,-57 185,-89 185,-149 0,-55 -73,-108 -149,-109 -53,-1 -92,17 -142,66 -45,44 -70,57 -108,57 -25,0 -31,-4 -31,-21 0,-118 221,-243 352,-200 108,36 188,127 188,216 0,106 -62,172 -221,235 -101,40 -158,76 -179,113 -13,24 -13,30 0,54 19,34 85,73 123,73 42,0 86,-22 146,-73 62,-53 88,-59 97,-23 9,37 -23,93 -78,137 -70,55 -152,74 -234,55 z"
|
||||
id="path18"></path>
|
||||
<path
|
||||
d="m 34121,18323 c -33,-28 -308,-750 -297,-780 7,-17 67,-17 88,0 8,6 30,52 47,101 47,131 35,122 143,118 53,-2 106,1 122,7 43,17 66,-4 104,-96 51,-123 64,-140 112,-148 23,-4 46,-3 50,1 5,5 -10,56 -32,114 -22,58 -58,152 -78,210 -135,376 -187,490 -226,490 -6,0 -21,-8 -33,-17 z m 32,-175 c 3,-13 26,-79 52,-147 25,-68 44,-125 43,-126 -7,-4 -203,-24 -206,-20 -3,2 16,64 42,138 25,74 46,144 46,156 0,28 16,27 23,-1 z"
|
||||
id="path19"></path>
|
||||
<path
|
||||
d="m 34672,18327 c -12,-14 -22,-780 -10,-792 13,-13 79,-9 89,5 5,8 9,150 9,315 1,281 2,298 17,270 39,-72 204,-302 311,-435 97,-120 124,-147 156,-158 21,-7 42,-10 45,-6 7,7 10,773 3,792 -2,7 -23,12 -54,12 h -50 l 4,-304 c 3,-282 2,-303 -13,-290 -9,8 -68,90 -130,182 -152,226 -284,391 -326,408 -39,17 -38,17 -51,1 z"
|
||||
id="path20"></path>
|
||||
<path
|
||||
d="m 13091,18311 c -102,-41 -148,-108 -139,-202 9,-88 77,-155 212,-210 179,-72 212,-109 171,-189 -16,-31 -89,-80 -120,-80 -44,0 -102,28 -150,72 -60,54 -93,70 -118,57 -21,-12 -22,-60 -2,-99 16,-31 123,-99 189,-120 66,-21 146,-8 210,35 94,62 121,120 106,228 -8,54 -14,66 -47,95 -40,35 -86,59 -191,98 -98,37 -159,81 -175,127 -5,13 8,28 54,62 91,67 119,64 232,-27 18,-15 48,-29 68,-30 30,-3 34,0 37,24 2,15 -4,40 -12,56 -16,31 -89,89 -139,110 -40,17 -135,14 -186,-7 z"
|
||||
id="path21"></path>
|
||||
<path
|
||||
d="m 14575,18321 c -3,-2 -5,-176 -5,-386 0,-283 3,-384 12,-393 7,-7 31,-12 55,-12 h 43 v 129 c 0,71 3,142 6,159 l 6,30 149,4 c 81,1 152,8 158,13 5,6 11,26 13,45 l 3,35 -160,5 -160,5 -5,100 c -3,55 -3,112 -1,128 l 3,27 h 174 174 v 61 61 l -230,-3 c -127,-2 -233,-5 -235,-8 z"
|
||||
id="path22"></path>
|
||||
<path
|
||||
d="m 15179,18318 c -14,-6 -19,-17 -19,-46 0,-21 5,-43 12,-50 8,-8 50,-12 125,-12 62,0 114,-1 115,-2 1,-2 4,-154 7,-338 l 6,-335 44,-3 c 28,-2 47,1 52,10 5,7 9,153 10,323 0,171 4,319 8,330 8,18 18,20 117,20 h 109 l 8,48 c 5,26 7,51 3,54 -8,9 -573,9 -597,1 z"
|
||||
id="path23"></path>
|
||||
<path
|
||||
d="m 17775,18323 c -11,-3 -23,-8 -27,-12 -11,-10 -22,-513 -13,-653 l 7,-128 h 53 53 l 4,138 c 4,161 10,176 67,166 20,-3 43,-8 52,-10 9,-3 49,-53 88,-113 103,-155 142,-191 208,-191 22,0 24,3 18,28 -10,39 -59,122 -121,204 -30,39 -54,77 -54,84 0,6 17,24 38,40 83,64 121,128 122,204 0,56 -36,134 -81,177 -59,56 -98,66 -254,68 -77,1 -149,0 -160,-2 z m 291,-122 c 59,-27 84,-63 84,-124 0,-46 -3,-53 -49,-98 l -48,-49 -79,6 c -130,11 -124,4 -124,138 0,91 3,118 16,130 23,23 146,21 200,-3 z"
|
||||
id="path24"></path>
|
||||
<path
|
||||
d="m 18610,18323 c -75,-2 -117,-8 -122,-16 -11,-18 -10,-749 2,-767 8,-12 37,-14 177,-12 92,1 192,5 221,8 l 52,6 v 58 57 l -167,-1 -168,-1 -3,100 c -5,159 -16,148 146,144 l 137,-4 3,53 c 4,61 20,55 -158,56 l -125,1 -9,47 c -6,29 -6,70 0,105 l 9,58 157,-4 158,-4 15,36 c 30,72 26,77 -69,77 -46,0 -96,1 -112,3 -16,2 -81,2 -144,0 z"
|
||||
id="path25"></path>
|
||||
<path
|
||||
d="m 19553,18323 -83,-4 v -389 -389 l 37,-7 c 62,-12 263,4 324,25 106,36 178,98 228,199 45,88 44,266 -2,362 -31,67 -131,161 -192,182 -54,19 -182,27 -312,21 z m 249,-115 c 57,-17 131,-87 158,-150 19,-45 22,-64 18,-140 -5,-72 -10,-96 -35,-140 -53,-94 -156,-143 -290,-136 l -58,3 -3,279 c -2,217 1,281 10,288 20,12 156,10 200,-4 z"
|
||||
id="path26"></path>
|
||||
<path
|
||||
d="m 20390,18321 -75,-6 -7,-340 c -4,-187 -5,-364 -2,-392 l 5,-53 142,1 c 198,1 306,11 318,29 9,14 6,77 -5,87 -3,3 -80,6 -173,7 l -168,1 -3,80 c -2,44 0,99 3,123 l 6,42 h 150 149 v 50 49 l -152,3 -153,3 -3,107 -3,108 173,-3 173,-2 8,48 c 5,26 6,51 2,55 -9,9 -287,11 -385,3 z"
|
||||
id="path27"></path>
|
||||
<path
|
||||
d="m 21755,18319 c -3,-8 -6,-187 -7,-399 l -3,-385 175,-3 c 96,-1 202,1 235,5 l 60,8 v 53 53 l -175,2 -175,2 -3,70 c -2,39 0,94 3,124 l 7,54 126,-5 c 70,-2 135,-3 145,0 14,3 17,14 17,59 v 56 l -140,-6 c -167,-8 -162,-11 -158,120 l 3,86 170,1 170,1 9,48 c 4,26 5,51 2,54 -4,4 -107,9 -231,11 -185,4 -226,2 -230,-9 z"
|
||||
id="path28"></path>
|
||||
<path
|
||||
d="m 22417,18143 c -9,-236 -9,-526 -1,-576 l 7,-37 h 205 c 113,1 219,4 234,7 27,5 28,8 28,59 0,63 26,56 -207,54 l -153,-1 -2,338 -3,338 -51,3 -51,3 z"
|
||||
id="path29"></path>
|
||||
<path
|
||||
d="m 23275,18319 c -206,-27 -350,-252 -301,-470 42,-186 160,-301 326,-316 109,-10 242,52 321,150 77,96 101,195 79,317 -18,102 -45,153 -116,219 -90,85 -187,116 -309,100 z m 153,-124 c 51,-22 120,-91 144,-145 24,-56 27,-179 5,-232 -21,-49 -92,-125 -140,-150 -51,-25 -130,-33 -185,-18 -58,15 -124,77 -152,143 -33,75 -34,214 -3,274 25,46 78,93 138,123 49,24 140,26 193,5 z"
|
||||
id="path30"></path>
|
||||
<path
|
||||
d="m 23939,18321 c -8,-2 -17,-9 -21,-15 -4,-6 -8,-180 -8,-387 0,-420 -6,-389 71,-389 h 39 v 134 c 0,73 3,147 6,164 l 6,30 122,4 c 108,3 127,7 173,31 107,55 146,184 89,295 -56,110 -112,134 -317,136 -81,1 -153,0 -160,-3 z m 302,-111 c 55,-15 82,-50 87,-115 6,-70 -11,-97 -74,-119 -58,-20 -209,-22 -221,-3 -9,15 -18,211 -9,232 4,12 24,15 93,15 48,0 104,-5 124,-10 z"
|
||||
id="path31"></path>
|
||||
<path
|
||||
d="m 24710,18320 -95,-5 -3,-384 c -2,-301 1,-386 10,-393 16,-9 216,-10 351,-1 l 97,6 v 56 56 h -172 -173 l -3,100 c -5,157 -15,146 141,145 73,-1 142,-1 155,-1 20,1 22,6 22,57 v 57 l -147,-7 c -82,-3 -154,-2 -161,2 -9,6 -12,35 -10,108 l 3,99 h 155 c 212,1 197,-2 202,45 2,23 1,45 -2,49 -7,11 -250,18 -370,11 z"
|
||||
id="path32"></path>
|
||||
<path
|
||||
d="m 25286,18323 c -3,-4 -6,-183 -6,-400 v -393 h 65 65 v 128 c 0,70 3,137 6,149 6,23 29,30 82,24 29,-3 40,-15 106,-115 41,-61 89,-126 106,-143 39,-40 130,-70 130,-43 0,12 -86,152 -146,237 -24,34 -44,66 -44,71 0,6 21,22 46,37 62,36 124,131 124,190 0,100 -78,218 -162,245 -43,14 -360,25 -372,13 z m 317,-114 c 21,-5 52,-23 69,-38 26,-23 32,-37 36,-85 l 5,-57 -47,-43 c -50,-47 -77,-56 -165,-56 -83,0 -84,2 -85,122 -1,117 6,155 32,161 33,9 117,7 155,-4 z"
|
||||
id="path33"></path>
|
||||
<path
|
||||
d="m 27150,17929 v -402 l 50,5 c 28,3 54,8 58,12 9,10 15,676 6,739 l -7,47 h -53 -54 z"
|
||||
id="path34"></path>
|
||||
<path
|
||||
d="m 27428,18324 c -5,-4 -8,-29 -8,-56 0,-46 1,-48 28,-49 77,-2 209,-10 220,-14 9,-3 12,-80 12,-340 v -335 h 54 54 l 7,318 c 3,174 10,328 14,342 7,24 10,25 113,28 101,3 105,4 117,28 6,14 11,36 11,49 v 25 h -257 c -142,0 -280,3 -308,6 -27,3 -53,3 -57,-2 z"
|
||||
id="path35"></path>
|
||||
<path
|
||||
d="m 28780,18315 c -78,-22 -134,-59 -179,-116 -160,-202 -96,-521 127,-631 58,-29 76,-33 147,-33 72,0 86,3 142,34 101,55 179,144 153,176 -22,27 -47,22 -104,-21 -104,-77 -166,-96 -254,-74 -132,31 -213,189 -183,355 14,76 52,130 121,172 57,35 120,49 173,39 18,-3 69,-29 112,-56 44,-28 87,-50 95,-50 16,0 40,38 40,63 0,25 -76,91 -138,120 -78,36 -170,44 -252,22 z"
|
||||
id="path36"></path>
|
||||
<path
|
||||
d="m 29632,18319 c -146,-29 -258,-136 -296,-284 -61,-232 88,-463 323,-501 90,-14 214,38 303,127 83,82 109,143 109,257 1,147 -29,222 -126,310 -84,77 -207,113 -313,91 z m 194,-142 c 118,-74 160,-177 129,-314 -18,-78 -51,-125 -120,-173 -124,-85 -283,-47 -361,86 -35,60 -46,200 -20,267 28,74 118,149 201,167 48,10 129,-6 171,-33 z"
|
||||
id="path37"></path>
|
||||
<path
|
||||
d="m 30287,18323 c -4,-3 -7,-181 -7,-394 v -387 l 23,-6 c 12,-3 37,-6 55,-6 h 32 l 1,203 c 0,111 0,249 -1,306 -1,63 2,101 8,97 5,-3 28,-36 50,-73 22,-38 78,-120 124,-184 249,-349 244,-343 305,-355 22,-4 32,-2 36,9 6,15 9,290 8,619 l -1,167 -34,6 c -19,4 -44,4 -56,0 -21,-7 -21,-8 -18,-310 2,-167 2,-303 2,-302 -31,37 -76,104 -97,142 -47,86 -328,459 -353,469 -21,8 -69,8 -77,-1 z"
|
||||
id="path38"></path>
|
||||
<path
|
||||
d="m 31861,18316 c -10,-11 -12,-81 -9,-288 l 3,-273 33,-68 c 27,-55 44,-74 85,-102 157,-102 376,-51 459,109 21,39 22,53 23,336 v 295 h -55 -55 v -275 c -1,-297 -4,-320 -56,-366 -84,-74 -248,-51 -294,41 -18,37 -20,61 -21,320 l -1,280 -50,3 c -35,2 -53,-2 -62,-12 z"
|
||||
id="path39"></path>
|
||||
<path
|
||||
d="m 32712,18323 -22,-4 2,-392 3,-392 219,1 c 245,2 244,2 253,73 l 6,41 h -181 c -155,0 -181,2 -186,16 -3,9 -6,154 -6,324 0,359 2,350 -88,333 z"
|
||||
id="path40"></path>
|
||||
<path
|
||||
d="m 13868,18301 c -71,-28 -113,-57 -162,-112 -100,-111 -117,-299 -41,-449 35,-70 106,-142 172,-175 50,-26 69,-30 138,-30 69,0 89,4 148,32 81,39 166,116 210,192 31,54 32,60 32,171 0,106 -2,120 -28,173 -38,76 -113,151 -184,185 -77,36 -213,42 -285,13 z m 240,-117 c 93,-46 152,-145 152,-253 0,-106 -69,-208 -180,-267 -49,-26 -164,-25 -209,1 -113,67 -167,224 -125,366 42,144 225,221 362,153 z"
|
||||
id="path41"></path>
|
||||
<path
|
||||
d="m 33171,18311 c -17,-11 -19,-67 -3,-83 7,-7 51,-12 116,-12 57,-1 108,-5 113,-9 4,-5 10,-159 12,-343 l 3,-335 57,3 56,3 5,330 c 3,182 9,336 13,343 6,8 40,12 112,12 h 103 l 11,31 c 26,74 45,69 -286,69 -164,0 -304,-4 -312,-9 z"
|
||||
id="path42"></path>
|
||||
<path
|
||||
d="m 35462,18273 3,-48 118,-6 c 65,-3 122,-10 126,-15 5,-5 11,-155 15,-333 5,-242 9,-327 19,-333 19,-12 72,-9 85,4 9,9 12,97 12,333 0,176 4,325 8,332 5,8 45,13 117,15 l 110,3 11,35 c 6,19 9,40 7,47 -4,10 -76,13 -320,13 h -314 z"
|
||||
id="path43"></path>
|
||||
<path
|
||||
d="m 26404,17976 c -37,-37 -45,-83 -23,-145 14,-40 35,-51 99,-51 44,0 56,4 82,31 28,27 30,34 25,78 -8,67 -25,98 -60,111 -55,19 -87,12 -123,-24 z"
|
||||
id="path44"></path>
|
||||
</g>
|
||||
</svg>
|
||||
<style>
|
||||
.logo {
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
max-width: 300px; /* Adjust as needed */
|
||||
}
|
||||
|
||||
.logo-group path {
|
||||
fill: #061e45; /* Dark blue for light mode */
|
||||
transition: fill 0.3s ease;
|
||||
}
|
||||
|
||||
/* If you're using a class-based dark mode approach */
|
||||
:global(.dark) .logo-group path {
|
||||
fill: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 20 KiB |
3
src/components/Ol.astro
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<ol class="list-disc list-inside" {...Astro.props}>
|
||||
<slot />
|
||||
</ol>
|
||||
22
src/components/PageHeadline.astro
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
const { title, subtitle } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="text-center mb-16">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{title}
|
||||
</h1>
|
||||
{
|
||||
subtitle && (
|
||||
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
<div class="mt-8 w-24 h-1 bg-mytheme-400 mx-auto rounded-full"></div>
|
||||
</div>
|
||||
10
src/components/Picture.astro
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
import { Picture as AstroPicture } from "astro:assets";
|
||||
---
|
||||
|
||||
<AstroPicture
|
||||
src={Astro.props.src}
|
||||
alt={Astro.props.alt}
|
||||
formats={["avif", "webp"]}
|
||||
{...Astro.props}
|
||||
/>
|
||||
99
src/components/ProjectSection.astro
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
import { type Project } from "../consts";
|
||||
import Carousel from "./Carousel.astro";
|
||||
import ThreeColumnSection from "./ThreeColumnSection.astro";
|
||||
|
||||
interface Props {
|
||||
projects: Project[];
|
||||
}
|
||||
|
||||
const { projects } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
projects.map((project) => (
|
||||
<ThreeColumnSection maxWidth="8xl" py="py-8">
|
||||
{/* ---------- LEFT: Project details ---------- */}
|
||||
<div slot="left" class="max-w-xs justify-self-center lg:justify-self-end">
|
||||
<article class="space-y-4">
|
||||
{/* Title + optional company */}
|
||||
<h3 class="text-2xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
{project.title}
|
||||
|
||||
{/* Duration */}
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 flex items-center mt-1">
|
||||
<Icon name="mdi:calendar" class="mr-2" />
|
||||
{project.duration}
|
||||
</p>
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p class="text-base text-slate-800 dark:text-slate-200">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
{/* Tech stack */}
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{project.tech_stack.map((tech) => (
|
||||
<span class="px-2 py-0.5 text-xs font-medium bg-slate-200 dark:bg-slate-600 text-slate-800 dark:text-slate-100 rounded">
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Deliverables (optional) */}
|
||||
{project.deliverables?.length && (
|
||||
<ul class="list-disc list-inside text-sm text-slate-700 dark:text-slate-300 mt-3">
|
||||
{project.deliverables.map((item) => (
|
||||
<li class="mb-1">{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Links */}
|
||||
<div class="flex space-x-4 mt-4">
|
||||
{project.live_url && (
|
||||
<a
|
||||
href={project.live_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-sm font-medium
|
||||
transition-colors duration-150
|
||||
bg-slate-100 hover:bg-slate-200 focus-visible:outline focus-visible:outline-2
|
||||
focus-visible:outline-offset-2 focus-visible:outline-slate-500
|
||||
dark:bg-slate-800 dark:hover:bg-slate-700 dark:focus-visible:outline-slate-400"
|
||||
>
|
||||
<Icon name="mdi:aspect-ratio" /> Live
|
||||
</a>
|
||||
)}
|
||||
{project.repo_url && (
|
||||
<a
|
||||
href={project.repo_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1.5 rounded px-3 py-1.5 text-sm font-medium
|
||||
transition-colors duration-150
|
||||
bg-slate-100 hover:bg-slate-200 focus-visible:outline focus-visible:outline-2
|
||||
focus-visible:outline-offset-2 focus-visible:outline-slate-500
|
||||
dark:bg-slate-800 dark:hover:bg-slate-700 dark:focus-visible:outline-slate-400"
|
||||
>
|
||||
<Icon name="mdi:github" /> Repo
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{/* ---------- CENTER: Carousel ---------- */}
|
||||
<div slot="center" class="w-full">
|
||||
<div class="rounded-lg overflow-hidden shadow-lg">
|
||||
<Carousel images={project.images} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---------- RIGHT: Empty for now ---------- */}
|
||||
<div slot="right" class="hidden lg:block" />
|
||||
</ThreeColumnSection>
|
||||
))
|
||||
}
|
||||
139
src/components/ThreeColumnSection.astro
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
---
|
||||
export interface Props {
|
||||
bgClass?: string;
|
||||
containerClass?: string;
|
||||
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "4xl" | "6xl" | "8xl";
|
||||
py?: string;
|
||||
reverseOnMobile?: boolean;
|
||||
}
|
||||
|
||||
const {
|
||||
bgClass = "",
|
||||
containerClass = "",
|
||||
maxWidth = "8xl",
|
||||
py = "py-8 md:py-12",
|
||||
reverseOnMobile = false,
|
||||
} = Astro.props;
|
||||
|
||||
const maxWidthClasses = {
|
||||
sm: "max-w-sm",
|
||||
md: "max-w-md",
|
||||
lg: "max-w-lg",
|
||||
xl: "max-w-xl",
|
||||
"2xl": "max-w-2xl",
|
||||
"4xl": "max-w-4xl",
|
||||
"6xl": "max-w-6xl",
|
||||
"8xl": "max-w-8xl",
|
||||
};
|
||||
---
|
||||
|
||||
<section class={`${bgClass} ${py}`} data-threecol>
|
||||
<div
|
||||
class={`threecol-row ${maxWidthClasses[maxWidth]} mx-auto flex flex-col lg:flex-row gap-8 px-4 ${containerClass}`}
|
||||
>
|
||||
<!-- Left Column -->
|
||||
<div
|
||||
class={`${reverseOnMobile ? "order-3 lg:order-1" : "order-1"} flex-col`}
|
||||
data-left
|
||||
>
|
||||
<slot name="left" />
|
||||
</div>
|
||||
|
||||
<!-- Center Column -->
|
||||
<div class={`order-1 lg:order-2 flex-col`} data-center>
|
||||
<slot name="center" />
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div
|
||||
class={`${reverseOnMobile ? "order-1 lg:order-3" : "order-3"} flex-col`}
|
||||
data-right
|
||||
>
|
||||
<slot name="right" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Base layout variables */
|
||||
[data-threecol] .threecol-row {
|
||||
--center-initial: 640px; /* initial center width at lg+ */
|
||||
--transition-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* Make sure columns can shrink properly */
|
||||
[data-threecol] [data-left],
|
||||
[data-threecol] [data-center],
|
||||
[data-threecol] [data-right] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Large-screen flex behavior (mobile stays stacked naturally) */
|
||||
@media (min-width: 1024px) {
|
||||
[data-threecol] .threecol-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
[data-threecol] [data-left] {
|
||||
max-width: 33.33%;
|
||||
flex: 1 1 33.33%;
|
||||
/* optional: transition flex changes if you expect subtle movement */
|
||||
transition: flex 0.55s var(--transition-ease);
|
||||
}
|
||||
|
||||
[data-threecol] [data-center] {
|
||||
flex: 0 0 var(--center-initial);
|
||||
transition:
|
||||
flex-basis 0.55s var(--transition-ease),
|
||||
flex-grow 0.55s var(--transition-ease),
|
||||
max-width 0.55s var(--transition-ease);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-threecol] [data-right] {
|
||||
flex: 1 1 0;
|
||||
transition:
|
||||
flex-basis 0.55s var(--transition-ease),
|
||||
flex-grow 0.55s var(--transition-ease),
|
||||
opacity 0.35s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* THEATER MODE (expanded center) */
|
||||
[data-threecol].theater-mode [data-center] {
|
||||
flex: 1 1 0; /* allow it to fill remaining space */
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
[data-threecol].theater-mode [data-right] {
|
||||
flex: 0 0 0;
|
||||
flex-grow: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Optional: subtle emphasis on the center content while expanding */
|
||||
[data-threecol] .threecol-row :is([data-center]) .carousel-theater-target,
|
||||
[data-threecol] .threecol-row :is([data-center]) > * {
|
||||
transition:
|
||||
box-shadow 0.55s var(--transition-ease),
|
||||
transform 0.55s var(--transition-ease);
|
||||
}
|
||||
[data-threecol].theater-mode [data-center] .carousel-theater-target,
|
||||
[data-threecol].theater-mode [data-center] > * {
|
||||
/* Example visual polish (comment out if not desired) */
|
||||
/* box-shadow: 0 6px 24px -4px rgba(0,0,0,.25); */
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
[data-threecol] [data-left],
|
||||
[data-threecol] [data-center],
|
||||
[data-threecol] [data-right] {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
3
src/components/Ul.astro
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<ul class="list-disc list-outside ml-8" {...Astro.props}>
|
||||
<slot />
|
||||
</ul>
|
||||
220
src/components/nav/DarkModeToggle.astro
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
---
|
||||
|
||||
---
|
||||
|
||||
<script>
|
||||
const themeToggleBtns = document.querySelectorAll(
|
||||
".theme-toggle",
|
||||
) as NodeListOf<HTMLInputElement>;
|
||||
const sliders = document.querySelectorAll(".slider");
|
||||
|
||||
// Function to animate theme transition
|
||||
function animateThemeTransition(
|
||||
toggleButton: HTMLElement,
|
||||
isDarkMode: boolean,
|
||||
) {
|
||||
// Set animation to start from top right corner
|
||||
const centerX = window.innerWidth - 20; // 20px from right edge
|
||||
const centerY = 20; // 20px from top edge
|
||||
|
||||
// Calculate radius needed to cover entire screen from top right
|
||||
const maxDistance = Math.sqrt(
|
||||
Math.pow(centerX, 2) + Math.pow(window.innerHeight - centerY, 2),
|
||||
);
|
||||
|
||||
// Create a temporary pseudo-element animation using CSS custom properties
|
||||
const newThemeColor = isDarkMode ? "rgb(17, 24, 39)" : "rgb(255, 255, 255)";
|
||||
|
||||
// Apply the animation using CSS custom properties
|
||||
document.documentElement.style.setProperty(
|
||||
"--theme-transition-color",
|
||||
newThemeColor,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--theme-transition-x",
|
||||
`${centerX}px`,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--theme-transition-y",
|
||||
`${centerY}px`,
|
||||
);
|
||||
document.documentElement.style.setProperty(
|
||||
"--theme-transition-radius",
|
||||
"0px",
|
||||
);
|
||||
|
||||
// Add animation class but don't change theme colors yet
|
||||
document.body.classList.add("theme-transitioning");
|
||||
|
||||
// Start animation
|
||||
requestAnimationFrame(() => {
|
||||
document.documentElement.style.setProperty(
|
||||
"--theme-transition-radius",
|
||||
`${maxDistance}px`,
|
||||
);
|
||||
});
|
||||
|
||||
// Switch theme colors halfway through animation when background has covered most content
|
||||
setTimeout(() => {
|
||||
if (isDarkMode) {
|
||||
document.documentElement.classList.add("dark");
|
||||
localStorage.setItem("color-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
localStorage.setItem("color-theme", "light");
|
||||
}
|
||||
}, 300); // Half of the 600ms animation
|
||||
|
||||
// Clean up after full animation completes
|
||||
setTimeout(() => {
|
||||
// Remove animation class
|
||||
document.body.classList.remove("theme-transitioning");
|
||||
}, 600);
|
||||
}
|
||||
|
||||
// Set initial state of toggle based on previous settings
|
||||
if (
|
||||
localStorage.getItem("color-theme") === "dark" ||
|
||||
(!("color-theme" in localStorage) &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
) {
|
||||
themeToggleBtns.forEach((btn) => (btn.checked = true));
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
themeToggleBtns.forEach((btn) => (btn.checked = false));
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
// Remove no-transition class after initial load
|
||||
window.addEventListener("load", () => {
|
||||
setTimeout(() => {
|
||||
sliders.forEach((slider) => slider.classList.remove("no-transition"));
|
||||
}, 0);
|
||||
});
|
||||
themeToggleBtns.forEach((btn) => {
|
||||
btn.addEventListener("change", function () {
|
||||
// Prevent default theme switching - we'll handle it after animation
|
||||
const currentIsDark = document.documentElement.classList.contains("dark");
|
||||
const targetIsDark = !currentIsDark;
|
||||
|
||||
// Update toggle states immediately for visual feedback
|
||||
themeToggleBtns.forEach(
|
||||
(toggleBtn) => (toggleBtn.checked = targetIsDark),
|
||||
);
|
||||
|
||||
// Find the closest switch element to get position
|
||||
const switchElement = btn.closest(".switch") as HTMLElement;
|
||||
|
||||
// Animate the transition
|
||||
animateThemeTransition(switchElement, targetIsDark);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<label class="switch" for="theme-toggle">
|
||||
<span class="sr-only">Toggle dark mode</span>
|
||||
<input
|
||||
id="theme-toggle"
|
||||
class="theme-toggle"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
/>
|
||||
<span class="slider round no-transition" aria-hidden="true"></span>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--tw-mytheme-200);
|
||||
transition: 0.4s;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: gold;
|
||||
transition: 0.4s;
|
||||
background: radial-gradient(
|
||||
yellow,
|
||||
orange 63%,
|
||||
transparent calc(63% + 3px) 100%
|
||||
);
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--tw-mytheme-600);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
background-color: white;
|
||||
background: radial-gradient(
|
||||
circle at 19% 19%,
|
||||
transparent 41%,
|
||||
var(--tw-mytheme-50) 43%
|
||||
);
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 5px var(--tw-mytheme-700);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
.slider.round {
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider.round:before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.no-transition {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.no-transition:before {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Theme transition animation */
|
||||
body.theme-transitioning::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: var(--theme-transition-color);
|
||||
clip-path: circle(
|
||||
var(--theme-transition-radius) at var(--theme-transition-x)
|
||||
var(--theme-transition-y)
|
||||
);
|
||||
transition: clip-path 0.6s ease-in-out;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
||||
22
src/components/nav/DesktopNav.astro
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
import HeaderLink from "../HeaderLink.astro";
|
||||
|
||||
export const navItems = [
|
||||
{ href: "/", label: "Home" },
|
||||
{ href: "/blog", label: "Blog" },
|
||||
{ href: "/projects", label: "Projects" },
|
||||
{ href: "/publications", label: "Publications" },
|
||||
{ href: "/contact", label: "Contact" },
|
||||
];
|
||||
---
|
||||
|
||||
<!-- Desktop Navigation -->
|
||||
<nav class="hidden lg:block">
|
||||
<div class="flex gap-4">
|
||||
{
|
||||
navItems.map((item) => (
|
||||
<HeaderLink href={item.href}>{item.label}</HeaderLink>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
39
src/components/nav/Footer.astro
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
---
|
||||
const today = new Date();
|
||||
const lastUpdated = new Date(); // You can set this to a specific date or pull from your build process
|
||||
---
|
||||
|
||||
<footer
|
||||
class="bg-gray-100/60 dark:bg-mytheme-900 shadow-sm text-gray-600 dark:text-gray-400 px-4 py-8"
|
||||
>
|
||||
<div class="max-w-full lg:px-16 md:px-8 px-2 py-4">
|
||||
<!-- Main horizontal layout -->
|
||||
<div
|
||||
class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4"
|
||||
>
|
||||
<!-- Copyright -->
|
||||
<div class="text-center sm:text-left">
|
||||
<p>
|
||||
© {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>
|
||||
40
src/components/nav/MobileNav.astro
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
|
||||
---
|
||||
|
||||
<!-- Mobile Navigation Button Only -->
|
||||
<div class="lg:hidden">
|
||||
<button
|
||||
id="mobile-nav-toggle"
|
||||
class="relative w-8 h-8 flex items-center justify-center flex-col z-50"
|
||||
aria-label="Toggle navigation"
|
||||
type="button"
|
||||
>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hamburger-line {
|
||||
@apply block w-5 h-0.5 bg-gray-600 dark:bg-gray-300 transition-all duration-300;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.hamburger-line:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
#mobile-nav-toggle.nav-open .hamburger-line:nth-child(1) {
|
||||
transform: rotate(45deg) translate(2px, 2px);
|
||||
}
|
||||
|
||||
#mobile-nav-toggle.nav-open .hamburger-line:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#mobile-nav-toggle.nav-open .hamburger-line:nth-child(3) {
|
||||
transform: rotate(-45deg) translate(2px, -2px);
|
||||
}
|
||||
</style>
|
||||
130
src/components/nav/MobileNavDrawer.astro
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
---
|
||||
import { navItems } from "./DesktopNav.astro";
|
||||
import HeaderLink from "../HeaderLink.astro";
|
||||
import DarkModeToggle from "./DarkModeToggle.astro";
|
||||
---
|
||||
|
||||
<!-- Mobile Navigation Drawer and Backdrop -->
|
||||
<div class="lg:hidden">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
id="mobile-backdrop"
|
||||
class="fixed inset-0 bg-black/25 z-40 opacity-0 pointer-events-none transition-opacity duration-300"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Drawer -->
|
||||
<nav
|
||||
id="mobile-drawer"
|
||||
class="fixed top-0 right-0 h-full w-48 bg-white dark:bg-gray-800 shadow-xl transform translate-x-full transition-transform duration-300 ease-in-out z-50"
|
||||
>
|
||||
<!-- Close button in drawer -->
|
||||
<div
|
||||
class="flex justify-between p-4 border-b border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<DarkModeToggle />
|
||||
<button
|
||||
id="mobile-nav-close"
|
||||
class="w-8 h-8 flex items-center justify-center text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white rounded-md hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="p-6">
|
||||
<div class="flex flex-col space-y-4">
|
||||
{
|
||||
navItems.map((item) => (
|
||||
<HeaderLink href={item.href}>{item.label}</HeaderLink>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<script>
|
||||
function initMobileNav() {
|
||||
const toggle = document.getElementById("mobile-nav-toggle")!;
|
||||
const closeBtn = document.getElementById("mobile-nav-close")!;
|
||||
const drawer = document.getElementById("mobile-drawer")!;
|
||||
const backdrop = document.getElementById("mobile-backdrop")!;
|
||||
|
||||
if (!toggle || !drawer || !backdrop) {
|
||||
console.error("Missing elements, retrying in 100ms...");
|
||||
setTimeout(initMobileNav, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
let isOpen = false;
|
||||
|
||||
function openNav() {
|
||||
console.log("Opening nav");
|
||||
isOpen = true;
|
||||
toggle.classList.add("nav-open");
|
||||
backdrop.classList.remove("opacity-0", "pointer-events-none");
|
||||
drawer.classList.remove("translate-x-full");
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
function closeNav() {
|
||||
console.log("Closing nav");
|
||||
isOpen = false;
|
||||
toggle.classList.remove("nav-open");
|
||||
backdrop.classList.add("opacity-0", "pointer-events-none");
|
||||
drawer.classList.add("translate-x-full");
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
|
||||
// Toggle button click
|
||||
toggle.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
console.log("Toggle clicked, isOpen:", isOpen);
|
||||
isOpen ? closeNav() : openNav();
|
||||
});
|
||||
|
||||
// Close button click
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
console.log("Close button clicked");
|
||||
closeNav();
|
||||
});
|
||||
}
|
||||
|
||||
// Backdrop click
|
||||
backdrop.addEventListener("click", () => {
|
||||
console.log("Backdrop clicked");
|
||||
closeNav();
|
||||
});
|
||||
|
||||
// Navigation links
|
||||
drawer.querySelectorAll("a").forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
console.log("Nav link clicked");
|
||||
closeNav();
|
||||
});
|
||||
});
|
||||
|
||||
// Escape key
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && isOpen) {
|
||||
console.log("Escape pressed");
|
||||
closeNav();
|
||||
}
|
||||
});
|
||||
|
||||
console.log("Mobile nav initialized successfully");
|
||||
}
|
||||
|
||||
// Try multiple initialization strategies
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initMobileNav);
|
||||
} else {
|
||||
initMobileNav();
|
||||
}
|
||||
|
||||
// Also try with astro:page-load for Astro's client-side navigation
|
||||
document.addEventListener("astro:page-load", initMobileNav);
|
||||
</script>
|
||||
34
src/components/nav/TopHeader.astro
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
---
|
||||
import DarkModeToggle from "./DarkModeToggle.astro";
|
||||
import DesktopNav from "./DesktopNav.astro";
|
||||
import Logo from "../Logo.astro";
|
||||
import MobileNav from "./MobileNav.astro";
|
||||
---
|
||||
|
||||
<header
|
||||
class="bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm border-b border-gray-200 dark:border-gray-700 z-20 relative"
|
||||
>
|
||||
<div class="max-w-full lg:px-16 md:px-8 px-2 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="font-bold text-xl mb-0 font-mono flex">
|
||||
<a
|
||||
href="/"
|
||||
class="hover:text-mytheme-600 dark:hover:text-mytheme-400 transition-colors"
|
||||
>
|
||||
<Logo />
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<div class="flex-1 flex justify-center">
|
||||
<div class="max-w-2xl w-full flex justify-end lg:justify-start">
|
||||
<DesktopNav />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="hidden lg:block"><DarkModeToggle /></div>
|
||||
<MobileNav />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
106
src/components/verification/AddCVVerification.astro
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<form
|
||||
method="POST"
|
||||
class="flex flex-col gap-4 max-w-lg mx-auto p-6 rounded-lg shadow-md
|
||||
bg-white dark:bg-gray-800 transition-colors"
|
||||
>
|
||||
<h2 class="text-2xl font-bold mb-4 text-gray-800 dark:text-white">
|
||||
Add CV Verification
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label
|
||||
for="company_name"
|
||||
class="font-medium text-gray-700 dark:text-gray-200"
|
||||
>
|
||||
Company Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="company_name"
|
||||
name="company_name"
|
||||
required
|
||||
class="border rounded-md p-2
|
||||
bg-gray-50 dark:bg-gray-900
|
||||
text-gray-900 dark:text-white
|
||||
border-gray-300 dark:border-gray-600
|
||||
focus:border-blue-500 dark:focus:border-blue-400
|
||||
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
|
||||
focus:outline-none
|
||||
placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="author" class="font-medium text-gray-700 dark:text-gray-200">
|
||||
Author
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="author"
|
||||
name="author"
|
||||
required
|
||||
value="Alexander Daichendt"
|
||||
class="border rounded-md p-2
|
||||
bg-gray-50 dark:bg-gray-900
|
||||
text-gray-900 dark:text-white
|
||||
border-gray-300 dark:border-gray-600
|
||||
focus:border-blue-500 dark:focus:border-blue-400
|
||||
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
|
||||
focus:outline-none
|
||||
placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="purpose" class="font-medium text-gray-700 dark:text-gray-200">
|
||||
Purpose
|
||||
</label>
|
||||
<textarea
|
||||
id="purpose"
|
||||
name="purpose"
|
||||
required
|
||||
rows="3"
|
||||
class="border rounded-md p-2
|
||||
bg-gray-50 dark:bg-gray-900
|
||||
text-gray-900 dark:text-white
|
||||
border-gray-300 dark:border-gray-600
|
||||
focus:border-blue-500 dark:focus:border-blue-400
|
||||
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
|
||||
focus:outline-none
|
||||
placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
>Job Application
|
||||
</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="tooling" class="font-medium text-gray-700 dark:text-gray-200">
|
||||
Tooling
|
||||
</label>
|
||||
<textarea
|
||||
id="tooling"
|
||||
name="tooling"
|
||||
required
|
||||
rows="3"
|
||||
class="border rounded-md p-2
|
||||
bg-gray-50 dark:bg-gray-900
|
||||
text-gray-900 dark:text-white
|
||||
border-gray-300 dark:border-gray-600
|
||||
focus:border-blue-500 dark:focus:border-blue-400
|
||||
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
|
||||
focus:outline-none
|
||||
placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
>Typst</textarea
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600
|
||||
text-white py-2 px-4 rounded-md
|
||||
transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Submit Verification
|
||||
</button>
|
||||
</form>
|
||||
101
src/components/verification/AddHashAndSig.astro
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
---
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
const { uid } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
class="max-w-lg mx-auto p-6 rounded-lg shadow-md bg-white dark:bg-gray-800 transition-colors mt-8"
|
||||
>
|
||||
<h2 class="text-2xl font-bold mb-4 text-gray-800 dark:text-white">
|
||||
Generated UID
|
||||
</h2>
|
||||
<div class="relative">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<code
|
||||
class="uid-code bg-gray-50 dark:bg-gray-900 px-3 py-2 rounded-md text-gray-900 dark:text-white flex-grow font-mono"
|
||||
>
|
||||
{uid}
|
||||
</code>
|
||||
<button
|
||||
class="copy-button p-2 text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white
|
||||
bg-gray-50 dark:bg-gray-900 rounded-md
|
||||
transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<Icon name="mdi:content-copy" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-gray-700 dark:text-gray-200 mb-4 mt-6">
|
||||
Embed this uid in the CV typst document. After that, calculate the sha256sum
|
||||
and enter it below along with your GPG signature
|
||||
</p>
|
||||
<form method="POST" class="flex flex-col gap-4">
|
||||
<input type="hidden" name="uid" value={uid} />
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="sha256" class="font-medium text-gray-700 dark:text-gray-200">
|
||||
SHA256 Hash
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="sha256"
|
||||
name="sha256"
|
||||
required
|
||||
class="border rounded-md p-2
|
||||
bg-gray-50 dark:bg-gray-900
|
||||
text-gray-900 dark:text-white
|
||||
border-gray-300 dark:border-gray-600
|
||||
focus:border-blue-500 dark:focus:border-blue-400
|
||||
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
|
||||
focus:outline-none
|
||||
placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="gpg" class="font-medium text-gray-700 dark:text-gray-200">
|
||||
PGP Signature
|
||||
</label>
|
||||
<textarea
|
||||
id="pgp_signature"
|
||||
name="pgp_signature"
|
||||
required
|
||||
rows="4"
|
||||
placeholder="Paste your PGP signature here"
|
||||
class="border rounded-md p-2
|
||||
bg-gray-50 dark:bg-gray-900
|
||||
text-gray-900 dark:text-white
|
||||
border-gray-300 dark:border-gray-600
|
||||
focus:border-blue-500 dark:focus:border-blue-400
|
||||
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
|
||||
focus:outline-none
|
||||
placeholder:text-gray-400 dark:placeholder:text-gray-500
|
||||
resize-y"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600
|
||||
text-white py-2 px-4 rounded-md
|
||||
transition-colors duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Submit Hash and Signature
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const buttons = document.querySelectorAll(".copy-button");
|
||||
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const uid = document.querySelectorAll(".uid-code")[0]?.textContent;
|
||||
if (uid) {
|
||||
await navigator.clipboard.writeText(uid);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
201
src/components/verification/DataTable.astro
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
---
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { cvTable } from "../../db/schema";
|
||||
import { Icon } from "astro-icon/components";
|
||||
|
||||
interface Props {
|
||||
cv: InferSelectModel<typeof cvTable>;
|
||||
}
|
||||
|
||||
const { cv } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-6 mb-4">
|
||||
<ul class="space-y-4 mb-0">
|
||||
<li class="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-2">
|
||||
<span
|
||||
class="text-gray-500 dark:text-gray-400 sm:min-w-32 whitespace-nowrap"
|
||||
>
|
||||
ID:
|
||||
</span>
|
||||
<span class="text-gray-800 dark:text-gray-200 font-medium break-all">
|
||||
{cv.uuid}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-2">
|
||||
<span
|
||||
class="text-gray-500 dark:text-gray-400 sm:min-w-32 whitespace-nowrap"
|
||||
>
|
||||
Issued by:
|
||||
</span>
|
||||
<span class="text-gray-800 dark:text-gray-200 font-medium">
|
||||
{cv.author}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-2">
|
||||
<span
|
||||
class="text-gray-500 dark:text-gray-400 sm:min-w-32 whitespace-nowrap"
|
||||
>
|
||||
Issued to:
|
||||
</span>
|
||||
<span class="text-gray-800 dark:text-gray-200 font-medium">
|
||||
{cv.company_name}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-2">
|
||||
<span
|
||||
class="text-gray-500 dark:text-gray-400 sm:min-w-32 whitespace-nowrap"
|
||||
>
|
||||
Issue date:
|
||||
</span>
|
||||
<span class="text-gray-800 dark:text-gray-200 font-medium">
|
||||
{cv.created?.toLocaleString()}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-2">
|
||||
<span
|
||||
class="text-gray-500 dark:text-gray-400 sm:min-w-32 whitespace-nowrap"
|
||||
>
|
||||
Purpose:
|
||||
</span>
|
||||
<span class="text-gray-800 dark:text-gray-200 font-medium">
|
||||
{cv.purpose}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex flex-col sm:flex-row sm:items-start gap-1 sm:gap-2">
|
||||
<span
|
||||
class="text-gray-500 dark:text-gray-400 sm:min-w-32 whitespace-nowrap"
|
||||
>
|
||||
SHA256:
|
||||
</span>
|
||||
<span class="text-gray-800 dark:text-gray-200 font-medium break-all">
|
||||
{cv.sha256}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<pre
|
||||
id="signature"
|
||||
class="p-6 rounded-lg overflow-x-auto text-sm font-mono whitespace-pre-wrap mb-0">{cv.pgp_signature}</pre>
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<details class="group bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<summary class="flex items-center justify-between p-4 cursor-pointer">
|
||||
<h3
|
||||
class="flex items-center text-xl font-semibold text-gray-900 dark:text-gray-100 mb-0"
|
||||
>
|
||||
<Icon name="mdi:information-outline" class="mr-2" />
|
||||
|
||||
Verification Instructions
|
||||
</h3>
|
||||
<span class="relative ml-4 flex-shrink-0">
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-500 dark:text-gray-400 transform group-open:rotate-180 transition-transform"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<div class="p-4 sm:p-6 pt-6 space-y-6">
|
||||
<div>
|
||||
<h4 class="text-xl font-bold mb-2 text-gray-800 dark:text-gray-200">
|
||||
1. Verify SHA256 Hash
|
||||
</h4>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-2">
|
||||
Compare the output of following command with the SHA256 hash shown
|
||||
above.
|
||||
</p>
|
||||
<pre
|
||||
class="bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 p-3 rounded text-sm font-mono">shasum -a 256 cv.pdf</pre>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-xl font-bold mb-2 text-gray-800 dark:text-gray-200">
|
||||
2. Verify PGP Signature
|
||||
</h4>
|
||||
|
||||
<ol
|
||||
class="list-decimal list-inside space-y-2 text-gray-600 dark:text-gray-400 mb-2"
|
||||
>
|
||||
<li>
|
||||
Download Alexander Daichendt's public pgp key from <a
|
||||
href="https://daichendt.one/pub.key"
|
||||
class="text-blue-500 dark:text-blue-400 hover:underline">here</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
Import the key into your keyring with<br />
|
||||
<code
|
||||
class="bg-gray-100 dark:bg-gray-800 p-3 rounded text-sm font-mono sm:ml-6 mb-2 break-all"
|
||||
>gpg --import ~/Downloads/pub.key</code
|
||||
>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Download the above PGP signature by clicking <button
|
||||
class="text-blue-500 dark:text-blue-400 hover:underline"
|
||||
id="download-pgp"
|
||||
>here
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
Run the following command:<br />
|
||||
<code
|
||||
class="bg-gray-100 dark:bg-gray-800 p-3 rounded text-sm font-mono sm:ml-6"
|
||||
>gpg --verify signature.asc cv.pdf</code
|
||||
>
|
||||
</li>
|
||||
</ol>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
If the verification is successful, GPG will indicate so.<br />
|
||||
<code
|
||||
class="bg-gray-100 dark:bg-gray-800 p-3 rounded text-xs font-mono mt-2 break-all"
|
||||
>gpg: Good signature from "Alexander Daichendt
|
||||
<alexander@daichendt.one>" [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>
|
||||
5
src/components/verification/NoCV.astro
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<div class="text-center p-8 bg-red-50 dark:bg-red-950 rounded-lg shadow-sm">
|
||||
<p class="text-red-600 dark:text-red-400 text-lg font-medium mb-0">
|
||||
No CV found with this UUID.
|
||||
</p>
|
||||
</div>
|
||||
22
src/components/verification/Revoked.astro
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
<div class="bg-red-100 dark:bg-red-900 p-2 rounded-full">
|
||||
<svg
|
||||
class="w-6 h-6 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-red-600 dark:text-red-400 mb-0">
|
||||
Revoked CV
|
||||
</h2>
|
||||
</div>
|
||||
<p class="text-red-600 dark:text-red-400 text-lg">
|
||||
This CV has been revoked and is no longer valid.
|
||||
</p>
|
||||
36
src/components/verification/Verified.astro
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
---
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import type { cvTable } from "../../db/schema";
|
||||
|
||||
interface Props {
|
||||
cv: InferSelectModel<typeof cvTable>;
|
||||
}
|
||||
|
||||
const { cv } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex items-center gap-2 mb-6">
|
||||
<div class="bg-green-100 dark:bg-green-900 p-2 rounded-full">
|
||||
<svg
|
||||
class="w-6 h-6 text-green-600 dark:text-green-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-gray-800 dark:text-gray-100 mb-0">
|
||||
Verified CV
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-6">
|
||||
This CV was issued and verified by
|
||||
<span class="font-semibold">{cv.author}</span>, for{" "}
|
||||
<span class="font-semibold">{cv.company_name}</span>
|
||||
</p>
|
||||
196
src/consts.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import type { ImageMetadata } from "astro";
|
||||
import discretizeui_demo from "./assets/projects/discretizeui/demo.png";
|
||||
import discretizeui_languages from "./assets/projects/discretizeui/languages.png";
|
||||
import discretizeui_tooltip from "./assets/projects/discretizeui/tooltip.png";
|
||||
import optimizer1 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_52_AM.png";
|
||||
import optimizer2 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_53_AM.png";
|
||||
import optimizer3 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_54_AM.png";
|
||||
import optimizer4 from "./assets/projects/optimizer/Discretize-Gear-Optimizer-08-05-2025_11_55_AM.png";
|
||||
import videovault_dashboard from "./assets/projects/videovault/dashboard.png";
|
||||
import videovault_edit from "./assets/projects/videovault/edit.png";
|
||||
import videovault_frontpage from "./assets/projects/videovault/frontpage.png";
|
||||
import videovault_player from "./assets/projects/videovault/player.png";
|
||||
|
||||
export const SITE_TITLE = "Alex Daichendt";
|
||||
export const SITE_DESCRIPTION =
|
||||
"Alex Daichendt's personal website, blog, and portfolio.";
|
||||
|
||||
export interface Project {
|
||||
title: string;
|
||||
featured: boolean;
|
||||
live_url?: string;
|
||||
repo_url?: string;
|
||||
description: string;
|
||||
tech_stack: string[];
|
||||
duration: string;
|
||||
deliverables: string[];
|
||||
company?: string;
|
||||
images: {
|
||||
src: ImageMetadata;
|
||||
alt: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const projects: Project[] = [
|
||||
{
|
||||
title: "VideoVault",
|
||||
featured: true,
|
||||
live_url: "https://videovault.shiverpeak.xyz/",
|
||||
repo_url: "https://github.com/AlexDaichendt/VideoVault",
|
||||
description:
|
||||
"A private, self-hosted video vault for your personal use. Supports multiple video transcodes, search, and is blazingly fast thanks to a backend written in Rust and a zero-js (vanilla) frontend.",
|
||||
tech_stack: [
|
||||
"Rust",
|
||||
"Axum",
|
||||
"sqlx",
|
||||
"Tera Templates",
|
||||
"Tailwind CSS",
|
||||
"Node.js",
|
||||
"pnpm",
|
||||
"cargo",
|
||||
"ffmpeg",
|
||||
],
|
||||
duration: "2025 - Present",
|
||||
deliverables: [
|
||||
"HLS Streaming of videos",
|
||||
"Video Scrubbing",
|
||||
"Aspect Ratio Awareness",
|
||||
"Multiple Video Transcodes",
|
||||
"External Transcoding Workers",
|
||||
"Responsive, lightweight design",
|
||||
"User Accounts via local, LDAP or OIDC",
|
||||
"Sharing with timestamps",
|
||||
"Quick and simple deployment (Docker)",
|
||||
],
|
||||
images: [
|
||||
{
|
||||
alt: "Frontpage",
|
||||
src: videovault_frontpage,
|
||||
},
|
||||
{
|
||||
alt: "VideoVault Dashboard",
|
||||
src: videovault_dashboard,
|
||||
},
|
||||
{
|
||||
alt: "VideoVault Video Player",
|
||||
src: videovault_player,
|
||||
},
|
||||
{
|
||||
alt: "VideoVault Edit",
|
||||
src: videovault_edit,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
title: "Discretize: Gear Optimizer",
|
||||
featured: true,
|
||||
live_url: "https://optimizer.discretize.eu/",
|
||||
repo_url: "https://github.com/discretize/discretize-gear-optimizer",
|
||||
description:
|
||||
"A gear optimizer for the popular MMORPG Guild Wars 2. The optimizer is used by thousands of players daily to find the best gear combinations for their characters.",
|
||||
tech_stack: ["React", "Redux", "Rust", "Vite", "MaterialUI"],
|
||||
duration: "2021 - Present",
|
||||
deliverables: [
|
||||
"User-friendly interface",
|
||||
"Rust/WASM calculation core",
|
||||
"Internationalization",
|
||||
"Keyboard Navigation",
|
||||
],
|
||||
images: [
|
||||
{
|
||||
src: optimizer1,
|
||||
alt: "Discretize Gear Optimizer Screenshot",
|
||||
},
|
||||
{
|
||||
src: optimizer2,
|
||||
alt: "Discretize Gear Optimizer Screenshot",
|
||||
},
|
||||
{
|
||||
src: optimizer3,
|
||||
alt: "Discretize Gear Optimizer Screenshot",
|
||||
},
|
||||
{
|
||||
src: optimizer4,
|
||||
alt: "Discretize Gear Optimizer Screenshot",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "@discretize/gw2-ui-new",
|
||||
featured: false,
|
||||
live_url: "https://discretize.github.io/discretize-ui/gw2-ui",
|
||||
repo_url: "https://github.com/discretize/discretize-ui",
|
||||
description: `A modern, lightweight React component library for Guild Wars 2 UI elements. Used by all Discretize applications.`,
|
||||
tech_stack: ["React", "TypeScript", "CSS Modules", "Storybook"],
|
||||
duration: "2023 – Present",
|
||||
deliverables: [
|
||||
"Refactored all components to TypeScript",
|
||||
"Replaced CSS-in-JS with CSS Modules",
|
||||
"Better performance by caching, batching",
|
||||
],
|
||||
images: [
|
||||
{
|
||||
src: discretizeui_demo,
|
||||
alt: "Production Demo of the component library",
|
||||
},
|
||||
|
||||
{
|
||||
src: discretizeui_tooltip,
|
||||
alt: "Tooltip component",
|
||||
},
|
||||
{
|
||||
src: discretizeui_languages,
|
||||
alt: "Supports multiple languages",
|
||||
},
|
||||
],
|
||||
},
|
||||
// {
|
||||
// title: "Discretize -- Rewritten Website",
|
||||
// description:
|
||||
// "Rewritten website for the Discretize community. Contains guides, builds, and other useful information for the popular MMORPG Guild Wars 2. Awaiting last few changes and content updates by players before deployment.",
|
||||
// live_url: "https://next.discretize.eu/",
|
||||
// repo_url: "https://github.com/discretize/discretize.eu-rewrite",
|
||||
// tech_stack: ["Astro", "React", "TypeScript", "Tailwind CSS"],
|
||||
// duration: "2022 - Present",
|
||||
// complexity: 5,
|
||||
// },
|
||||
// {
|
||||
// title: "Discretize -- CC Tool",
|
||||
// description:
|
||||
// "Allows players to create skill schedules with drag and drop. Used by high-end players to optimize and coordinate their gameplay.",
|
||||
// live_url: "https://cc-tool.pages.dev/",
|
||||
// repo_url: "https://github.com/discretize/cc-tool",
|
||||
// tech_stack: ["Vite", "React", "TypeScript", "Tailwind CSS"],
|
||||
// duration: "2024 - Present",
|
||||
// complexity: 3,
|
||||
// },
|
||||
// {
|
||||
// title: "Discretize -- Random Builds",
|
||||
// description:
|
||||
// "Generates random builds for the popular MMORPG Guild Wars 2. Meant as a way to force players out of their comfort zone and try new things.",
|
||||
// live_url: "https://random-builds.discretize.eu/",
|
||||
// tech_stack: ["Vite", "React", "TypeScript", "Tailwind CSS"],
|
||||
// duration: "2022",
|
||||
// complexity: 3,
|
||||
// },
|
||||
// {
|
||||
// title: "Discretize -- Old Website",
|
||||
// description:
|
||||
// "Currently deployed website for the Discretize community. Contains guides, builds, and other useful information for the popular MMORPG Guild Wars 2. Inherited project from previous maintainer. Several hundred thousand monthly users.",
|
||||
// live_url: "https://discretize.eu/",
|
||||
// tech_stack: ["React", "Gatsby", "Material UI"],
|
||||
// duration: "2019 - Present",
|
||||
// complexity: 3,
|
||||
// },
|
||||
// {
|
||||
// title: "Minecraft LandLord Spigot Plugin",
|
||||
// live_url: "https://www.spigotmc.org/resources/landlord-2.44398/",
|
||||
// repo_url: "https://github.com/LandlordPlugin/LandLord",
|
||||
// description:
|
||||
// "Landlord aims to keep the Minecraft experience simple and fluid for players while also protecting their land. The idea for this plugin is to protect player builds with minimal game-play interference, while also allowing them to tweak the protection details in a simple and user-friendly way. Handed over the project to a new group of maintainers in 2019.",
|
||||
// tech_stack: ["Java"],
|
||||
// duration: "2017 - 2019",
|
||||
// complexity: 2,
|
||||
// },
|
||||
];
|
||||
19
src/content.config.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { glob } from "astro/loaders";
|
||||
import { defineCollection, z } from "astro:content";
|
||||
|
||||
const blog = defineCollection({
|
||||
// Load Markdown and MDX files in the `src/content/blog/` directory.
|
||||
loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
|
||||
// Type-check frontmatter using a schema
|
||||
schema: ({ image }) =>
|
||||
z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
// Transform string to Date object
|
||||
pubDate: z.coerce.date(),
|
||||
updatedDate: z.coerce.date().optional(),
|
||||
heroImage: image().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog };
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
created: '2023-01-02'
|
||||
pubDate: '2023-01-02'
|
||||
title: 'Generate cover letters with ChatGPT'
|
||||
description: 'With the help of ChatGPT it is fairly easy to generate custom tailored cover letters for job applications with your own CV'
|
||||
keywords:
|
||||
117
src/content/blog/cv-verification.mdx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
---
|
||||
pubDate: '2025-01-02'
|
||||
title: 'Building a CV verification tool in Typst, Astro and Cloudflare D1'
|
||||
description: "Technical details on how I built a CV verification tool in Typst, Astro and Cloudflare D1."
|
||||
keywords:
|
||||
- CV
|
||||
- verification
|
||||
- tool
|
||||
- application
|
||||
- job
|
||||
---
|
||||
|
||||
In my CV in the bottom right corner, I have a QR code that links to this website. The called page displays information to whom this CV was issued, when and for what purpose. I can revoke a CV in my backend and have it display a message that the CV is no longer valid, for example, when the current sent CV is outdated. Furthermore, the site shows the sha256 hash and a PGP signature of the CV, which can be used to verify the integrity and authenticity of the CV.
|
||||
|
||||
While the recipient can still store and process the CV, they can only use it for its intended purpose since others can verify who it was issued to through the QR code. If a third party were to manipulate the CV, the hash would not match the one on the website, and the PGP signature would not be valid.
|
||||
Pretty cool gimmick! This effectively binds the CV document to my personal website and domain. It provides me more control over the CV and its usage, and it is a nice touch to show off my technical skills.
|
||||
|
||||

|
||||
[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'
|
||||
}}>
|
||||

|
||||
</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'
|
||||
}}>
|
||||

|
||||
</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.
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
---
|
||||
created: '2024-01-15'
|
||||
title: 'Detecting System Management Interrupts (SMIs)'
|
||||
pubDate: '2024-01-15'
|
||||
title: 'Detecting System Management Interrupts'
|
||||
description: ''
|
||||
keywords:
|
||||
- SMI
|
||||
---
|
||||
|
||||
# System Management Interrutps (SMI)
|
||||
## System Management Interrutps (SMIs)
|
||||
|
||||
- high priority interrupts caused by the hardware
|
||||
- transparent to the operating system
|
||||
- can be used by the mainboard for power management, thermal management, or other system-level functions independent of the OS
|
||||
- can take a long time to execute, causing a CPU core to be blocked from other work
|
||||
|
||||
## Detecting SMIs
|
||||
### Detecting SMIs
|
||||
|
||||
- compile a kernel with hwlat tracing capabilities; usually, a typical Linux kernel has this enabled; if not, the config can be found in the appendix
|
||||
- after starting the machine with a trace-capable image
|
||||
|
|
@ -22,7 +22,7 @@ keywords:
|
|||
- there now should be a process "hwlat" running that takes up 50% of one CPU
|
||||
- output of the hwlat tracer available `cat /sys/kernel/debug/tracing/trace` or `cat /sys/kernel/debug/tracing/trace_pipep`
|
||||
|
||||
## Example Output
|
||||
### Example Output
|
||||
|
||||
```
|
||||
# tracer: hwlat
|
||||
|
|
@ -41,7 +41,7 @@ keywords:
|
|||
|
||||
- inner/outer: where the latency was detected, see next section
|
||||
|
||||
### How does it work?
|
||||
#### How does it work?
|
||||
|
||||
- this hwlat process is taking timestamps in a loop
|
||||
- if distance between two timestamps is unreasonably large (bigger than ns), there was an SMI
|
||||
|
|
@ -62,18 +62,18 @@ keywords:
|
|||
}
|
||||
```
|
||||
|
||||
### Further options
|
||||
#### Further options
|
||||
|
||||
- by default, only 50% CPU time is used
|
||||
- this can be increased by echoing into `echo 9999999 > /sys/kernel/debug/tracing/hwlat_detector/width`, where the value is smaller than the set window `cat /sys/kernel/debug/tracing/hwlat_detector/window` to avoid starving the system.
|
||||
- from my experience, this, however, is not necessary to catch SMIs. The default option is "good enough".
|
||||
|
||||
## Firing an SMI manually
|
||||
### Firing an SMI manually
|
||||
|
||||
- There is a nice small kernel module [here](https://github.com/jib218/kernel-module-smi-trigger) for manually triggering an SMI to verify the setup
|
||||
- follow the instructions in the readme to compile and load the module
|
||||
|
||||
## Hardware Registers for counting SMIs
|
||||
### Hardware Registers for counting SMIs
|
||||
|
||||
- Intel: MSR0x34, can be read out with turbostat / perf
|
||||
- AMD: ls\_msi\_rx, can be used with `perf stat -e ls_smi_rx -I 60000`
|
||||
|
|
@ -81,7 +81,7 @@ However, doesn't seem to count everything; counts seem incorrect
|
|||
|
||||
---
|
||||
|
||||
## Sources, Appendix
|
||||
### Sources, Appendix
|
||||
|
||||
- https://wiki.linuxfoundation.org/realtime/documentation/howto/tools/hwlat
|
||||
- https://www.kernel.org/doc/html/latest/trace/hwlat_detector.html
|
||||
114
src/content/blog/hetzner-ansible.mdx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
---
|
||||
pubDate: "2025-06-21"
|
||||
title: "From Basement to Cloud: Migrating to Hetzner"
|
||||
description: "This post explains how to build a highly available infrastructure using Hetzner, Terraform, and Ansible—integrating services like Traefik for reverse proxying, Authentik for centralized authentication, and automated backups for seamless recovery."
|
||||
keywords:
|
||||
- hetzner
|
||||
- vps
|
||||
- ansible
|
||||
- terraform
|
||||
- timetagger
|
||||
- infrastructure
|
||||
---
|
||||
|
||||
Now that I graduated and started freelancing, I require some software infrastructure with higher availability guarantees than what I have from my basement.
|
||||
Typically, an ISP selling consumer-grade internet access guarantees uptime between 95% and 99%, which is ridiculously low. I've had several outages in the past, so I do not want to rely on my consumer-grade internet to host my infrastructure.
|
||||
A popular cloud alternative is Hetzner, a medium-sized German cloud provider whose principal office is just a few kilometers from my former employer in Unterföhring (Munich).
|
||||
Although they do not offer any SLA, which surprised me, people online have had positive experiences overall.
|
||||
Aside from that, their pricing is very competitive: if something goes wrong with Hetzner, I can still migrate away to another provider -- hopefully with minimal effort, which is what this post is about.
|
||||
|
||||
[Hetzner](https://www.hetzner.com/)
|
||||
|
||||
What kind of services do I need?
|
||||
|
||||
- Timetagger (time tracking)
|
||||
- VideoVault (video-on-demand platform I am currently developing)
|
||||
- Forgejo (self-hosted Git platform)
|
||||
- something for calendar, contacts, notes
|
||||
|
||||
Utilities:
|
||||
|
||||
- Authentik (single sign-on for all other services and potential client logins)
|
||||
- Traefik (reverse proxy)
|
||||
- Cloudflare-companion (DNS management)
|
||||
- ntfy (push notifications)
|
||||
|
||||
## Terraforming
|
||||
|
||||
Requesting a VM with code from Hetzner is trivial. They provide an awesome [Terraform provider](https://registry.terraform.io/providers/hetznercloud/hcloud/latest/docs).
|
||||
One interesting detail is that I am using the Cloudflare provider to set up DNS records for the three zones I manage.
|
||||
This is useful because it allows me to CNAME onto these DNS records later.
|
||||
In my tfvars, I simply define:
|
||||
|
||||
```terraform
|
||||
cloudflare_dns_records = [{
|
||||
zone_id = "zone1"
|
||||
name = "hetzner-fsn1-vm-001.shiverpeak.xyz"
|
||||
type = "A"
|
||||
proxied = false
|
||||
},
|
||||
{
|
||||
zone_id = "zone2"
|
||||
name = "hetzner-fsn1-vm-001.daichendt.one"
|
||||
type = "A"
|
||||
proxied = false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Another noteworthy aspect is that I use the null resource to call Ansible playbooks to configure the VM.
|
||||
There are two playbooks: the first prepares the machine, like setting up another user, adding SSH keys, etc. And the second one is the centerpiece of this post: it installs and configures the services I need.
|
||||
|
||||
## Ansible Playbooking
|
||||
|
||||
All my secrets are stored in a vault file in `ansible/vars/vault.yml`. The configuration is stored in `ansible/group_vars/all.yml`.
|
||||
A separate role installs and configures every service.
|
||||
|
||||
### Traefiking
|
||||
|
||||
Traefik is a popular reverse proxy in the cloud-native ecosystem. I like it because I can keep the DNS and proxy configurations close to the application via Docker labels.
|
||||
Although Caddy is also quite simple to use, arguably even simpler than Traefik, it only provides a centralized configuration file (maybe something has changed there now?).
|
||||
Traefik will automatically create a TLS certificate with ACME - Let's Encrypt for every service configured with the label `traefik.http.routers.<service>.tls.certresolver=`.
|
||||
|
||||
The last step is to automatically configure DNS records for the services. Traefik does not have a native way to do this, but there is a handy project called [cloudflare-companion](https://github.com/tiredofit/docker-traefik-cloudflare-companion). After supplying it with an API key with access to all DNS zones, it will automatically create CNAME records on the previously defined A records.
|
||||
Getting the config right was a little tricky since I needed 3 different zones (and I did not bother to read the README, mhm). The full config is [here](https://git.shiverpeak.xyz/shiverpeak.xyz/infrastructure/src/branch/main/ansible/roles/cloudflare-companion/tasks/main.yml).
|
||||
Now, whenever a new service is added to the reverse proxy, the DNS records will be automatically created.
|
||||
|
||||
### Applicationing
|
||||
|
||||
All my applications are installed via Docker containers. Jeff Geerling provides a great [Ansible Role](https://github.com/geerlingguy/ansible-role-docker) for Docker, which I use to install Docker on the server.
|
||||
The installation of the remaining applications went uneventful. I store each application's configuration and data in `/mnt/user/appdata/<service>`, the same way as it is managed in Unraid.
|
||||
Locating every application's state inside one folder is a useful property for managing backups and restores; more on that later.
|
||||
|
||||
### Authenticating
|
||||
|
||||
Authentik is great! At work, I started to use Keycloak; however, I wanted to get my hands dirty with the newer, hipper kid on the block, Authentik.
|
||||
I've got a couple Google Titan Security keys for essentially no money (3 EUR per), so I can use them for passwordless login. Passwordless login allows me to simply click the key and be logged in without needing to enter a password or even a username. Pretty cool stuff! Following [this Video](https://www.youtube.com/watch?v=aEpT2fYGwLw) explains how to set up a passwordless login with Authentik.
|
||||
|
||||
Another feature I required is invitations! I do not want anyone to sign up by themselves, but I would like to be able to send a signup link to selected individuals.
|
||||
Although Authentik provides a [guide](https://docs.goauthentik.io/docs/users-sources/user/invitations), it is not actually enough information to get it to work.
|
||||
The guide is missing a crucial step: Creating a new Invitation Stage and linking it to the imported flow. Only then will the yellow warning in the Invitation's Menu disappear, and an invitation link will be generated.
|
||||
|
||||
VideoVault and Forgejo can be easily connected to Authentik using the OIDC provider. With just a few mouse clicks, copying over the credentials to the respective application's configuration is all that is needed.
|
||||
Timetagger was harder: it does not support OIDC and has its own authentication mechanism. Luckily, Authentik covers Proxy Authentication too, where it will act as a layer in front of the application, handling authentication and authorization and setting the respective headers, which will be interpreted by Timetagger to automatically sign in the user.
|
||||
Getting this to work was a bit tricky. In the end, I stuck with the embedded outpost. The full setup involved:
|
||||
|
||||
- Creating a new Application with Proxy Provider (forward auth single app) in Authentik
|
||||
- Creating a Traefik Middleware for forward Auth ([link](https://git.shiverpeak.xyz/shiverpeak.xyz/infrastructure/src/branch/main/ansible/roles/traefik/templates/dynamic-conf.yml.j2#L15-L30))
|
||||
- Assigning the middleware to the application ([link](https://git.shiverpeak.xyz/shiverpeak.xyz/infrastructure/src/branch/main/ansible/roles/timetagger/tasks/main.yml#L100))
|
||||
- Making sure all the URLs are correct ;)
|
||||
|
||||
Full ansible role for [Authentik](https://git.shiverpeak.xyz/shiverpeak.xyz/infrastructure/src/branch/main/ansible/roles/authentik).
|
||||
|
||||
### Backuping
|
||||
|
||||
The goal is to be able to destroy the infrastructure and recreate it from scratch, all automatically.
|
||||
I need to store the state of each application and restore it when the infrastructure is recreated.
|
||||
A simple backup mechanism, which utilizes Backblaze B2 buckets, creates a tar.gz archive of each application's state daily, uploads it to the bucket, and deletes old backups.
|
||||
When an application is reinstalled and detects its state folder is empty or missing, it will download the latest backup from the bucket and extract it to the state folder.
|
||||
Simple but useful.
|
||||
|
||||
## Final remarks
|
||||
|
||||
I will keep this post updated as I adjust and tweak the infrastructure.
|
||||
Thanks for reading thus far.
|
||||
73
src/content/blog/huawei-matebook-x-pro-2024.mdx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
pubDate: 2024-12-21
|
||||
title: Linux on a Huawei MateBook X Pro 2024
|
||||
description: A guide on what is needed to get Linux running on the Huawei MateBook X Pro 2024.
|
||||
heroImage: ./images/matebook.jpg
|
||||
---
|
||||
|
||||
**UPDATE 24/08/2024**:
|
||||
Having used this machine for a year soon, I can not recommend it. None of my issues have been resolved, some even gotten worse. The laptop's battery life is poor, often barely reaching 3 hours while having a text editor open. I pretty much depend on plugging the laptop into the wall, all the time to not degrade the battery more than necessary. The webcam, fingerprint, S3 and Xe drivers are still all pretty much broken. Bluetooth also behaves a bit strangely where it sometimes requires multiple attempts to connect.
|
||||
|
||||
---
|
||||
|
||||
I recently bought a Huawei MateBook X Pro 2024. It is a beautiful laptop with a 3:2 aspect ratio display and a touchscreen. The laptop comes with Windows 11 preinstalled. However, I wanted to run Linux on it. Here is a guide on what is needed to get Linux running on the Huawei MateBook X Pro 2024.
|
||||
|
||||
Overall, the experience was okay, but not something I would recommend to an average user. There are a fair bit of quirks that need to be ironed out. Especially distros running older kernels will have a hard time. I am running CachyOS with the latest 6.13-rc1 kernel, more on that later.
|
||||
|
||||
[Here](https://linux-hardware.org/?probe=b6dcf78af5) is a linux-hardware.org probe of my laptop.
|
||||
|
||||
| Hardware | PCI/USB ID | Status |
|
||||
| ----------- | ------------------------------------------- | ------------------ |
|
||||
| CPU | | :white_check_mark: |
|
||||
| Touchpad | ps/2:7853-7853-bltp7840-00-347d | :white_check_mark: |
|
||||
| Touchscreen | | :white_check_mark: |
|
||||
| Keyboard | ps/2:0001-0001-at-translated-set-2-keyboard | :white_check_mark: |
|
||||
| WiFi | 8086:7e40 | :white_check_mark: |
|
||||
| Bluetooth | 8087:0033 | :white_check_mark: |
|
||||
| iGPU | 8086:7d55 | :neutral_face: |
|
||||
| Audio | 8086:7e28 | :ok: |
|
||||
| Webcam | 8086:7d19 | :x: |
|
||||
| Fingerprint | | :x: |
|
||||
|
||||
## CPU
|
||||
|
||||
The CPU on my SKU is an Intel Meteor Lake Core Ultra 155H. It comes with 6 performance cores, each with 2 threads, 8 efficiency cores, one thread each, and 2 LPE cores. The p and e cores share 24MB of L3 cache. The LPE cores do not have L3 cache and share 2MB L2 cache, which makes them rather slow. Below you can find the output of `lstopo`:
|
||||
|
||||

|
||||
|
||||
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
|
||||
49
src/content/blog/images/kagi_doggo_5.svg
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
src/content/blog/images/matebook.jpg
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
src/content/blog/images/msi.png
Normal file
|
After Width: | Height: | Size: 434 KiB |
BIN
src/content/blog/images/oslo.jpg
Normal file
|
After Width: | Height: | Size: 647 KiB |
|
|
@ -1,15 +1,12 @@
|
|||
---
|
||||
created: '2024-06-11'
|
||||
pubDate: '2024-06-11'
|
||||
title: "Kagi.com"
|
||||
description: ""
|
||||
description: "Thoughts on Kagi.com"
|
||||
keywords:
|
||||
- search engine
|
||||
hidden: false
|
||||
heroImage: ./images/kagi_doggo_5.svg
|
||||
---
|
||||
<script>
|
||||
import fastgpt from "./images/fastgpt.png?default"
|
||||
import Image from "$components/Image.svelte"
|
||||
</script>
|
||||
|
||||
Kagi is a paid search engine providing excellent search that reminds me of what Google was like in the early 2000s. Furthermore, it provides search-enhancing features like specific filters, custom site rankings, and an LLM summary of the search results.
|
||||
In this post, I would like to share my thoughts on Kagi.com and explain why I think it is a great search engine despite recent criticism.
|
||||
|
|
@ -21,19 +18,19 @@ Google has been overflooded by SEO spam: sites that do not contain any useful in
|
|||
If, for some reason, a bad site appears in the search results, I can easily block it. More relevant sites like Wikipedia or StackOverflow can be promoted to the top of the search results.
|
||||
|
||||
## AI Summary
|
||||
Kagi's AI will summarize the search results by simply appending a `?` to the end of the search query. LLMs are prone to generating nonsense, but Kagi's AI adds citations with links to the original source. If the AI summary provided helpful information, it was accurate; if it did not, the results were still there.
|
||||
Kagi's AI will summarize the search results by simply appending a `?` to the end of the search query. LLMs are prone to generating nonsense, but Kagi's AI adds citations with links to the original source. If the AI summary provided helpful information, it was accurate; if it did not, the results were still there.
|
||||
|
||||
<Image meta={fastgpt} alt="Example search query"/>
|
||||

|
||||
|
||||
## Privacy
|
||||
|
||||
By default, since the search engine requires registration and payment, Kagi could theoretically track the user's search history. However, I have no reason to believe that Kagi is doing this. Kagi repeatedly stated that they are a small company that aims to do things differently, i.e., not maximize profit over sustainability. That is also why they give free T-shirts to the first 20k users. Although I'm not convinced this is a wise business decision, I respect their commitment to their user base.
|
||||
By default, since the search engine requires registration and payment, Kagi could theoretically track the user's search history. However, I have no reason to believe that Kagi is doing this. Kagi repeatedly stated that they are a small company that aims to do things differently, i.e., not maximize profit over sustainability. That is also why they give free T-shirts to the first 20k users. Although I'm not convinced this is a wise business decision, I respect their commitment to their user base.
|
||||
|
||||
In recent criticism, Kagi's CEO Vlad has made questionable privacy statements. Mainly, he claimed that an Email address is not PII (Personally Identifiable Information) because the user could create single-use Email addresses. That statement is obviously regrettable, but the CEO has clarified and will be more careful in the future. Just because a CEO is more outspoken and engaging with the community (which does not happen often - if ever) and sometimes says woeful things does not mean that the company as a whole should be boycotted. It should be seen as a way to engage with the company and perhaps improve it. Kagi is the best we have right now, and I am happy to support them.
|
||||
In recent criticism, Kagi's CEO Vlad has made questionable privacy statements. Mainly, he claimed that an Email address is not PII (Personally Identifiable Information) because the user could create single-use Email addresses. That statement is obviously regrettable, but the CEO has clarified and will be more careful in the future. Just because a CEO is more outspoken and engaging with the community (which does not happen often - if ever) and sometimes says woeful things does not mean that the company as a whole should be boycotted. It should be seen as a way to engage with the company and perhaps improve it. Kagi is the best we have right now, and I am happy to support them.
|
||||
|
||||
This entire privacy discussion boils down to a big "trust me, bro" which I am willing to give Kagi - for now.
|
||||
This entire privacy discussion boils down to a big "trust me, bro" which I am willing to give Kagi - for now.
|
||||
I pay for search; at least I know that Kagi does not have to sell my data to keep the lights on - unlike specific competitors.
|
||||
|
||||
## Conclusion
|
||||
## Conclusion
|
||||
|
||||
Kagi is a great search engine that I can recommend to anyone who is tired of Google's SEO spam and wants to support a small company that is trying to do things differently. The search results are excellent, and the AI summaries are a nice addition. I am looking forward to seeing how Kagi will develop in the future.
|
||||
Kagi is a great search engine that I can recommend to anyone who is tired of Google's SEO spam and wants to support a small company that is trying to do things differently. The search results are excellent, and the AI summaries are a nice addition. I am looking forward to seeing how Kagi will develop in the future.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
created: '2024-08-25'
|
||||
pubDate: '2024-08-25'
|
||||
title: "Kata Containers: Custom Kernel Module in Guest"
|
||||
description: 'How to build a custom kernel module for a Kata Containers guest.'
|
||||
keywords:
|
||||
|
|
@ -16,8 +16,8 @@ hidden: false
|
|||
|
||||
Kata Containers is a lightweight container runtime that leverages hardware virtualization to provide strong isolation between containers. It is compatible with the Open Container Initiative (OCI) and the Container Runtime Interface (CRI). Kata Containers uses a lightweight VM to run each container, which provides an additional layer of isolation compared to traditional container runtimes like Docker or containerd.
|
||||
|
||||
The official documentation is fairly lackluster here and there. For example, see [here](https://github.com/kata-containers/kata-containers/blob/main/docs/how-to/how-to-load-kernel-modules-with-kata.md). There is a lot of prerequisite knowledge assumed.
|
||||
Another tutorial is [here](https://vadosware.io/post/building-custom-kernels-for-kata-containers/), which sheds some light into the building process of a custom kernel image, but leaves out custom kernel modules.
|
||||
The official documentation is fairly lackluster here and there. For example, see [here](https://github.com/kata-containers/kata-containers/blob/main/docs/how-to/how-to-load-kernel-modules-with-kata.md). There is a lot of prerequisite knowledge assumed.
|
||||
Another tutorial is [here](https://vadosware.io/post/building-custom-kernels-for-kata-containers/), which sheds some light into the building process of a custom kernel image, but leaves out custom kernel modules.
|
||||
|
||||
This article aims to provide a step-by-step guide on how to utilize a custom kernel module in a Kata Containers guest. In this example, we will include the igb_uio kernel module, which can be used with DPDK.
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ menuconfig IGB_UIO
|
|||
depends on UIO
|
||||
default y
|
||||
EOF
|
||||
# overwrite Makefile to avoid building the module as .ko file
|
||||
# overwrite Makefile to avoid building the module as .ko file
|
||||
echo "# SPDX-License-Identifier: GPL-2.0" > kata-linux-6.7-$KATA_CONFIG_VERSION/drivers/igb_uio/Makefile
|
||||
echo "obj-\$(CONFIG_IGB_UIO) += igb_uio.o" >> kata-linux-6.7-$KATA_CONFIG_VERSION/drivers/igb_uio/Makefile
|
||||
|
||||
|
|
@ -59,6 +59,6 @@ echo "CONFIG_IGB_UIO=y" >> kata-linux-6.7-$KATA_CONFIG_VERSION/.config
|
|||
|
||||
# build the kernel with the new module
|
||||
bash build-kernel.sh -v 6.7 build
|
||||
```
|
||||
```
|
||||
|
||||
Why Kata 3.2.0, an ancient version, you might ask? Unfortunately, we were unable to get newer version to work with SEV-SNP.
|
||||
Why Kata 3.2.0, an ancient version, you might ask? Unfortunately, we were unable to get newer version to work with SEV-SNP.
|
||||
40
src/content/blog/little-things-in-oslo.mdx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
---
|
||||
pubDate: '2025-01-01'
|
||||
title: "Little Things I noticed in Oslo"
|
||||
description: 'A collection of small things I noticed during my stay in Oslo.'
|
||||
keywords:
|
||||
- travel
|
||||
- oslo
|
||||
- norway
|
||||
hidden: false
|
||||
heroImage: ./images/oslo.jpg
|
||||
---
|
||||
|
||||
When I wondered through Ekeberg in Oslo, Norway, I noticed a few things that I found interesting. As a German, some of these things came to me as a surprise, others make a lot of sense.
|
||||
|
||||
### 1. No underground power lines
|
||||
Most of the power and utility lines are above ground. I would assume this is due to added cost burying them underground.
|
||||
|
||||
### 2. Rocky ground
|
||||
The ground is very rocky. When walking through the forrest, there's barely any soil, mostly just huge rocks.
|
||||
|
||||
### 3. Big Mailboxes
|
||||
I did not see a single mailbox that could not fit a package. They are all huge and can fit a package of 3-4 books easily. Meanwhile, in Germany, a mailbox can at most fit a single book. Is there some sort of regulation for this?
|
||||
|
||||
### 4. Great busses and trams, icky subways
|
||||
How are your busses and trams so clean, new and modern, but the subways are old and dirty?
|
||||
|
||||
### 5. Degraded streets
|
||||
Many streets in the suburbs are in bad shape. Something like that is not common in Germany. Have winters something to do with this?
|
||||
|
||||
### 6. Colorful plates
|
||||
There seems to be multiple types of license plates. I saw a lot of green plates on larger cars. Probably related to company cars.
|
||||
|
||||
### 7. Lots of secondary apartments
|
||||
It seems like that legislation is more permissive when it comes to renting out a basement or attic as a secondary apartment. I saw a lot of these in the suburbs. In Germany, there is so much red tape and law around renting that barely anyone bothers with it.
|
||||
|
||||
### 8. Old houses with chargers for EVs
|
||||
There is this stark contrast between old wooden houses with a Tesla or some other modern EV parked in front and hooked up to a charger. It's funny.
|
||||
|
||||
### 9. A lappen is a lappen
|
||||
Apparently, a lappen can mean driver's license, which is exactly the same in German. Never once I expected to find this informal colloquialism anywhere outside the DACH area.
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
created: '2022-09-24'
|
||||
pubDate: '2022-09-24'
|
||||
title: 'Securing a Caddy endpoint with LLDAP'
|
||||
description: ''
|
||||
keywords:
|
||||
|
|
@ -7,11 +7,6 @@ keywords:
|
|||
- Caddy
|
||||
---
|
||||
|
||||
<script>
|
||||
import overview from "./lldap_overview.png?default"
|
||||
import Image from "$components/Image.svelte"
|
||||
</script>
|
||||
|
||||
For my small home network, I was looking around for a solution to synchronize user
|
||||
accounts across services. I host various services like a file server or smaller web
|
||||
applications that are accessed by my significant other and a couple of friends. In the
|
||||
|
|
@ -46,7 +41,7 @@ bridge for networking so that I can resolve my other services with DNS. After th
|
|||
navigate to http://IP:17170 and are presented with the administration panel, where we can
|
||||
create users and groups.
|
||||
|
||||
<Image meta={overview} alt="LLDAP Userinterface"/>
|
||||

|
||||
|
||||
## Integration with Caddy
|
||||
|
||||