Compare commits

...

2 Commits

Author SHA1 Message Date
b6df2f1a34 Add Ability To Toggle Groups of Homelab Services
All checks were successful
Build and Deploy / build (push) Successful in 39s
2025-05-04 11:36:24 -07:00
c2ff03521c Hide Card Background when image only mode is selected 2025-05-04 11:15:38 -07:00
7 changed files with 283 additions and 6 deletions

View File

@ -15,6 +15,7 @@
iconSizeValue: 2, // Slider value: 1=small, 2=medium, 3=large iconSizeValue: 2, // Slider value: 1=small, 2=medium, 3=large
viewMode: 'grid', viewMode: 'grid',
displayMode: 'both', displayMode: 'both',
groupByCategory: false, // Whether to group services by category (default: ungrouped)
init() { init() {
// Load preferences from localStorage // Load preferences from localStorage
@ -23,6 +24,9 @@
// Set initial iconSizeValue based on iconSize // Set initial iconSizeValue based on iconSize
this.updateSliderFromIconSize(); this.updateSliderFromIconSize();
// Apply initial grouping mode
this.applyGroupingModeDirectly(this.groupByCategory);
// Listen for events from searchServices component // Listen for events from searchServices component
window.addEventListener('styleControls:updateIconSize', (e) => { window.addEventListener('styleControls:updateIconSize', (e) => {
if (e.detail && e.detail.size) { if (e.detail && e.detail.size) {
@ -65,6 +69,12 @@
this.displayMode = savedDisplayMode; this.displayMode = savedDisplayMode;
} }
// Load grouping preference
const savedGrouping = localStorage.getItem('services-group-by-category');
if (savedGrouping !== null) {
this.groupByCategory = savedGrouping === 'true';
}
// Update slider value based on loaded icon size // Update slider value based on loaded icon size
this.updateSliderFromIconSize(); this.updateSliderFromIconSize();
} catch (e) { } catch (e) {
@ -79,6 +89,7 @@
localStorage.setItem('services-icon-size', this.iconSize); localStorage.setItem('services-icon-size', this.iconSize);
localStorage.setItem('services-view-mode', this.viewMode); localStorage.setItem('services-view-mode', this.viewMode);
localStorage.setItem('services-display-mode', this.displayMode); localStorage.setItem('services-display-mode', this.displayMode);
localStorage.setItem('services-group-by-category', this.groupByCategory);
} catch (e) { } catch (e) {
console.error('Error saving preferences:', e); console.error('Error saving preferences:', e);
} }
@ -237,6 +248,110 @@
appList.classList.add('display-both'); appList.classList.add('display-both');
} }
} }
},
// Toggle grouping of services by category
toggleGrouping() {
this.groupByCategory = !this.groupByCategory;
this.savePreferences();
// Apply grouping mode directly with DOM restructuring
this.applyGroupingModeDirectly(this.groupByCategory);
// Dispatch event to notify searchServices component
window.dispatchEvent(new CustomEvent('searchServices:setGrouping', {
detail: { groupByCategory: this.groupByCategory }
}));
},
// Apply grouping mode directly to DOM elements with full restructuring
applyGroupingModeDirectly(groupByCategory) {
console.log('Applying grouping mode directly:', groupByCategory);
const appList = document.getElementById('app-list');
if (appList) {
// Toggle class on app-list for any global styling
appList.classList.toggle('no-category-grouping', !groupByCategory);
if (groupByCategory) {
// GROUPED MODE: Restore original category structure
// Get unified grid if it exists
const unifiedGrid = document.getElementById('unified-grid-container');
if (unifiedGrid) {
// Get all card wrappers in unified grid (these are the ScrollReveal containers)
const cardWrappers = Array.from(unifiedGrid.querySelectorAll('.scroll-reveal'));
// Show all category sections
document.querySelectorAll('.category-section').forEach(section => {
section.classList.remove('no-grouping');
section.style.display = '';
const categoryTitle = section.querySelector('.category-toggle');
if (categoryTitle) categoryTitle.style.display = '';
});
// Move each card wrapper back to its original category
cardWrappers.forEach(wrapper => {
// Find the app card inside the wrapper
const card = wrapper.querySelector('.app-card');
if (card) {
const categoryName = card.getAttribute('data-app-category');
if (categoryName) {
const originalSection = document.querySelector(`.category-section[data-category="${categoryName}"]`);
if (originalSection) {
const gridContainer = originalSection.querySelector('.grid');
if (gridContainer) {
// Move the wrapper back to its original grid
gridContainer.appendChild(wrapper);
}
}
}
}
});
// Remove unified grid
unifiedGrid.remove();
}
} else {
// UNGROUPED MODE: Create single unified grid
// Create unified grid if it doesn't exist
let unifiedGrid = document.getElementById('unified-grid-container');
if (!unifiedGrid) {
unifiedGrid = document.createElement('div');
unifiedGrid.id = 'unified-grid-container';
unifiedGrid.className = 'grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6';
// Add any necessary styles directly to ensure consistent spacing
unifiedGrid.style.gap = '1.5rem';
unifiedGrid.style.padding = '0.5rem';
appList.appendChild(unifiedGrid);
}
// Process each category section
document.querySelectorAll('.category-section').forEach(section => {
section.classList.add('no-grouping');
const categoryTitle = section.querySelector('.category-toggle');
if (categoryTitle) categoryTitle.style.display = 'none';
// Ensure all categories are expanded
if (section.__x) {
section.__x.$data.open = true;
}
// Get all card wrappers in this category (these are the ScrollReveal containers)
const cardWrappers = Array.from(section.querySelectorAll('.scroll-reveal'));
// Move all card wrappers to unified grid
cardWrappers.forEach(wrapper => {
unifiedGrid.appendChild(wrapper);
});
// Hide the now-empty category section
if (cardWrappers.length > 0) {
section.style.display = 'none';
}
});
}
}
} }
})); }));
@ -321,6 +436,16 @@
})); }));
} }
}); });
// Listen for grouping changes
window.addEventListener('searchServices:setGrouping', (e) => {
if (e.detail && e.detail.groupByCategory !== undefined) {
console.log('searchServices received grouping event:', e.detail.groupByCategory);
// Update any searchServices related functionality if needed
// This is primarily handled by styleControls but can be extended here
}
});
}, },
setupWatchers() { setupWatchers() {
@ -467,7 +592,20 @@
// Add the new view mode class // Add the new view mode class
appList.classList.add(`view-mode-${this.viewMode}`); appList.classList.add(`view-mode-${this.viewMode}`);
// Update all category sections // Check for unified grid (in ungrouped mode)
const unifiedGrid = document.getElementById('unified-grid-container');
if (unifiedGrid) {
// Update unified grid classes based on view mode
if (this.viewMode === 'grid') {
unifiedGrid.classList.remove('grid-cols-1');
unifiedGrid.classList.add('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
} else {
unifiedGrid.classList.remove('grid-cols-2', 'sm:grid-cols-3', 'lg:grid-cols-4');
unifiedGrid.classList.add('grid-cols-1');
}
}
// Update all category sections (for grouped mode)
document.querySelectorAll('.category-section').forEach(section => { document.querySelectorAll('.category-section').forEach(section => {
const gridContainer = section.querySelector('.grid'); const gridContainer = section.querySelector('.grid');
if (gridContainer) { if (gridContainer) {

View File

@ -50,6 +50,11 @@
} else if (e.altKey && e.key === 'n' && styleComponent.setDisplayMode) { } else if (e.altKey && e.key === 'n' && styleComponent.setDisplayMode) {
styleComponent.setDisplayMode('name'); styleComponent.setDisplayMode('name');
} }
// Alt+C for category grouping toggle
if (e.altKey && e.key === 'c' && styleComponent.toggleGrouping) {
styleComponent.toggleGrouping();
}
} }
}); });
}); });

