Compare commits

..

1 Commits

Author SHA1 Message Date
a652d06d31 update dependencies 2025-01-09 21:18:26 -06:00
145 changed files with 3879 additions and 15133 deletions

View File

@@ -1,5 +1,3 @@
.DS_Store
.astro
.vscode
node_modules
dist

View File

@@ -1,143 +0,0 @@
name: release-image-gitea
on:
push:
tags:
- 2.*
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-js
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 10.x
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 24.13.1
cache: pnpm
- name: Install Dependencies
run: pnpm install
- name: Lint Code
run: pnpm lint
- name: Build Project
run: pnpm build
release:
runs-on: ubuntu-js
needs: build
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REPOSITORY_HOST }}
username: ${{ gitea.actor }}
password: ${{ secrets.REPOSITORY_TOKEN }}
- name: Login to Docker
uses: docker/login-action@v3
with:
registry: ${{ vars.DH_REGISTRY }}
username: ${{ secrets.DH_USERNAME }}
password: ${{ secrets.DH_TOKEN }}
- name: Create Kubeconfig
run: |
mkdir $HOME/.kube
echo "${{ secrets.KUBECONFIG_BUILDX }}" > $HOME/.kube/config
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
with:
driver: kubernetes
driver-opts: |
namespace=gitea
qemu.install=true
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["harbor.alexlebens.net/proxy-hub.docker/"]
- name: Available Platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Extract Metadata
id: meta
uses: docker/metadata-action@v5
with:
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,format=long
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
images: |
${{ vars.REPOSITORY_HOST }}/${{ gitea.repository }}
- name: Get Version Info
id: version
run: |
echo "version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
echo "commit=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
if git describe --tags --exact-match HEAD 2>/dev/null; then
echo "is_release=true" >> $GITHUB_OUTPUT
else
echo "is_release=false" >> $GITHUB_OUTPUT
fi
- name: Build and Push Image
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.version.outputs.version }}
COMMIT_SHA=${{ steps.version.outputs.commit }}
IS_RELEASE=${{ steps.version.outputs.is_release }}
file: ./Dockerfile
- name: ntfy Success
uses: niniyas/ntfy-action@master
if: success()
with:
url: '${{ secrets.NTFY_URL }}'
topic: '${{ secrets.NTFY_TOPIC }}'
title: 'Release Success - Site Profile'
priority: 3
headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}'
tags: action,successfully,completed
details: 'Image for Site Profile has been released!'
icon: 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/gitea.png'
- name: ntfy Failed
uses: niniyas/ntfy-action@master
if: failure()
with:
url: '${{ secrets.NTFY_URL }}'
topic: '${{ secrets.NTFY_TOPIC }}'
title: 'Release Failure - Site Profile'
priority: 4
headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}'
tags: action,failed
details: 'Image for Site Profile has failed to be released.'
icon: 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/gitea.png'
actions: '[{"action": "view", "label": "Open Gitea", "url": "https://gitea.alexlebens.dev/alexlebens/site-profile/actions?workflow=release-image.yml", "clear": true}]'
image: true

View File

@@ -1,143 +0,0 @@
name: release-image-harbor
on:
push:
tags:
- 2.*
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-js
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 10.x
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 24.13.1
cache: pnpm
- name: Install Dependencies
run: pnpm install
- name: Lint Code
run: pnpm lint
- name: Build Project
run: pnpm build
release:
runs-on: ubuntu-js
needs: build
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_HOST }}
username: ${{ vars.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_SECRET }}
- name: Login to Docker
uses: docker/login-action@v3
with:
registry: ${{ vars.DH_REGISTRY }}
username: ${{ secrets.DH_USERNAME }}
password: ${{ secrets.DH_TOKEN }}
- name: Create Kubeconfig
run: |
mkdir $HOME/.kube
echo "${{ secrets.KUBECONFIG_BUILDX }}" > $HOME/.kube/config
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3
with:
driver: kubernetes
driver-opts: |
namespace=gitea
qemu.install=true
buildkitd-config-inline: |
[registry."docker.io"]
mirrors = ["harbor.alexlebens.net/proxy-hub.docker/"]
- name: Available Platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Extract Metadata
id: meta
uses: docker/metadata-action@v5
with:
tags: |
type=ref,event=branch
type=ref,event=tag
type=sha,format=long
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
images: |
${{ vars.REGISTRY_HOST }}/images/site-profile
- name: Get Version Info
id: version
run: |
echo "version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
echo "commit=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
if git describe --tags --exact-match HEAD 2>/dev/null; then
echo "is_release=true" >> $GITHUB_OUTPUT
else
echo "is_release=false" >> $GITHUB_OUTPUT
fi
- name: Build and Push Image
uses: docker/build-push-action@v6
with:
context: .
push: true
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.version.outputs.version }}
COMMIT_SHA=${{ steps.version.outputs.commit }}
IS_RELEASE=${{ steps.version.outputs.is_release }}
file: ./Dockerfile
- name: ntfy Success
uses: niniyas/ntfy-action@master
if: success()
with:
url: '${{ secrets.NTFY_URL }}'
topic: '${{ secrets.NTFY_TOPIC }}'
title: 'Release Success - Site Profile'
priority: 3
headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}'
tags: action,successfully,completed
details: 'Image for Site Profile has been released!'
icon: 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/gitea.png'
- name: ntfy Failed
uses: niniyas/ntfy-action@master
if: failure()
with:
url: '${{ secrets.NTFY_URL }}'
topic: '${{ secrets.NTFY_TOPIC }}'
title: 'Release Failure - Site Profile'
priority: 4
headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}'
tags: action,failed
details: 'Image for Site Profile has failed to be released.'
icon: 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/gitea.png'
actions: '[{"action": "view", "label": "Open Gitea", "url": "https://gitea.alexlebens.dev/alexlebens/site-profile/actions?workflow=release-image.yml", "clear": true}]'
image: true

View File

@@ -1,34 +0,0 @@
name: renovate
on:
schedule:
- cron: '@daily'
push:
branches:
- main
workflow_dispatch:
jobs:
renovate:
runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:43
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Renovate
run: renovate
env:
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: ${{ vars.INSTANCE_URL }}
RENOVATE_REPOSITORIES: alexlebens/site-profile
RENOVATE_GIT_AUTHOR: Renovate Bot <renovate-bot@alexlebens.net>
RENOVATE_REDIS_URL: ${{ vars.RENOVATE_REDIS_URL }}
LOG_LEVEL: info
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_GIT_PRIVATE_KEY: ${{ secrets.RENOVATE_GIT_PRIVATE_KEY }}
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}
RENOVATE_REGISTRY_ALIASES: '{"dhi.io": "dhi.io"}'
RENOVATE_HOST_RULES: '[{"matchHost":"dhi.io","hostType":"docker","username":"${{ secrets.RENOVATE_DHI_USER }}","password":"${{ secrets.RENOVATE_DHI_TOKEN }}"}]'

View File

@@ -1,87 +0,0 @@
name: test-build
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-js
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 10.x
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: 24.13.1
cache: pnpm
- name: Install Dependencies
run: pnpm install
- name: Lint Code
run: pnpm lint
- name: Build Project
run: pnpm build
- name: ntfy Failed
uses: niniyas/ntfy-action@master
if: failure()
with:
url: '${{ secrets.NTFY_URL }}'
topic: '${{ secrets.NTFY_TOPIC }}'
title: 'Test Failure - Site Profile'
priority: 4
headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}'
tags: action,failed
details: 'Tests have failed for building Site Profile'
icon: 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/gitea.png'
actions: '[{"action": "view", "label": "Open Gitea", "url": "https://gitea.alexlebens.dev/alexlebens/site-profile/actions?workflow=test-build.yaml", "clear": true}]'
image: true
guarddog:
runs-on: ubuntu-js
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.12'
- name: Install GuardDog
run: |
python3 -m pip install --upgrade pip
python3 -m pip install guarddog
- name: Run GuardDog
run: |
guarddog npm scan ./
- name: ntfy Failed
uses: niniyas/ntfy-action@master
if: failure()
with:
url: '${{ secrets.NTFY_URL }}'
topic: '${{ secrets.NTFY_TOPIC }}'
title: 'Security Failure - Site Profile'
priority: 4
headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}'
tags: action,failed
details: 'Guarddog scan failed for Site Profile'
icon: 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/gitea.png'
actions: '[{"action": "view", "label": "Open Gitea", "url": "https://gitea.alexlebens.dev/alexlebens/site-profile/actions?workflow=test-build.yaml", "clear": true}]'
image: true

View File

@@ -0,0 +1,2 @@
# This file is processed by Renovate bot so that it creates a PR on new major Renovate versions
FROM renovate/renovate:38

44
.github/renovate.json vendored Normal file
View File

@@ -0,0 +1,44 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
"mergeConfidence:all-badges",
":rebaseStalePrs"
],
"timezone": "US/Central",
"schedule": [
"every weekday"
],
"labels": [],
"prHourlyLimit": 0,
"prConcurrentLimit": 0,
"packageRules": [
{
"description": "Disables for non major Renovate version",
"matchPaths": [
".github/renovate-update-notification/Dockerfile"
],
"matchUpdateTypes": [
"minor",
"patch",
"pin",
"digest",
"rollback"
],
"enabled": false
},
{
"description": "Generate for major Renovate version",
"matchPaths": [
".github/renovate-update-notification/Dockerfile"
],
"matchUpdateTypes": [
"major"
],
"addLabels": [
"upgrade"
],
"automerge": false
}
]
}

45
.github/workflows/release-image.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: release-image
on:
push:
tags:
- 0.*
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
release-image:
permissions:
contents: read
packages: write
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Log into the container registry
uses: docker/login-action@3b8fed7e4b60203b2aa0ecc6c6d6d91d12c06760
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@70b2cdc6480c1a8b86edf1777157f8f437de2166
with:
tags: |
type=ref,event=branch
type=ref,event=tag
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- name: Build and push Docker image
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
file: ./Dockerfile

6
.gitignore vendored
View File

@@ -20,6 +20,8 @@ pnpm-debug.log*
# macOS-specific files
.DS_Store
# ide
.vscode/
# jetbrains setting folder
.idea/
# vscode workspace
site-profile.code-workspace

3
.npmrc
View File

@@ -1,3 +0,0 @@
registry=https://registry.npmjs.org/
engine-strict=true
save-exact=true

View File

@@ -1 +0,0 @@
/src/components/ui/sections/Experience.astro

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

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

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

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

View File

@@ -1,13 +1,17 @@
FROM docker.io/node:24.13.1-alpine AS builder
FROM node:22.13.0-alpine3.20 AS base
LABEL version="0.6.1"
LABEL description="Astro based website to use as a profile"
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
FROM builder AS prod-deps
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM prod-deps AS build-deps
@@ -17,16 +21,11 @@ FROM build-deps AS build
COPY . .
RUN pnpm run build
FROM dhi.io/node:24.13.1 AS runtime
WORKDIR /app
FROM base AS runtime
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
LABEL version="2.15.0"
LABEL description="Astro based personal website"
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE $PORT
CMD ["node", "./dist/server/entry.mjs"]
EXPOSE 4321
CMD node ./dist/server/entry.mjs

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 Alex Lebens
Copyright (c) 2024 Alex Lebens
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,21 +1 @@
# This is an open-source and simple blog built with Astro.
Personal site used for information about myself and blog.
## Development
With dependencies installed, you can utilize the following npm scripts to manage your project's development lifecycle:
- `pnpm build`: Bundles your site into static files for production.
- `pnpm dev`: Starts a local development server with hot reloading enabled.
- `pnpm preview`: Serves your build output locally for preview before deployment.
For detailed help with Astro CLI commands, visit [Astro's documentation](https://docs.astro.build/en/reference/cli-reference/).
## Thanks
Thanks https://github.com/godruoyi/gblog/tree/gblog-template, https://github.com/mearashadowfax/ScrewFast,
## License
This project is released under the MIT License. Please read the [LICENSE](https://gitea.alexlebens.dev/alexlebens/site-profile/src/LICENSE.md) file for more details.
# Profile

View File

@@ -1,90 +1,10 @@
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import partytown from '@astrojs/partytown';
import react from '@astrojs/react';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite';
import icon from 'astro-icon';
import swup from '@swup/astro';
import { getSiteURL } from './src/support/url';
import node from "@astrojs/node";
export default defineConfig({
site: getSiteURL(),
image: {
remotePatterns: [
{ protocol: 'https', hostname: '*.alexlebens.net' },
{ protocol: 'https', hostname: '*.jsdelivr.net' },
{ protocol: 'https', hostname: '*.icons8.com' },
],
service: {
entrypoint: 'astro/assets/services/sharp',
}
},
prefetch: true,
integrations: [
partytown(),
react(),
sitemap(),
icon({
include: {
mdi: ['*'],
},
}),
swup({
theme: 'fade',
native: true,
cache: true,
preload: true,
accessibility: true,
smoothScrolling: true,
morph: ['#nav'],
}),
(await import('@playform/compress')).default({
CSS: true,
JavaScript: true,
HTML: {
'html-minifier-terser': {
collapseWhitespace: true,
minifyCSS: false,
minifyJS: true,
},
},
Image: false,
SVG: true,
Logger: 2,
}),
],
markdown: {
syntaxHighlight: false,
},
plugins: {
'@tailwindcss/postcss': {},
},
vite: {
plugins: [tailwindcss()],
},
output: 'static',
output: "static",
adapter: node({
mode: 'standalone',
}),
build: {
// Specifies the directory in the build output where Astro-generated assets (bundled JS and CSS for example) should live.
// see https://docs.astro.build/en/reference/configuration-reference/#buildassets
assets: 'assets',
// see https://docs.astro.build/en/reference/configuration-reference/#buildassetsprefix
assetsPrefix:
!!import.meta.env.S3_ENABLE || !!process.env.S3_ENABLE ? 'https://digitalocean.com' : '',
},
mode: "standalone"
})
});

View File

@@ -1,11 +0,0 @@
import eslintPluginAstro from 'eslint-plugin-astro';
import eslintConfigPrettier from "eslint-config-prettier/flat";
export default [
...eslintPluginAstro.configs.recommended,
eslintConfigPrettier,
{
rules: {
}
}
];

50
lib/directus.ts Normal file
View File

@@ -0,0 +1,50 @@
import { createDirectus, rest, } from '@directus/sdk';
type Global = {
title: string;
description: string;
name: string;
tagline: string;
email: string;
portrait: string;
about: string;
}
type About = {
background: string;
experience: string;
education: string;
certifications: string;
}
type Skills = {
skill_1: string;
skill_1_description: string;
skill_2: string;
skill_2_description: string;
skill_3: string;
skill_3_description: string;
}
export type Post = {
slug: string;
title: string;
content: string;
image: string;
published_date: string;
tags: string[];
image_alt: string;
}
type Schema = {
global: Global;
about: About;
skills: Skills;
posts: Post[];
}
export const directus_url = "https://directus.alexlebens.dev"
const directus = createDirectus<Schema>(directus_url).with(rest());
export default directus;

View File

