Compare commits

..

1 Commits

Author SHA1 Message Date
71a6660c93 Update typescript-eslint monorepo to v8.37.0
All checks were successful
renovate/stability-days Updates have met minimum release age requirement
test-build / build (pull_request) Successful in 23s
2025-07-16 00:02:37 +00:00
105 changed files with 6926 additions and 13751 deletions

View File

@@ -0,0 +1,40 @@
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

View File

@@ -3,7 +3,7 @@ name: release-image
on: on:
push: push:
tags: tags:
- 2.* - 0.*
workflow_dispatch: workflow_dispatch:

View File

@@ -24,7 +24,7 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22.18.0 node-version: 22.17.x
cache: pnpm cache: pnpm
- name: Install Dependencies - name: Install Dependencies

3
.gitignore vendored
View File

@@ -12,9 +12,10 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
# environment variables # environment variables
.env .env
.env.local
.env.development
.env.production .env.production
# macOS-specific files # macOS-specific files

1
.npmrc
View File

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

View File

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

View File

@@ -1,7 +1,7 @@
ARG REGISTRY=docker.io ARG REGISTRY=docker.io
FROM ${REGISTRY}/node:22.18.0-alpine3.22 AS base FROM ${REGISTRY}/node:22.17.0-alpine3.22 AS base
LABEL version="2.0.0" LABEL version="0.9.0"
LABEL description="Astro based personal website" LABEL description="Astro based personal website"
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"

21
LICENSE
View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 Alex Lebens
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

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

View File

@@ -1,16 +1,8 @@
import { defineConfig, passthroughImageService, sharpImageService } from 'astro/config'; import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import node from '@astrojs/node';
import partytown from '@astrojs/partytown';
import react from '@astrojs/react';
import sitemap from '@astrojs/sitemap';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import icon from 'astro-icon'; import react from '@astrojs/react';
import swup from '@swup/astro';
import rehypePrettyCode from 'rehype-pretty-code'; import node from '@astrojs/node';
import { transformerCopyButton } from '@rehype-pretty/transformers';
const getSiteURL = () => { const getSiteURL = () => {
if (process.env.SITE_URL) { if (process.env.SITE_URL) {
@@ -21,71 +13,7 @@ const getSiteURL = () => {
export default defineConfig({ export default defineConfig({
site: getSiteURL(), site: getSiteURL(),
integrations: [tailwindcss(), react()],
image: {
service: {
entrypoint: 'astro/assets/services/sharp',
}
},
prefetch: true,
integrations: [
mdx(),
partytown(),
react(),
sitemap(),
icon({
include: {
mdi: ['*'],
},
}),
swup({
theme: 'fade',
native: true,
cache: true,
preload: true,
accessibility: true,
smoothScrolling: true,
morph: ['#nav'],
}),
(await import('@playform/compress')).default({
CSS: true,
JavaScript: true,
HTML: {
'html-minifier-terser': {
collapseWhitespace: true,
minifyCSS: false,
minifyJS: true,
},
},
Image: false,
SVG: true,
Logger: 2,
}),
],
markdown: {
syntaxHighlight: false,
rehypePlugins: [
[
rehypePrettyCode,
{
theme: {
light: 'github-light',
dark: 'github-dark-dimmed',
},
keepBackground: false,
transformers: [
transformerCopyButton({
visibility: 'always',
feedbackDuration: 2500,
}),
],
},
],
],
},
plugins: { plugins: {
'@tailwindcss/postcss': {}, '@tailwindcss/postcss': {},
@@ -95,18 +23,7 @@ export default defineConfig({
plugins: [tailwindcss()], plugins: [tailwindcss()],
}, },
output: 'static',
adapter: node({ adapter: node({
mode: 'standalone', mode: 'standalone',
}), }),
build: {
// Specifies the directory in the build output where Astro-generated assets (bundled JS and CSS for example) should live.
// see https://docs.astro.build/en/reference/configuration-reference/#buildassets
assets: 'assets',
// see https://docs.astro.build/en/reference/configuration-reference/#buildassetsprefix
assetsPrefix:
!!import.meta.env.S3_ENABLE || !!process.env.S3_ENABLE ? 'https://digitalocean.com' : '',
},
}); });

59
lib/directus.ts Normal file
View File

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

View File

@@ -1,84 +1,43 @@
{ {
"name": "site-profile", "name": "site-profile",
"type": "module", "type": "module",
"version": "2.0.0", "version": "0.9.0",
"homepage": "https://www.alexlebens.dev", "private": true,
"bugs": {
"url": "https://gitea.alexlebens.dev/alexlebens/site-profile/issues",
"email": "alexander.lebens@gmail.com"
},
"repository": {
"type": "git",
"url": "https://gitea.alexlebens.dev/alexlebens/site-profile"
},
"license": "MIT",
"author": {
"name": "Alex Lebens",
"email": "alexander.lebens@gmail.com",
"url": "https://www.alexlebens.dev"
},
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro",
"format": "prettier --write \"**/*.{js,jsx,ts,tsx,md,mdx,astro}\"", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,md,mdx,astro}\"",
"lint": "eslint \"src/**/*.{js,ts,jsx,tsx,astro}\"", "lint": "eslint \"src/**/*.{js,ts,jsx,tsx,astro}\"",
"lint:fix": "eslint --fix \"src/**/*.{js,ts,jsx,tsx,astro}\"" "lint:fix": "eslint --fix \"src/**/*.{js,ts,jsx,tsx,astro}\"",
"astro": "astro"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.4", "@astrojs/mdx": "^4.3.0",
"@astrojs/mdx": "^4.3.3", "@astrojs/node": "^9.2.2",
"@astrojs/node": "^9.3.3",
"@astrojs/partytown": "^2.1.4",
"@astrojs/react": "^4.3.0", "@astrojs/react": "^4.3.0",
"@astrojs/rss": "^4.0.12", "@astrojs/rss": "^4.0.12",
"@astrojs/sitemap": "^3.4.2", "@directus/sdk": "^20.0.0",
"@giscus/react": "^3.1.0",
"@iconify-json/mdi": "^1.1.63",
"@iconify-json/pajamas": "^1.2.13",
"@iconify-json/simple-icons": "^1.2.47",
"@playform/compress": "^0.0.4",
"@rehype-pretty/transformers": "^0.13.2",
"@swup/astro": "1.7.0",
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"@tailwindcss/vite": "^4.1.8", "@tailwindcss/vite": "^4.1.8",
"@directus/sdk": "^20.0.0", "astro": "^5.10.1",
"@types/react": "^19.0.0",
"@types/unist": "^3.0.2",
"astro": "^5.12.8",
"astro-compressor": "^0.4.1",
"astro-icon": "^1.1.5",
"framer-motion": "^12.16.0", "framer-motion": "^12.16.0",
"mdast-util-to-string": "^4.0.0",
"preline": "^3.1.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"reading-time": "^1.5.0", "react-hotkeys-hook": "^5.1.0",
"rehype-pretty-code": "^0.14.1", "react-icons": "^5.5.0",
"sharp": "^0.34.3", "sanitize-html": "^2.17.0",
"sharp-ico": "^0.1.5", "tailwindcss": "^4.1.8"
"shiki": "^3.2.2",
"tailwindcss": "^4.1.11",
"ultrahtml": "^1.5.3"
}, },
"devDependencies": { "devDependencies": {
"@eslint-react/eslint-plugin": "^1.52.3",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"astro-icon": "^1.1.5", "@typescript-eslint/parser": "8.37.0",
"eslint": "^9.32.0", "eslint": "9.31.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "10.1.5",
"eslint-plugin-astro": "^1.3.1", "eslint-plugin-astro": "1.3.1",
"eslint-plugin-format": "^1.0.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-astro": "^0.14.1", "prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.6.12",
"timeago.js": "^4.0.2", "typescript-eslint": "8.37.0"
"typescript": "5.8.3",
"typescript-eslint": "8.39.1"
} }
} }

9792
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.9 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
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';
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', size = 20, color = 'currentColor', className = '' }: {name: string, set: string, size: number, color: string, className: 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 size={size} color={color} className={className} />;
};
export default DynamicIcon;

View File

@@ -1,82 +1,133 @@
--- ---
import directus from '../../lib/directus';
import { readSingleton } from '@directus/sdk'; import { readSingleton } from '@directus/sdk';
import directus from '@lib/directus'; const global = await directus.request(readSingleton('global'));
import BrandLogo from '@components/ui/logos/BrandLogo.astro'; const links = await directus.request(readSingleton('links'));
import Image from '@components/ui/images/Image.astro';
import { NavigationLinks, FooterLinks } from '@/config';
import footerImg from '@images/flowers.png';
const global = await directus.request(readSingleton('site_global'));
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
const navLinks = [
{ text: 'About', href: '/about' },
{ text: 'Blog', href: '/blog' },
{ text: 'Topics', href: '/topics' },
{ text: 'RSS', href: '/rss.xml' },
];
const socialLinks = [
{
name: 'GitHub',
href: links.github,
icon: `<path fill-rule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clip-rule="evenodd"></path>`,
},
{
name: 'Gitea',
href: links.gitea,
icon: `<path d="M7 5C7 3.89543 7.89543 3 9 3C10.1046 3 11 3.89543 11 5C11 5.34168 10.9143 5.66336 10.7633 5.9447H11.3438C13.5529 5.9447 15.3438 7.73556 15.3438 9.9447V11.2244C15.9301 11.5731 16.323 12.213 16.323 12.9447C16.323 14.0493 15.4276 14.9447 14.323 14.9447C13.2184 14.9447 12.323 14.0493 12.323 12.9447C12.323 12.1959 12.7345 11.5432 13.3438 11.2004V9.9447C13.3438 8.84013 12.4483 7.9447 11.3438 7.9447H10V17.2676C10.5978 17.6134 11 18.2597 11 19C11 20.1046 10.1046 21 9 21C7.89543 21 7 20.1046 7 19C7 18.2597 7.4022 17.6134 8 17.2676V6.73244C7.4022 6.38663 7 5.74028 7 5Z" fill="currentColor"/>`,
},
{
name: 'LinkedIn',
href: links.linkedin,
icon: `<path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"></path>`,
},
];
--- ---
<footer <footer
class="w-full overflow-hidden bg-stone-300/40 dark:bg-stone-800/20" class="theme-transition-all relative mt-20 overflow-hidden border-t border-zinc-100 dark:border-zinc-800"
transition:animate="none"
> >
<div class="pointer-events-none absolute inset-0 overflow-hidden">
<div
class="theme-transition-all animate-float-slow absolute -top-40 -right-40 h-80 w-80 rounded-full bg-zinc-100 opacity-50 blur-3xl dark:bg-zinc-800/30"
>
</div>
<div
class="theme-transition-all animate-float-slow animation-delay-2000 absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-zinc-100 opacity-50 blur-3xl dark:bg-zinc-800/30"
>
</div>
<div
class="theme-transition-all animate-float-slow animation-delay-1000 absolute top-20 left-1/4 h-40 w-40 rounded-full bg-zinc-200/50 opacity-30 blur-2xl dark:bg-zinc-700/20"
>
</div>
</div>
<div class="relative px-4 pt-16 pb-12 sm:px-6"> <div class="relative px-4 pt-16 pb-12 sm:px-6">
<div class="mx-auto max-w-[85rem]"> <div class="mx-auto max-w-4xl">
<!-- Main footer content -->
<div class="grid grid-cols-1 gap-10 md:grid-cols-12"> <div class="grid grid-cols-1 gap-10 md:grid-cols-12">
<!-- 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="group inline-block">
<div class="flex items-center"> <div class="flex items-center">
<div class="mx-auto aspect-square overflow-hidden rounded-lg"> <div
<BrandLogo class="max-h-[40px] max-w-[40px] rounded-full" /> class="relative flex h-10 w-10 transform items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br from-zinc-800 to-zinc-600 shadow-lg transition-transform group-hover:scale-105 dark:from-zinc-200 dark:to-zinc-400"
>
<span
class="theme-transition-all text-xl font-bold text-white transition-transform duration-300 group-hover:scale-110 dark:text-zinc-900"
>{global.initals}</span
>
<div
class="absolute inset-0 bg-gradient-to-br from-zinc-700 to-zinc-900 opacity-0 transition-opacity duration-300 group-hover:opacity-100 dark:from-zinc-300 dark:to-zinc-100"
>
</div> </div>
</div>
<span class="ml-3 text-xl font-bold text-neutral-800 dark:text-neutral-200"> <span
Blog class="theme-transition-color ml-3 text-xl font-bold text-zinc-900 dark:text-zinc-100"
</span> >Blog</span
>
</div> </div>
</a> </a>
<p class="mt-4 text-sm leading-relaxed text-neutral-600 dark:text-neutral-400"> <p
A description of something. class="theme-transition-color mt-4 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400"
>
{global.description}
</p> </p>
</div>
<!-- Left links --> <!-- Social links -->
<div class="col-span-1 md:col-span-2"> <div class="mt-6 flex items-center space-x-4">
<h3
class="after:bg-steel dark:after:bg-bermuda relative inline-block pb-2 text-sm font-semibold tracking-wider text-neutral-800 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-8 after:content-[''] dark:text-neutral-100"
>
Blog
</h3>
<ul class="mt-4 space-y-3">
{ {
NavigationLinks.map((link) => ( socialLinks.map((social) => (
<li>
<a <a
href={link.url} href={social.href}
class="group flex items-center text-base text-neutral-600 transition-colors hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-200" target="_blank"
rel="noopener noreferrer"
class="hover group relative flex h-10 w-10 transform items-center justify-center rounded-full bg-zinc-100 text-zinc-500 transition-all duration-300 hover:-translate-y-1 hover:text-zinc-900 hover:ring-2 hover:ring-zinc-300 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-100 dark:hover:ring-zinc-700"
aria-label={social.name}
> >
<span class="relative inline-block overflow-hidden"> <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" />
<span class="relative z-10">{link.name}</span> <svg
</span> class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<Fragment set:html={social.icon} />
</svg>
</a> </a>
</li>
)) ))
} }
</ul>
</div> </div>
<!-- Right links --> </div>
<!-- Quick links -->
<div class="col-span-1 md:col-span-3"> <div class="col-span-1 md:col-span-3">
<h3 <h3
class="after:bg-steel dark:after:bg-bermuda relative inline-block pb-2 text-sm font-semibold tracking-wider text-neutral-800 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-8 after:content-[''] dark:text-neutral-100" class="theme-transition-color relative inline-block pb-2 text-sm font-semibold tracking-wider text-zinc-900 uppercase after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-8 after:bg-zinc-300 after:content-[''] dark:text-zinc-100 dark:after:bg-zinc-700"
> >
Other Navigation
</h3> </h3>
<ul class="mt-4 space-y-3"> <ul class="mt-4 space-y-3">
{ {
FooterLinks.map((link) => ( navLinks.map((link) => (
<li> <li>
<a <a
href={link.url} href={link.href}
class="group flex items-center text-base text-neutral-600 transition-colors hover:text-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-200" class="group flex items-center text-base text-zinc-600 transition-colors hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
> >
<span class="relative inline-block overflow-hidden"> <span class="relative inline-block overflow-hidden">
<span class="relative z-10">{link.name}</span> <span class="relative z-10">{link.text}</span>
<span class="absolute bottom-0 left-0 h-0.5 w-0 bg-zinc-800 transition-all duration-300 group-hover:w-full dark:bg-zinc-200" />
</span> </span>
</a> </a>
</li> </li>
@@ -84,39 +135,30 @@ const currentYear = new Date().getFullYear();
} }
</ul> </ul>
</div> </div>
<!-- Right image -->
<div class="col-span-3 mt-10 flex justify-center md:mt-0">
<div class="-mt-10 hidden max-h-[460px] max-w-[220px] scale-80 md:block">
<Image
src={footerImg}
alt={global.footer_image_alt}
class="h-full w-full object-cover object-center"
draggable="false"
loading="eager"
format="webp"
quality="low"
widths={[440]}
disableBlur={true}
/>
</div>
</div>
</div> </div>
<!-- Bottom section --> <!-- Bottom section -->
<div class="mt-12 border-t border-neutral-400/30 pt-8 dark:border-neutral-600/50"> <div class="theme-transition-all mt-12 border-t border-zinc-200 pt-8 dark:border-zinc-800">
<div class="flex flex-col items-center justify-between gap-4 md:flex-row"> <div class="flex flex-col items-center justify-between gap-4 md:flex-row">
<p class="text-sm text-neutral-600 dark:text-neutral-400"> <p class="theme-transition-color text-sm text-zinc-600 dark:text-zinc-400">
&copy; {currentYear} All rights reserved. &copy; {currentYear} All rights reserved.
</p> </p>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span class="text-xs text-neutral-500 dark:text-neutral-400">Built with </span> <span class="theme-transition-color text-xs text-zinc-500 dark:text-zinc-400"
>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-neutral-600 transition-colors hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100" class="group inline-flex items-center text-xs text-zinc-600 transition-colors hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
<svg
class="mr-1 h-4 w-4 text-[#FF5D01] group-hover:animate-pulse"
viewBox="0 0 36 36"
fill="none"
> >
<svg class="mr-1 h-4 w-4 text-[#FF5D01]" viewBox="0 0 36 36" fill="none">
<path <path
fill-rule="evenodd" fill-rule="evenodd"
clip-rule="evenodd" clip-rule="evenodd"
@@ -132,8 +174,7 @@ const currentYear = new Date().getFullYear();
Astro Astro
<span <span
class="absolute bottom-0 left-0 h-0.5 w-0 bg-[#FF5D01] transition-all duration-300 group-hover:w-full" class="absolute bottom-0 left-0 h-0.5 w-0 bg-[#FF5D01] transition-all duration-300 group-hover:w-full"
> ></span>
</span>
</span> </span>
</a> </a>
</div> </div>
@@ -142,3 +183,67 @@ const currentYear = new Date().getFullYear();
</div> </div>
</div> </div>
</footer> </footer>
<style>
.theme-transition-all {
transition-property: background-color, border-color, color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
.theme-transition-color {
transition-property: color, fill, stroke;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
.theme-transition-bg {
transition-property: background-color;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 300ms;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.2);
}
}
@keyframes float-slow {
0%,
100% {
transform: translateY(0) translateX(0);
}
25% {
transform: translateY(-10px) translateX(10px);
}
50% {
transform: translateY(-5px) translateX(-5px);
}
75% {
transform: translateY(10px) translateX(5px);
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
.animate-float-slow {
animation: float-slow 20s ease-in-out infinite;
}
.animation-delay-1000 {
animation-delay: 1s;
}
.animation-delay-2000 {
animation-delay: 2s;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,14 +5,14 @@
<button <button
id="theme-toggle" id="theme-toggle"
data-theme-toggle data-theme-toggle
class="group dark:hover:bg-steel/30 relative touch-manipulation overflow-hidden rounded-full p-1.5 transition-all duration-300 hover:bg-yellow-300/20 focus:outline-hidden sm:p-2" class="group relative touch-manipulation overflow-hidden rounded-full p-1.5 transition-all duration-300 hover:bg-zinc-100 focus:ring-2 focus:ring-zinc-300 focus:outline-hidden sm:p-2 dark:hover:bg-zinc-800 dark:focus:ring-zinc-700"
aria-label="Toggle dark mode" 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-neutral-600 transition-all duration-500 dark:scale-0 dark:-rotate-90 dark:text-neutral-400" class="icon-light absolute h-5 w-5 scale-100 rotate-0 text-zinc-800 transition-all duration-500 dark:scale-0 dark:-rotate-90 dark:text-zinc-200"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -29,7 +29,7 @@
<!-- 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-neutral-600 transition-all duration-500 dark:scale-100 dark:rotate-0 dark:text-neutral-400" class="icon-dark absolute h-5 w-5 scale-0 rotate-90 text-zinc-800 transition-all duration-500 dark:scale-100 dark:rotate-0 dark:text-zinc-200"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
@@ -40,27 +40,31 @@
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg> </svg>
</div> </div>
<!-- Ripple effect -->
<span
class="absolute inset-0 h-full w-full bg-zinc-200 opacity-0 transition-opacity duration-300 group-active:opacity-20 dark:bg-zinc-700"
></span>
</button> </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');
@@ -113,6 +117,11 @@
// Add transition class // Add transition class
document.documentElement.classList.add('theme-switching'); 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 // Force a reflow to ensure all elements update
document.body.offsetHeight; document.body.offsetHeight;
@@ -145,6 +154,7 @@
// Remove transition class after animation completes // Remove transition class after animation completes
document.documentElement.classList.remove('theme-switching'); document.documentElement.classList.remove('theme-switching');
ripple.remove();
}, 300); }, 300);
}, 50); }, 50);
}, },
@@ -174,7 +184,7 @@
} }
// 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', () => {
@@ -223,6 +233,30 @@
transition-property: background-color, border-color, color, fill, stroke !important; transition-property: background-color, border-color, color, fill, stroke !important;
} }
/* Ripple animation */
.theme-toggle-ripple {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
width: 100%;
height: 100%;
border-radius: 50%;
background-color: rgba(161, 161, 170, 0.3);
animation: ripple 0.8s ease-out;
}
@keyframes ripple {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 0.5;
}
100% {
transform: translate(-50%, -50%) scale(2.5);
opacity: 0;
}
}
/* Subtle hover animation */ /* Subtle hover animation */
#theme-toggle { #theme-toggle {
transform: translateY(0); transform: translateY(0);
@@ -268,6 +302,10 @@
transform: none; transform: none;
transition: none; transition: none;
} }
.theme-toggle-ripple {
animation-duration: 0.4s;
}
} }
/* Adjust size for very small screens */ /* Adjust size for very small screens */

