Compare commits

..

1 Commits

Author SHA1 Message Date
e13bdde349 Update dependency tailwindcss to v4 2025-06-08 21:50:40 +00:00
113 changed files with 7119 additions and 14132 deletions

View File

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

View File

@@ -0,0 +1,67 @@
name: release-image-gitea
on:
push:
tags:
- 0.*
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- 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
- name: Available Platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REPOSITORY_HOST }}
username: ${{ gitea.actor }}
password: ${{ secrets.REPOSITORY_TOKEN }}
- name: Extract Metadata
id: meta
uses: docker/metadata-action@v5
with:
tags: |
type=ref,event=branch
type=ref,event=tag
images: ${{ vars.REPOSITORY_HOST }}/${{ gitea.repository }}
- 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 }}
file: ./Dockerfile
- name: Actions Ntfy
run: |
curl \
-H "Authorization: Bearer ${{ secrets.NTFY_CRED }}" \
-H "Title: Site-Profile Image Released to Gitea: ${{ steps.meta.outputs.tags }}" \
-H "Content-Type: text/plain" \
-d $'Repo: ${{ gitea.repository }}\nCommit: ${{ gitea.sha }}\nRef: ${{ gitea.ref }}\nStatus: ${{ job.status}}' \
${{ secrets.NTFY_URL }}

View File

@@ -0,0 +1,67 @@
name: release-image-harbor
on:
push:
tags:
- 0.*
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- 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
- name: Available Platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_HOST }}
username: ${{ vars.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_SECRET }}
- name: Extract Metadata
id: meta
uses: docker/metadata-action@v5
with:
tags: |
type=ref,event=branch
type=ref,event=tag
images: ${{ vars.REGISTRY_HOST }}/images/site-profile
- 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 }}
file: ./Dockerfile
- name: Actions Ntfy
run: |
curl \
-H "Authorization: Bearer ${{ secrets.NTFY_CRED }}" \
-H "Title: Site-Profile Image Released to Gitea: ${{ steps.meta.outputs.tags }}" \
-H "Content-Type: text/plain" \
-d $'Repo: ${{ gitea.repository }}\nCommit: ${{ gitea.sha }}\nRef: ${{ gitea.ref }}\nStatus: ${{ job.status}}' \
${{ secrets.NTFY_URL }}

View File

@@ -1,98 +0,0 @@
name: release-image
on:
push:
tags:
- 2.*
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REPOSITORY_HOST }}
username: ${{ gitea.actor }}
password: ${{ secrets.REPOSITORY_TOKEN }}
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_HOST }}
username: ${{ vars.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_SECRET }}
- 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
images: |
${{ vars.REPOSITORY_HOST }}/${{ gitea.repository }}
${{ vars.REGISTRY_HOST }}/images/site-profile
- 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 }}
file: ./Dockerfile
- name: ntfy Success
uses: niniyas/ntfy-action@master
if: success()
with:
url: '${{ secrets.NTFY_URL }}'
topic: '${{ secrets.NTFY_TOPIC }}'
title: 'Gitea Action'
priority: 3
headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}'
tags: action,successfully,completed
details: 'Site Profile build workflow has successfully completed!'
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: 'Gitea Action'
priority: 4
headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}'
tags: action,failed
details: 'Site Profile build workflow has failed!'
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

@@ -13,20 +13,18 @@ on:
jobs:
renovate:
runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:41
container: ghcr.io/renovatebot/renovate:40
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Renovate
run: renovate
- uses: actions/checkout@v4
- run: renovate
env:
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: ${{ vars.INSTANCE_URL }}
RENOVATE_REPOSITORIES: alexlebens/site-profile
RENOVATE_AUTODISCOVER: true
RENOVATE_ONBOARDING: true
RENOVATE_ENDPOINT: http://gitea-http.gitea:3000
RENOVATE_GIT_AUTHOR: Renovate Bot <renovate-bot@alexlebens.net>
LOG_LEVEL: info
LOG_LEVEL: debug
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_GIT_PRIVATE_KEY: ${{ secrets.RENOVATE_GIT_PRIVATE_KEY }}
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}
RENOVATE_REDIS_URL: ${{ vars.RENOVATE_REDIS_URL }}
RENOVATE_REDIS_URL: redis://gitea-renovate-valkey-primary.gitea:6379

View File

@@ -1,37 +0,0 @@
name: test-build
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 10.x
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22.18.0
cache: pnpm
- name: Install Dependencies
run: pnpm install
- name: Lint Code
run: pnpm lint
- name: Build Project
run: pnpm build

4
.gitignore vendored
View File

@@ -12,9 +12,10 @@ yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.local
.env.development
.env.production
# macOS-specific files
@@ -23,3 +24,4 @@ pnpm-debug.log*
# ide
.vscode/
site-profile.code-workspace
.pre-commit-config.yaml

2
.npmrc
View File

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

View File

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

17
.prettierrc Normal file
View File

@@ -0,0 +1,17 @@
{
"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"
}
}
]
}

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,8 +1,7 @@
ARG REGISTRY=docker.io
FROM ${REGISTRY}/node:22.18.0-alpine3.22 AS base
FROM node:22.15.1-alpine3.20 AS base
LABEL version="2.0.5"
LABEL description="Astro based personal website"
LABEL version="0.7.0"
LABEL description="Astro based website to use as a personal site"
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
@@ -21,7 +20,6 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
FROM build-deps AS build
COPY . .
RUN pnpm run build
RUN pnpm prune --prod
FROM base AS runtime
COPY --from=prod-deps /app/node_modules /app/node_modules
@@ -31,6 +29,5 @@ ENV HOST=0.0.0.0
ENV SITE_URL=https://www.alexlebens.dev
ENV DIRECTUS_URL=https://directus.alexlebens.dev
ENV PORT=4321
EXPOSE $PORT
CMD ["node", "./dist/server/entry.mjs"]
CMD node ./dist/server/entry.mjs

View File

@@ -1,6 +1,6 @@
MIT License
# MIT License
Copyright (c) 2025 Alex Lebens
Copyright (c) 2025 Lê Vĩnh Khang
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,31 +1,78 @@
# This is an open-source and simple blog built with Astro.
# Alex Lebens Personal Site
Personal site used for information about myself and blog.
## Features
- 🐈 Simple And Beautiful
- 🖥️️ Responsive And Light/Dark mode
- 🐛 SiteMap & RSS Feed
- 🐝 Category Support
- 🐜 SEO and Responsiveness
- 🪲 Markdown And MDX
- 🏂🏾 Page Compression & Image Optimization
- 🚀 **Maximum Performance** - Built with Astro.js for lightning-fast static sites
- 🎨 **Minimalist Design** - Clean UI that focuses on content
- 🌓 **Light/Dark Mode** - Smooth theme switching
- 📱 **Responsive** - Perfect experience on all devices
- **SPA Transitions** - Smooth page navigation with transition effects
- 📝 **Markdown & MDX** - Write posts with Markdown and extend with MDX
- 🔍 **SEO Optimized** - Meta tags, Open Graph, and Twitter Cards
- 📊 **Analytics** - Reading time, views, and statistics
- 🔖 **Categorization** - Tags and categories system
- 🔄 **RSS Feed** - Automatically generated RSS feed
- 🌐 **Internationalization Ready** - Prepared for multiple languages
- 🔒 **Secure** - No unnecessary client-side JavaScript
### Development Commands
## Getting Started
With dependencies installed, you can utilize the following npm scripts to manage your project's development lifecycle:
### Requirements
- `pnpm run dev`: Starts a local development server with hot reloading enabled.
- `pnpm run preview`: Serves your build output locally for preview before deployment.
- `pnpm run build`: Bundles your site into static files for production.
- Node.js 16+ and pnpm/yarn
For detailed help with Astro CLI commands, visit [Astro's documentation](https://docs.astro.build/en/reference/cli-reference/).
### Installation
## Thanks
```bash
# Clone repository
git clone https://gitea.alexlebens.dev/alexlebens/site-profile
Thanks https://github.com/mearashadowfax/ScrewFast, https://github.com/godruoyi/gblog/tree/gblog-template
# Navigate to project directory
cd astro-blog
## License
# Install dependencies
pnpm install
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.
# Create .env file from template
cp .env.example .env
# Edit .env with your information
```
### Development
```bash
# Start development server
pnpm run dev
# Open browser at http://localhost:4321
```
### Build
```bash
# Create production build
pnpm run build
# Preview production build
pnpm run preview
```
## Project Structure
```
/
├── public/ # Static assets
├── src/
│ ├── components/ # Reusable UI components
│ ├── content/ # Blog content (Markdown/MDX)
│ ├── layouts/ # Page layouts
│ ├── pages/ # Pages and routes
│ ├── styles/ # CSS and Tailwind
│ └── utils/ # Utilities and helpers
├── astro.config.mjs # Astro configuration
├── tailwind.config.js # Tailwind configuration
└── tsconfig.json # TypeScript configuration
```

View File

@@ -1,16 +1,6 @@
import { defineConfig, passthroughImageService, sharpImageService } from 'astro/config';
import mdx from '@astrojs/mdx';
import node from '@astrojs/node';
import partytown from '@astrojs/partytown';
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
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 rehypePrettyCode from 'rehype-pretty-code';
import { transformerCopyButton } from '@rehype-pretty/transformers';
const getSiteURL = () => {
if (process.env.SITE_URL) {
@@ -21,92 +11,5 @@ const getSiteURL = () => {
export default defineConfig({
site: getSiteURL(),
image: {
service: {
entrypoint: 'astro/assets/services/sharp',
}
},
prefetch: true,
integrations: [
mdx(),
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,
rehypePlugins: [
[
rehypePrettyCode,
{
theme: {
light: 'github-light',
dark: 'github-dark-dimmed',
},
keepBackground: false,
transformers: [
transformerCopyButton({
visibility: 'always',
feedbackDuration: 2500,
}),
],
},
],
],
},
plugins: {
'@tailwindcss/postcss': {},
},
vite: {
plugins: [tailwindcss()],
},
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' : '',
},
integrations: [tailwind(), react()],
});

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: {
}
}
];

58
lib/directus.ts Normal file
View File

@@ -0,0 +1,58 @@
import { createDirectus, rest } from '@directus/sdk';
type Global = {
title: string;
description: string;
name: string;
initals: string;
tagline: string;
email: string;
portrait: string;
portrait_alt: string;
about: string;
};
type About = {
background: string;
experience: string;
education: string;
certifications: string;
};
type Links = {
github: string;
linkedin: string;
};
type Skill = {
title: string;
description: string;
icon: string;
level: string;
};
export type Post = {
slug: string;
title: string;
description: string;
content: string;
image: string;
image_alt: string;
published_date: Date;
updated_date: Date;
tags: string[];
};
type Schema = {
global: Global;
about: About;
links: Links;
skills: Skill[];
posts: Post[];
};
const directus = createDirectus<Schema>(
process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'
).with(rest());
export default directus;

View File

@@ -1,84 +1,37 @@
{
"name": "site-profile",
"type": "module",
"version": "2.0.5",
"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.7.0",
"private": true,
"scripts": {
"dev": "astro dev",
"build": "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}\""
"format": "prettier . --write",
"astro": "astro"
},
"dependencies": {
"@astrojs/check": "^0.9.4",
"@astrojs/mdx": "^4.3.3",
"@astrojs/node": "^9.3.3",
"@astrojs/partytown": "^2.1.4",
"@astrojs/mdx": "^4.3.0",
"@astrojs/node": "^9.2.2",
"@astrojs/react": "^4.3.0",
"@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.4.2",
"@giscus/react": "^3.1.0",
"@iconify-json/mdi": "^1.1.63",
"@iconify-json/pajamas": "^1.2.13",
"@iconify-json/simple-icons": "^1.2.47",
"@playform/compress": "^0.2.0",
"@rehype-pretty/transformers": "^0.13.2",
"@swup/astro": "1.7.0",
"@tailwindcss/postcss": "^4.1.8",
"@tailwindcss/vite": "^4.1.8",
"@directus/sdk": "^20.0.0",
"@types/react": "^19.0.0",
"@types/unist": "^3.0.2",
"astro": "^5.12.8",
"astro-compressor": "^1.0.0",
"astro-icon": "^1.1.5",
"@astrojs/sitemap": "^3.4.1",
"@astrojs/tailwind": "^6.0.2",
"@directus/sdk": "^19.1.0",
"astro": "^5.9.1",
"framer-motion": "^12.16.0",
"mdast-util-to-string": "^4.0.0",
"preline": "^3.1.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hotkeys-hook": "^5.1.0",
"react-icons": "^5.5.0",
"reading-time": "^1.5.0",
"rehype-pretty-code": "^0.14.1",
"sharp": "^0.34.3",
"sharp-ico": "^0.1.5",
"shiki": "^3.2.2",
"tailwindcss": "^4.1.11",
"ultrahtml": "^1.5.3"
"sanitize-html": "^2.17.0",
"tailwindcss": "^4.0.0"
},
"devDependencies": {
"@eslint-react/eslint-plugin": "^1.52.3",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"astro-icon": "^1.1.5",
"eslint": "^9.32.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-astro": "^1.3.1",
"eslint-plugin-format": "^1.0.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"prettier": "^3.5.3",
"prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.14",
"timeago.js": "^4.0.2",
"typescript": "5.9.2",
"typescript-eslint": "8.39.1"
"prettier-plugin-astro": "^0.14.0",
"prettier-plugin-tailwindcss": "^0.6.0"
}
}

9903
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,7 +1,13 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
tailwindcss: {},
autoprefixer: {},
'postcss-preset-env': {
features: {
'nesting-rules': false,
},
},
},
};

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;

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 713 B

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 +1,10 @@
{
"$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"
}
]
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", "mergeConfidence:all-badges", ":rebaseStalePrs"],
"timezone": "US/Central",
"schedule": ["* */1 * * *"],
"labels": [],
"prHourlyLimit": 0,
"prConcurrentLimit": 0,
"packageRules": []
}

View File

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

View File

@@ -0,0 +1,109 @@
---
// Background.astro - Dot pattern and ambient glow background with smooth theme transitions
---
<div class="theme-transition-all fixed inset-0 -z-10 overflow-hidden">
<!-- Dot pattern background -->
<div
class="bg-grid-pattern theme-transition-bg absolute inset-0 bg-[center_top_-1px] [mask-image:radial-gradient(white,transparent_85%)]"
>
</div>
<!-- Ambient glow effects -->
<div
class="animate-glow theme-transition-bg absolute left-1/4 top-1/4 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-zinc-400/20 opacity-50 blur-3xl dark:bg-zinc-500/20"
>
</div>
<div
class="animate-glow animation-delay-1000 theme-transition-bg absolute bottom-1/3 right-1/4 h-64 w-64 translate-x-1/2 translate-y-1/2 rounded-full bg-zinc-300/20 opacity-40 blur-3xl dark:bg-zinc-600/20"
>
</div>
<!-- Theme transition overlay -->
<div
id="theme-transition-overlay"
class="pointer-events-none absolute inset-0 bg-white opacity-0 dark:bg-zinc-900"
>
</div>
</div>
<script>
// Theme transition script
document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.querySelector('[data-theme-toggle]');
const overlay = document.getElementById('theme-transition-overlay');
if (themeToggle && overlay) {
themeToggle.addEventListener('click', () => {
// Add transitioning class to optimize performance
document.documentElement.classList.add('theme-transitioning');
// Fade in overlay
overlay.style.opacity = '0.15';
overlay.style.transition = 'opacity 0.3s ease';
setTimeout(() => {
// Fade out overlay
overlay.style.opacity = '0';
// Remove transitioning class after animation completes
setTimeout(() => {
document.documentElement.classList.remove('theme-transitioning');
}, 700);
}, 300);
});
}
});
</script>
<style>
/* Grid pattern for dots */
.bg-grid-pattern {
background-size: 24px 24px;
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.15) 1px, transparent 1px);
transition: background-image 0.7s cubic-bezier(0.65, 0, 0.35, 1);
}
/* Dark mode version */
:global(.dark) .bg-grid-pattern {
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
}
/* Ambient glow animations */
.animate-glow {
animation: glow 12s cubic-bezier(0.4, 0, 0.6, 1) infinite;
transition:
background-color 0.7s cubic-bezier(0.65, 0, 0.35, 1),
opacity 0.7s cubic-bezier(0.65, 0, 0.35, 1);
}
.animation-delay-1000 {
animation-delay: 1s;
}
@keyframes glow {
0%,
100% {
opacity: 0.4;
transform: translate(0, 0) scale(1);
}
25% {
opacity: 0.5;
transform: translate(5%, 5%) scale(1.1);
}
50% {
opacity: 0.3;
transform: translate(0, 10%) scale(0.95);
}
75% {
opacity: 0.5;
transform: translate(-5%, 5%) scale(1.05);
}
}
/* Theme transition overlay */
#theme-transition-overlay {
transition: opacity 0.3s ease;
z-index: 10;
}
</style>

View File

@@ -1,95 +0,0 @@
---
import { getImage } from 'astro:assets';
import { readSingleton } from '@directus/sdk';
import directus from '@lib/directus';
import brandSrc from '@images/brand_logo.png';
import faviconSvgSrc from '@images/favicon_icon.svg';
import faviconSrc from '@images/favicon_icon.png';
import { SEO } from '@/config';
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;
let card = 'summary_large_image';
if (!ogImage) {
ogImage = brandSrc;
card = 'summary';
}
const global = await directus.request(readSingleton('site_global'));
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" />
<!-- 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:type" content="website" />
<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" />
<!-- Twitter -->
<meta property="twitter:card" content={card} />
<meta property="twitter:url" content={Astro.url} />
<meta property="twitter:domain" content={Astro.url} />
<meta property="twitter:title" content={ogTitle} />
<meta property="twitter:description" content={ogDescription} />
<meta property="twitter:image" content={socialImage} />
<!-- Links -->
<link href={canonicalURL} rel="canonical" />
<link rel="sitemap" href="/sitemap-index.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

@@ -1,144 +1,244 @@
---
import directus from '../../lib/directus';
import { readSingleton } from '@directus/sdk';
import directus from '@lib/directus';
import BrandLogo from '@components/ui/logos/BrandLogo.astro';
import Image from '@components/ui/images/Image.astro';
import { NavigationLinks, FooterLinks } from '@/config';
import footerImg from '@images/flowers.png';
const global = await directus.request(readSingleton('global'));
const links = await directus.request(readSingleton('links'));
const global = await directus.request(readSingleton('site_global'));
const currentYear = new Date().getFullYear();
const navLinks = [
{ text: 'About', href: '/about' },
{ text: 'Blog', href: '/blog' },
{ text: 'Topics', href: '/topics' },
{ text: 'RSS', href: '/rss.xml' },
];
const socialLinks = [
{
name: 'GitHub',
href: links.github,
icon: `<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path>`,
},
{
name: 'LinkedIn',
href: links.linkedin,
icon: `<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"></path>`,
},
];
---
<footer
class="w-full overflow-hidden bg-stone-300/40 dark:bg-stone-800/20"
transition:animate="none"
class="theme-transition-all relative mt-20 overflow-hidden border-t border-zinc-100 dark:border-zinc-800"
>
<div class="relative px-4 pt-16 pb-12 sm:px-6">
<div class="mx-auto max-w-[85rem]">
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<div
class="theme-transition-all animate-float-slow absolute -right-40 -top-40 h-80 w-80 rounded-full bg-zinc-100 opacity-50 blur-3xl dark:bg-zinc-800/30"
>
</div>
<div
class="theme-transition-all animate-float-slow animation-delay-2000 absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-zinc-100 opacity-50 blur-3xl dark:bg-zinc-800/30"
>
</div>
<div
class="theme-transition-all animate-float-slow animation-delay-1000 absolute left-1/4 top-20 h-40 w-40 rounded-full bg-zinc-200/50 opacity-30 blur-2xl dark:bg-zinc-700/20"
>
</div>
</div>
<div class="relative px-4 pb-12 pt-16 sm:px-6">
<div class="mx-auto max-w-4xl">
<!-- Main footer content -->
<div class="grid grid-cols-1 gap-10 md:grid-cols-12">
<!-- 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 rounded-lg">
<BrandLogo class="max-h-[40px] max-w-[40px] rounded-full" />
<div
class="relative flex h-10 w-10 transform items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-zinc-800 to-zinc-600 shadow-lg transition-transform group-hover:scale-105 dark:from-zinc-200 dark:to-zinc-400"
>
<span
class="theme-transition-all text-xl font-bold text-white transition-transform duration-300 group-hover:scale-110 dark:text-zinc-900"
>{global.initals}</span
>
<div
class="absolute inset-0 bg-gradient-to-br from-zinc-700 to-zinc-900 opacity-0 transition-opacity duration-300 group-hover:opacity-100 dark:from-zinc-300 dark:to-zinc-100"
>
</div>
</div>
<span class="ml-3 text-xl font-bold text-neutral-800 dark:text-neutral-200">
{global.name}
</span>
<span
class="theme-transition-color ml-3 text-xl font-bold text-zinc-900 dark:text-zinc-100"
>Blog</span
>
</div>
</a>
<p class="mt-4 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400">
{global.about}
</p>
</div>
<!-- Left links -->
<div class="col-span-1 md:col-span-2">
<h3
class="after:bg-steel dark:after:bg-bermuda relative inline-block pb-2 text-sm font-semibold tracking-wider text-neutral-800 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-8 after:content-[''] dark:text-neutral-100"
<p
class="theme-transition-color mt-4 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400"
>
Blog
</h3>
<ul class="mt-4 space-y-3">
{
NavigationLinks.map((link) => (
<li>
<a
href={link.url}
class="group flex items-center text-base text-neutral-600 transition-colors hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-200"
>
<span class="relative inline-block overflow-hidden">
<span class="relative z-10">{link.name}</span>
</span>
</a>
</li>
))
}
</ul>
</div>
<!-- Right links -->
<div class="col-span-1 md:col-span-3">
<h3
class="after:bg-steel dark:after:bg-bermuda relative inline-block pb-2 text-sm font-semibold tracking-wider text-neutral-800 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-8 after:content-[''] dark:text-neutral-100"
>
Other
</h3>
<ul class="mt-4 space-y-3">
{
FooterLinks.map((link) => (
<li>
<a
href={link.url}
class="group flex items-center text-base text-neutral-600 transition-colors hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-200"
>
<span class="relative inline-block overflow-hidden">
<span class="relative z-10">{link.name}</span>
</span>
</a>
</li>
))
}
</ul>
</div>
<!-- Right image -->
<div class="col-span-3 mt-10 flex justify-center md:mt-0">
<div class="-mt-10 hidden max-h-[460px] max-w-[220px] scale-80 md:block">
<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]}
disableBlur={true}
/>
</div>
</div>
</div>
<!-- Bottom section -->
<div class="mt-12 border-t border-neutral-400/30 pt-8 dark:border-neutral-600/50">
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
<p class="text-sm text-neutral-600 dark:text-neutral-400">
&copy; {currentYear} All rights reserved.
{global.description}
</p>
<div class="flex items-center space-x-2">
<span class="text-xs text-neutral-500 dark:text-neutral-400">Built with </span>
<a
href="https://astro.build"
target="_blank"
rel="noopener noreferrer"
class="group inline-flex items-center text-xs text-neutral-600 transition-colors hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
>
<svg class="mr-1 h-4 w-4 text-[#FF5D01]" viewBox="0 0 36 36" fill="none">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.833 22.958c.622-1.185 1.832-1.918 3.18-1.918 2.292 0 4.145 1.86 4.145 4.153 0 1.34-.626 2.54-1.601 3.303 1.223-1.299 1.97-3.048 1.97-4.971 0-3.994-3.243-7.233-7.242-7.233-2.818 0-5.26 1.6-6.469 3.933.78-2.912 3.428-5.06 6.577-5.06 3.75 0 6.79 3.035 6.79 6.78 0 2.606-1.468 4.868-3.616 6.002a4.163 4.163 0 0 0 2.285-3.724c0-2.293-1.853-4.153-4.145-4.153-1.348 0-2.558.733-3.18 1.918l1.306-3.03Z"
fill="currentColor"></path>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M22.155 12.056c-.622 1.185-1.832 1.918-3.18 1.918-2.292 0-4.145-1.86-4.145-4.153 0-1.34.626-2.54 1.601-3.303-1.223 1.299-1.97 3.048-1.97 4.971 0 3.994 3.243 7.233 7.242 7.233 2.818 0 5.26-1.6 6.469-3.933-.78 2.912-3.428 5.06-6.577 5.06-3.75 0-6.79-3.035-6.79-6.78 0-2.606 1.468-4.868 3.616-6.002a4.163 4.163 0 0 0-2.285 3.724c0 2.293 1.853 4.153 4.145 4.153 1.348 0 2.558-.733 3.18-1.918l-1.306 3.03Z"
fill="currentColor"></path>
</svg>
<span class="relative">
Astro
<span
class="absolute bottom-0 left-0 h-0.5 w-0 bg-[#FF5D01] transition-all duration-300 group-hover:w-full"
<!-- Social links -->
<div class="mt-6 flex items-center space-x-4">
{
socialLinks.map((social) => (
<a
href={social.href}
target="_blank"
rel="noopener noreferrer"
class="group relative flex h-10 w-10 transform items-center justify-center rounded-full bg-zinc-100 text-zinc-500 transition-all duration-300 hover:-translate-y-1 hover:text-zinc-900 hover:ring-2 hover:ring-zinc-300 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100 dark:hover:ring-zinc-700"
aria-label={social.name}
>
<span class="absolute inset-0 rounded-full bg-gradient-to-br from-zinc-200 to-zinc-300 opacity-0 transition-opacity duration-300 group-hover:opacity-100 dark:from-zinc-700 dark:to-zinc-600" />
<svg
class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<Fragment set:html={social.icon} />
</svg>
</a>
))
}
</div>
</div>
<!-- Quick links -->
<div class="col-span-1 md:col-span-3">
<h3
class="theme-transition-color relative inline-block pb-2 text-sm font-semibold uppercase tracking-wider text-zinc-900 after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-8 after:bg-zinc-300 after:content-[''] dark:text-zinc-100 dark:after:bg-zinc-700"
>
Navigation
</h3>
<ul class="mt-4 space-y-3">
{
navLinks.map((link) => (
<li>
<a
href={link.href}
class="group flex items-center text-base text-zinc-600 transition-colors hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
<span class="relative inline-block overflow-hidden">
<span class="relative z-10">{link.text}</span>
<span class="absolute bottom-0 left-0 h-0.5 w-0 bg-zinc-800 transition-all duration-300 group-hover:w-full dark:bg-zinc-200" />
</span>
</a>
</li>
))
}
</ul>
</div>
<!-- Bottom section -->
<div class="theme-transition-all mt-12 border-t border-zinc-200 pt-8 dark:border-zinc-800">
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
<p class="theme-transition-color text-sm text-zinc-600 dark:text-zinc-400">
&copy; {currentYear} All rights reserved.
</p>
<div class="flex items-center space-x-2">
<span class="theme-transition-color text-xs text-zinc-500 dark:text-zinc-400"
>Built with</span
>
<a
href="https://astro.build"
target="_blank"
rel="noopener noreferrer"
class="group inline-flex items-center text-xs text-zinc-600 transition-colors hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
<svg
class="mr-1 h-4 w-4 text-[#FF5D01] group-hover:animate-pulse"
viewBox="0 0 36 36"
fill="none"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.833 22.958c.622-1.185 1.832-1.918 3.18-1.918 2.292 0 4.145 1.86 4.145 4.153 0 1.34-.626 2.54-1.601 3.303 1.223-1.299 1.97-3.048 1.97-4.971 0-3.994-3.243-7.233-7.242-7.233-2.818 0-5.26 1.6-6.469 3.933.78-2.912 3.428-5.06 6.577-5.06 3.75 0 6.79 3.035 6.79 6.78 0 2.606-1.468 4.868-3.616 6.002a4.163 4.163 0 0 0 2.285-3.724c0-2.293-1.853-4.153-4.145-4.153-1.348 0-2.558.733-3.18 1.918l1.306-3.03Z"
fill="currentColor"></path>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M22.155 12.056c-.622 1.185-1.832 1.918-3.18 1.918-2.292 0-4.145-1.86-4.145-4.153 0-1.34.626-2.54 1.601-3.303-1.223 1.299-1.97 3.048-1.97 4.971 0 3.994 3.243 7.233 7.242 7.233 2.818 0 5.26-1.6 6.469-3.933-.78 2.912-3.428 5.06-6.577 5.06-3.75 0-6.79-3.035-6.79-6.78 0-2.606 1.468-4.868 3.616-6.002a4.163 4.163 0 0 0-2.285 3.724c0 2.293 1.853 4.153 4.145 4.153 1.348 0 2.558-.733 3.18-1.918l-1.306 3.03Z"
fill="currentColor"></path>
</svg>
<span class="relative">
Astro
<span
class="absolute bottom-0 left-0 h-0.5 w-0 bg-[#FF5D01] transition-all duration-300 group-hover:w-full"
></span>
</span>
</span>
</a>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.theme-transition-all {
transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
.theme-transition-color {
transition-property: color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
.theme-transition-bg {
transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.2);
}
}
@keyframes float-slow {
0%,
100% {
transform: translateY(0) translateX(0);
}
25% {
transform: translateY(-10px) translateX(10px);
}
50% {
transform: translateY(-5px) translateX(-5px);
}
75% {
transform: translateY(10px) translateX(5px);
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.animate-float-slow {
animation: float-slow 20s ease-in-out infinite;
}
.animation-delay-1000 {
animation-delay: 1s;
}
.animation-delay-2000 {
animation-delay: 2s;
}
</style>
</footer>

View File

@@ -0,0 +1,21 @@
---
export interface Props {
date?: Date | string;
}
const { date } = Astro.props;
const parsedDate = typeof date === 'string' ? new Date(date) : date;
---
{
parsedDate && (
<time datetime={parsedDate.toISOString()}>
{parsedDate.toLocaleDateString('en-us', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
)
}

View File

@@ -1,100 +0,0 @@
---
import BrandLogo from '@components/ui/logos/BrandLogo.astro';
import ThemeToggle from '@components/ui/buttons/ThemeToggle.astro';
import { NavigationLinks } from '@/config';
const pathname = new URL(Astro.request.url).pathname;
const currentPath = pathname.slice(1);
---
<header
id="nav"
class="sticky inset-x-0 top-4 z-50 flex w-full flex-wrap text-sm transition-none md:flex-nowrap md:justify-start"
>
<nav
class="relative mx-2 w-full rounded-[36px] border border-neutral-100 bg-neutral-100 px-4 py-3 md:flex md:items-center md:justify-between md:px-6 lg:px-8 dark:border-neutral-700/40 dark:bg-neutral-800/80"
aria-label="Global"
>
<div class="flex items-center justify-between">
<a
class="h-[42px] flex-none rounded-lg text-xl font-bold ring-neutral-500 outline-none focus-visible:ring dark:ring-neutral-200 dark:focus:outline-none"
href="/"
aria-label="Brand"
>
<BrandLogo class="h-full w-auto rounded-full object-cover" />
</a>
<div class="ml-auto md:hidden">
<button
type="button"
class="hs-collapse-toggle flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold text-neutral-600 transition duration-300 hover:bg-neutral-200 disabled:pointer-events-none disabled:opacity-50 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:focus:outline-none"
data-hs-collapse="#navbar-collapse-with-animation"
aria-controls="navbar-collapse-with-animation"
aria-label="Toggle navigation"
>
<svg
class="hs-collapse-open:hidden h-[1.25rem] w-[1.25rem] flex-shrink-0"
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 hidden h-[1.25rem] w-[1.25rem] flex-shrink-0"
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>
<div
id="navbar-collapse-with-animation"
class="hs-collapse hidden grow basis-full overflow-hidden transition-all duration-300 md:block"
>
<div
class="mt-5 flex flex-col gap-x-0 gap-y-4 md:mt-0 md:flex-row md:items-center md:justify-end md:gap-x-4 md:gap-y-0 md:ps-7 lg:gap-x-7"
>
{
NavigationLinks.map((item) => {
const isActive = currentPath === (item.url === '/' ? '' : item.url.slice(1));
return (
<a
href={item.url}
class={`text-sm font-medium ${
isActive
? 'text-orange-500 dark:text-orange-300'
: 'text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100'
}`}
>
{item.name}
</a>
);
})
}
<span class="md:inline-block">
<ThemeToggle />
</span>
</div>
</div>
</nav>
</header>
<script is:inline src="/vendor/preline/collapse2.1.0.min.js"></script>

View File

@@ -0,0 +1,245 @@
---
import ThemeToggle from './ThemeToggle.astro';
import directus from '../../lib/directus';
import { readSingleton } from '@directus/sdk';
const global = await directus.request(readSingleton('global'));
const navItems = [
{ text: 'Home', href: '/' },
{ text: 'Blog', href: '/blog' },
{ text: 'Topics', href: '/topics' },
{ text: 'About', href: '/about' },
{ text: 'RSS', href: 'rss.xml' },
];
const pathname = new URL(Astro.request.url).pathname;
const currentPath = pathname.slice(1);
---
<header
class="fixed left-0 right-0 top-0 z-40 border-b border-zinc-100 bg-white py-4 dark:border-zinc-800 dark:bg-zinc-900"
>
<div class="mx-auto flex max-w-3xl items-center justify-between px-4">
<!-- Logo -->
<a href="/" class="text-xl font-bold text-zinc-900 dark:text-white">{global.initals}</a>
<!-- Desktop navigation -->
<nav class="hidden items-center space-x-6 sm:flex">
{
navItems.map((item) => {
const isActive = currentPath === (item.href === '/' ? '' : item.href.slice(1));
return (
<a
href={item.href}
class={`text-sm font-medium ${
isActive
? 'text-zinc-900 dark:text-white'
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white'
}`}
>
{item.text}
</a>
);
})
}
<ThemeToggle />
</nav>
<!-- Mobile menu button -->
<button id="mobile-menu-button" class="flex items-center sm:hidden" aria-label="Menu">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6 text-zinc-900 dark:text-white"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"></path>
</svg>
</button>
</div>
</header>
<!-- Mobile menu overlay -->
<div
id="mobile-menu"
class="pointer-events-none fixed inset-0 z-50 flex flex-col bg-white opacity-0 transition-all duration-300 ease-in-out dark:bg-zinc-900"
>
<div class="flex items-center justify-between border-b border-zinc-100 p-4 dark:border-zinc-800">
<a href="/" class="text-xl font-bold text-zinc-900 dark:text-white">JD</a>
<button
id="close-menu-button"
class="rounded-md p-2 text-zinc-900 transition-colors hover:bg-zinc-100 dark:text-white dark:hover:bg-zinc-800"
aria-label="Close menu"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<nav class="flex flex-1 flex-col items-center justify-center space-y-6 text-center">
{
navItems.map((item, index) => {
const isActive = currentPath === (item.href === '/' ? '' : item.href.slice(1));
return (
<a
href={item.href}
class={`mobile-nav-item translate-y-4 text-lg font-medium opacity-0 ${
isActive
? 'text-zinc-900 dark:text-white'
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white'
}`}
style={`transition-delay: ${index * 0.05}s;`}
>
{item.text}
</a>
);
})
}
<div class="mobile-nav-item translate-y-4 pt-4 opacity-0" style="transition-delay: 0.25s;">
<ThemeToggle />
</div>
</nav>
</div>
<!-- Spacer to prevent content from hiding behind fixed header -->
<div class="h-16"></div>
<script>
// Mobile menu toggle with animations
document.addEventListener('DOMContentLoaded', () => {
const mobileMenuButton = document.getElementById('mobile-menu-button');
const closeMenuButton = document.getElementById('close-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
const navItems = document.querySelectorAll('.mobile-nav-item');
// Open menu with animations
mobileMenuButton?.addEventListener('click', () => {
if (!mobileMenu) return;
// Prevent body scrolling
document.body.style.overflow = 'hidden';
// Show menu with fade in
mobileMenu.classList.remove('pointer-events-none');
mobileMenu.classList.add('pointer-events-auto');
// Animate opacity
setTimeout(() => {
mobileMenu.style.opacity = '1';
// Animate each nav item with staggered delay
navItems.forEach((item) => {
setTimeout(() => {
item.classList.remove('opacity-0', 'translate-y-4');
}, 150);
});
}, 50);
});
// Close menu with animations
const closeMenu = () => {
if (!mobileMenu) return;
// Fade out nav items first
navItems.forEach((item) => {
item.classList.add('opacity-0', 'translate-y-4');
});
// Then fade out the menu
setTimeout(() => {
mobileMenu.style.opacity = '0';
// After animation completes, hide menu and restore scrolling
setTimeout(() => {
mobileMenu.classList.remove('pointer-events-auto');
mobileMenu.classList.add('pointer-events-none');
document.body.style.overflow = '';
}, 300);
}, 100);
};
// Close button event
closeMenuButton?.addEventListener('click', closeMenu);
// Close menu when clicking a link
const mobileLinks = mobileMenu?.querySelectorAll('a');
mobileLinks?.forEach((link) => {
link.addEventListener('click', closeMenu);
});
// Close menu on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && mobileMenu?.classList.contains('pointer-events-auto')) {
closeMenu();
}
});
// Add smooth animation to header on scroll
const header = document.querySelector('header');
let lastScrollY = window.scrollY;
window.addEventListener('scroll', () => {
if (!header) return;
const currentScrollY = window.scrollY;
// Add shadow on scroll
if (currentScrollY > 10) {
header.classList.add('shadow-sm');
} else {
header.classList.remove('shadow-sm');
}
// Update last scroll position
lastScrollY = currentScrollY;
});
});
</script>
<style>
/* Smooth animations for mobile navigation */
.mobile-nav-item {
transition:
opacity 0.5s ease,
transform 0.5s ease,
color 0.3s ease;
}
/* Header transition */
header {
transition:
box-shadow 0.3s ease,
transform 0.3s ease,
background-color 0.3s ease;
}
/* Mobile menu button hover effect */
#mobile-menu-button {
transition: transform 0.2s ease;
}
#mobile-menu-button:hover {
transform: scale(1.05);
}
/* Mobile menu transition */
#mobile-menu {
transition: opacity 0.3s ease;
backdrop-filter: blur(4px);
}
</style>