@@ -1,80 +1,19 @@
{
"name": "site-profile",
"type": "module",
"version": "2.15.0",
"homepage": "https://www.alexlebens.dev",
"bugs": {
"url": "https://gitea.alexlebens.dev/alexlebens/site-profile/issues",
"email": "alexander.lebens@gmail.com"
},
"repository": {
"type": "git",
"url": "https://gitea.alexlebens.dev/alexlebens/site-profile"
},
"license": "MIT",
"author": {
"name": "Alex Lebens",
"email": "alexander.lebens@gmail.com",
"url": "https://www.alexlebens.dev"
},
"version": "0.6.1",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"start": "astro dev",
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,md,mdx,astro}\"",
"lint": "eslint \"src/**/*.{js,ts,jsx,tsx,astro}\"",
"lint:fix": "eslint --fix \"src/**/*.{js,ts,jsx,tsx,astro}\""
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.9.6",
"@astrojs/node": "^9.5.3",
"@astrojs/partytown": "^2.1.4",
"@astrojs/react": "^4.4.2",
"@astrojs/rss": "^4.0.15",
"@astrojs/sitemap": "^3.7.0",
"@directus/sdk": "^21.1.0",
"@giscus/react": "^3.1.0",
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/pajamas": "^1.2.15",
"@iconify-json/simple-icons": "^1.2.70",
"@playform/compress": "^0.2.1",
"@swup/astro": "^1.8.0",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/vite": "^4.1.18",
"@types/react": "^19.2.14",
"@types/unist": "^3.0.3",
"astro": "^5.17.2",
"astro-icon": "^1.1.5",
"marked": "^17.0.2",
"marked-shiki": "^1.2.1",
"mdast-util-to-string": "^4.0.0",
"preline": "^4.0.1",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"reading-time": "^1.5.0",
"sharp": "^0.34.5",
"sharp-ico": "^0.1.5",
"shiki": "^3.22.0",
"tailwindcss": "^4.1.18",
"ultrahtml": "^1.6.0"
},
"devDependencies": {
"@eslint-react/eslint-plugin": "^2.13.0",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/typography": "^0.5.19",
"eslint": "^10.0.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-astro": "^1.6.0",
"eslint-plugin-format": "^1.4.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.0",
"prettier": "^3.8.1",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"timeago.js": "^4.0.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.55.0"
"@astrojs/check": "^0.9.4",
"@astrojs/node": "^9.0.0",
"@directus/sdk": "^18.0.0",
"astro": "^5.1.4",
"typescript": "^5.7.3"
}
}

11419
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
onlyBuiltDependencies:
- swup

View File

@@ -1,8 +0,0 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;

View File

@@ -1,23 +0,0 @@
/** @type {import("prettier").Config} */
const config = {
printWidth: 100,
semi: true,
singleQuote: true,
tabWidth: 2,
trailingComma: 'es5',
useTabs: false,
plugins: [
'prettier-plugin-astro',
'prettier-plugin-tailwindcss',
],
overrides: [
{
files: '*.astro',
options: {
parser: 'astro',
},
},
],
};
export default config;

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1 @@
<svg height="640" width="1440" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a"><stop offset=".58" stop-opacity="0"/><stop offset="1"/></linearGradient><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="793.5" x2="759.5" xlink:href="#a" y1="261.5" y2="149.5"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="644.19" x2="645.54" xlink:href="#a" y1="398.02" y2="267.7"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="547" x2="522.36" xlink:href="#a" y1="457.27" y2="342.85"/><g clip-rule="evenodd" fill-rule="evenodd" opacity=".15"><path d="m439.57 249.55a2149.47 2149.47 0 0 1 1193.87-182.45l-12.48 93.17a2055.46 2055.46 0 0 0 -1141.66 174.47l-454.24 211.86-39.73-85.2z" fill="url(#b)"/><path d="m272.3 266.93a2393.36 2393.36 0 0 1 1328.96 205.6l-44.42 94.78a2288.7 2288.7 0 0 0 -1270.84-196.61l-553.29 73.05-13.7-103.77z" fill="url(#c)" opacity=".56"/><path d="m195.26 416.13a2149.46 2149.46 0 0 1 1204.86-83.21l-20.13 91.82a2055.46 2055.46 0 0 0 -1152.17 79.56l-470.18 173.62-32.56-88.18 470.18-173.62z" fill="url(#d)"/></g><path d="m-258.15 719.56 1743.12-517.56 182.93 616.12-1743.1 517.56z" fill="#090b11"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1440" height="640"><g opacity=".15"><path fill="url(#a)" d="M439.57 249.55A2149.47 2149.47 0 0 1 1633.44 67.1l-12.48 93.17A2055.46 2055.46 0 0 0 479.3 334.74L25.06 546.6l-39.73-85.2z"/><path fill="url(#b)" d="M272.3 265.93a2393.36 2393.36 0 0 1 1328.96 205.6l-44.42 94.78A2288.7 2288.7 0 0 0 286 369.7l-553.29 73.05-13.7-103.77z" opacity=".56"/><path fill="url(#c)" d="M195.26 416.13a2149.47 2149.47 0 0 1 1204.86-83.21l-20.13 91.82A2055.46 2055.46 0 0 0 227.82 504.3l-470.18 173.62-32.56-88.18 470.18-173.62z"/></g><path fill="#fff" d="M-258 718.56 1485.12 201l182.93 616.12-1743.11 517.56z"/><defs><linearGradient id="d"><stop offset=".58" stop-opacity="0"/><stop offset="1"/></linearGradient><linearGradient xlink:href="#d" id="a" x1="793.5" x2="759.5" y1="261.5" y2="149.5" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#d" id="b" x1="644.19" x2="645.54" y1="397.02" y2="266.7" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#d" id="c" x1="547" x2="522.36" y1="457.27" y2="342.85" gradientUnits="userSpaceOnUse"/></defs></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 749 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 381 KiB

View File

@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://www.alexlebens.dev/sitemap-index.xml

View File

@@ -1,352 +0,0 @@
/**
* Skipped minification because the original files appears to be already minified.
* Original file: /npm/@preline/collapse@2.1.0/index.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!(function (t, e) {
if ('object' == typeof exports && 'object' == typeof module) module.exports = e();
else if ('function' == typeof define && define.amd) define([], e);
else {
var n = e();
for (var o in n) ('object' == typeof exports ? exports : t)[o] = n[o];
}
})(self, () =>
(() => {
'use strict';
var t = {
737: (t, e) => {
/*
* HSBasePlugin
* @version: 2.1.0
* @author: HTMLStream
* @license: Licensed under MIT (https://preline.co/docs/license.html)
* Copyright 2023 HTMLStream
*/
Object.defineProperty(e, '__esModule', { value: !0 });
var n = (function () {
function t(t, e, n) {
((this.el = t),
(this.options = e),
(this.events = n),
(this.el = t),
(this.options = e),
(this.events = {}));
}
return (
(t.prototype.createCollection = function (t, e) {
var n;
t.push({
id:
(null === (n = null == e ? void 0 : e.el) || void 0 === n ? void 0 : n.id) ||
t.length + 1,
element: e,
});
}),
(t.prototype.fireEvent = function (t, e) {
if ((void 0 === e && (e = null), this.events.hasOwnProperty(t)))
return this.events[t](e);
}),
(t.prototype.on = function (t, e) {
this.events[t] = e;
}),
t
);
})();
e.default = n;
},
652: function (t, e, n) {
/*
* HSCollapse
* @version: 2.1.0
* @author: HTMLStream
* @license: Licensed under MIT (https://preline.co/docs/license.html)
* Copyright 2023 HTMLStream
*/
var o,
i =
(this && this.__extends) ||
((o = function (t, e) {
return (
(o =
Object.setPrototypeOf ||
({ __proto__: [] } instanceof Array &&
function (t, e) {
t.__proto__ = e;
}) ||
function (t, e) {
for (var n in e) Object.prototype.hasOwnProperty.call(e, n) && (t[n] = e[n]);
}),
o(t, e)
);
}),
function (t, e) {
if ('function' != typeof e && null !== e)
throw new TypeError(
'Class extends value ' + String(e) + ' is not a constructor or null'
);
function n() {
this.constructor = t;
}
(o(t, e),
(t.prototype =
null === e ? Object.create(e) : ((n.prototype = e.prototype), new n())));
});
Object.defineProperty(e, '__esModule', { value: !0 });
var s = n(969),
r = (function (t) {
function e(e, n, o) {
var i = t.call(this, e, n, o) || this;
return (
(i.contentId = i.el.dataset.hsCollapse),
(i.content = document.querySelector(i.contentId)),
(i.animationInProcess = !1),
i.content && i.init(),
i
);
}
return (
i(e, t),
(e.prototype.init = function () {
var t = this;
(this.createCollection(window.$hsCollapseCollection, this),
this.el.addEventListener('click', function () {
t.content.classList.contains('open') ? t.hide() : t.show();
}));
}),
(e.prototype.hideAllMegaMenuItems = function () {
this.content
.querySelectorAll('.hs-mega-menu-content.block')
.forEach(function (t) {
(t.classList.remove('block'), t.classList.add('hidden'));
});
}),
(e.prototype.show = function () {
var t = this;
if (this.animationInProcess || this.el.classList.contains('open')) return !1;
((this.animationInProcess = !0),
this.el.classList.add('open'),
this.content.classList.add('open'),
this.content.classList.remove('hidden'),
(this.content.style.height = '0'),
setTimeout(function () {
((t.content.style.height = ''.concat(t.content.scrollHeight, 'px')),
t.fireEvent('beforeOpen', t.el),
(0, s.dispatch)('beforeOpen.hs.collapse', t.el, t.el));
}),
(0, s.afterTransition)(this.content, function () {
((t.content.style.height = ''),
t.fireEvent('open', t.el),
(0, s.dispatch)('open.hs.collapse', t.el, t.el),
(t.animationInProcess = !1));
}));
}),
(e.prototype.hide = function () {
var t = this;
if (this.animationInProcess || !this.el.classList.contains('open')) return !1;
((this.animationInProcess = !0),
this.el.classList.remove('open'),
(this.content.style.height = ''.concat(this.content.scrollHeight, 'px')),
setTimeout(function () {
t.content.style.height = '0';
}),
this.content.classList.remove('open'),
(0, s.afterTransition)(this.content, function () {
(t.content.classList.add('hidden'),
(t.content.style.height = ''),
t.fireEvent('hide', t.el),
(0, s.dispatch)('hide.hs.collapse', t.el, t.el),
(t.animationInProcess = !1));
}),
this.content.querySelectorAll('.hs-mega-menu-content.block').length &&
this.hideAllMegaMenuItems());
}),
(e.getInstance = function (t, e) {
void 0 === e && (e = !1);
var n = window.$hsCollapseCollection.find(function (e) {
return e.element.el === ('string' == typeof t ? document.querySelector(t) : t);
});
return n ? (e ? n : n.element.el) : null;
}),
(e.autoInit = function () {
(window.$hsCollapseCollection || (window.$hsCollapseCollection = []),
document
.querySelectorAll('.hs-collapse-toggle:not(.--prevent-on-load-init)')
.forEach(function (t) {
window.$hsCollapseCollection.find(function (e) {
var n;
return (
(null === (n = null == e ? void 0 : e.element) || void 0 === n
? void 0
: n.el) === t
);
}) || new e(t);
}));
}),
(e.show = function (t) {
var e = window.$hsCollapseCollection.find(function (e) {
return e.element.el === ('string' == typeof t ? document.querySelector(t) : t);
});
e && e.element.content.classList.contains('hidden') && e.element.show();
}),
(e.hide = function (t) {
var e = window.$hsCollapseCollection.find(function (e) {
return e.element.el === ('string' == typeof t ? document.querySelector(t) : t);
});
e && !e.element.content.classList.contains('hidden') && e.element.hide();
}),
(e.on = function (t, e, n) {
var o = window.$hsCollapseCollection.find(function (t) {
return t.element.el === ('string' == typeof e ? document.querySelector(e) : e);
});
o && (o.element.events[t] = n);
}),
e
);
})(n(737).default);
(window.addEventListener('load', function () {
r.autoInit();
}),
'undefined' != typeof window && (window.HSCollapse = r),
(e.default = r));
},
969: function (t, e) {
var n = this;
(Object.defineProperty(e, '__esModule', { value: !0 }),
(e.menuSearchHistory =
e.classToClassList =
e.htmlToElement =
e.afterTransition =
e.dispatch =
e.debounce =
e.isFormElement =
e.isParentOrElementHidden =
e.isEnoughSpace =
e.isIpadOS =
e.isIOS =
e.getClassPropertyAlt =
e.getClassProperty =
e.stringToBoolean =
void 0));
e.stringToBoolean = function (t) {
return 'true' === t;
};
e.getClassProperty = function (t, e, n) {
return (
void 0 === n && (n = ''),
(window.getComputedStyle(t).getPropertyValue(e) || n).replace(' ', '')
);
};
e.getClassPropertyAlt = function (t, e, n) {
void 0 === n && (n = '');
var o = '';
return (
t.classList.forEach(function (t) {
t.includes(e) && (o = t);
}),
o.match(/:(.*)]/) ? o.match(/:(.*)]/)[1] : n
);
};
e.isIOS = function () {
return (
!!/iPad|iPhone|iPod/.test(navigator.platform) ||
(navigator.maxTouchPoints &&
navigator.maxTouchPoints > 2 &&
/MacIntel/.test(navigator.platform))
);
};
e.isIpadOS = function () {
return (
navigator.maxTouchPoints &&
navigator.maxTouchPoints > 2 &&
/MacIntel/.test(navigator.platform)
);
};
e.isEnoughSpace = function (t, e, n, o, i) {
(void 0 === n && (n = 'auto'), void 0 === o && (o = 10), void 0 === i && (i = null));
var s = e.getBoundingClientRect(),
r = i ? i.getBoundingClientRect() : null,
l = window.innerHeight,
c = r ? s.top - r.top : s.top,
a = (i ? r.bottom : l) - s.bottom,
u = t.clientHeight + o;
return 'bottom' === n ? a >= u : 'top' === n ? c >= u : c >= u || a >= u;
};
e.isFormElement = function (t) {
return (
t instanceof HTMLInputElement ||
t instanceof HTMLTextAreaElement ||
t instanceof HTMLSelectElement
);
};
var o = function (t) {
return !!t && ('none' === window.getComputedStyle(t).display || o(t.parentElement));
};
e.isParentOrElementHidden = o;
e.debounce = function (t, e) {
var o;
return (
void 0 === e && (e = 200),
function () {
for (var i = [], s = 0; s < arguments.length; s++) i[s] = arguments[s];
(clearTimeout(o),
(o = setTimeout(function () {
t.apply(n, i);
}, e)));
}
);
};
e.dispatch = function (t, e, n) {
void 0 === n && (n = null);
var o = new CustomEvent(t, {
detail: { payload: n },
bubbles: !0,
cancelable: !0,
composed: !1,
});
e.dispatchEvent(o);
};
e.afterTransition = function (t, e) {
var n = function () {
(e(), t.removeEventListener('transitionend', n, !0));
};
window.getComputedStyle(t, null).getPropertyValue('transition') !==
(navigator.userAgent.includes('Firefox') ? 'all' : 'all 0s ease 0s')
? t.addEventListener('transitionend', n, !0)
: e();
};
e.htmlToElement = function (t) {
var e = document.createElement('template');
return ((t = t.trim()), (e.innerHTML = t), e.content.firstChild);
};
e.classToClassList = function (t, e, n, o) {
(void 0 === n && (n = ' '),
void 0 === o && (o = 'add'),
t.split(n).forEach(function (t) {
return 'add' === o ? e.classList.add(t) : e.classList.remove(t);
}));
};
e.menuSearchHistory = {
historyIndex: -1,
addHistory: function (t) {
this.historyIndex = t;
},
existsInHistory: function (t) {
return t > this.historyIndex;
},
clearHistory: function () {
this.historyIndex = -1;
},
};
},
},
e = {};
var n = (function n(o) {
var i = e[o];
if (void 0 !== i) return i.exports;
var s = (e[o] = { exports: {} });
return (t[o].call(s.exports, s, s.exports, n), s.exports);
})(652);
return n;
})()
);

