Merge pull request '[codex] Add searchable speeches archive' (#2) from codex/searchable-speeches-archive into main
Some checks are pending
/ deploy (push) Waiting to run
Some checks are pending
/ deploy (push) Waiting to run
Reviewed-on: https://git.bcgen.ie/familyfedie/familyfedie-website/pulls/2
This commit is contained in:
commit
d8f7704733
6 changed files with 624 additions and 1 deletions
137
css/site.css
137
css/site.css
|
|
@ -21,4 +21,139 @@ body.custom-background { background-color: #ffffff; }
|
||||||
width:18%;
|
width:18%;
|
||||||
}
|
}
|
||||||
/* Homepage slider/frontpage settings from the original export. */
|
/* Homepage slider/frontpage settings from the original export. */
|
||||||
#colophon {display: none;} .slider-wrapper { max-width: 1200px; } #slider{ max-width: 1200px; max-height: 350px; border:0px solid #ffffff; } #front-text1, #front-text2 { color: #212121; } #front-columns > div { width: 32%; } #front-columns > div.ppcolumn:nth-child(3n) { margin-right: 0; } #front-columns > div.ppcolumn:nth-child(3n+1) { clear: left; } .column-image { padding: 0px; } .column-image h3 {margin-bottom: 0px; } .column-image img { max-height:201px;} .nivo-caption { background-color: rgb(252,250,250); background-color: rgba(252,250,250,0.7); } .nivo-caption, .nivo-caption a { color: #0e2012; } .theme-default .nivoSlider { background-color: #ffffff; } .theme-default .nivo-controlNav:before, .theme-default .nivo-controlNav:after { border-top-color:#ffffff; } .theme-default .nivo-controlNav { background-color:#ffffff; } .slider-bullets .nivo-controlNav a { background-color: #fdf7f7; } .slider-bullets .nivo-controlNav a:hover { background-color: #fdfcfc; } .slider-bullets .nivo-controlNav a.active {background-color: #ffffff; } .slider-numbers .nivo-controlNav a { color:#fdf7f7; background-color:#F7F7F2;} .slider-numbers .nivo-controlNav a:hover { color: #fdfcfc; background-color:#FFFFFF } .slider-numbers .nivo-controlNav a.active { color:#ffffff;} .column-image h3{ color: #333333; background-color: rgb(255,255,255); } .columnmore { background-color: #F7F7F2; } .columnmore:before { border-bottom-color: #F7F7F2; } #front-columns h3.column-header-noimage { background: #FFFFFF; }
|
#colophon {display: none;} .slider-wrapper { max-width: 1200px; } #slider{ max-width: 1200px; max-height: 350px; border:0px solid #ffffff; } #front-text1, #front-text2 { color: #212121; } #front-columns > div { width: 32%; } #front-columns > div.ppcolumn:nth-child(3n) { margin-right: 0; } #front-columns > div.ppcolumn:nth-child(3n+1) { clear: left; } .column-image { padding: 0px; } .column-image h3 {margin-bottom: 0px; } .column-image img { max-height:201px;} .nivo-caption { background-color: rgb(252,250,250); background-color: rgba(252,250,250,0.7); } .nivo-caption, .nivo-caption a { color: #0e2012; } .theme-default .nivoSlider { background-color: #ffffff; } .theme-default .nivo-controlNav:before, .theme-default .nivo-controlNav:after { border-top-color:#ffffff; } .theme-default .nivo-controlNav { background-color:#ffffff; } .slider-bullets .nivo-controlNav a { background-color: #fdf7f7; } .slider-bullets .nivo-controlNav a:hover { background-color: #fdfcfc; } .slider-bullets .nivo-controlNav a.active {background-color: #ffffff; } .slider-numbers .nivo-controlNav a { color:#fdf7f7; background-color:#F7F7F2;} .slider-numbers .nivo-controlNav a:hover { color: #fdfcfc; background-color:#FFFFFF } .slider-numbers .nivo-controlNav a.active { color:#ffffff;} .column-image h3{ color: #333333; background-color: rgb(255,255,255); } .columnmore { background-color: #F7F7F2; } .columnmore:before { border-bottom-color: #F7F7F2; } #front-columns h3.column-header-noimage { background: #FFFFFF; }
|
||||||
|
|
||||||
|
/* Searchable speeches archive. */
|
||||||
|
.speeches-archive .entry-content {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(240px, 2fr) repeat(3, minmax(150px, 1fr)) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: end;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #eeeeee;
|
||||||
|
background: #fbfbf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-filter {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-filter label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #444444;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-filter input,
|
||||||
|
.speech-filter select {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-reset {
|
||||||
|
min-height: 38px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #d8d8d0;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #333333;
|
||||||
|
background: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-reset:hover,
|
||||||
|
.speech-reset:focus {
|
||||||
|
border-color: #bf4d28;
|
||||||
|
color: #bf4d28;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-archive-status {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-results {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-result {
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #eeeeee;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-result-title {
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-result p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-result-meta,
|
||||||
|
.speech-result-categories {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-result-meta span,
|
||||||
|
.speech-result-categories span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border: 1px solid #eeeeee;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #5c5c56;
|
||||||
|
background: #f7f7f2;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-result-meta span:first-child {
|
||||||
|
border-color: #d5e6b3;
|
||||||
|
color: #365d26;
|
||||||
|
background: #f2f7e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-empty-state {
|
||||||
|
padding: 22px;
|
||||||
|
border: 1px solid #eeeeee;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.speech-filters {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-filter-search,
|
||||||
|
.speech-reset {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.speech-filters {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speech-result-title {
|
||||||
|
font-size: 21px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,12 @@ for (const dir of staticDirs) {
|
||||||
copyDir(path.join(repoRoot, dir), path.join(distRoot, dir));
|
copyDir(path.join(repoRoot, dir), path.join(distRoot, dir));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const speechIndexSource = path.join(distRoot, "speeches.html");
|
||||||
|
const speechIndexDestination = path.join(distRoot, "speeches", "index.html");
|
||||||
|
|
||||||
|
if (fs.existsSync(speechIndexSource)) {
|
||||||
|
fs.mkdirSync(path.dirname(speechIndexDestination), { recursive: true });
|
||||||
|
fs.copyFileSync(speechIndexSource, speechIndexDestination);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Copied ${staticDirs.join(", ")} into dist/.`);
|
console.log(`Copied ${staticDirs.join(", ")} into dist/.`);
|
||||||
|
|
|
||||||
224
src/lib/speeches.ts
Normal file
224
src/lib/speeches.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const speechesRoot = path.join(repoRoot, "content", "speeches");
|
||||||
|
|
||||||
|
type FrontmatterValue = string | string[];
|
||||||
|
|
||||||
|
type Frontmatter = {
|
||||||
|
title?: string;
|
||||||
|
published?: string;
|
||||||
|
updated?: string;
|
||||||
|
source?: string;
|
||||||
|
speechYear?: string;
|
||||||
|
speechCollection?: string;
|
||||||
|
categories?: string[];
|
||||||
|
tags?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SpeechArchiveItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
year: string;
|
||||||
|
speaker: string;
|
||||||
|
category: string;
|
||||||
|
categories: string[];
|
||||||
|
tags: string[];
|
||||||
|
excerpt: string;
|
||||||
|
published: string;
|
||||||
|
searchText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const entityMap: Record<string, string> = {
|
||||||
|
amp: "&",
|
||||||
|
apos: "'",
|
||||||
|
hellip: "...",
|
||||||
|
laquo: "<<",
|
||||||
|
nbsp: " ",
|
||||||
|
quot: '"',
|
||||||
|
raquo: ">>",
|
||||||
|
rsquo: "'",
|
||||||
|
lsquo: "'",
|
||||||
|
rdquo: '"',
|
||||||
|
ldquo: '"',
|
||||||
|
};
|
||||||
|
|
||||||
|
function walkMarkdownFiles(dir = speechesRoot): string[] {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
return walkMarkdownFiles(fullPath);
|
||||||
|
}
|
||||||
|
return entry.isFile() && entry.name.endsWith(".md") ? [fullPath] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeEntities(value = ""): string {
|
||||||
|
return value.replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, entity) => {
|
||||||
|
const key = entity.toLowerCase();
|
||||||
|
if (key.startsWith("#x")) {
|
||||||
|
return String.fromCodePoint(Number.parseInt(key.slice(2), 16));
|
||||||
|
}
|
||||||
|
if (key.startsWith("#")) {
|
||||||
|
return String.fromCodePoint(Number.parseInt(key.slice(1), 10));
|
||||||
|
}
|
||||||
|
return entityMap[key] ?? match;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function textFromHtml(html: string): string {
|
||||||
|
return decodeEntities(
|
||||||
|
html
|
||||||
|
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||||
|
.replace(/<[^>]+>/g, " "),
|
||||||
|
)
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScalar(value: string): string {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return JSON.parse(trimmed);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFrontmatter(markdown: string): { frontmatter: Frontmatter; body: string } {
|
||||||
|
const match = markdown.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
||||||
|
if (!match) {
|
||||||
|
return { frontmatter: {}, body: markdown };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed: Record<string, FrontmatterValue> = {};
|
||||||
|
let currentArrayKey = "";
|
||||||
|
|
||||||
|
for (const line of match[1].split("\n")) {
|
||||||
|
const arrayItem = line.match(/^\s+-\s+(.+)$/);
|
||||||
|
if (arrayItem && currentArrayKey) {
|
||||||
|
const current = parsed[currentArrayKey];
|
||||||
|
parsed[currentArrayKey] = [...(Array.isArray(current) ? current : []), parseScalar(arrayItem[1])];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyValue = line.match(/^([A-Za-z0-9_-]+):(?:\s*(.*))?$/);
|
||||||
|
if (!keyValue) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, key, rawValue = ""] = keyValue;
|
||||||
|
currentArrayKey = "";
|
||||||
|
|
||||||
|
if (rawValue.trim() === "[]") {
|
||||||
|
parsed[key] = [];
|
||||||
|
} else if (rawValue.trim() === "") {
|
||||||
|
parsed[key] = [];
|
||||||
|
currentArrayKey = key;
|
||||||
|
} else {
|
||||||
|
parsed[key] = parseScalar(rawValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { frontmatter: parsed as Frontmatter, body: match[2] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSearchText(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferSpeaker(frontmatter: Frontmatter, bodyText: string): string {
|
||||||
|
const categories = frontmatter.categories?.join(" ") ?? "";
|
||||||
|
const collection = frontmatter.speechCollection ?? "";
|
||||||
|
const searchable = `${categories} ${collection} ${bodyText.slice(0, 500)}`.toLowerCase();
|
||||||
|
|
||||||
|
if (searchable.includes("hak ja han") || collection === "mrs-hak-ja-han-moon") {
|
||||||
|
return "Hak Ja Han Moon";
|
||||||
|
}
|
||||||
|
if (searchable.includes("sun myung moon") || /^\d{4}$/.test(collection)) {
|
||||||
|
return "Rev. Sun Myung Moon";
|
||||||
|
}
|
||||||
|
return "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferYear(frontmatter: Frontmatter, filePath: string, bodyText: string): string {
|
||||||
|
if (frontmatter.speechYear) {
|
||||||
|
return frontmatter.speechYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeParts = path.relative(speechesRoot, filePath).split(path.sep);
|
||||||
|
const directoryYear = relativeParts.find((part) => /^(1\d{3}|20\d{2})$/.test(part));
|
||||||
|
if (directoryYear) {
|
||||||
|
return directoryYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryYear = frontmatter.categories?.join(" ").match(/\b(1\d{3}|20\d{2})\b/)?.[1];
|
||||||
|
if (categoryYear) {
|
||||||
|
return categoryYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
return bodyText.slice(0, 1400).match(/\b(1\d{3}|20\d{2})\b/)?.[1] ?? "Unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function excerptFromText(bodyText: string): string {
|
||||||
|
const excerpt = bodyText.replace(/^(REVEREND|REV\.|DR\.|MRS\.) [A-Z .]+/i, "").trim();
|
||||||
|
return excerpt.length > 230 ? `${excerpt.slice(0, 230).trim()}...` : excerpt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemFromFile(filePath: string): SpeechArchiveItem {
|
||||||
|
const markdown = fs.readFileSync(filePath, "utf8");
|
||||||
|
const { frontmatter, body } = parseFrontmatter(markdown);
|
||||||
|
const bodyText = textFromHtml(body);
|
||||||
|
const categories = frontmatter.categories ?? [];
|
||||||
|
const tags = frontmatter.tags ?? [];
|
||||||
|
const relativePath = path.relative(speechesRoot, filePath).split(path.sep).join("/");
|
||||||
|
const title = frontmatter.title ?? path.basename(filePath, ".md");
|
||||||
|
const year = inferYear(frontmatter, filePath, bodyText);
|
||||||
|
const speaker = inferSpeaker(frontmatter, bodyText);
|
||||||
|
const category = categories[0] ?? (speaker === "Unknown" ? "Uncategorized speeches" : `Speeches of ${speaker}`);
|
||||||
|
const searchText = normalizeSearchText([title, year, speaker, category, ...categories, ...tags, bodyText].join(" "));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: relativePath.replace(/\.md$/, ""),
|
||||||
|
title,
|
||||||
|
url: frontmatter.source ?? "#",
|
||||||
|
year,
|
||||||
|
speaker,
|
||||||
|
category,
|
||||||
|
categories,
|
||||||
|
tags,
|
||||||
|
excerpt: excerptFromText(bodyText),
|
||||||
|
published: frontmatter.published ?? "",
|
||||||
|
searchText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSpeechArchive(): SpeechArchiveItem[] {
|
||||||
|
return walkMarkdownFiles()
|
||||||
|
.map(itemFromFile)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const yearSort = Number.parseInt(b.year, 10) - Number.parseInt(a.year, 10);
|
||||||
|
if (Number.isFinite(yearSort) && yearSort !== 0) {
|
||||||
|
return yearSort;
|
||||||
|
}
|
||||||
|
return a.title.localeCompare(b.title);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uniqueSorted(values: string[], direction: "asc" | "desc" = "asc"): string[] {
|
||||||
|
const unique = Array.from(new Set(values.filter(Boolean)));
|
||||||
|
return unique.sort((a, b) => {
|
||||||
|
const numeric = Number.parseInt(a, 10) - Number.parseInt(b, 10);
|
||||||
|
const comparison = Number.isFinite(numeric) && numeric !== 0 ? numeric : a.localeCompare(b);
|
||||||
|
return direction === "desc" ? comparison * -1 : comparison;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@ export const migratedHtmlPages = new Set([
|
||||||
"events.html",
|
"events.html",
|
||||||
"index.html",
|
"index.html",
|
||||||
"services.html",
|
"services.html",
|
||||||
|
"speeches/index.html",
|
||||||
"the-founders.html",
|
"the-founders.html",
|
||||||
"videos.html",
|
"videos.html",
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
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>
|
||||||
10
src/pages/speeches/search-index.json.ts
Normal file
10
src/pages/speeches/search-index.json.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { getSpeechArchive } from "../../lib/speeches";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return new Response(JSON.stringify(getSpeechArchive()), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json; charset=utf-8",
|
||||||
|
"cache-control": "public, max-age=300",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue