← back to Fleet Tv
public/search.html
275 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Fleet TV — Search</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #07080a;
--fg: #e8e6e0;
--dim: #6a6862;
--accent: #c9a14b;
--pane: #11141a;
--rule: rgba(255,255,255,0.08);
}
html, body { background: var(--bg); color: var(--fg); font-family: -apple-system, "Helvetica Neue", "Segoe UI", system-ui, sans-serif; min-height: 100vh; }
.head {
position: sticky; top: 0; z-index: 5;
display: grid; grid-template-columns: auto 1fr auto; gap: 1.25rem; align-items: center;
padding: 1rem 1.5rem;
background: linear-gradient(180deg, rgba(7,8,10,0.94), rgba(7,8,10,0.6));
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--rule);
}
.head .title { display: flex; gap: 1rem; align-items: baseline; }
.head h1 { font-size: 0.95rem; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; }
.head .crumb { color: var(--dim); font-size: 0.78rem; letter-spacing: 0.16em; text-transform: uppercase; }
.head .crumb a { color: var(--dim); text-decoration: none; border-bottom: 1px solid transparent; }
.head .crumb a:hover { color: var(--fg); border-color: var(--rule); }
.search-box {
display: flex; gap: 0.5rem; align-items: center;
background: var(--pane); border: 1px solid var(--rule); border-radius: 4px;
padding: 0.5rem 0.85rem;
}
.search-box input {
flex: 1; background: transparent; border: 0; outline: none; color: var(--fg);
font-family: inherit; font-size: 0.95rem; letter-spacing: 0.02em;
}
.search-box input::placeholder { color: var(--dim); }
.search-box .glyph { color: var(--accent); font-size: 0.85rem; }
.search-box .clear {
background: transparent; border: 0; cursor: pointer; color: var(--dim);
font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase;
}
.search-box .clear:hover { color: var(--fg); }
.stamp { color: var(--dim); font-size: 0.75rem; letter-spacing: 0.18em; text-transform: uppercase; font-variant-numeric: tabular-nums; text-align: right; }
.stamp b { color: var(--fg); font-weight: 500; }
/* — Suggestion pills — */
.suggestions {
display: flex; flex-wrap: wrap; gap: 0.4rem;
padding: 0.85rem 1.5rem 0.5rem;
border-bottom: 1px solid var(--rule);
}
.suggestions .label { color: var(--dim); font-size: 0.7rem; letter-spacing: 0.22em; text-transform: uppercase; align-self: center; margin-right: 0.5rem; }
.suggestions button {
background: var(--pane); color: var(--fg); border: 1px solid var(--rule);
padding: 0.35rem 0.85rem; border-radius: 999px;
font-family: inherit; font-size: 0.78rem; letter-spacing: 0.04em;
cursor: pointer; transition: background 0.2s ease, border-color 0.2s ease;
}
.suggestions button:hover { background: #1a1d24; border-color: var(--accent); }
/* — Results grid — */
.grid {
columns: 5 220px;
column-gap: 0.6rem;
padding: 0.6rem;
}
figure {
break-inside: avoid;
margin: 0 0 0.6rem;
position: relative;
overflow: hidden;
border-radius: 4px;
background: #1a1c20;
cursor: pointer;
transform: translateY(8px);
opacity: 0;
animation: fadeIn 0.5s ease forwards;
}
@keyframes fadeIn { to { transform: translateY(0); opacity: 1; } }
figure img { display: block; width: 100%; height: auto; transition: transform 0.6s ease; }
figure:hover img { transform: scale(1.04); }
figure figcaption {
position: absolute; left: 0; right: 0; bottom: 0;
padding: 0.65rem 0.8rem 0.55rem;
background: linear-gradient(0deg, rgba(0,0,0,0.85), rgba(0,0,0,0));
color: #fff;
transform: translateY(105%);
transition: transform 0.35s ease;
}
figure:hover figcaption { transform: translateY(0); }
figure figcaption .t { font-size: 0.78rem; line-height: 1.25; max-height: 2.4em; overflow: hidden; }
figure figcaption .v { display: block; margin-top: 0.2rem; font-size: 0.62rem; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.65; }
.pill {
position: absolute; top: 0.5rem; left: 0.5rem;
padding: 0.18rem 0.55rem 0.2rem;
font-size: 0.6rem; letter-spacing: 0.18em; text-transform: uppercase;
background: rgba(0,0,0,0.7);
border: 1px solid currentColor;
border-radius: 999px;
color: #fff;
backdrop-filter: blur(4px);
}
.empty { padding: 4rem; text-align: center; color: var(--dim); font-size: 0.95rem; line-height: 1.6; }
.lightbox { position: fixed; inset: 0; z-index: 50; display: none; align-items: center; justify-content: center; background: rgba(0,0,0,0.92); padding: 2rem; }
.lightbox.open { display: flex; }
.lightbox img { max-width: 92vw; max-height: 80vh; border-radius: 4px; box-shadow: 0 30px 100px rgba(0,0,0,0.7); }
.lightbox .info { position: absolute; bottom: 2rem; left: 50%; transform: translateX(-50%); text-align: center; color: var(--fg); background: rgba(20,22,26,0.92); padding: 1rem 1.5rem; border-radius: 4px; backdrop-filter: blur(6px); max-width: 80vw; }
.lightbox .info .t { font-size: 1rem; line-height: 1.3; }
.lightbox .info .v { font-size: 0.7rem; letter-spacing: 0.18em; text-transform: uppercase; margin-top: 0.4rem; opacity: 0.7; }
.lightbox .info a { display: inline-block; margin-top: 0.6rem; color: #c9a14b; text-decoration: none; font-size: 0.75rem; letter-spacing: 0.16em; text-transform: uppercase; border-bottom: 1px solid currentColor; padding-bottom: 1px; }
.lightbox .close { position: absolute; top: 1.5rem; right: 1.5rem; color: var(--fg); font-size: 1rem; letter-spacing: 0.18em; text-transform: uppercase; cursor: pointer; }
</style>
</head>
<body>
<div class="head">
<div class="title">
<h1>Fleet TV</h1>
<span class="crumb"><a href="/">wall</a> · <a href="/mosaic.html">mosaic</a> · <a href="/stats.html">stats</a> · search</span>
</div>
<div class="search-box">
<span class="glyph">⌕</span>
<input id="q" type="text" autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="search across the fleet — flocked, art deco, kravet, gold, hollywood…">
<button class="clear" id="clear-btn">clear</button>
</div>
<div class="stamp" id="stamp">type to search</div>
</div>
<div class="suggestions">
<span class="label">try</span>
<button data-q="metallic">metallic</button>
<button data-q="art deco">art deco</button>
<button data-q="kravet">kravet</button>
<button data-q="gold">gold</button>
<button data-q="hollywood">hollywood</button>
<button data-q="paisley">paisley</button>
<button data-q="grass">grass</button>
<button data-q="silk">silk</button>
<button data-q="memphis">memphis</button>
<button data-q="velvet">velvet</button>
<button data-q="floral">floral</button>
</div>
<div class="grid" id="grid"></div>
<div class="empty" id="empty">type a query above to fan out across all 41 fleet sites in parallel.<br>tip: keys / focus search · esc clear · r refresh</div>
<div class="lightbox" id="lightbox">
<span class="close" id="lb-close">close ✕</span>
<img id="lb-img" alt="">
<div class="info">
<div class="t" id="lb-title">—</div>
<div class="v" id="lb-vendor">—</div>
<a id="lb-link" target="_blank" rel="noopener noreferrer">view on site →</a>
</div>
</div>
<script>
(() => {
const $ = (id) => document.getElementById(id);
const grid = $('grid'), empty = $('empty');
const lightbox = $('lightbox'), lbImg = $('lb-img'), lbTitle = $('lb-title'),
lbVendor = $('lb-vendor'), lbLink = $('lb-link'), lbClose = $('lb-close');
const qInput = $('q');
let lastReq = 0;
let debounceTimer = null;
async function search(q) {
const reqId = ++lastReq;
if (!q.trim()) {
grid.innerHTML = '';
empty.style.display = 'block';
empty.textContent = 'type a query above — fans out across all 41 fleet sites in parallel.';
$('stamp').textContent = 'idle';
return;
}
$('stamp').textContent = 'searching…';
grid.innerHTML = '';
empty.style.display = 'none';
try {
const r = await fetch('/api/search?q=' + encodeURIComponent(q) + '&per=8', { cache: 'no-store' });
if (reqId !== lastReq) return; // a newer request is in flight
const j = await r.json();
$('stamp').textContent = `${j.total} hits · ${j.sites}/${j.sitesQueried} sites`;
if (!j.items.length) {
empty.style.display = 'block';
empty.textContent = `no fleet matches for "${q}". try a vendor (kravet, thibaut), a material (silk, grass), or a motif (paisley, deco).`;
return;
}
const frag = document.createDocumentFragment();
j.items.forEach((p, i) => {
const fig = document.createElement('figure');
fig.style.animationDelay = (Math.min(i, 80) * 18) + 'ms';
fig.innerHTML = `
<span class="pill" style="color:${p.accent}">${p.site.replace(/wallpaper$|wallcoverings?$|walls$/i, m => '·' + m)}</span>
<img loading="lazy" referrerpolicy="no-referrer" src="${p.image}" alt="${escape(p.title)}" onerror="this.parentNode.style.display='none'">
<figcaption><span class="t">${escape(p.title)}</span><span class="v">${escape(p.vendor || p.site)}</span></figcaption>
`;
fig.addEventListener('click', () => openLightbox(p));
frag.appendChild(fig);
});
grid.appendChild(frag);
} catch (e) {
console.error(e);
empty.style.display = 'block';
empty.textContent = 'search failed — ' + e.message;
}
}
function debouncedSearch() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => search(qInput.value), 250);
}
function openLightbox(p) {
lbImg.src = p.image;
lbTitle.textContent = p.title || '(untitled)';
lbVendor.textContent = (p.vendor || '') + (p.vendor && p.site ? ' · ' : '') + p.site;
lbLink.href = p.productUrl;
lightbox.classList.add('open');
}
lbClose.addEventListener('click', () => lightbox.classList.remove('open'));
lightbox.addEventListener('click', (e) => { if (e.target === lightbox) lightbox.classList.remove('open'); });
qInput.addEventListener('input', debouncedSearch);
qInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { qInput.value = ''; debouncedSearch(); qInput.blur(); }
});
$('clear-btn').addEventListener('click', () => { qInput.value = ''; debouncedSearch(); qInput.focus(); });
document.querySelectorAll('.suggestions button').forEach(b => {
b.addEventListener('click', () => { qInput.value = b.dataset.q; debouncedSearch(); qInput.focus(); });
});
// Global keys: / focuses the search; w/m/s navigate; esc closes lightbox
document.addEventListener('keydown', (e) => {
if (e.key === '/' && document.activeElement !== qInput) {
e.preventDefault(); qInput.focus();
} else if (e.key === 'Escape') {
lightbox.classList.remove('open');
} else if (document.activeElement !== qInput) {
const k = e.key.toLowerCase();
if (k === 'w') location.href = '/';
if (k === 'm') location.href = '/mosaic.html';
if (k === 's' && !e.metaKey && !e.ctrlKey) location.href = '/stats.html';
if (k === 't') location.href = '/today.html';
if (k === 'a') location.href = '/atlas.html';
if (k === 'x') location.href = '/matrix.html';
if (k === 'r') debouncedSearch();
}
});
function escape(s) {
return String(s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
}
// Load query from ?q= if present
const initialQ = new URLSearchParams(location.search).get('q');
if (initialQ) { qInput.value = initialQ; debouncedSearch(); }
qInput.focus();
})();
</script>
</body>
</html>