chore: import components

This commit is contained in:
Cory Dransfeldt 2024-10-18 17:34:04 -07:00
parent de2bb89710
commit 68d941af6b
No known key found for this signature in database
12 changed files with 3078 additions and 190 deletions

View file

@ -44,16 +44,6 @@ export default async function (eleventyConfig) {
entryFileNames: 'assets/js/[name].[hash].js'
},
},
resolve: {
alias: {
'api-text': resolve('./node_modules/@cdransf/api-text/api-text.js'),
'select-pagination': resolve('./node_modules/@cdransf/select-pagination/select-pagination.js'),
'mastodon-post': resolve('./node_modules/@daviddarnes/mastodon-post/mastodon-post.js'),
'mini-search': resolve('./node_modules/minisearch/dist/umd/index.js'),
'theme-toggle': resolve('./node_modules/@cdransf/theme-toggle/theme-toggle.js'),
'youtube-video-element': resolve('./node_modules/youtube-video-element/youtube-video-element.js'),
},
},
},
plugins: [ViteMinifyPlugin({})],
},

View file

@ -11,8 +11,9 @@
"start:eleventy": "eleventy --serve",
"start:eleventy:quick": "eleventy --serve --incremental --ignore-initial",
"build": "ELEVENTY_PRODUCTION=true eleventy",
"update:deps": "npm upgrade && ncu",
"debug": "DEBUG=Eleventy* npx @11ty/eleventy --serve",
"update:deps": "npm upgrade && ncu",
"install:components": "node ./scripts/install-components.mjs",
"clean": "rimraf _site",
"build:worker": "node scripts/worker-build.mjs $WORKER_NAME",
"deploy:worker": "wrangler deploy --env production --config workers/$npm_config_worker/wrangler.toml"

View file

@ -0,0 +1,31 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const components = [
{ src: '@cdransf/api-text/api-text.js', dest: 'api-text.js' },
{ src: '@cdransf/select-pagination/select-pagination.js', dest: 'select-pagination.js' },
{ src: '@daviddarnes/mastodon-post/mastodon-post.js', dest: 'mastodon-post.js' },
{ src: 'minisearch/dist/es/index.js', dest: 'mini-search.js' },
{ src: '@cdransf/theme-toggle/theme-toggle.js', dest: 'theme-toggle.js' },
{ src: 'youtube-video-element/youtube-video-element.js', dest: 'youtube-video-element.js' }
]
const destDir = path.resolve(__dirname, '../src/assets/js/components')
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true })
console.log(`Created directory: ${destDir}`)
}
components.forEach(({ src, dest }) => {
const srcPath = path.resolve(__dirname, '../node_modules', src)
const destPath = path.join(destDir, dest)
fs.copyFile(srcPath, destPath, err => {
if (err) console.error(`Failed to copy ${src}:`, err)
else console.log(`Copied ${src} to ${destPath}`)
})
})

View file

@ -1,3 +1,8 @@
import './js/search.js'
import './js/index.js'
import './css/index.css'
import './js/components/api-text.js'
import './js/components/select-pagination.js'
import './js/components/mastodon-post.js'
import './js/components/theme-toggle.js'
import './js/components/youtube-video-element.js'
import './css/index.css'
import './js/index.js'

View file

@ -0,0 +1,69 @@
class ApiText extends HTMLElement {
static tagName = 'api-text'
static register(tagName = this.tagName, registry = globalThis.customElements) {
registry.define(tagName, this)
}
static attr = {
url: 'api-url',
}
get url() {
return this.getAttribute(ApiText.attr.url) || ''
}
async connectedCallback() {
if (this.shadowRoot) return
this.attachShadow({ mode: 'open' }).appendChild(document.createElement('slot'))
const loading = this.querySelector('.loading')
const content = this.querySelector('.content')
const cacheKey = this.url || 'api-text-cache'
const cache = sessionStorage?.getItem(cacheKey)
const noscriptContent = this.querySelector('noscript')?.innerHTML.trim() || ''
const loadText = (string) => {
if (!string) {
if (noscriptContent) {
content.innerHTML = noscriptContent
loading.style.display = 'none'
content.style.display = 'block'
} else {
this.style.display = 'none'
}
return
}
loading.style.display = 'none'
content.style.display = 'block'
content.innerHTML = string
}
if (cache) {
loadText(JSON.parse(cache))
} else {
loading.style.display = 'block'
content.style.display = 'none'
}
try {
const data = await this.data
const value = data.content
if (value) {
loadText(value)
sessionStorage?.setItem(cacheKey, JSON.stringify(value))
} else {
loadText('')
}
} catch (error) {
loadText('')
}
}
get data() {
return fetch(this.url).then(response => response.json()).catch(() => ({}))
}
}
ApiText.register()

