<!DOCTYPE html><html lang="en"> <head><!-- Theme flash prevention script - must be first in head --><script> // Immediately apply the saved theme to prevent flash (function() { function getThemePreference() { if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) { return localStorage.getItem('theme'); } return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } const theme = getThemePreference(); // Apply theme immediately to prevent flash if (theme === 'dark') { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } // Store the theme in localStorage for future visits if (typeof localStorage !== 'undefined') { localStorage.setItem('theme', theme); } // Add a class to indicate JS is loaded and theme is applied document.documentElement.classList.add('theme-loaded'); })(); </script><!-- Service worker registration --><script> // Register service worker for offline support and caching if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/service-worker.js') .then(registration => { console.log('SW registered: ', registration.scope); }) .catch(error => { console.log('SW registration failed: ', error); }); }); } </script><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="generator" content="Astro v5.7.5"><!-- Favicons --><link rel="icon" type="image/svg+xml" href="/favicons/favicon.png"><link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png"><link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png"><link rel="manifest" href="/site.webmanifest"><!-- Theme colors --><meta name="theme-color" content="#282828" media="(prefers-color-scheme: dark)"><meta name="theme-color" content="#ebdbb2" media="(prefers-color-scheme: light)"><!-- Preconnect to external resources --><link rel="preconnect" href="https://fonts.bunny.net" crossorigin><link rel="dns-prefetch" href="https://fonts.bunny.net"><link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin><link rel="dns-prefetch" href="https://cdn.jsdelivr.net"><link rel="preconnect" href="https://unpkg.com" crossorigin><link rel="dns-prefetch" href="https://unpkg.com"><!-- Preload critical fonts with font-display: swap --><link rel="preload" href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i&display=swap" as="style"><link href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i&display=swap" rel="stylesheet"><!-- Preload critical font files --><link rel="preload" href="https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff2" as="font" type="font/woff2" crossorigin><!-- Preload Alpine.js --><link rel="preload" href="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" as="script"><script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script><!-- Font loading observer --><script> if ("fonts" in document) { Promise.all([ document.fonts.load("1em IBM Plex Mono"), document.fonts.load("1em press-start-2p") ]).then(() => { document.documentElement.classList.add("fonts-loaded"); }); } else { document.documentElement.classList.add("fonts-loaded"); } </script><!-- Time-based theme scheduler --><script> // Time-based theme switching (function() { // Only run if user has selected auto mode const themeMode = localStorage.getItem('theme-mode'); if (themeMode === 'auto') { const checkTime = () => { const hour = new Date().getHours(); const isDayTime = hour >= 7 && hour < 19; // 7 AM to 7 PM const theme = isDayTime ? 'light' : 'dark'; const currentTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light'; if (theme !== currentTheme) { if (theme === 'dark') { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } localStorage.setItem('theme', theme); // Dispatch theme changed event window.dispatchEvent(new CustomEvent('theme-changed', { detail: { theme, automatic: true, mode: 'auto' } })); } }; // Check immediately checkTime(); // Check every hour setInterval(checkTime, 60 * 60 * 1000); // Also check at specific times (7 AM and 7 PM) const scheduleCheck = () => { const now = new Date(); const hours = now.getHours(); const minutes = now.getMinutes(); // Calculate time until next check (either 7 AM or 7 PM) let nextCheckHour; if (hours < 7) { nextCheckHour = 7; // Next check at 7 AM } else if (hours < 19) { nextCheckHour = 19; // Next check at 7 PM } else { nextCheckHour = 7; // Next check at 7 AM tomorrow } // Calculate milliseconds until next check let msUntilNextCheck; if (hours >= 19) { // After 7 PM, next check is 7 AM tomorrow msUntilNextCheck = ((24 - hours + 7) * 60 - minutes) * 60 * 1000; } else { // Before 7 PM, next check is either 7 AM or 7 PM today msUntilNextCheck = ((nextCheckHour - hours) * 60 - minutes) * 60 * 1000; } // Schedule the next check setTimeout(() => { checkTime(); scheduleCheck(); // Schedule the next check after this one }, msUntilNextCheck); }; // Start the scheduling scheduleCheck(); } })(); </script><!-- Theme transition script --><script type="module">document.addEventListener("DOMContentLoaded",()=>{window.addEventListener("theme-changed",t=>{const a=(t instanceof CustomEvent?t:null)?.detail?.theme||(document.documentElement.classList.contains("dark")?"dark":"light");window.matchMedia("(prefers-reduced-motion: reduce)").matches||(document.querySelectorAll("h1, h2, h3").forEach((e,o)=>{e.classList.remove("theme-animate-slide"),e.offsetWidth,setTimeout(()=>{e.classList.add("theme-animate-slide")},50+o*30)}),document.querySelectorAll(".card, article, section:not(section section)").forEach((e,o)=>{e.classList.remove("theme-animate-scale"),e.offsetWidth,setTimeout(()=>{e.classList.add("theme-animate-scale")},100+o*40)}),document.querySelectorAll("img, svg").forEach((e,o)=>{e.classList.remove("theme-animate-fade"),e.offsetWidth,setTimeout(()=>{e.classList.add("theme-animate-fade")},150+o*20)}),a==="dark"?(document.querySelectorAll("code, pre").forEach(e=>{e.classList.add("dark-theme-code")}),document.querySelectorAll(".button-primary, .cta-button").forEach(e=>{e.classList.add("dark-theme-glow")})):(document.querySelectorAll("code, pre").forEach(e=>{e.classList.remove("dark-theme-code")}),document.querySelectorAll(".button-primary, .cta-button").forEach(e=>{e.classList.remove("dark-theme-glow")})))})});const d=()=>{const t=document.createElement("style");t.textContent=` .dark-theme-code { box-shadow: 0 0 8px rgba(254, 128, 25, 0.3); } .dark-theme-glow { box-shadow: 0 0 12px rgba(254, 128, 25, 0.4); } @media (prefers-reduced-motion: reduce) { .theme-animate-fade, .theme-animate-slide, .theme-animate-scale { animation: none !important; transition: none !important; } } `,document.head.appendChild(t)};document.addEventListener("DOMContentLoaded",d);</script><script> /** * Initialize search functionality for any content type * @returns {Object} Alpine.js data object with search functionality */ function initializeSearch(contentSelector = '.searchable-item', options = {}) { const defaults = { nameAttribute: 'data-name', tagsAttribute: 'data-tags', categoryAttribute: 'data-category', additionalAttributes: [], noResultsMessage: 'No results found', allItemsMessage: 'Showing all items', resultCountMessage: (count) => `Found ${count} items`, itemLabel: 'items' }; const config = { ...defaults, ...options }; return { searchQuery: '', hasResults: true, visibleCount: 0, loading: false, // Start with loading state false - the LoadingManager will control this init() { // Initialize the visible count this.visibleCount = document.querySelectorAll(contentSelector).length; this.setupWatchers(); this.setupKeyboardShortcuts(); // Handle theme changes window.addEventListener('theme-changed', () => { this.filterContent(this.searchQuery); }); }, setupWatchers() { this.$watch('searchQuery', (query) => { // Filter content immediately - no artificial delay this.filterContent(query); }); }, setupKeyboardShortcuts() { // Track the currently focused item index this.focusedItemIndex = -1; document.addEventListener('keydown', (e) => { // '/' key focuses the search input if (e.key === '/' && document.activeElement.id !== 'app-search') { e.preventDefault(); document.getElementById('app-search').focus(); } // Escape key clears the search if (e.key === 'Escape' && this.searchQuery !== '') { this.searchQuery = ''; document.getElementById('app-search').focus(); this.focusedItemIndex = -1; this.clearItemFocus(); } // Arrow key navigation through results if (this.searchQuery && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { e.preventDefault(); const visibleItems = this.getVisibleItems(); if (visibleItems.length === 0) return; // Update focused item index if (e.key === 'ArrowDown') { this.focusedItemIndex = Math.min(this.focusedItemIndex + 1, visibleItems.length - 1); } else { this.focusedItemIndex = Math.max(this.focusedItemIndex - 1, -1); } // Clear previous focus this.clearItemFocus(); // If we're back at -1, focus the search input if (this.focusedItemIndex === -1) { document.getElementById('app-search').focus(); return; } // Focus the new item const itemToFocus = visibleItems[this.focusedItemIndex]; this.focusItem(itemToFocus); } // Enter key selects the focused item if (e.key === 'Enter' && this.focusedItemIndex >= 0) { const visibleItems = this.getVisibleItems(); if (visibleItems.length === 0) return; const selectedItem = visibleItems[this.focusedItemIndex]; const link = selectedItem.querySelector('a'); if (link) { link.click(); } } }); }, getVisibleItems() { return Array.from(document.querySelectorAll(contentSelector)) .filter(item => item.style.display !== 'none'); }, clearItemFocus() { // Remove focus styling from all items document.querySelectorAll(`${contentSelector}.keyboard-focus`).forEach(item => { item.classList.remove('keyboard-focus'); }); }, focusItem(item) { // Add focus styling item.classList.add('keyboard-focus'); item.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, filterContent(query) { query = query.toLowerCase(); let anyResults = false; let visibleCount = 0; // Process all content items document.querySelectorAll(contentSelector).forEach((item) => { // Get searchable attributes const name = (item.getAttribute(config.nameAttribute) || '').toLowerCase(); const tags = (item.getAttribute(config.tagsAttribute) || '').toLowerCase(); const category = (item.getAttribute(config.categoryAttribute) || '').toLowerCase(); // Check additional attributes if specified const additionalMatches = config.additionalAttributes.some(attr => { const value = (item.getAttribute(attr) || '').toLowerCase(); return value.includes(query); }); const isMatch = query === '' || name.includes(query) || tags.includes(query) || category.includes(query) || additionalMatches; if (isMatch) { item.style.display = ''; anyResults = true; visibleCount++; } else { item.style.display = 'none'; } }); // Update category visibility for homelab page this.updateCategoryVisibility(query); // Update parent containers if needed this.updateContainerVisibility(query); this.updateResultsStatus(query, anyResults, visibleCount); }, updateCategoryVisibility(query) { // Only proceed if we have category sections (homelab page) const categorySections = document.querySelectorAll('.category-section'); if (categorySections.length === 0) return; // For each category section, check if it has any visible app cards categorySections.forEach((categorySection) => { const categoryId = categorySection.getAttribute('data-category'); const appCards = categorySection.querySelectorAll('.app-card'); // Count visible app cards in this category const visibleApps = Array.from(appCards).filter(card => card.style.display !== 'none' ).length; // If no visible apps and we're searching, hide the category if (query !== '' && visibleApps === 0) { categorySection.style.display = 'none'; } else { categorySection.style.display = ''; } }); }, updateContainerVisibility(query) { // If there are container elements that should be hidden when empty const containers = document.querySelectorAll('.content-container'); if (containers.length > 0) { containers.forEach((container) => { const hasVisibleItems = Array.from( container.querySelectorAll(contentSelector) ).some((item) => item.style.display !== 'none'); if (query === '' || hasVisibleItems) { container.style.display = ''; } else { container.style.display = 'none'; } }); } }, updateResultsStatus(query, anyResults, count) { // Update results status this.hasResults = query === '' || anyResults; this.visibleCount = count; // Update screen reader status const statusEl = document.getElementById('search-status'); if (statusEl) { if (query === '') { statusEl.textContent = config.allItemsMessage; this.visibleCount = document.querySelectorAll(contentSelector).length; } else if (this.hasResults) { statusEl.textContent = config.resultCountMessage(count); } else { statusEl.textContent = config.noResultsMessage; } } } }; } // Register Alpine.js data components when Alpine is loaded document.addEventListener('alpine:init', () => { // Homelab search window.Alpine.data('searchServices', () => { const baseSearch = initializeSearch('.app-card', { nameAttribute: 'data-app-name', tagsAttribute: 'data-app-tags', categoryAttribute: 'data-app-category', noResultsMessage: 'No services found', allItemsMessage: 'Showing all services', resultCountMessage: (count) => `Found ${count} services`, itemLabel: 'services' }); // Add icon size slider functionality return { ...baseSearch, iconSizeValue: 2, // Slider value: 1=small, 2=medium, 3=large iconSize: 'medium', // small, medium, large viewMode: 'grid', // grid or list displayMode: 'both', // both, image, or name debounceTimeout: null, // For debouncing slider changes init() { baseSearch.init.call(this); // Apply initial icon size, view mode, and display mode this.applyIconSize(); this.applyViewMode(); this.applyDisplayMode(); }, // Icon size methods setIconSize(size) { if (typeof size === 'string') { // Handle legacy string values (small, medium, large) this.iconSize = size; this.iconSizeValue = size === 'small' ? 1 : size === 'medium' ? 2 : 3; } else { // Handle slider numeric values this.iconSizeValue = parseFloat(size); // Map slider value to size name if (this.iconSizeValue <= 1.33) { this.iconSize = 'small'; } else if (this.iconSizeValue <= 2.33) { this.iconSize = 'medium'; } else { this.iconSize = 'large'; } } this.applyIconSize(); }, // Handle slider input with debounce handleSliderChange(event) { const value = event.target.value; // Clear any existing timeout if (this.debounceTimeout) { clearTimeout(this.debounceTimeout); } // Set a new timeout this.debounceTimeout = setTimeout(() => { this.setIconSize(value); }, 50); // 50ms debounce }, applyIconSize() { const appList = document.getElementById('app-list'); if (!appList) return; // Remove existing size classes appList.classList.remove('icon-size-small', 'icon-size-medium', 'icon-size-large'); // Add the new size class appList.classList.add(`icon-size-${this.iconSize}`); // Apply custom CSS variable for fine-grained control appList.style.setProperty('--icon-scale', this.iconSizeValue); }, // View mode methods toggleViewMode() { this.viewMode = this.viewMode === 'grid' ? 'list' : 'grid'; this.applyViewMode(); }, setViewMode(mode) { this.viewMode = mode; this.applyViewMode(); }, applyViewMode() { const appList = document.getElementById('app-list'); if (!appList) return; // Remove existing view mode classes appList.classList.remove('view-mode-grid', 'view-mode-list'); // Add the new view mode class appList.classList.add(`view-mode-${this.viewMode}`); // Update all category sections document.querySelectorAll('.category-section').forEach(section => { const gridContainer = section.querySelector('.grid'); if (gridContainer) { // Update grid classes based on view mode if (this.viewMode === 'grid') { gridContainer.classList.remove('grid-cols-1'); gridContainer.classList.add('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4'); } else { gridContainer.classList.remove('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4'); gridContainer.classList.add('grid-cols-1'); } } }); }, // Display mode methods setDisplayMode(mode) { this.displayMode = mode; this.applyDisplayMode(); }, applyDisplayMode() { const appList = document.getElementById('app-list'); if (!appList) return; // Remove existing display mode classes appList.classList.remove('display-both', 'display-image-only', 'display-name-only'); // Add the new display mode class if (this.displayMode === 'image') { appList.classList.add('display-image-only'); } else if (this.displayMode === 'name') { appList.classList.add('display-name-only'); } else { appList.classList.add('display-both'); } // Update all category sections document.querySelectorAll('.category-section').forEach(section => { const gridContainer = section.querySelector('.grid'); if (gridContainer) { // Update grid classes based on view mode if (this.viewMode === 'grid') { gridContainer.classList.remove('grid-cols-1'); gridContainer.classList.add('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4'); } else { gridContainer.classList.remove('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4'); gridContainer.classList.add('grid-cols-1'); } } }); } }; }); // Blog search window.Alpine.data('searchArticles', () => { return initializeSearch('.article-item', { nameAttribute: 'data-title', tagsAttribute: 'data-tags', additionalAttributes: ['data-description'], noResultsMessage: 'No articles found', allItemsMessage: 'Showing all articles', resultCountMessage: (count) => `Found ${count} articles`, itemLabel: 'articles' }); }); // Projects search window.Alpine.data('searchProjects', () => { return initializeSearch('.project-item', { nameAttribute: 'data-title', tagsAttribute: 'data-tags', additionalAttributes: ['data-description', 'data-github', 'data-live'], noResultsMessage: 'No projects found', allItemsMessage: 'Showing all projects', resultCountMessage: (count) => `Found ${count} projects`, itemLabel: 'projects' }); }); }); </script> <title>Projects and Code • Justin Deal</title> <meta name="description" content="All of my projects, including both frontend and full-stack applications."> <meta property="og:title" content="Projects and Code • Justin Deal"> <meta property="og:description" content="A list of my web development projects and developer tools."> <meta property="og:image" content="https://justin.deal/pixel_avatar.png"> <meta property="og:url" content="https://justin.deal/projects"> <meta name="twitter:card" content="summary_large_image"> <meta name="twitter:title" content="Projects and Code • Justin Deal"> <meta name="twitter:description" content="A list of my web development projects and developer tools."> <meta name="twitter:image" content="https://justin.deal/pixel_avatar.png"> <meta http-equiv="content-language" content="en"> <meta name="language" content="English"> <link rel="canonical" href="https://justin.deal/projects"> <style>.skeleton-loader[data-astro-cid-shdhheiq]{position:relative;overflow:hidden;background-color:var(--color-zag-light-muted)}.dark .skeleton-loader[data-astro-cid-shdhheiq]{background-color:var(--color-zag-dark-muted)}.skeleton-loader[data-astro-cid-shdhheiq]:after{content:"";position:absolute;inset:0;transform:translate(-100%);background-image:linear-gradient(90deg,#fff0 0,#ffffff1a 20%,#fff3 60%,#fff0);animation:shimmer 2s infinite}.dark .skeleton-loader[data-astro-cid-shdhheiq]:after{background-image:linear-gradient(90deg,#0000 0,#0000001a 20%,#0003 60%,#0000)}@keyframes shimmer{to{transform:translate(100%)}}.animate-pulse[data-astro-cid-shdhheiq]{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes pulse{0%,to{opacity:1}50%{opacity:.7}} .search-input[data-astro-cid-3nswioeq]{box-shadow:2px 2px 0 var(--color-zag-dark)}:where(.dark,.dark *)[data-astro-cid-3nswioeq] .search-input[data-astro-cid-3nswioeq]{box-shadow:2px 2px 0 var(--color-zag-light)} </style> <link rel="stylesheet" href="/_astro/jellyfin-at-home.z5IOlzDh.css"></head> <body class="zag-bg zag-text zag-transition font-mono"> <!-- Loading overlay for long loading times --> <div class="loading-overlay fixed inset-0 flex flex-col items-center justify-center z-50 bg-opacity-90 zag-bg " role="alert" aria-live="polite" data-astro-cid-veun55td> <div class="loading-content text-center p-8 rounded-lg" data-astro-cid-veun55td> <!-- Enhanced loading indicator --> <div class="mb-6 flex justify-center" data-astro-cid-veun55td> <div class="loading-dots w-12 h-12" style="--dots-color:var(--color-zag-accent-dark)" data-astro-cid-ypbmo55r><div class="dot" data-astro-cid-ypbmo55r></div><div class="dot" data-astro-cid-ypbmo55r></div><div class="dot" data-astro-cid-ypbmo55r></div></div> </div> <!-- Messages with animation --> <div class="messages-container" data-astro-cid-veun55td> <p class="text-xl font-semibold mb-2 zag-text message-animate" data-astro-cid-veun55td>This is taking longer than expected...</p> <p class="text-base zag-text-muted message-animate-delay" data-astro-cid-veun55td>Still working on loading your content</p> </div> </div> </div> <script type="module">class e{overlay=null;timeoutId=null;longLoadingTimeoutId=null;constructor(){this.overlay=document.querySelector(".loading-overlay"),this.setupNavigationListeners()}setupNavigationListeners(){"navigation"in window?(window.navigation.addEventListener("navigate",i=>{new URL(i.destination.url).origin===window.location.origin&&this.showLoading()}),window.navigation.addEventListener("navigatesuccess",()=>{this.hideLoading()}),window.navigation.addEventListener("navigateerror",()=>{this.hideLoading()})):(window.addEventListener("beforeunload",()=>{this.showLoading()}),document.addEventListener("astro:page-load",()=>{this.hideLoading()})),document.addEventListener("astro:before-swap",()=>{this.showLoading()}),document.addEventListener("astro:after-swap",()=>{this.hideLoading()})}showLoading(){this.timeoutId&&(clearTimeout(this.timeoutId),this.timeoutId=null),this.longLoadingTimeoutId&&(clearTimeout(this.longLoadingTimeoutId),this.longLoadingTimeoutId=null);const i=this.getConnectionBasedDelay();this.timeoutId=setTimeout(()=>{document.querySelectorAll("[x-data]").forEach(t=>{t.__x&&typeof t.__x.$data.loading<"u"&&(t.__x.$data.loading=!0)})},i),this.longLoadingTimeoutId=setTimeout(()=>{this.overlay&&this.overlay.classList.add("visible")},5e3)}hideLoading(){this.timeoutId&&(clearTimeout(this.timeoutId),this.timeoutId=null),this.longLoadingTimeoutId&&(clearTimeout(this.longLoadingTimeoutId),this.longLoadingTimeoutId=null),this.overlay&&this.overlay.classList.remove("visible"),setTimeout(()=>{document.querySelectorAll("[x-data]").forEach(i=>{i.__x&&typeof i.__x.$data.loading<"u"&&(i.__x.$data.loading=!1)})},100)}getConnectionBasedDelay(){const i=navigator.connection||navigator.mozConnection||navigator.webkitConnection;if(i)switch(i.effectiveType){case"4g":return 100;case"3g":return 200;case"2g":case"slow-2g":return 0;default:return 100}return 100}}document.addEventListener("DOMContentLoaded",()=>{new e});</script> <!-- Theme-specific background patterns and transition effect --> <div class="theme-background" aria-hidden="true" data-astro-cid-nzjwcpgp> <div class="light-pattern" data-astro-cid-nzjwcpgp></div> <div class="dark-pattern" data-astro-cid-nzjwcpgp></div> </div> <div id="themeTransitionOverlay" class="theme-transition-overlay" aria-hidden="true" data-astro-cid-dewpavae></div> <script type="module">document.addEventListener("DOMContentLoaded",()=>{const t=document.getElementById("themeTransitionOverlay");if(!t)return;let o=document.documentElement.classList.contains("dark")?"dark":"light";window.addEventListener("theme-changed",a=>{const s=a.detail.theme;if(s===o)return;const n=document.getElementById("themeToggle");let i="50%",d="50%";if(n){const e=n.getBoundingClientRect();i=`${e.left+e.width/2}px`,d=`${e.top+e.height/2}px`}t.style.setProperty("--x",i),t.style.setProperty("--y",d),s==="dark"?(t.classList.add("light-to-dark"),t.classList.remove("dark-to-light")):(t.classList.add("dark-to-light"),t.classList.remove("light-to-dark")),t.classList.add("active"),setTimeout(()=>{t.classList.remove("active"),t.classList.remove("light-to-dark"),t.classList.remove("dark-to-light")},500),o=s})});</script> <header class="zag-bg zag-border-b zag-transition sticky top-0 w-full z-10"> <div class="zag-bg zag-transition sm:hidden relative z-50 py-4 flex items-center"> <button class="px-4" aria-label="Toggle navigation menu"> <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 512 512" class="zag-fill zag-transition"> <path d="M80 96h352v32H80zm0 144h352v32H80zm0 144h352v32H80z"></path> </svg> </button> </div> <nav class="zag-bg zag-border-b zag-transition fixed sm:relative inset-x-0 top-0 h-auto sm:px-4 flex justify-between flex-col gap-8 py-4 text-xl sm:flex-row max-w-2xl mx-auto sm:pt-4 sm:border-none"> <div class="flex flex-col font-mono font-medium gap-4 sm:flex-row px-4 sm:px-0 mt-16 sm:mt-0"> <a href="/" class="zag-link zag-interactive font-medium flex items-center" target="_self"> home </a><a href="/about" class="zag-link zag-interactive font-medium flex items-center" target="_self"> about </a><a href="/blog" class="zag-link zag-interactive font-medium flex items-center" target="_self"> blog </a><a href="/projects" class="zag-link zag-interactive font-medium flex items-center" target="_self"> projects </a><a href="/homelab" class="zag-link zag-interactive font-medium flex items-center" target="_self"> homelab </a><a href="https://code.justin.deal" class="zag-link zag-interactive font-medium flex items-center" target="_self"> code </a> </div> <div class="flex gap-4 justify-between px-4 sm:px-0"> <div class="theme-toggle-container" x-data="{ open: false }" data-astro-cid-x3pjskd3> <button class="theme-toggle-button zag-interactive" id="themeToggle" aria-label="Theme Toggle" @click="open = !open" data-astro-cid-x3pjskd3> <!-- Sun icon for light mode --> <svg class="theme-icon sun-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" data-astro-cid-x3pjskd3> <path class="zag-transition fill-neutral-900 dark:fill-transparent" fill-rule="evenodd" d="M12 17.5a5.5 5.5 0 1 0 0-11 5.5 5.5 0 0 0 0 11zm0 1.5a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm12-7a.8.8 0 0 1-.8.8h-2.4a.8.8 0 0 1 0-1.6h2.4a.8.8 0 0 1 .8.8zM4 12a.8.8 0 0 1-.8.8H.8a.8.8 0 0 1 0-1.6h2.5a.8.8 0 0 1 .8.8zm16.5-8.5a.8.8 0 0 1 0 1l-1.8 1.8a.8.8 0 0 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM6.3 17.7a.8.8 0 0 1 0 1l-1.7 1.8a.8.8 0 1 1-1-1l1.7-1.8a.8.8 0 0 1 1 0zM12 0a.8.8 0 0 1 .8.8v2.5a.8.8 0 0 1-1.6 0V.8A.8.8 0 0 1 12 0zm0 20a.8.8 0 0 1 .8.8v2.4a.8.8 0 0 1-1.6 0v-2.4a.8.8 0 0 1 .8-.8zM3.5 3.5a.8.8 0 0 1 1 0l1.8 1.8a.8.8 0 1 1-1 1L3.5 4.6a.8.8 0 0 1 0-1zm14.2 14.2a.8.8 0 0 1 1 0l1.8 1.7a.8.8 0 0 1-1 1l-1.8-1.7a.8.8 0 0 1 0-1z" data-astro-cid-x3pjskd3></path> </svg> <!-- Moon icon for dark mode --> <svg class="theme-icon moon-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" data-astro-cid-x3pjskd3> <path class="zag-transition fill-transparent dark:fill-neutral-100" fill-rule="evenodd" d="M16.5 6A10.5 10.5 0 0 1 4.7 16.4 8.5 8.5 0 1 0 16.4 4.7l.1 1.3zm-1.7-2a9 9 0 0 1 .2 2 9 9 0 0 1-11 8.8 9.4 9.4 0 0 1-.8-.3c-.4 0-.8.3-.7.7a10 10 0 0 0 .3.8 10 10 0 0 0 9.2 6 10 10 0 0 0 4-19.2 9.7 9.7 0 0 0-.9-.3c-.3-.1-.7.3-.6.7a9 9 0 0 1 .3.8z" data-astro-cid-x3pjskd3></path> </svg> </button> <!-- Dropdown menu --> <div x-show="open" @click.away="open = false" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 transform scale-95" x-transition:enter-end="opacity-100 transform scale-100" x-transition:leave="transition ease-in duration-100" x-transition:leave-start="opacity-100 transform scale-100" x-transition:leave-end="opacity-0 transform scale-95" class="theme-dropdown" x-cloak data-astro-cid-x3pjskd3> <div class="theme-dropdown-content" data-astro-cid-x3pjskd3> <button class="theme-option" id="lightTheme" data-astro-cid-x3pjskd3> <svg class="theme-option-icon" viewBox="0 0 24 24" width="16" height="16" data-astro-cid-x3pjskd3> <!-- Sun icon --> <circle cx="12" cy="12" r="5" fill="currentColor" data-astro-cid-x3pjskd3></circle> <path d="M12 3V1M12 23v-2M3 12H1m22 0h-2M5.6 5.6L4.2 4.2m14.6 14.6l-1.4-1.4M5.6 18.4l-1.4 1.4M18.4 5.6l1.4-1.4" stroke="currentColor" stroke-width="2" stroke-linecap="round" data-astro-cid-x3pjskd3></path> </svg> <span data-astro-cid-x3pjskd3>Light Mode</span> </button> <button class="theme-option" id="darkTheme" data-astro-cid-x3pjskd3> <svg class="theme-option-icon" viewBox="0 0 24 24" width="16" height="16" data-astro-cid-x3pjskd3> <!-- Moon icon --> <path d="M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z" fill="currentColor" data-astro-cid-x3pjskd3></path> </svg> <span data-astro-cid-x3pjskd3>Dark Mode</span> </button> <button class="theme-option" id="systemTheme" data-astro-cid-x3pjskd3> <svg class="theme-option-icon" viewBox="0 0 24 24" width="16" height="16" data-astro-cid-x3pjskd3> <!-- System icon --> <rect x="2" y="3" width="20" height="14" rx="2" stroke="currentColor" stroke-width="2" fill="none" data-astro-cid-x3pjskd3></rect> <path d="M8 21h8M12 17v4" stroke="currentColor" stroke-width="2" stroke-linecap="round" data-astro-cid-x3pjskd3></path> </svg> <span data-astro-cid-x3pjskd3>System Preference</span> </button> <button class="theme-option" id="autoTheme" data-astro-cid-x3pjskd3> <svg class="theme-option-icon" viewBox="0 0 24 24" width="16" height="16" data-astro-cid-x3pjskd3> <!-- Clock icon --> <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" data-astro-cid-x3pjskd3></circle> <path d="M12 6v6l4 2" stroke="currentColor" stroke-width="2" stroke-linecap="round" data-astro-cid-x3pjskd3></path> </svg> <span data-astro-cid-x3pjskd3>Time-Based</span> </button> </div> </div> </div> <script type="module">document.addEventListener("DOMContentLoaded",()=>{const m=document.getElementById("lightTheme"),a=document.getElementById("darkTheme"),d=document.getElementById("systemTheme"),n=document.getElementById("autoTheme");m?.addEventListener("click",()=>{document.documentElement.classList.remove("dark"),localStorage.setItem("theme","light"),localStorage.setItem("theme-preference-explicit","true"),localStorage.setItem("theme-mode","manual"),window.dispatchEvent(new CustomEvent("theme-changed",{detail:{theme:"light",mode:"manual"}}))}),a?.addEventListener("click",()=>{document.documentElement.classList.add("dark"),localStorage.setItem("theme","dark"),localStorage.setItem("theme-preference-explicit","true"),localStorage.setItem("theme-mode","manual"),window.dispatchEvent(new CustomEvent("theme-changed",{detail:{theme:"dark",mode:"manual"}}))}),d?.addEventListener("click",()=>{const e=window.matchMedia("(prefers-color-scheme: dark)").matches;e?document.documentElement.classList.add("dark"):document.documentElement.classList.remove("dark"),localStorage.setItem("theme",e?"dark":"light"),localStorage.setItem("theme-preference-explicit","false"),localStorage.setItem("theme-mode","system"),window.dispatchEvent(new CustomEvent("theme-changed",{detail:{theme:e?"dark":"light",mode:"system"}}))}),n?.addEventListener("click",()=>{const e=new Date().getHours(),t=e>=7&&e<19;t?document.documentElement.classList.remove("dark"):document.documentElement.classList.add("dark"),localStorage.setItem("theme",t?"light":"dark"),localStorage.setItem("theme-preference-explicit","false"),localStorage.setItem("theme-mode","auto"),window.dispatchEvent(new CustomEvent("theme-changed",{detail:{theme:t?"light":"dark",mode:"auto"}}))}),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",e=>{if(localStorage.getItem("theme-mode")==="system"){const t=e.matches;t?document.documentElement.classList.add("dark"):document.documentElement.classList.remove("dark"),localStorage.setItem("theme",t?"dark":"light"),window.dispatchEvent(new CustomEvent("theme-changed",{detail:{theme:t?"dark":"light",mode:"system"}}))}}),document.getElementById("themeToggle")?.addEventListener("click",()=>{})});</script> </div> </nav> </header> <script type="module">const a=document.querySelector("button"),n=document.querySelector("nav");let t=!1;function e(){window.matchMedia("(max-width: 640px)").matches?n.style.transform=t?"translateY(0)":"translateY(-100%)":(n.style.transform="translateY(0)",t=!1)}function s(){t=!t,e()}a?.addEventListener("click",s);window.addEventListener("resize",e);e();</script> <main> <section class="p-4 max-w-2xl mx-auto py-4 my-8"> <div x-data="searchProjects" x-init="init()" x-cloak> <!-- Search container - positioned at the top --> <div class="mb-4 pt-0"> <div class="w-full"> <div class="search-container " data-astro-cid-3nswioeq> <!-- Hidden live region for screen readers --> <div id="search-status" class="sr-only" aria-live="polite" aria-atomic="true" data-astro-cid-3nswioeq> Showing all services </div> <div class="relative" data-astro-cid-3nswioeq> <label for="app-search" class="sr-only" data-astro-cid-3nswioeq>Search projects</label> <!-- Search icon --> <div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none z-10" data-astro-cid-3nswioeq> <svg class="w-4 h-4 zag-text" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-astro-cid-3nswioeq> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" data-astro-cid-3nswioeq></path> </svg> </div> <input id="app-search" type="text" x-model="searchQuery" placeholder="Search projects..." role="searchbox" aria-label="Search projects" aria-describedby="search-status search-hint" aria-controls="app-list" class="search-input w-full pl-9 pr-8 py-1.5 text-sm border-2 border-solid zag-border-b rounded-lg focus:outline-none focus:ring-2 focus:ring-current zag-text zag-bg zag-transition" data-astro-cid-3nswioeq> <button x-show="searchQuery" @click="searchQuery = ''" aria-label="Clear search" class="absolute right-2 top-1/2 transform -translate-y-1/2" data-astro-cid-3nswioeq> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 zag-text" fill="none" viewBox="0 0 24 24" stroke="currentColor" data-astro-cid-3nswioeq> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" data-astro-cid-3nswioeq></path> </svg> </button> </div> <!-- Tooltip area - shows either help text or status text --> <div class="mt-2 h-5 text-center" data-astro-cid-3nswioeq> <!-- Fixed height to prevent layout shift --> <!-- Keyboard shortcut hint - shown when search is empty --> <div id="search-hint" class="text-xs zag-text" x-show="searchQuery === ''" data-astro-cid-3nswioeq> <kbd class="px-1 py-0.5 text-xs border border-current rounded zag-text" data-astro-cid-3nswioeq>/ </kbd> to search, <kbd class="px-1 py-0.5 text-xs border border-current rounded zag-text" data-astro-cid-3nswioeq>Esc</kbd> to clear </div> <!-- Status text - shown when user is typing --> <div id="visible-status" class="text-xs zag-text" x-show="searchQuery !== ''" x-text="hasResults ? 'Found ' + visibleCount + ' ' + (visibleCount === 1 ? 'item' : 'items') : 'No results found'" data-astro-cid-3nswioeq></div> </div> </div> </div> </div> <div class="flex items-center gap-4 pt-8 pb-16"> <h1 class="font-display text-3xl sm:text-4xl leading-loose">Projects</h1> </div> <!-- No results message --> <div x-show="searchQuery !== '' && !hasResults" x-transition class="text-center py-8 my-4 border-2 border-dashed border-current zag-text rounded-lg"> <p class="text-xl font-semibold zag-text">No Projects Found</p> </div> <!-- Loading skeleton --> <div x-show="loading" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"> <div class="project-snippet-skeleton mb-8"> <div class="flex flex-col md:flex-row gap-6"> <!-- Image skeleton --> <div class="md:w-1/3"> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 100%; height: 200px;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <div class="md:w-2/3"> <!-- Title skeleton --> <div class="mb-2"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 85%; height: 1.75rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Description skeleton --> <div class="mb-4"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 100%; height: 4rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Links skeleton --> <div class="flex gap-4 mb-3"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 100px; height: 1.25rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 100px; height: 1.25rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Tags skeleton --> <div class="flex flex-wrap gap-2"> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 60px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 80px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 70px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> </div> </div> </div><div class="project-snippet-skeleton mb-8"> <div class="flex flex-col md:flex-row gap-6"> <!-- Image skeleton --> <div class="md:w-1/3"> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 100%; height: 200px;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <div class="md:w-2/3"> <!-- Title skeleton --> <div class="mb-2"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 85%; height: 1.75rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Description skeleton --> <div class="mb-4"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 100%; height: 4rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Links skeleton --> <div class="flex gap-4 mb-3"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 100px; height: 1.25rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 100px; height: 1.25rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Tags skeleton --> <div class="flex flex-wrap gap-2"> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 60px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 80px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 70px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> </div> </div> </div><div class="project-snippet-skeleton mb-8"> <div class="flex flex-col md:flex-row gap-6"> <!-- Image skeleton --> <div class="md:w-1/3"> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 100%; height: 200px;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <div class="md:w-2/3"> <!-- Title skeleton --> <div class="mb-2"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 85%; height: 1.75rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Description skeleton --> <div class="mb-4"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 100%; height: 4rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Links skeleton --> <div class="flex gap-4 mb-3"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 100px; height: 1.25rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 100px; height: 1.25rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Tags skeleton --> <div class="flex flex-wrap gap-2"> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 60px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 80px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 70px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> </div> </div> </div> </div> <!-- Actual content --> <ul id="project-list" x-show="!loading" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"> <li class="project-item" data-title="this website" data-description="my personal blog, portfolio, and homelab dashboard built with astro, typescript, tailwindcss, and alpine.js featuring smart loading, advanced search, and..." data-tags="astro typescript tailwindcss alpinejs gruvbox-theme" data-github="https://code.justin.deal/dealjus/justin.deal" data-live> <div class="flex flex-col gap-3 pb-8"> <div class="flex flex-col gap-3 sm:flex-row sm:justify-between"> <a href="/projects/this-site" class="zag-link zag-interactive font-medium flex items-center text-xl" target="_self"> This Website </a> <div class="flex gap-2"> <a href="https://code.justin.deal/dealjus/justin.deal" class="zag-link zag-interactive font-medium flex items-center text-base" target="_blank"> Source <svg xmlns="http://www.w3.org/2000/svg" class="w-6 h-6" viewBox="0 0 512 512"> <path class="zag-fill zag-transition" fill-rule="evenodd" d="M362.666 149.333V320H320l-.001-97.831l-154.51 154.51l-30.169-30.17L289.829 192h-97.83v-42.666z"></path> </svg> </a> </div> </div> <p class="zag-text zag-transition"> My personal blog, portfolio, and homelab dashboard built with Astro, TypeScript, TailwindCSS, and Alpine.js featuring smart loading, advanced search, and... </p> <div class="flex flex-wrap gap-2 w-full"> <span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold rounded-md whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]" title="astro"> astro </span><span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold rounded-md whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]" title="typescript"> typescript </span><span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold rounded-md whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]" title="tailwindcss"> tailwindcss </span><span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold rounded-md whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]" title="alpinejs"> alpinejs </span><span class="-zag-text -zag-bg zag-transition px-2 py-1 text-sm font-semibold rounded-md whitespace-nowrap overflow-hidden text-ellipsis max-w-[150px]" title="gruvbox-theme"> gruvbox-theme </span> </div> </div> </li> </ul> </div> </section> </main> <footer class="mt-16 mb-8"> <section class="p-4 max-w-2xl mx-auto mb-4"> <div class="flex flex-col gap-6"> <!-- Social icons row - now above the links --> <div class="flex justify-center gap-4"> <a href="https://github.com/justindeal" class="zag-link zag-interactive font-medium flex items-center" target="_self" aria-label="GitHub Profile"> <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"> <path class="zag-fill zag-transition" d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"></path> </svg> </a> <a href="https://code.justin.deal/dealjus" class="zag-link zag-interactive font-medium flex items-center" target="_self" aria-label="Gitea Profile"> <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"> <path class="zag-fill zag-transition" d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5c.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34c-.46-1.16-1.11-1.47-1.11-1.47c-.91-.62.07-.6.07-.6c1 .07 1.53 1.03 1.53 1.03c.87 1.52 2.34 1.07 2.91.83c.09-.65.35-1.09.63-1.34c-2.22-.25-4.55-1.11-4.55-4.92c0-1.11.38-2 1.03-2.71c-.1-.25-.45-1.29.1-2.64c0 0 .84-.27 2.75 1.02c.79-.22 1.65-.33 2.5-.33s1.71.11 2.5.33c1.91-1.29 2.75-1.02 2.75-1.02c.55 1.35.2 2.39.1 2.64c.65.71 1.03 1.6 1.03 2.71c0 3.82-2.34 4.66-4.57 4.91c.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2"></path> </svg> </a> <a href="https://www.linkedin.com/in/justin-deal/" class="zag-link zag-interactive font-medium flex items-center" target="_self" aria-label="LinkedIn Profile"> <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"> <path class="zag-fill zag-transition" fill="currentColor" d="M19 0h-14c-2.76 0-5 2.24-5 5v14c0 2.76 2.24 5 5 5h14c2.76 0 5-2.24 5-5v-14c0-2.76-2.24-5-5-5zm-11.12 19h-3.08v-9h3.08v9zm-1.54-10.29c-.99 0-1.79-.8-1.79-1.79s.8-1.79 1.79-1.79 1.79.8 1.79 1.79-.8 1.79-1.79 1.79zm13.16 10.29h-3.08v-4.89c0-1.16-.02-2.64-1.61-2.64s-1.86 1.26-1.86 2.57v4.96h-3.08v-9h2.96v1.23h.04c.41-.78 1.4-1.6 2.88-1.6 3.08 0 3.65 2.03 3.65 4.66v4.71z"></path> </svg> </a> </div> <!-- Navigation links row - now below the icons --> <div class="zag-border-b zag-transition pb-4"> <ul class="flex flex-wrap justify-center gap-4 sm:gap-6"> <li> <a href="/" class="zag-link zag-interactive font-medium flex items-center" target="_self"> home </a> </li><li> <a href="/about" class="zag-link zag-interactive font-medium flex items-center" target="_self"> about </a> </li><li> <a href="/blog" class="zag-link zag-interactive font-medium flex items-center" target="_self"> blog </a> </li><li> <a href="/projects" class="zag-link zag-interactive font-medium flex items-center" target="_self"> projects </a> </li><li> <a href="/homelab" class="zag-link zag-interactive font-medium flex items-center" target="_self"> homelab </a> </li><li> <a href="https://code.justin.deal" class="zag-link zag-interactive font-medium flex items-center" target="_self"> code </a> </li> </ul> </div> </div> </section> <p class="zag-text zag-transition text-center text-sm font-medium"> © 2025 Justin Deal. All rights reserved. </p> </footer> </body></html>