View File

@@ -1,40 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
"mergeConfidence:all-badges",
":rebaseStalePrs"
],
"timezone": "US/Central",
"labels": [],
"prHourlyLimit": 0,
"prConcurrentLimit": 0,
"packageRules": [
{
"description": "Label dependency",
"matchDatasources": [
"npm"
],
"addLabels": [
"dependency"
],
"automerge": false,
"minimumReleaseAge": "1 days"
},
{
"description": "Automerge dependency patch",
"matchDatasources": [
"npm"
],
"matchUpdateTypes": [
"patch"
],
"addLabels": [
"dependency",
"automerge"
],
"automerge": true,
"minimumReleaseAge": "1 days"
}
]
}

View File

@@ -1,10 +0,0 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"typescript.tsdk": "node_modules/typescript/lib"
}
}

View File

@@ -1,90 +0,0 @@
---
import { getImage } from 'astro:assets';
import { readSingleton } from '@directus/sdk';
import directus from '@lib/directus';
import { SEO } from '@/config';
import brandSrc from '@images/brand_logo.png';
import faviconSvgSrc from '@images/favicon_icon.svg';
import faviconSrc from '@images/favicon_icon.png';
interface Props {
title: string;
description: string;
ogImage?: any;
ogTitle?: string;
ogDescription?: string;
structuredData?: object;
}
const canonicalURL = Astro.url.href;
let {
title,
description,
ogImage,
ogTitle = title,
ogDescription = description,
structuredData = SEO.structuredData,
} = Astro.props;
const global = await directus.request(readSingleton('site_global'));
let card = 'summary_large_image';
if (!ogImage) {
ogImage = brandSrc;
card = 'summary';
}
const faviconSvg = await getImage({ src: faviconSvgSrc, format: 'svg' });
const appleTouchIcon = await getImage({ src: faviconSrc, width: 180, height: 180, format: 'png' });
const socialImageRes = await getImage({ src: ogImage, width: 1200, height: 600 });
let socialImage = socialImageRes.src;
if (!socialImage.startsWith('http')) {
socialImage = Astro.url.origin + socialImageRes.src;
}
---
<!-- Inject structured data https://developers.google.com/search/docs/advanced/structured-data/intro-structured-data -->{
structuredData && <script type="application/ld+json" set:html={JSON.stringify(structuredData)} />
}
<!-- Global Metadata -->
<meta name="title" content={title} />
<meta name="description" content={description} />
<meta charset="utf-8" />
<meta name="web_author" content={global.name} />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=5.0, minimum-scale=1.0"
/>
<meta name="generator" content={Astro.generator} />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="theme-color" content="#facc15" />
<meta name="robots" content="index, follow" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={ogTitle} />
<meta property="og:site_name" content={global.name} />
<meta property="og:description" content={ogDescription} />
<meta property="og:image" content={socialImage} />
<meta content="1200" property="og:image:width" />
<meta content="600" property="og:image:height" />
<meta content="image/png" property="og:image:type" />
<!-- Links -->
<link href={canonicalURL} rel="canonical" />
<link rel="sitemap" href="/sitemap-index.xml" />
<link rel="alternate" type="application/rss+xml" title={title} href="/rss.xml" />
<!--<link href="/manifest.json" rel="manifest" />-->
<link href="/favicon.ico" rel="icon" sizes="any" type="image/x-icon" />
<link href={faviconSvg.src} rel="icon" type="image/svg+xml" sizes="any" />
<link href={appleTouchIcon.src} rel="apple-touch-icon" />
<link href={appleTouchIcon.src} rel="shortcut icon" />
<link rel="preconnect" href="https://461ZQ3AX3S-dsn.algolia.net" crossorigin />

View File

@@ -0,0 +1,55 @@
---
interface Props {
href: string;
}
const { href } = Astro.props;
---
<a href={href}><slot /></a>
<style>
a {
position: relative;
display: flex;
place-content: center;
text-align: center;
padding: 0.56em 2em;
gap: 0.8em;
color: var(--accent-text-over);
text-decoration: none;
line-height: 1.1;
border-radius: 999rem;
overflow: hidden;
background: var(--gradient-accent-orange);
box-shadow: var(--shadow-md);
white-space: nowrap;
}
@media (min-width: 20em) {
a {
font-size: var(--text-lg);
}
}
a::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
transition: background-color var(--theme-transition);
mix-blend-mode: overlay;
}
a:focus::after,
a:hover::after {
background-color: hsla(var(--gray-999-basis), 0.3);
}
@media (min-width: 50em) {
a {
padding: 1.125rem 2.5rem;
font-size: var(--text-xl);
}
}
</style>

View File