View File

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

View File

@@ -1,82 +0,0 @@
---
interface Props {
slug: string;
title: string;
description: string;
count: number;
publishDate: string;
layoutPattern?: {
smCol: number;
mdCol: number;
row: number;
index: number;
};
}
const { slug, layoutPattern, title, description, count, publishDate } = Astro.props;
const isSingleItem =
layoutPattern &&
layoutPattern.row === 1 &&
(layoutPattern.smCol === 1 || layoutPattern.mdCol === 1);
const formatedDescription = isSingleItem ? `No description available` : description;
const baseClasses =
'group group-hover rounded-xl flex h-full min-h-[220px] cursor-pointer flex-col overflow-hidden';
const bgColorClasses =
'bg-neutral-100/60 dark:bg-neutral-800/60 hover:bg-neutral-100 dark:hover:bg-neutral-800/90 ';
---
<a class={`rounded-xl`} href={`/categories/${slug}/`} data-astro-prefetch="false">
<div class={`${baseClasses}`}>
<div
class={`relative min-h-0 flex-grow overflow-hidden transition-all duration-300 ${bgColorClasses}`}
>
<div class="absolute inset-1 flex flex-col justify-end p-3 md:p-4 lg:p-5">
<h2
class="group-hover:text-steel dark:group-hover:text-bermuda transition-text mb-4 text-4xl font-extrabold tracking-tight text-balance text-neutral-800 duration-300 dark:text-neutral-200"
>
{title}
</h2>
<p
class=`mb-4 ${isSingleItem ? 'hidden lg:block' : ''} max-w-prose text-pretty font-light text-neutral-600 dark:text-neutral-400 sm:text-lg`
>
{formatedDescription}
</p>
<div
class="mt-auto flex items-center justify-between pt-1 text-xs text-neutral-600 md:pt-2 dark:text-neutral-300"
>
<span class="inline-flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class="mr-1"
>
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"></path>
</svg>
{count}
</span>
<span class="inline-flex items-center">
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
class="mr-1"
>
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
{publishDate}
</span>
</div>
</div>
</div>
</div>
</a>

