Add metadata component and move sizing component beneath search bar
1
.astro/types.d.ts
vendored
@ -1 +1,2 @@
|
||||
/// <reference types="astro/client" />
|
||||
/// <reference path="content.d.ts" />
|
12
node_modules/.vite/deps/_metadata.json
generated
vendored
@ -1,25 +1,25 @@
|
||||
{
|
||||
"hash": "7113b21e",
|
||||
"configHash": "2cd4a4ea",
|
||||
"hash": "492ba4b0",
|
||||
"configHash": "c5e12679",
|
||||
"lockfileHash": "53cd0e09",
|
||||
"browserHash": "f4d46c12",
|
||||
"browserHash": "c02b8ebb",
|
||||
"optimized": {
|
||||
"astro > cssesc": {
|
||||
"src": "../../.pnpm/cssesc@3.0.0/node_modules/cssesc/cssesc.js",
|
||||
"file": "astro___cssesc.js",
|
||||
"fileHash": "dc615560",
|
||||
"fileHash": "22e06d75",
|
||||
"needsInterop": true
|
||||
},
|
||||
"astro > aria-query": {
|
||||
"src": "../../.pnpm/aria-query@5.3.2/node_modules/aria-query/lib/index.js",
|
||||
"file": "astro___aria-query.js",
|
||||
"fileHash": "53d05d83",
|
||||
"fileHash": "c870f0ee",
|
||||
"needsInterop": true
|
||||
},
|
||||
"astro > axobject-query": {
|
||||
"src": "../../.pnpm/axobject-query@4.1.0/node_modules/axobject-query/lib/index.js",
|
||||
"file": "astro___axobject-query.js",
|
||||
"fileHash": "35e7ec58",
|
||||
"fileHash": "b44b4671",
|
||||
"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', () => {
|
||||
// Homelab search
|
||||
window.Alpine.data('searchServices', () => {
|
||||
return initializeSearch('.app-card', {
|
||||
const baseSearch = initializeSearch('.app-card', {
|
||||
nameAttribute: 'data-app-name',
|
||||
tagsAttribute: 'data-app-tags',
|
||||
categoryAttribute: 'data-app-category',
|
||||
@ -241,6 +241,112 @@ document.addEventListener('alpine:init', () => {
|
||||
resultCountMessage: (count) => `Found ${count} services`,
|
||||
itemLabel: 'services'
|
||||
});
|
||||
|
||||
// Add icon size slider functionality
|
||||
return {
|
||||
...baseSearch,
|
||||
iconSizeValue: 2, // Slider value: 1=small, 2=medium, 3=large
|
||||
iconSize: 'medium', // small, medium, large
|
||||
viewMode: 'grid', // grid or list
|
||||
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
|
||||
|
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}`}>
|
||||
<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 -->
|
||||
<div
|
||||
id="search-status"
|
||||
@ -26,7 +35,7 @@ const {
|
||||
<div class="relative">
|
||||
<label for="app-search" class="sr-only">{ariaLabel}</label>
|
||||
<!-- 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">
|
||||
<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>
|
||||
@ -40,7 +49,7 @@ const {
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby="search-status search-hint"
|
||||
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
|
||||
x-show="searchQuery"
|
||||
@ -55,7 +64,7 @@ const {
|
||||
</div>
|
||||
|
||||
<!-- 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 -->
|
||||
<div
|
||||
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;
|
||||
---
|
||||
|
||||
<a
|
||||
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"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
alt={alt}
|
||||
class="w-16 h-16"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<p class="mt-2 text-center">{name}</p>
|
||||
<div class="service-icon-container flex-shrink-0">
|
||||
<img
|
||||
src={img}
|
||||
alt={alt}
|
||||
class="service-icon w-16 h-16 transition-all duration-300"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<p class="service-name mt-2 text-center transition-all duration-300">{name}</p>
|
||||
</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 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">
|
||||
<img
|
||||
src={GLOBAL.profileImage}
|
||||
<OptimizedImage
|
||||
src={profileImage}
|
||||
alt={GLOBAL.username}
|
||||
width={160}
|
||||
height={160}
|
||||
class="rounded-full max-w-40 w-40 h-40 mb-4 z-0 grayscale"
|
||||
/>
|
||||
<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 {
|
||||
/**
|
||||
* The category name to display as the section title
|
||||
*/
|
||||
category: string;
|
||||
apps: Array<{
|
||||
name: string;
|
||||
link: string;
|
||||
icon: string;
|
||||
alt: string;
|
||||
tags?: string[];
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Array of service objects to display in this category
|
||||
*/
|
||||
apps: Service[];
|
||||
}
|
||||
|
||||
const { category, apps } = Astro.props;
|
||||
import ServiceCard from "../common/ServiceCard.astro";
|
||||
|
||||
// Pre-compute values during server-side rendering
|
||||
const categoryId = `category-${category.toLowerCase().replace(/\s+/g, '-')}`;
|
||||
@ -43,7 +64,7 @@ const categoryLower = category.toLowerCase();
|
||||
x-transition
|
||||
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.map(app => {
|
||||
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>
|
||||
<meta charset="UTF-8" />
|
||||
<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} />
|
||||
<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
|
||||
href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i"
|
||||
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>
|
||||
|
||||
<!-- 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 />
|
||||
<slot name="head" />
|
||||
</head>
|
||||
|
@ -1,3 +1,6 @@
|
||||
/**
|
||||
* Represents the frontmatter for a project
|
||||
*/
|
||||
export type ProjectFrontmatter = {
|
||||
/**
|
||||
* The title of the project
|
||||
@ -43,6 +46,9 @@ export type ProjectFrontmatter = {
|
||||
filename: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents the frontmatter for an article
|
||||
*/
|
||||
export type ArticleFrontmatter = {
|
||||
/**
|
||||
* The title of the article
|
||||
@ -50,7 +56,7 @@ export type ArticleFrontmatter = {
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* THe summary description of the article
|
||||
* The summary description of the article
|
||||
*/
|
||||
description: string;
|
||||
|
||||
@ -81,3 +87,40 @@ export type ArticleFrontmatter = {
|
||||
*/
|
||||
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
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
// Site metadata
|
||||
username: "Justin Deal",
|
||||
@ -9,8 +32,8 @@ export const GLOBAL = {
|
||||
|
||||
// Social media links
|
||||
githubProfile: "https://github.com/justindeal",
|
||||
twitterProfile: "https://twitter.com/",
|
||||
linkedinProfile: "https://www.linkedin.com/",
|
||||
giteaProfile: "https://code.justin.deal/dealjus",
|
||||
linkedinProfile: "https://www.linkedin.com/in/justin-deal/",
|
||||
|
||||
// Common text names used throughout the site
|
||||
articlesName: "Articles",
|
||||
|
@ -7,7 +7,6 @@ import Section from "../../components/common/Section.astro";
|
||||
import SearchBar from "../../components/common/SearchBar.astro";
|
||||
import { articles } from "../../lib/list";
|
||||
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));
|
||||
|
||||
@ -39,7 +38,7 @@ const tagCounts = countTags(articles.map((article) => article.tags).flat().filte
|
||||
<link rel="canonical" href={`${GLOBAL.rootUrl}/blog`} />
|
||||
</Fragment>
|
||||
|
||||
<!-- Search functionality is provided by search-client.js -->
|
||||
<!-- Search functionality is provided by SearchScript.astro -->
|
||||
|
||||
<Section class="my-8">
|
||||
<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 Section from "../../components/common/Section.astro";
|
||||
import SearchBar from "../../components/common/SearchBar.astro";
|
||||
import StyleControls from "../../components/common/StyleControls.astro";
|
||||
import CategorySection from "../../components/homelab/CategorySection.astro";
|
||||
import ServiceCardSkeleton from "../../components/common/ServiceCardSkeleton.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 { 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>
|
||||
<Fragment slot="head">
|
||||
<title>{GLOBAL.username} • {GLOBAL.shortDescription}</title>
|
||||
<meta 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}/pixel_avatar.png`} />
|
||||
<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}/pixel_avatar.png`} />
|
||||
<meta http-equiv="content-language" content="en" />
|
||||
<meta name="language" content="English" />
|
||||
<link rel="canonical" href={GLOBAL.rootUrl} />
|
||||
</Fragment>
|
||||
<SEO
|
||||
slot="head"
|
||||
title="Homelab Dashboard"
|
||||
description={`A collection of ${totalServices} self-hosted services and applications running on my personal homelab.`}
|
||||
canonicalUrl={`${GLOBAL.rootUrl}/homelab`}
|
||||
/>
|
||||
<StructuredData slot="head" type="WebPage" data={webpageData} />
|
||||
|
||||
<!-- Search functionality is provided by search-client.js -->
|
||||
<!-- Search functionality is provided by SearchScript.astro -->
|
||||
|
||||
<Section class="my-8">
|
||||
<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="w-full">
|
||||
<!-- Search bar in its own row -->
|
||||
<div class="w-full mb-3">
|
||||
<SearchBar
|
||||
placeholder="Search services..."
|
||||
ariaLabel="Search services"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Style controls in a row below search -->
|
||||
<div class="w-full flex justify-end">
|
||||
<StyleControls />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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: [
|
||||
{
|
||||
name: "https://justin.deal",
|
||||
|
@ -6,39 +6,37 @@ import Hero from "../components/home/Hero.astro";
|
||||
import Section from "../components/common/Section.astro";
|
||||
import FeaturedProjects from "../components/home/FeaturedProjects.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>
|
||||
<Fragment slot="head">
|
||||
<title>{GLOBAL.username} • {GLOBAL.shortDescription}</title>
|
||||
<meta
|
||||
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>
|
||||
<SEO slot="head" />
|
||||
<StructuredData slot="head" type="WebSite" data={websiteData} />
|
||||
<StructuredData slot="head" type="Person" data={personData} />
|
||||
<Section class="my-16">
|
||||
<Hero />
|
||||
</Section>
|
||||
|
@ -6,7 +6,6 @@ import ProjectSnippetSkeleton from "../../components/ProjectSnippetSkeleton.astr
|
||||
import SearchBar from "../../components/common/SearchBar.astro";
|
||||
import Layout from "../../layouts/Layout.astro";
|
||||
import { GLOBAL } from "../../lib/variables";
|
||||
import { initializeSearch } from "../../components/common/searchUtils.js";
|
||||
---
|
||||
|
||||
<Layout>
|
||||
@ -35,7 +34,7 @@ import { initializeSearch } from "../../components/common/searchUtils.js";
|
||||
<link rel="canonical" href={`${GLOBAL.rootUrl}/projects`} />
|
||||
</Fragment>
|
||||
|
||||
<!-- Search functionality is provided by search-client.js -->
|
||||
<!-- Search functionality is provided by SearchScript.astro -->
|
||||
|
||||
<Section class="py-4 my-8">
|
||||
<div x-data="searchProjects" x-init="init()" x-cloak>
|
||||
|
@ -2,8 +2,8 @@
|
||||
layout: ../../layouts/ProjectLayout.astro
|
||||
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.
|
||||
tags: ["astro", "typescript", "tailwindcss", "alpinejs", "responsive-design", "accessibility", "dark-mode", "gruvbox-theme"]
|
||||
githubUrl: https://code.justin.deal
|
||||
tags: ["astro", "typescript", "tailwindcss", "alpinejs", "gruvbox-theme"]
|
||||
githubUrl: https://code.justin.deal/dealjus/justin.deal
|
||||
timestamp: 2025-02-24T02:39:03+00:00
|
||||
featured: true
|
||||
filename: this-site
|
||||
|
@ -13,6 +13,19 @@ html.theme-loaded body {
|
||||
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 */
|
||||
[x-cloak] {
|
||||
display: none !important;
|
||||
@ -24,10 +37,11 @@ html.theme-loaded body {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Font declarations with optimized loading strategies */
|
||||
@font-face {
|
||||
font-family: "Literata Variable";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-display: swap; /* Use swap for text fonts */
|
||||
font-weight: 200 900;
|
||||
src: url(https://cdn.jsdelivr.net/fontsource/fonts/literata:vf@latest/latin-opsz-normal.woff2)
|
||||
format("woff2-variations");
|
||||
@ -39,7 +53,7 @@ html.theme-loaded body {
|
||||
@font-face {
|
||||
font-family: "press-start-2p";
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-display: optional; /* Use optional for decorative fonts */
|
||||
font-weight: 400;
|
||||
src: url(https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff2)
|
||||
format("woff2"),
|
||||
|
@ -3,6 +3,16 @@
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"],
|
||||
"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/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|