View File

@@ -0,0 +1,175 @@
---
export interface Props {
title: string;
url: string;
class?: string;
}
const { title, url, class: className = '' } = Astro.props;
const encodedTitle = encodeURIComponent(title);
const encodedUrl = encodeURIComponent(url);
---
<div class={`flex items-center gap-4 mt-8 ${className}`}>
<span class="text-sm font-medium text-zinc-500 dark:text-zinc-400">Share:</span>
<div class="flex gap-2">
<a
href={`https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`}
target="_blank"
rel="noopener noreferrer"
class="rounded-full p-2 text-zinc-500 transition-all duration-300 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
aria-label="Share on Twitter"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
><path
d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z"
></path></svg
>
</a>
<a
href={`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`}
target="_blank"
rel="noopener noreferrer"
class="rounded-full p-2 text-zinc-500 transition-all duration-300 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
aria-label="Share on Facebook"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"></path></svg
>
</a>
<a
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodedUrl}&title=${encodedTitle}`}
target="_blank"
rel="noopener noreferrer"
class="rounded-full p-2 text-zinc-500 transition-all duration-300 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
aria-label="Share on LinkedIn"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"
></path><rect x="2" y="9" width="4" height="12"></rect><circle cx="4" cy="4" r="2"
></circle></svg
>
</a>
<button
id="copy-link-button"
class="relative rounded-full p-2 text-zinc-500 transition-all duration-300 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
aria-label="Copy link"
title="Copy link to clipboard"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg
>
<span
id="copy-tooltip"
class="absolute -top-8 left-1/2 -translate-x-1/2 transform whitespace-nowrap rounded bg-zinc-800 px-2 py-1 text-xs text-white opacity-0 transition-opacity duration-300 dark:bg-zinc-700"
>
Copied!
</span>
</button>
</div>
</div>
<script>
// Function to handle copy link button
function setupCopyLinkButton() {
const copyButtons = document.querySelectorAll('#copy-link-button');
copyButtons.forEach((button) => {
button.addEventListener('click', () => {
// Get the current URL
const url = window.location.href;
// Copy to clipboard
navigator.clipboard
.writeText(url)
.then(() => {
// Show tooltip
const tooltip = button.querySelector('#copy-tooltip');
if (tooltip) {
tooltip.classList.add('opacity-100');
// Hide tooltip after 2 seconds
setTimeout(() => {
tooltip.classList.remove('opacity-100');
}, 2000);
}
})
.catch((err) => {
console.error('Failed to copy: ', err);
});
});
});
}
// Set up the copy link button when the DOM is loaded
document.addEventListener('DOMContentLoaded', setupCopyLinkButton);
// Also set up when the page content is updated via SPA navigation
document.addEventListener('astro:page-load', setupCopyLinkButton);
// For compatibility with the custom page transition system
document.addEventListener('page-transition-complete', setupCopyLinkButton);
// Handle SPA transitions for share links
function setupSpaTransitions() {
// Get all share links
const shareLinks = document.querySelectorAll('a[target="_blank"][rel="noopener noreferrer"]');
// Make sure external share links don't trigger page transitions
shareLinks.forEach((link) => {
link.setAttribute('data-spa-external', 'true');
});
}
// Initialize SPA transitions
document.addEventListener('DOMContentLoaded', setupSpaTransitions);
document.addEventListener('astro:page-load', setupSpaTransitions);
document.addEventListener('page-transition-complete', setupSpaTransitions);
// Dispatch custom event when share action is completed
function notifyShareComplete() {
document.dispatchEvent(new CustomEvent('share-action-complete'));
}
// Add analytics tracking for share actions if needed
function trackShareAction(platform) {
// You can implement analytics tracking here
console.log(`Shared on ${platform}`);
// Notify other components that share action is complete
notifyShareComplete();
}
</script>

View File

@@ -0,0 +1,23 @@
---
export interface Props {
tags: string[];
class?: string;
}
const { tags = [], class: className = '' } = Astro.props;
---
{
tags.length > 0 && (
<div class={`mt-3 flex flex-wrap gap-2 ${className}`}>
{tags.map((tag) => (
<a
href={`/tag/${tag}`}
class="inline-flex items-center rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs font-medium text-zinc-800 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
>
{tag}
</a>
))}
</div>
)
}

View File

@@ -5,14 +5,14 @@
<button
id="theme-toggle"
data-theme-toggle
class="group dark:hover:bg-steel/30 relative touch-manipulation overflow-hidden rounded-full p-1.5 transition-all duration-300 hover:bg-yellow-300/20 focus:outline-hidden sm:p-2"
class="group relative touch-manipulation overflow-hidden rounded-full p-1.5 transition-all duration-300 hover:bg-zinc-100 focus:outline-none focus:ring-2 focus:ring-zinc-300 dark:hover:bg-zinc-800 dark:focus:ring-zinc-700 sm:p-2"
aria-label="Toggle dark mode"
>
<div class="relative z-10 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 scale-100 rotate-0 text-neutral-600 transition-all duration-500 dark:scale-0 dark:-rotate-90 dark:text-neutral-400"
class="icon-light absolute h-5 w-5 rotate-0 scale-100 text-zinc-800 transition-all duration-500 dark:-rotate-90 dark:scale-0 dark:text-zinc-200"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -29,7 +29,7 @@
<!-- Moon icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-dark absolute h-5 w-5 scale-0 rotate-90 text-neutral-600 transition-all duration-500 dark:scale-100 dark:rotate-0 dark:text-neutral-400"
class="icon-dark absolute h-5 w-5 rotate-90 scale-0 text-zinc-800 transition-all duration-500 dark:rotate-0 dark:scale-100 dark:text-zinc-200"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -40,27 +40,31 @@
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</div>
<!-- Ripple effect -->
<span
class="absolute inset-0 h-full w-full bg-zinc-200 opacity-0 transition-opacity duration-300 group-active:opacity-20 dark:bg-zinc-700"
></span>
</button>
<script is:inline>
// Use a function to persist theme when using SPA transitions
// https://docs.astro.build/en/guides/view-transitions/#script-re-execution
function applyTheme() {
localStorage.theme === 'dark'
? document.documentElement.classList.add('dark')
: document.documentElement.classList.remove('dark');
}
document.addEventListener('astro:after-swap', applyTheme);
applyTheme();
</script>
<script>
// Use a function to handle theme toggle to ensure it can be called from anywhere
function setupThemeToggle() {
const themeToggles = document.querySelectorAll('[data-theme-toggle]');
// Check for dark mode preference at the system level
const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Check for saved theme preference or use the system preference
const currentTheme = localStorage.getItem('theme') || (prefersDarkMode ? 'dark' : 'light');
// Apply the theme on initial load
if (currentTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Create theme switch overlay element if it doesn't exist
if (!document.querySelector('.theme-switch-overlay')) {
const overlay = document.createElement('div');
@@ -113,6 +117,11 @@
// Add transition class
document.documentElement.classList.add('theme-switching');
// Add ripple effect
const ripple = document.createElement('span');
ripple.className = 'theme-toggle-ripple';
toggle.appendChild(ripple);
// Force a reflow to ensure all elements update
document.body.offsetHeight;
@@ -145,6 +154,7 @@
// Remove transition class after animation completes
document.documentElement.classList.remove('theme-switching');
ripple.remove();
}, 300);
}, 50);
},
@@ -174,7 +184,7 @@
}
// Run setup on load
document.addEventListener('astro:page-load', setupThemeToggle);
document.addEventListener('DOMContentLoaded', setupThemeToggle);
// Also run on page visibility change to ensure theme is consistent
document.addEventListener('visibilitychange', () => {
@@ -223,6 +233,30 @@
transition-property: background-color, border-color, color, fill, stroke !important;
}
/* Ripple animation */
.theme-toggle-ripple {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
width: 100%;
height: 100%;
border-radius: 50%;
background-color: rgba(161, 161, 170, 0.3);
animation: ripple 0.8s ease-out;
}
@keyframes ripple {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 0.5;
}
100% {
transform: translate(-50%, -50%) scale(2.5);
opacity: 0;
}
}
/* Subtle hover animation */
#theme-toggle {
transform: translateY(0);
@@ -240,12 +274,12 @@
}
#theme-toggle:hover .icon-light:not(.dark .icon-light) {
filter: drop-shadow-sm(0 0 2px rgba(251, 191, 36, 0.6));
filter: drop-shadow(0 0 2px rgba(251, 191, 36, 0.6));
transform: scale(1.1) rotate(15deg);
}
#theme-toggle:hover .icon-dark:not(:not(.dark) .icon-dark) {
filter: drop-shadow-sm(0 0 2px rgba(129, 140, 248, 0.6));
filter: drop-shadow(0 0 2px rgba(129, 140, 248, 0.6));
transform: scale(1.1) rotate(-15deg);
}
}
@@ -268,6 +302,10 @@
transform: none;
transition: none;
}
.theme-toggle-ripple {
animation-duration: 0.4s;
}
}
/* Adjust size for very small screens */

View File

@@ -1,61 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import type { Post } from '@lib/directusTypes';
import { getDirectusImageURL } from '@lib/directusFunctions';
import Image from '@components/ui/images/Image.astro';
import { formatDate } from '@support/time';
interface Props {
post: Post;
}
const { post } = Astro.props;
const baseClasses = 'group group-hover smooth-reveal-cards rounded-xl flex flex-col';
const borderClasses = 'border border-stone-200/50 dark:border-stone-700/50';
const bgColorClasses =
'bg-neutral-100/80 hover:bg-neutral-100 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/90';
const shadowClasses = 'shadow-xs hover:shadow-md dark:shadow-md dark:hover:shadow-lg';
---
<div class={`${baseClasses}`}>
<a
class={`rounded-xl duration-300 transition-all ${borderClasses} ${shadowClasses} ${bgColorClasses}`}
href={`/blog/${post.slug}/`}
data-astro-prefetch
>
<div
class="relative w-full flex-shrink-0 overflow-hidden rounded-t-xl before:absolute before:inset-x-0 before:z-[1] before:size-full"
>
<Image
class="h-auto w-full rounded-t-xl"
src={getDirectusImageURL(post.image)}
alt={post.image_alt}
draggable="false"
loading="eager"
format="webp"
width="800"
height="460"
/>
</div>
<div class="rounded-xl p-4 md:p-5">
<h3 class="text-xl font-bold text-neutral-600 dark:text-neutral-200">
{post.title}
</h3>
<div
class="group-hover:text-steel dark:group-hover:text-bermuda transition-text relative z-10 mx-auto flex min-h-[44px] items-center font-medium text-neutral-600 decoration-2 duration-300 sm:mx-0 sm:mt-4 dark:text-neutral-400"
>
<span class="relative inline-block overflow-hidden"> Read more </span>
<Icon
name="mdi:keyboard-arrow-right"
class="h-3 w-3 translate-y-0.25 transition duration-300 group-hover:translate-x-1 md:h-5 md:w-5"
/>
<p class="ml-auto text-sm text-neutral-600 dark:text-neutral-400">
{formatDate(post.published_date)}
</p>
</div>
</div>
</a>
</div>

View File

@@ -1,70 +0,0 @@
---
interface Props {
slug: string;
title: string;
description: string;
count: number;
publishDate: string;
}
const { slug, title, description, count, publishDate } = Astro.props;
const baseClasses =
'group group-hover rounded-xl flex h-full min-h-[220px] cursor-pointer flex-col overflow-hidden';
const bgColorClasses =
'bg-neutral-100/60 dark:bg-neutral-800/60 hover:bg-neutral-100 dark:hover:bg-neutral-800/90 ';
---
<a class={`rounded-xl`} href={`/categories/${slug}/`} data-astro-prefetch="false">
<div class={`${baseClasses}`}>
<div
class={`relative min-h-0 flex-grow overflow-hidden transition-all duration-300 ${bgColorClasses}`}
>
<div class="absolute inset-1 flex flex-col p-3 md:p-4 lg:p-5">
<div class="overflow-hidden">
<h2
class="group-hover:text-steel dark:group-hover:text-bermuda transition-text mb-4 text-4xl font-extrabold tracking-tight text-balance whitespace-nowrap text-neutral-800 duration-300 dark:text-neutral-200"
>
{title}
</h2>
<p class="mb-4 font-light text-neutral-600 sm:text-lg dark:text-neutral-400">
{description}
</p>
</div>
<div
class="mt-auto flex items-center justify-between pt-1 text-xs text-neutral-600 md:pt-2 dark:text-neutral-300"
>
<span class="inline-flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
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="12"
height="12"
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>
</div>
</a>

View File

@@ -1,29 +0,0 @@
---
import type { Post } from '@lib/directusTypes';
import BlogCard from '@components/blog/BlogCard.astro';
interface Props {
posts: Post[];
}
const { posts } = Astro.props;
---
<section class="mx-auto mb-10 max-w-[85rem] px-4 py-8 sm:px-6 lg:px-8 2xl:max-w-full">
<div class="text-left">
<h2
id="selected-articel"
class="smooth-reveal-2 mb-4 text-5xl font-extrabold tracking-tight text-balance text-neutral-800 dark:text-neutral-200"
>
Older Articles
</h2>
</div>
<div class="flex flex-col md:flex-row md:space-x-12 lg:space-x-16">
<div class="w-full">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((b) => <BlogCard post={b} />)}
</div>
</div>
</div>
</section>

View File

@@ -1,44 +0,0 @@
---
import PrimaryCTA from '@components/ui/buttons/PrimaryCTA.astro';
import Image from '@components/ui/images/Image.astro';
interface Props {
title: string;
subTitle: string;
btnExists?: boolean;
btnTitle?: string;
btnURL?: string;
img: any;
imgAlt: any;
}
const { title, subTitle, btnExists, btnTitle, btnURL, img, imgAlt } = Astro.props;
---
<section
class="mx-auto max-w-[85rem] items-center gap-8 px-4 py-10 sm:px-6 sm:py-16 md:grid md:grid-cols-2 lg:grid lg:grid-cols-2 lg:px-8 lg:py-14 xl:gap-16 2xl:max-w-full"
>
<Image
class="h-full w-full rounded-xl object-cover sm:max-h-[320px] md:max-h-[360px]"
src={img}
alt={imgAlt}
draggable="false"
loading="lazy"
width="850"
height="420"
/>
<div class="mt-4 md:mt-0">
<h2
class="mb-4 text-4xl font-extrabold tracking-tight text-balance text-neutral-800 dark:text-neutral-200"
>
{title}
</h2>
<p
class="mb-4 max-w-prose font-light text-pretty text-neutral-600 sm:text-lg dark:text-neutral-300"
>
{subTitle}
</p>
{btnExists ? <PrimaryCTA title={btnTitle} url={btnURL} /> : null}
</div>
</section>

View File

@@ -1,45 +0,0 @@
---
import type { Post } from '@lib/directusTypes';
import { getDirectusImageURL } from '@lib/directusFunctions';
import BlogLeftSection from '@components/blog/BlogLeftSection.astro';
import BlogRightSection from '@components/blog/BlogRightSection.astro';
interface Props {
posts: Post[];
}
const { posts } = Astro.props;
const blogPosts = posts.slice(0, 5);
---
<section class="smooth-reveal">
{
blogPosts.map((b, index) =>
index % 2 === 0 ? (
<BlogLeftSection
title={b.title}
subTitle={b.description}
btnExists={true}
btnTitle="Read More"
btnURL={`/blog/${b.slug}`}
img={getDirectusImageURL(b.image)}
imgAlt={b.image_alt}
/>
) : (
<BlogRightSection
title={b.title}
subTitle={b.description}
btnExists={true}
btnTitle="Read More"
btnURL={`/blog/${b.slug}`}
single={!b.image_second}
imgOne={getDirectusImageURL(b.image)}
imgOneAlt={b.image_alt}
imgTwo={getDirectusImageURL(b?.image_second)}
imgTwoAlt={b?.image_second_alt}
/>
)
)
}
</section>

View File

@@ -1,87 +0,0 @@
---
import PrimaryCTA from '@components/ui/buttons/PrimaryCTA.astro';
import Image from '@components/ui/images/Image.astro';
interface Props {
title: string;
subTitle: string;
btnExists?: boolean;
btnTitle?: string;
btnURL?: string;
single?: boolean;
imgOne?: any;
imgOneAlt?: any;
imgTwo?: any;
imgTwoAlt?: any;
}
const {
title,
subTitle,
btnExists,
btnTitle,
btnURL,
single,
imgOne,
imgOneAlt,
imgTwo,
imgTwoAlt,
} = Astro.props;
---
<section
class="mx-auto max-w-[85rem] items-center gap-16 px-4 py-10 sm:px-6 lg:grid lg:grid-cols-2 lg:px-8 lg:py-14 2xl:max-w-full"
>
<div>
<h2
class="mb-4 text-4xl font-extrabold tracking-tight text-balance text-neutral-800 dark:text-neutral-200"
>
{title}
</h2>
<p
class="mb-4 max-w-prose font-light text-pretty text-neutral-600 sm:text-lg dark:text-neutral-400"
>
{subTitle}
</p>
{btnExists ? <PrimaryCTA title={btnTitle} url={btnURL} /> : null}
</div>
{
single ? (
<div class="mt-8">
<Image
class="w-full rounded-lg"
src={imgOne}
alt={imgOneAlt}
format="webp"
loading="lazy"
width="850"
height="420"
/>
</div>
) : (
<div class="mt-8 grid grid-cols-2 gap-4">
<Image
class="w-full rounded-xl"
src={imgOne}
alt={imgOneAlt}
draggable="false"
format="webp"
loading="lazy"
width="400"
height="230"
/>
<Image
class="mt-4 w-full rounded-xl lg:mt-10"
src={imgTwo}
alt={imgTwoAlt}
draggable="false"
format="webp"
loading="lazy"
width="400"
height="230"
/>
</div>
)
}
</section>

View File

@@ -1,85 +0,0 @@
---
import Icon from '@components/ui/icons/icon.astro';
---
<button
type="button"
class="focus-visible:ring-secondary group inline-flex items-center rounded-lg p-2.5 text-neutral-600 ring-neutral-500 transition duration-300 outline-none hover:bg-neutral-100 focus:outline-none focus-visible:ring-1 focus-visible:outline-none dark:text-neutral-400 dark:ring-neutral-200 dark:hover:bg-neutral-700"
data-bookmark-button="bookmark-button"
>
<Icon name="bookmark" />
</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,32 +0,0 @@
---
import { Icon } from 'astro-icon/components';
const { title, url } = Astro.props;
interface Props {
title?: string;
url?: string;
}
const baseClasses =
'group group-hover inline-flex items-center justify-center gap-x-3 rounded-full px-4 py-3 text-center text-sm font-medium text-neutral-200';
const borderClasses = 'border border-transparent';
const bgColorClasses =
'bg-gitea-primary hover:bg-gitea-secondary dark:bg-gitea-secondary dark:hover:bg-gitea-primary';
const shadowClasses = 'shadow-sm';
const fontSizeClasses = '2xl:text-base';
---
<a
class={`${baseClasses} ${borderClasses} ${bgColorClasses} ${shadowClasses} ${fontSizeClasses} `}
href={url}
target="_blank"
rel="noopener noreferrer"
>
<Icon name="pajamas:gitea" class="h-4 w-4 md:h-6 md:w-6" />
{title}
<Icon
name="mdi:keyboard-arrow-right"
class="h-3 w-3 translate-y-0.25 transition duration-300 group-hover:translate-x-1 md:h-5 md:w-5"
/>
</a>

View File

@@ -1,35 +0,0 @@
---
import Icon from '@components/ui/icons/icon.astro';
const { title, noArrow } = Astro.props;
interface Props {
title?: string;
url?: string;
noArrow?: boolean;
addHome?: boolean;
}
const baseClasses =
'group inline-flex items-center justify-center gap-x-2 rounded-lg px-4 py-3 text-sm font-bold text-neutral-50 ring-neutral-500 transition duration-300 focus-visible:ring outline-none';
const borderClasses = 'border border-transparent';
const bgColorClasses = 'bg-steel hover:bg-sky-800 active:bg-orange-500 dark:focus:outline-none';
const disableClasses = 'disabled:pointer-events-none disabled:opacity-50';
const fontSizeClasses = '2xl:text-base';
const ringClasses = 'dark:ring-neutral-200';
---
<button
class={`${baseClasses} ${borderClasses} ${bgColorClasses} ${disableClasses} ${fontSizeClasses} ${ringClasses}`}
id="back-button"
data-astro-prefetch
>
{noArrow ? null : <Icon name="arrowLeft" />}
{title}
</button>
<script>
document.getElementById('back-button')?.addEventListener('click', () => {
window.history.back();
});
</script>

View File

@@ -1,45 +0,0 @@
---
import { Icon } from 'astro-icon/components';
const { title, url, noArrow, addHome, addClass } = Astro.props;
interface Props {
title?: string;
url?: string;
noArrow?: boolean;
addHome?: boolean;
addClass?: string;
}
const baseClasses =
'group inline-flex items-center justify-center gap-x-2 rounded-lg px-4 py-3 text-sm font-bold text-neutral-100 transition duration-300 ';
const borderClasses = 'border border-transparent';
const bgColorClasses = 'bg-bermuda hover:bg-turquoise dark:bg-turquoise dark:hover:bg-bermuda';
const disableClasses = 'disabled:pointer-events-none disabled:opacity-50';
const fontSizeClasses = '2xl:text-base';
const ringClasses = 'dark:ring-neutral-200';
---
<a
class={`${baseClasses} ${borderClasses} ${bgColorClasses} ${disableClasses} ${fontSizeClasses} ${ringClasses} ${addClass}`}
href={url}
data-astro-prefetch
>
{
addHome ? (
<Icon
name="mdi:home-variant-outline"
class="h-3 w-3 translate-y-0.25 transition duration-300 group-hover:translate-x-1 md:h-5 md:w-5"
/>
) : null
}
{title}
{
noArrow ? null : (
<Icon
name="mdi:keyboard-arrow-right"
class="h-3 w-3 translate-y-0.25 transition duration-300 group-hover:translate-x-1 md:h-5 md:w-5"
/>
)
}
</a>

View File

@@ -1,26 +0,0 @@
---
const { title, url } = Astro.props;
interface Props {
title?: string;
url?: string;
}
const baseClasses =
'inline-flex items-center justify-center gap-x-2 rounded-lg px-4 py-3 text-center text-sm font-medium text-neutral-600 shadow-sm outline-none ring-neutral-500 focus-visible:ring transition duration-300';
const borderClasses = 'border border-neutral-200';
const bgColorClasses = 'bg-neutral-300';
const hoverClasses = 'hover:bg-neutral-400/50 hover:text-neutral-600 active:text-neutral-700';
const disableClasses = 'disabled:pointer-events-none disabled:opacity-50';
const fontSizeClasses = '2xl:text-base';
const ringClasses = 'ring-neutral-500';
const darkClasses =
'dark:border-neutral-700 dark:bg-neutral-700 dark:text-neutral-300 dark:ring-neutral-200 dark:hover:bg-neutral-600 dark:focus:outline-none';
---
<a
class={`${baseClasses} ${borderClasses} ${bgColorClasses} ${hoverClasses} ${disableClasses} ${fontSizeClasses} ${ringClasses} ${darkClasses}`}
href={url}
>
{title}
</a>

View File

@@ -1,150 +0,0 @@
---
import Icon from '@components/ui/icons/icon.astro';
const { pageTitle, title = 'Share' } = Astro.props;
interface Props {
pageTitle: string;
title?: string;
}
type SocialPlatform = {
name: string;
url: string;
svg: string;
};
const socialPlatforms: SocialPlatform[] = [
{
name: 'Facebook',
url: `https://www.facebook.com/share.php?u=${Astro.url}&title=${pageTitle}`,
svg: 'facebook',
},
{
name: 'X',
url: `https://twitter.com/home/?status=${pageTitle}${Astro.url}`,
svg: 'x',
},
{
name: 'LinkedIn',
url: `https://www.linkedin.com/shareArticle?mini=true&url=${Astro.url}&title=${pageTitle}`,
svg: 'linkedIn',
},
];
---
<div class="hs-dropdown relative inline-flex [--auto-close:inside] [--placement:top-left]">
<button
id="hs-dropup"
type="button"
class="hs-dropdown-toggle inline-flex items-center gap-x-2 rounded-lg px-4 py-3 text-sm font-medium text-neutral-600 ring-neutral-500 transition duration-300 outline-none hover:bg-neutral-100 hover:text-neutral-700 focus-visible:ring dark:text-neutral-400 dark:ring-neutral-200 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:focus:outline-none"
>
<Icon name="share" />
{title}
</button>
<div
class="hs-dropdown-menu duration hs-dropdown-open:opacity-100 z-10 hidden w-72 divide-y divide-neutral-200 rounded-lg bg-neutral-50 p-2 opacity-0 shadow-md transition-[opacity,margin] dark:divide-neutral-700 dark:border dark:border-neutral-700 dark:bg-neutral-800"
aria-labelledby="hs-dropup"
>
<div class="py-2 first:pt-0 last:pb-0">
{
socialPlatforms.map((platform) => (
<a
class="flex items-center gap-x-3.5 rounded-lg px-3 py-2 text-sm text-neutral-700 hover:bg-neutral-200 focus:bg-neutral-100 focus:outline-none dark:text-neutral-300 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:focus:bg-neutral-700"
href={platform.url}
>
<Icon name={platform.svg} />
Share on {platform.name}
</a>
))
}
</div>
<div class="py-2 first:pt-0 last:pb-0">
<button
type="button"
class="js-clipboard hover:text-dark focus-visible:ring-secondary group inline-flex w-full items-center gap-x-3.5 rounded-lg px-3 py-2 text-sm text-neutral-700 hover:bg-neutral-200 focus:bg-neutral-100 focus:outline-none focus-visible:ring-1 focus-visible:outline-none dark:text-neutral-300 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:focus:bg-neutral-700"
data-clipboard-success-text="Copied"
>
<svg
class="js-clipboard-default h-4 w-4 transition group-hover:rotate-6"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="8" height="4" x="8" y="2" rx="1" ry="1"></rect>
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"></path>
</svg>
<svg
class="js-clipboard-success hidden h-4 w-4 text-neutral-700 dark:text-neutral-300"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
<span class="js-clipboard-success-text">Copy link</span>
</button>
</div>
</div>
</div>
<!--Import the necessary Dropdown and Clipboard plugins-->
<!--https://preline.co/plugins/html/dropdown.html-->
<!--<script is:inline src="/scripts/vendor/preline/dropdown/index.js"></script>-->
<!-- https://clipboardjs.com/ -->
<!--<script is:inline src="/scripts/vendor/clipboard.min.js"></script>-->
<script is:inline>
(function () {
window.addEventListener('load', () => {
const $clipboards = document.querySelectorAll('.js-clipboard');
$clipboards.forEach((el) => {
const clipboard = new ClipboardJS(el, {
text: () => {
return window.location.href;
},
});
clipboard.on('success', () => {
const $default = el.querySelector('.js-clipboard-default');
const $success = el.querySelector('.js-clipboard-success');
const $successText = el.querySelector('.js-clipboard-success-text');
const successText = el.dataset.clipboardSuccessText || '';
let oldSuccessText;
if ($successText) {
oldSuccessText = $successText.textContent;
$successText.textContent = successText;
}
if ($default && $success) {
$default.style.display = 'none';
$success.style.display = 'block';
}
setTimeout(() => {
if ($successText && oldSuccessText) {
$successText.textContent = oldSuccessText;
}
if ($default && $success) {
$success.style.display = '';
$default.style.display = '';
}
}, 800);
});
});
});
})();
</script>

