PageSourceSearch

https://creativestrategies.com/assets/js/research-controls.js?v=208da23294

js creativestrategies.com collected 2026-09-24 18:54:15 UTC 12,173 bytes, 238 lines download raw bytes

1import {findEntries, loadReusableSearchArchive, publications, resultPage, searchEndpoints} from './research-search.js?v=3';
2import {SNAPSHOT_MAX_AGE_MS} from './research-cache.js?v=1';
3import {setCardImage} from './image-delivery.js?v=1';
4
5export function initializeResearchSearch(document, window, loadArchive = loadReusableSearchArchive) {
6    const controls = document.querySelector('[data-research-controls]');
7    const stream = document.querySelector('[data-research-stream]');
8    if (!controls || !stream) return;
9
10    const search = controls.querySelector('[data-research-search]');
11    const publication = controls.querySelector('[data-research-publication]');
12    const author = controls.querySelector('[data-research-author]');
13    const clear = document.querySelector('[data-research-clear]');
14    const status = document.querySelector('[data-research-filter-status]');
15    const empty = document.querySelector('[data-research-filter-empty]');
16    const results = document.querySelector('[data-research-results]');
17    const cards = document.querySelector('[data-research-result-cards]');
18    const template = document.querySelector('[data-research-result-template]');
19    const browsePagination = document.querySelector('[data-research-browse-pagination]');
20    const pagination = document.querySelector('[data-research-result-pagination]');
21    const previous = pagination.querySelector('[data-search-previous]');
22    const next = pagination.querySelector('[data-search-next]');
23    const pageLabel = pagination.querySelector('[data-search-page]');
24    const retry = document.querySelector('[data-research-retry]');
25    const heading = document.querySelector('[data-research-heading]');
26    let archive;
27    let pending;
28    let failed = false;
29    let page = 1;
30    let debounce;
31    let dateFormatter;
32    let requestController;
33    let savedExpiry;
34    let warmupTimer;
35    let warmupController;
36    let warmed = false;
37    const params = new URLSearchParams(window.location.search);
38    search.value = params.get('q') || '';
39    publication.value = publications[params.get('publication')] ? params.get('publication') : '';
40    const requestedAuthor = params.get('author') || '';
41    if (requestedAuthor) addAuthor(requestedAuthor);
42    author.value = requestedAuthor;
43    page = Number.parseInt(params.get('resultPage'), 10) || 1;
44
45    function active() { return Boolean(search.value.trim() || publication.value || author.value); }
46    function addAuthor(name) {
47        const option = document.createElement('option');
48        option.value = name;
49        option.textContent = name || 'Any';
50        author.appendChild(option);
51    }
52    function populateAuthors() {
53        const selected = author.value;
54        const names = [...new Set(archive.items.flatMap(item => item.authors))].sort((a, b) => a.localeCompare(b));
55        if (selected && !names.includes(selected)) names.push(selected);
56        author.replaceChildren();
57        ['', ...names].forEach(addAuthor);
58        author.value = selected;
59    }
60    function updateUrl() {
61        const url = new URL(window.location.href);
62        for (const [key, value] of Object.entries({q: search.value.trim(), publication: publication.value,
63            author: author.value, resultPage: active() && page > 1 ? String(page) : ''})) {
64            if (value) url.searchParams.set(key, value);
65            else url.searchParams.delete(key);
66        }
67        window.history.replaceState(null, '', url);
68    }
69    function resultCard(item) {
70        const card = template.content.firstElementChild.cloneNode(true);
71        card.setAttribute('data-research-source', item.source);
72        const link = card.querySelector('a');
73        link.href = item.link;
74        if (item.source !== 'cs') { link.target = '_blank'; link.rel = 'noopener noreferrer'; }
75        card.querySelector('[data-result-source]').textContent = publications[item.source].name;
76        card.querySelector('h3').textContent = item.title;
77        card.querySelector('[data-result-authors]').textContent = item.authors.join(', ');
78        card.querySelector('[data-result-description]').textContent = item.description;
79        const date = card.querySelector('time');
80        date.dateTime = item.publishedAt;
81        dateFormatter ||= new Intl.DateTimeFormat('en-US', {month:'short',day:'numeric',year:'numeric',timeZone:'UTC'});
82        date.textContent = dateFormatter.format(new Date(item.publishedAt));
83        card.querySelector('[data-result-cta]').textContent = item.source === 'cs'
84            ? 'Read research' : 'Read on Substack (opens in a new tab)';
85        const image = card.querySelector('img');
86        image.hidden = !item.image;
87        if (item.image) setCardImage(image, item.image);
88        card.classList.toggle('has-image', Boolean(item.image));
89        return card;
90    }
91    function savedNotice() {
92        return failed ? 'Showing saved results. Updates could not be verified; retry to check for changes.'
93            : 'Showing saved results while checking every archive page for updates…';
94    }
95    function receiveArchive(value) {
96        clearTimeout(savedExpiry);
97        archive = value;
98        populateAuthors();
99        if (archive.saved && typeof window.setTimeout === 'function') {
100            savedExpiry = window.setTimeout(() => {
101                if (!archive?.saved) return;
102                archive = undefined;
103                failed = !pending;
104                render();
105            }, Math.max(0, archive.verifiedAt + SNAPSHOT_MAX_AGE_MS - Date.now()));
106        }
107    }
108    function render() {
109        if (archive?.saved && Date.now() - archive.verifiedAt >= SNAPSHOT_MAX_AGE_MS) {
110            archive = undefined;
111            failed = !pending;
112        }
113        const searching = active();
114        stream.hidden = searching;
115        browsePagination.hidden = searching;
116        results.hidden = !searching;
117        clear.hidden = !searching;
118        retry.hidden = !failed;
119        empty.hidden = true;
120        pagination.hidden = true;
121        heading.textContent = searching ? 'Search results' : 'Across four publications';
122        results.setAttribute('aria-busy', String(searching && Boolean(pending)));
123        if (!searching) {
124            const items = [...stream.querySelectorAll(':scope > [data-research-item]')];
125            items.forEach((item, index) => item.classList.toggle('research-featured', index === 0));
126            status.textContent = archive?.saved ? `${savedNotice()} You can keep browsing below.`
127                : failed ? 'Full archive search is unavailable. You can still browse the latest research below.'
128                : archive ? `Search ${archive.items.length.toLocaleString()} research posts across all four publications. Browsing newest first.`
129                    : pending ? 'Preparing search across all four publications… You can keep browsing below.'
130                        : 'Search all research across four publications by title, author, or topic.';
131            return;
132        }
133        if (!archive) {
134            cards.replaceChildren();
135            status.textContent = failed ? 'Could not load the complete archive. Retry search or clear the filters to keep browsing.'
136                : 'Searching the full archive across all four publications…';
137            return;
138        }
139        const matches = findEntries(archive.items, {query: search.value, publication: publication.value, author: author.value});
140        const slice = resultPage(matches, page);
141        page = slice.page;
142        cards.replaceChildren(...slice.items.map(resultCard));
143        empty.hidden = matches.length > 0;
144        status.textContent = `${slice.total.toLocaleString()} ${slice.total === 1 ? 'result' : 'results'} ${archive.saved ? 'in the saved archive' : 'across the full archive'}${search.value.trim() ? ` for “${search.value.trim()}”` : ''}. ` +
145            (slice.total ? `Showing ${slice.start}–${slice.end} · Newest first.` : 'Try another search or clear the filters.') +
146            (archive.saved ? ` ${savedNotice()}` : archive.stale ? ' Using the last saved publication archive; recent updates may be delayed.' : '');
147        pagination.hidden = slice.pages <= 1;
148        previous.disabled = page === 1;
149        next.disabled = page === slice.pages;
150        pageLabel.textContent = `Page ${page} of ${slice.pages}`;
151    }
152    function ensureArchive({force = false, refresh = false} = {}) {
153        clearTimeout(warmupTimer);
154        if ((archive && !force && !refresh) || pending) return pending;
155        failed = false;
156        const controller = requestController = new AbortController();
157        pending = loadArchive(window.location.origin, searchEndpoints[window.location.hostname], {
158            force, signal: controller.signal,
159            onSaved(value) { receiveArchive(value); render(); }
160        })
161            .then(receiveArchive)
162            .catch(() => { if (!controller.signal.aborted) failed = true; })
163            .finally(() => { pending = null; render(); updateUrl(); });
164        render();
165        return pending;
166    }
167    function change() {
168        clearTimeout(debounce);
169        page = 1;
170        if (active() && !archive && !pending) ensureArchive();
171        else render();
172        updateUrl();
173    }
174    search.addEventListener('input', () => {
175        clearTimeout(debounce);
176        page = 1;
177        if (active()) debounce = setTimeout(change, 180);
178        else change();
179    });
180    search.addEventListener('focus', ensureArchive);
181    publication.addEventListener('focus', ensureArchive);
182    author.addEventListener('focus', ensureArchive);
183    publication.addEventListener('change', change);
184    author.addEventListener('change', change);
185    retry.addEventListener('click', () => ensureArchive({force: true}));
186    controls.addEventListener('pointerenter', () => {
187        const connection = window.navigator?.connection;
188        if (warmed || archive || pending || connection?.saveData || ['slow-2g', '2g'].includes(connection?.effectiveType)) return;
189        clearTimeout(warmupTimer);
190        warmupTimer = setTimeout(() => {
191            if (archive || pending || document.hidden) return;
192            const connection = window.navigator?.connection;
193            if (connection?.saveData || ['slow-2g', '2g'].includes(connection?.effectiveType)) return;
194            warmed = true;
195            warmupController = new AbortController();
196            loadArchive(window.location.origin, searchEndpoints[window.location.hostname], {signal: warmupController.signal}).catch(() => {});
197        }, 120);
198    });
199    controls.addEventListener('pointerleave', () => clearTimeout(warmupTimer));
200    clear.addEventListener('click', () => {
201        clearTimeout(debounce);
202        search.value = ''; publication.value = ''; author.value = '';
203        change(); search.focus();
204    });
205    function movePage(delta) {
206        page += delta;
207        render(); updateUrl();
208        heading.focus({preventScroll: true});
209        heading.scrollIntoView({block: 'start'});
210    }
211    previous.addEventListener('click', () => movePage(-1));
212    next.addEventListener('click', () => movePage(1));
213    document.addEventListener('keydown', event => {
214        if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
215            event.preventDefault(); search.focus();
216        }
217    });
218    new window.MutationObserver(() => { if (!active()) render(); }).observe(stream, {childList: true});
219    window.addEventListener?.('pagehide', () => {
220        clearTimeout(warmupTimer);
221        clearTimeout(savedExpiry);
222        requestController?.abort();
223        warmupController?.abort();
224    });
225    window.addEventListener?.('pageshow', event => {
226        if (!event.persisted || (!archive && !active())) return;
227        if (archive) receiveArchive({...archive, saved: true});
228        const refresh = () => ensureArchive({refresh: true});
229        if (pending) pending.finally(refresh);
230        else refresh();
231    });
232    controls.hidden = false;
233    status.hidden = false;
234    render();
235    if (active()) ensureArchive();
236}
237
238if (typeof document !== 'undefined') initializeResearchSearch(document, window);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.