@@ -0,0 +1,51 @@
---
import CallToAction from './CallToAction.astro';
import Icon from './Icon.astro';
import directus from "../../lib/directus"
import { readSingleton } from "@directus/sdk";
const global = await directus.request(readSingleton("global"));
---
<aside>
<h2>Interested in working together?</h2>
<CallToAction href=`mailto:${global.email}`>
Send Me a Message
<Icon icon="paper-plane-tilt" size="1.2em" />
</CallToAction>
</aside>
<style>
aside {
display: flex;
flex-direction: column;
align-items: center;
gap: 3rem;
border-top: 1px solid var(--gray-800);
border-bottom: 1px solid var(--gray-800);
padding: 5rem 1.5rem;
background-color: var(--gray-999_40);
box-shadow: var(--shadow-sm);
}
h2 {
font-size: var(--text-xl);
text-align: center;
max-width: 15ch;
}
@media (min-width: 50em) {
aside {
padding: 7.5rem;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
h2 {
font-size: var(--text-3xl);
text-align: left;
}
}
</style>

View File

@@ -1,131 +1,79 @@
---
import { Image } from 'astro:assets';
import { readSingleton } from '@directus/sdk';
import BrandLogo from '@components/images/BrandLogo.astro';
import directus from '@lib/directus';
import { NavigationLinks, FooterLinks } from '@/config';
import footerImg from '@images/flowers.png';
const global = await directus.request(readSingleton('site_global'));
import Icon from './Icon.astro';
const currentYear = new Date().getFullYear();
import directus from "../../lib/directus"
import { readSingleton } from "@directus/sdk";
const global = await directus.request(readSingleton("global"));
---
<footer
class="bg-background-accent w-full overflow-hidden"
transition:animate="none"
>
<div class="relative px-4 sm:px-6 pt-16 pb-12">
<div class="max-w-340 mx-auto">
<div class="grid grid-cols-1 md:grid-cols-12 gap-10">
<!-- Brand section -->
<div class="col-span-1 md:col-span-3">
<a href="/" class="group inline-block">
<div class="flex items-center">
<div class="mx-auto aspect-square overflow-hidden">
<BrandLogo class="rounded-lg max-h-10 max-w-10"/>
</div>
<span class="text-header text-lg lg:text-2xl font-semibold leading-tight tracking-tight text-balance ml-3">
{global.name}
</span>
</div>
</a>
<p class="text-primary text-sm lg:text-base text-pretty leading-relaxed mt-4">
{global.about}
</p>
</div>
<!-- Left links -->
<div class="col-span-1 md:col-span-2">
<h3 class="relative inline-block text-header after:bg-main text-sm uppercase font-semibold tracking-wider pb-2 after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-8 after:content-['']">
Site
</h3>
<ul class="mt-4 space-y-3">
{NavigationLinks.map((link) => (
<li>
<a
href={link.url}
class="inline-flex items-center text-secondary hover:text-secondary-hover text-base transition-all duration-300 overflow-hidden"
>
{link.name}
</a>
</li>
))}
</ul>
</div>
<!-- Right links -->
<div class="col-span-1 md:col-span-3">
<h3 class="relative inline-block text-header after:bg-main text-sm uppercase font-semibold tracking-wider pb-2 after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-8 after:content-['']">
Other
</h3>
<ul class="mt-4 space-y-3">
{FooterLinks.map((link) => (
<li>
<a
href={link.url}
class="inline-flex items-center text-secondary hover:text-secondary-hover text-base transition-all duration-300 overflow-hidden"
>
{link.name}
</a>
</li>
))}
</ul>
</div>
<!-- Right image -->
<div class="flex justify-center col-span-4 mt-10 md:mt-0">
<div class="md:block max-h-115 max-w-55 -mt-10 scale-80 hidden">
<Image
src={footerImg}
alt={global.footer_image_alt}
class="h-full w-full object-cover object-center"
draggable="false"
loading="eager"
format="webp"
quality="low"
widths={[440]}
inferSize={true}
/>
</div>
</div>
</div>
<!-- Bottom section -->
<div class="border-t border-divider pt-8 mt-12">
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
<p class="text-secondary text-sm">
&copy; {currentYear} All rights reserved.
</p>
<div class="flex items-center">
<span class="text-secondary text-sm">
Weather provided by
</span>
<a
href="https://open-meteo.com/"
target="_blank"
rel="noopener noreferrer"
class="group inline-flex items-center text-secondary hover:text-secondary-hover text-sm transition-all duration-300"
>
<span class="relative underline ml-1">
Open-Meteo.
</span>
</a>
<div class="ml-4"/>
<span class="text-secondary text-sm">
Built with
</span>
<a
href="https://astro.build"
target="_blank"
rel="noopener noreferrer"
class="group inline-flex items-center text-secondary hover:text-secondary-hover text-sm transition-all duration-300"
>
<span class="relative underline ml-1">
Astro.
</span>
</a>
</div>
</div>
</div>
</div>
</div>
<footer>
<div class="group">
<p>
Designed & Developed in Minnesota with <a href="https://astro.build/">Astro</a>
<Icon icon="rocket-launch" size="1.2em" />
</p>
<p>&copy; {currentYear} {global.name}</p>
</div>
<p class="socials">
<a href="https://github.com/alexlebens"> GitHub</a>
<a href="https://www.linkedin.com/in/alexanderlebens"> LinkedIn</a>
</p>
</footer>
<style>
footer {
display: flex;
flex-direction: column;
gap: 3rem;
margin-top: auto;
padding: 3rem 2rem 3rem;
text-align: center;
color: var(--gray-400);
font-size: var(--text-sm);
}
footer a {
color: var(--gray-400);
text-decoration: 1px solid underline transparent;
text-underline-offset: 0.25em;
transition: text-decoration-color var(--theme-transition);
}
footer a:hover,
footer a:focus {
text-decoration-color: currentColor;
}
.group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.socials {
display: flex;
justify-content: center;
gap: 1rem;
flex-wrap: wrap;
}
@media (min-width: 50em) {
footer {
flex-direction: row;
justify-content: space-between;
padding: 2.5rem 5rem;
}
.group {
flex-direction: row;
gap: 1rem;
flex-wrap: wrap;
}
.socials {
justify-content: flex-end;
}
}
</style>

62
src/components/Grid.astro Normal file
View File

@@ -0,0 +1,62 @@
---
interface Props {
variant?: 'offset' | 'small';
}
const { variant } = Astro.props;
---
<ul class:list={['grid', { offset: variant === 'offset', small: variant === 'small' }]}>
<slot />
</ul>
<style>
.grid {
display: grid;
grid-auto-rows: 1fr;
gap: 1rem;
list-style: none;
padding: 0;
}
.grid.small {
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.grid.small > :global(:last-child:nth-child(odd)) {
grid-column: 1 / 3;
}
@media (min-width: 50em) {
.grid {
grid-template-columns: 1fr 1fr;
gap: 4rem;
}
.grid.offset {
--row-offset: 7.5rem;
padding-bottom: var(--row-offset);
}
.grid.offset > :global(:nth-child(odd)) {
transform: translateY(var(--row-offset));
}
.grid.offset > :global(:last-child:nth-child(odd)) {
grid-column: 2 / 3;
transform: none;
}
.grid.small {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem;
}
.grid.small > :global(*) {
flex-basis: 20rem;
}
}
</style>

View File

@@ -1,99 +0,0 @@
---
import BrandLogo from '@components/images/BrandLogo.astro';
import ThemeToggleButton from '@components/buttons/ThemeToggleButton.astro';
import { NavigationLinks } from '@/config';
const pathname = new URL(Astro.request.url).pathname;
const currentPath = pathname.slice(1);
---
<header
id="nav"
class="fixed flex flex-wrap md:flex-nowrap md:justify-start inset-x-0 top-0 w-full z-50"
>
<nav
class="nav-base relative md:flex md:items-center md:justify-between rounded-[36px] w-full px-4 mx-2 py-3 mt-4"
aria-label="Global"
>
<div class="flex items-center justify-between ml-0">
<a
class="flex-none rounded-full h-10.5"
href="/"
aria-label="Brand"
>
<BrandLogo class="h-full w-auto rounded-full object-cover"/>
</a>
<div class="md:hidden mr-auto ml-4">
<button
type="button"
class="hs-collapse-toggle flex items-center justify-center text-secondary text-sm font-bold hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded-full transition duration-300 disabled:pointer-events-none disabled:opacity-50 h-8 w-8"
data-hs-collapse="#navbar-collapse-with-animation"
aria-controls="navbar-collapse-with-animation"
aria-label="Toggle navigation"
>
<svg
class="hs-collapse-open:hidden shrink-0 h-5 w-5"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="3" x2="21" y1="6" y2="6"></line>
<line x1="3" x2="21" y1="12" y2="12"></line>
<line x1="3" x2="21" y1="18" y2="18"></line>
</svg>
<svg
class="hs-collapse-open:block shrink-0 h-5 w-5 hidden"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
<div class="md:hidden ml-2 mr-2">
<span class="">
<ThemeToggleButton />
</span>
</div>
</div>
<div class="flex md:flex-row items-center justify-between">
<div
id="navbar-collapse-with-animation"
class="hs-collapse grow basis-full md:block transition-all duration-300 ml-2 mb-2 md:mb-0 hidden overflow-hidden md:overflow-visible"
>
<div class="flex flex-col md:flex-row md:items-center md:justify-end gap-x-0 md:gap-x-4 lg:gap-x-7 gap-y-4 md:gap-y-0 md:ps-7 mr-2 mt-5 md:mt-0">
{NavigationLinks.map((item) => {
const isActive = currentPath === (item.url === '/' ? '' : item.url.slice(1));
return (
<a
href={item.url}
class={`text-sm font-medium ${isActive ? 'text-active' : 'text-secondary hover:text-secondary-hover'}`}
>
{item.name}
</a>
);
})}
</div>
</div>
<div class="hidden md:flex ml-2">
<span class="">
<ThemeToggleButton />
</span>
</div>
</div>
</nav>
</header>
<script is:inline src="/vendor/preline/collapse2.1.0.min.js"></script>

54
src/components/Hero.astro Normal file
View File

@@ -0,0 +1,54 @@
---
interface Props {
title: string;
tagline?: string;
align?: 'start' | 'center';
}
const { align = 'center', tagline, title } = Astro.props;
---
<div class:list={['hero stack gap-4', align]}>
<div class="stack gap-2">
<h1 class="title">{title}</h1>
{tagline && <p class="tagline">{tagline}</p>}
</div>
<slot />
</div>
<style>
.hero {
font-size: var(--text-lg);
text-align: center;
}
.title,
.tagline {
max-width: 37ch;
margin-inline: auto;
}
.title {
font-size: var(--text-3xl);
color: var(--gray-0);
}
@media (min-width: 50em) {
.hero {
font-size: var(--text-xl);
}
.start {
text-align: start;
}
.start .title,
.start .tagline {
margin-inline: unset;
}
.title {
font-size: var(--text-5xl);
}
}
</style>

56
src/components/Icon.astro Normal file
View File

@@ -0,0 +1,56 @@
---
import type { HTMLAttributes } from 'astro/types';
import { iconPaths } from './IconPaths';
interface Props {
icon: keyof typeof iconPaths;
color?: string;
gradient?: boolean;
size?: string;
}
const { color = 'currentcolor', gradient, icon, size } = Astro.props;
const iconPath = iconPaths[icon];
const attrs: HTMLAttributes<'svg'> = {};
if (size) attrs.style = { '--size': size };
const gradientId = 'icon-gradient-' + Math.round(Math.random() * 10e12).toString(36);
---
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 256 256"
aria-hidden="true"
stroke={gradient ? `url(#${gradientId})` : color}
fill={gradient ? `url(#${gradientId})` : color}
{...attrs}
>
<g set:html={iconPath} />
{
gradient && (
<linearGradient
id={gradientId}
x1="23"
x2="235"
y1="43"
y2="202"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--gradient-stop-1)" />
<stop offset=".5" stop-color="var(--gradient-stop-2)" />
<stop offset="1" stop-color="var(--gradient-stop-3)" />
</linearGradient>
)
}
</svg>
<style>
svg {
vertical-align: middle;
width: var(--size, 1em);
height: var(--size, 1em);
}
</style>

View File

@@ -0,0 +1,33 @@
/**
* Icons adapted from https://phosphoricons.com/
*
* Want to add more?
* 1. Find the icon you want on Phosphor Icons.
* 2. Click “Copy SVG”.
* 3. Paste the SVG code in your editor.
* 4. Remove the `<svg>` wrapper so you only have elements like `<path>`, `<circle>`, `<rect>` etc.
* 5. Remove any `stroke="#000000"` attributes
* 6. Replace any `fill="#000000"` attributes with `stroke="none"`
* (or add `stroke="none"` on shapes with no `fill` or `stroke` specified).
*/
export const iconPaths = {
'terminal-window': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m80 96 40 32-40 32m56 0h40"/><rect width="192" height="160" x="32" y="48" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16.97" rx="8.5"/>`,
trophy: `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M56 56v55.1c0 39.7 31.8 72.6 71.5 72.9a72 72 0 0 0 72.5-72V56a8 8 0 0 0-8-8H64a8 8 0 0 0-8 8Zm40 168h64m-32-40v40"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M198.2 128h9.8a32 32 0 0 0 32-32V80a8 8 0 0 0-8-8h-32M58 128H47.9a32 32 0 0 1-32-32V80a8 8 0 0 1 8-8h32"/>`,
strategy: `<circle cx="68" cy="188" r="28" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m40 72 40 40m0-40-40 40m136 56 40 40m0-40-40 40M136 80V40h40"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m136 40 16 16c40 40 8 88-24 96"/>`,
'paper-plane-tilt': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M210.3 35.9 23.9 88.4a8 8 0 0 0-1.2 15l85.6 40.5a7.8 7.8 0 0 1 3.8 3.8l40.5 85.6a8 8 0 0 0 15-1.2l52.5-186.4a7.9 7.9 0 0 0-9.8-9.8Zm-99.4 109.2 45.2-45.2"/>`,
'arrow-right': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M40 128h176m-72-72 72 72-72 72"/>`,
'arrow-left': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M216 128H40m72-72-72 72 72 72"/>`,
code: `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m64 88-48 40 48 40m128-80 48 40-48 40M160 40 96 216"/>`,
'hard-drives': `<path d="M208,136H48a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V152A16,16,0,0,0,208,136Zm0,64H48V152H208v48Zm0-160H48A16,16,0,0,0,32,56v48a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V56A16,16,0,0,0,208,40Zm0,64H48V56H208v48ZM192,80a12,12,0,1,1-12-12A12,12,0,0,1,192,80Zm0,96a12,12,0,1,1-12-12A12,12,0,0,1,192,176Z"/>`,
'cloud': `<path d="M160,40A88.09,88.09,0,0,0,81.29,88.67,64,64,0,1,0,72,216h88a88,88,0,0,0,0-176Zm0,160H72a48,48,0,0,1,0-96c1.1,0,2.2,0,3.29.11A88,88,0,0,0,72,128a8,8,0,0,0,16,0,72,72,0,1,1,72,72Z"/>`,
'network': '<path d="M232,112H136V88h8a16,16,0,0,0,16-16V40a16,16,0,0,0-16-16H112A16,16,0,0,0,96,40V72a16,16,0,0,0,16,16h8v24H24a8,8,0,0,0,0,16H56v32H48a16,16,0,0,0-16,16v32a16,16,0,0,0,16,16H80a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H72V128H184v32h-8a16,16,0,0,0-16,16v32a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16h-8V128h32a8,8,0,0,0,0-16ZM112,40h32V72H112ZM80,208H48V176H80Zm128,0H176V176h32Z"/>',
'microphone-stage': `<circle cx="168" cy="88" r="64" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m213.3 133.3-90.6-90.6M100 156l-12 12m16.8-70.1L28.1 202.5a7.9 7.9 0 0 0 .8 10.4l14.2 14.2a7.9 7.9 0 0 0 10.4.8l104.6-76.7"/>`,
'pencil-line': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M96 216H48a8 8 0 0 1-8-8v-44.7a7.9 7.9 0 0 1 2.3-5.6l120-120a8 8 0 0 1 11.4 0l44.6 44.6a8 8 0 0 1 0 11.4Zm40-152 56 56"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M216 216H96l-55.5-55.5M164 92l-96 96"/>`,
'rocket-launch': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M94.1 184.6c-11.4 33.9-56.6 33.9-56.6 33.9s0-45.2 33.9-56.6m124.5-56.5L128 173.3 82.7 128l67.9-67.9C176.3 34.4 202 34.7 213 36.3a7.8 7.8 0 0 1 6.7 6.7c1.6 11 1.9 36.7-23.8 62.4Z"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M184.6 116.7v64.6a8 8 0 0 1-2.4 5.6l-32.3 32.4a8 8 0 0 1-13.5-4.1l-8.4-41.9m11.3-101.9H74.7a8 8 0 0 0-5.6 2.4l-32.4 32.3a8 8 0 0 0 4.1 13.5l41.9 8.4"/>`,
list: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M40 128h176M40 64h176M40 192h176"/>`,
heart: `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M128 216S28 160 28 92a52 52 0 0 1 100-20h0a52 52 0 0 1 100 20c0 68-100 124-100 124Z"/>`,
'moon-stars': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M216 112V64m24 24h-48m-24-64v32m16-16h-32m65 113A92 92 0 0 1 103 39h0a92 92 0 1 0 114 114Z"/>`,
sun: `<circle cx="128" cy="128" r="60" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M128 36V16M63 63 49 49m-13 79H16m47 65-14 14m79 13v20m65-47 14 14m13-79h20m-47-65 14-14"/>`,
'github-logo': `<g stroke-linecap="round" stroke-linejoin="round"><path fill="none" stroke-width="14.7" d="M55.7 167.2c13.9 1 21.3 13.1 22.2 14.6 4.2 7.2 10.4 9.6 18.3 7.1l1.1-3.4a60.3 60.3 0 0 1-25.8-11.9c-12-10.1-18-25.6-18-46.3"/><path fill="none" stroke-width="16" d="M61.4 205.1a24.5 24.5 0 0 1-3-6.1c-3.2-7.9-7.1-10.6-7.8-11.1l-1-.6c-2.4-1.6-9.5-6.5-7.2-13.9 1.4-4.5 6-7.2 12.3-7.2h.8c4 .3 7.6 1.5 10.7 3.2-9.1-10.1-13.6-24.3-13.6-42.3 0-11.3 3.5-21.7 10.1-30.4A46.7 46.7 0 0 1 65 67.3a8.3 8.3 0 0 1 5-4.7c2.8-.9 13.3-2.7 33.2 9.9a105 105 0 0 1 50.5 0c19.9-12.6 30.4-10.8 33.2-9.9 2.3.7 4.1 2.4 5 4.7 5 12.7 4 23.2 2.6 29.4 6.7 8.7 10 18.9 10 30.4 0 42.6-25.8 54.7-43.6 58.7 1.4 4.1 2.2 8.8 2.2 13.7l-.1 23.4v2.3"/><path fill="none" stroke-width="16" d="M160.9 185.7c1.4 4.1 2.2 8.8 2.2 13.7l-.1 23.4v2.3A98.6 98.6 0 1 0 61.4 205c-1.4-2.1-11.3-17.5-11.8-17.8-2.4-1.6-9.5-6.5-7.2-13.9 1.4-4.5 6-7.2 12.3-7.2h.8c4 .3 7.6 1.5 10.7 3.2-9.1-10.1-13.6-24.3-13.6-42.3 0-11.3 3.5-21.7 10.1-30.4A46.4 46.4 0 0 1 65 67.3a8.3 8.3 0 0 1 5-4.7c2.8-.9 13.3-2.7 33.2 9.9a105 105 0 0 1 50.5 0c19.9-12.6 30.4-10.8 33.2-9.9 2.3.7 4.1 2.4 5 4.7 5 12.7 4 23.2 2.6 29.4 6.7 8.7 10 18.9 10 30.4.1 42.6-25.8 54.7-43.6 58.6z"/><path fill="none" stroke-width="18.7" d="m170.1 203.3 17.3-12 17.2-18.7 9.5-26.6v-27.9l-9.5-27.5" /><path fill="none" stroke-width="22.7" d="m92.1 57.3 23.3-4.6 18.7-1.4 29.3 5.4m-110 32.6-8 16-4 21.4.6 20.3 3.4 13" /><path fill="none" stroke-width="13.3" d="M28.8 133a100 100 0 0 0 66.9 94.4v-8.7c-22.4 1.8-33-11.5-35.6-19.8-3.4-8.6-7.8-11.4-8.5-11.8"/></g>`,
'linkedin-logo': `<rect width="184" height="184" x="36" y="36" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" rx="8"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M120 112v64m-32-64v64m32-36a28 28 0 0 1 56 0v36"/><circle stroke="none" cx="88" cy="80" r="12"/>`,
};

View File

@@ -0,0 +1,51 @@
---
import '../styles/global.css';
import directus from "../../lib/directus"
import { readSingleton } from "@directus/sdk";
interface Props {
title?: string | undefined;
description?: string | undefined;
}
const global = await directus.request(readSingleton("global"));
const {
title = `${global.name}`,
description = `A profile of ${global.name}`,
} = Astro.props;
---
<meta charset="UTF-8" />
<meta name="description" property="og:description" content={description} />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,400;0,700;1,400&family=Rubik:wght@500;600&display=swap"
rel="stylesheet"
/>
<script is:inline>
const getThemePreference = () => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const isDark = getThemePreference() === 'dark';
document.documentElement.classList[isDark ? 'add' : 'remove']('theme-dark');
if (typeof localStorage !== 'undefined') {
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains('theme-dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
}
</script>

360
src/components/Nav.astro Normal file
View File

@@ -0,0 +1,360 @@
---
import Icon from './Icon.astro';
import ThemeToggle from './ThemeToggle.astro';
import type { iconPaths } from './IconPaths';
import directus from "../../lib/directus"
import { readSingleton } from "@directus/sdk";
const textLinks: { label: string; href: string }[] = [
{ label: 'Home', href: '/' },
{ label: 'Projects', href: '/projects/' },
{ label: 'About', href: '/about/' },
];
const iconLinks: { label: string; href: string; icon: keyof typeof iconPaths }[] = [
{ label: 'GitHub', href: 'https://github.com/alexlebens', icon: 'github-logo' },
{ label: 'LinkedIn', href: 'https://www.linkedin.com/in/alexanderlebens', icon: 'linkedin-logo' },
];
const global = await directus.request(readSingleton("global"));
---
<nav>
<div class="menu-header">
<a href="/" class="site-title">
<Icon icon="terminal-window" color="var(--accent-regular)" size="1.6em" gradient />
{global.name}
</a>
<menu-button>
<template>
<button class="menu-button" aria-expanded="false">
<span class="sr-only">Menu</span>
<Icon icon="list" />
</button>
</template>
</menu-button>
</div>
<noscript>
<ul class="nav-items">
{
textLinks.map(({ label, href }) => (
<li>
<a
aria-current={Astro.url.pathname === href}
class:list={[
'link',
{
active:
Astro.url.pathname === href ||
(href !== '/' && Astro.url.pathname.startsWith(href)),
},
]}
href={href}
>
{label}
</a>
</li>
))
}
</ul>
</noscript>
<noscript>
<div class="menu-footer">
<div class="socials">
{
iconLinks.map(({ href, icon, label }) => (
<a href={href} class="social">
<span class="sr-only">{label}</span>
<Icon icon={icon} />
</a>
))
}
</div>
</div>
</noscript>
<div id="menu-content" hidden>
<ul class="nav-items">
{
textLinks.map(({ label, href }) => (
<li>
<a
aria-current={Astro.url.pathname === href}
class:list={[
'link',
{
active:
Astro.url.pathname === href ||
(href !== '/' && Astro.url.pathname.startsWith(href)),
},
]}
href={href}
>
{label}
</a>
</li>
))
}
</ul>
<div class="menu-footer">
<div class="socials">
{
iconLinks.map(({ href, icon, label }) => (
<a href={href} class="social">
<span class="sr-only">{label}</span>
<Icon icon={icon} />
</a>
))
}
</div>
<div class="theme-toggle">
<ThemeToggle />
</div>
</div>
</div>
</nav>
<script>
class MenuButton extends HTMLElement {
constructor() {
super();
this.appendChild(this.querySelector('template')!.content.cloneNode(true));
const btn = this.querySelector('button')!;
const menu = document.getElementById('menu-content')!;
menu.hidden = true;
menu.classList.add('menu-content');
const setExpanded = (expand: boolean) => {
btn.setAttribute('aria-expanded', expand ? 'true' : 'false');
menu.hidden = !expand;
};
btn.addEventListener('click', () => setExpanded(menu.hidden));
const handleViewports = (e: MediaQueryList | MediaQueryListEvent) => {
setExpanded(e.matches);
btn.hidden = e.matches;
};
const mediaQueries = window.matchMedia('(min-width: 50em)');
handleViewports(mediaQueries);
mediaQueries.addEventListener('change', handleViewports);
}
}
customElements.define('menu-button', MenuButton);
</script>
<style>
nav {
z-index: 9999;
position: relative;
font-family: var(--font-brand);
font-weight: 500;
margin-bottom: 3.5rem;
}
.menu-header {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 1.5rem;
}
.site-title {
display: flex;
gap: 0.5rem;
align-items: center;
line-height: 1.1;
color: var(--gray-0);
text-decoration: none;
}
.menu-button {
position: relative;
display: flex;
border: 0;
border-radius: 999rem;
padding: 0.5rem;
font-size: 1.5rem;
color: var(--gray-300);
background: radial-gradient(var(--gray-900), var(--gray-800) 150%);
box-shadow: var(--shadow-md);
}
.menu-button[aria-expanded='true'] {
color: var(--gray-0);
background: linear-gradient(180deg, var(--gray-600), transparent),
radial-gradient(var(--gray-900), var(--gray-800) 150%);
}
.menu-button[hidden] {
display: none;
}
.menu-button::before {
position: absolute;
inset: -1px;
content: '';
background: var(--gradient-stroke);
border-radius: 999rem;
z-index: -1;
}
.menu-content {
position: absolute;
left: 0;
right: 0;
}
.nav-items {
margin: 0;
display: flex;
flex-direction: column;
gap: 1rem;
font-size: var(--text-md);
line-height: 1.2;
list-style: none;
padding: 2rem;
background-color: var(--gray-999);
border-bottom: 1px solid var(--gray-800);
}
.link {
display: inline-block;
color: var(--gray-300);
text-decoration: none;
}
.link.active {
color: var(--gray-0);
}
.menu-footer {
--icon-size: var(--text-xl);
--icon-padding: 0.5rem;
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 1.5rem 2rem 1.5rem 1.5rem;
background-color: var(--gray-999);
border-radius: 0 0 0.75rem 0.75rem;
box-shadow: var(--shadow-lg);
}
.socials {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
font-size: var(--icon-size);
}
.social {
display: flex;
padding: var(--icon-padding);
text-decoration: none;
color: var(--accent-dark);
transition: color var(--theme-transition);
}
.social:hover,
.social:focus {
color: var(--accent-text-over);
}
.theme-toggle {
display: flex;
align-items: center;
height: calc(var(--icon-size) + 2 * var(--icon-padding));
}
@media (min-width: 50em) {
nav {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 2.5rem 5rem;
gap: 1rem;
}
.menu-header {
padding: 0;
}
.site-title {
font-size: var(--text-lg);
}
.menu-content {
display: contents;
}
.nav-items {
position: relative;
flex-direction: row;
font-size: var(--text-sm);
border-radius: 999rem;
border: 0;
padding: 0.5rem 0.5625rem;
background: radial-gradient(var(--gray-900), var(--gray-800) 150%);
box-shadow: var(--shadow-md);
}
.nav-items::before {
position: absolute;
inset: -1px;
content: '';
background: var(--gradient-stroke);
border-radius: 999rem;
z-index: -1;
}
.link {
padding: 0.5rem 1rem;
border-radius: 999rem;
transition:
color var(--theme-transition),
background-color var(--theme-transition);
}
.link:hover,
.link:focus {
color: var(--gray-100);
background-color: var(--accent-subtle-overlay);
}
.link.active {
color: var(--accent-text-over);
background-color: var(--accent-regular);
}
.menu-footer {
--icon-padding: 0.375rem;
justify-self: flex-end;
align-items: center;
padding: 0;
background-color: transparent;
box-shadow: none;
}
.socials {
display: none;
}
}
@media (min-width: 60em) {
.socials {
display: flex;
justify-content: flex-end;
gap: 0;
}
}
@media (forced-colors: active) {
.link.active {
color: SelectedItem;
}
}
</style>

16
src/components/Pill.astro Normal file
View File

@@ -0,0 +1,16 @@
<div class="pill"><slot /></div>
<style>
.pill {
display: flex;
padding: 0.5rem 1rem;
gap: 0.5rem;
color: var(--accent-text-over);
border: 1px solid var(--accent-regular);
background-color: var(--accent-regular);
border-radius: 999rem;
font-size: var(--text-md);
line-height: 1.35;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,65 @@
---
import type { Post } from '../../lib/directus';
import { directus_url } from '../../lib/directus';
interface Props {
posts: Post;
}
const post: Post = Astro.props.posts;
---
<a class="card" href={`/projects/${post.slug}`}>
<span class="title">{post.title}</span>
<img src={`${directus_url}/assets/${post.image}?width=500`} alt={post.image_alt || ''} loading="lazy" decoding="async" />
</a>
<style>
.card {
display: grid;
grid-template: auto 1fr / auto 1fr;
height: 11rem;
background: var(--gradient-subtle);
border: 1px solid var(--gray-800);
border-radius: 0.75rem;
overflow: hidden;
box-shadow: var(--shadow-sm);
text-decoration: none;
font-family: var(--font-brand);
font-size: var(--text-lg);
font-weight: 500;
transition: box-shadow var(--theme-transition);
}
.card:hover {
box-shadow: var(--shadow-md);
}
.title {
grid-area: 1 / 1 / 2 / 2;
z-index: 1;
margin: 0.5rem;
padding: 0.5rem 1rem;
background: var(--gray-999);
color: var(--gray-200);
border-radius: 0.375rem;
}
img {
grid-area: 1 / 1 / 3 / 3;
width: 100%;
height: 100%;
object-fit: cover;
}
@media (min-width: 50em) {
.card {
height: 22rem;
border-radius: 1.5rem;
}
.title {
border-radius: 0.9375rem;
}
}
</style>

View File

@@ -0,0 +1,67 @@
---
import Icon from './Icon.astro';
import directus from "../../lib/directus"
import { readSingleton } from "@directus/sdk";
const skills = await directus.request(readSingleton("skills"));
---
<section class="box skills">
<div class="stack gap-2 lg:gap-4">
<Icon icon="cloud" color="var(--accent-regular)" size="2.5rem" gradient />
<h2 set:html={skills.skill_1}/>
<p set:html={skills.skill_1_description}/>
</div>
<div class="stack gap-2 lg:gap-4">
<Icon icon="network" color="var(--accent-regular)" size="2.5rem" gradient />
<h2 set:html={skills.skill_2}/>
<p set:html={skills.skill_2_description}/>
</div>
<div class="stack gap-2 lg:gap-4">
<Icon icon="strategy" color="var(--accent-regular)" size="2.5rem" gradient />
<h2 set:html={skills.skill_3}/>
<p set:html={skills.skill_3_description}/>
</div>
</section>
<style>
.box {
border: 1px solid var(--gray-800);
border-radius: 0.75rem;
padding: 1.5rem;
background-color: var(--gray-999_40);
box-shadow: var(--shadow-sm);
}
.skills {
display: flex;
flex-direction: column;
gap: 3rem;
}
.skills h2 {
font-size: var(--text-lg);
}
.skills p {
color: var(--gray-400);
}
@media (min-width: 50em) {
.box {
border-radius: 1.5rem;
padding: 2.5rem;
}
.skills {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5rem;
}
.skills h2 {
font-size: var(--text-2xl);
}
}
</style>

View File

@@ -0,0 +1,92 @@
---
import Icon from './Icon.astro';
---
<theme-toggle>
<button>
<span class="sr-only">Dark theme</span>
<span class="icon light"><Icon icon="sun" /></span>
<span class="icon dark"><Icon icon="moon-stars" /></span>
</button>
</theme-toggle>
<style>
button {
display: flex;
border: 0;
border-radius: 999rem;
padding: 0;
background-color: var(--gray-999);
box-shadow: inset 0 0 0 1px var(--accent-overlay);
cursor: pointer;
}
.icon {
z-index: 1;
position: relative;
display: flex;
padding: 0.5rem;
width: 2rem;
height: 2rem;
font-size: 1rem;
color: var(--accent-overlay);
}
.icon.light::before {
content: '';
z-index: -1;
position: absolute;
inset: 0;
background-color: var(--accent-regular);
border-radius: 999rem;
}
:global(.theme-dark) .icon.light::before {
transform: translateX(100%);
}
:global(.theme-dark) .icon.dark,
:global(html:not(.theme-dark)) .icon.light,
button[aria-pressed='false'] .icon.light {
color: var(--accent-text-over);
}
@media (prefers-reduced-motion: no-preference) {
.icon,
.icon.light::before {
transition:
transform var(--theme-transition),
color var(--theme-transition);
}
}
@media (forced-colors: active) {
.icon.light::before {
background-color: SelectedItem;
}
}
</style>
<script>
class ThemeToggle extends HTMLElement {
constructor() {
super();
const button = this.querySelector('button')!;
const setTheme = (dark: boolean) => {
document.documentElement.classList[dark ? 'add' : 'remove']('theme-dark');
button.setAttribute('aria-pressed', String(dark));
};
button.addEventListener('click', () => setTheme(!this.isDark()));
setTheme(this.isDark());
}
isDark() {
return document.documentElement.classList.contains('theme-dark');
}
}
customElements.define('theme-toggle', ThemeToggle);
</script>

View File

@@ -1,100 +0,0 @@
---
---
<button
type="button"
class="button-base button-bg-blue group inline-flex items-center rounded-lg p-2.5"
data-bookmark-button="bookmark-button"
>
<svg
class="h-6 w-6 fill-none transition duration-300"
height=24
width=24
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"
class="fill-current text-neutral-500 transition duration-300 group-hover:text-red-400 group-hover:dark:text-red-400"
/>
</svg>
</button>
<script>
class Bookmark {
private static readonly BOOKMARKS_KEY = 'bookmarks';
private bookmarkButton: Element | null;
constructor(private dataAttrValue: string) {
this.bookmarkButton = document.querySelector(`[data-bookmark-button="${dataAttrValue}"]`);
}
private getStoredBookmarks(): string[] {
const item = localStorage.getItem(Bookmark.BOOKMARKS_KEY);
return item ? JSON.parse(item) : [];
}
init(): void {
if (this.bookmarkButton && this.isStored()) {
this.markAsStored();
}
this.bookmarkButton?.addEventListener('click', () => this.toggleBookmark());
}
isStored(): boolean {
return this.getStoredBookmarks().includes(window.location.pathname);
}
markAsStored(): void {
if (this.bookmarkButton) {
this.bookmarkButton.classList.add('bookmarked');
const svgElement = this.bookmarkButton.querySelector('svg');
if (svgElement) {
svgElement.setAttribute('class', 'h-6 w-6 fill-red-500 dark:fill-red-500');
}
const pathElement = svgElement?.querySelector('path');
if (pathElement) {
pathElement.setAttribute('class', 'fill-current text-red-500 dark:text-red-500');
}
}
}
unmarkAsStored(): void {
if (this.bookmarkButton) {
this.bookmarkButton.classList.remove('bookmarked');
const svgElement = this.bookmarkButton.querySelector('svg');
if (svgElement) {
svgElement.setAttribute('class', 'h-6 w-6 fill-none');
}
const pathElement = svgElement?.querySelector('path');
if (pathElement) {
pathElement.setAttribute(
'class',
'fill-current text-neutral-500 group-hover:text-red-400 dark:text-neutral-500 group-hover:dark:text-red-400'
);
}
}
}
toggleBookmark(): void {
const storedBookmarks = this.getStoredBookmarks();
const index = storedBookmarks.indexOf(window.location.pathname);
if (index !== -1) {
storedBookmarks.splice(index, 1);
this.unmarkAsStored();
} else {
storedBookmarks.push(window.location.pathname);
this.markAsStored();
}
localStorage.setItem(Bookmark.BOOKMARKS_KEY, JSON.stringify(storedBookmarks));
}
}
new Bookmark('bookmark-button').init();
</script>

View File

@@ -1,30 +0,0 @@
---
import { Icon } from 'astro-icon/components';
interface Props {
url?: string;
}
const { url } = Astro.props;
---
<a
class="button-base button-bg-gitea group inline-flex rounded-full gap-x-2"
href={url}
target="_blank"
rel="noopener noreferrer"
>
<div class="button-text-title flex relative items-center text-center">
<Icon
name="pajamas:gitea"
class="h-4 w-4 md:h-6 md:w-6"
/>
<span class="ml-2">
Continue to Gitea
</span>
<Icon
name="mdi:keyboard-arrow-right"
class="button-hover-arrow"
/>
</div>
</a>

View File

@@ -1,34 +0,0 @@
---
---
<button
class="button-base button-bg-blue group inline-flex rounded-lg gap-x-2"
id="back-button"
data-astro-prefetch
>
<div class="button-text-title flex relative items-center text-center">
<svg
class=" shrink-0 group-hover:-translate-x-1 transition duration-300 h-4 w-4"
height=24
width=24
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m15 18-6-6 6-6"/>
</svg>
<span class="ml-2">
Go Back
</span>
</div>
</button>
<script>
document.getElementById('back-button')?.addEventListener('click', () => {
window.history.back();
});
</script>

View File

@@ -1,25 +0,0 @@
---
import { Icon } from 'astro-icon/components';
interface Props {
url?: string;
}
const { url } = Astro.props;
---
<a
class="button-base button-bg-teal group inline-flex rounded-lg gap-x-2"
href={url}
data-astro-prefetch
>
<div class="button-text-title flex relative items-center text-center">
<Icon
name="mdi:home-variant-outline"
class="card-hover-icon-scale h-3 w-3 md:h-5 md:w-5"
/>
<span class="ml-2">
Return Home
</span>
</div>
</a>

View File

@@ -1,29 +0,0 @@
---
import { Icon } from 'astro-icon/components';
interface Props {
title?: string;
url?: string;
noArrow?: boolean;
}
const { title, url, noArrow } = Astro.props;
---
<a
class="button-base button-bg-teal group inline-flex rounded-lg gap-x-2"
href={url}
data-astro-prefetch
>
<div class="button-text-title flex relative items-center text-center">
<span class="mr-2">
{title}
</span>
{noArrow ? null : (
<Icon
name="mdi:keyboard-arrow-right"
class="button-hover-arrow"
/>
)}
</div>
</a>

View File

@@ -1,20 +0,0 @@
---
interface Props {
title?: string;
url?: string;
}
const { title, url } = Astro.props;
---
<a
class="button-base button-bg-neutral group inline-flex rounded-lg gap-x-2"
href={url}
data-astro-prefetch
>
<div class="button-text-title flex relative items-center text-center">
<span>
{title}
</span>
</div>
</a>

View File

@@ -1,59 +0,0 @@
---
import Logo from "@components/images/Logo.astro"
type SocialPlatform = {
name: string;
url: string;
iconLight: string;
iconDark: string;
};
interface Props {
pageTitle: string;
}
const { pageTitle } = Astro.props;
const socialPlatforms: SocialPlatform[] = [
{
name: 'Facebook',
url: `https://www.facebook.com/sharer/sharer.php?u=${Astro.url}`,
iconLight: 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/facebook.webp',
iconDark: 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/facebook.webp',
},
{
name: 'Twitter',
url: `https://x.com/intent/tweet?url=${Astro.url}&text=${pageTitle}`,
iconLight: 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/twitter.webp',
iconDark: 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/twitter.webp',
},
{
name: 'LinkedIn',
url: `https://www.linkedin.com/sharing/share-offsite/?url=${Astro.url}`,
iconLight: 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/linkedin.webp',
iconDark: 'https://cdn.jsdelivr.net/gh/selfhst/icons@main/webp/linkedin.webp',
},
];
---
<div class="inline-flex items-center gap-x-2">
{socialPlatforms.map((platform) => (
<a
class="button-base-hidden group inline-flex rounded-lg gap-x-2"
href={platform.url}
target="_blank"
rel="noopener noreferrer"
title={`Share on ${platform.name}`}
>
<div class="button-text-title-hidden flex relative items-center text-center">
<Logo
srcLight={platform.iconLight}
srcDark={platform.iconDark}
alt={platform.name}
width="24"
height="24"
/>
</div>
</a>
))}
</div>

View File

@@ -1,216 +0,0 @@
---
---
<button
id="theme-toggle"
data-theme-toggle
class="group dark:hover:bg-steel/30 hover:bg-yellow-300/20 transition-all duration-300 relative rounded-full p-1.5 sm:p-2 touch-manipulation"
aria-label="Toggle dark mode"
>
<div class="relative flex h-5 w-5 items-center justify-center">
<!-- Sun icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-light absolute h-5 w-5 text-neutral-600 dark:text-neutral-400 scale-100 dark:scale-0 rotate-0 dark:-rotate-90 transition-all duration-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="5"></circle>
<path
d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"
></path>
</svg>
<!-- Moon icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-dark absolute h-5 w-5 text-neutral-600 dark:text-neutral-400 scale-0 dark:scale-100 rotate-90 dark:rotate-0 transition-all duration-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</div>
</button>
<script is:inline>
const applyTheme = () => {
const isDark =
localStorage.theme === 'dark' ||
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', isDark);
};
applyTheme();
document.addEventListener('astro:after-swap', applyTheme);
</script>
<script>
function setupThemeToggle() {
const themeToggles = document.querySelectorAll('[data-theme-toggle]');
// Create theme switch overlay element
if (!document.querySelector('.theme-switch-overlay')) {
const overlay = document.createElement('div');
overlay.className = 'theme-switch-overlay fixed inset-0 pointer-events-none z-50';
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.3s ease-out';
document.body.appendChild(overlay);
}
themeToggles.forEach((toggle) => {
['click', 'touchend'].forEach((eventType) => {
toggle.addEventListener(
eventType,
(e) => {
e.preventDefault();
e.stopPropagation();
// Get click/touch position for radial animation
let x, y;
if (e.type === 'touchend' && e.changedTouches && e.changedTouches[0]) {
const rect = toggle.getBoundingClientRect();
x = e.changedTouches[0].clientX - rect.left;
y = e.changedTouches[0].clientY - rect.top;
} else {
const rect = toggle.getBoundingClientRect();
x = e.clientX - rect.left;
y = e.clientY - rect.top;
}
document.documentElement.style.setProperty('--x', `${x}px`);
document.documentElement.style.setProperty('--y', `${y}px`);
const overlay = document.querySelector('.theme-switch-overlay');
const isDark = document.documentElement.classList.contains('dark');
const newTheme = isDark ? 'light' : 'dark';
// Show overlay during transition
if (overlay) {
overlay.style.backgroundColor =
newTheme === 'dark' ? 'rgba(24, 24, 27, 0.3)' : 'rgba(255, 255, 255, 0.3)';
overlay.style.opacity = '1';
}
document.documentElement.classList.add('theme-switching');
// Force a reflow to ensure all elements update
document.body.offsetHeight;
// Toggle dark mode with a slight delay to allow overlay to appear
setTimeout(() => {
if (isDark) {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
localStorage.setItem('theme', newTheme);
document.dispatchEvent(
new CustomEvent('themeChanged', {
detail: { isDark: newTheme === 'dark' },
})
);
// Force another reflow to ensure all elements update
document.body.offsetHeight;
setTimeout(() => {
if (overlay) {
overlay.style.opacity = '0';
}
document.documentElement.classList.remove('theme-switching');
}, 300);
}, 50);
},
{ passive: false }
);
});
});
}
// Run setup on load
document.addEventListener('astro:page-load', setupThemeToggle);
// Also run on page visibility change to ensure theme is consistent
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
const currentTheme = localStorage.getItem('theme');
if (currentTheme === 'dark') {
document.documentElement.classList.add('dark');
} else if (currentTheme === 'light') {
document.documentElement.classList.remove('dark');
}
}
});
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ({ matches }) => {
if (!localStorage.getItem('theme')) {
if (matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
});
</script>
<style>
/* Subtle hover animation */
#theme-toggle {
transform: translateY(0);
box-shadow: 0 0 0 rgba(0, 0, 0, 0);
-webkit-tap-highlight-color: transparent;
min-height: 32px;
min-width: 32px;
}
@media (hover: hover) {
#theme-toggle:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
:global(:root:not(.dark)) #theme-toggle:hover .icon-light {
filter: drop-shadow(0 0 2px rgba(251, 191, 36, 0.6));
transform: scale(1.1) rotate(15deg);
}
:global(:root.dark) #theme-toggle:hover .icon-dark {
filter: drop-shadow(0 0 2px rgba(129, 140, 248, 0.6));
transform: scale(1.1) rotate(-15deg);
}
}
/* Optimize animations for mobile */
@media (prefers-reduced-motion: reduce) {
.icon-light,
.icon-dark {
transition: all 0.2s ease-out !important;
}
#theme-toggle,
#theme-toggle:hover {
transform: none;
transition: none;
}
}
/* Adjust size for very small screens */
@media (max-width: 320px) {
#theme-toggle {
padding: 0.25rem !important;
}
}
</style>

