← back to Dw Collections Viewer
add inline .env loader + Shopify pagination + all-products page
0b73541a6fcdccca729c833297cceb3bcdde0f21 · 2026-05-06 10:54:02 -0700 · Steve Abrams
Files touched
M public/products.htmlM server.js
Diff
commit 0b73541a6fcdccca729c833297cceb3bcdde0f21
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 6 10:54:02 2026 -0700
add inline .env loader + Shopify pagination + all-products page
---
public/products.html | 28 +++++++++++++++-------
server.js | 67 +++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 78 insertions(+), 17 deletions(-)
diff --git a/public/products.html b/public/products.html
index 07bddcf..b5a1dd0 100644
--- a/public/products.html
+++ b/public/products.html
@@ -131,9 +131,14 @@ async function load() {
fetch('/api/admin/collections').then(async x => {
if (x.ok) {
const c = await x.json(); COLLECTIONS = c.collections || [];
- $('admin-pill').textContent = `admin: ${COLLECTIONS.length} collections`; $('admin-pill').classList.add('live');
- // Populate the collection filter dropdown
- $('coll-filter').innerHTML = '<option value="">all collections</option>' + COLLECTIONS.map(c => `<option value="${c.id}">${esc(c.title)} (${c.products_count || 0})</option>`).join('');
+ $('admin-pill').textContent = `admin: ${c.custom||0} custom + ${c.smart||0} smart`; $('admin-pill').classList.add('live');
+ // Group filter dropdown by type. Custom listed first (writable). Smart marked "auto" (read-only filter).
+ const custom = COLLECTIONS.filter(c => c.type === 'custom');
+ const smart = COLLECTIONS.filter(c => c.type === 'smart');
+ let html = '<option value="">all collections</option>';
+ if (custom.length) html += '<optgroup label="Custom (manual)">' + custom.map(c => `<option value="${c.id}" data-type="custom">${esc(c.title)} (${c.products_count || 0})</option>`).join('') + '</optgroup>';
+ if (smart.length) html += '<optgroup label="Smart (auto-rules)">' + smart.map(c => `<option value="${c.id}" data-type="smart">${esc(c.title)} (${c.products_count || 0}) ⚙</option>`).join('') + '</optgroup>';
+ $('coll-filter').innerHTML = html;
}
else { $('admin-pill').textContent = 'admin token missing'; $('admin-pill').classList.add('warn'); }
}).catch(() => { $('admin-pill').textContent = 'admin: offline'; $('admin-pill').classList.add('warn'); });
@@ -211,14 +216,16 @@ $('batch-clear').addEventListener('click', () => { SELECTED.clear(); render(); }
$('batch-add').addEventListener('click', () => {
if (!SELECTED.size) return;
$('add-count').textContent = SELECTED.size;
- if (!COLLECTIONS.length) {
+ // Add-to: only custom collections are writable. Smart collections auto-include via rules.
+ const writable = COLLECTIONS.filter(c => c.type === 'custom');
+ if (!writable.length) {
$('add-notice').style.display = 'block';
- $('add-notice').textContent = 'No admin collections loaded. Set SHOPIFY_ADMIN_TOKEN in dw-collections-viewer .env and restart.';
- $('coll-pick').innerHTML = '<option>(no admin access)</option>';
+ $('add-notice').textContent = 'No custom (writable) collections found. Smart collections are auto-rule-based — products land in them automatically based on tags/title.';
+ $('coll-pick').innerHTML = '<option>(no writable collections)</option>';
$('add-confirm').disabled = true;
} else {
$('add-notice').style.display = 'none';
- $('coll-pick').innerHTML = COLLECTIONS.map(c => `<option value="${c.id}">${esc(c.title)} (${c.products_count || 0} items)</option>`).join('');
+ $('coll-pick').innerHTML = writable.map(c => `<option value="${c.id}">${esc(c.title)} (${c.products_count || 0} items)</option>`).join('');
$('add-confirm').disabled = false;
}
$('add-modal').classList.add('show');
@@ -226,8 +233,13 @@ $('batch-add').addEventListener('click', () => {
$('add-cancel').addEventListener('click', () => $('add-modal').classList.remove('show'));
$('batch-remove').addEventListener('click', async () => {
if (!SELECTED.size) return;
- const cid = $('coll-filter').value;
+ const sel = $('coll-filter');
+ const cid = sel.value;
if (!cid) return alert('Pick a collection in the filter dropdown first — the "Remove" action removes from that collection.');
+ // Smart collections aren't manually editable; rules drive membership.
+ if (sel.options[sel.selectedIndex]?.dataset.type === 'smart') {
+ return alert("Smart collections are auto-rule-based — Shopify won't let us remove products manually. Edit the collection's tag/title rules to change membership.");
+ }
if (!confirm(`Remove ${SELECTED.size} products from this collection? Products themselves are not deleted.`)) return;
try {
const r = await fetch('/api/products/remove-from-collection', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ product_ids:[...SELECTED], collection_id: parseInt(cid,10) }) });
diff --git a/server.js b/server.js
index 47c2517..980e43b 100644
--- a/server.js
+++ b/server.js
@@ -1,6 +1,19 @@
// DW Collections Viewer — local-only tool for managing which Shopify collections feed each DW family site.
// Browse all 49 sites with hero thumbnails, attach Shopify collections to a site, regenerate products.json.
+// Load local .env (no dotenv dep — minimal inline parser)
+(() => {
+ const _fs = require('fs'), _p = require('path');
+ const envFile = _p.join(__dirname, '.env');
+ if (!_fs.existsSync(envFile)) return;
+ for (const line of _fs.readFileSync(envFile, 'utf8').split('\n')) {
+ const m = line.match(/^\s*(?:export\s+)?([A-Z_][A-Z0-9_]*)\s*=\s*(.*?)\s*$/);
+ if (!m) continue;
+ let v = m[2];
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
+ if (v && !process.env[m[1]]) process.env[m[1]] = v;
+ }
+})();
const express = require('express');
const fs = require('fs');
const fsp = require('fs').promises;
@@ -438,11 +451,28 @@ app.get('/api/products/all', async (req, res) => {
async function shopifyAdmin(method, path, body) {
if (!SHOPIFY_ADMIN_TOKEN) throw new Error('SHOPIFY_ADMIN_TOKEN not set in env');
- const url = `https://${SHOPIFY_STORE}/admin/api/2024-10${path}`;
+ const url = path.startsWith('https://') ? path : `https://${SHOPIFY_STORE}/admin/api/2024-10${path}`;
const r = await fetch(url, { method, headers: { 'X-Shopify-Access-Token': SHOPIFY_ADMIN_TOKEN, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
const text = await r.text();
- if (!r.ok) throw new Error(`shopify ${method} ${path} ${r.status}: ${text.slice(0, 200)}`);
- return text ? JSON.parse(text) : {};
+ if (!r.ok) throw new Error(`shopify ${method} ${path.slice(0,100)} ${r.status}: ${text.slice(0, 200)}`);
+ const link = r.headers.get('link') || '';
+ const nextMatch = link.match(/<([^>]+)>;\s*rel="next"/);
+ const result = text ? JSON.parse(text) : {};
+ result._next = nextMatch ? nextMatch[1] : null;
+ return result;
+}
+// Cursor-paginate a Shopify list endpoint (max pages safety cap = 10).
+async function shopifyAdminAll(initialPath, listKey, maxPages = 10) {
+ let all = [];
+ let url = initialPath;
+ for (let i = 0; i < maxPages; i++) {
+ const r = await shopifyAdmin('GET', url);
+ const items = r[listKey] || [];
+ all = all.concat(items);
+ if (!r._next) break;
+ url = r._next;
+ }
+ return all;
}
app.post('/api/products/delete', async (req, res) => {
@@ -458,10 +488,20 @@ app.post('/api/products/delete', async (req, res) => {
res.json({ count: ids.length, ok: results.filter(r => r.ok).length, results });
});
+// All collections (custom + smart) with cursor-based pagination. 5-min cache.
+let _collCache = { at: 0, data: null };
app.get('/api/admin/collections', async (req, res) => {
+ if (_collCache.data && (Date.now() - _collCache.at) < 5 * 60_000) return res.json(_collCache.data);
try {
- const r = await shopifyAdmin('GET', '/custom_collections.json?limit=250');
- res.json({ collections: r.custom_collections || [] });
+ const customs = await shopifyAdminAll('/custom_collections.json?limit=250', 'custom_collections', 5);
+ const smarts = await shopifyAdminAll('/smart_collections.json?limit=250', 'smart_collections', 5);
+ const out = [
+ ...customs.map(c => ({ id: c.id, title: c.title, handle: c.handle, products_count: c.products_count, type: 'custom' })),
+ ...smarts.map(c => ({ id: c.id, title: c.title, handle: c.handle, products_count: c.products_count, type: 'smart' })),
+ ].sort((a, b) => a.title.localeCompare(b.title));
+ const data = { collections: out, custom: customs.length, smart: smarts.length };
+ _collCache = { at: Date.now(), data };
+ res.json(data);
} catch (e) { res.status(500).json({ error: e.message }); }
});
@@ -486,10 +526,19 @@ app.get('/api/admin/collection/:id/products', async (req, res) => {
try {
const { id } = req.params;
if (!/^\d+$/.test(id)) return res.status(400).json({ error: 'numeric id required' });
- // First get the collection's handle so we can use the public endpoint
- const c = await shopifyAdmin('GET', `/custom_collections/${id}.json`);
- const handle = c.custom_collection?.handle;
- if (!handle) return res.status(404).json({ error: 'collection not found' });
+ // Try custom_collections first, fall back to smart_collections
+ let handle = null;
+ try {
+ const c = await shopifyAdmin('GET', `/custom_collections/${id}.json`);
+ handle = c.custom_collection?.handle;
+ } catch {}
+ if (!handle) {
+ try {
+ const s = await shopifyAdmin('GET', `/smart_collections/${id}.json`);
+ handle = s.smart_collection?.handle;
+ } catch {}
+ }
+ if (!handle) return res.status(404).json({ error: 'collection not found (custom or smart)' });
const products = await fetchCollectionProducts(handle, 1000);
res.json({ collection_id: id, handle, count: products.length, ids: products.map(p => p.id) });
} catch (e) { res.status(500).json({ error: e.message }); }
← 9a73dc0 initial scaffold
·
back to Dw Collections Viewer
·
add Save & Deploy combined button (one-click save + render + 9c53f65 →