2025-05-03 15:31:48 -07:00
|
|
|
<!DOCTYPE html><html lang="en"> <head><!-- Theme flash prevention script - must be first in head --><script>
|
|
|
|
// Immediately apply the saved theme to prevent flash
|
|
|
|
(function() {
|
|
|
|
function getThemePreference() {
|
|
|
|
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
|
|
|
|
return localStorage.getItem('theme');
|
|
|
|
}
|
|
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
|
|
}
|
|
|
|
|
|
|
|
const theme = getThemePreference();
|
|
|
|
|
|
|
|
// Apply theme immediately to prevent flash
|
|
|
|
if (theme === 'dark') {
|
|
|
|
document.documentElement.classList.add('dark');
|
|
|
|
} else {
|
|
|
|
document.documentElement.classList.remove('dark');
|
|
|
|
}
|
|
|
|
|
|
|
|
// Store the theme in localStorage for future visits
|
|
|
|
if (typeof localStorage !== 'undefined') {
|
|
|
|
localStorage.setItem('theme', theme);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Add a class to indicate JS is loaded and theme is applied
|
|
|
|
document.documentElement.classList.add('theme-loaded');
|
|
|
|
})();
|
|
|
|
</script><!-- Service worker registration --><script>
|
|
|
|
// Register service worker for offline support and caching
|
|
|
|
if ('serviceWorker' in navigator) {
|
|
|
|
window.addEventListener('load', () => {
|
|
|
|
navigator.serviceWorker.register('/service-worker.js')
|
|
|
|
.then(registration => {
|
|
|
|
console.log('SW registered: ', registration.scope);
|
|
|
|
})
|
|
|
|
.catch(error => {
|
|
|
|
console.log('SW registration failed: ', error);
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
</script><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="generator" content="Astro v5.7.5"><!-- Favicons --><link rel="icon" type="image/svg+xml" href="/favicons/favicon.png"><link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png"><link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png"><link rel="icon" type="image/png" sizes="16x16" href="/favicons/favicon-16x16.png"><link rel="manifest" href="/site.webmanifest"><!-- Theme colors --><meta name="theme-color" content="#282828" media="(prefers-color-scheme: dark)"><meta name="theme-color" content="#ebdbb2" media="(prefers-color-scheme: light)"><!-- Preconnect to external resources --><link rel="preconnect" href="https://fonts.bunny.net" crossorigin><link rel="dns-prefetch" href="https://fonts.bunny.net"><link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin><link rel="dns-prefetch" href="https://cdn.jsdelivr.net"><link rel="preconnect" href="https://unpkg.com" crossorigin><link rel="dns-prefetch" href="https://unpkg.com"><!-- Preload critical fonts with font-display: swap --><link rel="preload" href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i&display=swap" as="style"><link href="https://fonts.bunny.net/css?family=ibm-plex-mono:400,400i,500,500i,600,600i,700,700i&display=swap" rel="stylesheet"><!-- Preload critical font files --><link rel="preload" href="https://cdn.jsdelivr.net/fontsource/fonts/press-start-2p@latest/latin-400-normal.woff2" as="font" type="font/woff2" crossorigin><!-- Preload Alpine.js --><link rel="preload" href="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" as="script"><script src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js" defer></script><!-- Font loading observer --><script>
|
|
|
|
if ("fonts" in document) {
|
|
|
|
Promise.all([
|
|
|
|
document.fonts.load("1em IBM Plex Mono"),
|
|
|
|
document.fonts.load("1em press-start-2p")
|
|
|
|
]).then(() => {
|
|
|
|
document.documentElement.classList.add("fonts-loaded");
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
document.documentElement.classList.add("fonts-loaded");
|
|
|
|
}
|
|
|
|
</script><!-- Time-based theme scheduler --><script>
|
|
|
|
// Time-based theme switching
|
|
|
|
(function() {
|
|
|
|
// Only run if user has selected auto mode
|
|
|
|
const themeMode = localStorage.getItem('theme-mode');
|
|
|
|
|
|
|
|
if (themeMode === 'auto') {
|
|
|
|
const checkTime = () => {
|
|
|
|
const hour = new Date().getHours();
|
|
|
|
const isDayTime = hour >= 7 && hour < 19; // 7 AM to 7 PM
|
|
|
|
|
|
|
|
const theme = isDayTime ? 'light' : 'dark';
|
|
|
|
const currentTheme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
|
|
|
|
|
|
|
if (theme !== currentTheme) {
|
|
|
|
if (theme === 'dark') {
|
|
|
|
document.documentElement.classList.add('dark');
|
|
|
|
} else {
|
|
|
|
document.documentElement.classList.remove('dark');
|
|
|
|
}
|
|
|
|
|
|
|
|
localStorage.setItem('theme', theme);
|
|
|
|
|
|
|
|
// Dispatch theme changed event
|
|
|
|
window.dispatchEvent(new CustomEvent('theme-changed', {
|
|
|
|
detail: { theme, automatic: true, mode: 'auto' }
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// Check immediately
|
|
|
|
checkTime();
|
|
|
|
|
|
|
|
// Check every hour
|
|
|
|
setInterval(checkTime, 60 * 60 * 1000);
|
|
|
|
|
|
|
|
// Also check at specific times (7 AM and 7 PM)
|
|
|
|
const scheduleCheck = () => {
|
|
|
|
const now = new Date();
|
|
|
|
const hours = now.getHours();
|
|
|
|
const minutes = now.getMinutes();
|
|
|
|
|
|
|
|
// Calculate time until next check (either 7 AM or 7 PM)
|
|
|
|
let nextCheckHour;
|
|
|
|
if (hours < 7) {
|
|
|
|
nextCheckHour = 7; // Next check at 7 AM
|
|
|
|
} else if (hours < 19) {
|
|
|
|
nextCheckHour = 19; // Next check at 7 PM
|
|
|
|
} else {
|
|
|
|
nextCheckHour = 7; // Next check at 7 AM tomorrow
|
|
|
|
}
|
|
|
|
|
|
|
|
// Calculate milliseconds until next check
|
|
|
|
let msUntilNextCheck;
|
|
|
|
if (hours >= 19) {
|
|
|
|
// After 7 PM, next check is 7 AM tomorrow
|
|
|
|
msUntilNextCheck = ((24 - hours + 7) * 60 - minutes) * 60 * 1000;
|
|
|
|
} else {
|
|
|
|
// Before 7 PM, next check is either 7 AM or 7 PM today
|
|
|
|
msUntilNextCheck = ((nextCheckHour - hours) * 60 - minutes) * 60 * 1000;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Schedule the next check
|
|
|
|
setTimeout(() => {
|
|
|
|
checkTime();
|
|
|
|
scheduleCheck(); // Schedule the next check after this one
|
|
|
|
}, msUntilNextCheck);
|
|
|
|
};
|
|
|
|
|
|
|
|
// Start the scheduling
|
|
|
|
scheduleCheck();
|
2025-04-27 22:15:19 -07:00
|
|
|
}
|
|
|
|
})();
|
2025-05-03 15:31:48 -07:00
|
|
|
</script><!-- Theme transition script --><script type="module">document.addEventListener("DOMContentLoaded",()=>{window.addEventListener("theme-changed",t=>{const a=(t instanceof CustomEvent?t:null)?.detail?.theme||(document.documentElement.classList.contains("dark")?"dark":"light");window.matchMedia("(prefers-reduced-motion: reduce)").matches||(document.querySelectorAll("h1, h2, h3").forEach((e,o)=>{e.classList.remove("theme-animate-slide"),e.offsetWidth,setTimeout(()=>{e.classList.add("theme-animate-slide")},50+o*30)}),document.querySelectorAll(".card, article, section:not(section section)").forEach((e,o)=>{e.classList.remove("theme-animate-scale"),e.offsetWidth,setTimeout(()=>{e.classList.add("theme-animate-scale")},100+o*40)}),document.querySelectorAll("img, svg").forEach((e,o)=>{e.classList.remove("theme-animate-fade"),e.offsetWidth,setTimeout(()=>{e.classList.add("theme-animate-fade")},150+o*20)}),a==="dark"?(document.querySelectorAll("code, pre").forEach(e=>{e.classList.add("dark-theme-code")}),document.querySelectorAll(".button-primary, .cta-button").forEach(e=>{e.classList.add("dark-theme-glow")})):(document.querySelectorAll("code, pre").forEach(e=>{e.classList.remove("dark-theme-code")}),document.querySelectorAll(".button-primary, .cta-button").forEach(e=>{e.classList.remove("dark-theme-glow")})))})});const d=()=>{const t=document.createElement("style");t.textContent=`
|
|
|
|
.dark-theme-code {
|
|
|
|
box-shadow: 0 0 8px rgba(254, 128, 25, 0.3);
|
|
|
|
}
|
|
|
|
|
|
|
|
.dark-theme-glow {
|
|
|
|
box-shadow: 0 0 12px rgba(254, 128, 25, 0.4);
|
|
|
|
}
|
|
|
|
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
|
|
.theme-animate-fade,
|
|
|
|
.theme-animate-slide,
|
|
|
|
.theme-animate-scale {
|
|
|
|
animation: none !important;
|
|
|
|
transition: none !important;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
`,document.head.appendChild(t)};document.addEventListener("DOMContentLoaded",d);</script><script>
|
|
|
|
/**
|
|
|
|
* Initialize search functionality for any content type
|
|
|
|
* @returns {Object} Alpine.js data object with search functionality
|
|
|
|
*/
|
|
|
|
function initializeSearch(contentSelector = '.searchable-item', options = {}) {
|
|
|
|
const defaults = {
|
|
|
|
nameAttribute: 'data-name',
|
|
|
|
tagsAttribute: 'data-tags',
|
|
|
|
categoryAttribute: 'data-category',
|
|
|
|
additionalAttributes: [],
|
|
|
|
noResultsMessage: 'No results found',
|
|
|
|
allItemsMessage: 'Showing all items',
|
|
|
|
resultCountMessage: (count) => `Found ${count} items`,
|
|
|
|
itemLabel: 'items'
|
|
|
|
};
|
2025-04-27 22:15:19 -07:00
|
|
|
|
2025-05-03 15:31:48 -07:00
|
|
|
const config = { ...defaults, ...options };
|
2025-04-27 22:15:19 -07:00
|
|
|
|
2025-05-03 15:31:48 -07:00
|
|
|
return {
|
|
|
|
searchQuery: '',
|
|
|
|
hasResults: true,
|
|
|
|
visibleCount: 0,
|
|
|
|
loading: false, // Start with loading state false - the LoadingManager will control this
|
|
|
|
|
|
|
|
init() {
|
|
|
|
// Initialize the visible count
|
|
|
|
this.visibleCount = document.querySelectorAll(contentSelector).length;
|
|
|
|
this.setupWatchers();
|
|
|
|
this.setupKeyboardShortcuts();
|
|
|
|
|
|
|
|
// Handle theme changes
|
|
|
|
window.addEventListener('theme-changed', () => {
|
|
|
|
this.filterContent(this.searchQuery);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
setupWatchers() {
|
|
|
|
this.$watch('searchQuery', (query) => {
|
|
|
|
// Filter content immediately - no artificial delay
|
|
|
|
this.filterContent(query);
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
setupKeyboardShortcuts() {
|
|
|
|
// Track the currently focused item index
|
|
|
|
this.focusedItemIndex = -1;
|
|
|
|
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
|
|
// '/' key focuses the search input
|
|
|
|
if (e.key === '/' && document.activeElement.id !== 'app-search') {
|
|
|
|
e.preventDefault();
|
|
|
|
document.getElementById('app-search').focus();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Escape key clears the search
|
|
|
|
if (e.key === 'Escape' && this.searchQuery !== '') {
|
|
|
|
this.searchQuery = '';
|
|
|
|
document.getElementById('app-search').focus();
|
|
|
|
this.focusedItemIndex = -1;
|
|
|
|
this.clearItemFocus();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Arrow key navigation through results
|
|
|
|
if (this.searchQuery && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
|
|
const visibleItems = this.getVisibleItems();
|
|
|
|
if (visibleItems.length === 0) return;
|
|
|
|
|
|
|
|
// Update focused item index
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
|
|
this.focusedItemIndex = Math.min(this.focusedItemIndex + 1, visibleItems.length - 1);
|
|
|
|
} else {
|
|
|
|
this.focusedItemIndex = Math.max(this.focusedItemIndex - 1, -1);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Clear previous focus
|
|
|
|
this.clearItemFocus();
|
|
|
|
|
|
|
|
// If we're back at -1, focus the search input
|
|
|
|
if (this.focusedItemIndex === -1) {
|
|
|
|
document.getElementById('app-search').focus();
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Focus the new item
|
|
|
|
const itemToFocus = visibleItems[this.focusedItemIndex];
|
|
|
|
this.focusItem(itemToFocus);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Enter key selects the focused item
|
|
|
|
if (e.key === 'Enter' && this.focusedItemIndex >= 0) {
|
|
|
|
const visibleItems = this.getVisibleItems();
|
|
|
|
if (visibleItems.length === 0) return;
|
|
|
|
|
|
|
|
const selectedItem = visibleItems[this.focusedItemIndex];
|
|
|
|
const link = selectedItem.querySelector('a');
|
|
|
|
if (link) {
|
|
|
|
link.click();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
getVisibleItems() {
|
|
|
|
return Array.from(document.querySelectorAll(contentSelector))
|
|
|
|
.filter(item => item.style.display !== 'none');
|
|
|
|
},
|
|
|
|
|
|
|
|
clearItemFocus() {
|
|
|
|
// Remove focus styling from all items
|
|
|
|
document.querySelectorAll(`${contentSelector}.keyboard-focus`).forEach(item => {
|
|
|
|
item.classList.remove('keyboard-focus');
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
focusItem(item) {
|
|
|
|
// Add focus styling
|
|
|
|
item.classList.add('keyboard-focus');
|
|
|
|
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
|
|
},
|
|
|
|
|
|
|
|
filterContent(query) {
|
|
|
|
query = query.toLowerCase();
|
|
|
|
let anyResults = false;
|
|
|
|
let visibleCount = 0;
|
|
|
|
|
|
|
|
// Process all content items
|
|
|
|
document.querySelectorAll(contentSelector).forEach((item) => {
|
|
|
|
// Get searchable attributes
|
|
|
|
const name = (item.getAttribute(config.nameAttribute) || '').toLowerCase();
|
|
|
|
const tags = (item.getAttribute(config.tagsAttribute) || '').toLowerCase();
|
|
|
|
const category = (item.getAttribute(config.categoryAttribute) || '').toLowerCase();
|
|
|
|
|
|
|
|
// Check additional attributes if specified
|
|
|
|
const additionalMatches = config.additionalAttributes.some(attr => {
|
|
|
|
const value = (item.getAttribute(attr) || '').toLowerCase();
|
|
|
|
return value.includes(query);
|
|
|
|
});
|
|
|
|
|
|
|
|
const isMatch = query === '' ||
|
|
|
|
name.includes(query) ||
|
|
|
|
tags.includes(query) ||
|
|
|
|
category.includes(query) ||
|
|
|
|
additionalMatches;
|
|
|
|
|
|
|
|
if (isMatch) {
|
|
|
|
item.style.display = '';
|
|
|
|
anyResults = true;
|
|
|
|
visibleCount++;
|
|
|
|
} else {
|
|
|
|
item.style.display = 'none';
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
// Update category visibility for homelab page
|
|
|
|
this.updateCategoryVisibility(query);
|
|
|
|
|
|
|
|
// Update parent containers if needed
|
|
|
|
this.updateContainerVisibility(query);
|
|
|
|
this.updateResultsStatus(query, anyResults, visibleCount);
|
|
|
|
},
|
|
|
|
|
|
|
|
updateCategoryVisibility(query) {
|
|
|
|
// Only proceed if we have category sections (homelab page)
|
|
|
|
const categorySections = document.querySelectorAll('.category-section');
|
|
|
|
if (categorySections.length === 0) return;
|
|
|
|
|
|
|
|
// For each category section, check if it has any visible app cards
|
|
|
|
categorySections.forEach((categorySection) => {
|
|
|
|
const categoryId = categorySection.getAttribute('data-category');
|
|
|
|
const appCards = categorySection.querySelectorAll('.app-card');
|
|
|
|
|
|
|
|
// Count visible app cards in this category
|
|
|
|
const visibleApps = Array.from(appCards).filter(card =>
|
|
|
|
card.style.display !== 'none'
|
|
|
|
).length;
|
|
|
|
|
|
|
|
// If no visible apps and we're searching, hide the category
|
|
|
|
if (query !== '' && visibleApps === 0) {
|
|
|
|
categorySection.style.display = 'none';
|
|
|
|
} else {
|
|
|
|
categorySection.style.display = '';
|
|
|
|
}
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
updateContainerVisibility(query) {
|
|
|
|
// If there are container elements that should be hidden when empty
|
|
|
|
const containers = document.querySelectorAll('.content-container');
|
|
|
|
if (containers.length > 0) {
|
|
|
|
containers.forEach((container) => {
|
|
|
|
const hasVisibleItems = Array.from(
|
|
|
|
container.querySelectorAll(contentSelector)
|
|
|
|
).some((item) => item.style.display !== 'none');
|
|
|
|
|
|
|
|
if (query === '' || hasVisibleItems) {
|
|
|
|
container.style.display = '';
|
|
|
|
} else {
|
|
|
|
container.style.display = 'none';
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
|
|
|
|
updateResultsStatus(query, anyResults, count) {
|
|
|
|
// Update results status
|
|
|
|
this.hasResults = query === '' || anyResults;
|
|
|
|
this.visibleCount = count;
|
|
|
|
|
|
|
|
// Update screen reader status
|
|
|
|
const statusEl = document.getElementById('search-status');
|
|
|
|
if (statusEl) {
|
|
|
|
if (query === '') {
|
|
|
|
statusEl.textContent = config.allItemsMessage;
|
|
|
|
this.visibleCount = document.querySelectorAll(contentSelector).length;
|
|
|
|
} else if (this.hasResults) {
|
|
|
|
statusEl.textContent = config.resultCountMessage(count);
|
|
|
|
} else {
|
|
|
|
statusEl.textContent = config.noResultsMessage;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2025-04-27 22:15:19 -07:00
|
|
|
};
|
2025-05-03 15:31:48 -07:00
|
|
|
}
|
2025-04-27 22:15:19 -07:00
|
|
|
|
2025-05-03 15:31:48 -07:00
|
|
|
// Register Alpine.js data components when Alpine is loaded
|
|
|
|
document.addEventListener('alpine:init', () => {
|
|
|
|
// Homelab search
|
|
|
|
window.Alpine.data('searchServices', () => {
|
|
|
|
const baseSearch = initializeSearch('.app-card', {
|
|
|
|
nameAttribute: 'data-app-name',
|
|
|
|
tagsAttribute: 'data-app-tags',
|
|
|
|
categoryAttribute: 'data-app-category',
|
|
|
|
noResultsMessage: 'No services found',
|
|
|
|
allItemsMessage: 'Showing all services',
|
|
|
|
resultCountMessage: (count) => `Found ${count} services`,
|
|
|
|
itemLabel: 'services'
|
|
|
|
});
|
|
|
|
|
|
|
|
// Add icon size slider functionality
|
|
|
|
return {
|
|
|
|
...baseSearch,
|
|
|
|
iconSizeValue: 2, // Slider value: 1=small, 2=medium, 3=large
|
|
|
|
iconSize: 'medium', // small, medium, large
|
|
|
|
viewMode: 'grid', // grid or list
|
|
|
|
displayMode: 'both', // both, image, or name
|
|
|
|
debounceTimeout: null, // For debouncing slider changes
|
|
|
|
|
|
|
|
init() {
|
|
|
|
baseSearch.init.call(this);
|
|
|
|
|
|
|
|
// Apply initial icon size, view mode, and display mode
|
|
|
|
this.applyIconSize();
|
|
|
|
this.applyViewMode();
|
|
|
|
this.applyDisplayMode();
|
|
|
|
},
|
|
|
|
|
|
|
|
// Icon size methods
|
|
|
|
setIconSize(size) {
|
|
|
|
if (typeof size === 'string') {
|
|
|
|
// Handle legacy string values (small, medium, large)
|
|
|
|
this.iconSize = size;
|
|
|
|
this.iconSizeValue = size === 'small' ? 1 : size === 'medium' ? 2 : 3;
|
|
|
|
} else {
|
|
|
|
// Handle slider numeric values
|
|
|
|
this.iconSizeValue = parseFloat(size);
|
|
|
|
|
|
|
|
// Map slider value to size name
|
|
|
|
if (this.iconSizeValue <= 1.33) {
|
|
|
|
this.iconSize = 'small';
|
|
|
|
} else if (this.iconSizeValue <= 2.33) {
|
|
|
|
this.iconSize = 'medium';
|
|
|
|
} else {
|
|
|
|
this.iconSize = 'large';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
this.applyIconSize();
|
|
|
|
},
|
|
|
|
|
|
|
|
// Handle slider input with debounce
|
|
|
|
handleSliderChange(event) {
|
|
|
|
const value = event.target.value;
|
|
|
|
|
|
|
|
// Clear any existing timeout
|
|
|
|
if (this.debounceTimeout) {
|
|
|
|
clearTimeout(this.debounceTimeout);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Set a new timeout
|
|
|
|
this.debounceTimeout = setTimeout(() => {
|
|
|
|
this.setIconSize(value);
|
|
|
|
}, 50); // 50ms debounce
|
|
|
|
},
|
|
|
|
|
|
|
|
applyIconSize() {
|
|
|
|
const appList = document.getElementById('app-list');
|
|
|
|
if (!appList) return;
|
|
|
|
|
|
|
|
// Remove existing size classes
|
|
|
|
appList.classList.remove('icon-size-small', 'icon-size-medium', 'icon-size-large');
|
|
|
|
|
|
|
|
// Add the new size class
|
|
|
|
appList.classList.add(`icon-size-${this.iconSize}`);
|
|
|
|
|
|
|
|
// Apply custom CSS variable for fine-grained control
|
|
|
|
appList.style.setProperty('--icon-scale', this.iconSizeValue);
|
|
|
|
},
|
|
|
|
|
|
|
|
// View mode methods
|
|
|
|
toggleViewMode() {
|
|
|
|
this.viewMode = this.viewMode === 'grid' ? 'list' : 'grid';
|
|
|
|
this.applyViewMode();
|
|
|
|
},
|
|
|
|
|
|
|
|
setViewMode(mode) {
|
|
|
|
this.viewMode = mode;
|
|
|
|
this.applyViewMode();
|
|
|
|
},
|
|
|
|
|
|
|
|
applyViewMode() {
|
|
|
|
const appList = document.getElementById('app-list');
|
|
|
|
if (!appList) return;
|
|
|
|
|
|
|
|
// Remove existing view mode classes
|
|
|
|
appList.classList.remove('view-mode-grid', 'view-mode-list');
|
|
|
|
|
|
|
|
// Add the new view mode class
|
|
|
|
appList.classList.add(`view-mode-${this.viewMode}`);
|
|
|
|
|
|
|
|
// Update all category sections
|
|
|
|
document.querySelectorAll('.category-section').forEach(section => {
|
|
|
|
const gridContainer = section.querySelector('.grid');
|
|
|
|
if (gridContainer) {
|
|
|
|
// Update grid classes based on view mode
|
|
|
|
if (this.viewMode === 'grid') {
|
|
|
|
gridContainer.classList.remove('grid-cols-1');
|
|
|
|
gridContainer.classList.add('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
|
|
|
|
} else {
|
|
|
|
gridContainer.classList.remove('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
|
|
|
|
gridContainer.classList.add('grid-cols-1');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
|
|
|
// Display mode methods
|
|
|
|
setDisplayMode(mode) {
|
|
|
|
this.displayMode = mode;
|
|
|
|
this.applyDisplayMode();
|
|
|
|
},
|
|
|
|
|
|
|
|
applyDisplayMode() {
|
|
|
|
const appList = document.getElementById('app-list');
|
|
|
|
if (!appList) return;
|
|
|
|
|
|
|
|
// Remove existing display mode classes
|
|
|
|
appList.classList.remove('display-both', 'display-image-only', 'display-name-only');
|
|
|
|
|
|
|
|
// Add the new display mode class
|
|
|
|
if (this.displayMode === 'image') {
|
|
|
|
appList.classList.add('display-image-only');
|
|
|
|
} else if (this.displayMode === 'name') {
|
|
|
|
appList.classList.add('display-name-only');
|
|
|
|
} else {
|
|
|
|
appList.classList.add('display-both');
|
|
|
|
}
|
|
|
|
|
|
|
|
// Update all category sections
|
|
|
|
document.querySelectorAll('.category-section').forEach(section => {
|
|
|
|
const gridContainer = section.querySelector('.grid');
|
|
|
|
if (gridContainer) {
|
|
|
|
// Update grid classes based on view mode
|
|
|
|
if (this.viewMode === 'grid') {
|
|
|
|
gridContainer.classList.remove('grid-cols-1');
|
|
|
|
gridContainer.classList.add('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
|
|
|
|
} else {
|
|
|
|
gridContainer.classList.remove('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
|
|
|
|
gridContainer.classList.add('grid-cols-1');
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
};
|
|
|
|
});
|
|
|
|
|
|
|
|
// Blog search
|
|
|
|
window.Alpine.data('searchArticles', () => {
|
|
|
|
return initializeSearch('.article-item', {
|
|
|
|
nameAttribute: 'data-title',
|
|
|
|
tagsAttribute: 'data-tags',
|
|
|
|
additionalAttributes: ['data-description'],
|
|
|
|
noResultsMessage: 'No articles found',
|
|
|
|
allItemsMessage: 'Showing all articles',
|
|
|
|
resultCountMessage: (count) => `Found ${count} articles`,
|
|
|
|
itemLabel: 'articles'
|
|
|
|
});
|
|
|
|
});
|
|
|
|
|
|
|
|
// Projects search
|
|
|
|
window.Alpine.data('searchProjects', () => {
|
|
|
|
return initializeSearch('.project-item', {
|
|
|
|
nameAttribute: 'data-title',
|
|
|
|
tagsAttribute: 'data-tags',
|
|
|
|
additionalAttributes: ['data-description', 'data-github', 'data-live'],
|
|
|
|
noResultsMessage: 'No projects found',
|
|
|
|
allItemsMessage: 'Showing all projects',
|
|
|
|
resultCountMessage: (count) => `Found ${count} projects`,
|
|
|
|
itemLabel: 'projects'
|
|
|
|
});
|
|
|
|
});
|
|
|
|
});
|
|
|
|
</script> <title>My Thoughts & Takes • Justin Deal</title> <meta name="description" content="Web development, tech trends, and the occasional programming mishap."> <meta property="og:title" content="My Thoughts & Takes • Justin Deal"> <meta property="og:description" content="Practical wisdom, unfiltered thoughts, and hot takes."> <meta property="og:image" content="https://justin.deal/pixel_avatar.png"> <meta property="og:url" content="https://justin.deal/blog"> <meta name="twitter:card" content="summary_large_image"> <meta name="twitter:title" content="My Thoughts & Takes • Justin Deal"> <meta name="twitter:description" content="Practical wisdom, unfiltered thoughts, and hot takes."> <meta name="twitter:image" content="https://justin.deal/pixel_avatar.png"> <meta http-equiv="content-language" content="en"> <meta name="language" content="English"> <link rel="canonical" href="https://justin.deal/blog"> <style>.skeleton-loader[data-astro-cid-shdhheiq]{position:relative;overflow:hidden;background-color:var(--color-zag-light-muted)}.dark .skeleton-loader[data-astro-cid-shdhheiq]{background-color:var(--color-zag-dark-muted)}.skeleton-loader[data-astro-cid-shdhheiq]:after{content:"";position:absolute;inset:0;transform:translate(-100%);background-image:linear-gradient(90deg,#fff0 0,#ffffff1a 20%,#fff3 60%,#fff0);animation:shimmer 2s infinite}.dark .skeleton-loader[data-astro-cid-shdhheiq]:after{background-image:linear-gradient(90deg,#0000 0,#0000001a 20%,#0003 60%,#0000)}@keyframes shimmer{to{transform:translate(100%)}}.animate-pulse[data-astro-cid-shdhheiq]{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes pulse{0%,to{opacity:1}50%{opacity:.7}}
|
|
|
|
.search-input[data-astro-cid-3nswioeq]{box-shadow:2px 2px 0 var(--color-zag-dark)}:where(.dark,.dark *)[data-astro-cid-3nswioeq] .search-input[data-astro-cid-3nswioeq]{box-shadow:2px 2px 0 var(--color-zag-light)}
|
|
|
|
</style>
|
|
|
|
<link rel="stylesheet" href="/_astro/jellyfin-at-home.z5IOlzDh.css"></head> <body class="zag-bg zag-text zag-transition font-mono"> <!-- Loading overlay for long loading times --> <div class="loading-overlay fixed inset-0 flex flex-col items-center justify-center z-50 bg-opacity-90 zag-bg " role="alert" aria-live="polite" data-astro-cid-veun55td> <div class="loading-content text-center p-8 rounded-lg" data-astro-cid-veun55td> <!-- Enhanced loading indicator --> <div class="mb-6 flex justify-center" data-astro-cid-veun55td> <div class="loading-dots w-12 h-12" style="--dots-color:var(--color-zag-accent-dark)" data-astro-cid-ypbmo55r><div class="dot" data-astro-cid-ypbmo55r></div><div class="dot" data-astro-cid-ypbmo55r></div><div class="dot" data-astro-cid-ypbmo55r></div></div> </div> <!-- Messages with animation --> <div class="messages-container" data-astro-cid-veun55td> <p class="text-xl font-semibold mb-2 zag-text message-animate" data-astro-cid-veun55td>This is taking longer than expected...</p> <p class="text-base zag-text-muted message-animate-delay" data-astro-cid-veun55td>Still working on loading your content</p> </div> </div> </div> <script type="module">class e{overlay=null;timeoutId=null;longLoadingTimeoutId=null;constructor(){this.overlay=document.querySelector(".loading-overlay"),this.setupNavigationListeners()}setupNavigationListeners(){"navigation"in window?(window.navigation.addEventListener("navigate",i=>{new URL(i.destination.url).origin===window.location.origin&&this.showLoading()}),window.navigation.addEventListener("navigatesuccess",()=>{this.hideLoading()}),window.navigation.addEventListener("navigateerror",()=>{this.hideLoading()})):(window.addEventListener("beforeunload",()=>{this.showLoading()}),document.addEventListener("astro:page-load",()=>{this.hideLoading()})),document.addEventListener("astro:before-swap",()=>{this.showLoading()}),document.addEventListener("astro:after-swap",()=>{this.hideLoading()})}showLoading(){this.timeoutId&&(clearTimeout(this.timeoutId),this.timeoutId=null),this.longLoadingTimeoutId&&(clearTimeout(this.longLoadingTimeoutId),this.longLoadingTimeoutId=null);const i=this.getConnectionBasedDelay();this.timeoutId=setTimeout(()=>{document.querySelectorAll("[x-data]").forEach(t=>{t.__x&&typeof t.__x.$data.loading<"u"&&(t.__x.$data.loading=!0)})},i),this.longLoadingTimeoutId=setTimeout(()=>{this.overlay&&this.overlay.classList.add("visible")},5e3)}hideLoading(){this.timeoutId&&(clearTimeout(this.timeoutId),this.timeoutId=null),this.longLoadingTimeoutId&&(clearTimeout(this.longLoadingTimeoutId),this.longLoadingTimeoutId=null),this.overlay&&this.overlay.classList.remove("visible"),setTimeout(()=>{document.querySelectorAll("[x-data]").forEach(i=>{i.__x&&typeof i.__x.$data.loading<"u"&&(i.__x.$data.loading=!1)})},100)}getConnectionBasedDelay(){const i=navigator.connection||navigator.mozConnection||navigator.webkitConnection;if(i)switch(i.effectiveType){case"4g":return 100;case"3g":return 200;case"2g":case"slow-2g":return 0;default:return 100}return 100}}document.addEventListener("DOMContentLoaded",()=>{new e});</script> <!-- Theme-specific background patterns and transition effect --> <div class="theme-background" aria-hidden="true" data-astro-cid-nzjwcpgp> <div class="light-pattern" data-astro-cid-nzjwcpgp></div> <div class="dark-pattern" data-astro-cid-nzjwcpgp></div> </div> <div id="themeTransitionOverlay" class="theme-transition-overlay" aria-hidden="true" data-astro-cid-dewpavae></div> <script type="module">document.addEventListener("DOMContentLoaded",()=>{const t=document.getElementById("themeTransitionOverlay");if(!t)return;let o=document.documentElement.classList.contains("dark")?"dark":"light";window.addEventListener("theme-changed",a=>{const s=a.detail.theme;if(s===o)return;const n=document.getElementById("themeToggle");let i="50%",d="50%";if(n){const e=n.getBoundingClientRect();i=`${e.left+e.width/2}px`,d=`${e.top+e.height/2}px`}t.style.setProperty("--x",i),t.style.setProperty("--y",d),s==="dark"?(t.classList.add("light-to-dark"),t.classList.remove("dark-to-light")):
|
|
|
|
Showing all services
|
|
|
|
</div> <div class="relative" data-astro-cid-3nswioeq> <label for="app-search" class="sr-only" data-astro-cid-3nswioeq>Search articles</label> <!-- Search icon --> <div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none z-10" data-astro-cid-3nswioeq> <svg class="w-4 h-4 zag-text" fill="none" stroke="currentColor" viewBox="0 0 24 24" data-astro-cid-3nswioeq> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" data-astro-cid-3nswioeq></path> </svg> </div> <input id="app-search" type="text" x-model="searchQuery" placeholder="Search articles..." role="searchbox" aria-label="Search articles" aria-describedby="search-status search-hint" aria-controls="app-list" class="search-input w-full pl-9 pr-8 py-1.5 text-sm border-2 border-solid zag-border-b rounded-lg focus:outline-none focus:ring-2 focus:ring-current zag-text zag-bg zag-transition" data-astro-cid-3nswioeq> <button x-show="searchQuery" @click="searchQuery = ''" aria-label="Clear search" class="absolute right-2 top-1/2 transform -translate-y-1/2" data-astro-cid-3nswioeq> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 zag-text" fill="none" viewBox="0 0 24 24" stroke="currentColor" data-astro-cid-3nswioeq> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" data-astro-cid-3nswioeq></path> </svg> </button> </div> <!-- Tooltip area - shows either help text or status text --> <div class="mt-2 h-5 text-center" data-astro-cid-3nswioeq> <!-- Fixed height to prevent layout shift --> <!-- Keyboard shortcut hint - shown when search is empty --> <div id="search-hint" class="text-xs zag-text" x-show="searchQuery === ''" data-astro-cid-3nswioeq> <kbd class="px-1 py-0.5 text-xs border border-current rounded zag-text" data-astro-cid-3nswioeq>/ </kbd> to search, <kbd class="px-1 py-0.5 text-xs border border-current rounded zag-text" data-astro-cid-3nswioeq>Esc</kbd> to clear
|
|
|
|
</div> <!-- Status text - shown when user is typing --> <div id="visible-status" class="text-xs zag-text" x-show="searchQuery !== ''" x-text="hasResults ? 'Found ' + visibleCount + ' ' + (visibleCount === 1 ? 'item' : 'items') : 'No results found'" data-astro-cid-3nswioeq></div> </div> </div> </div> </div> <div class="flex items-center gap-4 pt-8 pb-4"> <h1 class="font-display text-3xl sm:text-4xl leading-loose">Articles</h1> </div> <!-- No results message --> <div x-show="searchQuery !== '' && !hasResults" x-transition class="text-center py-8 my-4 border-2 border-dashed border-current zag-text rounded-lg"> <p class="text-xl font-semibold zag-text">No Articles Found</p> </div> <!-- Loading skeleton --> <div x-show="loading" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"> <div class="article-snippet-skeleton mb-8"> <!-- Title skeleton --> <div class="mb-2"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 85%; height: 1.75rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Date skeleton --> <div class="mb-3"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 30%; height: 1rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Description skeleton --> <div class="mb-4"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 100%; height: 3rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Tags skeleton --> <div class="flex flex-wrap gap-2"> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 60px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 80px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 70px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> </div><div class="article-snippet-skeleton mb-8"> <!-- Title skeleton --> <div class="mb-2"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 85%; height: 1.75rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Date skeleton --> <div class="mb-3"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 30%; height: 1rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Description skeleton --> <div class="mb-4"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 100%; height: 3rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> <!-- Tags skeleton --> <div class="flex flex-wrap gap-2"> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 60px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 80px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> <div class="skeleton-loader zag-bg-skeleton animate-pulse rounded-lg" style="width: 70px; height: 1.5rem;" aria-hidden="true" data-testid="skeleton-loader" data-index="0" data-astro-cid-shdhheiq></div> </div> </div><div class="article-snippet-skeleton mb-8"> <!-- Title skeleton --> <div class="mb-2"> <div class="skeleton-loader zag-bg-skeleton animate-pulse" style="width: 85%; height: 1.75rem;" aria-hidden="true" data-testid=
|
2025-04-27 22:15:19 -07:00
|
|
|
© 2025 Justin Deal. All rights reserved.
|
|
|
|
</p> </footer> </body></html>
|