View File

@@ -1,36 +0,0 @@
---
import type { Post } from '@lib/directusTypes';
import BlogCard from '@components/blog/BlogCard.astro';
interface Props {
posts: Post[];
}
const { posts } = Astro.props;
const description = 'Here are a few articles that I think are not bad, hope you like too.';
---
<section class="mx-auto mb-10 max-w-[85rem] px-4 py-8 sm:px-6 lg:px-8 2xl:max-w-full">
<div class="text-left">
<h2
id="selected-articel"
class="smooth-reveal-2 mb-4 text-5xl font-extrabold tracking-tight text-balance text-neutral-800 dark:text-neutral-200"
>
Posts
</h2>
<p
class="smooth-reveal-2 mb-8 max-w-prose font-light text-pretty text-neutral-600 sm:text-xl dark:text-neutral-400"
>
{description}
</p>
</div>
<div class="flex flex-col md:flex-row md:space-x-12 lg:space-x-16">
<div class="w-full">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((b) => <BlogCard post={b} />)}
</div>
</div>
</div>
</section>

View File

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

View File

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

View File

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

View File

@@ -1,85 +0,0 @@
---
import Icon from '@components/ui/icons/icon.astro';
---
<button
type="button"
class="focus-visible:ring-secondary group inline-flex items-center rounded-lg p-2.5 text-neutral-600 ring-neutral-500 transition duration-300 outline-none hover:bg-neutral-100 focus:outline-none focus-visible:ring-1 focus-visible:outline-none dark:text-neutral-400 dark:ring-neutral-200 dark:hover:bg-neutral-700"
data-bookmark-button="bookmark-button"
>
<Icon name="bookmark" />
</button>
<script>
class Bookmark {
private static readonly BOOKMARKS_KEY = 'bookmarks';
private bookmarkButton: Element | null;
constructor(private dataAttrValue: string) {
this.bookmarkButton = document.querySelector(`[data-bookmark-button="${dataAttrValue}"]`);
}
private getStoredBookmarks(): string[] {
const item = localStorage.getItem(Bookmark.BOOKMARKS_KEY);
return item ? JSON.parse(item) : [];
}
init(): void {
if (this.bookmarkButton && this.isStored()) {
this.markAsStored();
}
this.bookmarkButton?.addEventListener('click', () => this.toggleBookmark());
}
isStored(): boolean {
return this.getStoredBookmarks().includes(window.location.pathname);
}
markAsStored(): void {
if (this.bookmarkButton) {
this.bookmarkButton.classList.add('bookmarked');
const svgElement = this.bookmarkButton.querySelector('svg');
if (svgElement) {
svgElement.setAttribute('class', 'h-6 w-6 fill-red-500 dark:fill-red-500');
}
const pathElement = svgElement?.querySelector('path');
if (pathElement) {
pathElement.setAttribute('class', 'fill-current text-red-500 dark:text-red-500');
}
}
}
unmarkAsStored(): void {
if (this.bookmarkButton) {
this.bookmarkButton.classList.remove('bookmarked');
const svgElement = this.bookmarkButton.querySelector('svg');
if (svgElement) {
svgElement.setAttribute('class', 'h-6 w-6 fill-none');
}
const pathElement = svgElement?.querySelector('path');
if (pathElement) {
pathElement.setAttribute(
'class',
'fill-current text-neutral-500 group-hover:text-red-400 dark:text-neutral-500 group-hover:dark:text-red-400'
);
}
}
}
toggleBookmark(): void {
const storedBookmarks = this.getStoredBookmarks();
const index = storedBookmarks.indexOf(window.location.pathname);
if (index !== -1) {
storedBookmarks.splice(index, 1);
this.unmarkAsStored();
} else {
storedBookmarks.push(window.location.pathname);
this.markAsStored();
}
localStorage.setItem(Bookmark.BOOKMARKS_KEY, JSON.stringify(storedBookmarks));
}
}
new Bookmark('bookmark-button').init();
</script>

View File

@@ -1,32 +0,0 @@
---
import { Icon } from 'astro-icon/components';
const { title, url } = Astro.props;
interface Props {
title?: string;
url?: string;
}
const baseClasses =
'group group-hover inline-flex items-center justify-center gap-x-3 rounded-full px-4 py-3 text-center text-sm font-medium text-neutral-200';
const borderClasses = 'border border-transparent';
const bgColorClasses =
'bg-gitea-primary hover:bg-gitea-secondary dark:bg-gitea-secondary dark:hover:bg-gitea-primary';
const shadowClasses = 'shadow-sm';
const fontSizeClasses = '2xl:text-base';
---
<a
class={`${baseClasses} ${borderClasses} ${bgColorClasses} ${shadowClasses} ${fontSizeClasses} `}
href={url}
target="_blank"
rel="noopener noreferrer"
>
<Icon name="pajamas:gitea" class="h-4 w-4 md:h-6 md:w-6" />
{title}
<Icon
name="mdi:keyboard-arrow-right"
class="h-3 w-3 translate-y-0.25 transition duration-300 group-hover:translate-x-1 md:h-5 md:w-5"
/>
</a>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,37 +0,0 @@
---
import { readSingleton } from '@directus/sdk';
import directus from '@lib/directus';
import FeaturesCard from '@components/ui/cards/FeaturesCard.astro';
const global = await directus.request(readSingleton('site_global'));
---
<section class="mx-auto mb-20 max-w-[85rem] px-4 py-10 sm:px-6 lg:px-8 lg:py-14 2xl:max-w-full">
<div
class="flex flex-col items-center justify-center gap-y-2 sm:flex-row sm:gap-x-12 sm:gap-y-0 lg:gap-x-24"
>
<div class="mx-auto max-w-5xl px-4 sm:px-6 lg:px-8">
<div class="grid gap-3 sm:grid-cols-2 sm:gap-6 lg:grid-cols-3">
<FeaturesCard
title="Cloud Engineer"
description="Full stack and multi cloud engineer"
url="#"
icon="mdi:cloud-outline"
/>
<FeaturesCard
title="Homelab"
description="Tinkering, testing, deploying, etc, etc ..."
url="#"
icon="mdi:home-variant-outline"
/>
<FeaturesCard
title="Email Me"
description={`Reach me at ${global.email}`}
url=`mailto:${global.email}`
icon="mdi:email-fast"
/>
</div>
</div>
</div>
</section>

View File

@@ -1,35 +0,0 @@
---
import PrimaryCTA from '@components/ui/buttons/PrimaryCTA.astro';
interface Props {
title: string;
subTitle: string;
btnExists?: boolean;
btnTitle?: string;
btnURL?: string;
}
const { title, subTitle, btnExists, btnTitle, btnURL } = Astro.props;
---
<section class="mx-auto mt-10 px-4 sm:px-6 lg:px-8 lg:pt-10 2xl:max-w-full">
<div class="flex-wrap md:flex md:items-center md:justify-between">
<div class="w-full md:w-auto">
<h1
class="smooth-reveal block text-4xl font-bold tracking-tight text-balance text-neutral-800 md:text-5xl lg:text-6xl dark:text-neutral-200"
>
{title}
</h1>
<p class="smooth-reveal mt-4 text-lg text-pretty text-neutral-600 dark:text-neutral-400">
{subTitle}
</p>
{
btnExists ? (
<div class="smooth-reveal mt-4 md:mt-8">
<PrimaryCTA title={btnTitle} url={btnURL} />
</div>
) : null
}
</div>
</div>
</section>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,66 +0,0 @@
import { readSingleton } from '@directus/sdk';
import directus from '@lib/directus';
export interface NavigationLink {
name: string;
url: string;
}
const global = await directus.request(readSingleton('site_global'));
export const WorkInformation = [
{
name: 'Tech Startup',
position: 'Junior Web Developer',
location_type: 'On site',
location: 'Auckland, New Zealand',
url: 'https://techstartup.com',
startDate: '2024-01-01',
endDate: null,
summary:
'Developing and maintaining web applications using JavaScript, HTML, and CSS. Collaborating with the team to implement new features and fix bugs.',
highlights: ['Improved website performance by optimizing code'],
responsibilities: [
'Collaborated with senior developers to design and implement web applications using modern JavaScript frameworks.',
'Assisted in debugging and optimizing front-end code to ensure smooth user experiences.',
'Participated in code reviews and contributed to improving coding standards within the team.',
],
achievements: [
'Developing and maintaining web applications using JavaScript, HTML, and CSS. Collaborating with the team to implement new features and fix bugs.',
],
skills: ['React', 'Tailwind', 'GitHub'],
},
];
export const NavigationLinks: NavigationLink[] = [
{ name: 'Home', url: '/' },
{ name: 'Blog', url: '/blog/' },
{ name: 'Categories', url: '/categories/' },
{ name: 'About Me', url: '/about/' },
];
export const FooterLinks: NavigationLink[] = [
{ name: 'RSS', url: '/rss.xml' },
{ name: 'Gitea', url: '/https://gitea.alexlebens.dev' },
];
export const SEO = {
title: global.name,
description: global.about,
structuredData: {
'@context': 'https://schema.org',
'@type': 'WebPage',
inLanguage: 'en-US',
'@id': global.site_url,
url: global.site_url,
name: global.name,
description: global.about,
isPartOf: {
'@type': 'WebSite',
url: global.site_url,
name: global.name,
description: global.about,
},
},
};

View File

@@ -1,4 +0,0 @@
---
title: '二愣的小书屋 🔖'
description: '生活总是这样,不能让人处处都满意但我们还要热情地活下去'
---

View File

@@ -1,4 +0,0 @@
---
title: 'Golang 🚀'
description: 'Go 语言的词法分析与语法分析及折腾下 Go 编译原理吗'
---

View File

@@ -1,4 +0,0 @@
---
title: 'kubernetes 💩'
description: 'Kubernetes 是一个开源系统,用于自动化部署、扩展和管理容器化应用程序。'
---

View File

@@ -1,4 +0,0 @@
---
title: 'Laravel 🐛'
description: '最优雅的 PHP 框架'
---

View File

@@ -1,4 +0,0 @@
---
title: '生活 💃'
description: '房贷要还的,水电费要交的,小孩学费也贵的,钱肯定要不够的呀。所以工作不敢扔,我大部分的快乐,都是来自于生活的夹缝。'
---

View File

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

View File

@@ -1,4 +0,0 @@
---
title: 'Nginx 🐕‍🦺'
description: '高性能的 HTTP 和反向代理 Web 服务器,奈何反向代理的 PHP-FMP 服务太慢。'
---

View File

@@ -1,4 +0,0 @@
---
title: 'PHP 🐘'
description: 'PHP is a popular general-purpose scripting language that is especially suited to web development.'
---

View File

@@ -1,4 +0,0 @@
---
title: 'Tool 🪜'
description: '停止折腾就老了吧'
---

View File

@@ -1,4 +0,0 @@
---
title: 'What Is?'
description: '什么是快乐星球,什么是快乐星球,什么是快乐星球'
---

View File

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

5
src/env.d.ts vendored
View File

