upgrade to different layout
Some checks failed
renovate / renovate (push) Successful in 5m22s
release-image-gitea / release (push) Failing after 7m9s
release-image-harbor / release (push) Failing after 7m9s

This commit is contained in:
2025-06-08 16:02:39 -05:00
parent e1632629a9
commit 3e89e6cb1c
75 changed files with 8314 additions and 2884 deletions

View File

@@ -0,0 +1,93 @@
---
// Background.astro - Dot pattern and ambient glow background with smooth theme transitions
---
<div class="fixed inset-0 -z-10 overflow-hidden theme-transition-all">
<!-- Dot pattern background -->
<div class="absolute inset-0 bg-grid-pattern bg-[center_top_-1px] [mask-image:radial-gradient(white,transparent_85%)] theme-transition-bg"></div>
<!-- Ambient glow effects -->
<div class="absolute left-1/4 top-1/4 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-zinc-400/20 dark:bg-zinc-500/20 rounded-full blur-3xl opacity-50 animate-glow theme-transition-bg"></div>
<div class="absolute right-1/4 bottom-1/3 translate-x-1/2 translate-y-1/2 w-64 h-64 bg-zinc-300/20 dark:bg-zinc-600/20 rounded-full blur-3xl opacity-40 animate-glow animation-delay-1000 theme-transition-bg"></div>
<!-- Theme transition overlay -->
<div id="theme-transition-overlay" class="absolute inset-0 bg-white dark:bg-zinc-900 opacity-0 pointer-events-none"></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,55 +0,0 @@
---
interface Props {
href: string;
}
const { href } = Astro.props;
---
<a href={href}><slot /></a>
<style>
a {
position: relative;
display: flex;
place-content: center;
text-align: center;
padding: 0.56em 2em;
gap: 0.8em;
color: var(--accent-text-over);
text-decoration: none;
line-height: 1.1;
border-radius: 999rem;
overflow: hidden;
background: var(--gradient-accent-orange);
box-shadow: var(--shadow-md);
white-space: nowrap;
}
@media (min-width: 20em) {
a {
font-size: var(--text-lg);
}
}
a::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
transition: background-color var(--theme-transition);
mix-blend-mode: overlay;
}
a:focus::after,
a:hover::after {
background-color: hsla(var(--gray-999-basis), 0.3);
}
@media (min-width: 50em) {
a {
padding: 1.125rem 2.5rem;
font-size: var(--text-xl);
}
}
</style>

View File

@@ -1,51 +0,0 @@
---
import CallToAction from './CallToAction.astro';
import Icon from './Icon.astro';
import directus from "../../lib/directus"
import { readSingleton } from "@directus/sdk";
const global = await directus.request(readSingleton("global"));
---
<aside>
<h2>Interested in working together?</h2>
<CallToAction href=`mailto:${global.email}`>
Send Me a Message
<Icon icon="paper-plane-tilt" size="1.2em" />
</CallToAction>
</aside>
<style>
aside {
display: flex;
flex-direction: column;
align-items: center;
gap: 3rem;
border-top: 1px solid var(--gray-800);
border-bottom: 1px solid var(--gray-800);
padding: 5rem 1.5rem;
background-color: var(--gray-999_40);
box-shadow: var(--shadow-sm);
}
h2 {
font-size: var(--text-xl);
text-align: center;
max-width: 15ch;
}
@media (min-width: 50em) {
aside {
padding: 7.5rem;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
}
h2 {
font-size: var(--text-3xl);
text-align: left;
}
}
</style>

View File

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

View File