View file

@ -0,0 +1,115 @@
const mastodonPostTemplate = document.createElement("template");
mastodonPostTemplate.innerHTML = `
<figure>
<blockquote data-key="content"></blockquote>
<figcaption>
<cite>
<a data-key="url"><span data-key="username"></span>@<span data-key="hostname"></span></a>
</cite>
<dl>
<dt>Reposts</dt><dd data-key="reblogs_count"></dd>
<dt>Replies</dt><dd data-key="replies_count"></dd>
<dt>Favourites</dt><dd data-key="favourites_count"></dd>
</dl>
</figcaption>
</figure>
`;
mastodonPostTemplate.id = "mastodon-post-template";
if (!document.getElementById(mastodonPostTemplate.id)) {
document.body.appendChild(mastodonPostTemplate);
}
class MastodonPost extends HTMLElement {
static register(tagName) {
if ("customElements" in window) {
customElements.define(tagName || "mastodon-post", MastodonPost);
}
}
async connectedCallback() {
this.append(this.template);
const data = { ...(await this.data), ...this.linkData };
this.slots.forEach((slot) => {
slot.dataset.key.split(",").forEach((keyItem) => {
const value = this.getValue(keyItem, data);
if (keyItem === "content") {
slot.innerHTML = value;
} else {
this.populateSlot(slot, value);
}
});
});
}
populateSlot(slot, value) {
if (typeof value == "string" && value.startsWith("http")) {
if (slot.localName === "img") slot.src = value;
if (slot.localName === "a") slot.href = value;
} else {
slot.textContent = value;
}
}
handleKey(object, key) {
const parsedKeyInt = parseFloat(key);
if (Number.isNaN(parsedKeyInt)) {
return object[key];
}
return object[parsedKeyInt];
}
getValue(string, data) {
let keys = string.trim().split(/\.|\[|\]/g);
keys = keys.filter((string) => string.length);
const value = keys.reduce(
(object, key) => this.handleKey(object, key),
data
);
return value;
}
get template() {
return document
.getElementById(
this.getAttribute("template") || `${this.localName}-template`
)
.content.cloneNode(true);
}
get slots() {
return this.querySelectorAll("[data-key]");
}
get link() {
return this.querySelector("a").href;
}
get linkData() {
const url = new URL(this.link);
const paths = url.pathname.split("/").filter((string) => string.length);
return {
url: this.link,
hostname: url.hostname,
username: paths.find((path) => path.startsWith("@")),
postId: paths.find((path) => !path.startsWith("@"))
};
}
get endpoint() {
return `https://${this.linkData.hostname}/api/v1/statuses/${this.linkData.postId}`;
}
get data() {
return fetch(this.endpoint).then((response) => response.json());
}
}
MastodonPost.register();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,48 @@
class SelectPagination extends HTMLElement {
static register(tagName = 'select-pagination') {
if ("customElements" in window) customElements.define(tagName, this)
}
static get observedAttributes() {
return ['data-base-index']
}
get baseIndex() {
return this.getAttribute('data-base-index') || 0
}
connectedCallback() {
if (this.shadowRoot) return
this.attachShadow({ mode: 'open' }).appendChild(document.createElement('slot'))
const uriSegments = window.location.pathname.split('/').filter(Boolean)
let pageNumber = this.extractPageNumber(uriSegments) || 0
this.control = this.querySelector('select')
this.control.value = pageNumber
this.control.addEventListener('change', (event) => {
pageNumber = parseInt(event.target.value)
const updatedUrlSegments = this.updateUrlSegments(uriSegments, pageNumber)
window.location.href = `${window.location.origin}/${updatedUrlSegments.join('/')}`
})
}
extractPageNumber(segments) {
const lastSegment = segments[segments.length - 1]
return !isNaN(lastSegment) ? parseInt(lastSegment) : null
}
updateUrlSegments(segments, pageNumber) {
if (!isNaN(segments[segments.length - 1])) {
segments[segments.length - 1] = pageNumber.toString()
} else {
segments.push(pageNumber.toString())
}
if (pageNumber === parseInt(this.baseIndex)) segments.pop()
return segments
}
}
SelectPagination.register()

View file

@ -0,0 +1,45 @@
class ThemeToggle extends HTMLElement {
static tagName = 'theme-toggle'
static register(tagName = this.tagName, registry = globalThis.customElements) {
registry.define(tagName, this)
}
connectedCallback() {
if (this.shadowRoot) return
this.attachShadow({ mode: 'open' }).appendChild(document.createElement('slot'))
this.root = document.documentElement
this.button = this.querySelector('button')
this.prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)')
this.currentTheme = sessionStorage.getItem('theme')
this.setTheme()
this.button.addEventListener('click', () => this.toggleTheme())
this.prefersDarkScheme.addEventListener('change', (event) => this.onPreferredColorSchemeChange(event))
}
setTheme() {
if (!this.currentTheme) {
this.currentTheme = this.prefersDarkScheme.matches ? 'dark' : 'light'
}
this.theme = this.currentTheme
this.root.setAttribute('data-theme', this.theme)
}
toggleTheme() {
this.currentTheme = this.currentTheme === 'dark' ? 'light' : 'dark'
sessionStorage.setItem('theme', this.currentTheme)
this.setTheme()
}
onPreferredColorSchemeChange(event) {
if (!sessionStorage.getItem('theme')) {
this.currentTheme = event.matches ? 'dark' : 'light'
this.setTheme()
}
}
}
ThemeToggle.register()

View file

