← 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, '&amp;').replace(/</g, '&lt;')
    .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

// ---- 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();