← back to Fentucci Naturals
public/index.html
359 lines
<!doctype html><html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fentucci Naturals — Internal Line Viewer</title>
<style>
:root{--bg:#0e0f11;--card:#16181c;--panel:#16181c;--line:#2a2d33;--txt:#e8e6e1;--mut:#8b8f96;
--accent:#3fb8af;--acc:#3fb8af;--teal:#3fb8af;--rail:290px;--cols:6;--fs:1;--scale:1;}
*{box-sizing:border-box}
body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--txt)}
a{color:var(--accent)}
header{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px;flex-wrap:wrap;
position:sticky;top:0;background:var(--bg);z-index:10}
header h1{font-size:16px;margin:0;letter-spacing:.14em;font-weight:600;text-transform:uppercase;display:flex;align-items:center;gap:8px}
header .meta{color:var(--mut);font-size:12px}
header input[type=search]{flex:1;min-width:200px;padding:8px 10px;background:var(--panel);border:1px solid var(--line);color:var(--txt);border-radius:8px}
.bar{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--mut);letter-spacing:.04em}
.bar select{background:var(--panel);border:1px solid var(--line);color:var(--txt);border-radius:7px;padding:5px 7px;font-size:12px}
.bar input[type=range]{width:120px}
.quote{color:var(--teal);border:1px solid var(--teal);padding:3px 10px;border-radius:12px;font-size:11px;letter-spacing:.1em}
.dirbtn{background:var(--panel);border:1px solid var(--line);color:var(--accent);border-radius:7px;padding:5px 9px;cursor:pointer;font-family:inherit}
.dirbtn:hover{border-color:var(--accent)}
#railToggle{display:none}
/* ── layout: left rail + content ── */
.wrap{display:flex;align-items:flex-start}
aside{width:var(--rail);flex:0 0 var(--rail);position:sticky;top:57px;max-height:calc(100vh - 57px);
overflow-y:auto;padding:10px 10px 60px;border-right:1px solid var(--line)}
.active{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px}
.afilter{background:#23211a;border:1px solid var(--accent);color:var(--txt);border-radius:20px;padding:3px 9px;font-size:11px;cursor:pointer}
.afilter b{color:var(--accent);font-weight:600}
.clearall{background:none;border:1px solid var(--line);color:var(--mut);border-radius:20px;padding:3px 11px;font-size:11px;cursor:pointer}
.clearall:hover{border-color:var(--accent);color:var(--txt)}
details.sec{border:1px solid var(--line);border-radius:7px;margin-bottom:7px;background:var(--card)}
details.sec>summary{list-style:none;cursor:pointer;user-select:none;padding:8px 10px;font-size:10.5px;
letter-spacing:.09em;text-transform:uppercase;color:var(--mut);display:flex;align-items:center;gap:7px}
details.sec>summary::-webkit-details-marker{display:none}
details.sec>summary::before{content:'▸';color:var(--accent);font-size:10px}
details.sec[open]>summary::before{content:'▾'}
details.sec>summary .n{margin-left:auto;background:var(--accent);color:#0e0f11;border-radius:20px;padding:0 7px;font-size:10px;font-weight:700}
.fbody{padding:2px 8px 9px}
.fitem{display:flex;align-items:center;gap:7px;padding:2.5px 2px;cursor:pointer;font-size:12.5px;color:var(--txt);border-radius:5px}
.fitem:hover{background:#1d2026}
.fitem .box{width:13px;height:13px;flex:0 0 13px;border:1px solid var(--mut);border-radius:3px;display:inline-flex;align-items:center;justify-content:center;font-size:10px}
.fitem.on .box{background:var(--accent);border-color:var(--accent);color:#0e0f11;font-weight:700}
.fitem.on{color:var(--accent)}
.fitem .swatch{width:12px;height:12px;flex:0 0 12px;border-radius:50%;border:1px solid #0006}
.fitem .lbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.fitem .ct{color:var(--mut);font-size:10.5px}
.fitem.zero{opacity:.35}
.more{color:var(--accent);font-size:11.5px;cursor:pointer;padding:3px 2px;display:inline-block}
.ffind{width:100%;padding:4px 8px;font-size:11.5px;border-radius:6px;margin:2px 0 5px;background:var(--panel);border:1px solid var(--line);color:var(--txt)}
.fieldchip{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--mut);border:1px solid var(--line);
border-radius:6px;padding:3px 8px;margin:2px 3px 2px 0;cursor:pointer;background:var(--panel)}
.fieldchip.on{color:var(--accent);border-color:var(--accent)}
main{flex:1;min-width:0;padding:14px}
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px}
.card{border:1px solid var(--line);border-radius:6px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}
.card .imgwrap{aspect-ratio:1;background:#000;overflow:hidden}
.card img{width:100%;height:100%;object-fit:cover;display:block}
.card .body{padding:calc(8px*var(--fs))}
.card .acts{display:flex;gap:4px;padding:0 calc(8px*var(--fs)) calc(8px*var(--fs))}
.card .acts .act{flex:1;font:inherit;font-size:calc(10px*var(--fs)*var(--scale));color:var(--mut);background:transparent;border:1px solid var(--line);border-radius:5px;padding:4px 2px;cursor:pointer}
.card .acts .act:hover{border-color:var(--acc);color:var(--txt)}
.card .acts .act:disabled{opacity:.5;cursor:default}
body.imgonly .card .acts{display:none}
#toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%) translateY(20px);max-width:min(560px,92vw);
background:#1b1e24;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:10px 16px;font-size:13px;
box-shadow:0 8px 30px #0008;opacity:0;pointer-events:none;transition:.25s;z-index:50}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
#toast.ok{border-color:#2a6a4a} #toast.err{border-color:#7a2a2a}
.card .nm{font-size:calc(13px*var(--fs)*var(--scale));font-weight:600;line-height:1.25;margin-bottom:3px;color:var(--txt)}
.card .sub{font-size:calc(11px*var(--fs)*var(--scale));color:var(--mut)}
.card .when{font-size:calc(10px*var(--fs)*var(--scale));color:var(--mut);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.card .dot{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid #0006;vertical-align:middle;margin-right:5px}
.card .tags{margin-top:5px;display:flex;flex-wrap:wrap;gap:3px}
.card .tag{font-size:calc(9px*var(--fs)*var(--scale));background:#1d2026;color:var(--mut);padding:1px 6px;border-radius:8px}
body.imgonly .card .body{display:none}
.empty{color:var(--mut);padding:40px;text-align:center}
@media(max-width:900px){
#railToggle{display:inline-block}
aside{position:fixed;left:0;top:57px;bottom:0;background:var(--bg);z-index:20;transform:translateX(-100%);transition:transform .2s}
aside.open{transform:none;box-shadow:6px 0 30px rgba(0,0,0,.5)}
}
</style></head><body>
<header>
<h1>Fentucci Naturals <span class="quote">QUOTE-ONLY · $4.25 SAMPLE</span></h1>
<button class="dirbtn" id="railToggle">☰ Filters</button>
<span class="meta" id="meta">loading…</span>
<input type="search" id="q" placeholder="Search pattern / sku / color / material / description…">
<span class="bar">Sort
<select id="sort">
<option value="newest">Newest</option>
<option value="title">Title A→Z</option>
<option value="sku">SKU A→Z</option>
<option value="series">Series</option>
<option value="color">Color (family)</option>
<option value="material">Material</option>
<option value="style">Style (tag)</option>
</select>
<button class="dirbtn" id="dir" title="Flip sort direction">▲</button>
</span>
<span class="bar">Density <input type="range" id="dens" min="2" max="14" value="6"> <span id="densN"></span></span>
<span class="bar">Text <input type="range" id="tscale" min="80" max="150" value="100"></span>
<label class="bar"><input type="checkbox" id="imgonly"> Image-only wall</label>
</header>
<div class="wrap">
<aside id="rail">
<div class="active" id="activeRow"></div>
<details class="sec" data-k="fields"><summary>Card fields<span class="n" id="nFields"></span></summary><div class="fbody" id="fieldsRow"></div></details>
<details class="sec" data-k="material"><summary>Material<span class="n" id="nMaterial"></span></summary><div class="fbody" id="matRow"></div></details>
<details class="sec" data-k="pattern"><summary>Pattern<span class="n" id="nPattern"></span></summary><div class="fbody" id="patternRow"></div></details>
<details class="sec" data-k="series"><summary>Series (mfr)<span class="n" id="nSeries"></span></summary><div class="fbody" id="seriesRow"></div></details>
<details class="sec" data-k="category"><summary>Category<span class="n" id="nCategory"></span></summary><div class="fbody" id="catRow"></div></details>
<details class="sec" data-k="color"><summary>Color<span class="n" id="nColor"></span></summary><div class="fbody" id="colorRow"></div></details>
<details class="sec" data-k="colorname"><summary>Color name<span class="n" id="nColorName"></span></summary><div class="fbody" id="colorNameRow"></div></details>
<details class="sec" data-k="tags"><summary>Tags<span class="n" id="nTags"></span></summary><div class="fbody" id="tagsRow"></div></details>
<details class="sec" data-k="settlement"><summary>Settlement<span class="n" id="nSettlement"></span></summary><div class="fbody" id="setRow"></div></details>
<details class="sec" data-k="status"><summary>Status<span class="n" id="nStatus"></span></summary><div class="fbody" id="statusRow"></div></details>
<details class="sec" data-k="price"><summary>Price<span class="n" id="nPrice"></span></summary><div class="fbody" id="priceRow"></div></details>
<details class="sec" data-k="image"><summary>Image<span class="n" id="nImage"></span></summary><div class="fbody" id="imgRow"></div></details>
</aside>
<main>
<div class="grid" id="grid"></div>
<div class="empty" id="empty" style="display:none">No matches.</div>
</main>
</div>
<script>
const API = location.origin;
let ALL = [], LAST_FACETS = {};
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
const fmtDate = (d) => d ? new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '';
/* ── persisted multi-select facet state ── */
const SETS = { materials:'ftc_materials', patterns:'ftc_patterns', series:'ftc_series', categories:'ftc_categories',
colors:'ftc_colors', colorNames:'ftc_colorNames', tags:'ftc_tags', settlements:'ftc_settlements',
statuses:'ftc_statuses', prices:'ftc_prices', images:'ftc_images' };
const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k)||'[]')); } catch { return new Set(); } };
const state = {
q: '', sort: localStorage.getItem('ftc_sort')||'newest', dir: localStorage.getItem('ftc_dir')||'asc',
materials:loadSet(SETS.materials), patterns:loadSet(SETS.patterns), series:loadSet(SETS.series),
categories:loadSet(SETS.categories), colors:loadSet(SETS.colors), colorNames:loadSet(SETS.colorNames),
tags:loadSet(SETS.tags), settlements:loadSet(SETS.settlements), statuses:loadSet(SETS.statuses),
prices:loadSet(SETS.prices), images:loadSet(SETS.images),
};
function persistFacets(){ for(const [k,key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
const rowUI = {
matRow:{expand:false,find:''}, patternRow:{expand:false,find:'',typeahead:true}, seriesRow:{expand:false,find:'',typeahead:true},
catRow:{expand:false,find:''}, colorRow:{expand:false,find:''}, colorNameRow:{expand:false,find:'',typeahead:true},
tagsRow:{expand:false,find:'',typeahead:true}, setRow:{expand:false,find:''}, statusRow:{expand:false,find:''},
priceRow:{expand:false,find:''}, imgRow:{expand:false,find:''},
};
/* ── card-field visibility toggles (all off = image-only texture wall) ── */
const FIELDS = [['nm','Pattern'],['sub','Color · SKU'],['dot','Color dot'],['mat','Material'],['tags','Tags'],['when','Date']];
let fieldOn = (()=>{ try{ return JSON.parse(localStorage.getItem('ftc_fields')); }catch{} return null; })() || {nm:1,sub:1,dot:1,mat:1,tags:1,when:1};
function renderFieldChips(){
const box=document.getElementById('fieldsRow'); box.innerHTML='';
for(const [k,label] of FIELDS){
const el=document.createElement('span'); el.className='fieldchip'+(fieldOn[k]?' on':'');
el.innerHTML=`<span class="box" style="width:11px;height:11px;border:1px solid var(--mut);border-radius:3px;display:inline-flex">${fieldOn[k]?'✓':''}</span>${label}`;
el.onclick=()=>{ fieldOn[k]=fieldOn[k]?0:1; localStorage.setItem('ftc_fields',JSON.stringify(fieldOn)); renderFieldChips(); render(); };
box.appendChild(el);
}
document.getElementById('nFields').textContent = Object.values(fieldOn).filter(Boolean).length;
}
/* ── sort ── */
const FAMILY_ORDER = ['Red','Orange','Brown','Yellow','Gold','Green','Teal','Blue','Purple','Pink','Beige','Cream','Black','Gray','White','Multi'];
function sortRows(rows){
const str=(x)=>String(x||'');
const by={
newest:(a,b)=> new Date(b.created_at||0)-new Date(a.created_at||0) || (b.id-a.id),
title:(a,b)=> str(a.pattern_name||a.mfr_sku).localeCompare(str(b.pattern_name||b.mfr_sku)),
sku:(a,b)=> str(a.dw_sku||a.mfr_sku).localeCompare(str(b.dw_sku||b.mfr_sku)),
series:(a,b)=> str(a.pattern_series).localeCompare(str(b.pattern_series)) || str(a.mfr_sku).localeCompare(str(b.mfr_sku)),
color:(a,b)=>{ const i=(x)=>{ const c=(x.colors||[])[0]; const k=c?FAMILY_ORDER.indexOf(c):-1; return k<0?99:k; }; return i(a)-i(b) || str(a.mfr_sku).localeCompare(str(b.mfr_sku)); },
material:(a,b)=> str(a.material).localeCompare(str(b.material)) || str(a.mfr_sku).localeCompare(str(b.mfr_sku)),
style:(a,b)=> str((a.tags_clean||[])[0]||'zz').localeCompare(str((b.tags_clean||[])[0]||'zz')),
};
const out = rows.slice().sort(by[state.sort]||by.newest);
return state.dir==='desc' ? out.reverse() : out;
}
/* ── data load ── */
async function load(){
const [p,f] = await Promise.all([
fetch(API+'/api/products').then(r=>r.json()),
fetch(API+'/api/facets').then(r=>r.json()),
]);
ALL = p.products;
document.getElementById('meta').textContent = p.count+' colorways · '+new Set(ALL.map(x=>x.pattern_name)).size+' patterns';
LAST_FACETS = f;
renderFieldChips();
renderFacets(f);
render();
}
/* ── facet list rows: checkbox + count + type-ahead + cap/expand ── */
function toggleSet(set,val){ set.has(val)?set.delete(val):set.add(val); applyFilterChange(); }
function applyFilterChange(){ persistFacets(); renderFacets(LAST_FACETS); render(); }
function facetList(rowId, items, set, opts={}){
const row=document.getElementById(rowId); row.innerHTML='';
const ui=rowUI[rowId]||{expand:false,find:''};
if(ui.typeahead){
const find=document.createElement('input'); find.type='text'; find.className='ffind'; find.placeholder='find…'; find.value=ui.find;
find.oninput=()=>{ ui.find=find.value; renderFacets(LAST_FACETS); const f2=document.querySelector(`#${rowId} .ffind`); if(f2){ f2.focus(); f2.setSelectionRange(f2.value.length,f2.value.length); } };
row.appendChild(find);
}
let entries=(items||[]).map(it=>[it.v,it.n,it.dot]);
if(opts.order) entries.sort((a,b)=>opts.order.indexOf(a[0])-opts.order.indexOf(b[0]));
if(ui.find) entries=entries.filter(([v])=>String(v).toLowerCase().includes(ui.find.toLowerCase()));
const CAP=opts.cap||8;
let shown=entries, hidden=0;
if(!ui.expand && entries.length>CAP){ shown=entries.slice(0,CAP); hidden=entries.length-CAP; }
const shownVals=new Set(shown.map(([v])=>v));
for(const v of set) if(!shownVals.has(v)){ const e=entries.find(([x])=>x===v); shown.push(e||[v,0,'']); }
if(!shown.length){ const s=document.createElement('div'); s.style.cssText='font-size:10px;color:#6a6a72;padding:2px'; s.textContent='none'; row.appendChild(s); return; }
shown.forEach(([val,ct,dot])=>{
const el=document.createElement('div'); el.className='fitem'+(set.has(val)?' on':'')+(ct?'':' zero');
const sw = dot ? `<span class="swatch" style="background:${dot}"></span>` : '';
el.innerHTML=`<span class="box">${set.has(val)?'✓':''}</span>${sw}<span class="lbl" title="${esc(val)}">${esc(val)}</span><span class="ct">${(ct||0).toLocaleString()}</span>`;
el.onclick=()=>toggleSet(set,val);
row.appendChild(el);
});
if(hidden>0){ const el=document.createElement('span'); el.className='more'; el.textContent=`See ${hidden.toLocaleString()} more ▾`; el.onclick=()=>{ ui.expand=true; renderFacets(LAST_FACETS); }; row.appendChild(el); }
else if(ui.expand && entries.length>CAP){ const el=document.createElement('span'); el.className='more'; el.textContent='See less ▴'; el.onclick=()=>{ ui.expand=false; renderFacets(LAST_FACETS); }; row.appendChild(el); }
}
function renderFacets(f){
LAST_FACETS=f;
facetList('matRow', f.material, state.materials, {cap:12});
facetList('patternRow', f.pattern, state.patterns, {cap:12});
facetList('seriesRow', f.series, state.series, {cap:10});
facetList('catRow', f.category, state.categories, {cap:12});
facetList('colorRow', f.color, state.colors, {order:f.family_order||FAMILY_ORDER, cap:16});
facetList('colorNameRow', f.color_name, state.colorNames, {cap:10});
facetList('tagsRow', f.tags, state.tags, {cap:14});
facetList('setRow', f.settlement, state.settlements, {cap:4});
facetList('statusRow', f.status, state.statuses, {cap:4});
facetList('priceRow', f.price_band, state.prices, {cap:4});
facetList('imgRow', f.image_state, state.images, {cap:4});
renderActive();
}
/* ── active-filter chip row + per-section count badges ── */
const DIMS = [['Material','materials','nMaterial'],['Pattern','patterns','nPattern'],['Series','series','nSeries'],
['Category','categories','nCategory'],['Color','colors','nColor'],['Color name','colorNames','nColorName'],
['Tag','tags','nTags'],['Settlement','settlements','nSettlement'],['Status','statuses','nStatus'],
['Price','prices','nPrice'],['Image','images','nImage']];
function renderActive(){
const row=document.getElementById('activeRow'); row.innerHTML='';
for(const [,k,badgeId] of DIMS){ const b=document.getElementById(badgeId); if(b){ b.textContent=state[k].size||''; b.style.display=state[k].size?'':'none'; } }
let n=0;
for(const [label,k] of DIMS){
state[k].forEach((v)=>{
n++;
const el=document.createElement('span'); el.className='afilter';
el.innerHTML=`<b>${label}:</b> ${esc(v)} ✕`;
el.onclick=()=>{ state[k].delete(v); applyFilterChange(); };
row.appendChild(el);
});
}
if(n){ const c=document.createElement('button'); c.className='clearall'; c.textContent='✕ Clear filters'; c.onclick=()=>{ DIMS.forEach(([,k])=>state[k].clear()); applyFilterChange(); }; row.appendChild(c); }
}
/* ── filter predicate ── */
function matches(p){
const q=state.q;
if(q){ const hay=[p.pattern_name,p.dw_sku,p.mfr_sku,p.pattern_series,p.color_name,p.material,p.source_category,p.description].filter(Boolean).join(' ').toLowerCase(); if(!hay.includes(q)) return false; }
if(state.materials.size && !state.materials.has(p.material)) return false;
if(state.patterns.size && !state.patterns.has(p.pattern_name)) return false;
if(state.series.size && !state.series.has(p.pattern_series)) return false;
if(state.categories.size && !state.categories.has(p.source_category)) return false;
if(state.colors.size && !(p.colors||[]).some(c=>state.colors.has(c))) return false;
if(state.colorNames.size && !state.colorNames.has(p.color_name)) return false;
if(state.tags.size && !(p.tags_clean||[]).some(t=>state.tags.has(t))) return false;
if(state.settlements.size && !state.settlements.has(p.settlement_status)) return false;
if(state.statuses.size && !state.statuses.has(p.status)) return false;
if(state.prices.size && !state.prices.has(p.price_band)) return false;
if(state.images.size && !state.images.has(p.image_state)) return false;
return true;
}
function render(){
const grid=document.getElementById('grid'); const rows=sortRows(ALL.filter(matches));
document.getElementById('empty').style.display=rows.length?'none':'block';
grid.innerHTML = rows.map(p=>{
const hex=p.color_hex;
const tags=(p.tags_clean||[]).slice(0,4);
const anyBody = fieldOn.nm||fieldOn.sub||fieldOn.mat||fieldOn.tags||fieldOn.when;
const body = anyBody ? `<div class="body">
${fieldOn.nm?`<div class="nm">${esc(p.pattern_name||p.mfr_sku)} ${esc(p.color_name?'· '+p.color_name:'')}</div>`:''}
${fieldOn.sub?`<div class="sub">${fieldOn.dot&&hex?`<span class="dot" style="background:${esc(hex)}"></span>`:''}${esc(p.dw_sku||'')} · ${esc(p.mfr_sku||'')}</div>`:''}
${fieldOn.mat?`<div class="sub">${esc(p.material||'')}${p.source_category?' · '+esc(p.source_category):''}</div>`:''}
${fieldOn.tags&&tags.length?`<div class="tags">${tags.map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>`:''}
${fieldOn.when?`<div class="when" title="${esc(p.created_at||'')}">🕓 ${fmtDate(p.created_at)}</div>`:''}
</div>`:'';
return `<div class="card" data-sku="${esc(p.dw_sku||'')}" data-mfr="${esc(p.mfr_sku||'')}" data-title="${esc((p.pattern_name||'')+(p.color_name?' '+p.color_name:''))}">
<div class="imgwrap"><img loading="lazy" src="${esc(p.image||'')}" alt="${esc(p.pattern_name||'')}"></div>
${body}
<div class="acts">
<button class="act" data-act="memo" title="Log a memo-sample request">Memo</button>
<button class="act" data-act="stock" title="Check stock with the vendor">Stock</button>
<button class="act" data-act="price" title="Get current price from the vendor">Price</button>
</div></div>`;
}).join('');
}
/* ── Purchasing actions: Memo / Stock / Price → /api/request ── */
document.getElementById('grid').addEventListener('click', async (e)=>{
const btn=e.target.closest('.act'); if(!btn) return;
const card=btn.closest('.card'); const type=btn.dataset.act;
const payload={ type, dw_sku:card.dataset.sku, mfr_sku:card.dataset.mfr, title:card.dataset.title };
const old=btn.textContent; btn.disabled=true; btn.textContent='…';
try{
const r=await fetch(location.origin+'/api/request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.json());
toast(r.ok ? (r.message||('Logged '+(r.req_no||''))) : (r.error||'request failed'), !!r.ok);
}catch(err){ toast('request failed: '+err.message, false); }
btn.disabled=false; btn.textContent=old;
});
let toastT=null;
function toast(msg, ok){
let t=document.getElementById('toast');
if(!t){ t=document.createElement('div'); t.id='toast'; document.body.appendChild(t); }
t.textContent=msg; t.className='show '+(ok?'ok':'err');
clearTimeout(toastT); toastT=setTimeout(()=>t.className='',6000);
}
/* ── top-bar wiring ── */
const qEl=document.getElementById('q');
qEl.oninput=()=>{ state.q=qEl.value.trim().toLowerCase(); render(); };
const sortEl=document.getElementById('sort'); sortEl.value=state.sort;
sortEl.onchange=e=>{ state.sort=e.target.value; localStorage.setItem('ftc_sort',state.sort); render(); };
const dirBtn=document.getElementById('dir');
const drawDir=()=>{ dirBtn.textContent = state.dir==='desc'?'▼':'▲'; };
drawDir();
dirBtn.onclick=()=>{ state.dir=state.dir==='desc'?'asc':'desc'; localStorage.setItem('ftc_dir',state.dir); drawDir(); render(); };
const dens=document.getElementById('dens'), densN=document.getElementById('densN');
dens.value=localStorage.getItem('ftc_cols')||6;
const setCols=()=>{ document.documentElement.style.setProperty('--cols',dens.value); densN.textContent=dens.value; document.documentElement.style.setProperty('--fs', Math.max(.62,Math.min(1.15, 6/dens.value)).toFixed(2)); };
setCols();
dens.oninput=()=>{ setCols(); localStorage.setItem('ftc_cols',dens.value); };
const tscale=document.getElementById('tscale');
tscale.value=localStorage.getItem('ftc_tscale')||100;
document.documentElement.style.setProperty('--scale',(tscale.value/100));
tscale.oninput=e=>{ document.documentElement.style.setProperty('--scale',(e.target.value/100)); localStorage.setItem('ftc_tscale',e.target.value); };
const imgonlyEl=document.getElementById('imgonly');
imgonlyEl.checked = localStorage.getItem('ftc_imgonly')==='1';
document.body.classList.toggle('imgonly',imgonlyEl.checked);
imgonlyEl.onchange=e=>{ document.body.classList.toggle('imgonly',e.target.checked); localStorage.setItem('ftc_imgonly',e.target.checked?'1':'0'); };
document.getElementById('railToggle').onclick=()=>document.getElementById('rail').classList.toggle('open');
load();
</script></body></html>