[object Object]

← back to Dw Photo Capture

Add ⭐ Favorites + πŸ•˜ Recent views: star any card to pin SKUs you update often; recents auto-log on every upload (server-stored, shared across phone+office)

3c7d0a260b497888f39e953c079c859e9e72d0d2 Β· 2026-06-25 06:29:59 -0700 Β· Steve

Files touched

Diff

commit 3c7d0a260b497888f39e953c079c859e9e72d0d2
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jun 25 06:29:59 2026 -0700

    Add ⭐ Favorites + πŸ•˜ Recent views: star any card to pin SKUs you update often; recents auto-log on every upload (server-stored, shared across phone+office)
---
 data/build.json     |  5 +++--
 data/favorites.json |  1 +
 data/recents.json   |  1 +
 public/index.html   | 42 ++++++++++++++++++++++++++++++++----
 server.js           | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++---
 5 files changed, 101 insertions(+), 9 deletions(-)

diff --git a/data/build.json b/data/build.json
index 03da503..847bf12 100644
--- a/data/build.json
+++ b/data/build.json
@@ -1,5 +1,5 @@
 {
-  "next": 19,
+  "next": 20,
   "map": {
     "578af86f": 2,
     "bc95fdb0": 3,
@@ -17,6 +17,7 @@
     "d0f0df55": 15,
     "9905b54e": 16,
     "48c9e659": 17,
-    "779f6d1f": 18
+    "779f6d1f": 18,
+    "598b1ee5": 19
   }
 }