@@ -0,0 +1,19 @@
---
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,62 +0,0 @@
---
interface Props {
variant?: 'offset' | 'small';
}
const { variant } = Astro.props;
---
<ul class:list={['grid', { offset: variant === 'offset', small: variant === 'small' }]}>
<slot />
</ul>
<style>
.grid {
display: grid;
grid-auto-rows: 1fr;
gap: 1rem;
list-style: none;
padding: 0;
}
.grid.small {
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
.grid.small > :global(:last-child:nth-child(odd)) {
grid-column: 1 / 3;
}
@media (min-width: 50em) {
.grid {
grid-template-columns: 1fr 1fr;
gap: 4rem;
}
.grid.offset {
--row-offset: 7.5rem;
padding-bottom: var(--row-offset);
}
.grid.offset > :global(:nth-child(odd)) {
transform: translateY(var(--row-offset));
}
.grid.offset > :global(:last-child:nth-child(odd)) {
grid-column: 2 / 3;
transform: none;
}
.grid.small {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem;
}
.grid.small > :global(*) {
flex-basis: 20rem;
}
}
</style>

View File

@@ -1,54 +0,0 @@
---
interface Props {
title: string;
tagline?: string;
align?: 'start' | 'center';
}
const { align = 'center', tagline, title } = Astro.props;
---
<div class:list={['hero stack gap-4', align]}>
<div class="stack gap-2">
<h1 class="title">{title}</h1>
{tagline && <p class="tagline">{tagline}</p>}
</div>
<slot />
</div>
<style>
.hero {
font-size: var(--text-lg);
text-align: center;
}
.title,
.tagline {
max-width: 37ch;
margin-inline: auto;
}
.title {
font-size: var(--text-3xl);
color: var(--gray-0);
}
@media (min-width: 50em) {
.hero {
font-size: var(--text-xl);
}
.start {
text-align: start;
}
.start .title,
.start .tagline {
margin-inline: unset;
}
.title {
font-size: var(--text-5xl);
}
}
</style>

View File

@@ -1,56 +0,0 @@
---
import type { HTMLAttributes } from 'astro/types';
import { iconPaths } from './IconPaths';
interface Props {
icon: keyof typeof iconPaths;
color?: string;
gradient?: boolean;
size?: string;
}
const { color = 'currentcolor', gradient, icon, size } = Astro.props;
const iconPath = iconPaths[icon];
const attrs: HTMLAttributes<'svg'> = {};
if (size) attrs.style = { '--size': size };
const gradientId = 'icon-gradient-' + Math.round(Math.random() * 10e12).toString(36);
---
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 256 256"
aria-hidden="true"
stroke={gradient ? `url(#${gradientId})` : color}
fill={gradient ? `url(#${gradientId})` : color}
{...attrs}
>
<g set:html={iconPath} />
{
gradient && (
<linearGradient
id={gradientId}
x1="23"
x2="235"
y1="43"
y2="202"
gradientUnits="userSpaceOnUse"
>
<stop stop-color="var(--gradient-stop-1)" />
<stop offset=".5" stop-color="var(--gradient-stop-2)" />
<stop offset="1" stop-color="var(--gradient-stop-3)" />
</linearGradient>
)
}
</svg>
<style>
svg {
vertical-align: middle;
width: var(--size, 1em);
height: var(--size, 1em);
}
</style>

View File

@@ -1,33 +0,0 @@
/**
* Icons adapted from https://phosphoricons.com/
*
* Want to add more?
* 1. Find the icon you want on Phosphor Icons.
* 2. Click “Copy SVG”.
* 3. Paste the SVG code in your editor.
* 4. Remove the `<svg>` wrapper so you only have elements like `<path>`, `<circle>`, `<rect>` etc.
* 5. Remove any `stroke="#000000"` attributes
* 6. Replace any `fill="#000000"` attributes with `stroke="none"`
* (or add `stroke="none"` on shapes with no `fill` or `stroke` specified).
*/
export const iconPaths = {
'terminal-window': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m80 96 40 32-40 32m56 0h40"/><rect width="192" height="160" x="32" y="48" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16.97" rx="8.5"/>`,
trophy: `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M56 56v55.1c0 39.7 31.8 72.6 71.5 72.9a72 72 0 0 0 72.5-72V56a8 8 0 0 0-8-8H64a8 8 0 0 0-8 8Zm40 168h64m-32-40v40"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M198.2 128h9.8a32 32 0 0 0 32-32V80a8 8 0 0 0-8-8h-32M58 128H47.9a32 32 0 0 1-32-32V80a8 8 0 0 1 8-8h32"/>`,
strategy: `<circle cx="68" cy="188" r="28" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m40 72 40 40m0-40-40 40m136 56 40 40m0-40-40 40M136 80V40h40"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m136 40 16 16c40 40 8 88-24 96"/>`,
'paper-plane-tilt': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M210.3 35.9 23.9 88.4a8 8 0 0 0-1.2 15l85.6 40.5a7.8 7.8 0 0 1 3.8 3.8l40.5 85.6a8 8 0 0 0 15-1.2l52.5-186.4a7.9 7.9 0 0 0-9.8-9.8Zm-99.4 109.2 45.2-45.2"/>`,
'arrow-right': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M40 128h176m-72-72 72 72-72 72"/>`,
'arrow-left': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M216 128H40m72-72-72 72 72 72"/>`,
code: `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m64 88-48 40 48 40m128-80 48 40-48 40M160 40 96 216"/>`,
'hard-drives': `<path d="M208,136H48a16,16,0,0,0-16,16v48a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V152A16,16,0,0,0,208,136Zm0,64H48V152H208v48Zm0-160H48A16,16,0,0,0,32,56v48a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V56A16,16,0,0,0,208,40Zm0,64H48V56H208v48ZM192,80a12,12,0,1,1-12-12A12,12,0,0,1,192,80Zm0,96a12,12,0,1,1-12-12A12,12,0,0,1,192,176Z"/>`,
'cloud': `<path d="M160,40A88.09,88.09,0,0,0,81.29,88.67,64,64,0,1,0,72,216h88a88,88,0,0,0,0-176Zm0,160H72a48,48,0,0,1,0-96c1.1,0,2.2,0,3.29.11A88,88,0,0,0,72,128a8,8,0,0,0,16,0,72,72,0,1,1,72,72Z"/>`,
'network': '<path d="M232,112H136V88h8a16,16,0,0,0,16-16V40a16,16,0,0,0-16-16H112A16,16,0,0,0,96,40V72a16,16,0,0,0,16,16h8v24H24a8,8,0,0,0,0,16H56v32H48a16,16,0,0,0-16,16v32a16,16,0,0,0,16,16H80a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H72V128H184v32h-8a16,16,0,0,0-16,16v32a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16h-8V128h32a8,8,0,0,0,0-16ZM112,40h32V72H112ZM80,208H48V176H80Zm128,0H176V176h32Z"/>',
'microphone-stage': `<circle cx="168" cy="88" r="64" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="m213.3 133.3-90.6-90.6M100 156l-12 12m16.8-70.1L28.1 202.5a7.9 7.9 0 0 0 .8 10.4l14.2 14.2a7.9 7.9 0 0 0 10.4.8l104.6-76.7"/>`,
'pencil-line': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M96 216H48a8 8 0 0 1-8-8v-44.7a7.9 7.9 0 0 1 2.3-5.6l120-120a8 8 0 0 1 11.4 0l44.6 44.6a8 8 0 0 1 0 11.4Zm40-152 56 56"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M216 216H96l-55.5-55.5M164 92l-96 96"/>`,
'rocket-launch': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M94.1 184.6c-11.4 33.9-56.6 33.9-56.6 33.9s0-45.2 33.9-56.6m124.5-56.5L128 173.3 82.7 128l67.9-67.9C176.3 34.4 202 34.7 213 36.3a7.8 7.8 0 0 1 6.7 6.7c1.6 11 1.9 36.7-23.8 62.4Z"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M184.6 116.7v64.6a8 8 0 0 1-2.4 5.6l-32.3 32.4a8 8 0 0 1-13.5-4.1l-8.4-41.9m11.3-101.9H74.7a8 8 0 0 0-5.6 2.4l-32.4 32.3a8 8 0 0 0 4.1 13.5l41.9 8.4"/>`,
list: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M40 128h176M40 64h176M40 192h176"/>`,
heart: `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M128 216S28 160 28 92a52 52 0 0 1 100-20h0a52 52 0 0 1 100 20c0 68-100 124-100 124Z"/>`,
'moon-stars': `<path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M216 112V64m24 24h-48m-24-64v32m16-16h-32m65 113A92 92 0 0 1 103 39h0a92 92 0 1 0 114 114Z"/>`,
sun: `<circle cx="128" cy="128" r="60" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M128 36V16M63 63 49 49m-13 79H16m47 65-14 14m79 13v20m65-47 14 14m13-79h20m-47-65 14-14"/>`,
'github-logo': `<g stroke-linecap="round" stroke-linejoin="round"><path fill="none" stroke-width="14.7" d="M55.7 167.2c13.9 1 21.3 13.1 22.2 14.6 4.2 7.2 10.4 9.6 18.3 7.1l1.1-3.4a60.3 60.3 0 0 1-25.8-11.9c-12-10.1-18-25.6-18-46.3"/><path fill="none" stroke-width="16" d="M61.4 205.1a24.5 24.5 0 0 1-3-6.1c-3.2-7.9-7.1-10.6-7.8-11.1l-1-.6c-2.4-1.6-9.5-6.5-7.2-13.9 1.4-4.5 6-7.2 12.3-7.2h.8c4 .3 7.6 1.5 10.7 3.2-9.1-10.1-13.6-24.3-13.6-42.3 0-11.3 3.5-21.7 10.1-30.4A46.7 46.7 0 0 1 65 67.3a8.3 8.3 0 0 1 5-4.7c2.8-.9 13.3-2.7 33.2 9.9a105 105 0 0 1 50.5 0c19.9-12.6 30.4-10.8 33.2-9.9 2.3.7 4.1 2.4 5 4.7 5 12.7 4 23.2 2.6 29.4 6.7 8.7 10 18.9 10 30.4 0 42.6-25.8 54.7-43.6 58.7 1.4 4.1 2.2 8.8 2.2 13.7l-.1 23.4v2.3"/><path fill="none" stroke-width="16" d="M160.9 185.7c1.4 4.1 2.2 8.8 2.2 13.7l-.1 23.4v2.3A98.6 98.6 0 1 0 61.4 205c-1.4-2.1-11.3-17.5-11.8-17.8-2.4-1.6-9.5-6.5-7.2-13.9 1.4-4.5 6-7.2 12.3-7.2h.8c4 .3 7.6 1.5 10.7 3.2-9.1-10.1-13.6-24.3-13.6-42.3 0-11.3 3.5-21.7 10.1-30.4A46.4 46.4 0 0 1 65 67.3a8.3 8.3 0 0 1 5-4.7c2.8-.9 13.3-2.7 33.2 9.9a105 105 0 0 1 50.5 0c19.9-12.6 30.4-10.8 33.2-9.9 2.3.7 4.1 2.4 5 4.7 5 12.7 4 23.2 2.6 29.4 6.7 8.7 10 18.9 10 30.4.1 42.6-25.8 54.7-43.6 58.6z"/><path fill="none" stroke-width="18.7" d="m170.1 203.3 17.3-12 17.2-18.7 9.5-26.6v-27.9l-9.5-27.5" /><path fill="none" stroke-width="22.7" d="m92.1 57.3 23.3-4.6 18.7-1.4 29.3 5.4m-110 32.6-8 16-4 21.4.6 20.3 3.4 13" /><path fill="none" stroke-width="13.3" d="M28.8 133a100 100 0 0 0 66.9 94.4v-8.7c-22.4 1.8-33-11.5-35.6-19.8-3.4-8.6-7.8-11.4-8.5-11.8"/></g>`,
'linkedin-logo': `<rect width="184" height="184" x="36" y="36" fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" rx="8"/><path fill="none" stroke-linecap="round" stroke-linejoin="round" stroke-width="16" d="M120 112v64m-32-64v64m32-36a28 28 0 0 1 56 0v36"/><circle stroke="none" cx="88" cy="80" r="12"/>`,
};

View File

@@ -1,51 +0,0 @@
---
import '../styles/global.css';
import directus from "../../lib/directus"
import { readSingleton } from "@directus/sdk";
interface Props {
title?: string | undefined;
description?: string | undefined;
}
const global = await directus.request(readSingleton("global"));
const {
title = `${global.name}`,
description = `A profile of ${global.name}`,
} = Astro.props;
---
<meta charset="UTF-8" />
<meta name="description" property="og:description" content={description} />
<meta name="viewport" content="width=device-width" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Public+Sans:ital,wght@0,400;0,700;1,400&family=Rubik:wght@500;600&display=swap"
rel="stylesheet"
/>
<script is:inline>
const getThemePreference = () => {
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
const isDark = getThemePreference() === 'dark';
document.documentElement.classList[isDark ? 'add' : 'remove']('theme-dark');
if (typeof localStorage !== 'undefined') {
const observer = new MutationObserver(() => {
const isDark = document.documentElement.classList.contains('theme-dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
}
</script>

View File

@@ -1,360 +0,0 @@
---
import Icon from './Icon.astro';
import ThemeToggle from './ThemeToggle.astro';
import type { iconPaths } from './IconPaths';
import directus from "../../lib/directus"
import { readSingleton } from "@directus/sdk";
const textLinks: { label: string; href: string }[] = [
{ label: 'Home', href: '/' },
{ label: 'Projects', href: '/projects/' },
{ label: 'About', href: '/about/' },
];
const iconLinks: { label: string; href: string; icon: keyof typeof iconPaths }[] = [
{ label: 'GitHub', href: 'https://github.com/alexlebens', icon: 'github-logo' },
{ label: 'LinkedIn', href: 'https://www.linkedin.com/in/alexanderlebens', icon: 'linkedin-logo' },
];
const global = await directus.request(readSingleton("global"));
---
<nav>
<div class="menu-header">
<a href="/" class="site-title">
<Icon icon="terminal-window" color="var(--accent-regular)" size="1.6em" gradient />
{global.name}
</a>
<menu-button>
<template>
<button class="menu-button" aria-expanded="false">
<span class="sr-only">Menu</span>
<Icon icon="list" />
</button>
</template>
</menu-button>
</div>
<noscript>
<ul class="nav-items">
{
textLinks.map(({ label, href }) => (
<li>
<a
aria-current={Astro.url.pathname === href}
class:list={[
'link',
{
active:
Astro.url.pathname === href ||
(href !== '/' && Astro.url.pathname.startsWith(href)),
},
]}
href={href}
>
{label}
</a>
</li>
))
}
</ul>
</noscript>
<noscript>
<div class="menu-footer">
<div class="socials">
{
iconLinks.map(({ href, icon, label }) => (
<a href={href} class="social">
<span class="sr-only">{label}</span>
<Icon icon={icon} />
</a>
))
}
</div>
</div>
</noscript>
<div id="menu-content" hidden>
<ul class="nav-items">
{
textLinks.map(({ label, href }) => (
<li>
<a
aria-current={Astro.url.pathname === href}
class:list={[
'link',
{
active:
Astro.url.pathname === href ||
(href !== '/' && Astro.url.pathname.startsWith(href)),
},
]}
href={href}
>
{label}
</a>
</li>
))
}
</ul>
<div class="menu-footer">
<div class="socials">
{
iconLinks.map(({ href, icon, label }) => (
<a href={href} class="social">
<span class="sr-only">{label}</span>
<Icon icon={icon} />
</a>
))
}
</div>
<div class="theme-toggle">
<ThemeToggle />
</div>
</div>
</div>
</nav>
<script>
class MenuButton extends HTMLElement {
constructor() {
super();
this.appendChild(this.querySelector('template')!.content.cloneNode(true));
const btn = this.querySelector('button')!;
const menu = document.getElementById('menu-content')!;
menu.hidden = true;
menu.classList.add('menu-content');
const setExpanded = (expand: boolean) => {
btn.setAttribute('aria-expanded', expand ? 'true' : 'false');
menu.hidden = !expand;
};
btn.addEventListener('click', () => setExpanded(menu.hidden));
const handleViewports = (e: MediaQueryList | MediaQueryListEvent) => {
setExpanded(e.matches);
btn.hidden = e.matches;
};
const mediaQueries = window.matchMedia('(min-width: 50em)');
handleViewports(mediaQueries);
mediaQueries.addEventListener('change', handleViewports);
}
}
customElements.define('menu-button', MenuButton);
</script>
<style>
nav {
z-index: 9999;
position: relative;
font-family: var(--font-brand);
font-weight: 500;
margin-bottom: 3.5rem;
}
.menu-header {
display: flex;
justify-content: space-between;
gap: 0.5rem;
padding: 1.5rem;
}
.site-title {
display: flex;
gap: 0.5rem;
align-items: center;
line-height: 1.1;
color: var(--gray-0);
text-decoration: none;
}
.menu-button {
position: relative;
display: flex;
border: 0;
border-radius: 999rem;
padding: 0.5rem;
font-size: 1.5rem;
color: var(--gray-300);
background: radial-gradient(var(--gray-900), var(--gray-800) 150%);
box-shadow: var(--shadow-md);
}
.menu-button[aria-expanded='true'] {
color: var(--gray-0);
background: linear-gradient(180deg, var(--gray-600), transparent),
radial-gradient(var(--gray-900), var(--gray-800) 150%);
}
.menu-button[hidden] {
display: none;
}
.menu-button::before {
position: absolute;
inset: -1px;
content: '';
background: var(--gradient-stroke);
border-radius: 999rem;
z-index: -1;
}
.menu-content {
position: absolute;
left: 0;
right: 0;
}
.nav-items {
margin: 0;
display: flex;
flex-direction: column;
gap: 1rem;
font-size: var(--text-md);
line-height: 1.2;
list-style: none;
padding: 2rem;
background-color: var(--gray-999);
border-bottom: 1px solid var(--gray-800);
}
.link {
display: inline-block;
color: var(--gray-300);
text-decoration: none;
}
.link.active {
color: var(--gray-0);
}
.menu-footer {
--icon-size: var(--text-xl);
--icon-padding: 0.5rem;
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 1.5rem 2rem 1.5rem 1.5rem;
background-color: var(--gray-999);
border-radius: 0 0 0.75rem 0.75rem;
box-shadow: var(--shadow-lg);
}
.socials {
display: flex;
flex-wrap: wrap;
gap: 0.625rem;
font-size: var(--icon-size);
}
.social {
display: flex;
padding: var(--icon-padding);
text-decoration: none;
color: var(--accent-dark);
transition: color var(--theme-transition);
}
.social:hover,
.social:focus {
color: var(--accent-text-over);
}
.theme-toggle {
display: flex;
align-items: center;
height: calc(var(--icon-size) + 2 * var(--icon-padding));
}
@media (min-width: 50em) {
nav {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 2.5rem 5rem;
gap: 1rem;
}
.menu-header {
padding: 0;
}
.site-title {
font-size: var(--text-lg);
}
.menu-content {
display: contents;
}
.nav-items {
position: relative;
flex-direction: row;
font-size: var(--text-sm);
border-radius: 999rem;
border: 0;
padding: 0.5rem 0.5625rem;
background: radial-gradient(var(--gray-900), var(--gray-800) 150%);
box-shadow: var(--shadow-md);
}
.nav-items::before {
position: absolute;
inset: -1px;
content: '';
background: var(--gradient-stroke);
border-radius: 999rem;
z-index: -1;
}
.link {
padding: 0.5rem 1rem;
border-radius: 999rem;
transition:
color var(--theme-transition),
background-color var(--theme-transition);
}
.link:hover,
.link:focus {
color: var(--gray-100);
background-color: var(--accent-subtle-overlay);
}
.link.active {
color: var(--accent-text-over);
background-color: var(--accent-regular);
}
.menu-footer {
--icon-padding: 0.375rem;
justify-self: flex-end;
align-items: center;
padding: 0;
background-color: transparent;
box-shadow: none;
}
.socials {
display: none;
}
}
@media (min-width: 60em) {
.socials {
display: flex;
justify-content: flex-end;
gap: 0;
}
}
@media (forced-colors: active) {
.link.active {
color: SelectedItem;
}
}
</style>

View File

@@ -0,0 +1,205 @@
---
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); // remove the first "/"
---
<header class="py-4 fixed top-0 left-0 right-0 z-40 bg-white dark:bg-zinc-900 border-b border-zinc-100 dark:border-zinc-800">
<div class="max-w-3xl mx-auto px-4 flex items-center justify-between">
<!-- Logo -->
<a href="/" class="font-bold text-xl text-zinc-900 dark:text-white">{global.initals}</a>
<!-- Desktop navigation -->
<nav class="hidden sm:flex items-center space-x-6">
{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="sm:hidden flex items-center" 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="w-6 h-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" />
</svg>
</button>
</div>
</header>
<!-- Mobile menu overlay -->
<div id="mobile-menu" class="fixed inset-0 z-50 bg-white dark:bg-zinc-900 flex flex-col opacity-0 pointer-events-none transition-all duration-300 ease-in-out">
<div class="flex justify-between items-center p-4 border-b border-zinc-100 dark:border-zinc-800">
<a href="/" class="font-bold text-xl text-zinc-900 dark:text-white">JD</a>
<button id="close-menu-button" class="text-zinc-900 dark:text-white p-2 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors" aria-label="Close menu">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<nav class="flex-1 flex 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={`text-lg font-medium mobile-nav-item opacity-0 translate-y-4 ${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="pt-4 mobile-nav-item opacity-0 translate-y-4" style="transition-delay: 0.25s;">
<ThemeToggle />
</div>
</nav>
</div>
<!-- Spacer to prevent content from hiding behind fixed header -->
<div class="h-16"></div>
<script>
// Mobile menu toggle with animations
document.addEventListener('DOMContentLoaded', () => {
const mobileMenuButton = document.getElementById('mobile-menu-button');
const closeMenuButton = document.getElementById('close-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
const navItems = document.querySelectorAll('.mobile-nav-item');
// Open menu with animations
mobileMenuButton?.addEventListener('click', () => {
if (!mobileMenu) return;
// Prevent body scrolling
document.body.style.overflow = 'hidden';
// Show menu with fade in
mobileMenu.classList.remove('pointer-events-none');
mobileMenu.classList.add('pointer-events-auto');
// Animate opacity
setTimeout(() => {
mobileMenu.style.opacity = '1';
// Animate each nav item with staggered delay
navItems.forEach(item => {
setTimeout(() => {
item.classList.remove('opacity-0', 'translate-y-4');
}, 150);
});
}, 50);
});
// Close menu with animations
const closeMenu = () => {
if (!mobileMenu) return;
// Fade out nav items first
navItems.forEach(item => {
item.classList.add('opacity-0', 'translate-y-4');
});
// Then fade out the menu
setTimeout(() => {
mobileMenu.style.opacity = '0';
// After animation completes, hide menu and restore scrolling
setTimeout(() => {
mobileMenu.classList.remove('pointer-events-auto');
mobileMenu.classList.add('pointer-events-none');
document.body.style.overflow = '';
}, 300);
}, 100);
};
// Close button event
closeMenuButton?.addEventListener('click', closeMenu);
// Close menu when clicking a link
const mobileLinks = mobileMenu?.querySelectorAll('a');
mobileLinks?.forEach(link => {
link.addEventListener('click', closeMenu);
});
// Close menu on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && mobileMenu?.classList.contains('pointer-events-auto')) {
closeMenu();
}
});
// Add smooth animation to header on scroll
const header = document.querySelector('header');
let lastScrollY = window.scrollY;
window.addEventListener('scroll', () => {
if (!header) return;
const currentScrollY = window.scrollY;
// Add shadow on scroll
if (currentScrollY > 10) {
header.classList.add('shadow-sm');
} else {
header.classList.remove('shadow-sm');
}
// Update last scroll position
lastScrollY = currentScrollY;
});
});
</script>
<style>
/* Smooth animations for mobile navigation */
.mobile-nav-item {
transition: opacity 0.5s ease, transform 0.5s ease, color 0.3s ease;
}
/* Header transition */
header {
transition: box-shadow 0.3s ease, transform 0.3s ease, background-color 0.3s ease;
}
/* Mobile menu button hover effect */
#mobile-menu-button {
transition: transform 0.2s ease;
}
#mobile-menu-button:hover {
transform: scale(1.05);
}
/* Mobile menu transition */
#mobile-menu {
transition: opacity 0.3s ease;
backdrop-filter: blur(4px);
}
</style>

