β 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
M data/build.jsonA data/favorites.jsonA data/recents.jsonM public/index.htmlM server.js
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 #</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 β