View File

@ -31,6 +31,7 @@ const id = `scroll-reveal-${crypto.randomUUID().slice(0, 8)}`;
data-threshold={threshold} data-threshold={threshold}
data-root-margin={rootMargin} data-root-margin={rootMargin}
data-once={once} data-once={once}
style="display: contents;"
> >
<slot /> <slot />
</div> </div>

View File

@ -94,15 +94,18 @@ const cardId = `service-card-${crypto.randomUUID().slice(0, 8)}`;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
/* Transitions */ /* Enhanced transitions for smoother mode changes */
transition: transition:
transform var(--card-transition-duration) var(--card-transition-timing), transform var(--card-transition-duration) var(--card-transition-timing),
box-shadow var(--card-transition-duration) var(--card-transition-timing), box-shadow var(--card-transition-duration) var(--card-transition-timing),
border-color var(--card-transition-duration) var(--card-transition-timing), border-color var(--card-transition-duration) var(--card-transition-timing),
background-color var(--card-transition-duration) var(--card-transition-timing); border var(--card-transition-duration) var(--card-transition-timing),
background-color var(--card-transition-duration) var(--card-transition-timing),
opacity var(--card-transition-duration) var(--card-transition-timing),
padding var(--card-transition-duration) var(--card-transition-timing);
/* Performance optimizations */ /* Performance optimizations */
will-change: transform, box-shadow, border-color; will-change: transform, box-shadow, border-color, background-color, opacity;
} }
/* Gradient background effect */ /* Gradient background effect */
@ -139,6 +142,18 @@ const cardId = `service-card-${crypto.randomUUID().slice(0, 8)}`;
transform var(--card-transition-duration) var(--card-transition-timing); transform var(--card-transition-duration) var(--card-transition-timing);
} }
/* Enhanced background glow in image-only mode */
:global(.display-image-only) .service-icon-background {
transition:
opacity calc(var(--card-transition-duration) * 1.5) var(--card-transition-timing),
transform calc(var(--card-transition-duration) * 1.5) var(--card-transition-timing);
}
:global(.display-image-only) .service-card:hover .service-icon-background {
opacity: 0.25;
transform: scale(1.8);
}
/* Icon image */ /* Icon image */
.service-icon { .service-icon {
width: var(--icon-size); width: var(--icon-size);
@ -150,6 +165,18 @@ const cardId = `service-card-${crypto.randomUUID().slice(0, 8)}`;
filter var(--card-transition-duration) var(--card-transition-timing); filter var(--card-transition-duration) var(--card-transition-timing);
} }
/* Special icon animation for image-only mode */
:global(.display-image-only) .service-icon {
transition:
transform calc(var(--card-transition-duration) * 1.2) var(--card-transition-timing),
filter var(--card-transition-duration) var(--card-transition-timing);
}
:global(.display-image-only) .service-card:hover .service-icon {
transform: scale(1.15) rotate(3deg);
filter: drop-shadow(0 6px 8px rgba(0, 0, 0, 0.15));
}
/* Service name */ /* Service name */
.service-name { .service-name {
margin-top: 0.5rem; margin-top: 0.5rem;
@ -229,6 +256,39 @@ const cardId = `service-card-${crypto.randomUUID().slice(0, 8)}`;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
transform: translateY(0);
transition:
transform var(--card-transition-duration) var(--card-transition-timing),
filter var(--card-transition-duration) var(--card-transition-timing);
}
/* Enhanced icon effects in image-only mode */
:global(.display-image-only) .service-card:hover .service-icon-container {
transform: translateY(-2px) scale(1.02);
}
/* Completely hide card background in image-only mode */
:global(.display-image-only) .service-card {
background-color: transparent;
border-color: transparent;
border-width: 0;
box-shadow: none;
transform: scale(1.01);
padding: 0.5rem;
}
/* Adjust hover/active states in image-only mode */
:global(.display-image-only) .service-card:hover {
transform: translateY(-4px) scale(1.03);
border-color: transparent;
background-color: transparent;
box-shadow: none;
}
:global(.display-image-only) .service-card:hover::before,
:global(.display-image-only) .service-card::before {
opacity: 0;
display: none;
} }
/* Name only mode */ /* Name only mode */

