Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
290a502b1e |
48
assets/scss/components/_search.scss
Normal file
48
assets/scss/components/_search.scss
Normal file
@ -0,0 +1,48 @@
|
||||
.search-interface {
|
||||
@apply sticky top-0 bg-white z-10 p-4;
|
||||
|
||||
.search-form {
|
||||
@apply space-y-4;
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
@apply flex gap-2;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
@apply w-full p-2 border rounded-lg focus:ring-2 focus:ring-blue-500;
|
||||
}
|
||||
|
||||
.filter-toggle {
|
||||
@apply px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
@apply hidden transition-all duration-200 ease-in-out;
|
||||
@apply bg-gray-50 rounded-lg p-4 mt-4;
|
||||
|
||||
&.active {
|
||||
@apply block;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-groups {
|
||||
@apply grid gap-4 md:grid-cols-2 lg:grid-cols-3;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
@apply space-y-2;
|
||||
|
||||
h3 {
|
||||
@apply font-semibold text-gray-700;
|
||||
}
|
||||
}
|
||||
|
||||
.results-count {
|
||||
@apply mt-4 text-sm text-gray-600;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
@apply mt-4 space-y-4;
|
||||
}
|
||||
}
|
@ -11,6 +11,7 @@
|
||||
|
||||
// Components
|
||||
@import "components/wompum";
|
||||
@import "components/search";
|
||||
|
||||
body {
|
||||
font-family: $font-garamond;
|
||||
|
@ -8,3 +8,11 @@ theme = "hugo-starter-tailwind-basic"
|
||||
|
||||
[minify]
|
||||
minifyOutput = true
|
||||
|
||||
[outputs]
|
||||
home = ["HTML", "RSS", "JSON"]
|
||||
|
||||
[outputFormats.JSON]
|
||||
mediaType = "application/json"
|
||||
baseName = "search"
|
||||
isPlainText = true
|
||||
|
@ -27,5 +27,6 @@
|
||||
<script src="/js/sigil.js"></script>
|
||||
<script src="/js/colorCalculator.js"></script>
|
||||
<script src="/js/wompum.js"></script>
|
||||
{{ block "scripts" . }}{{ end }}
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,4 +1,5 @@
|
||||
{{ define "main" }}
|
||||
{{ partial "search-interface.html" . }}
|
||||
<main class="flex gap-4 lg:gap-16 justify-around mt-8 max-w-screen-xl mx-auto px-4 lg:px-0">
|
||||
<ul class="flex flex-col gap-4 w-full">
|
||||
{{ partial "article-list.html" (dict "Pages" (where .Site.RegularPages "Section" "articles")) }}
|
||||
|
14
layouts/index.json
Normal file
14
layouts/index.json
Normal file
@ -0,0 +1,14 @@
|
||||
{{- $searchIndex := slice -}}
|
||||
{{- range where .Site.RegularPages "Type" "articles" -}}
|
||||
{{- $searchIndex = $searchIndex | append (dict
|
||||
"title" .Title
|
||||
"url" .Permalink
|
||||
"summary" .Summary
|
||||
"tags" .Params.tags
|
||||
"narrator" .Params.narrator
|
||||
"facilitator" .Params.facilitator
|
||||
"date" (.Date.Format "2006-01-02")
|
||||
"content" .Plain
|
||||
) -}}
|
||||
{{- end -}}
|
||||
{{- $searchIndex | jsonify -}}
|
54
layouts/partials/search-interface.html
Normal file
54
layouts/partials/search-interface.html
Normal file
@ -0,0 +1,54 @@
|
||||
<div class="search-interface">
|
||||
<form class="search-form" data-search-form>
|
||||
<div class="search-input-group">
|
||||
<input type="search"
|
||||
name="q"
|
||||
placeholder="Search interviews..."
|
||||
class="search-input"
|
||||
data-search-input>
|
||||
<button type="button"
|
||||
class="filter-toggle"
|
||||
data-filter-toggle>
|
||||
Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-panel" data-filter-panel>
|
||||
<div class="filter-groups">
|
||||
<div class="filter-group">
|
||||
<h3>Tags</h3>
|
||||
<div data-filter-tags></div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<h3>Narrators</h3>
|
||||
<div data-filter-narrators></div>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<h3>Facilitators</h3>
|
||||
<div data-filter-facilitators></div>
|
||||
</div>
|
||||
<div class="filter-group col-span-full">
|
||||
<h3>Date Range</h3>
|
||||
<div class="flex gap-2">
|
||||
<input type="date" name="date-from" data-date-from>
|
||||
<input type="date" name="date-to" data-date-to>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label>
|
||||
<input type="checkbox" name="logic-mode" data-logic-mode>
|
||||
Use OR logic for filters
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="results-count" data-results-count></div>
|
||||
<div class="results-list" data-results></div>
|
||||
</div>
|
||||
|
||||
{{ define "scripts" }}
|
||||
<script src="https://cdn.jsdelivr.net/npm/fuse.js@7.0.0"></script>
|
||||
<script src="/js/search.js"></script>
|
||||
{{ end }}
|
296
static/js/search.js
Normal file
296
static/js/search.js
Normal file
@ -0,0 +1,296 @@
|
||||
class SearchInterface {
|
||||
constructor() {
|
||||
this.searchIndex = [];
|
||||
this.fuse = null;
|
||||
this.filters = {
|
||||
tags: new Set(),
|
||||
narrators: new Set(),
|
||||
facilitators: new Set(),
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
useOrLogic: false
|
||||
};
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
const response = await fetch('/search.json');
|
||||
this.searchIndex = await response.json();
|
||||
|
||||
this.fuse = new Fuse(this.searchIndex, {
|
||||
keys: ['title', 'content', 'summary', 'tags', 'narrator', 'facilitator'],
|
||||
threshold: 0.3,
|
||||
ignoreLocation: true,
|
||||
useExtendedSearch: true
|
||||
});
|
||||
|
||||
this.setupEventListeners();
|
||||
this.populateFilterOptions();
|
||||
this.initializeFromURL();
|
||||
} catch (error) {
|
||||
this.handleError(error, 'Failed to initialize search');
|
||||
}
|
||||
}
|
||||
|
||||
handleError(error, message = 'Search error') {
|
||||
console.error(message, error);
|
||||
const resultsDiv = document.querySelector('[data-results]');
|
||||
if (resultsDiv) {
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="text-red-500 text-center py-8">
|
||||
${message}. Please try again later.
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return (...args) => {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||
};
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
const searchInput = document.querySelector('[data-search-input]');
|
||||
const filterToggle = document.querySelector('[data-filter-toggle]');
|
||||
const filterPanel = document.querySelector('[data-filter-panel]');
|
||||
const logicMode = document.querySelector('[data-logic-mode]');
|
||||
const dateInputs = document.querySelectorAll('[data-date-from], [data-date-to]');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input',
|
||||
this.debounce(() => this.performSearch(), 300)
|
||||
);
|
||||
}
|
||||
|
||||
if (filterToggle && filterPanel) {
|
||||
filterToggle.addEventListener('click', () => {
|
||||
filterPanel.classList.toggle('active');
|
||||
});
|
||||
}
|
||||
|
||||
if (logicMode) {
|
||||
logicMode.addEventListener('change', () => {
|
||||
this.filters.useOrLogic = logicMode.checked;
|
||||
this.performSearch();
|
||||
});
|
||||
}
|
||||
|
||||
dateInputs.forEach(input => {
|
||||
input.addEventListener('change', () => this.performSearch());
|
||||
});
|
||||
}
|
||||
|
||||
performSearch() {
|
||||
const searchInput = document.querySelector('[data-search-input]');
|
||||
const dateFrom = document.querySelector('[data-date-from]');
|
||||
const dateTo = document.querySelector('[data-date-to]');
|
||||
const resultsDiv = document.querySelector('[data-results]');
|
||||
const countDiv = document.querySelector('[data-results-count]');
|
||||
|
||||
// Get current search state
|
||||
const query = searchInput?.value || '';
|
||||
this.filters.dateFrom = this.parseDate(dateFrom?.value);
|
||||
this.filters.dateTo = this.parseDate(dateTo?.value);
|
||||
|
||||
// Perform search
|
||||
let results = query ? this.fuse.search(query) : this.searchIndex;
|
||||
results = results.map(r => r.item || r); // Handle Fuse.js results format
|
||||
|
||||
// Apply filters
|
||||
results = results.filter(item => {
|
||||
const matchesTags = this.filters.tags.size === 0 || (item.tags &&
|
||||
(this.filters.useOrLogic
|
||||
? item.tags.some(tag => this.filters.tags.has(tag))
|
||||
: item.tags.every(tag => this.filters.tags.has(tag))));
|
||||
|
||||
const matchesNarrator = this.filters.narrators.size === 0 ||
|
||||
this.filters.narrators.has(item.narrator);
|
||||
|
||||
const matchesFacilitator = this.filters.facilitators.size === 0 ||
|
||||
this.filters.facilitators.has(item.facilitator);
|
||||
|
||||
const itemDate = new Date(item.date);
|
||||
const afterStartDate = !this.filters.dateFrom ||
|
||||
itemDate >= new Date(this.filters.dateFrom);
|
||||
const beforeEndDate = !this.filters.dateTo ||
|
||||
itemDate <= new Date(this.filters.dateTo);
|
||||
|
||||
return matchesTags && matchesNarrator && matchesFacilitator &&
|
||||
afterStartDate && beforeEndDate;
|
||||
});
|
||||
|
||||
// Update URL state
|
||||
const params = new URLSearchParams();
|
||||
if (query) params.set('q', query);
|
||||
if (this.filters.dateFrom) params.set('from', this.filters.dateFrom);
|
||||
if (this.filters.dateTo) params.set('to', this.filters.dateTo);
|
||||
if (this.filters.useOrLogic) params.set('logic', 'or');
|
||||
if (this.filters.tags.size) params.set('tags', Array.from(this.filters.tags).join(','));
|
||||
if (this.filters.narrators.size) params.set('narrators', Array.from(this.filters.narrators).join(','));
|
||||
if (this.filters.facilitators.size) params.set('facilitators', Array.from(this.filters.facilitators).join(','));
|
||||
|
||||
const newUrl = `${window.location.pathname}${params.toString() ? '?' + params.toString() : ''}`;
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
|
||||
// Render results
|
||||
if (resultsDiv) {
|
||||
if (results.length === 0) {
|
||||
resultsDiv.innerHTML = `
|
||||
<div class="text-gray-500 text-center py-8">
|
||||
No results found. Try adjusting your search or filters.
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultsDiv.innerHTML = results.map(item => `
|
||||
<article class="border-b border-gray-200 pb-4">
|
||||
<h3 class="text-xl font-semibold">
|
||||
<a href="${item.url}" class="hover:text-blue-600">${item.title}</a>
|
||||
</h3>
|
||||
<div class="flex gap-2 text-sm text-gray-600 mt-1">
|
||||
<span>${item.narrator}</span>
|
||||
<span>•</span>
|
||||
<span>${new Date(item.date).toLocaleDateString()}</span>
|
||||
</div>
|
||||
${item.tags ? `
|
||||
<div class="flex gap-2 mt-2">
|
||||
${item.tags.map(tag => `
|
||||
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">
|
||||
${tag}
|
||||
</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
<p class="mt-2 text-gray-600">${item.summary}</p>
|
||||
</article>
|
||||
`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// Update count
|
||||
if (countDiv) {
|
||||
countDiv.textContent = `${results.length} result${results.length === 1 ? '' : 's'} found`;
|
||||
}
|
||||
}
|
||||
|
||||
populateFilterOptions() {
|
||||
const tagsContainer = document.querySelector('[data-filter-tags]');
|
||||
const narratorsContainer = document.querySelector('[data-filter-narrators]');
|
||||
const facilitatorsContainer = document.querySelector('[data-filter-facilitators]');
|
||||
|
||||
// Get unique values
|
||||
const tags = new Set(this.searchIndex.flatMap(item => item.tags || []));
|
||||
const narrators = new Set(this.searchIndex.map(item => item.narrator).filter(Boolean));
|
||||
const facilitators = new Set(this.searchIndex.map(item => item.facilitator).filter(Boolean));
|
||||
|
||||
// Helper to create filter options
|
||||
const createFilterOption = (value, container, filterSet) => {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'flex items-center gap-2 text-sm';
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.value = value;
|
||||
checkbox.checked = filterSet.has(value);
|
||||
checkbox.addEventListener('change', () => {
|
||||
if (checkbox.checked) {
|
||||
filterSet.add(value);
|
||||
} else {
|
||||
filterSet.delete(value);
|
||||
}
|
||||
this.performSearch();
|
||||
});
|
||||
|
||||
label.appendChild(checkbox);
|
||||
label.appendChild(document.createTextNode(value));
|
||||
container?.appendChild(label);
|
||||
};
|
||||
|
||||
// Populate filters
|
||||
if (tagsContainer) {
|
||||
tagsContainer.innerHTML = '';
|
||||
Array.from(tags).sort().forEach(tag =>
|
||||
createFilterOption(tag, tagsContainer, this.filters.tags)
|
||||
);
|
||||
}
|
||||
|
||||
if (narratorsContainer) {
|
||||
narratorsContainer.innerHTML = '';
|
||||
Array.from(narrators).sort().forEach(narrator =>
|
||||
createFilterOption(narrator, narratorsContainer, this.filters.narrators)
|
||||
);
|
||||
}
|
||||
|
||||
if (facilitatorsContainer) {
|
||||
facilitatorsContainer.innerHTML = '';
|
||||
Array.from(facilitators).sort().forEach(facilitator =>
|
||||
createFilterOption(facilitator, facilitatorsContainer, this.filters.facilitators)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
initializeFromURL() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const searchInput = document.querySelector('[data-search-input]');
|
||||
const dateFrom = document.querySelector('[data-date-from]');
|
||||
const dateTo = document.querySelector('[data-date-to]');
|
||||
const logicMode = document.querySelector('[data-logic-mode]');
|
||||
|
||||
// Restore search query
|
||||
if (searchInput && params.has('q')) {
|
||||
searchInput.value = params.get('q');
|
||||
}
|
||||
|
||||
// Restore date filters
|
||||
if (dateFrom && params.has('from')) {
|
||||
dateFrom.value = params.get('from');
|
||||
this.filters.dateFrom = params.get('from');
|
||||
}
|
||||
if (dateTo && params.has('to')) {
|
||||
dateTo.value = params.get('to');
|
||||
this.filters.dateTo = params.get('to');
|
||||
}
|
||||
|
||||
// Restore logic mode
|
||||
if (logicMode) {
|
||||
this.filters.useOrLogic = params.get('logic') === 'or';
|
||||
logicMode.checked = this.filters.useOrLogic;
|
||||
}
|
||||
|
||||
// Restore filter selections
|
||||
if (params.has('tags')) {
|
||||
params.get('tags').split(',').forEach(tag => this.filters.tags.add(tag));
|
||||
}
|
||||
if (params.has('narrators')) {
|
||||
params.get('narrators').split(',').forEach(narrator => this.filters.narrators.add(narrator));
|
||||
}
|
||||
if (params.has('facilitators')) {
|
||||
params.get('facilitators').split(',').forEach(facilitator => this.filters.facilitators.add(facilitator));
|
||||
}
|
||||
|
||||
// Perform initial search if we have any parameters
|
||||
if ([...params.keys()].length > 0) {
|
||||
this.performSearch();
|
||||
}
|
||||
}
|
||||
|
||||
parseDate(dateString) {
|
||||
try {
|
||||
return dateString ? new Date(dateString) : null;
|
||||
} catch (error) {
|
||||
this.handleError(error, 'Invalid date format');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize search when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => new SearchInterface());
|
||||
} else {
|
||||
new SearchInterface();
|
||||
}
|
Reference in New Issue
Block a user