@@ -1,8 +1,3 @@
<<<<<<< HEAD
/// <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" />
=======
/// <reference types="astro/client" />
/// <reference path="../.astro/types.d.ts" />
>>>>>>> 184f0c7 (fix path)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,102 +1,438 @@
--- ---
import { readItems, readSingleton } from '@directus/sdk'; import BaseLayout from '../../layouts/BaseLayout.astro';
import type { Post } from '@lib/directusTypes'; import directus from '../../../lib/directus';
import { readItems } from '@directus/sdk';
import directus from '@lib/directus';
import BaseLayout from '@layouts/BaseLayout.astro';
import BlogRecentCard from '@components/blog/BlogRecentCard.astro';
import BlogFeaturedArticle from '@components/blog/BlogFeaturedArticle.astro';
import HeroSection from '@components/ui/sections/HeroSection.astro';
import blogImg from '@images/autumn_tree.png';
const global = await directus.request(readSingleton('site_global'));
const posts = await directus.request( const posts = await directus.request(
readItems('posts', { readItems('posts', {
fields: ['*'], fields: ['*'],
sort: ['-published_date'], sort: ['-published_date'],
}) })
); );
const selectedPosts: Post[] = posts.filter((p) => p.selected);
const description = const sortedPosts = posts.sort((a, b) => b.published_date.valueOf() - a.published_date.valueOf());
'Here are some articles that Alex Lebens believes are not bad, hope you enjoy them.';
// Group posts by year for timeline effect
const postsByYear = sortedPosts.reduce((acc, post) => {
const year = new Date(post.published_date).getFullYear();
if (!acc[year]) acc[year] = [];
acc[year].push(post);
return acc;
}, {});
const years = Object.keys(postsByYear).sort((a, b) => b - a);
// Get total post count
const totalPosts = sortedPosts.length;
// Get unique tags for search suggestions
const allTags = [...new Set(sortedPosts.flatMap((post) => post.tags || []))];
--- ---
<BaseLayout <BaseLayout title="Blog">
title="Blog" <div class="mx-auto w-full max-w-6xl px-4 py-10 sm:px-6 sm:py-16">
description={description} <!-- Header with search -->
structuredData={{ <div class="relative mb-12 sm:mb-20">
'@context': 'https://schema.org', <!-- Decorative elements -->
'@type': 'WebPage', <div
inLanguage: 'en-US', class="animate-blob absolute -top-10 -left-10 h-48 w-48 rounded-full bg-zinc-100 opacity-30 blur-3xl sm:-top-20 sm:-left-20 sm:h-72 sm:w-72 dark:bg-zinc-800/30"
'@id': Astro.url.href,
url: Astro.url.href,
name: `Blog | ${global.name}`,
description: description,
isPartOf: {
'@type': 'WebSite',
url: global.site_url,
name: global.name,
description: global.about,
},
}}
> >
<HeroSection title="Blog" subTitle={description} src={blogImg} alt={global.blog_image_alt} /> </div>
<div
class="animate-blob animation-delay-2000 absolute -right-10 -bottom-10 h-48 w-48 rounded-full bg-zinc-200 opacity-30 blur-3xl sm:-right-20 sm:-bottom-20 sm:h-72 sm:w-72 dark:bg-zinc-800/30"
>
</div>
<BlogRecentCard posts={posts} /> <div class="relative text-center">
<BlogFeaturedArticle posts={selectedPosts} /> <h1
class="mb-4 text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl md:text-5xl dark:text-zinc-100"
>
Blog
</h1>
<p
class="mx-auto mb-6 max-w-2xl text-sm text-zinc-600 sm:mb-10 sm:text-base dark:text-zinc-400"
>
Thoughts, ideas, and explorations on technology and selfhosting.
</p>
</div>
</div>
<!-- Grid layout for mobile experience -->
<div class="grid grid-cols-1 gap-6 sm:gap-8 md:grid-cols-12">
<!-- Featured post (if exists) -->
{
sortedPosts.length > 0 && (
<div class="mb-8 sm:mb-12 md:col-span-12">
<article class="group relative overflow-hidden rounded-none border-b border-zinc-200 pb-6 sm:pb-8 dark:border-zinc-800">
<div class="flex h-full flex-col gap-6 sm:gap-8 md:flex-row">
{sortedPosts[0].image && (
<div class="mx-auto h-60 w-full max-w-full overflow-hidden sm:h-80 sm:max-w-md md:mx-0 md:h-96 md:w-1/2">
<img
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${sortedPosts[0].image}`}
alt={sortedPosts[0].title}
class="h-full w-full object-cover grayscale transition-all duration-700 group-hover:scale-105 hover:grayscale-0"
loading="eager"
/>
</div>
)}
<div class="flex flex-1 flex-col justify-center">
<div class="mb-3 flex items-center justify-center gap-2 text-xs text-zinc-500 sm:text-sm md:justify-start dark:text-zinc-400">
<span class="font-medium tracking-wider uppercase">Featured</span>
<span class="h-px w-6 bg-zinc-300 sm:w-8 dark:bg-zinc-700" />
{sortedPosts[0].published_date && (
<time datetime={sortedPosts[0].published_date.toLocaleString()}>
{sortedPosts[0].published_date.toLocaleString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
)}
</div>
<h2 class="mb-3 text-center text-2xl font-bold text-zinc-900 transition-colors group-hover:text-zinc-700 sm:mb-4 sm:text-3xl md:text-left dark:text-zinc-100 dark:group-hover:text-zinc-300">
<a
href={`/blog/${sortedPosts[0].slug}/`}
class="before:absolute before:inset-0"
>
{sortedPosts[0].title}
</a>
</h2>
<p class="mb-4 line-clamp-3 text-center text-sm text-zinc-600 sm:mb-6 sm:text-base md:text-left dark:text-zinc-400">
{sortedPosts[0].description}
</p>
<div class="flex flex-wrap items-center justify-center gap-3 sm:gap-4 md:justify-start">
{sortedPosts[0].tags && (
<div class="flex flex-wrap justify-center gap-2 md:justify-start">
{sortedPosts[0].tags.slice(0, 2).map((tag) => (
<span class="border border-zinc-200 px-2 py-1 text-xs tracking-wider text-zinc-600 uppercase sm:px-3 dark:border-zinc-800 dark:text-zinc-400">
{tag}
</span>
))}
</div>
)}
</div>
</div>
</div>
</article>
</div>
)
}
<!-- Improved sidebar for mobile -->
<div class="relative md:col-span-3">
<div class="mb-8 space-y-4 md:sticky md:top-24 md:mb-0">
<h3
class="mb-4 text-center text-lg font-medium tracking-wider text-zinc-900 uppercase md:text-left dark:text-zinc-100"
>
Archive
</h3>
<!-- Horizontal scrollable archive on mobile, vertical on desktop -->
<div
class="hide-scrollbar flex overflow-x-auto pb-4 md:flex-col md:overflow-visible md:pb-0"
>
{
years.map((year, index) => (
<a
href={`#year-${year}`}
class={`hover mr-3 flex items-center rounded-full border-b border-zinc-100 px-4 py-2 whitespace-nowrap transition-colors hover:bg-zinc-50 md:mr-0 md:w-full md:rounded-none md:px-0 md:py-3 md:whitespace-normal dark:border-zinc-800 dark:hover:bg-zinc-900 ${index === 0 ? 'bg-zinc-50 dark:bg-zinc-800/50' : ''}`}
>
<span class="text-base font-medium text-zinc-900 md:text-lg dark:text-zinc-100">
{year}
</span>
<span class="ml-2 text-xs text-zinc-500 md:ml-auto md:text-sm dark:text-zinc-400">
{postsByYear[year].length} post{postsByYear[year].length !== 1 ? 's' : ''}
</span>
</a>
))
}
</div>
</div>
</div>
<!-- Improved post grid for mobile -->
<div class="md:col-span-9">
{
years.map((year) => (
<div id={`year-${year}`} class="mb-12 scroll-mt-16 sm:mb-20">
<h2 class="mb-6 border-b border-zinc-200 pb-3 text-center text-xl font-bold text-zinc-900 sm:mb-8 sm:pb-4 sm:text-2xl md:text-left dark:border-zinc-800 dark:text-zinc-100">
{year}
</h2>
<div
class={`grid grid-cols-1 ${postsByYear[year].length >= 2 ? 'md:grid-cols-2' : 'md:grid-cols-1'} gap-8 sm:gap-12`}
>
{postsByYear[year].map((post, index) => (
<article class="group relative mx-auto flex h-full w-full max-w-sm flex-col sm:max-w-md md:mx-0">
{post.image && (
<div class="mb-4 h-48 overflow-hidden rounded-lg sm:h-56">
<img
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${post.image}`}
alt={post.title}
class="h-full w-full object-cover grayscale transition-all duration-700 group-hover:scale-105 hover:grayscale-0"
loading="lazy"
/>
</div>
)}
<div class="flex flex-1 flex-col">
<div class="mb-2 flex flex-wrap items-center justify-center gap-3 text-xs text-zinc-500 sm:mb-3 sm:gap-4 sm:text-sm md:justify-start dark:text-zinc-400">
{post.pubDate && (
<time
datetime={post.published_date.toLocaleString()}
class="flex items-center"
>
{post.published_date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
})}
</time>
)}
</div>
<h3 class="mb-2 text-center text-lg font-semibold text-zinc-900 transition-colors group-hover:text-zinc-700 sm:mb-3 sm:text-xl md:text-left dark:text-zinc-100 dark:group-hover:text-zinc-300">
<a href={`/blog/${post.slug}/`} class="before:absolute before:inset-0">
{post.title}
</a>
</h3>
<p class="mb-4 line-clamp-2 grow text-center text-sm text-zinc-600 md:text-left dark:text-zinc-400">
{post.description}
</p>
{post.tags && (
<div class="mt-auto flex flex-wrap justify-center gap-2 md:justify-start">
{post.tags.slice(0, 2).map((tag) => (
<span class="border border-zinc-200 px-2 py-1 text-xs tracking-wider text-zinc-600 uppercase sm:px-3 dark:border-zinc-800 dark:text-zinc-400">
{tag}
</span>
))}
{post.tags.length > 2 && (
<span class="border border-zinc-200 px-2 py-1 text-xs tracking-wider text-zinc-600 uppercase sm:px-3 dark:border-zinc-800 dark:text-zinc-400">
+{post.tags.length - 2}
</span>
)}
</div>
)}
</div>
</article>
))}
</div>
</div>
))
}
</div>
</div>
</div>
</BaseLayout> </BaseLayout>
<style>
/* Blob animation */
.animate-blob {
animation: blob-bounce 8s infinite ease;
}
.animation-delay-2000 {
animation-delay: 2s;
}
@keyframes blob-bounce {
0%,
100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(5%, 5%) scale(1.05);
}
50% {
transform: translate(0, 10%) scale(1);
}
75% {
transform: translate(-5%, 5%) scale(0.95);
}
}
/* Search container hover effect */
.search-container:hover .search-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
/* Input focus animation */
input:focus + div .search-pulse {
animation: pulse 1.5s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
/* Hide scrollbar but keep functionality */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Line clamp for descriptions */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Improved touch targets for mobile */
@media (max-width: 640px) {
a,
button {
min-height: 44px;
display: flex;
align-items: center;
}
}
</style>
<script> <script>
// Add smooth reveal animations for content after loading // Script không thay đổi - giữ nguyên chức năng
document.addEventListener('astro:page-load', () => { document.addEventListener('DOMContentLoaded', () => {
const animateContent = () => { const backToTopButton = document.getElementById('back-to-top');
// Animate group 1
const smoothReveal = document.querySelectorAll('.smooth-reveal');
smoothReveal.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
200 + index * 300
);
});
// Animate group 2 if (backToTopButton) {
const smoothReveal2 = document.querySelectorAll('.smooth-reveal-2'); // Show button when scrolled down
smoothReveal2.forEach((el, index) => { const toggleBackToTopButton = () => {
setTimeout( if (window.scrollY > 300) {
() => { backToTopButton.classList.remove('opacity-0', 'invisible');
el.classList.add('animate-reveal'); backToTopButton.classList.add('opacity-100', 'visible');
}, } else {
500 + index * 100 backToTopButton.classList.remove('opacity-100', 'visible');
); backToTopButton.classList.add('opacity-0', 'invisible');
}); }
// Animate topic cards with staggered delay
const smoothRevealCards = document.querySelectorAll('.smooth-reveal-cards');
smoothRevealCards.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
1000 + index * 250
);
});
// Animate with just fade in with staggered delay
const smoothRevealFade = document.querySelectorAll('.smooth-reveal-fade');
smoothRevealFade.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal-fade');
},
100 + index * 250
);
});
}; };
animateContent(); // 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> </script>

View File

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

View File

@@ -1,182 +0,0 @@
---
import { getCollection } from 'astro:content';
import { readItems, readSingleton } from '@directus/sdk';
import type { Post } from '@lib/directusTypes';
import directus from '@lib/directus';
import BaseLayout from '@layouts/BaseLayout.astro';
import BlogCategoryCard from '@components/blog/BlogCategoryCard.astro';
import HeroSection from '@components/ui/sections/HeroSection.astro';
import { timeago } from '@support/time';
import categoryImg from '@images/autumn_bench.png';
const global = await directus.request(readSingleton('site_global'));
const posts = await directus.request(
readItems('posts', {
fields: ['*'],
sort: ['-published_date'],
})
);
const postMap: Map<string, Post[]> = posts
.sort((a: Post, b: Post) => b.published_date.valueOf() - a.published_date.valueOf())
.reduce((acc, obj) => {
let posts = acc.get(obj.category);
if (!posts) {
posts = [];
}
posts.push(obj);
acc.set(obj.category, posts);
return acc;
}, new Map<string, Post[]>());
const layoutPattern = [
{ col: 2, row: 2 },
{ col: 2, row: 1 },
{ col: 1, row: 1 },
{ col: 1, row: 1 },
{ col: 1, row: 2 },
{ col: 2, row: 1 },
{ col: 1, row: 1 },
{ col: 1, row: 1 },
{ col: 1, row: 1 },
{ col: 1, row: 1 },
];
const categories = (await getCollection('categories'))
.sort((a, b) => {
const aCount = postMap.get(a.slug)?.length ?? 0;
const bCount = postMap.get(b.slug)?.length ?? 0;
return bCount - aCount;
})
.map((c, index) => {
const posts = postMap.get(c.slug);
const pattern = layoutPattern[index % layoutPattern.length];
const smColSpan = Math.min(pattern.col, 2);
const mdColSpan = Math.min(pattern.col, 4);
const rowSpan = pattern.row;
const rowSpanClass = rowSpan > 1 ? `row-span-${rowSpan}` : 'row-span-1';
const gridItemClass = `col-span-${smColSpan} md:col-span-${mdColSpan} ${rowSpanClass} smooth-reveal-cards rounded-xl transition-all duration-300 shadow-xs hover:shadow-md dark:shadow-md dark:hover:shadow-lg border border-stone-200/50 dark:border-stone-700/50`;
return {
...c,
posts,
gridItemClass,
layoutPattern: {
smCol: smColSpan,
mdCol: mdColSpan,
row: rowSpan,
index,
},
};
});
const description =
'Here are some categories that I am interested in, including Laravel, Golang, and my life.';
---
<BaseLayout
title="All Categories"
description={description}
structuredData={{
'@context': 'https://schema.org',
'@type': 'WebPage',
inLanguage: 'en-US',
'@id': Astro.url.href,
url: Astro.url.href,
name: `All Categories | ${global.name}`,
description: description,
isPartOf: {
'@type': 'WebSite',
url: global.site_url,
name: global.name,
description: global.about,
},
}}
>
<HeroSection
title="Categories"
subTitle={description}
src={categoryImg}
alt={global.categories_image_alt}
/>
<section class="mx-auto px-4 py-10 sm:px-6 lg:px-8 lg:py-14 lg:pt-10 2xl:max-w-full">
<div class="grid grid-flow-row-dense grid-cols-2 gap-4 md:grid-cols-4">
{
categories.map((category) => {
return (
<div
class={category.gridItemClass}
style={category.layoutPattern.row > 1 ? 'grid-row: span 2 / span 2;' : ''}
>
<BlogCategoryCard
slug={category.slug}
title={category.data.title}
description={category.data.description}
count={postMap.get(category.slug)?.length ?? 0}
publishDate={timeago(postMap.get(category.slug)?.[0]?.published_date)}
layoutPattern={category.layoutPattern}
/>
</div>
);
})
}
</div>
</section>
</BaseLayout>
<script>
// Add smooth reveal animations for content after loading
document.addEventListener('astro:page-load', () => {
const animateContent = () => {
// Animate group 1
const smoothReveal = document.querySelectorAll('.smooth-reveal');
smoothReveal.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
50 + index * 100
);
});
// Animate group 2
const smoothReveal2 = document.querySelectorAll('.smooth-reveal-2');
smoothReveal2.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
200 + index * 150
);
});
// Animate topic cards with staggered delay
const smoothRevealCards = document.querySelectorAll('.smooth-reveal-cards');
smoothRevealCards.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
500 + index * 100
);
});
// Animate with just fade in with staggered delay
const smoothRevealFade = document.querySelectorAll('.smooth-reveal-fade');
smoothRevealFade.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal-fade');
},
100 + index * 250
);
});
};
animateContent();
});
</script>

View File

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

View File

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

View File

@@ -1,31 +0,0 @@
// https://docs.astro.build/en/guides/integrations-guide/sitemap/#usage
import type { APIRoute } from 'astro';
const robotsTxt = `
User-agent: Googlebot
Disallow:
Allow: /
Crawl-delay: 10
User-agent: Yandex
Disallow:
Allow: /
Crawl-delay: 2
User-agent: archive.org_bot
Disallow:
Allow: /
Crawl-delay: 2
User-agent: *
Allow: /
Sitemap: ${new URL('sitemap-index.xml', import.meta.env.SITE).href}`.trim();
export const GET: APIRoute = () => {
return new Response(robotsTxt, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
};

View File

@@ -1,26 +1,10 @@
// copy from https://github.com/delucis/astro-blog-full-text-rss import rss from '@astrojs/rss';
// see https://github.com/delucis/astro-blog-full-text-rss/blob/latest/src/pages/rss.xml.ts
// get more context
import { getContainerRenderer as getMDXRenderer } from '@astrojs/mdx'; import directus from '../../lib/directus';
import rss, { type RSSFeedItem } from '@astrojs/rss';
import type { APIContext } from 'astro';
import { transform, walk } from 'ultrahtml';
import sanitize from 'ultrahtml/transformers/sanitize';
import { readItems, readSingleton } from '@directus/sdk'; import { readItems, readSingleton } from '@directus/sdk';
import directus from '@lib/directus'; export async function GET(context: any) {
const global = await directus.request(readSingleton('global'));
const global = await directus.request(readSingleton('site_global'));
export async function GET(context: APIContext) {
// Get the URL to prepend to relative site links. Based on `site` in `astro.config.mjs`.
let baseUrl = context.site?.href || global.site_url;
if (baseUrl.at(-1) === '/') {
baseUrl = baseUrl.slice(0, -1);
}
// Load the content collection entries to add to our RSS feed.
const posts = await directus.request( const posts = await directus.request(
readItems('posts', { readItems('posts', {
fields: ['*'], fields: ['*'],
@@ -28,30 +12,16 @@ export async function GET(context: APIContext) {
}) })
); );
const feedItems: RSSFeedItem[] = [];
for (const post of posts) {
const content = await transform(post.content.replace(/^<!DOCTYPE html>/, ''), [
async (node) => {
await walk(node, (node) => {
if (node.name === 'a' && node.attributes.href?.startsWith('/')) {
node.attributes.href = baseUrl + node.attributes.href;
}
if (node.name === 'img' && node.attributes.src?.startsWith('/')) {
node.attributes.src = baseUrl + node.attributes.src;
}
});
return node;
},
sanitize({ dropElements: ['script', 'style'] }),
]);
feedItems.push({ ...post, link: `/blog/${post.slug}/`, content });
}
// Return our RSS feed XML response.
return rss({ return rss({
title: global.name, title: `${global.name}`,
description: global.about, description: `${global.description}`,
site: baseUrl, site: context.site,
items: feedItems, items: posts.map((post) => ({
title: post.title,
pubDate: post.published_date,
description: post.slug,
link: `/blog/${post.slug}/`,
categories: post.tags || [],
})),
}); });
} }

View File

@@ -0,0 +1,520 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import FormattedDate from '../../components/FormattedDate.astro';
import directus from '../../../lib/directus';
import { readItems } from '@directus/sdk';
export const prerender = true;
export async function getStaticPaths() {
const posts = await directus.request(
readItems('posts', {
fields: ['*'],
})
);
// Get all unique tags
const uniqueTags = [...new Set(posts.flatMap((post) => post.tags || []))];
// Create a path for each tag
return uniqueTags.map((tag) => {
// Make tag matching case-insensitive
const filteredPosts = posts.filter(
(post) => post.tags?.some((t) => t.toLowerCase() === (tag as string).toLowerCase()) // Explicitly cast tag to string
);
return {
params: { tag },
props: { posts: filteredPosts },
};
});
}
const { tag } = Astro.params as { tag: string };
const { posts = [] } = Astro.props;
console.log(`Tag: ${tag}, Number of posts: ${posts.length}`);
const sortedPosts =
posts && posts.length > 0
? [...posts].sort((a, b) => b.published_date.valueOf() - a.published_date.valueOf())
: [];
console.log(`Sorted posts length: ${sortedPosts.length}`);
const tagHue = Math.abs(tag.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 360);
const relatedTags = [
...new Set(sortedPosts.flatMap((post) => post.tags || []).filter((t) => t !== tag)),
].slice(0, 5);
---
<BaseLayout title={`Posts tagged with "${tag}"`}>
<div class="mx-auto max-w-5xl px-4 py-10 sm:py-16">
<!-- Header section -->
<div class="relative mb-10 sm:mb-16">
<div
class="animate-blob absolute -top-20 -left-20 h-48 w-48 rounded-full bg-zinc-100 opacity-30 blur-3xl sm:h-64 sm:w-64 dark:bg-zinc-900/30"
>
</div>
<div
class="animate-blob animation-delay-2000 absolute -right-10 -bottom-10 h-36 w-36 rounded-full bg-zinc-200 opacity-20 blur-2xl sm:h-48 sm:w-48 dark:bg-zinc-900/20"
>
</div>
<div class="relative text-center sm:text-left">
<a
href="/tags"
class="group mb-4 inline-flex items-center gap-2 text-sm font-medium text-zinc-600 transition-colors hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-4 w-4 transition-transform duration-300 group-hover:-translate-x-1"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"></path>
</svg>
<span>Back to all topics</span>
<span
class="block h-0.5 max-w-0 bg-zinc-300 transition-all duration-300 group-hover:max-w-full dark:bg-zinc-700"
></span>
</a>
<div
class="mb-2 flex flex-col justify-center gap-4 sm:flex-row sm:items-center sm:justify-start"
>
<div
class="tag-icon mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-zinc-100 shadow-xs sm:mx-0 dark:bg-zinc-800"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-6 w-6 text-zinc-700 dark:text-zinc-300"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
></path>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z"></path>
</svg>
</div>
<h1
class="text-3xl font-bold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-100"
>
<span class="relative">
#{tag}
<span class="absolute -bottom-1 left-0 h-1 w-full bg-zinc-200 dark:bg-zinc-700"
></span>
<span
class="animate-expand absolute -bottom-1 left-0 h-1 w-1/2 bg-zinc-900 opacity-70 dark:bg-zinc-100"
></span>
</span>
</h1>
</div>
<p
class="mx-auto mt-4 max-w-2xl text-base text-zinc-600 sm:mx-0 sm:text-lg dark:text-zinc-400"
>
Exploring <span class="font-medium text-zinc-900 dark:text-zinc-100"
>{sortedPosts.length}</span
> articles tagged with <span class="font-medium text-zinc-900 dark:text-zinc-100"
>"{tag}"</span
>
</p>
</div>
</div>
<!-- Related tags section -->
{
relatedTags.length > 0 && (
<div class="hide-scrollbar mb-8 overflow-x-auto pb-4 sm:mb-12">
<h2 class="mb-3 text-center text-lg font-medium text-zinc-900 sm:text-left dark:text-zinc-100">
Related topics
</h2>
<div class="flex flex-nowrap justify-center gap-2 sm:justify-start">
{relatedTags.map((relatedTag) => (
<a
href={`/topics/${relatedTag}`}
class="inline-flex shrink-0 items-center rounded-full bg-zinc-100 px-3 py-1.5 text-sm font-medium text-zinc-900 transition-colors hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700"
>
#{relatedTag}
</a>
))}
</div>
</div>
)
}
<!-- Posts list -->
<div class="relative">
<div class="bg-grid-pattern pointer-events-none absolute inset-0 opacity-5 dark:opacity-10">
</div>
<div class="relative space-y-6 sm:space-y-8">
{
sortedPosts.map((post) => (
<article class="hover-card group relative mx-auto flex max-w-2xl flex-col rounded-2xl border border-zinc-200 p-5 transition-all duration-300 hover:bg-zinc-50/80 hover:shadow-md sm:mx-0 sm:p-8 dark:border-zinc-800 dark:hover:bg-zinc-900/50">
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br from-zinc-50/0 to-zinc-100/0 opacity-0 transition-opacity duration-500 group-hover:opacity-100 dark:from-zinc-900/0 dark:to-zinc-800/0" />
<div class="flex flex-col gap-5 sm:flex-row sm:gap-6">
{post.image && (
<div class="mx-auto h-40 w-full shrink-0 overflow-hidden rounded-xl shadow-xs transition-all duration-300 group-hover:shadow-md sm:mx-0 sm:w-56">
<img
src={`${process.env.DIRECTUS_URL ?? 'https://directus.alexlebens.dev'}/assets/${post.image}?width=500`}
alt={post.image_alt}
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-105"
loading="lazy"
/>
</div>
)}
<div class="flex-1">
<div class="mb-2 flex flex-wrap items-center justify-center gap-3 text-xs text-zinc-500 sm:mb-3 sm:justify-start sm:gap-4 sm:text-sm dark:text-zinc-400">
{post.published_date && (
<time
datetime={post.published_date.toLocaleString()}
class="flex items-center gap-1.5"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-3.5 w-3.5 sm:h-4 sm:w-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0
A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg>
<FormattedDate date={post.published_date} />
</time>
)}
</div>
<h2 class="mb-2 text-center text-xl font-semibold text-zinc-900 transition-colors group-hover:text-zinc-700 sm:mb-3 sm:text-left sm:text-2xl dark:text-zinc-100 dark:group-hover:text-zinc-300">
<a href={`/blog/${post.slug}/`} class="before:absolute before:inset-0">
{post.title}
</a>
</h2>
<p class="mb-4 line-clamp-2 text-center text-sm text-zinc-600 sm:line-clamp-3 sm:text-left sm:text-base dark:text-zinc-400">
{post.description}
</p>
</div>
</div>
<div class="mt-4 flex flex-wrap items-end justify-center border-t border-zinc-100 pt-4 sm:justify-between dark:border-zinc-800">
{post.tags && post.tags.length > 0 && (
<div class="mb-3 flex flex-wrap justify-center gap-2 sm:mb-0 sm:justify-start">
{post.tags.slice(0, 3).map((postTag) => (
<a
href={`/topics/${postTag}`}
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors ${
postTag === tag
? 'bg-zinc-900/10 text-zinc-900 dark:bg-zinc-100/20 dark:text-zinc-100'
: 'bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700'
}`}
>
#{postTag}
</a>
))}
{post.tags.length > 3 && (
<span class="inline-flex items-center rounded-full bg-zinc-50 px-2 py-0.5 text-xs text-zinc-500 dark:bg-zinc-800/50 dark:text-zinc-400">
+{post.tags.length - 3}
</span>
)}
</div>
)}
<div class="mx-auto sm:mr-0 sm:ml-auto">
<a
href={`/blog/${post.slug}/`}
class="inline-flex items-center text-sm font-medium text-zinc-700 transition-colors group-hover:text-zinc-900 dark:text-zinc-300 dark:group-hover:text-zinc-100"
aria-hidden="true"
tabindex="-1"
>
<span class="relative inline-block overflow-hidden">
<span class="block transition-transform duration-300 group-hover:-translate-y-full">
Read article
</span>
<span class="absolute top-0 left-0 translate-y-full whitespace-nowrap transition-transform duration-300 group-hover:translate-y-0">
Explore now
</span>
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="ml-1 h-4 w-4 transition-transform duration-300 group-hover:translate-x-1"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"
/>
</svg>
</a>
</div>
</div>
</article>
))
}
</div>
</div>
<!-- Empty state với màu zinc -->
{
sortedPosts.length === 0 && (
<div class="py-12 text-center sm:py-20">
<div class="mb-4 inline-flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100 sm:mb-6 sm:h-20 sm:w-20 dark:bg-zinc-800">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-8 w-8 text-zinc-500 sm:h-10 sm:w-10 dark:text-zinc-400"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<h2 class="mb-2 text-xl font-semibold text-zinc-900 sm:text-2xl dark:text-zinc-100">
No posts found
</h2>
<p class="text-zinc-600 dark:text-zinc-400">There are no posts with this tag yet.</p>
<a
href="/blog"
class="mt-6 inline-flex items-center gap-2 rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-800 transition-all duration-300 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="h-4 w-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 12h-15m0 0l6.75 6.75M4.5 12l6.75-6.75"
/>
</svg>
<span>Browse all articles</span>
</a>
</div>
)
}
</div>
</BaseLayout>
<style>
/* Grid pattern background */
.bg-grid-pattern {
background-size: 30px 30px;
background-image: radial-gradient(circle, rgba(0, 0, 0, 0.05) 1px, transparent 1px);
}
:global(.dark) .bg-grid-pattern {
background-image: radial-gradient(circle, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
}
/* Hide scrollbar but keep functionality */
.hide-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}
/* Animated underline */
@keyframes expand {
from {
width: 0;
}
to {
width: 50%;
}
}
.animate-expand {
animation: expand 1s ease-out forwards;
}
/* Blob animation */
.animate-blob {
animation: blob 7s infinite;
}
.animation-delay-2000 {
animation-delay: 2s;
}
@keyframes blob {
0% {
transform: translate(0px, 0px) scale(1);
}
33% {
transform: translate(20px, -20px) scale(1.1);
}
66% {
transform: translate(-20px, 20px) scale(0.9);
}
100% {
transform: translate(0px, 0px) scale(1);
}
}
/* Hover card effect */
.hover-card {
transform: translateY(0);
transition:
transform 0.3s ease,
box-shadow 0.3s ease,
background-color 0.3s ease;
}
@media (hover: hover) {
.hover-card:hover {
transform: translateY(-2px);
}
}
/* Line clamp for descriptions */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Responsive adjustments */
@media (max-width: 640px) {
.animate-blob {
animation-duration: 10s;
}
}
</style>
<script>
// Handle SPA transitions for tag pages
function setupSPATransitions() {
// Handle all internal links for SPA transitions
document.querySelectorAll('a[href^="/"]').forEach((link) => {
// Skip links that are anchor links, external links, or already processed
if (
link.getAttribute('href').includes('#') ||
link.getAttribute('target') === '_blank' ||
link.hasAttribute('data-spa-handled')
) {
return;
}
// Mark as handled to avoid duplicate listeners
link.setAttribute('data-spa-handled', 'true');
link.addEventListener('click', (e) => {
// Don't handle if modifier keys are pressed (for opening in new tab, etc.)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
const targetHref = link.getAttribute('href');
// Trigger page transition animation
const pageTransition = document.getElementById('page-transition');
if (pageTransition) {
pageTransition.classList.remove('opacity-0');
pageTransition.classList.add('opacity-100');
// Navigate after transition effect
setTimeout(() => {
window.location.href = targetHref;
}, 300);
} else {
// Fallback if transition element doesn't exist
window.location.href = targetHref;
}
});
});
// Initialize animations for tag page
function animateTagContent() {
// Animate header elements
const headerElements = document.querySelectorAll('h1, .tag-icon, .tag-description');
headerElements.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
100 + index * 150
);
});
// Animate posts with staggered delay
const articles = document.querySelectorAll('article');
articles.forEach((article, index) => {
setTimeout(
() => {
article.classList.add('animate-reveal');
},
400 + index * 100
);
});
// Animate related tags
const relatedTags = document.querySelectorAll('.related-tags a');
relatedTags.forEach((tag, index) => {
setTimeout(
() => {
tag.classList.add('animate-reveal');
},
600 + index * 50
);
});
}
// Run animations
animateTagContent();
}
// Initialize on first load
document.addEventListener('DOMContentLoaded', setupSPATransitions);
// Re-initialize when content changes via Astro's view transitions
document.addEventListener('astro:page-load', setupSPATransitions);
// For compatibility with custom transition system
document.addEventListener('page-transition-complete', setupSPATransitions);
</script>
<!-- Add this at the end of your page -->