View File

@@ -1,45 +0,0 @@
---
import { Icon } from 'astro-icon/components';
interface Props {
title?: string;
description?: string;
url?: string;
icon?: string;
}
const { title, description, url, icon } = Astro.props;
const baseClasses = 'smooth-reveal-2 group group-hover flex flex-col ';
const borderClasses = 'border border-neutral-100 dark:border-stone-500/20';
const bgColorClasses =
'bg-neutral-100/80 hover:bg-neutral-100 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/90';
const shadowClasses = 'shadow-xs hover:shadow-md dark:shadow-md dark:hover:shadow-lg';
---
<div class={`${baseClasses}`}>
<a
class={`rounded-xl duration-300 transition-all h-30 ${borderClasses} ${bgColorClasses} ${shadowClasses}`}
href={url}
data-astro-prefetch
>
<div class="p-4 md:p-5">
<div class="flex">
<Icon
name={icon}
class="group-hover:text-steel dark:group-hover:text-bermuda h-6 w-6 text-neutral-600 transition-all duration-300 md:h-8 md:w-8 dark:text-neutral-200"
/>
<div class="ms-5 grow">
<span
class="group-hover:text-steel dark:group-hover:text-bermuda block text-lg font-bold text-neutral-600 transition-all duration-300 dark:text-neutral-300"
>
{title}
</span>
<span class="mt-1 block text-neutral-500 dark:text-neutral-400">
{description}
</span>
</div>
</div>
</div>
</a>
</div>

View File

@@ -1,39 +0,0 @@
---
import { Icons } from './icons.ts';
interface Path {
d: string;
class?: string;
}
const { name } = Astro.props;
const icon = (Icons as any)[name] || {};
const paths: Path[] = icon.paths || [];
---
{
icon ? (
<svg
class={icon.class}
height={icon.height}
viewBox={icon.viewBox}
width={icon.width}
fill={icon.fill}
clip-rule={icon.clipRule}
fill-rule={icon.fillRule}
stroke={icon.stroke}
stroke-width={icon.strokeWidth}
stroke-linecap={icon.strokeLinecap}
stroke-linejoin={icon.strokeLinejoin}
>
<title>{icon.title}</title>
<circle cx={icon.circleCx} cy={icon.circleCy} r={icon.circleR} />
{paths.map((path) => (
<path d={path.d} class={path.class || ''} />
))}
</svg>
) : (
'Icon not found'
)
}

View File

