Add searchable speeches archive

This commit is contained in:
Loyyd 2026-06-11 14:19:56 +02:00
parent d74495ddef
commit 09c4548942
6 changed files with 624 additions and 1 deletions

View 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 &#8211; 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>