Add metadata component and move sizing component beneath search bar

This commit is contained in:
Justin Deal 2025-05-03 13:19:10 -07:00
parent c6573287fe
commit 4ea6a4daa7
36 changed files with 865 additions and 502 deletions

1
.astro/types.d.ts vendored
View File

@ -1 +1,2 @@
/// <reference types="astro/client" /> /// <reference types="astro/client" />
/// <reference path="content.d.ts" />

View File

@ -1,25 +1,25 @@
{ {
"hash": "7113b21e", "hash": "492ba4b0",
"configHash": "2cd4a4ea", "configHash": "c5e12679",
"lockfileHash": "53cd0e09", "lockfileHash": "53cd0e09",
"browserHash": "f4d46c12", "browserHash": "c02b8ebb",
"optimized": { "optimized": {
"astro > cssesc": { "astro > cssesc": {
"src": "../../.pnpm/cssesc@3.0.0/node_modules/cssesc/cssesc.js", "src": "../../.pnpm/cssesc@3.0.0/node_modules/cssesc/cssesc.js",
"file": "astro___cssesc.js", "file": "astro___cssesc.js",
"fileHash": "dc615560", "fileHash": "22e06d75",
"needsInterop": true "needsInterop": true
}, },
"astro > aria-query": { "astro > aria-query": {
"src": "../../.pnpm/aria-query@5.3.2/node_modules/aria-query/lib/index.js", "src": "../../.pnpm/aria-query@5.3.2/node_modules/aria-query/lib/index.js",
"file": "astro___aria-query.js", "file": "astro___aria-query.js",
"fileHash": "53d05d83", "fileHash": "c870f0ee",
"needsInterop": true "needsInterop": true
}, },
"astro > axobject-query": { "astro > axobject-query": {
"src": "../../.pnpm/axobject-query@4.1.0/node_modules/axobject-query/lib/index.js", "src": "../../.pnpm/axobject-query@4.1.0/node_modules/axobject-query/lib/index.js",
"file": "astro___axobject-query.js", "file": "astro___axobject-query.js",
"fileHash": "35e7ec58", "fileHash": "b44b4671",
"needsInterop": true "needsInterop": true
} }
}, },

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
public/favicons/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

20
public/site.webmanifest Normal file
View File

@ -0,0 +1,20 @@
{
"name": "Justin Deal",
"short_name": "Justin Deal",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#282828",
"background_color": "#ebdbb2",
"display": "standalone",
"start_url": "/"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 KiB

72
src/components/SEO.astro Normal file
View File

@ -0,0 +1,72 @@
---
interface Props {
title?: string;
description?: string;
image?: string;
article?: boolean;
canonicalUrl?: string;
publishedTime?: string;
modifiedTime?: string;
tags?: string[];
}
import { GLOBAL } from "../lib/variables";
const {
title = GLOBAL.username,
description = GLOBAL.longDescription,
image = `${GLOBAL.rootUrl}/assets/images/${GLOBAL.profileImage}`,
article = false,
canonicalUrl = Astro.url.href,
publishedTime,
modifiedTime,
tags = [],
} = Astro.props;
// Format the title with site name
const formattedTitle = title === GLOBAL.username
? `${title} • ${GLOBAL.shortDescription}`
: `${title} • ${GLOBAL.username}`;
---
<!-- Primary Meta Tags -->
<title>{formattedTitle}</title>
<meta name="title" content={formattedTitle} />
<meta name="description" content={description} />
<!-- Canonical URL -->
<link rel="canonical" href={canonicalUrl} />
<!-- Open Graph / Facebook -->
<meta property="og:type" content={article ? "article" : "website"} />
<meta property="og:url" content={canonicalUrl} />
<meta property="og:title" content={formattedTitle} />
<meta property="og:description" content={description} />
<meta property="og:image" content={image} />
<meta property="og:site_name" content={GLOBAL.username} />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalUrl} />
<meta property="twitter:title" content={formattedTitle} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={image} />
<!-- Article specific tags -->
{article && publishedTime && (
<meta property="article:published_time" content={publishedTime} />
)}
{article && modifiedTime && (
<meta property="article:modified_time" content={modifiedTime} />
)}
{article && tags.length > 0 && (
tags.map(tag => <meta property="article:tag" content={tag} />)
)}
<!-- Language and locale -->
<meta http-equiv="content-language" content="en" />
<meta name="language" content="English" />
<!-- Additional SEO tags -->
<meta name="robots" content="index, follow" />
<meta name="author" content={GLOBAL.username} />

View File

@ -232,7 +232,7 @@ function initializeSearch(contentSelector = '.searchable-item', options = {}) {
document.addEventListener('alpine:init', () => { document.addEventListener('alpine:init', () => {
// Homelab search // Homelab search
window.Alpine.data('searchServices', () => { window.Alpine.data('searchServices', () => {
return initializeSearch('.app-card', { const baseSearch = initializeSearch('.app-card', {
nameAttribute: 'data-app-name', nameAttribute: 'data-app-name',
tagsAttribute: 'data-app-tags', tagsAttribute: 'data-app-tags',
categoryAttribute: 'data-app-category', categoryAttribute: 'data-app-category',
@ -241,6 +241,112 @@ document.addEventListener('alpine:init', () => {
resultCountMessage: (count) => `Found ${count} services`, resultCountMessage: (count) => `Found ${count} services`,
itemLabel: '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
debounceTimeout: null, // For debouncing slider changes
init() {
baseSearch.init.call(this);
// Apply initial icon size and view mode
this.applyIconSize();
this.applyViewMode();
},
// 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');
}
}
});
}
};
}); });
// Blog search // Blog search

View File

@ -0,0 +1,88 @@
---
interface Props {
type: 'WebSite' | 'WebPage' | 'Article' | 'Person' | 'BreadcrumbList' | 'FAQPage';
data: Record<string, any>;
}
const { type, data } = Astro.props;
// Base structured data templates
const templates = {
WebSite: {
"@context": "https://schema.org",
"@type": "WebSite",
"name": "",
"url": "",
"description": "",
"potentialAction": {
"@type": "SearchAction",
"target": "{search_term_string}",
"query-input": "required name=search_term_string"
}
},
WebPage: {
"@context": "https://schema.org",
"@type": "WebPage",
"name": "",
"description": "",
"url": "",
"isPartOf": {
"@type": "WebSite",
"name": "",
"url": ""
}
},
Article: {
"@context": "https://schema.org",
"@type": "Article",
"headline": "",
"description": "",
"image": "",
"datePublished": "",
"dateModified": "",
"author": {
"@type": "Person",
"name": "",
"url": ""
},
"publisher": {
"@type": "Person",
"name": "",
"url": ""
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": ""
}
},
Person: {
"@context": "https://schema.org",
"@type": "Person",
"name": "",
"url": "",
"jobTitle": "",
"sameAs": []
},
BreadcrumbList: {
"@context": "https://schema.org",
"@type": "BreadcrumbList",
"itemListElement": []
},
FAQPage: {
"@context": "https://schema.org",
"@type": "FAQPage",
"mainEntity": []
}
};
// Merge template with provided data
const structuredData = {
...templates[type],
...data
};
// Stringify the data for output
const jsonLd = JSON.stringify(structuredData, null, 2);
---
<script type="application/ld+json" set:html={jsonLd} />

View File

@ -0,0 +1,63 @@
---
import { Image } from 'astro:assets';
import type { ImageMetadata } from 'astro';
interface Props {
src: ImageMetadata | string;
alt: string;
width?: number;
height?: number;
class?: string;
}
const {
src,
alt,
width,
height,
class: className = ''
} = Astro.props;
---
{typeof src === 'string' ? (
<img
src={src}
alt={alt}
width={width}
height={height}
class={className}
/>
) : (
<Fragment>
{/* Use direct component with conditional attributes to avoid TypeScript errors */}
{width && height ? (
<Image
src={src}
alt={alt}
width={width}
height={height}
class={className}
/>
) : width ? (
<Image
src={src}
alt={alt}
width={width}
class={className}
/>
) : height ? (
<Image
src={src}
alt={alt}
height={height}
class={className}
/>
) : (
<Image
src={src}
alt={alt}
class={className}
/>
)}
</Fragment>
)}

View File

@ -13,6 +13,15 @@ const {
--- ---
<div class={`search-container ${className}`}> <div class={`search-container ${className}`}>
<style>
.search-input {
box-shadow: 2px 2px 0 var(--color-zag-dark);
}
:where(.dark, .dark *) .search-input {
box-shadow: 2px 2px 0 var(--color-zag-light);
}
</style>
<!-- Hidden live region for screen readers --> <!-- Hidden live region for screen readers -->
<div <div
id="search-status" id="search-status"
@ -26,7 +35,7 @@ const {
<div class="relative"> <div class="relative">
<label for="app-search" class="sr-only">{ariaLabel}</label> <label for="app-search" class="sr-only">{ariaLabel}</label>
<!-- Search icon --> <!-- Search icon -->
<div class="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none"> <div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none z-10">
<svg class="w-4 h-4 zag-text" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 zag-text" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" /> <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" />
</svg> </svg>
@ -40,7 +49,7 @@ const {
aria-label={ariaLabel} aria-label={ariaLabel}
aria-describedby="search-status search-hint" aria-describedby="search-status search-hint"
aria-controls="app-list" aria-controls="app-list"
class="w-full pl-8 pr-8 py-1 text-sm border rounded-lg focus:outline-none focus:ring-2 focus:ring-current zag-text zag-bg" 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"
/> />
<button <button
x-show="searchQuery" x-show="searchQuery"
@ -55,7 +64,7 @@ const {
</div> </div>
<!-- Tooltip area - shows either help text or status text --> <!-- Tooltip area - shows either help text or status text -->
<div class="mt-1 h-5 text-center"> <!-- Fixed height to prevent layout shift --> <div class="mt-2 h-5 text-center"> <!-- Fixed height to prevent layout shift -->
<!-- Keyboard shortcut hint - shown when search is empty --> <!-- Keyboard shortcut hint - shown when search is empty -->
<div <div
id="search-hint" id="search-hint"

View File

@ -1,19 +1,114 @@
--- ---
/**
* ServiceCard component displays a service with an icon and name
* @component
* @example
* ```astro
* <ServiceCard
* name="Gitea"
* href="https://code.justin.deal"
* img="https://cdn.jsdelivr.net/gh/selfhst/icons/svg/gitea.svg"
* alt="Gitea"
* />
* ```
*/
interface Props {
/**
* The name of the service to display
*/
name: string;
/**
* The URL to link to when the service card is clicked
*/
href: string;
/**
* The URL of the service icon
*/
img: string;
/**
* Alternative text for the service icon
*/
alt: string;
}
const { name, href, img, alt } = Astro.props; const { name, href, img, alt } = Astro.props;
--- ---
<a <a
href={href} href={href}
class="flex flex-col items-center transition-transform transform hover:scale-105 hover:opacity-90" class="service-card flex items-center transition-all duration-300 hover:opacity-90"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<img <div class="service-icon-container flex-shrink-0">
src={img} <img
alt={alt} src={img}
class="w-16 h-16" alt={alt}
loading="lazy" class="service-icon w-16 h-16 transition-all duration-300"
decoding="async" loading="lazy"
/> decoding="async"
<p class="mt-2 text-center">{name}</p> />
</div>
<p class="service-name mt-2 text-center transition-all duration-300">{name}</p>
</a> </a>
<style>
/* Default (grid) view */
.service-card {
flex-direction: column;
justify-content: center;
}
/* List view adjustments applied via JS */
:global(.view-mode-list) .service-card {
flex-direction: row;
justify-content: flex-start;
padding: 0.5rem;
border-radius: 0.375rem;
}
:global(.view-mode-list) .service-name {
margin-top: 0;
margin-left: 1rem;
text-align: left;
}
/* Icon size adjustments with CSS variables for fine-grained control */
:global(#app-list) {
--icon-scale: 2; /* Default medium size */
--icon-base-size: 1rem;
}
.service-icon {
width: calc(var(--icon-base-size) * var(--icon-scale) * 2);
height: calc(var(--icon-base-size) * var(--icon-scale) * 2);
}
/* Fallback discrete sizes for browsers that don't support calc */
:global(.icon-size-small) .service-icon {
width: 2rem;
height: 2rem;
}
:global(.icon-size-medium) .service-icon {
width: 4rem;
height: 4rem;
}
:global(.icon-size-large) .service-icon {
width: 6rem;
height: 6rem;
}
/* Hover effect */
.service-card:hover {
transform: translateY(-2px);
}
:global(.view-mode-list) .service-card:hover {
transform: translateX(2px);
}
</style>

View File

@ -0,0 +1,121 @@
---
interface Props {
showSizeSelector?: boolean;
showViewSelector?: boolean;
className?: string;
}
const {
showSizeSelector = true,
showViewSelector = true,
className = "",
} = Astro.props;
---
<div class={`flex items-center gap-4 ${className}`}>
{showSizeSelector && (
<div class="flex items-center gap-2">
<span class="text-sm zag-text-muted hidden sm:inline">Size:</span>
<div class="size-selector flex items-center gap-1 border-2 border-solid zag-border-b rounded-lg p-1 zag-bg zag-transition">
<button
@click="setIconSize('small')"
:class="iconSize === 'small' ? 'active-size' : 'inactive-size'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Small icons"
title="Small icons (Alt+1)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
</svg>
</button>
<button
@click="setIconSize('medium')"
:class="iconSize === 'medium' ? 'active-size' : 'inactive-size'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Medium icons"
title="Medium icons (Alt+2)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
</svg>
</button>
<button
@click="setIconSize('large')"
:class="iconSize === 'large' ? 'active-size' : 'inactive-size'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Large icons"
title="Large icons (Alt+3)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
</svg>
</button>
</div>
</div>
)}
{showViewSelector && (
<div class="flex items-center gap-2">
<span class="text-sm zag-text-muted hidden sm:inline">View:</span>
<div class="view-selector border-2 border-solid zag-border-b rounded-lg p-1 zag-bg zag-transition">
<button
@click="toggleViewMode"
:class="viewMode === 'grid' ? 'active-view' : 'inactive-view'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Toggle view mode"
:title="viewMode === 'grid' ? 'Switch to list view (Alt+G)' : 'Switch to grid view (Alt+G)'"
>
<svg x-show="viewMode === 'grid'" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
<svg x-show="viewMode === 'list'" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="8" y1="6" x2="21" y2="6"></line>
<line x1="8" y1="12" x2="21" y2="12"></line>
<line x1="8" y1="18" x2="21" y2="18"></line>
<line x1="3" y1="6" x2="3.01" y2="6"></line>
<line x1="3" y1="12" x2="3.01" y2="12"></line>
<line x1="3" y1="18" x2="3.01" y2="18"></line>
</svg>
</button>
</div>
</div>
)}
</div>
<style>
.size-selector, .view-selector {
box-shadow: 2px 2px 0 var(--color-zag-dark);
:where(.dark, .dark *) & {
box-shadow: 2px 2px 0 var(--color-zag-light);
}
}
.active-size, .active-view {
color: var(--color-zag-dark);
background-color: var(--color-zag-light);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
:where(.dark, .dark *) & {
color: var(--color-zag-dark);
}
}
.inactive-size, .inactive-view {
color: var(--color-zag-dark-muted);
:where(.dark, .dark *) & {
color: var(--color-zag-light-muted);
}
}
.inactive-size:hover, .inactive-view:hover {
background-color: var(--color-zag-light-muted);
color: var(--color-zag-dark);
:where(.dark, .dark *) & {
background-color: var(--color-zag-dark-muted);
color: var(--color-zag-light);
}
}
</style>

View File

@ -1,176 +0,0 @@
/**
* Client-side search functionality for filtering content across the site
*/
/**
* Initialize search functionality for any content type
* @param {string} contentSelector - CSS selector for the items to filter
* @param {Object} options - Configuration options
* @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,
init() {
// Initialize the visible count
this.visibleCount = document.querySelectorAll(contentSelector).length;
this.setupWatchers();
this.setupKeyboardShortcuts();
},
setupWatchers() {
this.$watch('searchQuery', (query) => {
this.filterContent(query);
});
},
setupKeyboardShortcuts() {
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();
}
});
},
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 parent containers if needed
this.updateContainerVisibility(query);
this.updateResultsStatus(query, anyResults, visibleCount);
},
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
document.addEventListener('alpine:init', () => {
// Homelab search
window.Alpine.data('searchServices', () => {
return 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'
});
});
// 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'
});
});
});

View File

@ -1,132 +0,0 @@
/**
* Generic search utility for filtering content across the site
*/
/**
* Initialize search functionality for any content type
* @returns {Object} Alpine.js data object with search functionality
*/
export 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,
init() {
// Initialize the visible count
this.visibleCount = document.querySelectorAll(contentSelector).length;
this.setupWatchers();
this.setupKeyboardShortcuts();
},
setupWatchers() {
this.$watch('searchQuery', (query) => {
this.filterContent(query);
});
},
setupKeyboardShortcuts() {
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();
}
});
},
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 parent containers if needed
this.updateContainerVisibility(query);
this.updateResultsStatus(query, anyResults, visibleCount);
},
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;
}
}
}
};
}

View File

@ -1,11 +1,15 @@
--- ---
import { GLOBAL } from "../../lib/variables"; import { GLOBAL } from "../../lib/variables";
import OptimizedImage from "../common/OptimizedImage.astro";
import profileImage from "../../assets/images/pixel_avatar.png";
--- ---
<div class="flex flex-col items-center sm:flex-row gap-8"> <div class="flex flex-col items-center sm:flex-row gap-8">
<img <OptimizedImage
src={GLOBAL.profileImage} src={profileImage}
alt={GLOBAL.username} alt={GLOBAL.username}
width={160}
height={160}
class="rounded-full max-w-40 w-40 h-40 mb-4 z-0 grayscale" class="rounded-full max-w-40 w-40 h-40 mb-4 z-0 grayscale"
/> />
<div> <div>

View File

@ -1,17 +1,38 @@
--- ---
import { type Service } from "../../lib/types";
import ServiceCard from "../common/ServiceCard.astro";
/**
* CategorySection component displays a collapsible section of services grouped by category
* @component
* @example
* ```astro
* <CategorySection
* category="Development"
* apps={[
* {
* name: "Gitea",
* link: "https://code.justin.deal",
* icon: "https://cdn.jsdelivr.net/gh/selfhst/icons/svg/gitea.svg",
* alt: "Gitea"
* }
* ]}
* />
* ```
*/
interface Props { interface Props {
/**
* The category name to display as the section title
*/
category: string; category: string;
apps: Array<{
name: string; /**
link: string; * Array of service objects to display in this category
icon: string; */
alt: string; apps: Service[];
tags?: string[];
}>;
} }
const { category, apps } = Astro.props; const { category, apps } = Astro.props;
import ServiceCard from "../common/ServiceCard.astro";
// Pre-compute values during server-side rendering // Pre-compute values during server-side rendering
const categoryId = `category-${category.toLowerCase().replace(/\s+/g, '-')}`; const categoryId = `category-${category.toLowerCase().replace(/\s+/g, '-')}`;
@ -43,7 +64,7 @@ const categoryLower = category.toLowerCase();
x-transition x-transition
id={categoryId} id={categoryId}
> >
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 transition-all duration-300">
{apps.length > 0 ? ( {apps.length > 0 ? (
apps.map(app => { apps.map(app => {
const appName = app.name.toLowerCase(); const appName = app.name.toLowerCase();

View File

@ -1,100 +0,0 @@
/**
* Initialize search functionality for the homelab page
* This function sets up the search filtering, keyboard shortcuts,
* and status updates for the search feature
*/
function initializeSearch() {
return {
searchQuery: '',
hasResults: true,
init() {
this.setupWatchers();
this.setupKeyboardShortcuts();
},
setupWatchers() {
this.$watch('searchQuery', (query) => {
this.filterServices(query);
});
},
setupKeyboardShortcuts() {
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();
}
});
},
filterServices(query) {
query = query.toLowerCase();
let anyResults = false;
let visibleCount = 0;
// Process all service cards
document.querySelectorAll('.app-card').forEach((card) => {
const serviceName = card.getAttribute('data-app-name') || '';
const serviceTags = card.getAttribute('data-app-tags') || '';
const serviceCategory = card.getAttribute('data-app-category') || '';
const isMatch = query === '' ||
serviceName.includes(query) ||
serviceTags.includes(query) ||
serviceCategory.includes(query);
if (isMatch) {
card.style.display = '';
anyResults = true;
visibleCount++;
} else {
card.style.display = 'none';
}
});
this.updateCategoryVisibility(query);
this.updateResultsStatus(query, anyResults, visibleCount);
},
updateCategoryVisibility(query) {
document.querySelectorAll('.category-section').forEach((category) => {
const hasVisibleApps = Array.from(
category.querySelectorAll('.app-card')
).some((card) => card.style.display !== 'none');
if (query === '' || hasVisibleApps) {
category.style.display = '';
} else {
category.style.display = 'none';
}
});
},
updateResultsStatus(query, anyResults, visibleCount) {
// Update results status
this.hasResults = query === '' || anyResults;
// Update screen reader status
const statusEl = document.getElementById('search-status');
if (statusEl) {
if (query === '') {
statusEl.textContent = 'Showing all services';
} else if (this.hasResults) {
statusEl.textContent = 'Found ' + visibleCount + ' services matching ' + query;
} else {
statusEl.textContent = 'No services found matching ' + query;
}
}
}
};
}
export { initializeSearch };

13
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
/// <reference types="astro/client" />
/**
* Type definitions for environment variables
*/
interface ImportMetaEnv {
readonly PUBLIC_SITE_URL: string;
// Add other environment variables here as needed
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

View File

@ -40,14 +40,63 @@ import "../styles/global.css";
</script> </script>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/pixel_avatar.png" />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<link rel="preconnect" href="https://fonts.bunny.net" />
<!-- 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 -->
<link
rel="preload"
href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i"
as="style"
/>
<link <link
href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i" href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i"
rel="stylesheet" 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" as="script" />
<script src="https://unpkg.com/alpinejs" defer></script> <script src="https://unpkg.com/alpinejs" defer></script>
<!-- Font loading observer -->
<script is:inline>
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>
<SearchScript /> <SearchScript />
<slot name="head" /> <slot name="head" />
</head> </head>

View File

@ -1,3 +1,6 @@
/**
* Represents the frontmatter for a project
*/
export type ProjectFrontmatter = { export type ProjectFrontmatter = {
/** /**
* The title of the project * The title of the project
@ -43,6 +46,9 @@ export type ProjectFrontmatter = {
filename: string; filename: string;
}; };
/**
* Represents the frontmatter for an article
*/
export type ArticleFrontmatter = { export type ArticleFrontmatter = {
/** /**
* The title of the article * The title of the article
@ -50,7 +56,7 @@ export type ArticleFrontmatter = {
title: string; title: string;
/** /**
* THe summary description of the article * The summary description of the article
*/ */
description: string; description: string;
@ -81,3 +87,40 @@ export type ArticleFrontmatter = {
*/ */
filename: string; filename: string;
}; };
/**
* Represents a service in the homelab
*/
export type Service = {
/**
* The name of the service
*/
name: string;
/**
* The URL to the service
*/
link: string;
/**
* The URL to the service icon
*/
icon: string;
/**
* Alternative text for the service icon
*/
alt: string;
/**
* Tags associated with the service for filtering and categorization
*/
tags?: string[];
};
/**
* Represents a category of services in the homelab
*/
export type ServiceCategory = {
[category: string]: Service[];
};

View File

@ -1,5 +1,28 @@
// Set any item to undefined to remove it from the site or to use the default value // Set any item to undefined to remove it from the site or to use the default value
/**
* Global variables used throughout the site
* @property {string} username - The username displayed on the site
* @property {string} rootUrl - The root URL of the site
* @property {string} shortDescription - A short description of the site
* @property {string} longDescription - A longer description of the site
* @property {string} githubProfile - The GitHub profile URL
* @property {string} giteaProfile - The Gitea profile URL
* @property {string} linkedinProfile - The LinkedIn profile URL
* @property {string} articlesName - The name used for articles
* @property {string} projectsName - The name used for projects
* @property {string} viewAll - The text used for "View All" links
* @property {string} noArticles - The text used when there are no articles
* @property {string} noProjects - The text used when there are no projects
* @property {string} blogTitle - The title of the blog section
* @property {string} blogShortDescription - A short description of the blog
* @property {string} blogLongDescription - A longer description of the blog
* @property {string} projectTitle - The title of the projects section
* @property {string} projectShortDescription - A short description of the projects
* @property {string} projectLongDescription - A longer description of the projects
* @property {string} profileImage - The profile image filename
* @property {Object} menu - The menu items
*/
export const GLOBAL = { export const GLOBAL = {
// Site metadata // Site metadata
username: "Justin Deal", username: "Justin Deal",
@ -9,8 +32,8 @@ export const GLOBAL = {
// Social media links // Social media links
githubProfile: "https://github.com/justindeal", githubProfile: "https://github.com/justindeal",
twitterProfile: "https://twitter.com/", giteaProfile: "https://code.justin.deal/dealjus",
linkedinProfile: "https://www.linkedin.com/", linkedinProfile: "https://www.linkedin.com/in/justin-deal/",
// Common text names used throughout the site // Common text names used throughout the site
articlesName: "Articles", articlesName: "Articles",

View File

@ -7,7 +7,6 @@ import Section from "../../components/common/Section.astro";
import SearchBar from "../../components/common/SearchBar.astro"; import SearchBar from "../../components/common/SearchBar.astro";
import { articles } from "../../lib/list"; import { articles } from "../../lib/list";
import { countTags } from "../../lib/utils"; import { countTags } from "../../lib/utils";
import { initializeSearch } from "../../components/common/searchUtils.js";
const tagCounts = countTags(articles.map((article) => article.tags).flat().filter((tag): tag is string => tag !== undefined)); const tagCounts = countTags(articles.map((article) => article.tags).flat().filter((tag): tag is string => tag !== undefined));
@ -39,7 +38,7 @@ const tagCounts = countTags(articles.map((article) => article.tags).flat().filte
<link rel="canonical" href={`${GLOBAL.rootUrl}/blog`} /> <link rel="canonical" href={`${GLOBAL.rootUrl}/blog`} />
</Fragment> </Fragment>
<!-- Search functionality is provided by search-client.js --> <!-- Search functionality is provided by SearchScript.astro -->
<Section class="my-8"> <Section class="my-8">
<div x-data="searchArticles" x-init="init()" x-cloak> <div x-data="searchArticles" x-init="init()" x-cloak>

View File

@ -3,42 +3,60 @@ import { GLOBAL } from "../../lib/variables";
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import Section from "../../components/common/Section.astro"; import Section from "../../components/common/Section.astro";
import SearchBar from "../../components/common/SearchBar.astro"; import SearchBar from "../../components/common/SearchBar.astro";
import StyleControls from "../../components/common/StyleControls.astro";
import CategorySection from "../../components/homelab/CategorySection.astro"; import CategorySection from "../../components/homelab/CategorySection.astro";
import ServiceCardSkeleton from "../../components/common/ServiceCardSkeleton.astro"; import ServiceCardSkeleton from "../../components/common/ServiceCardSkeleton.astro";
import SkeletonLoader from "../../components/common/SkeletonLoader.astro"; import SkeletonLoader from "../../components/common/SkeletonLoader.astro";
import SEO from "../../components/SEO.astro";
import StructuredData from "../../components/StructuredData.astro";
import { services } from "./services.ts"; import { services } from "./services.ts";
import { initializeSearch } from "../../components/common/searchUtils.js";
// Count total services
const totalServices = Object.values(services).reduce(
(count, serviceList) => count + serviceList.length,
0
);
// Structured data for the homelab page
const webpageData = {
name: "Homelab Dashboard",
description: `A collection of ${totalServices} self-hosted services and applications running on my personal homelab.`,
url: `${GLOBAL.rootUrl}/homelab`,
isPartOf: {
"@type": "WebSite",
"name": `${GLOBAL.username} • ${GLOBAL.shortDescription}`,
"url": GLOBAL.rootUrl
}
};
--- ---
<Layout> <Layout>
<Fragment slot="head"> <SEO
<title>{GLOBAL.username} • {GLOBAL.shortDescription}</title> slot="head"
<meta name="description" content={GLOBAL.longDescription} /> title="Homelab Dashboard"
<meta property="og:title" content={`${GLOBAL.username} • ${GLOBAL.shortDescription}`} /> description={`A collection of ${totalServices} self-hosted services and applications running on my personal homelab.`}
<meta property="og:description" content={GLOBAL.longDescription} /> canonicalUrl={`${GLOBAL.rootUrl}/homelab`}
<meta property="og:image" content={`${GLOBAL.rootUrl}/pixel_avatar.png`} /> />
<meta property="og:url" content={GLOBAL.rootUrl} /> <StructuredData slot="head" type="WebPage" data={webpageData} />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={`${GLOBAL.username} • ${GLOBAL.shortDescription}`} />
<meta name="twitter:description" content={GLOBAL.longDescription} />
<meta name="twitter:image" content={`${GLOBAL.rootUrl}/pixel_avatar.png`} />
<meta http-equiv="content-language" content="en" />
<meta name="language" content="English" />
<link rel="canonical" href={GLOBAL.rootUrl} />
</Fragment>
<!-- Search functionality is provided by search-client.js --> <!-- Search functionality is provided by SearchScript.astro -->
<Section class="my-8"> <Section class="my-8">
<div x-data="searchServices" x-init="init()" x-cloak> <div x-data="searchServices" x-init="init()" x-cloak>
<!-- Search container - positioned at the top --> <!-- Search and controls container -->
<div class="mb-4 pt-0"> <div class="mb-4 pt-0">
<div class="w-full"> <!-- Search bar in its own row -->
<div class="w-full mb-3">
<SearchBar <SearchBar
placeholder="Search services..." placeholder="Search services..."
ariaLabel="Search services" ariaLabel="Search services"
/> />
</div> </div>
<!-- Style controls in a row below search -->
<div class="w-full flex justify-end">
<StyleControls />
</div>
</div> </div>
<!-- Page heading - now below search --> <!-- Page heading - now below search -->

View File

@ -1,4 +1,9 @@
export const services = { import { type Service, type ServiceCategory } from "../../lib/types";
/**
* Services available in the homelab, organized by category
*/
export const services: ServiceCategory = {
Websites: [ Websites: [
{ {
name: "https://justin.deal", name: "https://justin.deal",

View File

@ -6,39 +6,37 @@ import Hero from "../components/home/Hero.astro";
import Section from "../components/common/Section.astro"; import Section from "../components/common/Section.astro";
import FeaturedProjects from "../components/home/FeaturedProjects.astro"; import FeaturedProjects from "../components/home/FeaturedProjects.astro";
import FeaturedArticles from "../components/home/FeaturedArticles.astro"; import FeaturedArticles from "../components/home/FeaturedArticles.astro";
import SEO from "../components/SEO.astro";
import StructuredData from "../components/StructuredData.astro";
// Structured data for the home page
const websiteData = {
name: `${GLOBAL.username} • ${GLOBAL.shortDescription}`,
url: GLOBAL.rootUrl,
description: GLOBAL.longDescription,
potentialAction: {
"@type": "SearchAction",
"target": `${GLOBAL.rootUrl}/search?q={search_term_string}`,
"query-input": "required name=search_term_string"
}
};
const personData = {
name: GLOBAL.username,
url: GLOBAL.rootUrl,
jobTitle: "Software Developer",
sameAs: [
GLOBAL.githubProfile,
GLOBAL.linkedinProfile,
GLOBAL.giteaProfile
]
};
--- ---
<Layout> <Layout>
<Fragment slot="head"> <SEO slot="head" />
<title>{GLOBAL.username} • {GLOBAL.shortDescription}</title> <StructuredData slot="head" type="WebSite" data={websiteData} />
<meta <StructuredData slot="head" type="Person" data={personData} />
name="description"
content={GLOBAL.longDescription}
/>
<meta
property="og:title"
content={`${GLOBAL.username} • ${GLOBAL.shortDescription}`}
/>
<meta
property="og:description"
content={GLOBAL.longDescription}
/>
<meta property="og:image" content={`${GLOBAL.rootUrl}/${GLOBAL.profileImage}`} />
<meta property="og:url" content={GLOBAL.rootUrl} />
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:title"
content={`${GLOBAL.username} • ${GLOBAL.shortDescription}`}
/>
<meta
name="twitter:description"
content={GLOBAL.longDescription}
/>
<meta name="twitter:image" content={`${GLOBAL.rootUrl}/${GLOBAL.profileImage}`} />
<meta http-equiv="content-language" content="en" />
<meta name="language" content="English" />
<link rel="canonical" href={GLOBAL.rootUrl} />
</Fragment>
<Section class="my-16"> <Section class="my-16">
<Hero /> <Hero />
</Section> </Section>

View File

@ -6,7 +6,6 @@ import ProjectSnippetSkeleton from "../../components/ProjectSnippetSkeleton.astr
import SearchBar from "../../components/common/SearchBar.astro"; import SearchBar from "../../components/common/SearchBar.astro";
import Layout from "../../layouts/Layout.astro"; import Layout from "../../layouts/Layout.astro";
import { GLOBAL } from "../../lib/variables"; import { GLOBAL } from "../../lib/variables";
import { initializeSearch } from "../../components/common/searchUtils.js";
--- ---
<Layout> <Layout>
@ -35,7 +34,7 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
<link rel="canonical" href={`${GLOBAL.rootUrl}/projects`} /> <link rel="canonical" href={`${GLOBAL.rootUrl}/projects`} />
</Fragment> </Fragment>
<!-- Search functionality is provided by search-client.js --> <!-- Search functionality is provided by SearchScript.astro -->
<Section class="py-4 my-8"> <Section class="py-4 my-8">
<div x-data="searchProjects" x-init="init()" x-cloak> <div x-data="searchProjects" x-init="init()" x-cloak>

View File

@ -2,8 +2,8 @@
layout: ../../layouts/ProjectLayout.astro layout: ../../layouts/ProjectLayout.astro
title: This Website title: This Website
description: My personal blog, portfolio, and homelab dashboard built with Astro, TypeScript, TailwindCSS, and Alpine.js featuring smart loading, advanced search, and accessibility features. description: My personal blog, portfolio, and homelab dashboard built with Astro, TypeScript, TailwindCSS, and Alpine.js featuring smart loading, advanced search, and accessibility features.
tags: ["astro", "typescript", "tailwindcss", "alpinejs", "responsive-design", "accessibility", "dark-mode", "gruvbox-theme"] tags: ["astro", "typescript", "tailwindcss", "alpinejs", "gruvbox-theme"]
githubUrl: https://code.justin.deal githubUrl: https://code.justin.deal/dealjus/justin.deal
timestamp: 2025-02-24T02:39:03+00:00 timestamp: 2025-02-24T02:39:03+00:00
featured: true featured: true
filename: this-site filename: this-site

View File

@ -13,6 +13,19 @@ html.theme-loaded body {
transition: background-color 0.3s ease, color 0.3s ease; transition: background-color 0.3s ease, color 0.3s ease;
} }
/* Font loading states */
html:not(.fonts-loaded) body {
/* Fallback font metrics that match your custom font */
font-family: monospace;
}
html.fonts-loaded body {
/* Your custom font */
font-family: var(--font-mono);
/* Add a subtle transition for font changes */
transition: font-family 0.1s ease-out;
}
/* Hide elements with x-cloak until Alpine.js is loaded */ /* Hide elements with x-cloak until Alpine.js is loaded */
[x-cloak] { [x-cloak] {
display: none !important; display: none !important;
@ -24,10 +37,11 @@ html.theme-loaded body {
outline-offset: 2px; outline-offset: 2px;
} }
/* Font declarations with optimized loading strategies */
@font-face { @font-face {
font-family: "Literata Variable"; font-family: "Literata Variable";
font-style: normal; font-style: normal;
font-display: swap; font-display: swap; /* Use swap for text fonts */
font-weight: 200 900; font-weight: 200 900;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/literata:vf@latest/latin-opsz-normal.woff2) src: url(https://cdn.jsdelivr.net/fontsource/fonts/literata:vf@latest/latin-opsz-normal.woff2)
format("woff2-variations"); format("woff2-variations");
@ -39,7 +53,7 @@ html.theme-loaded body {
@font-face { @font-face {
font-family: "press-start-2p"; font-family: "press-start-2p";
font-style: normal; font-style: normal;
font-display: swap; font-display: optional; /* Use optional for decorative fonts */
font-weight: 400; font-weight: 400;
src: url(https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff2) src: url(https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff2)
format("woff2"), format("woff2"),

View File

@ -3,6 +3,16 @@
"include": [".astro/types.d.ts", "**/*"], "include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"], "exclude": ["dist"],
"compilerOptions": { "compilerOptions": {
"allowImportingTsExtensions": true "allowImportingTsExtensions": true,
"exactOptionalPropertyTypes": false, // Temporarily disabled to fix component issues
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noPropertyAccessFromIndexSignature": true,
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
} }
} }