feat: add portfolio, blog, and contact features, update theme styling, refine imports, and clean up unused code (#2)
Reviewed-on: #2
This commit is contained in:
6
bun.lock
6
bun.lock
@ -11,12 +11,12 @@
|
||||
"@iconify-json/logos": "^1.2.4",
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
"@iconify-json/ph": "^1.2.2",
|
||||
"@studio-freight/lenis": "^1.0.42",
|
||||
"@tailwindcss/postcss": "^4.0.12",
|
||||
"@tailwindcss/vite": "^4.0.12",
|
||||
"astro": "^5.4.2",
|
||||
"astro-icon": "^1.1.5",
|
||||
"framer-motion": "^12.4.7",
|
||||
"lenis": "^1.2.3",
|
||||
"next": "15.1.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@ -343,8 +343,6 @@
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@studio-freight/lenis": ["@studio-freight/lenis@1.0.42", "", {}, "sha512-HJAGf2DeM+BTvKzHv752z6Z7zy6bA643nZM7W88Ft9tnw2GsJSp6iJ+3cekjyMIWH+cloL2U9X82dKXgdU8kPg=="],
|
||||
|
||||
"@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
@ -983,6 +981,8 @@
|
||||
|
||||
"language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
|
||||
|
||||
"lenis": ["lenis@1.2.3", "", { "peerDependencies": { "@nuxt/kit": ">=3.0.0", "react": ">=17.0.0", "vue": ">=3.0.0" }, "optionalPeers": ["@nuxt/kit", "react", "vue"] }, "sha512-H3VUn62jvQPfyxGW2F0STJPhP1VzX5r05KtiZ0uxHYD9xtbfTvj2eX/Km26+x13zlaKfHacEe1DTC3ouFrxw+g=="],
|
||||
|
||||
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.29.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.29.2", "lightningcss-darwin-x64": "1.29.2", "lightningcss-freebsd-x64": "1.29.2", "lightningcss-linux-arm-gnueabihf": "1.29.2", "lightningcss-linux-arm64-gnu": "1.29.2", "lightningcss-linux-arm64-musl": "1.29.2", "lightningcss-linux-x64-gnu": "1.29.2", "lightningcss-linux-x64-musl": "1.29.2", "lightningcss-win32-arm64-msvc": "1.29.2", "lightningcss-win32-x64-msvc": "1.29.2" } }, "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA=="],
|
||||
|
@ -17,12 +17,12 @@
|
||||
"@iconify-json/logos": "^1.2.4",
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
"@iconify-json/ph": "^1.2.2",
|
||||
"@studio-freight/lenis": "^1.0.42",
|
||||
"@tailwindcss/postcss": "^4.0.12",
|
||||
"@tailwindcss/vite": "^4.0.12",
|
||||
"astro": "^5.4.2",
|
||||
"astro-icon": "^1.1.5",
|
||||
"framer-motion": "^12.4.7",
|
||||
"lenis": "^1.2.3",
|
||||
"next": "15.1.7",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
49
public/manifest.json
Normal file
49
public/manifest.json
Normal file
@ -0,0 +1,49 @@
|
||||
{
|
||||
"name": "Jan-Marlon Leibl | Fullstack Developer",
|
||||
"short_name": "JL Portfolio",
|
||||
"description": "Personal website and portfolio of Jan-Marlon Leibl, Fullstack Developer",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0A0A0A",
|
||||
"theme_color": "#0A0A0A",
|
||||
"orientation": "portrait-primary",
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/screenshots/mobile.png",
|
||||
"sizes": "375x812",
|
||||
"type": "image/png",
|
||||
"form_factor": "narrow",
|
||||
"label": "Jan-Marlon Leibl's Portfolio (Mobile)"
|
||||
},
|
||||
{
|
||||
"src": "/screenshots/desktop.png",
|
||||
"sizes": "1280x800",
|
||||
"type": "image/png",
|
||||
"form_factor": "wide",
|
||||
"label": "Jan-Marlon Leibl's Portfolio (Desktop)"
|
||||
}
|
||||
],
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "About Me",
|
||||
"short_name": "About",
|
||||
"description": "Learn more about Jan-Marlon Leibl",
|
||||
"url": "/#about",
|
||||
"icons": [{ "src": "/icons/about-icon.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "My Work",
|
||||
"short_name": "Work",
|
||||
"description": "View Jan-Marlon's portfolio projects",
|
||||
"url": "/#work",
|
||||
"icons": [{ "src": "/icons/work-icon.png", "sizes": "192x192" }]
|
||||
},
|
||||
{
|
||||
"name": "Contact",
|
||||
"short_name": "Contact",
|
||||
"description": "Get in touch with Jan-Marlon",
|
||||
"url": "/#contact",
|
||||
"icons": [{ "src": "/icons/contact-icon.png", "sizes": "192x192" }]
|
||||
}
|
||||
]
|
||||
}
|
7
public/robots.txt
Normal file
7
public/robots.txt
Normal file
@ -0,0 +1,7 @@
|
||||
# www.robotstxt.org/
|
||||
# Allow crawling of all content
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
# Optimize crawling
|
||||
Crawl-delay: 10
|
122
public/service-worker.js
Normal file
122
public/service-worker.js
Normal file
@ -0,0 +1,122 @@
|
||||
const CACHE_NAME = 'jleibl-portfolio-v1';
|
||||
|
||||
const PRECACHE_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.json',
|
||||
];
|
||||
|
||||
const STYLE_ASSETS = [
|
||||
'/styles/global.css',
|
||||
'/styles/theme.css'
|
||||
];
|
||||
|
||||
const IMAGE_ASSETS = [
|
||||
'/images/profile-image.jpg'
|
||||
];
|
||||
|
||||
const ALL_ASSETS = [
|
||||
...PRECACHE_ASSETS,
|
||||
...STYLE_ASSETS,
|
||||
...IMAGE_ASSETS
|
||||
];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
self.skipWaiting();
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
console.log('Opened cache');
|
||||
return cache.addAll(PRECACHE_ASSETS);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('activate', event => {
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => {
|
||||
if (cacheName !== CACHE_NAME) {
|
||||
console.log('Deleting old cache:', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
}
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return self.clients.claim();
|
||||
});
|
||||
|
||||
function shouldCache(url) {
|
||||
if (url.origin === self.location.origin) {
|
||||
if (url.pathname.endsWith('.html') ||
|
||||
url.pathname.endsWith('.css') ||
|
||||
url.pathname.endsWith('.js') ||
|
||||
url.pathname.endsWith('.jpg') ||
|
||||
url.pathname.endsWith('.jpeg') ||
|
||||
url.pathname.endsWith('.png') ||
|
||||
url.pathname.endsWith('.svg') ||
|
||||
url.pathname.endsWith('.webp') ||
|
||||
url.pathname.endsWith('.woff') ||
|
||||
url.pathname.endsWith('.woff2') ||
|
||||
url.pathname.endsWith('.json')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (url.pathname === '/') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
self.addEventListener('fetch', event => {
|
||||
if (event.request.mode === 'navigate' || shouldCache(new URL(event.request.url))) {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(response => {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const fetchRequest = event.request.clone();
|
||||
|
||||
return fetch(fetchRequest)
|
||||
.then(response => {
|
||||
if (!response || response.status !== 200 || response.type !== 'basic') {
|
||||
return response;
|
||||
}
|
||||
|
||||
const responseToCache = response.clone();
|
||||
|
||||
if (shouldCache(new URL(event.request.url))) {
|
||||
caches.open(CACHE_NAME)
|
||||
.then(cache => {
|
||||
cache.put(event.request, responseToCache);
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Fetch failed:', error);
|
||||
|
||||
if (event.request.mode === 'navigate') {
|
||||
return caches.match('/');
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
self.addEventListener('message', event => {
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
});
|
@ -6,7 +6,7 @@ import MobileMenu from './MobileMenu.astro';
|
||||
class="fixed top-0 left-0 right-0 z-40 nav-glass backdrop-blur-lg theme-transition"
|
||||
role="banner"
|
||||
>
|
||||
<div class="max-w-(--breakpoint-xl) mx-auto">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<nav class="py-4 px-6 sm:px-8 flex justify-between items-center font-['Instrument_Sans']" role="navigation" aria-label="Main navigation">
|
||||
<div class="flex items-center">
|
||||
<a href="#hero" class="flex items-center gap-2.5 group" aria-label="Home">
|
||||
@ -22,10 +22,10 @@ import MobileMenu from './MobileMenu.astro';
|
||||
|
||||
<div class="hidden md:flex items-center">
|
||||
<div class="flex bg-black/5 backdrop-blur-md overflow-hidden theme-border border divide-x divide-white/5 shadow-sm rounded-xl">
|
||||
{["About", "Work"].map((item, index) => (
|
||||
{["About", "Work", "Blog", "Contact"].map((item, index) => (
|
||||
<a
|
||||
href={`#${item.toLowerCase()}`}
|
||||
class={`nav-item px-5 py-2 theme-secondary hover:theme-primary theme-transition ${index === 0 ? "rounded-l-xl rounded-r-none" : "rounded-r-xl rounded-l-none"}`}
|
||||
class={`nav-item px-5 py-2 theme-secondary hover:theme-primary theme-transition ${index === 0 ? "rounded-l-xl" : ""} ${index === 3 ? "rounded-r-xl" : ""}`}
|
||||
aria-label={`View my ${item.toLowerCase()}`}
|
||||
>
|
||||
{item}
|
||||
@ -33,18 +33,34 @@ import MobileMenu from './MobileMenu.astro';
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="ml-6">
|
||||
<div class="flex items-center gap-4 ml-6">
|
||||
<a
|
||||
href="mailto:jleibl@proton.me"
|
||||
class="flex items-center gap-2 px-5 py-2 theme-bg-05 theme-primary border theme-border rounded-xl hover:bg-opacity-100 transition-all duration-300 group shadow-sm"
|
||||
aria-label="Contact me via email"
|
||||
>
|
||||
<svg class="w-4 h-4 theme-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
class="w-4 h-4 theme-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path 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"/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium">Contact</span>
|
||||
<div class="w-0 overflow-hidden group-hover:w-4 transition-all duration-300">
|
||||
<svg class="w-4 h-4 theme-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<svg
|
||||
class="w-4 h-4 theme-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
@ -62,33 +78,71 @@ import MobileMenu from './MobileMenu.astro';
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const header = document.querySelector('header');
|
||||
if (header) {
|
||||
header.style.transform = 'translateY(0)';
|
||||
header.style.opacity = '1';
|
||||
}
|
||||
if (!header) return;
|
||||
|
||||
header.style.transform = 'translateY(0)';
|
||||
header.style.opacity = '1';
|
||||
|
||||
let lastScrollY = window.scrollY;
|
||||
const scrollThreshold = 100;
|
||||
let isHeaderVisible = true;
|
||||
|
||||
const sections = document.querySelectorAll('section[id]');
|
||||
const navItems = document.querySelectorAll('.nav-item');
|
||||
|
||||
function setActiveNavItem() {
|
||||
const scrollPosition = window.scrollY + 200;
|
||||
|
||||
navItems.forEach(item => {
|
||||
item.classList.remove('nav-item-active');
|
||||
item.removeAttribute('aria-current');
|
||||
});
|
||||
|
||||
let currentSection: string | null = null;
|
||||
|
||||
sections.forEach(section => {
|
||||
const sectionTop = section.getBoundingClientRect().top + window.scrollY;
|
||||
const sectionHeight = section.getBoundingClientRect().height;
|
||||
|
||||
if (scrollPosition >= sectionTop && scrollPosition < sectionTop + sectionHeight) {
|
||||
currentSection = section.getAttribute('id');
|
||||
}
|
||||
});
|
||||
|
||||
if (currentSection) {
|
||||
navItems.forEach(item => {
|
||||
const href = item.getAttribute('href')?.substring(1);
|
||||
if (href === currentSection) {
|
||||
item.classList.add('nav-item-active');
|
||||
item.setAttribute('aria-current', 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
if (!header) return;
|
||||
|
||||
const currentScrollY = window.scrollY;
|
||||
|
||||
// Apply subtle shadow and border when scrolling down
|
||||
if (currentScrollY > 20) {
|
||||
header.classList.add('scrolled');
|
||||
} else {
|
||||
header.classList.remove('scrolled');
|
||||
}
|
||||
|
||||
// Hide header when scrolling down, show when scrolling up
|
||||
if (currentScrollY > lastScrollY && currentScrollY > 100) {
|
||||
header.style.transform = 'translateY(-100%)';
|
||||
} else {
|
||||
header.style.transform = 'translateY(0)';
|
||||
}
|
||||
|
||||
lastScrollY = currentScrollY;
|
||||
|
||||
setActiveNavItem();
|
||||
});
|
||||
|
||||
setActiveNavItem();
|
||||
|
||||
navItems.forEach(item => {
|
||||
item.addEventListener('keydown', (e) => {
|
||||
if (e instanceof KeyboardEvent && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
item.dispatchEvent(new MouseEvent('click'));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@ -101,11 +155,46 @@ import MobileMenu from './MobileMenu.astro';
|
||||
}
|
||||
|
||||
header.scrolled {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
border-radius: 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-item::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.black .nav-item::after {
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.red .nav-item::after {
|
||||
background-color: rgba(248, 113, 113, 0.8);
|
||||
}
|
||||
.gold .nav-item::after {
|
||||
background-color: rgba(251, 191, 36, 0.8);
|
||||
}
|
||||
|
||||
.nav-item:hover::after {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.nav-item-active {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.nav-item-active::after {
|
||||
transform: translateX(0);
|
||||
}
|
||||
</style>
|
108
src/components/layout/Meta.astro
Normal file
108
src/components/layout/Meta.astro
Normal file
@ -0,0 +1,108 @@
|
||||
---
|
||||
const defaultTitle = "Jan-Marlon Leibl • Fullstack Software Developer | PHP & TypeScript Expert";
|
||||
const defaultDescription = "Experienced Fullstack Developer specializing in PHP and TypeScript. Creating high-performance web applications and digital experiences with modern technologies.";
|
||||
const defaultImage = "/images/profile-image.jpg";
|
||||
const siteUrl = "https://jleibl.net";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
canonicalUrl?: string;
|
||||
type?: "website" | "article";
|
||||
publishedDate?: string;
|
||||
modifiedDate?: string;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
const {
|
||||
title = defaultTitle,
|
||||
description = defaultDescription,
|
||||
image = defaultImage,
|
||||
canonicalUrl = siteUrl,
|
||||
type = "website",
|
||||
publishedDate,
|
||||
modifiedDate,
|
||||
keywords = [
|
||||
"Software Development",
|
||||
"PHP Developer",
|
||||
"TypeScript",
|
||||
"Fullstack Engineer",
|
||||
"Web Development",
|
||||
"System Architecture",
|
||||
"MySQL",
|
||||
"React",
|
||||
"Performance Optimization"
|
||||
]
|
||||
} = Astro.props;
|
||||
|
||||
const structuredDataPerson = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Person",
|
||||
"name": "Jan-Marlon Leibl",
|
||||
"url": siteUrl,
|
||||
"image": `${siteUrl}${image}`,
|
||||
"jobTitle": "Fullstack Software Developer",
|
||||
"worksFor": {
|
||||
"@type": "Organization",
|
||||
"name": "team neusta"
|
||||
},
|
||||
"description": "Fullstack Software Developer specializing in PHP and TypeScript",
|
||||
"sameAs": [
|
||||
"https://github.com/AtomicWasTaken",
|
||||
"https://www.linkedin.com/in/janmarlonleibl/",
|
||||
],
|
||||
"knowsAbout": ["PHP", "TypeScript", "Web Development", "System Architecture", "Database Design"]
|
||||
};
|
||||
|
||||
const structuredDataWebsite = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": type,
|
||||
"headline": title,
|
||||
"description": description,
|
||||
"image": `${siteUrl}${image}`,
|
||||
"url": canonicalUrl,
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "Jan-Marlon Leibl",
|
||||
"url": siteUrl
|
||||
},
|
||||
...(publishedDate && {"datePublished": publishedDate}),
|
||||
...(modifiedDate && {"dateModified": modifiedDate})
|
||||
};
|
||||
---
|
||||
|
||||
<title>{title}</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="title" content={title} />
|
||||
<meta name="description" content={description} />
|
||||
<meta name="keywords" content={keywords.join(", ")} />
|
||||
<meta name="author" content="Jan-Marlon Leibl" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="theme-color" content="#0A0A0A" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<link rel="canonical" href={canonicalUrl} />
|
||||
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={`${siteUrl}${image}`} />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:image:alt" content="Jan-Marlon Leibl - Fullstack Software Developer" />
|
||||
<meta property="og:site_name" content="Jan-Marlon Leibl" />
|
||||
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<script type="application/ld+json" set:html={JSON.stringify(structuredDataPerson)} />
|
||||
<script type="application/ld+json" set:html={JSON.stringify(structuredDataWebsite)} />
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="manifest" href="/manifest.json" />
|
@ -1,70 +1,75 @@
|
||||
---
|
||||
import GermanyFlag from '../ui/GermanyFlag.astro';
|
||||
import { FaGithub, FaLinkedin, FaEnvelope } from "react-icons/fa";
|
||||
|
||||
const navItems = [
|
||||
{ label: "About", href: "#about" },
|
||||
{ label: "Work", href: "#work" },
|
||||
{ label: "Blog", href: "#blog" },
|
||||
{ label: "Contact", href: "#contact" }
|
||||
];
|
||||
---
|
||||
|
||||
<div class="mobile-menu">
|
||||
<div class="mobile-menu-container">
|
||||
<button
|
||||
id="mobile-menu-toggle"
|
||||
class="p-2.5 theme-accent rounded-xl theme-border border shadow-sm hover:theme-bg-05 transition-all duration-300"
|
||||
aria-label="Open menu"
|
||||
id="menu-toggle"
|
||||
class="p-2 theme-accent rounded-xl flex flex-col justify-center items-center gap-1.5 border theme-border overflow-hidden"
|
||||
aria-label="Toggle mobile menu"
|
||||
aria-expanded="false"
|
||||
aria-controls="mobile-nav"
|
||||
>
|
||||
<svg class="w-5 h-5 theme-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
<span class="block w-5 h-0.5 theme-primary transition-all duration-300 transform bar-1"></span>
|
||||
<span class="block w-5 h-0.5 theme-primary transition-all duration-300 transform bar-2"></span>
|
||||
<span class="block w-5 h-0.5 theme-primary transition-all duration-300 transform bar-3"></span>
|
||||
</button>
|
||||
|
||||
<div id="mobile-menu-overlay" class="fixed inset-0 bg-black opacity-0 z-90 pointer-events-none transition-opacity duration-300"></div>
|
||||
<div
|
||||
id="mobile-nav"
|
||||
class="fixed inset-0 z-50 invisible opacity-0 flex flex-col justify-start items-stretch pt-24 nav-glass transition-all duration-300 transform translate-x-full"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<nav class="px-6 sm:px-8 pt-6 pb-12 flex flex-col gap-4">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class="mobile-nav-item py-3 px-6 theme-secondary text-lg font-medium rounded-xl theme-accent border theme-border transition-all duration-300 hover:theme-primary"
|
||||
aria-label={`Go to ${item.label} section`}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div id="mobile-menu-panel" class="fixed top-0 bottom-0 right-0 w-full sm:w-80 z-100 h-[100dvh] translate-x-full transition-transform duration-300">
|
||||
<div class="nav-glass w-full h-full flex flex-col shadow-2xl" style="backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);">
|
||||
<button
|
||||
id="mobile-menu-close"
|
||||
class="absolute top-4 right-4 p-2.5 theme-accent rounded-xl theme-border border z-110 shadow-sm hover:theme-bg-05 transition-all duration-300"
|
||||
aria-label="Close menu"
|
||||
<div class="px-6 sm:px-8 py-6 mt-auto border-t theme-border flex flex-col gap-5">
|
||||
<a
|
||||
href="mailto:jleibl@proton.me"
|
||||
class="flex items-center justify-center gap-2 py-3 bg-white/10 text-white rounded-xl hover:bg-white/15 transition-all duration-300 shadow-sm"
|
||||
aria-label="Contact me via email"
|
||||
>
|
||||
<svg class="w-5 h-5 theme-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="p-1 rounded-lg bg-white/5">
|
||||
<FaEnvelope className="w-5 h-5" />
|
||||
</span>
|
||||
<span class="text-base font-medium font-['Instrument_Sans']">Contact Me</span>
|
||||
</a>
|
||||
|
||||
<div class="w-full h-full p-6 pt-16 flex flex-col overflow-y-auto">
|
||||
<nav class="mb-8">
|
||||
<div class="flex flex-col space-y-3">
|
||||
{[
|
||||
{ href: "#about", label: "About" },
|
||||
{ href: "#work", label: "Work" }
|
||||
].map((item) => (
|
||||
<a
|
||||
href={item.href}
|
||||
class="menu-link p-4 text-xl font-medium theme-primary rounded-xl theme-bg-05 border theme-border flex items-center hover:bg-white/[0.03] transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<svg class="ml-auto w-5 h-5 theme-primary opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto">
|
||||
<div class="pt-6 border-t theme-border">
|
||||
<a
|
||||
href="mailto:jleibl@proton.me"
|
||||
class="flex items-center justify-center gap-3 w-full py-4 px-6 theme-button-primary rounded-xl transition-all duration-300 shadow-sm hover:shadow-md"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path 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"/>
|
||||
</svg>
|
||||
<span class="font-medium">Contact Me</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="pt-8 flex justify-center pb-6 opacity-70">
|
||||
<GermanyFlag />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 justify-center mt-2">
|
||||
<a
|
||||
href="https://github.com/AtomicWasTaken"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub profile"
|
||||
class="p-3 rounded-xl bg-black/10 hover:bg-black/15 transition-all"
|
||||
>
|
||||
<FaGithub className="w-5 h-5 text-white" />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.linkedin.com/in/janmarlonleibl/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="LinkedIn profile"
|
||||
class="p-3 rounded-xl bg-black/10 hover:bg-black/15 transition-all"
|
||||
>
|
||||
<FaLinkedin className="w-5 h-5 text-white" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -72,37 +77,92 @@ import GermanyFlag from '../ui/GermanyFlag.astro';
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const toggle = document.getElementById('mobile-menu-toggle');
|
||||
const close = document.getElementById('mobile-menu-close');
|
||||
const overlay = document.getElementById('mobile-menu-overlay');
|
||||
const panel = document.getElementById('mobile-menu-panel');
|
||||
const menuLinks = document.querySelectorAll('.menu-link');
|
||||
const menuToggle = document.getElementById('menu-toggle');
|
||||
const mobileNav = document.getElementById('mobile-nav');
|
||||
const mobileNavItems = document.querySelectorAll('.mobile-nav-item');
|
||||
let isMenuOpen = false;
|
||||
|
||||
function openMenu() {
|
||||
document.body.style.overflow = 'hidden';
|
||||
toggle?.setAttribute('aria-expanded', 'true');
|
||||
overlay?.classList.remove('opacity-0', 'pointer-events-none');
|
||||
overlay?.classList.add('opacity-90');
|
||||
panel?.classList.remove('translate-x-full');
|
||||
function toggleMenu() {
|
||||
isMenuOpen = !isMenuOpen;
|
||||
|
||||
if (menuToggle) {
|
||||
menuToggle.setAttribute('aria-expanded', isMenuOpen.toString());
|
||||
menuToggle.classList.toggle('active', isMenuOpen);
|
||||
}
|
||||
|
||||
if (mobileNav) {
|
||||
mobileNav.setAttribute('aria-hidden', (!isMenuOpen).toString());
|
||||
|
||||
if (isMenuOpen) {
|
||||
mobileNav.classList.remove('invisible', 'opacity-0', 'translate-x-full');
|
||||
mobileNav.classList.add('opacity-100', 'translate-x-0');
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
mobileNavItems.forEach((item, index) => {
|
||||
setTimeout(() => {
|
||||
item.classList.add('show');
|
||||
}, 100 + (index * 50));
|
||||
});
|
||||
} else {
|
||||
mobileNav.classList.remove('opacity-100', 'translate-x-0');
|
||||
mobileNav.classList.add('opacity-0', 'translate-x-full');
|
||||
document.body.style.overflow = '';
|
||||
|
||||
setTimeout(() => {
|
||||
if (!isMenuOpen) {
|
||||
mobileNav.classList.add('invisible');
|
||||
mobileNavItems.forEach(item => {
|
||||
item.classList.remove('show');
|
||||
});
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
document.body.style.overflow = '';
|
||||
toggle?.setAttribute('aria-expanded', 'false');
|
||||
overlay?.classList.add('opacity-0', 'pointer-events-none');
|
||||
overlay?.classList.remove('opacity-90');
|
||||
panel?.classList.add('translate-x-full');
|
||||
}
|
||||
menuToggle?.addEventListener('click', toggleMenu);
|
||||
|
||||
toggle?.addEventListener('click', openMenu);
|
||||
close?.addEventListener('click', closeMenu);
|
||||
overlay?.addEventListener('click', closeMenu);
|
||||
menuLinks.forEach(link => link.addEventListener('click', closeMenu));
|
||||
mobileNavItems.forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
toggleMenu();
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
if (isMenuOpen && mobileNav && e.target instanceof Node && !mobileNav.contains(e.target) && e.target !== menuToggle) {
|
||||
toggleMenu();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && toggle?.getAttribute('aria-expanded') === 'true') {
|
||||
closeMenu();
|
||||
if (isMenuOpen && e.key === 'Escape') {
|
||||
toggleMenu();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#menu-toggle.active .bar-1 {
|
||||
transform: translateY(8px) rotate(45deg);
|
||||
}
|
||||
|
||||
#menu-toggle.active .bar-2 {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
#menu-toggle.active .bar-3 {
|
||||
transform: translateY(-8px) rotate(-45deg);
|
||||
}
|
||||
|
||||
.mobile-nav-item {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease, background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.mobile-nav-item.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
@ -83,8 +83,8 @@ const interests = [
|
||||
</div>
|
||||
<ul class="space-y-4 sm:space-y-5">
|
||||
{technologies.map((tech) => (
|
||||
<li class="text-base sm:text-lg group flex items-center gap-3 theme-text-70 hover:theme-text-90 transition-colors cursor-default theme-bg-05 px-4 py-2.5 rounded-xl shadow-sm hover:shadow-md border border-transparent hover:theme-border transition-all duration-300">
|
||||
<span class="theme-text-40 group-hover:theme-text-90 flex items-center justify-center w-6 h-6 transition-colors">
|
||||
<li class="text-base sm:text-lg group flex items-center gap-3 theme-text-70 hover:theme-text-90 cursor-default theme-bg-05 px-4 py-2.5 rounded-xl shadow-sm hover:shadow-md border border-transparent hover:theme-border transition-all duration-300">
|
||||
<span class="theme-text-40 group-hover:theme-text-90 flex items-center justify-center w-6 h-6 transition-all">
|
||||
<tech.icon className="w-5 h-5" />
|
||||
</span>
|
||||
{tech.name}
|
||||
@ -102,8 +102,8 @@ const interests = [
|
||||
</div>
|
||||
<ul class="space-y-4 sm:space-y-5">
|
||||
{interests.map((interest) => (
|
||||
<li class="text-base sm:text-lg group flex items-center gap-3 theme-text-70 hover:theme-text-90 transition-colors cursor-default theme-bg-05 px-4 py-2.5 rounded-xl shadow-sm hover:shadow-md border border-transparent hover:theme-border transition-all duration-300">
|
||||
<span class="theme-text-40 group-hover:theme-text-90 flex items-center justify-center w-6 h-6 transition-colors">
|
||||
<li class="text-base sm:text-lg group flex items-center gap-3 theme-text-70 hover:theme-text-90 cursor-default theme-bg-05 px-4 py-2.5 rounded-xl shadow-sm hover:shadow-md border border-transparent hover:theme-border transition-all duration-300">
|
||||
<span class="theme-text-40 group-hover:theme-text-90 flex items-center justify-center w-6 h-6 transition-all">
|
||||
<interest.icon className="w-5 h-5" />
|
||||
</span>
|
||||
{interest.name}
|
||||
|
132
src/components/sections/BlogPreview.astro
Normal file
132
src/components/sections/BlogPreview.astro
Normal file
@ -0,0 +1,132 @@
|
||||
---
|
||||
import FadeIn from '../ui/FadeIn.astro';
|
||||
|
||||
const blogPosts = [
|
||||
{
|
||||
title: "Best Practices for Modern PHP Development",
|
||||
excerpt: "Explore how to leverage PHP 8.x features and modern tools to create maintainable and performant PHP applications.",
|
||||
date: "May 15, 2023",
|
||||
slug: "best-practices-modern-php-development",
|
||||
tags: ["PHP", "Development", "Best Practices"]
|
||||
},
|
||||
{
|
||||
title: "Building Type-Safe APIs with TypeScript",
|
||||
excerpt: "How to use TypeScript's powerful type system to create robust and maintainable API integrations in your frontend applications.",
|
||||
date: "April 3, 2023",
|
||||
slug: "building-type-safe-apis-typescript",
|
||||
tags: ["TypeScript", "API", "Frontend"]
|
||||
},
|
||||
{
|
||||
title: "Performance Optimization Techniques for Web Applications",
|
||||
excerpt: "A comprehensive guide to identifying and solving performance bottlenecks in modern web applications.",
|
||||
date: "March 12, 2023",
|
||||
slug: "performance-optimization-web-applications",
|
||||
tags: ["Performance", "Web", "Optimization"]
|
||||
}
|
||||
];
|
||||
---
|
||||
|
||||
<section id="blog" class="py-20 sm:py-32 px-4 sm:px-8 relative">
|
||||
<div class="absolute inset-0 theme-bg-gradient opacity-30"></div>
|
||||
|
||||
<div class="max-w-7xl mx-auto relative">
|
||||
<FadeIn className="space-y-16">
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-3xl sm:text-4xl md:text-5xl font-bold font-['DM_Sans'] tracking-tight theme-primary">
|
||||
Blog
|
||||
</h2>
|
||||
<p class="font-['Instrument_Sans'] theme-secondary text-lg sm:text-xl max-w-3xl mt-4">
|
||||
Thoughts, insights, and tutorials on software development and tech.
|
||||
<span class="inline-flex items-center px-3 py-1 ml-2 rounded-full text-xs font-medium bg-black/20 text-white font-['Instrument_Sans']">Coming Soon</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="hidden sm:block">
|
||||
<button
|
||||
class="animate-button inline-flex items-center justify-center gap-2 px-6 py-3 bg-transparent border theme-border theme-text-50 rounded-xl transition-all text-sm tracking-tight font-medium shadow-sm font-['Instrument_Sans'] cursor-not-allowed opacity-75"
|
||||
title="Coming soon!"
|
||||
>
|
||||
View All Posts
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 sm:gap-8 relative p-12">
|
||||
<div class="absolute inset-0 backdrop-blur-sm z-10 flex items-center justify-center rounded-xl">
|
||||
<div class="bg-black/80 px-6 py-5 rounded-xl border border-white/20 text-center">
|
||||
<span class="text-xl font-semibold text-white font-['DM_Sans']">Blog Coming Soon!</span>
|
||||
<p class="text-white/80 mt-2 font-['Instrument_Sans']">The blog feature is currently in development.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{blogPosts.map((post) => (
|
||||
<div
|
||||
class="flex flex-col h-full theme-accent border theme-border rounded-xl overflow-hidden opacity-80 group"
|
||||
>
|
||||
<div class="p-6 sm:p-8 flex flex-col h-full">
|
||||
<div class="flex-grow space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{post.tags.map((tag) => (
|
||||
<span class="inline-flex px-2.5 py-1 rounded-lg text-xs font-medium theme-text-70 bg-black/10 font-['Instrument_Sans']">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<h3 class="text-xl font-bold theme-primary font-['DM_Sans']">{post.title}</h3>
|
||||
</div>
|
||||
<p class="theme-secondary font-['Instrument_Sans'] text-sm sm:text-base leading-relaxed">{post.excerpt}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 pt-6 border-t theme-border flex items-center justify-between">
|
||||
<span class="text-sm theme-text-70 font-['Instrument_Sans']">{post.date}</span>
|
||||
<div class="p-2 rounded-full theme-accent">
|
||||
<svg
|
||||
class="w-4 h-4 theme-primary"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div class="sm:hidden flex justify-center mt-8">
|
||||
<button
|
||||
class="animate-button inline-flex items-center justify-center gap-2 px-8 py-4 bg-transparent border theme-border theme-text-50 rounded-xl transition-all text-base tracking-tight font-medium shadow-sm font-['Instrument_Sans'] cursor-not-allowed opacity-75"
|
||||
title="Coming soon!"
|
||||
>
|
||||
View All Posts
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</FadeIn>
|
||||
</div>
|
||||
</section>
|
179
src/components/sections/Contact.astro
Normal file
179
src/components/sections/Contact.astro
Normal file
@ -0,0 +1,179 @@
|
||||
---
|
||||
import FadeIn from '../ui/FadeIn.astro';
|
||||
import { FaGithub, FaLinkedin, FaEnvelope, FaPhone, FaMapMarkerAlt, FaGlobe } from "react-icons/fa";
|
||||
---
|
||||
|
||||
<section id="contact" class="py-20 sm:py-32 px-4 sm:px-8 relative">
|
||||
<div class="absolute inset-0 theme-bg-gradient opacity-30"></div>
|
||||
|
||||
<div class="max-w-7xl mx-auto relative">
|
||||
<FadeIn className="space-y-16">
|
||||
<div class="space-y-6">
|
||||
<h2 class="text-3xl sm:text-4xl md:text-5xl font-bold font-['DM_Sans'] tracking-tight theme-primary">
|
||||
Get In Touch
|
||||
</h2>
|
||||
<p class="font-['Instrument_Sans'] theme-secondary text-lg sm:text-xl max-w-3xl">
|
||||
Have a project in mind or want to chat? Feel free to reach out.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-10 lg:gap-16">
|
||||
<div class="space-y-8">
|
||||
<form id="contact-form" class="space-y-6" onsubmit="return openEmailClient(event)">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="name" class="block mb-2 text-sm font-medium theme-secondary font-['Instrument_Sans']">Your Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
class="w-full px-4 py-3 rounded-xl theme-accent border theme-border theme-secondary focus:ring-2 focus:ring-white/20 focus:border-white/30 transition-all bg-transparent font-['Instrument_Sans']"
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block mb-2 text-sm font-medium theme-secondary font-['Instrument_Sans']">Your Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
class="w-full px-4 py-3 rounded-xl theme-accent border theme-border theme-secondary focus:ring-2 focus:ring-white/20 focus:border-white/30 transition-all bg-transparent font-['Instrument_Sans']"
|
||||
required
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="subject" class="block mb-2 text-sm font-medium theme-secondary font-['Instrument_Sans']">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
name="subject"
|
||||
class="w-full px-4 py-3 rounded-xl theme-accent border theme-border theme-secondary focus:ring-2 focus:ring-white/20 focus:border-white/30 transition-all bg-transparent font-['Instrument_Sans']"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="message" class="block mb-2 text-sm font-medium theme-secondary font-['Instrument_Sans']">Your Message</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows="5"
|
||||
class="w-full px-4 py-3 rounded-xl theme-accent border theme-border theme-secondary focus:ring-2 focus:ring-white/20 focus:border-white/30 bg-transparent font-['Instrument_Sans']"
|
||||
required
|
||||
aria-required="true"
|
||||
></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
class="animate-button w-full sm:w-auto inline-flex items-center justify-center gap-2 px-8 py-4 theme-button-primary rounded-xl transition-all text-base tracking-tight font-medium group shadow-sm font-['Instrument_Sans']"
|
||||
>
|
||||
Send Message
|
||||
<svg
|
||||
class="w-5 h-5 transform group-hover:translate-x-1 transition-transform"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M5 12h14M12 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="form-status" class="mt-4 text-sm hidden font-['Instrument_Sans']"></div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h3 class="text-2xl font-semibold theme-primary mb-4 font-['DM_Sans']">Contact Information</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="p-2 rounded-xl theme-bg-05 border theme-border theme-text-70 mt-1">
|
||||
<FaEnvelope className="w-5 h-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h4 class="theme-secondary font-medium font-['Instrument_Sans']">Email</h4>
|
||||
<a href="mailto:jleibl@proton.me" class="theme-primary hover:underline font-['Instrument_Sans']">jleibl@proton.me</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="p-2 rounded-xl theme-bg-05 border theme-border theme-text-70 mt-1">
|
||||
<FaMapMarkerAlt className="w-5 h-5" />
|
||||
</span>
|
||||
<div>
|
||||
<h4 class="theme-secondary font-medium font-['Instrument_Sans']">Location</h4>
|
||||
<p class="theme-primary font-['Instrument_Sans']">Bremen, Germany</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-2xl font-semibold theme-primary mb-4 font-['DM_Sans']">Connect</h3>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<a
|
||||
href="https://github.com/AtomicWasTaken"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="GitHub profile"
|
||||
class="p-3 sm:p-4 rounded-xl theme-bg-05 border theme-border theme-text-70 hover:theme-text-90 hover:scale-105 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<FaGithub className="w-6 h-6" />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.linkedin.com/in/janmarlonleibl/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="LinkedIn profile"
|
||||
class="p-3 sm:p-4 rounded-xl theme-bg-05 border theme-border theme-text-70 hover:theme-text-90 hover:scale-105 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<FaLinkedin className="w-6 h-6" />
|
||||
</a>
|
||||
<a
|
||||
href="https://jleibl.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="My website"
|
||||
class="p-3 sm:p-4 rounded-xl theme-bg-05 border theme-border theme-text-70 hover:theme-text-90 hover:scale-105 transition-all duration-300 shadow-sm"
|
||||
>
|
||||
<FaGlobe className="w-6 h-6" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FadeIn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
(window as any).openEmailClient = (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
const name = formData.get('name') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const subject = formData.get('subject') as string || 'Contact Form Message';
|
||||
const message = formData.get('message') as string;
|
||||
|
||||
const formStatus = document.getElementById('form-status');
|
||||
if (!name || !email || !message) {
|
||||
if (formStatus) {
|
||||
formStatus.textContent = 'Please fill in all required fields.';
|
||||
formStatus.classList.remove('hidden');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = `Name: ${name}\nEmail: ${email}\n\n${message}`;
|
||||
window.location.href = `mailto:jleibl@proton.me?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
||||
|
||||
return false;
|
||||
};
|
||||
});
|
||||
</script>
|
@ -6,7 +6,7 @@ import GermanyFlag from '../ui/GermanyFlag.astro';
|
||||
<section class="min-h-[100dvh] flex items-center justify-center px-4 sm:px-8 relative pt-24 pb-16" id="hero">
|
||||
<div class="absolute inset-0 theme-bg-gradient opacity-80"></div>
|
||||
|
||||
<div class="max-w-7xl w-full relative pt-8 sm:pt-24">
|
||||
<div class="max-w-7xl w-full relative pt-8 sm:pt-4">
|
||||
<FadeIn className="space-y-10 sm:space-y-16">
|
||||
<div class="space-y-8 sm:space-y-10">
|
||||
<div class="space-y-6 sm:space-y-8">
|
||||
|
50
src/components/ui/SkipToContent.astro
Normal file
50
src/components/ui/SkipToContent.astro
Normal file
@ -0,0 +1,50 @@
|
||||
<a
|
||||
href="#main-content"
|
||||
class="skip-to-content"
|
||||
aria-label="Skip to main content"
|
||||
>
|
||||
<span class="font-['Instrument_Sans']">Skip to content</span>
|
||||
</a>
|
||||
|
||||
<style>
|
||||
.skip-to-content {
|
||||
position: absolute;
|
||||
top: -100px;
|
||||
left: 0;
|
||||
padding: 1rem;
|
||||
margin: 1rem;
|
||||
background: black;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
z-index: 100;
|
||||
transition: top 0.3s ease-out;
|
||||
font-weight: 500;
|
||||
font-family: 'Instrument Sans', sans-serif;
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.skip-to-content:focus {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Theme variations */
|
||||
html[data-theme="red"] .skip-to-content {
|
||||
background: #170c0c;
|
||||
color: #f87171;
|
||||
border-color: rgba(248, 113, 113, 0.15);
|
||||
}
|
||||
|
||||
html[data-theme="gold"] .skip-to-content {
|
||||
background: #121107;
|
||||
color: #fbbf24;
|
||||
border-color: rgba(251, 191, 36, 0.15);
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
:root.dark .skip-to-content {
|
||||
background: #0A0A0A;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
</style>
|
@ -1,8 +1,12 @@
|
||||
---
|
||||
import Header from '../components/layout/Header.astro';
|
||||
import Meta from '../components/layout/Meta.astro';
|
||||
import SkipToContent from '../components/ui/SkipToContent.astro';
|
||||
import Hero from '../components/sections/Hero.astro';
|
||||
import About from '../components/sections/About.astro';
|
||||
import Work from '../components/sections/Work.astro';
|
||||
import BlogPreview from '../components/sections/BlogPreview.astro';
|
||||
import Contact from '../components/sections/Contact.astro';
|
||||
import Footer from '../components/layout/Footer.astro';
|
||||
|
||||
import '../styles/global.css';
|
||||
@ -18,52 +22,30 @@ import '@fontsource/instrument-sans/500.css';
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Jan-Marlon Leibl • Fullstack Software Developer | PHP & TypeScript Expert</title>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<meta name="title" content="Jan-Marlon Leibl • Fullstack Software Developer | PHP & TypeScript Expert" />
|
||||
<meta name="description" content="Experienced Fullstack Developer specializing in PHP and TypeScript. Creating high-performance web applications and digital experiences with modern technologies." />
|
||||
<meta name="keywords" content="Software Development, PHP Developer, TypeScript, Fullstack Engineer, Web Development, System Architecture, MySQL, React, Performance Optimization" />
|
||||
<meta name="author" content="Jan-Marlon Leibl" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://jleibl.net/" />
|
||||
<meta property="og:title" content="Jan-Marlon Leibl • Fullstack Software Developer" />
|
||||
<meta property="og:description" content="Experienced Fullstack Developer specializing in PHP and TypeScript. Creating high-performance web applications and digital experiences with modern technologies." />
|
||||
<meta property="og:image" content="https://jleibl.net/profile-image.jpg" />
|
||||
<meta property="og:image:width" content="400" />
|
||||
<meta property="og:image:height" content="400" />
|
||||
<meta property="og:image:alt" content="Jan-Marlon Leibl - Fullstack Software Developer" />
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:url" content="https://jleibl.net/" />
|
||||
<meta name="twitter:title" content="Jan-Marlon Leibl • Fullstack Software Developer" />
|
||||
<meta name="twitter:description" content="Experienced Fullstack Developer specializing in PHP and TypeScript. Creating high-performance web applications and digital experiences with modern technologies." />
|
||||
<meta name="twitter:image" content="https://jleibl.net/profile-image.jpg" />
|
||||
<meta name="twitter:image:alt" content="Jan-Marlon Leibl - Fullstack Software Developer" />
|
||||
|
||||
<link rel="icon" href="/profile-image.jpg" type="image/jpeg" />
|
||||
<link rel="canonical" href="https://jleibl.net/" />
|
||||
<Meta />
|
||||
</head>
|
||||
<body>
|
||||
<SkipToContent />
|
||||
<div id="app-root" class="min-h-screen theme-transition black overflow-hidden">
|
||||
<Header />
|
||||
<Hero />
|
||||
<About />
|
||||
<Work />
|
||||
<main id="main-content">
|
||||
<Hero />
|
||||
<About />
|
||||
<Work />
|
||||
<BlogPreview />
|
||||
<Contact />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<script>
|
||||
// Theme Provider functionality
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const appRoot = document.getElementById('app-root');
|
||||
const themeButtons = document.querySelectorAll('.theme-button');
|
||||
const currentTheme = localStorage.getItem('theme') || 'black';
|
||||
|
||||
const currentTheme = localStorage.getItem('colorTheme') || 'black';
|
||||
|
||||
if (appRoot) {
|
||||
appRoot.classList.remove('black', 'red', 'gold');
|
||||
@ -80,13 +62,15 @@ import '@fontsource/instrument-sans/500.css';
|
||||
if (appRoot && newTheme) {
|
||||
appRoot.classList.remove('black', 'red', 'gold');
|
||||
appRoot.classList.add(newTheme);
|
||||
|
||||
localStorage.setItem('colorTheme', newTheme);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import Lenis from '@studio-freight/lenis';
|
||||
import Lenis from 'lenis';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const lenis = new Lenis({
|
||||
@ -95,6 +79,7 @@ import '@fontsource/instrument-sans/500.css';
|
||||
smoothWheel: true,
|
||||
wheelMultiplier: 1,
|
||||
touchMultiplier: 2,
|
||||
infinite: false
|
||||
});
|
||||
|
||||
const anchorLinks = document.querySelectorAll('a[href^="#"]');
|
||||
@ -117,11 +102,62 @@ import '@fontsource/instrument-sans/500.css';
|
||||
});
|
||||
});
|
||||
|
||||
function raf(time) {
|
||||
let animationId: number;
|
||||
|
||||
function raf(time: number) {
|
||||
lenis.raf(time);
|
||||
requestAnimationFrame(raf);
|
||||
animationId = requestAnimationFrame(raf);
|
||||
}
|
||||
|
||||
requestAnimationFrame(raf);
|
||||
animationId = requestAnimationFrame(raf);
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
cancelAnimationFrame(animationId);
|
||||
} else {
|
||||
animationId = requestAnimationFrame(raf);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if ('loading' in HTMLImageElement.prototype) {
|
||||
const lazyImages = document.querySelectorAll('img[data-src]');
|
||||
lazyImages.forEach(img => {
|
||||
const imgElement = img as HTMLImageElement;
|
||||
imgElement.src = imgElement.dataset.src || '';
|
||||
imgElement.removeAttribute('data-src');
|
||||
});
|
||||
} else {
|
||||
const lazyImageObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const lazyImage = entry.target as HTMLImageElement;
|
||||
if (lazyImage.dataset.src) {
|
||||
lazyImage.src = lazyImage.dataset.src;
|
||||
lazyImage.removeAttribute('data-src');
|
||||
}
|
||||
lazyImageObserver.unobserve(lazyImage);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('img[data-src]').forEach(image => {
|
||||
lazyImageObserver.observe(image);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
if ('serviceWorker' in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/service-worker.js')
|
||||
.catch(error => {
|
||||
console.error('Service worker registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
</script>
|
@ -55,7 +55,6 @@
|
||||
|
||||
.black .nav-item-active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-bottom: 2px solid rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
.red .nav-item-active {
|
||||
background: rgba(248, 113, 113, 0.08);
|
||||
|
Reference in New Issue
Block a user