Compare commits
37 Commits
Author | SHA1 | Date | |
---|---|---|---|
06f7546212 | |||
abd1d43f79 | |||
07f2f5f0e1 | |||
91b53a33c2 | |||
b3e23f3e6c | |||
ab68b6248f | |||
37d1f1d1f2 | |||
89e1c59e37 | |||
7153f29022 | |||
51041f6ae9 | |||
67f12ecf72 | |||
3e89e6cb1c | |||
e1632629a9 | |||
87343e78bb | |||
f243249fb8 | |||
6e5458de37 | |||
b7ea8165d2 | |||
ae4941073c | |||
de87ffeff2 | |||
369e97af41 | |||
754ff5d9a9 | |||
351cac00b3 | |||
11c85e324e | |||
5a418428d3 | |||
3d4c9c2214 | |||
10262c4b7a | |||
57ea8374a5 | |||
e9e1cabd11 | |||
4c1ec680a9 | |||
4f826e8964 | |||
3ddce86e64 | |||
61aa06310c | |||
03195017c5 | |||
fc3f4fdad4 | |||
fc42f31fb0 | |||
|
e56b3a001e | ||
|
13711618b7 |
67
.gitea/workflows/release-image-gitea.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
name: release-image-gitea
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 0.*
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create Kubeconfig
|
||||
run: |
|
||||
mkdir $HOME/.kube
|
||||
echo "${{ secrets.KUBECONFIG_BUILDX }}" > $HOME/.kube/config
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: kubernetes
|
||||
driver-opts: |
|
||||
namespace=gitea
|
||||
qemu.install=true
|
||||
|
||||
- name: Available Platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
- name: Login to Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.REPOSITORY_HOST }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.REPOSITORY_TOKEN }}
|
||||
|
||||
- name: Extract Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
images: ${{ vars.REPOSITORY_HOST }}/${{ gitea.repository }}
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
file: ./Dockerfile
|
||||
|
||||
- name: Actions Ntfy
|
||||
run: |
|
||||
curl \
|
||||
-H "Authorization: Bearer ${{ secrets.NTFY_CRED }}" \
|
||||
-H "Title: Site-Profile Image Released to Gitea: ${{ steps.meta.outputs.tags }}" \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d 'Repo: ${{ gitea.repository }}\nCommit: ${{ gitea.sha }}\nRef: ${{ gitea.ref }}\nStatus: ${{ job.status}}' \
|
||||
${{ secrets.NTFY_URL }}
|
67
.gitea/workflows/release-image-harbor.yml
Normal file
@@ -0,0 +1,67 @@
|
||||
name: release-image-harbor
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 0.*
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Create Kubeconfig
|
||||
run: |
|
||||
mkdir $HOME/.kube
|
||||
echo "${{ secrets.KUBECONFIG_BUILDX }}" > $HOME/.kube/config
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: kubernetes
|
||||
driver-opts: |
|
||||
namespace=gitea
|
||||
qemu.install=true
|
||||
|
||||
- name: Available Platforms
|
||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
||||
|
||||
- name: Login to Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ vars.REGISTRY_HOST }}
|
||||
username: ${{ vars.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_SECRET }}
|
||||
|
||||
- name: Extract Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
images: ${{ vars.REGISTRY_HOST }}/images/site-profile
|
||||
|
||||
- name: Build and Push Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
file: ./Dockerfile
|
||||
|
||||
- name: Actions Ntfy
|
||||
run: |
|
||||
curl \
|
||||
-H "Authorization: Bearer ${{ secrets.NTFY_CRED }}" \
|
||||
-H "Title: Site-Profile Image Released to Harbor: ${{ steps.meta.outputs.tags }}" \
|
||||
-H "Content-Type: text/plain" \
|
||||
-d 'Repo: ${{ gitea.repository }}\nCommit: ${{ gitea.sha }}\nRef: ${{ gitea.ref }}\nStatus: ${{ job.status}}' \
|
||||
${{ secrets.NTFY_URL }}
|
30
.gitea/workflows/renovate.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
name: renovate
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '@daily'
|
||||
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: renovate
|
||||
env:
|
||||
RENOVATE_PLATFORM: gitea
|
||||
RENOVATE_AUTODISCOVER: true
|
||||
RENOVATE_ONBOARDING: true
|
||||
RENOVATE_ENDPOINT: http://gitea-http.gitea:3000
|
||||
RENOVATE_GIT_AUTHOR: Renovate Bot <renovate-bot@alexlebens.net>
|
||||
LOG_LEVEL: debug
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
RENOVATE_GIT_PRIVATE_KEY: ${{ secrets.RENOVATE_GIT_PRIVATE_KEY }}
|
||||
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}
|
||||
RENOVATE_REDIS_URL: redis://gitea-renovate-valkey-primary.gitea:6379
|
@@ -1,2 +0,0 @@
|
||||
# This file is processed by Renovate bot so that it creates a PR on new major Renovate versions
|
||||
FROM renovate/renovate:39
|
44
.github/renovate.json
vendored
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
"mergeConfidence:all-badges",
|
||||
":rebaseStalePrs"
|
||||
],
|
||||
"timezone": "US/Central",
|
||||
"schedule": [
|
||||
"every weekday"
|
||||
],
|
||||
"labels": [],
|
||||
"prHourlyLimit": 0,
|
||||
"prConcurrentLimit": 0,
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Disables for non major Renovate version",
|
||||
"matchFileNames": [
|
||||
".github/renovate-update-notification/Dockerfile"
|
||||
],
|
||||
"matchUpdateTypes": [
|
||||
"minor",
|
||||
"patch",
|
||||
"pin",
|
||||
"digest",
|
||||
"rollback"
|
||||
],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"description": "Generate for major Renovate version",
|
||||
"matchFileNames": [
|
||||
".github/renovate-update-notification/Dockerfile"
|
||||
],
|
||||
"matchUpdateTypes": [
|
||||
"major"
|
||||
],
|
||||
"addLabels": [
|
||||
"upgrade"
|
||||
],
|
||||
"automerge": false
|
||||
}
|
||||
]
|
||||
}
|
45
.github/workflows/release-image.yml
vendored
@@ -1,45 +0,0 @@
|
||||
name: release-image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 0.*
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
release-image:
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log into the container registry
|
||||
uses: docker/login-action@327cd5a69de6c009b9ce71bce8395f28e651bf99
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@8e1d5461f02b7886d3c1a774bfbd873650445aa2
|
||||
with:
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@31ca4e5d51253d7e4a2317bfe74699cbe3a398a9
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
file: ./Dockerfile
|
12
.gitignore
vendored
@@ -12,16 +12,16 @@ 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/
|
||||
|
||||
# vscode workspace
|
||||
site-profile.code-workspace
|
||||
# ide
|
||||
.vscode/
|
||||
site-profile.code-workspace
|
||||
.pre-commit-config.yaml
|
||||
|
17
.prettierrc
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"useTabs": false,
|
||||
"plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.astro",
|
||||
"options": {
|
||||
"parser": "astro"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
15
Dockerfile
@@ -1,7 +1,7 @@
|
||||
FROM node:22.13.0-alpine3.20 AS base
|
||||
FROM node:22.16.0-alpine3.22 AS base
|
||||
|
||||
LABEL version="0.6.2"
|
||||
LABEL description="Astro based website to use as a profile"
|
||||
LABEL version="0.8.3"
|
||||
LABEL description="Astro based website to use as a personal site"
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
@@ -12,20 +12,23 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
FROM base AS prod-deps
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --save form-data
|
||||
|
||||
FROM prod-deps AS build-deps
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
||||
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --save form-data
|
||||
|
||||
FROM build-deps AS build
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
RUN npm prune --production
|
||||
|
||||
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
|
||||
EXPOSE $PORT
|
||||
CMD node ./dist/server/entry.mjs
|
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
# MIT License
|
||||
|
||||
Copyright (c) 2024 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
|
79
README.md
@@ -1 +1,78 @@
|
||||
# Profile
|
||||
# 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 16+ and pnpm/yarn
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://gitea.alexlebens.dev/alexlebens/site-profile
|
||||
|
||||
# Navigate to project directory
|
||||
cd astro-blog
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Create .env file from template
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env with your information
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Start development server
|
||||
pnpm run dev
|
||||
|
||||
# Open browser at http://localhost:4321
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
# Create production build
|
||||
pnpm run build
|
||||
|
||||
# Preview production build
|
||||
pnpm run preview
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/
|
||||
├── public/ # Static assets
|
||||
├── src/
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ ├── content/ # Blog content (Markdown/MDX)
|
||||
│ ├── layouts/ # Page layouts
|
||||
│ ├── pages/ # Pages and routes
|
||||
│ ├── styles/ # CSS and Tailwind
|
||||
│ └── utils/ # Utilities and helpers
|
||||
├── astro.config.mjs # Astro configuration
|
||||
├── tailwind.config.js # Tailwind configuration
|
||||
└── tsconfig.json # TypeScript configuration
|
||||
```
|
||||
|
@@ -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'
|
||||
})
|
||||
});
|
@@ -1,50 +1,58 @@
|
||||
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;
|
||||
};
|
||||
|
||||
type Skill = {
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
level: string;
|
||||
};
|
||||
|
||||
export type Post = {
|
||||
slug: string;
|
||||
title: string;
|
||||
content: string;
|
||||
image: string;
|
||||
published_date: string;
|
||||
tags: string[];
|
||||
image_alt: 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;
|
||||
|
38
package.json
@@ -1,19 +1,39 @@
|
||||
{
|
||||
"name": "site-profile",
|
||||
"type": "module",
|
||||
"version": "0.6.2",
|
||||
"version": "0.8.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"format": "prettier . --write",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/node": "^9.0.0",
|
||||
"@directus/sdk": "^18.0.0",
|
||||
"astro": "^5.1.4",
|
||||
"typescript": "^5.7.3"
|
||||
"@astrojs/mdx": "^4.3.0",
|
||||
"@astrojs/node": "^9.2.2",
|
||||
"@astrojs/react": "^4.3.0",
|
||||
"@astrojs/rss": "^4.0.12",
|
||||
"@astrojs/sitemap": "^3.4.1",
|
||||
"@directus/sdk": "^19.1.0",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@tailwindcss/vite": "^4.1.8",
|
||||
"astro": "^5.9.1",
|
||||
"form-data": "4.0.3",
|
||||
"framer-motion": "^12.16.0",
|
||||
"postcss-preset-env": "^10.2.1",
|
||||
"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",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-astro": "^0.14.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
5904
pnpm-lock.yaml
generated
14
postcss.config.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
autoprefixer: {},
|
||||
'postcss-preset-env': {
|
||||
features: {
|
||||
'nesting-rules': false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
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 |
@@ -1,9 +1,9 @@
|
||||
<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>
|
||||
<path fill="#000" 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>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 749 B After Width: | Height: | Size: 713 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
|
10
renovate.json
Normal file
@@ -0,0 +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": []
|
||||
}
|
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 bg-[center_top_-1px] [mask-image:radial-gradient(white,transparent_85%)]"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Ambient glow effects -->
|
||||
<div
|
||||
class="animate-glow theme-transition-bg absolute left-1/4 top-1/4 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-zinc-400/20 opacity-50 blur-3xl dark:bg-zinc-500/20"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-glow animation-delay-1000 theme-transition-bg absolute bottom-1/3 right-1/4 h-64 w-64 translate-x-1/2 translate-y-1/2 rounded-full bg-zinc-300/20 opacity-40 blur-3xl dark:bg-zinc-600/20"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Theme transition overlay -->
|
||||
<div
|
||||
id="theme-transition-overlay"
|
||||
class="pointer-events-none absolute inset-0 bg-white opacity-0 dark:bg-zinc-900"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Theme transition script
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const themeToggle = document.querySelector('[data-theme-toggle]');
|
||||
const overlay = document.getElementById('theme-transition-overlay');
|
||||
|
||||
if (themeToggle && overlay) {
|
||||
themeToggle.addEventListener('click', () => {
|
||||
// Add transitioning class to optimize performance
|
||||
document.documentElement.classList.add('theme-transitioning');
|
||||
|
||||
// Fade in overlay
|
||||
overlay.style.opacity = '0.15';
|
||||
overlay.style.transition = 'opacity 0.3s ease';
|
||||
|
||||
setTimeout(() => {
|
||||
// Fade out overlay
|
||||
overlay.style.opacity = '0';
|
||||
|
||||
// Remove transitioning class after animation completes
|
||||
setTimeout(() => {
|
||||
document.documentElement.classList.remove('theme-transitioning');
|
||||
}, 700);
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Grid pattern for dots */
|
||||
.bg-grid-pattern {
|
||||
background-size: 24px 24px;
|
||||
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.15) 1px, transparent 1px);
|
||||
transition: background-image 0.7s cubic-bezier(0.65, 0, 0.35, 1);
|
||||
}
|
||||
|
||||
/* Dark mode version */
|
||||
:global(.dark) .bg-grid-pattern {
|
||||
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
|
||||
}
|
||||
|
||||
/* Ambient glow animations */
|
||||
.animate-glow {
|
||||
animation: glow 12s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
transition:
|
||||
background-color 0.7s cubic-bezier(0.65, 0, 0.35, 1),
|
||||
opacity 0.7s cubic-bezier(0.65, 0, 0.35, 1);
|
||||
}
|
||||
|
||||
.animation-delay-1000 {
|
||||
animation-delay: 1s;
|
||||
}
|
||||
|
||||
@keyframes glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.5;
|
||||
transform: translate(5%, 5%) scale(1.1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
transform: translate(0, 10%) scale(0.95);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.5;
|
||||
transform: translate(-5%, 5%) scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme transition overlay */
|
||||
#theme-transition-overlay {
|
||||
transition: opacity 0.3s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
@@ -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,244 @@
|
||||
---
|
||||
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: '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 -right-40 -top-40 h-80 w-80 rounded-full bg-zinc-100 opacity-50 blur-3xl dark:bg-zinc-800/30"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="theme-transition-all animate-float-slow animation-delay-2000 absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-zinc-100 opacity-50 blur-3xl dark:bg-zinc-800/30"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="theme-transition-all animate-float-slow animation-delay-1000 absolute left-1/4 top-20 h-40 w-40 rounded-full bg-zinc-200/50 opacity-30 blur-2xl dark:bg-zinc-700/20"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative px-4 pb-12 pt-16 sm:px-6">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<!-- Main footer content -->
|
||||
<div class="grid grid-cols-1 gap-10 md:grid-cols-12">
|
||||
<!-- Brand section -->
|
||||
<div class="col-span-1 md:col-span-3">
|
||||
<a href="/" class="group inline-block">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
class="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="group relative flex h-10 w-10 transform items-center justify-center rounded-full bg-zinc-100 text-zinc-500 transition-all duration-300 hover:-translate-y-1 hover:text-zinc-900 hover:ring-2 hover:ring-zinc-300 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100 dark:hover:ring-zinc-700"
|
||||
aria-label={social.name}
|
||||
>
|
||||
<span class="absolute inset-0 rounded-full bg-gradient-to-br from-zinc-200 to-zinc-300 opacity-0 transition-opacity duration-300 group-hover:opacity-100 dark:from-zinc-700 dark:to-zinc-600" />
|
||||
<svg
|
||||
class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Fragment set:html={social.icon} />
|
||||
</svg>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick links -->
|
||||
<div class="col-span-1 md:col-span-3">
|
||||
<h3
|
||||
class="theme-transition-color relative inline-block pb-2 text-sm font-semibold uppercase tracking-wider text-zinc-900 after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-8 after:bg-zinc-300 after:content-[''] dark:text-zinc-100 dark:after:bg-zinc-700"
|
||||
>
|
||||
Navigation
|
||||
</h3>
|
||||
<ul class="mt-4 space-y-3">
|
||||
{
|
||||
navLinks.map((link) => (
|
||||
<li>
|
||||
<a
|
||||
href={link.href}
|
||||
class="group flex items-center text-base text-zinc-600 transition-colors hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
||||
>
|
||||
<span class="relative inline-block overflow-hidden">
|
||||
<span class="relative z-10">{link.text}</span>
|
||||
<span class="absolute bottom-0 left-0 h-0.5 w-0 bg-zinc-800 transition-all duration-300 group-hover:w-full dark:bg-zinc-200" />
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Bottom section -->
|
||||
<div class="theme-transition-all mt-12 border-t border-zinc-200 pt-8 dark:border-zinc-800">
|
||||
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
||||
<p class="theme-transition-color text-sm text-zinc-600 dark:text-zinc-400">
|
||||
© {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 left-0 right-0 top-0 z-40 border-b border-zinc-100 bg-white py-4 dark:border-zinc-800 dark:bg-zinc-900"
|
||||
>
|
||||
<div class="mx-auto flex max-w-3xl items-center justify-between px-4">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="text-xl font-bold text-zinc-900 dark:text-white">{global.initals}</a>
|
||||
|
||||
<!-- Desktop navigation -->
|
||||
<nav class="hidden items-center space-x-6 sm:flex">
|
||||
{
|
||||
navItems.map((item) => {
|
||||
const isActive = currentPath === (item.href === '/' ? '' : item.href.slice(1));
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
class={`text-sm font-medium ${
|
||||
isActive
|
||||
? 'text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
<ThemeToggle />
|
||||
</nav>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button id="mobile-menu-button" class="flex items-center sm:hidden" aria-label="Menu">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-6 w-6 text-zinc-900 dark:text-white"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Mobile menu overlay -->
|
||||
<div
|
||||
id="mobile-menu"
|
||||
class="pointer-events-none fixed inset-0 z-50 flex flex-col bg-white opacity-0 transition-all duration-300 ease-in-out dark:bg-zinc-900"
|
||||
>
|
||||
<div class="flex items-center justify-between border-b border-zinc-100 p-4 dark:border-zinc-800">
|
||||
<a href="/" class="text-xl font-bold text-zinc-900 dark:text-white">JD</a>
|
||||
<button
|
||||
id="close-menu-button"
|
||||
class="rounded-md p-2 text-zinc-900 transition-colors hover:bg-zinc-100 dark:text-white dark:hover:bg-zinc-800"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-6 w-6"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-1 flex-col items-center justify-center space-y-6 text-center">
|
||||
{
|
||||
navItems.map((item, index) => {
|
||||
const isActive = currentPath === (item.href === '/' ? '' : item.href.slice(1));
|
||||
return (
|
||||
<a
|
||||
href={item.href}
|
||||
class={`mobile-nav-item translate-y-4 text-lg font-medium opacity-0 ${
|
||||
isActive
|
||||
? 'text-zinc-900 dark:text-white'
|
||||
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white'
|
||||
}`}
|
||||
style={`transition-delay: ${index * 0.05}s;`}
|
||||
>
|
||||
{item.text}
|
||||
</a>
|
||||
);
|
||||
})
|
||||
}
|
||||
<div class="mobile-nav-item translate-y-4 pt-4 opacity-0" style="transition-delay: 0.25s;">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Spacer to prevent content from hiding behind fixed header -->
|
||||
<div class="h-16"></div>
|
||||
|
||||
<script>
|
||||
// Mobile menu toggle with animations
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const closeMenuButton = document.getElementById('close-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const navItems = document.querySelectorAll('.mobile-nav-item');
|
||||
|
||||
// Open menu with animations
|
||||
mobileMenuButton?.addEventListener('click', () => {
|
||||
if (!mobileMenu) return;
|
||||
|
||||
// Prevent body scrolling
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Show menu with fade in
|
||||
mobileMenu.classList.remove('pointer-events-none');
|
||||
mobileMenu.classList.add('pointer-events-auto');
|
||||
|
||||
// Animate opacity
|
||||
setTimeout(() => {
|
||||
mobileMenu.style.opacity = '1';
|
||||
|
||||
// Animate each nav item with staggered delay
|
||||
navItems.forEach((item) => {
|
||||
setTimeout(() => {
|
||||
item.classList.remove('opacity-0', 'translate-y-4');
|
||||
}, 150);
|
||||
});
|
||||
}, 50);
|
||||
});
|
||||
|
||||
// Close menu with animations
|
||||
const closeMenu = () => {
|
||||
if (!mobileMenu) return;
|
||||
|
||||
// Fade out nav items first
|
||||
navItems.forEach((item) => {
|
||||
item.classList.add('opacity-0', 'translate-y-4');
|
||||
});
|
||||
|
||||
// Then fade out the menu
|
||||
setTimeout(() => {
|
||||
mobileMenu.style.opacity = '0';
|
||||
|
||||
// After animation completes, hide menu and restore scrolling
|
||||
setTimeout(() => {
|
||||
mobileMenu.classList.remove('pointer-events-auto');
|
||||
mobileMenu.classList.add('pointer-events-none');
|
||||
document.body.style.overflow = '';
|
||||
}, 300);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Close button event
|
||||
closeMenuButton?.addEventListener('click', closeMenu);
|
||||
|
||||
// Close menu when clicking a link
|
||||
const mobileLinks = mobileMenu?.querySelectorAll('a');
|
||||
mobileLinks?.forEach((link) => {
|
||||
link.addEventListener('click', closeMenu);
|
||||
});
|
||||
|
||||
// Close menu on escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && mobileMenu?.classList.contains('pointer-events-auto')) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// Add smooth animation to header on scroll
|
||||
const header = document.querySelector('header');
|
||||
let lastScrollY = window.scrollY;
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!header) return;
|
||||
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
// Add shadow on scroll
|
||||
if (currentScrollY > 10) {
|
||||
header.classList.add('shadow-sm');
|
||||
} else {
|
||||
header.classList.remove('shadow-sm');
|
||||
}
|
||||
|
||||
// Update last scroll position
|
||||
lastScrollY = currentScrollY;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Smooth animations for mobile navigation */
|
||||
.mobile-nav-item {
|
||||
transition:
|
||||
opacity 0.5s ease,
|
||||
transform 0.5s ease,
|
||||
color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Header transition */
|
||||
header {
|
||||
transition:
|
||||
box-shadow 0.3s ease,
|
||||
transform 0.3s ease,
|
||||
background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Mobile menu button hover effect */
|
||||
#mobile-menu-button {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
#mobile-menu-button:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* Mobile menu transition */
|
||||
#mobile-menu {
|
||||
transition: opacity 0.3s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
</style>
|
@@ -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="rounded-full p-2 text-zinc-500 transition-all duration-300 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||
aria-label="Share on Twitter"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
><path
|
||||
d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
<a
|
||||
href={`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rounded-full p-2 text-zinc-500 transition-all duration-300 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||
aria-label="Share on Facebook"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"></path></svg
|
||||
>
|
||||
</a>
|
||||
<a
|
||||
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodedUrl}&title=${encodedTitle}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="rounded-full p-2 text-zinc-500 transition-all duration-300 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||
aria-label="Share on LinkedIn"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"
|
||||
></path><rect x="2" y="9" width="4" height="12"></rect><circle cx="4" cy="4" r="2"
|
||||
></circle></svg
|
||||
>
|
||||
</a>
|
||||
<button
|
||||
id="copy-link-button"
|
||||
class="relative rounded-full p-2 text-zinc-500 transition-all duration-300 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
||||
aria-label="Copy link"
|
||||
title="Copy link to clipboard"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-4 w-4"
|
||||
><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path
|
||||
d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg
|
||||
>
|
||||
<span
|
||||
id="copy-tooltip"
|
||||
class="absolute -top-8 left-1/2 -translate-x-1/2 transform whitespace-nowrap rounded bg-zinc-800 px-2 py-1 text-xs text-white opacity-0 transition-opacity duration-300 dark:bg-zinc-700"
|
||||
>
|
||||
Copied!
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Function to handle copy link button
|
||||
function setupCopyLinkButton() {
|
||||
const copyButtons = document.querySelectorAll('#copy-link-button');
|
||||
|
||||
copyButtons.forEach((button) => {
|
||||
button.addEventListener('click', () => {
|
||||
// Get the current URL
|
||||
const url = window.location.href;
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard
|
||||
.writeText(url)
|
||||
.then(() => {
|
||||
// Show tooltip
|
||||
const tooltip = button.querySelector('#copy-tooltip');
|
||||
if (tooltip) {
|
||||
tooltip.classList.add('opacity-100');
|
||||
|
||||
// Hide tooltip after 2 seconds
|
||||
setTimeout(() => {
|
||||
tooltip.classList.remove('opacity-100');
|
||||
}, 2000);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to copy: ', err);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Set up the copy link button when the DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', setupCopyLinkButton);
|
||||
|
||||
// Also set up when the page content is updated via SPA navigation
|
||||
document.addEventListener('astro:page-load', setupCopyLinkButton);
|
||||
|
||||
// For compatibility with the custom page transition system
|
||||
document.addEventListener('page-transition-complete', setupCopyLinkButton);
|
||||
|
||||
// Handle SPA transitions for share links
|
||||
function setupSpaTransitions() {
|
||||
// Get all share links
|
||||
const shareLinks = document.querySelectorAll('a[target="_blank"][rel="noopener noreferrer"]');
|
||||
|
||||
// Make sure external share links don't trigger page transitions
|
||||
shareLinks.forEach((link) => {
|
||||
link.setAttribute('data-spa-external', 'true');
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize SPA transitions
|
||||
document.addEventListener('DOMContentLoaded', setupSpaTransitions);
|
||||
document.addEventListener('astro:page-load', setupSpaTransitions);
|
||||
document.addEventListener('page-transition-complete', setupSpaTransitions);
|
||||
|
||||
// Dispatch custom event when share action is completed
|
||||
function notifyShareComplete() {
|
||||
document.dispatchEvent(new CustomEvent('share-action-complete'));
|
||||
}
|
||||
|
||||
// Add analytics tracking for share actions if needed
|
||||
function trackShareAction(platform) {
|
||||
// You can implement analytics tracking here
|
||||
console.log(`Shared on ${platform}`);
|
||||
|
||||
// Notify other components that share action is complete
|
||||
notifyShareComplete();
|
||||
}
|
||||
</script>
|
@@ -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:outline-none focus:ring-2 focus:ring-zinc-300 dark:hover:bg-zinc-800 dark:focus:ring-zinc-700 sm:p-2"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<div class="relative z-10 flex h-5 w-5 items-center justify-center">
|
||||
<!-- Sun icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon-light absolute h-5 w-5 rotate-0 scale-100 text-zinc-800 transition-all duration-500 dark:-rotate-90 dark:scale-0 dark:text-zinc-200"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
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 rotate-90 scale-0 text-zinc-800 transition-all duration-500 dark:rotate-0 dark:scale-100 dark:text-zinc-200"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
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(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(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.description}>
|
||||
<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.description}>
|
||||
<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 mx-auto max-w-4xl dark:prose-invert lg:prose-lg">
|
||||
<div class="mb-12">
|
||||
<h1
|
||||
class="mb-4 text-4xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl"
|
||||
>
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
<div class="mb-6 flex items-center gap-x-4 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<FormattedDate date={published_date} />
|
||||
</div>
|
||||
|
||||
<TagList tags={post.tags} class="mt-2" />
|
||||
</div>
|
||||
|
||||
<!-- Hero image -->
|
||||
{
|
||||
post.image && (
|
||||
<div class="relative mb-8 overflow-hidden rounded-xl shadow-lg sm:mb-12">
|
||||
<div class="aspect-[16/9] w-full">
|
||||
<img
|
||||
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${post.image}?width=500`}
|
||||
alt={post.image_alt}
|
||||
class="h-full w-full object-cover"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="markdown-content">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Add the like button after the content -->
|
||||
<div class="mt-12 border-t border-zinc-200 pt-8 dark:border-zinc-800">
|
||||
<div class="flex flex-col items-center justify-between gap-6 sm:flex-row">
|
||||
<ShareButtons url={canonicalURL.toString()} title={post.title} />
|
||||
<!-- Convert URL to string -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
post.updated_date && (
|
||||
<div class="mt-8 text-sm italic text-zinc-500 dark:text-zinc-400">
|
||||
Last updated on <FormattedDate date={post.updated_date} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</article>
|
||||
|
||||
<slot name="after-article" />
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
// Blog post SPA transitions
|
||||
function setupBlogPostTransitions() {
|
||||
// Animate article entrance
|
||||
const article = document.querySelector('article');
|
||||
if (article) {
|
||||
article.classList.add('article-entering');
|
||||
|
||||
// Remove class after animation completes
|
||||
setTimeout(() => {
|
||||
article.classList.remove('article-entering');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Ensure consistent code block styling
|
||||
function updateCodeBlockStyles() {
|
||||
document.querySelectorAll('pre').forEach((pre) => {
|
||||
// Force the background color with !important for both light and dark mode
|
||||
pre.setAttribute('style', 'background-color: #1e293b !important');
|
||||
|
||||
// Also apply to any nested code elements
|
||||
const codeElements = pre.querySelectorAll('code');
|
||||
codeElements.forEach((code) => {
|
||||
code.setAttribute(
|
||||
'style',
|
||||
'background-color: transparent !important; color: #e5e7eb !important;'
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial application
|
||||
updateCodeBlockStyles();
|
||||
|
||||
// Watch for theme changes
|
||||
const observer = new MutationObserver(() => {
|
||||
updateCodeBlockStyles();
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
// Also run on any content changes that might add new code blocks
|
||||
const contentObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.addedNodes.length) {
|
||||
updateCodeBlockStyles();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
contentObserver.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Clean up observers when navigating away
|
||||
document.addEventListener('spa-navigation-start', () => {
|
||||
observer.disconnect();
|
||||
contentObserver.disconnect();
|
||||
});
|
||||
|
||||
// Remove the parallax effect for hero image
|
||||
|
||||
// Handle prev/next navigation links
|
||||
const navLinks = document.querySelectorAll('.blog-nav-link');
|
||||
navLinks.forEach((link) => {
|
||||
if (!link.hasAttribute('data-spa-handled')) {
|
||||
link.setAttribute('data-spa-handled', 'true');
|
||||
|
||||
link.addEventListener('mouseenter', () => {
|
||||
link.classList.add('nav-link-hover');
|
||||
});
|
||||
|
||||
link.addEventListener('mouseleave', () => {
|
||||
link.classList.remove('nav-link-hover');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Animate headings when they enter the viewport
|
||||
const animateHeadings = () => {
|
||||
const headings = document.querySelectorAll('article h2, article h3');
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('heading-visible');
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
},
|
||||
{
|
||||
threshold: 0.2,
|
||||
rootMargin: '0px 0px -100px 0px',
|
||||
}
|
||||
);
|
||||
|
||||
headings.forEach((heading) => {
|
||||
heading.classList.add('heading-animated');
|
||||
observer.observe(heading);
|
||||
});
|
||||
|
||||
return observer;
|
||||
};
|
||||
|
||||
// Initialize heading animations
|
||||
const headingObserver = animateHeadings();
|
||||
|
||||
// Enhance code blocks with syntax highlighting and copy button
|
||||
function enhanceCodeBlocks() {
|
||||
const codeBlocks = document.querySelectorAll('pre code');
|
||||
|
||||
codeBlocks.forEach((codeBlock) => {
|
||||
// Skip if already processed
|
||||
if (codeBlock.parentElement.classList.contains('enhanced')) return;
|
||||
|
||||
// Mark as enhanced
|
||||
codeBlock.parentElement.classList.add('enhanced');
|
||||
|
||||
// Create copy button
|
||||
const copyButton = document.createElement('button');
|
||||
copyButton.className = 'copy-code-button';
|
||||
copyButton.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
|
||||
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// Add copy functionality
|
||||
copyButton.addEventListener('click', () => {
|
||||
const code = codeBlock.textContent;
|
||||
navigator.clipboard.writeText(code);
|
||||
|
||||
// Show copied feedback
|
||||
copyButton.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
`;
|
||||
|
||||
setTimeout(() => {
|
||||
copyButton.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M8 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" />
|
||||
<path d="M6 3a2 2 0 00-2 2v11a2 2 0 002 2h8a2 2 0 002-2V5a2 2 0 00-2-2 3 3 0 01-3 3H9a3 3 0 01-3-3z" />
|
||||
</svg>
|
||||
`;
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
// Add copy button to pre element
|
||||
codeBlock.parentElement.appendChild(copyButton);
|
||||
|
||||
// Fix line numbers implementation
|
||||
const codeText = codeBlock.textContent;
|
||||
const lines = codeText.split('\n');
|
||||
|
||||
const lineNumbers = document.createElement('div');
|
||||
lineNumbers.className = 'line-numbers';
|
||||
|
||||
// Always include all lines, including empty ones
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineNumber = document.createElement('span');
|
||||
lineNumber.textContent = i + 1;
|
||||
lineNumbers.appendChild(lineNumber);
|
||||
}
|
||||
|
||||
codeBlock.parentElement.classList.add('with-line-numbers');
|
||||
codeBlock.parentElement.insertBefore(lineNumbers, codeBlock);
|
||||
|
||||
// Fix language label detection and display
|
||||
const className = codeBlock.className;
|
||||
const languageMatch = className.match(/language-(\w+)/);
|
||||
|
||||
if (languageMatch && languageMatch[1]) {
|
||||
const language = languageMatch[1];
|
||||
|
||||
// Add language label at top right
|
||||
const languageLabel = document.createElement('div');
|
||||
languageLabel.className = 'language-label';
|
||||
languageLabel.textContent = language;
|
||||
codeBlock.parentElement.appendChild(languageLabel);
|
||||
|
||||
// Add language badge at bottom right with markdown syntax
|
||||
const languageBadge = document.createElement('div');
|
||||
languageBadge.className = 'language-badge';
|
||||
languageBadge.textContent = `\`\`\`${language}`;
|
||||
languageBadge.style.position = 'absolute';
|
||||
languageBadge.style.bottom = '0.5rem';
|
||||
languageBadge.style.right = '0.5rem';
|
||||
languageBadge.style.fontSize = '0.7rem';
|
||||
languageBadge.style.padding = '0.1rem 0.3rem';
|
||||
languageBadge.style.backgroundColor = 'rgba(75, 85, 99, 0.7)';
|
||||
languageBadge.style.color = '#e5e7eb';
|
||||
languageBadge.style.borderRadius = '0.25rem';
|
||||
languageBadge.style.fontFamily =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
|
||||
languageBadge.style.zIndex = '10';
|
||||
codeBlock.parentElement.appendChild(languageBadge);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Enhance tables with better styling
|
||||
function enhanceTables() {
|
||||
const tables = document.querySelectorAll('.markdown-content table');
|
||||
|
||||
tables.forEach((table) => {
|
||||
if (table.classList.contains('enhanced-table')) return;
|
||||
|
||||
table.classList.add('enhanced-table');
|
||||
|
||||
// Wrap table in responsive container
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'table-container';
|
||||
table.parentNode.insertBefore(wrapper, table);
|
||||
wrapper.appendChild(table);
|
||||
|
||||
// Add zebra striping to rows
|
||||
const rows = table.querySelectorAll('tbody tr');
|
||||
rows.forEach((row, index) => {
|
||||
if (index % 2 === 0) {
|
||||
row.classList.add('even-row');
|
||||
} else {
|
||||
row.classList.add('odd-row');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Enhance blockquotes with icons
|
||||
function enhanceBlockquotes() {
|
||||
const blockquotes = document.querySelectorAll('.markdown-content blockquote');
|
||||
|
||||
blockquotes.forEach((blockquote) => {
|
||||
if (blockquote.classList.contains('enhanced-quote')) return;
|
||||
|
||||
blockquote.classList.add('enhanced-quote');
|
||||
|
||||
// Add quote icon
|
||||
const icon = document.createElement('div');
|
||||
icon.className = 'quote-icon';
|
||||
icon.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 01.865-.501 48.172 48.172 0 003.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z" />
|
||||
</svg>
|
||||
`;
|
||||
|
||||
blockquote.insertBefore(icon, blockquote.firstChild);
|
||||
});
|
||||
}
|
||||
|
||||
// Run all enhancements
|
||||
enhanceCodeBlocks();
|
||||
enhanceTables();
|
||||
enhanceBlockquotes();
|
||||
|
||||
// Clean up observers when navigating away
|
||||
document.addEventListener('spa-navigation-start', () => {
|
||||
if (headingObserver) {
|
||||
headingObserver.disconnect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on first load
|
||||
document.addEventListener('DOMContentLoaded', setupBlogPostTransitions);
|
||||
|
||||
// Re-initialize when content changes via Astro's view transitions
|
||||
document.addEventListener('astro:page-load', setupBlogPostTransitions);
|
||||
|
||||
// For compatibility with custom transition system
|
||||
document.addEventListener('page-transition-complete', setupBlogPostTransitions);
|
||||
|
||||
// Also initialize when SPA navigation completes
|
||||
document.addEventListener('spa-navigation-complete', setupBlogPostTransitions);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Enhanced hero image styling */
|
||||
article img:first-of-type {
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
article img:first-of-type:hover {
|
||||
transform: scale(1.01);
|
||||
}
|
||||
|
||||
/* Article entrance animation */
|
||||
.article-entering {
|
||||
animation: article-fade-in 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes article-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Rest of the styles remain unchanged... */
|
||||
</style>
|
336
src/layouts/Layout.astro
Normal file
@@ -0,0 +1,336 @@
|
||||
---
|
||||
import Navigation from '../components/Navigation.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import Background from '../components/Background.astro';
|
||||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title?: string | undefined;
|
||||
description?: string | undefined;
|
||||
}
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<meta name="description" content={description} />
|
||||
<title>{title}</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
class="flex min-h-screen flex-col bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100"
|
||||
>
|
||||
<!-- Page transition overlay - for smooth transitions between pages -->
|
||||
<div
|
||||
id="page-transition"
|
||||
class="pointer-events-none fixed inset-0 z-40 flex items-center justify-center bg-white opacity-0 transition-opacity duration-300 dark:bg-zinc-900"
|
||||
>
|
||||
<div class="transition-spinner"></div>
|
||||
</div>
|
||||
|
||||
<!-- Background component with dot pattern and ambient glow -->
|
||||
<Background />
|
||||
|
||||
<div class="mx-auto w-full max-w-3xl flex-grow px-4 sm:px-6">
|
||||
<Navigation />
|
||||
<main class="py-12">
|
||||
<slot />
|
||||
</main>
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
<script>
|
||||
// SPA transition system with history API
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const pageTransition = document.getElementById('page-transition');
|
||||
const mainContent = document.querySelector('main');
|
||||
|
||||
// Initialize content with entrance animation
|
||||
if (mainContent) {
|
||||
mainContent.classList.add('content-entering');
|
||||
setTimeout(() => {
|
||||
mainContent.classList.remove('content-entering');
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// Function to load content via fetch
|
||||
async function loadContent(url) {
|
||||
try {
|
||||
// Show transition overlay
|
||||
if (pageTransition) {
|
||||
pageTransition.classList.remove('opacity-0', 'pointer-events-none');
|
||||
pageTransition.classList.add('opacity-100');
|
||||
}
|
||||
|
||||
// Fade out current content
|
||||
if (mainContent) {
|
||||
mainContent.style.opacity = '0';
|
||||
mainContent.style.transform = 'translateY(10px)';
|
||||
}
|
||||
|
||||
// Fetch the new page content
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`Failed to fetch ${url}`);
|
||||
const html = await response.text();
|
||||
|
||||
// Create a temporary element to parse the HTML
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
// Extract the main content
|
||||
const newContent = doc.querySelector('main');
|
||||
if (!newContent) throw new Error('Could not find main content in the fetched page');
|
||||
|
||||
// Extract the title
|
||||
const newTitle = doc.querySelector('title');
|
||||
if (newTitle) {
|
||||
document.title = newTitle.textContent;
|
||||
}
|
||||
|
||||
// Extract meta description
|
||||
const newDescription = doc.querySelector('meta[name="description"]');
|
||||
if (newDescription) {
|
||||
const currentDescription = document.querySelector('meta[name="description"]');
|
||||
if (currentDescription) {
|
||||
currentDescription.setAttribute(
|
||||
'content',
|
||||
newDescription.getAttribute('content') || ''
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a bit for transition effect
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// Replace the content
|
||||
if (mainContent && newContent) {
|
||||
mainContent.innerHTML = newContent.innerHTML;
|
||||
|
||||
// Run scripts in the new content
|
||||
Array.from(newContent.querySelectorAll('script')).forEach((oldScript) => {
|
||||
const newScript = document.createElement('script');
|
||||
Array.from(oldScript.attributes).forEach((attr) => {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
});
|
||||
newScript.textContent = oldScript.textContent;
|
||||
if (oldScript.parentNode) {
|
||||
mainContent.appendChild(newScript);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fade in new content with animation
|
||||
if (mainContent) {
|
||||
mainContent.style.opacity = '0';
|
||||
mainContent.style.transform = 'translateY(10px)';
|
||||
|
||||
setTimeout(() => {
|
||||
mainContent.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
|
||||
mainContent.style.opacity = '1';
|
||||
mainContent.style.transform = 'translateY(0)';
|
||||
|
||||
// Add entrance animation class
|
||||
mainContent.classList.add('content-entering');
|
||||
setTimeout(() => {
|
||||
mainContent.classList.remove('content-entering');
|
||||
}, 800);
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// Hide transition overlay
|
||||
if (pageTransition) {
|
||||
setTimeout(() => {
|
||||
pageTransition.classList.add('opacity-0', 'pointer-events-none');
|
||||
pageTransition.classList.remove('opacity-100');
|
||||
}, 200);
|
||||
}
|
||||
|
||||
// Dispatch custom event for content loaded
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('spa-content-loaded', {
|
||||
detail: { url },
|
||||
})
|
||||
);
|
||||
|
||||
// Scroll to top or to saved position
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
// Re-attach event listeners to new content
|
||||
attachLinkListeners();
|
||||
} catch (error) {
|
||||
console.error('Error loading content:', error);
|
||||
|
||||
// Fallback to traditional navigation on error
|
||||
window.location.href = url;
|
||||
}
|
||||
}
|
||||
|
||||
// Function to attach event listeners to all links
|
||||
function attachLinkListeners() {
|
||||
document.querySelectorAll('a').forEach((link) => {
|
||||
// Skip links that are already handled, anchor links, external links, or have special attributes
|
||||
if (
|
||||
link.hasAttribute('data-spa-handled') ||
|
||||
!link.href.startsWith(window.location.origin) ||
|
||||
link.href.includes('#') ||
|
||||
link.hasAttribute('target') ||
|
||||
link.hasAttribute('download') ||
|
||||
link.getAttribute('rel') === 'external' ||
|
||||
link.getAttribute('rel') === 'nofollow'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as handled to avoid duplicate listeners
|
||||
link.setAttribute('data-spa-handled', 'true');
|
||||
|
||||
link.addEventListener('click', (e) => {
|
||||
// Don't handle if modifier keys are pressed (for opening in new tab, etc.)
|
||||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
const targetHref = link.href;
|
||||
|
||||
// Don't transition if clicking the current page
|
||||
if (targetHref === window.location.href) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update browser history
|
||||
window.history.pushState({ path: targetHref }, '', targetHref);
|
||||
|
||||
// Load the new content
|
||||
loadContent(targetHref);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initial attachment of link listeners
|
||||
attachLinkListeners();
|
||||
|
||||
// Handle browser back/forward navigation
|
||||
window.addEventListener('popstate', (e) => {
|
||||
if (e.state && e.state.path) {
|
||||
loadContent(e.state.path);
|
||||
} else {
|
||||
loadContent(window.location.href);
|
||||
}
|
||||
});
|
||||
|
||||
// Check RSS feed availability
|
||||
const checkAndGenerateRSS = async () => {
|
||||
try {
|
||||
const response = await fetch('/rss.xml');
|
||||
if (!response.ok) {
|
||||
console.warn('RSS feed not found. Please generate it using an RSS plugin for Astro.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not check RSS feed status.');
|
||||
}
|
||||
};
|
||||
|
||||
// Check RSS feed availability
|
||||
checkAndGenerateRSS();
|
||||
});
|
||||
|
||||
// Theme handling with transition effects
|
||||
function setupThemeHandling() {
|
||||
// Apply theme from localStorage or system preference
|
||||
const theme = localStorage.getItem('theme');
|
||||
if (
|
||||
theme === 'dark' ||
|
||||
(!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Listen for theme changes
|
||||
document.addEventListener('themeChanged', () => {
|
||||
// Add transition class to body
|
||||
document.body.classList.add('theme-transitioning');
|
||||
|
||||
// Remove class after transition completes
|
||||
setTimeout(() => {
|
||||
document.body.classList.remove('theme-transitioning');
|
||||
}, 500);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize theme handling
|
||||
document.addEventListener('DOMContentLoaded', setupThemeHandling);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style>
|
||||
/* Page transition effects */
|
||||
#page-transition {
|
||||
transition: opacity 0.3s ease;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Transition spinner animation */
|
||||
.transition-spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 50%;
|
||||
border-top-color: #3b82f6;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
:global(.dark) .transition-spinner {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
border-top-color: #60a5fa;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Content entrance animation */
|
||||
main {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
opacity 0.5s ease,
|
||||
transform 0.5s ease;
|
||||
}
|
||||
|
||||
main.content-entering {
|
||||
animation: content-fade-in 0.6s ease forwards;
|
||||
}
|
||||
|
||||
@keyframes content-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme transition effect */
|
||||
body.theme-transitioning * {
|
||||
transition-duration: 0.3s !important;
|
||||
}
|
||||
</style>
|
25
src/layouts/TransitionLayout.astro
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
import { ViewTransitions } from 'astro:transitions';
|
||||
import BaseLayout from './BaseLayout.astro';
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
---
|
||||
|
||||
<BaseLayout title={title} description={description}>
|
||||
<ViewTransitions fallback="swap" />
|
||||
|
||||
<div transition:animate="slide">
|
||||
<slot />
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
/* Custom transition styles */
|
||||
::view-transition-old(root) {
|
||||
animation: 0.5s cubic-bezier(0.76, 0, 0.24, 1) both slide-to-left;
|
||||
}
|
||||
|
||||
::view-transition-new(root) {
|
||||
animation: 0.5s cubic-bezier(0.76, 0, 0.24, 1) both slide-from-right;
|
||||
}
|
||||
</style>
|
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 -left-20 -top-20 h-64 w-64 rounded-full bg-zinc-100 opacity-50 blur-3xl dark:bg-zinc-800/50"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-2000 absolute right-1/4 top-1/2 h-96 w-96 rounded-full bg-zinc-200 opacity-30 blur-3xl dark:bg-zinc-800/30"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-4000 absolute bottom-20 left-1/3 h-72 w-72 rounded-full bg-zinc-100 opacity-40 blur-3xl dark:bg-zinc-800/40"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content with animation -->
|
||||
<div class="relative z-10 mx-auto max-w-xl">
|
||||
<div class="glitch-wrapper">
|
||||
<h1
|
||||
class="glitch text-9xl font-bold leading-none text-zinc-900 dark:text-zinc-100 sm:text-[12rem]"
|
||||
data-text="404"
|
||||
>
|
||||
404
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<h2 class="mt-6 text-2xl font-bold text-zinc-800 dark:text-zinc-200 sm:text-3xl">
|
||||
Page Not Found
|
||||
</h2>
|
||||
|
||||
<p class="mx-auto mt-6 max-w-md text-lg text-zinc-600 dark:text-zinc-400">
|
||||
The page you're looking for does not exist.
|
||||
</p>
|
||||
|
||||
<div class="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row">
|
||||
<a
|
||||
href="/"
|
||||
class="group relative inline-flex items-center gap-2 overflow-hidden rounded-lg bg-zinc-900 px-6 py-3 text-zinc-100 shadow-lg transition-all duration-300 hover:bg-zinc-800 hover:shadow-xl dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-0 z-0 bg-gradient-to-r from-zinc-700 to-zinc-900 opacity-0 transition-opacity duration-300 group-hover:opacity-100 dark:from-zinc-300 dark:to-zinc-100"
|
||||
></span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="relative z-10 h-5 w-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
|
||||
></path>
|
||||
</svg>
|
||||
<span class="relative z-10 font-medium">Return Home</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
id="back-button"
|
||||
class="group inline-flex items-center gap-2 rounded-lg border border-zinc-300 px-6 py-3 text-zinc-700 shadow-sm transition-all duration-300 hover:bg-zinc-100 hover:shadow-md dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="h-5 w-5 transition-transform duration-300 group-hover:-translate-x-1"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"></path>
|
||||
</svg>
|
||||
<span class="font-medium">Go Back</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Random fun fact -->
|
||||
<div
|
||||
class="mx-auto mt-16 max-w-md rounded-xl border border-zinc-100 bg-zinc-50 p-6 shadow-sm backdrop-blur-sm dark:border-zinc-700/50 dark:bg-zinc-800/50"
|
||||
>
|
||||
<h3 class="text-sm font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
|
||||
Did you know?
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-zinc-700 dark:text-zinc-300" id="fun-fact">
|
||||
The 404 error code originated when CERN's web server displayed room 404 (their server
|
||||
room) as the error message when a file wasn't found.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
// Go back functionality
|
||||
document.getElementById('back-button')?.addEventListener('click', () => {
|
||||
window.history.back();
|
||||
});
|
||||
|
||||
// Array of fun 404 facts
|
||||
const funFacts = [
|
||||
"The 404 error code originated when CERN's web server displayed room 404 (their server room) as the error message when a file wasn't found.",
|
||||
"In internet slang, '404' has become shorthand for something that's missing or someone who's clueless.",
|
||||
"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,583 @@
|
||||
---
|
||||
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 -left-10 -top-10 h-36 w-36 rounded-full bg-zinc-100 opacity-30 blur-3xl dark:bg-zinc-800/30 sm:-left-20 sm:-top-20 sm:h-48 sm:w-48 md:h-72 md:w-72"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-2000 theme-transition-bg absolute -bottom-10 -right-10 h-36 w-36 rounded-full bg-zinc-200 opacity-30 blur-3xl dark:bg-zinc-800/30 sm:-bottom-20 sm:-right-20 sm:h-48 sm:w-48 md:h-72 md:w-72"
|
||||
>
|
||||
</div>
|
||||
|
||||
<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 dark:text-zinc-100 sm:mb-6 sm:text-4xl md:text-5xl"
|
||||
>
|
||||
Hello, I'm <span
|
||||
class="theme-transition-all bg-gradient-to-r from-zinc-500 to-zinc-900 bg-clip-text text-transparent dark:from-zinc-300 dark:to-zinc-100"
|
||||
>{global.name}</span
|
||||
>
|
||||
</h1>
|
||||
|
||||
<ContactCTA />
|
||||
</div>
|
||||
<p
|
||||
class="theme-transition-color mb-6 text-lg leading-relaxed text-zinc-600 dark:text-zinc-400 sm:mb-8 sm:text-xl"
|
||||
>
|
||||
{about.background}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="social-links-container theme-transition-children flex flex-wrap justify-center gap-4 md:justify-start"
|
||||
>
|
||||
<!-- Social links remain the same -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative order-1 md:order-2">
|
||||
<div
|
||||
class="theme-transition-all mx-auto aspect-square w-full max-w-[280px] overflow-hidden rounded-3xl border-4 border-white shadow-xl dark:border-zinc-800 sm:max-w-[320px] sm:border-8 sm:shadow-2xl md:max-w-md"
|
||||
>
|
||||
<img
|
||||
src=`${process.env.DIRECTUS_URL ?? "https://directus.alexlebens.dev"}/assets/${global.portrait}`
|
||||
alt={global.portrait_alt}
|
||||
class="h-full w-full object-cover"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Decorative elements -->
|
||||
<div
|
||||
class="theme-transition-all absolute -bottom-4 -right-4 flex h-16 w-16 items-center justify-center rounded-full border-2 border-white bg-zinc-100 shadow-lg dark:border-zinc-900 dark:bg-zinc-800 sm:-bottom-6 sm:-right-6 sm:h-20 sm:w-20 sm:border-4 md:h-24 md:w-24"
|
||||
>
|
||||
<span class="text-2xl sm:text-3xl">👋</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- About Section -->
|
||||
<div class="theme-transition-all mb-16 sm:mb-20 md:mb-24">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h2
|
||||
class="theme-transition-color mb-6 flex items-center justify-center text-2xl font-bold text-zinc-900 dark:text-zinc-100 sm:mb-8 sm:text-3xl md:justify-start"
|
||||
>
|
||||
<span
|
||||
class="theme-transition-bg mr-4 hidden h-1 w-8 bg-zinc-300 dark:bg-zinc-700 sm:inline-block sm:w-12"
|
||||
></span>
|
||||
About Me
|
||||
<span
|
||||
class="theme-transition-bg ml-4 hidden h-1 w-8 bg-zinc-300 dark:bg-zinc-700 sm:inline-block sm:w-12"
|
||||
></span>
|
||||
</h2>
|
||||
|
||||
<div class="theme-transition-all prose prose-zinc max-w-none dark:prose-invert">
|
||||
<p class="theme-transition-color mb-4 text-base leading-relaxed sm:mb-6 sm:text-lg">
|
||||
{about.experience}
|
||||
</p>
|
||||
|
||||
<p class="theme-transition-color mb-4 text-base leading-relaxed sm:mb-6 sm:text-lg">
|
||||
{about.education}
|
||||
</p>
|
||||
|
||||
<p class="theme-transition-color mb-4 text-base leading-relaxed sm:mb-6 sm:text-lg">
|
||||
{about.certifications}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills Section -->
|
||||
<div class="theme-transition-all mb-16 sm:mb-20 md:mb-24">
|
||||
<h2
|
||||
class="theme-transition-color mb-8 text-center text-2xl font-bold text-zinc-900 dark:text-zinc-100 sm:mb-12 sm:text-3xl"
|
||||
>
|
||||
Tech Stack
|
||||
</h2>
|
||||
|
||||
<div class="tech-stack-slider relative overflow-hidden py-4 sm:py-8">
|
||||
<!-- Main slider container -->
|
||||
<div class="slider-track animate-slide flex">
|
||||
{
|
||||
skills.map((skill, index) => (
|
||||
<div
|
||||
key={`${skill.title}-${index}`}
|
||||
class="skill-card theme-transition-element mx-2 min-w-[220px] transform rounded-xl border border-zinc-200 bg-white transition-all duration-300 hover:-translate-y-2 hover:scale-105 hover:border-zinc-300 hover:shadow-xl dark:border-zinc-700 dark:bg-zinc-800/50 dark:hover:border-zinc-600 sm:mx-4 sm:min-w-[280px]"
|
||||
>
|
||||
<div class="p-4 sm:p-6">
|
||||
<div class="mb-4 flex items-center justify-between sm:mb-6">
|
||||
<div class="flex items-center gap-2 sm:gap-4">
|
||||
<div class="theme-transition-bg theme-transition-color flex h-8 w-8 transform items-center justify-center rounded-lg bg-zinc-100 text-zinc-800 transition-transform group-hover:rotate-12 dark:bg-zinc-800 dark:text-zinc-200 sm:h-12 sm:w-12">
|
||||
<skill.icon
|
||||
size={20}
|
||||
className="sm:text-2xl transform transition-all hover:scale-125"
|
||||
/>
|
||||
</div>
|
||||
<h3 class="theme-transition-color text-base font-semibold text-zinc-900 dark:text-zinc-100 sm:text-xl">
|
||||
{skill.title}
|
||||
</h3>
|
||||
</div>
|
||||
<span class="theme-transition-all rounded-full bg-zinc-100 px-2 py-0.5 font-mono text-xs text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400 sm:px-2.5 sm:py-1 sm:text-sm">
|
||||
{skill.level}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="theme-transition-bg relative h-1.5 w-full overflow-hidden rounded-full bg-zinc-100 dark:bg-zinc-700 sm:h-2">
|
||||
<div
|
||||
class="progress-bar-animate theme-transition-bg absolute left-0 top-0 h-full rounded-full bg-gradient-to-r from-zinc-700 via-zinc-600 to-zinc-800 transition-all duration-1000 dark:from-zinc-300 dark:via-zinc-400 dark:to-zinc-200"
|
||||
style={`width: ${skill.level}%`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="theme-transition-color mt-1 flex justify-between font-mono text-[10px] text-zinc-400 dark:text-zinc-500 sm:mt-2 sm:text-xs">
|
||||
<span>Beginner</span>
|
||||
<span>Advanced</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Gradient overlays for smooth fade effect -->
|
||||
<div
|
||||
class="theme-transition-bg absolute bottom-0 left-0 top-0 z-10 w-12 bg-gradient-to-r from-white to-transparent dark:from-zinc-900 sm:w-24"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="theme-transition-bg absolute bottom-0 right-0 top-0 z-10 w-12 bg-gradient-to-l from-white to-transparent dark:from-zinc-900 sm:w-24"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Section -->
|
||||
<div class="theme-transition-all mx-auto max-w-3xl text-center">
|
||||
<h2
|
||||
class="theme-transition-color mb-4 text-2xl font-bold text-zinc-900 dark:text-zinc-100 sm:mb-6 sm:text-3xl"
|
||||
>
|
||||
Get in Touch
|
||||
</h2>
|
||||
<p
|
||||
class="theme-transition-color mb-6 text-base text-zinc-600 dark:text-zinc-400 sm:mb-8 sm:text-lg"
|
||||
>
|
||||
I'm always open to new opportunities and collaborations. If you'd like to work together or
|
||||
just say hello, feel free to reach out.
|
||||
</p>
|
||||
|
||||
<a
|
||||
href=`mailto:${global.email}`
|
||||
class="theme-transition-all inline-flex items-center justify-center rounded-lg bg-zinc-900 px-6 py-3 text-base font-medium text-zinc-100 transition-colors hover:bg-zinc-700 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-300 sm:px-8 sm:py-4 sm:text-lg"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-4 w-4 sm:h-5 sm:w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
Say Hello
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
.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 max-w-none dark:prose-invert sm:prose-base prose-headings:scroll-mt-24 prose-headings:font-semibold prose-a:font-medium prose-a:text-zinc-800 prose-a:underline-offset-4 hover:prose-a:text-zinc-600 prose-img:rounded-xl dark:prose-a:text-zinc-300 dark:hover:prose-a:text-zinc-100"
|
||||
>
|
||||
<div set:html={post.content} />
|
||||
</div>
|
||||
|
||||
<!-- Next/Previous Navigation - Improved responsive design -->
|
||||
<div
|
||||
class="mt-12 grid grid-cols-1 gap-4 border-t border-zinc-200 pt-8 dark:border-zinc-800 sm:mt-16 sm:gap-6 sm:pt-12 md:grid-cols-2"
|
||||
>
|
||||
{
|
||||
prevPost && (
|
||||
<a
|
||||
href={`/blog/${prevPost.slug}`}
|
||||
class="group relative flex h-full flex-col overflow-hidden rounded-xl border border-zinc-200 p-4 transition-all duration-300 hover:-translate-y-1 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800/50 sm:p-6"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-zinc-100 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100 dark:from-zinc-800 dark:to-transparent" />
|
||||
<span class="relative z-10 mb-1 flex items-center gap-1 text-xs font-medium text-zinc-500 dark:text-zinc-400 sm:mb-2 sm:gap-2 sm:text-sm">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-3 w-3 transition-transform duration-300 group-hover:-translate-x-1 sm:h-4 sm:w-4"
|
||||
>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
Previous Article
|
||||
</span>
|
||||
<h3 class="line-clamp-2 text-base font-medium text-zinc-900 transition-colors group-hover:text-zinc-700 dark:text-white dark:group-hover:text-zinc-300 sm:text-lg">
|
||||
{prevPost.title}
|
||||
</h3>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
nextPost && (
|
||||
<a
|
||||
href={`/blog/${nextPost.slug}`}
|
||||
class="group relative flex h-full flex-col overflow-hidden rounded-xl border border-zinc-200 p-4 transition-all duration-300 hover:-translate-y-1 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800/50 sm:p-6 md:text-right"
|
||||
>
|
||||
<div class="absolute inset-0 bg-gradient-to-l from-zinc-100 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100 dark:from-zinc-800 dark:to-transparent" />
|
||||
<span class="relative z-10 mb-1 flex items-center gap-1 text-xs font-medium text-zinc-500 dark:text-zinc-400 sm:mb-2 sm:gap-2 sm:text-sm md:justify-end">
|
||||
Next Article
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-3 w-3 transition-transform duration-300 group-hover:translate-x-1 sm:h-4 sm:w-4"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
<h3 class="line-clamp-2 text-base font-medium text-zinc-900 transition-colors group-hover:text-zinc-700 dark:text-white dark:group-hover:text-zinc-300 sm:text-lg">
|
||||
{nextPost.title}
|
||||
</h3>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</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 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 -left-10 -top-10 h-48 w-48 rounded-full bg-zinc-100 opacity-30 blur-3xl dark:bg-zinc-800/30 sm:-left-20 sm:-top-20 sm:h-72 sm:w-72"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-2000 absolute -bottom-10 -right-10 h-48 w-48 rounded-full bg-zinc-200 opacity-30 blur-3xl dark:bg-zinc-800/30 sm:-bottom-20 sm:-right-20 sm:h-72 sm:w-72"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="relative text-center">
|
||||
<h1
|
||||
class="mb-4 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-4xl md:text-5xl"
|
||||
>
|
||||
Blog
|
||||
</h1>
|
||||
|
||||
<p
|
||||
class="mx-auto mb-6 max-w-2xl text-sm text-zinc-600 dark:text-zinc-400 sm:mb-10 sm:text-base"
|
||||
>
|
||||
Thoughts, ideas, and explorations on technology and selfhosting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid layout for mobile experience -->
|
||||
<div class="grid grid-cols-1 gap-6 sm:gap-8 md:grid-cols-12">
|
||||
<!-- Featured post (if exists) -->
|
||||
{
|
||||
sortedPosts.length > 0 && (
|
||||
<div class="mb-8 sm:mb-12 md:col-span-12">
|
||||
<article class="group relative overflow-hidden rounded-none border-b border-zinc-200 pb-6 dark:border-zinc-800 sm:pb-8">
|
||||
<div class="flex h-full flex-col gap-6 sm:gap-8 md:flex-row">
|
||||
{sortedPosts[0].image && (
|
||||
<div class="mx-auto h-60 w-full max-w-full overflow-hidden sm:h-80 sm:max-w-md md:mx-0 md:h-96 md:w-1/2">
|
||||
<img
|
||||
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${sortedPosts[0].image}`}
|
||||
alt={sortedPosts[0].title}
|
||||
class="h-full w-full object-cover grayscale transition-all duration-700 hover:grayscale-0 group-hover:scale-105"
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex flex-1 flex-col justify-center">
|
||||
<div class="mb-3 flex items-center justify-center gap-2 text-xs text-zinc-500 dark:text-zinc-400 sm:text-sm md:justify-start">
|
||||
<span class="font-medium uppercase tracking-wider">Featured</span>
|
||||
<span class="h-px w-6 bg-zinc-300 dark:bg-zinc-700 sm:w-8" />
|
||||
{sortedPosts[0].published_date && (
|
||||
<time datetime={sortedPosts[0].published_date.toLocaleString()}>
|
||||
{sortedPosts[0].published_date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h2 class="mb-3 text-center text-2xl font-bold text-zinc-900 transition-colors group-hover:text-zinc-700 dark:text-zinc-100 dark:group-hover:text-zinc-300 sm:mb-4 sm:text-3xl md:text-left">
|
||||
<a
|
||||
href={`/blog/${sortedPosts[0].slug}/`}
|
||||
class="before:absolute before:inset-0"
|
||||
>
|
||||
{sortedPosts[0].title}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<p class="mb-4 line-clamp-3 text-center text-sm text-zinc-600 dark:text-zinc-400 sm:mb-6 sm:text-base md:text-left">
|
||||
{sortedPosts[0].description}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-center gap-3 sm:gap-4 md:justify-start">
|
||||
{sortedPosts[0].tags && (
|
||||
<div class="flex flex-wrap justify-center gap-2 md:justify-start">
|
||||
{sortedPosts[0].tags.slice(0, 2).map((tag) => (
|
||||
<span class="border border-zinc-200 px-2 py-1 text-xs uppercase tracking-wider text-zinc-600 dark:border-zinc-800 dark:text-zinc-400 sm:px-3">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Improved sidebar for mobile -->
|
||||
<div class="relative md:col-span-3">
|
||||
<div class="mb-8 space-y-4 md:sticky md:top-24 md:mb-0">
|
||||
<h3
|
||||
class="mb-4 text-center text-lg font-medium uppercase tracking-wider text-zinc-900 dark:text-zinc-100 md:text-left"
|
||||
>
|
||||
Archive
|
||||
</h3>
|
||||
|
||||
<!-- Horizontal scrollable archive on mobile, vertical on desktop -->
|
||||
<div
|
||||
class="hide-scrollbar flex overflow-x-auto pb-4 md:flex-col md:overflow-visible md:pb-0"
|
||||
>
|
||||
{
|
||||
years.map((year, index) => (
|
||||
<a
|
||||
href={`#year-${year}`}
|
||||
class={`mr-3 flex items-center whitespace-nowrap rounded-full border-b border-zinc-100 px-4 py-2 transition-colors hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-900 md:mr-0 md:w-full md:whitespace-normal md:rounded-none md:px-0 md:py-3 ${index === 0 ? 'bg-zinc-50 dark:bg-zinc-800/50' : ''}`}
|
||||
>
|
||||
<span class="text-base font-medium text-zinc-900 dark:text-zinc-100 md:text-lg">
|
||||
{year}
|
||||
</span>
|
||||
<span class="ml-2 text-xs text-zinc-500 dark:text-zinc-400 md:ml-auto md:text-sm">
|
||||
{postsByYear[year].length} post{postsByYear[year].length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</a>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Improved post grid for mobile -->
|
||||
<div class="md:col-span-9">
|
||||
{
|
||||
years.map((year) => (
|
||||
<div id={`year-${year}`} class="mb-12 scroll-mt-16 sm:mb-20">
|
||||
<h2 class="mb-6 border-b border-zinc-200 pb-3 text-center text-xl font-bold text-zinc-900 dark:border-zinc-800 dark:text-zinc-100 sm:mb-8 sm:pb-4 sm:text-2xl md:text-left">
|
||||
{year}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class={`grid grid-cols-1 ${postsByYear[year].length >= 2 ? 'md:grid-cols-2' : 'md:grid-cols-1'} gap-8 sm:gap-12`}
|
||||
>
|
||||
{postsByYear[year].map((post, index) => (
|
||||
<article class="group relative mx-auto flex h-full w-full max-w-sm flex-col sm:max-w-md md:mx-0">
|
||||
{post.image && (
|
||||
<div class="mb-4 h-48 overflow-hidden rounded-lg sm:h-56">
|
||||
<img
|
||||
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${post.image}`}
|
||||
alt={post.title}
|
||||
class="h-full w-full object-cover grayscale transition-all duration-700 hover:grayscale-0 group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div class="flex flex-1 flex-col">
|
||||
<div class="mb-2 flex flex-wrap items-center justify-center gap-3 text-xs text-zinc-500 dark:text-zinc-400 sm:mb-3 sm:gap-4 sm:text-sm md:justify-start">
|
||||
{post.pubDate && (
|
||||
<time
|
||||
datetime={post.published_date.toLocaleString()}
|
||||
class="flex items-center"
|
||||
>
|
||||
{post.published_date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 class="mb-2 text-center text-lg font-semibold text-zinc-900 transition-colors group-hover:text-zinc-700 dark:text-zinc-100 dark:group-hover:text-zinc-300 sm:mb-3 sm:text-xl md:text-left">
|
||||
<a href={`/blog/${post.slug}/`} class="before:absolute before:inset-0">
|
||||
{post.title}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<p class="mb-4 line-clamp-2 flex-grow text-center text-sm text-zinc-600 dark:text-zinc-400 md:text-left">
|
||||
{post.description}
|
||||
</p>
|
||||
|
||||
{post.tags && (
|
||||
<div class="mt-auto flex flex-wrap justify-center gap-2 md:justify-start">
|
||||
{post.tags.slice(0, 2).map((tag) => (
|
||||
<span class="border border-zinc-200 px-2 py-1 text-xs uppercase tracking-wider text-zinc-600 dark:border-zinc-800 dark:text-zinc-400 sm:px-3">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{post.tags.length > 2 && (
|
||||
<span class="border border-zinc-200 px-2 py-1 text-xs uppercase tracking-wider text-zinc-600 dark:border-zinc-800 dark:text-zinc-400 sm:px-3">
|
||||
+{post.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
<style>
|
||||
/* Blob animation */
|
||||
.animate-blob {
|
||||
animation: blob-bounce 8s infinite ease;
|
||||
}
|
||||
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
|
||||
@keyframes blob-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
25% {
|
||||
transform: translate(5%, 5%) scale(1.05);
|
||||
}
|
||||
50% {
|
||||
transform: translate(0, 10%) scale(1);
|
||||
}
|
||||
75% {
|
||||
transform: translate(-5%, 5%) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
/* Search container hover effect */
|
||||
.search-container:hover .search-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Input focus animation */
|
||||
input:focus + div .search-pulse {
|
||||
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Line clamp for descriptions */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Improved touch targets for mobile */
|
||||
@media (max-width: 640px) {
|
||||
a,
|
||||
button {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// 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 -left-10 -top-10 h-40 w-40 rounded-full bg-zinc-100 opacity-50 blur-3xl dark:bg-zinc-800/50 sm:-left-20 sm:-top-20 sm:h-64 sm:w-64"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-2000 theme-transition-bg absolute -bottom-10 -right-10 h-40 w-40 rounded-full bg-zinc-200 opacity-30 blur-3xl dark:bg-zinc-800/30 sm:-bottom-20 sm:-right-20 sm:h-64 sm:w-64"
|
||||
>
|
||||
</div>
|
||||
|
||||
<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 dark:text-zinc-100 sm:text-4xl md:text-5xl lg:text-6xl"
|
||||
>
|
||||
<span class="block">Writing on technology,</span>
|
||||
<span class="mt-1 block">development, and</span>
|
||||
<span class="relative mt-1 block">
|
||||
<span class="relative inline-block">
|
||||
selfhosting.
|
||||
<span
|
||||
class="theme-transition-bg absolute -bottom-1 left-0 h-1 w-full origin-left transform bg-zinc-800 dark:bg-zinc-200"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
<p
|
||||
class="theme-transition-color mx-auto mt-4 max-w-lg text-base leading-relaxed text-zinc-600 dark:text-zinc-400 sm:mx-0 sm:mt-6 sm:text-lg md:mt-8"
|
||||
>
|
||||
{global.about}
|
||||
</p>
|
||||
<div
|
||||
class="mt-6 flex flex-wrap justify-center gap-3 sm:mt-8 sm:justify-start sm:gap-4 md:mt-10 md:gap-6"
|
||||
>
|
||||
<a
|
||||
href="/about"
|
||||
class="theme-transition-color group relative inline-flex min-h-[44px] items-center gap-2 text-sm font-medium text-zinc-900 transition-all duration-300 hover:text-zinc-700 dark:text-zinc-100 dark:hover:text-zinc-300"
|
||||
>
|
||||
<span>More about me</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"></path>
|
||||
</svg>
|
||||
<span
|
||||
class="theme-transition-bg absolute -bottom-1 left-0 h-0.5 w-0 bg-zinc-800 transition-all duration-300 group-hover:w-full dark:bg-zinc-200"
|
||||
></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Skills />
|
||||
</div>
|
||||
<!-- Featured Post Section - Improved for mobile -->
|
||||
<section
|
||||
class="theme-transition-all border-t border-zinc-100 px-4 py-10 dark:border-zinc-800 sm:px-6 sm:py-12 md:py-16"
|
||||
>
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div
|
||||
class="mb-6 flex flex-col justify-between gap-4 sm:mb-8 sm:flex-row sm:items-center md:mb-12"
|
||||
>
|
||||
<h2
|
||||
class="theme-transition-color text-center text-xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-left sm:text-2xl md:text-3xl"
|
||||
>
|
||||
Recent Posts
|
||||
</h2>
|
||||
<a
|
||||
href="/blog"
|
||||
class="theme-transition-color group relative flex min-h-[44px] items-center justify-center self-center text-sm font-medium text-zinc-900 hover:text-zinc-700 dark:text-zinc-100 dark:hover:text-zinc-300 sm:self-auto"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
View all posts
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-4 w-4 transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span
|
||||
class="theme-transition-bg absolute -bottom-1 left-0 h-0.5 w-0 bg-zinc-800 transition-all duration-300 group-hover:w-full dark:bg-zinc-200"
|
||||
></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<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 dark:bg-zinc-800/50 sm:-inset-x-6 sm:rounded-2xl" />
|
||||
|
||||
<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 dark:text-zinc-400 sm:justify-start sm:gap-x-4">
|
||||
<time datetime={post.published_date.toLocaleString()} class="font-medium">
|
||||
<FormattedDate date={post.published_date} />
|
||||
</time>
|
||||
</div>
|
||||
|
||||
</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 dark:text-zinc-100 dark:group-hover:text-zinc-300 sm:text-left sm:text-xl">
|
||||
<a
|
||||
href={`/blog/${post.slug}`}
|
||||
class="flex min-h-[44px] items-center justify-center sm:justify-start"
|
||||
>
|
||||
<span class="absolute -inset-x-4 -inset-y-2.5 sm:-inset-x-6 sm:-inset-y-4" />
|
||||
{post.title}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<p class="theme-transition-color relative z-10 mt-2 line-clamp-3 w-full text-center text-sm text-zinc-600 dark:text-zinc-400 sm:mt-3 sm:text-left">
|
||||
{post.description}
|
||||
</p>
|
||||
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div class="relative z-10 mt-3 flex w-full flex-wrap justify-center gap-2 sm:mt-4 sm:justify-start">
|
||||
{post.tags.slice(0, 3).map((tag) => (
|
||||
<a
|
||||
href={`/topics/${tag}`}
|
||||
class="theme-transition-all inline-flex min-h-[28px] items-center rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-800 transition-colors hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 sm:px-3"
|
||||
>
|
||||
#{tag}
|
||||
</a>
|
||||
))}
|
||||
{post.tags.length > 3 && (
|
||||
<span class="theme-transition-all inline-flex min-h-[28px] items-center rounded-full bg-zinc-50 px-2 py-1 text-xs font-medium text-zinc-500 dark:bg-zinc-800/50 dark:text-zinc-400">
|
||||
+{post.tags.length - 3} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={`/blog/${post.slug}`}
|
||||
class="theme-transition-color relative z-10 mx-auto mt-3 flex min-h-[44px] items-center text-sm font-medium text-zinc-700 transition-colors group-hover:text-zinc-900 dark:text-zinc-300 dark:group-hover:text-zinc-100 sm:mx-0 sm:mt-4"
|
||||
>
|
||||
<span class="relative inline-block overflow-hidden">
|
||||
<span class="block transition-transform duration-300 group-hover:-translate-y-full">
|
||||
Read article
|
||||
</span>
|
||||
<span class="absolute left-0 top-0 translate-y-full whitespace-nowrap transition-transform duration-300 group-hover:translate-y-0">
|
||||
Explore now
|
||||
</span>
|
||||
</span>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
class="ml-1 h-4 w-4 stroke-current transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
<path
|
||||
d="M6.75 5.75 9.25 8l-2.5 2.25"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Topics/Tags Section - Improved for mobile -->
|
||||
{
|
||||
allTags.length > 0 && (
|
||||
<section class="theme-transition-all border-t border-zinc-100 px-4 py-10 dark:border-zinc-800 sm:px-6 sm:py-12 md:py-16">
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<h2 class="theme-transition-color mb-6 text-center text-xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:mb-8 sm:text-left sm:text-2xl md:text-3xl">
|
||||
Explore Topics
|
||||
</h2>
|
||||
|
||||
<div class="mx-auto grid max-w-xs grid-cols-1 gap-3 sm:max-w-none sm:grid-cols-2 sm:gap-4 md:grid-cols-3">
|
||||
{allTags.map((tag) => {
|
||||
const tagCount = posts.filter((post) => post.tags && post.tags.includes(tag)).length;
|
||||
return (
|
||||
<a
|
||||
href={`/topics/${tag}`}
|
||||
class="theme-transition-all group flex min-h-[80px] flex-col rounded-xl border border-zinc-200 p-3 transition-all duration-300 hover:bg-zinc-50 dark:border-zinc-800 dark:hover:bg-zinc-800/70 sm:min-h-[90px] sm:p-4 md:p-6"
|
||||
>
|
||||
<div class="mb-2 flex items-start justify-between">
|
||||
<span class="theme-transition-color mr-2 text-sm font-medium text-zinc-900 dark:text-zinc-100">
|
||||
#{tag}
|
||||
</span>
|
||||
<span class="theme-transition-all flex-shrink-0 rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
{tagCount} {tagCount === 1 ? 'post' : 'posts'}
|
||||
</span>
|
||||
</div>
|
||||
<p class="theme-transition-color mt-1 text-xs text-zinc-600 dark:text-zinc-400">
|
||||
Explore articles about {tag}
|
||||
</p>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-center sm:mt-8">
|
||||
<a
|
||||
href="/tags"
|
||||
class="theme-transition-color inline-flex min-h-[44px] items-center text-sm font-medium text-zinc-900 hover:text-zinc-700 dark:text-zinc-100 dark:hover:text-zinc-300"
|
||||
>
|
||||
<span>View all topics</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="ml-1 h-4 w-4 transition-transform duration-300 group-hover:translate-x-1"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
</Layout>
|
||||
|
||||
<script>
|
||||
// Add 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 -left-20 -top-20 h-48 w-48 rounded-full bg-zinc-100 opacity-30 blur-3xl dark:bg-zinc-900/30 sm:h-64 sm:w-64"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-2000 absolute -bottom-10 -right-10 h-36 w-36 rounded-full bg-zinc-200 opacity-20 blur-2xl dark:bg-zinc-900/20 sm:h-48 sm:w-48"
|
||||
>
|
||||
</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-sm dark:bg-zinc-800 sm:mx-0"
|
||||
>
|
||||
<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 dark:text-zinc-100 sm:text-4xl"
|
||||
>
|
||||
<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 dark:text-zinc-400 sm:mx-0 sm:text-lg"
|
||||
>
|
||||
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 dark:text-zinc-100 sm:text-left">
|
||||
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 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 dark:border-zinc-800 dark:hover:bg-zinc-900/50 sm:mx-0 sm:p-8">
|
||||
<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 flex-shrink-0 overflow-hidden rounded-xl shadow-sm 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 dark:text-zinc-400 sm:mb-3 sm:justify-start sm:gap-4 sm:text-sm">
|
||||
{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 dark:text-zinc-100 dark:group-hover:text-zinc-300 sm:mb-3 sm:text-left sm:text-2xl">
|
||||
<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 dark:text-zinc-400 sm:line-clamp-3 sm:text-left sm:text-base">
|
||||
{post.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-end justify-center border-t border-zinc-100 pt-4 dark:border-zinc-800 sm:justify-between">
|
||||
{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:ml-auto sm:mr-0">
|
||||
<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 left-0 top-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 dark:bg-zinc-800 sm:mb-6 sm:h-20 sm:w-20">
|
||||
<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 dark:text-zinc-400 sm:h-10 sm:w-10"
|
||||
>
|
||||
<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 dark:text-zinc-100 sm:text-2xl">
|
||||
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 -left-16 -top-16 h-36 w-36 rounded-full bg-zinc-100 opacity-50 blur-3xl dark:bg-zinc-800/50 sm:h-48 sm:w-48 md:h-72 md:w-72"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-2000 theme-transition-bg absolute -bottom-16 -right-16 h-36 w-36 rounded-full bg-zinc-200 opacity-30 blur-3xl dark:bg-zinc-800/30 sm:h-48 sm:w-48 md:h-72 md:w-72"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="animate-blob animation-delay-4000 theme-transition-bg absolute right-8 top-8 h-24 w-24 rounded-full bg-zinc-100/30 opacity-40 blur-2xl dark:bg-zinc-700/20 sm:h-32 sm:w-32 md:h-40 md:w-40"
|
||||
>
|
||||
</div>
|
||||
|
||||
<h1
|
||||
class="theme-transition-color relative mb-3 text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:mb-4 sm:text-4xl md:mb-6 md:text-5xl lg:text-6xl"
|
||||
>
|
||||
<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-sm 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 dark:from-zinc-600 dark:to-zinc-400 sm:-bottom-2 sm:h-1"
|
||||
></span>
|
||||
</span>
|
||||
</span>
|
||||
</h1>
|
||||
<p
|
||||
class="theme-transition-color relative mx-auto max-w-2xl text-sm text-zinc-600 dark:text-zinc-400 sm:text-base md:text-lg lg:text-xl"
|
||||
>
|
||||
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 dark:bg-zinc-800 sm:mb-4 sm:h-20 sm:w-20 md:mb-6 md:h-24 md:w-24">
|
||||
<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 dark:text-zinc-400 sm:h-10 sm:w-10 md:h-12 md:w-12"
|
||||
>
|
||||
<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 dark:text-zinc-200 sm:text-xl md:text-2xl">
|
||||
No tags found yet.
|
||||
</p>
|
||||
<p class="theme-transition-color mt-2 text-xs text-zinc-500 dark:text-zinc-500 sm:text-sm md:text-base">
|
||||
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-sm dark:border-zinc-800 dark:bg-zinc-900/50 sm:rounded-xl sm:p-4 md:rounded-2xl md:p-6 lg:rounded-3xl lg:p-8">
|
||||
<div class="bg-grid-pattern theme-transition-bg absolute inset-0 opacity-5 dark:opacity-10" />
|
||||
<div class="theme-transition-bg absolute -right-8 -top-8 h-20 w-20 rounded-full bg-gradient-to-br from-zinc-200/30 to-zinc-300/20 blur-xl dark:from-zinc-700/20 dark:to-zinc-800/10 sm:h-24 sm:w-24 md:h-32 md:w-32 lg:h-40 lg:w-40" />
|
||||
<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 dark:from-zinc-700/20 dark:to-zinc-800/10 sm:h-24 sm:w-24 md:h-32 md:w-32 lg:h-40 lg:w-40" />
|
||||
|
||||
<h2 class="theme-transition-color mb-3 text-center text-lg font-bold text-zinc-900 dark:text-zinc-100 sm:mb-4 sm:text-xl md:mb-6 md:text-2xl lg:mb-8 lg:text-3xl">
|
||||
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 flex-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 dark:border-zinc-800 dark:hover:border-zinc-700 sm:rounded-lg sm:hover:shadow-lg md:rounded-xl"
|
||||
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 flex-shrink-0 items-center justify-center rounded-full bg-zinc-100 text-zinc-700 shadow-sm transition-all duration-300 dark:bg-zinc-800 dark:text-zinc-300 sm:h-8 sm:w-8 md:h-10 md:w-10">
|
||||
<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 hyphens-auto break-words text-[10px] font-bold text-zinc-900 transition-colors group-hover:text-zinc-700 dark:text-zinc-100 dark:group-hover:text-zinc-300 sm:text-sm md:text-base">
|
||||
{tag.name}
|
||||
</h3>
|
||||
<p class="xxxs:text-[9px] xxs:text-[9px] xs:text-[10px] theme-transition-color truncate text-[8px] text-zinc-500 dark:text-zinc-400 sm:text-xs md:text-xs">
|
||||
{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 */
|
||||
.flex-grow {
|
||||
flex-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 {
|
||||
@reference 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 {
|
||||
@reference 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.2s ease;
|
||||
}
|
||||
|
||||
.stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
a:hover,
|
||||
button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.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;
|
||||
/* Smooth page transitions */
|
||||
.page-transition {
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
transform 0.3s ease;
|
||||
}
|
||||
|
||||
@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-entering {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.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,3 +1,29 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict"
|
||||
}
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"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"]
|
||||
}
|
||||
|