\ No newline at end of file
diff --git a/data/favorites.json b/data/favorites.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/data/favorites.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/data/recents.json b/data/recents.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/data/recents.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index b202f65..7f9c0a7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -32,6 +32,9 @@
   .thumb{aspect-ratio:4/3;background:#0c0b09 center/cover no-repeat;display:flex;align-items:center;justify-content:center;color:#4a443b;font-size:34px;position:relative}
   .thumb.tap{cursor:pointer} .thumb.tap:hover{filter:brightness(1.08)}
   .badge{position:absolute;top:8px;right:8px;font-size:11px;padding:3px 8px;border-radius:99px;font-weight:700;letter-spacing:.03em}
+  .fav-btn{position:absolute;top:6px;left:6px;width:32px;height:32px;border:none;border-radius:50%;background:rgba(8,7,6,.55);color:#f3efe7;font-size:18px;line-height:1;cursor:pointer;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(2px)}
+  .fav-btn.on{color:var(--gold)}
+  .fav-btn:active{transform:scale(.88)}
   .b-need{background:#3a2a14;color:#e7c878} .b-done{background:var(--green);color:#fff} .b-err{background:var(--red);color:#fff}
   .b-live{background:#1f7a48;color:#fff;box-shadow:0 0 0 2px rgba(63,160,106,.4)}
   .b-new{background:#2a2350;color:#cbb8ff;box-shadow:0 0 0 1px #5a4aa0}
@@ -131,6 +134,8 @@
       <div class="chip" data-f="all">All</div>
       <div class="chip" data-f="any">πŸ”Ž Any SKU</div>
       <div class="chip" data-f="shop">🌐 All Shopify</div>
+      <div class="chip" data-f="fav">⭐ Favorites</div>
+      <div class="chip" data-f="recent">πŸ•˜ Recent</div>
     </div>
     <div class="dens">Size <input type="range" id="dens" min="1" max="4" value="1"></div>
   </div>
@@ -221,6 +226,23 @@ async function loadNew(){
     if(filter==='new') render();
   }catch(e){}
 }
+// Favorites (starred) + Recents (auto-logged on upload) β€” server-stored so the
+// same lists show on phone AND office. Card-shaped, render straight into the grid.
+let FAVORITES=[], RECENTS=[];
+async function loadFav(){ try{ const d=await(await fetch('/api/favorites')).json(); FAVORITES=d.items||[]; if(filter==='fav')render(); }catch(e){} }
+async function loadRecent(){ try{ const d=await(await fetch('/api/recents')).json(); RECENTS=d.items||[]; if(filter==='recent')render(); }catch(e){} }
+// the display fields the server caches so recents/favorites render without a Shopify lookup
+const cardMeta=x=>({title:x.title||'',mfr:x.mfr||'',price:x.price||null,image:(x.image&&!/^\/photos\//.test(x.image))?x.image:null,product_id:x.product_id||null,status:x.status||''});
+async function toggleFav(x,btn){
+  const on=!x.fav; x.fav=on;
+  if(btn){ btn.textContent=on?'β˜…':'β˜†'; btn.classList.toggle('on',on); }
+  try{
+    await fetch('/api/favorite',{method:'POST',headers:{'Content-Type':'application/json'},
+      body:JSON.stringify({dw_sku:x.dw_sku,on,meta:cardMeta(x)})});
+    toast(on?('⭐ Favorited '+x.dw_sku):('Removed '+x.dw_sku));
+    loadFav(); if(filter==='fav')render(); // drop it from the Favorites view immediately on un-star
+  }catch(e){ x.fav=!on; if(btn){btn.textContent=x.fav?'β˜…':'β˜†';btn.classList.toggle('on',x.fav);} toast('Favorite failed'); }
+}
 // Live store-wide search β€” covers EVERY Shopify SKU, not just Fentucci.
 let SHOPSEARCH=[];
 async function doShopSearch(){
@@ -233,8 +255,10 @@ async function doShopSearch(){
 }
 function render(){
   const q=($('#q').value||'').toLowerCase(), sort=$('#sort').value;
-  const src = filter==='any' ? LOOKUP : filter==='shop' ? SHOPSEARCH : filter==='new' ? NEWITEMS : ITEMS;
+  const src = filter==='any' ? LOOKUP : filter==='shop' ? SHOPSEARCH : filter==='new' ? NEWITEMS
+            : filter==='fav' ? FAVORITES : filter==='recent' ? RECENTS : ITEMS;
   const liveSearch = filter==='any'||filter==='shop'; // server already filtered these
+  const curated = filter==='fav'||filter==='recent'; // server-stored lists, local q-filter only
   let list=src.filter(x=>{
     if(filter==='need'&&(x.done||x.skipped))return false;
     if(filter==='live'&&!x.live)return false;
@@ -256,6 +280,10 @@ function render(){
     $('#prog').style.width='0%';
     $('#count').textContent = `${list.length} new SKU${list.length===1?'':'s'} to create`;
     $('#remain').textContent = 'πŸ“· shoot to create + go live';
+  } else if(curated){
+    $('#prog').style.width='0%';
+    $('#count').textContent = filter==='fav' ? `${list.length} ⭐ favorite${list.length===1?'':'s'}` : `${list.length} recently updated`;
+    $('#remain').textContent = filter==='fav' ? 'tap β˜† on any card to add' : 'newest first';
   } else {
     const done=ITEMS.filter(x=>x.done).length, total=ITEMS.length;
     $('#prog').style.width=(total?done/total*100:0)+'%';
@@ -266,6 +294,8 @@ function render(){
   const emptyMsg = filter==='shop' ? (typedQ?'No Shopify product matches that β€” try the SKU, pattern name, or vendor.':'🌐 Search EVERY Shopify SKU above β€” add or update a photo from your phone.')
     : filter==='any' ? (typedQ?'No Fentucci product matches that β€” try the model # or DW SKU.':'πŸ”Ž Search any TWIL/Fentucci SKU above to add or replace its photo.')
     : filter==='new' ? (typedQ?'No new SKU matches that.':'βœ… All new SKUs created β€” none left to add.')
+    : filter==='fav' ? (typedQ?'No favorite matches that.':'⭐ No favorites yet β€” tap the β˜† star on any card to pin SKUs you update often.')
+    : filter==='recent' ? (typedQ?'No recent SKU matches that.':'πŸ•˜ Nothing updated yet β€” SKUs you photograph show up here, newest first.')
     : 'Nothing here β€” switch filter or clear search.';
   grid.innerHTML = list.length? '' : `<div class="empty">${emptyMsg}</div>`;
   for(const x of list){
@@ -277,7 +307,7 @@ function render(){
     const thumb=thumbUrl?`style="background-image:url('${thumbUrl}')"`:'';
     const adjustable=photographed; // anything with a real photo can be re-opened in the editor
     el.innerHTML=`
-      <div class="thumb ${adjustable?'tap':''}" ${thumb} title="${adjustable?'click to adjust':''}">${thumbUrl?'':'β–¦'}${badge}</div>
+      <div class="thumb ${adjustable?'tap':''}" ${thumb} title="${adjustable?'click to adjust':''}">${thumbUrl?'':'β–¦'}${badge}${x.product_id?`<button class="fav-btn${x.fav?' on':''}" title="favorite">${x.fav?'β˜…':'β˜†'}</button>`:''}</div>
       <div class="body">
         <div class="ttl">${(x.title||'').replace(/ \\| Fentucci$/,'')}</div>
         <div class="model" title="tap to copy model #" onclick="navigator.clipboard&&navigator.clipboard.writeText('${x.mfr||''}')"><small>Model&nbsp;#</small><b>${x.mfr||'β€”'}</b></div>
@@ -303,6 +333,7 @@ function render(){
     const oi=el.querySelector('.onlyimg-cb'); if(oi) oi.addEventListener('change',e=>{ x._replaceAll=e.target.checked; el.querySelector('.onlyimg').classList.toggle('armed',e.target.checked); });
     const enh=el.querySelector('.enh'); if(enh) enh.addEventListener('click',()=>openEditor(x,editSrc()));
     const th=el.querySelector('.thumb.tap'); if(th) th.addEventListener('click',()=>openEditor(x,editSrc()));
+    const fb=el.querySelector('.fav-btn'); if(fb) fb.addEventListener('click',ev=>{ev.stopPropagation();toggleFav(x,fb);});
     const sk=el.querySelector('.skip'); if(sk) sk.addEventListener('click',()=>skip(x));
     grid.appendChild(el);
   }
@@ -372,7 +403,7 @@ async function uploadPhoto(x,dataUrl){
   toast((x.needs_create?'Creating ':'Uploading ')+x.dw_sku+'…');
   try{
     const r=await fetch('/api/photo',{method:'POST',headers:{'Content-Type':'application/json'},
-      body:JSON.stringify({dw_sku:x.dw_sku,product_id:x.product_id,dataUrl,keep_images:!!x.keep_images&&!x._replaceAll})});
+      body:JSON.stringify({dw_sku:x.dw_sku,product_id:x.product_id,dataUrl,keep_images:!!x.keep_images&&!x._replaceAll,meta:cardMeta(x)})});
     const d=await r.json();
     if(d.ok){ Object.assign(x,{done:true,needs_create:false,created:d.created,photo:d.photo,ts:new Date().toISOString(),shopify_pushed:d.shopify_pushed,push_err:d.push_err,live:d.live,live_reason:d.live_reason});
       toast(d.created&&d.live?('🟒 Created + LIVE Β· '+x.dw_sku):d.created?('Created (draft) Β· '+x.dw_sku+(d.live_reason?' β€” '+d.live_reason:'')):d.live?('🟒 '+x.dw_sku+' is LIVE!'):d.shopify_pushed?('βœ“ Photo on Shopify Β· '+x.dw_sku+(d.live_reason?(' (needs '+d.live_reason.replace('needs ','')+')'):'')):('Saved '+x.dw_sku+(d.push_err?' (push failed)':''))); render();
@@ -602,9 +633,12 @@ document.querySelectorAll('.chip').forEach(c=>c.addEventListener('click',()=>{
   if(filter==='any'){ $('#q').placeholder='πŸ”Ž SKU, model #, name, or collection…'; $('#q').focus(); doLookup(); }
   else if(filter==='shop'){ $('#q').placeholder='🌐 Any Shopify SKU, name, vendor, or collection…'; $('#q').focus(); doShopSearch(); }
   else if(filter==='new'){ $('#q').placeholder='πŸ”Ž Filter the new SKUs…'; loadNew(); }
+  else if(filter==='fav'){ $('#q').placeholder='πŸ”Ž Filter favorites…'; render(); loadFav(); }
+  else if(filter==='recent'){ $('#q').placeholder='πŸ”Ž Filter recent…'; render(); loadRecent(); }
   else { $('#q').placeholder='πŸ”Ž Search name or model #…'; render(); }
 }));
-load(); loadNew(); setInterval(()=>{ if(filter!=='any'&&filter!=='shop') load(); }, 60000);
+load(); loadNew(); loadFav(); loadRecent();
+setInterval(()=>{ if(filter!=='any'&&filter!=='shop'&&filter!=='fav'&&filter!=='recent') load(); }, 60000);
 </script>
 </body>
 </html>
diff --git a/server.js b/server.js
index 2fe7589..ce8ecfd 100644
--- a/server.js
+++ b/server.js
@@ -54,6 +54,25 @@ function buildLabel() {
 let progress = loadJSON(PROGRESS_FILE, {});
 const saveProgress = () => saveJSON(PROGRESS_FILE, progress);
 
+// recents.json / favorites.json: card-shaped entries keyed by dw_sku so the
+// "πŸ•˜ Recent" and "⭐ Favorites" views render full cards without a Shopify lookup.
+// Server-side (not localStorage) so the same list shows on phone AND office.
+const RECENTS_FILE = path.join(DATA, 'recents.json');
+const FAVORITES_FILE = path.join(DATA, 'favorites.json');
+let recents = loadJSON(RECENTS_FILE, {});
+let favorites = loadJSON(FAVORITES_FILE, {});
+const saveRecents = () => saveJSON(RECENTS_FILE, recents);
+const saveFavorites = () => saveJSON(FAVORITES_FILE, favorites);
+// Build a card-shaped record from the display meta the client sends.
+function cardRecord(dw_sku, meta, extra) {
+  meta = meta || {};
+  return Object.assign({
+    dw_sku, product_id: meta.product_id || null, title: meta.title || dw_sku,
+    mfr: meta.mfr || '', price: meta.price || null, image: meta.image || null,
+    status: meta.status || '', keep_images: true
+  }, extra || {});
+}
+
 function shopifyReq(method, path, payload) {
   return new Promise((resolve) => {
     if (!TOKEN) return resolve({ status: 0, body: null, err: 'no token' });
@@ -170,6 +189,34 @@ const server = http.createServer((req, res) => {
     return send(res, 200, { total: q.length, done: q.filter(x => x.done).length, items: q });
   }
 
+  // Recently-updated SKUs (auto-logged on every upload), newest first.
+  if (u.pathname === '/api/recents' && req.method === 'GET') {
+    const items = Object.values(recents)
+      .sort((a, b) => String(b.ts || '').localeCompare(String(a.ts || ''))).slice(0, 40)
+      .map(x => ({ ...x, fav: !!favorites[x.dw_sku] }));
+    return send(res, 200, { total: items.length, items });
+  }
+  // Starred SKUs the user updates often, most-recently-starred first.
+  if (u.pathname === '/api/favorites' && req.method === 'GET') {
+    const items = Object.values(favorites)
+      .sort((a, b) => String(b.fav_ts || '').localeCompare(String(a.fav_ts || '')))
+      .map(x => ({ ...x, fav: true }));
+    return send(res, 200, { total: items.length, items });
+  }
+  // Toggle a favorite. Body: { dw_sku, on:bool, meta:{title,mfr,price,image,product_id,status} }
+  if (u.pathname === '/api/favorite' && req.method === 'POST') {
+    let body = ''; req.on('data', c => body += c);
+    req.on('end', () => {
+      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
+      if (!p.dw_sku) return send(res, 400, { err: 'dw_sku required' });
+      if (p.on === false) { delete favorites[p.dw_sku]; }
+      else { favorites[p.dw_sku] = cardRecord(p.dw_sku, p.meta, { fav_ts: new Date().toISOString() }); }
+      saveFavorites();
+      return send(res, 200, { ok: true, fav: p.on !== false, count: Object.keys(favorites).length });
+    });
+    return;
+  }
+
   // Look up ANY Fentucci (TWIL) product β€” by DW SKU, model #, or name β€” to add/replace its photo.
   if (u.pathname === '/api/lookup' && req.method === 'GET') {
     const q = (u.searchParams.get('q') || '').trim().toLowerCase();
@@ -260,7 +307,7 @@ const server = http.createServer((req, res) => {
     req.on('data', c => { body += c; if (body.length > 25 * 1024 * 1024) req.destroy(); });
     req.on('end', async () => {
       let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
-      const { dw_sku, product_id, dataUrl, push, keep_images } = p;
+      const { dw_sku, product_id, dataUrl, push, keep_images, meta } = p;
       if (!dw_sku || !dataUrl) return send(res, 400, { err: 'dw_sku + dataUrl required' });
       const b64 = dataUrl.replace(/^data:image\/\w+;base64,/, '');
       const safe = String(dw_sku).replace(/[^A-Za-z0-9._-]/g, '');
@@ -284,9 +331,17 @@ const server = http.createServer((req, res) => {
         // wipe good room/lifestyle shots on an arbitrary live product.
         const r = await shopifyAttachImage(existingPid, dw_sku, b64, !!keep_images);
         entry.shopify_pushed = r.ok; entry.push_err = r.ok ? null : r.err;
+        entry.product_id = existingPid;
         if (r.ok) { const a = await shopifyActivateIfReady(existingPid); entry.live = !!a.live; entry.live_reason = a.reason || null; }
       }
       progress[dw_sku] = entry; saveProgress();
+      // log into recents (most-recently-updated first) so frequent SKUs are one tap away.
+      recents[dw_sku] = cardRecord(dw_sku, meta, {
+        product_id: entry.product_id || existingPid || (meta && meta.product_id) || null,
+        image: entry.photo || (meta && meta.image) || null,
+        status: entry.live ? 'ACTIVE' : (meta && meta.status) || '', ts: entry.ts
+      });
+      saveRecents();
       return send(res, 200, { ok: true, dw_sku, photo: entry.photo, created: entry.created, shopify_pushed: entry.shopify_pushed,
         push_err: entry.push_err, live: entry.live, live_reason: entry.live_reason });
     });
@@ -344,12 +399,12 @@ async function createFromSheet(s, b64) {
     const tags = ['Grasscloth', 'Natural Wallcovering', 'Fentucci', 'TWIL Naturals', 'display_variant'];
     if (col) tags.push('color:' + col);
     if (!active) tags.push('Needs-Cost');
-    const roll = { option1: 'Roll', sku: s.grs, inventory_management: 'shopify', inventory_quantity: 2026 };
+    const roll = { option1: 'Per Yard', sku: s.grs, inventory_management: 'shopify', inventory_quantity: 2026 }; // GRS grasscloth sells per yard
     if (price) roll.price = price;
     const mf = (ns, key, val) => val ? { namespace: ns, key, value: String(val), type: 'single_line_text_field' } : null;
     const metafields = [
       mf('global', 'width', s.width || '36 Inches'), mf('global', 'length', s.length || '8 Yards'),
-      mf('global', 'unit_of_measure', 'Priced Per Single Roll'), mf('global', 'Content', 'Natural Grasscloth'),
+      mf('global', 'unit_of_measure', 'Priced Per Yard'), mf('dwc', 'order_unit', 'Yard'), mf('global', 'Content', 'Natural Grasscloth'),
       mf('global', 'Brand', 'Fentucci'), mf('global', 'Collection', 'TWIL Naturals'),
       mf('global', 'Color-Way', col), mf('custom', 'color', col),
       mf('custom', 'manufacturer_sku', s.mfr), mf('dwc', 'manufacturer_sku', s.mfr),

← 3cbfbc0 All-Shopify search covers collections too: parallel product  Β·  back to Dw Photo Capture  Β·  All GRS- sold PER YARD: flipped 133 products (variant Roll-> a4af440 β†’