View File

@@ -1,56 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import { Image } from 'astro:assets';
import type { Post } from '@lib/directusTypes';
import { formatDate } from '@support/time';
import { getDirectusImageURL } from '@/support/url';
interface Props {
post: Post;
}
const { post } = Astro.props;
---
<div class="smooth-reveal-cards group flex flex-col">
<a
class="card-base border-none!"
href={`/blog/${post.slug}/`}
data-astro-prefetch
>
<div class="relative shrink-0 rounded-t-xl w-full overflow-hidden before:absolute before:inset-x-0 before:z-1 before:size-full">
<Image
class="rounded-t-xl h-auto w-full"
src={getDirectusImageURL(post.image)}
alt={post.image_alt}
draggable="false"
loading="eager"
format="webp"
inferSize={true}
/>
</div>
<div class="rounded-xl p-4 md:p-5">
<h3 class="card-text-title text-xl">
{post.title}
</h3>
<div class="ml-6 flex">
<div class="relative inline-block w-full">
<div class="card-text-title card-hover-text-title flex relative items-center mx-auto min-h-11 sm:mx-0 sm:mt-4">
<span class="relative inline-block overflow-hidden ml-2">
Read more
</span>
<Icon
name="mdi:keyboard-arrow-right"
class="translate-y-0.5 transition duration-300 group-hover:translate-x-1"
/>
<p class="card-text-description text-sm ml-auto">
{formatDate(post.published_date)}
</p>
</div>
</div>
</div>
</div>
</a>
</div>

