Compare commits
60 Commits
Author | SHA1 | Date | |
---|---|---|---|
1972b3bc19 | |||
af77f90a49 | |||
bdda29f369 | |||
644c5fcd6a | |||
bafd8158d3 | |||
4d9c1a3e8c | |||
4a4233ac62 | |||
c71957348d | |||
400bf16dd9 | |||
85535614a0 | |||
38fcbb635b | |||
b1e57c3f17 | |||
e22a1985be | |||
70b0b86944 | |||
ba36de8e36 | |||
d2e44fe046 | |||
36ec797d3b | |||
086d98ba50 | |||
8a05fa4d96 | |||
dbbf886de9 | |||
ae7e21eb82 | |||
ce6f476e8f | |||
0ca6be1d91 | |||
cedcae02ce | |||
4ef6e85ed9 | |||
1ad039e9ff | |||
034d6d1120 | |||
2c436100c5 | |||
6ea1467653 | |||
1ba76ab5cf | |||
478482ab01 | |||
f1e3e4ecaa | |||
05eb8a092c | |||
633e374a17 | |||
cd75440a6d | |||
3354975e2e | |||
1ffe933d6e | |||
90318aad14 | |||
e454a510c6 | |||
a6d3ec5052 | |||
1d134d43da | |||
54c7c9e259 | |||
0d8cf28be4 | |||
d78a8d8c45 | |||
5b6abeb9f9 | |||
a3b0301d23 | |||
06f7546212 | |||
abd1d43f79 | |||
07f2f5f0e1 | |||
91b53a33c2 | |||
b3e23f3e6c | |||
ab68b6248f | |||
37d1f1d1f2 | |||
89e1c59e37 | |||
7153f29022 | |||
51041f6ae9 | |||
67f12ecf72 | |||
3e89e6cb1c | |||
e1632629a9 | |||
87343e78bb |
@@ -1,3 +1,5 @@
|
||||
.DS_Store
|
||||
.astro
|
||||
.vscode
|
||||
node_modules
|
||||
dist
|
||||
dist
|
||||
|
@@ -1,58 +0,0 @@
|
||||
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
|
@@ -1,58 +0,0 @@
|
||||
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
|
98
.gitea/workflows/release-image.yml
Normal file
@@ -0,0 +1,98 @@
|
||||
name: release-image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 0.*
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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
|
32
.gitea/workflows/renovate.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
name: renovate
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '@daily'
|
||||
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:40
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Renovate
|
||||
run: renovate
|
||||
env:
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_ENDPOINT: ${{ vars.INSTANCE_URL }}
|
||||
RENOVATE_REPOSITORIES: alexlebens/site-profile
|
||||
RENOVATE_GIT_AUTHOR: Renovate Bot <renovate-bot@alexlebens.net>
|
||||
LOG_LEVEL: info
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
RENOVATE_GIT_PRIVATE_KEY: ${{ secrets.RENOVATE_GIT_PRIVATE_KEY }}
|
||||
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}
|
||||
RENOVATE_REDIS_URL: ${{ vars.RENOVATE_REDIS_URL }}
|
75
.gitea/workflows/tag-old-issues.yaml
Normal file
@@ -0,0 +1,75 @@
|
||||
name: tag-old-issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '@daily'
|
||||
|
||||
jobs:
|
||||
tag-old-issues:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Tag Old Issues
|
||||
env:
|
||||
BOT_TOKEN: ${{ secrets.BOT_TOKEN }}
|
||||
INSTANCE_URL: ${{ vars.INSTANCE_URL }}
|
||||
REPO_OWNER: ${{ github.repository_owner }}
|
||||
REPO_NAME: ${{ github.repository_name }}
|
||||
TAG_NAME: 'stale'
|
||||
DAYS_OLD: 3
|
||||
EXCLUDE_TAG_NAME: ''
|
||||
REQUIRED_TAG: 'automerge'
|
||||
run: |
|
||||
# Install necessary tools
|
||||
apt-get update && apt-get install -y jq curl
|
||||
|
||||
# --- Conditionally build the API URL ---
|
||||
API_URL="${GITEA_INSTANCE_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues?state=open"
|
||||
if [[ -n "${REQUIRED_TAG}" ]]; then
|
||||
echo "Filtering for issues with the required tag: ${REQUIRED_TAG}"
|
||||
# URL-encode the tag to handle special characters
|
||||
ENCODED_TAG=$(jq -s -R -r @uri <<< "${REQUIRED_TAG}")
|
||||
API_URL="${API_URL}&labels=${ENCODED_TAG}"
|
||||
else
|
||||
echo "No required tag specified. Checking all open issues."
|
||||
fi
|
||||
|
||||
# Fetch issues using the constructed URL
|
||||
ISSUES=$(curl -s -X GET \
|
||||
-H "Authorization: token ${BOT_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"${API_URL}")
|
||||
|
||||
# Calculate the date ${DAYS_OLD} days ago in ISO 8601 format
|
||||
OLDER_THAN_DATE=$(date -d "-${DAYS_OLD} days" -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
# Filter issues older than the specified date and without the exclusion tag
|
||||
echo "$ISSUES" | jq -c '.[] | select(.created_at < "'"$OLDER_THAN_DATE"'")' | while read -r issue; do
|
||||
ISSUE_NUMBER=$(echo "$issue" | jq -r '.number')
|
||||
LABELS=$(echo "$issue" | jq -r '.labels[].name')
|
||||
|
||||
# Check if the issue has the exclusion tag
|
||||
if ! echo "$LABELS" | grep -q -w "${EXCLUDE_TAG_NAME}"; then
|
||||
echo "Tagging issue #${ISSUE_NUMBER} as ${TAG_NAME}"
|
||||
|
||||
# Get existing labels for the issue
|
||||
EXISTING_LABELS=$(curl -s -X GET \
|
||||
-H "Authorization: token ${BOT_TOKEN}" \
|
||||
-H "Accept: application/json" \
|
||||
"${INSTANCE_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${ISSUE_NUMBER}/labels" | jq -r '.[].name')
|
||||
|
||||
# Add the new tag to the list of existing labels
|
||||
NEW_LABELS=$(echo -e "${EXISTING_LABELS}\n${TAG_NAME}" | sort -u | jq -R -s -c 'split("\n") | map(select(length > 0))')
|
||||
|
||||
# Update the issue with the new set of labels
|
||||
curl -s -X PUT \
|
||||
-H "Authorization: token ${BOT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"labels\": $(echo "$NEW_LABELS" | jq -r 'map(select(. != ""))')}" \
|
||||
"${INSTANCE_URL}/api/v1/repos/${REPO_OWNER}/${REPO_NAME}/issues/${ISSUE_NUMBER}/labels"
|
||||
else
|
||||
echo "Skipping issue #${ISSUE_NUMBER} because it has the '${EXCLUDE_TAG_NAME}' tag."
|
||||
fi
|
||||
done
|
37
.gitea/workflows/test-build.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
name: test-build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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.16.x
|
||||
cache: pnpm
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint Code
|
||||
run: pnpm lint
|
||||
|
||||
- name: Build Project
|
||||
run: pnpm build
|
7
.gitignore
vendored
@@ -12,18 +12,15 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
|
||||
# jetbrains setting folder
|
||||
.idea/
|
||||
|
||||
# ide
|
||||
.vscode/
|
||||
site-profile.code-workspace
|
||||
.pre-commit-config.yaml
|
||||
|
4
.vscode/extensions.json
vendored
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
11
.vscode/launch.json
vendored
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
15
Dockerfile
@@ -1,7 +1,8 @@
|
||||
FROM node:22.15.1-alpine3.20 AS base
|
||||
ARG REGISTRY=docker.io
|
||||
FROM ${REGISTRY}/node:22.16.0-alpine3.22 AS base
|
||||
|
||||
LABEL version="0.6.8"
|
||||
LABEL description="Astro based website to use as a personal site"
|
||||
LABEL version="0.8.12"
|
||||
LABEL description="Astro based personal website"
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
@@ -20,12 +21,16 @@ 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
|
||||
COPY --from=build /app/dist /app/dist
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV SITE_URL=https://www.alexlebens.dev
|
||||
ENV DIRECTUS_URL=https://directus.alexlebens.dev
|
||||
ENV PORT=4321
|
||||
EXPOSE 4321
|
||||
CMD node ./dist/server/entry.mjs
|
||||
|
||||
EXPOSE $PORT
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
|
@@ -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
|
84
README.md
@@ -1,30 +1,74 @@
|
||||
# Astro Starter Kit: Portfolio
|
||||
# Alex Lebens Personal Site
|
||||
|
||||
Personal site used for information about myself and blog.
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 **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
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Requirements
|
||||
|
||||
- Node.js 22+ and pnpm
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://gitea.alexlebens.dev/alexlebens/site-profile
|
||||
|
||||
# Navigate to project directory
|
||||
cd site-profile
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
```sh
|
||||
pnpm create astro@latest -- --template portfolio
|
||||
```
|
||||
|
||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/portfolio)
|
||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/portfolio)
|
||||
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/portfolio/devcontainer.json)
|
||||
### Development
|
||||
|
||||
> 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
|
||||
```bash
|
||||
# Start development server
|
||||
pnpm dev
|
||||
|
||||

|
||||
# Open browser at http://localhost:4321
|
||||
```
|
||||
|
||||
## 🧞 Commands
|
||||
### Build
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
```bash
|
||||
# Create production build
|
||||
pnpm build
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :----------------------------------------------- |
|
||||
| `pnpm install` | Installs dependencies |
|
||||
| `pnpm dev` | Starts local dev server at `localhost:4321` |
|
||||
| `pnpm build` | Build your production site to `./dist/` |
|
||||
| `pnpm preview` | Preview your build locally, before deploying |
|
||||
| `pnpm astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `pnpm astro -- --help` | Get help using the Astro CLI |
|
||||
# Preview production build
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
## 👀 Want to learn more?
|
||||
## Project Structure
|
||||
|
||||
Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
|
||||
```
|
||||
/
|
||||
├── 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
|
||||
```
|
||||
|
@@ -1,10 +1,29 @@
|
||||
import { defineConfig } from 'astro/config';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@astrojs/react';
|
||||
|
||||
import node from "@astrojs/node";
|
||||
import node from '@astrojs/node';
|
||||
|
||||
const getSiteURL = () => {
|
||||
if (process.env.SITE_URL) {
|
||||
return `https://${process.env.SITE_URL}`;
|
||||
}
|
||||
return 'http://localhost:4321';
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
output: "static",
|
||||
site: getSiteURL(),
|
||||
integrations: [tailwindcss(), react()],
|
||||
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
|
||||
adapter: node({
|
||||
mode: "standalone"
|
||||
})
|
||||
});
|
||||
mode: 'standalone',
|
||||
}),
|
||||
});
|
||||
|
11
eslint.config.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
import eslintPluginAstro from 'eslint-plugin-astro';
|
||||
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
||||
|
||||
export default [
|
||||
...eslintPluginAstro.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
{
|
||||
rules: {
|
||||
}
|
||||
}
|
||||
];
|
@@ -1,50 +1,59 @@
|
||||
import { createDirectus, rest, } from '@directus/sdk';
|
||||
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 Skills = {
|
||||
skill_1: string;
|
||||
skill_1_description: string;
|
||||
skill_2: string;
|
||||
skill_2_description: string;
|
||||
skill_3: string;
|
||||
skill_3_description: string;
|
||||
}
|
||||
type Links = {
|
||||
github: string;
|
||||
linkedin: string;
|
||||
gitea: string;
|
||||
};
|
||||
|
||||
type Skill = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
level: string;
|
||||
};
|
||||
|
||||
export type Post = {
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
image: string;
|
||||
image_alt: string;
|
||||
published_date: string;
|
||||
tags: string[];
|
||||
}
|
||||
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;
|
||||
skills: Skills;
|
||||
links: Links;
|
||||
skills: Skill[];
|
||||
posts: Post[];
|
||||
}
|
||||
};
|
||||
|
||||
export const directus_url = "https://directus.alexlebens.dev"
|
||||
|
||||
const directus = createDirectus<Schema>(directus_url).with(rest());
|
||||
const directus = createDirectus<Schema>(
|
||||
process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'
|
||||
).with(rest());
|
||||
|
||||
export default directus;
|
||||
|
37
package.json
@@ -1,18 +1,43 @@
|
||||
{
|
||||
"name": "site-profile",
|
||||
"type": "module",
|
||||
"version": "0.6.8",
|
||||
"version": "0.8.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,md,mdx,astro}\"",
|
||||
"lint": "eslint \"src/**/*.{js,ts,jsx,tsx,astro}\"",
|
||||
"lint:fix": "eslint --fix \"src/**/*.{js,ts,jsx,tsx,astro}\"",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/node": "^9.2.1",
|
||||
"@astrojs/mdx": "^4.3.0",
|
||||
"@astrojs/node": "^9.2.2",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@astrojs/rss": "^4.0.12",
|
||||
"@directus/sdk": "^19.1.0",
|
||||
"astro": "^5.7.13",
|
||||
"typescript": "^5.8.3"
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"astro": "^5.9.2",
|
||||
"framer-motion": "^12.16.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hotkeys-hook": "^5.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"tailwindcss": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@typescript-eslint/parser": "8.34.0",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-config-prettier": "10.1.5",
|
||||
"eslint-plugin-astro": "1.3.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-astro": "^0.14.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.12",
|
||||
"typescript-eslint": "8.34.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
3905
pnpm-lock.yaml
generated
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
23
prettier.config.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
/** @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;
|
Before Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 6.2 KiB |
Before Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 12 KiB |
@@ -1 +0,0 @@
|
||||
<svg height="640" width="1440" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a"><stop offset=".58" stop-opacity="0"/><stop offset="1"/></linearGradient><linearGradient id="b" gradientUnits="userSpaceOnUse" x1="793.5" x2="759.5" xlink:href="#a" y1="261.5" y2="149.5"/><linearGradient id="c" gradientUnits="userSpaceOnUse" x1="644.19" x2="645.54" xlink:href="#a" y1="398.02" y2="267.7"/><linearGradient id="d" gradientUnits="userSpaceOnUse" x1="547" x2="522.36" xlink:href="#a" y1="457.27" y2="342.85"/><g clip-rule="evenodd" fill-rule="evenodd" opacity=".15"><path d="m439.57 249.55a2149.47 2149.47 0 0 1 1193.87-182.45l-12.48 93.17a2055.46 2055.46 0 0 0 -1141.66 174.47l-454.24 211.86-39.73-85.2z" fill="url(#b)"/><path d="m272.3 266.93a2393.36 2393.36 0 0 1 1328.96 205.6l-44.42 94.78a2288.7 2288.7 0 0 0 -1270.84-196.61l-553.29 73.05-13.7-103.77z" fill="url(#c)" opacity=".56"/><path d="m195.26 416.13a2149.46 2149.46 0 0 1 1204.86-83.21l-20.13 91.82a2055.46 2055.46 0 0 0 -1152.17 79.56l-470.18 173.62-32.56-88.18 470.18-173.62z" fill="url(#d)"/></g><path d="m-258.15 719.56 1743.12-517.56 182.93 616.12-1743.1 517.56z" fill="#090b11"/></svg>
|
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 14 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1440" height="640"><g opacity=".15"><path fill="url(#a)" d="M439.57 249.55A2149.47 2149.47 0 0 1 1633.44 67.1l-12.48 93.17A2055.46 2055.46 0 0 0 479.3 334.74L25.06 546.6l-39.73-85.2z"/><path fill="url(#b)" d="M272.3 265.93a2393.36 2393.36 0 0 1 1328.96 205.6l-44.42 94.78A2288.7 2288.7 0 0 0 286 369.7l-553.29 73.05-13.7-103.77z" opacity=".56"/><path fill="url(#c)" d="M195.26 416.13a2149.47 2149.47 0 0 1 1204.86-83.21l-20.13 91.82A2055.46 2055.46 0 0 0 227.82 504.3l-470.18 173.62-32.56-88.18 470.18-173.62z"/></g><path fill="#fff" d="M-258 718.56 1485.12 201l182.93 616.12-1743.11 517.56z"/><defs><linearGradient id="d"><stop offset=".58" stop-opacity="0"/><stop offset="1"/></linearGradient><linearGradient xlink:href="#d" id="a" x1="793.5" x2="759.5" y1="261.5" y2="149.5" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#d" id="b" x1="644.19" x2="645.54" y1="397.02" y2="266.7" gradientUnits="userSpaceOnUse"/><linearGradient xlink:href="#d" id="c" x1="547" x2="522.36" y1="457.27" y2="342.85" gradientUnits="userSpaceOnUse"/></defs></svg>
|
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 6.6 KiB |
Before Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 6.2 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 7.3 KiB |
Before Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 27 KiB |
BIN
public/favicon.png
Normal file
After Width: | Height: | Size: 28 KiB |
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
Before Width: | Height: | Size: 749 B |
BIN
public/i.jpg
Normal file
After Width: | Height: | Size: 381 KiB |
4
public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://www.alexlebens.dev/sitemap-index.xml
|
@@ -1,14 +1,10 @@
|
||||
{
|
||||
"$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": []
|
||||
"$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": []
|
||||
}
|
||||
|
109
src/components/Background.astro
Normal 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 [mask-image:radial-gradient(white,transparent_85%)] bg-[center_top_-1px]"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Ambient glow effects -->
|
||||
<div
|
||||
class="animate-glow theme-transition-bg absolute top-1/4 left-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 right-1/4 bottom-1/3 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>
|
@@ -1,55 +0,0 @@
|
||||
---
|
||||
interface Props {
|
||||
href: string;
|
||||
}
|
||||
|
||||
const { href } = Astro.props;
|
||||
---
|
||||
|
||||
<a href={href}><slot /></a>
|
||||
|
||||
<style>
|
||||
a {
|
||||
position: relative;
|
||||
display: flex;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
padding: 0.56em 2em;
|
||||
gap: 0.8em;
|
||||
color: var(--accent-text-over);
|
||||
text-decoration: none;
|
||||
line-height: 1.1;
|
||||
border-radius: 999rem;
|
||||
overflow: hidden;
|
||||
background: var(--gradient-accent-orange);
|
||||
box-shadow: var(--shadow-md);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (min-width: 20em) {
|
||||
a {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
}
|
||||
|
||||
a::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
transition: background-color var(--theme-transition);
|
||||
mix-blend-mode: overlay;
|
||||
}
|
||||
|
||||
a:focus::after,
|
||||
a:hover::after {
|
||||
background-color: hsla(var(--gray-999-basis), 0.3);
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
a {
|
||||
padding: 1.125rem 2.5rem;
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -1,51 +0,0 @@
|
||||
---
|
||||
import CallToAction from './CallToAction.astro';
|
||||
import Icon from './Icon.astro';
|
||||
|
||||
import directus from "../../lib/directus"
|
||||
import { readSingleton } from "@directus/sdk";
|
||||
|
||||
const global = await directus.request(readSingleton("global"));
|
||||
---
|
||||
|
||||
<aside>
|
||||
<h2>Interested in working together?</h2>
|
||||
<CallToAction href=`mailto:${global.email}`>
|
||||
Send Me a Message
|
||||
<Icon icon="paper-plane-tilt" size="1.2em" />
|
||||
</CallToAction>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 3rem;
|
||||
border-top: 1px solid var(--gray-800);
|
||||
border-bottom: 1px solid var(--gray-800);
|
||||
padding: 5rem 1.5rem;
|
||||
background-color: var(--gray-999_40);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-xl);
|
||||
text-align: center;
|
||||
max-width: 15ch;
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
aside {
|
||||
padding: 7.5rem;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-3xl);
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -1,79 +1,250 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import directus from '../../lib/directus';
|
||||
import { readSingleton } from '@directus/sdk';
|
||||
|
||||
const global = await directus.request(readSingleton('global'));
|
||||
const links = await directus.request(readSingleton('links'));
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
import directus from "../../lib/directus"
|
||||
import { readSingleton } from "@directus/sdk";
|
||||
const navLinks = [
|
||||
{ text: 'About', href: '/about' },
|
||||
{ text: 'Blog', href: '/blog' },
|
||||
{ text: 'Topics', href: '/topics' },
|
||||
{ text: 'RSS', href: '/rss.xml' },
|
||||
];
|
||||
|
||||
const global = await directus.request(readSingleton("global"));
|
||||
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: 'Gitea',
|
||||
href: links.gitea,
|
||||
icon: `<path d="M7 5C7 3.89543 7.89543 3 9 3C10.1046 3 11 3.89543 11 5C11 5.34168 10.9143 5.66336 10.7633 5.9447H11.3438C13.5529 5.9447 15.3438 7.73556 15.3438 9.9447V11.2244C15.9301 11.5731 16.323 12.213 16.323 12.9447C16.323 14.0493 15.4276 14.9447 14.323 14.9447C13.2184 14.9447 12.323 14.0493 12.323 12.9447C12.323 12.1959 12.7345 11.5432 13.3438 11.2004V9.9447C13.3438 8.84013 12.4483 7.9447 11.3438 7.9447H10V17.2676C10.5978 17.6134 11 18.2597 11 19C11 20.1046 10.1046 21 9 21C7.89543 21 7 20.1046 7 19C7 18.2597 7.4022 17.6134 8 17.2676V6.73244C7.4022 6.38663 7 5.74028 7 5Z" fill="currentColor"/>`,
|
||||
},
|
||||
{
|
||||
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>
|
||||
<div class="group">
|
||||
<p>
|
||||
Designed & Developed in Minnesota with <a href="https://astro.build/">Astro</a>
|
||||
<Icon icon="rocket-launch" size="1.2em" />
|
||||
</p>
|
||||
<p>© {currentYear} {global.name}</p>
|
||||
</div>
|
||||
<p class="socials">
|
||||
<a href="https://github.com/alexlebens"> GitHub</a>
|
||||
<a href="https://www.linkedin.com/in/alexanderlebens"> LinkedIn</a>
|
||||
</p>
|
||||
<footer
|
||||
class="theme-transition-all relative mt-20 overflow-hidden border-t border-zinc-100 dark:border-zinc-800"
|
||||
>
|
||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
class="theme-transition-all animate-float-slow absolute -top-40 -right-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 top-20 left-1/4 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 pt-16 pb-12 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="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="theme-transition-color ml-3 text-xl font-bold text-zinc-900 dark:text-zinc-100"
|
||||
>Blog</span
|
||||
>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<p
|
||||
class="theme-transition-color mt-4 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400"
|
||||
>
|
||||
{global.description}
|
||||
</p>
|
||||
|
||||
<!-- 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="hover 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 tracking-wider text-zinc-900 uppercase 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>
|
||||
</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">
|
||||
© {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>
|
||||
</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>
|
||||
|
||||
<style>
|
||||
footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
margin-top: auto;
|
||||
padding: 3rem 2rem 3rem;
|
||||
text-align: center;
|
||||
color: var(--gray-400);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
footer a {
|
||||
color: var(--gray-400);
|
||||
text-decoration: 1px solid underline transparent;
|
||||
text-underline-offset: 0.25em;
|
||||
transition: text-decoration-color var(--theme-transition);
|
||||
}
|
||||
|
||||
footer a:hover,
|
||||
footer a:focus {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.socials {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
footer {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 2.5rem 5rem;
|
||||
}
|
||||
|
||||
.group {
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.socials {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
21
src/components/FormattedDate.astro
Normal 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>
|
||||
)
|
||||
}
|
@@ -1,62 +0,0 @@
|
||||
---
|
||||
interface Props {
|
||||
variant?: 'offset' | 'small';
|
||||
}
|
||||
|
||||
const { variant } = Astro.props;
|
||||
---
|
||||
|
||||
<ul class:list={['grid', { offset: variant === 'offset', small: variant === 'small' }]}>
|
||||
<slot />
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-auto-rows: 1fr;
|
||||
gap: 1rem;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.grid.small {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.grid.small > :global(:last-child:nth-child(odd)) {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
.grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4rem;
|
||||
}
|
||||
|
||||
.grid.offset {
|
||||
--row-offset: 7.5rem;
|
||||
padding-bottom: var(--row-offset);
|
||||
}
|
||||
|
||||
.grid.offset > :global(:nth-child(odd)) {
|
||||
transform: translateY(var(--row-offset));
|
||||
}
|
||||
|
||||
.grid.offset > :global(:last-child:nth-child(odd)) {
|
||||
grid-column: 2 / 3;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.grid.small {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.grid.small > :global(*) {
|
||||
flex-basis: 20rem;
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -1,54 +0,0 @@
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
tagline?: string;
|
||||
align?: 'start' | 'center';
|
||||
}
|
||||
|
||||
const { align = 'center', tagline, title } = Astro.props;
|
||||
---
|
||||
|
||||
<div class:list={['hero stack gap-4', align]}>
|
||||
<div class="stack gap-2">
|
||||
<h1 class="title">{title}</h1>
|
||||
{tagline && <p class="tagline">{tagline}</p>}
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hero {
|
||||
font-size: var(--text-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title,
|
||||
.tagline {
|
||||
max-width: 37ch;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--text-3xl);
|
||||
color: var(--gray-0);
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
.hero {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
|
||||
.start {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.start .title,
|
||||
.start .tagline {
|
||||
margin-inline: unset;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
}
|
||||
</style>
|
@@ -1,56 +0,0 @@
|
||||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
import { iconPaths } from './IconPaths';
|
||||
|
||||
interface Props {
|
||||
icon: keyof typeof iconPaths;
|
||||
color?: string;
|
||||
gradient?: boolean;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
const { color = 'currentcolor', gradient, icon, size } = Astro.props;
|
||||
const iconPath = iconPaths[icon];
|
||||
|
||||
const attrs: HTMLAttributes<'svg'> = {};
|
||||
if (size) attrs.style = { '--size': size };
|
||||
|
||||
const gradientId = 'icon-gradient-' + Math.round(Math.random() * 10e12).toString(36);
|
||||
---
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 256 256"
|
||||
aria-hidden="true"
|
||||
stroke={gradient ? `url(#${gradientId})` : color}
|
||||
fill={gradient ? `url(#${gradientId})` : color}
|
||||
{...attrs}
|
||||
>
|
||||
<g set:html={iconPath} />
|
||||
{
|
||||
gradient && (
|
||||
<linearGradient
|
||||
id={gradientId}
|
||||
x1="23"
|
||||
x2="235"
|
||||
y1="43"
|
||||
y2="202"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="var(--gradient-stop-1)" />
|
||||
<stop offset=".5" stop-color="var(--gradient-stop-2)" />
|
||||
<stop offset="1" stop-color="var(--gradient-stop-3)" />
|
||||
</linearGradient>
|
||||
)
|
||||
}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
width: var(--size, 1em);
|
||||
height: var(--size, 1em);
|
||||
}
|
||||
</style>
|
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* Icons adapted from https://phosphoricons.com/
|
||||
*
|
||||
* Want to add more?
|
||||
* 1. Find the icon you want on Phosphor Icons.
|
||||
* 2. Click “Copy SVG”.
|
||||
* 3. Paste the SVG code in your editor.
|
||||
* 4. Remove the `<svg>` wrapper so you only have elements like `<path>`, `<circle>`, `<rect>` etc.
|
||||
* 5. Remove any `stroke="#000000"` attributes
|
||||
* 6. Replace any `fill="#000000"` attributes with `stroke="none"`
|
||||
* (or add `stroke="none"` on shapes with no `fill` or `stroke` specified).
|
||||
*/
|
||||
export const iconPaths = {
|
||||
'terminal-window': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m80 96 40 32-40 32m56 0h40"/><rect width="192" height="160" x="32" y="48" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16.97" rx="8.5"/>`,
|
||||
trophy: `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M56 56v55.1c0 39.7 31.8 72.6 71.5 72.9a72 72 0 0 0 72.5-72V56a8 8 0 0 0-8-8H64a8 8 0 0 0-8 8Zm40 168h64m-32-40v40"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M198.2 128h9.8a32 32 0 0 0 32-32V80a8 8 0 0 0-8-8h-32M58 128H47.9a32 32 0 0 1-32-32V80a8 8 0 0 1 8-8h32"/>`,
|
||||
strategy: `<circle cx="68" cy="188" r="28" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m40 72 40 40m0-40-40 40m136 56 40 40m0-40-40 40M136 80V40h40"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m136 40 16 16c40 40 8 88-24 96"/>`,
|
||||
'paper-plane-tilt': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M210.3 35.9 23.9 88.4a8 8 0 0 0-1.2 15l85.6 40.5a7.8 7.8 0 0 1 3.8 3.8l40.5 85.6a8 8 0 0 0 15-1.2l52.5-186.4a7.9 7.9 0 0 0-9.8-9.8Zm-99.4 109.2 45.2-45.2"/>`,
|
||||
'arrow-right': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M40 128h176m-72-72 72 72-72 72"/>`,
|
||||
'arrow-left': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M216 128H40m72-72-72 72 72 72"/>`,
|
||||
code: `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m64 88-48 40 48 40m128-80 48 40-48 40M160 40 96 216"/>`,
|
||||
'hard-drives': `<path d="M208,136H48a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V152A16,16,0,0,0,208,136Zm0,64H48V152H208v48Zm0-160H48A16,16,0,0,0,32,56v48a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V56A16,16,0,0,0,208,40Zm0,64H48V56H208v48ZM192,80a12,12,0,1,1-12-12A12,12,0,0,1,192,80Zm0,96a12,12,0,1,1-12-12A12,12,0,0,1,192,176Z"/>`,
|
||||
'cloud': `<path d="M160,40A88.09,88.09,0,0,0,81.29,88.67,64,64,0,1,0,72,216h88a88,88,0,0,0,0-176Zm0,160H72a48,48,0,0,1,0-96c1.1,0,2.2,0,3.29.11A88,88,0,0,0,72,128a8,8,0,0,0,16,0,72,72,0,1,1,72,72Z"/>`,
|
||||
'network': '<path d="M232,112H136V88h8a16,16,0,0,0,16-16V40a16,16,0,0,0-16-16H112A16,16,0,0,0,96,40V72a16,16,0,0,0,16,16h8v24H24a8,8,0,0,0,0,16H56v32H48a16,16,0,0,0-16,16v32a16,16,0,0,0,16,16H80a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H72V128H184v32h-8a16,16,0,0,0-16,16v32a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16h-8V128h32a8,8,0,0,0,0-16ZM112,40h32V72H112ZM80,208H48V176H80Zm128,0H176V176h32Z"/>',
|
||||
'microphone-stage': `<circle cx="168" cy="88" r="64" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m213.3 133.3-90.6-90.6M100 156l-12 12m16.8-70.1L28.1 202.5a7.9 7.9 0 0 0 .8 10.4l14.2 14.2a7.9 7.9 0 0 0 10.4.8l104.6-76.7"/>`,
|
||||
'pencil-line': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M96 216H48a8 8 0 0 1-8-8v-44.7a7.9 7.9 0 0 1 2.3-5.6l120-120a8 8 0 0 1 11.4 0l44.6 44.6a8 8 0 0 1 0 11.4Zm40-152 56 56"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M216 216H96l-55.5-55.5M164 92l-96 96"/>`,
|
||||
'rocket-launch': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M94.1 184.6c-11.4 33.9-56.6 33.9-56.6 33.9s0-45.2 33.9-56.6m124.5-56.5L128 173.3 82.7 128l67.9-67.9C176.3 34.4 202 34.7 213 36.3a7.8 7.8 0 0 1 6.7 6.7c1.6 11 1.9 36.7-23.8 62.4Z"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M184.6 116.7v64.6a8 8 0 0 1-2.4 5.6l-32.3 32.4a8 8 0 0 1-13.5-4.1l-8.4-41.9m11.3-101.9H74.7a8 8 0 0 0-5.6 2.4l-32.4 32.3a8 8 0 0 0 4.1 13.5l41.9 8.4"/>`,
|
||||
list: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M40 128h176M40 64h176M40 192h176"/>`,
|
||||
heart: `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M128 216S28 160 28 92a52 52 0 0 1 100-20h0a52 52 0 0 1 100 20c0 68-100 124-100 124Z"/>`,
|
||||
'moon-stars': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M216 112V64m24 24h-48m-24-64v32m16-16h-32m65 113A92 92 0 0 1 103 39h0a92 92 0 1 0 114 114Z"/>`,
|
||||
sun: `<circle cx="128" cy="128" r="60" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M128 36V16M63 63 49 49m-13 79H16m47 65-14 14m79 13v20m65-47 14 14m13-79h20m-47-65 14-14"/>`,
|
||||
'github-logo': `<g stroke-linecap="round" stroke-linejoin="round"><path fill="none" stroke-width="14.7" d="M55.7 167.2c13.9 1 21.3 13.1 22.2 14.6 4.2 7.2 10.4 9.6 18.3 7.1l1.1-3.4a60.3 60.3 0 0 1-25.8-11.9c-12-10.1-18-25.6-18-46.3"/><path fill="none" stroke-width="16" d="M61.4 205.1a24.5 24.5 0 0 1-3-6.1c-3.2-7.9-7.1-10.6-7.8-11.1l-1-.6c-2.4-1.6-9.5-6.5-7.2-13.9 1.4-4.5 6-7.2 12.3-7.2h.8c4 .3 7.6 1.5 10.7 3.2-9.1-10.1-13.6-24.3-13.6-42.3 0-11.3 3.5-21.7 10.1-30.4A46.7 46.7 0 0 1 65 67.3a8.3 8.3 0 0 1 5-4.7c2.8-.9 13.3-2.7 33.2 9.9a105 105 0 0 1 50.5 0c19.9-12.6 30.4-10.8 33.2-9.9 2.3.7 4.1 2.4 5 4.7 5 12.7 4 23.2 2.6 29.4 6.7 8.7 10 18.9 10 30.4 0 42.6-25.8 54.7-43.6 58.7 1.4 4.1 2.2 8.8 2.2 13.7l-.1 23.4v2.3"/><path fill="none" stroke-width="16" d="M160.9 185.7c1.4 4.1 2.2 8.8 2.2 13.7l-.1 23.4v2.3A98.6 98.6 0 1 0 61.4 205c-1.4-2.1-11.3-17.5-11.8-17.8-2.4-1.6-9.5-6.5-7.2-13.9 1.4-4.5 6-7.2 12.3-7.2h.8c4 .3 7.6 1.5 10.7 3.2-9.1-10.1-13.6-24.3-13.6-42.3 0-11.3 3.5-21.7 10.1-30.4A46.4 46.4 0 0 1 65 67.3a8.3 8.3 0 0 1 5-4.7c2.8-.9 13.3-2.7 33.2 9.9a105 105 0 0 1 50.5 0c19.9-12.6 30.4-10.8 33.2-9.9 2.3.7 4.1 2.4 5 4.7 5 12.7 4 23.2 2.6 29.4 6.7 8.7 10 18.9 10 30.4.1 42.6-25.8 54.7-43.6 58.6z"/><path fill="none" stroke-width="18.7" d="m170.1 203.3 17.3-12 17.2-18.7 9.5-26.6v-27.9l-9.5-27.5" /><path fill="none" stroke-width="22.7" d="m92.1 57.3 23.3-4.6 18.7-1.4 29.3 5.4m-110 32.6-8 16-4 21.4.6 20.3 3.4 13" /><path fill="none" stroke-width="13.3" d="M28.8 133a100 100 0 0 0 66.9 94.4v-8.7c-22.4 1.8-33-11.5-35.6-19.8-3.4-8.6-7.8-11.4-8.5-11.8"/></g>`,
|
||||
'linkedin-logo': `<rect width="184" height="184" x="36" y="36" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" rx="8"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M120 112v64m-32-64v64m32-36a28 28 0 0 1 56 0v36"/><circle stroke="none" cx="88" cy="80" r="12"/>`,
|
||||
};
|
@@ -1,51 +0,0 @@
|
||||
---
|
||||
import '../styles/global.css';
|
||||
|
||||
import directus from "../../lib/directus"
|
||||
import { readSingleton } from "@directus/sdk";
|
||||
|
||||
interface Props {
|
||||
title?: string | undefined;
|
||||
description?: string | undefined;
|
||||
}
|
||||
|
||||
const global = await directus.request(readSingleton("global"));
|
||||
|
||||
const {
|
||||
title = `${global.name}`,
|
||||
description = `A profile of ${global.name}`,
|
||||
} = Astro.props;
|
||||
---
|
||||
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" property="og:description" content={description} />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,400;0,700;1,400&family=Rubik:wght@500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<script is:inline>
|
||||
const getThemePreference = () => {
|
||||
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
||||
return localStorage.getItem('theme');
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
const isDark = getThemePreference() === 'dark';
|
||||
document.documentElement.classList[isDark ? 'add' : 'remove']('theme-dark');
|
||||
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const observer = new MutationObserver(() => {
|
||||
const isDark = document.documentElement.classList.contains('theme-dark');
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
});
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
</script>
|
@@ -1,360 +0,0 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
import ThemeToggle from './ThemeToggle.astro';
|
||||
import type { iconPaths } from './IconPaths';
|
||||
|
||||
import directus from "../../lib/directus"
|
||||
import { readSingleton } from "@directus/sdk";
|
||||
|
||||
const textLinks: { label: string; href: string }[] = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Projects', href: '/projects/' },
|
||||
{ label: 'About', href: '/about/' },
|
||||
];
|
||||
|
||||
const iconLinks: { label: string; href: string; icon: keyof typeof iconPaths }[] = [
|
||||
{ label: 'GitHub', href: 'https://github.com/alexlebens', icon: 'github-logo' },
|
||||
{ label: 'LinkedIn', href: 'https://www.linkedin.com/in/alexanderlebens', icon: 'linkedin-logo' },
|
||||
];
|
||||
|
||||
const global = await directus.request(readSingleton("global"));
|
||||
---
|
||||
|
||||
<nav>
|
||||
<div class="menu-header">
|
||||
<a href="/" class="site-title">
|
||||
<Icon icon="terminal-window" color="var(--accent-regular)" size="1.6em" gradient />
|
||||
{global.name}
|
||||
</a>
|
||||
<menu-button>
|
||||
<template>
|
||||
<button class="menu-button" aria-expanded="false">
|
||||
<span class="sr-only">Menu</span>
|
||||
<Icon icon="list" />
|
||||
</button>
|
||||
</template>
|
||||
</menu-button>
|
||||
</div>
|
||||
<noscript>
|
||||
<ul class="nav-items">
|
||||
{
|
||||
textLinks.map(({ label, href }) => (
|
||||
<li>
|
||||
<a
|
||||
aria-current={Astro.url.pathname === href}
|
||||
class:list={[
|
||||
'link',
|
||||
{
|
||||
active:
|
||||
Astro.url.pathname === href ||
|
||||
(href !== '/' && Astro.url.pathname.startsWith(href)),
|
||||
},
|
||||
]}
|
||||
href={href}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</noscript>
|
||||
<noscript>
|
||||
<div class="menu-footer">
|
||||
<div class="socials">
|
||||
{
|
||||
iconLinks.map(({ href, icon, label }) => (
|
||||
<a href={href} class="social">
|
||||
<span class="sr-only">{label}</span>
|
||||
<Icon icon={icon} />
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</noscript>
|
||||
<div id="menu-content" hidden>
|
||||
<ul class="nav-items">
|
||||
{
|
||||
textLinks.map(({ label, href }) => (
|
||||
<li>
|
||||
<a
|
||||
aria-current={Astro.url.pathname === href}
|
||||
class:list={[
|
||||
'link',
|
||||
{
|
||||
active:
|
||||
Astro.url.pathname === href ||
|
||||
(href !== '/' && Astro.url.pathname.startsWith(href)),
|
||||
},
|
||||
]}
|
||||
href={href}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
<div class="menu-footer">
|
||||
<div class="socials">
|
||||
{
|
||||
iconLinks.map(({ href, icon, label }) => (
|
||||
<a href={href} class="social">
|
||||
<span class="sr-only">{label}</span>
|
||||
<Icon icon={icon} />
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="theme-toggle">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
class MenuButton extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.appendChild(this.querySelector('template')!.content.cloneNode(true));
|
||||
const btn = this.querySelector('button')!;
|
||||
|
||||
const menu = document.getElementById('menu-content')!;
|
||||
menu.hidden = true;
|
||||
menu.classList.add('menu-content');
|
||||
|
||||
const setExpanded = (expand: boolean) => {
|
||||
btn.setAttribute('aria-expanded', expand ? 'true' : 'false');
|
||||
menu.hidden = !expand;
|
||||
};
|
||||
|
||||
btn.addEventListener('click', () => setExpanded(menu.hidden));
|
||||
|
||||
const handleViewports = (e: MediaQueryList | MediaQueryListEvent) => {
|
||||
setExpanded(e.matches);
|
||||
btn.hidden = e.matches;
|
||||
};
|
||||
const mediaQueries = window.matchMedia('(min-width: 50em)');
|
||||
handleViewports(mediaQueries);
|
||||
mediaQueries.addEventListener('change', handleViewports);
|
||||
}
|
||||
}
|
||||
customElements.define('menu-button', MenuButton);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
nav {
|
||||
z-index: 9999;
|
||||
position: relative;
|
||||
font-family: var(--font-brand);
|
||||
font-weight: 500;
|
||||
margin-bottom: 3.5rem;
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
line-height: 1.1;
|
||||
color: var(--gray-0);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
border: 0;
|
||||
border-radius: 999rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 1.5rem;
|
||||
color: var(--gray-300);
|
||||
background: radial-gradient(var(--gray-900), var(--gray-800) 150%);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.menu-button[aria-expanded='true'] {
|
||||
color: var(--gray-0);
|
||||
background: linear-gradient(180deg, var(--gray-600), transparent),
|
||||
radial-gradient(var(--gray-900), var(--gray-800) 150%);
|
||||
}
|
||||
|
||||
.menu-button[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.menu-button::before {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
content: '';
|
||||
background: var(--gradient-stroke);
|
||||
border-radius: 999rem;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.nav-items {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.2;
|
||||
list-style: none;
|
||||
padding: 2rem;
|
||||
background-color: var(--gray-999);
|
||||
border-bottom: 1px solid var(--gray-800);
|
||||
}
|
||||
|
||||
.link {
|
||||
display: inline-block;
|
||||
color: var(--gray-300);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link.active {
|
||||
color: var(--gray-0);
|
||||
}
|
||||
|
||||
.menu-footer {
|
||||
--icon-size: var(--text-xl);
|
||||
--icon-padding: 0.5rem;
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1.5rem 2rem 1.5rem 1.5rem;
|
||||
background-color: var(--gray-999);
|
||||
border-radius: 0 0 0.75rem 0.75rem;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.socials {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.625rem;
|
||||
font-size: var(--icon-size);
|
||||
}
|
||||
|
||||
.social {
|
||||
display: flex;
|
||||
padding: var(--icon-padding);
|
||||
text-decoration: none;
|
||||
color: var(--accent-dark);
|
||||
transition: color var(--theme-transition);
|
||||
}
|
||||
|
||||
.social:hover,
|
||||
.social:focus {
|
||||
color: var(--accent-text-over);
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: calc(var(--icon-size) + 2 * var(--icon-padding));
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
padding: 2.5rem 5rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.menu-header {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.site-title {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.menu-content {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.nav-items {
|
||||
position: relative;
|
||||
flex-direction: row;
|
||||
font-size: var(--text-sm);
|
||||
border-radius: 999rem;
|
||||
border: 0;
|
||||
padding: 0.5rem 0.5625rem;
|
||||
background: radial-gradient(var(--gray-900), var(--gray-800) 150%);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.nav-items::before {
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
content: '';
|
||||
background: var(--gradient-stroke);
|
||||
border-radius: 999rem;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.link {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 999rem;
|
||||
transition:
|
||||
color var(--theme-transition),
|
||||
background-color var(--theme-transition);
|
||||
}
|
||||
|
||||
.link:hover,
|
||||
.link:focus {
|
||||
color: var(--gray-100);
|
||||
background-color: var(--accent-subtle-overlay);
|
||||
}
|
||||
|
||||
.link.active {
|
||||
color: var(--accent-text-over);
|
||||
background-color: var(--accent-regular);
|
||||
}
|
||||
|
||||
.menu-footer {
|
||||
--icon-padding: 0.375rem;
|
||||
|
||||
justify-self: flex-end;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.socials {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 60em) {
|
||||
.socials {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0;
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.link.active {
|
||||
color: SelectedItem;
|
||||
}
|
||||
}
|
||||
</style>
|
245
src/components/Navigation.astro
Normal 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 top-0 right-0 left-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">{global.initals}</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-xs');
|
||||
} else {
|
||||
header.classList.remove('shadow-xs');
|
||||
}
|
||||
|
||||
// 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-sm(4px);
|
||||
}
|
||||
</style>
|
@@ -1,16 +0,0 @@
|
||||
<div class="pill"><slot /></div>
|
||||
|
||||
<style>
|
||||
.pill {
|
||||
display: flex;
|
||||
padding: 0.5rem 1rem;
|
||||
gap: 0.5rem;
|
||||
color: var(--accent-text-over);
|
||||
border: 1px solid var(--accent-regular);
|
||||
background-color: var(--accent-regular);
|
||||
border-radius: 999rem;
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
@@ -1,65 +0,0 @@
|
||||
---
|
||||
import type { Post } from '../../lib/directus';
|
||||
import { directus_url } from '../../lib/directus';
|
||||
|
||||
interface Props {
|
||||
posts: Post;
|
||||
}
|
||||
|
||||
const post: Post = Astro.props.posts;
|
||||
---
|
||||
|
||||
<a class="card" href={`/projects/${post.slug}`}>
|
||||
<span class="title">{post.title}</span>
|
||||
<img src={`${directus_url}/assets/${post.image}?width=500`} alt={post.image_alt || ''} loading="lazy" decoding="async" />
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
display: grid;
|
||||
grid-template: auto 1fr / auto 1fr;
|
||||
height: 11rem;
|
||||
background: var(--gradient-subtle);
|
||||
border: 1px solid var(--gray-800);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-sm);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-brand);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 500;
|
||||
transition: box-shadow var(--theme-transition);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.title {
|
||||
grid-area: 1 / 1 / 2 / 2;
|
||||
z-index: 1;
|
||||
margin: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--gray-999);
|
||||
color: var(--gray-200);
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
img {
|
||||
grid-area: 1 / 1 / 3 / 3;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
.card {
|
||||
height: 22rem;
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
border-radius: 0.9375rem;
|
||||
}
|
||||
}
|
||||
</style>
|
175
src/components/ShareButtons.astro
Normal 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="hover 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="hover 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="hover 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 rounded-sm bg-zinc-800 px-2 py-1 text-xs whitespace-nowrap 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>
|
@@ -1,67 +0,0 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
|
||||
import directus from "../../lib/directus"
|
||||
import { readSingleton } from "@directus/sdk";
|
||||
|
||||
const skills = await directus.request(readSingleton("skills"));
|
||||
---
|
||||
|
||||
<section class="box skills">
|
||||
<div class="stack gap-2 lg:gap-4">
|
||||
<Icon icon="cloud" color="var(--accent-regular)" size="2.5rem" gradient />
|
||||
<h2 set:html={skills.skill_1}/>
|
||||
<p set:html={skills.skill_1_description}/>
|
||||
</div>
|
||||
<div class="stack gap-2 lg:gap-4">
|
||||
<Icon icon="network" color="var(--accent-regular)" size="2.5rem" gradient />
|
||||
<h2 set:html={skills.skill_2}/>
|
||||
<p set:html={skills.skill_2_description}/>
|
||||
</div>
|
||||
<div class="stack gap-2 lg:gap-4">
|
||||
<Icon icon="strategy" color="var(--accent-regular)" size="2.5rem" gradient />
|
||||
<h2 set:html={skills.skill_3}/>
|
||||
<p set:html={skills.skill_3_description}/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.box {
|
||||
border: 1px solid var(--gray-800);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
background-color: var(--gray-999_40);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.skills {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3rem;
|
||||
}
|
||||
|
||||
.skills h2 {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.skills p {
|
||||
color: var(--gray-400);
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
.box {
|
||||
border-radius: 1.5rem;
|
||||
padding: 2.5rem;
|
||||
}
|
||||
|
||||
.skills {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 5rem;
|
||||
}
|
||||
|
||||
.skills h2 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
}
|
||||
</style>
|
23
src/components/TagList.astro
Normal 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>
|
||||
)
|
||||
}
|
@@ -1,92 +1,317 @@
|
||||
---
|
||||
import Icon from './Icon.astro';
|
||||
---
|
||||
|
||||
<theme-toggle>
|
||||
<button>
|
||||
<span class="sr-only">Dark theme</span>
|
||||
<span class="icon light"><Icon icon="sun" /></span>
|
||||
<span class="icon dark"><Icon icon="moon-stars" /></span>
|
||||
</button>
|
||||
</theme-toggle>
|
||||
---
|
||||
|
||||
<style>
|
||||
button {
|
||||
display: flex;
|
||||
border: 0;
|
||||
border-radius: 999rem;
|
||||
padding: 0;
|
||||
background-color: var(--gray-999);
|
||||
box-shadow: inset 0 0 0 1px var(--accent-overlay);
|
||||
cursor: pointer;
|
||||
}
|
||||
<button
|
||||
id="theme-toggle"
|
||||
data-theme-toggle
|
||||
class="group relative touch-manipulation overflow-hidden rounded-full p-1.5 transition-all duration-300 hover:bg-zinc-100 focus:ring-2 focus:ring-zinc-300 focus:outline-hidden sm:p-2 dark:hover:bg-zinc-800 dark:focus:ring-zinc-700"
|
||||
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-zinc-800 transition-all duration-500 dark:scale-0 dark:-rotate-90 dark:text-zinc-200"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<path
|
||||
d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"
|
||||
></path>
|
||||
</svg>
|
||||
|
||||
.icon {
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
padding: 0.5rem;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
font-size: 1rem;
|
||||
color: var(--accent-overlay);
|
||||
}
|
||||
<!-- Moon icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon-dark absolute h-5 w-5 scale-0 rotate-90 text-zinc-800 transition-all duration-500 dark:scale-100 dark:rotate-0 dark:text-zinc-200"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
.icon.light::before {
|
||||
content: '';
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--accent-regular);
|
||||
border-radius: 999rem;
|
||||
}
|
||||
|
||||
:global(.theme-dark) .icon.light::before {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
:global(.theme-dark) .icon.dark,
|
||||
:global(html:not(.theme-dark)) .icon.light,
|
||||
button[aria-pressed='false'] .icon.light {
|
||||
color: var(--accent-text-over);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.icon,
|
||||
.icon.light::before {
|
||||
transition:
|
||||
transform var(--theme-transition),
|
||||
color var(--theme-transition);
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.icon.light::before {
|
||||
background-color: SelectedItem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- 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>
|
||||
class ThemeToggle extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
// Use a function to handle theme toggle to ensure it can be called from anywhere
|
||||
function setupThemeToggle() {
|
||||
const themeToggles = document.querySelectorAll('[data-theme-toggle]');
|
||||
|
||||
const button = this.querySelector('button')!;
|
||||
// Check for dark mode preference at the system level
|
||||
const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
const setTheme = (dark: boolean) => {
|
||||
document.documentElement.classList[dark ? 'add' : 'remove']('theme-dark');
|
||||
button.setAttribute('aria-pressed', String(dark));
|
||||
};
|
||||
// Check for saved theme preference or use the system preference
|
||||
const currentTheme = localStorage.getItem('theme') || (prefersDarkMode ? 'dark' : 'light');
|
||||
|
||||
button.addEventListener('click', () => setTheme(!this.isDark()));
|
||||
// Apply the theme on initial load
|
||||
if (currentTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
setTheme(this.isDark());
|
||||
}
|
||||
// Create theme switch overlay element if it doesn't exist
|
||||
if (!document.querySelector('.theme-switch-overlay')) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'theme-switch-overlay fixed inset-0 pointer-events-none z-50';
|
||||
overlay.style.opacity = '0';
|
||||
overlay.style.transition = 'opacity 0.3s ease-out';
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
isDark() {
|
||||
return document.documentElement.classList.contains('theme-dark');
|
||||
}
|
||||
}
|
||||
customElements.define('theme-toggle', ThemeToggle);
|
||||
// Toggle theme when any theme toggle button is clicked
|
||||
themeToggles.forEach((toggle) => {
|
||||
// Add event listeners for both click and touch events
|
||||
['click', 'touchend'].forEach((eventType) => {
|
||||
toggle.addEventListener(
|
||||
eventType,
|
||||
(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Get click/touch position for radial animation
|
||||
let x, y;
|
||||
if (e.type === 'touchend' && e.changedTouches && e.changedTouches[0]) {
|
||||
const rect = toggle.getBoundingClientRect();
|
||||
x = e.changedTouches[0].clientX - rect.left;
|
||||
y = e.changedTouches[0].clientY - rect.top;
|
||||
} else {
|
||||
const rect = toggle.getBoundingClientRect();
|
||||
x = e.clientX - rect.left;
|
||||
y = e.clientY - rect.top;
|
||||
}
|
||||
|
||||
// Set the position variables for the radial gradient
|
||||
document.documentElement.style.setProperty('--x', `${x}px`);
|
||||
document.documentElement.style.setProperty('--y', `${y}px`);
|
||||
|
||||
// Get the overlay element
|
||||
const overlay = document.querySelector('.theme-switch-overlay');
|
||||
|
||||
// Determine the new theme
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const newTheme = isDark ? 'light' : 'dark';
|
||||
|
||||
// Show overlay during transition
|
||||
if (overlay) {
|
||||
overlay.style.backgroundColor =
|
||||
newTheme === 'dark' ? 'rgba(24, 24, 27, 0.3)' : 'rgba(255, 255, 255, 0.3)';
|
||||
overlay.style.opacity = '1';
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Toggle dark mode with a slight delay to allow overlay to appear
|
||||
setTimeout(() => {
|
||||
if (isDark) {
|
||||
document.documentElement.classList.remove('dark');
|
||||
} else {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
|
||||
// Store the preference
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
||||
// Dispatch a custom event for other components to react to
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('themeChanged', {
|
||||
detail: { isDark: newTheme === 'dark' },
|
||||
})
|
||||
);
|
||||
|
||||
// Force another reflow to ensure all elements update
|
||||
document.body.offsetHeight;
|
||||
|
||||
// Hide overlay after theme has changed
|
||||
setTimeout(() => {
|
||||
if (overlay) {
|
||||
overlay.style.opacity = '0';
|
||||
}
|
||||
|
||||
// Remove transition class after animation completes
|
||||
document.documentElement.classList.remove('theme-switching');
|
||||
ripple.remove();
|
||||
}, 300);
|
||||
}, 50);
|
||||
},
|
||||
{ passive: false }
|
||||
);
|
||||
});
|
||||
|
||||
// Add touch feedback
|
||||
toggle.addEventListener(
|
||||
'touchstart',
|
||||
() => {
|
||||
toggle.classList.add('active-touch');
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
toggle.addEventListener(
|
||||
'touchend',
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
toggle.classList.remove('active-touch');
|
||||
}, 150);
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Run setup on load
|
||||
document.addEventListener('DOMContentLoaded', setupThemeToggle);
|
||||
|
||||
// Also run on page visibility change to ensure theme is consistent
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
const currentTheme = localStorage.getItem('theme');
|
||||
if (currentTheme === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else if (currentTheme === 'light') {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for system preference changes
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ({ matches }) => {
|
||||
if (!localStorage.getItem('theme')) {
|
||||
if (matches) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Smooth transition for the entire page when theme changes */
|
||||
:global(body) {
|
||||
transition:
|
||||
background-color 0.5s ease,
|
||||
color 0.5s ease;
|
||||
}
|
||||
|
||||
/* Theme transition overlay */
|
||||
:global(.theme-switch-overlay) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Ensure theme transitions apply to all elements */
|
||||
:global(.theme-switching *) {
|
||||
transition-duration: 0.5s !important;
|
||||
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);
|
||||
box-shadow: 0 0 0 rgba(0, 0, 0, 0);
|
||||
-webkit-tap-highlight-color: transparent; /* Remove default mobile tap highlight */
|
||||
min-height: 32px; /* Ensure minimum touch target size */
|
||||
min-width: 32px; /* Ensure minimum touch target size */
|
||||
}
|
||||
|
||||
/* Only apply hover effects on non-touch devices */
|
||||
@media (hover: hover) {
|
||||
#theme-toggle:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
#theme-toggle:hover .icon-light:not(.dark .icon-light) {
|
||||
filter: drop-shadow-sm(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));
|
||||
transform: scale(1.1) rotate(-15deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Touch feedback */
|
||||
#theme-toggle.active-touch {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
/* Optimize animations for mobile */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.icon-light,
|
||||
.icon-dark {
|
||||
transition: all 0.2s ease-out !important;
|
||||
}
|
||||
|
||||
#theme-toggle,
|
||||
#theme-toggle:hover {
|
||||
transform: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.theme-toggle-ripple {
|
||||
animation-duration: 0.4s;
|
||||
}
|
||||
}
|
||||
|
||||
/* Adjust size for very small screens */
|
||||
@media (max-width: 320px) {
|
||||
#theme-toggle {
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
2
src/env.d.ts
vendored
@@ -1 +1,3 @@
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference types="astro/content" />
|
||||
|
17
src/layouts/Base.astro
Normal 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.title}>
|
||||
<slot />
|
||||
</Layout>
|
@@ -1,113 +1,59 @@
|
||||
---
|
||||
import MainHead from '../components/MainHead.astro';
|
||||
import Nav from '../components/Nav.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import Layout from './Layout.astro';
|
||||
|
||||
interface Props {
|
||||
title?: string | undefined;
|
||||
description?: string | undefined;
|
||||
import directus from '../../lib/directus';
|
||||
import { readSingleton } from '@directus/sdk';
|
||||
|
||||
const global = await directus.request(readSingleton('global'));
|
||||
|
||||
export interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<MainHead title={title} description={description} />
|
||||
</head>
|
||||
<body>
|
||||
<div class="stack backgrounds">
|
||||
<Nav />
|
||||
<slot />
|
||||
<Footer />
|
||||
</div>
|
||||
<Layout title={global.title} description={global.title}>
|
||||
<slot />
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
addEventListener('load', () => document.documentElement.classList.add('loaded'));
|
||||
</script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--_placeholder-bg: linear-gradient(transparent, transparent);
|
||||
--bg-image-main: url('/assets/backgrounds/bg-main-light-800w.jpg');
|
||||
--bg-image-main-curves: url('/assets/backgrounds/bg-main-light.svg');
|
||||
--bg-image-subtle-1: var(--_placeholder-bg);
|
||||
--bg-image-subtle-2: var(--_placeholder-bg);
|
||||
--bg-image-footer: var(--_placeholder-bg);
|
||||
--bg-svg-blend-mode: overlay;
|
||||
--bg-blend-mode: darken;
|
||||
--bg-image-aspect-ratio: 2.25;
|
||||
--bg-scale: 1.68;
|
||||
--bg-aspect-ratio: calc(var(--bg-image-aspect-ratio) / var(--bg-scale));
|
||||
--bg-gradient-size: calc(var(--bg-scale) * 100%);
|
||||
}
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', () => {
|
||||
document.documentElement.classList.add('theme-switching');
|
||||
|
||||
:root.theme-dark {
|
||||
--bg-image-main: url('/assets/backgrounds/bg-main-dark-800w.jpg');
|
||||
--bg-image-main-curves: url('/assets/backgrounds/bg-main-dark.svg');
|
||||
--bg-svg-blend-mode: darken;
|
||||
--bg-blend-mode: lighten;
|
||||
}
|
||||
const rippleElements = document.querySelectorAll('.theme-ripple');
|
||||
rippleElements.forEach((el) => {
|
||||
el.classList.add('ripple-active');
|
||||
setTimeout(() => {
|
||||
el.classList.remove('ripple-active');
|
||||
}, 600);
|
||||
});
|
||||
|
||||
:root.loaded {
|
||||
--bg-image-subtle-1: url('/assets/backgrounds/bg-subtle-1-light-800w.jpg');
|
||||
--bg-image-subtle-2: url('/assets/backgrounds/bg-subtle-2-light-800w.jpg');
|
||||
--bg-image-footer: url('/assets/backgrounds/bg-footer-light-800w.jpg');
|
||||
}
|
||||
const event = new CustomEvent('themeChange', {
|
||||
detail: {
|
||||
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
|
||||
},
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
|
||||
:root.loaded.theme-dark {
|
||||
--bg-image-subtle-1: url('/assets/backgrounds/bg-subtle-1-dark-800w.jpg');
|
||||
--bg-image-subtle-2: url('/assets/backgrounds/bg-subtle-2-dark-800w.jpg');
|
||||
--bg-image-footer: url('/assets/backgrounds/bg-footer-dark-800w.jpg');
|
||||
}
|
||||
setTimeout(() => {
|
||||
document.documentElement.classList.remove('theme-switching');
|
||||
}, 600);
|
||||
});
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
:root {
|
||||
--bg-scale: 1;
|
||||
--bg-image-main: url('/assets/backgrounds/bg-main-light-1440w.jpg');
|
||||
}
|
||||
const socialLinks = document.querySelectorAll('.social-link');
|
||||
socialLinks.forEach((link) => {
|
||||
link.addEventListener('mouseenter', () => {
|
||||
link.classList.add('hover-active');
|
||||
});
|
||||
|
||||
:root.theme-dark {
|
||||
--bg-image-main: url('/assets/backgrounds/bg-main-dark-1440w.jpg');
|
||||
}
|
||||
|
||||
:root.loaded {
|
||||
--bg-image-subtle-1: url('/assets/backgrounds/bg-subtle-1-light-1440w.jpg');
|
||||
--bg-image-subtle-2: url('/assets/backgrounds/bg-subtle-2-light-1440w.jpg');
|
||||
--bg-image-footer: url('/assets/backgrounds/bg-footer-light-1440w.jpg');
|
||||
}
|
||||
|
||||
:root.loaded.theme-dark {
|
||||
--bg-image-subtle-1: url('/assets/backgrounds/bg-subtle-1-dark-1440w.jpg');
|
||||
--bg-image-subtle-2: url('/assets/backgrounds/bg-subtle-2-dark-1440w.jpg');
|
||||
--bg-image-footer: url('/assets/backgrounds/bg-footer-dark-1440w.jpg');
|
||||
}
|
||||
}
|
||||
|
||||
.backgrounds {
|
||||
min-height: 100%;
|
||||
isolation: isolate;
|
||||
background:
|
||||
url('/assets/backgrounds/noise.png') top center/220px repeat,
|
||||
var(--bg-image-footer) bottom center/var(--bg-gradient-size) no-repeat,
|
||||
var(--bg-image-main-curves) top center/var(--bg-gradient-size) no-repeat,
|
||||
var(--bg-image-main) top center/var(--bg-gradient-size) no-repeat,
|
||||
var(--gray-999);
|
||||
background-blend-mode:
|
||||
overlay,
|
||||
var(--bg-blend-mode),
|
||||
var(--bg-svg-blend-mode),
|
||||
normal,
|
||||
normal;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
.backgrounds {
|
||||
background: none;
|
||||
background-blend-mode: none;
|
||||
--bg-gradient-size: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</body>
|
||||
</html>
|
||||
link.addEventListener('mouseleave', () => {
|
||||
link.classList.remove('hover-active');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
398
src/layouts/BlogPost.astro
Normal 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 dark:prose-invert lg:prose-lg mx-auto max-w-4xl">
|
||||
<div class="mb-12">
|
||||
<h1
|
||||
class="mb-4 text-4xl font-bold tracking-tight text-zinc-900 sm:text-5xl dark:text-zinc-100"
|
||||
>
|
||||
{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 text-zinc-500 italic 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
@@ -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.png" />
|
||||
<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 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-sm(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>
|
25
src/layouts/TransitionLayout.astro
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import { ClientRouter } from 'astro:transitions';
|
||||
import BaseLayout from './BaseLayout.astro';
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description}>
|
||||
<ClientRouter 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>
|
890
src/layouts/styles/markdown.css
Normal 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);
|
||||
}
|
@@ -1,8 +1,467 @@
|
||||
---
|
||||
import Hero from '../components/Hero.astro';
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="Not Found" description="404 Error — this page was not found">
|
||||
<Hero title="Page Not Found" tagline="Not found" />
|
||||
</BaseLayout>
|
||||
<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 -top-20 -left-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 top-1/2 right-1/4 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>
|
||||
|
||||
<!-- Main content with animation -->
|
||||
<div class="relative z-10 mx-auto max-w-xl">
|
||||
<div class="glitch-wrapper">
|
||||
<h1
|
||||
class="glitch text-9xl leading-none font-bold text-zinc-900 sm:text-[12rem] dark:text-zinc-100"
|
||||
data-text="404"
|
||||
>
|
||||
404
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-6 text-2xl font-bold text-zinc-800 sm:text-3xl dark:text-zinc-200">
|
||||
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-xs 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-xs backdrop-blur-xs dark:border-zinc-700/50 dark:bg-zinc-800/50"
|
||||
>
|
||||
<h3 class="text-sm font-medium tracking-wider text-zinc-500 uppercase 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.",
|
||||
"Some websites turn their 404 pages into games, like Google's Pac-Man 404 page that once existed.",
|
||||
'The first web server was a NeXT computer used by Tim Berners-Lee at CERN, where the 404 error was born.',
|
||||
'Many companies use creative 404 pages as a way to showcase their brand personality and humor.',
|
||||
"The HTTP 1.0 specification from 1996 officially defined the 404 error as 'Not Found'.",
|
||||
'Studies show that well-designed 404 pages can reduce bounce rates by up to 30%.',
|
||||
'The most common cause of 404 errors is mistyped URLs.',
|
||||
];
|
||||
|
||||
// Display a random fun fact
|
||||
const funFactElement = document.getElementById('fun-fact');
|
||||
if (funFactElement) {
|
||||
const randomFact = funFacts[Math.floor(Math.random() * funFacts.length)];
|
||||
funFactElement.textContent = randomFact;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.glitch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
animation: glitch-skew 1s infinite linear alternate-reverse;
|
||||
}
|
||||
|
||||
.glitch::before,
|
||||
.glitch::after {
|
||||
content: attr(data-text);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.glitch::before {
|
||||
left: 2px;
|
||||
text-shadow: -2px 0 #ff00c1;
|
||||
clip: rect(44px, 450px, 56px, 0);
|
||||
animation: glitch-anim 5s infinite linear alternate-reverse;
|
||||
}
|
||||
|
||||
.glitch::after {
|
||||
left: -2px;
|
||||
text-shadow:
|
||||
-2px 0 #00fff9,
|
||||
2px 2px #ff00c1;
|
||||
animation: glitch-anim2 1s infinite linear alternate-reverse;
|
||||
}
|
||||
|
||||
@keyframes glitch-anim {
|
||||
0% {
|
||||
clip: rect(31px, 9999px, 94px, 0);
|
||||
transform: skew(0.85deg);
|
||||
}
|
||||
5% {
|
||||
clip: rect(70px, 9999px, 71px, 0);
|
||||
transform: skew(0.17deg);
|
||||
}
|
||||
10% {
|
||||
clip: rect(9px, 9999px, 85px, 0);
|
||||
transform: skew(0.4deg);
|
||||
}
|
||||
15% {
|
||||
clip: rect(47px, 9999px, 18px, 0);
|
||||
transform: skew(0.22deg);
|
||||
}
|
||||
20% {
|
||||
clip: rect(7px, 9999px, 78px, 0);
|
||||
transform: skew(0.96deg);
|
||||
}
|
||||
25% {
|
||||
clip: rect(53px, 9999px, 54px, 0);
|
||||
transform: skew(0.05deg);
|
||||
}
|
||||
30% {
|
||||
clip: rect(84px, 9999px, 52px, 0);
|
||||
transform: skew(0.94deg);
|
||||
}
|
||||
35% {
|
||||
clip: rect(46px, 9999px, 7px, 0);
|
||||
transform: skew(0.01deg);
|
||||
}
|
||||
40% {
|
||||
clip: rect(2px, 9999px, 66px, 0);
|
||||
transform: skew(0.66deg);
|
||||
}
|
||||
45% {
|
||||
clip: rect(34px, 9999px, 33px, 0);
|
||||
transform: skew(0.52deg);
|
||||
}
|
||||
50% {
|
||||
clip: rect(80px, 9999px, 73px, 0);
|
||||
transform: skew(0.9deg);
|
||||
}
|
||||
55% {
|
||||
clip: rect(8px, 9999px, 81px, 0);
|
||||
transform: skew(0.3deg);
|
||||
}
|
||||
60% {
|
||||
clip: rect(10px, 9999px, 86px, 0);
|
||||
transform: skew(0.85deg);
|
||||
}
|
||||
65% {
|
||||
clip: rect(36px, 9999px, 25px, 0);
|
||||
transform: skew(0.28deg);
|
||||
}
|
||||
70% {
|
||||
clip: rect(75px, 9999px, 31px, 0);
|
||||
transform: skew(0.46deg);
|
||||
}
|
||||
75% {
|
||||
clip: rect(46px, 9999px, 87px, 0);
|
||||
transform: skew(0.44deg);
|
||||
}
|
||||
80% {
|
||||
clip: rect(19px, 9999px, 40px, 0);
|
||||
transform: skew(0.07deg);
|
||||
}
|
||||
85% {
|
||||
clip: rect(85px, 9999px, 88px, 0);
|
||||
transform: skew(0.71deg);
|
||||
}
|
||||
90% {
|
||||
clip: rect(1px, 9999px, 89px, 0);
|
||||
transform: skew(0.76deg);
|
||||
}
|
||||
95% {
|
||||
clip: rect(44px, 9999px, 25px, 0);
|
||||
transform: skew(0.58deg);
|
||||
}
|
||||
100% {
|
||||
clip: rect(31px, 9999px, 26px, 0);
|
||||
transform: skew(0.6deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glitch-anim2 {
|
||||
0% {
|
||||
clip: rect(65px, 9999px, 65px, 0);
|
||||
transform: skew(0.16deg);
|
||||
}
|
||||
5% {
|
||||
clip: rect(8px, 9999px, 42px, 0);
|
||||
transform: skew(0.65deg);
|
||||
}
|
||||
10% {
|
||||
clip: rect(64px, 9999px, 30px, 0);
|
||||
transform: skew(0.42deg);
|
||||
}
|
||||
15% {
|
||||
clip: rect(29px, 9999px, 49px, 0);
|
||||
transform: skew(0.05deg);
|
||||
}
|
||||
20% {
|
||||
clip: rect(25px, 9999px, 56px, 0);
|
||||
transform: skew(0.09deg);
|
||||
}
|
||||
25% {
|
||||
clip: rect(76px, 9999px, 98px, 0);
|
||||
transform: skew(0.79deg);
|
||||
}
|
||||
30% {
|
||||
clip: rect(72px, 9999px, 3px, 0);
|
||||
transform: skew(0.12deg);
|
||||
}
|
||||
35% {
|
||||
clip: rect(20px, 9999px, 60px, 0);
|
||||
transform: skew(0.09deg);
|
||||
}
|
||||
40% {
|
||||
clip: rect(61px, 9999px, 47px, 0);
|
||||
transform: skew(0.45deg);
|
||||
}
|
||||
45% {
|
||||
clip: rect(29px, 9999px, 69px, 0);
|
||||
transform: skew(0.09deg);
|
||||
}
|
||||
50% {
|
||||
clip: rect(82px, 9999px, 96px, 0);
|
||||
transform: skew(0.05deg);
|
||||
}
|
||||
55% {
|
||||
clip: rect(33px, 9999px, 91px, 0);
|
||||
transform: skew(0.16deg);
|
||||
}
|
||||
60% {
|
||||
clip: rect(56px, 9999px, 23px, 0);
|
||||
transform: skew(0.01deg);
|
||||
}
|
||||
65% {
|
||||
clip: rect(46px, 9999px, 21px, 0);
|
||||
transform: skew(0.89deg);
|
||||
}
|
||||
70% {
|
||||
clip: rect(50px, 9999px, 1px, 0);
|
||||
transform: skew(0.85deg);
|
||||
}
|
||||
75% {
|
||||
clip: rect(82px, 9999px, 33px, 0);
|
||||
transform: skew(0.87deg);
|
||||
}
|
||||
80% {
|
||||
clip: rect(94px, 9999px, 46px, 0);
|
||||
transform: skew(0.64deg);
|
||||
}
|
||||
85% {
|
||||
clip: rect(48px, 9999px, 95px, 0);
|
||||
transform: skew(0.43deg);
|
||||
}
|
||||
90% {
|
||||
clip: rect(60px, 9999px, 10px, 0);
|
||||
transform: skew(0.29deg);
|
||||
}
|
||||
95% {
|
||||
clip: rect(85px, 9999px, 62px, 0);
|
||||
transform: skew(0.66deg);
|
||||
}
|
||||
100% {
|
||||
clip: rect(61px, 9999px, 58px, 0);
|
||||
transform: skew(0.74deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes glitch-skew {
|
||||
0% {
|
||||
transform: skew(-1deg);
|
||||
}
|
||||
10% {
|
||||
transform: skew(0deg);
|
||||
}
|
||||
20% {
|
||||
transform: skew(0.5deg);
|
||||
}
|
||||
30% {
|
||||
transform: skew(-0.5deg);
|
||||
}
|
||||
40% {
|
||||
transform: skew(0.2deg);
|
||||
}
|
||||
50% {
|
||||
transform: skew(0deg);
|
||||
}
|
||||
60% {
|
||||
transform: skew(-0.5deg);
|
||||
}
|
||||
70% {
|
||||
transform: skew(0.8deg);
|
||||
}
|
||||
80% {
|
||||
transform: skew(-0.2deg);
|
||||
}
|
||||
90% {
|
||||
transform: skew(0.5deg);
|
||||
}
|
||||
100% {
|
||||
transform: skew(0deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@@ -1,119 +1,580 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import { FaJs, FaReact, FaNodeJs, FaPython } from 'react-icons/fa';
|
||||
import { SiTypescript, SiAstro } from 'react-icons/si';
|
||||
|
||||
import ContactCTA from '../components/ContactCTA.astro';
|
||||
import Hero from '../components/Hero.astro';
|
||||
import directus from '../../lib/directus';
|
||||
import { readSingleton, readItems } from '@directus/sdk';
|
||||
|
||||
import directus, { directus_url } from "../../lib/directus"
|
||||
import { readSingleton } from "@directus/sdk";
|
||||
const global = await directus.request(readSingleton('global'));
|
||||
const about = await directus.request(readSingleton('about'));
|
||||
|
||||
const global = await directus.request(readSingleton("global"));
|
||||
const about = await directus.request(readSingleton("about"));
|
||||
const skills = await directus.request(
|
||||
readItems('skills', {
|
||||
fields: ['*'],
|
||||
})
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout title=`About | ${global.name}` description=`About ${global.name}`>
|
||||
<div class="stack gap-20">
|
||||
<main class="wrapper about">
|
||||
<Hero
|
||||
title="About"
|
||||
tagline="Thanks for stopping by. Read below to learn more about myself and my background."
|
||||
>
|
||||
<img
|
||||
width="1553"
|
||||
height="873"
|
||||
src=`${directus_url}/assets/${global.about}`
|
||||
alt=`${global.name} hiking in Texas`
|
||||
/>
|
||||
</Hero>
|
||||
<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 -top-10 -left-10 h-36 w-36 rounded-full bg-zinc-100 opacity-30 blur-3xl sm:-top-20 sm:-left-20 sm:h-48 sm:w-48 md:h-72 md:w-72 dark:bg-zinc-800/30"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-2000 theme-transition-bg absolute -right-10 -bottom-10 h-36 w-36 rounded-full bg-zinc-200 opacity-30 blur-3xl sm:-right-20 sm:-bottom-20 sm:h-48 sm:w-48 md:h-72 md:w-72 dark:bg-zinc-800/30"
|
||||
>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 class="section-title">Background</h2>
|
||||
<div
|
||||
class="content"
|
||||
set:html={about.background}
|
||||
/>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="section-title">Experience</h2>
|
||||
<div
|
||||
class="content"
|
||||
set:html={about.experience}
|
||||
/>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="section-title">Education</h2>
|
||||
<div
|
||||
class="content"
|
||||
set:html={about.education}
|
||||
/>
|
||||
</section>
|
||||
<section>
|
||||
<h2 class="section-title">Certifications</h2>
|
||||
<div
|
||||
class="content"
|
||||
set:html={about.certifications}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
<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 sm:mb-6 sm:text-4xl md:text-5xl dark:text-zinc-100"
|
||||
>
|
||||
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>
|
||||
|
||||
<ContactCTA />
|
||||
</div>
|
||||
<p
|
||||
class="theme-transition-color mb-6 text-lg leading-relaxed text-zinc-600 sm:mb-8 sm:text-xl dark:text-zinc-400"
|
||||
>
|
||||
{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 sm:max-w-[320px] sm:border-8 sm:shadow-2xl md:max-w-md dark:border-zinc-800"
|
||||
>
|
||||
<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 -right-4 -bottom-4 flex h-16 w-16 items-center justify-center rounded-full border-2 border-white bg-zinc-100 shadow-lg sm:-right-6 sm:-bottom-6 sm:h-20 sm:w-20 sm:border-4 md:h-24 md:w-24 dark:border-zinc-900 dark:bg-zinc-800"
|
||||
>
|
||||
<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 sm:mb-8 sm:text-3xl md:justify-start dark:text-zinc-100"
|
||||
>
|
||||
<span
|
||||
class="theme-transition-bg mr-4 hidden h-1 w-8 bg-zinc-300 sm:inline-block sm:w-12 dark:bg-zinc-700"
|
||||
></span>
|
||||
About Me
|
||||
<span
|
||||
class="theme-transition-bg ml-4 hidden h-1 w-8 bg-zinc-300 sm:inline-block sm:w-12 dark:bg-zinc-700"
|
||||
></span>
|
||||
</h2>
|
||||
|
||||
<div class="theme-transition-all prose prose-zinc dark:prose-invert max-w-none">
|
||||
<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 sm:mb-12 sm:text-3xl dark:text-zinc-100"
|
||||
>
|
||||
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
|
||||
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 sm:mx-4 sm:min-w-[280px] dark:border-zinc-700 dark:bg-zinc-800/50 dark:hover:border-zinc-600"
|
||||
>
|
||||
<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 sm:h-12 sm:w-12 dark:bg-zinc-800 dark:text-zinc-200">
|
||||
<skill.icon
|
||||
/>
|
||||
</div>
|
||||
<h3 class="theme-transition-color text-base font-semibold text-zinc-900 sm:text-xl dark:text-zinc-100">
|
||||
{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 sm:px-2.5 sm:py-1 sm:text-sm dark:bg-zinc-800 dark:text-zinc-400">
|
||||
{skill.level}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="theme-transition-bg relative h-1.5 w-full overflow-hidden rounded-full bg-zinc-100 sm:h-2 dark:bg-zinc-700">
|
||||
<div
|
||||
class="progress-bar-animate theme-transition-bg absolute top-0 left-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 sm:mt-2 sm:text-xs dark:text-zinc-500">
|
||||
<span>Beginner</span>
|
||||
<span>Advanced</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Gradient overlays for smooth fade effect -->
|
||||
<div
|
||||
class="theme-transition-bg absolute top-0 bottom-0 left-0 z-10 w-12 bg-gradient-to-r from-white to-transparent sm:w-24 dark:from-zinc-900"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="theme-transition-bg absolute top-0 right-0 bottom-0 z-10 w-12 bg-gradient-to-l from-white to-transparent sm:w-24 dark:from-zinc-900"
|
||||
>
|
||||
</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 sm:mb-6 sm:text-3xl dark:text-zinc-100"
|
||||
>
|
||||
Get in Touch
|
||||
</h2>
|
||||
<p
|
||||
class="theme-transition-color mb-6 text-base text-zinc-600 sm:mb-8 sm:text-lg dark:text-zinc-400"
|
||||
>
|
||||
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="hover 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 sm:px-8 sm:py-4 sm:text-lg dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300"
|
||||
>
|
||||
<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>
|
||||
.about {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3.5rem;
|
||||
}
|
||||
/* Blob animation */
|
||||
.animate-blob {
|
||||
animation: blob-bounce 8s infinite ease;
|
||||
}
|
||||
|
||||
img {
|
||||
margin-top: 1.5rem;
|
||||
border-radius: 1.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
color: var(--gray-200);
|
||||
}
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
grid-column-start: 1;
|
||||
font-size: var(--text-xl);
|
||||
color: var(--gray-0);
|
||||
}
|
||||
/* Tech Stack Slider */
|
||||
.slider-track {
|
||||
width: fit-content;
|
||||
animation: scroll 40s linear infinite;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-column: 2 / 4;
|
||||
}
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(calc(-220px * 6 - 16px * 6)); /* Card width + margin for mobile */
|
||||
}
|
||||
}
|
||||
|
||||
.content :global(a) {
|
||||
text-decoration: 1px solid underline transparent;
|
||||
text-underline-offset: 0.25em;
|
||||
transition: text-decoration-color var(--theme-transition);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.slider-track {
|
||||
animation: scroll 60s linear infinite;
|
||||
}
|
||||
|
||||
.content :global(a:hover),
|
||||
.content :global(a:focus) {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(calc(-280px * 6 - 32px * 6)); /* Card width + margin for desktop */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
.about {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 60% 1fr;
|
||||
}
|
||||
.tech-stack-slider:hover .slider-track {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
.about > :global(:first-child) {
|
||||
grid-column-start: 2;
|
||||
}
|
||||
.skill-card {
|
||||
transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
section {
|
||||
display: contents;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
}
|
||||
.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>
|
||||
// Wait for the DOM to be fully loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const sliderTrack = document.querySelector('.slider-track');
|
||||
|
||||
// Create seamless infinite scrolling effect
|
||||
function setupInfiniteScroll() {
|
||||
const cards = document.querySelectorAll('.skill-card');
|
||||
if (!cards.length) return;
|
||||
|
||||
// Clone the first set of cards and append to create seamless loop
|
||||
const firstSetCount = cards.length / 3; // We have 3 sets in the markup
|
||||
|
||||
// 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';
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
347
src/pages/blog/[...slug].astro
Normal file
@@ -0,0 +1,347 @@
|
||||
---
|
||||
import BlogPost from '../../layouts/BlogPost.astro';
|
||||
|
||||
import directus from '../../../lib/directus';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await directus.request(
|
||||
readItems('posts', {
|
||||
fields: ['*'],
|
||||
})
|
||||
);
|
||||
|
||||
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;
|
||||
---
|
||||
|
||||
<BlogPost
|
||||
slug={post.slug}
|
||||
title={post.title}
|
||||
description={post.description}
|
||||
content={post.content}
|
||||
image={post.image}
|
||||
image_alt={post.image_alt}
|
||||
published_date={post.published_date}
|
||||
updated_date={post.updated_date}
|
||||
tags={post.tags}
|
||||
>
|
||||
<!-- Main Content - Enhanced with better typography and spacing -->
|
||||
<div
|
||||
class="prose prose-sm prose-zinc 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 max-w-none"
|
||||
>
|
||||
<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 sm:mt-16 sm:gap-6 sm:pt-12 md:grid-cols-2 dark:border-zinc-800"
|
||||
>
|
||||
{
|
||||
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 sm:p-6 dark:border-zinc-800 dark:hover:bg-zinc-800/50"
|
||||
>
|
||||
<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 sm:mb-2 sm:gap-2 sm:text-sm dark:text-zinc-400">
|
||||
<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="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 sm:text-lg dark:text-white dark:group-hover:text-zinc-300">
|
||||
{prevPost.title}
|
||||
</h3>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
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 sm:p-6 md:text-right dark:border-zinc-800 dark:hover:bg-zinc-800/50"
|
||||
>
|
||||
<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 sm:mb-2 sm:gap-2 sm:text-sm md:justify-end dark:text-zinc-400">
|
||||
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 sm:text-lg dark:text-white dark:group-hover:text-zinc-300">
|
||||
{nextPost.title}
|
||||
</h3>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</BlogPost>
|
||||
|
||||
<script>
|
||||
// Removing TOC-related functions
|
||||
|
||||
// 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 {
|
||||
@reference text-zinc-800 dark:text-zinc-200;
|
||||
}
|
||||
|
||||
.prose h1,
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
.prose h4 {
|
||||
@reference font-semibold text-zinc-900 dark:text-zinc-100;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
@reference text-2xl sm:text-3xl md:text-4xl;
|
||||
}
|
||||
|
||||
.prose h2 {
|
||||
@reference 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 {
|
||||
@reference mb-2 mt-6 text-lg sm:mb-3 sm:mt-8 sm:text-xl;
|
||||
}
|
||||
|
||||
.prose p {
|
||||
@reference mb-4 text-sm leading-relaxed sm:mb-6 sm:text-base;
|
||||
}
|
||||
|
||||
.prose a {
|
||||
@reference 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 {
|
||||
@reference 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 {
|
||||
@reference rounded-sm 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 {
|
||||
@reference 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 {
|
||||
@reference bg-transparent p-0 text-zinc-200 dark:text-zinc-200 !important;
|
||||
}
|
||||
|
||||
.prose img {
|
||||
@reference mx-auto my-6 h-auto max-w-full rounded-lg shadow-md sm:my-8;
|
||||
}
|
||||
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
@reference my-4 pl-5 sm:my-6 sm:pl-6;
|
||||
}
|
||||
|
||||
.prose li {
|
||||
@reference mb-1 text-sm sm:mb-2 sm:text-base;
|
||||
}
|
||||
|
||||
.prose hr {
|
||||
@reference 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>
|
438
src/pages/blog/index.astro
Normal file
@@ -0,0 +1,438 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
|
||||
import directus from '../../../lib/directus';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
const posts = await directus.request(
|
||||
readItems('posts', {
|
||||
fields: ['*'],
|
||||
sort: ['-published_date'],
|
||||
})
|
||||
);
|
||||
|
||||
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">
|
||||
<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 -top-10 -left-10 h-48 w-48 rounded-full bg-zinc-100 opacity-30 blur-3xl sm:-top-20 sm:-left-20 sm:h-72 sm:w-72 dark:bg-zinc-800/30"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-2000 absolute -right-10 -bottom-10 h-48 w-48 rounded-full bg-zinc-200 opacity-30 blur-3xl sm:-right-20 sm:-bottom-20 sm:h-72 sm:w-72 dark:bg-zinc-800/30"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center">
|
||||
<h1
|
||||
class="mb-4 text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl md:text-5xl dark:text-zinc-100"
|
||||
>
|
||||
Blog
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="mx-auto mb-6 max-w-2xl text-sm text-zinc-600 sm:mb-10 sm:text-base dark:text-zinc-400"
|
||||
>
|
||||
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 sm:pb-8 dark:border-zinc-800">
|
||||
<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 group-hover:scale-105 hover:grayscale-0"
|
||||
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 sm:text-sm md:justify-start dark:text-zinc-400">
|
||||
<span class="font-medium tracking-wider uppercase">Featured</span>
|
||||
<span class="h-px w-6 bg-zinc-300 sm:w-8 dark:bg-zinc-700" />
|
||||
{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 sm:mb-4 sm:text-3xl md:text-left dark:text-zinc-100 dark:group-hover:text-zinc-300">
|
||||
<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 sm:mb-6 sm:text-base md:text-left dark:text-zinc-400">
|
||||
{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 tracking-wider text-zinc-600 uppercase sm:px-3 dark:border-zinc-800 dark:text-zinc-400">
|
||||
{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 tracking-wider text-zinc-900 uppercase md:text-left dark:text-zinc-100"
|
||||
>
|
||||
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={`hover mr-3 flex items-center rounded-full border-b border-zinc-100 px-4 py-2 whitespace-nowrap transition-colors hover:bg-zinc-50 md:mr-0 md:w-full md:rounded-none md:px-0 md:py-3 md:whitespace-normal dark:border-zinc-800 dark:hover:bg-zinc-900 ${index === 0 ? 'bg-zinc-50 dark:bg-zinc-800/50' : ''}`}
|
||||
>
|
||||
<span class="text-base font-medium text-zinc-900 md:text-lg dark:text-zinc-100">
|
||||
{year}
|
||||
</span>
|
||||
<span class="ml-2 text-xs text-zinc-500 md:ml-auto md:text-sm dark:text-zinc-400">
|
||||
{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 sm:mb-8 sm:pb-4 sm:text-2xl md:text-left dark:border-zinc-800 dark:text-zinc-100">
|
||||
{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 group-hover:scale-105 hover:grayscale-0"
|
||||
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 sm:mb-3 sm:gap-4 sm:text-sm md:justify-start dark:text-zinc-400">
|
||||
{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 sm:mb-3 sm:text-xl md:text-left dark:text-zinc-100 dark:group-hover:text-zinc-300">
|
||||
<a href={`/blog/${post.slug}/`} class="before:absolute before:inset-0">
|
||||
{post.title}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<p class="mb-4 line-clamp-2 grow text-center text-sm text-zinc-600 md:text-left dark:text-zinc-400">
|
||||
{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 tracking-wider text-zinc-600 uppercase sm:px-3 dark:border-zinc-800 dark:text-zinc-400">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{post.tags.length > 2 && (
|
||||
<span class="border border-zinc-200 px-2 py-1 text-xs tracking-wider text-zinc-600 uppercase sm:px-3 dark:border-zinc-800 dark:text-zinc-400">
|
||||
+{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>
|
||||
// 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',
|
||||
});
|
||||
});
|
||||
|
||||
// Check scroll position
|
||||
window.addEventListener('scroll', toggleBackToTopButton);
|
||||
toggleBackToTopButton(); // Initial check
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
if (targetElement) {
|
||||
window.scrollTo({
|
||||
top: targetElement.offsetTop - 100,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
|
||||
// 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>
|
@@ -1,228 +1,591 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
import Layout from '../layouts/Layout.astro';
|
||||
import FormattedDate from '../components/FormattedDate.astro';
|
||||
|
||||
import CallToAction from '../components/CallToAction.astro';
|
||||
import Grid from '../components/Grid.astro';
|
||||
import Hero from '../components/Hero.astro';
|
||||
import Icon from '../components/Icon.astro';
|
||||
import Pill from '../components/Pill.astro';
|
||||
import PortfolioPreview from '../components/PortfolioPreview.astro';
|
||||
import directus from '../../lib/directus';
|
||||
import { readItems, readSingleton } from '@directus/sdk';
|
||||
|
||||
import ContactCTA from '../components/ContactCTA.astro';
|
||||
import Skills from '../components/Skills.astro';
|
||||
|
||||
import directus, { directus_url } from "../../lib/directus"
|
||||
import { readItems,readSingleton } from "@directus/sdk";
|
||||
|
||||
const global = await directus.request(readSingleton("global"));
|
||||
const global = await directus.request(readSingleton('global'));
|
||||
|
||||
const posts = await directus.request(
|
||||
readItems("posts", {
|
||||
readItems('posts', {
|
||||
fields: ['*'],
|
||||
sort: ["-published_date"],
|
||||
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 | ${global.name}`
|
||||
description=""
|
||||
>
|
||||
<div class="stack gap-20 lg:gap-48">
|
||||
<div class="wrapper stack gap-8 lg:gap-20">
|
||||
<header class="hero">
|
||||
<Hero
|
||||
title=`Hello, my name is ${global.name}`
|
||||
tagline={global.tagline}
|
||||
align="start"
|
||||
>
|
||||
<div class="roles">
|
||||
<Pill><Icon icon="hard-drives" size="1.33em" /> Engineer</Pill>
|
||||
<Pill><Icon icon="code" size="1.33em" /> Developer</Pill>
|
||||
<Pill><Icon icon="pencil-line" size="1.33em" /> Writer</Pill>
|
||||
</div>
|
||||
</Hero>
|
||||
<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 -top-10 -left-10 h-40 w-40 rounded-full bg-zinc-100 opacity-50 blur-3xl sm:-top-20 sm:-left-20 sm:h-64 sm:w-64 dark:bg-zinc-800/50"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-2000 theme-transition-bg absolute -right-10 -bottom-10 h-40 w-40 rounded-full bg-zinc-200 opacity-30 blur-3xl sm:-right-20 sm:-bottom-20 sm:h-64 sm:w-64 dark:bg-zinc-800/30"
|
||||
>
|
||||
</div>
|
||||
|
||||
<img
|
||||
alt=`${global.name} in Antarctica`
|
||||
width="480"
|
||||
height="620"
|
||||
src=`${directus_url}/assets/${global.portrait}`
|
||||
/>
|
||||
</header>
|
||||
<div class="relative text-center sm:text-left">
|
||||
<h1
|
||||
class="theme-transition-color hero-text text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl md:text-5xl lg:text-6xl dark:text-zinc-100"
|
||||
>
|
||||
<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 sm:mx-0 sm:mt-6 sm:text-lg md:mt-8 dark:text-zinc-400"
|
||||
>
|
||||
{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>
|
||||
|
||||
<Skills />
|
||||
</div>
|
||||
<!-- Featured Post Section - Improved for mobile -->
|
||||
<section
|
||||
class="theme-transition-all border-t border-zinc-100 px-4 py-10 sm:px-6 sm:py-12 md:py-16 dark:border-zinc-800"
|
||||
>
|
||||
<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 sm:text-left sm:text-2xl md:text-3xl dark:text-zinc-100"
|
||||
>
|
||||
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 sm:self-auto dark:text-zinc-100 dark:hover:text-zinc-300"
|
||||
>
|
||||
<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>
|
||||
|
||||
<main class="wrapper stack gap-20 lg:gap-48">
|
||||
<section class="section with-background with-cta">
|
||||
<header class="section-header stack gap-2 lg:gap-4">
|
||||
<h3>Selected Projects</h3>
|
||||
<p>Take a look below at some of my projects from the past few years.</p>
|
||||
</header>
|
||||
<!-- 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 sm:-inset-x-6 sm:rounded-2xl dark:bg-zinc-800/50" />
|
||||
|
||||
<div class="gallery">
|
||||
<Grid variant="offset">
|
||||
{
|
||||
posts.map((post) => (
|
||||
<li>
|
||||
<PortfolioPreview posts={post} />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</Grid>
|
||||
</div>
|
||||
{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="cta">
|
||||
<CallToAction href="/projects/">
|
||||
View All
|
||||
<Icon icon="arrow-right" size="1.2em" />
|
||||
</CallToAction>
|
||||
</div>
|
||||
</section>
|
||||
<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 sm:justify-start sm:gap-x-4 dark:text-zinc-400">
|
||||
<time datetime={post.published_date.toLocaleString()} class="font-medium">
|
||||
<FormattedDate date={post.published_date} />
|
||||
</time>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
<ContactCTA />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
<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 sm:text-left sm:text-xl dark:text-zinc-100 dark:group-hover:text-zinc-300">
|
||||
<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 sm:mt-3 sm:text-left dark:text-zinc-400">
|
||||
{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 sm:px-3 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
|
||||
>
|
||||
#{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 sm:mx-0 sm:mt-4 dark:text-zinc-300 dark:group-hover:text-zinc-100"
|
||||
>
|
||||
<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 top-0 left-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 sm:px-6 sm:py-12 md:py-16 dark:border-zinc-800">
|
||||
<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 sm:mb-8 sm:text-left sm:text-2xl md:text-3xl dark:text-zinc-100">
|
||||
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 sm:min-h-[90px] sm:p-4 md:p-6 dark:border-zinc-800 dark:hover:bg-zinc-800/70"
|
||||
>
|
||||
<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 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 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 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');
|
||||
},
|
||||
100 + index * 150
|
||||
);
|
||||
});
|
||||
|
||||
// Animate posts with staggered delay
|
||||
const articles = document.querySelectorAll('article.group');
|
||||
articles.forEach((article, index) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
article.classList.add('animate-reveal');
|
||||
},
|
||||
500 + index * 150
|
||||
);
|
||||
});
|
||||
|
||||
// Animate topic cards with staggered delay
|
||||
const topicCards = document.querySelectorAll('a.group.flex.flex-col');
|
||||
topicCards.forEach((card, index) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
card.classList.add('animate-reveal');
|
||||
},
|
||||
800 + index * 100
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// 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>
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
/* Fix for theme transition issues */
|
||||
:global(:root) {
|
||||
--theme-transition-duration: 0.5s;
|
||||
--theme-transition-timing: ease;
|
||||
}
|
||||
|
||||
.roles {
|
||||
display: none;
|
||||
}
|
||||
:global(html),
|
||||
:global(body) {
|
||||
transition: background-color var(--theme-transition-duration) var(--theme-transition-timing);
|
||||
}
|
||||
|
||||
.hero img {
|
||||
aspect-ratio: 5 / 4;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
border-radius: 1.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
:global(.theme-transition-all) {
|
||||
transition: all var(--theme-transition-duration) var(--theme-transition-timing);
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 6fr 4fr;
|
||||
padding-inline: 2.5rem;
|
||||
gap: 3.75rem;
|
||||
}
|
||||
:global(.theme-transition-bg) {
|
||||
transition: background-color var(--theme-transition-duration) var(--theme-transition-timing);
|
||||
}
|
||||
|
||||
.roles {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
:global(.theme-transition-color) {
|
||||
transition: color var(--theme-transition-duration) var(--theme-transition-timing);
|
||||
}
|
||||
|
||||
.hero img {
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 4.5rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
/* 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);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.with-background {
|
||||
position: relative;
|
||||
}
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.with-background::before {
|
||||
--hero-bg: var(--bg-image-subtle-2);
|
||||
.animate-reveal {
|
||||
opacity: 1 !important;
|
||||
transform: translateY(0) !important;
|
||||
}
|
||||
|
||||
content: '';
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
left: 50%;
|
||||
width: 100vw;
|
||||
aspect-ratio: calc(2.25 / var(--bg-scale));
|
||||
top: 0;
|
||||
transform: translateY(-75%) translateX(-50%);
|
||||
background:
|
||||
url('/assets/backgrounds/noise.png') top center/220px repeat,
|
||||
var(--hero-bg) center center / var(--bg-gradient-size) no-repeat,
|
||||
var(--gray-999);
|
||||
background-blend-mode: overlay, normal, normal, normal;
|
||||
mix-blend-mode: var(--bg-blend-mode);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.with-background.bg-variant::before {
|
||||
--hero-bg: var(--bg-image-subtle-1);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
justify-self: center;
|
||||
text-align: center;
|
||||
max-width: 50ch;
|
||||
font-size: var(--text-md);
|
||||
color: var(--gray-300);
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
.section {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-areas: 'header header header header' 'gallery gallery gallery gallery';
|
||||
gap: 5rem;
|
||||
}
|
||||
|
||||
.section.with-cta {
|
||||
grid-template-areas: 'header header header cta' 'gallery gallery gallery gallery';
|
||||
}
|
||||
|
||||
.section-header {
|
||||
grid-area: header;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
|
||||
.with-cta .section-header {
|
||||
justify-self: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
grid-area: gallery;
|
||||
}
|
||||
|
||||
.cta {
|
||||
grid-area: cta;
|
||||
}
|
||||
}
|
||||
|
||||
.mention-card {
|
||||
display: flex;
|
||||
height: 7rem;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
border: 1px solid var(--gray-800);
|
||||
border-radius: 1.5rem;
|
||||
color: var(--gray-300);
|
||||
background: var(--gradient-subtle);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
.mention-card {
|
||||
border-radius: 1.5rem;
|
||||
height: 9.5rem;
|
||||
}
|
||||
}
|
||||
/* Rest of your existing styles... */
|
||||
</style>
|
||||
|
@@ -1,45 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||
|
||||
import ContactCTA from '../components/ContactCTA.astro';
|
||||
import PortfolioPreview from '../components/PortfolioPreview.astro';
|
||||
import Hero from '../components/Hero.astro';
|
||||
import Grid from '../components/Grid.astro';
|
||||
|
||||
import directus from "../../lib/directus"
|
||||
import { readItems,readSingleton } from "@directus/sdk";
|
||||
|
||||
const global = await directus.request(readSingleton("global"));
|
||||
|
||||
const posts = await directus.request(
|
||||
readItems("posts", {
|
||||
fields: ['*'],
|
||||
sort: ["-published_date"],
|
||||
})
|
||||
);
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title=`My Projects | ${global.name}`
|
||||
description=`Learn about ${global.name}'s most recent projects`
|
||||
>
|
||||
<div class="stack gap-20">
|
||||
<main class="wrapper stack gap-8">
|
||||
<Hero
|
||||
title="My Projects"
|
||||
tagline="See my most recent projects below to get an idea of my past experience."
|
||||
align="start"
|
||||
/>
|
||||
<Grid variant="offset">
|
||||
{
|
||||
posts.map((post) => (
|
||||
<li>
|
||||
<PortfolioPreview posts={post} />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</Grid>
|
||||
</main>
|
||||
<ContactCTA />
|
||||
</div>
|
||||
</BaseLayout>
|
@@ -1,147 +0,0 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
|
||||
import ContactCTA from '../../components/ContactCTA.astro';
|
||||
import Hero from '../../components/Hero.astro';
|
||||
import Icon from '../../components/Icon.astro';
|
||||
import Pill from '../../components/Pill.astro';
|
||||
|
||||
import directus, { directus_url } 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 = new Date(post.published_date).toLocaleDateString();
|
||||
---
|
||||
|
||||
<BaseLayout title={post.title}>
|
||||
<div class="stack gap-20">
|
||||
<div class="stack gap-15">
|
||||
<header>
|
||||
<div class="wrapper stack gap-2">
|
||||
<a class="back-link" href="/projects/"><Icon icon="arrow-left" /> Projects</a>
|
||||
<Hero
|
||||
title={post.title}
|
||||
tagline=`Published on ${published_date}`
|
||||
align="start"
|
||||
>
|
||||
<div class="details">
|
||||
<div class="tags">
|
||||
{post.tags.map((t) => <Pill>{t}</Pill>)}
|
||||
</div>
|
||||
</div>
|
||||
</Hero>
|
||||
</div>
|
||||
</header>
|
||||
<main class="wrapper">
|
||||
<div class="stack gap-10 content">
|
||||
{post.image && <img src={`${directus_url}/assets/${post.image}?width=500`} alt={post.image_alt || ''} />}
|
||||
<div class="content">
|
||||
<div set:html={post.content} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<ContactCTA />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
header {
|
||||
padding-bottom: 2.5rem;
|
||||
border-bottom: 1px solid var(--gray-800);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem;
|
||||
gap: 1.5rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: var(--text-lg);
|
||||
max-width: 54ch;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 65ch;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.content > :global(* + *) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.content :global(h1),
|
||||
.content :global(h2),
|
||||
.content :global(h3),
|
||||
.content :global(h4),
|
||||
.content :global(h5) {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.content :global(img) {
|
||||
border-radius: 1.5rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
background: var(--gradient-subtle);
|
||||
border: 1px solid var(--gray-800);
|
||||
}
|
||||
|
||||
.content :global(blockquote) {
|
||||
font-size: var(--text-lg);
|
||||
font-family: var(--font-brand);
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
padding-inline-start: 1.5rem;
|
||||
border-inline-start: 0.25rem solid var(--accent-dark);
|
||||
color: var(--gray-0);
|
||||
}
|
||||
|
||||
.back-link,
|
||||
.content :global(a) {
|
||||
text-decoration: 1px solid underline transparent;
|
||||
text-underline-offset: 0.25em;
|
||||
transition: text-decoration-color var(--theme-transition);
|
||||
}
|
||||
|
||||
.back-link:hover,
|
||||
.back-link:focus,
|
||||
.content :global(a:hover),
|
||||
.content :global(a:focus) {
|
||||
text-decoration-color: currentColor;
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
.back-link {
|
||||
display: block;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex-direction: row;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.content :global(blockquote) {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
}
|
||||
</style>
|
27
src/pages/rss.xml.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import rss from '@astrojs/rss';
|
||||
|
||||
import directus from '../../lib/directus';
|
||||
import { readItems, readSingleton } from '@directus/sdk';
|
||||
|
||||
export async function GET(context: any) {
|
||||
const global = await directus.request(readSingleton('global'));
|
||||
const posts = await directus.request(
|
||||
readItems('posts', {
|
||||
fields: ['*'],
|
||||
sort: ['-published_date'],
|
||||
})
|
||||
);
|
||||
|
||||
return rss({
|
||||
title: `${global.name}`,
|
||||
description: `${global.description}`,
|
||||
site: context.site,
|
||||
items: posts.map((post) => ({
|
||||
title: post.title,
|
||||
pubDate: post.published_date,
|
||||
description: post.slug,
|
||||
link: `/blog/${post.slug}/`,
|
||||
categories: post.tags || [],
|
||||
})),
|
||||
});
|
||||
}
|
520
src/pages/topics/[tag].astro
Normal file
@@ -0,0 +1,520 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
import FormattedDate from '../../components/FormattedDate.astro';
|
||||
|
||||
import directus from '../../../lib/directus';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await directus.request(
|
||||
readItems('posts', {
|
||||
fields: ['*'],
|
||||
})
|
||||
);
|
||||
|
||||
// Get all unique tags
|
||||
const uniqueTags = [...new Set(posts.flatMap((post) => post.tags || []))];
|
||||
|
||||
// Create a path for each tag
|
||||
return uniqueTags.map((tag) => {
|
||||
// Make tag matching case-insensitive
|
||||
const filteredPosts = posts.filter(
|
||||
(post) => post.tags?.some((t) => t.toLowerCase() === (tag as string).toLowerCase()) // Explicitly cast tag to string
|
||||
);
|
||||
return {
|
||||
params: { tag },
|
||||
props: { posts: filteredPosts },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const { tag } = Astro.params as { tag: string };
|
||||
const { posts = [] } = Astro.props;
|
||||
|
||||
console.log(`Tag: ${tag}, Number of posts: ${posts.length}`);
|
||||
|
||||
const sortedPosts =
|
||||
posts && posts.length > 0
|
||||
? [...posts].sort((a, b) => b.published_date.valueOf() - a.published_date.valueOf())
|
||||
: [];
|
||||
console.log(`Sorted posts length: ${sortedPosts.length}`);
|
||||
|
||||
const tagHue = Math.abs(tag.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 360);
|
||||
const relatedTags = [
|
||||
...new Set(sortedPosts.flatMap((post) => post.tags || []).filter((t) => t !== tag)),
|
||||
].slice(0, 5);
|
||||
---
|
||||
|
||||
<BaseLayout title={`Posts tagged with "${tag}"`}>
|
||||
<div class="mx-auto max-w-5xl px-4 py-10 sm:py-16">
|
||||
<!-- Header section -->
|
||||
<div class="relative mb-10 sm:mb-16">
|
||||
<div
|
||||
class="animate-blob absolute -top-20 -left-20 h-48 w-48 rounded-full bg-zinc-100 opacity-30 blur-3xl sm:h-64 sm:w-64 dark:bg-zinc-900/30"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-2000 absolute -right-10 -bottom-10 h-36 w-36 rounded-full bg-zinc-200 opacity-20 blur-2xl sm:h-48 sm:w-48 dark:bg-zinc-900/20"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center sm:text-left">
|
||||
<a
|
||||
href="/tags"
|
||||
class="group mb-4 inline-flex items-center gap-2 text-sm font-medium text-zinc-600 transition-colors hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||
>
|
||||
<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="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"></path>
|
||||
</svg>
|
||||
<span>Back to all topics</span>
|
||||
<span
|
||||
class="block h-0.5 max-w-0 bg-zinc-300 transition-all duration-300 group-hover:max-w-full dark:bg-zinc-700"
|
||||
></span>
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="mb-2 flex flex-col justify-center gap-4 sm:flex-row sm:items-center sm:justify-start"
|
||||
>
|
||||
<div
|
||||
class="tag-icon mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-zinc-100 shadow-xs sm:mx-0 dark:bg-zinc-800"
|
||||
>
|
||||
<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-700 dark:text-zinc-300"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
||||
></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
class="text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-100"
|
||||
>
|
||||
<span class="relative">
|
||||
#{tag}
|
||||
<span class="absolute -bottom-1 left-0 h-1 w-full bg-zinc-200 dark:bg-zinc-700"
|
||||
></span>
|
||||
<span
|
||||
class="animate-expand absolute -bottom-1 left-0 h-1 w-1/2 bg-zinc-900 opacity-70 dark:bg-zinc-100"
|
||||
></span>
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="mx-auto mt-4 max-w-2xl text-base text-zinc-600 sm:mx-0 sm:text-lg dark:text-zinc-400"
|
||||
>
|
||||
Exploring <span class="font-medium text-zinc-900 dark:text-zinc-100"
|
||||
>{sortedPosts.length}</span
|
||||
> articles tagged with <span class="font-medium text-zinc-900 dark:text-zinc-100"
|
||||
>"{tag}"</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related tags section -->
|
||||
{
|
||||
relatedTags.length > 0 && (
|
||||
<div class="hide-scrollbar mb-8 overflow-x-auto pb-4 sm:mb-12">
|
||||
<h2 class="mb-3 text-center text-lg font-medium text-zinc-900 sm:text-left dark:text-zinc-100">
|
||||
Related topics
|
||||
</h2>
|
||||
<div class="flex flex-nowrap justify-center gap-2 sm:justify-start">
|
||||
{relatedTags.map((relatedTag) => (
|
||||
<a
|
||||
href={`/topics/${relatedTag}`}
|
||||
class="inline-flex shrink-0 items-center rounded-full bg-zinc-100 px-3 py-1.5 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
|
||||
>
|
||||
#{relatedTag}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Posts list -->
|
||||
<div class="relative">
|
||||
<div class="bg-grid-pattern pointer-events-none absolute inset-0 opacity-5 dark:opacity-10">
|
||||
</div>
|
||||
|
||||
<div class="relative space-y-6 sm:space-y-8">
|
||||
{
|
||||
sortedPosts.map((post) => (
|
||||
<article class="hover-card group relative mx-auto flex max-w-2xl flex-col rounded-2xl border border-zinc-200 p-5 transition-all duration-300 hover:bg-zinc-50/80 hover:shadow-md sm:mx-0 sm:p-8 dark:border-zinc-800 dark:hover:bg-zinc-900/50">
|
||||
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br from-zinc-50/0 to-zinc-100/0 opacity-0 transition-opacity duration-500 group-hover:opacity-100 dark:from-zinc-900/0 dark:to-zinc-800/0" />
|
||||
|
||||
<div class="flex flex-col gap-5 sm:flex-row sm:gap-6">
|
||||
{post.image && (
|
||||
<div class="mx-auto h-40 w-full shrink-0 overflow-hidden rounded-xl shadow-xs transition-all duration-300 group-hover:shadow-md sm:mx-0 sm:w-56">
|
||||
<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 transition-transform duration-500 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex-1">
|
||||
<div class="mb-2 flex flex-wrap items-center justify-center gap-3 text-xs text-zinc-500 sm:mb-3 sm:justify-start sm:gap-4 sm:text-sm dark:text-zinc-400">
|
||||
{post.published_date && (
|
||||
<time
|
||||
datetime={post.published_date.toLocaleString()}
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-3.5 w-3.5 sm:h-4 sm:w-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0
|
||||
A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
<FormattedDate date={post.published_date} />
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 class="mb-2 text-center text-xl font-semibold text-zinc-900 transition-colors group-hover:text-zinc-700 sm:mb-3 sm:text-left sm:text-2xl dark:text-zinc-100 dark:group-hover:text-zinc-300">
|
||||
<a href={`/blog/${post.slug}/`} class="before:absolute before:inset-0">
|
||||
{post.title}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<p class="mb-4 line-clamp-2 text-center text-sm text-zinc-600 sm:line-clamp-3 sm:text-left sm:text-base dark:text-zinc-400">
|
||||
{post.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-end justify-center border-t border-zinc-100 pt-4 sm:justify-between dark:border-zinc-800">
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div class="mb-3 flex flex-wrap justify-center gap-2 sm:mb-0 sm:justify-start">
|
||||
{post.tags.slice(0, 3).map((postTag) => (
|
||||
<a
|
||||
href={`/topics/${postTag}`}
|
||||
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors ${
|
||||
postTag === tag
|
||||
? 'bg-zinc-900/10 text-zinc-900 dark:bg-zinc-100/20 dark:text-zinc-100'
|
||||
: 'bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
#{postTag}
|
||||
</a>
|
||||
))}
|
||||
{post.tags.length > 3 && (
|
||||
<span class="inline-flex items-center rounded-full bg-zinc-50 px-2 py-0.5 text-xs text-zinc-500 dark:bg-zinc-800/50 dark:text-zinc-400">
|
||||
+{post.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="mx-auto sm:mr-0 sm:ml-auto">
|
||||
<a
|
||||
href={`/blog/${post.slug}/`}
|
||||
class="inline-flex 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"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
>
|
||||
<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 top-0 left-0 translate-y-full whitespace-nowrap transition-transform duration-300 group-hover:translate-y-0">
|
||||
Explore now
|
||||
</span>
|
||||
</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>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state với màu zinc -->
|
||||
{
|
||||
sortedPosts.length === 0 && (
|
||||
<div class="py-12 text-center sm:py-20">
|
||||
<div class="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100 sm:mb-6 sm:h-20 sm:w-20 dark:bg-zinc-800">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-8 w-8 text-zinc-500 sm:h-10 sm:w-10 dark:text-zinc-400"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mb-2 text-xl font-semibold text-zinc-900 sm:text-2xl dark:text-zinc-100">
|
||||
No posts found
|
||||
</h2>
|
||||
<p class="text-zinc-600 dark:text-zinc-400">There are no posts with this tag yet.</p>
|
||||
<a
|
||||
href="/blog"
|
||||
class="mt-6 inline-flex items-center gap-2 rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-800 transition-all duration-300 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 12h-15m0 0l6.75 6.75M4.5 12l6.75-6.75"
|
||||
/>
|
||||
</svg>
|
||||
<span>Browse all articles</span>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
/* Grid pattern background */
|
||||
.bg-grid-pattern {
|
||||
background-size: 30px 30px;
|
||||
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
:global(.dark) .bg-grid-pattern {
|
||||
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Animated underline */
|
||||
@keyframes expand {
|
||||
from {
|
||||
width: 0;
|
||||
}
|
||||
to {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-expand {
|
||||
animation: expand 1s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Blob animation */
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
@keyframes blob {
|
||||
0% {
|
||||
transform: translate(0px, 0px) scale(1);
|
||||
}
|
||||
33% {
|
||||
transform: translate(20px, -20px) scale(1.1);
|
||||
}
|
||||
66% {
|
||||
transform: translate(-20px, 20px) scale(0.9);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0px, 0px) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Hover card effect */
|
||||
.hover-card {
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
box-shadow 0.3s ease,
|
||||
background-color 0.3s ease;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.hover-card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 640px) {
|
||||
.animate-blob {
|
||||
animation-duration: 10s;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Handle SPA transitions for tag pages
|
||||
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 tag page
|
||||
function animateTagContent() {
|
||||
// Animate header elements
|
||||
const headerElements = document.querySelectorAll('h1, .tag-icon, .tag-description');
|
||||
headerElements.forEach((el, index) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
el.classList.add('animate-reveal');
|
||||
},
|
||||
100 + index * 150
|
||||
);
|
||||
});
|
||||
|
||||
// Animate posts with staggered delay
|
||||
const articles = document.querySelectorAll('article');
|
||||
articles.forEach((article, index) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
article.classList.add('animate-reveal');
|
||||
},
|
||||
400 + index * 100
|
||||
);
|
||||
});
|
||||
|
||||
// Animate related tags
|
||||
const relatedTags = document.querySelectorAll('.related-tags a');
|
||||
relatedTags.forEach((tag, index) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
tag.classList.add('animate-reveal');
|
||||
},
|
||||
600 + index * 50
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Run animations
|
||||
animateTagContent();
|
||||
}
|
||||
|
||||
// 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>
|
||||
|
||||
<!-- Add this at the end of your page -->
|
714
src/pages/topics/index.astro
Normal file
@@ -0,0 +1,714 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
|
||||
import directus from '../../../lib/directus';
|
||||
import { readItems } from '@directus/sdk';
|
||||
|
||||
const posts = await directus.request(
|
||||
readItems('posts', {
|
||||
fields: ['*'],
|
||||
sort: ['-published_date'],
|
||||
})
|
||||
);
|
||||
|
||||
const tags = [...new Set(posts.flatMap((post) => post.tags || []))].sort();
|
||||
|
||||
// Count posts for each tag and create tag objects with additional data
|
||||
const tagObjects = tags.map((tag) => {
|
||||
const count = posts.filter((post) => post.tags?.includes(tag)).length;
|
||||
// Generate a consistent but random-looking hue for each tag
|
||||
const hue = Math.abs(tag.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 360);
|
||||
return {
|
||||
name: tag,
|
||||
count,
|
||||
size: Math.max(1, Math.min(3, Math.floor(count / 2) + 1)), // Size 1-3 based on count
|
||||
hue,
|
||||
};
|
||||
});
|
||||
|
||||
const sortedTags = [...tagObjects].sort((a, b) => b.count - a.count);
|
||||
---
|
||||
|
||||
<BaseLayout title="Explore Tags">
|
||||
<div class="theme-transition-all mx-auto w-full px-3 py-6 sm:px-6 sm:py-12 md:py-16">
|
||||
<!-- Enhanced header section with animated elements - improved for mobile -->
|
||||
<div class="theme-transition-element relative mb-8 text-center sm:mb-12 md:mb-16">
|
||||
<div
|
||||
class="animate-blob theme-transition-bg absolute -top-16 -left-16 h-36 w-36 rounded-full bg-zinc-100 opacity-50 blur-3xl sm:h-48 sm:w-48 md:h-72 md:w-72 dark:bg-zinc-800/50"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-2000 theme-transition-bg absolute -right-16 -bottom-16 h-36 w-36 rounded-full bg-zinc-200 opacity-30 blur-3xl sm:h-48 sm:w-48 md:h-72 md:w-72 dark:bg-zinc-800/30"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-4000 theme-transition-bg absolute top-8 right-8 h-24 w-24 rounded-full bg-zinc-100/30 opacity-40 blur-2xl sm:h-32 sm:w-32 md:h-40 md:w-40 dark:bg-zinc-700/20"
|
||||
>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
class="theme-transition-color relative mb-3 text-3xl font-bold tracking-tight text-zinc-900 sm:mb-4 sm:text-4xl md:mb-6 md:text-5xl lg:text-6xl dark:text-zinc-100"
|
||||
>
|
||||
<span class="relative inline-block">
|
||||
<span class="relative inline-block">
|
||||
<span
|
||||
class="theme-transition-bg absolute -inset-1 rounded-lg bg-gradient-to-r from-zinc-200/50 to-zinc-300/50 blur-xs dark:from-zinc-800/50 dark:to-zinc-700/50"
|
||||
></span>
|
||||
<span class="relative">Explore</span>
|
||||
</span>
|
||||
{' '}
|
||||
<span class="relative inline-block">
|
||||
Topics
|
||||
<span
|
||||
class="animate-underline theme-transition-bg absolute -bottom-1 left-0 h-0.5 w-full origin-left transform bg-gradient-to-r from-zinc-400 to-zinc-600 sm:-bottom-2 sm:h-1 dark:from-zinc-600 dark:to-zinc-400"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
<p
|
||||
class="theme-transition-color relative mx-auto max-w-2xl text-sm text-zinc-600 sm:text-base md:text-lg lg:text-xl dark:text-zinc-400"
|
||||
>
|
||||
Discover content organized by your interests
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{
|
||||
tags.length === 0 ? (
|
||||
<div class="theme-transition-element py-8 text-center sm:py-12 md:py-16">
|
||||
<div class="theme-transition-bg mb-3 inline-flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100 shadow-inner sm:mb-4 sm:h-20 sm:w-20 md:mb-6 md:h-24 md:w-24 dark:bg-zinc-800">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="theme-transition-color h-8 w-8 text-zinc-500 sm:h-10 sm:w-10 md:h-12 md:w-12 dark:text-zinc-400"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p class="theme-transition-color text-lg font-medium text-zinc-800 sm:text-xl md:text-2xl dark:text-zinc-200">
|
||||
No tags found yet.
|
||||
</p>
|
||||
<p class="theme-transition-color mt-2 text-xs text-zinc-500 sm:text-sm md:text-base dark:text-zinc-500">
|
||||
Check back later for categorized content.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div class="flex w-full justify-center">
|
||||
<div class="tag-cloud hover-3d glass theme-transition-all relative w-full rounded-lg border border-zinc-100 bg-white/50 p-3 backdrop-blur-xs sm:rounded-xl sm:p-4 md:rounded-2xl md:p-6 lg:rounded-3xl lg:p-8 dark:border-zinc-800 dark:bg-zinc-900/50">
|
||||
<div class="bg-grid-pattern theme-transition-bg absolute inset-0 opacity-5 dark:opacity-10" />
|
||||
<div class="theme-transition-bg absolute -top-8 -right-8 h-20 w-20 rounded-full bg-gradient-to-br from-zinc-200/30 to-zinc-300/20 blur-xl sm:h-24 sm:w-24 md:h-32 md:w-32 lg:h-40 lg:w-40 dark:from-zinc-700/20 dark:to-zinc-800/10" />
|
||||
<div class="theme-transition-bg absolute -bottom-8 -left-8 h-20 w-20 rounded-full bg-gradient-to-tl from-zinc-200/30 to-zinc-300/20 blur-xl sm:h-24 sm:w-24 md:h-32 md:w-32 lg:h-40 lg:w-40 dark:from-zinc-700/20 dark:to-zinc-800/10" />
|
||||
|
||||
<h2 class="theme-transition-color mb-3 text-center text-lg font-bold text-zinc-900 sm:mb-4 sm:text-xl md:mb-6 md:text-2xl lg:mb-8 lg:text-3xl dark:text-zinc-100">
|
||||
Popular Topics
|
||||
</h2>
|
||||
|
||||
<div class="xxxs:grid-cols-2 xxs:grid-cols-2 xs:grid-cols-3 xxxs:gap-2 xxs:gap-2 xs:gap-2 grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 sm:gap-3 md:grid-cols-4 md:gap-4 lg:grid-cols-5">
|
||||
{sortedTags.map((tag) => (
|
||||
<a
|
||||
href={`/topics/${tag.name}`}
|
||||
class="theme-transition-element theme-ripple group relative min-w-0 grow overflow-hidden rounded-md border border-zinc-200 transition-all duration-300 hover:scale-[1.03] hover:border-zinc-300 hover:shadow-md active:scale-95 sm:rounded-lg sm:hover:shadow-lg md:rounded-xl dark:border-zinc-800 dark:hover:border-zinc-700"
|
||||
style={`--tag-hue: ${tag.hue};`}
|
||||
>
|
||||
<div class="theme-transition-bg absolute inset-0 bg-gradient-to-br from-zinc-50/90 to-zinc-100/90 opacity-100 transition-opacity group-hover:opacity-95 dark:from-zinc-800/90 dark:to-zinc-900/90" />
|
||||
|
||||
<div class="xxxs:px-2 xxs:px-2 xs:px-2 xxxs:py-2 xxs:py-2 xs:py-2 xxs:gap-2 relative flex w-full items-center gap-1.5 px-1.5 py-1.5 sm:px-3 sm:py-3 md:px-4 md:py-4">
|
||||
<div class="xxxs:w-6 xxxs:h-6 xxs:w-6 xxs:h-6 xs:w-7 xs:h-7 group-hover:bg-accent/20 dark:group-hover:bg-accent/20 group-hover:text-accent-dark dark:group-hover:text-accent-light theme-transition-all flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-zinc-700 shadow-xs transition-all duration-300 sm:h-8 sm:w-8 md:h-10 md:w-10 dark:bg-zinc-800 dark:text-zinc-300">
|
||||
<span class="xxxs:text-xs xxs:text-xs xs:text-sm text-xs font-semibold sm:text-base md:text-lg">
|
||||
#
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1 overflow-hidden">
|
||||
<h3 class="xxxs:text-xs xxs:text-xs xs:text-xs theme-transition-color truncate text-[10px] font-bold break-words hyphens-auto text-zinc-900 transition-colors group-hover:text-zinc-700 sm:text-sm md:text-base dark:text-zinc-100 dark:group-hover:text-zinc-300">
|
||||
{tag.name}
|
||||
</h3>
|
||||
<p class="xxxs:text-[9px] xxs:text-[9px] xs:text-[10px] theme-transition-color truncate text-[8px] text-zinc-500 sm:text-xs md:text-xs dark:text-zinc-400">
|
||||
{tag.count} article{tag.count !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<script>
|
||||
// Ultra-reliable responsiveness handling
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Fix viewport width issues on mobile
|
||||
const fixViewportWidth = () => {
|
||||
// Force the viewport to be exactly the width of the device
|
||||
const viewport = document.querySelector('meta[name="viewport"]');
|
||||
if (!viewport) {
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'viewport';
|
||||
meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no';
|
||||
document.getElementsByTagName('head')[0].appendChild(meta);
|
||||
} else {
|
||||
viewport.setAttribute(
|
||||
'content',
|
||||
'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'
|
||||
);
|
||||
}
|
||||
|
||||
// Fix for horizontal overflow
|
||||
document.body.style.overflowX = 'hidden';
|
||||
document.documentElement.style.overflowX = 'hidden';
|
||||
document.documentElement.style.width = '100%';
|
||||
document.body.style.width = '100%';
|
||||
};
|
||||
|
||||
fixViewportWidth();
|
||||
|
||||
// Adjust tag items based on screen size with extreme precision
|
||||
const adjustTagItems = () => {
|
||||
const tagItems = document.querySelectorAll('.theme-ripple');
|
||||
const width =
|
||||
window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
|
||||
const isVerySmall = width < 360;
|
||||
const isExtremelySmall = width < 280;
|
||||
const isMicroScreen = width < 240;
|
||||
|
||||
// Fix container width to match viewport exactly
|
||||
const container = document.querySelector('.tag-cloud');
|
||||
if (container) {
|
||||
container.style.maxWidth = '100vw';
|
||||
container.style.width = '100%';
|
||||
container.style.boxSizing = 'border-box';
|
||||
|
||||
// Remove any margins that might cause overflow
|
||||
container.style.marginLeft = '0';
|
||||
container.style.marginRight = '0';
|
||||
}
|
||||
|
||||
// Fix grid width
|
||||
const grid = document.querySelector('.grid');
|
||||
if (grid) {
|
||||
grid.style.width = '100%';
|
||||
grid.style.maxWidth = '100%';
|
||||
}
|
||||
|
||||
tagItems.forEach((item) => {
|
||||
// Set appropriate classes based on screen size
|
||||
if (isMicroScreen) {
|
||||
item.classList.add('micro-screen');
|
||||
item.classList.remove('extremely-small-screen', 'very-small-screen');
|
||||
} else if (isExtremelySmall) {
|
||||
item.classList.add('extremely-small-screen');
|
||||
item.classList.remove('very-small-screen', 'micro-screen');
|
||||
} else if (isVerySmall) {
|
||||
item.classList.add('very-small-screen');
|
||||
item.classList.remove('extremely-small-screen', 'micro-screen');
|
||||
} else {
|
||||
item.classList.remove('very-small-screen', 'extremely-small-screen', 'micro-screen');
|
||||
}
|
||||
|
||||
// Ensure text doesn't overflow on small screens
|
||||
const tagName = item.querySelector('h3');
|
||||
const tagCount = item.querySelector('p');
|
||||
|
||||
if (tagName) {
|
||||
// Set title for accessibility
|
||||
tagName.title = tagName.textContent.trim();
|
||||
|
||||
// Adjust text length based on screen size
|
||||
if (isMicroScreen && tagName.textContent.length > 6) {
|
||||
tagName.dataset.fullText = tagName.textContent;
|
||||
tagName.textContent = tagName.textContent.substring(0, 6) + '...';
|
||||
} else if (isExtremelySmall && tagName.textContent.length > 8) {
|
||||
tagName.dataset.fullText = tagName.textContent;
|
||||
tagName.textContent = tagName.textContent.substring(0, 8) + '...';
|
||||
} else if (isVerySmall && tagName.textContent.length > 12) {
|
||||
tagName.dataset.fullText = tagName.textContent;
|
||||
tagName.textContent = tagName.textContent.substring(0, 12) + '...';
|
||||
} else if (tagName.dataset.fullText) {
|
||||
tagName.textContent = tagName.dataset.fullText;
|
||||
delete tagName.dataset.fullText;
|
||||
}
|
||||
}
|
||||
|
||||
// Set the tag hue for hover effects
|
||||
const hue = item.style.getPropertyValue('--tag-hue');
|
||||
item.style.setProperty('--hover-hue', hue);
|
||||
});
|
||||
};
|
||||
|
||||
// Run on load
|
||||
adjustTagItems();
|
||||
|
||||
// Run on resize with optimized debounce
|
||||
let resizeTimer;
|
||||
const handleResize = () => {
|
||||
if (resizeTimer) {
|
||||
window.cancelAnimationFrame(resizeTimer);
|
||||
}
|
||||
|
||||
resizeTimer = window.requestAnimationFrame(() => {
|
||||
fixViewportWidth();
|
||||
adjustTagItems();
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('orientationchange', handleResize);
|
||||
|
||||
// Ensure layout is recalculated after page is fully loaded
|
||||
window.addEventListener('load', () => {
|
||||
fixViewportWidth();
|
||||
adjustTagItems();
|
||||
// Force recalculation after images and fonts are loaded
|
||||
setTimeout(() => {
|
||||
fixViewportWidth();
|
||||
adjustTagItems();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Fix for iOS Safari and other mobile browsers
|
||||
if (/iPhone|iPad|iPod|Android/.test(navigator.userAgent)) {
|
||||
document.documentElement.style.setProperty(
|
||||
'--safe-area-inset-bottom',
|
||||
'env(safe-area-inset-bottom)'
|
||||
);
|
||||
|
||||
// Fix for mobile viewport height issues
|
||||
const setVh = () => {
|
||||
const vh = window.innerHeight * 0.01;
|
||||
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||
};
|
||||
|
||||
setVh();
|
||||
window.addEventListener('resize', setVh);
|
||||
window.addEventListener('orientationchange', () => {
|
||||
// Wait for orientation change to complete
|
||||
setTimeout(() => {
|
||||
setVh();
|
||||
fixViewportWidth();
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Add touch support for mobile devices
|
||||
const addTouchSupport = () => {
|
||||
const tagItems = document.querySelectorAll('.theme-ripple');
|
||||
|
||||
tagItems.forEach((item) => {
|
||||
item.addEventListener(
|
||||
'touchstart',
|
||||
() => {
|
||||
item.classList.add('touch-active');
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
item.addEventListener(
|
||||
'touchend',
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
item.classList.remove('touch-active');
|
||||
}, 150);
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
|
||||
// Cancel active state if touch moves away
|
||||
item.addEventListener(
|
||||
'touchmove',
|
||||
(e) => {
|
||||
const touch = e.touches[0];
|
||||
const rect = item.getBoundingClientRect();
|
||||
|
||||
if (
|
||||
touch.clientX < rect.left ||
|
||||
touch.clientX > rect.right ||
|
||||
touch.clientY < rect.top ||
|
||||
touch.clientY > rect.bottom
|
||||
) {
|
||||
item.classList.remove('touch-active');
|
||||
}
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
addTouchSupport();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Base styles */
|
||||
.tag-cloud {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.03),
|
||||
0 2px 4px rgba(0, 0, 0, 0.03),
|
||||
0 4px 8px rgba(0, 0, 0, 0.05);
|
||||
transform-style: preserve-3d;
|
||||
perspective: 1000px;
|
||||
transition: all var(--theme-transition);
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box;
|
||||
margin-left: 0 !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
/* Fix for horizontal overflow */
|
||||
:global(html),
|
||||
:global(body) {
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
:global(.max-w-6xl) {
|
||||
max-width: 100% !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Ultra-responsive breakpoints for extreme reliability */
|
||||
/* Micro screens (below 240px) */
|
||||
@media (max-width: 239px) {
|
||||
.tag-cloud {
|
||||
padding: 0.5rem !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 0.25rem !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.tag-cloud h2 {
|
||||
font-size: 0.875rem !important;
|
||||
margin-bottom: 0.375rem !important;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr)) !important;
|
||||
gap: 0.375rem !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.micro-screen .flex {
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
|
||||
.micro-screen h3 {
|
||||
font-size: 0.625rem !important;
|
||||
}
|
||||
|
||||
.micro-screen p {
|
||||
font-size: 0.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra extra extra small screens (240px-279px) */
|
||||
@media (min-width: 240px) and (max-width: 279px) {
|
||||
.xxxs\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.xxxs\:px-2 {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.xxxs\:py-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.xxxs\:w-6 {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.xxxs\:h-6 {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.xxxs\:text-xs {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.xxxs\:gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.xxxs\:text-\[9px\] {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra extra small screens (280px-359px) */
|
||||
@media (min-width: 280px) {
|
||||
.xxs\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.xxs\:px-2 {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.xxs\:py-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.xxs\:w-6 {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
.xxs\:h-6 {
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
.xxs\:text-xs {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.xxs\:gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.xxs\:text-\[9px\] {
|
||||
font-size: 9px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small screens (360px-639px) */
|
||||
@media (min-width: 360px) {
|
||||
.xs\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.xs\:px-2 {
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.xs\:py-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.xs\:w-7 {
|
||||
width: 1.75rem;
|
||||
}
|
||||
|
||||
.xs\:h-7 {
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.xs\:text-xs {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.xs\:text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.xs\:gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.xs\:text-\[10px\] {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure text doesn't overflow on small screens */
|
||||
.truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Ensure proper word breaking for long tag names */
|
||||
.break-words {
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.hyphens-auto {
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
/* Improved shadow for dark mode */
|
||||
:global(.dark) .tag-cloud {
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05),
|
||||
0 2px 4px rgba(0, 0, 0, 0.1),
|
||||
0 4px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Prevent layout shifts */
|
||||
.grow {
|
||||
grow: 1;
|
||||
}
|
||||
|
||||
.min-w-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Ensure container doesn't overflow */
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Touch support for mobile */
|
||||
.touch-active {
|
||||
transform: scale(0.97) !important;
|
||||
opacity: 0.9;
|
||||
transition:
|
||||
transform 0.15s ease-in-out,
|
||||
opacity 0.15s ease-in-out !important;
|
||||
}
|
||||
|
||||
/* Animation for blob */
|
||||
@keyframes blob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: translate(10px, -10px) scale(1.05);
|
||||
}
|
||||
50% {
|
||||
transform: translate(0, 20px) scale(0.95);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-10px, -10px) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-blob {
|
||||
animation: blob 20s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
|
||||
/* Animation for underline */
|
||||
@keyframes underline {
|
||||
0% {
|
||||
transform: scaleX(0);
|
||||
}
|
||||
100% {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-underline {
|
||||
animation: underline 1.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Fix for iOS Safari notch */
|
||||
@supports (padding: max(0px)) {
|
||||
.tag-cloud {
|
||||
padding-left: max(0.75rem, env(safe-area-inset-left));
|
||||
padding-right: max(0.75rem, env(safe-area-inset-right));
|
||||
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Handle SPA transitions for tags index 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add hover effect for tag cards on touch devices
|
||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
|
||||
if (isTouchDevice) {
|
||||
const tagCards = document.querySelectorAll('.tag-cloud a');
|
||||
|
||||
tagCards.forEach((card) => {
|
||||
card.addEventListener('touchstart', () => {
|
||||
card.classList.add('is-touched');
|
||||
});
|
||||
|
||||
card.addEventListener('touchend', () => {
|
||||
setTimeout(() => {
|
||||
card.classList.remove('is-touched');
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Animate tag cards with staggered delay
|
||||
const tagCards = document.querySelectorAll('.tag-cloud a');
|
||||
tagCards.forEach((card, index) => {
|
||||
setTimeout(
|
||||
() => {
|
||||
card.classList.add('animate-reveal');
|
||||
},
|
||||
100 + index * 50
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 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>
|
@@ -1,246 +1,151 @@
|
||||
:root {
|
||||
--gray-0: #090b11;
|
||||
--gray-50: #141925;
|
||||
--gray-100: #283044;
|
||||
--gray-200: #3d4663;
|
||||
--gray-300: #505d84;
|
||||
--gray-400: #6474a2;
|
||||
--gray-500: #8490b5;
|
||||
--gray-600: #a3acc8;
|
||||
--gray-700: #c3cadb;
|
||||
--gray-800: #e3e6ee;
|
||||
--gray-900: #f3f4f7;
|
||||
--gray-999-basis: 0, 0%, 100%;
|
||||
--gray-999_40: hsla(var(--gray-999-basis), 0.4);
|
||||
--gray-999: #ffffff;
|
||||
/* Remove all the complex mobile menu styles and keep only what's necessary */
|
||||
@import 'tailwindcss';
|
||||
|
||||
--accent-light: #c561f6;
|
||||
--accent-regular: #7611a6;
|
||||
--accent-dark: #1c0056;
|
||||
--accent-overlay: hsla(280, 89%, 67%, 0.33);
|
||||
--accent-subtle-overlay: var(--accent-overlay);
|
||||
--accent-text-over: var(--gray-999);
|
||||
@layer base {
|
||||
:root {
|
||||
font-family: 'Inter', sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
--theme-transition: 0.3s ease;
|
||||
}
|
||||
|
||||
--link-color: var(--accent-regular);
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 5rem;
|
||||
}
|
||||
|
||||
--gradient-stop-1: var(--accent-light);
|
||||
--gradient-stop-2: var(--accent-regular);
|
||||
--gradient-stop-3: var(--accent-dark);
|
||||
--gradient-subtle: linear-gradient(150deg, var(--gray-900) 19%, var(--gray-999) 150%);
|
||||
--gradient-accent: linear-gradient(
|
||||
150deg,
|
||||
var(--gradient-stop-1),
|
||||
var(--gradient-stop-2),
|
||||
var(--gradient-stop-3)
|
||||
);
|
||||
--gradient-accent-orange: linear-gradient(
|
||||
150deg,
|
||||
#ca7879,
|
||||
var(--accent-regular),
|
||||
var(--accent-dark)
|
||||
);
|
||||
--gradient-stroke: linear-gradient(180deg, var(--gray-900), var(--gray-700));
|
||||
body {
|
||||
@apply min-h-screen bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
--shadow-sm: 0px 6px 3px rgba(9, 11, 17, 0.01), 0px 4px 2px rgba(9, 11, 17, 0.01),
|
||||
0px 2px 2px rgba(9, 11, 17, 0.02), 0px 0px 1px rgba(9, 11, 17, 0.03);
|
||||
--shadow-md: 0px 28px 11px rgba(9, 11, 17, 0.01), 0px 16px 10px rgba(9, 11, 17, 0.03),
|
||||
0px 7px 7px rgba(9, 11, 17, 0.05), 0px 2px 4px rgba(9, 11, 17, 0.06);
|
||||
--shadow-lg: 0px 62px 25px rgba(9, 11, 17, 0.01), 0px 35px 21px rgba(9, 11, 17, 0.05),
|
||||
0px 16px 16px rgba(9, 11, 17, 0.1), 0px 4px 9px rgba(9, 11, 17, 0.12);
|
||||
|
||||
--text-sm: 0.875rem;
|
||||
--text-base: 1rem;
|
||||
--text-md: 1.125rem;
|
||||
--text-lg: 1.25rem;
|
||||
--text-xl: 1.625rem;
|
||||
--text-2xl: 2.125rem;
|
||||
--text-3xl: 2.625rem;
|
||||
--text-4xl: 3.5rem;
|
||||
--text-5xl: 4.5rem;
|
||||
|
||||
--font-system: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
|
||||
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
--font-body: 'Public Sans', var(--font-system);
|
||||
--font-brand: Rubik, var(--font-system);
|
||||
|
||||
--theme-transition: 0.2s ease-in-out;
|
||||
/* Simple theme transition */
|
||||
body,
|
||||
a,
|
||||
button {
|
||||
transition:
|
||||
background-color var(--theme-transition),
|
||||
color var(--theme-transition),
|
||||
border-color var(--theme-transition);
|
||||
}
|
||||
}
|
||||
|
||||
:root.theme-dark {
|
||||
--gray-0: #ffffff;
|
||||
--gray-50: #f3f4f7;
|
||||
--gray-100: #e3e6ee;
|
||||
--gray-200: #c3cadb;
|
||||
--gray-300: #a3acc8;
|
||||
--gray-400: #8490b5;
|
||||
--gray-500: #6474a2;
|
||||
--gray-600: #505d84;
|
||||
--gray-700: #3d4663;
|
||||
--gray-800: #283044;
|
||||
--gray-900: #141925;
|
||||
--gray-999-basis: 225, 31%, 5%;
|
||||
--gray-999: #090b11;
|
||||
/* Minimal responsive styles */
|
||||
@media (max-width: 640px) {
|
||||
html {
|
||||
scroll-padding-top: 4rem;
|
||||
}
|
||||
|
||||
--accent-light: #1c0056;
|
||||
--accent-regular: #7611a6;
|
||||
--accent-dark: #c561f6;
|
||||
--accent-overlay: hsla(280, 89%, 67%, 0.33);
|
||||
--accent-subtle-overlay: hsla(281, 81%, 36%, 0.33);
|
||||
--accent-text-over: var(--gray-0);
|
||||
|
||||
--link-color: var(--accent-dark);
|
||||
|
||||
--gradient-stop-1: #4c11c6;
|
||||
--gradient-subtle: linear-gradient(150deg, var(--gray-900) 19%, var(--gray-999) 81%);
|
||||
--gradient-accent-orange: linear-gradient(
|
||||
150deg,
|
||||
#ca7879,
|
||||
var(--accent-regular),
|
||||
var(--accent-light)
|
||||
);
|
||||
--gradient-stroke: linear-gradient(180deg, var(--gray-600), var(--gray-800));
|
||||
|
||||
--shadow-sm: 0px 6px 3px rgba(255, 255, 255, 0.01), 0px 4px 2px rgba(255, 255, 255, 0.01),
|
||||
0px 2px 2px rgba(255, 255, 255, 0.02), 0px 0px 1px rgba(255, 255, 255, 0.03);
|
||||
--shadow-md: 0px 28px 11px rgba(255, 255, 255, 0.01), 0px 16px 10px rgba(255, 255, 255, 0.03),
|
||||
0px 7px 7px rgba(255, 255, 255, 0.05), 0px 2px 4px rgba(255, 255, 255, 0.06);
|
||||
--shadow-lg: 0px 62px 25px rgba(255, 255, 255, 0.01), 0px 35px 21px rgba(255, 255, 255, 0.05),
|
||||
0px 16px 16px rgba(255, 255, 255, 0.1), 0px 4px 9px rgba(255, 255, 255, 0.12);
|
||||
/* Better touch targets on mobile */
|
||||
button,
|
||||
a {
|
||||
@apply min-h-[44px];
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
min-height: 100%;
|
||||
overflow-x: hidden;
|
||||
/* Add smooth animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--gray-999);
|
||||
color: var(--gray-200);
|
||||
font-family: var(--font-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
line-height: 1.5;
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::after,
|
||||
*::before {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-color);
|
||||
/* Apply animations to elements */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.6s ease forwards;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
line-height: 1.1;
|
||||
font-family: var(--font-brand);
|
||||
font-weight: 600;
|
||||
color: var(--gray-100);
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.6s ease forwards;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-5xl);
|
||||
.animate-slide-down {
|
||||
animation: slideDown 0.6s ease forwards;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: var(--text-4xl);
|
||||
.animate-scale-in {
|
||||
animation: scaleIn 0.6s ease forwards;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--text-3xl);
|
||||
/* Staggered animation delays */
|
||||
.delay-100 {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: var(--text-2xl);
|
||||
.delay-200 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: var(--text-xl);
|
||||
.delay-300 {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
.delay-400 {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
max-width: 83rem;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1.5rem;
|
||||
/* Smooth hover transitions */
|
||||
a,
|
||||
button {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
a.hover:hover,
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Smooth page transitions */
|
||||
.page-transition {
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
.gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
.gap-8 {
|
||||
gap: 2rem;
|
||||
}
|
||||
.gap-10 {
|
||||
gap: 2.5rem;
|
||||
}
|
||||
.gap-15 {
|
||||
gap: 3.75rem;
|
||||
}
|
||||
.gap-20 {
|
||||
gap: 5rem;
|
||||
}
|
||||
.gap-30 {
|
||||
gap: 7.5rem;
|
||||
}
|
||||
.gap-48 {
|
||||
gap: 12rem;
|
||||
.page-entering {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
@media (min-width: 50em) {
|
||||
.lg\:gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.lg\:gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
.lg\:gap-8 {
|
||||
gap: 2rem;
|
||||
}
|
||||
.lg\:gap-10 {
|
||||
gap: 2.5rem;
|
||||
}
|
||||
.lg\:gap-15 {
|
||||
gap: 3.75rem;
|
||||
}
|
||||
.lg\:gap-20 {
|
||||
gap: 5rem;
|
||||
}
|
||||
.lg\:gap-30 {
|
||||
gap: 7.5rem;
|
||||
}
|
||||
.lg\:gap-48 {
|
||||
gap: 12rem;
|
||||
}
|
||||
.page-entered {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
1
src/types/astro.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="astro/client" />
|
3
src/utils/debug.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function debugObject(obj: any): string {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}
|
58
tailwind.config.cjs
Normal file
@@ -0,0 +1,58 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}', '*.{js,ts,jsx,tsx,mdx}'],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
typography: (theme) => ({
|
||||
DEFAULT: {
|
||||
css: {
|
||||
a: {
|
||||
color: theme('colors.zinc.900'),
|
||||
'&:hover': {
|
||||
color: theme('colors.zinc.700'),
|
||||
},
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: theme('colors.zinc.400'),
|
||||
textUnderlineOffset: '2px',
|
||||
},
|
||||
'h1, h2, h3, h4, h5, h6': {
|
||||
color: theme('colors.zinc.900'),
|
||||
},
|
||||
code: {
|
||||
color: theme('colors.zinc.900'),
|
||||
backgroundColor: theme('colors.zinc.100'),
|
||||
borderRadius: theme('borderRadius.md'),
|
||||
padding: `${theme('padding.1')} ${theme('padding.1.5')}`,
|
||||
},
|
||||
'code::before': {
|
||||
content: '""',
|
||||
},
|
||||
'code::after': {
|
||||
content: '""',
|
||||
},
|
||||
},
|
||||
},
|
||||
invert: {
|
||||
css: {
|
||||
a: {
|
||||
color: theme('colors.zinc.100'),
|
||||
'&:hover': {
|
||||
color: theme('colors.zinc.300'),
|
||||
},
|
||||
textDecorationColor: theme('colors.zinc.700'),
|
||||
},
|
||||
'h1, h2, h3, h4, h5, h6': {
|
||||
color: theme('colors.zinc.100'),
|
||||
},
|
||||
code: {
|
||||
color: theme('colors.zinc.100'),
|
||||
backgroundColor: theme('colors.zinc.800'),
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
plugins: [require('@tailwindcss/typography')],
|
||||
};
|
@@ -1,5 +1,29 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"]
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"target": "ES6",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"baseUrl": ".",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|