View File

@@ -0,0 +1,714 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import directus from '../../../lib/directus';
import { readItems } from '@directus/sdk';
const posts = await directus.request(
readItems('posts', {
fields: ['*'],
sort: ['-published_date'],
})
);
const tags = [...new Set(posts.flatMap((post) => post.tags || []))].sort();
// Count posts for each tag and create tag objects with additional data
const tagObjects = tags.map((tag) => {
const count = posts.filter((post) => post.tags?.includes(tag)).length;
// Generate a consistent but random-looking hue for each tag
const hue = Math.abs(tag.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) % 360);
return {
name: tag,
count,
size: Math.max(1, Math.min(3, Math.floor(count / 2) + 1)), // Size 1-3 based on count
hue,
};
});
const sortedTags = [...tagObjects].sort((a, b) => b.count - a.count);
---
<BaseLayout title="Explore Tags">
<div class="theme-transition-all mx-auto w-full px-3 py-6 sm:px-6 sm:py-12 md:py-16">
<!-- Enhanced header section with animated elements - improved for mobile -->
<div class="theme-transition-element relative mb-8 text-center sm:mb-12 md:mb-16">
<div
class="animate-blob theme-transition-bg absolute -top-16 -left-16 h-36 w-36 rounded-full bg-zinc-100 opacity-50 blur-3xl sm:h-48 sm:w-48 md:h-72 md:w-72 dark:bg-zinc-800/50"
>
</div>
<div
class="animate-blob animation-delay-2000 theme-transition-bg absolute -right-16 -bottom-16 h-36 w-36 rounded-full bg-zinc-200 opacity-30 blur-3xl sm:h-48 sm:w-48 md:h-72 md:w-72 dark:bg-zinc-800/30"
>
</div>
<div
class="animate-blob animation-delay-4000 theme-transition-bg absolute top-8 right-8 h-24 w-24 rounded-full bg-zinc-100/30 opacity-40 blur-2xl sm:h-32 sm:w-32 md:h-40 md:w-40 dark:bg-zinc-700/20"
>
</div>
<h1
class="theme-transition-color relative mb-3 text-3xl font-bold tracking-tight text-zinc-900 sm:mb-4 sm:text-4xl md:mb-6 md:text-5xl lg:text-6xl dark:text-zinc-100"
>
<span class="relative inline-block">
<span class="relative inline-block">
<span
class="theme-transition-bg absolute -inset-1 rounded-lg bg-gradient-to-r from-zinc-200/50 to-zinc-300/50 blur-xs dark:from-zinc-800/50 dark:to-zinc-700/50"
></span>
<span class="relative">Explore</span>
</span>
{' '}
<span class="relative inline-block">
Topics
<span
class="animate-underline theme-transition-bg absolute -bottom-1 left-0 h-0.5 w-full origin-left transform bg-gradient-to-r from-zinc-400 to-zinc-600 sm:-bottom-2 sm:h-1 dark:from-zinc-600 dark:to-zinc-400"
></span>
</span>
</span>
</h1>
<p
class="theme-transition-color relative mx-auto max-w-2xl text-sm text-zinc-600 sm:text-base md:text-lg lg:text-xl dark:text-zinc-400"
>
Discover content organized by your interests
</p>
</div>
{
tags.length === 0 ? (
<div class="theme-transition-element py-8 text-center sm:py-12 md:py-16">
<div class="theme-transition-bg mb-3 inline-flex h-16 w-16 items-center justify-center rounded-full bg-zinc-100 shadow-inner sm:mb-4 sm:h-20 sm:w-20 md:mb-6 md:h-24 md:w-24 dark:bg-zinc-800">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="theme-transition-color h-8 w-8 text-zinc-500 sm:h-10 sm:w-10 md:h-12 md:w-12 dark:text-zinc-400"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
</div>
<p class="theme-transition-color text-lg font-medium text-zinc-800 sm:text-xl md:text-2xl dark:text-zinc-200">
No tags found yet.
</p>
<p class="theme-transition-color mt-2 text-xs text-zinc-500 sm:text-sm md:text-base dark:text-zinc-500">
Check back later for categorized content.
</p>
</div>
) : (
<div class="flex w-full justify-center">
<div class="tag-cloud hover-3d glass theme-transition-all relative w-full rounded-lg border border-zinc-100 bg-white/50 p-3 backdrop-blur-xs sm:rounded-xl sm:p-4 md:rounded-2xl md:p-6 lg:rounded-3xl lg:p-8 dark:border-zinc-800 dark:bg-zinc-900/50">
<div class="bg-grid-pattern theme-transition-bg absolute inset-0 opacity-5 dark:opacity-10" />
<div class="theme-transition-bg absolute -top-8 -right-8 h-20 w-20 rounded-full bg-gradient-to-br from-zinc-200/30 to-zinc-300/20 blur-xl sm:h-24 sm:w-24 md:h-32 md:w-32 lg:h-40 lg:w-40 dark:from-zinc-700/20 dark:to-zinc-800/10" />
<div class="theme-transition-bg absolute -bottom-8 -left-8 h-20 w-20 rounded-full bg-gradient-to-tl from-zinc-200/30 to-zinc-300/20 blur-xl sm:h-24 sm:w-24 md:h-32 md:w-32 lg:h-40 lg:w-40 dark:from-zinc-700/20 dark:to-zinc-800/10" />
<h2 class="theme-transition-color mb-3 text-center text-lg font-bold text-zinc-900 sm:mb-4 sm:text-xl md:mb-6 md:text-2xl lg:mb-8 lg:text-3xl dark:text-zinc-100">
Popular Topics
</h2>
<div class="xxxs:grid-cols-2 xxs:grid-cols-2 xs:grid-cols-3 xxxs:gap-2 xxs:gap-2 xs:gap-2 grid w-full grid-cols-2 gap-1.5 sm:grid-cols-3 sm:gap-3 md:grid-cols-4 md:gap-4 lg:grid-cols-5">
{sortedTags.map((tag) => (
<a
href={`/topics/${tag.name}`}
class="theme-transition-element theme-ripple group relative min-w-0 grow overflow-hidden rounded-md border border-zinc-200 transition-all duration-300 hover:scale-[1.03] hover:border-zinc-300 hover:shadow-md active:scale-95 sm:rounded-lg sm:hover:shadow-lg md:rounded-xl dark:border-zinc-800 dark:hover:border-zinc-700"
style={`--tag-hue: ${tag.hue};`}
>
<div class="theme-transition-bg absolute inset-0 bg-gradient-to-br from-zinc-50/90 to-zinc-100/90 opacity-100 transition-opacity group-hover:opacity-95 dark:from-zinc-800/90 dark:to-zinc-900/90" />
<div class="xxxs:px-2 xxs:px-2 xs:px-2 xxxs:py-2 xxs:py-2 xs:py-2 xxs:gap-2 relative flex w-full items-center gap-1.5 px-1.5 py-1.5 sm:px-3 sm:py-3 md:px-4 md:py-4">
<div class="xxxs:w-6 xxxs:h-6 xxs:w-6 xxs:h-6 xs:w-7 xs:h-7 group-hover:bg-accent/20 dark:group-hover:bg-accent/20 group-hover:text-accent-dark dark:group-hover:text-accent-light theme-transition-all flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-zinc-700 shadow-xs transition-all duration-300 sm:h-8 sm:w-8 md:h-10 md:w-10 dark:bg-zinc-800 dark:text-zinc-300">
<span class="xxxs:text-xs xxs:text-xs xs:text-sm text-xs font-semibold sm:text-base md:text-lg">
#
</span>
</div>
<div class="min-w-0 flex-1 overflow-hidden">
<h3 class="xxxs:text-xs xxs:text-xs xs:text-xs theme-transition-color truncate text-[10px] font-bold break-words hyphens-auto text-zinc-900 transition-colors group-hover:text-zinc-700 sm:text-sm md:text-base dark:text-zinc-100 dark:group-hover:text-zinc-300">
{tag.name}
</h3>
<p class="xxxs:text-[9px] xxs:text-[9px] xs:text-[10px] theme-transition-color truncate text-[8px] text-zinc-500 sm:text-xs md:text-xs dark:text-zinc-400">
{tag.count} article{tag.count !== 1 ? 's' : ''}
</p>
</div>
</div>
</a>
))}
</div>
</div>
</div>
)
}
</div>
</BaseLayout>
<script>
// Ultra-reliable responsiveness handling
document.addEventListener('DOMContentLoaded', () => {
// Fix viewport width issues on mobile
const fixViewportWidth = () => {
// Force the viewport to be exactly the width of the device
const viewport = document.querySelector('meta[name="viewport"]');
if (!viewport) {
const meta = document.createElement('meta');
meta.name = 'viewport';
meta.content = 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no';
document.getElementsByTagName('head')[0].appendChild(meta);
} else {
viewport.setAttribute(
'content',
'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no'
);
}
// Fix for horizontal overflow
document.body.style.overflowX = 'hidden';
document.documentElement.style.overflowX = 'hidden';
document.documentElement.style.width = '100%';
document.body.style.width = '100%';
};
fixViewportWidth();
// Adjust tag items based on screen size with extreme precision
const adjustTagItems = () => {
const tagItems = document.querySelectorAll('.theme-ripple');
const width =
window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
const isVerySmall = width < 360;
const isExtremelySmall = width < 280;
const isMicroScreen = width < 240;
// Fix container width to match viewport exactly
const container = document.querySelector('.tag-cloud');
if (container) {
container.style.maxWidth = '100vw';
container.style.width = '100%';
container.style.boxSizing = 'border-box';
// Remove any margins that might cause overflow
container.style.marginLeft = '0';
container.style.marginRight = '0';
}
// Fix grid width
const grid = document.querySelector('.grid');
if (grid) {
grid.style.width = '100%';
grid.style.maxWidth = '100%';
}
tagItems.forEach((item) => {
// Set appropriate classes based on screen size
if (isMicroScreen) {
item.classList.add('micro-screen');
item.classList.remove('extremely-small-screen', 'very-small-screen');
} else if (isExtremelySmall) {
item.classList.add('extremely-small-screen');
item.classList.remove('very-small-screen', 'micro-screen');
} else if (isVerySmall) {
item.classList.add('very-small-screen');
item.classList.remove('extremely-small-screen', 'micro-screen');
} else {
item.classList.remove('very-small-screen', 'extremely-small-screen', 'micro-screen');
}
// Ensure text doesn't overflow on small screens
const tagName = item.querySelector('h3');
const tagCount = item.querySelector('p');
if (tagName) {
// Set title for accessibility
tagName.title = tagName.textContent.trim();
// Adjust text length based on screen size
if (isMicroScreen && tagName.textContent.length > 6) {
tagName.dataset.fullText = tagName.textContent;
tagName.textContent = tagName.textContent.substring(0, 6) + '...';
} else if (isExtremelySmall && tagName.textContent.length > 8) {
tagName.dataset.fullText = tagName.textContent;
tagName.textContent = tagName.textContent.substring(0, 8) + '...';
} else if (isVerySmall && tagName.textContent.length > 12) {
tagName.dataset.fullText = tagName.textContent;
tagName.textContent = tagName.textContent.substring(0, 12) + '...';
} else if (tagName.dataset.fullText) {
tagName.textContent = tagName.dataset.fullText;
delete tagName.dataset.fullText;
}
}
// Set the tag hue for hover effects
const hue = item.style.getPropertyValue('--tag-hue');
item.style.setProperty('--hover-hue', hue);
});
};
// Run on load
adjustTagItems();
// Run on resize with optimized debounce
let resizeTimer;
const handleResize = () => {
if (resizeTimer) {
window.cancelAnimationFrame(resizeTimer);
}
resizeTimer = window.requestAnimationFrame(() => {
fixViewportWidth();
adjustTagItems();
});
};
window.addEventListener('resize', handleResize);
window.addEventListener('orientationchange', handleResize);
// Ensure layout is recalculated after page is fully loaded
window.addEventListener('load', () => {
fixViewportWidth();
adjustTagItems();
// Force recalculation after images and fonts are loaded
setTimeout(() => {
fixViewportWidth();
adjustTagItems();
}, 500);
});
// Fix for iOS Safari and other mobile browsers
if (/iPhone|iPad|iPod|Android/.test(navigator.userAgent)) {
document.documentElement.style.setProperty(
'--safe-area-inset-bottom',
'env(safe-area-inset-bottom)'
);
// Fix for mobile viewport height issues
const setVh = () => {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
};
setVh();
window.addEventListener('resize', setVh);
window.addEventListener('orientationchange', () => {
// Wait for orientation change to complete
setTimeout(() => {
setVh();
fixViewportWidth();
}, 100);
});
}
// Add touch support for mobile devices
const addTouchSupport = () => {
const tagItems = document.querySelectorAll('.theme-ripple');
tagItems.forEach((item) => {
item.addEventListener(
'touchstart',
() => {
item.classList.add('touch-active');
},
{ passive: true }
);
item.addEventListener(
'touchend',
() => {
setTimeout(() => {
item.classList.remove('touch-active');
}, 150);
},
{ passive: true }
);
// Cancel active state if touch moves away
item.addEventListener(
'touchmove',
(e) => {
const touch = e.touches[0];
const rect = item.getBoundingClientRect();
if (
touch.clientX < rect.left ||
touch.clientX > rect.right ||
touch.clientY < rect.top ||
touch.clientY > rect.bottom
) {
item.classList.remove('touch-active');
}
},
{ passive: true }
);
});
};
addTouchSupport();
});
</script>
<style>
/* Base styles */
.tag-cloud {
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.03),
0 2px 4px rgba(0, 0, 0, 0.03),
0 4px 8px rgba(0, 0, 0, 0.05);
transform-style: preserve-3d;
perspective: 1000px;
transition: all var(--theme-transition);
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box;
margin-left: 0 !important;
margin-right: 0 !important;
}
/* Fix for horizontal overflow */
:global(html),
:global(body) {
overflow-x: hidden;
width: 100%;
max-width: 100%;
}
:global(.max-w-6xl) {
max-width: 100% !important;
width: 100% !important;
}
/* Ultra-responsive breakpoints for extreme reliability */
/* Micro screens (below 240px) */
@media (max-width: 239px) {
.tag-cloud {
padding: 0.5rem !important;
margin: 0 !important;
border-radius: 0.25rem !important;
width: 100% !important;
}
.tag-cloud h2 {
font-size: 0.875rem !important;
margin-bottom: 0.375rem !important;
}
.grid {
grid-template-columns: repeat(1, minmax(0, 1fr)) !important;
gap: 0.375rem !important;
width: 100% !important;
}
.micro-screen .flex {
padding: 0.25rem !important;
}
.micro-screen h3 {
font-size: 0.625rem !important;
}
.micro-screen p {
font-size: 0.5rem !important;
}
}
/* Extra extra extra small screens (240px-279px) */
@media (min-width: 240px) and (max-width: 279px) {
.xxxs\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.xxxs\:px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.xxxs\:py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.xxxs\:w-6 {
width: 1.5rem;
}
.xxxs\:h-6 {
height: 1.5rem;
}
.xxxs\:text-xs {
font-size: 0.75rem;
}
.xxxs\:gap-2 {
gap: 0.5rem;
}
.xxxs\:text-\[9px\] {
font-size: 9px;
}
}
/* Extra extra small screens (280px-359px) */
@media (min-width: 280px) {
.xxs\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.xxs\:px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.xxs\:py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.xxs\:w-6 {
width: 1.5rem;
}
.xxs\:h-6 {
height: 1.5rem;
}
.xxs\:text-xs {
font-size: 0.75rem;
}
.xxs\:gap-2 {
gap: 0.5rem;
}
.xxs\:text-\[9px\] {
font-size: 9px;
}
}
/* Extra small screens (360px-639px) */
@media (min-width: 360px) {
.xs\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.xs\:px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
.xs\:py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.xs\:w-7 {
width: 1.75rem;
}
.xs\:h-7 {
height: 1.75rem;
}
.xs\:text-xs {
font-size: 0.75rem;
}
.xs\:text-sm {
font-size: 0.875rem;
}
.xs\:gap-2 {
gap: 0.5rem;
}
.xs\:text-\[10px\] {
font-size: 10px;
}
}
/* Ensure text doesn't overflow on small screens */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
/* Ensure proper word breaking for long tag names */
.break-words {
word-break: break-word;
overflow-wrap: break-word;
}
.hyphens-auto {
hyphens: auto;
}
/* Improved shadow for dark mode */
:global(.dark) .tag-cloud {
box-shadow:
0 0 0 1px rgba(255, 255, 255, 0.05),
0 2px 4px rgba(0, 0, 0, 0.1),
0 4px 8px rgba(0, 0, 0, 0.15);
}
/* Prevent layout shifts */
.grow {
grow: 1;
}
.min-w-0 {
min-width: 0;
}
/* Ensure container doesn't overflow */
.overflow-hidden {
overflow: hidden;
}
/* Touch support for mobile */
.touch-active {
transform: scale(0.97) !important;
opacity: 0.9;
transition:
transform 0.15s ease-in-out,
opacity 0.15s ease-in-out !important;
}
/* Animation for blob */
@keyframes blob {
0%,
100% {
transform: translate(0, 0) scale(1);
}
25% {
transform: translate(10px, -10px) scale(1.05);
}
50% {
transform: translate(0, 20px) scale(0.95);
}
75% {
transform: translate(-10px, -10px) scale(1.05);
}
}
.animate-blob {
animation: blob 20s infinite ease-in-out;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}
/* Animation for underline */
@keyframes underline {
0% {
transform: scaleX(0);
}
100% {
transform: scaleX(1);
}
}
.animate-underline {
animation: underline 1.5s ease-out forwards;
}
/* Fix for iOS Safari notch */
@supports (padding: max(0px)) {
.tag-cloud {
padding-left: max(0.75rem, env(safe-area-inset-left));
padding-right: max(0.75rem, env(safe-area-inset-right));
padding-bottom: max(0.75rem, env(safe-area-inset-bottom));
}
}
</style>
<script>
// Handle SPA transitions for tags index page
function setupSPATransitions() {
// Handle all internal links for SPA transitions
document.querySelectorAll('a[href^="/"]').forEach((link) => {
// Skip links that are anchor links, external links, or already processed
if (
link.getAttribute('href').includes('#') ||
link.getAttribute('target') === '_blank' ||
link.hasAttribute('data-spa-handled')
) {
return;
}
// Mark as handled to avoid duplicate listeners
link.setAttribute('data-spa-handled', 'true');
link.addEventListener('click', (e) => {
// Don't handle if modifier keys are pressed (for opening in new tab, etc.)
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
const targetHref = link.getAttribute('href');
// Trigger page transition animation
const pageTransition = document.getElementById('page-transition');
if (pageTransition) {
pageTransition.classList.remove('opacity-0');
pageTransition.classList.add('opacity-100');
// Navigate after transition effect
setTimeout(() => {
window.location.href = targetHref;
}, 300);
} else {
// Fallback if transition element doesn't exist
window.location.href = targetHref;
}
});
});
// Add hover effect for tag cards on touch devices
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
if (isTouchDevice) {
const tagCards = document.querySelectorAll('.tag-cloud a');
tagCards.forEach((card) => {
card.addEventListener('touchstart', () => {
card.classList.add('is-touched');
});
card.addEventListener('touchend', () => {
setTimeout(() => {
card.classList.remove('is-touched');
}, 300);
});
});
}
// Animate tag cards with staggered delay
const tagCards = document.querySelectorAll('.tag-cloud a');
tagCards.forEach((card, index) => {
setTimeout(
() => {
card.classList.add('animate-reveal');
},
100 + index * 50
);
});
}
// Initialize on first load
document.addEventListener('DOMContentLoaded', setupSPATransitions);
// Re-initialize when content changes via Astro's view transitions
document.addEventListener('astro:page-load', setupSPATransitions);
// For compatibility with custom transition system
document.addEventListener('page-transition-complete', setupSPATransitions);
</script>

