- Search, pagination

This commit is contained in:
Ty Clifford
2026-06-07 12:06:03 -04:00
parent c22e525fb7
commit 6e96e34c0e
+297 -14
View File
@@ -404,6 +404,116 @@ define('POST_PAGE_TITLE', 'Updates — Post Manager');
.edit-form.open { display: flex; animation: fade-in .2s ease; } .edit-form.open { display: flex; animation: fade-in .2s ease; }
/* ── Misc ───────────────────────────────────────────────────────────────── */ /* ── Misc ───────────────────────────────────────────────────────────────── */
/* ── Search bar ─────────────────────────────────────────────────────────── */
.search-wrap {
padding: .65rem 1.25rem;
border-bottom: 1px solid var(--border);
position: relative;
}
.search-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: .75rem;
color: var(--text-3);
pointer-events: none;
flex-shrink: 0;
}
.search-input {
width: 100%;
padding: .55rem .9rem .55rem 2.2rem;
font-family: 'Share Tech Mono', monospace;
font-size: .82rem;
color: var(--text);
background: rgba(255,255,255,.03);
border: 1px solid var(--border-2);
border-radius: var(--r);
outline: none;
transition: border-color .2s, box-shadow .2s, background .2s;
}
.search-input:focus {
border-color: var(--blue);
background: rgba(0,200,255,.04);
box-shadow: 0 0 0 3px rgba(0,200,255,.1);
}
.search-input::placeholder { color: var(--text-3); }
.search-clear {
position: absolute;
right: .6rem;
background: none;
border: none;
cursor: pointer;
color: var(--text-3);
padding: .2rem;
display: none;
align-items: center;
transition: color .15s;
}
.search-clear:hover { color: var(--red); }
.search-clear.visible { display: flex; }
/* highlight matched text */
mark {
background: rgba(0,200,255,.2);
color: var(--blue);
border-radius: 2px;
padding: 0 .1em;
}
/* ── Pagination ──────────────────────────────────────────────────────────── */
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: .5rem;
padding: .65rem 1.25rem;
border-top: 1px solid var(--border);
}
.pagination-info {
font-family: 'Share Tech Mono', monospace;
font-size: .6rem;
letter-spacing: .06em;
color: var(--text-3);
}
.pagination-btns {
display: flex;
align-items: center;
gap: .3rem;
flex-wrap: wrap;
}
.page-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 28px;
padding: 0 .45rem;
border: 1px solid var(--border-2);
border-radius: 4px;
background: rgba(255,255,255,.03);
font-family: 'Share Tech Mono', monospace;
font-size: .65rem;
letter-spacing: .04em;
color: var(--text-3);
cursor: pointer;
transition: border-color .15s, color .15s, background .15s;
}
.page-btn:hover:not(:disabled) {
border-color: var(--green);
color: var(--green);
background: rgba(0,232,122,.05);
}
.page-btn.active {
border-color: var(--green);
color: var(--green);
background: rgba(0,232,122,.08);
}
.page-btn:disabled { opacity: .3; cursor: not-allowed; }
.page-ellipsis { border-color: transparent; background: transparent; cursor: default; pointer-events: none; }
.feed-status { .feed-status {
padding: 2.5rem 1.25rem; padding: 2.5rem 1.25rem;
font-family: 'Share Tech Mono', monospace; font-size: .72rem; font-family: 'Share Tech Mono', monospace; font-size: .72rem;
@@ -540,9 +650,34 @@ define('POST_PAGE_TITLE', 'Updates — Post Manager');
<span class="section-title">All Posts</span> <span class="section-title">All Posts</span>
<span class="section-badge" id="post-count">—</span> <span class="section-badge" id="post-count">—</span>
</div> </div>
<!-- Search bar -->
<div class="search-wrap">
<div class="search-input-wrap">
<svg class="search-icon" width="14" height="14" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input class="search-input" type="text" id="search-input"
placeholder="Search posts by text, tag, or ID…"
autocomplete="off" spellcheck="false"
oninput="onSearchInput(this.value)">
<button class="search-clear" id="search-clear"
onclick="clearSearch()" aria-label="Clear search">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<ul id="post-list"> <ul id="post-list">
<li class="loading-row"><div class="big-spinner"></div><span>loading posts…</span></li> <li class="loading-row"><div class="big-spinner"></div><span>loading posts…</span></li>
</ul> </ul>
<!-- Pagination bar (built by JS) -->
<div id="pagination-bar" style="display:none"></div>
</div> </div>
</div><!-- /main-ui --> </div><!-- /main-ui -->
@@ -760,35 +895,179 @@ function doAuth() {
function doLogout() { sessionStorage.removeItem(STORE_KEY); location.reload(); } function doLogout() { sessionStorage.removeItem(STORE_KEY); location.reload(); }
/* ── Feed ────────────────────────────────────────────────────────────────────── */ /* ════════════════════════════════════════════════════════════════════════════
FEED — search, pagination, rendering
════════════════════════════════════════════════════════════════════════════ */
const PER_PAGE = 15; // posts shown per page
let allPosts = []; // master array from last API fetch
let currentPage = 1;
let searchQuery = '';
let searchTimer = null;
/* Load all posts from API, then filter+render */
function loadFeed() { function loadFeed() {
const list = document.getElementById('post-list'); const list = document.getElementById('post-list');
list.innerHTML = `<li class="loading-row"><div class="big-spinner"></div><span>loading…</span></li>`; list.innerHTML = `<li class="loading-row"><div class="big-spinner"></div><span>loading…</span></li>`;
apiFetch('') apiFetch('limit=1000') // fetch everything; client handles paging
.then(data => renderFeed(data.items, data.total)) .then(data => {
.catch(err => { list.innerHTML = `<li class="feed-status">failed: ${esc(err.message)}</li>`; }); allPosts = data.items || [];
currentPage = 1;
filterAndPage();
})
.catch(err => {
list.innerHTML = `<li class="feed-status">failed: ${esc(err.message)}</li>`;
hidePagination();
});
}
/* Filter allPosts by searchQuery, then render the requested page */
function filterAndPage() {
const q = searchQuery.trim().toLowerCase();
const filtered = q
? allPosts.filter(p =>
(p.text || '').toLowerCase().includes(q) ||
(p.id || '').toLowerCase().includes(q) ||
(p.tags || []).some(t => t.toLowerCase().includes(q)) ||
(p.link || '').toLowerCase().includes(q) ||
(p.note || '').toLowerCase().includes(q)
)
: allPosts;
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
currentPage = Math.min(currentPage, totalPages);
const start = (currentPage - 1) * PER_PAGE;
const page = filtered.slice(start, start + PER_PAGE);
// Update badge
const badge = document.getElementById('post-count');
if (q) {
badge.textContent = filtered.length + ' of ' + allPosts.length + ' match' + (filtered.length !== 1 ? 'es' : '');
} else {
badge.textContent = allPosts.length + ' post' + (allPosts.length !== 1 ? 's' : '');
}
// Render list
const list = document.getElementById('post-list');
if (!page.length) {
list.innerHTML = q
? `<li class="feed-status">no results for "${esc(q)}"</li>`
: '<li class="feed-status">no posts yet</li>';
hidePagination();
return;
}
list.innerHTML = page.map(p => postHTML(p, q)).join('');
// Render pagination
if (totalPages <= 1) {
hidePagination();
} else {
renderPagination(currentPage, totalPages, total, start);
}
}
/* ── Pagination controls ─────────────────────────────────────────────────── */
function renderPagination(page, totalPages, total, start) {
const bar = document.getElementById('pagination-bar');
bar.style.display = '';
bar.className = 'pagination-bar';
const from = start + 1;
const to = Math.min(start + PER_PAGE, total);
// Build page number buttons with sliding window ± 2
let btns = '';
const lo = Math.max(1, page - 2);
const hi = Math.min(totalPages, page + 2);
btns += `<button class="page-btn" ${page <= 1 ? 'disabled' : ''}
onclick="goPage(${page - 1})"></button>`;
if (lo > 1) {
btns += `<button class="page-btn" onclick="goPage(1)">1</button>`;
if (lo > 2) btns += `<span class="page-btn page-ellipsis">…</span>`;
}
for (let p = lo; p <= hi; p++) {
btns += `<button class="page-btn${p === page ? ' active' : ''}"
onclick="goPage(${p})">${p}</button>`;
}
if (hi < totalPages) {
if (hi < totalPages - 1) btns += `<span class="page-btn page-ellipsis">…</span>`;
btns += `<button class="page-btn" onclick="goPage(${totalPages})">${totalPages}</button>`;
}
btns += `<button class="page-btn" ${page >= totalPages ? 'disabled' : ''}
onclick="goPage(${page + 1})"></button>`;
bar.innerHTML = `
<span class="pagination-info">${from}${to} of ${total}</span>
<div class="pagination-btns">${btns}</div>`;
}
function hidePagination() {
const bar = document.getElementById('pagination-bar');
bar.style.display = 'none';
bar.innerHTML = '';
}
function goPage(n) {
currentPage = n;
filterAndPage();
// Scroll the list back into view smoothly
document.getElementById('post-list').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
/* ── Search input handler (debounced 250 ms) ─────────────────────────────── */
function onSearchInput(val) {
const clearBtn = document.getElementById('search-clear');
clearBtn.classList.toggle('visible', val.length > 0);
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
searchQuery = val;
currentPage = 1;
filterAndPage();
}, 250);
}
function clearSearch() {
document.getElementById('search-input').value = '';
document.getElementById('search-clear').classList.remove('visible');
searchQuery = '';
currentPage = 1;
filterAndPage();
document.getElementById('search-input').focus();
}
/* ── Highlight matched text ──────────────────────────────────────────────── */
function highlight(text, query) {
if (!query) return esc(text);
var safe = esc(text);
// Escape regex special chars safely without regex literal char-class issues
var safeQ = esc(query).replace(/[-[\]{}()*+?.,^$|#\s]/g, '\\$&');
try {
return safe.replace(new RegExp('(' + safeQ + ')', 'gi'), '<mark>$1</mark>');
} catch(e) { return safe; }
} }
function renderFeed(posts, total) { function renderFeed(posts, total) {
document.getElementById('post-count').textContent = total + ' post' + (total!==1?'s':''); // Legacy path — called by old code paths if any; route through new system
const list = document.getElementById('post-list'); allPosts = posts;
list.innerHTML = posts.length filterAndPage();
? posts.map(p => postHTML(p)).join('')
: '<li class="feed-status">no posts yet</li>';
} }
function postHTML(p) { function postHTML(p, query) {
const tags = (p.tags||[]).map(t=>`<span class="post-tag">${esc(t)}</span>`).join(''); query = query || '';
const tags = (p.tags||[]).map(t=>`<span class="post-tag">${highlight(t, query)}</span>`).join('');
const link = p.link ? `<a class="post-link" href="${esc(p.link)}" target="_blank" rel="noopener">${esc(p.link_label||p.link)}</a>` : ''; const link = p.link ? `<a class="post-link" href="${esc(p.link)}" target="_blank" rel="noopener">${esc(p.link_label||p.link)}</a>` : '';
const media = mediaStripHTML(p.media||[]); const media = mediaStripHTML(p.media||[]);
return `<li class="post-item" id="pi-${esc(p.id)}"> return `<li class="post-item" id="pi-${esc(p.id)}">
<div class="post-meta"> <div class="post-meta">
<span class="post-id">#${esc(p.id)}</span> <span class="post-id">#${highlight(p.id, query)}</span>
<span class="post-date">${esc(relTime(p.date))}</span> <span class="post-date">${esc(relTime(p.date))}</span>
${tags} ${tags}
</div> </div>
<p class="post-text">${esc(p.text)}</p> <p class="post-text">${highlight(p.text, query)}</p>
${link} ${link}
${media} ${media}
<div class="post-actions"> <div class="post-actions">
@@ -911,7 +1190,7 @@ function saveEdit(id) {
return apiFetch('id=' + encodeURIComponent(id), { method: 'PUT', body: JSON.stringify(body) }); return apiFetch('id=' + encodeURIComponent(id), { method: 'PUT', body: JSON.stringify(body) });
}) })
.then(updated => { .then(updated => {
document.getElementById('pi-' + id).outerHTML = postHTML(updated); document.getElementById('pi-' + id).outerHTML = postHTML(updated, searchQuery);
}) })
.catch(err => showAlert('ef-alert-'+id,'error',err.message)); .catch(err => showAlert('ef-alert-'+id,'error',err.message));
} }
@@ -931,6 +1210,10 @@ function deletePost(id) {
/* ── Enter key + session restore ─────────────────────────────────────────────── */ /* ── Enter key + session restore ─────────────────────────────────────────────── */
document.getElementById('key-input').addEventListener('keydown', e => { if (e.key==='Enter') doAuth(); }); document.getElementById('key-input').addEventListener('keydown', e => { if (e.key==='Enter') doAuth(); });
/* Search: clear on Escape */
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && searchQuery) clearSearch();
});
(function() { (function() {
const saved = sessionStorage.getItem(STORE_KEY); const saved = sessionStorage.getItem(STORE_KEY);