1 Commits
main ... search

Author SHA1 Message Date
290a502b1e initial search filter feature 2025-03-31 14:43:38 -06:00
8 changed files with 423 additions and 0 deletions

View 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;
}
}

View File

@ -11,6 +11,7 @@
// Components
@import "components/wompum";
@import "components/search";
body {
font-family: $font-garamond;

View File

@ -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

View File

@ -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>

View File

@ -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
View 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 -}}

View 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
View 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();
}