@ -0,0 +1,547 @@
// https://developers.google.com/youtube/iframe_api_reference
const EMBED_BASE = 'https://www.youtube.com/embed';
const API_URL = 'https://www.youtube.com/iframe_api';
const API_GLOBAL = 'YT';
const API_GLOBAL_READY = 'onYouTubeIframeAPIReady';
const MATCH_SRC =
/(?:youtu\.be\/|youtube\.com\/(?:shorts\/|embed\/|v\/|watch\?v=|watch\?.+&v=))((\w|-){11})/;
function getTemplateHTML(attrs) {
const iframeAttrs = {
src: serializeIframeUrl(attrs),
frameborder: 0,
width: '100%',
height: '100%',
allow: 'accelerometer; fullscreen; autoplay; encrypted-media; gyroscope; picture-in-picture',
};
return /*html*/`
<style>
:host {
display: inline-block;
line-height: 0;
position: relative;
min-width: 300px;
min-height: 150px;
}
iframe {
position: absolute;
top: 0;
left: 0;
}
</style>
<iframe${serializeAttributes(iframeAttrs)}></iframe>
`;
}
function serializeIframeUrl(attrs) {
if (!attrs.src) return;
const matches = attrs.src.match(MATCH_SRC);
const srcId = matches && matches[1];
const params = {
// ?controls=true is enabled by default in the iframe
controls: attrs.controls === '' ? null : 0,
autoplay: attrs.autoplay,
loop: attrs.loop,
mute: attrs.muted,
playsinline: attrs.playsinline,
preload: attrs.preload ?? 'metadata',
// origin: globalThis.location?.origin,
enablejsapi: 1,
showinfo: 0,
rel: 0,
iv_load_policy: 3,
modestbranding: 1,
};
return `${EMBED_BASE}/${srcId}?${serialize(params)}`;
}
class YoutubeVideoElement extends (globalThis.HTMLElement ?? class {}) {
static getTemplateHTML = getTemplateHTML;
static shadowRootOptions = { mode: 'open' };
static observedAttributes = [
'autoplay',
'controls',
'crossorigin',
'loop',
'muted',
'playsinline',
'poster',
'preload',
'src',
];
loadComplete = new PublicPromise();
#loadRequested;
#hasLoaded;
#readyState = 0;
#seeking = false;
#seekComplete;
isLoaded = false;
async load() {
if (this.#loadRequested) return;
if (!this.shadowRoot) {
this.attachShadow({ mode: 'open' });
}
if (this.#hasLoaded) {
this.loadComplete = new PublicPromise();
this.isLoaded = false;
}
this.#hasLoaded = true;
// Wait 1 tick to allow other attributes to be set.
await (this.#loadRequested = Promise.resolve());
this.#loadRequested = null;
this.#readyState = 0;
this.dispatchEvent(new Event('emptied'));
let oldApi = this.api;
this.api = null;
if (!this.src) {
// Removes the <iframe> containing the player.
oldApi?.destroy();
return;
}
this.dispatchEvent(new Event('loadstart'));
let iframe = this.shadowRoot.querySelector('iframe');
let attrs = namedNodeMapToObject(this.attributes);
if (!iframe?.src || iframe.src !== serializeIframeUrl(attrs)) {
this.shadowRoot.innerHTML = getTemplateHTML(attrs);
iframe = this.shadowRoot.querySelector('iframe');
}
const YT = await loadScript(API_URL, API_GLOBAL, API_GLOBAL_READY);
this.api = new YT.Player(iframe, {
events: {
onReady: () => {
this.#readyState = 1; // HTMLMediaElement.HAVE_METADATA
this.dispatchEvent(new Event('loadedmetadata'));
this.dispatchEvent(new Event('durationchange'));
this.dispatchEvent(new Event('volumechange'));
this.dispatchEvent(new Event('loadcomplete'));
this.isLoaded = true;
this.loadComplete.resolve();
},
onError: (error) => console.error(error),
},
});
/* onStateChange
-1 (unstarted)
0 (ended)
1 (playing)
2 (paused)
3 (buffering)
5 (video cued).
*/
let playFired = false;
this.api.addEventListener('onStateChange', (event) => {
const state = event.data;
if (
state === YT.PlayerState.PLAYING ||
state === YT.PlayerState.BUFFERING
) {
if (!playFired) {
playFired = true;
this.dispatchEvent(new Event('play'));
}
}
if (state === YT.PlayerState.PLAYING) {
if (this.seeking) {
this.#seeking = false;
this.#seekComplete?.resolve();
this.dispatchEvent(new Event('seeked'));
}
this.#readyState = 3; // HTMLMediaElement.HAVE_FUTURE_DATA
this.dispatchEvent(new Event('playing'));
} else if (state === YT.PlayerState.PAUSED) {
const diff = Math.abs(this.currentTime - lastCurrentTime);
if (!this.seeking && diff > 0.1) {
this.#seeking = true;
this.dispatchEvent(new Event('seeking'));
}
playFired = false;
this.dispatchEvent(new Event('pause'));
}
if (state === YT.PlayerState.ENDED) {
playFired = false;
this.dispatchEvent(new Event('pause'));
this.dispatchEvent(new Event('ended'));
if (this.loop) {
this.play();
}
}
});
this.api.addEventListener('onPlaybackRateChange', () => {
this.dispatchEvent(new Event('ratechange'));
});
this.api.addEventListener('onVolumeChange', () => {
this.dispatchEvent(new Event('volumechange'));
});
this.api.addEventListener('onVideoProgress', () => {
this.dispatchEvent(new Event('timeupdate'));
});
await this.loadComplete;
let lastCurrentTime = 0;
setInterval(() => {
const diff = Math.abs(this.currentTime - lastCurrentTime);
const bufferedEnd = this.buffered.end(this.buffered.length - 1);
if (this.seeking && bufferedEnd > 0.1) {
this.#seeking = false;
this.#seekComplete?.resolve();
this.dispatchEvent(new Event('seeked'));
} else if (!this.seeking && diff > 0.1) {
this.#seeking = true;
this.dispatchEvent(new Event('seeking'));
}
lastCurrentTime = this.currentTime;
}, 50);
let lastBufferedEnd;
const progressInterval = setInterval(() => {
const bufferedEnd = this.buffered.end(this.buffered.length - 1);
if (bufferedEnd >= this.duration) {
clearInterval(progressInterval);
this.#readyState = 4; // HTMLMediaElement.HAVE_ENOUGH_DATA
}
if (lastBufferedEnd != bufferedEnd) {
lastBufferedEnd = bufferedEnd;
this.dispatchEvent(new Event('progress'));
}
}, 100);
}
async attributeChangedCallback(attrName, oldValue, newValue) {
if (oldValue === newValue) return;
// This is required to come before the await for resolving loadComplete.
switch (attrName) {
case 'src':
case 'autoplay':
case 'controls':
case 'loop':
case 'playsinline': {
this.load();
}
}
}
async play() {
this.#seekComplete = null;
await this.loadComplete;
// yt.playVideo doesn't return a play promise.
this.api?.playVideo();
return createPlayPromise(this);
}
async pause() {
await this.loadComplete;
return this.api?.pauseVideo();
}
get seeking() {
return this.#seeking;
}
get readyState() {
return this.#readyState;
}
// If the getter from SuperVideoElement is overridden, it's required to define
// the setter again too unless it's a read only property! It's a JS thing.
get src() {
return this.getAttribute('src');
}
set src(val) {
if (this.src == val) return;
this.setAttribute('src', val);
}
/* onStateChange
-1 (unstarted)
0 (ended)
1 (playing)
2 (paused)
3 (buffering)
5 (video cued).
*/
get paused() {
if (!this.isLoaded) return !this.autoplay;
return [-1, 0, 2, 5].includes(this.api?.getPlayerState?.());
}
get duration() {
return this.api?.getDuration?.() ?? NaN;
}
get autoplay() {
return this.hasAttribute('autoplay');
}
set autoplay(val) {
if (this.autoplay == val) return;
this.toggleAttribute('autoplay', Boolean(val));
}
get buffered() {
if (!this.isLoaded) return createTimeRanges();
const progress =
this.api?.getVideoLoadedFraction() * this.api?.getDuration();
if (progress > 0) {
return createTimeRanges(0, progress);
}
return createTimeRanges();
}
get controls() {
return this.hasAttribute('controls');
}
set controls(val) {
if (this.controls == val) return;
this.toggleAttribute('controls', Boolean(val));
}
get currentTime() {
return this.api?.getCurrentTime?.() ?? 0;
}
set currentTime(val) {
if (this.currentTime == val) return;
this.#seekComplete = new PublicPromise();
this.loadComplete.then(() => {
this.api?.seekTo(val, true);
if (this.paused) {
this.#seekComplete?.then(() => {
if (!this.#seekComplete) return;
this.api?.pauseVideo();
});
}
});
}
set defaultMuted(val) {
if (this.defaultMuted == val) return;
this.toggleAttribute('muted', Boolean(val));
}
get defaultMuted() {
return this.hasAttribute('muted');
}
get loop() {
return this.hasAttribute('loop');
}
set loop(val) {
if (this.loop == val) return;
this.toggleAttribute('loop', Boolean(val));
}
set muted(val) {
if (this.muted == val) return;
this.loadComplete.then(() => {
val ? this.api?.mute() : this.api?.unMute();
});
}
get muted() {
if (!this.isLoaded) return this.defaultMuted;
return this.api?.isMuted?.();
}
get playbackRate() {
return this.api?.getPlaybackRate?.() ?? 1;
}
set playbackRate(val) {
if (this.playbackRate == val) return;
this.loadComplete.then(() => {
this.api?.setPlaybackRate(val);
});
}
get playsInline() {
return this.hasAttribute('playsinline');
}
set playsInline(val) {
if (this.playsInline == val) return;
this.toggleAttribute('playsinline', Boolean(val));
}
get poster() {
return this.getAttribute('poster');
}
set poster(val) {
if (this.poster == val) return;
this.setAttribute('poster', `${val}`);
}
set volume(val) {
if (this.volume == val) return;
this.loadComplete.then(() => {
this.api?.setVolume(val * 100);
});
}
get volume() {
if (!this.isLoaded) return 1;
return this.api?.getVolume() / 100;
}
}
function serializeAttributes(attrs) {
let html = '';
for (const key in attrs) {
const value = attrs[key];
if (value === '') html += ` ${key}`;
else html += ` ${key}="${value}"`;
}
return html;
}
function serialize(props) {
return String(new URLSearchParams(boolToBinary(props)));
}
function boolToBinary(props) {
let p = {};
for (let key in props) {
let val = props[key];
if (val === true || val === '') p[key] = 1;
else if (val === false) p[key] = 0;
else if (val != null) p[key] = val;
}
return p;
}
function namedNodeMapToObject(namedNodeMap) {
let obj = {};
for (let attr of namedNodeMap) {
obj[attr.name] = attr.value;
}
return obj;
}
const loadScriptCache = {};
async function loadScript(src, globalName, readyFnName) {
if (loadScriptCache[src]) return loadScriptCache[src];
if (globalName && self[globalName]) {
await delay(0);
return self[globalName];
}
return (loadScriptCache[src] = new Promise(function (resolve, reject) {
const script = document.createElement('script');
script.src = src;
const ready = () => resolve(self[globalName]);
if (readyFnName) (self[readyFnName] = ready);
script.onload = () => !readyFnName && ready();
script.onerror = reject;
document.head.append(script);
}));
}
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function promisify(fn) {
return (...args) =>
new Promise((resolve) => {
fn(...args, (...res) => {
if (res.length > 1) resolve(res);
else resolve(res[0]);
});
});
}
function createPlayPromise(player) {
return promisify((event, cb) => {
let fn;
player.addEventListener(
event,
(fn = () => {
player.removeEventListener(event, fn);
cb();
})
);
})('playing');
}
/**
* A utility to create Promises with convenient public resolve and reject methods.
* @return {Promise}
*/
class PublicPromise extends Promise {
constructor(executor = () => {}) {
let res, rej;
super((resolve, reject) => {
executor(resolve, reject);
res = resolve;
rej = reject;
});
this.resolve = res;
this.reject = rej;
}
}
/**
* Creates a fake `TimeRanges` object.
*
* A TimeRanges object. This object is normalized, which means that ranges are
* ordered, don't overlap, aren't empty, and don't touch (adjacent ranges are
* folded into one bigger range).
*
* @param {(Number|Array)} Start of a single range or an array of ranges
* @param {Number} End of a single range
* @return {Array}
*/
function createTimeRanges(start, end) {
if (Array.isArray(start)) {
return createTimeRangesObj(start);
} else if (start == null || end == null || (start === 0 && end === 0)) {
return createTimeRangesObj([[0, 0]]);
}
return createTimeRangesObj([[start, end]]);
}
function createTimeRangesObj(ranges) {
Object.defineProperties(ranges, {
start: {
value: i => ranges[i][0]
},
end: {
value: i => ranges[i][1]
}
});
return ranges;
}
if (globalThis.customElements && !globalThis.customElements.get('youtube-video')) {
globalThis.customElements.define('youtube-video', YoutubeVideoElement);
}
export default YoutubeVideoElement;

View file

@ -1,3 +1,5 @@
import MiniSearch from './components/mini-search.js'
window.addEventListener('load', () => {
// menu keyboard controls
;(() => {
@ -84,4 +86,179 @@ window.addEventListener('load', () => {
button.textContent = isHidden ? 'Show more' : 'Show less'
})
})()
;(() => {
if (!MiniSearch) return
const miniSearch = new MiniSearch({
fields: ['title', 'text', 'tags', 'type'],
})
const $form = document.querySelector('.search__form')
const $input = document.querySelector('.search__form--input')
const $fallback = document.querySelector('.search__form--fallback')
const $typeCheckboxes = document.querySelectorAll('.search__form--type input[type="checkbox"]')
const $results = document.querySelector('.search__results')
const $loadMoreButton = document.querySelector('.search__load-more')
$form.removeAttribute('action')
$form.removeAttribute('method')
$fallback.remove()
const PAGE_SIZE = 10
let currentPage = 1
let currentResults = []
const loadSearchIndex = async () => {
try {
const response = await fetch('/api/search')
const index = await response.json()
const resultsById = index.reduce((byId, result) => {
byId[result.id] = result
return byId
}, {})
miniSearch.addAll(index)
return resultsById
} catch (error) {
console.error('Error fetching search index:', error)
return {}
}
}
let resultsById = {}
let debounceTimeout
loadSearchIndex().then(loadedResultsById => resultsById = loadedResultsById)
const getSelectedTypes = () => {
return Array.from($typeCheckboxes)
.filter(checkbox => checkbox.checked)
.map(checkbox => checkbox.value)
}
$input.addEventListener('input', () => {
const query = $input.value
clearTimeout(debounceTimeout)
if (query.length === 0) {
renderSearchResults([])
$loadMoreButton.style.display = 'none'
return
}
debounceTimeout = setTimeout(() => {
const results = (query.length > 1) ? getSearchResults(query) : []
currentResults = results
currentPage = 1
renderSearchResults(getResultsForPage(currentPage))
$loadMoreButton.style.display = results.length > PAGE_SIZE ? 'block' : 'none'
}, 300)
})
$input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') event.preventDefault()
})
$typeCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', () => {
const query = $input.value
const results = getSearchResults(query)
currentResults = results
currentPage = 1
renderSearchResults(getResultsForPage(currentPage))
$loadMoreButton.style.display = results.length > PAGE_SIZE ? 'block' : 'none'
})
})
$loadMoreButton.addEventListener('click', () => {
currentPage++
const nextResults = getResultsForPage(currentPage)
appendSearchResults(nextResults)
if (currentPage * PAGE_SIZE >= currentResults.length) $loadMoreButton.style.display = 'none'
})
const getSearchResults = (query) => {
const selectedTypes = getSelectedTypes()
return miniSearch.search(query, { prefix: true, fuzzy: 0.2, boost: { title: 2 } })
.map(({ id }) => resultsById[id])
.filter(result => selectedTypes.includes(result.type))
}
const getResultsForPage = (page) => {
const start = (page - 1) * PAGE_SIZE
const end = page * PAGE_SIZE
return currentResults.slice(start, end)
}
const parseMarkdown = (markdown) => {
if (!markdown) return ''
markdown = markdown.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
markdown = markdown.replace(/\*(.*?)\*/g, '<em>$1</em>')
markdown = markdown.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2">$1</a>')
markdown = markdown.replace(/\n/g, '<br>')
markdown = markdown.replace(/[#*_~`]/g, '')
return markdown
}
const truncateDescription = (markdown, maxLength = 150) => {
const htmlDescription = parseMarkdown(markdown)
const tempDiv = document.createElement('div')
tempDiv.innerHTML = htmlDescription
const plainText = tempDiv.textContent || tempDiv.innerText || ''
if (plainText.length > maxLength) return plainText.substring(0, maxLength) + '...'
return plainText
}
const formatArtistTitle = (title, totalPlays) => {
if (totalPlays > 0) return `${title} <strong class="highlight-text">${totalPlays} plays</strong>`
return `${title}`
}
const renderSearchResults = (results) => {
if (results.length > 0) {
$results.innerHTML = results.map(({ title, url, description, type, total_plays }) => {
const truncatedDesc = truncateDescription(description)
let formattedTitle = title
if (type === 'artist' && total_plays !== undefined) formattedTitle = formatArtistTitle(title, total_plays)
return `
<li class="search__results--result">
<a href="${url}">
<h3>${formattedTitle}</h3>
</a>
<p>${truncatedDesc}</p>
</li>
`
}).join('\n')
$results.style.display = 'block'
} else {
$results.innerHTML = '<li class="search__results--no-results">No results found.</li>'
$results.style.display = 'block'
}
}
const appendSearchResults = (results) => {
const newResults = results.map(({ title, url, description, type, total_plays }) => {
const truncatedDesc = truncateDescription(description)
let formattedTitle = title
if (type === 'artist' && total_plays !== undefined) formattedTitle = formatArtistTitle(title, total_plays)
return `
<li class="search__results--result">
<a href="${url}">
<h3>${formattedTitle}</h3>
</a>
<p>${truncatedDesc}</p>
</li>
`
}).join('\n')
$results.insertAdjacentHTML('beforeend', newResults)
}
})()
})