View File

@@ -1,63 +0,0 @@
---
interface Props {
slug: string;
title: string;
description: string;
count: number;
publishDate: string;
}
const { slug, title, description, count, publishDate } = Astro.props;
---
<div class="smooth-reveal-cards group h-full">
<a
class="card-base flex flex-col h-full min-h-55"
href={`/categories/${slug}/`}
data-astro-prefetch
>
<div class="relative grow overflow-hidden">
<div class="absolute inset-1 flex flex-col p-3 md:p-4 lg:p-5">
<div class="overflow-hidden">
<h3 class="card-text-title-major card-hover-text-title whitespace-nowrap mb-4">
{title}
</h3>
<p class="card-text-description mb-4">
{description}
</p>
</div>
<div class="card-text-description flex items-center justify-between text-xs mt-auto pt-1 md:pt-2">
<span class="inline-flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class="mr-1"
>
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"></path>
</svg>
{count}
</span>
<span class="inline-flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class="mr-1"
>
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
{publishDate}
</span>
</div>
</div>
</div>
</a>
</div>

View File

@@ -1,65 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import Logo from '@components/images/Logo.astro';
import { getDirectusImageURL } from '@/support/url';
interface Props {
topic: string;
area: string;
date: string;
url: string;
logoUrlLight?: string;
logoUrlDark?: string;
logoIcon?: string;
}
const { topic, area, date, url, logoUrlLight, logoIcon } = Astro.props;
const logoUrlDark = Astro.props.logoUrlDark || logoUrlLight;
---
<div class="smooth-reveal group flex flex-col">
<a
class="card-base flex items-center"
href={url}
>
<div class="p-4 md:p-10">
<div class="flex items-center">
{logoUrlLight ? (
<div class="card-hover-icon-scale mr-5">
<Logo
srcLight={getDirectusImageURL(logoUrlLight)}
srcDark={getDirectusImageURL(logoUrlDark!)}
alt={`Logo of ${topic}`}
/>
</div>
) : logoIcon ? (
<div class="mr-5 text-header">
<Icon name={logoIcon} class="card-hover-icon-scale h-12 w-12" />
</div>
) : null}
<div class="grow text-left">
<span class="card-text-title block text-lg">
{topic}
</span>
<span class="card-text-description block mt-1 font-medium text-xs uppercase">
{area} - {new Date(date).getFullYear()}
</span>
</div>
</div>
<div class="ml-6 flex">
<div class="relative inline-block">
<div class="card-text-title card-hover-text-title flex relative mx-auto min-h-11 items-center font-semibold text-md sm:mx-0 sm:mt-4">
<span class="relative inline-block overflow-hidden">
Visit Page
</span>
<Icon
name="mdi:keyboard-arrow-right"
class="translate-y-0.5 transition duration-300 group-hover:translate-x-1"
/>
</div>
</div>
</div>
</div>
</a>
</div>

View File

@@ -1,45 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import Logo from "@components/images/Logo.astro"
interface Props {
title?: string;
description?: string;
url?: string;
logoUrlLight?: string;
logoUrlDark?: string;
}
const { title, description, url, logoUrlLight }: Props = Astro.props;
const logoUrlDark = Astro.props.logoUrlDark || logoUrlLight;
---
<div class="smooth-reveal-2 group flex flex-col">
<a
class="card-base flex items-center h-30 w-100 md:w-75"
href={url}
data-astro-prefetch
>
<div class="p-5 w-full">
<div class="flex items-center">
{logoUrlLight && (
<div class="card-hover-icon-scale">
<Logo
srcLight={logoUrlLight}
srcDark={logoUrlDark}
alt={`Logo of ${title}`}
/>
</div>
)}
<div class="ms-5 grow text-left">
<span class="card-text-title card-hover-text-title block text-lg">
{title}
</span>
<p class="card-text-description block mt-1">
{description}
</p>
</div>
</div>
</div>
</a>
</div>

View File

@@ -1,72 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import Logo from '@components/images/Logo.astro';
interface Props {
title?: string;
description?: string;
url?: string;
logoUrlLight?: string;
logoUrlDark?: string;
highlights?: string[];
visitSource?: boolean;
}
const { title, description, url, logoUrlLight, logoUrlDark, highlights, visitSource } = Astro.props;
const visitText = visitSource ? 'Visit Source' : 'Visit Page';
const visitClass = visitSource ? 'card-hover-text-gitea' : 'card-hover-text-title';
---
<div class="smooth-reveal group flex flex-col">
<a
class="card-base flex items-center"
href={url}
>
<div class="p-4 md:p-10">
<div class="flex items-center mb-4">
{logoUrlLight && (
<div class="card-hover-icon-scale mr-5">
<Logo
srcLight={logoUrlLight}
srcDark={logoUrlDark}
alt={`Logo of ${title}`}
/>
</div>
)}
<div class="grow text-left">
<span class="card-text-title block text-lg">
{title}
</span>
<p class="card-text-description block mt-1">
{description}
</p>
</div>
</div>
{highlights && (
<ul class="card-text-description text-sm mt-1 flex flex-col list-disc gap-2 [&>li]:ml-4">
{highlights.map((highlight) => (
<li class="marker:text-accent">
{highlight}
</li>
))}
</ul>
)}
<div class="ml-6 flex">
<div class="relative inline-block">
<div class={`card-text-title ${visitClass} flex relative items-center font-semibold text-md min-h-11 mx-auto sm:mx-0 sm:mt-4`}>
{visitSource && <Icon name="pajamas:gitea" />}
<span class="relative inline-block overflow-hidden ml-2">
{visitText}
</span>
<Icon
name="mdi:keyboard-arrow-right"
class="translate-y-0.5 transition duration-300 group-hover:translate-x-1"
/>
</div>
</div>
</div>
</div>
</a>
</div>

View File

@@ -1,56 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import { Image } from 'astro:assets';
import { getDirectusImageURL } from '@/support/url';
interface Props {
title: string;
subTitle: string;
url: string;
img: string;
imgAlt: string;
}
const { title, subTitle, url, img, imgAlt } = Astro.props;
---
<div class="smooth-reveal flex flex-col px-4 py-10 mx-auto">
<a
class="md:card-base-hidden group items-center md:grid md:grid-cols-2 lg:grid lg:grid-cols-2 gap-8 xl:gap-16 max-w-340 2xl:max-w-full md:px-8 md:py-8"
href={url}
data-astro-prefetch
>
<div>
<Image
class="rounded-2xl rounded-b-none md:rounded-2xl w-full h-full sm:max-h-80 md:max-h-90 object-cover"
src={getDirectusImageURL(img)}
alt={imgAlt}
draggable="false"
loading="lazy"
width="850"
height="420"
inferSize={true}
/>
</div>
<div class="bg-background-card md:bg-transparent group-hover:bg-neutral-100 md:group-hover:bg-transparent dark:group-hover:bg-neutral-800/90 md:dark:group-hover:bg-transparent rounded-b-2xl transition-all duration-300 p-6">
<h2 class="card-text-header mb-2">
{title}
</h2>
<p class="card-text-title font-light text-pretty sm:text-lg max-w-prose mb-8">
{subTitle}
</p>
<div class="button-base button-bg-teal inline-flex rounded-lg gap-x-2">
<div class="button-text-title flex relative items-center text-center">
<span class="mr-2">
Read More
</span>
<Icon
name="mdi:keyboard-arrow-right"
class="button-hover-arrow"
/>
</div>
</div>
</div>
</a>
</div>

View File

@@ -1,86 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import { Image } from 'astro:assets';
import { getDirectusImageURL } from '@/support/url';
interface Props {
title: string;
subTitle: string;
url: string;
single?: boolean;
imgOne: any;
imgOneAlt: any;
imgTwo?: any;
imgTwoAlt?: any;
}
const { title, subTitle, url, single, imgOne, imgOneAlt, imgTwo, imgTwoAlt } = Astro.props;
---
<div class="smooth-reveal flex flex-col px-5 py-10 mx-auto">
<a
class="md:card-base-hidden group flex flex-col-reverse md:items-center md:grid md:grid-cols-2 lg:grid lg:grid-cols-2 md:gap-8 xl:gap-16 max-w-340 2xl:max-w-full md:px-8 md:py-8"
href={url}
data-astro-prefetch
>
<div class="bg-background-card md:bg-transparent group-hover:bg-neutral-100 md:group-hover:bg-transparent dark:group-hover:bg-neutral-800/90 md:dark:group-hover:bg-transparent rounded-b-2xl transition-all duration-300 p-6">
<h2 class="card-text-header mb-2">
{title}
</h2>
<p class="card-text-title font-light text-pretty sm:text-lg max-w-prose mb-8">
{subTitle}
</p>
<div class="button-base button-bg-teal inline-flex rounded-lg gap-x-2">
<div class="button-text-title flex relative items-center text-center">
<span class="mr-2">
Read More
</span>
<Icon
name="mdi:keyboard-arrow-right"
class="button-hover-arrow"
/>
</div>
</div>
</div>
{single ? (
<div>
<Image
class="rounded-2xl rounded-b-none md:rounded-2xl w-full"
src={getDirectusImageURL(imgOne)}
alt={imgOneAlt}
format="webp"
loading="lazy"
width="850"
height="420"
inferSize={true}
/>
</div>
) : (
<div class="grid grid-cols-2 gap-4">
<Image
class="rounded-xl w-full"
src={getDirectusImageURL(imgOne)}
alt={imgOneAlt}
draggable="false"
format="webp"
loading="lazy"
width="400"
height="230"
inferSize={true}
/>
<Image
class="rounded-xl w-full mt-4 lg:mt-10"
src={getDirectusImageURL(imgTwo)}
alt={imgTwoAlt}
draggable="false"
format="webp"
loading="lazy"
width="400"
height="230"
inferSize={true}
/>
</div>
)}
</a>
</div>

View File

@@ -1,35 +0,0 @@
---
interface Props {
dayName: string;
label: string;
icon: string;
temp: number;
}
const { dayName, label, icon, temp } = Astro.props;
---
<div class="smooth-reveal-2 group flex flex-col">
<div class="card-base w-32 md:w-40">
<div class="p-5 text-center">
<span class="card-text-description block font-bold text-xs uppercase tracking-widest">
{dayName}
</span>
<div class="flex justify-center my-2">
<img
src={`https://openweathermap.org/img/wn/${icon}@2x.png`}
alt={label}
class="card-hover-icon-scale h-12 w-12"
/>
</div>
<div class="mt-2">
<span class="card-text-title card-hover-text-title block text-2xl">
{temp}°F
</span>
<span class="card-text-description mt-1 block text-xs capitalize">
{label}
</span>
</div>
</div>
</div>
</div>