@@ -1,573 +0,0 @@
export const Icons = {
groups: {
paths: [
{
d: 'm150-400 82-80-82-82-80 82 80 80Zm573-10 87-140 88 140H723Zm-243-70q-50 0-85-35t-35-85q0-51 35-85.5t85-34.5q51 0 85.5 34.5T600-600q0 50-34.5 85T480-480Zm.351-180Q455-660 437.5-642.851t-17.5 42.5Q420-575 437.351-557.5t43 17.5Q506-540 523-557.351t17-43Q540-626 522.851-643t-42.5-17ZM480-600ZM0-240v-53q0-39.464 42-63.232T150.398-380q12.158 0 23.38.5T196-377.273q-8 17.273-12 34.842-4 17.57-4 37.431v65H0Zm240 0v-65q0-65 66.5-105T480-450q108 0 174 40t66 105v65H240Zm570-140q67.5 0 108.75 23.768T960-293v53H780v-65q0-19.861-3.5-37.431Q773-360 765-377.273q11-1.727 22.171-2.227 11.172-.5 22.829-.5Zm-330.2-10Q400-390 350-366q-50 24-50 61v5h360v-6q0-36-49.5-60t-130.7-24Zm.2 90Z',
},
],
class: 'mt-1 h-8 w-8 flex-shrink-0 fill-orange-400 dark:fill-orange-300',
width: 48,
height: 48,
viewBox: '0 -960 960 960',
},
books: {
paths: [
{
d: 'M343-420h225v-60H343v60Zm0-90h395v-60H343v60Zm0-90h395v-60H343v60Zm-83 400q-24 0-42-18t-18-42v-560q0-24 18-42t42-18h560q24 0 42 18t18 42v560q0 24-18 42t-42 18H260Zm0-60h560v-560H260v560ZM140-80q-24 0-42-18t-18-42v-620h60v620h620v60H140Zm120-740v560-560Z',
},
],
class: 'mt-1 h-8 w-8 flex-shrink-0 fill-orange-400 dark:fill-orange-300',
width: 48,
height: 48,
viewBox: '0 -960 960 960',
},
verified: {
paths: [
{
d: 'm346-60-76-130-151-31 17-147-96-112 96-111-17-147 151-31 76-131 134 62 134-62 77 131 150 31-17 147 96 111-96 112 17 147-150 31-77 130-134-62-134 62Zm27-79 107-45 110 45 67-100 117-30-12-119 81-92-81-94 12-119-117-28-69-100-108 45-110-45-67 100-117 28 12 119-81 94 81 92-12 121 117 28 70 100Zm107-341Zm-43 133 227-225-45-41-182 180-95-99-46 45 141 140Z',
},
],
class: 'mt-1 h-8 w-8 flex-shrink-0 fill-orange-400 dark:fill-orange-300',
width: 48,
height: 48,
viewBox: '0 -960 960 960',
},
frame: {
paths: [
{
d: 'M480-480q-51 0-85.5-34.5T360-600q0-50 34.5-85t85.5-35q50 0 85 35t35 85q0 51-35 85.5T480-480Zm-.351-60Q505-540 522.5-557.149t17.5-42.5Q540-625 522.649-642.5t-43-17.5Q454-660 437-642.649t-17 43Q420-574 437.149-557t42.5 17ZM240-240v-76q0-27 17.5-47.5T300-397q42-22 86.943-32.5 44.942-10.5 93-10.5Q528-440 573-429.5t87 32.5q25 13 42.5 33.5T720-316v76H240Zm240-140q-47.546 0-92.773 13T300-328v28h360v-28q-42-26-87.227-39-45.227-13-92.773-13Zm0-220Zm0 300h180-360 180ZM140-80q-24 0-42-18t-18-42v-172h60v172h172v60H140ZM80-648v-172q0-24 18-42t42-18h172v60H140v172H80ZM648-80v-60h172v-172h60v172q0 24-18 42t-42 18H648Zm172-568v-172H648v-60h172q24 0 42 18t18 42v172h-60Z',
},
],
class: 'mt-1 h-8 w-8 flex-shrink-0 fill-orange-400 dark:fill-orange-300',
width: 48,
height: 48,
viewBox: '0 -960 960 960',
},
tools: {
paths: [
{
d: 'M764-80q-6 0-11-2t-10-7L501-331q-5-5-7-10t-2-11q0-6 2-11t7-10l85-85q5-5 10-7t11-2q6 0 11 2t10 7l242 242q5 5 7 10t2 11q0 6-2 11t-7 10l-85 85q-5 5-10 7t-11 2Zm0-72 43-43-200-200-43 43 200 200ZM195-80q-6 0-11.5-2T173-89l-84-84q-5-5-7-10.5T80-195q0-6 2-11t7-10l225-225h85l38-38-175-175h-57L80-779l99-99 125 125v57l175 175 130-130-67-67 56-56H485l-18-18 128-128 18 18v113l56-56 169 169q15 15 23.5 34.5T870-600q0 20-6.5 38.5T845-528l-85-85-56 56-52-52-211 211v84L216-89q-5 5-10 7t-11 2Zm0-72 200-200v-43h-43L152-195l43 43Zm0 0-43-43 22 21 21 22Zm569 0 43-43-43 43Z',
},
],
class:
'mt-2 h-6 w-6 flex-shrink-0 fill-neutral-700 hs-tab-active:fill-orange-400 dark:fill-neutral-300 dark:hs-tab-active:fill-orange-300 md:h-7 md:w-7',
width: 48,
height: 48,
viewBox: '0 -960 960 960',
},
dashboard: {
paths: [
{
d: 'M510-570v-270h330v270H510ZM120-450v-390h330v390H120Zm390 330v-390h330v390H510Zm-390 0v-270h330v270H120Zm60-390h210v-270H180v270Zm390 330h210v-270H570v270Zm0-450h210v-150H570v150ZM180-180h210v-150H180v150Zm210-330Zm180-120Zm0 180ZM390-330Z',
},
],
class:
'mt-2 h-6 w-6 flex-shrink-0 fill-neutral-700 hs-tab-active:fill-orange-400 dark:fill-neutral-300 dark:hs-tab-active:fill-orange-300 md:h-7 md:w-7',
width: 48,
height: 48,
viewBox: '0 -960 960 960',
},
house: {
paths: [
{
d: 'M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25',
},
],
class: 'h-4 w-4 flex-shrink-0 md:h-5 md:w-5',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
home: {
paths: [
{
d: 'M8.25 21v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21m0 0h4.5V3.545M12.75 21h7.5V10.75M2.25 21h1.5m18 0h-18M2.25 9l4.5-1.636M18.75 3l-1.5.545m0 6.205 3 1m1.5.5-1.5-.5M6.75 7.364V3h-3v18m3-13.636 10.5-3.819',
},
],
class:
'h-6 w-6 flex-shrink-0 group-hover:text-steel dark:group-hover:text-steel transition-all duration-200 text-neutral-600 dark:text-neutral-300 md:h-7 md:w-7',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '1.5',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
arrowUp: {
paths: [
{
d: 'm5 12 7-7 7 7',
},
{
d: 'M12 19V5',
},
],
class: 'h-5 w-5 flex-shrink-0 text-orange-400 dark:text-orange-300',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
checkCircle: {
paths: [
{
d: 'M10 18a8 8 0 100-16 8 8 0 000 16zM13.707 8.293a1 1 0 00-1.414-1.414L9 10.586l-1.293-1.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z',
},
],
class: 'h-5 w-5 shrink-0',
viewBox: '0 0 20 20',
fill: 'currentColor',
fillRule: 'evenodd',
clipRule: 'evenodd',
},
bookmark: {
paths: [
{
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',
},
],
class: 'h-6 w-6 fill-none transition duration-300',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '1.5',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
arrowRight: {
paths: [
{
d: 'm9 18 6-6-6-6',
},
],
class: 'h-4 w-4 flex-shrink-0 transition duration-300 group-hover:translate-x-1',
width: 20,
height: 20,
viewBox: '0 0 22 22',
fill: 'none',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
arrowLeft: {
paths: [
{
d: 'm15 18-6-6 6-6',
},
],
class: 'h-4 w-4 flex-shrink-0 transition duration-300 group-hover:-translate-x-1',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
facebook: {
paths: [
{
d: 'M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z',
},
],
class: 'size-4 flex-shrink-0 fill-current',
viewBox: '0 0 24 24',
stroke: 'currentColor',
},
x: {
paths: [
{
d: 'M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z',
},
],
class: 'size-4 flex-shrink-0 fill-current',
viewBox: '0 0 24 24',
stroke: 'currentColor',
},
linkedIn: {
paths: [
{
d: 'M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z',
},
],
class: 'size-4 flex-shrink-0 fill-current',
viewBox: '0 0 24 24',
stroke: 'currentColor',
},
share: {
paths: [
{
d: 'M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z',
},
],
class: 'h-4 w-4 group-hover:text-neutral-700',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '1.5',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
github: {
paths: [
{
d: 'M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z',
},
],
class: 'w-4.5 h-4.5 transition flex-shrink-0 text-neutral-700 duration-300',
width: 16,
height: 16,
viewBox: '0 0 16 16',
fill: 'currentColor',
},
gitea: {
paths: [
{
d: 'M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z',
},
],
class: 'w-6 h-6 transition flex-shrink-0 duration-300',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'currentColor',
},
arrowRightStatic: {
paths: [
{
d: 'm9 18 6-6-6-6',
},
],
class: 'size-4 flex-shrink-0',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
openInNew: {
paths: [
{
d: 'm4.5 19.5 15-15m0 0H8.25m11.25 0v11.25',
},
],
class: 'ml-0.5 w-3 h-3 md:w-4 md:h-4 inline pb-0.5',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '3',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
accordionNotActive: {
paths: [
{
d: 'm6 9 6 6 6-6',
},
],
class:
'block h-5 w-5 flex-shrink-0 text-neutral-600 group-hover:text-neutral-500 hs-accordion-active:hidden dark:text-neutral-400',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
accordionActive: {
paths: [
{
d: 'm18 15-6-6-6 6',
},
],
class:
'hidden h-5 w-5 flex-shrink-0 text-neutral-600 group-hover:text-neutral-500 hs-accordion-active:block dark:text-neutral-400',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
xFooter: {
paths: [
{
d: 'M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z',
},
],
class: 'h-4 w-4 flex-shrink-0 fill-current text-neutral-700 dark:text-neutral-400',
viewBox: '0 0 24 24',
fill: 'currentColor',
title: 'Twitter',
},
facebookFooter: {
paths: [
{
d: 'M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z',
},
],
class: 'h-4 w-4 flex-shrink-0 fill-current text-neutral-700 dark:text-neutral-400',
viewBox: '0 0 24 24',
fill: 'currentColor',
title: 'Facebook',
},
githubFooter: {
paths: [
{
d: 'M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12',
},
],
class: 'h-4 w-4 flex-shrink-0 fill-current text-neutral-700 dark:text-neutral-400',
viewBox: '0 0 24 24',
fill: 'currentColor',
title: 'GitHub',
},
googleFooter: {
paths: [
{
d: 'M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z',
},
],
class: 'h-4 w-4 flex-shrink-0 fill-current text-neutral-700 dark:text-neutral-400',
viewBox: '0 0 24 24',
fill: 'currentColor',
title: 'Google',
},
slackFooter: {
paths: [
{
d: 'M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z',
},
],
class: 'h-4 w-4 flex-shrink-0 fill-current text-neutral-700 dark:text-neutral-400',
viewBox: '0 0 24 24',
fill: 'currentColor',
title: 'Slack',
},
quotation: {
paths: [
{
d: 'M7.39762 10.3C7.39762 11.0733 7.14888 11.7 6.6514 12.18C6.15392 12.6333 5.52552 12.86 4.76621 12.86C3.84979 12.86 3.09047 12.5533 2.48825 11.94C1.91222 11.3266 1.62421 10.4467 1.62421 9.29999C1.62421 8.07332 1.96459 6.87332 2.64535 5.69999C3.35231 4.49999 4.33418 3.55332 5.59098 2.85999L6.4943 4.25999C5.81354 4.73999 5.26369 5.27332 4.84476 5.85999C4.45201 6.44666 4.19017 7.12666 4.05926 7.89999C4.29491 7.79332 4.56983 7.73999 4.88403 7.73999C5.61716 7.73999 6.21938 7.97999 6.69067 8.45999C7.16197 8.93999 7.39762 9.55333 7.39762 10.3ZM14.6242 10.3C14.6242 11.0733 14.3755 11.7 13.878 12.18C13.3805 12.6333 12.7521 12.86 11.9928 12.86C11.0764 12.86 10.3171 12.5533 9.71484 11.94C9.13881 11.3266 8.85079 10.4467 8.85079 9.29999C8.85079 8.07332 9.19117 6.87332 9.87194 5.69999C10.5789 4.49999 11.5608 3.55332 12.8176 2.85999L13.7209 4.25999C13.0401 4.73999 12.4903 5.27332 12.0713 5.85999C11.6786 6.44666 11.4168 7.12666 11.2858 7.89999C11.5215 7.79332 11.7964 7.73999 12.1106 7.73999C12.8437 7.73999 13.446 7.97999 13.9173 8.45999C14.3886 8.93999 14.6242 9.55333 14.6242 10.3Z',
},
],
class:
'absolute start-0 top-0 h-16 w-16 -translate-x-6 -translate-y-8 transform text-neutral-300 dark:text-neutral-700',
width: 16,
height: 16,
viewBox: '0 0 16 16',
fill: 'currentColor',
},
question: {
paths: [
{
d: 'M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z',
},
],
class: 'mt-1.5 h-6 w-6 flex-shrink-0 text-neutral-600 dark:text-neutral-400',
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '1.5',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
chatBubble: {
paths: [
{
d: 'M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155',
},
],
class: 'mt-1.5 h-6 w-6 flex-shrink-0 text-neutral-600 dark:text-neutral-400',
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '1.5',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
mapPin: {
paths: [
{
d: 'M15 10.5a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z',
},
{
d: 'M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1 1 15 0Z',
},
],
class: 'mt-1.5 h-6 w-6 flex-shrink-0 text-neutral-600 dark:text-neutral-400',
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '1.5',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
envelopeOpen: {
paths: [
{
d: 'M21.75 9v.906a2.25 2.25 0 0 1-1.183 1.981l-6.478 3.488M2.25 9v.906a2.25 2.25 0 0 0 1.183 1.981l6.478 3.488m8.839 2.51-4.66-2.51m0 0-1.023-.55a2.25 2.25 0 0 0-2.134 0l-1.022.55m0 0-4.661 2.51m16.5 1.615a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V8.844a2.25 2.25 0 0 1 1.183-1.981l7.5-4.039a2.25 2.25 0 0 1 2.134 0l7.5 4.039a2.25 2.25 0 0 1 1.183 1.98V19.5Z',
},
],
class: 'mt-1.5 h-6 w-6 flex-shrink-0 text-neutral-600 dark:text-neutral-400',
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '1.5',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
earth: {
paths: [
{
d: 'm20.893 13.393-1.135-1.135a2.252 2.252 0 0 1-.421-.585l-1.08-2.16a.414.414 0 0 0-.663-.107.827.827 0 0 1-.812.21l-1.273-.363a.89.89 0 0 0-.738 1.595l.587.39c.59.395.674 1.23.172 1.732l-.2.2c-.212.212-.33.498-.33.796v.41c0 .409-.11.809-.32 1.158l-1.315 2.191a2.11 2.11 0 0 1-1.81 1.025 1.055 1.055 0 0 1-1.055-1.055v-1.172c0-.92-.56-1.747-1.414-2.089l-.655-.261a2.25 2.25 0 0 1-1.383-2.46l.007-.042a2.25 2.25 0 0 1 .29-.787l.09-.15a2.25 2.25 0 0 1 2.37-1.048l1.178.236a1.125 1.125 0 0 0 1.302-.795l.208-.73a1.125 1.125 0 0 0-.578-1.315l-.665-.332-.091.091a2.25 2.25 0 0 1-1.591.659h-.18c-.249 0-.487.1-.662.274a.931.931 0 0 1-1.458-1.137l1.411-2.353a2.25 2.25 0 0 0 .286-.76m11.928 9.869A9 9 0 0 0 8.965 3.525m11.928 9.868A9 9 0 1 1 8.965 3.525',
},
],
class: 'w-4 h-4 flex-shrink-0',
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '1.5',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
party: {
paths: [
{
d: 'M5.8 11.3 2 22l10.7-3.79',
},
{
d: 'M4 3h.01',
},
{
d: 'M22 8h.01',
},
{
d: 'M15 2h.01',
},
{
d: 'M22 20h.01',
},
{
d: 'm22 2-2.24.75a2.9 2.9 0 0 0-1.96 3.12v0c.1.86-.57 1.63-1.45 1.63h-.38c-.86 0-1.6.6-1.76 1.44L14 10',
},
{
d: 'm22 13-.82-.33c-.86-.34-1.82.2-1.98 1.11v0c-.11.7-.72 1.22-1.43 1.22H17',
},
{
d: 'm11 2 .33.82c.34.86-.2 1.82-1.11 1.98v0C9.52 4.9 9 5.52 9 6.23V7',
},
{
d: 'M11 13c1.93 1.93 2.83 4.17 2 5-.83.83-3.07-.07-5-2-1.93-1.93-2.83-4.17-2-5 .83-.83 3.07.07 5 2Z',
},
],
class:
'w-6 h-6 group-hover:text-steel dark:group-hover:text-steel transition-all duration-200 text-neutral-600 dark:text-neutral-300',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
email: {
paths: [
{
d: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
},
],
class:
'w-8 h-8 group-hover:text-steel dark:group-hover:text-steel transition-all duration-200 text-neutral-600 dark:text-neutral-300',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
sun: {
paths: [
{
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',
},
],
circleCx: '12',
circleCy: '12',
circleR: '5',
class:
'icon-light absolute h-5 w-5 scale-100 rotate-0 text-neutral-800 transition-all duration-500 dark:scale-0 dark:-rotate-90 dark:text-neutral-200',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
moon: {
paths: [
{
d: 'M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z',
},
],
class:
'icon-dark absolute h-5 w-5 scale-0 rotate-90 text-neutral-800 transition-all duration-500 dark:scale-100 dark:rotate-0 dark:text-neutral-200',
width: 24,
height: 24,
viewBox: '0 0 24 24',
fill: 'none',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
arrow: {
paths: [
{
d: 'M5.22 14.78a.75.75 0 001.06 0l7.22-7.22v5.69a.75.75 0 001.5 0v-7.5a.75.75 0 00-.75-.75h-7.5a.75.75 0 000 1.5h5.69l-7.22 7.22a.75.75 0 000 1.06z',
},
],
class:
'icon-dark absolute h-5 w-5 scale-0 rotate-90 text-neutral-800 transition-all duration-500 dark:scale-100 dark:rotate-0 dark:text-neutral-200',
width: 16,
height: 16,
viewBox: '0 0 20 20',
fill: 'none',
strokeWidth: '2',
strokeLinecap: 'round',
strokeLinejoin: 'round',
stroke: 'currentColor',
},
};

View File

@@ -1,17 +0,0 @@
---
import { Image } from 'astro:assets';
import { ImageMetadata } from 'astro';
import { blurStyle } from '@support/image';
interface FsPathImage extends ImageMetadata {
fsPath?: string;
}
const props = Astro.props;
const image = props.src as FsPathImage;
const showBlur = !props.disableBlur;
const blurCSS = image.fsPath && showBlur ? await blurStyle(image.fsPath) : {};
---
<Image {...props} style={blurCSS} inferSize={true} />

View File

@@ -1,11 +0,0 @@
---
import { readSingleton } from '@directus/sdk';
import Image from '@components/ui/images/Image.astro';
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} {...Astro.props} draggable="false" loading="eager" />

View File

@@ -1,129 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import { readItems } from '@directus/sdk';
import type { Education } from '@lib/directusTypes';
import directus from '@lib/directus';
const education = await directus.request(
readItems('site_education', {
fields: ['*'],
sort: ['-graduationDate'],
})
);
const certificate = await directus.request(
readItems('site_certificate', {
fields: ['*'],
sort: ['-issuerDate'],
})
);
const baseClasses = ' rounded-xl flex flex-col';
const borderClasses = 'border border-neutral-100 dark:border-stone-500/20';
const bgColorClasses =
'bg-neutral-100/80 hover:bg-neutral-100 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/90';
const shadowClasses = 'shadow-xs hover:shadow-md dark:shadow-md dark:hover:shadow-lg';
---
<section class:list={['order-first flex flex-col gap-4', Astro.props.className]}>
<h3
class="smooth-reveal-1 relative flex w-full items-center gap-3 pb-5 text-5xl text-neutral-800 dark:text-neutral-200"
>
Education
</h3>
<div class="ml-8">
<h4 class="smooth-reveal-1 pt-5 text-2xl font-semibold text-neutral-800 dark:text-neutral-200">
University
</h4>
<ul class="space-y-4 py-3">
{
education.map(({ institution, area, url }) => {
return (
<div class="smooth-reveal-cards mt-4 grid grid-cols-3 gap-4 rounded-xl">
<div>
<div
class={`p-4 transition-all duration-300 md:p-5 ${shadowClasses} ${bgColorClasses} ${baseClasses} ${borderClasses}`}
>
<h3 class="flex flex-row text-lg font-bold text-neutral-800 dark:text-neutral-200">
<Icon
name="mdi:university-outline"
class="mr-2 h-3 w-3 translate-y-1 md:h-5 md:w-5"
/>
{institution}
</h3>
<p class="mt-2 ml-7 text-xs font-medium text-neutral-600 uppercase dark:text-neutral-400">
{area}
</p>
<div class="ml-6 flex">
<a
class="group group-hover relative inline-block gap-x-1 rounded-lg border border-transparent disabled:pointer-events-none disabled:opacity-50"
href={url}
>
<div class="group-hover:text-steel dark:group-hover:text-bermuda transition-text relative z-10 mx-auto flex min-h-[44px] items-center text-sm font-semibold text-neutral-600 decoration-2 duration-300 sm:mx-0 sm:mt-4 dark:text-neutral-300">
<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>
</a>
</div>
</div>
</div>
</div>
);
})
}
</ul>
</div>
{
certificate.length > 0 && (
<div class="ml-8">
<h4 class="smooth-reveal-1 pt-8 text-2xl font-semibold text-neutral-800 dark:text-neutral-200">
Certificates
</h4>
<ul class="space-y-4 py-3">
{certificate.map(({ name, issuer, url }) => {
return (
<div class="smooth-reveal-cards mt-4 grid grid-cols-3 gap-4 rounded-xl">
<div>
<div
class={`p-4 transition-all duration-300 md:p-5 ${shadowClasses} ${bgColorClasses} ${baseClasses} ${borderClasses}`}
>
<h3 class="flex flex-row text-lg font-bold text-neutral-800 dark:text-neutral-200">
<Icon
name="mdi:script-text-outline"
class="mr-2 h-3 w-3 translate-y-1 md:h-5 md:w-5"
/>
{name}
</h3>
<p class="mt-2 ml-7 text-xs font-medium text-neutral-600 uppercase dark:text-neutral-400">
{issuer}
</p>
<div class="ml-6 flex">
<a
class="group group-hover relative inline-block gap-x-1 rounded-lg border border-transparent disabled:pointer-events-none disabled:opacity-50"
href={url}
>
<div class="group-hover:text-steel dark:group-hover:text-bermuda transition-text relative z-10 mx-auto flex min-h-[44px] items-center text-sm font-semibold text-neutral-600 decoration-2 duration-300 sm:mx-0 sm:mt-4 dark:text-neutral-300">
<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>
</a>
</div>
</div>
</div>
</div>
);
})}
</ul>
</div>
)
}
</section>

View File

@@ -1,152 +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', {
fields: ['*'],
sort: ['-endDate'],
})
);
---
<section
class:list={['flex flex-col gap-4', Astro.props.className]}
>
<h3 class="relative smooth-reveal-1 flex w-full items-center gap-3 pb-10 text-5xl text-neutral-800 dark:text-neutral-200">Experience</h3>
<ul class="ml-8 w-full flex flex-col">
{
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="group smooth-reveal-2 relative grid pb-1 transition-all sm:grid-cols-18 sm:gap-8 md:gap-6 lg:hover:!opacity-100">
<header class="relative mt-1 text-lg font-semibold sm:col-span-3 text-neutral-800 dark:text-neutral-200">
<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 pb-6 before:absolute before:mt-8 before:-ml-6 before:h-full before:w-px before:bg-stone-400 sm:col-span-12">
<div class="absolute mt-4 h-2 w-2 -translate-x-[1.71rem] rounded-full bg-stone-400" />
<h3>
<div
class="inline-flex items-center text-2xl leading-tight font-semibold"
aria-label="{position} - {company}"
>
<span class="text-neutral-800 dark:text-neutral-200">
{experience.position} <span>@</span>
{experience.url ? (
<a
class="hover:text-steel dark:hover:text-bermuda"
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-sm text-neutral-600 dark:text-neutral-400">
{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="font-semibold text-neutral-800 dark:text-neutral-200">Summary:</h4>
<ul class="flex list-disc flex-col gap-2 text-neutral-700 dark:text-neutral-400 [&>li]:ml-4">
{Array.isArray(experience.summary) ? (
experience.summary.map((item) => ({ item }))
) : (
<li class="marker:text-steel dark:marker:text-bermuda">{experience.summary}</li>
)}
</ul>
</div>
)}
{(experience.responsibilities || experience.achievements) && (
<div class="relative flex flex-col gap-4 max-sm:!h-auto md:after:absolute md:after:bottom-0 md:after:h-12 md:after:w-full md:after:bg-gradient-to-t md:after:from-neutral-200 dark:md:after:from-stone-700 md:after:content-[''] " :class="expanded ? 'after:hidden' : ''" x-show="expanded" x-collapse.min.50px>
{experience.responsibilities && (
<div class="flex flex-col gap-1">
<h4 class="font-semibold text-neutral-800 dark:text-neutral-200">Responsibilities:</h4>
<ul class="text-neutral-700 dark:text-neutral-400 [&>li]:ml-4 flex list-disc flex-col gap-2">
{experience.responsibilities.map(responsibility => (
<li class="marker:text-steel dark:marker:text-bermuda">{responsibility}</li>
))}
</ul>
</div>
)}
{experience.achievements && (
<div class="flex flex-col gap-1">
<h4 class="font-semibold text-neutral-800 dark:text-neutral-200">Achievements:</h4>
<ul class="text-neutral-700 dark:text-neutral-400 [&>li]:ml-4 flex list-disc flex-col gap-2">
{experience.achievements.map(achievement => (
<li class="marker:text-steel dark:marker:text-bermuda">{achievement}</li>
))}
</ul>
</div>
)}
</div>
<button @click="expanded = ! expanded" class="group/more w-fit cursor-pointer items-center justify-center gap-1.5 text-xs underline text-neutral-700 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-neutral-400 transition-all flex">
<span x-text="expanded ? 'Show less' : 'Show more'">Show more</span>
<svg
class="h-4 w-4 duration-200 ease-out group-hover/more:translate-y-0.5"
: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" aria-label="Technologies used">
{experience.skills && experience.skills.map(skill => {
const iconName = skill.toLowerCase();
return (
<li class="bg-steel/20 border-steel/20 text-neutral-800 dark:bg-bermuda/20 dark:border-bermuda/20 dark:text-neutral-200 flex gap-1 items-center border-solid border rounded-md px-2 py-0.5 text-xs">
<Icon name={`mdi:${iconName}`} /> <span>{skill}</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,37 +0,0 @@
---
import { readSingleton } from '@directus/sdk';
import directus from '@lib/directus';
import FeaturesCard from '@components/ui/cards/FeaturesCard.astro';
const global = await directus.request(readSingleton('site_global'));
---
<section class="mx-auto mb-20 max-w-[85rem] px-4 py-10 sm:px-6 lg:px-8 lg:py-14 2xl:max-w-full">
<div
class="flex flex-col items-center justify-center gap-y-2 sm:flex-row sm:gap-x-12 sm:gap-y-0 lg:gap-x-24"
>
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<div class="grid gap-3 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3">
<FeaturesCard
title="Cloud Engineer"
description="Full stack and cloud engineer."
url="/about"
icon="mdi:cloud-outline"
/>
<FeaturesCard
title="Homelab"
description="Tinkering, testing, deploying, etc, etc ..."
url="/categories/homelab/"
icon="mdi:home-variant-outline"
/>
<FeaturesCard
title="Email"
description={`Send me a message.`}
url=`mailto:${global.email}`
icon="mdi:email-fast"
/>
</div>
</div>
</div>
</section>

View File

@@ -1,35 +0,0 @@
---
import PrimaryCTA from '@components/ui/buttons/PrimaryCTA.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 block text-4xl font-bold tracking-tight text-balance text-neutral-800 md:text-5xl lg:text-6xl dark:text-neutral-200"
>
{title}
</h1>
<p class="smooth-reveal mt-4 text-lg text-pretty text-neutral-600 dark:text-neutral-400">
{subTitle}
</p>
{
btnExists ? (
<div class="smooth-reveal mt-4 md:mt-8">
<PrimaryCTA title={btnTitle} url={btnURL} />
</div>
) : null
}
</div>
</div>
</section>

View File

@@ -1,63 +0,0 @@
---
import PrimaryCTA from '@components/ui/buttons/PrimaryCTA.astro';
import SecondaryCTA from '@components/ui/buttons/SecondaryCTA.astro';
import Image from '@components/ui/images/Image.astro';
const { title, subTitle, primaryBtn, primaryBtnURL, secondaryBtn, secondaryBtnURL, src, alt } =
Astro.props;
interface Props {
title: string;
subTitle?: string;
primaryBtn?: string;
primaryBtnURL?: string;
secondaryBtn?: string;
secondaryBtnURL?: string;
src?: any;
alt?: string;
}
---
<section
class="mx-auto grid max-w-[85rem] 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 block text-3xl font-bold tracking-tight text-balance text-neutral-800 sm:text-4xl lg:text-7xl lg:leading-tight dark:text-neutral-200"
>
<Fragment set:html={title} />
</h1>
{
subTitle && (
<p class="smooth-reveal mt-6 text-lg leading-relaxed text-pretty text-neutral-700 lg:w-4/5 dark:text-neutral-300">
{subTitle}
</p>
)
}
<div class="smooth-reveal mt-7 grid w-full gap-3 sm:inline-flex">
{primaryBtn && <PrimaryCTA title={primaryBtn} url={primaryBtnURL} />}
{secondaryBtn && <SecondaryCTA title={secondaryBtn} url={secondaryBtnURL} />}
</div>
</div>
<div class="smooth-reveal-fade hidden w-full md:block">
<div class="top-12 flex w-full justify-center overflow-hidden md:ml-4">
{
src && alt && (
<Image
src={src}
alt={alt}
class="h-full w-[420px] scale-100 object-cover object-center"
draggable="false"
loading="eager"
format="webp"
quality="low"
widths={[840]}
disableBlur={true}
/>
)
}
</div>
</div>
</section>

View File

@@ -1,164 +0,0 @@
---
import GiteaBtn from '@components/ui/buttons/GiteaBtn.astro';
const { title, subTitle, url } = Astro.props;
const btnTitle = 'Continue to Gitea';
interface Props {
title: string;
subTitle?: string;
url?: string;
}
---
<section class="lg:px- relative mx-auto mb-20 max-w-[85rem] px-4 pt-30 pb-30 sm:px-6">
<div
class="smooth-reveal absolute top-[55%] left-0 scale-90 md:top-[20%] xl:top-[25%] xl:left-[10%]"
>
<svg
class="animate-hover 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="animate-hover 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="animate-hover 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>
<!-- Hero Section Heading -->
<div class="smooth-reveal-2 mx-auto mt-5 max-w-xl text-center">
<h2
class="block text-4xl leading-tight font-bold tracking-tight text-balance text-neutral-800 md:text-5xl lg:text-5xl dark:text-neutral-200"
>
{title}
</h2>
</div>
<!-- Hero Section Sub-heading -->
<div class="smooth-reveal-2 mx-auto mt-5 max-w-3xl text-center">
{
subTitle && (
<p class="text-lg text-pretty text-neutral-600 dark:text-neutral-400">{subTitle}</p>
)
}
</div>
<!-- Github Button -->
{
url && (
<div class="smooth-reveal-2 mt-8 flex justify-center gap-3">
<GiteaBtn url={url} title={btnTitle} />
</div>
)
}
</section>
<style>
@keyframes animate-hover {
from {
transform: translateY(15px);
}
to {
transform: translateY(-15px);
}
}
.animate-hover {
animation: animate-hover ease-in-out;
animation-iteration-count: infinite;
animation-direction: alternate;
}
.animate-hover-1 {
animation-duration: 5s;
}
.animate-hover-2 {
animation-duration: 5.5s;
}
.animate-hover-3 {
animation-duration: 6s;
}
</style>

View File

@@ -1,35 +0,0 @@
---
import { readItems } from '@directus/sdk';
import directus from '@lib/directus';
import type { Post } from '@lib/directusTypes';
import BlogCard from '@components/blog/BlogCard.astro';
const posts = await directus.request(
readItems('posts', {
filter: { published: { _eq: true } },
fields: ['*'],
sort: ['-published_date'],
})
);
const recentPosts = posts
.sort((a: Post, b: Post) => b.published_date.getTime() - a.published_date.getTime())
.slice(0, 3);
---
<section class="mx-auto mb-20 max-w-[85rem] px-4 py-10 sm:px-6 lg:px-8 lg:py-14 2xl:max-w-full">
<div class="mx-auto mb-10 max-w-2xl text-center lg:mb-14">
<h1
class="smooth-reveal block text-4xl font-bold text-neutral-800 md:text-5xl md:leading-tight lg:text-5xl dark:text-neutral-200"
>
Latest Posts
</h1>
<p class="smooth-reveal mt-1 text-pretty text-neutral-600 dark:text-neutral-300">
More recent posts.
</p>
</div>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{recentPosts.map((b) => <BlogCard post={b} />)}
</div>
</section>

View File

@@ -1,75 +0,0 @@
---
import { Icon } from 'astro-icon/components';
import { readItems } from '@directus/sdk';
import type { Project } from '@lib/directusTypes';
import directus from '@lib/directus';
const projects = await directus.request(
readItems('site_projects', {
fields: ['*'],
sort: ['-isActive'],
})
);
const baseClasses = 'smooth-reveal-cards rounded-xl flex flex-col';
const borderClasses = 'border border-neutral-100 dark:border-stone-500/20';
const bgColorClasses =
'bg-neutral-100/80 hover:bg-neutral-100 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/90';
const shadowClasses = 'shadow-xs hover:shadow-md dark:shadow-md dark:hover:shadow-lg';
---
<section class:list={['flex flex-col gap-4', Astro.props.className]}>
<h3
class="relative flex w-full items-center gap-3 pb-10 text-5xl text-neutral-800 dark:text-neutral-200"
>
Projects
</h3>
<div class="ml-8 grid grid-cols-1 gap-3 md:grid-cols-2 print:flex print:flex-col">
{
projects.map((project: Project) => {
return (
<div class={`${baseClasses}`}>
<div
class={`rounded-xl transition-all duration-300 ${borderClasses} ${bgColorClasses} ${shadowClasses}`}
>
<div class="p-4 md:p-10">
<h3 class="text-lg font-bold text-gray-800 dark:text-white">{project.name}</h3>
<p class="mt-2 text-gray-500 dark:text-neutral-400">{project.description}</p>
<ul class="mt-1 flex list-disc flex-col gap-2 text-sm text-gray-500 dark:text-neutral-400 [&>li]:ml-4">
{project.highlights.map((highlight) => {
return <li class="marker:text-yellow-500">{highlight}</li>;
})}
</ul>
<div class="flex">
<a
class="group group-hover relative inline-block gap-x-1 rounded-lg border border-transparent disabled:pointer-events-none disabled:opacity-50"
href={project.url}
>
<div class="group-hover:text-steel dark:group-hover:text-bermuda transition-text text-md relative z-10 mx-auto flex min-h-[44px] items-center font-semibold text-neutral-600 decoration-2 duration-300 sm:mx-0 sm:mt-4 dark:text-neutral-300">
<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>
</a>
<a
class="group group-hover relative ml-auto inline-block gap-x-1 rounded-lg border border-transparent disabled:pointer-events-none disabled:opacity-50"
href={project.source}
>
<div class="group-hover:text-gitea-primary dark:group-hover:text-gitea-primary transition-text text-md relative z-10 mx-auto flex min-h-[44px] items-center font-semibold text-neutral-600 decoration-2 duration-300 sm:mx-0 sm:mt-4 dark:text-neutral-300">
<span class="relative inline-block overflow-hidden"> Source </span>
<Icon name="pajamas:gitea" class="ml-2 translate-y-0.5" />
</div>
</a>
</div>
</div>
</div>
</div>
);
})
}
</div>
</section>

View File

@@ -1,250 +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', {
fields: ['*'],
sort: ['-date_created'],
})
);
const baseClasses = 'mx-2 min-w-[220px] sm:mx-4 sm:min-w-[280px]';
const borderClasses =
'border border-neutral-100 hover:border-neutral-200 dark:border-stone-500/20 dark:hover:border-neutral-800';
const bgColorClasses = 'bg-neutral-100/80 dark:bg-neutral-800/60 dark:hover:bg-neutral-800/90';
const hoverClasses = 'hover:-translate-y-2 hover:scale-105 ';
const shadowClasses = 'shadow-xs hover:shadow-lg';
---
<section class:list={['flex flex-col gap-4', Astro.props.className]}>
<h3
class="relative flex w-full items-center gap-3 pb-4 text-5xl text-neutral-800 dark:text-neutral-200"
>
Skills
</h3>
<div class="">
<div class="tech-stack-slider relative overflow-hidden py-4 sm:py-8">
<!-- Main slider container -->
<div class="slider-track animate-slide flex">
{
[...skills, ...skills, ...skills].map((skill: Skill) => {
return (
<div
class={`skill-card transform rounded-xl transition-all duration-300 ${baseClasses} ${borderClasses} ${bgColorClasses} ${hoverClasses} ${shadowClasses}`}
>
<div class="p-4 sm:p-6">
<div class="mb-4 flex items-center justify-between sm:mb-6">
<div class="flex items-center gap-2 sm:gap-4">
<div class="flex transform items-center justify-center rounded-lg text-neutral-800 transition-transform group-hover:rotate-12 dark:text-neutral-200">
<Icon name={skill.icon} class="h-8 w-8 sm:h-12 sm:w-12" />
</div>
<h3 class="text-base font-semibold text-neutral-900 sm:text-xl dark:text-neutral-100">
{skill.title}
</h3>
</div>
<span class="rounded-full bg-neutral-200 px-2 py-0.5 font-mono text-xs text-neutral-700 sm:px-2.5 sm:py-1 sm:text-sm dark:bg-neutral-800 dark:text-neutral-300">
{skill.level}%
</span>
</div>
<div class="relative h-1.5 w-full overflow-hidden rounded-full bg-stone-500/20 sm:h-2 dark:bg-stone-500/20">
<div
class="progress-bar-animate from-steel via-bermuda to-steel absolute top-0 left-0 h-full rounded-full bg-gradient-to-r transition-all duration-1000"
style={`width: ${skill.level}%`}
/>
</div>
<div class="mt-1 flex justify-between font-mono text-[10px] text-neutral-600 sm:mt-2 sm:text-xs dark:text-neutral-400">
<span>Beginner</span>
<span>Advanced</span>
</div>
</div>
</div>
);
})
}
</div>
<!-- Gradient overlays for smooth fade effect -->
<div
class="absolute top-0 bottom-0 left-0 z-10 w-12 bg-gradient-to-r from-neutral-200 to-transparent sm:w-24 dark:from-stone-700"
>
</div>
<div
class="absolute top-0 right-0 bottom-0 z-10 w-12 bg-gradient-to-l from-neutral-200 to-transparent sm:w-24 dark:from-stone-700"
>
</div>
</div>
</div>
</section>
<script>
document.addEventListener('astro:page-load', () => {
// Create seamless infinite scrolling effect
function setupInfiniteScroll() {
const cards = document.querySelectorAll('.skill-card');
if (!cards.length) return;
}
setupInfiniteScroll();
// Add hover effects to cards - only on non-touch devices
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>
/* Tech Stack Slider */
.slider-track {
width: fit-content;
animation: scroll 40s linear infinite;
}
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-220px * 6 - 16px * 6)); /* Card width + margin for mobile */
}
}
@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)); /* Card width + margin for desktop */
}
}
}
.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,66 +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 WorkInformation = [
{
name: 'Tech Startup',
position: 'Junior Web Developer',
location_type: 'On site',
location: 'Auckland, New Zealand',
url: 'https://techstartup.com',
startDate: '2024-01-01',
endDate: null,
summary:
'Developing and maintaining web applications using JavaScript, HTML, and CSS. Collaborating with the team to implement new features and fix bugs.',
highlights: ['Improved website performance by optimizing code'],
responsibilities: [
'Collaborated with senior developers to design and implement web applications using modern JavaScript frameworks.',
'Assisted in debugging and optimizing front-end code to ensure smooth user experiences.',
'Participated in code reviews and contributed to improving coding standards within the team.',
],
achievements: [
'Developing and maintaining web applications using JavaScript, HTML, and CSS. Collaborating with the team to implement new features and fix bugs.',
],
skills: ['React', 'Tailwind', 'GitHub'],
},
];
export const NavigationLinks: NavigationLink[] = [
{ name: 'Home', url: '/' },
{ name: 'Blog', url: '/blog/' },
{ name: 'Categories', url: '/categories/' },
{ name: 'About Me', url: '/about/' },
];
export const FooterLinks: NavigationLink[] = [
{ name: 'RSS', url: '/rss.xml' },
{ name: 'Gitea', url: '/https://gitea.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'
---

View File

@@ -1,4 +0,0 @@
---
title: 'Kubernetes ☸️'
description: 'The container orchestration system'
---

View File

@@ -1,4 +0,0 @@
---
title: 'Life 🏃🏻'
description: 'Just random musings on everyday stuff'
---

View File

@@ -1,4 +0,0 @@
---
title: 'Minnesota 🌳'
description: 'Land of 10,000 Lakes'
---

View File

@@ -1,4 +0,0 @@
---
title: 'PostgreSQL'
description: 'PostgreSQL is an open-source relational database management system (RDBMS)'
---

View File

@@ -1,4 +0,0 @@
---
title: 'Python 🐍'
description: 'Generally my go to language'
---

View File

@@ -1,4 +0,0 @@
---
title: 'Tool 🪜'
description: 'Usually just the software kind'
---

View File

@@ -1,4 +0,0 @@
---
title: 'What Is?'
description: 'A series on discovery'
---

View File

@@ -1,12 +0,0 @@
import { defineCollection, z } from 'astro:content';
const categoryCollection = defineCollection({
type: 'content',
schema: () =>
z.object({
title: z.string(),
description: z.string(),
}),
});
export const collections = { categories: categoryCollection };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

17
src/layouts/Base.astro Normal file
View File

@@ -0,0 +1,17 @@
---
import Layout from './Layout.astro';
import directus from '../../lib/directus';
import { readSingleton } from '@directus/sdk';
const global = await directus.request(readSingleton('global'));
export interface Props {
title: string;
description?: string;
}
---
<Layout title={global.title} description={global.description}>
<slot />
</Layout>

View File

@@ -1,93 +1,59 @@
---
import { ClientRouter } from 'astro:transitions';
import Layout from './Layout.astro';
import directus from '../../lib/directus';
import { readSingleton } from '@directus/sdk';
import directus from '@lib/directus';
import BaseHead from '@components/BaseHead.astro';
import Footer from '@components/Footer.astro';
import Header from '@components/Header.astro';
const global = await directus.request(readSingleton('global'));
import '@styles/global.css';
interface Props {
title?: string;
export interface Props {
title: string;
description?: string;
ogImage?: any;
lang?: string;
structuredData?: object;
}
const { title, description = 'Alex Lebens', ogImage, lang = 'en', structuredData } = Astro.props;
const global = await directus.request(readSingleton('site_global'));
const normalizeTitle = !title ? global.name : `${title} | ${global.name}`;
---
<html lang={lang}>
<head>
<title>{normalizeTitle}</title>
<BaseHead
title={normalizeTitle}
description={description}
ogImage={ogImage}
ogTitle={title === '' ? global.name : title}
ogDescription={description}
structuredData={structuredData}
/>
<ClientRouter fallback="swap" />
<script is:inline>
const theme = (() => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
}
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
})();
<Layout title={global.title} description={global.description}>
<slot />
</Layout>
if (theme === 'light') {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
window.localStorage.setItem('theme', theme);
</script>
</head>
<body class="bg-stone-200 selection:bg-yellow-400 selection:text-neutral-700 dark:bg-stone-700">
<!-- <div class="fixed inset-0 -z-10">
<div
class="bg-grid-pattern absolute inset-0 [mask-image:radial-gradient(white,transparent_85%)] bg-[center_top_-1px]"
>
</div>
</div> -->
<div class="mx-auto w-full max-w-(--breakpoint-2xl) flex-grow px-4 sm:px-6 lg:px-8">
<Header />
<main class="min-h-screen">
<slot />
</main>
</div>
<Footer />
<style>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
</body>
</html>
<script>
document.addEventListener('DOMContentLoaded', () => {
const themeToggle = document.getElementById('theme-toggle');
<style>
.bg-grid-pattern {
background-size: 24px 24px;
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.2) 1px, transparent 1px);
transition: background-image 0.7s cubic-bezier(0.65, 0, 0.35, 1);
}
if (themeToggle) {
themeToggle.addEventListener('click', () => {
document.documentElement.classList.add('theme-switching');
:global(.dark) .bg-grid-pattern {
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.25) 1px, transparent 1px);
}
</style>
const rippleElements = document.querySelectorAll('.theme-ripple');
rippleElements.forEach((el) => {
el.classList.add('ripple-active');
setTimeout(() => {
el.classList.remove('ripple-active');
}, 600);
});
const event = new CustomEvent('themeChange', {
detail: {
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
},
});
document.dispatchEvent(event);
setTimeout(() => {
document.documentElement.classList.remove('theme-switching');
}, 600);
});
}
const socialLinks = document.querySelectorAll('.social-link');
socialLinks.forEach((link) => {
link.addEventListener('mouseenter', () => {
link.classList.add('hover-active');
});
link.addEventListener('mouseleave', () => {
link.classList.remove('hover-active');
});
});
});
</script>

398
src/layouts/BlogPost.astro Normal file
View File

@@ -0,0 +1,398 @@
---
import Layout from './Layout.astro';
import FormattedDate from '../components/FormattedDate.astro';
import ShareButtons from '../components/ShareButtons.astro';
import TagList from '../components/TagList.astro';
import './styles/markdown.css';
import directus from '../../lib/directus';
import { readItems } from '@directus/sdk';
export async function getStaticPaths() {
const posts = await directus.request(
readItems('posts', {
fields: ['*'],
})
);
return posts.map((post) => ({ params: { slug: post.slug }, props: post }));
}
const post = Astro.props;
const published_date: string = post.published_date.toLocaleString();
let canonicalURL;
try {
canonicalURL = new URL(Astro.url.pathname, Astro.site || process.env.SITE_URL);
} catch (error) {
console.error('Error creating canonical URL:', error);
canonicalURL = new URL('https://www.example.com');
}
---
<Layout title={post.title} description={post.description}>
<article class="prose prose-zinc mx-auto max-w-4xl dark:prose-invert lg:prose-lg">
<div class="mb-12">
<h1
class="mb-4 text-4xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl"
>
{post.title}
</h1>
<div class="mb-6 flex items-center gap-x-4 text-sm text-zinc-500 dark:text-zinc-400">
<FormattedDate date={published_date} />
</div>
<TagList tags={post.tags} class="mt-2" />
</div>
<!-- Hero image -->
{
post.image && (
<div class="relative mb-8 overflow-hidden rounded-xl shadow-lg sm:mb-12">
<div class="aspect-[16/9] w-full">
<img
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${post.image}?width=500`}
alt={post.image_alt}
class="h-full w-full object-cover"
loading="eager"
/>
</div>
<div class="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
</div>
)
}
<div class="markdown-content">
<slot />
</div>
<!-- Add the like button after the content -->
<div class="mt-12 border-t border-zinc-200 pt-8 dark:border-zinc-800">
<div class="flex flex-col items-center justify-between gap-6 sm:flex-row">
<ShareButtons url={canonicalURL.toString()} title={post.title} />
<!-- Convert URL to string -->
</div>
</div>
{
post.updated_date && (
<div class="mt-8 text-sm italic text-zinc-500 dark:text-zinc-400">
Last updated on <FormattedDate date={post.updated_date} />
</div>
)
}
</article>
<slot name="after-article" />
</Layout>
<script>
// Blog post SPA transitions
function setupBlogPostTransitions() {
// Animate article entrance
const article = document.querySelector('article');
if (article) {
article.classList.add('article-entering');
// Remove class after animation completes
setTimeout(() => {
article.classList.remove('article-entering');
}, 1000);
}
// Ensure consistent code block styling
function updateCodeBlockStyles() {
document.querySelectorAll('pre').forEach((pre) => {
// Force the background color with !important for both light and dark mode
pre.setAttribute('style', 'background-color: #1e293b !important');
// Also apply to any nested code elements
const codeElements = pre.querySelectorAll('code');
codeElements.forEach((code) => {
code.setAttribute(
'style',
'background-color: transparent !important; color: #e5e7eb !important;'
);
});
});
}
// Initial application
updateCodeBlockStyles();
// Watch for theme changes
const observer = new MutationObserver(() => {
updateCodeBlockStyles();
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
// Also run on any content changes that might add new code blocks
const contentObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.addedNodes.length) {
updateCodeBlockStyles();
break;
}
}
});
contentObserver.observe(document.body, { childList: true, subtree: true });
// Clean up observers when navigating away
document.addEventListener('spa-navigation-start', () => {
observer.disconnect();
contentObserver.disconnect();
});
// Remove the parallax effect for hero image
// Handle prev/next navigation links
const navLinks = document.querySelectorAll('.blog-nav-link');
navLinks.forEach((link) => {
if (!link.hasAttribute('data-spa-handled')) {
link.setAttribute('data-spa-handled', 'true');
link.addEventListener('mouseenter', () => {
link.classList.add('nav-link-hover');
});
link.addEventListener('mouseleave', () => {
link.classList.remove('nav-link-hover');
});
}
});
// Animate headings when they enter the viewport
const animateHeadings = () => {
const headings = document.querySelectorAll('article h2, article h3');
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('heading-visible');
observer.unobserve(entry.target);
}
});
},
{
threshold: 0.2,
rootMargin: '0px 0px -100px 0px',
}
);
headings.forEach((heading) => {
heading.classList.add('heading-animated');
observer.observe(heading);
});
return observer;
};
// Initialize heading animations
const headingObserver = animateHeadings();
// Enhance code blocks with syntax highlighting and copy button
function enhanceCodeBlocks() {
const codeBlocks = document.querySelectorAll('pre code');
codeBlocks.forEach((codeBlock) => {
// Skip if already processed
if (codeBlock.parentElement.classList.contains('enhanced')) return;
// Mark as enhanced
codeBlock.parentElement.classList.add('enhanced');
// Create copy button
const copyButton = document.createElement('button');
copyButton.className = 'copy-code-button';
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
</svg>
`;
// Add copy functionality
copyButton.addEventListener('click', () => {
const code = codeBlock.textContent;
navigator.clipboard.writeText(code);
// Show copied feedback
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
`;
setTimeout(() => {
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
</svg>
`;
}, 2000);
});
// Add copy button to pre element
codeBlock.parentElement.appendChild(copyButton);
// Fix line numbers implementation
const codeText = codeBlock.textContent;
const lines = codeText.split('\n');
const lineNumbers = document.createElement('div');
lineNumbers.className = 'line-numbers';
// Always include all lines, including empty ones
for (let i = 0; i < lines.length; i++) {
const lineNumber = document.createElement('span');
lineNumber.textContent = i + 1;
lineNumbers.appendChild(lineNumber);
}
codeBlock.parentElement.classList.add('with-line-numbers');
codeBlock.parentElement.insertBefore(lineNumbers, codeBlock);
// Fix language label detection and display
const className = codeBlock.className;
const languageMatch = className.match(/language-(\w+)/);
if (languageMatch && languageMatch[1]) {
const language = languageMatch[1];
// Add language label at top right
const languageLabel = document.createElement('div');
languageLabel.className = 'language-label';
languageLabel.textContent = language;
codeBlock.parentElement.appendChild(languageLabel);
// Add language badge at bottom right with markdown syntax
const languageBadge = document.createElement('div');
languageBadge.className = 'language-badge';
languageBadge.textContent = `\`\`\`${language}`;
languageBadge.style.position = 'absolute';
languageBadge.style.bottom = '0.5rem';
languageBadge.style.right = '0.5rem';
languageBadge.style.fontSize = '0.7rem';
languageBadge.style.padding = '0.1rem 0.3rem';
languageBadge.style.backgroundColor = 'rgba(75, 85, 99, 0.7)';
languageBadge.style.color = '#e5e7eb';
languageBadge.style.borderRadius = '0.25rem';
languageBadge.style.fontFamily =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
languageBadge.style.zIndex = '10';
codeBlock.parentElement.appendChild(languageBadge);
}
});
}
// Enhance tables with better styling
function enhanceTables() {
const tables = document.querySelectorAll('.markdown-content table');
tables.forEach((table) => {
if (table.classList.contains('enhanced-table')) return;
table.classList.add('enhanced-table');
// Wrap table in responsive container
const wrapper = document.createElement('div');
wrapper.className = 'table-container';
table.parentNode.insertBefore(wrapper, table);
wrapper.appendChild(table);
// Add zebra striping to rows
const rows = table.querySelectorAll('tbody tr');
rows.forEach((row, index) => {
if (index % 2 === 0) {
row.classList.add('even-row');
} else {
row.classList.add('odd-row');
}
});
});
}
// Enhance blockquotes with icons
function enhanceBlockquotes() {
const blockquotes = document.querySelectorAll('.markdown-content blockquote');
blockquotes.forEach((blockquote) => {
if (blockquote.classList.contains('enhanced-quote')) return;
blockquote.classList.add('enhanced-quote');
// Add quote icon
const icon = document.createElement('div');
icon.className = 'quote-icon';
icon.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
</svg>
`;
blockquote.insertBefore(icon, blockquote.firstChild);
});
}
// Run all enhancements
enhanceCodeBlocks();
enhanceTables();
enhanceBlockquotes();
// Clean up observers when navigating away
document.addEventListener('spa-navigation-start', () => {
if (headingObserver) {
headingObserver.disconnect();
}
});
}
// Initialize on first load
document.addEventListener('DOMContentLoaded', setupBlogPostTransitions);
// Re-initialize when content changes via Astro's view transitions
document.addEventListener('astro:page-load', setupBlogPostTransitions);
// For compatibility with custom transition system
document.addEventListener('page-transition-complete', setupBlogPostTransitions);
// Also initialize when SPA navigation completes
document.addEventListener('spa-navigation-complete', setupBlogPostTransitions);
</script>
<style>
/* Enhanced hero image styling */
article img:first-of-type {
border-radius: 1rem;
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.1),
0 8px 10px -6px rgba(0, 0, 0, 0.1);
transition: transform 0.3s ease;
}
article img:first-of-type:hover {
transform: scale(1.01);
}
/* Article entrance animation */
.article-entering {
animation: article-fade-in 0.8s ease-out forwards;
}
@keyframes article-fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Rest of the styles remain unchanged... */
</style>

336
src/layouts/Layout.astro Normal file
View File

@@ -0,0 +1,336 @@
---
import Navigation from '../components/Navigation.astro';
import Footer from '../components/Footer.astro';
import Background from '../components/Background.astro';
import '../styles/global.css';
interface Props {
title?: string | undefined;
description?: string | undefined;
}
const { title, description } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="generator" content={Astro.generator} />
<meta name="description" content={description} />
<title>{title}</title>
<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=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body
class="flex min-h-screen flex-col bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100"
>
<!-- Page transition overlay - for smooth transitions between pages -->
<div
id="page-transition"
class="pointer-events-none fixed inset-0 z-40 flex items-center justify-center bg-white opacity-0 transition-opacity duration-300 dark:bg-zinc-900"
>
<div class="transition-spinner"></div>
</div>
<!-- Background component with dot pattern and ambient glow -->
<Background />
<div class="mx-auto w-full max-w-3xl flex-grow px-4 sm:px-6">
<Navigation />
<main class="py-12">
<slot />
</main>
</div>
<Footer />
<script>
// SPA transition system with history API
document.addEventListener('DOMContentLoaded', () => {
const pageTransition = document.getElementById('page-transition');
const mainContent = document.querySelector('main');
// Initialize content with entrance animation
if (mainContent) {
mainContent.classList.add('content-entering');
setTimeout(() => {
mainContent.classList.remove('content-entering');
}, 800);
}
// Function to load content via fetch
async function loadContent(url) {
try {
// Show transition overlay
if (pageTransition) {
pageTransition.classList.remove('opacity-0', 'pointer-events-none');
pageTransition.classList.add('opacity-100');
}
// Fade out current content
if (mainContent) {
mainContent.style.opacity = '0';
mainContent.style.transform = 'translateY(10px)';
}
// Fetch the new page content
const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch ${url}`);
const html = await response.text();
// Create a temporary element to parse the HTML
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// Extract the main content
const newContent = doc.querySelector('main');
if (!newContent) throw new Error('Could not find main content in the fetched page');
// Extract the title
const newTitle = doc.querySelector('title');
if (newTitle) {
document.title = newTitle.textContent;
}
// Extract meta description
const newDescription = doc.querySelector('meta[name="description"]');
if (newDescription) {
const currentDescription = document.querySelector('meta[name="description"]');
if (currentDescription) {
currentDescription.setAttribute(
'content',
newDescription.getAttribute('content') || ''
);
}
}
// Wait a bit for transition effect
await new Promise((resolve) => setTimeout(resolve, 300));
// Replace the content
if (mainContent && newContent) {
mainContent.innerHTML = newContent.innerHTML;
// Run scripts in the new content
Array.from(newContent.querySelectorAll('script')).forEach((oldScript) => {
const newScript = document.createElement('script');
Array.from(oldScript.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value);
});
newScript.textContent = oldScript.textContent;
if (oldScript.parentNode) {
mainContent.appendChild(newScript);
}
});
}
// Fade in new content with animation
if (mainContent) {
mainContent.style.opacity = '0';
mainContent.style.transform = 'translateY(10px)';
setTimeout(() => {
mainContent.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
mainContent.style.opacity = '1';
mainContent.style.transform = 'translateY(0)';
// Add entrance animation class
mainContent.classList.add('content-entering');
setTimeout(() => {
mainContent.classList.remove('content-entering');
}, 800);
}, 50);
}
// Hide transition overlay
if (pageTransition) {
setTimeout(() => {
pageTransition.classList.add('opacity-0', 'pointer-events-none');
pageTransition.classList.remove('opacity-100');
}, 200);
}
// Dispatch custom event for content loaded
document.dispatchEvent(
new CustomEvent('spa-content-loaded', {
detail: { url },
})
);
// Scroll to top or to saved position
window.scrollTo(0, 0);
// Re-attach event listeners to new content
attachLinkListeners();
} catch (error) {
console.error('Error loading content:', error);
// Fallback to traditional navigation on error
window.location.href = url;
}
}
// Function to attach event listeners to all links
function attachLinkListeners() {
document.querySelectorAll('a').forEach((link) => {
// Skip links that are already handled, anchor links, external links, or have special attributes
if (
link.hasAttribute('data-spa-handled') ||
!link.href.startsWith(window.location.origin) ||
link.href.includes('#') ||
link.hasAttribute('target') ||
link.hasAttribute('download') ||
link.getAttribute('rel') === 'external' ||
link.getAttribute('rel') === 'nofollow'
) {
return;
}
// Mark as handled to avoid duplicate listeners
link.setAttribute('data-spa-handled', 'true');
link.addEventListener('click', (e) => {
// Don't handle if modifier keys are pressed (for opening in new tab, etc.)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
const targetHref = link.href;
// Don't transition if clicking the current page
if (targetHref === window.location.href) {
return;
}
// Update browser history
window.history.pushState({ path: targetHref }, '', targetHref);
// Load the new content
loadContent(targetHref);
});
});
}
// Initial attachment of link listeners
attachLinkListeners();
// Handle browser back/forward navigation
window.addEventListener('popstate', (e) => {
if (e.state && e.state.path) {
loadContent(e.state.path);
} else {
loadContent(window.location.href);
}
});
// Check RSS feed availability
const checkAndGenerateRSS = async () => {
try {
const response = await fetch('/rss.xml');
if (!response.ok) {
console.warn('RSS feed not found. Please generate it using an RSS plugin for Astro.');
}
} catch (error) {
console.warn('Could not check RSS feed status.');
}
};
// Check RSS feed availability
checkAndGenerateRSS();
});
// Theme handling with transition effects
function setupThemeHandling() {
// Apply theme from localStorage or system preference
const theme = localStorage.getItem('theme');
if (
theme === 'dark' ||
(!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Listen for theme changes
document.addEventListener('themeChanged', () => {
// Add transition class to body
document.body.classList.add('theme-transitioning');
// Remove class after transition completes
setTimeout(() => {
document.body.classList.remove('theme-transitioning');
}, 500);
});
}
// Initialize theme handling
document.addEventListener('DOMContentLoaded', setupThemeHandling);
</script>
</body>
</html>
<style>
/* Page transition effects */
#page-transition {
transition: opacity 0.3s ease;
backdrop-filter: blur(4px);
}
/* Transition spinner animation */
.transition-spinner {
width: 30px;
height: 30px;
border: 2px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #3b82f6;
animation: spin 0.7s linear infinite;
}
:global(.dark) .transition-spinner {
border-color: rgba(255, 255, 255, 0.1);
border-top-color: #60a5fa;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Content entrance animation */
main {
opacity: 1;
transform: translateY(0);
transition:
opacity 0.5s ease,
transform 0.5s ease;
}
main.content-entering {
animation: content-fade-in 0.6s ease forwards;
}
@keyframes content-fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Theme transition effect */
body.theme-transitioning * {
transition-duration: 0.3s !important;
}
</style>

View File

@@ -0,0 +1,25 @@
---
import { ViewTransitions } from 'astro:transitions';
import BaseLayout from './BaseLayout.astro';
const { title, description } = Astro.props;
---
<BaseLayout title={title} description={description}>
<ViewTransitions fallback="swap" />
<div transition:animate="slide">
<slot />
</div>
</BaseLayout>
<style>
/* Custom transition styles */
::view-transition-old(root) {
animation: 0.5s cubic-bezier(0.76, 0, 0.24, 1) both slide-to-left;
}
::view-transition-new(root) {
animation: 0.5s cubic-bezier(0.76, 0, 0.24, 1) both slide-from-right;
}
</style>

View File

@@ -0,0 +1,890 @@
/* Article entrance animation */
article {
opacity: 1;
transform: translateY(0);
}
article.article-entering {
animation: article-fade-in 0.8s ease forwards;
}
@keyframes article-fade-in {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Hero image hover effect */
article img {
transition: transform 0.7s cubic-bezier(0.33, 1, 0.68, 1);
}
/* Heading animations */
article .heading-animated {
opacity: 0;
transform: translateY(10px);
transition:
opacity 0.5s ease,
transform 0.5s ease;
}
article .heading-visible {
opacity: 1;
transform: translateY(0);
}
/* Navigation link hover effect */
.blog-nav-link {
transition:
transform 0.3s ease,
box-shadow 0.3s ease;
}
.blog-nav-link.nav-link-hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.1);
}
/* Ensure dark mode compatibility */
:global(.dark) .blog-nav-link.nav-link-hover {
box-shadow: 0 10px 20px -10px rgba(0, 0, 0, 0.3);
}
/* Enhanced Markdown Content Styling */
.markdown-content {
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Open Sans',
'Helvetica Neue',
sans-serif;
line-height: 1.7;
color: #374151;
}
.dark .markdown-content {
color: #e5e7eb;
}
/* Headings */
.markdown-content h1 {
font-size: 2.5rem;
font-weight: 800;
margin-top: 2.5rem;
margin-bottom: 1.5rem;
line-height: 1.2;
color: #111827;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.5rem;
}
.dark .markdown-content h1 {
color: #f9fafb;
border-bottom-color: #374151;
}
.markdown-content h2 {
font-size: 2rem;
font-weight: 700;
margin-top: 2.5rem;
margin-bottom: 1rem;
line-height: 1.3;
color: #111827;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 0.5rem;
}
.dark .markdown-content h2 {
color: #f9fafb;
border-bottom-color: #374151;
}
.markdown-content h3 {
font-size: 1.5rem;
font-weight: 600;
margin-top: 2rem;
margin-bottom: 1rem;
line-height: 1.4;
color: #111827;
}
.dark .markdown-content h3 {
color: #f9fafb;
}
.markdown-content h4 {
font-size: 1.25rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
line-height: 1.5;
color: #111827;
}
.dark .markdown-content h4 {
color: #f9fafb;
}
.markdown-content h5 {
font-size: 1.125rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
line-height: 1.5;
color: #111827;
}
.dark .markdown-content h5 {
color: #f9fafb;
}
.markdown-content h6 {
font-size: 1rem;
font-weight: 600;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
line-height: 1.5;
color: #111827;
}
.dark .markdown-content h6 {
color: #f9fafb;
}
/* Paragraphs */
.markdown-content p {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
}
/* Links */
.markdown-content a {
color: #2563eb;
text-decoration: none;
border-bottom: 1px solid transparent;
transition:
border-color 0.2s ease,
color 0.2s ease;
}
.markdown-content a:hover {
color: #1d4ed8;
border-bottom-color: #1d4ed8;
}
.dark .markdown-content a {
color: #3b82f6;
}
.dark .markdown-content a:hover {
color: #60a5fa;
border-bottom-color: #60a5fa;
}
/* Bold text styling - enhanced */
.markdown-content strong {
font-weight: 700;
color: #0f766e;
background: linear-gradient(to bottom, transparent 60%, rgba(20, 184, 166, 0.2) 40%);
padding: 0 0.2em;
border-radius: 0.2em;
}
.dark .markdown-content strong {
color: #14b8a6;
background: linear-gradient(to bottom, transparent 60%, rgba(20, 184, 166, 0.15) 40%);
}
/* Lists */
.markdown-content ul,
.markdown-content ol {
margin-top: 1rem;
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.markdown-content ul {
list-style-type: disc;
}
.markdown-content ol {
list-style-type: decimal;
}
.markdown-content li {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.markdown-content li > ul,
.markdown-content li > ol {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
/* Blockquotes */
.markdown-content blockquote {
border-left: 4px solid #3b82f6;
padding: 1rem 1.5rem;
margin: 1.5rem 0;
background-color: #f3f4f6;
border-radius: 0.375rem;
font-style: italic;
position: relative;
overflow: hidden;
}
.dark .markdown-content blockquote {
background-color: #1f2937;
border-left-color: #60a5fa;
}
.markdown-content blockquote p {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.markdown-content blockquote .quote-icon {
position: absolute;
top: 0.5rem;
right: 0.5rem;
opacity: 0.1;
color: #3b82f6;
}
.dark .markdown-content blockquote .quote-icon {
color: #60a5fa;
}
/* Code blocks */
.markdown-content pre {
margin: 1.5rem 0;
padding: 1rem;
background-color: #1e293b !important;
border-radius: 0.5rem;
overflow-x: auto;
position: relative;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* Dark mode code blocks - ensure consistency */
.dark .markdown-content pre {
background-color: #1e293b !important;
}
.markdown-content pre code {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 0.875rem;
line-height: 1.7;
color: #e5e7eb !important;
background-color: transparent !important;
padding: 0;
border-radius: 0;
display: block;
}
.dark .markdown-content pre code {
color: #e5e7eb !important;
background-color: transparent !important;
}
.markdown-content pre.with-line-numbers {
padding-left: 3.5rem;
}
.markdown-content pre code {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 0.875rem;
line-height: 1.7;
color: #e5e7eb;
background-color: transparent;
padding: 0;
border-radius: 0;
display: block;
}
.markdown-content .line-numbers {
position: absolute;
top: 1rem;
left: 0;
width: 2.5rem;
text-align: right;
padding-right: 0.75rem;
color: #6b7280;
user-select: none;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 0.875rem;
line-height: 1.7;
border-right: 1px solid #4b5563;
height: calc(100% - 2rem);
overflow: hidden;
}
.markdown-content .line-numbers span {
display: block;
height: 1.7em;
}
.markdown-content .copy-code-button {
position: absolute;
top: 0.25rem;
right: 0.25rem;
background-color: #4b5563;
color: #e5e7eb;
border: none;
border-radius: 0.25rem;
padding: 0.15rem;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s ease;
z-index: 10;
width: 1.25rem;
height: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.markdown-content .copy-code-button:hover {
opacity: 1;
background-color: #6b7280;
}
.markdown-content .copy-code-button svg {
width: 0.875rem;
height: 0.875rem;
}
/* Language label */
.markdown-content .language-label {
position: absolute;
top: 0;
right: 2.5rem;
background-color: #4b5563;
color: #e5e7eb;
font-size: 0.65rem;
padding: 0.125rem 0.375rem;
border-bottom-left-radius: 0.25rem;
border-bottom-right-radius: 0.25rem;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 0.05em;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
opacity: 0.8;
transition: opacity 0.2s ease;
z-index: 5;
}
.markdown-content pre:hover .language-label {
opacity: 1;
}
/* Language badge at bottom right */
.markdown-content .language-badge {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
font-size: 0.7rem;
padding: 0.1rem 0.3rem;
background-color: rgba(75, 85, 99, 0.7);
color: #e5e7eb;
border-radius: 0.25rem;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
opacity: 0.8;
transition: opacity 0.2s ease;
z-index: 10;
}
.markdown-content pre:hover .language-badge {
opacity: 1;
}
/* Inline code */
.markdown-content code:not(pre code) {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 0.875em;
color: #ef4444;
background-color: #f3f4f6;
padding: 0.2em 0.4em;
border-radius: 0.25rem;
white-space: nowrap;
}
.dark .markdown-content code:not(pre code) {
color: #f87171;
background-color: #1f2937;
}
/* Tables */
.markdown-content .table-container {
overflow-x: auto;
margin: 1.5rem 0;
border-radius: 0.5rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.875rem;
}
.markdown-content table th {
background-color: #f3f4f6;
color: #111827;
font-weight: 600;
padding: 0.75rem 1rem;
border-bottom: 2px solid #e5e7eb;
}
.dark .markdown-content table th {
background-color: #1f2937;
color: #f9fafb;
border-bottom-color: #374151;
}
.markdown-content table td {
padding: 0.75rem 1rem;
border-bottom: 1px solid #e5e7eb;
}
.dark .markdown-content table td {
border-bottom-color: #374151;
}
.markdown-content table tr.even-row {
background-color: #f9fafb;
}
.dark .markdown-content table tr.even-row {
background-color: #111827;
}
.markdown-content table tr.odd-row {
background-color: #ffffff;
}
.dark .markdown-content table tr.odd-row {
background-color: #1f2937;
}
.markdown-content table tr:last-child td {
border-bottom: none;
}
/* Images */
.markdown-content img {
max-width: 100%;
height: auto;
border-radius: 0.5rem;
margin: 1.5rem 0;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
/* Horizontal rule */
.markdown-content hr {
border: 0;
height: 1px;
background-color: #e5e7eb;
margin: 2rem 0;
}
.dark .markdown-content hr {
background-color: #374151;
}
/* Task lists */
.markdown-content ul li[data-task-list-item] {
list-style-type: none;
position: relative;
padding-left: 1.5rem;
}
.markdown-content ul li[data-task-list-item]::before {
content: '';
position: absolute;
left: 0;
top: 0.25rem;
width: 1rem;
height: 1rem;
border: 1px solid #9ca3af;
border-radius: 0.25rem;
}
.markdown-content ul li[data-task-list-item][data-checked]::before {
background-color: #3b82f6;
border-color: #3b82f6;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%23ffffff'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M5 13l4 4L19 7'%3E%3C/path%3E%3C/svg%3E");
background-size: 0.75rem;
background-position: center;
background-repeat: no-repeat;
}
/* Footnotes */
.markdown-content .footnotes {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
font-size: 0.875rem;
}
.dark .markdown-content .footnotes {
border-top-color: #374151;
}
.markdown-content .footnotes ol {
padding-left: 1rem;
}
.markdown-content .footnotes li {
margin-bottom: 0.5rem;
}
.markdown-content .footnote-backref {
font-size: 0.75rem;
vertical-align: super;
}
/* Definition lists */
.markdown-content dl {
margin: 1.5rem 0;
}
.markdown-content dt {
font-weight: 600;
color: #111827;
margin-top: 1rem;
}
.dark .markdown-content dt {
color: #f9fafb;
}
.markdown-content dd {
margin-left: 1.5rem;
margin-bottom: 1rem;
}
/* Callouts and admonitions */
.markdown-content .callout {
margin: 1.5rem 0;
padding: 1rem;
border-radius: 0.5rem;
border-left: 4px solid;
background-color: #f3f4f6;
}
.dark .markdown-content .callout {
background-color: #1f2937;
}
.markdown-content .callout.info {
border-left-color: #3b82f6;
}
.markdown-content .callout.warning {
border-left-color: #f59e0b;
background-color: rgba(245, 158, 11, 0.1);
}
.dark .markdown-content .callout.warning {
background-color: rgba(245, 158, 11, 0.05);
}
.markdown-content .callout.danger {
border-left-color: #ef4444;
background-color: rgba(239, 68, 68, 0.1);
}
.dark .markdown-content .callout.danger {
background-color: rgba(239, 68, 68, 0.05);
}
.markdown-content .callout.tip {
border-left-color: #10b981;
background-color: rgba(16, 185, 129, 0.1);
}
.dark .markdown-content .callout.tip {
background-color: rgba(16, 185, 129, 0.05);
}
/* Code syntax highlighting - Light theme */
.markdown-content .token.comment,
.markdown-content .token.prolog,
.markdown-content .token.doctype,
.markdown-content .token.cdata {
color: #6b7280;
}
.markdown-content .token.punctuation {
color: #6b7280;
}
.markdown-content .token.namespace {
opacity: 0.7;
}
.markdown-content .token.property,
.markdown-content .token.tag,
.markdown-content .token.boolean,
.markdown-content .token.number,
.markdown-content .token.constant,
.markdown-content .token.symbol {
color: #ef4444;
}
.markdown-content .token.selector,
.markdown-content .token.attr-name,
.markdown-content .token.string,
.markdown-content .token.char,
.markdown-content .token.builtin {
color: #10b981;
}
.markdown-content .token.operator,
.markdown-content .token.entity,
.markdown-content .token.url,
.markdown-content .language-css .token.string,
.markdown-content .style .token.string {
color: #9333ea;
}
.markdown-content .token.atrule,
.markdown-content .token.attr-value,
.markdown-content .token.keyword {
color: #3b82f6;
}
.markdown-content .token.function,
.markdown-content .token.class-name {
color: #f59e0b;
}
.markdown-content .token.regex,
.markdown-content .token.important,
.markdown-content .token.variable {
color: #ec4899;
}
.markdown-content .token.important,
.markdown-content .token.bold {
font-weight: bold;
}
.markdown-content .token.italic {
font-style: italic;
}
.markdown-content .token.entity {
cursor: help;
}
/* Responsive adjustments */
@media (max-width: 640px) {
.markdown-content h1 {
font-size: 2rem;
}
.markdown-content h2 {
font-size: 1.5rem;
}
.markdown-content h3 {
font-size: 1.25rem;
}
.markdown-content pre {
padding: 0.75rem;
}
.markdown-content pre.with-line-numbers {
padding-left: 3rem;
}
.markdown-content .line-numbers {
width: 2rem;
}
.markdown-content blockquote {
padding: 0.75rem 1rem;
}
}
/* Print styles */
@media print {
.markdown-content {
font-size: 12pt;
}
.markdown-content pre,
.markdown-content code {
font-size: 10pt;
}
.markdown-content a {
color: #000 !important;
text-decoration: underline;
}
.markdown-content blockquote {
border-left: 2pt solid #000;
padding: 0.5cm 1cm;
background: none !important;
}
.markdown-content img {
max-width: 100% !important;
page-break-inside: avoid;
}
.markdown-content h2,
.markdown-content h3,
.markdown-content h4 {
page-break-after: avoid;
}
.markdown-content p,
.markdown-content h2,
.markdown-content h3 {
orphans: 3;
widows: 3;
}
}
/* Additional elements */
.markdown-content details {
margin: 1.5rem 0;
padding: 0.5rem 1rem;
background-color: #f3f4f6;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
}
.dark .markdown-content details {
background-color: #1f2937;
border-color: #374151;
}
.markdown-content details summary {
font-weight: 600;
cursor: pointer;
padding: 0.5rem 0;
}
.markdown-content details[open] summary {
margin-bottom: 0.5rem;
border-bottom: 1px solid #e5e7eb;
}
.dark .markdown-content details[open] summary {
border-bottom-color: #374151;
}
/* Keyboard shortcuts */
.markdown-content kbd {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
font-size: 0.8em;
padding: 0.2em 0.4em;
margin: 0 0.1em;
background-color: #f3f4f6;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
box-shadow: 0 1px 0 #d1d5db;
}
.dark .markdown-content kbd {
background-color: #1f2937;
border-color: #4b5563;
box-shadow: 0 1px 0 #4b5563;
}
/* Abbreviations */
.markdown-content abbr {
cursor: help;
text-decoration: underline dotted;
}
/* Highlight text */
.markdown-content mark {
background-color: #fef3c7;
color: #92400e;
padding: 0.1em 0.2em;
border-radius: 0.25rem;
}
.dark .markdown-content mark {
background-color: rgba(254, 243, 199, 0.2);
color: #fbbf24;
}
/* Subscript and superscript */
.markdown-content sub,
.markdown-content sup {
font-size: 0.75em;
line-height: 0;
position: relative;
vertical-align: baseline;
}
.markdown-content sup {
top: -0.5em;
}
.markdown-content sub {
bottom: -0.25em;
}
/* Diagrams and charts */
.markdown-content .mermaid {
margin: 1.5rem 0;
text-align: center;
}
/* Math equations */
.markdown-content .math {
overflow-x: auto;
margin: 1.5rem 0;
}
/* Embedded content */
.markdown-content iframe {
max-width: 100%;
margin: 1.5rem 0;
border-radius: 0.5rem;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}

View File

@@ -1,27 +0,0 @@
import { createDirectus, rest } from '@directus/sdk';
import type {
Global,
Post,
Experience,
Education,
Certificate,
Project,
Skill,
} from '@lib/directusTypes';
import { getDirectusURL } from '@lib/directusFunctions';
type Schema = {
site_global: Global;
posts: Post[];
site_experience: Experience;
site_education: Education;
site_certificate: Certificate;
site_projects: Project;
site_skills: Skill;
};
const directus = createDirectus<Schema>(getDirectusURL()).with(rest());
export default directus;

View File

@@ -1,12 +0,0 @@
const getDirectusURL = () => {
if (process.env.DIRECTUS_URL) {
return `https://${process.env.DIRECTUS_URL}`;
}
return 'https://directus.alexlebens.dev';
};
async function getDirectusImageURL(image: string) {
return `${getDirectusURL()}/assets/${image}`;
}
export { getDirectusURL, getDirectusImageURL };

View File

@@ -1,88 +0,0 @@
export type Global = {
name: string;
about: string;
about_description: string;
initials: string;
email: string;
site_url: string;
logo: string;
portrait: string;
portrait_alt: string;
home_image: string;
home_image_alt: string;
categories_image: string;
categories_image_alt: string;
blog_image: string;
blog_image_alt: string;
footer_image: string;
footer_image_alt: string;
};
export type Post = {
slug: string;
title: string;
description: string;
tags: string[];
category: string;
selected: boolean;
published: boolean;
content: string;
image: string;
image_alt: string;
image_second: string;
image_second_alt: string;
published_date: Date;
updated_date: Date;
};
export type Experience = {
id: string;
name: string;
location: string;
location_type: string;
url: string;
startDate: string;
endDate: string;
position: string;
summary: string;
responsibilities: string[];
highlights: string[];
achievements: string[];
skills: string[];
};
export type Education = {
id: string;
institution: string;
url: string;
area: string;
studyType: string;
graduationDate: string;
};
export type Certificate = {
id: string;
name: string;
issuer: string;
issuerDate: string;
url: string;
};
export type Project = {
id: string;
name: string;
isActive: boolean;
description: string;
highlights: string[];
url: string;
source: string;
};
export type Skill = {
id: string;
title: string;
description: string;
icon: string;
level: string;
date_created: string;
};

View File

@@ -1,75 +1,115 @@
---
import { readSingleton } from '@directus/sdk';
import directus from '@lib/directus';
import BaseLayout from '@layouts/BaseLayout.astro';
import PrimaryCTA from '@components/ui/buttons/PrimaryCTA.astro';
import GoBack from '@/components/ui/buttons/GoBack.astro';
const global = await directus.request(readSingleton('site_global'));
import Layout from '../layouts/Layout.astro';
---
<BaseLayout
title="Page Not Found"
description="Page Not Found"
structuredData={{
'@context': 'https://schema.org',
'@type': 'WebPage',
inLanguage: 'en-US',
'@id': Astro.url.href,
url: Astro.url.href,
name: `Page Not Found | ${global.name}`,
description: 'Page Not Found',
isPartOf: {
'@type': 'WebSite',
url: global.site_url,
name: global.name,
description: global.about,
},
}}
>
<section class="mt-20 grid place-content-center">
<div class="mx-auto max-w-screen-xl px-4 py-8 lg:px-6 lg:py-16">
<div class="mx-auto max-w-screen-sm text-center">
<div class="glitch-wrapper smooth-reveal">
<h1
class="glitch text-9xl leading-none font-bold text-neutral-900 sm:text-[12rem] dark:text-neutral-100"
data-text="404"
>
Not Found
</h1>
</div>
<h1
class="text-dark smooth-reveal mb-4 text-7xl font-extrabold text-yellow-500 lg:text-9xl dark:text-yellow-400"
>
{`Page Not Found - ${global.name}`}
</h1>
<div
class="smooth-reveal mx-auto mt-16 max-w-md rounded-xl bg-neutral-100 p-6 shadow-xs dark:border-neutral-700/50 dark:bg-stone-800"
>
<h3
class="text-sm font-medium tracking-wider text-neutral-500 uppercase dark:text-neutral-400"
>
Did you know?
</h3>
<p class="mt-2 text-sm text-neutral-600 dark:text-neutral-300" id="fun-fact">
The 404 error code originated when CERN's web server displayed room 404 (their server
room) as the error message when a file wasn't found.
</p>
</div>
<div
class="smooth-reveal mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row"
>
<GoBack title="Go Back" />
<PrimaryCTA title="Return Home" url={global.site_url} noArrow addHome />
</div>
<Layout title="404 - Page Not Found">
<div
class="relative flex min-h-[80vh] flex-col items-center justify-center overflow-hidden px-4 py-20 text-center"
>
<!-- Animated background elements -->
<div class="absolute inset-0 overflow-hidden">
<div
class="animate-blob absolute -left-20 -top-20 h-64 w-64 rounded-full bg-zinc-100 opacity-50 blur-3xl dark:bg-zinc-800/50"
>
</div>
<div
class="animate-blob animation-delay-2000 absolute right-1/4 top-1/2 h-96 w-96 rounded-full bg-zinc-200 opacity-30 blur-3xl dark:bg-zinc-800/30"
>
</div>
<div
class="animate-blob animation-delay-4000 absolute bottom-20 left-1/3 h-72 w-72 rounded-full bg-zinc-100 opacity-40 blur-3xl dark:bg-zinc-800/40"
>
</div>
</div>
</section>
</BaseLayout>
<!-- Main content with animation -->
<div class="relative z-10 mx-auto max-w-xl">
<div class="glitch-wrapper">
<h1
class="glitch text-9xl font-bold leading-none text-zinc-900 dark:text-zinc-100 sm:text-[12rem]"
data-text="404"
>
404
</h1>
</div>
<h2 class="mt-6 text-2xl font-bold text-zinc-800 dark:text-zinc-200 sm:text-3xl">
Page Not Found
</h2>
<p class="mx-auto mt-6 max-w-md text-lg text-zinc-600 dark:text-zinc-400">
The page you're looking for does not exist.
</p>
<div class="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row">
<a
href="/"
class="group relative inline-flex items-center gap-2 overflow-hidden rounded-lg bg-zinc-900 px-6 py-3 text-zinc-100 shadow-lg transition-all duration-300 hover:bg-zinc-800 hover:shadow-xl dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
<span
class="absolute inset-0 z-0 bg-gradient-to-r from-zinc-700 to-zinc-900 opacity-0 transition-opacity duration-300 group-hover:opacity-100 dark:from-zinc-300 dark:to-zinc-100"
></span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="relative z-10 h-5 w-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
></path>
</svg>
<span class="relative z-10 font-medium">Return Home</span>
</a>
<button
id="back-button"
class="group inline-flex items-center gap-2 rounded-lg border border-zinc-300 px-6 py-3 text-zinc-700 shadow-sm transition-all duration-300 hover:bg-zinc-100 hover:shadow-md dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="h-5 w-5 transition-transform duration-300 group-hover:-translate-x-1"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"></path>
</svg>
<span class="font-medium">Go Back</span>
</button>
</div>
<!-- Random fun fact -->
<div
class="mx-auto mt-16 max-w-md rounded-xl border border-zinc-100 bg-zinc-50 p-6 shadow-sm backdrop-blur-sm dark:border-zinc-700/50 dark:bg-zinc-800/50"
>
<h3 class="text-sm font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Did you know?
</h3>
<p class="mt-2 text-sm text-zinc-700 dark:text-zinc-300" id="fun-fact">
The 404 error code originated when CERN's web server displayed room 404 (their server
room) as the error message when a file wasn't found.
</p>
</div>
</div>
</div>
</Layout>
<script>
// Go back functionality
document.getElementById('back-button')?.addEventListener('click', () => {
window.history.back();
});
// Array of fun 404 facts
const funFacts = [
"The 404 error code originated when CERN's web server displayed room 404 (their server room) as the error message when a file wasn't found.",
"In internet slang, '404' has become shorthand for something that's missing or someone who's clueless.",
@@ -88,26 +128,96 @@ const global = await directus.request(readSingleton('site_global'));
funFactElement.textContent = randomFact;
}
// Add smooth reveal animations for content after loading
document.addEventListener('astro:page-load', () => {
const animateContent = () => {
// Animate group 1
const smoothReveal = document.querySelectorAll('.smooth-reveal');
smoothReveal.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
100 + index * 150
);
});
};
// Handle SPA transitions for 404 page
function setupSPATransitions() {
// Handle all internal links for SPA transitions
document.querySelectorAll('a[href^="/"]').forEach((link) => {
// Skip links that are anchor links, external links, or already processed
if (
link.getAttribute('href').includes('#') ||
link.getAttribute('target') === '_blank' ||
link.hasAttribute('data-spa-handled')
) {
return;
}
animateContent();
});
// Mark as handled to avoid duplicate listeners
link.setAttribute('data-spa-handled', 'true');
link.addEventListener('click', (e) => {
// Don't handle if modifier keys are pressed (for opening in new tab, etc.)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
const targetHref = link.getAttribute('href');
// Trigger page transition animation
const pageTransition = document.getElementById('page-transition');
if (pageTransition) {
pageTransition.classList.remove('opacity-0');
pageTransition.classList.add('opacity-100');
// Navigate after transition effect
setTimeout(() => {
window.location.href = targetHref;
}, 300);
} else {
// Fallback if transition element doesn't exist
window.location.href = targetHref;
}
});
});
// Re-initialize back button after SPA navigation
const backButton = document.getElementById('back-button');
if (backButton) {
backButton.addEventListener('click', () => {
window.history.back();
});
}
}
// Initialize on first load
document.addEventListener('DOMContentLoaded', setupSPATransitions);
// Re-initialize when content changes via Astro's view transitions
document.addEventListener('astro:page-load', setupSPATransitions);
// For compatibility with custom transition system
document.addEventListener('page-transition-complete', setupSPATransitions);
</script>
<style>
/* Animation for floating blobs */
@keyframes blob {
0% {
transform: translate(0px, 0px) scale(1);
}
33% {
transform: translate(30px, -50px) scale(1.1);
}
66% {
transform: translate(-20px, 20px) scale(0.9);
}
100% {
transform: translate(0px, 0px) scale(1);
}
}
.animate-blob {
animation: blob 7s infinite;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}
/* Glitch effect for 404 text */
.glitch-wrapper {
position: relative;

View File

@@ -1,107 +1,583 @@
---
import { readSingleton } from '@directus/sdk';
import BaseLayout from '../layouts/BaseLayout.astro';
import { FaJs, FaReact, FaNodeJs, FaPython } from 'react-icons/fa';
import { SiTypescript, SiAstro } from 'react-icons/si';
import directus from '@lib/directus';
import BaseLayout from '@layouts/BaseLayout.astro';
import HeroSection from '@components/ui/sections/HeroSection.astro';
import Experience from '@components/ui/sections/Experience.astro';
import Projects from '@components/ui/sections/Projects.astro';
import Skills from '@components/ui/sections/Skills.astro';
import Education from '@components/ui/sections/Education.astro';
import portraitImg from '@images/portrait.avif';
import directus from '../../lib/directus';
import { readSingleton, readItems } from '@directus/sdk';
const global = await directus.request(readSingleton('site_global'));
const global = await directus.request(readSingleton('global'));
const about = await directus.request(readSingleton('about'));
const description = 'About me.';
const skills = await directus.request(
readItems('skills', {
fields: ['*'],
})
);
---
<BaseLayout
title="About Me"
description={description}
structuredData={{
'@context': 'https://schema.org',
'@type': 'WebPage',
inLanguage: 'en-US',
'@id': Astro.url.href,
url: Astro.url.href,
name: `About | ${global.name}`,
description: description,
isPartOf: {
'@type': 'WebSite',
url: global.site_url,
name: global.name,
description: global.about,
},
}}
>
<HeroSection
title="About Me"
subTitle={global.about}
src={portraitImg}
alt={global.portrait_alt}
/>
<section class="mx-auto max-w-[85rem] px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
<main class="relative grid max-w-7xl gap-12 p-8 max-sm:py-16 md:grid-cols-6 md:p-16 xl:gap-24">
<div class="space-y-12 md:col-span-8">
<Experience className="smooth-reveal-2" />
<Education className="smooth-reveal-2 mt-30" />
<Projects className="smooth-reveal-2 mt-30" />
<Skills className="smooth-reveal-2 mt-30" />
<BaseLayout title="About Me" description={global.description}>
<div class="theme-transition-all mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12 md:py-16">
<!-- Hero Section -->
<div class="relative mb-12 sm:mb-16 md:mb-20">
<!-- Decorative elements -->
<div
class="animate-blob theme-transition-bg absolute -left-10 -top-10 h-36 w-36 rounded-full bg-zinc-100 opacity-30 blur-3xl dark:bg-zinc-800/30 sm:-left-20 sm:-top-20 sm:h-48 sm:w-48 md:h-72 md:w-72"
>
</div>
</main>
</section>
<div
class="animate-blob animation-delay-2000 theme-transition-bg absolute -bottom-10 -right-10 h-36 w-36 rounded-full bg-zinc-200 opacity-30 blur-3xl dark:bg-zinc-800/30 sm:-bottom-20 sm:-right-20 sm:h-48 sm:w-48 md:h-72 md:w-72"
>
</div>
<div class="relative grid grid-cols-1 items-center gap-8 md:grid-cols-2 md:gap-12">
<div class="order-2 text-center md:order-1 md:text-left">
<h1
class="theme-transition-color mb-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:mb-6 sm:text-4xl md:text-5xl"
>
Hello, I'm <span
class="theme-transition-all bg-gradient-to-r from-zinc-500 to-zinc-900 bg-clip-text text-transparent dark:from-zinc-300 dark:to-zinc-100"
>{global.name}</span
>
</h1>
<p
class="theme-transition-color mb-6 text-lg leading-relaxed text-zinc-600 dark:text-zinc-400 sm:mb-8 sm:text-xl"
>
{about.background}
</p>
<div
class="social-links-container theme-transition-children flex flex-wrap justify-center gap-4 md:justify-start"
>
<!-- Social links remain the same -->
</div>
</div>
<div class="relative order-1 md:order-2">
<div
class="theme-transition-all mx-auto aspect-square w-full max-w-[280px] overflow-hidden rounded-3xl border-4 border-white shadow-xl dark:border-zinc-800 sm:max-w-[320px] sm:border-8 sm:shadow-2xl md:max-w-md"
>
<img
src=`${process.env.DIRECTUS_URL ?? "https://directus.alexlebens.dev"}/assets/${global.portrait}`
alt={global.portrait_alt}
class="h-full w-full object-cover"
loading="eager"
/>
</div>
<!-- Decorative elements -->
<div
class="theme-transition-all absolute -bottom-4 -right-4 flex h-16 w-16 items-center justify-center rounded-full border-2 border-white bg-zinc-100 shadow-lg dark:border-zinc-900 dark:bg-zinc-800 sm:-bottom-6 sm:-right-6 sm:h-20 sm:w-20 sm:border-4 md:h-24 md:w-24"
>
<span class="text-2xl sm:text-3xl">👋</span>
</div>
</div>
</div>
</div>
<!-- About Section -->
<div class="theme-transition-all mb-16 sm:mb-20 md:mb-24">
<div class="mx-auto max-w-3xl">
<h2
class="theme-transition-color mb-6 flex items-center justify-center text-2xl font-bold text-zinc-900 dark:text-zinc-100 sm:mb-8 sm:text-3xl md:justify-start"
>
<span
class="theme-transition-bg mr-4 hidden h-1 w-8 bg-zinc-300 dark:bg-zinc-700 sm:inline-block sm:w-12"
></span>
About Me
<span
class="theme-transition-bg ml-4 hidden h-1 w-8 bg-zinc-300 dark:bg-zinc-700 sm:inline-block sm:w-12"
></span>
</h2>
<div class="theme-transition-all prose prose-zinc max-w-none dark:prose-invert">
<p class="theme-transition-color mb-4 text-base leading-relaxed sm:mb-6 sm:text-lg">
{about.experience}
</p>
<p class="theme-transition-color mb-4 text-base leading-relaxed sm:mb-6 sm:text-lg">
{about.education}
</p>
<p class="theme-transition-color mb-4 text-base leading-relaxed sm:mb-6 sm:text-lg">
{about.certifications}
</p>
</div>
</div>
</div>
<!-- Skills Section -->
<div class="theme-transition-all mb-16 sm:mb-20 md:mb-24">
<h2
class="theme-transition-color mb-8 text-center text-2xl font-bold text-zinc-900 dark:text-zinc-100 sm:mb-12 sm:text-3xl"
>
Tech Stack
</h2>
<div class="tech-stack-slider relative overflow-hidden py-4 sm:py-8">
<!-- Main slider container -->
<div class="slider-track animate-slide flex">
{
skills.map((skill, index) => (
<div
key={`${skill.title}-${index}`}
class="skill-card theme-transition-element mx-2 min-w-[220px] transform rounded-xl border border-zinc-200 bg-white transition-all duration-300 hover:-translate-y-2 hover:scale-105 hover:border-zinc-300 hover:shadow-xl dark:border-zinc-700 dark:bg-zinc-800/50 dark:hover:border-zinc-600 sm:mx-4 sm:min-w-[280px]"
>
<div class="p-4 sm:p-6">
<div class="mb-4 flex items-center justify-between sm:mb-6">
<div class="flex items-center gap-2 sm:gap-4">
<div class="theme-transition-bg theme-transition-color flex h-8 w-8 transform items-center justify-center rounded-lg bg-zinc-100 text-zinc-800 transition-transform group-hover:rotate-12 dark:bg-zinc-800 dark:text-zinc-200 sm:h-12 sm:w-12">
<skill.icon
size={20}
className="sm:text-2xl transform transition-all hover:scale-125"
/>
</div>
<h3 class="theme-transition-color text-base font-semibold text-zinc-900 dark:text-zinc-100 sm:text-xl">
{skill.title}
</h3>
</div>
<span class="theme-transition-all rounded-full bg-zinc-100 px-2 py-0.5 font-mono text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 sm:px-2.5 sm:py-1 sm:text-sm">
{skill.level}%
</span>
</div>
<div class="theme-transition-bg relative h-1.5 w-full overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-700 sm:h-2">
<div
class="progress-bar-animate theme-transition-bg absolute left-0 top-0 h-full rounded-full bg-gradient-to-r from-zinc-700 via-zinc-600 to-zinc-800 transition-all duration-1000 dark:from-zinc-300 dark:via-zinc-400 dark:to-zinc-200"
style={`width: ${skill.level}%`}
/>
</div>
<div class="theme-transition-color mt-1 flex justify-between font-mono text-[10px] text-zinc-400 dark:text-zinc-500 sm:mt-2 sm:text-xs">
<span>Beginner</span>
<span>Advanced</span>
</div>
</div>
</div>
))
}
</div>
<!-- Gradient overlays for smooth fade effect -->
<div
class="theme-transition-bg absolute bottom-0 left-0 top-0 z-10 w-12 bg-gradient-to-r from-white to-transparent dark:from-zinc-900 sm:w-24"
>
</div>
<div
class="theme-transition-bg absolute bottom-0 right-0 top-0 z-10 w-12 bg-gradient-to-l from-white to-transparent dark:from-zinc-900 sm:w-24"
>
</div>
</div>
</div>
<!-- Contact Section -->
<div class="theme-transition-all mx-auto max-w-3xl text-center">
<h2
class="theme-transition-color mb-4 text-2xl font-bold text-zinc-900 dark:text-zinc-100 sm:mb-6 sm:text-3xl"
>
Get in Touch
</h2>
<p
class="theme-transition-color mb-6 text-base text-zinc-600 dark:text-zinc-400 sm:mb-8 sm:text-lg"
>
I'm always open to new opportunities and collaborations. If you'd like to work together or
just say hello, feel free to reach out.
</p>
<a
href=`mailto:${global.email}`
class="theme-transition-all inline-flex items-center justify-center rounded-lg bg-zinc-900 px-6 py-3 text-base font-medium text-zinc-100 transition-colors hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300 sm:px-8 sm:py-4 sm:text-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-4 w-4 sm:h-5 sm:w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
></path>
</svg>
Say Hello
</a>
</div>
</div>
</BaseLayout>
<style>
/* Blob animation */
.animate-blob {
animation: blob-bounce 8s infinite ease;
}
.animation-delay-2000 {
animation-delay: 2s;
}
@keyframes blob-bounce {
0%,
100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(5%, 5%) scale(1.05);
}
50% {
transform: translate(0, 10%) scale(1);
}
75% {
transform: translate(-5%, 5%) scale(0.95);
}
}
/* Tech Stack Slider */
.slider-track {
width: fit-content;
animation: scroll 40s linear infinite;
}
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-220px * 6 - 16px * 6)); /* Card width + margin for mobile */
}
}
@media (min-width: 640px) {
.slider-track {
animation: scroll 60s linear infinite;
}
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(-280px * 6 - 32px * 6)); /* Card width + margin for desktop */
}
}
}
.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 for better performance */
@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%;
}
}
/* Improved touch targets for mobile */
@media (max-width: 640px) {
a,
button {
min-height: 44px;
display: flex;
align-items: center;
}
.social-link {
min-width: 44px;
min-height: 44px;
}
}
/* Theme transition effect */
:global(.theme-switching) .theme-transition-element {
animation: fadeIn 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
/* Smooth card transition during theme switch */
.skill-card.theme-transition-element {
transition:
background-color var(--theme-transition),
border-color var(--theme-transition),
color var(--theme-transition),
box-shadow var(--theme-transition),
transform 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
</style>
<script>
// Add smooth reveal animations for content after loading
document.addEventListener('astro:page-load', () => {
const animateContent = () => {
// Animate group 1
const smoothReveal = document.querySelectorAll('.smooth-reveal');
smoothReveal.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
50 + index * 100
);
});
// Wait for the DOM to be fully loaded
document.addEventListener('DOMContentLoaded', () => {
const sliderTrack = document.querySelector('.slider-track');
// Animate group 2
const smoothReveal2 = document.querySelectorAll('.smooth-reveal-2');
smoothReveal2.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
200 + index * 250
);
});
// Create seamless infinite scrolling effect
function setupInfiniteScroll() {
const cards = document.querySelectorAll('.skill-card');
if (!cards.length) return;
// Animate topic cards with staggered delay
const smoothRevealCards = document.querySelectorAll('.smooth-reveal-cards');
smoothRevealCards.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
400 + index * 250
);
});
// Clone the first set of cards and append to create seamless loop
const firstSetCount = cards.length / 3; // We have 3 sets in the markup
// Animate with just fade in with staggered delay
const smoothRevealFade = document.querySelectorAll('.smooth-reveal-fade');
smoothRevealFade.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal-fade');
},
100 + index * 250
);
});
};
// Set proper animation based on screen size
function updateScrollAnimation() {
if (window.innerWidth >= 640) {
sliderTrack.style.animation = 'scroll 60s linear infinite';
} else {
sliderTrack.style.animation = 'scroll 40s linear infinite';
}
}
animateContent();
updateScrollAnimation();
window.addEventListener('resize', updateScrollAnimation);
}
setupInfiniteScroll();
// Pause animation on hover/touch
sliderTrack?.addEventListener('mouseenter', () => {
sliderTrack.style.animationPlayState = 'paused';
});
sliderTrack?.addEventListener('touchstart', () => {
sliderTrack.style.animationPlayState = 'paused';
});
sliderTrack?.addEventListener('mouseleave', () => {
sliderTrack.style.animationPlayState = 'running';
});
sliderTrack?.addEventListener('touchend', () => {
setTimeout(() => {
sliderTrack.style.animationPlayState = 'running';
}, 1000); // Delay resuming animation after touch
});
// Add hover effects to cards - only on non-touch devices
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);
});
});
}
// Handle theme transition
document.addEventListener('themeChange', () => {
// Add special effects during theme transition
cards.forEach((card, index) => {
// Add staggered animation delay
setTimeout(() => {
card.classList.add('theme-changing');
setTimeout(() => {
card.classList.remove('theme-changing');
}, 600);
}, index * 50);
});
});
});
</script>
<script>
// Handle SPA transitions for about page
function setupSPATransitions() {
// Handle all internal links for SPA transitions
document.querySelectorAll('a[href^="/"]').forEach((link) => {
// Skip links that are anchor links, external links, or already processed
if (
link.getAttribute('href').includes('#') ||
link.getAttribute('target') === '_blank' ||
link.hasAttribute('data-spa-handled')
) {
return;
}
// Mark as handled to avoid duplicate listeners
link.setAttribute('data-spa-handled', 'true');
link.addEventListener('click', (e) => {
// Don't handle if modifier keys are pressed (for opening in new tab, etc.)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
const targetHref = link.getAttribute('href');
// Trigger page transition animation
const pageTransition = document.getElementById('page-transition');
if (pageTransition) {
pageTransition.classList.remove('opacity-0');
pageTransition.classList.add('opacity-100');
// Navigate after transition effect
setTimeout(() => {
window.location.href = targetHref;
}, 300);
} else {
// Fallback if transition element doesn't exist
window.location.href = targetHref;
}
});
});
// Initialize animations for about page
function animateAboutContent() {
// Animate hero section elements
const heroElements = document.querySelectorAll('h1, .order-2 p, .social-links-container');
heroElements.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
100 + index * 150
);
});
// Animate profile image
const profileImage = document.querySelector('.aspect-square');
if (profileImage) {
setTimeout(() => {
profileImage.classList.add('animate-reveal');
}, 200);
}
// Animate skill bars with staggered delay
const skillBars = document.querySelectorAll('.skill-bar');
skillBars.forEach((bar, index) => {
setTimeout(
() => {
bar.classList.add('animate-skill');
},
500 + index * 100
);
});
// Animate sections with staggered delay
const sections = document.querySelectorAll('section');
sections.forEach((section, index) => {
setTimeout(
() => {
section.classList.add('animate-reveal');
},
300 + index * 200
);
});
}
// Run animations
animateAboutContent();
}
// Initialize on first load
document.addEventListener('DOMContentLoaded', setupSPATransitions);
// Re-initialize when content changes via Astro's view transitions
document.addEventListener('astro:page-load', setupSPATransitions);
// For compatibility with custom transition system
document.addEventListener('page-transition-complete', setupSPATransitions);
</script>

