Add metadata component and move sizing component beneath search bar
1
.astro/types.d.ts
vendored
@ -1 +1,2 @@
|
|||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
|
/// <reference path="content.d.ts" />
|
12
node_modules/.vite/deps/_metadata.json
generated
vendored
@ -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
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
BIN
public/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
public/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
public/favicons/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
public/favicons/favicon-16x16.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
public/favicons/favicon-32x32.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
public/favicons/favicon.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
20
public/site.webmanifest
Normal 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": "/"
|
||||||
|
}
|
BIN
src/assets/images/michael-dam-unsplash.webp
Normal file
After Width: | Height: | Size: 195 KiB |
BIN
src/assets/images/pixel_avatar.png
Normal file
After Width: | Height: | Size: 2.5 MiB |
BIN
src/assets/images/zaggonaut.png
Normal file
After Width: | Height: | Size: 956 KiB |
72
src/components/SEO.astro
Normal 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} />
|
@ -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
|
||||||
|
88
src/components/StructuredData.astro
Normal 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} />
|
63
src/components/common/OptimizedImage.astro
Normal 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>
|
||||||
|
)}
|
@ -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"
|
||||||
|
@ -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"
|
||||||
>
|
>
|
||||||
|
<div class="service-icon-container flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
src={img}
|
src={img}
|
||||||
alt={alt}
|
alt={alt}
|
||||||
class="w-16 h-16"
|
class="service-icon w-16 h-16 transition-all duration-300"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
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>
|
||||||
|
121
src/components/common/StyleControls.astro
Normal 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>
|
@ -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'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
@ -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>
|
||||||
|
@ -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();
|
||||||
|
@ -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
@ -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;
|
||||||
|
}
|
@ -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>
|
||||||
|
@ -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[];
|
||||||
|
};
|
||||||
|
@ -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",
|
||||||
|
@ -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>
|
||||||
|
@ -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 -->
|
||||||
|
@ -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",
|
||||||
|
@ -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>
|
||||||
|
@ -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>
|
||||||
|
@ -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
|
||||||
|
@ -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"),
|
||||||
|
@ -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/*"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|