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 = `
${escapeHtml(r.desc)}
`;
return div;
}
// ---------- Ajax Function ----------
async function fetchData(query = "") {
// 1. Show loading state if needed
masonry.style.opacity = "0.5";
try {
const response = await fetch(`/wp-json/pexpo/v1/query?q=${encodeURIComponent(query)}&limit=${filters.maxResults}`);
const data = await response.json();
results = data;
requestLayout();
} catch (error) {
console.error("Search failed:", error);
masonry.innerHTML = `Error loading results.
`;
} finally {
masonry.style.opacity = "1";
}
}
// FOR TESTING ONLY REMOVE IN PROD
// 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);
}
const sliced = results.slice();
// Measure & Pack
const { measured } = measureCardHeights(cols, sliced);
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]) => ``)
.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