View File

@@ -1,16 +0,0 @@
<div class="pill"><slot /></div>
<style>
.pill {
display: flex;
padding: 0.5rem 1rem;
gap: 0.5rem;
color: var(--accent-text-over);
border: 1px solid var(--accent-regular);
background-color: var(--accent-regular);
border-radius: 999rem;
font-size: var(--text-md);
line-height: 1.35;
white-space: nowrap;
}
</style>

View File

@@ -1,65 +0,0 @@
---
import type { Post } from '../../lib/directus';
import { directus_url } from '../../lib/directus';
interface Props {
posts: Post;
}
const post: Post = Astro.props.posts;
---
<a class="card" href={`/projects/${post.slug}`}>
<span class="title">{post.title}</span>
<img src={`${directus_url}/assets/${post.image}?width=500`} alt={post.image_alt || ''} loading="lazy" decoding="async" />
</a>
<style>
.card {
display: grid;
grid-template: auto 1fr / auto 1fr;
height: 11rem;
background: var(--gradient-subtle);
border: 1px solid var(--gray-800);
border-radius: 0.75rem;
overflow: hidden;
box-shadow: var(--shadow-sm);
text-decoration: none;
font-family: var(--font-brand);
font-size: var(--text-lg);
font-weight: 500;
transition: box-shadow var(--theme-transition);
}
.card:hover {
box-shadow: var(--shadow-md);
}
.title {
grid-area: 1 / 1 / 2 / 2;
z-index: 1;
margin: 0.5rem;
padding: 0.5rem 1rem;
background: var(--gray-999);
color: var(--gray-200);
border-radius: 0.375rem;
}
img {
grid-area: 1 / 1 / 3 / 3;
width: 100%;
height: 100%;
object-fit: cover;
}
@media (min-width: 50em) {
.card {
height: 22rem;
border-radius: 1.5rem;
}
.title {
border-radius: 0.9375rem;
}
}
</style>