View File

@@ -1,181 +1,347 @@
---
import { type CollectionEntry, getCollection } from 'astro:content';
import getReadingTime from 'reading-time';
import { readItems, readSingleton } from '@directus/sdk';
import BlogPost from '../../layouts/BlogPost.astro';
import directus from '@lib/directus';
import { getDirectusImageURL } from '@lib/directusFunctions';
import BaseLayout from '@layouts/BaseLayout.astro';
import Image from '@components/ui/images/Image.astro';
import { formatDateTime } from '@support/time';
import directus from '../../../lib/directus';
import { readItems } from '@directus/sdk';
export async function getStaticPaths() {
const posts = await directus.request(readItems('posts'));
return posts.map((post) => ({
params: { slug: post.slug },
props: post,
}));
}
const post = Astro.props;
const posts = await directus.request(
readItems('posts', {
fields: ['*'],
})
);
const global = await directus.request(readSingleton('site_global'));
const category: CollectionEntry<'categories'> = (await getCollection('categories'))
.filter((c) => c.slug === post.category)
.pop() as CollectionEntry<'categories'>;
const readingTime = getReadingTime(post.content);
const sortedEntries = [...posts].sort(
(a, b) => b.published_date.valueOf() - a.published_date.valueOf()
);
return sortedEntries.map((post, index) => {
return {
params: { slug: post.slug },
props: {
post,
nextPost: index > 0 ? sortedEntries[index - 1] : null,
prevPost: index < sortedEntries.length - 1 ? sortedEntries[index + 1] : null,
},
};
});
}
const { post, nextPost, prevPost } = Astro.props;
---
<BaseLayout
<BlogPost
slug={post.slug}
title={post.title}
description={post.description}
ogImage={getDirectusImageURL(post.image)}
structuredData={{
'@context': 'https://schema.org',
'@type': 'NewsArticle',
inLanguage: 'en-US',
'@id': Astro.url.href,
url: Astro.url.href,
description: post.description,
isPartOf: {
'@type': 'WebSite',
url: `${global.site_url}/blog`,
name: global.name,
description: global.about,
},
image: [
// post.data.banner,
],
headline: post.title,
datePublished: post.published_date,
dateModified: post.updated_date,
author: [
{
'@type': 'Person',
name: `${global.name}`,
url: `${global.site_url}`,
},
],
}}
content={post.content}
image={post.image}
image_alt={post.image_alt}
published_date={post.published_date}
updated_date={post.updated_date}
tags={post.tags}
>
<section class="mx-auto max-w-6xl px-4 pt-8 pb-12 sm:px-6 lg:px-8 lg:pt-12">
<div class="smooth-reveal relative w-full">
<div class="mt-4 rounded-2xl shadow-none sm:mt-0 sm:shadow-sm">
<Image
class="max-h-[600px] w-full rounded-t-2xl object-cover"
src={getDirectusImageURL(post.image)}
alt={post.image_alt}
draggable="false"
format="webp"
loading="lazy"
inferSize={true}
/>
<div
class="rounded-b-2xl px-0 py-6 sm:bg-neutral-100 sm:px-6 md:px-10 lg:px-14 sm:dark:bg-neutral-900/30"
<!-- Main Content - Enhanced with better typography and spacing -->
<div
class="prose prose-sm prose-zinc max-w-none dark:prose-invert sm:prose-base prose-headings:scroll-mt-24 prose-headings:font-semibold prose-a:font-medium prose-a:text-zinc-800 prose-a:underline-offset-4 hover:prose-a:text-zinc-600 prose-img:rounded-xl dark:prose-a:text-zinc-300 dark:hover:prose-a:text-zinc-100"
>
<div set:html={post.content} />
</div>
<!-- Next/Previous Navigation - Improved responsive design -->
<div
class="mt-12 grid grid-cols-1 gap-4 border-t border-zinc-200 pt-8 dark:border-zinc-800 sm:mt-16 sm:gap-6 sm:pt-12 md:grid-cols-2"
>
{
prevPost && (
<a
href={`/blog/${prevPost.slug}`}
class="group relative flex h-full flex-col overflow-hidden rounded-xl border border-zinc-200 p-4 transition-all duration-300 hover:-translate-y-1 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800/50 sm:p-6"
>
<div class="mb-16">
<h2
class="mb-6 block text-3xl font-bold tracking-tight text-balance text-neutral-800 md:text-4xl lg:text-5xl dark:text-neutral-300"
<div class="absolute inset-0 bg-gradient-to-r from-zinc-100 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100 dark:from-zinc-800 dark:to-transparent" />
<span class="relative z-10 mb-1 flex items-center gap-1 text-xs font-medium text-zinc-500 dark:text-zinc-400 sm:mb-2 sm:gap-2 sm:text-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-3 w-3 transition-transform duration-300 group-hover:-translate-x-1 sm:h-4 sm:w-4"
>
{post.title}
</h2>
<ol class="mt-8 flex items-center whitespace-nowrap">
<li class="inline-flex items-center">
<a
class="flex items-center text-sm text-neutral-500 transition-all duration-300 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200"
href=`/categories/${category.slug}`
>
{category?.data?.title}
</a>
<svg
class="mx-2 size-5 flex-shrink-0 text-neutral-500 dark:text-neutral-500"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M6 13L10 3" stroke="currentColor" stroke-linecap="round"></path>
</svg>
</li>
<li
class="inline-flex items-center text-sm text-neutral-500 transition-all duration-300 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200"
>
{formatDateTime(post.published_date)}
<svg
class="mx-2 size-5 flex-shrink-0 text-neutral-500 dark:text-neutral-500"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M6 13L10 3" stroke="currentColor" stroke-linecap="round"></path>
</svg>
</li>
<li
class="inline-flex items-center text-sm text-neutral-500 transition-all duration-300 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200"
aria-current="page"
>
{readingTime.minutes.toPrecision(1)} minutes to read
</li>
</ol>
</div>
<article
class="prose prose-blog sm:prose-lg dark:prose-invert max-w-none text-justify text-neutral-800 dark:text-neutral-200"
>
<div set:html={post.content} />
</article>
<div
class="mx-auto mt-10 grid max-w-screen-lg gap-y-5 sm:flex sm:items-center sm:justify-between sm:gap-y-0 md:mt-14"
>
<div class="flex flex-wrap gap-x-2 gap-y-1 sm:flex-nowrap sm:items-center sm:gap-y-0">
{
post.tags.map((tag: string) => (
<span class="bg-steel/30 dark:bg-bermuda/60 inline-flex items-center gap-x-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-neutral-700 outline-none focus:outline-none focus-visible:ring focus-visible:outline-none dark:text-neutral-200">
{tag}
</span>
))
}
</div>
</div>
</div>
</div>
</div>
</section>
<style is:inline>
code[data-theme*=' '],
code[data-theme*=' '] span {
color: var(--shiki-light);
<path d="m15 18-6-6 6-6" />
</svg>
Previous Article
</span>
<h3 class="line-clamp-2 text-base font-medium text-zinc-900 transition-colors group-hover:text-zinc-700 dark:text-white dark:group-hover:text-zinc-300 sm:text-lg">
{prevPost.title}
</h3>
</a>
)
}
html.dark {
code[data-theme*=' '],
code[data-theme*=' '] span {
color: var(--shiki-dark);
}
{
nextPost && (
<a
href={`/blog/${nextPost.slug}`}
class="group relative flex h-full flex-col overflow-hidden rounded-xl border border-zinc-200 p-4 transition-all duration-300 hover:-translate-y-1 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800/50 sm:p-6 md:text-right"
>
<div class="absolute inset-0 bg-gradient-to-l from-zinc-100 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100 dark:from-zinc-800 dark:to-transparent" />
<span class="relative z-10 mb-1 flex items-center gap-1 text-xs font-medium text-zinc-500 dark:text-zinc-400 sm:mb-2 sm:gap-2 sm:text-sm md:justify-end">
Next Article
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-3 w-3 transition-transform duration-300 group-hover:translate-x-1 sm:h-4 sm:w-4"
>
<path d="m9 18 6-6-6-6" />
</svg>
</span>
<h3 class="line-clamp-2 text-base font-medium text-zinc-900 transition-colors group-hover:text-zinc-700 dark:text-white dark:group-hover:text-zinc-300 sm:text-lg">
{nextPost.title}
</h3>
</a>
)
}
</style>
</BaseLayout>
</div>
</BlogPost>
<script>
// Add smooth reveal animations for content after loading
document.addEventListener('astro:page-load', () => {
const animateContent = () => {
const smoothReveal = document.querySelectorAll('.smooth-reveal');
smoothReveal.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
100 + index * 100
);
});
};
// Removing TOC-related functions
animateContent();
});
// Add copy buttons to code blocks
function initializeCodeCopyButtons() {
const codeBlocks = document.querySelectorAll('pre');
codeBlocks.forEach((block) => {
// Skip if already processed by either method
if (
block.classList.contains('code-block-processed') ||
block.classList.contains('enhanced')
) {
return;
}
block.classList.add('code-block-processed');
// Create wrapper if not already wrapped
let wrapper;
if (
block.parentNode.classList.contains('relative') &&
block.parentNode.classList.contains('group')
) {
wrapper = block.parentNode;
} else {
wrapper = document.createElement('div');
wrapper.className = 'relative group';
block.parentNode.insertBefore(wrapper, block);
wrapper.appendChild(block);
}
// Add copy button if not already present
if (!wrapper.querySelector('.copy-button') && !wrapper.querySelector('.copy-code-button')) {
const copyButton = document.createElement('button');
copyButton.className =
'copy-button absolute top-2 right-2 p-1.5 rounded-md bg-zinc-700/50 hover:bg-zinc-700 text-zinc-200 opacity-0 group-hover:opacity-100 transition-opacity duration-200';
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
</svg>
`;
copyButton.addEventListener('click', () => {
const code = block.querySelector('code').innerText;
navigator.clipboard.writeText(code);
// Show copied feedback
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
`;
setTimeout(() => {
copyButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 17.25v3.375c0 .621-.504 1.125-1.125 1.125h-9.75a1.125 1.125 0 01-1.125-1.125V7.875c0-.621.504-1.125 1.125-1.125H6.75a9.06 9.06 0 011.5.124m7.5 10.376h3.375c.621 0 1.125-.504 1.125-1.125V11.25c0-4.46-3.243-8.161-7.5-8.876a9.06 9.06 0 00-1.5-.124H9.375c-.621 0-1.125.504-1.125 1.125v3.5m7.5 10.375H9.375a1.125 1.125 0 01-1.125-1.125v-9.25m12 6.625v-1.875a3.375 3.375 0 00-3.375-3.375h-1.5a1.125 1.125 0 01-1.125-1.125v-1.5a3.375 3.375 0 00-3.375-3.375H9.75" />
</svg>
`;
}, 2000);
});
wrapper.appendChild(copyButton);
}
});
}
// Handle SPA transitions for blog post navigation
function setupSPATransitions() {
// Handle prev/next navigation links
document.querySelectorAll('a[href^="/blog/"]').forEach((link) => {
// Skip links that are anchor links or already processed
if (link.getAttribute('href').includes('#') || link.hasAttribute('data-spa-handled')) {
return;
}
// Mark as handled to avoid duplicate listeners
link.setAttribute('data-spa-handled', 'true');
link.addEventListener('click', (e) => {
// Don't handle if modifier keys are pressed (for opening in new tab, etc.)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
const targetHref = link.getAttribute('href');
// Trigger page transition animation
const pageTransition = document.getElementById('page-transition');
if (pageTransition) {
pageTransition.classList.remove('opacity-0');
pageTransition.classList.add('opacity-100');
// Navigate after transition effect
setTimeout(() => {
window.location.href = targetHref;
}, 300);
} else {
// Fallback if transition element doesn't exist
window.location.href = targetHref;
}
});
});
}
// Main initialization function
function initializeBlogPost() {
// Initialize remaining components
initializeCodeCopyButtons();
setupSPATransitions();
// Scroll to hash if present in URL
if (window.location.hash) {
setTimeout(() => {
const element = document.querySelector(window.location.hash);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 100);
}
}
// Initialize on first load
document.addEventListener('DOMContentLoaded', initializeBlogPost);
// Re-initialize when content changes via Astro's view transitions
document.addEventListener('astro:page-load', initializeBlogPost);
// For compatibility with custom transition system
document.addEventListener('page-transition-complete', initializeBlogPost);
</script>
<style>
/* Removing TOC-related styles */
/* Language badge styling */
.language-badge {
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
monospace;
text-transform: lowercase;
letter-spacing: 0.05em;
}
/* Extra small screens */
@media (min-width: 480px) {
.xs\:inline {
display: inline;
}
.xs\:hidden {
display: none;
}
}
/* Enhanced typography for blog content - Responsive adjustments */
.prose {
@apply text-zinc-800 dark:text-zinc-200;
}
.prose h1,
.prose h2,
.prose h3,
.prose h4 {
@apply font-semibold text-zinc-900 dark:text-zinc-100;
}
.prose h1 {
@apply text-2xl sm:text-3xl md:text-4xl;
}
.prose h2 {
@apply mb-3 mt-8 border-b border-zinc-200 pb-2 text-xl dark:border-zinc-800 sm:mb-4 sm:mt-12 sm:text-2xl;
}
.prose h3 {
@apply mb-2 mt-6 text-lg sm:mb-3 sm:mt-8 sm:text-xl;
}
.prose p {
@apply mb-4 text-sm leading-relaxed sm:mb-6 sm:text-base;
}
.prose a {
@apply font-medium text-zinc-800 underline decoration-zinc-400 underline-offset-2 transition-colors hover:text-zinc-600 hover:decoration-zinc-600 dark:text-zinc-300 dark:decoration-zinc-600 dark:hover:text-zinc-100 dark:hover:decoration-zinc-400;
}
.prose blockquote {
@apply my-4 border-l-4 border-zinc-300 pl-4 italic text-zinc-700 dark:border-zinc-700 dark:text-zinc-300 sm:my-6;
}
.prose code {
@apply rounded bg-zinc-100 px-1.5 py-0.5 text-sm font-medium text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200;
}
.prose pre {
@apply my-4 overflow-x-auto rounded-lg bg-[#1e293b] p-3 text-xs text-zinc-200 shadow-md dark:bg-[#1e293b] sm:my-6 sm:p-4 sm:text-sm !important;
}
.prose pre code {
@apply bg-transparent p-0 text-zinc-200 dark:text-zinc-200 !important;
}
.prose img {
@apply mx-auto my-6 h-auto max-w-full rounded-lg shadow-md sm:my-8;
}
.prose ul,
.prose ol {
@apply my-4 pl-5 sm:my-6 sm:pl-6;
}
.prose li {
@apply mb-1 text-sm sm:mb-2 sm:text-base;
}
.prose hr {
@apply my-8 border-zinc-200 dark:border-zinc-800 sm:my-10;
}
/* Line clamp for truncating text */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -1,103 +1,438 @@
---
import { readItems, readSingleton } from '@directus/sdk';
import BaseLayout from '../../layouts/BaseLayout.astro';
import type { Post } from '@lib/directusTypes';
import directus from '../../../lib/directus';
import { readItems } from '@directus/sdk';
import directus from '@lib/directus';
import BaseLayout from '@layouts/BaseLayout.astro';
import BlogRecentCard from '@components/blog/BlogRecentCard.astro';
import BlogFeaturedArticle from '@components/blog/BlogFeaturedArticle.astro';
import HeroSection from '@components/ui/sections/HeroSection.astro';
import blogImg from '@images/autumn_tree.png';
const global = await directus.request(readSingleton('site_global'));
const posts = await directus.request(
readItems('posts', {
filter: { published: { _eq: true } },
fields: ['*'],
sort: ['-published_date'],
})
);
const selectedPosts: Post[] = posts.filter((p) => p.selected);
const description =
"Sharing what I've learned, one post at a time. I hope you find something useful.";
const sortedPosts = posts.sort((a, b) => b.published_date.valueOf() - a.published_date.valueOf());
// Group posts by year for timeline effect
const postsByYear = sortedPosts.reduce((acc, post) => {
const year = new Date(post.published_date).getFullYear();
if (!acc[year]) acc[year] = [];
acc[year].push(post);
return acc;
}, {});
const years = Object.keys(postsByYear).sort((a, b) => b - a);
// Get total post count
const totalPosts = sortedPosts.length;
// Get unique tags for search suggestions
const allTags = [...new Set(sortedPosts.flatMap((post) => post.tags || []))];
---
<BaseLayout
title="Blog"
description={description}
structuredData={{
'@context': 'https://schema.org',
'@type': 'WebPage',
inLanguage: 'en-US',
'@id': Astro.url.href,
url: Astro.url.href,
name: `Blog | ${global.name}`,
description: description,
isPartOf: {
'@type': 'WebSite',
url: global.site_url,
name: global.name,
description: global.about,
},
}}
>
<HeroSection title="Blog" subTitle={description} src={blogImg} alt={global.blog_image_alt} />
<BaseLayout title="Blog">
<div class="mx-auto w-full max-w-6xl px-4 py-10 sm:px-6 sm:py-16">
<!-- Header with search -->
<div class="relative mb-12 sm:mb-20">
<!-- Decorative elements -->
<div
class="animate-blob absolute -left-10 -top-10 h-48 w-48 rounded-full bg-zinc-100 opacity-30 blur-3xl dark:bg-zinc-800/30 sm:-left-20 sm:-top-20 sm:h-72 sm:w-72"
>
</div>
<div
class="animate-blob animation-delay-2000 absolute -bottom-10 -right-10 h-48 w-48 rounded-full bg-zinc-200 opacity-30 blur-3xl dark:bg-zinc-800/30 sm:-bottom-20 sm:-right-20 sm:h-72 sm:w-72"
>
</div>
<BlogRecentCard posts={posts} />
<BlogFeaturedArticle posts={selectedPosts} />
<div class="relative text-center">
<h1
class="mb-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-4xl md:text-5xl"
>
Blog
</h1>
<p
class="mx-auto mb-6 max-w-2xl text-sm text-zinc-600 dark:text-zinc-400 sm:mb-10 sm:text-base"
>
Thoughts, ideas, and explorations on technology and selfhosting.
</p>
</div>
</div>
<!-- Grid layout for mobile experience -->
<div class="grid grid-cols-1 gap-6 sm:gap-8 md:grid-cols-12">
<!-- Featured post (if exists) -->
{
sortedPosts.length > 0 && (
<div class="mb-8 sm:mb-12 md:col-span-12">
<article class="group relative overflow-hidden rounded-none border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:pb-8">
<div class="flex h-full flex-col gap-6 sm:gap-8 md:flex-row">
{sortedPosts[0].image && (
<div class="mx-auto h-60 w-full max-w-full overflow-hidden sm:h-80 sm:max-w-md md:mx-0 md:h-96 md:w-1/2">
<img
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${sortedPosts[0].image}`}
alt={sortedPosts[0].title}
class="h-full w-full object-cover grayscale transition-all duration-700 hover:grayscale-0 group-hover:scale-105"
loading="eager"
/>
</div>
)}
<div class="flex flex-1 flex-col justify-center">
<div class="mb-3 flex items-center justify-center gap-2 text-xs text-zinc-500 dark:text-zinc-400 sm:text-sm md:justify-start">
<span class="font-medium uppercase tracking-wider">Featured</span>
<span class="h-px w-6 bg-zinc-300 dark:bg-zinc-700 sm:w-8" />
{sortedPosts[0].published_date && (
<time datetime={sortedPosts[0].published_date.toLocaleString()}>
{sortedPosts[0].published_date.toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
)}
</div>
<h2 class="mb-3 text-center text-2xl font-bold text-zinc-900 transition-colors group-hover:text-zinc-700 dark:text-zinc-100 dark:group-hover:text-zinc-300 sm:mb-4 sm:text-3xl md:text-left">
<a
href={`/blog/${sortedPosts[0].slug}/`}
class="before:absolute before:inset-0"
>
{sortedPosts[0].title}
</a>
</h2>
<p class="mb-4 line-clamp-3 text-center text-sm text-zinc-600 dark:text-zinc-400 sm:mb-6 sm:text-base md:text-left">
{sortedPosts[0].description}
</p>
<div class="flex flex-wrap items-center justify-center gap-3 sm:gap-4 md:justify-start">
{sortedPosts[0].tags && (
<div class="flex flex-wrap justify-center gap-2 md:justify-start">
{sortedPosts[0].tags.slice(0, 2).map((tag) => (
<span class="border border-zinc-200 px-2 py-1 text-xs uppercase tracking-wider text-zinc-600 dark:border-zinc-800 dark:text-zinc-400 sm:px-3">
{tag}
</span>
))}
</div>
)}
</div>
</div>
</div>
</article>
</div>
)
}
<!-- Improved sidebar for mobile -->
<div class="relative md:col-span-3">
<div class="mb-8 space-y-4 md:sticky md:top-24 md:mb-0">
<h3
class="mb-4 text-center text-lg font-medium uppercase tracking-wider text-zinc-900 dark:text-zinc-100 md:text-left"
>
Archive
</h3>
<!-- Horizontal scrollable archive on mobile, vertical on desktop -->
<div
class="hide-scrollbar flex overflow-x-auto pb-4 md:flex-col md:overflow-visible md:pb-0"
>
{
years.map((year, index) => (
<a
href={`#year-${year}`}
class={`mr-3 flex items-center whitespace-nowrap rounded-full border-b border-zinc-100 px-4 py-2 transition-colors hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-900 md:mr-0 md:w-full md:whitespace-normal md:rounded-none md:px-0 md:py-3 ${index === 0 ? 'bg-zinc-50 dark:bg-zinc-800/50' : ''}`}
>
<span class="text-base font-medium text-zinc-900 dark:text-zinc-100 md:text-lg">
{year}
</span>
<span class="ml-2 text-xs text-zinc-500 dark:text-zinc-400 md:ml-auto md:text-sm">
{postsByYear[year].length} post{postsByYear[year].length !== 1 ? 's' : ''}
</span>
</a>
))
}
</div>
</div>
</div>
<!-- Improved post grid for mobile -->
<div class="md:col-span-9">
{
years.map((year) => (
<div id={`year-${year}`} class="mb-12 scroll-mt-16 sm:mb-20">
<h2 class="mb-6 border-b border-zinc-200 pb-3 text-center text-xl font-bold text-zinc-900 dark:border-zinc-800 dark:text-zinc-100 sm:mb-8 sm:pb-4 sm:text-2xl md:text-left">
{year}
</h2>
<div
class={`grid grid-cols-1 ${postsByYear[year].length >= 2 ? 'md:grid-cols-2' : 'md:grid-cols-1'} gap-8 sm:gap-12`}
>
{postsByYear[year].map((post, index) => (
<article class="group relative mx-auto flex h-full w-full max-w-sm flex-col sm:max-w-md md:mx-0">
{post.image && (
<div class="mb-4 h-48 overflow-hidden rounded-lg sm:h-56">
<img
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${post.image}`}
alt={post.title}
class="h-full w-full object-cover grayscale transition-all duration-700 hover:grayscale-0 group-hover:scale-105"
loading="lazy"
/>
</div>
)}
<div class="flex flex-1 flex-col">
<div class="mb-2 flex flex-wrap items-center justify-center gap-3 text-xs text-zinc-500 dark:text-zinc-400 sm:mb-3 sm:gap-4 sm:text-sm md:justify-start">
{post.pubDate && (
<time
datetime={post.published_date.toLocaleString()}
class="flex items-center"
>
{post.published_date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
})}
</time>
)}
</div>
<h3 class="mb-2 text-center text-lg font-semibold text-zinc-900 transition-colors group-hover:text-zinc-700 dark:text-zinc-100 dark:group-hover:text-zinc-300 sm:mb-3 sm:text-xl md:text-left">
<a href={`/blog/${post.slug}/`} class="before:absolute before:inset-0">
{post.title}
</a>
</h3>
<p class="mb-4 line-clamp-2 flex-grow text-center text-sm text-zinc-600 dark:text-zinc-400 md:text-left">
{post.description}
</p>
{post.tags && (
<div class="mt-auto flex flex-wrap justify-center gap-2 md:justify-start">
{post.tags.slice(0, 2).map((tag) => (
<span class="border border-zinc-200 px-2 py-1 text-xs uppercase tracking-wider text-zinc-600 dark:border-zinc-800 dark:text-zinc-400 sm:px-3">
{tag}
</span>
))}
{post.tags.length > 2 && (
<span class="border border-zinc-200 px-2 py-1 text-xs uppercase tracking-wider text-zinc-600 dark:border-zinc-800 dark:text-zinc-400 sm:px-3">
+{post.tags.length - 2}
</span>
)}
</div>
)}
</div>
</article>
))}
</div>
</div>
))
}
</div>
</div>
</div>
</BaseLayout>
<style>
/* Blob animation */
.animate-blob {
animation: blob-bounce 8s infinite ease;
}
.animation-delay-2000 {
animation-delay: 2s;
}
@keyframes blob-bounce {
0%,
100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(5%, 5%) scale(1.05);
}
50% {
transform: translate(0, 10%) scale(1);
}
75% {
transform: translate(-5%, 5%) scale(0.95);
}
}
/* Search container hover effect */
.search-container:hover .search-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
/* Input focus animation */
input:focus + div .search-pulse {
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Hide scrollbar but keep functionality */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Line clamp for descriptions */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Improved touch targets for mobile */
@media (max-width: 640px) {
a,
button {
min-height: 44px;
display: flex;
align-items: center;
}
}
</style>
<script>
// Add smooth reveal animations for content after loading
document.addEventListener('astro:page-load', () => {
const animateContent = () => {
// Animate group 1
const smoothReveal = document.querySelectorAll('.smooth-reveal');
smoothReveal.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
200 + index * 300
);
// Script không thay đổi - giữ nguyên chức năng
document.addEventListener('DOMContentLoaded', () => {
const backToTopButton = document.getElementById('back-to-top');
if (backToTopButton) {
// Show button when scrolled down
const toggleBackToTopButton = () => {
if (window.scrollY > 300) {
backToTopButton.classList.remove('opacity-0', 'invisible');
backToTopButton.classList.add('opacity-100', 'visible');
} else {
backToTopButton.classList.remove('opacity-100', 'visible');
backToTopButton.classList.add('opacity-0', 'invisible');
}
};
// Scroll to top when clicked
backToTopButton.addEventListener('click', () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
});
// Animate group 2
const smoothReveal2 = document.querySelectorAll('.smooth-reveal-2');
smoothReveal2.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
500 + index * 100
);
});
// Check scroll position
window.addEventListener('scroll', toggleBackToTopButton);
toggleBackToTopButton(); // Initial check
}
// Animate topic cards with staggered delay
const smoothRevealCards = document.querySelectorAll('.smooth-reveal-cards');
smoothRevealCards.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
1000 + index * 250
);
});
// Add smooth scrolling to year links
document.querySelectorAll('a[href^="#year-"]').forEach((anchor) => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const targetId = this.getAttribute('href');
const targetElement = document.querySelector(targetId);
// Animate with just fade in with staggered delay
const smoothRevealFade = document.querySelectorAll('.smooth-reveal-fade');
smoothRevealFade.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal-fade');
},
100 + index * 250
);
});
};
if (targetElement) {
window.scrollTo({
top: targetElement.offsetTop - 100,
behavior: 'smooth',
});
animateContent();
// Update URL hash without jumping
history.pushState(null, null, targetId);
}
});
});
// Add touch support for hover effects
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
if (isTouchDevice) {
const articles = document.querySelectorAll('article');
articles.forEach((article) => {
article.addEventListener('touchstart', () => {
article.classList.add('is-touched');
});
article.addEventListener('touchend', () => {
setTimeout(() => {
article.classList.remove('is-touched');
}, 300);
});
});
}
});
// SPA transition handling
function setupSPATransitions() {
// Handle all blog post links for SPA transitions
document.querySelectorAll('a[href^="/blog/"]').forEach((link) => {
// Skip links that are anchor links or already processed
if (link.getAttribute('href').includes('#') || link.hasAttribute('data-spa-handled')) {
return;
}
// Mark as handled to avoid duplicate listeners
link.setAttribute('data-spa-handled', 'true');
link.addEventListener('click', (e) => {
// Don't handle if modifier keys are pressed (for opening in new tab, etc.)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
const targetHref = link.getAttribute('href');
// Trigger page transition animation
const pageTransition = document.getElementById('page-transition');
if (pageTransition) {
pageTransition.classList.remove('opacity-0');
pageTransition.classList.add('opacity-100');
// Navigate after transition effect
setTimeout(() => {
window.location.href = targetHref;
}, 300);
} else {
// Fallback if transition element doesn't exist
window.location.href = targetHref;
}
});
});
// Handle year anchor links specially
document.querySelectorAll('a[href^="#year-"]').forEach((anchor) => {
anchor.setAttribute('data-spa-internal', 'true');
});
}
// Initialize on first load
document.addEventListener('DOMContentLoaded', setupSPATransitions);
// Re-initialize when content changes via Astro's view transitions
document.addEventListener('astro:page-load', setupSPATransitions);
// For compatibility with custom transition system
document.addEventListener('page-transition-complete', setupSPATransitions);
</script>

View File

@@ -1,67 +0,0 @@
---
import { getCollection } from 'astro:content';
import { readItems, readSingleton } from '@directus/sdk';
import directus from '@lib/directus';
import type { Post } from '@lib/directusTypes';
import BaseLayout from '@layouts/BaseLayout.astro';
import BlogCard from '@components/blog/BlogCard.astro';
import HeaderSection from '@components/ui/sections/HeaderSection.astro';
export async function getStaticPaths() {
const categories = await getCollection('categories');
return categories.map((category) => ({
params: { slug: category.slug },
props: { category },
}));
}
const { category } = Astro.props;
const global = await directus.request(readSingleton('site_global'));
const posts = await directus.request(
readItems('posts', {
filter: { published: { _eq: true } },
fields: ['*'],
sort: ['-published_date'],
})
);
const categoriesPosts = posts
.sort((a: Post, b: Post) => b.published_date.valueOf() - a.published_date.valueOf())
.filter((b) => {
return b.category === category.slug;
});
---
<BaseLayout
title={category.data.title}
description={category.data.description}
structuredData={{
'@context': 'https://schema.org',
'@type': 'WebPage',
inLanguage: 'en-US',
'@id': Astro.url.href,
url: Astro.url.href,
name: `${category.data.title} | ${global.name}`,
description: category.data.description,
isPartOf: {
url: `${global.site_url}/categories`,
name: global.name,
description: global.about,
},
}}
>
<HeaderSection
title={category.data.title}
subTitle={category.data.description}
btnExists
btnTitle="Back to Categories"
btnURL="/categories"
/>
<section class="mx-auto mt-10 mb-10 max-w-[85rem] px-4 py-8 sm:px-6 lg:px-8 2xl:max-w-full">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{categoriesPosts.map((b) => <BlogCard post={b} />)}
</div>
</section>
</BaseLayout>

View File

@@ -1,182 +0,0 @@
---
import { getCollection } from 'astro:content';
import { readItems, readSingleton } from '@directus/sdk';
import type { Post } from '@lib/directusTypes';
import directus from '@lib/directus';
import BaseLayout from '@layouts/BaseLayout.astro';
import BlogCategoryCard from '@components/blog/BlogCategoryCard.astro';
import HeroSection from '@components/ui/sections/HeroSection.astro';
import { timeago } from '@support/time';
import categoryImg from '@images/autumn_bench.png';
const global = await directus.request(readSingleton('site_global'));
const posts = await directus.request(
readItems('posts', {
filter: { published: { _eq: true } },
fields: ['*'],
sort: ['-published_date'],
})
);
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 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 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} smooth-reveal-cards rounded-xl transition-all duration-300 shadow-xs hover:shadow-md dark:shadow-md dark:hover:shadow-lg border border-stone-200/50 dark:border-stone-700/50`;
return {
...c,
posts,
gridItemClass,
layoutPattern: {
smCol: smColSpan,
mdCol: mdColSpan,
row: rowSpan,
index,
},
};
});
const description =
'Here are some of the general categories that I am interested in, including homelabs, technology, and Minnesota.';
---
<BaseLayout
title="All Categories"
description={description}
structuredData={{
'@context': 'https://schema.org',
'@type': 'WebPage',
inLanguage: 'en-US',
'@id': Astro.url.href,
url: Astro.url.href,
name: `All Categories | ${global.name}`,
description: description,
isPartOf: {
'@type': 'WebSite',
url: global.site_url,
name: global.name,
description: global.about,
},
}}
>
<HeroSection
title="Categories"
subTitle={description}
src={categoryImg}
alt={global.categories_image_alt}
/>
<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 gap-4 md:grid-cols-4">
{
categories.map((category) => {
return (
<div
class={category.gridItemClass}
style={category.layoutPattern.row > 1 ? 'grid-row: span 2 / span 2;' : ''}
>
<BlogCategoryCard
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>
</BaseLayout>
<script>
// Add smooth reveal animations for content after loading
document.addEventListener('astro:page-load', () => {
const animateContent = () => {
// Animate group 1
const smoothReveal = document.querySelectorAll('.smooth-reveal');
smoothReveal.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
50 + index * 100
);
});
// Animate group 2
const smoothReveal2 = document.querySelectorAll('.smooth-reveal-2');
smoothReveal2.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
200 + index * 150
);
});
// Animate topic cards with staggered delay
const smoothRevealCards = document.querySelectorAll('.smooth-reveal-cards');
smoothRevealCards.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
500 + index * 100
);
});
// Animate with just fade in with staggered delay
const smoothRevealFade = document.querySelectorAll('.smooth-reveal-fade');
smoothRevealFade.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal-fade');
},
100 + index * 250
);
});
};
animateContent();
});
</script>