View File

@@ -1,39 +1,18 @@
/* Remove all the complex mobile menu styles and keep only what's necessary */
@import 'tailwindcss'; @import 'tailwindcss';
@import 'preline/variants.css';
@plugin '@tailwindcss/typography';
@plugin '@tailwindcss/forms';
/* Dark mode support for Tailwind CSS v4 */ /* Dark mode support for Tailwind CSS v4 */
/* https://tailwindcss.com/docs/dark-mode */ /* https://tailwindcss.com/docs/dark-mode */
@custom-variant dark (&:where(.dark, .dark *)); @custom-variant dark (&:where(.dark, .dark *));
/* Add custom colors */
@theme {
--color-midnight: #0c354d;
--color-turquoise: #0da797;
--color-steel: #4682b4;
--color-bermuda: #7fbab4;
--color-desert: #f9deb2;
--color-bronze: #9e7f5e;
--color-gitea-primary: #609926;
--color-gitea-secondary: #4c7a33;
}
@layer base { @layer base {
:root { :root {
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;
} }
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
html { html {
scroll-behavior: smooth; scroll-behavior: smooth;
scroll-padding-top: 5rem; scroll-padding-top: 5rem;
@@ -41,17 +20,13 @@
} }
body { body {
@apply min-h-screen bg-white text-zinc-900 dark:bg-zinc-900 dark:text-zinc-100;
margin: 0; margin: 0;
padding: 0; padding: 0;
overflow-x: hidden; overflow-x: hidden;
--swup-fade-theme-duration: 0.2s;
}
button:not(:disabled),
[role='button']:not(:disabled) {
cursor: pointer;
} }
/* Simple theme transition */
body, body,
a, a,
button { button {
@@ -62,31 +37,120 @@
} }
} }
/* Content reveal animations */ /* Minimal responsive styles */
.smooth-reveal, @media (max-width: 640px) {
.smooth-reveal-2, html {
.smooth-reveal-cards { scroll-padding-top: 4rem;
}
/* Better touch targets on mobile */
button,
a {
@apply min-h-[44px];
}
}
/* Add smooth animations */
@keyframes fadeIn {
from {
opacity: 0; opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(20px); transform: translateY(20px);
transition:
opacity 0.8s ease,
transform 0.8s ease;
}
.animate-reveal {
opacity: 1 !important;
transform: translateY(0) !important;
}
.smooth-reveal-fade {
opacity: 0; opacity: 0;
transform: translateY(0px); }
transition: to {
opacity 1.8s ease, transform: translateY(0);
transform 0.8s ease; opacity: 1;
}
} }
.animate-reveal-fade { @keyframes slideDown {
opacity: 1 !important; from {
transform: translateY(0) !important; transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes scaleIn {
from {
transform: scale(0.95);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
/* Apply animations to elements */
.animate-fade-in {
animation: fadeIn 0.6s ease forwards;
}
.animate-slide-up {
animation: slideUp 0.6s ease forwards;
}
.animate-slide-down {
animation: slideDown 0.6s ease forwards;
}
.animate-scale-in {
animation: scaleIn 0.6s ease forwards;
}
/* Staggered animation delays */
.delay-100 {
animation-delay: 0.1s;
}
.delay-200 {
animation-delay: 0.2s;
}
.delay-300 {
animation-delay: 0.3s;
}
.delay-400 {
animation-delay: 0.4s;
}
/* Smooth hover transitions */
a,
button {
transition: all 0.5s ease;
}
a.hover:hover,
button:hover {
transform: translateY(-2px);
}
/* Smooth page transitions */
.page-transition {
transition:
opacity 0.3s ease,
transform 0.3s ease;
}
.page-entering {
opacity: 0;
transform: translateY(10px);
}
.page-entered {
opacity: 1;
transform: translateY(0);
} }

View File

@@ -1,50 +0,0 @@
// Add smooth reveal animations for content after loading
document.addEventListener('astro:page-load', () => {
const animateContent = () => {
// Animate group 1
const smoothReveal = document.querySelectorAll('.smooth-reveal');
smoothReveal.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
50 + index * 100
);
});
// Animate group 2
const smoothReveal2 = document.querySelectorAll('.smooth-reveal-2');
smoothReveal2.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
200 + index * 250
);
});
// Animate topic cards with staggered delay
const smoothRevealCards = document.querySelectorAll('.smooth-reveal-cards');
smoothRevealCards.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal');
},
400 + index * 250
);
});
// Animate with just fade in with staggered delay
const smoothRevealFade = document.querySelectorAll('.smooth-reveal-fade');
smoothRevealFade.forEach((el, index) => {
setTimeout(
() => {
el.classList.add('animate-reveal-fade');
},
100 + index * 250
);
});
};
animateContent();
});

