- Search, pagination
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user