Files
PartnerExpo-Core/public/search_demo.html
2026-01-29 20:41:13 +01:00

1031 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Mock Search UI (Masonry)</title>
<style>
:root{
--accent1: #950000;
--accent2: #2c3489;
--bg: #f6f7fb;
--panel: #ffffff;
--panel2: #f2f4fb;
--stroke: rgba(16, 24, 40, .10);
--stroke2: rgba(16, 24, 40, .14);
--text: rgba(16, 24, 40, .92);
--muted: rgba(16, 24, 40, .62);
--shadow: 0 18px 45px rgba(16,24,40,.12);
--r: 14px;
--cardMin: 240;
--gap: 12px;
--filterW: 170px;
--filterWOpen: 360px;
--topbarH: 64px;
}
*{ box-sizing: border-box; }
html,body{ height:100%; }
body{
margin:0;
font: 14px/1.35 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
background: conic-gradient(from 135deg, #0a0901 0.000deg, #332f32 130.000deg, #2c3377 180.000deg, #1f38b2 240.000deg, #113ccc 250.000deg, #0a0901 360.000deg);
color: var(--text);
display:flex;
align-items:center;
justify-content:center;
padding: 28px;
}
.shell{
width: min(1100px, 96vw);
height: min(720px, 92vh);
background: linear-gradient(180deg, rgba(44,52,137,.05), transparent 42%), var(--panel);
border: 1px solid var(--stroke);
border-radius: calc(var(--r) + 2px);
box-shadow: var(--shadow);
overflow: hidden;
display:flex;
flex-direction:column;
position: relative;
isolation: isolate;
}
/* --- Top row --- */
.topbar{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap: 12px;
padding: 14px;
border-bottom: 1px solid var(--stroke);
background: linear-gradient(180deg, rgba(16,24,40,.02), rgba(16,24,40,.00));
position: relative;
z-index: 3;
}
.topHeight {
height: 45px;
}
/* Filter button */
.filterWrap{
width: var(--filterW);
transition: width 220ms ease;
}
.filterWrap.open{ width: var(--filterWOpen); }
.filterBtn{
width: 100%;
display:flex;
align-items:center;
justify-content:space-between;
gap: 10px;
padding: 10px 10px;
border-radius: 12px;
border: 1px solid var(--stroke2);
background: var(--panel2);
color: var(--text);
cursor:pointer;
user-select:none;
box-shadow: 0 8px 22px rgba(16,24,40,.10);
}
.filterBtn .left{
display:flex;
align-items:center;
gap: 10px;
min-width: 0;
}
.pill{
font-size: 12px;
padding: 3px 8px;
border: 1px solid rgba(44,52,137,.25);
border-radius: 999px;
color: rgba(44,52,137,.85);
background: rgba(44,52,137,.06);
white-space:nowrap;
}
.filterBtn .label{
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Sort */
.sortWrap{
display:flex;
justify-content: space-between;
align-items:center;
gap: 10px;
padding: 10px 10px;
border-radius: 12px;
border: 1px solid var(--stroke2);
background: var(--panel2);
color: var(--text);
box-shadow: 0 8px 22px rgba(16,24,40,.10);
flex: 0 0 auto;
}
.sortLabel{
font-weight: 650;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sortSelect{
border: 1px solid rgba(16,24,40,.16);
background: rgba(255,255,255,.95);
color: var(--text);
border-radius: 10px;
padding: 5px 10px;
outline: none;
font: inherit;
}
.sortSelect:focus{
border-color: rgba(44,52,137,.45);
box-shadow: 0 0 0 3px rgba(44,52,137,.14);
}
/* Search */
.searchWrap{
flex: 1 1 auto;
display:flex;
justify-content:flex-end;
align-items:flex-start;
}
.search{
width: min(520px, 100%);
display:flex;
align-items:center;
gap: 10px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--stroke2);
background: rgba(255,255,255,.9);
box-shadow: 0 8px 22px rgba(16,24,40,.10);
}
.search input{
width: 100%;
border: none;
outline: none;
background: transparent;
color: var(--text);
font-size: 14px;
}
.kbd{
color: rgba(16,24,40,.62);
border: 1px solid rgba(16,24,40,.16);
border-bottom-color: rgba(16,24,40,.22);
background: rgba(255,255,255,.85);
border-radius: 8px;
padding: 2px 8px;
font-size: 12px;
white-space: nowrap;
}
/* --- Main body --- */
.body{
flex: 1 1 auto;
padding: 14px;
overflow:auto;
position: relative;
z-index: 1;
}
.metaRow{
display:flex;
align-items:center;
justify-content:space-between;
gap: 10px;
margin-bottom: 12px;
color: var(--muted);
font-size: 12px;
}
/* Masonry */
.masonry{
display:flex;
gap: var(--gap);
align-items:flex-start;
}
.mCol{
flex: 1 1 0;
min-width: 0;
display:flex;
flex-direction:column;
gap: var(--gap);
}
.card{
border-radius: 14px;
border: 1px solid var(--stroke2);
background:
linear-gradient(180deg, rgba(143, 152, 255, 0.05), rgba(255,255,255,.92));
padding: 12px;
box-shadow: 0 12px 26px rgba(16,24,40,.10);
display:flex;
flex-direction:column;
gap: 8px;
}
.imageWrap{
width: 100%;
height: auto;
border-radius: 10px;
overflow: hidden;
}
.imageWrap img{
width: 100%;
height: auto;
display: block;
}
.cardTop{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap: 10px;
}
.title{
font-weight: 750;
letter-spacing: .15px;
}
.tag{
font-size: 12px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid rgba(149,0,0,.28);
color: rgba(149,0,0,.90);
background: rgba(149,0,0,.06);
white-space:nowrap;
}
.desc{
color: rgba(16,24,40,.74);
font-size: 13px;
}
.foot{
margin-top:auto;
display:flex;
align-items:center;
justify-content:space-between;
color: rgba(16,24,40,.60);
font-size: 12px;
border-top: 1px dashed rgba(16,24,40,.16);
padding-top: 8px;
}
.dot{
width: 7px; height: 7px;
border-radius: 999px;
background: rgba(44,52,137,.55);
display:inline-block;
margin-right: 8px;
}
.status{
display:flex;
align-items:center;
gap: 0;
}
/* Scrollbars */
.body::-webkit-scrollbar{ width: 10px; }
.body::-webkit-scrollbar-thumb{
background: rgba(16,24,40,.14);
border-radius: 999px;
border: 2px solid rgba(0,0,0,0);
background-clip: padding-box;
}
.body::-webkit-scrollbar-track{ background: transparent; }
/* Filter drawer */
.filterDrawerBackdrop{
position:absolute;
inset: var(--topbarH) 0 0 0;
background: rgba(16,24,40,.20);
backdrop-filter: blur(2px);
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
z-index: 2;
}
.filterDrawerBackdrop.show{
opacity: 1;
pointer-events: auto;
}
.filterDrawer{
position:absolute;
top: var(--topbarH);
bottom: 0;
left: 14px;
width: var(--filterW);
border-radius: 14px;
border: 1px solid var(--stroke2);
background: rgba(255,255,255,.92);
overflow:hidden;
box-shadow: 0 18px 45px rgba(16,24,40,.18);
opacity: 0;
transform: translateY(-8px);
pointer-events: none;
transition: opacity 200ms ease, transform 200ms ease, width 220ms ease;
z-index: 3;
display:flex;
flex-direction:column;
min-height: 0;
margin: 12.5px 0 12.5px 0;
}
.filterDrawer.open{
opacity: 1;
transform: translateY(0);
pointer-events: auto;
width: var(--filterWOpen);
}
.filterPanelHeader{
display:flex;
align-items:center;
justify-content:space-between;
padding: 10px 10px;
border-bottom: 1px solid var(--stroke);
color: rgba(16,24,40,.60);
background: linear-gradient(180deg, rgba(44,52,137,.05), rgba(255,255,255,.0));
flex: 0 0 auto;
}
.filterPanelHeader b{ color: var(--text); }
.jsonBox{
padding: 10px;
flex: 1 1 auto;
overflow:auto;
min-height: 0;
}
textarea{
width: 100%;
height: 210px;
resize: vertical;
min-height: 180px;
max-height: 60vh;
border-radius: 10px;
border: 1px solid rgba(44,52,137,.20);
background: #fbfbfe;
color: rgba(16,24,40,.92);
padding: 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
line-height: 1.35;
outline: none;
}
textarea:focus{
border-color: rgba(44,52,137,.45);
box-shadow: 0 0 0 3px rgba(44,52,137,.14);
}
.hint{
margin-top: 8px;
color: rgba(16,24,40,.60);
font-size: 12px;
}
.error{
margin-top: 8px;
color: #950000;
font-size: 12px;
display:none;
}
.error.show{ display:block; }
/* Drawer open padding */
.shell.drawerOpen .body{
/* padding-left: calc(14px + var(--filterWOpen) + 14px); */
transition: padding-left 220ms ease;
}
.shell:not(.drawerOpen) .body{
transition: padding-left 220ms ease;
}
/* Icons */
.icons-search{
box-sizing: border-box;
position: relative;
display: block;
transform: scale(var(--ggs, 1));
width: 16px;
height: 16px;
border: 2px solid;
border-radius: 100%;
margin-left: -4px;
margin-top: -4px;
}
.icons-search:after{
content: "";
display: block;
box-sizing: border-box;
position: absolute;
border-radius: 3px;
width: 3px;
height: 8px;
background: currentColor;
transform: rotate(-45deg);
top: 10px;
left: 12px;
}
.icons-down{
box-sizing: border-box;
position: relative;
display: block;
transform: scale(var(--ggs, 1));
width: 22px;
height: 22px;
border: 2px solid transparent;
border-radius: 100px;
}
.icons-down:after {
content: "";
display: block;
box-sizing: border-box;
position: absolute;
width: 10px;
height: 10px;
border-bottom: 2px solid;
border-right: 2px solid;
transform: rotate(45deg);
transition: 1s transform ease;
left: 4px;
top: 2px;
}
.filterWrap.open .icons-down{
transform: rotate(-135deg) translate(-1px,-1px);
}
.icons-options {
box-sizing: border-box;
position: relative;
display: block;
transform: scale(var(--ggs, 1));
width: 10px;
height: 2px;
box-shadow:
-3px 4px 0 0,
3px -4px 0 0;
margin: 0 10px 0 10px;
}
.icons-options::after,
.icons-options::before {
content: "";
display: block;
box-sizing: border-box;
position: absolute;
width: 8px;
height: 8px;
border: 2px solid;
border-radius: 100%;
}
.icons-options::before {
top: -7px;
left: -4px;
}
.icons-options::after {
bottom: -7px;
right: -4px;
}
@media (max-width: 720px){
.topbar{ flex-direction:column; }
.searchWrap{ justify-content:stretch; }
.search{ width: 100%; }
.filterWrap{ width: 100%; }
.filterWrap.open{ width: 100%; }
.sortWrap{ width: 100%; }
.sortSelect{ width: 110px; }
.searchWrap{ width: 100%; }
.filterDrawer{
left: 14px;
right: 14px;
width: auto;
}
.filterDrawer.open{ width: auto; }
.shell.drawerOpen .body{ padding-left: 14px; }
}
</style>
</head>
<body>
<div class="shell" id="shell">
<div class="topbar" id="topbar">
<!-- Filter dropdown (top-left) -->
<div class="filterWrap" id="filterWrap">
<div class="filterBtn topHeight" id="filterBtn" role="button" aria-expanded="false" tabindex="0">
<div class="left">
<span class="icons-options" aria-hidden="true"></span>
<span class="label" id="filterLabel">Filterek</span>
</div>
<span class="icons-down" aria-hidden="true"></span>
</div>
</div>
<!-- Sort (top row) -->
<div class="sortWrap topHeight">
<label class="sortLabel" for="sortSelect">Rendezés</label>
<select id="sortSelect" class="sortSelect">
<!-- options are populated by JS so its easy to expand -->
</select>
</div>
<!-- Search (top-right) -->
<div class="searchWrap topHeight">
<div class="search" role="search">
<span class="icons-search" aria-hidden="true"></span>
<form id="qForm" action="javascript:void(0)">
<input id="q" placeholder="Keresés..." autocomplete="off" />
</form>
</div>
</div>
</div>
<!-- Drawer backdrop + drawer panel (drops to bottom of shell) -->
<div class="filterDrawerBackdrop" id="drawerBackdrop" aria-hidden="true"></div>
<div class="filterDrawer" id="filterDrawer" aria-hidden="true">
<div class="filterPanelHeader">
<span><b>Filter JSON</b> (edit + apply)</span>
<span class="pill" id="applyHint">Ctrl + Enter</span>
</div>
<div class="jsonBox">
<textarea id="filterJson" spellcheck="false">
{
"category": "all",
"sort": "relevance",
"status": ["active", "archived"],
"minScore": 0,
"tags": ["ui", "mock"],
"maxResults": 17
}
</textarea>
<div class="error" id="jsonError">Invalid JSON. Fix the syntax and try again.</div>
</div>
</div>
<div class="body">
<div class="metaRow">
<div id="metaLeft"><b id="count">0</b> találat</div>
<div id="metaRight">Elrendezés: <span id="layoutMeta"></span></div>
</div>
<!-- Masonry container -->
<div class="masonry" id="masonry" aria-live="polite"></div>
<!-- Hidden measuring area -->
<div id="measure" style="position:absolute; left:-9999px; top:-9999px; width:300px; visibility:hidden;"></div>
</div>
</div>
<script>
// ---------- Mock data generator ----------
function makeResults(n, q = "") {
const words = [
"apple","river","mountain","cloud","stone","forest","breeze","candle","mirror","shadow",
"sunrise","sunset","ocean","island","valley","thunder","lightning","rain","snow","frost",
"ember","flame","smoke","ash","spark","comet","star","planet","orbit","galaxy",
"nebula","vacuum","gravity","rocket","satellite","signal","beacon","radar","sonar","echo",
"whisper","melody","rhythm","harmony","tempo","chord","lyric","verse","chorus","bridge",
"canvas","pigment","brush","palette","sketch","mural","statue","marble","bronze","copper",
"silver","golden","velvet","cotton","linen","silk","denim","leather","paper","ink",
"quill","notebook","journal","diary","letter","envelope","stamp","postcard","parcel","package",
"crate","barrel","bottle","jar","kettle","teapot","mug","cup","saucer","plate",
"bowl","spoon","fork","knife","napkin","table","chair","couch","pillow","blanket",
"curtain","window","door","hallway","kitchen","pantry","cellar","attic","ladder","hammer",
"nail","screw","wrench","pliers","drill","saw","chisel","ruler","compass","protractor",
"calculator","keyboard","mouse","screen","monitor","printer","scanner","speaker","microphone","camera",
"tripod","lens","shutter","focus","zoom","pixel","vector","shader","texture","model",
"render","frame","layer","filter","gradient","border","margin","padding","align","justify",
"center","left","right","top","bottom","header","footer","sidebar","menu","button",
"toggle","slider","checkbox","radio","select","option","input","output","submit","cancel",
"confirm","alert","notice","banner","badge","icon","symbol","logo","brand","slogan",
"motto","phrase","idiom","proverb","story","novel","poem","essay","article","report",
"summary","outline","draft","revision","edition","volume","chapter","page","paragraph","sentence",
"word","letterform","alphabet","grammar","syntax","semantics","context","nuance","tone","mood",
"theme","motif","plot","scene","actor","director","writer","editor","critic","reader",
"viewer","listener","audience","crowd","group","team","crew","squad","unit","league",
"match","game","round","score","point","level","stage","arena","field","court",
"track","trail","path","route","journey","voyage","trip","tour","adventure","quest",
"mission","challenge","victory","legend"
];
const out = [];
for (let i = 1; i <= n; i++) {
const score = Math.round(Math.random() * 100);
// Vary description length slightly so masonry actually shows height differences
const extra = " ".repeat(Math.floor(Math.random() * 3)).replace(/ /g, "—");
out.push({
id: i,
title: Math.random() < 0.3
? `The ${words[Math.floor(Math.random() * words.length)]} of ${words[Math.floor(Math.random() * words.length)]}`
: `${words[Math.floor(Math.random() * words.length)]} ${words[Math.floor(Math.random() * words.length)]} ${words[Math.floor(Math.random() * words.length)]}`,
tag: score > 70 ? "High" : score > 40 ? "Medium" : "Low",
desc:
"Lorem ipsum dolor sit amet consectetur adipiscing elit. Consectetur adipiscing elit quisque faucibus ex sapien vitae." +
(score % 3 === 0 ? " Ex sapien vitae pellentesque sem placerat in id." : "") +
(score % 5 === 0 ? " Placerat in id cursus mi pretium tellus duis. Pretium tellus duis convallis tempus leo eu aenean." : "") +
(extra ? "" : ""),
score,
updated: new Date(Date.now() - Math.random() * 1000 * 60 * 60 * 24 * 30)
.toISOString()
.slice(0, 10),
// computed later (depends on query + sort mode)
relevance: 0
});
}
return out;
}
// ---------- DOM ----------
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");
// Sort UI (requires the <select id="sortSelect"> in your topbar HTML)
const sortSelect = document.getElementById("sortSelect");
// ---------- State ----------
let filters = {
sort: "relevance",
status: ["active", "archived"],
minScore: 0,
tags: ["ui", "mock"],
maxResults: 17
};
let activeSortKey = String(filters.sort || "relevance");
let results = makeResults(filters.maxResults);
// ---------- Sorts ----------
const SORTS = {
relevance: {
label: "Relevance",
compare: (a, b) =>
(b.relevance - a.relevance) ||
(b.score - a.score) ||
(b.updated.localeCompare(a.updated))
},
score_desc: {
label: "Score (high → low)",
compare: (a, b) => (b.score - a.score) || (b.relevance - a.relevance)
},
updated_desc: {
label: "Updated (new → old)",
compare: (a, b) => (b.updated.localeCompare(a.updated)) || (b.relevance - a.relevance)
},
title_asc: {
label: "Title (A → Z)",
compare: (a, b) => (a.title.localeCompare(b.title)) || (b.relevance - a.relevance)
}
};
// ---------- Helpers ----------
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, s => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;"
}[s]));
}
function cardEl(r) {
const div = document.createElement("div");
div.className = "card";
div.innerHTML = `
<div class="imageWrap">
<img src="https://placehold.co/600x400?text=Image+${escapeHtml(r.id)}" alt="Image for ${escapeHtml(r.title)}" style="width:100%; border-radius:10px; object-fit:cover;" />
</div>
<div class="cardTop">
<div class="title">${escapeHtml(r.title)}</div>
</div>
<div class="desc">${escapeHtml(r.desc)}</div>
<div class="foot">
<div class="status"><span class="dot"></span>score: <b style="margin-left:6px;color:rgba(16,24,40,.86)">${r.score}</b></div>
<div>updated: ${r.updated}</div>
</div>
`;
return div;
}
// Simple relevance scorer: term matches in title/desc/tag + some weight from score
function computeRelevance(r, q) {
const query = (q || "").trim().toLowerCase();
if (!query) return r.score;
const terms = query.split(/\s+/).filter(Boolean);
if (!terms.length) return r.score;
const title = (r.title || "").toLowerCase();
const hay = `${r.title} ${r.desc} ${r.tag}`.toLowerCase();
let hits = 0;
let titleHits = 0;
for (const t of terms) {
if (hay.includes(t)) hits++;
if (title.includes(t)) titleHits++;
}
return (hits * 30) + (titleHits * 25) + (r.score * 0.35);
}
function recomputeRelevance() {
const q = qInput.value.trim();
for (const r of results) r.relevance = computeRelevance(r, q);
}
function setSortKey(key) {
const safe = SORTS[key] ? key : "relevance";
activeSortKey = safe;
filters.sort = safe; // keep filter state in sync (optional but nice)
if (sortSelect) sortSelect.value = safe;
requestLayout();
}
// Build sort dropdown from SORTS (so expanding logic is just editing SORTS)
(function initSortUI() {
if (!sortSelect) return;
sortSelect.innerHTML = Object.entries(SORTS)
.map(([k, def]) => `<option value="${escapeHtml(k)}">${escapeHtml(def.label)}</option>`)
.join("");
sortSelect.value = activeSortKey;
sortSelect.addEventListener("change", () => setSortKey(sortSelect.value));
})();
// ---------- Filter drawer behavior ----------
function syncTopbarHeight() {
const h = topbar.offsetHeight;
document.documentElement.style.setProperty("--topbarH", h + "px");
}
function setOpen(open) {
filterWrap.classList.toggle("open", open);
filterBtn.setAttribute("aria-expanded", String(open));
filterDrawer.classList.toggle("open", open);
filterDrawer.setAttribute("aria-hidden", String(!open));
drawerBackdrop.classList.toggle("show", open);
drawerBackdrop.setAttribute("aria-hidden", String(!open));
shell.classList.toggle("drawerOpen", open);
syncTopbarHeight();
if (open) {
filterJson.focus();
}
// requestLayout();
}
filterBtn.addEventListener("click", () => setOpen(!filterDrawer.classList.contains("open")));
filterBtn.addEventListener("keydown", (e) => {
if (e.keyCode===13 || e.key === " ") {
e.preventDefault();
setOpen(!filterDrawer.classList.contains("open"));
}
});
drawerBackdrop.addEventListener("click", () => setOpen(false));
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && filterDrawer.classList.contains("open")) setOpen(false);
});
// Apply JSON (Ctrl/⌘ + Enter)
function applyJson() {
try {
const parsed = JSON.parse(filterJson.value);
jsonError.classList.remove("show");
filters = parsed;
filterLabel.textContent = (filters.category || "Custom");
// keep sort selection in sync (if provided)
activeSortKey = SORTS[String(filters.sort)] ? String(filters.sort) : activeSortKey;
if (sortSelect) sortSelect.value = activeSortKey;
const max = Number(filters.maxResults ?? 12);
results = makeResults(
Math.max(0, Math.min(80, isFinite(max) ? max : 12)),
qInput.value.trim()
);
// relevance depends on query; compute now
recomputeRelevance();
render();
requestLayout();
} catch (err) {
jsonError.classList.add("show");
}
}
filterJson.addEventListener("keydown", (e) => {
const isCmdEnter = (e.keyCode===13 && (e.ctrlKey || e.metaKey));
if (isCmdEnter) {
e.preventDefault();
applyJson();
}
});
// ---------- Search behavior ----------
qInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const q = qInput.value.trim();
results = makeResults(results.length || 12, q);
recomputeRelevance();
render();
requestLayout();
}
});
// ---------- Masonry layout ----------
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() {
// Use available width of masonry container (changes when drawer opens due to body padding)
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) {
// Set measuring width to match a column width in the current masonry container
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; // forces layout inside measure
measure.removeChild(el);
return { r, idx, h };
});
return { measured, colW };
}
// Rank-aware packing:
// Keep the chosen sort order meaningful (esp. relevance) while still balancing columns:
// From the next K ranked items, pick the tallest and place into the shortest column.
function packRankAware(measured, cols, K = 6) {
const colHeights = new Array(cols).fill(0);
const placements = [];
const queue = measured.slice(); // already in ranked order
while (queue.length) {
// shortest column
let bestCol = 0;
for (let c = 1; c < cols; c++) {
if (colHeights[c] < colHeights[bestCol]) bestCol = c;
}
// choose tallest among next K items (bounded reorder)
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;
// clear
masonry.innerHTML = "";
const cols = getColumnCount();
// create columns
const colEls = [];
for (let c = 0; c < cols; c++) {
const col = document.createElement("div");
col.className = "mCol";
masonry.appendChild(col);
colEls.push(col);
}
// 1) ensure relevance is up-to-date
recomputeRelevance();
// 2) rank by chosen sort
const sorter = SORTS[activeSortKey] || SORTS.relevance;
const ranked = results.slice().sort(sorter.compare);
// 3) measure cards in ranked order
const { measured } = measureCardHeights(cols, ranked);
// 4) pack for good layout, but respect ranking
const placements = packRankAware(measured, cols, 6);
// 5) render
for (const p of placements) {
colEls[p.col].appendChild(cardEl(p.item.r));
}
layoutMeta.textContent = `${cols} oszlop • ${SORTS[activeSortKey]?.label || "Relevance"}`;
}
function render() {
// masonry is built in applyMasonry(); render just updates meta count fast if needed
count.textContent = results.length;
}
// React to size changes (window resize + body padding shift + drawer open/close)
const ro = new ResizeObserver(() => {
syncTopbarHeight();
requestLayout();
});
ro.observe(shell);
ro.observe(masonry);
window.addEventListener("resize", () => {
syncTopbarHeight();
requestLayout();
});
// ---------- Init ----------
syncTopbarHeight();
recomputeRelevance();
render();
requestLayout();
</script>
</body>
</html>