← back to Fentucci Naturals
internal line viewer :9981 (fentucci-viewer pm2) — full internal-line-viewer spec: left rail collapsed facet tables w/ counts+type-ahead, card-field toggles to image-only wall, sort+density+text-scale persisted, unauth /healthz before basic-auth, created date+time chip, local-image serving, Memo/Stock/Price vendor-requests; E2E green chromium+webkit(creds-URL)
934b28236496d5b45f3fdb1c38ac29b1d364e881 · 2026-07-22 18:14:01 -0700 · Steve Abrams
Files touched
A lib/vendor-requests.jsA package-lock.jsonA package.jsonA public/index.htmlA server.js
Diff
commit 934b28236496d5b45f3fdb1c38ac29b1d364e881
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 22 18:14:01 2026 -0700
internal line viewer :9981 (fentucci-viewer pm2) — full internal-line-viewer spec: left rail collapsed facet tables w/ counts+type-ahead, card-field toggles to image-only wall, sort+density+text-scale persisted, unauth /healthz before basic-auth, created date+time chip, local-image serving, Memo/Stock/Price vendor-requests; E2E green chromium+webkit(creds-URL)
---
lib/vendor-requests.js | 222 ++++++++++
package-lock.json | 1092 ++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 14 +
public/index.html | 358 ++++++++++++++++
server.js | 186 +++++++++
5 files changed, 1872 insertions(+)
diff --git a/lib/vendor-requests.js b/lib/vendor-requests.js
new file mode 100644
index 0000000..cdf81fe
--- /dev/null
+++ b/lib/vendor-requests.js
@@ -0,0 +1,222 @@
+/* Internal purchasing requests — shared module for every *.designerwallcoverings.com
+ internal vendor PDP (astek, reidwitlin, quadrille, sangetsu, …).
+
+ POST /api/request → create REQ-YYMMDD-<id> in dw_unified.vendor_requests;
+ stock/price requests AUTO-EMAIL the vendor via
+ george-gmail (:9850) when a vendor email is on file.
+ POST /api/request/preview → compose the exact email (no insert, no send).
+ GET /api/requests → recent requests for this vendor (internal queue view).
+
+ Hard rules baked in:
+ - NEVER include a client name — emails reference our account number + REQ only.
+ - Vendor emails go out ONLY on an explicit user click behind Basic Auth; the
+ george external-send guard token is passed because that click IS the approval.
+ - No vendor email on file → request is still logged (status no_email) and the
+ caller is told to phone the vendor instead. */
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const { Pool } = require('pg');
+
+const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
+const FROM = 'steve@designerwallcoverings.com';
+
+function envFrom(file, key) {
+ try { const m = fs.readFileSync(file, 'utf8').match(new RegExp('^' + key + '=(.+)$', 'm')); return m ? m[1].replace(/^["']|["']$/g, '').trim() : ''; }
+ catch { return ''; }
+}
+// Resolve a value from the first source that has it: an explicit process env var,
+// then each candidate .env file. Makes the lib portable across Mac (~/Projects/...)
+// and Kamatera, where george lives at ~/DW-Agents/gmail-agent — the old single
+// hard-coded path returned '' there, so the send token was empty and george
+// rejected every request (status email_failed).
+function firstEnv(key, files) {
+ if (process.env[key]) return process.env[key];
+ for (const f of files) { const v = envFrom(f, key); if (v) return v; }
+ return '';
+}
+const HOME = os.homedir();
+const GEORGE_ENVS = [
+ path.join(HOME, 'Projects/george-gmail/.env'), // Mac dev
+ path.join(HOME, 'DW-Agents/gmail-agent/.env'), // Kamatera prod
+];
+const SEND_TOKEN = firstEnv('GEORGE_EXTERNAL_SEND_TOKEN', GEORGE_ENVS);
+const PGPASS = firstEnv('DW_ADMIN_DB_PASSWORD', [path.join(HOME, 'Projects/secrets-manager/.env')]);
+// george basic auth — mirror george's own resolution: GEORGE_BASIC_AUTH from its
+// .env when set, else the historical fallback (admin + empty password).
+const GEORGE_AUTH = firstEnv('GEORGE_BASIC_AUTH', GEORGE_ENVS) || 'admin:';
+
+let pool = null;
+function db() {
+ if (!pool) pool = new Pool({ host: '127.0.0.1', port: 5432, user: 'dw_admin', database: 'dw_unified', password: PGPASS, max: 3 });
+ return pool;
+}
+
+// Self-bootstrap the table so a fresh DB (Kamatera, mac2, a new sister site)
+// never throws `relation "vendor_requests" does not exist` on the first click.
+// Idempotent (IF NOT EXISTS) and memoized — the DDL runs at most once per process.
+let schemaReady = null;
+function ensureSchema() {
+ if (!schemaReady) {
+ schemaReady = db().query(`
+ CREATE TABLE IF NOT EXISTS vendor_requests (
+ id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ req_no text,
+ req_type text,
+ vendor_code text,
+ vendor_name text,
+ vendor_email text,
+ account_number text,
+ dw_sku text,
+ mfr_sku text,
+ product_title text,
+ qty text,
+ note text,
+ requested_by text,
+ status text,
+ email_subject text,
+ email_body text,
+ email_message_id text,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ sent_at timestamptz
+ );
+ CREATE INDEX IF NOT EXISTS vendor_requests_vendor_code_id_idx
+ ON vendor_requests (vendor_code, id DESC);
+ `).catch(e => { schemaReady = null; console.error('[vendor-requests] ensureSchema failed:', e.message); throw e; });
+ }
+ return schemaReady;
+}
+
+const esc = s => String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c]));
+
+// ---- email composition — polite, account-number-only, client-anonymous ----
+// vendor.buy_from = distributor that carries the line (e.g. Artmura via NewWall):
+// the email greets the distributor; the line name stays in the subject/item.
+function composeEmail(type, vendor, item, reqNo) {
+ const who = vendor.buy_from || vendor.name;
+ const acct = vendor.account_number || null;
+ const ask = type === 'stock'
+ ? 'Could you please check current stock availability on the following item?'
+ : 'Could you please confirm our current net / wholesale cost (and any applicable discount) on the following item?';
+ // HARD RULE (Steve 2026-07-02): outbound emails NEVER mention our internal
+ // (DW) SKU numbers — manufacturer SKU + REQ number only.
+ const subjectHead = type === 'stock' ? 'Stock check' : 'Pricing request';
+ const subjectSku = item.mfr_sku ? ` (${item.mfr_sku})` : '';
+ const subject = `${subjectHead} — ${item.title || item.mfr_sku || 'item inquiry'}${subjectSku}${acct ? ` — Acct #${acct}` : ''} — ${reqNo}`;
+ const rows = [
+ ['Pattern', item.title], ['Item / SKU', item.mfr_sku],
+ ['Quantity', item.qty || (type === 'stock' ? 'to be confirmed' : null)],
+ ['Note', item.note],
+ ].filter(r => r[1]);
+ const body = [
+ `<p>Hello ${esc(who)} team,</p>`,
+ `<p>${ask}</p>`,
+ `<p>${rows.map(r => ` <b>${esc(r[0])}:</b> ${esc(r[1])}`).join('<br>')}</p>`,
+ `<p>This is for Designer Wallcoverings${acct ? `, account #${esc(acct)}` : ''}. Please reference <b>${reqNo}</b> in your reply.</p>`,
+ `<p>Thank you very much — we appreciate your help!</p>`,
+ `<p>Warm regards,<br><br>Designer Wallcoverings — Purchasing<br>${FROM}${acct ? `<br>Account #${esc(acct)}` : ''} · Ref ${reqNo}</p>`,
+ ].join('\n');
+ return { subject, body };
+}
+
+function georgeSend({ to, subject, body }) {
+ return new Promise((resolve) => {
+ const payload = JSON.stringify({ account: 'steve-office', from: FROM, to, subject, body, source: 'vendor-pdp' });
+ const u = new URL(GEORGE + '/api/send');
+ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname, method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload), 'X-Send-Approval': SEND_TOKEN,
+ 'Authorization': 'Basic ' + Buffer.from(GEORGE_AUTH).toString('base64') } }, res => {
+ let d = ''; res.on('data', c => d += c);
+ res.on('end', () => { try { resolve({ status: res.statusCode, ...JSON.parse(d) }); } catch { resolve({ status: res.statusCode, error: d.slice(0, 300) }); } });
+ });
+ req.on('error', e => resolve({ status: 0, error: e.message }));
+ req.setTimeout(15000, () => { req.destroy(); resolve({ status: 0, error: 'george timeout' }); });
+ req.end(payload);
+ });
+}
+
+function reqNoFor(id, createdAt) {
+ const d = new Date(createdAt);
+ const ymd = d.getFullYear().toString().slice(2) + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0');
+ return `REQ-${ymd}-${id}`;
+}
+
+function mountVendorRequests(app, { vendorCode, getVendor, dataDir }) {
+ // warm the schema on mount so the table exists before the first request lands
+ ensureSchema().catch(() => {});
+
+ // exact email preview — same composer the send path uses; nothing persisted
+ app.post('/api/request/preview', (req, res) => {
+ const { type, dw_sku, mfr_sku, title, qty, note } = req.body || {};
+ if (!['stock', 'price'].includes(type)) return res.status(400).json({ ok: false, error: 'preview is for stock/price only' });
+ const vendor = getVendor() || {};
+ const { subject, body } = composeEmail(type, vendor, { dw_sku, mfr_sku, title, qty, note }, 'REQ-(assigned on send)');
+ res.json({ ok: true, to: vendor.email || null, from: FROM, subject, body, no_email: !vendor.email, phone: vendor.phone || null });
+ });
+
+ app.post('/api/request', async (req, res) => {
+ const { dw_sku, mfr_sku, title, qty, note, name } = req.body || {};
+ const type = ['memo', 'stock', 'price'].includes(req.body && req.body.type) ? req.body.type : 'memo';
+ if (!dw_sku && !mfr_sku) return res.status(400).json({ ok: false, error: 'sku required' });
+ const vendor = getVendor() || {};
+ try {
+ await ensureSchema(); // guarantees the table on a cold process (no more "relation does not exist")
+ const ins = await db().query(
+ `INSERT INTO vendor_requests (req_type, vendor_code, vendor_name, vendor_email, account_number,
+ dw_sku, mfr_sku, product_title, qty, note, requested_by, status)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,'open') RETURNING id, created_at`,
+ [type, vendorCode, vendor.name || null, vendor.email || null, vendor.account_number || null,
+ dw_sku || null, mfr_sku || null, title || null, qty || null, note || null, name || FROM]);
+ const { id, created_at } = ins.rows[0];
+ const reqNo = reqNoFor(id, created_at);
+
+ let status = 'open', emailed = false, message, mail = null;
+ if (type === 'memo') {
+ message = `${reqNo} logged — purchasing will order the memo sample.`;
+ } else if (!vendor.email) {
+ status = 'no_email';
+ message = `${reqNo} logged. No vendor email on file — call ${vendor.buy_from || vendor.name || 'the vendor'}${vendor.phone ? ' at ' + vendor.phone : ''}.`;
+ } else {
+ mail = composeEmail(type, vendor, { dw_sku, mfr_sku, title, qty, note }, reqNo);
+ const to = process.env.REQUEST_EMAIL_OVERRIDE || vendor.email; // override = smoke-test only
+ const sent = await georgeSend({ to, ...mail });
+ if (sent.success) {
+ status = 'emailed'; emailed = true;
+ message = `${reqNo} sent to ${vendor.buy_from || vendor.name} (${to}).`;
+ } else {
+ status = 'email_failed';
+ message = `${reqNo} logged, but the email failed (${sent.error || 'status ' + sent.status}) — call ${vendor.phone || 'the vendor'}.`;
+ }
+ await db().query(
+ `UPDATE vendor_requests SET req_no=$1, status=$2, email_subject=$3, email_body=$4,
+ email_message_id=$5, sent_at=CASE WHEN $2='emailed' THEN now() ELSE NULL END WHERE id=$6`,
+ [reqNo, status, mail.subject, mail.body, sent.messageId || null, id]);
+ }
+ if (type === 'memo' || status === 'no_email') {
+ await db().query(`UPDATE vendor_requests SET req_no=$1, status=$2 WHERE id=$3`, [reqNo, status, id]);
+ }
+ // local trail, same file the old inquiry endpoint used
+ try {
+ fs.appendFileSync(path.join(dataDir, 'inquiries.jsonl'),
+ JSON.stringify({ at: new Date().toISOString(), req_no: reqNo, type, status, sku: dw_sku || mfr_sku, qty: qty || '', note: note || '' }) + '\n');
+ } catch {}
+ res.json({ ok: true, req_no: reqNo, status, emailed, message });
+ } catch (e) {
+ console.error('[vendor-requests]', e.message);
+ res.status(500).json({ ok: false, error: 'request failed: ' + e.message });
+ }
+ });
+
+ app.get('/api/requests', async (_req, res) => {
+ try {
+ await ensureSchema();
+ const { rows } = await db().query(
+ `SELECT req_no, req_type, status, dw_sku, mfr_sku, product_title, qty, created_at, sent_at
+ FROM vendor_requests WHERE vendor_code=$1 ORDER BY id DESC LIMIT 50`, [vendorCode]);
+ res.json({ requests: rows });
+ } catch (e) { res.status(500).json({ error: e.message }); }
+ });
+}
+
+module.exports = { mountVendorRequests, composeEmail };
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..8ba071d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1092 @@
+{
+ "name": "fentucci-viewer",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "fentucci-viewer",
+ "version": "1.0.0",
+ "dependencies": {
+ "basic-auth": "^2.0.1",
+ "compression": "^1.7.4",
+ "express": "^4.19.2",
+ "pg": "^8.12.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/accepts/node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/basic-auth": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz",
+ "integrity": "sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.6",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+ "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": ">= 1.43.0 < 2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
+ "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "compressible": "~2.0.18",
+ "debug": "2.6.9",
+ "negotiator": "~0.6.4",
+ "on-headers": "~1.1.0",
+ "safe-buffer": "5.2.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/compression/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-disposition/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types/node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+ "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+ "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.15.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+ "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4530bab
--- /dev/null
+++ b/package.json
@@ -0,0 +1,14 @@
+{
+ "name": "fentucci-viewer",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Fentucci Naturals internal line viewer (basic-auth, tokiwa_catalog, local images)",
+ "main": "server.js",
+ "scripts": { "start": "node server.js" },
+ "dependencies": {
+ "basic-auth": "^2.0.1",
+ "compression": "^1.7.4",
+ "express": "^4.19.2",
+ "pg": "^8.12.0"
+ }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..f5a650a
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,358 @@
+<!doctype html><html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Fentucci Naturals — Internal Line Viewer</title>
+<style>
+ :root{--bg:#0e0f11;--card:#16181c;--panel:#16181c;--line:#2a2d33;--txt:#e8e6e1;--mut:#8b8f96;
+ --accent:#3fb8af;--acc:#3fb8af;--teal:#3fb8af;--rail:290px;--cols:6;--fs:1;--scale:1;}
+ *{box-sizing:border-box}
+ body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--txt)}
+ a{color:var(--accent)}
+ header{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px;flex-wrap:wrap;
+ position:sticky;top:0;background:var(--bg);z-index:10}
+ header h1{font-size:16px;margin:0;letter-spacing:.14em;font-weight:600;text-transform:uppercase;display:flex;align-items:center;gap:8px}
+ header .meta{color:var(--mut);font-size:12px}
+ header input[type=search]{flex:1;min-width:200px;padding:8px 10px;background:var(--panel);border:1px solid var(--line);color:var(--txt);border-radius:8px}
+ .bar{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--mut);letter-spacing:.04em}
+ .bar select{background:var(--panel);border:1px solid var(--line);color:var(--txt);border-radius:7px;padding:5px 7px;font-size:12px}
+ .bar input[type=range]{width:120px}
+ .quote{color:var(--teal);border:1px solid var(--teal);padding:3px 10px;border-radius:12px;font-size:11px;letter-spacing:.1em}
+ .dirbtn{background:var(--panel);border:1px solid var(--line);color:var(--accent);border-radius:7px;padding:5px 9px;cursor:pointer;font-family:inherit}
+ .dirbtn:hover{border-color:var(--accent)}
+ #railToggle{display:none}
+
+ /* ── layout: left rail + content ── */
+ .wrap{display:flex;align-items:flex-start}
+ aside{width:var(--rail);flex:0 0 var(--rail);position:sticky;top:57px;max-height:calc(100vh - 57px);
+ overflow-y:auto;padding:10px 10px 60px;border-right:1px solid var(--line)}
+
+ .active{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px}
+ .afilter{background:#23211a;border:1px solid var(--accent);color:var(--txt);border-radius:20px;padding:3px 9px;font-size:11px;cursor:pointer}
+ .afilter b{color:var(--accent);font-weight:600}
+ .clearall{background:none;border:1px solid var(--line);color:var(--mut);border-radius:20px;padding:3px 11px;font-size:11px;cursor:pointer}
+ .clearall:hover{border-color:var(--accent);color:var(--txt)}
+
+ details.sec{border:1px solid var(--line);border-radius:7px;margin-bottom:7px;background:var(--card)}
+ details.sec>summary{list-style:none;cursor:pointer;user-select:none;padding:8px 10px;font-size:10.5px;
+ letter-spacing:.09em;text-transform:uppercase;color:var(--mut);display:flex;align-items:center;gap:7px}
+ details.sec>summary::-webkit-details-marker{display:none}
+ details.sec>summary::before{content:'▸';color:var(--accent);font-size:10px}
+ details.sec[open]>summary::before{content:'▾'}
+ details.sec>summary .n{margin-left:auto;background:var(--accent);color:#0e0f11;border-radius:20px;padding:0 7px;font-size:10px;font-weight:700}
+ .fbody{padding:2px 8px 9px}
+ .fitem{display:flex;align-items:center;gap:7px;padding:2.5px 2px;cursor:pointer;font-size:12.5px;color:var(--txt);border-radius:5px}
+ .fitem:hover{background:#1d2026}
+ .fitem .box{width:13px;height:13px;flex:0 0 13px;border:1px solid var(--mut);border-radius:3px;display:inline-flex;align-items:center;justify-content:center;font-size:10px}
+ .fitem.on .box{background:var(--accent);border-color:var(--accent);color:#0e0f11;font-weight:700}
+ .fitem.on{color:var(--accent)}
+ .fitem .swatch{width:12px;height:12px;flex:0 0 12px;border-radius:50%;border:1px solid #0006}
+ .fitem .lbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+ .fitem .ct{color:var(--mut);font-size:10.5px}
+ .fitem.zero{opacity:.35}
+ .more{color:var(--accent);font-size:11.5px;cursor:pointer;padding:3px 2px;display:inline-block}
+ .ffind{width:100%;padding:4px 8px;font-size:11.5px;border-radius:6px;margin:2px 0 5px;background:var(--panel);border:1px solid var(--line);color:var(--txt)}
+ .fieldchip{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--mut);border:1px solid var(--line);
+ border-radius:6px;padding:3px 8px;margin:2px 3px 2px 0;cursor:pointer;background:var(--panel)}
+ .fieldchip.on{color:var(--accent);border-color:var(--accent)}
+
+ main{flex:1;min-width:0;padding:14px}
+ .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px}
+ .card{border:1px solid var(--line);border-radius:6px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}
+ .card .imgwrap{aspect-ratio:1;background:#000;overflow:hidden}
+ .card img{width:100%;height:100%;object-fit:cover;display:block}
+ .card .body{padding:calc(8px*var(--fs))}
+ .card .acts{display:flex;gap:4px;padding:0 calc(8px*var(--fs)) calc(8px*var(--fs))}
+ .card .acts .act{flex:1;font:inherit;font-size:calc(10px*var(--fs)*var(--scale));color:var(--mut);background:transparent;border:1px solid var(--line);border-radius:5px;padding:4px 2px;cursor:pointer}
+ .card .acts .act:hover{border-color:var(--acc);color:var(--txt)}
+ .card .acts .act:disabled{opacity:.5;cursor:default}
+ body.imgonly .card .acts{display:none}
+ #toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%) translateY(20px);max-width:min(560px,92vw);
+ background:#1b1e24;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:10px 16px;font-size:13px;
+ box-shadow:0 8px 30px #0008;opacity:0;pointer-events:none;transition:.25s;z-index:50}
+ #toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
+ #toast.ok{border-color:#2a6a4a} #toast.err{border-color:#7a2a2a}
+ .card .nm{font-size:calc(13px*var(--fs)*var(--scale));font-weight:600;line-height:1.25;margin-bottom:3px;color:var(--txt)}
+ .card .sub{font-size:calc(11px*var(--fs)*var(--scale));color:var(--mut)}
+ .card .when{font-size:calc(10px*var(--fs)*var(--scale));color:var(--mut);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .card .dot{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid #0006;vertical-align:middle;margin-right:5px}
+ .card .tags{margin-top:5px;display:flex;flex-wrap:wrap;gap:3px}
+ .card .tag{font-size:calc(9px*var(--fs)*var(--scale));background:#1d2026;color:var(--mut);padding:1px 6px;border-radius:8px}
+ body.imgonly .card .body{display:none}
+ .empty{color:var(--mut);padding:40px;text-align:center}
+
+ @media(max-width:900px){
+ #railToggle{display:inline-block}
+ aside{position:fixed;left:0;top:57px;bottom:0;background:var(--bg);z-index:20;transform:translateX(-100%);transition:transform .2s}
+ aside.open{transform:none;box-shadow:6px 0 30px rgba(0,0,0,.5)}
+ }
+</style></head><body>
+<header>
+ <h1>Fentucci Naturals <span class="quote">QUOTE-ONLY · $4.25 SAMPLE</span></h1>
+ <button class="dirbtn" id="railToggle">☰ Filters</button>
+ <span class="meta" id="meta">loading…</span>
+ <input type="search" id="q" placeholder="Search pattern / sku / color / material / description…">
+ <span class="bar">Sort
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="title">Title A→Z</option>
+ <option value="sku">SKU A→Z</option>
+ <option value="series">Series</option>
+ <option value="color">Color (family)</option>
+ <option value="material">Material</option>
+ <option value="style">Style (tag)</option>
+ </select>
+ <button class="dirbtn" id="dir" title="Flip sort direction">▲</button>
+ </span>
+ <span class="bar">Density <input type="range" id="dens" min="2" max="14" value="6"> <span id="densN"></span></span>
+ <span class="bar">Text <input type="range" id="tscale" min="80" max="150" value="100"></span>
+ <label class="bar"><input type="checkbox" id="imgonly"> Image-only wall</label>
+</header>
+
+<div class="wrap">
+ <aside id="rail">
+ <div class="active" id="activeRow"></div>
+ <details class="sec" data-k="fields"><summary>Card fields<span class="n" id="nFields"></span></summary><div class="fbody" id="fieldsRow"></div></details>
+ <details class="sec" data-k="material"><summary>Material<span class="n" id="nMaterial"></span></summary><div class="fbody" id="matRow"></div></details>
+ <details class="sec" data-k="pattern"><summary>Pattern<span class="n" id="nPattern"></span></summary><div class="fbody" id="patternRow"></div></details>
+ <details class="sec" data-k="series"><summary>Series (mfr)<span class="n" id="nSeries"></span></summary><div class="fbody" id="seriesRow"></div></details>
+ <details class="sec" data-k="category"><summary>Category<span class="n" id="nCategory"></span></summary><div class="fbody" id="catRow"></div></details>
+ <details class="sec" data-k="color"><summary>Color<span class="n" id="nColor"></span></summary><div class="fbody" id="colorRow"></div></details>
+ <details class="sec" data-k="colorname"><summary>Color name<span class="n" id="nColorName"></span></summary><div class="fbody" id="colorNameRow"></div></details>
+ <details class="sec" data-k="tags"><summary>Tags<span class="n" id="nTags"></span></summary><div class="fbody" id="tagsRow"></div></details>
+ <details class="sec" data-k="settlement"><summary>Settlement<span class="n" id="nSettlement"></span></summary><div class="fbody" id="setRow"></div></details>
+ <details class="sec" data-k="status"><summary>Status<span class="n" id="nStatus"></span></summary><div class="fbody" id="statusRow"></div></details>
+ <details class="sec" data-k="price"><summary>Price<span class="n" id="nPrice"></span></summary><div class="fbody" id="priceRow"></div></details>
+ <details class="sec" data-k="image"><summary>Image<span class="n" id="nImage"></span></summary><div class="fbody" id="imgRow"></div></details>
+ </aside>
+ <main>
+ <div class="grid" id="grid"></div>
+ <div class="empty" id="empty" style="display:none">No matches.</div>
+ </main>
+</div>
+<script>
+const API = location.origin;
+let ALL = [], LAST_FACETS = {};
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
+const fmtDate = (d) => d ? new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '';
+
+/* ── persisted multi-select facet state ── */
+const SETS = { materials:'ftc_materials', patterns:'ftc_patterns', series:'ftc_series', categories:'ftc_categories',
+ colors:'ftc_colors', colorNames:'ftc_colorNames', tags:'ftc_tags', settlements:'ftc_settlements',
+ statuses:'ftc_statuses', prices:'ftc_prices', images:'ftc_images' };
+const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k)||'[]')); } catch { return new Set(); } };
+const state = {
+ q: '', sort: localStorage.getItem('ftc_sort')||'newest', dir: localStorage.getItem('ftc_dir')||'asc',
+ materials:loadSet(SETS.materials), patterns:loadSet(SETS.patterns), series:loadSet(SETS.series),
+ categories:loadSet(SETS.categories), colors:loadSet(SETS.colors), colorNames:loadSet(SETS.colorNames),
+ tags:loadSet(SETS.tags), settlements:loadSet(SETS.settlements), statuses:loadSet(SETS.statuses),
+ prices:loadSet(SETS.prices), images:loadSet(SETS.images),
+};
+function persistFacets(){ for(const [k,key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
+const rowUI = {
+ matRow:{expand:false,find:''}, patternRow:{expand:false,find:'',typeahead:true}, seriesRow:{expand:false,find:'',typeahead:true},
+ catRow:{expand:false,find:''}, colorRow:{expand:false,find:''}, colorNameRow:{expand:false,find:'',typeahead:true},
+ tagsRow:{expand:false,find:'',typeahead:true}, setRow:{expand:false,find:''}, statusRow:{expand:false,find:''},
+ priceRow:{expand:false,find:''}, imgRow:{expand:false,find:''},
+};
+
+/* ── card-field visibility toggles (all off = image-only texture wall) ── */
+const FIELDS = [['nm','Pattern'],['sub','Color · SKU'],['dot','Color dot'],['mat','Material'],['tags','Tags'],['when','Date']];
+let fieldOn = (()=>{ try{ return JSON.parse(localStorage.getItem('ftc_fields')); }catch{} return null; })() || {nm:1,sub:1,dot:1,mat:1,tags:1,when:1};
+function renderFieldChips(){
+ const box=document.getElementById('fieldsRow'); box.innerHTML='';
+ for(const [k,label] of FIELDS){
+ const el=document.createElement('span'); el.className='fieldchip'+(fieldOn[k]?' on':'');
+ el.innerHTML=`<span class="box" style="width:11px;height:11px;border:1px solid var(--mut);border-radius:3px;display:inline-flex">${fieldOn[k]?'✓':''}</span>${label}`;
+ el.onclick=()=>{ fieldOn[k]=fieldOn[k]?0:1; localStorage.setItem('ftc_fields',JSON.stringify(fieldOn)); renderFieldChips(); render(); };
+ box.appendChild(el);
+ }
+ document.getElementById('nFields').textContent = Object.values(fieldOn).filter(Boolean).length;
+}
+
+/* ── sort ── */
+const FAMILY_ORDER = ['Red','Orange','Brown','Yellow','Gold','Green','Teal','Blue','Purple','Pink','Beige','Cream','Black','Gray','White','Multi'];
+function sortRows(rows){
+ const str=(x)=>String(x||'');
+ const by={
+ newest:(a,b)=> new Date(b.created_at||0)-new Date(a.created_at||0) || (b.id-a.id),
+ title:(a,b)=> str(a.pattern_name||a.mfr_sku).localeCompare(str(b.pattern_name||b.mfr_sku)),
+ sku:(a,b)=> str(a.dw_sku||a.mfr_sku).localeCompare(str(b.dw_sku||b.mfr_sku)),
+ series:(a,b)=> str(a.pattern_series).localeCompare(str(b.pattern_series)) || str(a.mfr_sku).localeCompare(str(b.mfr_sku)),
+ color:(a,b)=>{ const i=(x)=>{ const c=(x.colors||[])[0]; const k=c?FAMILY_ORDER.indexOf(c):-1; return k<0?99:k; }; return i(a)-i(b) || str(a.mfr_sku).localeCompare(str(b.mfr_sku)); },
+ material:(a,b)=> str(a.material).localeCompare(str(b.material)) || str(a.mfr_sku).localeCompare(str(b.mfr_sku)),
+ style:(a,b)=> str((a.tags_clean||[])[0]||'zz').localeCompare(str((b.tags_clean||[])[0]||'zz')),
+ };
+ const out = rows.slice().sort(by[state.sort]||by.newest);
+ return state.dir==='desc' ? out.reverse() : out;
+}
+
+/* ── data load ── */
+async function load(){
+ const [p,f] = await Promise.all([
+ fetch(API+'/api/products').then(r=>r.json()),
+ fetch(API+'/api/facets').then(r=>r.json()),
+ ]);
+ ALL = p.products;
+ document.getElementById('meta').textContent = p.count+' colorways · '+new Set(ALL.map(x=>x.pattern_name)).size+' patterns';
+ LAST_FACETS = f;
+ renderFieldChips();
+ renderFacets(f);
+ render();
+}
+
+/* ── facet list rows: checkbox + count + type-ahead + cap/expand ── */
+function toggleSet(set,val){ set.has(val)?set.delete(val):set.add(val); applyFilterChange(); }
+function applyFilterChange(){ persistFacets(); renderFacets(LAST_FACETS); render(); }
+function facetList(rowId, items, set, opts={}){
+ const row=document.getElementById(rowId); row.innerHTML='';
+ const ui=rowUI[rowId]||{expand:false,find:''};
+ if(ui.typeahead){
+ const find=document.createElement('input'); find.type='text'; find.className='ffind'; find.placeholder='find…'; find.value=ui.find;
+ find.oninput=()=>{ ui.find=find.value; renderFacets(LAST_FACETS); const f2=document.querySelector(`#${rowId} .ffind`); if(f2){ f2.focus(); f2.setSelectionRange(f2.value.length,f2.value.length); } };
+ row.appendChild(find);
+ }
+ let entries=(items||[]).map(it=>[it.v,it.n,it.dot]);
+ if(opts.order) entries.sort((a,b)=>opts.order.indexOf(a[0])-opts.order.indexOf(b[0]));
+ if(ui.find) entries=entries.filter(([v])=>String(v).toLowerCase().includes(ui.find.toLowerCase()));
+ const CAP=opts.cap||8;
+ let shown=entries, hidden=0;
+ if(!ui.expand && entries.length>CAP){ shown=entries.slice(0,CAP); hidden=entries.length-CAP; }
+ const shownVals=new Set(shown.map(([v])=>v));
+ for(const v of set) if(!shownVals.has(v)){ const e=entries.find(([x])=>x===v); shown.push(e||[v,0,'']); }
+ if(!shown.length){ const s=document.createElement('div'); s.style.cssText='font-size:10px;color:#6a6a72;padding:2px'; s.textContent='none'; row.appendChild(s); return; }
+ shown.forEach(([val,ct,dot])=>{
+ const el=document.createElement('div'); el.className='fitem'+(set.has(val)?' on':'')+(ct?'':' zero');
+ const sw = dot ? `<span class="swatch" style="background:${dot}"></span>` : '';
+ el.innerHTML=`<span class="box">${set.has(val)?'✓':''}</span>${sw}<span class="lbl" title="${esc(val)}">${esc(val)}</span><span class="ct">${(ct||0).toLocaleString()}</span>`;
+ el.onclick=()=>toggleSet(set,val);
+ row.appendChild(el);
+ });
+ if(hidden>0){ const el=document.createElement('span'); el.className='more'; el.textContent=`See ${hidden.toLocaleString()} more ▾`; el.onclick=()=>{ ui.expand=true; renderFacets(LAST_FACETS); }; row.appendChild(el); }
+ else if(ui.expand && entries.length>CAP){ const el=document.createElement('span'); el.className='more'; el.textContent='See less ▴'; el.onclick=()=>{ ui.expand=false; renderFacets(LAST_FACETS); }; row.appendChild(el); }
+}
+
+function renderFacets(f){
+ LAST_FACETS=f;
+ facetList('matRow', f.material, state.materials, {cap:12});
+ facetList('patternRow', f.pattern, state.patterns, {cap:12});
+ facetList('seriesRow', f.series, state.series, {cap:10});
+ facetList('catRow', f.category, state.categories, {cap:12});
+ facetList('colorRow', f.color, state.colors, {order:f.family_order||FAMILY_ORDER, cap:16});
+ facetList('colorNameRow', f.color_name, state.colorNames, {cap:10});
+ facetList('tagsRow', f.tags, state.tags, {cap:14});
+ facetList('setRow', f.settlement, state.settlements, {cap:4});
+ facetList('statusRow', f.status, state.statuses, {cap:4});
+ facetList('priceRow', f.price_band, state.prices, {cap:4});
+ facetList('imgRow', f.image_state, state.images, {cap:4});
+ renderActive();
+}
+
+/* ── active-filter chip row + per-section count badges ── */
+const DIMS = [['Material','materials','nMaterial'],['Pattern','patterns','nPattern'],['Series','series','nSeries'],
+ ['Category','categories','nCategory'],['Color','colors','nColor'],['Color name','colorNames','nColorName'],
+ ['Tag','tags','nTags'],['Settlement','settlements','nSettlement'],['Status','statuses','nStatus'],
+ ['Price','prices','nPrice'],['Image','images','nImage']];
+function renderActive(){
+ const row=document.getElementById('activeRow'); row.innerHTML='';
+ for(const [,k,badgeId] of DIMS){ const b=document.getElementById(badgeId); if(b){ b.textContent=state[k].size||''; b.style.display=state[k].size?'':'none'; } }
+ let n=0;
+ for(const [label,k] of DIMS){
+ state[k].forEach((v)=>{
+ n++;
+ const el=document.createElement('span'); el.className='afilter';
+ el.innerHTML=`<b>${label}:</b> ${esc(v)} ✕`;
+ el.onclick=()=>{ state[k].delete(v); applyFilterChange(); };
+ row.appendChild(el);
+ });
+ }
+ if(n){ const c=document.createElement('button'); c.className='clearall'; c.textContent='✕ Clear filters'; c.onclick=()=>{ DIMS.forEach(([,k])=>state[k].clear()); applyFilterChange(); }; row.appendChild(c); }
+}
+
+/* ── filter predicate ── */
+function matches(p){
+ const q=state.q;
+ if(q){ const hay=[p.pattern_name,p.dw_sku,p.mfr_sku,p.pattern_series,p.color_name,p.material,p.source_category,p.description].filter(Boolean).join(' ').toLowerCase(); if(!hay.includes(q)) return false; }
+ if(state.materials.size && !state.materials.has(p.material)) return false;
+ if(state.patterns.size && !state.patterns.has(p.pattern_name)) return false;
+ if(state.series.size && !state.series.has(p.pattern_series)) return false;
+ if(state.categories.size && !state.categories.has(p.source_category)) return false;
+ if(state.colors.size && !(p.colors||[]).some(c=>state.colors.has(c))) return false;
+ if(state.colorNames.size && !state.colorNames.has(p.color_name)) return false;
+ if(state.tags.size && !(p.tags_clean||[]).some(t=>state.tags.has(t))) return false;
+ if(state.settlements.size && !state.settlements.has(p.settlement_status)) return false;
+ if(state.statuses.size && !state.statuses.has(p.status)) return false;
+ if(state.prices.size && !state.prices.has(p.price_band)) return false;
+ if(state.images.size && !state.images.has(p.image_state)) return false;
+ return true;
+}
+
+function render(){
+ const grid=document.getElementById('grid'); const rows=sortRows(ALL.filter(matches));
+ document.getElementById('empty').style.display=rows.length?'none':'block';
+ grid.innerHTML = rows.map(p=>{
+ const hex=p.color_hex;
+ const tags=(p.tags_clean||[]).slice(0,4);
+ const anyBody = fieldOn.nm||fieldOn.sub||fieldOn.mat||fieldOn.tags||fieldOn.when;
+ const body = anyBody ? `<div class="body">
+ ${fieldOn.nm?`<div class="nm">${esc(p.pattern_name||p.mfr_sku)} ${esc(p.color_name?'· '+p.color_name:'')}</div>`:''}
+ ${fieldOn.sub?`<div class="sub">${fieldOn.dot&&hex?`<span class="dot" style="background:${esc(hex)}"></span>`:''}${esc(p.dw_sku||'')} · ${esc(p.mfr_sku||'')}</div>`:''}
+ ${fieldOn.mat?`<div class="sub">${esc(p.material||'')}${p.source_category?' · '+esc(p.source_category):''}</div>`:''}
+ ${fieldOn.tags&&tags.length?`<div class="tags">${tags.map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>`:''}
+ ${fieldOn.when?`<div class="when" title="${esc(p.created_at||'')}">🕓 ${fmtDate(p.created_at)}</div>`:''}
+ </div>`:'';
+ return `<div class="card" data-sku="${esc(p.dw_sku||'')}" data-mfr="${esc(p.mfr_sku||'')}" data-title="${esc((p.pattern_name||'')+(p.color_name?' '+p.color_name:''))}">
+ <div class="imgwrap"><img loading="lazy" src="${esc(p.image||'')}" alt="${esc(p.pattern_name||'')}"></div>
+ ${body}
+ <div class="acts">
+ <button class="act" data-act="memo" title="Log a memo-sample request">Memo</button>
+ <button class="act" data-act="stock" title="Check stock with the vendor">Stock</button>
+ <button class="act" data-act="price" title="Get current price from the vendor">Price</button>
+ </div></div>`;
+ }).join('');
+}
+
+/* ── Purchasing actions: Memo / Stock / Price → /api/request ── */
+document.getElementById('grid').addEventListener('click', async (e)=>{
+ const btn=e.target.closest('.act'); if(!btn) return;
+ const card=btn.closest('.card'); const type=btn.dataset.act;
+ const payload={ type, dw_sku:card.dataset.sku, mfr_sku:card.dataset.mfr, title:card.dataset.title };
+ const old=btn.textContent; btn.disabled=true; btn.textContent='…';
+ try{
+ const r=await fetch(location.origin+'/api/request',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.json());
+ toast(r.ok ? (r.message||('Logged '+(r.req_no||''))) : (r.error||'request failed'), !!r.ok);
+ }catch(err){ toast('request failed: '+err.message, false); }
+ btn.disabled=false; btn.textContent=old;
+});
+let toastT=null;
+function toast(msg, ok){
+ let t=document.getElementById('toast');
+ if(!t){ t=document.createElement('div'); t.id='toast'; document.body.appendChild(t); }
+ t.textContent=msg; t.className='show '+(ok?'ok':'err');
+ clearTimeout(toastT); toastT=setTimeout(()=>t.className='',6000);
+}
+
+/* ── top-bar wiring ── */
+const qEl=document.getElementById('q');
+qEl.oninput=()=>{ state.q=qEl.value.trim().toLowerCase(); render(); };
+const sortEl=document.getElementById('sort'); sortEl.value=state.sort;
+sortEl.onchange=e=>{ state.sort=e.target.value; localStorage.setItem('ftc_sort',state.sort); render(); };
+const dirBtn=document.getElementById('dir');
+const drawDir=()=>{ dirBtn.textContent = state.dir==='desc'?'▼':'▲'; };
+drawDir();
+dirBtn.onclick=()=>{ state.dir=state.dir==='desc'?'asc':'desc'; localStorage.setItem('ftc_dir',state.dir); drawDir(); render(); };
+const dens=document.getElementById('dens'), densN=document.getElementById('densN');
+dens.value=localStorage.getItem('ftc_cols')||6;
+const setCols=()=>{ document.documentElement.style.setProperty('--cols',dens.value); densN.textContent=dens.value; document.documentElement.style.setProperty('--fs', Math.max(.62,Math.min(1.15, 6/dens.value)).toFixed(2)); };
+setCols();
+dens.oninput=()=>{ setCols(); localStorage.setItem('ftc_cols',dens.value); };
+const tscale=document.getElementById('tscale');
+tscale.value=localStorage.getItem('ftc_tscale')||100;
+document.documentElement.style.setProperty('--scale',(tscale.value/100));
+tscale.oninput=e=>{ document.documentElement.style.setProperty('--scale',(e.target.value/100)); localStorage.setItem('ftc_tscale',e.target.value); };
+const imgonlyEl=document.getElementById('imgonly');
+imgonlyEl.checked = localStorage.getItem('ftc_imgonly')==='1';
+document.body.classList.toggle('imgonly',imgonlyEl.checked);
+imgonlyEl.onchange=e=>{ document.body.classList.toggle('imgonly',e.target.checked); localStorage.setItem('ftc_imgonly',e.target.checked?'1':'0'); };
+document.getElementById('railToggle').onclick=()=>document.getElementById('rail').classList.toggle('open');
+
+load();
+</script></body></html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..8c4b8f0
--- /dev/null
+++ b/server.js
@@ -0,0 +1,186 @@
+// Fentucci Naturals internal line viewer — basic-auth, reads dw_unified.tokiwa_catalog.
+// Port 9981 (Mac2 local). Internal curation surface per the internal-line-viewer spec
+// (crezana-internal / astek-landing reference). QUOTE-ONLY line, product_type Wallcovering.
+// INTERNAL ONLY: mfr_sku (Tokiwa) is visible here — that is fine per the
+// internal-line-viewer precedent (basic-auth admin surface); the real vendor name is
+// NEVER customer-facing anywhere else.
+//
+// Catalog is loaded ONCE into memory from local PG (host=/tmp), each row enriched
+// (color family from color_name/color_hex, tag fold, image path from image_local),
+// re-loaded every 15 min. Images served from the LOCAL ./images dir (images-always-local).
+const express = require('express');
+const basicAuth = require('basic-auth');
+const compression = require('compression');
+const path = require('path');
+
+const PORT = process.env.PORT || 9981;
+const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');
+
+const app = express();
+app.use(compression());
+
+// ---- unauthenticated /healthz FIRST (hard rule: the fleet keepalive must see a
+// plain 200, or it misreads the healthy 401 as dead and restart-loops the process) ----
+app.get('/healthz', (_req, res) => res.status(200).json({ ok: true, service: 'fentucci-viewer', count: ROWS.length }));
+
+// ---- Basic auth (admin / DW2024!) for everything else ----
+app.use((req, res, next) => {
+ const cred = basicAuth(req);
+ if (!cred || cred.name !== AUTH_USER || cred.pass !== AUTH_PASS) {
+ res.set('WWW-Authenticate', 'Basic realm="Fentucci Naturals Internal"');
+ return res.status(401).send('Auth required');
+ }
+ next();
+});
+
+app.use(express.static(path.join(__dirname, 'public')));
+app.use('/images', express.static(path.join(__dirname, 'images'), { maxAge: '1d' }));
+
+// ---- Internal purchasing requests: Memo / Stock / Price (shared module, spec #8).
+// Quote-only line, no vendor email on file → requests log + prompt to call. ----
+app.use(express.json());
+require('./lib/vendor-requests').mountVendorRequests(app, {
+ vendorCode: 'fentucci',
+ getVendor: () => ({ name: 'Fentucci Naturals' }), // no email on file — log + call
+ dataDir: path.join(__dirname, 'data'),
+});
+
+// ─── enrichment (in-memory rows) ─────────────────────────────────────────────
+const FAMILY_ORDER = ['Red', 'Orange', 'Brown', 'Yellow', 'Gold', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Beige', 'Cream', 'Black', 'Gray', 'White', 'Multi'];
+const FAMILY_HEX = {
+ Red: '#c0392b', Orange: '#e67e22', Brown: '#8b5a2b', Yellow: '#f1c40f', Gold: '#c9a227',
+ Green: '#27ae60', Teal: '#1abc9c', Blue: '#2980b9', Purple: '#8e44ad', Pink: '#e84393',
+ Beige: '#d2b48c', Cream: '#f5f0e1', Black: '#1a1a1a', Gray: '#8b8f96', White: '#f5f5f5',
+ Multi: 'linear-gradient(90deg,#c0392b,#27ae60,#2980b9)',
+};
+const NAME_TO_FAMILY = {
+ red: 'Red', crimson: 'Red', maroon: 'Red', burgundy: 'Red', rust: 'Red', coral: 'Red', terracotta: 'Orange',
+ orange: 'Orange', peach: 'Orange', apricot: 'Orange', amber: 'Orange',
+ brown: 'Brown', tan: 'Brown', taupe: 'Brown', chocolate: 'Brown', khaki: 'Brown', mocha: 'Brown', umber: 'Brown', sienna: 'Brown', chestnut: 'Brown', espresso: 'Brown', walnut: 'Brown',
+ yellow: 'Yellow', mustard: 'Yellow', lemon: 'Yellow', straw: 'Yellow',
+ gold: 'Gold', golden: 'Gold', brass: 'Gold', bronze: 'Gold', champagne: 'Gold',
+ green: 'Green', olive: 'Green', sage: 'Green', mint: 'Green', emerald: 'Green', forest: 'Green', moss: 'Green', seagrass: 'Green',
+ teal: 'Teal', turquoise: 'Teal', aqua: 'Teal', cyan: 'Teal',
+ blue: 'Blue', navy: 'Blue', indigo: 'Blue', cobalt: 'Blue', denim: 'Blue', 'sky blue': 'Blue',
+ purple: 'Purple', violet: 'Purple', lavender: 'Purple', plum: 'Purple', mauve: 'Purple', lilac: 'Purple',
+ pink: 'Pink', rose: 'Pink', blush: 'Pink', magenta: 'Pink', fuchsia: 'Pink',
+ beige: 'Beige', sand: 'Beige', ecru: 'Beige', natural: 'Beige', wheat: 'Beige', flax: 'Beige', hemp: 'Beige',
+ cream: 'Cream', ivory: 'Cream', 'off-white': 'Cream', 'off white': 'Cream', vanilla: 'Cream', linen: 'Cream', alabaster: 'Cream', oatmeal: 'Cream',
+ black: 'Black', charcoal: 'Black', ebony: 'Black', onyx: 'Black',
+ gray: 'Gray', grey: 'Gray', silver: 'Gray', slate: 'Gray', graphite: 'Gray', pewter: 'Gray', smoke: 'Gray', ash: 'Gray',
+ white: 'White', snow: 'White', 'pure white': 'White',
+};
+function hexFamily(hex) {
+ if (!hex) return null;
+ const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
+ if (!m) return null;
+ const n = parseInt(m[1], 16);
+ const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+ const light = (mx + mn) / 2 / 255;
+ if (d < 26) {
+ if (light > 0.90) return 'White';
+ if (light > 0.80) return 'Cream';
+ if (light > 0.16) return 'Gray';
+ return 'Black';
+ }
+ let h;
+ if (mx === r) h = ((g - b) / d) % 6;
+ else if (mx === g) h = (b - r) / d + 2;
+ else h = (r - g) / d + 4;
+ h = (h * 60 + 360) % 360;
+ if (h < 15 || h >= 345) return 'Red';
+ if (h < 45) { if (light < 0.42) return 'Brown'; if (light > 0.82 && d < 90) return 'Beige'; return 'Orange'; }
+ if (h < 65) { if (light < 0.42) return 'Brown'; return light > 0.78 ? 'Gold' : 'Yellow'; }
+ if (h < 80) return 'Gold';
+ if (h < 160) return 'Green';
+ if (h < 195) return 'Teal';
+ if (h < 255) return 'Blue';
+ if (h < 290) return 'Purple';
+ if (h < 345) return 'Pink';
+ return 'Multi';
+}
+function rowFamily(r) {
+ // color_name word first (e.g. "Pewter" → Gray), else bucket the hex
+ const words = String(r.color_name || '').toLowerCase().split(/[\s/]+/);
+ for (const w of words) { if (NAME_TO_FAMILY[w]) return NAME_TO_FAMILY[w]; }
+ return hexFamily(r.color_hex) || 'Multi';
+}
+// tags carry plumbing values (dw_sku, display_variant, quotes, brand strings) — keep
+// them OUT of the facet but leave the row's raw tags intact for search/audit.
+const TAG_NOISE = new Set(['display_variant', 'quotes', 'fentucci naturals', 'fentucci naturals collection', 'natural wallcovering', 'natural fiber', 'wallcovering']);
+function cleanTags(r) {
+ return (r.tags || []).filter((t) => {
+ const lo = String(t).toLowerCase();
+ if (TAG_NOISE.has(lo)) return false;
+ if (/^dwfn-/i.test(t)) return false;
+ if (t === r.pattern_name || t === r.color_name || t === r.material) return false;
+ return true;
+ });
+}
+function enrich(r) {
+ r.colors = [rowFamily(r)];
+ r.tags_clean = cleanTags(r);
+ r.image = r.image_local ? '/' + String(r.image_local).replace(/^\/+/, '') : null; // images/<file>.jpg → /images/<file>.jpg
+ r.image_state = r.image ? 'Has image' : 'No image';
+ r.price_band = 'Quote-only · $4.25 sample';
+ return r;
+}
+
+// ---- in-memory catalog, reloaded from local PG every 15 min ----
+let ROWS = [];
+async function load() {
+ const { Pool } = require('pg');
+ const pool = new Pool({ host: process.env.PGHOST || '/tmp', database: 'dw_unified' });
+ const { rows } = await pool.query(`
+ SELECT id, dw_sku, mfr_sku, pattern_series, pattern_name, color_name, color_hex,
+ brand, product_type, material, source_category, image_local, price_mode,
+ sample_price, status, settlement_status, description, tags, specs, created_at, enriched_at
+ FROM tokiwa_catalog
+ ORDER BY id`);
+ await pool.end();
+ ROWS = rows.map(enrich);
+ console.log(`[fentucci] loaded ${ROWS.length} products from tokiwa_catalog`);
+}
+setInterval(() => load().catch((e) => console.error('[fentucci] reload failed', e.message)), 15 * 60 * 1000);
+
+// Facet counts — {v,n[,dot]} lists, count desc (color family-ordered w/ swatch).
+function facets() {
+ const scalar = (col) => {
+ const m = new Map();
+ for (const r of ROWS) { const v = r[col]; if (v == null || v === '') continue; m.set(v, (m.get(v) || 0) + 1); }
+ return [...m.entries()].sort((a, b) => b[1] - a[1]).map(([v, n]) => ({ v, n }));
+ };
+ const listCol = (col, opts = {}) => {
+ const m = new Map();
+ for (const r of ROWS) { for (const val of (r[col] || [])) { if (val == null || val === '') continue; m.set(val, (m.get(val) || 0) + 1); } }
+ let e = [...m.entries()];
+ if (opts.order) e.sort((a, b) => opts.order.indexOf(a[0]) - opts.order.indexOf(b[0]));
+ else e.sort((a, b) => b[1] - a[1]);
+ return e.map(([v, n]) => ({ v, n, ...(opts.dot ? { dot: FAMILY_HEX[v] || '' } : {}) }));
+ };
+ return {
+ material: scalar('material'),
+ pattern: scalar('pattern_name'),
+ series: scalar('pattern_series'),
+ category: scalar('source_category'),
+ color: listCol('colors', { order: FAMILY_ORDER, dot: true }),
+ color_name: scalar('color_name'),
+ tags: listCol('tags_clean'),
+ settlement: scalar('settlement_status'),
+ status: scalar('status'),
+ price_band: [{ v: 'Quote-only · $4.25 sample', n: ROWS.length }],
+ image_state: scalar('image_state'),
+ family_order: FAMILY_ORDER,
+ };
+}
+
+app.get('/api/products', (_req, res) => res.json({ count: ROWS.length, products: ROWS }));
+app.get('/api/facets', (req, res) => {
+ try { res.json(facets()); } catch (e) { res.status(500).json({ error: e.message }); }
+});
+app.get('/api/health', (_req, res) => res.json({ ok: true, service: 'fentucci-viewer', count: ROWS.length }));
+
+load().then(() => {
+ app.listen(PORT, () => console.log(`fentucci-viewer on :${PORT}`));
+}).catch((e) => { console.error('[fentucci] load failed', e); process.exit(1); });
← 73c3434 gated importer scaffold (create-drafts.py, resumable, day-ca
·
back to Fentucci Naturals
·
add fentucci-scraper-manager skill (in-repo copy; live at ~/ ba65537 →