← back to Astek Import Viewer
public/app.js
292 lines
'use strict';
// ---- State ----
let ALL = []; // full catalog
let VIEW = []; // filtered + sorted
const selected = new Set(); // ids (as strings)
const filters = { pattern: null, color: null, material: null };
const LS = {
sort: 'astek.sort',
density: 'astek.density',
sel: 'astek.selected',
};
// ---- DOM ----
const $ = (id) => document.getElementById(id);
const grid = $('grid');
const empty = $('empty');
const searchEl = $('search');
const sortEl = $('sort');
const densityEl = $('density');
const densityVal = $('densityVal');
const activeFiltersEl = $('activeFilters');
// ---- Utils ----
function fmtWhen(iso) {
if (!iso) return '';
const d = new Date(iso);
if (isNaN(d)) return '';
return d.toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
});
}
function fmtPrice(p) {
if (p === null || p === undefined || p === '' || isNaN(Number(p))) return null;
return '$' + Number(p).toFixed(2);
}
function debounce(fn, ms) {
let t;
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
// ---- Persist selection: localStorage immediately, server debounced ----
const pushServer = debounce(() => {
fetch('/api/select', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids: Array.from(selected) }),
}).catch(() => {});
}, 600);
function persistSelection() {
localStorage.setItem(LS.sel, JSON.stringify(Array.from(selected)));
pushServer();
updateCounts();
}
// ---- Filtering + sorting ----
function applyView() {
const terms = searchEl.value.toLowerCase().split(/\s+/).filter(Boolean);
VIEW = ALL.filter((p) => {
if (filters.pattern && (p.pattern_name || '') !== filters.pattern) return false;
if (filters.color && (p.color_name || '') !== filters.color) return false;
if (filters.material && (p.material || '') !== filters.material) return false;
if (terms.length) {
const hay = [
p.title, p.pattern_name, p.color_name, p.dw_sku, p.mfr_sku,
p.material, p.width, p.japan_sku, p.japan_vendor,
].join(' ').toLowerCase();
for (const t of terms) if (!hay.includes(t)) return false;
}
return true;
});
const sort = sortEl.value;
const cmpStr = (a, b) => a.localeCompare(b);
VIEW.sort((a, b) => {
switch (sort) {
case 'title': return cmpStr(a.title || '', b.title || '');
case 'pattern': return cmpStr(a.pattern_name || '', b.pattern_name || '');
case 'color': return cmpStr(a.color_name || '', b.color_name || '');
case 'sku': return cmpStr(a.dw_sku || a.mfr_sku || '', b.dw_sku || b.mfr_sku || '');
case 'price_asc': return priceNum(a) - priceNum(b);
case 'price_desc': return priceNum(b) - priceNum(a);
case 'newest':
default:
return new Date(b.created_at || 0) - new Date(a.created_at || 0);
}
});
renderFilters();
render();
}
function priceNum(p) {
const n = Number(p.price);
return isNaN(n) ? -Infinity : n;
}
// ---- Render active filter chips ----
function renderFilters() {
const active = Object.entries(filters).filter(([, v]) => v);
if (!active.length) { activeFiltersEl.hidden = true; activeFiltersEl.innerHTML = ''; return; }
activeFiltersEl.hidden = false;
activeFiltersEl.innerHTML =
'<span class="lbl">Filtering:</span>' +
active.map(([k, v]) =>
`<span class="filter-tag" data-key="${esc(k)}">${esc(k)}: ${esc(v)}</span>`
).join('');
}
// ---- Render grid ----
function render() {
const frag = document.createDocumentFragment();
for (const p of VIEW) {
const id = String(p.id);
const isSel = selected.has(id);
const dead = !!p.image_rejected;
const price = fmtPrice(p.price);
const whenFull = p.created_at || '';
const whenTxt = fmtWhen(p.created_at);
const card = document.createElement('article');
card.className = 'card' + (isSel ? ' selected' : '') + (dead ? ' dead' : '');
card.dataset.id = id;
card.innerHTML = `
<div class="card-imgwrap">
${dead ? '<span class="badge-dead">⚠ dead image</span>' : ''}
<input type="checkbox" class="card-check" ${isSel ? 'checked' : ''}
title="Select for import" aria-label="Select ${esc(p.title)}" />
<img class="card-img" loading="lazy" src="${esc(p.image_url || '')}"
alt="${esc(p.title || '')}" />
</div>
<div class="card-body">
<h3 class="card-title">
${p.product_url
? `<a href="${esc(p.product_url)}" target="_blank" rel="noopener">${esc(p.title || '(untitled)')}</a>`
: esc(p.title || '(untitled)')}
</h3>
<div class="chips">
${p.image_url ? `<img class="chipthumb" loading="lazy" src="${esc(p.image_url)}" alt="" title="${esc(p.pattern_name || '')}" onerror="this.style.display='none'" />` : ''}
${p.pattern_name ? `<span class="chip" data-filter="pattern" data-val="${esc(p.pattern_name)}"><span class="k">pattern</span> ${esc(p.pattern_name)}</span>` : ''}
${p.color_name ? `<span class="chip" data-filter="color" data-val="${esc(p.color_name)}"><span class="k">color</span> ${esc(p.color_name)}</span>` : ''}
${p.material ? `<span class="chip" data-filter="material" data-val="${esc(p.material)}"><span class="k">mat</span> ${esc(p.material)}</span>` : ''}
${p.japan_sku ? `<a class="chip jp ${p.japan_conf === 'possible' ? 'poss' : 'hi'}" href="${esc(p.japan_img_url || ('https://japan.designerwallcoverings.com/?q=' + encodeURIComponent(p.japan_sku)))}" target="_blank" rel="noopener noreferrer" title="Open the exact ${esc(p.japan_vendor)} ${esc(p.japan_sku)} image on japan.designerwallcoverings.com (visual match — ${esc(p.japan_conf)})">${p.japan_thumb ? `<img class="jt" loading="lazy" src="${esc(p.japan_thumb)}" alt="" onerror="this.style.display='none'" />` : ''}≈ ${esc(p.japan_vendor)} ${esc(p.japan_sku)} ↗</a>` : ''}
</div>
<div class="meta">
<div class="row"><span class="sku" data-copy="${esc(p.dw_sku || '')}" title="Click to copy dw_sku">${esc(p.dw_sku || '—')}</span>
<span class="price ${price ? '' : 'none'}">${price || 'no price'}</span></div>
<div class="row"><span>mfr: ${esc(p.mfr_sku || '—')}</span><span>${esc(p.width || '')}</span></div>
</div>
<span class="when" title="${esc(whenFull)}">🕓 ${esc(whenTxt)}</span>
</div>
`;
frag.appendChild(card);
}
grid.replaceChildren(frag);
empty.hidden = VIEW.length > 0;
updateCounts();
}
function updateCounts() {
$('selCount').textContent = selected.size + ' selected';
$('shownCount').textContent = VIEW.length.toLocaleString() + ' shown';
$('totalCount').textContent = ALL.length.toLocaleString() + ' total';
const ex = $('exportBtn');
ex.classList.toggle('disabled', selected.size === 0);
}
// ---- Events (delegated) ----
grid.addEventListener('change', (e) => {
if (!e.target.classList.contains('card-check')) return;
const card = e.target.closest('.card');
const id = card.dataset.id;
if (e.target.checked) { selected.add(id); card.classList.add('selected'); }
else { selected.delete(id); card.classList.remove('selected'); }
persistSelection();
});
grid.addEventListener('click', (e) => {
const chip = e.target.closest('.chip');
if (chip && chip.dataset.filter) { // japan-match chip has no data-filter — not a filter
filters[chip.dataset.filter] = chip.dataset.val;
applyView();
window.scrollTo({ top: 0, behavior: 'smooth' });
return;
}
const sku = e.target.closest('.sku');
if (sku && sku.dataset.copy) {
navigator.clipboard.writeText(sku.dataset.copy).then(() => {
sku.classList.add('copied');
setTimeout(() => sku.classList.remove('copied'), 1200);
}).catch(() => {});
}
});
activeFiltersEl.addEventListener('click', (e) => {
const tag = e.target.closest('.filter-tag');
if (!tag) return;
filters[tag.dataset.key] = null;
applyView();
});
searchEl.addEventListener('input', debounce(applyView, 180));
sortEl.addEventListener('change', () => {
localStorage.setItem(LS.sort, sortEl.value);
applyView();
});
densityEl.addEventListener('input', () => {
const v = densityEl.value;
document.documentElement.style.setProperty('--cols', v);
densityVal.textContent = v;
localStorage.setItem(LS.density, v);
});
$('selectAll').addEventListener('click', () => {
for (const p of VIEW) selected.add(String(p.id));
// reflect on visible cards
grid.querySelectorAll('.card').forEach((c) => {
c.classList.add('selected');
const cb = c.querySelector('.card-check');
if (cb) cb.checked = true;
});
persistSelection();
});
$('clearSel').addEventListener('click', () => {
selected.clear();
grid.querySelectorAll('.card').forEach((c) => {
c.classList.remove('selected');
const cb = c.querySelector('.card-check');
if (cb) cb.checked = false;
});
persistSelection();
});
// broken-image fallback marker (does NOT set image_rejected, just visual)
grid.addEventListener('error', (e) => {
if (e.target && e.target.classList && e.target.classList.contains('card-img')) {
e.target.classList.add('broken');
e.target.src = 'data:image/svg+xml;utf8,' +
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><rect width="80" height="80" fill="%23eee"/></svg>');
}
}, true);
// ---- Boot ----
function restoreControls() {
const s = localStorage.getItem(LS.sort);
if (s) sortEl.value = s;
const d = localStorage.getItem(LS.density);
if (d) { densityEl.value = d; densityVal.textContent = d; }
document.documentElement.style.setProperty('--cols', densityEl.value);
}
async function boot() {
restoreControls();
// Restore selection: prefer server, fall back to localStorage
let serverIds = [];
try {
const r = await fetch('/api/selections');
const j = await r.json();
serverIds = Array.isArray(j.ids) ? j.ids : [];
} catch (_) {}
let localIds = [];
try { localIds = JSON.parse(localStorage.getItem(LS.sel) || '[]'); } catch (_) {}
for (const id of [...serverIds, ...localIds]) selected.add(String(id));
const res = await fetch('/api/products');
ALL = await res.json();
applyView();
}
boot();