1031 lines
29 KiB
HTML
1031 lines
29 KiB
HTML
<!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 it’s 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 => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'"
|
||
}[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>
|