View File

@ -107,6 +107,30 @@ const {
</div> </div>
</div> </div>
)} )}
{/* Grouping toggle */}
<div class="flex items-center gap-2">
<span class="text-sm zag-text-muted hidden sm:inline">Group:</span>
<div class="grouping-selector border-2 border-solid zag-border-b rounded-lg p-1 zag-bg zag-transition">
<button
@click="toggleGrouping"
:class="groupByCategory ? 'active-view' : 'inactive-view'"
class="p-1.5 transition-all rounded-md focus:outline-none focus:ring-2 focus:ring-current"
aria-label="Toggle category grouping"
:title="groupByCategory ? 'Show all apps together' : 'Group by category'"
>
<!-- Icon for grouped view (shows when grouping is active) -->
<svg x-show="groupByCategory" 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="18" height="7" rx="1"></rect>
<rect x="3" y="14" width="18" height="7" rx="1"></rect>
</svg>
<!-- Icon for ungrouped view (shows when grouping is inactive) -->
<svg x-show="!groupByCategory" 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="18" height="18" rx="1"></rect>
</svg>
</button>
</div>
</div>
</div> </div>
<style> <style>

View File

@ -79,7 +79,7 @@ const categoryLower = category.toLowerCase();
x-transition:leave-end="opacity-0 transform -translate-y-4" x-transition:leave-end="opacity-0 transform -translate-y-4"
id={categoryId} id={categoryId}
> >
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4 transition-all duration-300"> <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6 transition-all duration-300">
{apps.length > 0 ? ( {apps.length > 0 ? (
apps.map(app => { apps.map(app => {
const appName = app.name.toLowerCase(); const appName = app.name.toLowerCase();

View File

@ -40,8 +40,57 @@ const webpageData = {
/> />
<StructuredData slot="head" type="WebPage" data={webpageData} /> <StructuredData slot="head" type="WebPage" data={webpageData} />
<!-- Print-specific styles --> <!-- Print-specific styles and no-grouping styles -->
<style is:global slot="head"> <style is:global slot="head">
/* No grouping styles - completely overhauled grid layout */
:global(.no-category-grouping) .category-section {
margin: 0 !important;
padding: 0 !important;
}
:global(.no-category-grouping) .category-section:not(:first-child) {
margin: 0 !important;
}
/* When a category section has the no-grouping class */
.category-section.no-grouping {
margin: 0 !important;
padding: 0 !important;
}
/* Create a unified grid container for all items in ungrouped mode */
:global(.no-category-grouping) #app-list {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)) !important;
gap: 1.5rem !important; /* Force equal spacing in both directions */
row-gap: 1.5rem !important; /* Explicitly set row gap */
column-gap: 1.5rem !important; /* Explicitly set column gap */
width: 100% !important;
padding: 0.75rem !important; /* Add some padding around the entire grid */
}
/* Completely flatten grid to be a single container in ungrouped mode */
:global(.no-category-grouping) .grid {
display: contents !important; /* Make grid container act as its children */
margin: 0 !important;
padding: 0 !important;
gap: 0 !important;
}
/* Fix each app card to ensure proper layout in the grid */
:global(.no-category-grouping) .app-card {
height: 100% !important;
margin: 0 !important;
}
/* Fix ScrollReveal containers in no-grouping mode */
:global(.no-category-grouping) .scroll-reveal {
height: 100% !important;
margin: 0 !important;
padding: 0 !important;
}
/* Print styles */
@media print { @media print {
/* Hide non-essential UI elements */ /* Hide non-essential UI elements */
header, footer, .search-container, .style-controls, button[aria-controls] { header, footer, .search-container, .style-controls, button[aria-controls] {