mirror of
https://github.com/tiennm99/HugoBlox-kit.git
synced 2026-10-13 10:15:22 +00:00
BREAKING CHANGE Module paths moved to `github.com/HugoBlox/kit` with new names: - core `blox-tailwind` → `blox` - analytics → `analytics` - netlify → `integrations/netlify` - slides (reveal) → `slides` Update go.mod, module.yaml/params, scripts, tests, and v0.11 migration guide accordingly.
485 lines
21 KiB
HTML
485 lines
21 KiB
HTML
{{/* Modern Search Modal - Custom Pagefind API Implementation */}}
|
|
<div
|
|
x-data="searchModal()"
|
|
x-show="$store.search.open"
|
|
@keydown.escape.window="$store.search.open = false"
|
|
@keydown.cmd.k.window.prevent="$store.search.open = !$store.search.open"
|
|
@keydown.ctrl.k.window.prevent="$store.search.open = !$store.search.open"
|
|
x-cloak
|
|
class="fixed inset-0 z-[100] bg-black/50 backdrop-blur-sm"
|
|
style="display: none;"
|
|
>
|
|
{{/* Backdrop */}}
|
|
<div class="absolute inset-0" @click="$store.search.open = false"></div>
|
|
|
|
{{/* Modal Container */}}
|
|
<div
|
|
class="relative mx-auto mt-[10vh] max-w-3xl"
|
|
x-show="$store.search.open"
|
|
x-transition:enter="transition ease-out duration-200"
|
|
x-transition:enter-start="opacity-0 translate-y-4 scale-95"
|
|
x-transition:enter-end="opacity-100 translate-y-0 scale-100"
|
|
x-transition:leave="transition ease-in duration-150"
|
|
x-transition:leave-start="opacity-100 translate-y-0 scale-100"
|
|
x-transition:leave-end="opacity-0 translate-y-4 scale-95"
|
|
>
|
|
<div class="mx-4 overflow-hidden rounded-2xl bg-white dark:bg-gray-900 shadow-2xl ring-1 ring-gray-900/10 dark:ring-white/10">
|
|
|
|
{{/* Search Header */}}
|
|
<div class="border-b border-gray-200 dark:border-gray-800">
|
|
<div class="flex items-center gap-3 px-4 py-3">
|
|
|
|
{{/* Search Icon */}}
|
|
<svg class="h-5 w-5 flex-shrink-0 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
|
</svg>
|
|
|
|
{{/* Search Input */}}
|
|
<input
|
|
x-ref="searchInput"
|
|
x-model="query"
|
|
@input="loading = query.trim() !== ''"
|
|
@input.debounce.300ms="search()"
|
|
type="text"
|
|
placeholder="Search for answers..."
|
|
class="flex-1 bg-transparent text-lg outline-none text-gray-900 dark:text-gray-100 placeholder-gray-400"
|
|
autocomplete="off"
|
|
autofocus
|
|
/>
|
|
|
|
{{/* Loading Indicator */}}
|
|
<div x-show="loading" class="flex-shrink-0">
|
|
<svg class="animate-spin h-5 w-5 text-primary-600" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
</div>
|
|
|
|
{{/* ESC hint */}}
|
|
<kbd class="hidden sm:block flex-shrink-0 rounded bg-gray-100 px-2 py-1 text-xs font-semibold text-gray-600 dark:bg-gray-800 dark:text-gray-400">ESC</kbd>
|
|
|
|
{{/* Close Button */}}
|
|
<button
|
|
@click="$store.search.open = false"
|
|
class="flex-shrink-0 rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300 cursor-pointer"
|
|
>
|
|
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{{/* Filters - Auto-show when search has results */}}
|
|
<div x-show="query && availableFilters.length > 0" x-transition class="border-t border-gray-100 dark:border-gray-800 px-4 py-2">
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
@click="activeFilter = null; search()"
|
|
:class="activeFilter === null ? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400'"
|
|
class="rounded-full px-3 py-1 text-xs font-medium transition-colors cursor-pointer"
|
|
>
|
|
All Results
|
|
</button>
|
|
<template x-for="filter in availableFilters" :key="filter.filterKey">
|
|
<button
|
|
@click="activeFilter = filter.filterKey; search()"
|
|
:class="activeFilter === filter.filterKey ? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400'"
|
|
class="rounded-full px-3 py-1 text-xs font-medium transition-colors cursor-pointer"
|
|
x-text="filter.label + ' (' + filter.count + ')'"
|
|
></button>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{/* Results Container */}}
|
|
<div class="max-h-[60vh] overflow-y-auto">
|
|
|
|
{{/* Empty State - Quick Actions */}}
|
|
<div x-show="!query && results.length === 0" class="p-6">
|
|
|
|
{{/* Quick Actions - Configurable via site params */}}
|
|
{{ $quick_actions := site.Params.hugoblox.search.quick_actions }}
|
|
{{ if $quick_actions }}
|
|
<div class="mb-8">
|
|
<h3 class="mb-4 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
Quick Actions
|
|
</h3>
|
|
<div class="grid gap-3 sm:grid-cols-2">
|
|
{{ range $quick_actions }}
|
|
<a href="{{ .url }}" {{ if .external }}target="_blank" rel="noopener"{{ end }} class="group flex items-center gap-3 rounded-xl border border-gray-200 dark:border-gray-700 p-4 hover:border-{{ .color | default "primary" }}-500 hover:bg-{{ .color | default "primary" }}-50 dark:hover:bg-{{ .color | default "primary" }}-900/10 transition-all">
|
|
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-{{ .color | default "primary" }}-100 text-{{ .color | default "primary" }}-600 dark:bg-{{ .color | default "primary" }}-900 dark:text-{{ .color | default "primary" }}-400">
|
|
{{ partial "functions/get_icon" (dict "name" (.icon | default "question-mark-circle") "attributes" "class=\"h-5 w-5\"") }}
|
|
</div>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="font-semibold text-gray-900 dark:text-white truncate">{{ .title }}</div>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400 truncate">{{ .description }}</div>
|
|
</div>
|
|
{{ if .external }}
|
|
<svg class="h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
|
|
</svg>
|
|
{{ end }}
|
|
</a>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
{{ end }}
|
|
|
|
{{/* Trending Searches - Configurable via site params */}}
|
|
{{ $trending := site.Params.hugoblox.search.suggestions }}
|
|
{{ if $trending }}
|
|
<div>
|
|
<h3 class="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
|
Trending Searches
|
|
</h3>
|
|
<div class="flex flex-wrap gap-2">
|
|
{{ range $trending }}
|
|
<button
|
|
@click="query = '{{ . }}'; search()"
|
|
class="rounded-full bg-gradient-to-r from-primary-100 to-blue-100 dark:from-primary-900/50 dark:to-blue-900/50 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:from-primary-200 hover:to-blue-200 dark:hover:from-primary-800/50 dark:hover:to-blue-800/50 transition-all cursor-pointer"
|
|
>
|
|
{{ . }}
|
|
</button>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
{{ end }}
|
|
</div>
|
|
|
|
{{/* Search Results */}}
|
|
<div x-show="query && results.length > 0" class="divide-y divide-gray-200 dark:divide-gray-800">
|
|
<template x-for="(result, index) in results" :key="result.id">
|
|
<a
|
|
:href="result.url"
|
|
@mouseenter="selectedIndex = index"
|
|
@click="$store.search.open = false"
|
|
:class="selectedIndex === index ? 'bg-primary-50 dark:bg-primary-900/20 border-l-2 border-primary-600' : ''"
|
|
class="block px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors group search-result"
|
|
>
|
|
{{/* Result Badges */}}
|
|
<div class="flex items-center gap-2 mb-2 flex-wrap">
|
|
{{/* Type badge with i18n */}}
|
|
<span
|
|
x-show="result.filters && result.filters.type"
|
|
:class="{
|
|
'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300': result.filters.type && result.filters.type[0] === 'questions',
|
|
'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300': result.filters.type && result.filters.type[0] === 'faq',
|
|
'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300': result.filters.type && result.filters.type[0] === 'docs'
|
|
}"
|
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
|
x-text="result.filters.type ? (typeLabels[result.filters.type[0]] || result.filters.type[0]) : ''"
|
|
></span>
|
|
|
|
{{/* Category badge */}}
|
|
<span
|
|
x-show="result.filters && result.filters.category"
|
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
|
x-text="result.filters.category ? result.filters.category[0] : ''"
|
|
></span>
|
|
|
|
{{/* Difficulty badge */}}
|
|
<span
|
|
x-show="result.filters && result.filters.difficulty"
|
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300"
|
|
x-text="result.filters.difficulty ? result.filters.difficulty[0] : ''"
|
|
></span>
|
|
</div>
|
|
|
|
{{/* Title */}}
|
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400 transition-colors mb-2" x-html="result.meta.title || 'Untitled'"></h3>
|
|
|
|
{{/* Excerpt */}}
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2" x-html="result.excerpt"></p>
|
|
</a>
|
|
</template>
|
|
</div>
|
|
|
|
{{/* Loading State */}}
|
|
<div x-show="query && loading" class="px-6 py-16 text-center">
|
|
<div class="inline-flex items-center gap-3">
|
|
<svg class="animate-spin h-8 w-8 text-primary-600" fill="none" viewBox="0 0 24 24">
|
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
</svg>
|
|
<div class="text-left">
|
|
<p class="text-lg font-medium text-gray-900 dark:text-white">Searching...</p>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400" x-text="'Finding results for "' + query + '"'"></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{/* No Results - Only show if search has completed at least once */}}
|
|
<div x-show="query && results.length === 0 && !loading && hasSearched" class="px-6 py-12 text-center">
|
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
</svg>
|
|
<h3 class="mt-4 text-lg font-semibold text-gray-900 dark:text-white">No results found</h3>
|
|
<p class="mt-2 text-gray-600 dark:text-gray-400" x-text="'No results for "' + query + '"'"></p>
|
|
<button
|
|
@click="query = ''; $refs.searchInput.focus()"
|
|
class="mt-4 text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium"
|
|
>
|
|
Clear search
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{{/* Footer */}}
|
|
<div class="border-t border-gray-200 dark:border-gray-800 px-4 py-3 bg-gray-50 dark:bg-gray-900/50">
|
|
<div class="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
|
<div class="flex items-center gap-3">
|
|
<span class="flex items-center gap-1">
|
|
<kbd class="rounded bg-white dark:bg-gray-800 px-1.5 py-0.5 font-mono text-[10px] font-semibold border border-gray-300 dark:border-gray-700">↑↓</kbd>
|
|
Navigate
|
|
</span>
|
|
<span class="flex items-center gap-1">
|
|
<kbd class="rounded bg-white dark:bg-gray-800 px-1.5 py-0.5 font-mono text-[10px] font-semibold border border-gray-300 dark:border-gray-700">↵</kbd>
|
|
Select
|
|
</span>
|
|
<span x-show="availableFilters.length > 0" class="flex items-center gap-1">
|
|
<svg class="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"></path>
|
|
</svg>
|
|
<span x-text="availableFilters.length + ' filters'"></span>
|
|
</span>
|
|
</div>
|
|
<a href="https://hugoblox.com" target="_blank" rel="noopener" class="flex items-center gap-1 hover:text-primary-600 dark:hover:text-primary-400 transition-colors">
|
|
<svg class="h-3 w-3" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
|
</svg>
|
|
Powered by Hugo Blox
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Alpine.js component for search modal
|
|
function searchModal() {
|
|
return {
|
|
query: '',
|
|
results: [],
|
|
loading: false,
|
|
hasSearched: false, // Track if at least one search has completed
|
|
selectedIndex: -1,
|
|
activeFilter: null,
|
|
availableFilters: [],
|
|
allFilters: [],
|
|
trendingSearches: {{ site.Params.hugoblox.search.suggestions | default (slice "Search term 1" "Search term 2") | jsonify }},
|
|
pagefind: null,
|
|
pagefindModulePath: window.hbb?.assetPaths?.pagefind ?? '/pagefind/pagefind.js',
|
|
|
|
// i18n translations for content types
|
|
typeLabels: {
|
|
'questions': {{ i18n "questions" | default "Questions" | jsonify }},
|
|
'faq': {{ i18n "faq" | default "FAQ" | jsonify }},
|
|
'docs': {{ i18n "docs" | default "Documentation" | jsonify }}
|
|
},
|
|
|
|
async init() {
|
|
// Initialize Pagefind
|
|
try {
|
|
this.pagefind = await import(this.pagefindModulePath);
|
|
await this.pagefind.init();
|
|
console.log('✓ Pagefind initialized');
|
|
|
|
// Load available filters
|
|
await this.loadFilters();
|
|
} catch (error) {
|
|
console.error('Failed to initialize Pagefind:', error);
|
|
}
|
|
|
|
// Keyboard navigation
|
|
this.$watch('$store.search.open', (open) => {
|
|
if (open) {
|
|
this.$nextTick(() => this.$refs.searchInput?.focus());
|
|
document.body.style.overflow = 'hidden';
|
|
} else {
|
|
document.body.style.overflow = '';
|
|
this.query = '';
|
|
this.results = [];
|
|
this.selectedIndex = -1;
|
|
this.hasSearched = false; // Reset search state when modal closes
|
|
}
|
|
});
|
|
|
|
// Arrow key and Enter navigation
|
|
window.addEventListener('keydown', (e) => {
|
|
if (!this.$store.search.open || this.results.length === 0) return;
|
|
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
this.selectedIndex = this.selectedIndex < this.results.length - 1 ? this.selectedIndex + 1 : 0;
|
|
this.scrollToSelected();
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
this.selectedIndex = this.selectedIndex > 0 ? this.selectedIndex - 1 : this.results.length - 1;
|
|
this.scrollToSelected();
|
|
} else if (e.key === 'Enter' && this.selectedIndex >= 0) {
|
|
e.preventDefault();
|
|
const selected = this.results[this.selectedIndex];
|
|
if (selected) {
|
|
window.location.href = selected.url;
|
|
this.$store.search.open = false;
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
scrollToSelected() {
|
|
this.$nextTick(() => {
|
|
const selectedEl = document.querySelector('.search-result:nth-child(' + (this.selectedIndex + 1) + ')');
|
|
if (selectedEl) {
|
|
selectedEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
}
|
|
});
|
|
},
|
|
|
|
buildFilters(filterIndex) {
|
|
const filters = [];
|
|
|
|
if (filterIndex?.type) {
|
|
Object.entries(filterIndex.type).forEach(([value, count]) => {
|
|
filters.push({
|
|
category: 'type',
|
|
value,
|
|
label: this.typeLabels[value] || value.charAt(0).toUpperCase() + value.slice(1),
|
|
count,
|
|
filterKey: `type:${value}`
|
|
});
|
|
});
|
|
}
|
|
|
|
if (filterIndex?.category) {
|
|
Object.entries(filterIndex.category).forEach(([value, count]) => {
|
|
filters.push({
|
|
category: 'category',
|
|
value,
|
|
label: value,
|
|
count,
|
|
filterKey: `category:${value}`
|
|
});
|
|
});
|
|
}
|
|
|
|
if (filterIndex?.difficulty) {
|
|
Object.entries(filterIndex.difficulty).forEach(([value, count]) => {
|
|
filters.push({
|
|
category: 'difficulty',
|
|
value,
|
|
label: value,
|
|
count,
|
|
filterKey: `difficulty:${value}`
|
|
});
|
|
});
|
|
}
|
|
|
|
return filters;
|
|
},
|
|
|
|
mergeFilterCounts(filterCounts) {
|
|
if (!this.allFilters.length) {
|
|
return [];
|
|
}
|
|
|
|
return this.allFilters.map((filter) => {
|
|
const count = filterCounts?.[filter.category]?.[filter.value];
|
|
return {
|
|
...filter,
|
|
count: typeof count === 'number' ? count : 0
|
|
};
|
|
});
|
|
},
|
|
|
|
async search() {
|
|
if (!this.query.trim()) {
|
|
this.results = [];
|
|
this.hasSearched = false;
|
|
return;
|
|
}
|
|
|
|
this.loading = true;
|
|
this.hasSearched = false; // Reset until search completes
|
|
|
|
try {
|
|
const options = {};
|
|
|
|
// Apply active filter if selected
|
|
if (this.activeFilter) {
|
|
const [filterCategory, filterValue] = this.activeFilter.split(':');
|
|
options.filters = { [filterCategory]: filterValue };
|
|
}
|
|
|
|
const search = await this.pagefind.search(this.query, options);
|
|
console.log('Search results:', search);
|
|
console.log('Search filters:', search.filters);
|
|
|
|
// Process results
|
|
this.results = await Promise.all(
|
|
search.results.slice(0, 10).map(async (result) => {
|
|
const data = await result.data();
|
|
console.log('Result data:', data);
|
|
return {
|
|
id: data.url,
|
|
url: data.url,
|
|
meta: data.meta,
|
|
excerpt: data.excerpt,
|
|
filters: data.filters || {}
|
|
};
|
|
})
|
|
);
|
|
|
|
const filterCounts = search.totalFilters || search.filters || {};
|
|
|
|
if (this.allFilters.length) {
|
|
this.availableFilters = this.mergeFilterCounts(filterCounts);
|
|
} else {
|
|
this.availableFilters = this.buildFilters(filterCounts);
|
|
}
|
|
console.log('Final availableFilters array:', this.availableFilters);
|
|
|
|
// Set first result as selected for keyboard navigation
|
|
if (this.results.length > 0) {
|
|
this.selectedIndex = 0;
|
|
}
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
this.results = [];
|
|
} finally {
|
|
this.loading = false;
|
|
this.hasSearched = true; // Mark that search has completed
|
|
}
|
|
},
|
|
|
|
async loadFilters() {
|
|
// Pre-load available filters from the full Pagefind index
|
|
try {
|
|
const filterIndex = await this.pagefind.filters();
|
|
console.log('Available Pagefind filters:', filterIndex);
|
|
|
|
const filters = this.buildFilters(filterIndex);
|
|
this.allFilters = filters;
|
|
this.availableFilters = filters;
|
|
console.log('Processed filters:', this.availableFilters);
|
|
} catch (error) {
|
|
console.error('Failed to load filters:', error);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// Alpine store for global search state
|
|
document.addEventListener('alpine:init', () => {
|
|
Alpine.store('search', {
|
|
open: false
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<style>
|
|
[x-cloak] { display: none !important; }
|
|
</style>
|