View File

@@ -1,18 +0,0 @@
---
import { Image } from 'astro:assets';
import { readSingleton } from '@directus/sdk';
import logo from '@images/brand_logo.png';
import directus from '@lib/directus';
const global = await directus.request(readSingleton('site_global'));
---
<Image
src={logo}
alt={global.name}
draggable="false"
loading="eager"
inferSize={true}
{...Astro.props}
/>

View File

@@ -1,46 +0,0 @@
---
import { Image } from 'astro:assets';
const { srcLight, srcDark, alt, style, width, height } = Astro.props;
---
<div class="themed-image-container">
<Image
src={srcLight}
alt={alt}
class={`light-logo ${style}`}
inferSize={true}
width={width}
height={height}
inferSize={true}
/>
<Image
src={srcDark}
alt={alt}
class={`dark-logo ${style}`}
inferSize={true}
width={width}
height={height}
inferSize={true}
/>
</div>
<style>
.themed-image-container {
display: grid;
grid-template-areas: "stack";
}
.themed-image-container :global(img) {
grid-area: stack;
}
:global(.dark) .light-logo {
display: none !important;
}
:global(.dark) .dark-logo {
display: block !important;
}
</style>

View File

@@ -1,22 +0,0 @@
---
import ImageTheme from '@components/images/ImageTheme.astro';
const {
srcLight,
srcDark,
alt,
width = 48,
height = 48,
} = Astro.props;
---
<ImageTheme
srcLight={srcLight}
srcDark={srcDark}
alt={alt}
style=`color: transparent; width: ${width}px; height: ${height}px; object-fit: contain; max-height: 100%; max-width: 100%;`
draggable="false"
loading="lazy"
width={width}
height={height}
/>

View File

@@ -1,30 +0,0 @@
---
import { readItems } from '@directus/sdk';
import type { Application } from '@lib/directusTypes';
import HighlightsCard from '@components/cards/HighlightsCard.astro';
import directus from '@lib/directus';
const applications = ((await directus.request(
readItems('site_applications' as any, {
fields: ['*'],
sort: ['-isActive'],
})
)) as unknown) as Application[];
---
<section class:list={['mx-auto max-w-7xl px-4 py-10 sm:px-6 lg:px-8 lg:py-14', Astro.props.className]}>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:gap-8 print:flex print:flex-col">
{applications.map((application: Application) => (
<HighlightsCard
title={application.name}
description={application.description}
url={application.url}
logoUrlLight={application.logoUrl}
logoUrlDark={application.logoUrl}
highlights={application.highlights}
/>
))}
</div>
</section>

View File

@@ -1,93 +0,0 @@
---
import { getCollection } from 'astro:content';
import { readItems } from '@directus/sdk';
import type { Post } from '@lib/directusTypes';
import CategoryCard from '@components/cards/CategoryCard.astro';
import directus from '@lib/directus';
import { timeago } from '@support/time';
const posts = await directus.request(
readItems('posts', {
filter: { published: { _eq: true } },
fields: ['*'],
sort: ['-published_date'],
})
);
const layoutPattern = [
{ col: 2, row: 2 },
{ col: 2, row: 1 },
{ col: 1, row: 1 },
{ col: 1, row: 1 },
{ col: 1, row: 2 },
{ col: 2, row: 1 },
{ col: 1, row: 1 },
{ col: 1, row: 1 },
{ col: 1, row: 1 },
{ col: 1, row: 1 },
];
const postMap: Map<string, Post[]> = posts
.sort((a: Post, b: Post) => b.published_date.valueOf() - a.published_date.valueOf())
.reduce((acc, obj) => {
let posts = acc.get(obj.category);
if (!posts) {
posts = [];
}
posts.push(obj);
acc.set(obj.category, posts);
return acc;
}, new Map<string, Post[]>());
const categories = (await getCollection('categories'))
.sort((a, b) => {
const aCount = postMap.get(a.slug)?.length ?? 0;
const bCount = postMap.get(b.slug)?.length ?? 0;
return bCount - aCount;
})
.map((c, index) => {
const posts = postMap.get(c.slug);
const pattern = layoutPattern[index % layoutPattern.length];
const smColSpan = Math.min(pattern.col, 2);
const mdColSpan = Math.min(pattern.col, 4);
const rowSpan = pattern.row;
const rowSpanClass = rowSpan > 1 ? `row-span-${rowSpan}` : 'row-span-1';
const gridItemClass = `col-span-${smColSpan} md:col-span-${mdColSpan} ${rowSpanClass}`;
return {
...c,
posts,
gridItemClass,
layoutPattern: {
smCol: smColSpan,
mdCol: mdColSpan,
row: rowSpan,
index,
},
};
});
---
<section class="mx-auto px-4 py-10 sm:px-6 lg:px-8 lg:py-14 lg:pt-10 2xl:max-w-full">
<div class="grid grid-flow-row-dense grid-cols-2 md:grid-cols-4 gap-4">
{categories.map((category) => {
return (
<div
class={category.gridItemClass}
style={category.layoutPattern.row > 1 ? 'grid-row: span 2 / span 2;' : ''}
>
<CategoryCard
slug={category.slug}
title={category.data.title}
description={category.data.description}
count={postMap.get(category.slug)?.length ?? 0}
publishDate={timeago(postMap.get(category.slug)?.[0]?.published_date)}
/>
</div>
);
})}
</div>
</section>

View File

@@ -1,65 +0,0 @@
---
import { readItems } from '@directus/sdk';
import type { Education, Certificate} from '@lib/directusTypes';
import EducationCard from '@components/cards/EducationCard.astro';
import directus from '@lib/directus';
const educations = ((await directus.request(
readItems('site_education' as any, {
fields: ['*'],
sort: ['-graduationDate'],
})
)) as unknown) as Education[];
const certificates = ((await directus.request(
readItems('site_certificate' as any, {
fields: ['*'],
sort: ['-issuerDate'],
})
)) as unknown) as Certificate[];
---
<section class:list={['flex flex-col gap-4', Astro.props.className]}>
<h3 class="smooth-reveal card-text-header flex relative items-center w-full gap-3 pb-5">
Education
</h3>
<div class="mx-8">
<h4 class="smooth-reveal card-text-header-minor pt-5">
College
</h4>
<div class="grid md:grid-cols-2 sm:grid-cols-1 gap-4 py-3">
{educations.map((education: Education) => (
<EducationCard
topic={education.institution}
area={education.area}
date={education.graduationDate}
url={education.url}
logoUrlLight={education.logo}
logoUrlDark={education.logoDark}
/>
))}
</div>
</div>
{certificates.length > 0 && (
<div class="mx-8">
<h4 class="smooth-reveal card-text-header-minor pt-8">
Certificates
</h4>
<div class="grid md:grid-cols-2 sm:grid-cols-1 gap-4 py-3">
{certificates.map((certificate: Certificate) => (
<EducationCard
topic={certificate.name}
area={certificate.issuer}
date={certificate.issuerDate}
url={certificate.url}
logoUrlLight={certificate.logo}
logoUrlDark={certificate.logoDark}
/>
))}
</div>
</div>
)}
</section>

View File

@@ -1,159 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import { readItems } from '@directus/sdk';
import type { Experience } from '@lib/directusTypes';
import directus from '@lib/directus';
const experiences = ((await directus.request(
readItems('site_experience'as any, {
fields: ['*'],
sort: ['-endDate'],
})
)) as unknown) as Experience[];
---
<section class:list={['flex flex-col gap-8', Astro.props.className]}>
<h3 class="smooth-reveal card-text-header flex relative items-center w-full gap-3 pb-10">
Experience
</h3>
<ul class="flex flex-col w-full ml-8 pr-8">
{experiences.map((experience: Experience) => {
const startYear = new Date(experience.startDate).getFullYear();
const endYear = experience.endDate != null ? new Date(experience.endDate).getFullYear() : 'Present';
return (
<li class="relative">
<div class="smooth-reveal group relative grid sm:grid-cols-18 sm:gap-8 md:gap-6 pb-16">
<header class="relative sm:col-span-3 text-header font-semibold text-lg mt-1">
<time datetime={experience.startDate} data-title={experience.startDate}>
{startYear}
</time>
{' '}-{' '}
<time datetime={experience.endDate} data-title={experience.endDate}>
{endYear}
</time>
</header>
<div class="relative flex flex-col sm:col-span-12 pb-6">
<div class="absolute bg-accent -translate-x-[1.71rem] rounded-full h-2 w-2 mt-3"/>
<h3>
<div
class="inline-flex items-center text-2xl leading-tight font-semibold"
aria-label="{position} - {company}"
>
<span class="text-header">
{experience.position} <span>@</span>
{experience.url ? (
<a
class="hover:text-main"
href={experience.url}
title={`Ver ${experience.name}`}
target="_blank"
>
{experience.name}
</a>
) : (
<span>{experience.name}</span>
)}
</span>
</div>
</h3>
{(experience.location || experience.location_type) && (
<div class="text-secondary text-sm">
{experience.location} {experience.location && experience.location_type && '-'} {experience.location_type}
</div>
)}
<div class="text-md mt-4 flex flex-col gap-4" x-data="{ expanded: false }">
{experience.summary && (
<div class="flex flex-col gap-1">
<h4 class="text-header font-semibold">
Summary:
</h4>
<ul class="flex flex-col text-primary list-disc gap-2 [&>li]:ml-4">
<li class="marker:text-main">
{experience.summary}
</li>
</ul>
</div>
)}
{(experience.responsibilities || experience.achievements) && (
<div class="relative flex flex-col gap-4" :class="expanded ? '' : 'mask-[linear-gradient(to_bottom,black_50%,transparent)]'" x-show="expanded" x-collapse.min.50px>
{experience.responsibilities && (
<div class="flex flex-col gap-1">
<h4 class="text-header font-semibold">
Responsibilities:
</h4>
<ul class="flex flex-col text-primary list-disc gap-2 [&>li]:ml-4">
{experience.responsibilities.map(responsibility => (
<li class="marker:text-main">
{responsibility}
</li>
))}
</ul>
</div>
)}
{experience.achievements && (
<div class="flex flex-col gap-1">
<h4 class="text-header font-semibold">
Achievements:
</h4>
<ul class="flex flex-col text-primary list-disc gap-2 [&>li]:ml-4">
{experience.achievements.map(achievement => (
<li class="marker:text-main">
{achievement}
</li>
))}
</ul>
</div>
)}
</div>
<button @click="expanded = ! expanded" class="group/more flex items-center justify-center text-primary hover:text-primary-hover text-xs underline transition-all gap-1.5 w-fit cursor-pointer">
<span x-text="expanded ? 'Show less' : 'Show more'">
Show more
</span>
<svg
class="group-hover/more:translate-y-0.5 ease-out duration-300 h-4 w-4"
:class="{ 'rotate-180': expanded }"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
<ul
class="flex print:hidden flex-wrap gap-2 mt-2"
aria-label="Technologies used"
>
{experience.skills && experience.skills.map(skill => {
const iconName = skill.toLowerCase();
const skillName = skill.split(':')[1].replace(/^language-/, '').replace(/-/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
return (
<li class="flex items-center bg-steel/20 dark:bg-bermuda/20 text-neutral-800 dark:text-neutral-200 text-xs rounded-md border border-solid border-steel/20 dark:border-bermuda/20 gap-1 px-2 py-0.5">
<Icon name={`${iconName}`} class="h-4 w-4" /> <span>{skillName}</span>
</li>
)
})}
</ul>
)}
</div>
</div>
</div>
</li>
);
})}
</ul>
</section>
<!-- Alpine Plugins -->
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
<!-- Alpine Core -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>

View File

@@ -1,41 +0,0 @@
---
import { readSingleton } from '@directus/sdk';
import FeaturesCard from '@components/cards/FeaturesCard.astro';
import directus from '@lib/directus';
const global = await directus.request(readSingleton('site_global'));
---
<section class="max-w-340 2xl:max-w-full px-4 sm:px-6 lg:px-8 py-10 lg:py-14 mx-auto mb-2 md:mb-8">
<div class="flex flex-col sm:flex-row items-center justify-center gap-y-2 sm:gap-x-12 sm:gap-y-0 lg:gap-x-24">
<div class="max-w-5xl sm:px-6 lg:px-8">
<div class="flex flex-wrap gap-6 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3 justify-center">
<FeaturesCard
title="Cloud Engineer"
description="Full stack and cloud engineer."
url="/about"
logoUrlLight="https://img.icons8.com/cotton/64/cloud-development--v2.png"
/>
<FeaturesCard
title="Homelab"
description="Tinkering, testing, deploying, etc, etc ..."
url="/categories/homelab/"
logoUrlLight="https://img.icons8.com/cotton/64/smart-home-connection.png"
/>
<FeaturesCard
title="Documentation"
description="Reference and guides for my homelab."
url="https://docs.alexlebens.dev"
logoUrlLight="https://img.icons8.com/cotton/64/bookmarked-document--v1.png"
/>
<FeaturesCard
title="Email"
description={`Send me a message.`}
url=`mailto:${global.email}`
logoUrlLight="https://img.icons8.com/cotton/64/secured-letter--v3.png"
/>
</div>
</div>
</div>
</section>

View File

@@ -1,155 +0,0 @@
---
import GiteaButton from '@components/buttons/GiteaButton.astro';
interface Props {
title: string;
subTitle?: string;
url?: string;
}
const { title, subTitle, url } = Astro.props;
---
<section class="relative max-w-340 pt-30 pb-30 px-4 sm:px-6 lg:px- mx-auto mb-2 md:mb-10">
<!-- Animated shapes -->
<div class="smooth-reveal absolute top-[55%] left-0 scale-90 md:top-[20%] xl:top-[25%] xl:left-[10%]">
<svg
class="gitea-animate-hover gitea-animate-hover-1"
width="64"
height="64"
fill="none"
stroke-width="1.5"
color="#ea580c"
viewBox="0 0 24 24"
>
<path
fill="#ea580c"
stroke="#ea580c"
stroke-linecap="round"
stroke-linejoin="round"
d="M12 23a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM3 8a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM3 18a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
></path>
<path
stroke="#ea580c"
stroke-linecap="round"
stroke-linejoin="round"
d="M21 7.353v9.294a.6.6 0 0 1-.309.525l-8.4 4.666a.6.6 0 0 1-.582 0l-8.4-4.666A.6.6 0 0 1 3 16.647V7.353a.6.6 0 0 1 .309-.524l8.4-4.667a.6.6 0 0 1 .582 0l8.4 4.667a.6.6 0 0 1 .309.524Z"
></path>
<path
stroke="#ea580c"
stroke-linecap="round"
stroke-linejoin="round"
d="m3.528 7.294 8.18 4.544a.6.6 0 0 0 .583 0l8.209-4.56M12 21v-9"></path>
</svg>
</div>
<div class="smooth-reveal absolute top-0 left-[85%] scale-75">
<svg
class="gitea-animate-hover gitea-animate-hover-2"
width="64"
height="64"
fill="none"
stroke-width="1.5"
color="#fbbf24"
viewBox="0 0 24 24"
>
<path
stroke="#fbbf24"
stroke-linecap="round"
stroke-linejoin="round"
d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10Z"></path>
<path
fill="#fbbf24"
stroke="#fbbf24"
stroke-linecap="round"
stroke-linejoin="round"
d="M5 6a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path>
<path stroke="#fbbf24" stroke-linecap="round" stroke-linejoin="round" d="M5 10.5V9M5 15v-1.5"
></path>
<path
fill="#fbbf24"
stroke="#fbbf24"
stroke-linecap="round"
stroke-linejoin="round"
d="M5 20a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM19 20a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"></path>
<path
stroke="#fbbf24"
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 19H9M15 19h-1.5"></path>
</svg>
</div>
<div class="smooth-reveal absolute bottom-[5%] left-[60%] scale-[.6] xl:bottom-[15%] xl:left-[35%]">
<svg
class="gitea-animate-hover gitea-animate-hover-3"
width="64"
height="64"
fill="none"
stroke-width="1.5"
color="#a3a3a3"
viewBox="0 0 24 24"
>
<path
stroke="#a3a3a3"
stroke-linecap="round"
stroke-linejoin="round"
d="M5.164 17c.29-1.049.67-2.052 1.132-3M11.5 7.794A16.838 16.838 0 0 1 14 6.296M4.5 22a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5Z"
></path>
<path
stroke="#a3a3a3"
stroke-linecap="round"
stroke-linejoin="round"
d="M9.5 12a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5ZM19.5 7a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5Z"
></path>
</svg>
</div>
<!-- Heading -->
<div class="smooth-reveal-2 mx-auto mt-5 max-w-xl text-center">
<h1 class="card-text-header block">
{title}
</h1>
</div>
<!-- Sub-heading -->
<div class="smooth-reveal-2 mx-auto mt-5 max-w-3xl text-center">
{subTitle && (
<p class="card-text-header-description">
{subTitle}
</p>
)}
</div>
<!-- Gitea Button -->
{url && (
<div class="smooth-reveal-2 flex justify-center mt-8 gap-3">
<GiteaButton url={url}/>
</div>
)}
</section>
<style>
@keyframes gitea-animate-hover {
from {
transform: translateY(15px);
}
to {
transform: translateY(-15px);
}
}
.gitea-animate-hover {
animation: gitea-animate-hover ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
}
.gitea-animate-hover-1 {
animation-duration: 5s;
}
.gitea-animate-hover-2 {
animation-duration: 5.5s;
}
.gitea-animate-hover-3 {
animation-duration: 6s;
}
</style>

View File

@@ -1,31 +0,0 @@
---
import GoLinkPrimaryButton from '@components/buttons/GoLinkPrimaryButton.astro';
interface Props {
title: string;
subTitle: string;
btnExists?: boolean;
btnTitle?: string;
btnURL?: string;
}
const { title, subTitle, btnExists, btnTitle, btnURL } = Astro.props;
---
<section class="mx-auto mt-10 px-4 sm:px-6 lg:px-8 lg:pt-10 2xl:max-w-full">
<div class="flex-wrap md:flex md:items-center md:justify-between">
<div class="w-full md:w-auto">
<h1 class="smooth-reveal card-text-header block lg:text-6xl">
{title}
</h1>
<p class="smooth-reveal card-text-header-description mt-4">
{subTitle}
</p>
{btnExists ? (
<div class="smooth-reveal mt-4 md:mt-8">
<GoLinkPrimaryButton title={btnTitle} url={btnURL}/>
</div>
) : null}
</div>
</div>
</section>

View File

@@ -1,57 +0,0 @@
---
import { Image } from 'astro:assets';
import GoLinkPrimaryButton from '@components/buttons/GoLinkPrimaryButton.astro';
import GoLinkSecondaryButton from '@components/buttons/GoLinkSecondaryButton.astro';
interface Props {
title: string;
subTitle?: string;
primaryBtn?: string;
primaryBtnURL?: string;
secondaryBtn?: string;
secondaryBtnURL?: string;
src?: any;
alt?: string;
rounded?: boolean;
}
const { title, subTitle, primaryBtn, primaryBtnURL, secondaryBtn, secondaryBtnURL, src, alt } = Astro.props;
const roundedClasses = Astro.props.rounded ? "rounded-2xl" : null;
---
<section class="mx-auto grid max-w-340 gap-4 px-4 py-14 sm:px-6 md:grid-cols-2 md:items-center md:gap-8 lg:px-8 2xl:max-w-full">
<div>
<h1 class="smooth-reveal card-text-header block lg:text-7xl">
<Fragment set:html={title} />
</h1>
{subTitle && (
<p class="smooth-reveal card-text-header-description lg:w-4/5 mt-6">
{subTitle}
</p>
)}
<div class="smooth-reveal grid sm:inline-flex mt-7 w-full gap-3">
{primaryBtn && <GoLinkPrimaryButton title={primaryBtn} url={primaryBtnURL} />}
{secondaryBtn && <GoLinkSecondaryButton title={secondaryBtn} url={secondaryBtnURL} />}
</div>
</div>
<div class="smooth-reveal-fade md:block w-full hidden">
<div class="flex justify-center w-full top-12 md:ml-4 overflow-hidden">
{src && alt && (
<Image
src={src}
alt={alt}
class={`h-full w-105 scale-100 object-cover object-center ${roundedClasses}`}
draggable="false"
loading="eager"
format="webp"
quality="low"
widths={[840]}
inferSize={true}
/>
)}
</div>
</div>
</section>

View File

@@ -1,32 +0,0 @@
---
import { readItems } from '@directus/sdk';
import type { Project } from '@lib/directusTypes';
import HighlightsCard from '@components/cards/HighlightsCard.astro';
import directus from '@lib/directus';
const projects = ((await directus.request(
readItems('site_projects' as any, {
fields: ['*'],
sort: ['-isActive'],
})
)) as unknown) as Project[];
---
<section class:list={['flex flex-col gap-y-8', Astro.props.className]}>
<h3 class="smooth-reveal card-text-header flex relative items-center w-full gap-3 pb-5">
Projects
</h3>
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:gap-8 print:flex print:flex-col">
{projects.map((project: Project) => (
<HighlightsCard
title={project.name}
description={project.description}
url={project.source}
highlights={project.highlights}
visitSource={true}
/>
))}
</div>
</section>

View File

@@ -1,29 +0,0 @@
---
import type { Post } from '@lib/directusTypes';
import BlogCard from '@components/cards/BlogCard.astro';
interface Props {
posts: Post[];
title: string;
subTitle?: string;
}
const { posts, title, subTitle } = Astro.props;
---
<section class="max-w-340 2xl:max-w-full px-4 sm:px-6 lg:px-8 py-10 lg:py-14 mx-auto mb-2 md:mb-8">
<div class="text-center max-w-2xl mx-auto mb-10 lg:mb-14">
<h1 class="smooth-reveal card-text-header block">
{title}
</h1>
<div class="smooth-reveal mx-auto mt-5 max-w-3xl text-center">
<span class="card-text-header-description">
{subTitle}
</span>
</div>
</div>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((b) => <BlogCard post={b} />)}
</div>
</section>