View file

@ -1,176 +0,0 @@
window.addEventListener('load', () => {
;(() => {
if (!MiniSearch) return
const miniSearch = new MiniSearch({
fields: ['title', 'text', 'tags', 'type'],
})
const $form = document.querySelector('.search__form')
const $input = document.querySelector('.search__form--input')
const $fallback = document.querySelector('.search__form--fallback')
const $typeCheckboxes = document.querySelectorAll('.search__form--type input[type="checkbox"]')
const $results = document.querySelector('.search__results')
const $loadMoreButton = document.querySelector('.search__load-more')
$form.removeAttribute('action')
$form.removeAttribute('method')
$fallback.remove()
const PAGE_SIZE = 10
let currentPage = 1
let currentResults = []
const loadSearchIndex = async () => {
try {
const response = await fetch('/api/search')
const index = await response.json()
const resultsById = index.reduce((byId, result) => {
byId[result.id] = result
return byId
}, {})
miniSearch.addAll(index)
return resultsById
} catch (error) {
console.error('Error fetching search index:', error)
return {}
}
}
let resultsById = {}
let debounceTimeout
loadSearchIndex().then(loadedResultsById => resultsById = loadedResultsById)
const getSelectedTypes = () => {
return Array.from($typeCheckboxes)
.filter(checkbox => checkbox.checked)
.map(checkbox => checkbox.value)
}
$input.addEventListener('input', () => {
const query = $input.value
clearTimeout(debounceTimeout)
if (query.length === 0) {
renderSearchResults([])
$loadMoreButton.style.display = 'none'
return
}
debounceTimeout = setTimeout(() => {
const results = (query.length > 1) ? getSearchResults(query) : []
currentResults = results
currentPage = 1
renderSearchResults(getResultsForPage(currentPage))
$loadMoreButton.style.display = results.length > PAGE_SIZE ? 'block' : 'none'
}, 300)
})
$input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') event.preventDefault()
})
$typeCheckboxes.forEach(checkbox => {
checkbox.addEventListener('change', () => {
const query = $input.value
const results = getSearchResults(query)
currentResults = results
currentPage = 1
renderSearchResults(getResultsForPage(currentPage))
$loadMoreButton.style.display = results.length > PAGE_SIZE ? 'block' : 'none'
})
})
$loadMoreButton.addEventListener('click', () => {
currentPage++
const nextResults = getResultsForPage(currentPage)
appendSearchResults(nextResults)
if (currentPage * PAGE_SIZE >= currentResults.length) $loadMoreButton.style.display = 'none'
})
const getSearchResults = (query) => {
const selectedTypes = getSelectedTypes()
return miniSearch.search(query, { prefix: true, fuzzy: 0.2, boost: { title: 2 } })
.map(({ id }) => resultsById[id])
.filter(result => selectedTypes.includes(result.type))
}
const getResultsForPage = (page) => {
const start = (page - 1) * PAGE_SIZE
const end = page * PAGE_SIZE
return currentResults.slice(start, end)
}
const parseMarkdown = (markdown) => {
if (!markdown) return ''
markdown = markdown.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
markdown = markdown.replace(/\*(.*?)\*/g, '<em>$1</em>')
markdown = markdown.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2">$1</a>')
markdown = markdown.replace(/\n/g, '<br>')
markdown = markdown.replace(/[#*_~`]/g, '')
return markdown
}
const truncateDescription = (markdown, maxLength = 150) => {
const htmlDescription = parseMarkdown(markdown)
const tempDiv = document.createElement('div')
tempDiv.innerHTML = htmlDescription
const plainText = tempDiv.textContent || tempDiv.innerText || ''
if (plainText.length > maxLength) return plainText.substring(0, maxLength) + '...'
return plainText
}
const formatArtistTitle = (title, totalPlays) => {
if (totalPlays > 0) return `${title} <strong class="highlight-text">${totalPlays} plays</strong>`
return `${title}`
}
const renderSearchResults = (results) => {
if (results.length > 0) {
$results.innerHTML = results.map(({ title, url, description, type, total_plays }) => {
const truncatedDesc = truncateDescription(description)
let formattedTitle = title
if (type === 'artist' && total_plays !== undefined) formattedTitle = formatArtistTitle(title, total_plays)
return `
<li class="search__results--result">
<a href="${url}">
<h3>${formattedTitle}</h3>
</a>
<p>${truncatedDesc}</p>
</li>
`
}).join('\n')
$results.style.display = 'block'
} else {
$results.innerHTML = '<li class="search__results--no-results">No results found.</li>'
$results.style.display = 'block'
}
}
const appendSearchResults = (results) => {
const newResults = results.map(({ title, url, description, type, total_plays }) => {
const truncatedDesc = truncateDescription(description)
let formattedTitle = title
if (type === 'artist' && total_plays !== undefined) formattedTitle = formatArtistTitle(title, total_plays)
return `
<li class="search__results--result">
<a href="${url}">
<h3>${formattedTitle}</h3>
</a>
<p>${truncatedDesc}</p>
</li>
`
}).join('\n')
$results.insertAdjacentHTML('beforeend', newResults)
}
})()
})