View File

@@ -1,66 +0,0 @@
import fs from 'node:fs/promises';
export interface BlurImageMetadata {
/**
* The width of the origin image
*/
width: number;
/**
* The height of the origin image
*/
height: number;
/**
* blurDataURL of the image
*/
blurDataURL: string;
/**
* blur image width
*/
blurWidth: number;
/**
* blur image height
*/
blurHeight: number;
}
export async function blurStyle(filePath: string) {
const image = await blurImageMetadata(filePath);
const svg = blurImageSVG(image);
return {
backgroundSize: 'cover',
backgroundPosition: '50% 50%',
backgroundRepeat: 'no-repeat',
backgroundImage: `url("data:image/svg+xml;charset=utf-8,${svg}")`,
};
}
function blurImageSVG(image: BlurImageMetadata): string {
const { blurDataURL, blurWidth, blurHeight, width, height } = image;
const std = 20;
const svgWidth = blurWidth ? blurWidth * 40 : width;
const svgHeight = blurHeight ? blurHeight * 40 : height;
const viewBox = svgWidth && svgHeight ? `viewBox='0 0 ${svgWidth} ${svgHeight}'` : '';
return `%3Csvg xmlns='http://www.w3.org/2000/svg' ${viewBox}%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='${std}'/%3E%3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E%3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E%3CfeComposite operator='out' in='s'/%3E%3CfeComposite in2='SourceGraphic'/%3E%3CfeGaussianBlur stdDeviation='${std}'/%3E%3C/filter%3E%3Cimage width='100%25' height='100%25' x='0' y='0' preserveAspectRatio='xMidYMid slice' style='filter: url(%23b);' href='${blurDataURL}'/%3E%3C/svg%3E`;
}
async function blurImageMetadata(filepath: string): Promise<BlurImageMetadata> {
const { default: sharp } = await import('sharp');
const buffer = await fs.readFile(filepath);
const img = sharp(buffer);
const { width, height } = await img.metadata();
if (width == null || height == null) {
throw new Error(`Invalid image path: ${filepath}`);
}
const aspectRatio = width / height;
const blurWidth = 8;
const blurHeight = Math.round(blurWidth / aspectRatio);
const blurImage = await img.resize(blurWidth, blurHeight).webp({ quality: 10 }).toBuffer();
const blurDataURL = `data:image/webp;base64,${blurImage.toString('base64')}`;
return { blurDataURL, blurHeight, blurWidth, width, height };
}

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