View File

@@ -1,24 +0,0 @@
import path from 'node:path';
import type { APIRoute } from 'astro';
import sharp from 'sharp';
import ico from 'sharp-ico';
const faviconSrc = path.resolve('src/images/favicon_icon.png');
export const GET: APIRoute = async () => {
// Resize the image to multiple sizes
const sizes = [16, 32];
const buffers = await Promise.all(
sizes.map(async (size) => {
return await sharp(faviconSrc).resize(size).toFormat('png').toBuffer();
})
);
// Convert the image to an ICO file
const icoBuffer = ico.encode(buffers);
return new Response(icoBuffer, {
headers: { 'Content-Type': 'image/x-icon' },
});
};

View File

@@ -1,108 +1,591 @@
---
import { readSingleton } from '@directus/sdk';
import Layout from '../layouts/Layout.astro';
import FormattedDate from '../components/FormattedDate.astro';
import directus from '@lib/directus';
import BaseLayout from '@layouts/BaseLayout.astro';
import HeroSection from '@components/ui/sections/HeroSection.astro';
import FeaturesSection from '@components/ui/sections/FeaturesSection.astro';
import LatestPosts from '@components/ui/sections/LatestPosts.astro';
import HeroSectionAlt from '@components/ui/sections/HeroSectionAlt.astro';
import homeImg from '@images/autumn_mountain.png';
import directus from '../../lib/directus';
import { readItems, readSingleton } from '@directus/sdk';
const global = await directus.request(readSingleton('site_global'));
const global = await directus.request(readSingleton('global'));
const description =
'Engineering the cloud by day, homelab by night, and exploring Minnesota in between.';
const posts = await directus.request(
readItems('posts', {
fields: ['*'],
sort: ['-published_date'],
})
);
const recentPosts = posts
.sort((a, b) => b.published_date.getTime() - a.published_date.getTime())
.slice(0, 3);
const allTags = [...new Set(posts.flatMap((post) => post.tags || []))].slice(0, 5);
---
<BaseLayout
title="Home"
description={description}
structuredData={{
'@context': 'https://schema.org',
'@type': 'WebPage',
inLanguage: 'en-US',
'@id': Astro.url.href,
url: Astro.url.href,
name: `Home | ${global.name}`,
description: description,
isPartOf: {
'@type': 'WebSite',
url: global.site_url,
name: global.name,
description: global.about,
},
}}
>
<HeroSection
title={`Hello, I'm <span class="text-steel dark:text-steel">Alex Lebens</span>`}
subTitle={description}
primaryBtn="About Me"
primaryBtnURL="/about"
src={homeImg}
alt={global.home_image_alt}
/>
<Layout title=`Home | ${global.name}`>
<!-- Hero Section with improved mobile responsiveness -->
<section class="theme-transition-all px-4 py-10 sm:px-6 sm:py-16 md:py-20">
<div class="relative mx-auto max-w-2xl">
<!-- Adjusted blob positions and sizes for better mobile appearance -->
<div
class="animate-blob theme-transition-bg absolute -left-10 -top-10 h-40 w-40 rounded-full bg-zinc-100 opacity-50 blur-3xl dark:bg-zinc-800/50 sm:-left-20 sm:-top-20 sm:h-64 sm:w-64"
>
</div>
<div
class="animate-blob animation-delay-2000 theme-transition-bg absolute -bottom-10 -right-10 h-40 w-40 rounded-full bg-zinc-200 opacity-30 blur-3xl dark:bg-zinc-800/30 sm:-bottom-20 sm:-right-20 sm:h-64 sm:w-64"
>
</div>
<FeaturesSection />
<div class="relative text-center sm:text-left">
<h1
class="theme-transition-color hero-text text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-4xl md:text-5xl lg:text-6xl"
>
<span class="block">Writing on technology,</span>
<span class="mt-1 block">development, and</span>
<span class="relative mt-1 block">
<span class="relative inline-block">
selfhosting.
<span
class="theme-transition-bg absolute -bottom-1 left-0 h-1 w-full origin-left transform bg-zinc-800 dark:bg-zinc-200"
></span>
</span>
</span>
</h1>
<p
class="theme-transition-color mx-auto mt-4 max-w-lg text-base leading-relaxed text-zinc-600 dark:text-zinc-400 sm:mx-0 sm:mt-6 sm:text-lg md:mt-8"
>
{global.about}
</p>
<div
class="mt-6 flex flex-wrap justify-center gap-3 sm:mt-8 sm:justify-start sm:gap-4 md:mt-10 md:gap-6"
>
<a
href="/about"
class="theme-transition-color group relative inline-flex min-h-[44px] items-center gap-2 text-sm font-medium text-zinc-900 transition-all duration-300 hover:text-zinc-700 dark:text-zinc-100 dark:hover:text-zinc-300"
>
<span>More about me</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"></path>
</svg>
<span
class="theme-transition-bg absolute -bottom-1 left-0 h-0.5 w-0 bg-zinc-800 transition-all duration-300 group-hover:w-full dark:bg-zinc-200"
></span>
</a>
</div>
</div>
</div>
</section>
<LatestPosts />
<!-- Featured Post Section - Improved for mobile -->
<section
class="theme-transition-all border-t border-zinc-100 px-4 py-10 dark:border-zinc-800 sm:px-6 sm:py-12 md:py-16"
>
<div class="mx-auto max-w-3xl">
<div
class="mb-6 flex flex-col justify-between gap-4 sm:mb-8 sm:flex-row sm:items-center md:mb-12"
>
<h2
class="theme-transition-color text-center text-xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-left sm:text-2xl md:text-3xl"
>
Recent Posts
</h2>
<a
href="/blog"
class="theme-transition-color group relative flex min-h-[44px] items-center justify-center self-center text-sm font-medium text-zinc-900 hover:text-zinc-700 dark:text-zinc-100 dark:hover:text-zinc-300 sm:self-auto"
>
<span class="flex items-center gap-1">
View all posts
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"></path>
</svg>
</span>
<span
class="theme-transition-bg absolute -bottom-1 left-0 h-0.5 w-0 bg-zinc-800 transition-all duration-300 group-hover:w-full dark:bg-zinc-200"
></span>
</a>
</div>
<HeroSectionAlt
title="Follow me on Gitea"
subTitle="I love open source and have my code availabile on my Gitea server."
url="https://gitea.alexlebens.dev"
/>
</BaseLayout>
<!-- Improved grid for better mobile layout -->
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 sm:gap-8 md:gap-12 lg:grid-cols-3">
{
recentPosts.map((post, index) => (
<article class="hover-3d theme-transition-element group relative mx-auto flex w-full max-w-sm flex-col items-start sm:mx-0">
<div class="theme-transition-bg absolute -inset-x-4 -inset-y-6 z-0 scale-95 bg-zinc-50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 dark:bg-zinc-800/50 sm:-inset-x-6 sm:rounded-2xl" />
{post.image && (
<div class="relative z-10 mb-4 aspect-video w-full overflow-hidden rounded-lg">
<img
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${post.image}`}
alt={post.title}
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading={index === 0 ? 'eager' : 'lazy'}
width="400"
height="225"
/>
</div>
)}
<div class="theme-transition-color relative z-10 flex w-full flex-wrap items-center justify-center gap-x-3 gap-y-2 text-xs text-zinc-500 dark:text-zinc-400 sm:justify-start sm:gap-x-4">
<time datetime={post.published_date.toLocaleString()} class="font-medium">
<FormattedDate date={post.published_date} />
</time>
</div>
<h3 class="theme-transition-color relative z-10 mt-3 w-full text-center text-lg font-semibold tracking-tight text-zinc-900 transition-colors group-hover:text-zinc-700 dark:text-zinc-100 dark:group-hover:text-zinc-300 sm:text-left sm:text-xl">
<a
href={`/blog/${post.slug}`}
class="flex min-h-[44px] items-center justify-center sm:justify-start"
>
<span class="absolute -inset-x-4 -inset-y-2.5 sm:-inset-x-6 sm:-inset-y-4" />
{post.title}
</a>
</h3>
<p class="theme-transition-color relative z-10 mt-2 line-clamp-3 w-full text-center text-sm text-zinc-600 dark:text-zinc-400 sm:mt-3 sm:text-left">
{post.description}
</p>
{post.tags && post.tags.length > 0 && (
<div class="relative z-10 mt-3 flex w-full flex-wrap justify-center gap-2 sm:mt-4 sm:justify-start">
{post.tags.slice(0, 3).map((tag) => (
<a
href={`/topics/${tag}`}
class="theme-transition-all inline-flex min-h-[28px] items-center rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-800 transition-colors hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 sm:px-3"
>
#{tag}
</a>
))}
{post.tags.length > 3 && (
<span class="theme-transition-all inline-flex min-h-[28px] items-center rounded-full bg-zinc-50 px-2 py-1 text-xs font-medium text-zinc-500 dark:bg-zinc-800/50 dark:text-zinc-400">
+{post.tags.length - 3} more
</span>
)}
</div>
)}
<a
href={`/blog/${post.slug}`}
class="theme-transition-color relative z-10 mx-auto mt-3 flex min-h-[44px] items-center text-sm font-medium text-zinc-700 transition-colors group-hover:text-zinc-900 dark:text-zinc-300 dark:group-hover:text-zinc-100 sm:mx-0 sm:mt-4"
>
<span class="relative inline-block overflow-hidden">
<span class="block transition-transform duration-300 group-hover:-translate-y-full">
Read article
</span>
<span class="absolute left-0 top-0 translate-y-full whitespace-nowrap transition-transform duration-300 group-hover:translate-y-0">
Explore now
</span>
</span>
<svg
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
class="ml-1 h-4 w-4 stroke-current transition-transform duration-300 group-hover:translate-x-1"
>
<path
d="M6.75 5.75 9.25 8l-2.5 2.25"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</a>
</article>
))
}
</div>
</div>
</section>
<!-- Topics/Tags Section - Improved for mobile -->
{
allTags.length > 0 && (
<section class="theme-transition-all border-t border-zinc-100 px-4 py-10 dark:border-zinc-800 sm:px-6 sm:py-12 md:py-16">
<div class="mx-auto max-w-3xl">
<h2 class="theme-transition-color mb-6 text-center text-xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:mb-8 sm:text-left sm:text-2xl md:text-3xl">
Explore Topics
</h2>
<div class="mx-auto grid max-w-xs grid-cols-1 gap-3 sm:max-w-none sm:grid-cols-2 sm:gap-4 md:grid-cols-3">
{allTags.map((tag) => {
const tagCount = posts.filter((post) => post.tags && post.tags.includes(tag)).length;
return (
<a
href={`/topics/${tag}`}
class="theme-transition-all group flex min-h-[80px] flex-col rounded-xl border border-zinc-200 p-3 transition-all duration-300 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800/70 sm:min-h-[90px] sm:p-4 md:p-6"
>
<div class="mb-2 flex items-start justify-between">
<span class="theme-transition-color mr-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">
#{tag}
</span>
<span class="theme-transition-all flex-shrink-0 rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
{tagCount} {tagCount === 1 ? 'post' : 'posts'}
</span>
</div>
<p class="theme-transition-color mt-1 text-xs text-zinc-600 dark:text-zinc-400">
Explore articles about {tag}
</p>
</a>
);
})}
</div>
<div class="mt-6 text-center sm:mt-8">
<a
href="/tags"
class="theme-transition-color inline-flex min-h-[44px] items-center text-sm font-medium text-zinc-900 hover:text-zinc-700 dark:text-zinc-100 dark:hover:text-zinc-300"
>
<span>View all topics</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="ml-1 h-4 w-4 transition-transform duration-300 group-hover:translate-x-1"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</a>
</div>
</div>
</section>
)
}
</Layout>
<script>
// Add smooth reveal animations for content after loading
document.addEventListener('astro:page-load', () => {
// Add hover effect for cards on touch devices
document.addEventListener('DOMContentLoaded', () => {
// Check if it's a touch device
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
if (isTouchDevice) {
const cards = document.querySelectorAll('.hover-3d');
cards.forEach((card) => {
card.addEventListener('touchstart', () => {
card.classList.add('is-touched');
});
card.addEventListener('touchend', () => {
setTimeout(() => {
card.classList.remove('is-touched');
}, 300);
});
});
// Disable hover animations on touch devices for better performance
document.documentElement.classList.add('touch-device');
}
// Improved viewport height fix for mobile browsers
const setVh = () => {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
};
// Set initial value
setVh();
// Update on resize and scroll to prevent content shifting
window.addEventListener('resize', setVh);
// Use a debounced scroll handler to prevent performance issues
let scrollTimeout;
window.addEventListener('scroll', () => {
if (scrollTimeout) {
window.cancelAnimationFrame(scrollTimeout);
}
scrollTimeout = window.requestAnimationFrame(() => {
// Lock width during scroll
document.body.style.width = '100%';
document.body.style.overflowX = 'hidden';
});
});
// Fix for iOS Safari address bar height changes
if (/iPhone|iPad|iPod/.test(navigator.userAgent)) {
// Force the layout to use the initial viewport size
document.documentElement.style.setProperty('--initial-vh', `${window.innerHeight * 0.01}px`);
// Apply fixed height to sections to prevent resizing
const sections = document.querySelectorAll('section');
sections.forEach((section) => {
section.style.width = '100%';
});
}
// Improved theme change handler that preserves scroll position and provides smoother transitions
document.addEventListener('themeChanged', () => {
// Store current scroll position
const scrollPosition = window.scrollY;
// Create a temporary overlay for smoother transition
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
inset: 0;
background-color: ${document.documentElement.classList.contains('dark') ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'};
z-index: 9999;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s ease;
`;
document.body.appendChild(overlay);
// Fade in overlay
requestAnimationFrame(() => {
overlay.style.opacity = '0.5';
// Update theme-transition elements without forcing reflow of entire page
requestAnimationFrame(() => {
document
.querySelectorAll(
'.theme-transition-all, .theme-transition-bg, .theme-transition-color'
)
.forEach((el) => {
// Apply a subtle animation instead of a hard reset
el.style.transition = 'all 0.5s ease';
});
// Fade out overlay after transition completes
setTimeout(() => {
overlay.style.opacity = '0';
setTimeout(() => {
overlay.remove();
}, 300);
}, 300);
});
});
// Restore scroll position (prevents jumping to top)
if (scrollPosition > 0) {
setTimeout(() => {
window.scrollTo({
top: scrollPosition,
behavior: 'auto', // Use 'auto' to prevent animation
});
}, 10);
}
});
// Fix theme inconsistency issues by checking theme on visibility change
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
const storedTheme = localStorage.getItem('theme');
const currentThemeIsDark = document.documentElement.classList.contains('dark');
if (storedTheme === 'dark' && !currentThemeIsDark) {
document.documentElement.classList.add('dark');
} else if (storedTheme === 'light' && currentThemeIsDark) {
document.documentElement.classList.remove('dark');
}
}
});
// Add smooth reveal animations for content after loading
const animateContent = () => {
// Animate group 1
const smoothReveal = document.querySelectorAll('.smooth-reveal');
smoothReveal.forEach((el, index) => {
// Animate hero section
const heroElements = document.querySelectorAll(
'.hero-text span, .hero-text + p, .hero-text ~ div'
);
heroElements.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
50 + index * 100
100 + index * 150
);
});
// Animate group 2
const smoothReveal2 = document.querySelectorAll('.smooth-reveal-2');
smoothReveal2.forEach((el, index) => {
// Animate posts with staggered delay
const articles = document.querySelectorAll('article.group');
articles.forEach((article, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
article.classList.add('animate-reveal');
},
200 + index * 250
500 + index * 150
);
});
// Animate topic cards with staggered delay
const smoothRevealCards = document.querySelectorAll('.smooth-reveal-cards');
smoothRevealCards.forEach((el, index) => {
const topicCards = document.querySelectorAll('a.group.flex.flex-col');
topicCards.forEach((card, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
card.classList.add('animate-reveal');
},
400 + index * 250
);
});
// Animate with just fade in with staggered delay
const smoothRevealFade = document.querySelectorAll('.smooth-reveal-fade');
smoothRevealFade.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal-fade');
},
100 + index * 250
800 + index * 100
);
});
};
animateContent();
// Run animations after the loading screen is hidden
const loadingScreen = document.getElementById('loading-screen');
if (loadingScreen) {
// Check if loading screen is already hidden (page refresh)
if (loadingScreen.style.display === 'none') {
animateContent();
} else {
// Wait for loading screen to hide
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (
mutation.target === loadingScreen &&
mutation.type === 'attributes' &&
mutation.attributeName === 'style' &&
loadingScreen.style.display === 'none'
) {
animateContent();
observer.disconnect();
}
});
});
observer.observe(loadingScreen, { attributes: true });
// Fallback
setTimeout(animateContent, 3500);
}
} else {
// If loading screen doesn't exist for some reason
animateContent();
}
});
// SPA transition handling for homepage
function setupSPATransitions() {
// Handle all internal links for SPA transitions
document.querySelectorAll('a[href^="/"]').forEach((link) => {
// Skip links that are anchor links, external links, or already processed
if (
link.getAttribute('href').includes('#') ||
link.getAttribute('target') === '_blank' ||
link.hasAttribute('data-spa-handled')
) {
return;
}
// Mark as handled to avoid duplicate listeners
link.setAttribute('data-spa-handled', 'true');
link.addEventListener('click', (e) => {
// Don't handle if modifier keys are pressed (for opening in new tab, etc.)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
const targetHref = link.getAttribute('href');
// Trigger page transition animation
const pageTransition = document.getElementById('page-transition');
if (pageTransition) {
pageTransition.classList.remove('opacity-0');
pageTransition.classList.add('opacity-100');
// Navigate after transition effect
setTimeout(() => {
window.location.href = targetHref;
}, 300);
} else {
// Fallback if transition element doesn't exist
window.location.href = targetHref;
}
});
});
}
// Initialize on first load
document.addEventListener('DOMContentLoaded', setupSPATransitions);
// Re-initialize when content changes via Astro's view transitions
document.addEventListener('astro:page-load', setupSPATransitions);
// For compatibility with custom transition system
document.addEventListener('page-transition-complete', setupSPATransitions);
</script>
<style>
/* Fix for theme transition issues */
:global(:root) {
--theme-transition-duration: 0.5s;
--theme-transition-timing: ease;
}
:global(html),
:global(body) {
transition: background-color var(--theme-transition-duration) var(--theme-transition-timing);
}
:global(.theme-transition-all) {
transition: all var(--theme-transition-duration) var(--theme-transition-timing);
}
:global(.theme-transition-bg) {
transition: background-color var(--theme-transition-duration) var(--theme-transition-timing);
}
:global(.theme-transition-color) {
transition: color var(--theme-transition-duration) var(--theme-transition-timing);
}
/* Ensure transitions apply to all theme-related properties */
:global(*) {
transition-property: background-color, border-color, color, fill, stroke, opacity;
transition-duration: var(--theme-transition-duration);
transition-timing-function: var(--theme-transition-timing);
}
/* Remove the forced transition disabling which causes flickering */
:global(.theme-switching),
:global(.theme-switching *) {
/* Use a subtle transition instead of none */
transition-duration: 0.3s !important;
}
/* Content reveal animations */
.hero-text span,
.hero-text + p,
.hero-text ~ div,
article.group,
a.group.flex.flex-col {
opacity: 0;
transform: translateY(20px);
transition:
opacity 0.8s ease,
transform 0.8s ease;
}
.animate-reveal {
opacity: 1 !important;
transform: translateY(0) !important;
}
/* Rest of your existing styles... */
</style>

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