Add searchable speeches archive
This commit is contained in:
parent
d74495ddef
commit
09c4548942
6 changed files with 624 additions and 1 deletions
245
src/pages/speeches/index.astro
Normal file
245
src/pages/speeches/index.astro
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
---
|
||||
import SiteLayout from "../../components/SiteLayout.astro";
|
||||
import { getSpeechArchive, uniqueSorted } from "../../lib/speeches";
|
||||
|
||||
const speeches = getSpeechArchive();
|
||||
const years = uniqueSorted(speeches.map((speech) => speech.year), "desc");
|
||||
const speakers = uniqueSorted(speeches.map((speech) => speech.speaker));
|
||||
const categories = uniqueSorted(speeches.flatMap((speech) => speech.categories.length ? speech.categories : [speech.category]));
|
||||
---
|
||||
|
||||
<SiteLayout
|
||||
title="Speeches Archive – FFWPU Ireland"
|
||||
bodyClass="page-template page-template-templates page-template-template-onecolumn page-template-templatestemplate-onecolumn-php page custom-background tribe-no-js metaslider-plugin parabola-image-three caption-light meta-light parabola_triagles parabola-menu-left"
|
||||
pathname="/speeches/"
|
||||
canonical="/speeches/"
|
||||
>
|
||||
<div id="main">
|
||||
<div id="forbottom">
|
||||
<div style="clear:both;"> </div>
|
||||
|
||||
<section id="container" class="one-column speeches-archive">
|
||||
<div id="content" role="main">
|
||||
<div class="post page type-page status-publish hentry">
|
||||
<h1 class="entry-title">Speeches Archive</h1>
|
||||
|
||||
<div class="entry-content">
|
||||
<form class="speech-filters" id="speech-filters" role="search">
|
||||
<div class="speech-filter speech-filter-search">
|
||||
<label for="speech-search">Search text</label>
|
||||
<input id="speech-search" name="q" type="search" autocomplete="off" placeholder="Search titles and full speech text" />
|
||||
</div>
|
||||
|
||||
<div class="speech-filter">
|
||||
<label for="speech-year">Year</label>
|
||||
<select id="speech-year" name="year">
|
||||
<option value="">All years</option>
|
||||
{years.map((year) => <option value={year}>{year}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="speech-filter">
|
||||
<label for="speech-speaker">Speaker</label>
|
||||
<select id="speech-speaker" name="speaker">
|
||||
<option value="">All speakers</option>
|
||||
{speakers.map((speaker) => <option value={speaker}>{speaker}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="speech-filter">
|
||||
<label for="speech-category">Category</label>
|
||||
<select id="speech-category" name="category">
|
||||
<option value="">All categories</option>
|
||||
{categories.map((category) => <option value={category}>{category}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button class="speech-reset" type="reset">Reset filters</button>
|
||||
</form>
|
||||
|
||||
<div class="speech-archive-status" aria-live="polite">
|
||||
<strong id="speech-result-count">{speeches.length}</strong> speeches
|
||||
</div>
|
||||
|
||||
<div class="speech-results" id="speech-results">
|
||||
{
|
||||
speeches.map((speech) => (
|
||||
<article class="speech-result">
|
||||
<div class="speech-result-meta">
|
||||
<span>{speech.year}</span>
|
||||
<span>{speech.speaker}</span>
|
||||
</div>
|
||||
<h2 class="speech-result-title">
|
||||
<a href={speech.url}>{speech.title}</a>
|
||||
</h2>
|
||||
<p>{speech.excerpt}</p>
|
||||
<div class="speech-result-categories">
|
||||
{(speech.categories.length ? speech.categories : [speech.category]).map((category) => <span>{category}</span>)}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div style="clear:both;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script is:inline slot="scripts">
|
||||
(() => {
|
||||
const form = document.getElementById("speech-filters");
|
||||
const results = document.getElementById("speech-results");
|
||||
const count = document.getElementById("speech-result-count");
|
||||
const searchInput = document.getElementById("speech-search");
|
||||
const yearSelect = document.getElementById("speech-year");
|
||||
const speakerSelect = document.getElementById("speech-speaker");
|
||||
const categorySelect = document.getElementById("speech-category");
|
||||
|
||||
if (!form || !results || !count || !searchInput || !yearSelect || !speakerSelect || !categorySelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
let speeches = [];
|
||||
let renderTimer = 0;
|
||||
|
||||
function normalize(value) {
|
||||
return value
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim();
|
||||
}
|
||||
|
||||
function selectedFilters() {
|
||||
return {
|
||||
q: searchInput.value.trim(),
|
||||
year: yearSelect.value,
|
||||
speaker: speakerSelect.value,
|
||||
category: categorySelect.value,
|
||||
};
|
||||
}
|
||||
|
||||
function syncUrl(filters) {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) {
|
||||
params.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
const nextUrl = params.toString() ? `${window.location.pathname}?${params}` : window.location.pathname;
|
||||
window.history.replaceState(null, "", nextUrl);
|
||||
}
|
||||
|
||||
function hydrateFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
searchInput.value = params.get("q") || "";
|
||||
yearSelect.value = params.get("year") || "";
|
||||
speakerSelect.value = params.get("speaker") || "";
|
||||
categorySelect.value = params.get("category") || "";
|
||||
}
|
||||
|
||||
function matchesSpeech(speech, filters) {
|
||||
if (filters.year && speech.year !== filters.year) {
|
||||
return false;
|
||||
}
|
||||
if (filters.speaker && speech.speaker !== filters.speaker) {
|
||||
return false;
|
||||
}
|
||||
if (filters.category && !speech.categories.includes(filters.category) && speech.category !== filters.category) {
|
||||
return false;
|
||||
}
|
||||
if (!filters.q) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const terms = normalize(filters.q).split(/\s+/).filter(Boolean);
|
||||
return terms.every((term) => speech.searchText.includes(term));
|
||||
}
|
||||
|
||||
function appendMeta(parent, values) {
|
||||
values.filter(Boolean).forEach((value) => {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = value;
|
||||
parent.append(span);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSpeech(speech) {
|
||||
const article = document.createElement("article");
|
||||
article.className = "speech-result";
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "speech-result-meta";
|
||||
appendMeta(meta, [speech.year, speech.speaker]);
|
||||
|
||||
const title = document.createElement("h2");
|
||||
title.className = "speech-result-title";
|
||||
const link = document.createElement("a");
|
||||
link.href = speech.url;
|
||||
link.textContent = speech.title;
|
||||
title.append(link);
|
||||
|
||||
const excerpt = document.createElement("p");
|
||||
excerpt.textContent = speech.excerpt;
|
||||
|
||||
const categories = document.createElement("div");
|
||||
categories.className = "speech-result-categories";
|
||||
appendMeta(categories, speech.categories.length ? speech.categories : [speech.category]);
|
||||
|
||||
article.append(meta, title, excerpt, categories);
|
||||
return article;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const filters = selectedFilters();
|
||||
const matched = speeches.filter((speech) => matchesSpeech(speech, filters));
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
if (matched.length) {
|
||||
matched.forEach((speech) => fragment.append(renderSpeech(speech)));
|
||||
} else {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "speech-empty-state";
|
||||
empty.textContent = "No speeches match these filters.";
|
||||
fragment.append(empty);
|
||||
}
|
||||
|
||||
results.replaceChildren(fragment);
|
||||
count.textContent = matched.length.toString();
|
||||
syncUrl(filters);
|
||||
}
|
||||
|
||||
function queueRender() {
|
||||
window.clearTimeout(renderTimer);
|
||||
renderTimer = window.setTimeout(render, 120);
|
||||
}
|
||||
|
||||
hydrateFromUrl();
|
||||
|
||||
fetch("/speeches/search-index.json")
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Speech index request failed: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then((data) => {
|
||||
speeches = data;
|
||||
render();
|
||||
form.addEventListener("input", queueRender);
|
||||
form.addEventListener("change", render);
|
||||
form.addEventListener("reset", () => {
|
||||
window.setTimeout(render, 0);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
count.textContent = results.querySelectorAll(".speech-result").length.toString();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</SiteLayout>
|
||||
Loading…
Add table
Add a link
Reference in a new issue