Skip to content

Commit b26b99f

Browse files
committed
Add homepage pagination, inline Pagefind search, and more share options
- Add reusable SearchBox with header popover, homepage hero inline search, and rewritten /search/ page; wire search init via public classic script (bundled URL dynamic imports were silently dropped by the Astro 7 bundler) - Add numbered Pagination component used on homepage, blog, and /page/N/ - Homepage: hero search replaces form; page 1 + /page/[n] archive route - Add Facebook, Threads, and Tumblr share links alongside X/LinkedIn/Copy - Mark article content with data-pagefind-body for focused indexing
1 parent 06b8cc8 commit b26b99f

12 files changed

Lines changed: 246 additions & 39 deletions

File tree

‎public/pagefind-search.js‎

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
(function () {
2+
var pagefindPromise = null;
3+
4+
function escapeHtml(value) {
5+
return String(value).replace(/[&<>"']/g, function (char) {
6+
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char] || char;
7+
});
8+
}
9+
10+
function loadPagefind() {
11+
if (!pagefindPromise) {
12+
pagefindPromise = import('/pagefind/pagefind.js');
13+
}
14+
return pagefindPromise;
15+
}
16+
17+
function runSearch(root) {
18+
var input = root.querySelector('input[type="search"]');
19+
var results = root.querySelector('.search-results');
20+
var status = root.querySelector('.search-status');
21+
if (!input || !results) return;
22+
var query = input.value.trim();
23+
if (!query) {
24+
results.innerHTML = '';
25+
if (status) status.textContent = '';
26+
return;
27+
}
28+
var seq = (root._nodehuntSeq = (root._nodehuntSeq || 0) + 1);
29+
loadPagefind()
30+
.then(function (pagefind) {
31+
return pagefind.search(query).then(function (response) {
32+
return Promise.all(response.results.slice(0, 8).map(function (result) { return result.data(); }));
33+
});
34+
})
35+
.then(function (items) {
36+
if (root._nodehuntSeq !== seq) return;
37+
if (status) status.textContent = items.length ? items.length + ' result' + (items.length === 1 ? '' : 's') + ' for "' + query + '"' : '';
38+
results.innerHTML = items.length
39+
? items
40+
.map(function (item) {
41+
var title = escapeHtml((item.meta && item.meta.title) || '');
42+
var excerpt = item.excerpt ? '<span>' + escapeHtml(item.excerpt) + '</span>' : '';
43+
return '<a class="result" href="' + item.url + '"><strong>' + title + '</strong>' + excerpt + '</a>';
44+
})
45+
.join('')
46+
: '<p class="search-empty">No matching articles yet. Try a broader term.</p>';
47+
})
48+
.catch(function () {
49+
if (root._nodehuntSeq === seq && status) status.textContent = 'Search is unavailable right now.';
50+
});
51+
}
52+
53+
function initSearchBox(root) {
54+
if (root.dataset.searchReady) return;
55+
root.dataset.searchReady = 'true';
56+
var input = root.querySelector('input[type="search"]');
57+
if (!input) return;
58+
var timer;
59+
function schedule() {
60+
window.clearTimeout(timer);
61+
timer = window.setTimeout(function () { runSearch(root); }, 160);
62+
}
63+
input.addEventListener('input', schedule);
64+
input.addEventListener('keydown', function (event) {
65+
if (event.key === 'Enter') {
66+
event.preventDefault();
67+
window.clearTimeout(timer);
68+
runSearch(root);
69+
}
70+
});
71+
var query = new URLSearchParams(window.location.search).get('q');
72+
if (query) {
73+
input.value = query;
74+
runSearch(root);
75+
}
76+
}
77+
78+
function initAll() {
79+
document.querySelectorAll('[data-search-box]').forEach(initSearchBox);
80+
}
81+
82+
if (document.readyState === 'loading') {
83+
document.addEventListener('DOMContentLoaded', initAll);
84+
} else {
85+
initAll();
86+
}
87+
})();

