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

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

|
||||
|
||||
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
|
||||
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 434 KiB |
|
Before Width: | Height: | Size: 647 KiB |
|
|
@ -1,40 +0,0 @@
|
|||
---
|
||||
pubDate: '2025-01-01'
|
||||
title: "Little Things I noticed in Oslo"
|
||||
description: 'A collection of small things I noticed during my stay in Oslo.'
|
||||
keywords:
|
||||
- travel
|
||||
- oslo
|
||||
- norway
|
||||
hidden: false
|
||||
heroImage: ./images/oslo.jpg
|
||||
---
|
||||
|
||||
When I wondered through Ekeberg in Oslo, Norway, I noticed a few things that I found interesting. As a German, some of these things came to me as a surprise, others make a lot of sense.
|
||||
|
||||
### 1. No underground power lines
|
||||
Most of the power and utility lines are above ground. I would assume this is due to added cost burying them underground.
|
||||
|
||||
### 2. Rocky ground
|
||||
The ground is very rocky. When walking through the forrest, there's barely any soil, mostly just huge rocks.
|
||||
|
||||
### 3. Big Mailboxes
|
||||
I did not see a single mailbox that could not fit a package. They are all huge and can fit a package of 3-4 books easily. Meanwhile, in Germany, a mailbox can at most fit a single book. Is there some sort of regulation for this?
|
||||
|
||||
### 4. Great busses and trams, icky subways
|
||||
How are your busses and trams so clean, new and modern, but the subways are old and dirty?
|
||||
|
||||
### 5. Degraded streets
|
||||
Many streets in the suburbs are in bad shape. Something like that is not common in Germany. Have winters something to do with this?
|
||||
|
||||
### 6. Colorful plates
|
||||
There seems to be multiple types of license plates. I saw a lot of green plates on larger cars. Probably related to company cars.
|
||||
|
||||
### 7. Lots of secondary apartments
|
||||
It seems like that legislation is more permissive when it comes to renting out a basement or attic as a secondary apartment. I saw a lot of these in the suburbs. In Germany, there is so much red tape and law around renting that barely anyone bothers with it.
|
||||
|
||||
### 8. Old houses with chargers for EVs
|
||||
There is this stark contrast between old wooden houses with a Tesla or some other modern EV parked in front and hooked up to a charger. It's funny.
|
||||
|
||||
### 9. A lappen is a lappen
|
||||
Apparently, a lappen can mean driver's license, which is exactly the same in German. Never once I expected to find this informal colloquialism anywhere outside the DACH area.
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
export const publications = [
|
||||
{
|
||||
authors: [
|
||||
"Alexander Daichendt",
|
||||
"Florian Wiedner",
|
||||
"Jonas Andre",
|
||||
"Georg Carle",
|
||||
],
|
||||
title:
|
||||
"Applicability of Hardware-Supported Containers in Low-Latency Networking",
|
||||
conference:
|
||||
"20th International Conference on Network and Service Management (CNSM 2024)",
|
||||
location: "Prague, Czech Republic",
|
||||
date: "Oct. 2024",
|
||||
links: {
|
||||
pdf: "http://www.net.in.tum.de/fileadmin/bibtex/publications/papers/wiedner_2024_cnsm.pdf",
|
||||
homepage: "https://tumi8.github.io/applicability-hwsupported-containers",
|
||||
bibtex: "https://www.net.in.tum.de/publications/bibtex/Wied24CNSM.bib",
|
||||
},
|
||||
},
|
||||
{
|
||||
authors: [
|
||||
"Florian Wiedner",
|
||||
"Max Helm",
|
||||
"Alexander Daichendt",
|
||||
"Jonas Andre",
|
||||
"Georg Carle",
|
||||
],
|
||||
title:
|
||||
"Performance evaluation of containers for low-latency packet processing in virtualized network environments",
|
||||
journal: "Performance Evaluation",
|
||||
volume: "166",
|
||||
date: "Nov. 2024",
|
||||
pages: "102442",
|
||||
links: {
|
||||
doi: "https://doi.org/10.1016/j.peva.2024.102442",
|
||||
pdf: "http://www.net.in.tum.de/fileadmin/bibtex/publications/papers/wiedner-helm-2024-peva.pdf",
|
||||
homepage: "https://wiednerf.github.io/container-in-low-latency/",
|
||||
bibtex: "/publications/bibtex/WiedHelm24Container.bib",
|
||||
},
|
||||
},
|
||||
{
|
||||
authors: [
|
||||
"Florian Wiedner",
|
||||
"Alexander Daichendt",
|
||||
"Jonas Andre",
|
||||
"Georg Carle",
|
||||
],
|
||||
title: "Control Groups Added Latency in NFVs: An Update Needed?",
|
||||
conference:
|
||||
"2023 IEEE Conference on Network Function Virtualization and Software Defined Networks (NFV-SDN)",
|
||||
date: "Nov. 2023",
|
||||
links: {
|
||||
pdf: "https://www.net.in.tum.de/fileadmin/bibtex/publications/papers/wiedner_nfvsdn2023.pdf",
|
||||
homepage: "https://wiednerf.github.io/cgroups-nfv/",
|
||||
bibtex: "/publications/bibtex/wiedner2023containercgroups.bib",
|
||||
},
|
||||
},
|
||||
{
|
||||
authors: [
|
||||
"Florian Wiedner",
|
||||
"Max Helm",
|
||||
"Alexander Daichendt",
|
||||
"Jonas Andre",
|
||||
"Georg Carle",
|
||||
],
|
||||
title:
|
||||
"Containing Low Tail-Latencies in Packet Processing Using Lightweight Virtualization",
|
||||
conference: "2023 35rd International Teletraffic Congress (ITC-35)",
|
||||
date: "Oct. 2023",
|
||||
links: {
|
||||
pdf: "https://www.net.in.tum.de/fileadmin/bibtex/publications/papers/wiedner_itc35.pdf",
|
||||
homepage: "https://wiednerf.github.io/containerized-low-latency/",
|
||||
bibtex: "/publications/bibtex/wiedner2023container.bib",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const cvTable = sqliteTable("cv", {
|
||||
uuid: text("uuid").primaryKey(),
|
||||
company_name: text("company_name").notNull(),
|
||||
created: integer("created", {
|
||||
mode: "timestamp_ms",
|
||||
}),
|
||||
author: text("author").notNull(),
|
||||
purpose: text("purpose").notNull(),
|
||||
tooling: text("tooling").notNull(),
|
||||
status: text("status", {
|
||||
enum: ["active", "revoked"],
|
||||
}).default("active"),
|
||||
sha256: text("sha256"),
|
||||
pgp_signature: text("pgp_signature"),
|
||||
});
|
||||
10
src/env.d.ts
vendored
|
|
@ -1,10 +0,0 @@
|
|||
// src/env.d.ts
|
||||
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
type Runtime = import("@astrojs/cloudflare").Runtime<Env>;
|
||||
|
||||
declare namespace App {
|
||||
interface Locals extends Runtime {}
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
---
|
||||
import BaseHead from "../components/BaseHead.astro";
|
||||
import Footer from "../components/nav/Footer.astro";
|
||||
import { SITE_TITLE, SITE_DESCRIPTION } from "../consts";
|
||||
import "@fontsource/ubuntu";
|
||||
import "@fontsource/ubuntu/700.css";
|
||||
import TopHeader from "../components/nav/TopHeader.astro";
|
||||
import PageHeadline from "../components/PageHeadline.astro";
|
||||
import MobileNavDrawer from "../components/nav/MobileNavDrawer.astro";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
title = SITE_TITLE,
|
||||
description = SITE_DESCRIPTION,
|
||||
subtitle,
|
||||
className = "max-w-2xl px-4 py-8",
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<BaseHead title={title} description={description} />
|
||||
<script is:inline>
|
||||
// Prevent FOUC for dark mode
|
||||
if (
|
||||
localStorage.getItem("color-theme") === "dark" ||
|
||||
(!("color-theme" in localStorage) &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
) {
|
||||
document.documentElement.classList.add("dark");
|
||||
} else {
|
||||
document.documentElement.classList.remove("dark");
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body
|
||||
class="bg-white dark:bg-gray-900 text-black dark:text-white min-h-screen flex flex-col"
|
||||
>
|
||||
<div id="theme-overlay" class="theme-overlay"></div>
|
||||
|
||||
<MobileNavDrawer />
|
||||
|
||||
<TopHeader />
|
||||
|
||||
<main class="flex-1">
|
||||
<div class={`${className} mx-auto`}>
|
||||
<PageHeadline title={title} subtitle={subtitle} />
|
||||
<slot />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
<style>
|
||||
.theme-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
display: none;
|
||||
transition: clip-path 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
---
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import FormattedDate from "../components/FormattedDate.astro";
|
||||
import BaseLayout from "./BaseLayout.astro";
|
||||
import { Picture } from "astro:assets";
|
||||
|
||||
type Props = CollectionEntry<"blog">["data"] & {
|
||||
readingTime: number;
|
||||
};
|
||||
|
||||
const { title, description, pubDate, updatedDate, heroImage, readingTime } =
|
||||
Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description}>
|
||||
<article class="max-w-3xl mx-auto">
|
||||
{
|
||||
heroImage && (
|
||||
<div class="mb-12">
|
||||
<Picture
|
||||
src={heroImage}
|
||||
alt=""
|
||||
width={752}
|
||||
class="rounded-lg shadow-lg w-full object-cover aspect-[16/9] dark:shadow-gray-800/30 transition-transform hover:scale-[1.02]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
<div class="space-y-2 text-center mb-12">
|
||||
<div class="space-y-2">
|
||||
<time class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<FormattedDate date={pubDate} />
|
||||
</time>
|
||||
|
||||
{
|
||||
updatedDate && (
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
Last updated on <FormattedDate date={updatedDate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<h1 class="font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-center space-x-4 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4 mr-1"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{readingTime} min read
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<hr class="w-32 mx-auto border-gray-200 dark:border-gray-800" />
|
||||
</div>
|
||||
|
||||
<div class="mt-8 text-gray-800 dark:text-gray-200">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
27
src/lib/components/CatImage.svelte
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import type { ImageMetadata } from '$lib/utils/types';
|
||||
export let metadata: ImageMetadata[];
|
||||
export let sizes: string;
|
||||
|
||||
const fallback = metadata[metadata.length - 1];
|
||||
const _metadata = metadata.slice(0, metadata.length - 1);
|
||||
|
||||
const srcset = _metadata
|
||||
.map(({ href, width }) => `https://cats.daichendt.one/${href} ${width}w`)
|
||||
.join(',');
|
||||
</script>
|
||||
|
||||
{#if !fallback && !metadata}
|
||||
No metadata supplied
|
||||
{:else}
|
||||
<img {srcset} class="image" alt="A cute kitty" {sizes} loading="lazy" />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
77
src/lib/components/Code.svelte
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
<script lang="ts">
|
||||
let copied = false;
|
||||
|
||||
function copyToClipboard(event: MouseEvent) {
|
||||
// @ts-ignore
|
||||
const target = event.target?.innerText.split('\n')[0];
|
||||
navigator.clipboard.writeText(target);
|
||||
|
||||
// select the double clicked node
|
||||
let sel = document.getSelection();
|
||||
let range = new Range();
|
||||
range.selectNode(event.target?.firstChild);
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
if (!copied) {
|
||||
copied = true;
|
||||
|
||||
setTimeout(() => (copied = false), 3000);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<code on:dblclick={copyToClipboard}>
|
||||
<span class="text">
|
||||
<slot />
|
||||
</span>
|
||||
<div class:copied class="copyWrapper">
|
||||
{#if copied}
|
||||
Copied
|
||||
{:else}
|
||||
Double click to copy
|
||||
{/if}
|
||||
</div>
|
||||
</code>
|
||||
|
||||
<style>
|
||||
.copied {
|
||||
background-color: yellowgreen !important;
|
||||
min-width: 3rem !important;
|
||||
}
|
||||
|
||||
.copyWrapper {
|
||||
background-color: var(--special-color);
|
||||
margin-bottom: 0.2rem;
|
||||
padding: 0.2rem;
|
||||
border-radius: 3px;
|
||||
position: absolute;
|
||||
min-width: 10rem;
|
||||
visibility: hidden;
|
||||
z-index: 1;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
margin-left: 0px;
|
||||
}
|
||||
code:hover .copyWrapper {
|
||||
visibility: visible;
|
||||
}
|
||||
code {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
background-color: var(--light-color);
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 5px var(--shadow-color);
|
||||
padding: 2px;
|
||||
border: 1px solid var(--border-color);
|
||||
line-break: anywhere;
|
||||
}
|
||||
:global([data-nu-scheme-is='dark'] body code:not([class*='language-'])) {
|
||||
color: var(--bg-color);
|
||||
}
|
||||
:global(pre[class*='language-']) {
|
||||
margin: 0.5em 1rem !important;
|
||||
}
|
||||
:global(code[class*='language-'], pre[class*='language-']) {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
9
src/lib/components/Divider.svelte
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<hr />
|
||||
|
||||
<style>
|
||||
hr {
|
||||
background-color: var(--special-color);
|
||||
border: none;
|
||||
height: 1px;
|
||||
}
|
||||
</style>
|
||||
42
src/lib/components/Footer.svelte
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
<script context="module">
|
||||
const year = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import Icon from '@iconify/svelte';
|
||||
import Link from './Link.svelte';
|
||||
</script>
|
||||
|
||||
<footer class="mt-16 p-8">
|
||||
<!-- container class inherited from __layout-->
|
||||
<div class="container">
|
||||
<p class="flex items-center gap-1">
|
||||
Copyright <Icon icon="material-symbols:copyright" class="inline-block" />
|
||||
{year} Alexander Daichendt
|
||||
</p>
|
||||
|
||||
<div class="flex md:justify-between md:flex-row flex-col gap-1">
|
||||
<Link href="/cat">Meeeeeow</Link>
|
||||
<Link href="/privacy">Privacy Policy</Link>
|
||||
<Link href="/impressum">Impressum</Link>
|
||||
<Link href="https://github.com/AlexDaichendt/site">Source</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
footer {
|
||||
background-color: var(--special-bg-color);
|
||||
}
|
||||
@media screen and (max-width: 500px) {
|
||||
.footerLinks {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
:global(footer div a) {
|
||||
color: var(--text-soft-color) !important;
|
||||
}
|
||||
:global(footer div a:hover) {
|
||||
color: var(--light-color) !important;
|
||||
}
|
||||
</style>
|
||||
104
src/lib/components/Header.svelte
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<script>
|
||||
import ThemeSwitcher from './ThemeSwitcher.svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/blog', label: 'Blog' },
|
||||
{ href: '/publications', label: 'Publications' },
|
||||
{ href: '/projects', label: 'Projects' },
|
||||
{ href: '/contact', label: 'Contact' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<header>
|
||||
<div class="header">
|
||||
<a href="/">
|
||||
<h1>Alex Daichendt</h1>
|
||||
</a>
|
||||
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
<nav>
|
||||
<ol class="navList">
|
||||
{#each NAV_ITEMS as navItem}
|
||||
<li
|
||||
class="navItem {$page.url.pathname === navItem.href ||
|
||||
(navItem.href === '/blog' && $page.url.pathname.includes('/blog'))
|
||||
? 'active'
|
||||
: ''}"
|
||||
>
|
||||
<a href={navItem.href}>{navItem.label}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
.active {
|
||||
font-weight: 600;
|
||||
}
|
||||
.navList {
|
||||
padding: 0;
|
||||
}
|
||||
.navItem {
|
||||
display: inline;
|
||||
}
|
||||
.navItem:not(:last-child)::after {
|
||||
content: '·';
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.navItem a:hover {
|
||||
color: var(--text-strong-color);
|
||||
}
|
||||
.navItem a:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: 50%;
|
||||
width: 0%;
|
||||
border-bottom: 3px solid var(--outline-color);
|
||||
transition: 0.3s;
|
||||
}
|
||||
.navItem a:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
width: 0%;
|
||||
border-bottom: 3px solid var(--outline-color);
|
||||
transition: 0.3s;
|
||||
}
|
||||
.navItem a:hover:after {
|
||||
width: 50%;
|
||||
}
|
||||
.navItem a:hover:before {
|
||||
width: 50%;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
color: var(--special-color);
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
font-size: 1.2rem;
|
||||
position: relative;
|
||||
}
|
||||
ol {
|
||||
list-style-type: none;
|
||||
}
|
||||
</style>
|
||||
168
src/lib/components/Image.svelte
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* the output of a vite-imagetools import, using the `meta` query for output
|
||||
* format
|
||||
*
|
||||
* full type:
|
||||
* [code](https://github.com/JonasKruckenberg/imagetools/blob/main/packages/core/src/output-formats.ts),
|
||||
* [docs](https://github.com/JonasKruckenberg/imagetools/blob/main/docs/guide/getting-started.md#metadata)
|
||||
*/
|
||||
export let meta: { src: string; width: number; format: string }[];
|
||||
// if there is only one, vite-imagetools won't wrap the object in an array
|
||||
if (!(meta instanceof Array)) meta = [meta];
|
||||
|
||||
// all images by format
|
||||
let sources = new Map<string, typeof meta>();
|
||||
meta.map((m) => sources.set(m.format, []));
|
||||
meta.map((m) => (sources.get(m.format) ?? []).push(m));
|
||||
|
||||
// fallback image: first resolution of last format
|
||||
let image = (sources.get([...sources.keys()].slice(-1)[0]) ?? [])[0];
|
||||
|
||||
/**
|
||||
* `source` attribute. default: width of the first resolution specified in the
|
||||
* import.
|
||||
*/
|
||||
export let sizes = '100vw';
|
||||
|
||||
/** `img` attribute */
|
||||
export let alt: string;
|
||||
|
||||
/** `img` attribute */
|
||||
export let loading: 'lazy' | 'eager' = 'lazy';
|
||||
</script>
|
||||
|
||||
<!--
|
||||
@component
|
||||
takes the output of a vite-imagetools import (using the `meta` output format)
|
||||
and generates a `<picture>` with `<source>` tags and an `<img>`.
|
||||
|
||||
usage
|
||||
|
||||
- in `global.d.ts`
|
||||
```typescript
|
||||
declare module "*&imagetools" {
|
||||
const out;
|
||||
export default out;
|
||||
}
|
||||
```
|
||||
- in svelte file
|
||||
- typescript
|
||||
```typescript
|
||||
import Image from "$lib/Image.svelte";
|
||||
import me from "$lib/assets/me.jpg?w=200;400&format=webp;png&meta&imagetools";
|
||||
```
|
||||
- html
|
||||
```html
|
||||
<span><Image meta="{me}" alt="me" /></span>
|
||||
```
|
||||
- it's not necessary to wrap it in a `<span>`, but i like to avoid unnested
|
||||
`:global()` selectors in svelte css
|
||||
- scss
|
||||
```scss
|
||||
span :global(img) {
|
||||
border-radius: 50%;
|
||||
}
|
||||
```
|
||||
|
||||
example generated `<picture>`
|
||||
|
||||
```html
|
||||
<picture>
|
||||
<source
|
||||
sizes="200px"
|
||||
type="image/webp"
|
||||
srcset="
|
||||
/_app/assets/me-3cfc7c5f.webp 200w,
|
||||
/_app/assets/me-ab564f98.webp 400w
|
||||
"
|
||||
/>
|
||||
<source
|
||||
sizes="200px"
|
||||
type="image/png"
|
||||
srcset="
|
||||
/_app/assets/me-2bc09a6d.png 200w,
|
||||
/_app/assets/me-6f16cc18.png 400w
|
||||
"
|
||||
/>
|
||||
<img src="/_app/assets/me-2bc09a6d.png" alt="me" />
|
||||
</picture>
|
||||
```
|
||||
|
||||
notes
|
||||
|
||||
- from the documentation for
|
||||
[`sizes`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-sizes),
|
||||
|
||||
> The selected source size affects the intrinsic size of the image (the
|
||||
> image’s display size if no CSS styling is applied). If the srcset
|
||||
> attribute is absent, or contains no values with a width descriptor, then
|
||||
> the sizes attribute has no effect.
|
||||
|
||||
there are other things that may also affect the intrinsic (and separately,
|
||||
display) size of the image, but this is all we set here.
|
||||
|
||||
- the `&imagetools` in the usage above is to make typescript happy. there are
|
||||
other workarounds, if you'd prefer a differnet one
|
||||
https://github.com/JonasKruckenberg/imagetools/issues/160
|
||||
- it'd be nice if we could just use a plain `<img>` tag, but in my bit of
|
||||
testing that didn't seem to allow for multiple formats. i was also tempted
|
||||
to just use png, but in my bit of testing the webp file was only ~10% (!)
|
||||
the size of the png.
|
||||
|
||||
assumptions
|
||||
|
||||
- this counts on vite-imagetools returning metadata objects in the same order
|
||||
as the query values are specified
|
||||
- e.g. for `?width=100;200&format=webp;png&meta` we expect the source with
|
||||
`width=100` to come before the one with `width=200`, and likewise for
|
||||
`webp` and `png`
|
||||
- i don't think this is guaranteed, so hopefully it doesn't change. looks
|
||||
like it depends on this bit of code
|
||||
https://github.com/JonasKruckenberg/imagetools/blob/main/packages/core/src/lib/resolve-configs.ts#L17
|
||||
|
||||
references
|
||||
|
||||
- responsive images
|
||||
- mdn https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images
|
||||
- css-tricks https://css-tricks.com/a-guide-to-the-responsive-images-syntax-in-html/
|
||||
- web
|
||||
- html
|
||||
- picture https://developer.mozilla.org/en-US/docs/Web/HTML/Element/picture
|
||||
- source https://developer.mozilla.org/en-US/docs/Web/HTML/Element/source
|
||||
- img https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img
|
||||
- js
|
||||
- Map https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
|
||||
- ts
|
||||
- wildcard module declarations https://www.typescriptlang.org/docs/handbook/modules.html#wildcard-module-declarations
|
||||
- docs
|
||||
- vite-imagetools https://github.com/JonasKruckenberg/imagetools/tree/main/docs
|
||||
- other
|
||||
- how to generate srcset https://github.com/JonasKruckenberg/imagetools/blob/main/packages/core/src/output-formats.ts
|
||||
- `Map` preserves insertion order https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
|
||||
- you don't set elements on `Map` objects the way you do on regular objects
|
||||
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map
|
||||
(this was really hard to figure out lol)
|
||||
- vite-imagetools extensions (to make the import query string shorter)
|
||||
- docs https://github.com/JonasKruckenberg/imagetools/blob/main/docs/guide/extending.md
|
||||
- code (options) https://github.com/JonasKruckenberg/imagetools/blob/main/packages/vite/src/types.ts
|
||||
-->
|
||||
<picture>
|
||||
{#each [...sources.entries()] as [format, meta]}
|
||||
<source
|
||||
{sizes}
|
||||
type="image/{format}"
|
||||
srcset={meta.map((m) => `${m.src} ${m.width}w`).join(', ')}
|
||||
/>
|
||||
{/each}
|
||||
<img src={image.src} {alt} {loading} />
|
||||
</picture>
|
||||
|
||||
<style>
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
53
src/lib/components/Link.svelte
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<script lang="ts">
|
||||
import { mdiLinkVariant } from '@mdi/js';
|
||||
import { mdiChevronRight } from '@mdi/js';
|
||||
import Icon from 'mdi-svelte';
|
||||
export let href: string;
|
||||
export let disableIcon = false;
|
||||
export let disablePrefetch = false;
|
||||
// svelte-ignore unused-export-let
|
||||
export let rel = '';
|
||||
|
||||
const internal = !href.startsWith('http');
|
||||
|
||||
// external props
|
||||
let props: Record<string,string|boolean> = {
|
||||
rel: "nofollow noreferrer noopener",
|
||||
target: "_blank"
|
||||
}
|
||||
if (internal) {
|
||||
// internal props
|
||||
if (!disablePrefetch ){
|
||||
props = {
|
||||
"data-sveltekit-prefetch": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
{...$$props}
|
||||
{...props}
|
||||
{href}
|
||||
>
|
||||
<span class="text"><slot /></span>
|
||||
|
||||
{#if !disableIcon && !internal}
|
||||
<Icon path={internal ? mdiChevronRight : mdiLinkVariant} size="1rem" />
|
||||
{/if}
|
||||
</a><style>
|
||||
a {
|
||||
color: var(--special-color);
|
||||
text-decoration: none;
|
||||
font-weight: 550;
|
||||
}
|
||||
a:hover {
|
||||
background-color: var(--outline-color);
|
||||
color: var(--dark-color)
|
||||
}
|
||||
.text {
|
||||
text-decoration: underline;
|
||||
word-wrap: break-word;
|
||||
|
||||
}
|
||||
</style>
|
||||
11
src/lib/components/ListItem.svelte
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<script lang="ts">
|
||||
export let id: string | undefined = undefined;
|
||||
</script>
|
||||
|
||||
<li {id}><slot /></li>
|
||||
|
||||
<style>
|
||||
li {
|
||||
margin: 0.4rem 0;
|
||||
}
|
||||
</style>
|
||||
39
src/lib/components/MoveUpButton.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
import { mdiChevronDoubleUp } from '@mdi/js';
|
||||
import Icon from 'mdi-svelte';
|
||||
let y: number = 0;
|
||||
|
||||
$: enabled = y > 100;
|
||||
|
||||
function onClick() {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:scrollY={y} />
|
||||
|
||||
{#if enabled}
|
||||
<button on:click={onClick}><Icon path={mdiChevronDoubleUp} /></button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
button:focus {
|
||||
box-shadow: 0 0 5px var(--special-shadow-color);
|
||||
}
|
||||
button {
|
||||
border-radius: 35px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
right: 32px;
|
||||
color: var(--light-color);
|
||||
border: 1px solid var(--special-shadow-color);
|
||||
background-color: var(--dark-color);
|
||||
}
|
||||
button:hover {
|
||||
border: 1px solid var(--shadow-color);
|
||||
color: var(--dark-color);
|
||||
background-color: var(--light-color);
|
||||
}
|
||||
</style>
|
||||
25
src/lib/components/SEO.svelte
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export let title = "Alex Daichendt's website";
|
||||
export let keywords: string[] = [];
|
||||
export let description: string = '';
|
||||
let seo = $page.data?.seo;
|
||||
|
||||
if (seo) {
|
||||
title = seo.title ? `${seo.title} - Alex Daichendt` : "Alex Daichendt's website";
|
||||
description = seo.description;
|
||||
keywords = seo.keywords || [];
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head
|
||||
><title>{title}</title>
|
||||
{#if description.length > 0}
|
||||
<meta name="description" content={description} />
|
||||
{/if}
|
||||
<meta name="author" content="Alexander Daichendt" />
|
||||
{#if keywords.length > 0}
|
||||
<meta name="keywords" content={keywords.join(',')} />
|
||||
{/if}
|
||||
</svelte:head>
|
||||
51
src/lib/components/Table.svelte
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<script>
|
||||
</script>
|
||||
|
||||
<div tabindex="-1">
|
||||
<table id="table">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0px 0px 2px var(--shadow-color);
|
||||
border: 1px solid var(--outline-color);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(#table thead th) {
|
||||
padding-bottom: 0.25rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
border: solid;
|
||||
width: 350px;
|
||||
margin: auto;
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
background: linear-gradient(var(--special-color), var(--special-color)) bottom
|
||||
/* left or right or else */ no-repeat;
|
||||
background-size: 50% 2px;
|
||||
}
|
||||
|
||||
:global(#table tbody tr) {
|
||||
border-bottom: 1px solid var(--outline-color);
|
||||
}
|
||||
|
||||
:global(#table tbody tr:last-child) {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
:global(#table tbody td) {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
138
src/lib/components/ThemeSwitcher.svelte
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<script lang="js">
|
||||
// @ts-nocheck
|
||||
import { onMount } from 'svelte';
|
||||
let checked = false;
|
||||
|
||||
onMount(() => {
|
||||
const ROOT = document.querySelector(':root');
|
||||
const DARK = 'dark';
|
||||
const LIGHT = 'light';
|
||||
const HIGH = 'more';
|
||||
const LOW = 'no-preference';
|
||||
const SCHEMES = [DARK, LIGHT];
|
||||
const CONTRASTS = [HIGH, LOW];
|
||||
function observeContext(data) {
|
||||
if (data.find((record) => !record.attributeName.endsWith('-is'))) {
|
||||
setScheme();
|
||||
setContrast();
|
||||
}
|
||||
}
|
||||
const schemeMedia = matchMedia('(prefers-color-scheme: dark)');
|
||||
const contrastMedia = matchMedia('(prefers-contrast: more)');
|
||||
let globalScheme = schemeMedia.matches ? DARK : LIGHT;
|
||||
let globalContrast = contrastMedia.matches ? HIGH : LOW;
|
||||
schemeMedia.addListener((_media) => {
|
||||
globalScheme = _media.matches ? DARK : LIGHT;
|
||||
setScheme();
|
||||
});
|
||||
contrastMedia.addListener((_media) => {
|
||||
globalContrast = _media.matches ? HIGH : LOW;
|
||||
setContrast();
|
||||
});
|
||||
function setScheme() {
|
||||
const setting = ROOT.dataset.nuScheme;
|
||||
ROOT.dataset.nuSchemeIs =
|
||||
(setting !== 'auto' && SCHEMES.includes(setting) && setting) || globalScheme;
|
||||
}
|
||||
function setContrast() {
|
||||
const setting = ROOT.dataset.nuContrast;
|
||||
ROOT.dataset.nuContrastIs =
|
||||
(setting !== 'auto' && CONTRASTS.includes(setting) && setting) || globalContrast;
|
||||
}
|
||||
const observer = new MutationObserver((data) => observeContext(data));
|
||||
observer.observe(ROOT, {
|
||||
characterData: false,
|
||||
attributes: true,
|
||||
childList: false,
|
||||
subtree: false,
|
||||
});
|
||||
setScheme();
|
||||
// adjust the theme selector
|
||||
checked = globalScheme === DARK;
|
||||
|
||||
setContrast();
|
||||
// Switch to dark scheme
|
||||
// ROOT.dataset.nuContrast = 'more';
|
||||
// Increase contrast
|
||||
// ROOT.dataset.nuScheme = 'dark';
|
||||
});
|
||||
|
||||
function toggleTheme() {
|
||||
const root = document.querySelector(':root');
|
||||
const theme = root.dataset['nuSchemeIs'];
|
||||
|
||||
if (theme === 'light') {
|
||||
root.dataset['nuScheme'] = 'dark';
|
||||
} else {
|
||||
root.dataset['nuScheme'] = 'light';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<label class="switch">
|
||||
<input aria-label="Nightmode" type="checkbox" bind:checked on:change={toggleTheme} />
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
|
||||
<style>
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: lightskyblue;
|
||||
transition: 0.4s;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: gold;
|
||||
transition: 0.4s;
|
||||
background: radial-gradient(yellow, orange 63%, transparent calc(63% + 3px) 100%);
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--special-mark-color);
|
||||
}
|
||||
input:checked + .slider:before {
|
||||
background-color: white;
|
||||
background: radial-gradient(circle at 19% 19%, transparent 41%, var(--outline-color) 43%);
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 5px var(--special-shadow-color);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
/* Rounded sliders */
|
||||
.slider.round {
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider.round:before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||