added searchbox shortcode
This commit is contained in:
332
public/js/searchbox.js
Normal file
332
public/js/searchbox.js
Normal file
@@ -0,0 +1,332 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// ---------- DOM Elements ----------
|
||||
const shell = document.getElementById("shell");
|
||||
const topbar = document.getElementById("topbar");
|
||||
|
||||
const filterWrap = document.getElementById("filterWrap");
|
||||
const filterBtn = document.getElementById("filterBtn");
|
||||
const filterDrawer = document.getElementById("filterDrawer");
|
||||
const drawerBackdrop = document.getElementById("drawerBackdrop");
|
||||
|
||||
const filterJson = document.getElementById("filterJson");
|
||||
const jsonError = document.getElementById("jsonError");
|
||||
const filterLabel = document.getElementById("filterLabel");
|
||||
|
||||
const qInput = document.getElementById("q");
|
||||
const masonry = document.getElementById("masonry");
|
||||
const measure = document.getElementById("measure");
|
||||
|
||||
const count = document.getElementById("count");
|
||||
const layoutMeta = document.getElementById("layoutMeta");
|
||||
const sortSelect = document.getElementById("sortSelect");
|
||||
|
||||
// ---------- State ----------
|
||||
let filters = {
|
||||
sort: "relevance",
|
||||
status: ["active", "archived"],
|
||||
tags: ["ui", "mock"],
|
||||
maxResults: 20
|
||||
};
|
||||
|
||||
let activeSortKey = "relevance";
|
||||
let results = []; // Fills via Ajax
|
||||
|
||||
// ---------- Sort Definitions ----------
|
||||
const SORTS = {
|
||||
relevance: {
|
||||
label: "Relevance",
|
||||
compare: (a, b) => (b.relevance - a.relevance) || (b.score - a.score)
|
||||
},
|
||||
score_desc: {
|
||||
label: "Score (high → low)",
|
||||
compare: (a, b) => (b.score - a.score)
|
||||
},
|
||||
date_desc: {
|
||||
label: "Date (new → old)",
|
||||
compare: (a, b) => new Date(b.updated) - new Date(a.updated)
|
||||
},
|
||||
title_asc: {
|
||||
label: "Title (A → Z)",
|
||||
compare: (a, b) => (a.title || "").localeCompare(b.title || "")
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- Helpers ----------
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/[&<>"']/g, s => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
||||
}[s]));
|
||||
}
|
||||
|
||||
// Generates HTML with the correct 'pexpo-core-' classes
|
||||
function cardEl(r) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "pexpo-core-card";
|
||||
div.innerHTML = `
|
||||
<div class="pexpo-core-imageWrap">
|
||||
<img src="${escapeHtml(r.image_url || 'https://placehold.co/600x400')}"
|
||||
alt="${escapeHtml(r.title)}"
|
||||
style="width:100%; border-radius:10px; object-fit:cover;" />
|
||||
</div>
|
||||
<div class="pexpo-core-cardTop">
|
||||
<div class="pexpo-core-title">${escapeHtml(r.title)}</div>
|
||||
</div>
|
||||
<div class="pexpo-core-desc">${escapeHtml(r.desc)}</div>
|
||||
<div class="pexpo-core-foot">
|
||||
<div class="pexpo-core-status">
|
||||
<span class="pexpo-core-dot"></span>
|
||||
score: <b style="margin-left:6px;color:rgba(16,24,40,.86)">${r.score}</b>
|
||||
</div>
|
||||
<div>${escapeHtml(r.updated)}</div>
|
||||
</div>
|
||||
`;
|
||||
return div;
|
||||
}
|
||||
|
||||
// ---------- Ajax Function ----------
|
||||
async function fetchData(query = "") {
|
||||
// 1. Show loading state if needed
|
||||
masonry.style.opacity = "0.5";
|
||||
|
||||
try {
|
||||
// REPLACE THIS URL with your actual endpoint
|
||||
// const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=${filters.maxResults}`);
|
||||
// const data = await response.json();
|
||||
|
||||
// --- SIMULATED FETCH FOR DEMO (Remove this block in production) ---
|
||||
await new Promise(r => setTimeout(r, 600)); // Fake network delay
|
||||
const data = simulateBackendResponse(query);
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
results = data;
|
||||
|
||||
// 2. Compute local relevance if the API doesn't return it
|
||||
// If your API returns a 'relevance' score, skip this.
|
||||
computeLocalRelevance(query);
|
||||
|
||||
// 3. Render
|
||||
requestLayout();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Search failed:", error);
|
||||
masonry.innerHTML = `<div style="padding:20px; color:red;">Error loading results.</div>`;
|
||||
} finally {
|
||||
masonry.style.opacity = "1";
|
||||
}
|
||||
}
|
||||
|
||||
// Simple client-side scorer if API doesn't rank them
|
||||
function computeLocalRelevance(query) {
|
||||
if (!query) {
|
||||
results.forEach(r => r.relevance = r.score);
|
||||
return;
|
||||
}
|
||||
const q = query.toLowerCase();
|
||||
results.forEach(r => {
|
||||
let hits = 0;
|
||||
if (r.title.toLowerCase().includes(q)) hits += 50;
|
||||
if (r.desc.toLowerCase().includes(q)) hits += 20;
|
||||
r.relevance = hits + r.score;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Layout & Masonry Logic ----------
|
||||
const minCard = () => Number(getComputedStyle(document.documentElement).getPropertyValue("--cardMin")) || 240;
|
||||
const gap = () => Number(getComputedStyle(document.documentElement).getPropertyValue("--gap")) || 12;
|
||||
|
||||
let layoutQueued = false;
|
||||
function requestLayout() {
|
||||
if (layoutQueued) return;
|
||||
layoutQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
layoutQueued = false;
|
||||
applyMasonry();
|
||||
});
|
||||
}
|
||||
|
||||
function getColumnCount() {
|
||||
const w = masonry.clientWidth || masonry.getBoundingClientRect().width || 1;
|
||||
const mc = minCard();
|
||||
const g = gap();
|
||||
return Math.max(1, Math.floor((w + g) / (mc + g)));
|
||||
}
|
||||
|
||||
function measureCardHeights(cols, list) {
|
||||
const w = masonry.clientWidth || 1;
|
||||
const g = gap();
|
||||
const colW = Math.floor((w - (cols - 1) * g) / cols);
|
||||
measure.style.width = colW + "px";
|
||||
measure.innerHTML = "";
|
||||
|
||||
const measured = list.map((r, idx) => {
|
||||
const el = cardEl(r);
|
||||
measure.appendChild(el);
|
||||
const h = el.offsetHeight;
|
||||
measure.removeChild(el);
|
||||
return { r, idx, h };
|
||||
});
|
||||
|
||||
return { measured, colW };
|
||||
}
|
||||
|
||||
function packRankAware(measured, cols, K = 6) {
|
||||
const colHeights = new Array(cols).fill(0);
|
||||
const placements = [];
|
||||
const queue = measured.slice();
|
||||
|
||||
while (queue.length) {
|
||||
// Find shortest column
|
||||
let bestCol = 0;
|
||||
for (let c = 1; c < cols; c++) {
|
||||
if (colHeights[c] < colHeights[bestCol]) bestCol = c;
|
||||
}
|
||||
|
||||
// Look at top K items, pick the one that fits best (tallest)
|
||||
// or just the next one if you prefer strict ordering.
|
||||
// Here we pick the tallest of the next K to fill gaps.
|
||||
const lookN = Math.min(K, queue.length);
|
||||
let pick = 0;
|
||||
for (let i = 1; i < lookN; i++) {
|
||||
if (queue[i].h > queue[pick].h) pick = i;
|
||||
}
|
||||
|
||||
const picked = queue.splice(pick, 1)[0];
|
||||
placements.push({ item: picked, col: bestCol });
|
||||
colHeights[bestCol] += picked.h + gap();
|
||||
}
|
||||
return placements;
|
||||
}
|
||||
|
||||
function applyMasonry() {
|
||||
const n = results.length || 0;
|
||||
count.textContent = n;
|
||||
masonry.innerHTML = "";
|
||||
|
||||
const cols = getColumnCount();
|
||||
|
||||
// Create Columns with pexpo-core class
|
||||
const colEls = [];
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const col = document.createElement("div");
|
||||
col.className = "pexpo-core-mCol";
|
||||
masonry.appendChild(col);
|
||||
colEls.push(col);
|
||||
}
|
||||
|
||||
// Sort
|
||||
const sorter = SORTS[activeSortKey] || SORTS.relevance;
|
||||
const ranked = results.slice().sort(sorter.compare);
|
||||
|
||||
// Measure & Pack
|
||||
const { measured } = measureCardHeights(cols, ranked);
|
||||
const placements = packRankAware(measured, cols, 6);
|
||||
|
||||
// Render
|
||||
for (const p of placements) {
|
||||
colEls[p.col].appendChild(cardEl(p.item.r));
|
||||
}
|
||||
|
||||
layoutMeta.textContent = `${cols} cols • ${SORTS[activeSortKey]?.label || "Relevance"}`;
|
||||
}
|
||||
|
||||
// ---------- UI Events ----------
|
||||
|
||||
// Sort Dropdown
|
||||
if (sortSelect) {
|
||||
sortSelect.innerHTML = Object.entries(SORTS)
|
||||
.map(([k, def]) => `<option value="${escapeHtml(k)}">${escapeHtml(def.label)}</option>`)
|
||||
.join("");
|
||||
sortSelect.value = activeSortKey;
|
||||
sortSelect.addEventListener("change", () => {
|
||||
activeSortKey = sortSelect.value;
|
||||
requestLayout();
|
||||
});
|
||||
}
|
||||
|
||||
// Search
|
||||
qInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
fetchData(qInput.value.trim());
|
||||
}
|
||||
});
|
||||
|
||||
// Filter Drawer Logic
|
||||
function syncTopbarHeight() {
|
||||
const h = topbar.offsetHeight;
|
||||
document.documentElement.style.setProperty("--topbarH", h + "px");
|
||||
}
|
||||
|
||||
function setOpen(open) {
|
||||
filterWrap.classList.toggle("pexpo-core-open", open); // Updated class
|
||||
filterBtn.setAttribute("aria-expanded", String(open));
|
||||
|
||||
filterDrawer.classList.toggle("pexpo-core-open", open); // Updated class
|
||||
filterDrawer.setAttribute("aria-hidden", String(!open));
|
||||
|
||||
drawerBackdrop.classList.toggle("pexpo-core-show", open); // Updated class
|
||||
shell.classList.toggle("pexpo-core-drawerOpen", open);
|
||||
|
||||
syncTopbarHeight();
|
||||
if (open) filterJson.focus();
|
||||
}
|
||||
|
||||
filterBtn.addEventListener("click", () => setOpen(!filterDrawer.classList.contains("pexpo-core-open")));
|
||||
drawerBackdrop.addEventListener("click", () => setOpen(false));
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && filterDrawer.classList.contains("pexpo-core-open")) setOpen(false);
|
||||
});
|
||||
|
||||
// Apply JSON Filter
|
||||
function applyJson() {
|
||||
try {
|
||||
const parsed = JSON.parse(filterJson.value);
|
||||
jsonError.classList.remove("pexpo-core-show");
|
||||
filters = parsed;
|
||||
filterLabel.textContent = (filters.category || "Custom");
|
||||
|
||||
// Trigger new search with new filters
|
||||
fetchData(qInput.value.trim());
|
||||
|
||||
// Close drawer on success (optional)
|
||||
// setOpen(false);
|
||||
} catch (err) {
|
||||
jsonError.classList.add("pexpo-core-show");
|
||||
}
|
||||
}
|
||||
|
||||
filterJson.addEventListener("keydown", (e) => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
applyJson();
|
||||
}
|
||||
});
|
||||
|
||||
// Resize Observer
|
||||
const ro = new ResizeObserver(() => {
|
||||
syncTopbarHeight();
|
||||
requestLayout();
|
||||
});
|
||||
ro.observe(shell);
|
||||
ro.observe(masonry);
|
||||
|
||||
// Initial Load
|
||||
syncTopbarHeight();
|
||||
fetchData("");
|
||||
|
||||
// --- Temporary Mock Response Generator (DELETE ME IN PRODUCTION) ---
|
||||
function simulateBackendResponse(q) {
|
||||
const count = filters.maxResults || 12;
|
||||
const arr = [];
|
||||
for(let i=0; i<count; i++) {
|
||||
arr.push({
|
||||
id: i,
|
||||
title: `Item ${i} - ${q || 'Random'}`,
|
||||
desc: "This is a placeholder description returned from the fake backend.",
|
||||
score: Math.floor(Math.random()*100),
|
||||
updated: new Date().toISOString().slice(0,10),
|
||||
relevance: 0
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user