Skip to content

Commit 054a5c6

Browse files
Add Algolia search (#115)
* Add Algolia search * Add search to footer
1 parent 321cdac commit 054a5c6

File tree

11 files changed

+284
-3
lines changed

11 files changed

+284
-3
lines changed

config/_default/menus.toml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,23 +70,29 @@ pre = "is-uppercase"
7070
url = "/news/"
7171
weight = 7
7272

73+
[[footer]]
74+
name = "Search"
75+
pre = "is-uppercase"
76+
url = "/search/"
77+
weight = 8
78+
7379
[[footer]]
7480
name = "Special Projects & Investigations"
7581
pre = "is-uppercase"
7682
url = "/series/"
77-
weight = 8
83+
weight = 9
7884

7985
[[footer]]
8086
name = "Topic Archive"
8187
pre = "is-uppercase"
8288
url = "/topics/"
83-
weight = 9
89+
weight = 10
8490

8591
[[footer]]
8692
name = "Donate"
8793
pre = "is-uppercase"
8894
url = "/donate/"
89-
weight = 10
95+
weight = 11
9096

9197
[[about]]
9298
name = "About Us"

content/news/_index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,5 @@ outputs = [
1111
]
1212
+++
1313
Spotlight PA is dedicated to producing non­partisan investigative journalism about Pennsylvania government and urgent statewide issues. We are an independent watchdog unafraid to dig deep, fight for the truth and take on the powerful to expose wrongdoing and spur meaningful reform. We connect Pennsylvanians to their state, and to each other, through public service journalism that matters to their lives and is creatively told in the many modern, digital ways they consume their news.
14+
15+
{{<news-search>}}

content/search.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
+++
2+
title = "Search Spotlight PA News"
3+
description = "Search Spotlight PA News articles"
4+
section = "root"
5+
+++
6+
7+
{{<news-search>}}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
{{ $iconSearch := resources.Get "@fontawesome/solid/search.svg" }}
2+
3+
<div x-cloak x-data="spl.searchArticles()" x-init="init">
4+
<div class="field has-addons">
5+
<div class="control is-expanded has-icons-left">
6+
<input
7+
class="input"
8+
x-model="query"
9+
placeholder="Search Spotlight PA news…"
10+
>
11+
<span class="icon is-large is-left">
12+
{{ $iconSearch.Content|safeHTML }}
13+
</span>
14+
</div>
15+
<div class="control">
16+
<a
17+
class="button is-danger has-text-weight-semibold"
18+
:class="isLoading ? 'is-loading' : ''"
19+
>
20+
Search
21+
</a>
22+
</div>
23+
</div>
24+
<div class="field">
25+
<p x-show="query && !isLoading" class="help">
26+
<span x-text="resultsText"></span>
27+
</p>
28+
<template x-if="error">
29+
<div class="help is-danger">
30+
Could not load results. <span x-text="error.message"></span>
31+
</div>
32+
</template>
33+
</div>
34+
35+
<div class="tile is-ancestor is-vertical mt-5">
36+
<template x-for="story in stories" :key="story.url">
37+
<article class="tile is-vertical is-bottom">
38+
<div class="tile">
39+
<div class="tile is-parent is-top">
40+
<div class="tile is-child">
41+
<h3 class="tag is-square is-black is-uppercase has-text-weight-semibold mb-2" x-text="story.kicker">
42+
</h3>
43+
</div>
44+
</div>
45+
</div>
46+
<div class="tile is-bottom">
47+
<div class="tile is-parent is-4 is-top-tablet">
48+
<div class="tile is-child">
49+
<figure>
50+
<a
51+
class="image is-16by9 has-background-grey-lighter"
52+
:href="story.url"
53+
>
54+
<picture class="has-ratio">
55+
<img
56+
:alt="story.imageDescription"
57+
:title="story.imageDescription"
58+
:data-src="story.imageSource"
59+
:src="magicPixel"
60+
@load.once="loadImage($event)"
61+
loading="lazy">
62+
</picture>
63+
</a>
64+
<figcaption class="is-clearfix is-size-7 is-uppercase has-text-grey-light">
65+
<span
66+
class="mt-2 is-single-spaced is-pulled-right"
67+
x-text="story.imageCredit"
68+
></span>
69+
</figcaption>
70+
</figure>
71+
</div>
72+
</div>
73+
<div class="tile is-parent is-8 is-bottom-tablet">
74+
<div class="tile is-child" @click.capture="analytics($event)">
75+
<h2 class="title is-spaced is-4 mt-2-tablet">
76+
<a
77+
class="has-text-black hover-underline"
78+
:href="story.url"
79+
x-text="story.title"
80+
>
81+
</a>
82+
</h2>
83+
<h3 class="subtitle has-margin-top-negative-medium is-5 has-text-weight-normal">
84+
<a :href="story.url" class="has-text-grey hover-underline" x-text="story.byline">
85+
</a>
86+
</h3>
87+
<h3
88+
class="subtitle has-margin-top-negative-medium is-5 has-text-weight-normal"
89+
>
90+
<a :href="story.url" class="has-text-grey hover-underline">
91+
<time :datetime="story.publishedISO" x-text="story.published">
92+
</time>
93+
</a>
94+
</h3>
95+
</div>
96+
</div>
97+
</div>
98+
</article>
99+
</template>
100+
</div>
101+
</div>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
</div>
2+
3+
{{ partial "search-articles.html" }}
4+
5+
<div class="content">

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"alpinejs": "^2.4.0",
3737
"autoprefixer": "^9.5.1",
3838
"bulma": "^0.9.0",
39+
"journalize": "^2.4.0",
3940
"parcel-bundler": "^1.12.4",
4041
"postcss-cli": "^7.1.0",
4142
"vue": "^2.6.10",
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { apdate } from "journalize";
2+
3+
import imgproxy from "../utils/imgproxy-url.js";
4+
import { sendGAEvent } from "../utils/google-analytics.js";
5+
import searchAPI from "../utils/search-api.js";
6+
import { debouncer } from "../utils/timers.js";
7+
8+
function roundUp(n, by) {
9+
return by * Math.ceil(n / by);
10+
}
11+
12+
function normalize(obj) {
13+
return {
14+
title: obj["link-title"] || obj.hed,
15+
url: obj.URL || "",
16+
byline: obj.byline || "",
17+
imageCredit: obj["image-credit"] || "",
18+
imageDescription: obj["image-description"] || "",
19+
kicker: obj.kicker || "",
20+
publishedISO: obj["pub-date"] || "",
21+
get imageSource() {
22+
return obj["image-url"] || "";
23+
},
24+
get published() {
25+
return apdate(new Date(this.publishedISO));
26+
},
27+
};
28+
}
29+
30+
const magicPixel =
31+
"data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==";
32+
33+
export default function searchArticles() {
34+
return {
35+
query: "",
36+
results: null,
37+
error: null,
38+
isLoading: false,
39+
magicPixel,
40+
41+
init() {
42+
const bouncedSearch = debouncer({ milliseconds: 500 }, () =>
43+
searchAPI(this.query)
44+
.then((results) => {
45+
this.error = null;
46+
if (results) {
47+
this.results = results;
48+
}
49+
})
50+
.catch((error) => {
51+
this.isLoading = false;
52+
this.error = error;
53+
})
54+
.finally(() => {
55+
this.isLoading = false;
56+
})
57+
);
58+
59+
this.$watch("query", () => {
60+
this.isLoading = true;
61+
bouncedSearch();
62+
});
63+
},
64+
65+
get stories() {
66+
if (!this.results || !this.results.hits) {
67+
return [];
68+
}
69+
return this.results.hits.map(normalize);
70+
},
71+
72+
get resultsCount() {
73+
return this.results?.nbHits ?? 0;
74+
},
75+
76+
get resultsText() {
77+
let nHits = this.resultsCount;
78+
if (!nHits) {
79+
return "No search results";
80+
}
81+
if (nHits === 1) {
82+
return "Got one search result.";
83+
}
84+
let nStories = this.results?.hits?.length ?? 0;
85+
let more = nHits > nStories ? `Showing first ${nStories}.` : "";
86+
return `Got ${nHits} search results. ${more}`;
87+
},
88+
89+
loadImage(ev) {
90+
let el = ev.target;
91+
let { src } = el.dataset;
92+
if (el.src !== this.magicPixel || src === "") {
93+
return;
94+
}
95+
let { width = 0, height = 0 } = el;
96+
let aspectRatio = height / width;
97+
width = roundUp(window.devicePixelRatio * width, 100);
98+
height = Math.round(aspectRatio * width);
99+
el.src = imgproxy(src, { width, height });
100+
},
101+
102+
analytics($event) {
103+
let { href = "" } = $event.target;
104+
sendGAEvent({
105+
eventCategory: "Internal Link",
106+
eventAction: "Search",
107+
eventLabel: href,
108+
transport: "beacon",
109+
});
110+
},
111+
};
112+
}

src/utils/enhancements.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import modal from "../enhancements/modal.js";
1212
import mostPopular from "../enhancements/most-popular.js";
1313
import readmore from "../enhancements/read-more.js";
1414
import sticky from "../enhancements/sticky.js";
15+
import searchArticles from "../enhancements/search-articles.js";
1516

1617
window.spl = Object.assign({}, window.spl, {
1718
embedList,
@@ -20,6 +21,7 @@ window.spl = Object.assign({}, window.spl, {
2021
modal,
2122
mostPopular,
2223
readmore,
24+
searchArticles,
2325
sticky,
2426
});
2527

src/utils/search-api.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import fetchJSON from "./fetch-json.js";
2+
3+
const appID = `5M1ASV9W0A`;
4+
const publicKey = `fd9492397caaffd9cb49be210170e63a`;
5+
const indexName = `spotlightpa-content`;
6+
7+
let baseURL = `https://${appID}-dsn.algolia.net/1/indexes/${indexName}?x-algolia-agent=spotlightpa&x-algolia-application-id=${appID}&x-algolia-api-key=${publicKey}&query=`;
8+
9+
export default function searchAPI(query) {
10+
if (!query) {
11+
return Promise.resolve(null);
12+
}
13+
return fetchJSON(baseURL + encodeURIComponent(query));
14+
}

src/utils/timers.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
function millisecondsFrom({ minutes, seconds, milliseconds }) {
2+
let time = minutes;
3+
time *= 60;
4+
time += seconds;
5+
time *= 1000;
6+
time += milliseconds;
7+
return time;
8+
}
9+
10+
export function after({ minutes = 0, seconds = 0, milliseconds = 0 }) {
11+
return new Promise((resolve) => {
12+
window.setTimeout(
13+
resolve,
14+
millisecondsFrom({ minutes, seconds, milliseconds })
15+
);
16+
});
17+
}
18+
19+
export function debouncer({ minutes = 0, seconds = 0, milliseconds = 0 }, cb) {
20+
let time = millisecondsFrom({ minutes, seconds, milliseconds });
21+
let timeoutID = null;
22+
return (...args) => {
23+
window.clearTimeout(timeoutID);
24+
timeoutID = window.setTimeout(() => cb(...args), time);
25+
};
26+
}

0 commit comments

Comments
 (0)