‎src/components/article/ShareLinks.astro‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ interface Props { title: string; url: string; }
33
const { title, url } = Astro.props;
44
const xUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(title)}&url=${encodeURIComponent(url)}`;
55
const linkedinUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`;
6+
const facebookUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`;
7+
const threadsUrl = `https://www.threads.net/intent/post?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`;
8+
const tumblrUrl = `https://www.tumblr.com/widgets/share/tool?canonicalUrl=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}`;
69
---
7-
<aside class="share" aria-label="Share this article"><span class="share-label">Share</span><a class="share-link" href={xUrl} target="_blank" rel="noopener noreferrer" aria-label="Share on X">X</a><a class="share-link" href={linkedinUrl} target="_blank" rel="noopener noreferrer" aria-label="Share on LinkedIn">LinkedIn</a><button class="share-link" type="button" data-copy-link>Copy link</button><span class="copy-status" role="status" aria-live="polite"></span></aside>
10+
<aside class="share" aria-label="Share this article"><span class="share-label">Share</span><a class="share-link" href={xUrl} target="_blank" rel="noopener noreferrer" aria-label="Share on X">X</a><a class="share-link" href={facebookUrl} target="_blank" rel="noopener noreferrer" aria-label="Share on Facebook">Facebook</a><a class="share-link" href={linkedinUrl} target="_blank" rel="noopener noreferrer" aria-label="Share on LinkedIn">LinkedIn</a><a class="share-link" href={threadsUrl} target="_blank" rel="noopener noreferrer" aria-label="Share on Threads">Threads</a><a class="share-link" href={tumblrUrl} target="_blank" rel="noopener noreferrer" aria-label="Share on Tumblr">Tumblr</a><button class="share-link" type="button" data-copy-link>Copy link</button><span class="copy-status" role="status" aria-live="polite"></span></aside>
811
<script>
912
const button = document.querySelector<HTMLButtonElement>('[data-copy-link]');
1013
const status = document.querySelector<HTMLElement>('.copy-status');
@@ -18,7 +21,7 @@ const linkedinUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encod
1821
});
1922
</script>
2023
<style>
21-
.share { display: flex; align-items: center; gap: .5rem; margin: 2.5rem 0; }
24+
.share { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin: 2.5rem 0; }
2225
.share-label { margin-right: .25rem; color: var(--muted); font: 800 .78rem var(--font-display); letter-spacing: .08em; text-transform: uppercase; }
2326
.share-link { min-height: 2.25rem; padding: .4rem .8rem; border: 1px solid var(--line); border-radius: .6rem; background: var(--surface); color: var(--text); font-size: .85rem; font-weight: 800; text-decoration: none; cursor: pointer; }
2427
.share-link:hover { border-color: var(--accent); }
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
interface Props {
3+
current: number;
4+
total: number;
5+
base: string;
6+
}
7+
const { current, total, base } = Astro.props;
8+
const pageUrl = (n: number) => (n === 1 ? base : `${base}page/${n}/`);
9+
const start = Math.max(1, current - 2);
10+
const end = Math.min(total, current + 2);
11+
---
12+
<nav class="pagination" aria-label="Pagination">
13+
{current > 1 && <a class="page-arrow prev" href={pageUrl(current - 1)} rel="prev">← Newer</a>}
14+
<div class="page-numbers">
15+
{start > 1 && <><a href={pageUrl(1)} aria-label="Page 1">1</a>{start > 2 && <span class="gap" aria-hidden="true">…</span>}</>}
16+
{Array.from({ length: end - start + 1 }, (_, i) => start + i).map((n) =>
17+
n === current ? <span class="current" aria-current="page">{n}</span> : <a href={pageUrl(n)} aria-label={`Page ${n}`}>{n}</a>
18+
)}
19+
{end < total && <>{end < total - 1 && <span class="gap" aria-hidden="true">…</span>}<a href={pageUrl(total)} aria-label={`Page ${total}`}>{total}</a></>}
20+
</div>
21+
{current < total && <a class="page-arrow next" href={pageUrl(current + 1)} rel="next">Older →</a>}
22+
</nav>
23+
<style>
24+
.pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .8rem; margin: 2.5rem 0 1rem; }
25+
.page-arrow { color: var(--accent); font-weight: 800; text-decoration: none; font-size: .9rem; }
26+
.page-arrow:hover { text-decoration: underline; }
27+
.page-numbers { display: flex; align-items: center; gap: .35rem; }
28+
.page-numbers a, .page-numbers .current { display: grid; min-width: 2.3rem; height: 2.3rem; place-items: center; border-radius: .55rem; font-weight: 800; font-size: .9rem; text-decoration: none; }
29+
.page-numbers a { border: 1px solid var(--line); color: var(--text); }
30+
.page-numbers a:hover { border-color: var(--accent); color: var(--accent); }
31+
.page-numbers .current { background: var(--accent); color: #062219; }
32+
.gap { color: var(--muted); padding: 0 .2rem; }
33+
@media(max-width: 560px) { .page-arrow { flex: 1 0 100%; text-align: center; } }
34+
</style>

‎src/components/layout/SiteHeader.astro‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
---
22
import { navigation } from '@/config/site';
3+
import SearchBox from '@/components/search/SearchBox.astro';
34
const path = Astro.url.pathname;
45
---
56
<header class="site-header">
@@ -9,9 +10,12 @@ const path = Astro.url.pathname;
910
{navigation.map((item) => <a class:list={{ active: path.startsWith(item.href) }} href={item.href}>{item.label}</a>)}
1011
</nav>
1112
<div class="header-actions">
12-
<a class="search-link" href="/search/" aria-label="Search NodeHunt">⌕ <span>Search</span></a>
13+
<div class="search-wrap">
14+
<button class="search-toggle" type="button" data-search-toggle aria-expanded="false" aria-controls="header-search-panel" aria-haspopup="dialog">⌕ <span>Search</span></button>
15+
<div id="header-search-panel" class="search-popover" hidden><SearchBox id="header" /></div>
16+
</div>
1317
<button class="theme-toggle" type="button" aria-label="Toggle color theme" aria-pressed="false">◐</button>
14-
<details class="mobile-nav"><summary aria-label="Open navigation">Menu</summary><nav aria-label="Mobile navigation">{navigation.map((item) => <a href={item.href}>{item.label}</a>)}<a href="/search/">Search</a></nav></details>
18+
<details class="mobile-nav"><summary aria-label="Open navigation">Menu</summary><nav aria-label="Mobile navigation">{navigation.map((item) => <a href={item.href}>{item.label}</a>)}</nav></details>
1519
</div>
1620
</div>
1721
</header>
@@ -23,13 +27,33 @@ const path = Astro.url.pathname;
2327
const update = () => button?.setAttribute('aria-pressed', String(root.dataset.theme === 'light'));
2428
update();
2529
button?.addEventListener('click', () => { root.dataset.theme = root.dataset.theme === 'light' ? 'dark' : 'light'; localStorage.setItem('nodehunt-theme', root.dataset.theme); update(); });
30+
const searchToggle = document.querySelector<HTMLButtonElement>('[data-search-toggle]');
31+
const searchPanel = document.querySelector<HTMLElement>('#header-search-panel');
32+
const openPanel = () => {
33+
searchPanel?.removeAttribute('hidden');
34+
searchToggle?.setAttribute('aria-expanded', 'true');
35+
searchPanel?.querySelector('input')?.focus();
36+
};
37+
const closePanel = () => {
38+
searchPanel?.setAttribute('hidden', '');
39+
searchToggle?.setAttribute('aria-expanded', 'false');
40+
};
41+
searchToggle?.addEventListener('click', () => {
42+
if (searchPanel?.hasAttribute('hidden')) openPanel();
43+
else closePanel();
44+
});
45+
document.addEventListener('click', (event) => {
46+
const target = event.target as Node;
47+
if (searchPanel && !searchPanel.hasAttribute('hidden') && !searchPanel.contains(target) && !searchToggle?.contains(target)) closePanel();
48+
});
49+
document.addEventListener('keydown', (event) => { if (event.key === 'Escape') closePanel(); });
2650
</script>
2751
<style>
2852
.site-header { position: sticky; top: 0; z-index: 20; border-bottom: 1px solid color-mix(in srgb, var(--line) 85%, transparent); background: color-mix(in srgb, var(--bg) 88%, transparent); backdrop-filter: blur(14px); }
2953
.header-inner { display: flex; min-height: 4.7rem; align-items: center; justify-content: space-between; gap: 1.25rem; }
3054
.brand { display: inline-flex; align-items: center; gap: .55rem; font: 800 1.2rem var(--font-display); text-decoration: none; }
3155
.brand:hover { color: var(--text); }.brand-mark { display: grid; width: 1.75rem; height: 1.75rem; place-items: center; border-radius: .55rem; background: linear-gradient(135deg,var(--accent),var(--violet)); color: #07150f; font-size: 1rem; }
3256
.desktop-nav { display:flex; gap:1.15rem; font-size:.9rem; font-weight:700; }.desktop-nav a { color:var(--muted); text-decoration:none; }.desktop-nav a.active,.desktop-nav a:hover { color:var(--text); }
33-
.header-actions { display:flex; align-items:center; gap:.5rem; }.search-link,.theme-toggle { min-height:2.5rem; border:1px solid var(--line); border-radius:.6rem; background:var(--surface); color:var(--text); padding:.45rem .7rem; text-decoration:none; cursor:pointer; }.search-link { font-size:.85rem; }.mobile-nav { display:none; position:relative; }.mobile-nav summary { cursor:pointer; list-style:none; padding:.45rem .25rem; font-weight:700; }.mobile-nav nav { position:absolute; right:0; top:2.4rem; display:grid; min-width:12rem; padding:.5rem; border:1px solid var(--line); border-radius:.75rem; background:var(--surface); box-shadow:0 1rem 3rem #0004; }.mobile-nav a { padding:.65rem; text-decoration:none; }
34-
@media(max-width: 780px) { .desktop-nav,.search-link { display:none; }.mobile-nav { display:block; } }
57+
.header-actions { display:flex; align-items:center; gap:.5rem; }.search-wrap { position:relative; }.search-toggle,.theme-toggle { min-height:2.5rem; border:1px solid var(--line); border-radius:.6rem; background:var(--surface); color:var(--text); padding:.45rem .7rem; text-decoration:none; cursor:pointer; }.search-toggle { font-size:.85rem; }.search-toggle:hover,.theme-toggle:hover { border-color:var(--accent); }.search-popover { position:absolute; right:0; top:calc(100% + .55rem); z-index:30; width:26rem; max-width:min(92vw,26rem); padding:.9rem; border:1px solid var(--line); border-radius:.8rem; background:var(--surface); box-shadow:0 1rem 3rem #0005; }.search-popover[hidden] { display:none; }.mobile-nav { display:none; position:relative; }.mobile-nav summary { cursor:pointer; list-style:none; padding:.45rem .25rem; font-weight:700; }.mobile-nav nav { position:absolute; right:0; top:2.4rem; display:grid; min-width:12rem; padding:.5rem; border:1px solid var(--line); border-radius:.75rem; background:var(--surface); box-shadow:0 1rem 3rem #0004; }.mobile-nav a { padding:.65rem; text-decoration:none; }
58+
@media(max-width: 780px) { .desktop-nav { display:none; }.mobile-nav { display:block; }.search-popover { position:fixed; right:1rem; left:1rem; top:calc(4.7rem + .5rem); width:auto; max-width:none; } }
3559
</style>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
interface Props {
3+
id: string;
4+
placeholder?: string;
5+
label?: string;
6+
}
7+
const { id, placeholder = 'Search guides, networks, and security topics', label = 'Search NodeHunt' } = Astro.props;
8+
const inputId = `${id}-search-input`;
9+
const resultsId = `${id}-search-results`;
10+
const statusId = `${id}-search-status`;
11+
---
12+
<div class="search-box" data-search-box={id}>
13+
<label class="sr-only" for={inputId}>{label}</label>
14+
<input id={inputId} type="search" placeholder={placeholder} autocomplete="off" aria-controls={resultsId} />
15+
<p id={statusId} class="search-status" role="status" aria-live="polite"></p>
16+
<div id={resultsId} class="search-results"></div>
17+
</div>
18+
<style>
19+
.search-box { display: grid; gap: .35rem; width: 100%; }
20+
.search-box input { width: 100%; padding: .75rem 1rem; border: 1px solid var(--line); border-radius: .65rem; background: var(--surface); color: var(--text); font-size: .95rem; }
21+
.search-box input:focus { outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent); outline-offset: 0; border-color: transparent; }
22+
.search-status { min-height: 1.1rem; margin: 0; color: var(--muted); font-size: .8rem; }
23+
.search-status:empty { display: none; }
24+
.search-results { display: grid; gap: .4rem; max-height: 22rem; overflow: auto; }
25+
.search-results:empty { display: none; }
26+
.result { display: grid; gap: .15rem; padding: .7rem .8rem; border-radius: .55rem; background: var(--surface-raised); text-decoration: none; }
27+
.result:hover { outline: 1px solid var(--line); }
28+
.result strong { color: var(--text); font-size: .92rem; }
29+
.result span { color: var(--muted); font-size: .82rem; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
30+
.search-empty { margin: 0; padding: .6rem .2rem; color: var(--muted); font-size: .9rem; }
31+
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); }
32+
</style>

‎src/config/site.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const siteConfig = {
44
description: 'Research-led guides and analysis for Web3 nodes, DePIN, crypto infrastructure, wallet security, and AI x blockchain.',
55
url: import.meta.env.PUBLIC_SITE_URL || 'https://nodehunt.example',
66
postsPerPage: 12,
7+
homePostsPerPage: 6,
78
} as const;
89

910
export const navigation = [

‎src/layouts/BaseLayout.astro‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ import AdSocialBar from '@/components/ads/AdSocialBar.astro';
1111
interface Props { title?: string; description?: string; noindex?: boolean; jsonLd?: Record<string, unknown>; }
1212
const { title, description, noindex, jsonLd } = Astro.props;
1313
---
14-
<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><SiteHead {title} {description} {noindex} {jsonLd} /><AdPopunder /></head><body><a href="#main" class="skip-link">Skip to content</a><SiteHeader /><TopBannerAd /><main id="main"><slot /></main><FooterAd /><StickyMobileAd /><SiteFooter /><AdSocialBar /><script>import '@/components/ads/ad-loader';</script></body></html>
14+
<!doctype html><html lang="en"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><SiteHead {title} {description} {noindex} {jsonLd} /><AdPopunder /></head><body><a href="#main" class="skip-link">Skip to content</a><SiteHeader /><TopBannerAd /><main id="main"><slot /></main><FooterAd /><StickyMobileAd /><SiteFooter /><AdSocialBar /><script>import '@/components/ads/ad-loader';</script><script src="/pagefind-search.js" defer></script></body></html>

0 commit comments

Comments
 (0)