View File

@@ -0,0 +1,124 @@
---
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="p-2 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-300"
aria-label="Share on Twitter"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4"><path d="M22 4s-.7 2.1-2 3.4c1.6 10-9.4 17.3-18 11.6 2.2.1 4.4-.6 6-2C3 15.5.5 9.6 3 5c2.2 2.6 5.6 4.1 9 4-.9-4.2 4-6.6 7-3.8 1.1 0 3-1.2 3-1.2z"></path></svg>
</a>
<a
href={`https://www.facebook.com/sharer/sharer.php?u=${encodedUrl}`}
target="_blank"
rel="noopener noreferrer"
class="p-2 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-300"
aria-label="Share on Facebook"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4"><path d="M18 2h-3a5 5 0 0 0-5 5v3H7v4h3v8h4v-8h3l1-4h-4V7a1 1 0 0 1 1-1h3z"></path></svg>
</a>
<a
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodedUrl}&title=${encodedTitle}`}
target="_blank"
rel="noopener noreferrer"
class="p-2 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-300"
aria-label="Share on LinkedIn"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-4 h-4"><path d="M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z"></path><rect x="2" y="9" width="4" height="12"></rect><circle cx="4" cy="4" r="2"></circle></svg>
</a>
<button
id="copy-link-button"
class="p-2 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 dark:hover:text-zinc-300 rounded-full hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-all duration-300 relative"
aria-label="Copy link"
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="w-4 h-4"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
<span id="copy-tooltip" class="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-zinc-800 dark:bg-zinc-700 text-white text-xs py-1 px-2 rounded opacity-0 transition-opacity duration-300 whitespace-nowrap">
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

@@ -1,67 +0,0 @@
---
import Icon from './Icon.astro';
import directus from "../../lib/directus"
import { readSingleton } from "@directus/sdk";
const skills = await directus.request(readSingleton("skills"));
---
<section class="box skills">
<div class="stack gap-2 lg:gap-4">
<Icon icon="cloud" color="var(--accent-regular)" size="2.5rem" gradient />
<h2 set:html={skills.skill_1}/>
<p set:html={skills.skill_1_description}/>
</div>
<div class="stack gap-2 lg:gap-4">
<Icon icon="network" color="var(--accent-regular)" size="2.5rem" gradient />
<h2 set:html={skills.skill_2}/>
<p set:html={skills.skill_2_description}/>
</div>
<div class="stack gap-2 lg:gap-4">
<Icon icon="strategy" color="var(--accent-regular)" size="2.5rem" gradient />
<h2 set:html={skills.skill_3}/>
<p set:html={skills.skill_3_description}/>
</div>
</section>
<style>
.box {
border: 1px solid var(--gray-800);
border-radius: 0.75rem;
padding: 1.5rem;
background-color: var(--gray-999_40);
box-shadow: var(--shadow-sm);
}
.skills {
display: flex;
flex-direction: column;
gap: 3rem;
}
.skills h2 {
font-size: var(--text-lg);
}
.skills p {
color: var(--gray-400);
}
@media (min-width: 50em) {
.box {
border-radius: 1.5rem;
padding: 2.5rem;
}
.skills {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 5rem;
}
.skills h2 {
font-size: var(--text-2xl);
}
}
</style>

View File

@@ -0,0 +1,21 @@
---
export interface Props {
tags: string[];
class?: string;
}
const { tags = [], class: className = '' } = Astro.props;
---
{tags.length > 0 && (
<div class={`flex flex-wrap gap-2 mt-3 ${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

@@ -1,92 +1,293 @@
---
import Icon from './Icon.astro';
---
<theme-toggle>
<button>
<span class="sr-only">Dark theme</span>
<span class="icon light"><Icon icon="sun" /></span>
<span class="icon dark"><Icon icon="moon-stars" /></span>
</button>
</theme-toggle>
<style>
button {
display: flex;
border: 0;
border-radius: 999rem;
padding: 0;
background-color: var(--gray-999);
box-shadow: inset 0 0 0 1px var(--accent-overlay);
cursor: pointer;
}
.icon {
z-index: 1;
position: relative;
display: flex;
padding: 0.5rem;
width: 2rem;
height: 2rem;
font-size: 1rem;
color: var(--accent-overlay);
}
.icon.light::before {
content: '';
z-index: -1;
position: absolute;
inset: 0;
background-color: var(--accent-regular);
border-radius: 999rem;
}
:global(.theme-dark) .icon.light::before {
transform: translateX(100%);
}
:global(.theme-dark) .icon.dark,
:global(html:not(.theme-dark)) .icon.light,
button[aria-pressed='false'] .icon.light {
color: var(--accent-text-over);
}
@media (prefers-reduced-motion: no-preference) {
.icon,
.icon.light::before {
transition:
transform var(--theme-transition),
color var(--theme-transition);
}
}
@media (forced-colors: active) {
.icon.light::before {
background-color: SelectedItem;
}
}
</style>
<button
id="theme-toggle"
data-theme-toggle
class="relative overflow-hidden rounded-full p-1.5 sm:p-2 transition-all duration-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-zinc-300 dark:focus:ring-zinc-700 group touch-manipulation"
aria-label="Toggle dark mode"
>
<div class="relative z-10 flex h-5 w-5 items-center justify-center">
<!-- Sun icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-light absolute h-5 w-5 rotate-0 scale-100 transition-all duration-500 dark:-rotate-90 dark:scale-0 text-zinc-800 dark:text-zinc-200"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="5"/>
<path d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/>
</svg>
<!-- Moon icon -->
<svg
xmlns="http://www.w3.org/2000/svg"
class="icon-dark absolute h-5 w-5 rotate-90 scale-0 transition-all duration-500 dark:rotate-0 dark:scale-100 text-zinc-800 dark:text-zinc-200"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
</svg>
</div>
<!-- Ripple effect -->
<span class="absolute inset-0 h-full w-full bg-zinc-200 dark:bg-zinc-700 opacity-0 transition-opacity duration-300 group-active:opacity-20"></span>
</button>
<script>
class ThemeToggle extends HTMLElement {
constructor() {
super();
const button = this.querySelector('button')!;
const setTheme = (dark: boolean) => {
document.documentElement.classList[dark ? 'add' : 'remove']('theme-dark');
button.setAttribute('aria-pressed', String(dark));
};
button.addEventListener('click', () => setTheme(!this.isDark()));
setTheme(this.isDark());
}
isDark() {
return document.documentElement.classList.contains('theme-dark');
}
}
customElements.define('theme-toggle', ThemeToggle);
// Use a function to handle theme toggle to ensure it can be called from anywhere
function setupThemeToggle() {
const themeToggles = document.querySelectorAll('[data-theme-toggle]');
// Check for dark mode preference at the system level
const prefersDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
// Check for saved theme preference or use the system preference
const currentTheme = localStorage.getItem('theme') || (prefersDarkMode ? 'dark' : 'light');
// Apply the theme on initial load
if (currentTheme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// Create theme switch overlay element if it doesn't exist
if (!document.querySelector('.theme-switch-overlay')) {
const overlay = document.createElement('div');
overlay.className = 'theme-switch-overlay fixed inset-0 pointer-events-none z-50';
overlay.style.opacity = '0';
overlay.style.transition = 'opacity 0.3s ease-out';
document.body.appendChild(overlay);
}
// Toggle theme when any theme toggle button is clicked
themeToggles.forEach(toggle => {
// Add event listeners for both click and touch events
['click', 'touchend'].forEach(eventType => {
toggle.addEventListener(eventType, (e) => {
e.preventDefault();
e.stopPropagation();
// Get click/touch position for radial animation
let x, y;
if (e.type === 'touchend' && e.changedTouches && e.changedTouches[0]) {
const rect = toggle.getBoundingClientRect();
x = e.changedTouches[0].clientX - rect.left;
y = e.changedTouches[0].clientY - rect.top;
} else {
const rect = toggle.getBoundingClientRect();
x = e.clientX - rect.left;
y = e.clientY - rect.top;
}
// Set the position variables for the radial gradient
document.documentElement.style.setProperty('--x', `${x}px`);
document.documentElement.style.setProperty('--y', `${y}px`);
// Get the overlay element
const overlay = document.querySelector('.theme-switch-overlay');
// Determine the new theme
const isDark = document.documentElement.classList.contains('dark');
const newTheme = isDark ? 'light' : 'dark';
// Show overlay during transition
if (overlay) {
overlay.style.backgroundColor = newTheme === 'dark' ? 'rgba(24, 24, 27, 0.3)' : 'rgba(255, 255, 255, 0.3)';
overlay.style.opacity = '1';
}
// Add transition class
document.documentElement.classList.add('theme-switching');
// Add ripple effect
const ripple = document.createElement('span');
ripple.className = 'theme-toggle-ripple';
toggle.appendChild(ripple);
// Force a reflow to ensure all elements update
document.body.offsetHeight;
// Toggle dark mode with a slight delay to allow overlay to appear
setTimeout(() => {
if (isDark) {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
// Store the preference
localStorage.setItem('theme', newTheme);
// Dispatch a custom event for other components to react to
document.dispatchEvent(new CustomEvent('themeChanged', {
detail: { isDark: newTheme === 'dark' }
}));
// Force another reflow to ensure all elements update
document.body.offsetHeight;
// Hide overlay after theme has changed
setTimeout(() => {
if (overlay) {
overlay.style.opacity = '0';
}
// Remove transition class after animation completes
document.documentElement.classList.remove('theme-switching');
ripple.remove();
}, 300);
}, 50);
}, { passive: false });
});
// Add touch feedback
toggle.addEventListener('touchstart', () => {
toggle.classList.add('active-touch');
}, { passive: true });
toggle.addEventListener('touchend', () => {
setTimeout(() => {
toggle.classList.remove('active-touch');
}, 150);
}, { passive: true });
});
}
// Run setup on load
document.addEventListener('DOMContentLoaded', setupThemeToggle);
// Also run on page visibility change to ensure theme is consistent
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
const currentTheme = localStorage.getItem('theme');
if (currentTheme === 'dark') {
document.documentElement.classList.add('dark');
} else if (currentTheme === 'light') {
document.documentElement.classList.remove('dark');
}
}
});
// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', ({ matches }) => {
if (!localStorage.getItem('theme')) {
if (matches) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}
});
</script>
<style>
/* Smooth transition for the entire page when theme changes */
:global(body) {
transition: background-color 0.5s ease, color 0.5s ease;
}
/* Theme transition overlay */
:global(.theme-switch-overlay) {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
transition: opacity 0.3s ease-out;
}
/* Ensure theme transitions apply to all elements */
:global(.theme-switching *) {
transition-duration: 0.5s !important;
transition-property: background-color, border-color, color, fill, stroke !important;
}
/* Ripple animation */
.theme-toggle-ripple {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) scale(0);
width: 100%;
height: 100%;
border-radius: 50%;
background-color: rgba(161, 161, 170, 0.3);
animation: ripple 0.8s ease-out;
}
@keyframes ripple {
0% {
transform: translate(-50%, -50%) scale(0);
opacity: 0.5;
}
100% {
transform: translate(-50%, -50%) scale(2.5);
opacity: 0;
}
}
/* Subtle hover animation */
#theme-toggle {
transform: translateY(0);
box-shadow: 0 0 0 rgba(0, 0, 0, 0);
-webkit-tap-highlight-color: transparent; /* Remove default mobile tap highlight */
min-height: 32px; /* Ensure minimum touch target size */
min-width: 32px; /* Ensure minimum touch target size */
}
/* Only apply hover effects on non-touch devices */
@media (hover: hover) {
#theme-toggle:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
#theme-toggle:hover .icon-light:not(.dark .icon-light) {
filter: drop-shadow(0 0 2px rgba(251, 191, 36, 0.6));
transform: scale(1.1) rotate(15deg);
}
#theme-toggle:hover .icon-dark:not(:not(.dark) .icon-dark) {
filter: drop-shadow(0 0 2px rgba(129, 140, 248, 0.6));
transform: scale(1.1) rotate(-15deg);
}
}
/* Touch feedback */
#theme-toggle.active-touch {
transform: scale(0.95);
transition: transform 0.15s ease-in-out;
}
/* Optimize animations for mobile */
@media (prefers-reduced-motion: reduce) {
.icon-light, .icon-dark {
transition: all 0.2s ease-out !important;
}
#theme-toggle, #theme-toggle:hover {
transform: none;
transition: none;
}
.theme-toggle-ripple {
animation-duration: 0.4s;
}
}
/* Adjust size for very small screens */
@media (max-width: 320px) {
#theme-toggle {
padding: 0.25rem !important;
}
}
</style>