Compare commits
1 Commits
1.0.0
...
2a4306e3b4
Author | SHA1 | Date | |
---|---|---|---|
2a4306e3b4
|
@@ -1,5 +1,3 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
.astro
|
|
||||||
.vscode
|
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
@@ -1,40 +0,0 @@
|
|||||||
name: process-repository
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: "@daily"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
process-repository:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout Python Script
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
repository: alexlebens/workflow-scripts
|
|
||||||
ref: main
|
|
||||||
token: ${{ secrets.BOT_TOKEN }}
|
|
||||||
path: workflow-scripts
|
|
||||||
|
|
||||||
- name: Set up Python
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.13"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install requests immutabledict
|
|
||||||
|
|
||||||
- name: Run Script
|
|
||||||
env:
|
|
||||||
INSTANCE_URL: ${{ vars.INSTANCE_URL }}
|
|
||||||
OWNER: ${{ gitea.owner }}
|
|
||||||
REPOSITORY: ${{ gitea.repository }}
|
|
||||||
TOKEN: ${{ secrets.BOT_TOKEN }}
|
|
||||||
LOG_LEVEL: DEBUG
|
|
||||||
ISSUE_STALE_DAYS: 3
|
|
||||||
ISSUE_STALE_TAG: 23
|
|
||||||
ISSUE_EXCLUDE_TAG: 17
|
|
||||||
PULL_REQUEST_STALE_DAYS: 3
|
|
||||||
PULL_REQUEST_STALE_TAG: 23
|
|
||||||
PULL_REQUEST_REQUIRED_TAG: 22
|
|
||||||
run: python ./workflow-scripts/process-repository.py
|
|
67
.gitea/workflows/release-image-gitea.yml
Normal file
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
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 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 }}
|
@@ -1,98 +0,0 @@
|
|||||||
name: release-image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 0.*
|
|
||||||
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Login to Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ vars.REPOSITORY_HOST }}
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.REPOSITORY_TOKEN }}
|
|
||||||
|
|
||||||
- name: Login to Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ vars.REGISTRY_HOST }}
|
|
||||||
username: ${{ vars.REGISTRY_USER }}
|
|
||||||
password: ${{ secrets.REGISTRY_SECRET }}
|
|
||||||
|
|
||||||
- name: Create Kubeconfig
|
|
||||||
run: |
|
|
||||||
mkdir $HOME/.kube
|
|
||||||
echo "${{ secrets.KUBECONFIG_BUILDX }}" > $HOME/.kube/config
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
id: buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
with:
|
|
||||||
driver: kubernetes
|
|
||||||
driver-opts: |
|
|
||||||
namespace=gitea
|
|
||||||
qemu.install=true
|
|
||||||
buildkitd-config-inline: |
|
|
||||||
[registry."docker.io"]
|
|
||||||
mirrors = ["harbor.alexlebens.net/proxy-hub.docker/"]
|
|
||||||
|
|
||||||
- name: Available Platforms
|
|
||||||
run: echo ${{ steps.buildx.outputs.platforms }}
|
|
||||||
|
|
||||||
- name: Extract Metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
tags: |
|
|
||||||
type=ref,event=branch
|
|
||||||
type=ref,event=tag
|
|
||||||
images: |
|
|
||||||
${{ vars.REPOSITORY_HOST }}/${{ gitea.repository }}
|
|
||||||
${{ vars.REGISTRY_HOST }}/images/site-profile
|
|
||||||
|
|
||||||
- name: Build and Push Image
|
|
||||||
uses: docker/build-push-action@v6
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
platforms: linux/amd64
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
file: ./Dockerfile
|
|
||||||
|
|
||||||
- name: ntfy Success
|
|
||||||
uses: niniyas/ntfy-action@master
|
|
||||||
if: success()
|
|
||||||
with:
|
|
||||||
url: '${{ secrets.NTFY_URL }}'
|
|
||||||
topic: '${{ secrets.NTFY_TOPIC }}'
|
|
||||||
title: 'Gitea Action'
|
|
||||||
priority: 3
|
|
||||||
headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}'
|
|
||||||
tags: action,successfully,completed
|
|
||||||
details: 'Site Profile build workflow has successfully completed!'
|
|
||||||
icon: 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/gitea.png'
|
|
||||||
|
|
||||||
- name: ntfy Failed
|
|
||||||
uses: niniyas/ntfy-action@master
|
|
||||||
if: failure()
|
|
||||||
with:
|
|
||||||
url: '${{ secrets.NTFY_URL }}'
|
|
||||||
topic: '${{ secrets.NTFY_TOPIC }}'
|
|
||||||
title: 'Gitea Action'
|
|
||||||
priority: 4
|
|
||||||
headers: '{"Authorization": "Bearer ${{ secrets.NTFY_CRED }}"}'
|
|
||||||
tags: action,failed
|
|
||||||
details: 'Site Profile build workflow has failed!'
|
|
||||||
icon: 'https://cdn.jsdelivr.net/gh/selfhst/icons/png/gitea.png'
|
|
||||||
actions: '[{"action": "view", "label": "Open Gitea", "url": "https://gitea.alexlebens.dev/alexlebens/site-profile/actions?workflow=release-image.yml", "clear": true}]'
|
|
||||||
image: true
|
|
@@ -2,7 +2,7 @@ name: renovate
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '@daily'
|
- cron: "@daily"
|
||||||
|
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
@@ -13,20 +13,18 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
renovate:
|
renovate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: ghcr.io/renovatebot/renovate:41
|
container: ghcr.io/renovatebot/renovate:40
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v4
|
||||||
uses: actions/checkout@v4
|
- run: renovate
|
||||||
|
|
||||||
- name: Renovate
|
|
||||||
run: renovate
|
|
||||||
env:
|
env:
|
||||||
RENOVATE_PLATFORM: gitea
|
RENOVATE_PLATFORM: gitea
|
||||||
RENOVATE_ENDPOINT: ${{ vars.INSTANCE_URL }}
|
RENOVATE_AUTODISCOVER: true
|
||||||
RENOVATE_REPOSITORIES: alexlebens/site-profile
|
RENOVATE_ONBOARDING: true
|
||||||
|
RENOVATE_ENDPOINT: http://gitea-http.gitea:3000
|
||||||
RENOVATE_GIT_AUTHOR: Renovate Bot <renovate-bot@alexlebens.net>
|
RENOVATE_GIT_AUTHOR: Renovate Bot <renovate-bot@alexlebens.net>
|
||||||
LOG_LEVEL: info
|
LOG_LEVEL: debug
|
||||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
RENOVATE_GIT_PRIVATE_KEY: ${{ secrets.RENOVATE_GIT_PRIVATE_KEY }}
|
RENOVATE_GIT_PRIVATE_KEY: ${{ secrets.RENOVATE_GIT_PRIVATE_KEY }}
|
||||||
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}
|
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}
|
||||||
RENOVATE_REDIS_URL: ${{ vars.RENOVATE_REDIS_URL }}
|
RENOVATE_REDIS_URL: redis://gitea-renovate-valkey-primary.gitea:6379
|
||||||
|
@@ -1,37 +0,0 @@
|
|||||||
name: test-build
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 10.x
|
|
||||||
|
|
||||||
- name: Set up Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22.17.1
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: pnpm install
|
|
||||||
|
|
||||||
- name: Lint Code
|
|
||||||
run: pnpm lint
|
|
||||||
|
|
||||||
- name: Build Project
|
|
||||||
run: pnpm build
|
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,3 +24,4 @@ pnpm-debug.log*
|
|||||||
# ide
|
# ide
|
||||||
.vscode/
|
.vscode/
|
||||||
site-profile.code-workspace
|
site-profile.code-workspace
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
1
.npmrc
1
.npmrc
@@ -1,2 +1,3 @@
|
|||||||
engine-strict=true
|
engine-strict=true
|
||||||
save-exact=true
|
save-exact=true
|
||||||
|
|
||||||
|
18
.prettierrc
Normal file
18
.prettierrc
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"useTabs": false,
|
||||||
|
"plugins": ["prettier-plugin-astro", "prettier-plugin-tailwindcss"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.astro",
|
||||||
|
"options": {
|
||||||
|
"parser": "astro"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
4
.vscode/extensions.json
vendored
Normal file
4
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["astro-build.astro-vscode"],
|
||||||
|
"unwantedRecommendations": []
|
||||||
|
}
|
11
.vscode/launch.json
vendored
Normal file
11
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "./node_modules/.bin/astro dev",
|
||||||
|
"name": "Development server",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "node-terminal"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
11
Dockerfile
11
Dockerfile
@@ -1,8 +1,7 @@
|
|||||||
ARG REGISTRY=docker.io
|
FROM node:22.15.1-alpine3.20 AS base
|
||||||
FROM ${REGISTRY}/node:22.17.1-alpine3.22 AS base
|
|
||||||
|
|
||||||
LABEL version="1.0.0"
|
LABEL version="0.7.0"
|
||||||
LABEL description="Astro based personal website"
|
LABEL description="Astro based website to use as a personal site"
|
||||||
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
@@ -21,7 +20,6 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
|
|||||||
FROM build-deps AS build
|
FROM build-deps AS build
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
RUN pnpm prune --prod
|
|
||||||
|
|
||||||
FROM base AS runtime
|
FROM base AS runtime
|
||||||
COPY --from=prod-deps /app/node_modules /app/node_modules
|
COPY --from=prod-deps /app/node_modules /app/node_modules
|
||||||
@@ -31,6 +29,5 @@ ENV HOST=0.0.0.0
|
|||||||
ENV SITE_URL=https://www.alexlebens.dev
|
ENV SITE_URL=https://www.alexlebens.dev
|
||||||
ENV DIRECTUS_URL=https://directus.alexlebens.dev
|
ENV DIRECTUS_URL=https://directus.alexlebens.dev
|
||||||
ENV PORT=4321
|
ENV PORT=4321
|
||||||
|
|
||||||
EXPOSE $PORT
|
EXPOSE $PORT
|
||||||
CMD ["node", "./dist/server/entry.mjs"]
|
CMD node ./dist/server/entry.mjs
|
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
Copyright (c) 2025 Lê Vĩnh Khang
|
Copyright (c) 2025 Lê Vĩnh Khang
|
||||||
|
|
||||||
Copyright (c) 2025 Alex Lebens
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
|
14
README.md
14
README.md
@@ -21,7 +21,7 @@ Personal site used for information about myself and blog.
|
|||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Node.js 22+ and pnpm
|
- Node.js 16+ and pnpm/yarn
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
@@ -30,18 +30,22 @@ Personal site used for information about myself and blog.
|
|||||||
git clone https://gitea.alexlebens.dev/alexlebens/site-profile
|
git clone https://gitea.alexlebens.dev/alexlebens/site-profile
|
||||||
|
|
||||||
# Navigate to project directory
|
# Navigate to project directory
|
||||||
cd site-profile
|
cd astro-blog
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
pnpm install
|
pnpm install
|
||||||
|
|
||||||
|
# Create .env file from template
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Edit .env with your information
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start development server
|
# Start development server
|
||||||
pnpm dev
|
pnpm run dev
|
||||||
|
|
||||||
# Open browser at http://localhost:4321
|
# Open browser at http://localhost:4321
|
||||||
```
|
```
|
||||||
@@ -50,10 +54,10 @@ pnpm dev
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Create production build
|
# Create production build
|
||||||
pnpm build
|
pnpm run build
|
||||||
|
|
||||||
# Preview production build
|
# Preview production build
|
||||||
pnpm preview
|
pnpm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
@@ -1,9 +1,7 @@
|
|||||||
import { defineConfig } from 'astro/config';
|
import { defineConfig } from 'astro/config';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwind from '@astrojs/tailwind';
|
||||||
import react from '@astrojs/react';
|
import react from '@astrojs/react';
|
||||||
|
|
||||||
import node from '@astrojs/node';
|
|
||||||
|
|
||||||
const getSiteURL = () => {
|
const getSiteURL = () => {
|
||||||
if (process.env.SITE_URL) {
|
if (process.env.SITE_URL) {
|
||||||
return `https://${process.env.SITE_URL}`;
|
return `https://${process.env.SITE_URL}`;
|
||||||
@@ -13,17 +11,8 @@ const getSiteURL = () => {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
site: getSiteURL(),
|
site: getSiteURL(),
|
||||||
integrations: [tailwindcss(), react()],
|
integrations: [
|
||||||
|
tailwind(),
|
||||||
plugins: {
|
react(),
|
||||||
'@tailwindcss/postcss': {},
|
],
|
||||||
},
|
|
||||||
|
|
||||||
vite: {
|
|
||||||
plugins: [tailwindcss()],
|
|
||||||
},
|
|
||||||
|
|
||||||
adapter: node({
|
|
||||||
mode: 'standalone',
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
@@ -1,11 +0,0 @@
|
|||||||
import eslintPluginAstro from 'eslint-plugin-astro';
|
|
||||||
import eslintConfigPrettier from "eslint-config-prettier/flat";
|
|
||||||
|
|
||||||
export default [
|
|
||||||
...eslintPluginAstro.configs.recommended,
|
|
||||||
eslintConfigPrettier,
|
|
||||||
{
|
|
||||||
rules: {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
@@ -1,4 +1,4 @@
|
|||||||
import { createDirectus, rest } from '@directus/sdk';
|
import { createDirectus, rest, } from '@directus/sdk';
|
||||||
|
|
||||||
type Global = {
|
type Global = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -9,41 +9,39 @@ type Global = {
|
|||||||
email: string;
|
email: string;
|
||||||
portrait: string;
|
portrait: string;
|
||||||
portrait_alt: string;
|
portrait_alt: string;
|
||||||
logo: string;
|
|
||||||
about: string;
|
about: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
type About = {
|
type About = {
|
||||||
background: string;
|
background: string;
|
||||||
experience: string;
|
experience: string;
|
||||||
education: string;
|
education: string;
|
||||||
certifications: string;
|
certifications: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
type Links = {
|
type Links = {
|
||||||
github: string;
|
github: string;
|
||||||
linkedin: string;
|
linkedin: string;
|
||||||
gitea: string;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
type Skill = {
|
type Skill = {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
level: string;
|
level: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export type Post = {
|
export type Post = {
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
content: string;
|
content: string;
|
||||||
image: string;
|
image: string;
|
||||||
image_alt: string;
|
image_alt: string;
|
||||||
published_date: Date;
|
published_date: Date;
|
||||||
updated_date: Date;
|
updated_date: Date;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
};
|
}
|
||||||
|
|
||||||
type Schema = {
|
type Schema = {
|
||||||
global: Global;
|
global: Global;
|
||||||
@@ -51,10 +49,8 @@ type Schema = {
|
|||||||
links: Links;
|
links: Links;
|
||||||
skills: Skill[];
|
skills: Skill[];
|
||||||
posts: Post[];
|
posts: Post[];
|
||||||
};
|
}
|
||||||
|
|
||||||
const directus = createDirectus<Schema>(
|
const directus = createDirectus<Schema>(process.env.DIRECTUS_URL ?? "https://directus.alexlebens.dev").with(rest());
|
||||||
process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'
|
|
||||||
).with(rest());
|
|
||||||
|
|
||||||
export default directus;
|
export default directus;
|
||||||
|
25
package.json
25
package.json
@@ -1,15 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "site-profile",
|
"name": "site-profile",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.0.0",
|
"version": "0.7.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"build": "astro build",
|
"build": "astro build",
|
||||||
"preview": "astro preview",
|
"preview": "astro preview",
|
||||||
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,md,mdx,astro}\"",
|
|
||||||
"lint": "eslint \"src/**/*.{js,ts,jsx,tsx,astro}\"",
|
|
||||||
"lint:fix": "eslint --fix \"src/**/*.{js,ts,jsx,tsx,astro}\"",
|
|
||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -17,27 +14,23 @@
|
|||||||
"@astrojs/node": "^9.2.2",
|
"@astrojs/node": "^9.2.2",
|
||||||
"@astrojs/react": "^4.3.0",
|
"@astrojs/react": "^4.3.0",
|
||||||
"@astrojs/rss": "^4.0.12",
|
"@astrojs/rss": "^4.0.12",
|
||||||
"@directus/sdk": "^20.0.0",
|
"@astrojs/sitemap": "^3.4.1",
|
||||||
"@tailwindcss/postcss": "^4.1.8",
|
"@astrojs/tailwind": "^6.0.2",
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@directus/sdk": "^19.1.0",
|
||||||
"astro": "^5.10.1",
|
"astro": "^5.9.1",
|
||||||
"framer-motion": "^12.16.0",
|
"framer-motion": "^12.16.0",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hotkeys-hook": "^5.1.0",
|
"react-hotkeys-hook": "^5.1.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"reading-time": "^1.5.0",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
"tailwindcss": "^4.1.8"
|
"tailwindcss": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@typescript-eslint/parser": "8.38.0",
|
|
||||||
"eslint": "9.31.0",
|
|
||||||
"eslint-config-prettier": "10.1.8",
|
|
||||||
"eslint-plugin-astro": "1.3.1",
|
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-astro": "^0.14.1",
|
"prettier-plugin-astro": "^0.12.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.12",
|
"prettier-plugin-tailwindcss": "^0.5.14"
|
||||||
"typescript-eslint": "8.38.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
2500
pnpm-lock.yaml
generated
2500
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,13 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
/** @type {import('postcss-load-config').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
plugins: {
|
plugins: {
|
||||||
'@tailwindcss/postcss': {},
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
'postcss-preset-env': {
|
||||||
|
features: {
|
||||||
|
'nesting-rules': false,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -1,23 +0,0 @@
|
|||||||
/** @type {import("prettier").Config} */
|
|
||||||
const config = {
|
|
||||||
printWidth: 100,
|
|
||||||
semi: true,
|
|
||||||
singleQuote: true,
|
|
||||||
tabWidth: 2,
|
|
||||||
trailingComma: 'es5',
|
|
||||||
useTabs: false,
|
|
||||||
plugins: [
|
|
||||||
'prettier-plugin-astro',
|
|
||||||
'prettier-plugin-tailwindcss',
|
|
||||||
],
|
|
||||||
overrides: [
|
|
||||||
{
|
|
||||||
files: '*.astro',
|
|
||||||
options: {
|
|
||||||
parser: 'astro',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export default config;
|
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 713 B |
@@ -6,35 +6,9 @@
|
|||||||
":rebaseStalePrs"
|
":rebaseStalePrs"
|
||||||
],
|
],
|
||||||
"timezone": "US/Central",
|
"timezone": "US/Central",
|
||||||
|
"schedule": [ "* */1 * * *" ],
|
||||||
"labels": [],
|
"labels": [],
|
||||||
"prHourlyLimit": 0,
|
"prHourlyLimit": 0,
|
||||||
"prConcurrentLimit": 0,
|
"prConcurrentLimit": 0,
|
||||||
"packageRules": [
|
"packageRules": []
|
||||||
{
|
|
||||||
"description": "Label dependency",
|
|
||||||
"matchDatasources": [
|
|
||||||
"npm"
|
|
||||||
],
|
|
||||||
"addLabels": [
|
|
||||||
"dependency"
|
|
||||||
],
|
|
||||||
"automerge": false,
|
|
||||||
"minimumReleaseAge": "1 days"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "Automerge dependency patch",
|
|
||||||
"matchDatasources": [
|
|
||||||
"npm"
|
|
||||||
],
|
|
||||||
"matchUpdateTypes": [
|
|
||||||
"patch"
|
|
||||||
],
|
|
||||||
"addLabels": [
|
|
||||||
"dependency",
|
|
||||||
"automerge"
|
|
||||||
],
|
|
||||||
"automerge": true,
|
|
||||||
"minimumReleaseAge": "1 days"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
@@ -1,47 +1,39 @@
|
|||||||
---
|
---
|
||||||
|
// Background.astro - Dot pattern and ambient glow background with smooth theme transitions
|
||||||
|
---
|
||||||
|
|
||||||
---
|
<div class="fixed inset-0 -z-10 overflow-hidden theme-transition-all">
|
||||||
|
|
||||||
<div class="theme-transition-all fixed inset-0 -z-10 overflow-hidden">
|
|
||||||
<!-- Dot pattern background -->
|
<!-- Dot pattern background -->
|
||||||
<div
|
<div class="absolute inset-0 bg-grid-pattern bg-[center_top_-1px] [mask-image:radial-gradient(white,transparent_85%)] theme-transition-bg"></div>
|
||||||
class="bg-grid-pattern theme-transition-bg absolute inset-0 [mask-image:radial-gradient(white,transparent_85%)] bg-[center_top_-1px]"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ambient glow effects -->
|
<!-- Ambient glow effects -->
|
||||||
<div
|
<div class="absolute left-1/4 top-1/4 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-zinc-400/20 dark:bg-zinc-500/20 rounded-full blur-3xl opacity-50 animate-glow theme-transition-bg"></div>
|
||||||
class="animate-glow theme-transition-bg absolute top-1/4 left-1/4 h-96 w-96 -translate-x-1/2 -translate-y-1/2 rounded-full bg-zinc-400/20 opacity-50 blur-3xl dark:bg-zinc-500/20"
|
<div class="absolute right-1/4 bottom-1/3 translate-x-1/2 translate-y-1/2 w-64 h-64 bg-zinc-300/20 dark:bg-zinc-600/20 rounded-full blur-3xl opacity-40 animate-glow animation-delay-1000 theme-transition-bg"></div>
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="animate-glow animation-delay-1000 theme-transition-bg absolute right-1/4 bottom-1/3 h-64 w-64 translate-x-1/2 translate-y-1/2 rounded-full bg-zinc-300/20 opacity-40 blur-3xl dark:bg-zinc-600/20"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Theme transition overlay -->
|
<!-- Theme transition overlay -->
|
||||||
<div
|
<div id="theme-transition-overlay" class="absolute inset-0 bg-white dark:bg-zinc-900 opacity-0 pointer-events-none"></div>
|
||||||
id="theme-transition-overlay"
|
|
||||||
class="pointer-events-none absolute inset-0 bg-white opacity-0 dark:bg-zinc-900"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Theme transition script
|
// Theme transition script
|
||||||
document.addEventListener('astro:page-load', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const themeToggle = document.querySelector('[data-theme-toggle]');
|
const themeToggle = document.querySelector('[data-theme-toggle]');
|
||||||
const overlay = document.getElementById('theme-transition-overlay');
|
const overlay = document.getElementById('theme-transition-overlay');
|
||||||
|
|
||||||
if (themeToggle && overlay) {
|
if (themeToggle && overlay) {
|
||||||
themeToggle.addEventListener('click', () => {
|
themeToggle.addEventListener('click', () => {
|
||||||
|
// Add transitioning class to optimize performance
|
||||||
document.documentElement.classList.add('theme-transitioning');
|
document.documentElement.classList.add('theme-transitioning');
|
||||||
|
|
||||||
|
// Fade in overlay
|
||||||
overlay.style.opacity = '0.15';
|
overlay.style.opacity = '0.15';
|
||||||
overlay.style.transition = 'opacity 0.3s ease';
|
overlay.style.transition = 'opacity 0.3s ease';
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
// Fade out overlay
|
||||||
overlay.style.opacity = '0';
|
overlay.style.opacity = '0';
|
||||||
|
|
||||||
|
// Remove transitioning class after animation completes
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
document.documentElement.classList.remove('theme-transitioning');
|
document.documentElement.classList.remove('theme-transitioning');
|
||||||
}, 700);
|
}, 700);
|
||||||
@@ -55,21 +47,19 @@
|
|||||||
/* Grid pattern for dots */
|
/* Grid pattern for dots */
|
||||||
.bg-grid-pattern {
|
.bg-grid-pattern {
|
||||||
background-size: 24px 24px;
|
background-size: 24px 24px;
|
||||||
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.2) 1px, transparent 1px);
|
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);
|
transition: background-image 0.7s cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode version */
|
/* Dark mode version */
|
||||||
:global(.dark) .bg-grid-pattern {
|
:global(.dark) .bg-grid-pattern {
|
||||||
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.15) 1px, transparent 1px);
|
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.1) 1px, transparent 1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ambient glow animations */
|
/* Ambient glow animations */
|
||||||
.animate-glow {
|
.animate-glow {
|
||||||
animation: glow 12s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
animation: glow 12s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
transition:
|
transition: background-color 0.7s cubic-bezier(0.65, 0, 0.35, 1), opacity 0.7s cubic-bezier(0.65, 0, 0.35, 1);
|
||||||
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-1000 {
|
||||||
@@ -77,8 +67,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes glow {
|
@keyframes glow {
|
||||||
0%,
|
0%, 100% {
|
||||||
100% {
|
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
transform: translate(0, 0) scale(1);
|
transform: translate(0, 0) scale(1);
|
||||||
}
|
}
|
||||||
|
@@ -1,178 +1,122 @@
|
|||||||
---
|
---
|
||||||
import directus from '../../lib/directus';
|
import directus from "../../lib/directus"
|
||||||
import { readSingleton } from '@directus/sdk';
|
import { readSingleton } from "@directus/sdk";
|
||||||
|
|
||||||
const global = await directus.request(readSingleton('global'));
|
const global = await directus.request(readSingleton("global"));
|
||||||
const links = await directus.request(readSingleton('links'));
|
const links = await directus.request(readSingleton("links"));
|
||||||
|
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ text: 'Home', href: '/' },
|
|
||||||
{ text: 'Blog', href: '/blog' },
|
|
||||||
{ text: 'About', href: '/about' },
|
{ text: 'About', href: '/about' },
|
||||||
{ text: 'RSS', href: '/rss' },
|
{ text: 'Blog', href: '/blog' },
|
||||||
|
{ text: 'Topics', href: '/topics' },
|
||||||
|
{ text: 'RSS', href: '/rss.xml' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const socialLinks = [
|
const socialLinks = [
|
||||||
{
|
{
|
||||||
name: 'GitHub',
|
name: 'GitHub',
|
||||||
href: links.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>`,
|
icon: `<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path>`
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Gitea',
|
|
||||||
href: links.gitea,
|
|
||||||
icon: `<path d="M7 5C7 3.89543 7.89543 3 9 3C10.1046 3 11 3.89543 11 5C11 5.34168 10.9143 5.66336 10.7633 5.9447H11.3438C13.5529 5.9447 15.3438 7.73556 15.3438 9.9447V11.2244C15.9301 11.5731 16.323 12.213 16.323 12.9447C16.323 14.0493 15.4276 14.9447 14.323 14.9447C13.2184 14.9447 12.323 14.0493 12.323 12.9447C12.323 12.1959 12.7345 11.5432 13.3438 11.2004V9.9447C13.3438 8.84013 12.4483 7.9447 11.3438 7.9447H10V17.2676C10.5978 17.6134 11 18.2597 11 19C11 20.1046 10.1046 21 9 21C7.89543 21 7 20.1046 7 19C7 18.2597 7.4022 17.6134 8 17.2676V6.73244C7.4022 6.38663 7 5.74028 7 5Z" fill="currentColor"/>`,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'LinkedIn',
|
name: 'LinkedIn',
|
||||||
href: links.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>`,
|
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
|
<footer class="relative mt-20 overflow-hidden border-t border-zinc-100 dark:border-zinc-800 theme-transition-all">
|
||||||
class="theme-transition-all relative mt-20 overflow-hidden border-t border-zinc-100 dark:border-zinc-800"
|
<div class="absolute inset-0 pointer-events-none overflow-hidden">
|
||||||
transition:animate="none"
|
<div class="absolute -top-40 -right-40 w-80 h-80 bg-zinc-100 dark:bg-zinc-800/30 rounded-full blur-3xl opacity-50 theme-transition-all animate-float-slow"></div>
|
||||||
>
|
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-zinc-100 dark:bg-zinc-800/30 rounded-full blur-3xl opacity-50 theme-transition-all animate-float-slow animation-delay-2000"></div>
|
||||||
<div class="pointer-events-none absolute inset-0 overflow-hidden">
|
<div class="absolute top-20 left-1/4 w-40 h-40 bg-zinc-200/50 dark:bg-zinc-700/20 rounded-full blur-2xl opacity-30 theme-transition-all animate-float-slow animation-delay-1000"></div>
|
||||||
<div
|
|
||||||
class="theme-transition-all animate-float-slow absolute -top-40 -right-40 h-80 w-80 rounded-full bg-zinc-100 opacity-50 blur-3xl dark:bg-zinc-800/30"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="theme-transition-all animate-float-slow animation-delay-2000 absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-zinc-100 opacity-50 blur-3xl dark:bg-zinc-800/30"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="theme-transition-all animate-float-slow animation-delay-1000 absolute top-20 left-1/4 h-40 w-40 rounded-full bg-zinc-200/50 opacity-30 blur-2xl dark:bg-zinc-700/20"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative px-4 pt-16 pb-12 sm:px-6">
|
<div class="relative pt-16 pb-12 px-4 sm:px-6">
|
||||||
<div class="mx-auto max-w-4xl">
|
<div class="max-w-4xl mx-auto">
|
||||||
<div class="grid grid-cols-1 gap-10 md:grid-cols-12">
|
<!-- Main footer content -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-12 gap-10">
|
||||||
<!-- Brand section -->
|
<!-- Brand section -->
|
||||||
<div class="col-span-1 md:col-span-3">
|
<div class="col-span-1 md:col-span-3">
|
||||||
<a href="/" class="group inline-block">
|
<a href="/" class="inline-block group">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="mx-auto aspect-square overflow-hidden rounded-lg">
|
<div class="relative w-10 h-10 rounded-lg bg-gradient-to-br from-zinc-800 to-zinc-600 dark:from-zinc-200 dark:to-zinc-400 flex items-center justify-center overflow-hidden shadow-lg transform transition-transform group-hover:scale-105">
|
||||||
<img
|
<span class="text-white dark:text-zinc-900 text-xl font-bold theme-transition-all group-hover:scale-110 transition-transform duration-300">{global.initals}</span>
|
||||||
src=`${process.env.DIRECTUS_URL ?? "https://directus.alexlebens.dev"}/assets/${global.logo}`
|
<div class="absolute inset-0 bg-gradient-to-br from-zinc-700 to-zinc-900 dark:from-zinc-300 dark:to-zinc-100 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||||
alt="logo"
|
|
||||||
class="max-h-[40px] max-w-[40px] object-cover"
|
|
||||||
loading="eager"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="ml-3 text-xl font-bold text-zinc-900 dark:text-zinc-100 theme-transition-color">Blog</span>
|
||||||
<span
|
|
||||||
class="theme-transition-color ml-3 text-xl font-bold text-zinc-900 dark:text-zinc-100"
|
|
||||||
>
|
|
||||||
Blog
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<p
|
<p class="mt-4 text-sm text-zinc-600 dark:text-zinc-400 theme-transition-color leading-relaxed">
|
||||||
class="theme-transition-color mt-4 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400"
|
|
||||||
>
|
|
||||||
{global.description}
|
{global.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Social links -->
|
<!-- Social links -->
|
||||||
<div class="mt-6 flex items-center space-x-4">
|
<div class="mt-6 flex items-center space-x-4">
|
||||||
{
|
{socialLinks.map(social => (
|
||||||
socialLinks.map((social) => (
|
<a
|
||||||
<a
|
href={social.href}
|
||||||
href={social.href}
|
target="_blank"
|
||||||
target="_blank"
|
rel="noopener noreferrer"
|
||||||
rel="noopener noreferrer"
|
class="group relative flex items-center justify-center w-10 h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 transition-all duration-300 hover:ring-2 hover:ring-zinc-300 dark:hover:ring-zinc-700 transform hover:-translate-y-1"
|
||||||
class="hover group relative flex h-10 w-10 transform items-center justify-center rounded-full bg-zinc-100 text-zinc-500 transition-all duration-300 hover:-translate-y-1 hover:text-zinc-900 hover:ring-2 hover:ring-zinc-300 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100 dark:hover:ring-zinc-700"
|
aria-label={social.name}
|
||||||
aria-label={social.name}
|
>
|
||||||
>
|
<span class="absolute inset-0 rounded-full bg-gradient-to-br from-zinc-200 to-zinc-300 dark:from-zinc-700 dark:to-zinc-600 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></span>
|
||||||
<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="w-5 h-5 relative z-10 transition-transform duration-300 group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<svg
|
<Fragment set:html={social.icon} />
|
||||||
class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
|
</svg>
|
||||||
fill="currentColor"
|
</a>
|
||||||
viewBox="0 0 24 24"
|
))}
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<Fragment set:html={social.icon} />
|
|
||||||
</svg>
|
|
||||||
</a>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick links -->
|
<!-- Quick links -->
|
||||||
<div class="col-span-1 md:col-span-3">
|
<div class="col-span-1 md:col-span-3">
|
||||||
<h3
|
<h3 class="text-sm font-semibold text-zinc-900 dark:text-zinc-100 uppercase tracking-wider theme-transition-color relative inline-block after:content-[''] after:absolute after:w-8 after:h-0.5 after:bg-zinc-300 dark:after:bg-zinc-700 after:bottom-0 after:left-0 pb-2">Navigation</h3>
|
||||||
class="theme-transition-color after:bg-turquoise dark:after:bg-turquoise relative inline-block pb-2 text-sm font-semibold tracking-wider text-zinc-900 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-8 after:content-[''] dark:text-zinc-100"
|
|
||||||
>
|
|
||||||
Navigation
|
|
||||||
</h3>
|
|
||||||
<ul class="mt-4 space-y-3">
|
<ul class="mt-4 space-y-3">
|
||||||
{
|
{navLinks.map(link => (
|
||||||
navLinks.map((link) => (
|
<li>
|
||||||
<li>
|
<a
|
||||||
<a
|
href={link.href}
|
||||||
href={link.href}
|
class="group flex items-center text-base text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 transition-colors"
|
||||||
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 overflow-hidden inline-block">
|
||||||
<span class="relative inline-block overflow-hidden">
|
<span class="relative z-10">{link.text}</span>
|
||||||
<span class="relative z-10">{link.text}</span>
|
<span class="absolute left-0 bottom-0 w-0 h-0.5 bg-zinc-800 dark:bg-zinc-200 transition-all duration-300 group-hover:w-full"></span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
))
|
))}
|
||||||
}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bottom section -->
|
<!-- Bottom section -->
|
||||||
<div class="theme-transition-all mt-12 border-t border-zinc-200 pt-8 dark:border-zinc-800">
|
<div class="mt-12 pt-8 border-t border-zinc-200 dark:border-zinc-800 theme-transition-all">
|
||||||
<div class="flex flex-col items-center justify-between gap-4 md:flex-row">
|
<div class="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
<p class="theme-transition-color text-sm text-zinc-600 dark:text-zinc-400">
|
<p class="text-sm text-zinc-600 dark:text-zinc-400 theme-transition-color">
|
||||||
© {currentYear} All rights reserved.
|
© {currentYear} All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex items-center space-x-2">
|
<div class="flex items-center space-x-2">
|
||||||
<span class="theme-transition-color text-xs text-zinc-500 dark:text-zinc-400"
|
<span class="text-xs text-zinc-500 dark:text-zinc-400 theme-transition-color">Built with</span>
|
||||||
>Built with
|
|
||||||
</span>
|
|
||||||
<a
|
<a
|
||||||
href="https://astro.build"
|
href="https://astro.build"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
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"
|
class="group inline-flex items-center text-xs text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 transition-colors"
|
||||||
>
|
>
|
||||||
<svg
|
<svg class="h-4 w-4 mr-1 text-[#FF5D01] group-hover:animate-pulse" viewBox="0 0 36 36" fill="none">
|
||||||
class="mr-1 h-4 w-4 text-[#FF5D01] group-hover:animate-pulse"
|
<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"/>
|
||||||
viewBox="0 0 36 36"
|
<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"/>
|
||||||
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>
|
</svg>
|
||||||
<span class="relative">
|
<span class="relative">
|
||||||
Astro
|
Astro
|
||||||
<span
|
<span class="absolute left-0 bottom-0 w-0 h-0.5 bg-[#FF5D01] transition-all duration-300 group-hover:w-full"></span>
|
||||||
class="absolute bottom-0 left-0 h-0.5 w-0 bg-[#FF5D01] transition-all duration-300 group-hover:w-full"
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,8 +146,7 @@ const socialLinks = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%,
|
0%, 100% {
|
||||||
100% {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
@@ -214,8 +157,7 @@ const socialLinks = [
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes float-slow {
|
@keyframes float-slow {
|
||||||
0%,
|
0%, 100% {
|
||||||
100% {
|
|
||||||
transform: translateY(0) translateX(0);
|
transform: translateY(0) translateX(0);
|
||||||
}
|
}
|
||||||
25% {
|
25% {
|
||||||
@@ -244,4 +186,5 @@ const socialLinks = [
|
|||||||
.animation-delay-2000 {
|
.animation-delay-2000 {
|
||||||
animation-delay: 2s;
|
animation-delay: 2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
@@ -8,29 +8,12 @@ const { date } = Astro.props;
|
|||||||
const parsedDate = typeof date === 'string' ? new Date(date) : date;
|
const parsedDate = typeof date === 'string' ? new Date(date) : date;
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{parsedDate && (
|
||||||
parsedDate && (
|
<time datetime={parsedDate.toISOString()}>
|
||||||
<time datetime={parsedDate.toISOString()} class="z-10 flex items-center gap-1.5">
|
{parsedDate.toLocaleDateString('en-us', {
|
||||||
<svg
|
year: 'numeric',
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
month: 'long',
|
||||||
fill="none"
|
day: 'numeric',
|
||||||
viewBox="0 0 24 24"
|
})}
|
||||||
stroke-width="1.5"
|
</time>
|
||||||
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>
|
|
||||||
{parsedDate.toLocaleDateString('en-us', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})}
|
|
||||||
</time>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
@@ -1,130 +1,82 @@
|
|||||||
---
|
---
|
||||||
import ThemeToggle from './ThemeToggle.astro';
|
import ThemeToggle from './ThemeToggle.astro';
|
||||||
|
|
||||||
import directus from '../../lib/directus';
|
import directus from "../../lib/directus"
|
||||||
import { readSingleton } from '@directus/sdk';
|
import { readSingleton } from "@directus/sdk";
|
||||||
|
|
||||||
const global = await directus.request(readSingleton('global'));
|
const global = await directus.request(readSingleton("global"));
|
||||||
const links = await directus.request(readSingleton('links'));
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ text: 'Home', href: '/' },
|
{ text: 'Home', href: '/' },
|
||||||
{ text: 'Blog', href: '/blog' },
|
{ text: 'Blog', href: '/blog' },
|
||||||
|
{ text: 'Topics', href: '/topics' },
|
||||||
{ text: 'About', href: '/about' },
|
{ text: 'About', href: '/about' },
|
||||||
{ text: 'Gitea', href: links.gitea },
|
|
||||||
{ text: 'RSS', href: 'rss.xml' },
|
{ text: 'RSS', href: 'rss.xml' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const pathname = new URL(Astro.request.url).pathname;
|
const pathname = new URL(Astro.request.url).pathname;
|
||||||
const currentPath = pathname.slice(1);
|
const currentPath = pathname.slice(1); // remove the first "/"
|
||||||
---
|
---
|
||||||
|
|
||||||
<header
|
<header class="py-4 fixed top-0 left-0 right-0 z-40 bg-white dark:bg-zinc-900 border-b border-zinc-100 dark:border-zinc-800">
|
||||||
class="fixed top-0 right-0 left-0 z-40 border-b border-zinc-100 bg-white py-4 dark:border-zinc-800 dark:bg-zinc-900"
|
<div class="max-w-3xl mx-auto px-4 flex items-center justify-between">
|
||||||
transition:animate="none"
|
|
||||||
>
|
|
||||||
<div class="mx-auto flex max-w-3xl items-center justify-between px-4">
|
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<a
|
<a href="/" class="font-bold text-xl text-zinc-900 dark:text-white">{global.initals}</a>
|
||||||
href="/"
|
|
||||||
class="from-midnight to-turquoise relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br text-xl shadow-lg transition-transform"
|
|
||||||
>
|
|
||||||
<div class="mx-auto aspect-square overflow-hidden rounded-lg">
|
|
||||||
<img
|
|
||||||
src=`${process.env.DIRECTUS_URL ?? "https://directus.alexlebens.dev"}/assets/${global.logo}`
|
|
||||||
alt="logo"
|
|
||||||
class="max-h-[40px] max-w-[40px] object-cover"
|
|
||||||
loading="eager"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Desktop navigation -->
|
<!-- Desktop navigation -->
|
||||||
<nav class="hidden items-center space-x-6 sm:flex">
|
<nav class="hidden sm:flex items-center space-x-6">
|
||||||
{
|
{navItems.map(item => {
|
||||||
navItems.map((item) => {
|
const isActive = currentPath === (item.href === '/' ? '' : item.href.slice(1));
|
||||||
const isActive = currentPath === (item.href === '/' ? '' : item.href.slice(1));
|
return (
|
||||||
return (
|
<a
|
||||||
<a
|
href={item.href}
|
||||||
href={item.href}
|
class={`text-sm font-medium ${isActive
|
||||||
class={`text-sm font-medium ${
|
? 'text-zinc-900 dark:text-white'
|
||||||
isActive
|
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white'}`}
|
||||||
? 'text-zinc-900 dark:text-zinc-100'
|
>
|
||||||
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100'
|
{item.text}
|
||||||
}`}
|
</a>
|
||||||
>
|
)
|
||||||
{item.text}
|
})}
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
<button id="mobile-menu-button" class="flex items-center sm:hidden" aria-label="Menu">
|
<button id="mobile-menu-button" class="sm:hidden flex items-center" aria-label="Menu">
|
||||||
<svg
|
<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 text-zinc-900 dark:text-white">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Mobile menu overlay -->
|
<!-- Mobile menu overlay -->
|
||||||
<div
|
<div id="mobile-menu" class="fixed inset-0 z-50 bg-white dark:bg-zinc-900 flex flex-col opacity-0 pointer-events-none transition-all duration-300 ease-in-out">
|
||||||
id="mobile-menu"
|
<div class="flex justify-between items-center p-4 border-b border-zinc-100 dark:border-zinc-800">
|
||||||
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"
|
<a href="/" class="font-bold text-xl text-zinc-900 dark:text-white">JD</a>
|
||||||
>
|
<button id="close-menu-button" class="text-zinc-900 dark:text-white p-2 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors" aria-label="Close menu">
|
||||||
<div class="flex items-center justify-between border-b border-zinc-100 p-4 dark:border-zinc-800">
|
<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">
|
||||||
<a href="/" class="text-xl font-bold text-zinc-900 dark:text-white">{global.initals}</a>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||||
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="flex flex-1 flex-col items-center justify-center space-y-6 text-center">
|
<nav class="flex-1 flex flex-col items-center justify-center space-y-6 text-center">
|
||||||
{
|
{navItems.map((item, index) => {
|
||||||
navItems.map((item, index) => {
|
const isActive = currentPath === (item.href === '/' ? '' : item.href.slice(1));
|
||||||
const isActive = currentPath === (item.href === '/' ? '' : item.href.slice(1));
|
return (
|
||||||
return (
|
<a
|
||||||
<a
|
href={item.href}
|
||||||
href={item.href}
|
class={`text-lg font-medium mobile-nav-item opacity-0 translate-y-4 ${isActive
|
||||||
class={`mobile-nav-item translate-y-4 text-lg font-medium opacity-0 ${
|
? 'text-zinc-900 dark:text-white'
|
||||||
isActive
|
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white'}`}
|
||||||
? 'text-zinc-900 dark:text-zinc-100'
|
style={`transition-delay: ${index * 0.05}s;`}
|
||||||
: 'text-zinc-600 group-hover:text-zinc-900 dark:text-zinc-400 dark:group-hover:text-zinc-100'
|
>
|
||||||
}`}
|
{item.text}
|
||||||
style={`transition-delay: ${index * 0.05}s;`}
|
</a>
|
||||||
>
|
)
|
||||||
{item.text}
|
})}
|
||||||
</a>
|
<div class="pt-4 mobile-nav-item opacity-0 translate-y-4" style="transition-delay: 0.25s;">
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
<div class="mobile-nav-item translate-y-4 pt-4 opacity-0" style="transition-delay: 0.25s;">
|
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -135,7 +87,7 @@ const currentPath = pathname.slice(1);
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Mobile menu toggle with animations
|
// Mobile menu toggle with animations
|
||||||
document.addEventListener('astro:page-load', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||||
const closeMenuButton = document.getElementById('close-menu-button');
|
const closeMenuButton = document.getElementById('close-menu-button');
|
||||||
const mobileMenu = document.getElementById('mobile-menu');
|
const mobileMenu = document.getElementById('mobile-menu');
|
||||||
@@ -157,7 +109,7 @@ const currentPath = pathname.slice(1);
|
|||||||
mobileMenu.style.opacity = '1';
|
mobileMenu.style.opacity = '1';
|
||||||
|
|
||||||
// Animate each nav item with staggered delay
|
// Animate each nav item with staggered delay
|
||||||
navItems.forEach((item) => {
|
navItems.forEach(item => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
item.classList.remove('opacity-0', 'translate-y-4');
|
item.classList.remove('opacity-0', 'translate-y-4');
|
||||||
}, 150);
|
}, 150);
|
||||||
@@ -170,7 +122,7 @@ const currentPath = pathname.slice(1);
|
|||||||
if (!mobileMenu) return;
|
if (!mobileMenu) return;
|
||||||
|
|
||||||
// Fade out nav items first
|
// Fade out nav items first
|
||||||
navItems.forEach((item) => {
|
navItems.forEach(item => {
|
||||||
item.classList.add('opacity-0', 'translate-y-4');
|
item.classList.add('opacity-0', 'translate-y-4');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -192,7 +144,7 @@ const currentPath = pathname.slice(1);
|
|||||||
|
|
||||||
// Close menu when clicking a link
|
// Close menu when clicking a link
|
||||||
const mobileLinks = mobileMenu?.querySelectorAll('a');
|
const mobileLinks = mobileMenu?.querySelectorAll('a');
|
||||||
mobileLinks?.forEach((link) => {
|
mobileLinks?.forEach(link => {
|
||||||
link.addEventListener('click', closeMenu);
|
link.addEventListener('click', closeMenu);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -214,9 +166,9 @@ const currentPath = pathname.slice(1);
|
|||||||
|
|
||||||
// Add shadow on scroll
|
// Add shadow on scroll
|
||||||
if (currentScrollY > 10) {
|
if (currentScrollY > 10) {
|
||||||
header.classList.add('shadow-xs');
|
header.classList.add('shadow-sm');
|
||||||
} else {
|
} else {
|
||||||
header.classList.remove('shadow-xs');
|
header.classList.remove('shadow-sm');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update last scroll position
|
// Update last scroll position
|
||||||
@@ -228,18 +180,12 @@ const currentPath = pathname.slice(1);
|
|||||||
<style>
|
<style>
|
||||||
/* Smooth animations for mobile navigation */
|
/* Smooth animations for mobile navigation */
|
||||||
.mobile-nav-item {
|
.mobile-nav-item {
|
||||||
transition:
|
transition: opacity 0.5s ease, transform 0.5s ease, color 0.3s ease;
|
||||||
opacity 0.5s ease,
|
|
||||||
transform 0.5s ease,
|
|
||||||
color 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Header transition */
|
/* Header transition */
|
||||||
header {
|
header {
|
||||||
transition:
|
transition: box-shadow 0.3s ease, transform 0.3s ease, background-color 0.3s ease;
|
||||||
box-shadow 0.3s ease,
|
|
||||||
transform 0.3s ease,
|
|
||||||
background-color 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile menu button hover effect */
|
/* Mobile menu button hover effect */
|
||||||
@@ -254,6 +200,6 @@ const currentPath = pathname.slice(1);
|
|||||||
/* Mobile menu transition */
|
/* Mobile menu transition */
|
||||||
#mobile-menu {
|
#mobile-menu {
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
backdrop-filter: blur-sm(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@@ -17,93 +17,108 @@ const encodedUrl = encodeURIComponent(url);
|
|||||||
href={`https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`}
|
href={`https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="hover rounded-full p-2 text-zinc-500 transition-all duration-300 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
class="p-2 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-300"
|
||||||
aria-label="Share on Twitter"
|
aria-label="Share on Twitter"
|
||||||
>
|
>
|
||||||
<svg
|
<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="w-4 h-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>
|
||||||
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>
|
||||||
<a
|
<a
|
||||||
href={`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`}
|
href={`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="hover rounded-full p-2 text-zinc-500 transition-all duration-300 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
class="p-2 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-300"
|
||||||
aria-label="Share on Facebook"
|
aria-label="Share on Facebook"
|
||||||
>
|
>
|
||||||
<svg
|
<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="w-4 h-4"><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"></path></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>
|
||||||
<a
|
<a
|
||||||
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodedUrl}&title=${encodedTitle}`}
|
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodedUrl}&title=${encodedTitle}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
class="hover rounded-full p-2 text-zinc-500 transition-all duration-300 hover:bg-zinc-100 hover:text-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-300"
|
class="p-2 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-300"
|
||||||
aria-label="Share on LinkedIn"
|
aria-label="Share on LinkedIn"
|
||||||
>
|
>
|
||||||
<svg
|
<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="w-4 h-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>
|
||||||
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>
|
</a>
|
||||||
<button
|
<button
|
||||||
id="copy-link-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"
|
class="p-2 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-300 relative"
|
||||||
aria-label="Copy link"
|
aria-label="Copy link"
|
||||||
title="Copy link to clipboard"
|
title="Copy link to clipboard"
|
||||||
>
|
>
|
||||||
<svg
|
<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="w-4 h-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>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<span id="copy-tooltip" class="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-zinc-800 dark:bg-zinc-700 text-white text-xs py-1 px-2 rounded opacity-0 transition-opacity duration-300 whitespace-nowrap">
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="h-4 w-4"
|
|
||||||
>
|
|
||||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"> </path>
|
|
||||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"> </path>
|
|
||||||
</svg>
|
|
||||||
<span
|
|
||||||
id="copy-tooltip"
|
|
||||||
class="absolute -top-8 left-1/2 -translate-x-1/2 transform rounded-sm bg-zinc-800 px-2 py-1 text-xs whitespace-nowrap text-white opacity-0 transition-opacity duration-300 dark:bg-zinc-700"
|
|
||||||
>
|
|
||||||
Copied!
|
Copied!
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
@@ -7,22 +7,15 @@ export interface Props {
|
|||||||
const { tags = [], class: className = '' } = Astro.props;
|
const { tags = [], class: className = '' } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
{
|
{tags.length > 0 && (
|
||||||
tags && (
|
<div class={`flex flex-wrap gap-2 mt-3 ${className}`}>
|
||||||
<div class={`mb-3 flex flex-wrap justify-center gap-2 sm:mb-0 sm:justify-start ${className}`}>
|
{tags.map(tag => (
|
||||||
{tags.slice(0, 2).map((postTag) => (
|
<a
|
||||||
<a
|
href={`/tag/${tag}`}
|
||||||
href={`/tags/${postTag}`}
|
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"
|
||||||
class={`inline-flex items-center rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs font-medium text-zinc-600 transition-colors hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700`}
|
>
|
||||||
>
|
{tag}
|
||||||
#{postTag}
|
</a>
|
||||||
</a>
|
))}
|
||||||
))}
|
</div>
|
||||||
{tags.length > 2 && (
|
)}
|
||||||
<span class="inline-flex items-center rounded-full bg-zinc-50 px-2 py-0.5 text-xs text-zinc-500 dark:bg-zinc-900 dark:text-zinc-400">
|
|
||||||
+{tags.length - 3}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
@@ -1,18 +1,17 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="theme-toggle"
|
id="theme-toggle"
|
||||||
data-theme-toggle
|
data-theme-toggle
|
||||||
class="group hover:bg-desert/50 dark:hover:bg-midnight/50 relative touch-manipulation overflow-hidden rounded-full p-1.5 transition-all duration-300 focus:ring-2 focus:ring-zinc-300 focus:outline-hidden sm:p-2 dark:focus:ring-zinc-700"
|
class="relative overflow-hidden rounded-full p-1.5 sm:p-2 transition-all duration-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-zinc-300 dark:focus:ring-zinc-700 group touch-manipulation"
|
||||||
aria-label="Toggle dark mode"
|
aria-label="Toggle dark mode"
|
||||||
>
|
>
|
||||||
<div class="relative z-10 flex h-5 w-5 items-center justify-center">
|
<div class="relative z-10 flex h-5 w-5 items-center justify-center">
|
||||||
<!-- Sun icon -->
|
<!-- Sun icon -->
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="icon-light absolute h-5 w-5 scale-100 rotate-0 text-zinc-800 transition-all duration-500 dark:scale-0 dark:-rotate-90 dark:text-zinc-200"
|
class="icon-light absolute h-5 w-5 rotate-0 scale-100 transition-all duration-500 dark:-rotate-90 dark:scale-0 text-zinc-800 dark:text-zinc-200"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -20,16 +19,14 @@
|
|||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
>
|
>
|
||||||
<circle cx="12" cy="12" r="5"></circle>
|
<circle cx="12" cy="12" r="5"/>
|
||||||
<path
|
<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"/>
|
||||||
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>
|
</svg>
|
||||||
|
|
||||||
<!-- Moon icon -->
|
<!-- Moon icon -->
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="icon-dark absolute h-5 w-5 scale-0 rotate-90 text-zinc-800 transition-all duration-500 dark:scale-100 dark:rotate-0 dark:text-zinc-200"
|
class="icon-dark absolute h-5 w-5 rotate-90 scale-0 transition-all duration-500 dark:rotate-0 dark:scale-100 text-zinc-800 dark:text-zinc-200"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -42,30 +39,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ripple effect -->
|
<!-- Ripple effect -->
|
||||||
<span
|
<span class="absolute inset-0 h-full w-full bg-zinc-200 dark:bg-zinc-700 opacity-0 transition-opacity duration-300 group-active:opacity-20"></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>
|
</button>
|
||||||
|
|
||||||
<script is:inline>
|
|
||||||
// Use a function to persist theme when using SPA transitions
|
|
||||||
// https://docs.astro.build/en/guides/view-transitions/#script-re-execution
|
|
||||||
function applyTheme() {
|
|
||||||
localStorage.theme === 'dark'
|
|
||||||
? document.documentElement.classList.add('dark')
|
|
||||||
: document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('astro:after-swap', applyTheme);
|
|
||||||
|
|
||||||
applyTheme();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Use a function to handle theme toggle to ensure it can be called from anywhere
|
// Use a function to handle theme toggle to ensure it can be called from anywhere
|
||||||
function setupThemeToggle() {
|
function setupThemeToggle() {
|
||||||
const themeToggles = document.querySelectorAll('[data-theme-toggle]');
|
const themeToggles = document.querySelectorAll('[data-theme-toggle]');
|
||||||
|
|
||||||
|
// Check for dark mode preference at the system level
|
||||||
|
const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
|
// Check for saved theme preference or use the system preference
|
||||||
|
const currentTheme = localStorage.getItem('theme') || (prefersDarkMode ? 'dark' : 'light');
|
||||||
|
|
||||||
|
// Apply the theme on initial load
|
||||||
|
if (currentTheme === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
|
||||||
// Create theme switch overlay element if it doesn't exist
|
// Create theme switch overlay element if it doesn't exist
|
||||||
if (!document.querySelector('.theme-switch-overlay')) {
|
if (!document.querySelector('.theme-switch-overlay')) {
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
@@ -76,116 +70,101 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Toggle theme when any theme toggle button is clicked
|
// Toggle theme when any theme toggle button is clicked
|
||||||
themeToggles.forEach((toggle) => {
|
themeToggles.forEach(toggle => {
|
||||||
// Add event listeners for both click and touch events
|
// Add event listeners for both click and touch events
|
||||||
['click', 'touchend'].forEach((eventType) => {
|
['click', 'touchend'].forEach(eventType => {
|
||||||
toggle.addEventListener(
|
toggle.addEventListener(eventType, (e) => {
|
||||||
eventType,
|
e.preventDefault();
|
||||||
(e) => {
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
// Get click/touch position for radial animation
|
// Get click/touch position for radial animation
|
||||||
let x, y;
|
let x, y;
|
||||||
if (e.type === 'touchend' && e.changedTouches && e.changedTouches[0]) {
|
if (e.type === 'touchend' && e.changedTouches && e.changedTouches[0]) {
|
||||||
const rect = toggle.getBoundingClientRect();
|
const rect = toggle.getBoundingClientRect();
|
||||||
x = e.changedTouches[0].clientX - rect.left;
|
x = e.changedTouches[0].clientX - rect.left;
|
||||||
y = e.changedTouches[0].clientY - rect.top;
|
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 {
|
} else {
|
||||||
const rect = toggle.getBoundingClientRect();
|
document.documentElement.classList.add('dark');
|
||||||
x = e.clientX - rect.left;
|
|
||||||
y = e.clientY - rect.top;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the position variables for the radial gradient
|
// Store the preference
|
||||||
document.documentElement.style.setProperty('--x', `${x}px`);
|
localStorage.setItem('theme', newTheme);
|
||||||
document.documentElement.style.setProperty('--y', `${y}px`);
|
|
||||||
|
|
||||||
// Get the overlay element
|
// Dispatch a custom event for other components to react to
|
||||||
const overlay = document.querySelector('.theme-switch-overlay');
|
document.dispatchEvent(new CustomEvent('themeChanged', {
|
||||||
|
detail: { isDark: newTheme === 'dark' }
|
||||||
|
}));
|
||||||
|
|
||||||
// Determine the new theme
|
// Force another reflow to ensure all elements update
|
||||||
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;
|
document.body.offsetHeight;
|
||||||
|
|
||||||
// Toggle dark mode with a slight delay to allow overlay to appear
|
// Hide overlay after theme has changed
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (isDark) {
|
if (overlay) {
|
||||||
document.documentElement.classList.remove('dark');
|
overlay.style.opacity = '0';
|
||||||
} else {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the preference
|
// Remove transition class after animation completes
|
||||||
localStorage.setItem('theme', newTheme);
|
document.documentElement.classList.remove('theme-switching');
|
||||||
|
ripple.remove();
|
||||||
// Dispatch a custom event for other components to react to
|
}, 300);
|
||||||
document.dispatchEvent(
|
}, 50);
|
||||||
new CustomEvent('themeChanged', {
|
}, { passive: false });
|
||||||
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
|
// Add touch feedback
|
||||||
toggle.addEventListener(
|
toggle.addEventListener('touchstart', () => {
|
||||||
'touchstart',
|
toggle.classList.add('active-touch');
|
||||||
() => {
|
}, { passive: true });
|
||||||
toggle.classList.add('active-touch');
|
|
||||||
},
|
|
||||||
{ passive: true }
|
|
||||||
);
|
|
||||||
|
|
||||||
toggle.addEventListener(
|
toggle.addEventListener('touchend', () => {
|
||||||
'touchend',
|
setTimeout(() => {
|
||||||
() => {
|
toggle.classList.remove('active-touch');
|
||||||
setTimeout(() => {
|
}, 150);
|
||||||
toggle.classList.remove('active-touch');
|
}, { passive: true });
|
||||||
}, 150);
|
|
||||||
},
|
|
||||||
{ passive: true }
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run setup on load
|
// Run setup on load
|
||||||
document.addEventListener('astro:page-load', setupThemeToggle);
|
document.addEventListener('DOMContentLoaded', setupThemeToggle);
|
||||||
|
|
||||||
// Also run on page visibility change to ensure theme is consistent
|
// Also run on page visibility change to ensure theme is consistent
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
@@ -214,9 +193,7 @@
|
|||||||
<style>
|
<style>
|
||||||
/* Smooth transition for the entire page when theme changes */
|
/* Smooth transition for the entire page when theme changes */
|
||||||
:global(body) {
|
:global(body) {
|
||||||
transition:
|
transition: background-color 0.5s ease, color 0.5s ease;
|
||||||
background-color 0.5s ease,
|
|
||||||
color 0.5s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Theme transition overlay */
|
/* Theme transition overlay */
|
||||||
@@ -275,12 +252,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
#theme-toggle:hover .icon-light:not(.dark .icon-light) {
|
#theme-toggle:hover .icon-light:not(.dark .icon-light) {
|
||||||
filter: drop-shadow-sm(0 0 2px rgba(251, 191, 36, 0.6));
|
filter: drop-shadow(0 0 2px rgba(251, 191, 36, 0.6));
|
||||||
transform: scale(1.1) rotate(15deg);
|
transform: scale(1.1) rotate(15deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
#theme-toggle:hover .icon-dark:not(:not(.dark) .icon-dark) {
|
#theme-toggle:hover .icon-dark:not(:not(.dark) .icon-dark) {
|
||||||
filter: drop-shadow-sm(0 0 2px rgba(129, 140, 248, 0.6));
|
filter: drop-shadow(0 0 2px rgba(129, 140, 248, 0.6));
|
||||||
transform: scale(1.1) rotate(-15deg);
|
transform: scale(1.1) rotate(-15deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -293,13 +270,11 @@
|
|||||||
|
|
||||||
/* Optimize animations for mobile */
|
/* Optimize animations for mobile */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.icon-light,
|
.icon-light, .icon-dark {
|
||||||
.icon-dark {
|
|
||||||
transition: all 0.2s ease-out !important;
|
transition: all 0.2s ease-out !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
#theme-toggle,
|
#theme-toggle, #theme-toggle:hover {
|
||||||
#theme-toggle:hover {
|
|
||||||
transform: none;
|
transform: none;
|
||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
1
src/env.d.ts
vendored
1
src/env.d.ts
vendored
@@ -1,3 +1,4 @@
|
|||||||
/// <reference path="../.astro/types.d.ts" />
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
/// <reference types="astro/content" />
|
/// <reference types="astro/content" />
|
||||||
|
|
||||||
|
18
src/layouts/Base.astro
Normal file
18
src/layouts/Base.astro
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
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,17 +1,61 @@
|
|||||||
---
|
---
|
||||||
import Layout from './Layout.astro';
|
import Layout from './Layout.astro';
|
||||||
|
|
||||||
import directus from '../../lib/directus';
|
import directus from "../../lib/directus"
|
||||||
import { readSingleton } from '@directus/sdk';
|
import { readSingleton } from "@directus/sdk";
|
||||||
|
|
||||||
const global = await directus.request(readSingleton('global'));
|
const global = await directus.request(readSingleton("global"));
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={global.title} description={global.title}>
|
<Layout title={global.title} description={global.description}>
|
||||||
<slot />
|
<slot />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', () => {
|
||||||
|
document.documentElement.classList.add('theme-switching');
|
||||||
|
|
||||||
|
const rippleElements = document.querySelectorAll('.theme-ripple');
|
||||||
|
rippleElements.forEach(el => {
|
||||||
|
el.classList.add('ripple-active');
|
||||||
|
setTimeout(() => {
|
||||||
|
el.classList.remove('ripple-active');
|
||||||
|
}, 600);
|
||||||
|
});
|
||||||
|
|
||||||
|
const event = new CustomEvent('themeChange', {
|
||||||
|
detail: {
|
||||||
|
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
document.documentElement.classList.remove('theme-switching');
|
||||||
|
}, 600);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const socialLinks = document.querySelectorAll('.social-link');
|
||||||
|
socialLinks.forEach(link => {
|
||||||
|
|
||||||
|
link.addEventListener('mouseenter', () => {
|
||||||
|
link.classList.add('hover-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
link.addEventListener('mouseleave', () => {
|
||||||
|
link.classList.remove('hover-active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
@@ -5,161 +5,377 @@ import ShareButtons from '../components/ShareButtons.astro';
|
|||||||
import TagList from '../components/TagList.astro';
|
import TagList from '../components/TagList.astro';
|
||||||
import './styles/markdown.css';
|
import './styles/markdown.css';
|
||||||
|
|
||||||
import directus from '../../lib/directus';
|
import directus from "../../lib/directus"
|
||||||
import { readItems } from '@directus/sdk';
|
import { readItems } from "@directus/sdk";
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = await directus.request(
|
const posts = await directus.request(readItems("posts", {
|
||||||
readItems('posts', {
|
fields: ['*'],
|
||||||
fields: ['*'],
|
}));
|
||||||
})
|
|
||||||
);
|
|
||||||
return posts.map((post) => ({ params: { slug: post.slug }, props: post }));
|
return posts.map((post) => ({ params: { slug: post.slug }, props: post }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const post = Astro.props;
|
const post = Astro.props;
|
||||||
|
const published_date: string = post.published_date.toLocaleString();
|
||||||
|
|
||||||
let canonicalURL;
|
let canonicalURL;
|
||||||
try {
|
try {
|
||||||
canonicalURL = new URL(Astro.url.pathname, Astro.site || process.env.SITE_URL);
|
canonicalURL = new URL(Astro.url.pathname, Astro.site || process.env.SITE_URL);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating canonical URL:', error);
|
console.error('Error creating canonical URL:', error);
|
||||||
canonicalURL = new URL('https://www.example.com');
|
canonicalURL = new URL("https://www.example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title={post.title} description={post.description}>
|
<Layout title={post.title} description={post.description}>
|
||||||
<article class="prose prose-zinc dark:prose-invert lg:prose-lg mx-auto max-w-4xl">
|
<article class="prose dark:prose-invert prose-zinc lg:prose-lg mx-auto max-w-4xl">
|
||||||
<div class="hero-text mb-12">
|
<div class="mb-12">
|
||||||
<h1
|
<h1 class="mb-4 text-4xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 sm:text-5xl">
|
||||||
class="mb-4 text-4xl font-bold tracking-tight text-zinc-900 sm:text-5xl dark:text-zinc-100"
|
|
||||||
>
|
|
||||||
{post.title}
|
{post.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p
|
<div class="flex items-center gap-x-4 text-sm text-zinc-500 dark:text-zinc-400 mb-6">
|
||||||
class="mb-2 line-clamp-2 text-center text-sm text-zinc-600 sm:mb-3 sm:line-clamp-3 sm:text-left sm:text-base dark:text-zinc-400"
|
<FormattedDate date={published_date} />
|
||||||
>
|
|
||||||
{post.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="hero-text mb-2 flex flex-wrap items-center justify-center gap-3 text-xs text-zinc-500 sm:mb-3 sm:justify-start sm:gap-4 sm:text-sm dark:text-zinc-400"
|
|
||||||
>
|
|
||||||
<FormattedDate date={post.published_date} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<TagList tags={post.tags} class="mt-2" />
|
||||||
class="hero-text mb-2 flex flex-wrap items-center justify-center gap-3 text-xs text-zinc-500 sm:mb-3 sm:justify-start sm:gap-4 sm:text-sm dark:text-zinc-400"
|
|
||||||
>
|
|
||||||
<TagList tags={post.tags} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hero image -->
|
<!-- Hero image -->
|
||||||
{
|
{post.image && (
|
||||||
post.image && (
|
<div class="relative mb-8 sm:mb-12 overflow-hidden rounded-xl shadow-lg">
|
||||||
<div class="relative mb-8 overflow-hidden rounded-xl shadow-lg sm:mb-12">
|
<div class="aspect-[16/9] w-full">
|
||||||
<div class="aspect-[16/9] w-full">
|
<img
|
||||||
<img
|
src={`${process.env.DIRECTUS_URL ?? "https://directus.alexlebens.dev"}/assets/${post.image}?width=500`}
|
||||||
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${post.image}?width=500`}
|
alt={post.image_alt}
|
||||||
alt={post.image_alt}
|
class="w-full h-full object-cover"
|
||||||
class="h-full w-full object-cover"
|
loading="eager"
|
||||||
loading="eager"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent" />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
<div class="absolute inset-0 bg-gradient-to-t from-black/30 to-transparent"></div>
|
||||||
}
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div class="markdown-content">
|
<div class="markdown-content">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add the like button after the content -->
|
<!-- Add the like button after the content -->
|
||||||
<div class="mt-12 border-t border-zinc-200 pt-8 dark:border-zinc-800">
|
<div class="mt-12 pt-8 border-t border-zinc-200 dark:border-zinc-800">
|
||||||
<div class="flex flex-col items-center justify-between gap-6 sm:flex-row">
|
<div class="flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||||
<ShareButtons url={canonicalURL.toString()} title={post.title} />
|
<ShareButtons url={canonicalURL.toString()} title={post.title} /> <!-- Convert URL to string -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{post.updated_date && (
|
||||||
post.updated_date && (
|
<div class="mt-8 text-sm text-zinc-500 dark:text-zinc-400 italic">
|
||||||
<div class="mt-8 text-sm text-zinc-500 italic dark:text-zinc-400">
|
Last updated on <FormattedDate date={post.updated_date} />
|
||||||
Last updated on <FormattedDate date={post.updated_date} />
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
|
||||||
}
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<slot name="after-article" />
|
<slot name="after-article" />
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('astro:page-load', () => {
|
// Blog post SPA transitions
|
||||||
// Add smooth reveal animations for content after loading
|
function setupBlogPostTransitions() {
|
||||||
const animateContent = () => {
|
// Animate article entrance
|
||||||
// Animate hero section
|
const article = document.querySelector('article');
|
||||||
const heroElements = document.querySelectorAll(
|
if (article) {
|
||||||
'.hero-text div, .hero-text ~ div, .hero-text h1, .hero-text span, .hero-text p, .hero-text + a'
|
article.classList.add('article-entering');
|
||||||
);
|
|
||||||
heroElements.forEach((el, index) => {
|
// Remove class after animation completes
|
||||||
setTimeout(
|
setTimeout(() => {
|
||||||
() => {
|
article.classList.remove('article-entering');
|
||||||
el.classList.add('animate-reveal');
|
}, 1000);
|
||||||
},
|
}
|
||||||
100 + index * 150
|
|
||||||
);
|
// 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'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Animate posts with staggered delay
|
headings.forEach(heading => {
|
||||||
const articles = document.querySelectorAll('article.group');
|
heading.classList.add('heading-animated');
|
||||||
articles.forEach((article, index) => {
|
observer.observe(heading);
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
article.classList.add('animate-reveal');
|
|
||||||
},
|
|
||||||
500 + index * 150
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return observer;
|
||||||
};
|
};
|
||||||
|
|
||||||
animateContent();
|
// 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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* Content reveal animations */
|
/* Enhanced hero image styling */
|
||||||
.hero-text h1,
|
|
||||||
.hero-text div,
|
|
||||||
.hero-text ~ div,
|
|
||||||
.hero-text span,
|
|
||||||
.hero-text p,
|
|
||||||
.hero-text + a,
|
|
||||||
article.group {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
transition:
|
|
||||||
opacity 0.8s ease,
|
|
||||||
transform 0.8s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-reveal {
|
|
||||||
opacity: 1 !important;
|
|
||||||
transform: translateY(0) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero image styling */
|
|
||||||
article img:first-of-type {
|
article img:first-of-type {
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
box-shadow:
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||||
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;
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
article img:first-of-type:hover {
|
article img:first-of-type:hover {
|
||||||
transform: scale(1.01);
|
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>
|
</style>
|
||||||
|
@@ -1,21 +1,18 @@
|
|||||||
---
|
---
|
||||||
import { ClientRouter } from 'astro:transitions';
|
|
||||||
|
|
||||||
import Navigation from '../components/Navigation.astro';
|
import Navigation from '../components/Navigation.astro';
|
||||||
import Footer from '../components/Footer.astro';
|
import Footer from '../components/Footer.astro';
|
||||||
import Background from '../components/Background.astro';
|
import Background from '../components/Background.astro';
|
||||||
|
|
||||||
import '../styles/global.css';
|
import '../styles/global.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title?: string | undefined;
|
title?: string | undefined;
|
||||||
description?: string | undefined;
|
description?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { title, description } = Astro.props;
|
const { title, description } = Astro.props;
|
||||||
---
|
---
|
||||||
|
|
||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
@@ -24,56 +21,278 @@ const { title, description } = Astro.props;
|
|||||||
<meta name="generator" content={Astro.generator} />
|
<meta name="generator" content={Astro.generator} />
|
||||||
<meta name="description" content={description} />
|
<meta name="description" content={description} />
|
||||||
<title>{title}</title>
|
<title>{title}</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<!-- Load theme early to prevent flashes between light and dark modes -->
|
|
||||||
<script is:inline>
|
|
||||||
const theme = (() => {
|
|
||||||
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
|
||||||
return localStorage.getItem('theme');
|
|
||||||
}
|
|
||||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
||||||
return 'dark';
|
|
||||||
}
|
|
||||||
return 'light';
|
|
||||||
})();
|
|
||||||
|
|
||||||
if (theme === 'light') {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
}
|
|
||||||
window.localStorage.setItem('theme', theme);
|
|
||||||
</script>
|
|
||||||
<ClientRouter />
|
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body class="bg-white dark:bg-zinc-900 text-zinc-900 dark:text-zinc-100 min-h-screen flex flex-col">
|
||||||
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="fixed inset-0 z-40 bg-white dark:bg-zinc-900 opacity-0 pointer-events-none transition-opacity duration-300 flex items-center justify-center">
|
||||||
|
<div class="transition-spinner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Background component with dot pattern and ambient glow -->
|
||||||
<Background />
|
<Background />
|
||||||
|
|
||||||
<div class="mx-auto w-full max-w-3xl grow px-4 sm:px-6">
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 w-full flex-grow">
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<main class="py-12">
|
<main class="py-12">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
<style>
|
<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 */
|
/* Content entrance animation */
|
||||||
main {
|
main {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
transition:
|
transition: opacity 0.5s ease, transform 0.5s ease;
|
||||||
opacity 0.5s ease,
|
|
||||||
transform 0.5s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main.content-entering {
|
main.content-entering {
|
||||||
|
25
src/layouts/TransitionLayout.astro
Normal file
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>
|
File diff suppressed because it is too large
Load Diff
@@ -3,85 +3,54 @@ import Layout from '../layouts/Layout.astro';
|
|||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="404 - Page Not Found">
|
<Layout title="404 - Page Not Found">
|
||||||
<div
|
<div class="relative flex flex-col items-center justify-center min-h-[80vh] py-20 text-center px-4 overflow-hidden">
|
||||||
class="relative flex min-h-[80vh] flex-col items-center justify-center overflow-hidden px-4 py-20 text-center"
|
<!-- Animated background elements -->
|
||||||
transition:animate="slide"
|
<div class="absolute inset-0 overflow-hidden">
|
||||||
>
|
<div class="absolute -top-20 -left-20 w-64 h-64 bg-zinc-100 dark:bg-zinc-800/50 rounded-full blur-3xl opacity-50 animate-blob"></div>
|
||||||
|
<div class="absolute top-1/2 right-1/4 w-96 h-96 bg-zinc-200 dark:bg-zinc-800/30 rounded-full blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
|
||||||
|
<div class="absolute bottom-20 left-1/3 w-72 h-72 bg-zinc-100 dark:bg-zinc-800/40 rounded-full blur-3xl opacity-40 animate-blob animation-delay-4000"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main content with animation -->
|
<!-- Main content with animation -->
|
||||||
<div class="relative z-10 mx-auto max-w-xl">
|
<div class="relative z-10 max-w-xl mx-auto">
|
||||||
<div class="glitch-wrapper">
|
<div class="glitch-wrapper">
|
||||||
<h1
|
<h1 class="glitch text-9xl sm:text-[12rem] font-bold text-zinc-900 dark:text-zinc-100 leading-none" data-text="404">404</h1>
|
||||||
class="glitch text-9xl leading-none font-bold text-zinc-900 sm:text-[12rem] dark:text-zinc-100"
|
|
||||||
data-text="404"
|
|
||||||
>
|
|
||||||
404
|
|
||||||
</h1>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="mt-6 text-2xl font-bold text-zinc-800 sm:text-3xl dark:text-zinc-200">
|
<h2 class="mt-6 text-2xl sm:text-3xl font-bold text-zinc-800 dark:text-zinc-200">Page Not Found</h2>
|
||||||
Page Not Found
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p class="mx-auto mt-6 max-w-md text-lg text-zinc-600 dark:text-zinc-400">
|
<p class="mt-6 text-zinc-600 dark:text-zinc-400 max-w-md mx-auto text-lg">
|
||||||
The page you're looking for does not exist.
|
The page you're looking for does not exist.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mt-10 flex flex-col items-center justify-center gap-4 sm:flex-row">
|
<div class="mt-10 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
class="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-cyan-600 hover:text-zinc-100 hover:shadow-xl dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-cyan-600"
|
class="group relative inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-zinc-900 text-zinc-100 hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200 transition-all duration-300 overflow-hidden shadow-lg hover:shadow-xl"
|
||||||
>
|
>
|
||||||
<svg
|
<span class="absolute inset-0 bg-gradient-to-r from-zinc-700 to-zinc-900 dark:from-zinc-300 dark:to-zinc-100 opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-0"></span>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 relative z-10">
|
||||||
fill="none"
|
<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" />
|
||||||
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>
|
</svg>
|
||||||
<span class="relative z-10 font-medium">Return Home</span>
|
<span class="font-medium relative z-10">Return Home</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="back-button"
|
id="back-button"
|
||||||
class="group inline-flex translate-y-0 items-center gap-2 rounded-lg border border-zinc-300 px-6 py-3 text-zinc-700 shadow-xs transition-all duration-300 hover:bg-zinc-100 hover:shadow-md dark:border-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-800"
|
class="group inline-flex items-center gap-2 px-6 py-3 rounded-lg border border-zinc-300 dark:border-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-300 shadow-sm hover:shadow-md"
|
||||||
>
|
>
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-5 h-5 transition-transform duration-300 group-hover:-translate-x-1">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||||
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>
|
</svg>
|
||||||
<span class="font-medium">Go Back</span>
|
<span class="font-medium">Go Back</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Random fun fact -->
|
<!-- Random fun fact -->
|
||||||
<div
|
<div class="mt-16 p-6 bg-zinc-50 dark:bg-zinc-800/50 rounded-xl shadow-sm max-w-md mx-auto backdrop-blur-sm border border-zinc-100 dark:border-zinc-700/50">
|
||||||
class="mx-auto mt-16 max-w-md rounded-xl border border-zinc-100 bg-zinc-50 p-6 shadow-xs backdrop-blur-xs dark:border-zinc-700/50 dark:bg-zinc-800/50"
|
<h3 class="text-sm font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Did you know?</h3>
|
||||||
>
|
<p class="mt-2 text-zinc-700 dark:text-zinc-300 text-sm" id="fun-fact">
|
||||||
<h3 class="text-sm font-medium tracking-wider text-zinc-500 uppercase dark:text-zinc-400">
|
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.
|
||||||
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,11 +68,11 @@ import Layout from '../layouts/Layout.astro';
|
|||||||
"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.",
|
"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.",
|
"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.",
|
"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.',
|
"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.',
|
"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'.",
|
"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%.',
|
"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.',
|
"The most common cause of 404 errors is mistyped URLs."
|
||||||
];
|
];
|
||||||
|
|
||||||
// Display a random fun fact
|
// Display a random fun fact
|
||||||
@@ -112,9 +81,95 @@ import Layout from '../layouts/Layout.astro';
|
|||||||
const randomFact = funFacts[Math.floor(Math.random() * funFacts.length)];
|
const randomFact = funFacts[Math.floor(Math.random() * funFacts.length)];
|
||||||
funFactElement.textContent = randomFact;
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<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 effect for 404 text */
|
||||||
.glitch-wrapper {
|
.glitch-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -146,9 +201,7 @@ import Layout from '../layouts/Layout.astro';
|
|||||||
|
|
||||||
.glitch::after {
|
.glitch::after {
|
||||||
left: -2px;
|
left: -2px;
|
||||||
text-shadow:
|
text-shadow: -2px 0 #00fff9, 2px 2px #ff00c1;
|
||||||
-2px 0 #00fff9,
|
|
||||||
2px 2px #ff00c1;
|
|
||||||
animation: glitch-anim2 1s infinite linear alternate-reverse;
|
animation: glitch-anim2 1s infinite linear alternate-reverse;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,268 +1,176 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro';
|
import BaseLayout from '../layouts/BaseLayout.astro';
|
||||||
import DynamicIcon from '../utils/DynamicIcon.tsx';
|
import { FaJs, FaReact, FaNodeJs, FaPython } from 'react-icons/fa';
|
||||||
|
import { SiTypescript, SiAstro } from 'react-icons/si';
|
||||||
|
|
||||||
import directus from '../../lib/directus';
|
import directus from "../../lib/directus"
|
||||||
import { readSingleton, readItems } from '@directus/sdk';
|
import { readSingleton, readItems } 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(
|
const skills = await directus.request(
|
||||||
readItems('skills', {
|
readItems("skills", {
|
||||||
fields: ['*'],
|
fields: ['*']
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
---
|
|
||||||
|
|
||||||
|
---
|
||||||
<BaseLayout title="About Me" description={global.description}>
|
<BaseLayout title="About Me" description={global.description}>
|
||||||
<div
|
<div class="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-12 md:py-16 theme-transition-all">
|
||||||
class="theme-transition-all mx-auto max-w-6xl px-4 py-8 sm:px-6 sm:py-12 md:py-16"
|
<!-- Hero Section -->
|
||||||
transition:animate="slide"
|
|
||||||
>
|
|
||||||
<!-- Introduction Section -->
|
|
||||||
<div class="relative mb-12 sm:mb-16 md:mb-20">
|
<div class="relative mb-12 sm:mb-16 md:mb-20">
|
||||||
<div class="relative grid grid-cols-1 items-center gap-8 md:grid-cols-2 md:gap-12">
|
<!-- Decorative elements -->
|
||||||
<div class="hero-text order-2 text-center md:order-1 md:text-left">
|
<div class="absolute -top-10 sm:-top-20 -left-10 sm:-left-20 w-36 sm:w-48 md:w-72 h-36 sm:h-48 md:h-72 bg-zinc-100 dark:bg-zinc-800/30 rounded-full blur-3xl opacity-30 animate-blob theme-transition-bg"></div>
|
||||||
<h1
|
<div class="absolute -bottom-10 sm:-bottom-20 -right-10 sm:-right-20 w-36 sm:w-48 md:w-72 h-36 sm:h-48 md:h-72 bg-zinc-200 dark:bg-zinc-800/30 rounded-full blur-3xl opacity-30 animate-blob animation-delay-2000 theme-transition-bg"></div>
|
||||||
class="theme-transition-color hero-text mb-4 text-3xl font-bold tracking-tight text-zinc-900 sm:mb-6 sm:text-4xl md:text-5xl dark:text-zinc-100"
|
|
||||||
>
|
<div class="relative grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 items-center">
|
||||||
Hello, I'm <span class="theme-transition-all bg-clip-text">{global.name}</span>
|
<div class="order-2 md:order-1 text-center md:text-left">
|
||||||
|
<h1 class="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 mb-4 sm:mb-6 theme-transition-color">
|
||||||
|
Hello, I'm <span class="text-transparent bg-clip-text bg-gradient-to-r from-zinc-500 to-zinc-900 dark:from-zinc-300 dark:to-zinc-100 theme-transition-all">{global.name}</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p
|
<p class="text-lg sm:text-xl text-zinc-600 dark:text-zinc-400 mb-6 sm:mb-8 leading-relaxed theme-transition-color">
|
||||||
class="theme-transition-color hero-text mb-6 text-lg leading-relaxed text-zinc-600 sm:mb-8 sm:text-xl dark:text-zinc-400"
|
|
||||||
>
|
|
||||||
{about.background}
|
{about.background}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4 social-links-container justify-center md:justify-start theme-transition-children">
|
||||||
|
<!-- Social links remain the same -->
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative order-1 md:order-2">
|
<div class="order-1 md:order-2 relative">
|
||||||
<div
|
<div class="aspect-square w-full max-w-[280px] sm:max-w-[320px] md:max-w-md mx-auto overflow-hidden rounded-3xl border-4 sm:border-8 border-white dark:border-zinc-800 shadow-xl sm:shadow-2xl theme-transition-all">
|
||||||
class="theme-transition-all mx-auto aspect-square w-full max-w-[280px] overflow-hidden rounded-3xl border-4 border-white shadow-xl sm:max-w-[320px] sm:border-8 sm:shadow-2xl md:max-w-md dark:border-zinc-800"
|
|
||||||
>
|
|
||||||
<img
|
<img
|
||||||
src=`${process.env.DIRECTUS_URL ?? "https://directus.alexlebens.dev"}/assets/${global.portrait}`
|
src=`${process.env.DIRECTUS_URL ?? "https://directus.alexlebens.dev"}/assets/${global.portrait}`
|
||||||
alt={global.portrait_alt}
|
alt={global.portrait_alt}
|
||||||
class="h-full w-full object-cover"
|
class="w-full h-full object-cover"
|
||||||
loading="eager"
|
loading="eager"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Decorative elements -->
|
||||||
|
<div class="absolute -bottom-4 sm:-bottom-6 -right-4 sm:-right-6 w-16 sm:w-20 md:w-24 h-16 sm:h-20 md:h-24 bg-zinc-100 dark:bg-zinc-800 rounded-full border-2 sm:border-4 border-white dark:border-zinc-900 shadow-lg flex items-center justify-center theme-transition-all">
|
||||||
|
<span class="text-2xl sm:text-3xl">👋</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- About Me section -->
|
<!-- About Section -->
|
||||||
<div class="theme-transition-all mb-16 sm:mb-20 md:mb-24">
|
<div class="mb-16 sm:mb-20 md:mb-24 theme-transition-all">
|
||||||
<div class="mx-auto max-w-3xl">
|
<div class="max-w-3xl mx-auto">
|
||||||
<h2
|
<h2 class="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100 mb-6 sm:mb-8 flex items-center justify-center md:justify-start theme-transition-color">
|
||||||
class="theme-transition-color mb-6 flex items-center justify-center text-2xl font-bold text-zinc-900 sm:mb-8 sm:text-3xl md:justify-start dark:text-zinc-100"
|
<span class="hidden sm:inline-block w-8 sm:w-12 h-1 bg-zinc-300 dark:bg-zinc-700 mr-4 theme-transition-bg"></span>
|
||||||
>
|
|
||||||
<span class="theme-transition-bg bg-turquoise mr-4 hidden h-1 w-8 sm:inline-block sm:w-12"
|
|
||||||
></span>
|
|
||||||
About Me
|
About Me
|
||||||
<span class="theme-transition-bg bg-turquoise ml-4 hidden h-1 w-8 sm:inline-block sm:w-12"
|
<span class="hidden sm:inline-block w-8 sm:w-12 h-1 bg-zinc-300 dark:bg-zinc-700 ml-4 theme-transition-bg"></span>
|
||||||
></span>
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="theme-transition-all hero-text prose prose-zinc dark:prose-invert max-w-none">
|
<div class="prose prose-zinc dark:prose-invert max-w-none theme-transition-all">
|
||||||
<p
|
<p class="text-base sm:text-lg leading-relaxed mb-4 sm:mb-6 theme-transition-color">
|
||||||
class="theme-transition-color hero-text mb-4 text-base leading-relaxed sm:mb-6 sm:text-lg"
|
|
||||||
>
|
|
||||||
{about.experience}
|
{about.experience}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p
|
<p class="text-base sm:text-lg leading-relaxed mb-4 sm:mb-6 theme-transition-color">
|
||||||
class="theme-transition-color hero-text mb-4 text-base leading-relaxed sm:mb-6 sm:text-lg"
|
|
||||||
>
|
|
||||||
{about.education}
|
{about.education}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p
|
<p class="text-base sm:text-lg leading-relaxed mb-4 sm:mb-6 theme-transition-color">
|
||||||
class="theme-transition-color hero-text mb-4 text-base leading-relaxed sm:mb-6 sm:text-lg"
|
|
||||||
>
|
|
||||||
{about.certifications}
|
{about.certifications}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Skills Section -->
|
<!-- Skills Section -->
|
||||||
<div class="theme-transition-all mb-16 sm:mb-20 md:mb-24">
|
<div class="mb-16 sm:mb-20 md:mb-24 theme-transition-all">
|
||||||
<h2
|
<h2 class="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100 mb-8 sm:mb-12 text-center theme-transition-color">Tech Stack</h2>
|
||||||
class="theme-transition-color mb-8 text-center text-2xl font-bold text-zinc-900 sm:mb-12 sm:text-3xl dark:text-zinc-100"
|
|
||||||
>
|
|
||||||
Tech Stack
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div class="tech-stack-slider relative overflow-hidden py-4 sm:py-8">
|
<div class="tech-stack-slider relative overflow-hidden py-4 sm:py-8">
|
||||||
<!-- Main slider container -->
|
<!-- Main slider container -->
|
||||||
<div class="slider-track animate-slide flex">
|
<div class="slider-track flex animate-slide">
|
||||||
{
|
{ skills.map((skill, index) => (
|
||||||
[...skills, ...skills, ...skills].map((skill, index) => (
|
<div key={`${skill.title}-${index}`} class="skill-card min-w-[220px] sm:min-w-[280px] mx-2 sm:mx-4 bg-white dark:bg-zinc-800/50 rounded-xl border border-zinc-200 dark:border-zinc-700 hover:border-zinc-300 dark:hover:border-zinc-600 transition-all duration-300 hover:shadow-xl transform hover:-translate-y-2 hover:scale-105 theme-transition-element">
|
||||||
<div class="skill-card theme-transition-element Ztransition-all mx-2 min-w-[220px] transform rounded-xl border border-zinc-300 bg-white duration-300 hover:-translate-y-2 hover:scale-105 hover:border-zinc-200 hover:shadow-xl sm:mx-4 sm:min-w-[280px] dark:border-zinc-700 dark:bg-zinc-900 dark:hover:border-zinc-800 dark:hover:bg-zinc-900">
|
<div class="p-4 sm:p-6">
|
||||||
<div class="p-4 sm:p-6">
|
<div class="flex items-center justify-between mb-4 sm:mb-6">
|
||||||
<div class="mb-4 flex items-center justify-between sm:mb-6">
|
<div class="flex items-center gap-2 sm:gap-4">
|
||||||
<div class="flex items-center gap-2 sm:gap-4">
|
<div class="w-8 h-8 sm:w-12 sm:h-12 flex items-center justify-center bg-zinc-100 dark:bg-zinc-800 rounded-lg text-zinc-800 dark:text-zinc-200 transform transition-transform group-hover:rotate-12 theme-transition-bg theme-transition-color">
|
||||||
<div class="theme-transition-bg theme-transition-color flex h-8 w-8 transform items-center justify-center rounded-lg bg-zinc-100 text-zinc-800 transition-transform group-hover:rotate-12 sm:h-12 sm:w-12 dark:bg-zinc-800 dark:text-zinc-200">
|
<skill.icon size={20} className="sm:text-2xl transform transition-all hover:scale-125" />
|
||||||
<DynamicIcon name={skill.icon} />
|
|
||||||
</div>
|
|
||||||
<h3 class="theme-transition-color text-base font-semibold text-zinc-900 sm:text-xl dark:text-zinc-100">
|
|
||||||
{skill.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="theme-transition-all rounded-full bg-zinc-100 px-2 py-0.5 font-mono text-xs text-zinc-600 sm:px-2.5 sm:py-1 sm:text-sm dark:bg-zinc-800 dark:text-zinc-400">
|
<h3 class="text-base sm:text-xl font-semibold text-zinc-900 dark:text-zinc-100 theme-transition-color">{skill.title}</h3>
|
||||||
{skill.level}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<span class="text-xs sm:text-sm font-mono bg-zinc-100 dark:bg-zinc-800 text-zinc-600 dark:text-zinc-400 px-2 py-0.5 sm:px-2.5 sm:py-1 rounded-full theme-transition-all">{skill.level}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="theme-transition-bg relative h-1.5 w-full overflow-hidden rounded-full bg-zinc-100 sm:h-2 dark:bg-zinc-700">
|
<div class="relative h-1.5 sm:h-2 w-full bg-zinc-100 dark:bg-zinc-700 overflow-hidden rounded-full theme-transition-bg">
|
||||||
<div
|
<div
|
||||||
class="progress-bar-animate theme-transition-bg from-turquoise via-bermuda to-turquoise absolute top-0 left-0 h-full rounded-full bg-gradient-to-r transition-all duration-1000"
|
class="absolute top-0 left-0 h-full bg-gradient-to-r from-zinc-700 via-zinc-600 to-zinc-800 dark:from-zinc-300 dark:via-zinc-400 dark:to-zinc-200 rounded-full transition-all duration-1000 progress-bar-animate theme-transition-bg"
|
||||||
style={`width: ${skill.level}%`}
|
style={`width: ${skill.level}%`}
|
||||||
/>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="theme-transition-color mt-1 flex justify-between font-mono text-[10px] text-zinc-400 sm:mt-2 sm:text-xs dark:text-zinc-500">
|
<div class="flex justify-between mt-1 sm:mt-2 text-[10px] sm:text-xs text-zinc-400 dark:text-zinc-500 font-mono theme-transition-color">
|
||||||
<span>Beginner</span>
|
<span>Beginner</span>
|
||||||
<span>Advanced</span>
|
<span>Advanced</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
</div>
|
||||||
}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Gradient overlays for smooth fade effect -->
|
<!-- Gradient overlays for smooth fade effect -->
|
||||||
<div
|
<div class="absolute top-0 bottom-0 left-0 w-12 sm:w-24 bg-gradient-to-r from-white dark:from-zinc-900 to-transparent z-10 theme-transition-bg"></div>
|
||||||
class="theme-transition-bg absolute top-0 bottom-0 left-0 z-10 w-12 bg-gradient-to-r from-white to-transparent sm:w-24 dark:from-zinc-900"
|
<div class="absolute top-0 bottom-0 right-0 w-12 sm:w-24 bg-gradient-to-l from-white dark:from-zinc-900 to-transparent z-10 theme-transition-bg"></div>
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="theme-transition-bg absolute top-0 right-0 bottom-0 z-10 w-12 bg-gradient-to-l from-white to-transparent sm:w-24 dark:from-zinc-900"
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Contact Section -->
|
<!-- Contact Section -->
|
||||||
<div class="theme-transition-all mx-auto max-w-3xl text-center">
|
<div class="max-w-3xl mx-auto text-center theme-transition-all">
|
||||||
<h2
|
<h2 class="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100 mb-4 sm:mb-6 theme-transition-color">Get in Touch</h2>
|
||||||
class="theme-transition-color mb-4 text-2xl font-bold text-zinc-900 sm:mb-6 sm:text-3xl dark:text-zinc-100"
|
<p class="text-base sm:text-lg text-zinc-600 dark:text-zinc-400 mb-6 sm:mb-8 theme-transition-color">
|
||||||
>
|
I'm always open to new opportunities and collaborations. If you'd like to work together or just say hello,
|
||||||
Get in Touch
|
feel free to reach out.
|
||||||
</h2>
|
|
||||||
<p
|
|
||||||
class="theme-transition-color mb-6 text-base text-zinc-600 sm:mb-8 sm:text-lg dark:text-zinc-400"
|
|
||||||
>
|
|
||||||
I'm always open to new opportunities and collaborations. If you'd like to work together or
|
|
||||||
just say hello, feel free to reach out.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="group">
|
|
||||||
<a
|
<a
|
||||||
href=`mailto:${global.email}`
|
href=`mailto:${global.email}`
|
||||||
class="theme-transition-all group-hover:bg-turquoise inline-flex items-center justify-center rounded-lg bg-zinc-900 px-6 py-3 text-base font-medium text-zinc-100 transition-colors duration-300 group-hover:text-zinc-100 sm:px-8 sm:py-4 sm:text-lg dark:bg-zinc-100 dark:text-zinc-900 dark:group-hover:text-zinc-100"
|
class="inline-flex items-center justify-center px-6 sm:px-8 py-3 sm:py-4 rounded-lg bg-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 hover:bg-zinc-700 dark:hover:bg-zinc-300 transition-colors text-base sm:text-lg font-medium theme-transition-all"
|
||||||
>
|
>
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 sm:h-5 sm:w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<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" />
|
||||||
class="mr-2 h-4 w-4 sm:h-5 sm:w-5"
|
</svg>
|
||||||
fill="none"
|
Say Hello
|
||||||
viewBox="0 0 24 24"
|
</a>
|
||||||
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>
|
|
||||||
<span class="relative inline-block overflow-hidden">
|
|
||||||
<span class="relative z-10">Send Email</span>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('astro:page-load', () => {
|
|
||||||
// Add smooth reveal animations for content after loading
|
|
||||||
const animateContent = () => {
|
|
||||||
const heroElements = document.querySelectorAll(
|
|
||||||
'.hero-text ~ div, .hero-text h1, .hero-text span, .hero-text p'
|
|
||||||
);
|
|
||||||
heroElements.forEach((el, index) => {
|
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
el.classList.add('animate-reveal');
|
|
||||||
},
|
|
||||||
100 + index * 150
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
animateContent();
|
|
||||||
|
|
||||||
// Create seamless infinite scrolling effect
|
|
||||||
function setupInfiniteScroll() {
|
|
||||||
const cards = document.querySelectorAll('.skill-card');
|
|
||||||
if (!cards.length) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setupInfiniteScroll();
|
|
||||||
|
|
||||||
// Add hover effects to cards - only on non-touch devices
|
|
||||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
|
||||||
const cards = document.querySelectorAll('.skill-card');
|
|
||||||
|
|
||||||
if (!isTouchDevice) {
|
|
||||||
cards.forEach((card) => {
|
|
||||||
card.addEventListener('mousemove', (e) => {
|
|
||||||
const rect = card.getBoundingClientRect();
|
|
||||||
const x = e.clientX - rect.left;
|
|
||||||
const y = e.clientY - rect.top;
|
|
||||||
|
|
||||||
const centerX = rect.width / 2;
|
|
||||||
const centerY = rect.height / 2;
|
|
||||||
|
|
||||||
const angleX = (y - centerY) / 15;
|
|
||||||
const angleY = (centerX - x) / 15;
|
|
||||||
|
|
||||||
card.style.transform = `perspective(1000px) rotateX(${angleX}deg) rotateY(${angleY}deg) scale(1.08) translateZ(20px)`;
|
|
||||||
|
|
||||||
// Dynamic shadow based on tilt
|
|
||||||
const shadowX = (x - centerX) / 25;
|
|
||||||
const shadowY = (y - centerY) / 25;
|
|
||||||
card.style.boxShadow = `
|
|
||||||
${shadowX}px ${shadowY}px 20px rgba(0, 0, 0, 0.1),
|
|
||||||
0 10px 20px rgba(0, 0, 0, 0.05)
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
card.addEventListener('mouseleave', () => {
|
|
||||||
card.style.transform = '';
|
|
||||||
card.style.boxShadow = '';
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Simpler effects for touch devices
|
|
||||||
cards.forEach((card) => {
|
|
||||||
card.addEventListener('touchstart', () => {
|
|
||||||
card.classList.add('is-touched');
|
|
||||||
});
|
|
||||||
|
|
||||||
card.addEventListener('touchend', () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
card.classList.remove('is-touched');
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Blob animation */
|
||||||
|
.animate-blob {
|
||||||
|
animation: blob-bounce 8s infinite ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animation-delay-2000 {
|
||||||
|
animation-delay: 2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blob-bounce {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translate(5%, 5%) scale(1.05);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(0, 10%) scale(1);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translate(-5%, 5%) scale(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Tech Stack Slider */
|
/* Tech Stack Slider */
|
||||||
.slider-track {
|
.slider-track {
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
@@ -307,12 +215,10 @@ const skills = await directus.request(
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduce animation complexity on mobile */
|
/* Reduce animation complexity on mobile for better performance */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
.skill-card {
|
.skill-card {
|
||||||
transition:
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
transform 0.3s ease,
|
|
||||||
box-shadow 0.3s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.skill-card:hover {
|
.skill-card:hover {
|
||||||
@@ -328,11 +234,7 @@ const skills = await directus.request(
|
|||||||
left: -10%;
|
left: -10%;
|
||||||
width: 120%;
|
width: 120%;
|
||||||
height: 120%;
|
height: 120%;
|
||||||
background: radial-gradient(
|
background: radial-gradient(circle at center, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0) 70%);
|
||||||
circle at center,
|
|
||||||
rgba(255, 255, 255, 0.1) 0%,
|
|
||||||
rgba(255, 255, 255, 0) 70%
|
|
||||||
);
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.5s ease;
|
transition: opacity 0.5s ease;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -354,7 +256,7 @@ const skills = await directus.request(
|
|||||||
left: -100%;
|
left: -100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
|
||||||
animation: progress-shine 2s infinite;
|
animation: progress-shine 2s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,10 +269,9 @@ const skills = await directus.request(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Touch targets for mobile */
|
/* Improved touch targets for mobile */
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
a,
|
a, button {
|
||||||
button {
|
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -381,4 +282,219 @@ const skills = await directus.request(
|
|||||||
min-height: 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>
|
</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>
|
||||||
|
@@ -1,15 +1,13 @@
|
|||||||
---
|
---
|
||||||
import BlogPost from '../../layouts/BlogPost.astro';
|
import BlogPost from '../../layouts/BlogPost.astro';
|
||||||
|
|
||||||
import directus from '../../../lib/directus';
|
import directus from "../../../lib/directus"
|
||||||
import { readItems } from '@directus/sdk';
|
import { readItems } from "@directus/sdk";
|
||||||
|
|
||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = await directus.request(
|
const posts = await directus.request(readItems("posts", {
|
||||||
readItems('posts', {
|
fields: ['*'],
|
||||||
fields: ['*'],
|
}));
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const sortedEntries = [...posts].sort(
|
const sortedEntries = [...posts].sort(
|
||||||
(a, b) => b.published_date.valueOf() - a.published_date.valueOf()
|
(a, b) => b.published_date.valueOf() - a.published_date.valueOf()
|
||||||
@@ -21,142 +19,71 @@ export async function getStaticPaths() {
|
|||||||
props: {
|
props: {
|
||||||
post,
|
post,
|
||||||
nextPost: index > 0 ? sortedEntries[index - 1] : null,
|
nextPost: index > 0 ? sortedEntries[index - 1] : null,
|
||||||
prevPost: index < sortedEntries.length - 1 ? sortedEntries[index + 1] : null,
|
prevPost: index < sortedEntries.length - 1 ? sortedEntries[index + 1] : null
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { post, nextPost, prevPost } = Astro.props;
|
const { post, nextPost, prevPost } = Astro.props;
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<BlogPost
|
<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}>
|
||||||
slug={post.slug}
|
<!-- Main Content - Enhanced with better typography and spacing -->
|
||||||
title={post.title}
|
<div class="prose prose-zinc dark:prose-invert max-w-none prose-headings:scroll-mt-24 prose-headings:font-semibold prose-a:text-zinc-800 dark:prose-a:text-zinc-300 prose-a:font-medium prose-a:underline-offset-4 hover:prose-a:text-zinc-600 dark:hover:prose-a:text-zinc-100 prose-img:rounded-xl sm:prose-base prose-sm">
|
||||||
description={post.description}
|
<div set:html={post.content} />
|
||||||
content={post.content}
|
</div>
|
||||||
image={post.image}
|
|
||||||
image_alt={post.image_alt}
|
|
||||||
published_date={post.published_date}
|
|
||||||
updated_date={post.updated_date}
|
|
||||||
tags={post.tags}
|
|
||||||
>
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div
|
|
||||||
class="hero-text prose prose-sm prose-zinc dark:prose-invert sm:prose-base prose-headings:scroll-mt-24 prose-headings:font-semibold prose-a:font-medium prose-a:text-zinc-800 prose-a:underline-offset-4 hover:prose-a:text-zinc-600 prose-img:rounded-xl dark:prose-a:text-zinc-300 dark:hover:prose-a:text-zinc-100 max-w-none"
|
|
||||||
>
|
|
||||||
<div set:html={post.content} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Next/Previous Navigation -->
|
<!-- Next/Previous Navigation - Improved responsive design -->
|
||||||
<div
|
<div class="mt-12 sm:mt-16 grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6 border-t border-zinc-200 dark:border-zinc-800 pt-8 sm:pt-12">
|
||||||
class="mt-12 grid grid-cols-1 gap-4 border-t border-zinc-200 pt-8 sm:mt-16 sm:gap-6 sm:pt-12 md:grid-cols-2 dark:border-zinc-800"
|
{prevPost && (
|
||||||
>
|
|
||||||
{
|
|
||||||
prevPost && (
|
|
||||||
<a
|
<a
|
||||||
href={`/blog/${prevPost.slug}`}
|
href={`/blog/${prevPost.slug}`}
|
||||||
class="group relative flex h-full flex-col overflow-hidden rounded-xl border border-zinc-200 p-4 transition-all duration-300 hover:-translate-y-1 hover:bg-zinc-50 sm:p-6 dark:border-zinc-800 dark:hover:bg-zinc-800/50"
|
class="group relative flex flex-col h-full p-4 sm:p-6 rounded-xl border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-all duration-300 hover:-translate-y-1 overflow-hidden"
|
||||||
>
|
>
|
||||||
<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" />
|
<div class="absolute inset-0 bg-gradient-to-r from-zinc-100 to-transparent dark:from-zinc-800 dark:to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||||
<span class="relative z-10 mb-1 flex items-center gap-1 text-xs font-medium text-zinc-500 sm:mb-2 sm:gap-2 sm:text-sm dark:text-zinc-400">
|
<span class="relative z-10 text-xs sm:text-sm font-medium text-zinc-500 dark:text-zinc-400 flex items-center gap-1 sm:gap-2 mb-1 sm:mb-2">
|
||||||
<svg
|
<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="w-3 h-3 sm:w-4 sm:h-4 transition-transform duration-300 group-hover:-translate-x-1">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path d="m15 18-6-6 6-6"></path>
|
||||||
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>
|
</svg>
|
||||||
Previous Article
|
Previous Article
|
||||||
</span>
|
</span>
|
||||||
<h3 class="line-clamp-2 text-base font-medium text-zinc-900 transition-colors group-hover:text-zinc-700 sm:text-lg dark:text-white dark:group-hover:text-zinc-300">
|
<h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-white line-clamp-2 group-hover:text-zinc-700 dark:group-hover:text-zinc-300 transition-colors">
|
||||||
{prevPost.title}
|
{prevPost.title}
|
||||||
</h3>
|
</h3>
|
||||||
</a>
|
</a>
|
||||||
)
|
)}
|
||||||
}
|
{nextPost && (
|
||||||
{
|
|
||||||
nextPost && (
|
|
||||||
<a
|
<a
|
||||||
href={`/blog/${nextPost.slug}`}
|
href={`/blog/${nextPost.slug}`}
|
||||||
class="group relative flex h-full flex-col overflow-hidden rounded-xl border border-zinc-200 p-4 transition-all duration-300 hover:-translate-y-1 hover:bg-zinc-50 sm:p-6 md:text-right dark:border-zinc-800 dark:hover:bg-zinc-800/50"
|
class="group relative flex flex-col h-full p-4 sm:p-6 rounded-xl border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-all duration-300 hover:-translate-y-1 md:text-right overflow-hidden"
|
||||||
>
|
>
|
||||||
<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" />
|
<div class="absolute inset-0 bg-gradient-to-l from-zinc-100 to-transparent dark:from-zinc-800 dark:to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||||
<span class="relative z-10 mb-1 flex items-center gap-1 text-xs font-medium text-zinc-500 sm:mb-2 sm:gap-2 sm:text-sm md:justify-end dark:text-zinc-400">
|
<span class="relative z-10 text-xs sm:text-sm font-medium text-zinc-500 dark:text-zinc-400 flex items-center gap-1 sm:gap-2 mb-1 sm:mb-2 md:justify-end">
|
||||||
Next Article
|
Next Article
|
||||||
<svg
|
<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="w-3 h-3 sm:w-4 sm:h-4 transition-transform duration-300 group-hover:translate-x-1">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path d="m9 18 6-6-6-6"></path>
|
||||||
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>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<h3 class="line-clamp-2 text-base font-medium text-zinc-900 transition-colors group-hover:text-zinc-700 sm:text-lg dark:text-white dark:group-hover:text-zinc-300">
|
<h3 class="text-base sm:text-lg font-medium text-zinc-900 dark:text-white line-clamp-2 group-hover:text-zinc-700 dark:group-hover:text-zinc-300 transition-colors">
|
||||||
{nextPost.title}
|
{nextPost.title}
|
||||||
</h3>
|
</h3>
|
||||||
</a>
|
</a>
|
||||||
)
|
)}
|
||||||
}
|
</div>
|
||||||
</div>
|
|
||||||
</BlogPost>
|
</BlogPost>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('astro:page-load', () => {
|
// Removing TOC-related functions
|
||||||
// Add smooth reveal animations for content after loading
|
|
||||||
const animateContent = () => {
|
|
||||||
// Animate hero section
|
|
||||||
const heroElements = document.querySelectorAll(
|
|
||||||
'.hero-text ~ div, .hero-text h1, .hero-text span, .hero-text p'
|
|
||||||
);
|
|
||||||
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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
animateContent();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add copy buttons to code blocks
|
// Add copy buttons to code blocks
|
||||||
function initializeCodeCopyButtons() {
|
function initializeCodeCopyButtons() {
|
||||||
const codeBlocks = document.querySelectorAll('pre');
|
const codeBlocks = document.querySelectorAll('pre');
|
||||||
|
|
||||||
codeBlocks.forEach((block) => {
|
codeBlocks.forEach(block => {
|
||||||
// Skip if already processed by either method
|
// Skip if already processed by either method
|
||||||
if (
|
if (block.classList.contains('code-block-processed') || block.classList.contains('enhanced')) {
|
||||||
block.classList.contains('code-block-processed') ||
|
|
||||||
block.classList.contains('enhanced')
|
|
||||||
) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,10 +91,7 @@ const { post, nextPost, prevPost } = Astro.props;
|
|||||||
|
|
||||||
// Create wrapper if not already wrapped
|
// Create wrapper if not already wrapped
|
||||||
let wrapper;
|
let wrapper;
|
||||||
if (
|
if (block.parentNode.classList.contains('relative') && block.parentNode.classList.contains('group')) {
|
||||||
block.parentNode.classList.contains('relative') &&
|
|
||||||
block.parentNode.classList.contains('group')
|
|
||||||
) {
|
|
||||||
wrapper = block.parentNode;
|
wrapper = block.parentNode;
|
||||||
} else {
|
} else {
|
||||||
wrapper = document.createElement('div');
|
wrapper = document.createElement('div');
|
||||||
@@ -179,8 +103,7 @@ const { post, nextPost, prevPost } = Astro.props;
|
|||||||
// Add copy button if not already present
|
// Add copy button if not already present
|
||||||
if (!wrapper.querySelector('.copy-button') && !wrapper.querySelector('.copy-code-button')) {
|
if (!wrapper.querySelector('.copy-button') && !wrapper.querySelector('.copy-code-button')) {
|
||||||
const copyButton = document.createElement('button');
|
const copyButton = document.createElement('button');
|
||||||
copyButton.className =
|
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';
|
||||||
'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 = `
|
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">
|
<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" />
|
<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" />
|
||||||
@@ -212,9 +135,50 @@ const { post, nextPost, prevPost } = Astro.props;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Main initialization function
|
||||||
function initializeBlogPost() {
|
function initializeBlogPost() {
|
||||||
|
// Initialize remaining components
|
||||||
initializeCodeCopyButtons();
|
initializeCodeCopyButtons();
|
||||||
|
setupSPATransitions();
|
||||||
|
|
||||||
// Scroll to hash if present in URL
|
// Scroll to hash if present in URL
|
||||||
if (window.location.hash) {
|
if (window.location.hash) {
|
||||||
@@ -227,16 +191,22 @@ const { post, nextPost, prevPost } = Astro.props;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize on first load
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeBlogPost);
|
||||||
|
|
||||||
// Re-initialize when content changes via Astro's view transitions
|
// Re-initialize when content changes via Astro's view transitions
|
||||||
document.addEventListener('astro:page-load', initializeBlogPost);
|
document.addEventListener('astro:page-load', initializeBlogPost);
|
||||||
|
|
||||||
|
// For compatibility with custom transition system
|
||||||
|
document.addEventListener('page-transition-complete', initializeBlogPost);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
/* Removing TOC-related styles */
|
||||||
|
|
||||||
/* Language badge styling */
|
/* Language badge styling */
|
||||||
.language-badge {
|
.language-badge {
|
||||||
font-family:
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
|
||||||
monospace;
|
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
@@ -252,69 +222,66 @@ const { post, nextPost, prevPost } = Astro.props;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Typography for blog content */
|
/* Enhanced typography for blog content - Responsive adjustments */
|
||||||
.prose {
|
.prose {
|
||||||
@reference text-zinc-800 dark:text-zinc-200;
|
@apply text-zinc-800 dark:text-zinc-200;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose h1,
|
.prose h1, .prose h2, .prose h3, .prose h4 {
|
||||||
.prose h2,
|
@apply text-zinc-900 dark:text-zinc-100 font-semibold;
|
||||||
.prose h3,
|
|
||||||
.prose h4 {
|
|
||||||
@reference font-semibold text-zinc-900 dark:text-zinc-100;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose h1 {
|
.prose h1 {
|
||||||
@reference text-2xl sm:text-3xl md:text-4xl;
|
@apply text-2xl sm:text-3xl md:text-4xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose h2 {
|
.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;
|
@apply text-xl sm:text-2xl mt-8 sm:mt-12 mb-3 sm:mb-4 pb-2 border-b border-zinc-200 dark:border-zinc-800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose h3 {
|
.prose h3 {
|
||||||
@reference mb-2 mt-6 text-lg sm:mb-3 sm:mt-8 sm:text-xl;
|
@apply text-lg sm:text-xl mt-6 sm:mt-8 mb-2 sm:mb-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose p {
|
.prose p {
|
||||||
@reference mb-4 text-sm leading-relaxed sm:mb-6 sm:text-base;
|
@apply leading-relaxed mb-4 sm:mb-6 text-sm sm:text-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose a {
|
.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;
|
@apply text-zinc-800 dark:text-zinc-300 font-medium underline decoration-zinc-400 dark:decoration-zinc-600 underline-offset-2 hover:text-zinc-600 dark:hover:text-zinc-100 hover:decoration-zinc-600 dark:hover:decoration-zinc-400 transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose blockquote {
|
.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;
|
@apply border-l-4 border-zinc-300 dark:border-zinc-700 pl-4 italic text-zinc-700 dark:text-zinc-300 my-4 sm:my-6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose code {
|
.prose code {
|
||||||
@reference rounded-sm bg-zinc-100 px-1.5 py-0.5 text-sm font-medium text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200;
|
@apply bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 rounded text-zinc-800 dark:text-zinc-200 text-sm font-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose pre {
|
.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;
|
@apply bg-[#1e293b] dark:bg-[#1e293b] text-zinc-200 p-3 sm:p-4 rounded-lg overflow-x-auto text-xs sm:text-sm my-4 sm:my-6 shadow-md !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose pre code {
|
.prose pre code {
|
||||||
@reference bg-transparent p-0 text-zinc-200 dark:text-zinc-200 !important;
|
@apply bg-transparent p-0 text-zinc-200 dark:text-zinc-200 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.prose img {
|
.prose img {
|
||||||
@reference mx-auto my-6 h-auto max-w-full rounded-lg shadow-md sm:my-8;
|
@apply rounded-lg shadow-md my-6 sm:my-8 mx-auto max-w-full h-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose ul,
|
.prose ul, .prose ol {
|
||||||
.prose ol {
|
@apply my-4 sm:my-6 pl-5 sm:pl-6;
|
||||||
@reference my-4 pl-5 sm:my-6 sm:pl-6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose li {
|
.prose li {
|
||||||
@reference mb-1 text-sm sm:mb-2 sm:text-base;
|
@apply mb-1 sm:mb-2 text-sm sm:text-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose hr {
|
.prose hr {
|
||||||
@reference my-8 border-zinc-200 dark:border-zinc-800 sm:my-10;
|
@apply my-8 sm:my-10 border-zinc-200 dark:border-zinc-800;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Line clamp for truncating text */
|
/* Line clamp for truncating text */
|
||||||
|
@@ -1,281 +1,263 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||||
import FormattedDate from '../../components/FormattedDate.astro';
|
|
||||||
import TagList from '../../components/TagList.astro';
|
|
||||||
|
|
||||||
import directus from '../../../lib/directus';
|
import directus from "../../../lib/directus"
|
||||||
import { readItems } from '@directus/sdk';
|
import { readItems } from "@directus/sdk";
|
||||||
|
|
||||||
const posts = await directus.request(
|
const posts = await directus.request(
|
||||||
readItems('posts', {
|
readItems("posts", {
|
||||||
fields: ['*'],
|
fields: ['*'],
|
||||||
sort: ['-published_date'],
|
sort: ["-published_date"],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sortedPosts = posts.sort(
|
||||||
|
(a, b) => b.published_date.valueOf() - a.published_date.valueOf()
|
||||||
|
);
|
||||||
|
|
||||||
// Group posts by year for timeline effect
|
// Group posts by year for timeline effect
|
||||||
const sortedPosts = posts.sort((a, b) => b.published_date.valueOf() - a.published_date.valueOf());
|
|
||||||
const postsByYear = sortedPosts.reduce((acc, post) => {
|
const postsByYear = sortedPosts.reduce((acc, post) => {
|
||||||
const year = new Date(post.published_date).getFullYear();
|
const year = new Date(post.published_date).getFullYear();
|
||||||
if (!acc[year]) acc[year] = [];
|
if (!acc[year]) acc[year] = [];
|
||||||
acc[year].push(post);
|
acc[year].push(post);
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const years = Object.keys(postsByYear).sort((a, b) => b - a);
|
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">
|
<BaseLayout title="Blog">
|
||||||
<div class="mx-auto w-full max-w-6xl px-4 py-10 sm:px-6 sm:py-16" transition:animate="slide">
|
<div class="w-full max-w-6xl mx-auto px-4 sm:px-6 py-10 sm:py-16">
|
||||||
|
<!-- Header with search -->
|
||||||
<div class="relative mb-12 sm:mb-20">
|
<div class="relative mb-12 sm:mb-20">
|
||||||
<div class="hero-text relative text-center">
|
<!-- Decorative elements -->
|
||||||
<h1
|
<div class="absolute -top-10 sm:-top-20 -left-10 sm:-left-20 w-48 sm:w-72 h-48 sm:h-72 bg-zinc-100 dark:bg-zinc-800/30 rounded-full blur-3xl opacity-30 animate-blob"></div>
|
||||||
class="hero-text mb-4 text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl md:text-5xl dark:text-zinc-100"
|
<div class="absolute -bottom-10 sm:-bottom-20 -right-10 sm:-right-20 w-48 sm:w-72 h-48 sm:h-72 bg-zinc-200 dark:bg-zinc-800/30 rounded-full blur-3xl opacity-30 animate-blob animation-delay-2000"></div>
|
||||||
>
|
|
||||||
|
<div class="relative text-center">
|
||||||
|
<h1 class="text-3xl sm:text-4xl md:text-5xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 mb-4">
|
||||||
Blog
|
Blog
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p
|
<p class="text-sm sm:text-base text-zinc-600 dark:text-zinc-400 mb-6 sm:mb-10 max-w-2xl mx-auto">
|
||||||
class="hero-text mx-auto mb-6 max-w-2xl text-sm text-zinc-600 sm:mb-10 sm:text-base dark:text-zinc-400"
|
Thoughts, ideas, and explorations on technology and selfhosting.
|
||||||
>
|
|
||||||
A couple thoughts, a few ideas, and some guides on technology, development, and selfhosting.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Featured post -->
|
<!-- Grid layout for mobile experience -->
|
||||||
<div class="grid grid-cols-1 gap-6 sm:gap-8 md:grid-cols-12">
|
<div class="grid grid-cols-1 md:grid-cols-12 gap-6 sm:gap-8">
|
||||||
{
|
<!-- Featured post (if exists) -->
|
||||||
sortedPosts.length > 0 && (
|
{sortedPosts.length > 0 && (
|
||||||
<div class="mb-8 sm:mb-12 md:col-span-12">
|
<div class="md:col-span-12 mb-8 sm:mb-12">
|
||||||
<article class="hover-3d theme-transition-element group relative mx-auto flex max-w-2xl flex-col p-5 sm:mx-0 sm:p-8">
|
<article class="group relative overflow-hidden rounded-none border-b border-zinc-200 dark:border-zinc-800 pb-6 sm:pb-8">
|
||||||
<div class="absolute inset-0 rounded-2xl border border-zinc-200 bg-white/50 transition-all duration-300 group-hover:bg-zinc-50 hover:bg-zinc-50/80 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900/50 dark:group-hover:bg-zinc-800/70 dark:hover:bg-zinc-900/50" />
|
<div class="flex flex-col md:flex-row h-full gap-6 sm:gap-8">
|
||||||
|
{sortedPosts[0].image && (
|
||||||
<div class="flex flex-col gap-5 sm:flex-row sm:gap-6">
|
<div class="w-full md:w-1/2 h-60 sm:h-80 md:h-96 overflow-hidden mx-auto md:mx-0 max-w-full sm:max-w-md">
|
||||||
{sortedPosts[0].image && (
|
<img
|
||||||
<div class="z-10 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">
|
src={`${process.env.DIRECTUS_URL ?? "https://directus.alexlebens.dev"}/assets/${sortedPosts[0].image}`}
|
||||||
<img
|
alt={sortedPosts[0].title}
|
||||||
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${sortedPosts[0].image}?width=500`}
|
class="w-full h-full object-cover grayscale hover:grayscale-0 transition-all duration-700 group-hover:scale-105"
|
||||||
alt={sortedPosts[0].image_alt}
|
loading="eager"
|
||||||
class="h-full w-full object-cover"
|
/>
|
||||||
loading="eager"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div class="z-10 flex-1">
|
|
||||||
<h2 class="mb-2 text-center text-xl font-semibold text-zinc-900 sm:mb-3 sm:text-left sm:text-2xl dark:text-zinc-100">
|
|
||||||
<a
|
|
||||||
href={`/blog/${sortedPosts[0].slug}/`}
|
|
||||||
class="before:absolute before:inset-0"
|
|
||||||
>
|
|
||||||
{sortedPosts[0].title}
|
|
||||||
</a>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p class="mb-2 line-clamp-2 text-center text-sm text-zinc-600 sm:mb-3 sm:line-clamp-3 sm:text-left sm:text-base dark:text-zinc-400">
|
|
||||||
{sortedPosts[0].description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mb-2 flex flex-wrap items-center justify-center gap-3 text-xs text-zinc-500 sm:mb-3 sm:justify-start sm:gap-4 sm:text-sm dark:text-zinc-400">
|
|
||||||
<FormattedDate date={sortedPosts[0].published_date} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div class="z-10 mt-4 flex flex-wrap items-end justify-center border-t border-zinc-100 pt-4 sm:justify-between dark:border-zinc-800">
|
<div class="flex-1 flex flex-col justify-center">
|
||||||
<TagList tags={sortedPosts[0].tags} />
|
<div class="flex items-center text-xs sm:text-sm text-zinc-500 dark:text-zinc-400 gap-2 mb-3 justify-center md:justify-start">
|
||||||
|
<span class="font-medium uppercase tracking-wider">Featured</span>
|
||||||
|
<span class="h-px w-6 sm:w-8 bg-zinc-300 dark:bg-zinc-700"></span>
|
||||||
|
{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>
|
||||||
|
|
||||||
<div class="mx-auto sm:mr-0 sm:ml-auto">
|
<h2 class="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-zinc-100 mb-3 sm:mb-4 group-hover:text-zinc-700 dark:group-hover:text-zinc-300 transition-colors text-center md:text-left">
|
||||||
<a
|
<a href={`/blog/${sortedPosts[0].slug}/`} class="before:absolute before:inset-0">
|
||||||
href={`/blog/${sortedPosts[0].slug}`}
|
{sortedPosts[0].title}
|
||||||
class="theme-transition-color relative z-10 mx-auto mt-3 flex min-h-[44px] items-center text-sm font-medium text-zinc-700 transition-colors group-hover:text-zinc-900 sm:mx-0 sm:mt-4 dark:text-zinc-300 dark:group-hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
<span class="relative inline-block overflow-hidden">
|
|
||||||
<span class="relative z-10">Read article</span>
|
|
||||||
<span class="bg-turquoise absolute bottom-0 left-0 h-0.5 w-0 transition-all duration-300 group-hover:w-full" />
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<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>
|
</a>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-sm sm:text-base text-zinc-600 dark:text-zinc-400 mb-4 sm:mb-6 line-clamp-3 text-center md:text-left">
|
||||||
|
{sortedPosts[0].description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Improved mobile layout for featured post metadata -->
|
||||||
|
<div class="flex items-center gap-3 sm:gap-4 justify-center md:justify-start flex-wrap">
|
||||||
|
{sortedPosts[0].tags && (
|
||||||
|
<div class="flex flex-wrap gap-2 justify-center md:justify-start">
|
||||||
|
{sortedPosts[0].tags.slice(0, 2).map((tag) => (
|
||||||
|
<span class="px-2 sm:px-3 py-1 text-xs uppercase tracking-wider border border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
</div>
|
</article>
|
||||||
)
|
</div>
|
||||||
}
|
)}
|
||||||
|
|
||||||
<!-- Sidebar for mobile -->
|
<!-- Improved sidebar for mobile -->
|
||||||
<div class="relative md:col-span-3">
|
<div class="md:col-span-3 relative">
|
||||||
<div class="mb-8 space-y-4 md:sticky md:top-24 md:mb-0">
|
<div class="md:sticky md:top-24 space-y-4 mb-8 md:mb-0">
|
||||||
<h3
|
<h3 class="text-lg font-medium text-zinc-900 dark:text-zinc-100 mb-4 uppercase tracking-wider text-center md:text-left">Archive</h3>
|
||||||
class="mb-4 text-center text-lg font-medium tracking-wider text-zinc-900 uppercase md:text-left dark:text-zinc-100"
|
|
||||||
>
|
|
||||||
History
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div
|
<!-- Horizontal scrollable archive on mobile, vertical on desktop -->
|
||||||
class="hide-scrollbar flex overflow-x-auto pb-4 md:flex-col md:overflow-visible md:pb-0"
|
<div class="flex md:flex-col overflow-x-auto md:overflow-visible pb-4 md:pb-0 hide-scrollbar">
|
||||||
>
|
{years.map((year, index) => (
|
||||||
{
|
<a
|
||||||
years.map((year, index) => (
|
href={`#year-${year}`}
|
||||||
<a
|
class={`flex items-center py-2 md:py-3 px-4 md:px-0 mr-3 md:mr-0 border-b border-zinc-100 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-900 transition-colors md:w-full whitespace-nowrap md:whitespace-normal rounded-full md:rounded-none ${index === 0 ? 'bg-zinc-50 dark:bg-zinc-800/50' : ''}`}
|
||||||
href={`#year-${year}`}
|
>
|
||||||
class={`mr-3 flex items-center rounded-xl border border-zinc-300 bg-white/50 px-4 py-2 whitespace-nowrap transition-all duration-300 hover:bg-zinc-50 sm:rounded-2xl md:mr-0 md:w-full md:px-0 md:py-3 md:whitespace-normal dark:border-zinc-800 dark:hover:bg-zinc-800/70 ${index === 0 ? 'bg-white/50 dark:bg-zinc-900/50' : ''}`}
|
<span class="text-base md:text-lg font-medium text-zinc-900 dark:text-zinc-100">{year}</span>
|
||||||
>
|
<span class="ml-2 md:ml-auto text-xs md:text-sm text-zinc-500 dark:text-zinc-400">
|
||||||
<span class="mr-3 ml-3 text-base font-medium text-zinc-900 md:text-lg dark:text-zinc-100">
|
{postsByYear[year].length} post{postsByYear[year].length !== 1 ? 's' : ''}
|
||||||
{year}
|
</span>
|
||||||
</span>
|
</a>
|
||||||
<span class="mr-3 text-xs text-zinc-500 md:ml-auto md:text-sm dark:text-zinc-400">
|
))}
|
||||||
{postsByYear[year].length} post{postsByYear[year].length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Post grid -->
|
<!-- Improved post grid for mobile -->
|
||||||
<div class="md:col-span-9">
|
<div class="md:col-span-9">
|
||||||
{
|
{years.map((year) => (
|
||||||
years.map((year) => (
|
<div id={`year-${year}`} class="mb-12 sm:mb-20 scroll-mt-16">
|
||||||
<div id={`year-${year}`} class="mb-12 scroll-mt-16 sm:mb-20">
|
<h2 class="text-xl sm:text-2xl font-bold text-zinc-900 dark:text-zinc-100 mb-6 sm:mb-8 border-b border-zinc-200 dark:border-zinc-800 pb-3 sm:pb-4 text-center md:text-left">
|
||||||
<h2 class="mb-6 border-b border-zinc-200 pb-3 text-center text-xl font-bold text-zinc-900 sm:mb-8 sm:pb-4 sm:text-2xl md:text-left dark:border-zinc-800 dark:text-zinc-100">
|
{year}
|
||||||
{year}
|
</h2>
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div
|
<div class={`grid grid-cols-1 ${postsByYear[year].length >= 2 ? 'md:grid-cols-2' : 'md:grid-cols-1'} gap-8 sm:gap-12`}>
|
||||||
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 flex flex-col h-full mx-auto md:mx-0 w-full max-w-sm sm:max-w-md">
|
||||||
{postsByYear[year].map((post) => (
|
{post.image && (
|
||||||
<article class="hover-3d theme-transition-element group relative mx-auto flex max-w-2xl flex-col p-5 sm:mx-0 sm:p-8">
|
<div class="h-48 sm:h-56 overflow-hidden mb-4 rounded-lg">
|
||||||
<div class="absolute inset-0 rounded-2xl border border-zinc-200 bg-white/50 transition-all duration-300 group-hover:bg-zinc-50 hover:bg-zinc-50/80 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900/50 dark:group-hover:bg-zinc-800/70 dark:hover:bg-zinc-900/50" />
|
<img
|
||||||
|
src={`${process.env.DIRECTUS_URL ?? "https://directus.alexlebens.dev"}/assets/${post.image}`}
|
||||||
|
alt={post.title}
|
||||||
|
class="w-full h-full object-cover grayscale hover:grayscale-0 transition-all duration-700 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{post.image && (
|
<div class="flex-1 flex flex-col">
|
||||||
<div class="relative z-10 mb-4 aspect-video w-full overflow-hidden rounded-lg">
|
<div class="flex items-center text-xs sm:text-sm text-zinc-500 dark:text-zinc-400 gap-3 sm:gap-4 mb-2 sm:mb-3 justify-center md:justify-start flex-wrap">
|
||||||
<img
|
{post.pubDate && (
|
||||||
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${post.image}`}
|
<time datetime={post.published_date.toLocaleString()} class="flex items-center">
|
||||||
alt={post.title}
|
{post.published_date.toLocaleString('en-US', {
|
||||||
class="h-full w-full object-cover"
|
month: 'short',
|
||||||
loading="lazy"
|
day: 'numeric'
|
||||||
/>
|
})}
|
||||||
</div>
|
</time>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col">
|
<h3 class="text-lg sm:text-xl font-semibold text-zinc-900 dark:text-zinc-100 mb-2 sm:mb-3 group-hover:text-zinc-700 dark:group-hover:text-zinc-300 transition-colors text-center md:text-left">
|
||||||
<div class="mb-2 flex flex-wrap items-center justify-center gap-3 text-xs text-zinc-500 sm:mb-3 sm:gap-4 sm:text-sm md:justify-start dark:text-zinc-400">
|
<a href={`/blog/${post.slug}/`} class="before:absolute before:inset-0">
|
||||||
{post.pubDate && (
|
{post.title}
|
||||||
<time
|
</a>
|
||||||
datetime={post.published_date.toLocaleString()}
|
</h3>
|
||||||
class="flex items-center"
|
|
||||||
>
|
<p class="text-sm text-zinc-600 dark:text-zinc-400 mb-4 line-clamp-2 flex-grow text-center md:text-left">
|
||||||
{post.published_date.toLocaleString('en-US', {
|
{post.description}
|
||||||
month: 'short',
|
</p>
|
||||||
day: 'numeric',
|
|
||||||
})}
|
{post.tags && (
|
||||||
</time>
|
<div class="flex flex-wrap gap-2 mt-auto justify-center md:justify-start">
|
||||||
|
{post.tags.slice(0, 2).map((tag) => (
|
||||||
|
<span class="px-2 sm:px-3 py-1 text-xs uppercase tracking-wider border border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{post.tags.length > 2 && (
|
||||||
|
<span class="px-2 sm:px-3 py-1 text-xs uppercase tracking-wider border border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400">
|
||||||
|
+{post.tags.length - 2}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<h3 class="z-10 mb-2 text-center text-lg font-semibold text-zinc-900 transition-colors group-hover:text-zinc-700 sm:mb-3 sm:text-xl md:text-left dark:text-zinc-100 dark:group-hover:text-zinc-300">
|
</div>
|
||||||
<a href={`/blog/${post.slug}/`} class="before:absolute before:inset-0">
|
</article>
|
||||||
{post.title}
|
))}
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p class="z-10 mb-4 line-clamp-2 grow text-center text-sm text-zinc-600 md:text-left dark:text-zinc-400">
|
|
||||||
{post.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mb-2 flex flex-wrap items-center justify-center gap-3 text-xs text-zinc-500 sm:mb-3 sm:justify-start sm:gap-4 sm:text-sm dark:text-zinc-400">
|
|
||||||
<FormattedDate date={post.published_date} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="z-10 mt-4 flex flex-wrap items-end justify-center border-t border-zinc-100 pt-4 sm:justify-between dark:border-zinc-800">
|
|
||||||
<TagList tags={post.tags} />
|
|
||||||
|
|
||||||
<div class="mx-auto sm:mr-0 sm:ml-auto">
|
|
||||||
<a
|
|
||||||
href={`/blog/${post.slug}`}
|
|
||||||
class="theme-transition-color relative z-10 mx-auto mt-3 flex min-h-[44px] items-center text-sm font-medium text-zinc-700 transition-colors group-hover:text-zinc-900 sm:mx-0 sm:mt-4 dark:text-zinc-300 dark:group-hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
<span class="relative inline-block overflow-hidden">
|
|
||||||
<span class="relative z-10">Read article</span>
|
|
||||||
<span class="bg-turquoise absolute bottom-0 left-0 h-0.5 w-0 transition-all duration-300 group-hover:w-full" />
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
</div>
|
||||||
}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('astro:page-load', () => {
|
|
||||||
// Add smooth reveal animations for content after loading
|
|
||||||
const animateContent = () => {
|
|
||||||
// Animate hero section
|
|
||||||
const heroElements = document.querySelectorAll(
|
|
||||||
'.hero-text ~ div, .hero-text h1, .hero-text span, .hero-text p'
|
|
||||||
);
|
|
||||||
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
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
animateContent();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<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 for descriptions */
|
||||||
.line-clamp-2 {
|
.line-clamp-2 {
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -291,53 +273,135 @@ const years = Object.keys(postsByYear).sort((a, b) => b - a);
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Prevent layout shifts */
|
/* Improved touch targets for mobile */
|
||||||
.grow {
|
|
||||||
grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.min-w-0 {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ensure container doesn't overflow */
|
|
||||||
.overflow-hidden {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Touch targets for mobile */
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
a,
|
a, button {
|
||||||
button {
|
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.touch-active {
|
|
||||||
transform: scale(0.97) !important;
|
|
||||||
opacity: 0.9;
|
|
||||||
transition:
|
|
||||||
transform 0.15s ease-in-out,
|
|
||||||
opacity 0.15s ease-in-out !important;
|
|
||||||
}
|
|
||||||
</style>
|
</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,262 +1,489 @@
|
|||||||
---
|
---
|
||||||
import Layout from '../layouts/Layout.astro';
|
import Layout from '../layouts/Layout.astro';
|
||||||
import FormattedDate from '../components/FormattedDate.astro';
|
import FormattedDate from '../components/FormattedDate.astro';
|
||||||
import TagList from '../components/TagList.astro';
|
|
||||||
|
|
||||||
import directus from '../../lib/directus';
|
import directus from "../../lib/directus"
|
||||||
import { readItems, readSingleton } from '@directus/sdk';
|
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(
|
const posts = await directus.request(
|
||||||
readItems('posts', {
|
readItems("posts", {
|
||||||
fields: ['*'],
|
fields: ['*'],
|
||||||
sort: ['-published_date'],
|
sort: ["-published_date"],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const recentPosts = posts
|
const recentPosts = posts
|
||||||
.sort((a, b) => b.published_date.getTime() - a.published_date.getTime())
|
.sort((a, b) => b.published_date.getTime() - a.published_date.getTime())
|
||||||
.slice(0, 3);
|
.slice(0, 3);
|
||||||
const allTags = [...new Set(posts.flatMap((post) => post.tags || []))].slice(0, 5);
|
|
||||||
|
const allTags = [...new Set(posts.flatMap(post => post.tags || []))].slice(0, 5);
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title=`Home | ${global.name}`>
|
<Layout title=`Home | ${global.name}`>
|
||||||
<!-- Header section -->
|
<!-- Hero Section with improved mobile responsiveness -->
|
||||||
<section
|
<section class="py-10 sm:py-16 md:py-20 px-4 sm:px-6 theme-transition-all">
|
||||||
class="theme-transition-all px-4 py-10 sm:px-6 sm:py-16 md:py-20"
|
<div class="max-w-2xl mx-auto relative">
|
||||||
transition:animate="slide"
|
<!-- Adjusted blob positions and sizes for better mobile appearance -->
|
||||||
>
|
<div class="absolute -top-10 sm:-top-20 -left-10 sm:-left-20 w-40 sm:w-64 h-40 sm:h-64 bg-zinc-100 dark:bg-zinc-800/50 rounded-full blur-3xl opacity-50 animate-blob theme-transition-bg"></div>
|
||||||
<div class="relative mx-auto max-w-2xl">
|
<div class="absolute -bottom-10 sm:-bottom-20 -right-10 sm:-right-20 w-40 sm:w-64 h-40 sm:h-64 bg-zinc-200 dark:bg-zinc-800/30 rounded-full blur-3xl opacity-30 animate-blob animation-delay-2000 theme-transition-bg"></div>
|
||||||
|
|
||||||
<div class="relative text-center sm:text-left">
|
<div class="relative text-center sm:text-left">
|
||||||
<h1
|
<h1 class="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 theme-transition-color hero-text">
|
||||||
class="theme-transition-color hero-text text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl md:text-5xl lg:text-6xl dark:text-zinc-100"
|
|
||||||
>
|
|
||||||
<span class="block">Writing on technology,</span>
|
<span class="block">Writing on technology,</span>
|
||||||
<span class="mt-1 block">development, and</span>
|
<span class="block mt-1">development, and</span>
|
||||||
<span class="relative mt-1 block">
|
<span class="block mt-1 relative">
|
||||||
<span class="relative inline-block">
|
<span class="relative inline-block">
|
||||||
selfhosting.
|
selfhosting.
|
||||||
<span
|
<span class="absolute -bottom-1 left-0 w-full h-1 bg-zinc-800 dark:bg-zinc-200 transform origin-left theme-transition-bg"></span>
|
||||||
class="theme-transition-bg bg-turquoise absolute -bottom-1 left-0 h-1 w-full origin-left transform"
|
|
||||||
></span>
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p
|
<p class="mt-4 sm:mt-6 md:mt-8 text-base sm:text-lg text-zinc-600 dark:text-zinc-400 leading-relaxed theme-transition-color max-w-lg mx-auto sm:mx-0">
|
||||||
class="theme-transition-color mx-auto mt-4 max-w-lg text-base leading-relaxed text-zinc-600 sm:mx-0 sm:mt-6 sm:text-lg md:mt-8 dark:text-zinc-400"
|
|
||||||
>
|
|
||||||
{global.about}
|
{global.about}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div class="mt-6 sm:mt-8 md:mt-10 flex flex-wrap gap-3 sm:gap-4 md:gap-6 justify-center sm:justify-start">
|
||||||
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
|
<a
|
||||||
href="/about"
|
href="/about"
|
||||||
class="theme-transition-color group relative inline-flex min-h-[44px] items-center gap-2 text-sm font-medium text-zinc-600 transition-all duration-300 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
class="group relative inline-flex items-center gap-2 text-sm font-medium text-zinc-900 dark:text-zinc-100 hover:text-zinc-700 dark:hover:text-zinc-300 transition-all duration-300 theme-transition-color min-h-[44px]"
|
||||||
>
|
>
|
||||||
<span>More about me</span>
|
<span>More about me</span>
|
||||||
<svg
|
<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 transition-transform duration-300 group-hover:translate-x-1">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||||
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>
|
</svg>
|
||||||
|
<span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-zinc-800 dark:bg-zinc-200 transition-all duration-300 group-hover:w-full theme-transition-bg"></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Featured post section -->
|
<!-- Featured Post Section - Improved for mobile -->
|
||||||
<section
|
<section class="py-10 sm:py-12 md:py-16 px-4 sm:px-6 border-t border-zinc-100 dark:border-zinc-800 theme-transition-all">
|
||||||
class="theme-transition-all border-t border-zinc-200 px-4 py-10 sm:px-6 sm:py-12 md:py-16 dark:border-zinc-800"
|
<div class="max-w-3xl mx-auto">
|
||||||
>
|
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6 sm:mb-8 md:mb-12">
|
||||||
<div class="mx-auto max-w-3xl">
|
<h2 class="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 theme-transition-color text-center sm:text-left">Recent Posts</h2>
|
||||||
<div
|
|
||||||
class="mb-6 flex flex-col justify-between gap-4 sm:mb-8 sm:flex-row sm:items-center md:mb-12"
|
|
||||||
>
|
|
||||||
<h2
|
|
||||||
class="theme-transition-color text-center text-xl font-bold tracking-tight text-zinc-900 sm:text-left sm:text-2xl md:text-3xl dark:text-zinc-100"
|
|
||||||
>
|
|
||||||
Recent Posts
|
|
||||||
</h2>
|
|
||||||
<a
|
<a
|
||||||
href="/blog"
|
href="/blog"
|
||||||
class="theme-transition-color group relative flex min-h-[44px] items-center justify-center self-center text-sm font-medium text-zinc-600 transition-all duration-300 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
|
class="group relative text-sm font-medium text-zinc-900 dark:text-zinc-100 hover:text-zinc-700 dark:hover:text-zinc-300 self-center sm:self-auto theme-transition-color min-h-[44px] flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<span class="flex items-center gap-1">
|
<span class="flex items-center gap-1">
|
||||||
View all posts
|
View all posts
|
||||||
<svg
|
<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 transition-transform duration-300 group-hover:translate-x-1">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||||
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>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
|
<span class="absolute -bottom-1 left-0 w-0 h-0.5 bg-zinc-800 dark:bg-zinc-200 transition-all duration-300 group-hover:w-full theme-transition-bg"></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Post grid -->
|
<!-- 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">
|
<div class="grid gap-6 sm:gap-8 md:gap-12 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{
|
{recentPosts.map((post, index) => (
|
||||||
recentPosts.map((post, index) => (
|
<article class="group relative flex flex-col items-start hover-3d theme-transition-element max-w-sm mx-auto sm:mx-0 w-full">
|
||||||
<article class="theme-transition-element group relative mx-auto flex w-full max-w-sm flex-col items-start sm:mx-0">
|
<div class="absolute -inset-x-4 -inset-y-6 z-0 scale-95 bg-zinc-50 dark:bg-zinc-800/50 opacity-0 transition group-hover:scale-100 group-hover:opacity-100 sm:-inset-x-6 sm:rounded-2xl theme-transition-bg"></div>
|
||||||
<div class="theme-transition-all absolute -inset-x-4 -inset-y-6 z-0 border border-zinc-300 bg-white/50 transition-all duration-300 group-hover:bg-zinc-50 sm:-inset-x-6 sm:rounded-2xl dark:border-zinc-800 dark:bg-zinc-900/50 dark:group-hover:bg-zinc-800/70" />
|
|
||||||
|
|
||||||
{post.image && (
|
{post.image && (
|
||||||
<div class="relative z-10 mb-4 aspect-video w-full overflow-hidden rounded-lg">
|
<div class="relative z-10 w-full aspect-video mb-4 overflow-hidden rounded-lg">
|
||||||
<img
|
<img
|
||||||
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${post.image}`}
|
src={`${process.env.DIRECTUS_URL ?? "https://directus.alexlebens.dev"}/assets/${post.image}`}
|
||||||
alt={post.title}
|
alt={post.title}
|
||||||
class="h-full w-full object-cover"
|
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
loading={index === 0 ? 'eager' : 'lazy'}
|
loading={index === 0 ? "eager" : "lazy"}
|
||||||
width="400"
|
width="400"
|
||||||
height="225"
|
height="225"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h3 class="theme-transition-color relative z-10 mt-3 w-full text-center text-lg font-semibold tracking-tight text-zinc-900 transition-colors group-hover:text-zinc-700 sm:text-left sm:text-xl dark:text-zinc-100 dark:group-hover:text-zinc-300">
|
|
||||||
<a
|
|
||||||
href={`/blog/${post.slug}`}
|
|
||||||
class="flex min-h-[44px] items-center justify-center sm:justify-start"
|
|
||||||
>
|
|
||||||
<span class="absolute -inset-x-4 -inset-y-2.5 sm:-inset-x-6 sm:-inset-y-4" />
|
|
||||||
{post.title}
|
|
||||||
</a>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<p class="z-10 mb-2 line-clamp-2 text-center text-sm text-zinc-600 sm:mb-3 sm:line-clamp-3 sm:text-left sm:text-base dark:text-zinc-400">
|
|
||||||
{post.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mb-2 flex flex-wrap items-center justify-center gap-3 text-xs text-zinc-500 sm:mb-3 sm:justify-start sm:gap-4 sm:text-sm dark:text-zinc-400">
|
|
||||||
<FormattedDate date={post.published_date} />
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<TagList tags={post.tags} class="z-10" />
|
<div class="relative z-10 flex items-center flex-wrap gap-x-3 sm:gap-x-4 gap-y-2 text-xs text-zinc-500 dark:text-zinc-400 theme-transition-color justify-center sm:justify-start w-full">
|
||||||
|
<time datetime={post.published_date.toLocaleString()} class="font-medium">
|
||||||
|
<FormattedDate date={post.published_date} />
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
<a
|
<h3 class="relative z-10 mt-3 text-lg sm:text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100 group-hover:text-zinc-700 dark:group-hover:text-zinc-300 transition-colors theme-transition-color text-center sm:text-left w-full">
|
||||||
href={`/blog/${post.slug}`}
|
<a href={`/blog/${post.slug}`} class="min-h-[44px] flex items-center justify-center sm:justify-start">
|
||||||
class="theme-transition-color relative z-10 mx-auto mt-3 flex min-h-[44px] items-center text-sm font-medium text-zinc-700 transition-colors group-hover:text-zinc-900 sm:mx-0 sm:mt-4 dark:text-zinc-300 dark:group-hover:text-zinc-100"
|
<span class="absolute -inset-x-4 -inset-y-2.5 sm:-inset-x-6 sm:-inset-y-4"></span>
|
||||||
>
|
{post.title}
|
||||||
<span class="relative inline-block overflow-hidden">
|
|
||||||
<span class="relative z-10">Read article</span>
|
|
||||||
<span class="bg-turquoise absolute bottom-0 left-0 h-0.5 w-0 transition-all duration-300 group-hover:w-full" />
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<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>
|
</a>
|
||||||
</article>
|
</h3>
|
||||||
))
|
|
||||||
}
|
<p class="relative z-10 mt-2 sm:mt-3 text-sm text-zinc-600 dark:text-zinc-400 line-clamp-3 theme-transition-color text-center sm:text-left w-full">
|
||||||
|
{post.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<div class="relative z-10 mt-3 sm:mt-4 flex flex-wrap gap-2 justify-center sm:justify-start w-full">
|
||||||
|
{post.tags.slice(0, 3).map(tag => (
|
||||||
|
<a
|
||||||
|
href={`/topics/${tag}`}
|
||||||
|
class="inline-flex items-center rounded-full bg-zinc-100 px-2 sm:px-3 py-1 text-xs font-medium text-zinc-800 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 transition-colors theme-transition-all min-h-[28px]"
|
||||||
|
>
|
||||||
|
#{tag}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
{post.tags.length > 3 && (
|
||||||
|
<span class="inline-flex 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 theme-transition-all min-h-[28px]">
|
||||||
|
+{post.tags.length - 3} more
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`/blog/${post.slug}`}
|
||||||
|
class="relative z-10 mt-3 sm:mt-4 flex items-center text-sm font-medium text-zinc-700 dark:text-zinc-300 group-hover:text-zinc-900 dark:group-hover:text-zinc-100 transition-colors theme-transition-color mx-auto sm:mx-0 min-h-[44px]"
|
||||||
|
>
|
||||||
|
<span class="relative overflow-hidden inline-block">
|
||||||
|
<span class="group-hover:-translate-y-full block transition-transform duration-300">Read article</span>
|
||||||
|
<span class="absolute top-0 left-0 translate-y-full group-hover:translate-y-0 transition-transform duration-300 whitespace-nowrap">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"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Tags section -->
|
<!-- Topics/Tags Section - Improved for mobile -->
|
||||||
{
|
{allTags.length > 0 && (
|
||||||
allTags.length > 0 && (
|
<section class="py-10 sm:py-12 md:py-16 px-4 sm:px-6 border-t border-zinc-100 dark:border-zinc-800 theme-transition-all">
|
||||||
<section class="theme-transition-all border-t border-zinc-200 px-4 py-10 sm:px-6 sm:py-12 md:py-16 dark:border-zinc-800">
|
<div class="max-w-3xl mx-auto">
|
||||||
<div class="mx-auto max-w-3xl">
|
<h2 class="text-xl sm:text-2xl md:text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 mb-6 sm:mb-8 theme-transition-color text-center sm:text-left">Explore Topics</h2>
|
||||||
<h2 class="theme-transition-color mb-6 text-center text-xl font-bold tracking-tight text-zinc-900 sm:mb-8 sm:text-left sm:text-2xl md:text-3xl dark:text-zinc-100">
|
|
||||||
Popular Tags
|
|
||||||
</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">
|
<!-- Improved grid layout for mobile -->
|
||||||
{allTags.map((tag) => {
|
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3 sm:gap-4 max-w-xs sm:max-w-none mx-auto">
|
||||||
const tagCount = posts.filter((post) => post.tags && post.tags.includes(tag)).length;
|
{allTags.map(tag => {
|
||||||
return (
|
const tagCount = posts.filter(post => post.tags && post.tags.includes(tag)).length;
|
||||||
<a
|
return (
|
||||||
href={`/tags/${tag}`}
|
<a
|
||||||
class="theme-transition-all flex min-h-[80px] flex-col rounded-xl border border-zinc-300 bg-white/50 p-3 transition-all duration-300 hover:bg-zinc-50 sm:min-h-[90px] sm:p-4 md:p-6 dark:border-zinc-800 dark:bg-zinc-900/50 dark:hover:bg-zinc-800/70"
|
href={`/topics/${tag}`}
|
||||||
>
|
class="group flex flex-col p-3 sm:p-4 md:p-6 rounded-xl border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-800/70 transition-all duration-300 theme-transition-all min-h-[80px] sm:min-h-[90px]"
|
||||||
<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">
|
<div class="flex items-start justify-between mb-2">
|
||||||
#{tag}
|
<span class="text-sm font-medium text-zinc-900 dark:text-zinc-100 theme-transition-color mr-2">#{tag}</span>
|
||||||
</span>
|
<span class="text-xs bg-zinc-100 dark:bg-zinc-800 text-zinc-500 dark:text-zinc-400 px-2 py-0.5 rounded-full flex-shrink-0 theme-transition-all">
|
||||||
<span class="theme-transition-all shrink-0 rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs font-medium text-zinc-600 transition-colors hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700">
|
{tagCount} {tagCount === 1 ? 'post' : 'posts'}
|
||||||
{tagCount} {tagCount === 1 ? 'post' : 'posts'}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<p class="text-xs text-zinc-600 dark:text-zinc-400 mt-1 theme-transition-color">
|
||||||
<p class="theme-transition-color mt-1 text-xs text-zinc-600 dark:text-zinc-400">
|
Explore articles about {tag}
|
||||||
Explore articles about {tag}
|
</p>
|
||||||
</p>
|
</a>
|
||||||
</a>
|
)
|
||||||
);
|
})}
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
)
|
<div class="mt-6 sm:mt-8 text-center">
|
||||||
}
|
<a
|
||||||
|
href="/tags"
|
||||||
|
class="inline-flex items-center text-sm font-medium text-zinc-900 dark:text-zinc-100 hover:text-zinc-700 dark:hover:text-zinc-300 theme-transition-color min-h-[44px]"
|
||||||
|
>
|
||||||
|
<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="w-4 h-4 ml-1 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>
|
</Layout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('astro:page-load', () => {
|
// Add hover effect for cards on touch devices
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Check if it's a touch device
|
||||||
|
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||||
|
|
||||||
|
if (isTouchDevice) {
|
||||||
|
const cards = document.querySelectorAll('.hover-3d');
|
||||||
|
|
||||||
|
cards.forEach(card => {
|
||||||
|
card.addEventListener('touchstart', () => {
|
||||||
|
card.classList.add('is-touched');
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener('touchend', () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
card.classList.remove('is-touched');
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable hover animations on touch devices for better performance
|
||||||
|
document.documentElement.classList.add('touch-device');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Improved viewport height fix for mobile browsers
|
||||||
|
const setVh = () => {
|
||||||
|
const vh = window.innerHeight * 0.01;
|
||||||
|
document.documentElement.style.setProperty('--vh', `${vh}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set initial value
|
||||||
|
setVh();
|
||||||
|
|
||||||
|
// Update on resize and scroll to prevent content shifting
|
||||||
|
window.addEventListener('resize', setVh);
|
||||||
|
|
||||||
|
// Use a debounced scroll handler to prevent performance issues
|
||||||
|
let scrollTimeout;
|
||||||
|
window.addEventListener('scroll', () => {
|
||||||
|
if (scrollTimeout) {
|
||||||
|
window.cancelAnimationFrame(scrollTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTimeout = window.requestAnimationFrame(() => {
|
||||||
|
// Lock width during scroll
|
||||||
|
document.body.style.width = '100%';
|
||||||
|
document.body.style.overflowX = 'hidden';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix for iOS Safari address bar height changes
|
||||||
|
if (/iPhone|iPad|iPod/.test(navigator.userAgent)) {
|
||||||
|
// Force the layout to use the initial viewport size
|
||||||
|
document.documentElement.style.setProperty('--initial-vh', `${window.innerHeight * 0.01}px`);
|
||||||
|
|
||||||
|
// Apply fixed height to sections to prevent resizing
|
||||||
|
const sections = document.querySelectorAll('section');
|
||||||
|
sections.forEach(section => {
|
||||||
|
section.style.width = '100%';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Improved theme change handler that preserves scroll position and provides smoother transitions
|
||||||
|
document.addEventListener('themeChanged', () => {
|
||||||
|
// Store current scroll position
|
||||||
|
const scrollPosition = window.scrollY;
|
||||||
|
|
||||||
|
// Create a temporary overlay for smoother transition
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: ${document.documentElement.classList.contains('dark') ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)'};
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
// Fade in overlay
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
overlay.style.opacity = '0.5';
|
||||||
|
|
||||||
|
// Update theme-transition elements without forcing reflow of entire page
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.querySelectorAll('.theme-transition-all, .theme-transition-bg, .theme-transition-color')
|
||||||
|
.forEach(el => {
|
||||||
|
// Apply a subtle animation instead of a hard reset
|
||||||
|
el.style.transition = 'all 0.5s ease';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fade out overlay after transition completes
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.style.opacity = '0';
|
||||||
|
setTimeout(() => {
|
||||||
|
overlay.remove();
|
||||||
|
}, 300);
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore scroll position (prevents jumping to top)
|
||||||
|
if (scrollPosition > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.scrollTo({
|
||||||
|
top: scrollPosition,
|
||||||
|
behavior: 'auto' // Use 'auto' to prevent animation
|
||||||
|
});
|
||||||
|
}, 10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix theme inconsistency issues by checking theme on visibility change
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
const storedTheme = localStorage.getItem('theme');
|
||||||
|
const currentThemeIsDark = document.documentElement.classList.contains('dark');
|
||||||
|
|
||||||
|
if (storedTheme === 'dark' && !currentThemeIsDark) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else if (storedTheme === 'light' && currentThemeIsDark) {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Add smooth reveal animations for content after loading
|
// Add smooth reveal animations for content after loading
|
||||||
const animateContent = () => {
|
const animateContent = () => {
|
||||||
// Animate hero section
|
// Animate hero section
|
||||||
const heroElements = document.querySelectorAll(
|
const heroElements = document.querySelectorAll('.hero-text span, .hero-text + p, .hero-text ~ div');
|
||||||
'.hero-text span, .hero-text + p, .hero-text ~ div'
|
|
||||||
);
|
|
||||||
heroElements.forEach((el, index) => {
|
heroElements.forEach((el, index) => {
|
||||||
setTimeout(
|
setTimeout(() => {
|
||||||
() => {
|
el.classList.add('animate-reveal');
|
||||||
el.classList.add('animate-reveal');
|
}, 100 + (index * 150));
|
||||||
},
|
|
||||||
100 + index * 150
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Animate posts with staggered delay
|
// Animate posts with staggered delay
|
||||||
const articles = document.querySelectorAll('article.group');
|
const articles = document.querySelectorAll('article.group');
|
||||||
articles.forEach((article, index) => {
|
articles.forEach((article, index) => {
|
||||||
setTimeout(
|
setTimeout(() => {
|
||||||
() => {
|
article.classList.add('animate-reveal');
|
||||||
article.classList.add('animate-reveal');
|
}, 500 + (index * 150));
|
||||||
},
|
|
||||||
500 + index * 150
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Animate topic cards with staggered delay
|
// Animate topic cards with staggered delay
|
||||||
const topicCards = document.querySelectorAll('a.group.flex.flex-col');
|
const topicCards = document.querySelectorAll('a.group.flex.flex-col');
|
||||||
topicCards.forEach((card, index) => {
|
topicCards.forEach((card, index) => {
|
||||||
setTimeout(
|
setTimeout(() => {
|
||||||
() => {
|
card.classList.add('animate-reveal');
|
||||||
card.classList.add('animate-reveal');
|
}, 800 + (index * 100));
|
||||||
},
|
|
||||||
800 + index * 100
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
animateContent();
|
// Run animations after the loading screen is hidden
|
||||||
|
const loadingScreen = document.getElementById('loading-screen');
|
||||||
|
if (loadingScreen) {
|
||||||
|
// Check if loading screen is already hidden (page refresh)
|
||||||
|
if (loadingScreen.style.display === 'none') {
|
||||||
|
animateContent();
|
||||||
|
} else {
|
||||||
|
// Wait for loading screen to hide
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.target === loadingScreen &&
|
||||||
|
mutation.type === 'attributes' &&
|
||||||
|
mutation.attributeName === 'style' &&
|
||||||
|
loadingScreen.style.display === 'none') {
|
||||||
|
animateContent();
|
||||||
|
observer.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(loadingScreen, { attributes: true });
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
setTimeout(animateContent, 3500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If loading screen doesn't exist for some reason
|
||||||
|
animateContent();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SPA transition handling for homepage
|
||||||
|
function setupSPATransitions() {
|
||||||
|
// Handle all internal links for SPA transitions
|
||||||
|
document.querySelectorAll('a[href^="/"]').forEach(link => {
|
||||||
|
// Skip links that are anchor links, external links, or already processed
|
||||||
|
if (link.getAttribute('href').includes('#') ||
|
||||||
|
link.getAttribute('target') === '_blank' ||
|
||||||
|
link.hasAttribute('data-spa-handled')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as handled to avoid duplicate listeners
|
||||||
|
link.setAttribute('data-spa-handled', 'true');
|
||||||
|
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
// Don't handle if modifier keys are pressed (for opening in new tab, etc.)
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
const targetHref = link.getAttribute('href');
|
||||||
|
|
||||||
|
// Trigger page transition animation
|
||||||
|
const pageTransition = document.getElementById('page-transition');
|
||||||
|
if (pageTransition) {
|
||||||
|
pageTransition.classList.remove('opacity-0');
|
||||||
|
pageTransition.classList.add('opacity-100');
|
||||||
|
|
||||||
|
// Navigate after transition effect
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = targetHref;
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
// Fallback if transition element doesn't exist
|
||||||
|
window.location.href = targetHref;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on first load
|
||||||
|
document.addEventListener('DOMContentLoaded', setupSPATransitions);
|
||||||
|
|
||||||
|
// Re-initialize when content changes via Astro's view transitions
|
||||||
|
document.addEventListener('astro:page-load', setupSPATransitions);
|
||||||
|
|
||||||
|
// For compatibility with custom transition system
|
||||||
|
document.addEventListener('page-transition-complete', setupSPATransitions);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Fix for theme transition issues */
|
||||||
|
:global(:root) {
|
||||||
|
--theme-transition-duration: 0.5s;
|
||||||
|
--theme-transition-timing: ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(html), :global(body) {
|
||||||
|
transition: background-color var(--theme-transition-duration) var(--theme-transition-timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.theme-transition-all) {
|
||||||
|
transition: all var(--theme-transition-duration) var(--theme-transition-timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.theme-transition-bg) {
|
||||||
|
transition: background-color var(--theme-transition-duration) var(--theme-transition-timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.theme-transition-color) {
|
||||||
|
transition: color var(--theme-transition-duration) var(--theme-transition-timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure transitions apply to all theme-related properties */
|
||||||
|
:global(*) {
|
||||||
|
transition-property: background-color, border-color, color, fill, stroke, opacity;
|
||||||
|
transition-duration: var(--theme-transition-duration);
|
||||||
|
transition-timing-function: var(--theme-transition-timing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove the forced transition disabling which causes flickering */
|
||||||
|
:global(.theme-switching), :global(.theme-switching *) {
|
||||||
|
/* Use a subtle transition instead of none */
|
||||||
|
transition-duration: 0.3s !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content reveal animations */
|
||||||
|
.hero-text span,
|
||||||
|
.hero-text + p,
|
||||||
|
.hero-text ~ div,
|
||||||
|
article.group,
|
||||||
|
a.group.flex.flex-col {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
transition: opacity 0.8s ease, transform 0.8s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-reveal {
|
||||||
|
opacity: 1 !important;
|
||||||
|
transform: translateY(0) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rest of your existing styles... */
|
||||||
|
</style>
|
||||||
|
@@ -1,14 +1,14 @@
|
|||||||
import rss from '@astrojs/rss';
|
import rss from '@astrojs/rss';
|
||||||
|
|
||||||
import directus from '../../lib/directus';
|
import directus from "../../lib/directus"
|
||||||
import { readItems, readSingleton } from '@directus/sdk';
|
import { readItems,readSingleton } from "@directus/sdk";
|
||||||
|
|
||||||
export async function GET(context: any) {
|
export async function GET(context: any) {
|
||||||
const global = await directus.request(readSingleton('global'));
|
const global = await directus.request(readSingleton("global"));
|
||||||
const posts = await directus.request(
|
const posts = await directus.request(
|
||||||
readItems('posts', {
|
readItems("posts", {
|
||||||
fields: ['*'],
|
fields: ['*'],
|
||||||
sort: ['-published_date'],
|
sort: ["-published_date"],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@@ -1,358 +0,0 @@
|
|||||||
---
|
|
||||||
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: ['*'],
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
const sortedPosts =
|
|
||||||
posts && posts.length > 0
|
|
||||||
? [...posts].sort((a, b) => b.published_date.valueOf() - a.published_date.valueOf())
|
|
||||||
: [];
|
|
||||||
|
|
||||||
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" transition:animate="slide">
|
|
||||||
<div class="relative mb-10 sm:mb-16">
|
|
||||||
<div class="relative text-center sm:text-left">
|
|
||||||
<a
|
|
||||||
href="/blog#topics"
|
|
||||||
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 blog</span>
|
|
||||||
<span
|
|
||||||
class="block h-0.5 max-w-0 bg-zinc-300 transition-all duration-300 group-hover:max-w-full dark:bg-zinc-700"
|
|
||||||
></span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="mb-2 flex flex-col justify-center gap-4 sm:flex-row sm:items-center sm:justify-start"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="tag-icon mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-zinc-100 shadow-xs sm:mx-0 dark:bg-zinc-800"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="h-6 w-6 text-zinc-700 dark:text-zinc-300"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
|
|
||||||
>
|
|
||||||
</path>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z"> </path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1
|
|
||||||
class="text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-100"
|
|
||||||
>
|
|
||||||
<span class="relative">
|
|
||||||
#{tag}
|
|
||||||
<span class="absolute -bottom-1 left-0 h-1 w-full bg-zinc-200 dark:bg-zinc-700"
|
|
||||||
></span>
|
|
||||||
<span
|
|
||||||
class="animate-expand bg-turquoise absolute -bottom-1 left-0 h-1 w-full opacity-70"
|
|
||||||
></span>
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p
|
|
||||||
class="mx-auto mt-4 max-w-2xl text-base text-zinc-600 sm:mx-0 sm:text-lg dark:text-zinc-400"
|
|
||||||
>
|
|
||||||
Exploring <span class="font-medium text-zinc-900 dark:text-zinc-100"
|
|
||||||
>{sortedPosts.length}</span
|
|
||||||
> articles tagged with <span class="font-medium text-zinc-900 dark:text-zinc-100"
|
|
||||||
>"{tag}"</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Related tags section -->
|
|
||||||
{
|
|
||||||
relatedTags.length > 0 && (
|
|
||||||
<div class="hero-text hide-scrollbar mb-8 overflow-x-auto pb-4 sm:mb-12">
|
|
||||||
<h2 class="mb-3 text-center text-lg font-medium text-zinc-900 sm:text-left dark:text-zinc-100">
|
|
||||||
Related topics
|
|
||||||
</h2>
|
|
||||||
<div class="flex flex-nowrap justify-center gap-2 sm:justify-start">
|
|
||||||
{relatedTags.map((relatedTag) => (
|
|
||||||
<a
|
|
||||||
href={`/tags/${relatedTag}`}
|
|
||||||
class="inline-flex shrink-0 items-center rounded-full bg-zinc-100 px-3 py-1.5 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
#{relatedTag}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
<!-- Posts list -->
|
|
||||||
<div class="relative">
|
|
||||||
<div
|
|
||||||
class="hero-text 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-3d theme-transition-element group relative mx-auto flex max-w-2xl flex-col p-5 sm:mx-0 sm:p-8">
|
|
||||||
<div class="absolute inset-0 rounded-2xl border border-zinc-200 bg-white/50 transition-all duration-300 group-hover:bg-zinc-50 hover:bg-zinc-50/80 hover:shadow-md dark:border-zinc-800 dark:bg-zinc-900/50 dark:group-hover:bg-zinc-800/70 dark:hover:bg-zinc-900/50" />
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-5 sm:flex-row sm:gap-6">
|
|
||||||
{post.image && (
|
|
||||||
<div class="z-10 mx-auto h-40 w-full shrink-0 overflow-hidden rounded-xl 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"
|
|
||||||
loading="lazy"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div class="z-10 flex-1">
|
|
||||||
<h2 class="mb-2 text-center text-xl font-semibold text-zinc-900 sm:mb-3 sm:text-left sm:text-2xl dark:text-zinc-100">
|
|
||||||
<a href={`/blog/${post.slug}/`} class="before:absolute before:inset-0">
|
|
||||||
{post.title}
|
|
||||||
</a>
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<p class="mb-4 line-clamp-2 text-center text-sm text-zinc-600 sm:line-clamp-3 sm:text-left sm:text-base dark:text-zinc-400">
|
|
||||||
{post.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mb-2 flex flex-wrap items-center justify-center gap-3 text-xs text-zinc-500 sm:mb-3 sm:justify-start sm:gap-4 sm:text-sm dark:text-zinc-400">
|
|
||||||
<FormattedDate date={post.published_date} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="z-10 mt-4 flex flex-wrap items-end justify-center border-t border-zinc-100 pt-4 sm:justify-between dark:border-zinc-800">
|
|
||||||
{post.tags && post.tags.length > 0 && (
|
|
||||||
<div class="mb-3 flex flex-wrap justify-center gap-2 sm:mb-0 sm:justify-start">
|
|
||||||
{post.tags.slice(0, 3).map((postTag) => (
|
|
||||||
<a
|
|
||||||
href={`/blog/${postTag}`}
|
|
||||||
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors ${
|
|
||||||
postTag === tag
|
|
||||||
? 'bg-zinc-900/10 text-zinc-900 dark:bg-zinc-100/20 dark:text-zinc-100'
|
|
||||||
: 'bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
#{postTag}
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
{post.tags.length > 3 && (
|
|
||||||
<span class="inline-flex items-center rounded-full bg-zinc-50 px-2 py-0.5 text-xs text-zinc-500 dark:bg-zinc-800/50 dark:text-zinc-400">
|
|
||||||
+{post.tags.length - 3}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div class="mx-auto sm:mr-0 sm:ml-auto">
|
|
||||||
<a
|
|
||||||
href={`/blog/${post.slug}`}
|
|
||||||
class="theme-transition-color relative z-10 mx-auto mt-3 flex min-h-[44px] items-center text-sm font-medium text-zinc-700 transition-colors group-hover:text-zinc-900 sm:mx-0 sm:mt-4 dark:text-zinc-300 dark:group-hover:text-zinc-100"
|
|
||||||
>
|
|
||||||
<span class="relative inline-block overflow-hidden">
|
|
||||||
<span class="relative z-10">Read article</span>
|
|
||||||
<span class="bg-turquoise absolute bottom-0 left-0 h-0.5 w-0 transition-all duration-300 group-hover:w-full" />
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
{
|
|
||||||
sortedPosts.length === 0 && (
|
|
||||||
<div class="py-12 text-center sm:py-20">
|
|
||||||
<div class="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100 sm:mb-6 sm:h-20 sm:w-20 dark:bg-zinc-800">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="h-8 w-8 text-zinc-500 sm:h-10 sm:w-10 dark:text-zinc-400"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h2 class="mb-2 text-xl font-semibold text-zinc-900 sm:text-2xl dark:text-zinc-100">
|
|
||||||
No posts found
|
|
||||||
</h2>
|
|
||||||
<p class="text-zinc-600 dark:text-zinc-400">There are no posts with this tag yet.</p>
|
|
||||||
<a
|
|
||||||
href="/blog"
|
|
||||||
class="mt-6 inline-flex items-center gap-2 rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-800 transition-all duration-300 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="h-4 w-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M19.5 12h-15m0 0l6.75 6.75M4.5 12l6.75-6.75"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Browse all articles</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</BaseLayout>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.addEventListener('astro:page-load', () => {
|
|
||||||
// Add smooth reveal animations for content after loading
|
|
||||||
const animateContent = () => {
|
|
||||||
// Animate hero section
|
|
||||||
const heroElements = document.querySelectorAll(
|
|
||||||
'.hero-text ~ div, .hero-text h1, .hero-text span, .hero-text p'
|
|
||||||
);
|
|
||||||
heroElements.forEach((el, index) => {
|
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
el.classList.add('animate-reveal');
|
|
||||||
},
|
|
||||||
100 + index * 150
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Animate posts
|
|
||||||
const articles = document.querySelectorAll('article.group');
|
|
||||||
articles.forEach((article, index) => {
|
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
article.classList.add('animate-reveal');
|
|
||||||
},
|
|
||||||
500 + index * 150
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
animateContent();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
/* Animated underline */
|
|
||||||
@keyframes expand {
|
|
||||||
from {
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-expand {
|
|
||||||
animation: expand 1s ease-out forwards;
|
|
||||||
}
|
|
||||||
|
|
||||||
.animate-reveal {
|
|
||||||
opacity: 1 !important;
|
|
||||||
transform: translateY(0) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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;
|
|
||||||
}
|
|
||||||
</style>
|
|
390
src/pages/topics/[tag].astro
Normal file
390
src/pages/topics/[tag].astro
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
---
|
||||||
|
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="max-w-5xl mx-auto px-4 py-10 sm:py-16">
|
||||||
|
<!-- Header section -->
|
||||||
|
<div class="relative mb-10 sm:mb-16">
|
||||||
|
<div class="absolute -top-20 -left-20 w-48 sm:w-64 h-48 sm:h-64 bg-zinc-100 dark:bg-zinc-900/30 rounded-full blur-3xl opacity-30 animate-blob"></div>
|
||||||
|
<div class="absolute -bottom-10 -right-10 w-36 sm:w-48 h-36 sm:h-48 bg-zinc-200 dark:bg-zinc-900/20 rounded-full blur-2xl opacity-20 animate-blob animation-delay-2000"></div>
|
||||||
|
|
||||||
|
<div class="relative text-center sm:text-left">
|
||||||
|
<a href="/tags" class="inline-flex items-center gap-2 text-sm font-medium text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors mb-4 group">
|
||||||
|
<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 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" />
|
||||||
|
</svg>
|
||||||
|
<span>Back to all topics</span>
|
||||||
|
<span class="block max-w-0 group-hover:max-w-full transition-all duration-300 h-0.5 bg-zinc-300 dark:bg-zinc-700"></span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center gap-4 mb-2 justify-center sm:justify-start">
|
||||||
|
<div class="tag-icon flex items-center justify-center w-12 h-12 rounded-xl bg-zinc-100 dark:bg-zinc-800 shadow-sm mx-auto 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="w-6 h-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 stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-3xl sm:text-4xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100">
|
||||||
|
<span class="relative">
|
||||||
|
#{tag}
|
||||||
|
<span class="absolute -bottom-1 left-0 w-full h-1 bg-zinc-200 dark:bg-zinc-700"></span>
|
||||||
|
<span class="absolute -bottom-1 left-0 w-1/2 h-1 bg-zinc-900 dark:bg-zinc-100 opacity-70 animate-expand"></span>
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-base sm:text-lg text-zinc-600 dark:text-zinc-400 mt-4 max-w-2xl mx-auto sm:mx-0">
|
||||||
|
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="mb-8 sm:mb-12 overflow-x-auto pb-4 hide-scrollbar">
|
||||||
|
<h2 class="text-lg font-medium text-zinc-900 dark:text-zinc-100 mb-3 text-center sm:text-left">Related topics</h2>
|
||||||
|
<div class="flex gap-2 flex-nowrap justify-center sm:justify-start">
|
||||||
|
{relatedTags.map(relatedTag => (
|
||||||
|
<a
|
||||||
|
href={`/topics/${relatedTag}`}
|
||||||
|
class="flex-shrink-0 inline-flex items-center rounded-full px-3 py-1.5 text-sm font-medium bg-zinc-100 text-zinc-900 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
#{relatedTag}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<!-- Posts list -->
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-0 bg-grid-pattern opacity-5 dark:opacity-10 pointer-events-none"></div>
|
||||||
|
|
||||||
|
<div class="relative space-y-6 sm:space-y-8">
|
||||||
|
{sortedPosts.map((post) => (
|
||||||
|
<article class="group relative flex flex-col p-5 sm:p-8 rounded-2xl border border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50/80 dark:hover:bg-zinc-900/50 transition-all duration-300 hover:shadow-md hover-card max-w-2xl mx-auto sm:mx-0">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-br from-zinc-50/0 to-zinc-100/0 dark:from-zinc-900/0 dark:to-zinc-800/0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 rounded-2xl"></div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-5 sm:gap-6">
|
||||||
|
{post.image && (
|
||||||
|
<div class="flex-shrink-0 w-full sm:w-56 h-40 rounded-xl overflow-hidden shadow-sm group-hover:shadow-md transition-all duration-300 mx-auto sm:mx-0">
|
||||||
|
<img
|
||||||
|
src={`${process.env.DIRECTUS_URL ?? "https://directus.alexlebens.dev"}/assets/${post.image}?width=500`}
|
||||||
|
alt={post.image_alt}
|
||||||
|
class="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex flex-wrap items-center text-xs sm:text-sm text-zinc-500 dark:text-zinc-400 gap-3 sm:gap-4 mb-2 sm:mb-3 justify-center sm:justify-start">
|
||||||
|
{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="w-3.5 h-3.5 sm:w-4 sm:h-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="text-xl sm:text-2xl font-semibold text-zinc-900 dark:text-zinc-100 mb-2 sm:mb-3 group-hover:text-zinc-700 dark:group-hover:text-zinc-300 transition-colors text-center sm:text-left">
|
||||||
|
<a href={`/blog/${post.slug}/`} class="before:absolute before:inset-0">
|
||||||
|
{post.title}
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-sm sm:text-base text-zinc-600 dark:text-zinc-400 mb-4 line-clamp-2 sm:line-clamp-3 text-center sm:text-left">
|
||||||
|
{post.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-center sm:justify-between items-end mt-4 pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<div class="flex flex-wrap gap-2 mb-3 sm:mb-0 justify-center 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 dark:text-zinc-300 group-hover:text-zinc-900 dark:group-hover:text-zinc-100 transition-colors"
|
||||||
|
aria-hidden="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<span class="relative overflow-hidden inline-block">
|
||||||
|
<span class="block transition-transform duration-300 group-hover:-translate-y-full">Read article</span>
|
||||||
|
<span class="absolute top-0 left-0 translate-y-full group-hover:translate-y-0 transition-transform duration-300 whitespace-nowrap">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="w-4 h-4 ml-1 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="text-center py-12 sm:py-20">
|
||||||
|
<div class="inline-flex items-center justify-center w-16 h-16 sm:w-20 sm:h-20 rounded-full bg-zinc-100 dark:bg-zinc-800 mb-4 sm:mb-6">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8 sm:w-10 sm:h-10 text-zinc-500 dark:text-zinc-400">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-xl sm:text-2xl font-semibold text-zinc-900 dark:text-zinc-100 mb-2">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="inline-flex items-center gap-2 mt-6 px-4 py-2 rounded-md bg-zinc-100 dark:bg-zinc-800 text-zinc-800 dark:text-zinc-200 hover:bg-zinc-200 dark:hover:bg-zinc-700 transition-all duration-300 text-sm font-medium">
|
||||||
|
<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="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 -->
|
||||||
|
</BaseLayout>
|
648
src/pages/topics/index.astro
Normal file
648
src/pages/topics/index.astro
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
---
|
||||||
|
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="w-full mx-auto px-3 sm:px-6 py-6 sm:py-12 md:py-16 theme-transition-all">
|
||||||
|
<!-- Enhanced header section with animated elements - improved for mobile -->
|
||||||
|
<div class="relative mb-8 sm:mb-12 md:mb-16 text-center theme-transition-element">
|
||||||
|
<div class="absolute -top-16 -left-16 w-36 sm:w-48 md:w-72 h-36 sm:h-48 md:h-72 bg-zinc-100 dark:bg-zinc-800/50 rounded-full blur-3xl opacity-50 animate-blob theme-transition-bg"></div>
|
||||||
|
<div class="absolute -bottom-16 -right-16 w-36 sm:w-48 md:w-72 h-36 sm:h-48 md:h-72 bg-zinc-200 dark:bg-zinc-800/30 rounded-full blur-3xl opacity-30 animate-blob animation-delay-2000 theme-transition-bg"></div>
|
||||||
|
<div class="absolute top-8 right-8 w-24 sm:w-32 md:w-40 h-24 sm:h-32 md:h-40 bg-zinc-100/30 dark:bg-zinc-700/20 rounded-full blur-2xl opacity-40 animate-blob animation-delay-4000 theme-transition-bg"></div>
|
||||||
|
|
||||||
|
<h1 class="relative text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold tracking-tight text-zinc-900 dark:text-zinc-100 mb-3 sm:mb-4 md:mb-6 theme-transition-color">
|
||||||
|
<span class="inline-block relative">
|
||||||
|
<span class="relative inline-block">
|
||||||
|
<span class="absolute -inset-1 rounded-lg bg-gradient-to-r from-zinc-200/50 to-zinc-300/50 dark:from-zinc-800/50 dark:to-zinc-700/50 blur-sm theme-transition-bg"></span>
|
||||||
|
<span class="relative">Explore</span>
|
||||||
|
</span>
|
||||||
|
{" "}
|
||||||
|
<span class="relative inline-block">
|
||||||
|
Topics
|
||||||
|
<span class="absolute -bottom-1 sm:-bottom-2 left-0 w-full h-0.5 sm:h-1 bg-gradient-to-r from-zinc-400 to-zinc-600 dark:from-zinc-600 dark:to-zinc-400 transform origin-left animate-underline theme-transition-bg"></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p class="relative text-sm sm:text-base md:text-lg lg:text-xl text-zinc-600 dark:text-zinc-400 max-w-2xl mx-auto theme-transition-color">
|
||||||
|
Discover content organized by your interests
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tags.length === 0 ? (
|
||||||
|
<div class="text-center py-8 sm:py-12 md:py-16 theme-transition-element">
|
||||||
|
<div class="inline-flex items-center justify-center w-16 sm:w-20 md:w-24 h-16 sm:h-20 md:h-24 rounded-full bg-zinc-100 dark:bg-zinc-800 mb-3 sm:mb-4 md:mb-6 shadow-inner theme-transition-bg">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 sm:w-10 md:w-12 h-8 sm:h-10 md:h-12 text-zinc-500 dark:text-zinc-400 theme-transition-color">
|
||||||
|
<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="text-lg sm:text-xl md:text-2xl font-medium text-zinc-800 dark:text-zinc-200 theme-transition-color">No tags found yet.</p>
|
||||||
|
<p class="mt-2 text-xs sm:text-sm md:text-base text-zinc-500 dark:text-zinc-500 theme-transition-color">Check back later for categorized content.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="flex justify-center w-full">
|
||||||
|
<!-- Featured Tags Section - ultra-responsive design -->
|
||||||
|
<div class="tag-cloud relative p-3 sm:p-4 md:p-6 lg:p-8 rounded-lg sm:rounded-xl md:rounded-2xl lg:rounded-3xl border border-zinc-100 dark:border-zinc-800 bg-white/50 dark:bg-zinc-900/50 backdrop-blur-sm hover-3d glass theme-transition-all w-full">
|
||||||
|
<div class="absolute inset-0 bg-grid-pattern opacity-5 dark:opacity-10 theme-transition-bg"></div>
|
||||||
|
<div class="absolute -top-8 -right-8 w-20 sm:w-24 md:w-32 lg:w-40 h-20 sm:h-24 md:h-32 lg:h-40 bg-gradient-to-br from-zinc-200/30 to-zinc-300/20 dark:from-zinc-700/20 dark:to-zinc-800/10 rounded-full blur-xl theme-transition-bg"></div>
|
||||||
|
<div class="absolute -bottom-8 -left-8 w-20 sm:w-24 md:w-32 lg:w-40 h-20 sm:h-24 md:h-32 lg:h-40 bg-gradient-to-tl from-zinc-200/30 to-zinc-300/20 dark:from-zinc-700/20 dark:to-zinc-800/10 rounded-full blur-xl theme-transition-bg"></div>
|
||||||
|
|
||||||
|
<h2 class="text-lg sm:text-xl md:text-2xl lg:text-3xl font-bold text-zinc-900 dark:text-zinc-100 mb-3 sm:mb-4 md:mb-6 lg:mb-8 text-center theme-transition-color">Popular Topics</h2>
|
||||||
|
|
||||||
|
<!-- Ultra-responsive grid layout with fallbacks -->
|
||||||
|
<div class="grid grid-cols-2 xxxs:grid-cols-2 xxs:grid-cols-2 xs:grid-cols-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-1.5 xxxs:gap-2 xxs:gap-2 xs:gap-2 sm:gap-3 md:gap-4 w-full">
|
||||||
|
{sortedTags.map((tag) => (
|
||||||
|
<a
|
||||||
|
href={`/topics/${tag.name}`}
|
||||||
|
class="group relative overflow-hidden rounded-md sm:rounded-lg md:rounded-xl border border-zinc-200 dark:border-zinc-800 transition-all duration-300 hover:shadow-md sm:hover:shadow-lg hover:scale-[1.03] hover:border-zinc-300 dark:hover:border-zinc-700 active:scale-95 theme-transition-element theme-ripple flex-grow min-w-0"
|
||||||
|
style={`--tag-hue: ${tag.hue};`}
|
||||||
|
>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-br from-zinc-50/90 to-zinc-100/90 dark:from-zinc-800/90 dark:to-zinc-900/90 opacity-100 group-hover:opacity-95 transition-opacity theme-transition-bg"></div>
|
||||||
|
|
||||||
|
<div class="relative px-1.5 xxxs:px-2 xxs:px-2 xs:px-2 sm:px-3 md:px-4 py-1.5 xxxs:py-2 xxs:py-2 xs:py-2 sm:py-3 md:py-4 flex items-center gap-1.5 xxs:gap-2 w-full">
|
||||||
|
<div class="flex-shrink-0 flex items-center justify-center w-5 h-5 xxxs:w-6 xxxs:h-6 xxs:w-6 xxs:h-6 xs:w-7 xs:h-7 sm:w-8 sm:h-8 md:w-10 md:h-10 rounded-full bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300 group-hover:bg-accent/20 dark:group-hover:bg-accent/20 group-hover:text-accent-dark dark:group-hover:text-accent-light transition-all duration-300 shadow-sm theme-transition-all">
|
||||||
|
<span class="text-xs xxxs:text-xs xxs:text-xs xs:text-sm sm:text-base md:text-lg font-semibold">#</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0 overflow-hidden">
|
||||||
|
<h3 class="text-[10px] xxxs:text-xs xxs:text-xs xs:text-xs sm:text-sm md:text-base font-bold text-zinc-900 dark:text-zinc-100 group-hover:text-zinc-700 dark:group-hover:text-zinc-300 transition-colors theme-transition-color break-words hyphens-auto truncate">
|
||||||
|
{tag.name}
|
||||||
|
</h3>
|
||||||
|
<p class="text-[8px] xxxs:text-[9px] xxs:text-[9px] xs:text-[10px] sm:text-xs md:text-xs text-zinc-500 dark:text-zinc-400 theme-transition-color truncate">{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,21 +1,11 @@
|
|||||||
@import 'tailwindcss';
|
/* Remove all the complex mobile menu styles and keep only what's necessary */
|
||||||
|
@tailwind base;
|
||||||
/* Dark mode support for Tailwind CSS v4 */
|
@tailwind components;
|
||||||
/* https://tailwindcss.com/docs/dark-mode */
|
@tailwind utilities;
|
||||||
@custom-variant dark (&:where(.dark, .dark *));
|
|
||||||
|
|
||||||
/* Add custom color palette */
|
|
||||||
@theme {
|
|
||||||
--color-midnight: #0c354d;
|
|
||||||
--color-turquoise: #0da797;
|
|
||||||
--color-bermuda: #7fbab4;
|
|
||||||
--color-desert: #f9deb2;
|
|
||||||
--color-bronze: #9e7f5e;
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: "Inter", sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
--theme-transition: 0.3s ease;
|
--theme-transition: 0.3s ease;
|
||||||
@@ -24,7 +14,6 @@
|
|||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scroll-padding-top: 5rem;
|
scroll-padding-top: 5rem;
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -35,13 +24,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Simple theme transition */
|
/* Simple theme transition */
|
||||||
body,
|
body, a, button {
|
||||||
a,
|
transition: background-color var(--theme-transition),
|
||||||
button {
|
color var(--theme-transition),
|
||||||
transition:
|
border-color var(--theme-transition);
|
||||||
background-color var(--theme-transition),
|
|
||||||
color var(--theme-transition),
|
|
||||||
border-color var(--theme-transition);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,54 +37,32 @@
|
|||||||
scroll-padding-top: 4rem;
|
scroll-padding-top: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Touch targets on mobile */
|
/* Better touch targets on mobile */
|
||||||
button,
|
button, a {
|
||||||
a {
|
|
||||||
@apply min-h-[44px];
|
@apply min-h-[44px];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Add smooth animations */
|
/* Add smooth animations */
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from {
|
from { opacity: 0; }
|
||||||
opacity: 0;
|
to { opacity: 1; }
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideUp {
|
@keyframes slideUp {
|
||||||
from {
|
from { transform: translateY(20px); opacity: 0; }
|
||||||
transform: translateY(20px);
|
to { transform: translateY(0); opacity: 1; }
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideDown {
|
@keyframes slideDown {
|
||||||
from {
|
from { transform: translateY(-20px); opacity: 0; }
|
||||||
transform: translateY(-20px);
|
to { transform: translateY(0); opacity: 1; }
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scaleIn {
|
@keyframes scaleIn {
|
||||||
from {
|
from { transform: scale(0.95); opacity: 0; }
|
||||||
transform: scale(0.95);
|
to { transform: scale(1); opacity: 1; }
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: scale(1);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Apply animations to elements */
|
/* Apply animations to elements */
|
||||||
@@ -136,27 +100,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth hover transitions */
|
/* Smooth hover transitions */
|
||||||
a,
|
a, button {
|
||||||
button {
|
transition: all 0.2s ease;
|
||||||
transition: all 0.5s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Content reveal animations */
|
a:hover, button:hover {
|
||||||
.hero-text h1,
|
transform: translateY(-1px);
|
||||||
.hero-text span,
|
}
|
||||||
.hero-text p,
|
|
||||||
.hero-text + p,
|
/* Smooth page transitions */
|
||||||
.hero-text ~ div,
|
.page-transition {
|
||||||
article.group,
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
a.group.flex.flex-col {
|
}
|
||||||
|
|
||||||
|
.page-entering {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(20px);
|
transform: translateY(10px);
|
||||||
transition:
|
|
||||||
opacity 0.8s ease,
|
|
||||||
transform 0.8s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-reveal {
|
.page-entered {
|
||||||
opacity: 1 !important;
|
opacity: 1;
|
||||||
transform: translateY(0) !important;
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
@@ -1,40 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import * as FaIcons from 'react-icons/fa';
|
|
||||||
import * as MdIcons from 'react-icons/md';
|
|
||||||
import * as AiIcons from 'react-icons/ai';
|
|
||||||
import * as GiIcons from 'react-icons/gi';
|
|
||||||
import * as IoIcons from 'react-icons/io';
|
|
||||||
import * as CiIcons from 'react-icons/ci';
|
|
||||||
import * as FiIcons from 'react-icons/fi';
|
|
||||||
import * as LuIcons from 'react-icons/lu';
|
|
||||||
import * as SiIcons from 'react-icons/si';
|
|
||||||
|
|
||||||
// Load React Icon library dynamically from attributes in Directus
|
|
||||||
|
|
||||||
const iconSets = {
|
|
||||||
fa: FaIcons,
|
|
||||||
md: MdIcons,
|
|
||||||
ai: AiIcons,
|
|
||||||
gi: GiIcons,
|
|
||||||
io: IoIcons,
|
|
||||||
ci: CiIcons,
|
|
||||||
fi: FiIcons,
|
|
||||||
lu: LuIcons,
|
|
||||||
si: SiIcons,
|
|
||||||
};
|
|
||||||
|
|
||||||
const DynamicIcon = ({ name, set = 'fa' }: { name: string; set?: string }) => {
|
|
||||||
let IconComponent = FaIcons.FaAlignCenter;
|
|
||||||
|
|
||||||
if (name.startsWith('Fa')) {
|
|
||||||
IconComponent = iconSets['fa'][name];
|
|
||||||
} else if (name.startsWith('Si')) {
|
|
||||||
IconComponent = iconSets['si'][name];
|
|
||||||
} else {
|
|
||||||
IconComponent = iconSets[set][name];
|
|
||||||
}
|
|
||||||
|
|
||||||
return <IconComponent />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DynamicIcon;
|
|
@@ -1,3 +1,4 @@
|
|||||||
export function debugObject(obj: any): string {
|
export function debugObject(obj: any): string {
|
||||||
return JSON.stringify(obj, null, 2);
|
return JSON.stringify(obj, null, 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}', '*.{js,ts,jsx,tsx,mdx}'],
|
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}", "*.{js,ts,jsx,tsx,mdx}"],
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
@@ -54,5 +54,7 @@ module.exports = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [require('@tailwindcss/typography')],
|
plugins: [
|
||||||
|
require('@tailwindcss/typography'),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
@@ -3,7 +3,6 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"target": "ES6",
|
"target": "ES6",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
Reference in New Issue
Block a user