View File

@@ -1,35 +0,0 @@
---
import type { Post } from '@lib/directusTypes';
import LargeBlogLeftCard from '@components/cards/LargeBlogLeftCard.astro';
import LargeBlogRightCard from '@components/cards/LargeBlogRightCard.astro';
interface Props {
posts: Post[];
}
const { posts } = Astro.props;
---
<section class="smooth-reveal flex flex-col gap-4">
{posts.map((post, index) => index % 2 === 0 ? (
<LargeBlogLeftCard
title={post.title}
subTitle={post.description}
url={`/blog/${post.slug}`}
img={post.image}
imgAlt={post.image_alt}
/>
) : (
<LargeBlogRightCard
title={post.title}
subTitle={post.description}
url={`/blog/${post.slug}`}
single={!post.image_second}
imgOne={post.image}
imgOneAlt={post.image_alt}
imgTwo={post?.image_second}
imgTwoAlt={post?.image_second_alt}
/>
))}
</section>

View File

@@ -1,223 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import { readItems } from '@directus/sdk';
import type { Skill } from '@lib/directusTypes';
import directus from '@lib/directus';
const skills = ((await directus.request(
readItems('site_skills' as any, {
fields: ['*'],
sort: ['-date_created'],
})
)) as unknown) as Skill[];
---
<section class:list={['flex flex-col gap-4', Astro.props.className]}>
<h3 class="smooth-reveal card-text-header flex relative items-center w-full gap-3 pb-5">
Skills
</h3>
<div>
<div class="tech-stack-slider relative overflow-hidden py-4 sm:py-8 mask-[linear-gradient(to_right,transparent,black_10%,black_90%,transparent)]">
<!-- Main slider container -->
<div class="slider-track animate-slide flex">
{[...skills, ...skills, ...skills].map((skill: Skill) => {
return (
<div class="skill-card card-base transform hover:-translate-y-2 hover:scale-105 transition-all duration-300 mx-2 min-w-55 sm:mx-4 sm:min-w-70">
<div class="p-4 sm:p-6">
<div class="flex items-center justify-between mb-4 sm:mb-6">
<div class="flex items-center gap-2 sm:gap-4">
<div class="flex items-center justify-center rounded-lg text-primary">
<Icon name={skill.icon} class="h-8 w-8 sm:h-12 sm:w-12" />
</div>
<h3 class="text-neutral-900 dark:text-neutral-100 text-base font-semibold sm:text-xl">
{skill.title}
</h3>
</div>
<span class=" bg-neutral-200 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 font-mono text-xs sm:text-sm rounded-full px-2 sm:px-2.5 py-0.5 sm:py-1">
{skill.level}%
</span>
</div>
<div class="relative bg-stone-500/20 dark:bg-stone-500/20 rounded-full h-1.5 sm:h-2 w-full overflow-hidden">
<div
class="progress-bar-animate bg-linear-to-r from-steel via-bermuda to-steel absolute top-0 left-0 h-full rounded-full transition-all duration-1000"
style={`width: ${skill.level}%`}
/>
</div>
<div class="flex justify-between text-secondary font-mono text-[10px] mt-1 sm:mt-2 sm:text-xs">
<span>Beginner</span>
<span>Advanced</span>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
</section>
<script>
document.addEventListener('astro:page-load', () => {
function setupInfiniteScroll() {
const cards = document.querySelectorAll('.skill-card');
if (!cards.length) return;
}
setupInfiniteScroll();
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
const cards = document.querySelectorAll('.skill-card');
if (!isTouchDevice) {
cards.forEach((card) => {
card.addEventListener('mousemove', (e) => {
const rect = card.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const centerX = rect.width / 2;
const centerY = rect.height / 2;
const angleX = (y - centerY) / 15;
const angleY = (centerX - x) / 15;
card.style.transform = `perspective(1000px) rotateX(${angleX}deg) rotateY(${angleY}deg) scale(1.08) translateZ(20px)`;
// Dynamic shadow based on tilt
const shadowX = (x - centerX) / 25;
const shadowY = (y - centerY) / 25;
card.style.boxShadow = `
${shadowX}px ${shadowY}px 20px rgba(0, 0, 0, 0.1),
0 10px 20px rgba(0, 0, 0, 0.05)
`;
});
card.addEventListener('mouseleave', () => {
card.style.transform = '';
card.style.boxShadow = '';
});
});
} else {
// Simpler effects for touch devices
cards.forEach((card) => {
card.addEventListener('touchstart', () => {
card.classList.add('is-touched');
});
card.addEventListener('touchend', () => {
setTimeout(() => {
card.classList.remove('is-touched');
}, 300);
});
});
}
});
</script>
<style>
/* Specific css to enable sliding effect */
.slider-track {
width: fit-content;
animation: scroll 40s linear infinite;
}
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-220px * 6 - 16px * 6));
}
}
@media (min-width: 640px) {
.slider-track {
animation: scroll 80s linear infinite;
}
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-280px * 6 - 32px * 6));
}
}
}
.tech-stack-slider:hover .slider-track {
animation-play-state: paused;
}
.skill-card {
transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
position: relative;
overflow: hidden;
}
.skill-card:hover {
z-index: 10;
}
/* Reduce animation complexity on mobile */
@media (max-width: 640px) {
.skill-card {
transition:
transform 0.3s ease,
box-shadow 0.3s ease;
}
.skill-card:hover {
transform: translateY(-5px) !important;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1) !important;
}
}
.skill-card:before {
content: '';
position: absolute;
top: -10%;
left: -10%;
width: 120%;
height: 120%;
background: radial-gradient(
circle at center,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0) 70%
);
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: none;
}
.skill-card:hover:before {
opacity: 1;
}
.progress-bar-animate {
position: relative;
overflow: hidden;
}
.progress-bar-animate:after {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
animation: progress-shine 2s infinite;
}
@keyframes progress-shine {
0% {
left: -100%;
}
100% {
left: 100%;
}
}
</style>

View File

@@ -1,37 +0,0 @@
---
import WeatherCard from '@components/cards/WeatherCard.astro';
import { getFiveDayForecast } from '@support/weather';
const { latitude = "44.95", longitude = "-93.09", cityName = "St. Paul, Minnesota", timezone = "America/Chicago" } = Astro.props;
const { forecastDays, error } = await getFiveDayForecast(latitude, longitude, timezone);
---
<section class="max-w-340 2xl:max-w-fullpx-4 sm:px-6 lg:px-8 py-10 lg:py-14 mx-auto mb-2 md:mb-8">
<div class="text-center max-w-2xl mx-auto mb-10 lg:mb-14">
<h1 class="smooth-reveal card-text-header block">
Weather in my Area
</h1>
<div class="smooth-reveal mx-auto mt-5 max-w-3xl text-center">
<span class="card-text-header-description">
Five day forecast for {cityName}
</span>
</div>
</div>
{error ? (
<div class="card-base p-10 text-accent text-center">
{error}
</div>
) : (
<div class="flex flex-wrap justify-center gap-4 lg:gap-6">
{forecastDays.map((forecastDay) => (
<WeatherCard
dayName={forecastDay.dayName}
label={forecastDay.label}
icon={forecastDay.icon}
temp={forecastDay.temp}
/>
))}
</div>
)}
</section>

View File

@@ -1,44 +0,0 @@
import { readSingleton } from '@directus/sdk';
import directus from '@lib/directus';
export interface NavigationLink {
name: string;
url: string;
}
const global = await directus.request(readSingleton('site_global'));
export const NavigationLinks: NavigationLink[] = [
{ name: 'Home', url: '/' },
{ name: 'Blog', url: '/blog/' },
{ name: 'Categories', url: '/categories/' },
{ name: 'Apps', url: '/apps/' },
{ name: 'About Me', url: '/about/' },
];
export const FooterLinks: NavigationLink[] = [
{ name: 'RSS', url: '/rss.xml' },
{ name: 'Gitea', url: 'https://gitea.alexlebens.dev' },
{ name: 'Docs', url: 'https://docs.alexlebens.dev' },
];
export const SEO = {
title: global.name,
description: global.about,
structuredData: {
'@context': 'https://schema.org',
'@type': 'WebPage',
inLanguage: 'en-US',
'@id': global.site_url,
url: global.site_url,
name: global.name,
description: global.about,
isPartOf: {
'@type': 'WebSite',
url: global.site_url,
name: global.name,
description: global.about,
},
},
};

View File

@@ -1,4 +0,0 @@
---
title: 'Books 📖'
description: 'Books I have read or listened to'
---

View File

@@ -1,4 +0,0 @@
---
title: 'Cloud ☁️'
description: "Its just someone else's server"
---

View File

@@ -1,4 +0,0 @@
---
title: 'Homelab 🏠'
description: 'What happens when rack servers find a home'
---

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