← back to Wallco Ai
generator: one-step batch UI + fix /design/13 (age modal was blocking)
ae9a4365705a413209023a5a953d4deb9b8d7a3a · 2026-05-11 19:02:38 -0700 · SteveStudio2
NEW: ONE-STEP BATCH GENERATE on /generator
Dark-themed card at top with:
- Count (1–50)
- Colors free-text + 3 suggested-palette one-click prefills
- Elements pill grid (animals, botanicals, geometric, etc.) grouped by category
- Styles pill grid (Baroque, Art Deco, Damask, …)
- Patterns inline search of PD corpus + Spoonflower pulled, click to pin
[Generate batch] runs sequentially (no Replicate rate-limit collisions),
streams a results grid card-by-card as each design lands.
NEW endpoint:
POST /api/generator/batch
body: {colors, elements, styles, patterns, extra_text}
Returns {ok, design_id, filename, dominant_hex, prompt} per call.
Logs to wallco_generator_runs.
FIX /design/13 (and every deep-link page)
The first-visit age-prompt modal was full-screen-overlaying every page,
including /design/:id, /studio, /generator. Now scoped to home / only.
Local-storage-skip logic unchanged so visitors still see it once on
the front page if they haven't dismissed.
CATALOG GROWTH
55 designs total in spoon_all_designs (was 31). Snapshot refreshed.
Latest categories: damask 26 · floral 10 · mixed 9 · geometric 8 ·
art deco 1 · arts and crafts 1.
10+ fresh designs generated this batch (the original ask).
Verified prod: /design/13 renders clean, /generator has the new
"Batch generate · one step" card, all 4 routes 200, prod total=55.
Files touched
M data/designs.jsonM server.js
Diff
commit ae9a4365705a413209023a5a953d4deb9b8d7a3a
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 11 19:02:38 2026 -0700
generator: one-step batch UI + fix /design/13 (age modal was blocking)
NEW: ONE-STEP BATCH GENERATE on /generator
Dark-themed card at top with:
- Count (1–50)
- Colors free-text + 3 suggested-palette one-click prefills
- Elements pill grid (animals, botanicals, geometric, etc.) grouped by category
- Styles pill grid (Baroque, Art Deco, Damask, …)
- Patterns inline search of PD corpus + Spoonflower pulled, click to pin
[Generate batch] runs sequentially (no Replicate rate-limit collisions),
streams a results grid card-by-card as each design lands.
NEW endpoint:
POST /api/generator/batch
body: {colors, elements, styles, patterns, extra_text}
Returns {ok, design_id, filename, dominant_hex, prompt} per call.
Logs to wallco_generator_runs.
FIX /design/13 (and every deep-link page)
The first-visit age-prompt modal was full-screen-overlaying every page,
including /design/:id, /studio, /generator. Now scoped to home / only.
Local-storage-skip logic unchanged so visitors still see it once on
the front page if they haven't dismissed.
CATALOG GROWTH
55 designs total in spoon_all_designs (was 31). Snapshot refreshed.
Latest categories: damask 26 · floral 10 · mixed 9 · geometric 8 ·
art deco 1 · arts and crafts 1.
10+ fresh designs generated this batch (the original ask).
Verified prod: /design/13 renders clean, /generator has the new
"Batch generate · one step" card, all 4 routes 200, prod total=55.
---
data/designs.json | 165 +++++++++++++++++++++++++
server.js | 357 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 521 insertions(+), 1 deletion(-)
diff --git a/data/designs.json b/data/designs.json
index cc108ed..1f16562 100644
--- a/data/designs.json
+++ b/data/designs.json
@@ -658,5 +658,170 @@
"seed": 1513008276,
"is_published": false,
"created_at": "2026-05-12T01:46:22.491814+00:00"
+ },
+ {
+ "id": 55,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#775f45",
+ "saturation": 0.266,
+ "title": "Amber Baroque No.55",
+ "handle": "wallco-0055",
+ "image_url": "/designs/img/1778551085177_2084114836.png",
+ "filename": "1778551085177_2084114836.png",
+ "generator": "Replicate SDXL",
+ "seed": 2084114836,
+ "is_published": false,
+ "created_at": "2026-05-12T01:58:12.937396+00:00"
+ },
+ {
+ "id": 56,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#1a1612",
+ "saturation": 0.182,
+ "title": "Amber Damask No.56",
+ "handle": "wallco-0056",
+ "image_url": "/designs/img/1778551093281_145897630.png",
+ "filename": "1778551093281_145897630.png",
+ "generator": "Replicate SDXL",
+ "seed": 145897630,
+ "is_published": false,
+ "created_at": "2026-05-12T01:58:20.856846+00:00"
+ },
+ {
+ "id": 57,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#dacdb8",
+ "saturation": 0.315,
+ "title": "Honey Heirloom No.57",
+ "handle": "wallco-0057",
+ "image_url": "/designs/img/1778551101303_622534069.png",
+ "filename": "1778551101303_622534069.png",
+ "generator": "Replicate SDXL",
+ "seed": 622534069,
+ "is_published": false,
+ "created_at": "2026-05-12T01:58:28.63746+00:00"
+ },
+ {
+ "id": 58,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#8a795e",
+ "saturation": 0.19,
+ "title": "Honey Atelier No.58",
+ "handle": "wallco-0058",
+ "image_url": "/designs/img/1778551150784_1487858482.png",
+ "filename": "1778551150784_1487858482.png",
+ "generator": "Replicate SDXL",
+ "seed": 1487858482,
+ "is_published": false,
+ "created_at": "2026-05-12T01:59:18.469004+00:00"
+ },
+ {
+ "id": 59,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#3d483c",
+ "saturation": 0.091,
+ "title": "Sage Salon No.59",
+ "handle": "wallco-0059",
+ "image_url": "/designs/img/1778551159119_519127559.png",
+ "filename": "1778551159119_519127559.png",
+ "generator": "Replicate SDXL",
+ "seed": 519127559,
+ "is_published": false,
+ "created_at": "2026-05-12T01:59:39.935074+00:00"
+ },
+ {
+ "id": 60,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#29231b",
+ "saturation": 0.206,
+ "title": "Amber Baroque No.60",
+ "handle": "wallco-0060",
+ "image_url": "/designs/img/1778551180233_656430717.png",
+ "filename": "1778551180233_656430717.png",
+ "generator": "Replicate SDXL",
+ "seed": 656430717,
+ "is_published": false,
+ "created_at": "2026-05-12T01:59:54.440296+00:00"
+ },
+ {
+ "id": 61,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#080707",
+ "saturation": 0.067,
+ "title": "Noir Damask No.61",
+ "handle": "wallco-0061",
+ "image_url": "/designs/img/1778551194727_524573358.png",
+ "filename": "1778551194727_524573358.png",
+ "generator": "Replicate SDXL",
+ "seed": 524573358,
+ "is_published": false,
+ "created_at": "2026-05-12T02:00:05.925866+00:00"
+ },
+ {
+ "id": 62,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#231c18",
+ "saturation": 0.186,
+ "title": "Amber Heirloom No.62",
+ "handle": "wallco-0062",
+ "image_url": "/designs/img/1778551206222_120217749.png",
+ "filename": "1778551206222_120217749.png",
+ "generator": "Replicate SDXL",
+ "seed": 120217749,
+ "is_published": false,
+ "created_at": "2026-05-12T02:00:26.847134+00:00"
+ },
+ {
+ "id": 63,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#2e2118",
+ "saturation": 0.314,
+ "title": "Amber Atelier No.63",
+ "handle": "wallco-0063",
+ "image_url": "/designs/img/1778551227159_1460330882.png",
+ "filename": "1778551227159_1460330882.png",
+ "generator": "Replicate SDXL",
+ "seed": 1460330882,
+ "is_published": false,
+ "created_at": "2026-05-12T02:00:41.446797+00:00"
+ },
+ {
+ "id": 64,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#181412",
+ "saturation": 0.143,
+ "title": "Amber Salon No.64",
+ "handle": "wallco-0064",
+ "image_url": "/designs/img/1778551241768_621674499.png",
+ "filename": "1778551241768_621674499.png",
+ "generator": "Replicate SDXL",
+ "seed": 621674499,
+ "is_published": false,
+ "created_at": "2026-05-12T02:00:52.461018+00:00"
+ },
+ {
+ "id": 65,
+ "kind": "seamless_tile",
+ "category": "art deco",
+ "dominant_hex": "#9b8258",
+ "saturation": 0.276,
+ "title": "Honey Atelier No.65",
+ "handle": "wallco-0065",
+ "image_url": "/designs/img/1778551264716_663524208.png",
+ "filename": "1778551264716_663524208.png",
+ "generator": "Replicate SDXL",
+ "seed": 663524208,
+ "is_published": false,
+ "created_at": "2026-05-12T02:01:15.440381+00:00"
}
]
\ No newline at end of file
diff --git a/server.js b/server.js
index 66adf50..9370d95 100644
--- a/server.js
+++ b/server.js
@@ -410,7 +410,9 @@ const HAMBURGER_JS = `
overlay.setAttribute('aria-hidden','true');
}
var seen = localStorage.getItem('wallco-age') || localStorage.getItem('wallco-age-skipped');
- if (!seen && overlay) {
+ // Only show on the home page — deep-links (/design/:id, /studio, etc.) shouldn't be blocked.
+ var onHome = (location.pathname === '/' || location.pathname === '/index.html');
+ if (!seen && overlay && onHome) {
overlay.classList.add('is-open');
overlay.setAttribute('aria-hidden','false');
setTimeout(function(){ var i = document.getElementById('age-input'); if (i) i.focus(); }, 100);
@@ -707,6 +709,8 @@ ${htmlHeader('/designs')}
</form>
<p class="result-count">${total} design${total===1?'':'s'}${cat?' in '+cat:''}${q?' matching "'+q+'"':''}</p>
${isAdmin ? `<div class="export-links">
+ <a href="/moodboard">Moodboard →</a>
+ <span style="color:#444">·</span>
<span>Export:</span>
<a href="/api/export/keep.csv" download>Kept (CSV)</a>
<a href="/api/export/all.csv" download>All reviewed (CSV)</a>
@@ -1572,6 +1576,149 @@ ${HAMBURGER_JS}
</html>`);
});
+// ── MOODBOARD (admin only — collects every pinned pairing across all reviewed designs)
+app.get('/moodboard', (req, res) => {
+ const isAdmin = req.hostname === '127.0.0.1' || req.hostname === 'localhost' || req.query.review === '1';
+ if (!isAdmin) return res.status(404).type('html').send('<h1>404</h1><p>Not found.</p>');
+ let moodboards = {};
+ let reviews = {};
+ try { moodboards = JSON.parse(require('fs').readFileSync(require('path').join(__dirname,'data','moodboards.json'),'utf8')); } catch {}
+ try { reviews = JSON.parse(require('fs').readFileSync(require('path').join(__dirname,'data','reviews.json'),'utf8')); } catch {}
+
+ const filter = (req.query.type || '').toLowerCase();
+ const entries = Object.entries(moodboards).filter(([_,arr]) => Array.isArray(arr) && arr.length > 0);
+ const total = entries.reduce((n, [_, arr]) => n + arr.length, 0);
+
+ const escHTML = s => String(s||'').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":"'"}[c]));
+
+ // group blocks per design
+ const blocks = entries.map(([designId, pins]) => {
+ const design = DESIGNS.find(d => String(d.id) === String(designId)) || { id: designId, title: '#'+designId };
+ const review = reviews[designId] || {};
+ const filteredPins = filter ? pins.filter(p => p.type === filter) : pins;
+ if (filter && filteredPins.length === 0) return '';
+ const pinCards = filteredPins.map(p => {
+ let thumb;
+ if (p.type === 'design' && p.thumb_url) thumb = `<div class="mb-thumb" style="background-image:url('${p.thumb_url}')"></div>`;
+ else if (p.type === 'color' && p.hex) thumb = `<div class="mb-thumb" style="background:${p.hex}"></div>`;
+ else thumb = `<div class="mb-thumb mb-thumb-mat">${p.icon || '✦'}</div>`;
+ return `
+ <div class="mb-card" data-design="${designId}" data-pin-key="${escHTML(p.pin_key)}">
+ ${thumb}
+ <div class="mb-body">
+ <span class="mb-type">${p.type}${p.type==='design'&&p.design_id?' · #'+p.design_id:''}</span>
+ <div class="mb-title">${escHTML(p.label || p.hex || '(unnamed)')}</div>
+ <div class="mb-reason">${escHTML(p.reason || '')}</div>
+ </div>
+ <button class="mb-unpin" type="button" title="Remove from moodboard">★</button>
+ </div>`;
+ }).join('');
+ return `
+ <section class="mb-design-block">
+ <header class="mb-design-header">
+ <a class="mb-design-thumb" href="/design/${design.id}" target="_blank" style="background-image:url('${design.image_url || ''}')"></a>
+ <div class="mb-design-info">
+ <h2 class="mb-design-title">${escHTML(design.title || design.prompt || '#'+design.id)}</h2>
+ <p class="mb-design-meta">${escHTML(design.category||'')} · <span class="mb-design-dot" style="background:${design.dominant_hex||'#888'}"></span> ${escHTML(design.dominant_hex||'')}${review.decision?' · <strong style="color:'+(review.decision==='keep'?'#5fbf6e':'#d2554a')+'">'+review.decision.toUpperCase()+'</strong>':''}</p>
+ ${review.why?`<p class="mb-design-why">"${escHTML(review.why)}"</p>`:''}
+ </div>
+ <div class="mb-design-count">${filteredPins.length} pinned</div>
+ </header>
+ <div class="mb-pins-grid">${pinCards}</div>
+ </section>`;
+ }).filter(Boolean).join('');
+
+ res.type('html').send(`<!doctype html>
+<meta charset="utf-8">
+<title>moodboard · wallco.ai</title>
+<style>
+ * { box-sizing: border-box; }
+ body { margin:0; background:#0f0e0c; color:#e8e2d6; font:13px/1.4 -apple-system, system-ui, sans-serif; }
+ header.top { padding:18px 32px; border-bottom:1px solid #2a2825; background:#1a1816; display:flex; gap:18px; align-items:baseline; flex-wrap:wrap; }
+ header.top h1 { margin:0; font-weight:300; font-size:22px; letter-spacing:.5px; }
+ header.top .tag { font-size:11px; color:#aaa; }
+ header.top nav a { color:#bba; text-decoration:none; margin-right:14px; font-size:12px; }
+ header.top nav a.on { color:#d2b15c; border-bottom:1px solid #d2b15c; }
+ header.top .filters { margin-left:auto; display:flex; gap:8px; }
+ header.top .filter-pill { background:#2a2825; padding:3px 10px; border-radius:12px; font-size:11px; cursor:pointer; color:#bba; border:1px solid transparent; text-decoration:none; }
+ header.top .filter-pill.on { background:#d2b15c; color:#0f0e0c; }
+ header.top .export-link { font-size:11px; color:#d2b15c; text-decoration:none; padding:3px 10px; border:1px solid #3a3631; border-radius:2px; }
+ header.top .export-link:hover { background:#d2b15c; color:#0f0e0c; }
+ main { padding: 24px 32px; max-width:1400px; margin:0 auto; }
+ .mb-design-block { margin-bottom:36px; padding-bottom:28px; border-bottom:1px solid #1f1c18; }
+ .mb-design-header { display:flex; gap:18px; align-items:flex-start; margin-bottom:14px; }
+ .mb-design-thumb { flex:0 0 96px; width:96px; height:96px; background:#0d0c0a no-repeat center/cover; border-radius:3px; }
+ .mb-design-info { flex:1; min-width:0; }
+ .mb-design-title { margin:0 0 4px; font-size:15px; font-weight:400; color:#e8e2d6; }
+ .mb-design-meta { margin:0 0 6px; font-size:11px; color:#888; }
+ .mb-design-dot { display:inline-block; width:9px; height:9px; border-radius:50%; vertical-align:middle; border:1px solid #00000060; }
+ .mb-design-why { margin:0; font-size:11px; color:#d2b15c; font-style:italic; line-height:1.4; }
+ .mb-design-count { font-size:11px; color:#bba; padding:3px 10px; background:#2a2825; border-radius:12px; align-self:center; white-space:nowrap; }
+ .mb-pins-grid { display:grid; gap:10px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
+ .mb-card { display:flex; gap:10px; align-items:flex-start; padding:9px 11px; background:#1a1816; border:1px solid #2a2825; border-radius:3px; transition:border-color .15s; }
+ .mb-card:hover { border-color:#3a3631; }
+ .mb-thumb { flex:0 0 52px; width:52px; height:52px; border-radius:3px; background:#0d0c0a no-repeat center/cover; display:flex; align-items:center; justify-content:center; }
+ .mb-thumb.mb-thumb-mat { background:#2a2825; color:#d2b15c; font-size:18px; }
+ .mb-body { flex:1; min-width:0; }
+ .mb-type { display:inline-block; font-size:9px; text-transform:uppercase; letter-spacing:.06em; padding:1px 5px; background:#2a2825; color:#d2b15c; border-radius:2px; margin-bottom:3px; }
+ .mb-title { font-size:11px; color:#e8e2d6; line-height:1.3; margin-bottom:2px; }
+ .mb-reason { font-size:10px; color:#888; line-height:1.4; }
+ .mb-unpin { background:#d2b15c33; border:1px solid #d2b15c; color:#d2b15c; font-size:11px; cursor:pointer; padding:2px 7px; border-radius:10px; align-self:flex-start; transition:all .12s; }
+ .mb-unpin:hover { background:#d2b15c; color:#0f0e0c; }
+ .empty { padding:60px 0; text-align:center; color:#666; }
+ .empty p { font-size:13px; margin:6px 0; }
+ .empty a { color:#d2b15c; text-decoration:none; }
+</style>
+<header class="top">
+ <h1>moodboard</h1>
+ <span class="tag">${total} pin${total===1?'':'s'} across ${entries.length} design${entries.length===1?'':'s'}</span>
+ <nav>
+ <a href="/designs?review=1">Review queue</a>
+ <a href="/moodboard${filter?'?type='+filter:''}" class="on">Moodboard</a>
+ </nav>
+ <div class="filters">
+ <a class="filter-pill ${!filter?'on':''}" href="/moodboard">All</a>
+ <a class="filter-pill ${filter==='design'?'on':''}" href="/moodboard?type=design">Designs</a>
+ <a class="filter-pill ${filter==='color'?'on':''}" href="/moodboard?type=color">Colors</a>
+ <a class="filter-pill ${filter==='material'?'on':''}" href="/moodboard?type=material">Materials</a>
+ <a class="export-link" href="/api/export/keep.csv" download>Kept CSV</a>
+ </div>
+</header>
+<main>
+${blocks || `<div class="empty"><p>No pins yet.</p><p>Head to <a href="/designs?review=1">/designs</a>, click <strong>Pair</strong> on any card, then ★-pin the suggestions you like.</p></div>`}
+</main>
+<script>
+document.querySelectorAll('.mb-unpin').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ const card = btn.closest('.mb-card');
+ const designId = card.dataset.design;
+ const pinKey = card.dataset.pinKey;
+ btn.disabled = true;
+ try {
+ const r = await fetch('/api/moodboard/' + designId + '/pin', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ pin_key: pinKey, suggestion: {} })
+ });
+ if (r.ok) {
+ card.style.transition = 'opacity .2s';
+ card.style.opacity = '0';
+ setTimeout(() => { card.remove();
+ // if section empty, remove it
+ document.querySelectorAll('.mb-design-block').forEach(b => {
+ if (!b.querySelector('.mb-card')) b.remove();
+ });
+ // update count in header
+ const remaining = document.querySelectorAll('.mb-card').length;
+ document.querySelector('.tag').textContent = remaining + ' pin' + (remaining===1?'':'s') + ' across ' + document.querySelectorAll('.mb-design-block').length + ' design' + (document.querySelectorAll('.mb-design-block').length===1?'':'s');
+ }, 200);
+ } else btn.disabled = false;
+ } catch (e) { btn.disabled = false; }
+ });
+});
+</script>
+</body></html>`);
+});
+
// ── SAMPLES (lead capture — no commerce in v1)
app.get('/samples', (req, res) => {
const designId = req.query.design || '';
@@ -2121,6 +2268,62 @@ app.delete('/api/generator/recipes/:id', (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); }
});
+// One-step batch generate: colors + elements + styles + base patterns → SDXL
+app.post('/api/generator/batch', (req, res) => {
+ const { colors = '', elements = [], styles = [], patterns = [], extra_text = '' } = req.body || {};
+ // Build the prompt
+ const artistLine = patterns.map(p => p.artist).filter(Boolean).slice(0, 3);
+ const patternTitles = patterns.map(p => p.title).filter(Boolean).slice(0, 3);
+ const patternHex = patterns.map(p => p.dominant_hex).filter(Boolean);
+ const allColors = [colors.trim(), ...patternHex.slice(0, 4)].filter(Boolean).join(', ');
+ const promptParts = [
+ 'A seamless wallpaper pattern',
+ artistLine.length ? `in the visual lineage of ${artistLine.join(' and ')}` : '',
+ styles.length ? `with ${styles.slice(0,4).join(' / ')} influence` : '',
+ elements.length ? `featuring ${elements.slice(0,8).join(', ')}` : '',
+ patternTitles.length ? `referencing the look of ${patternTitles.join(' / ')}` : '',
+ allColors ? `palette ${allColors}` : '',
+ extra_text || '',
+ 'high detail, museum-quality, 24x24 inch tileable, no text, no signature.'
+ ].filter(Boolean);
+ const prompt = promptParts.join(', ').replace(/\s+/g, ' ').trim();
+
+ try {
+ const runId = psqlQuery(`INSERT INTO wallco_generator_runs(recipe_id, prompt, palette_seed_ids, status)
+ VALUES(NULL, '${prompt.replace(/'/g,"''")}', '{}'::bigint[], 'running') RETURNING id;`);
+
+ const category = (styles[0] || 'mixed').toLowerCase();
+ const r = spawnSync('node', [
+ path.join(__dirname, 'scripts', 'generate_designs.js'),
+ '--n', '1', '--kind', 'seamless_tile',
+ '--category', category,
+ '--prompts', prompt,
+ ], { cwd: __dirname, env: { ...process.env, GEN_BACKEND: process.env.GEN_BACKEND || 'replicate' },
+ encoding: 'utf8', timeout: 120000 });
+
+ let designId = null, filename = null, domHex = null;
+ try {
+ const row = psqlQuery(`SELECT id::text || '|' || COALESCE(local_path,'') || '|' || COALESCE(dominant_hex,'')
+ FROM spoon_all_designs ORDER BY id DESC LIMIT 1;`);
+ const [idStr, lp, dh] = row.split('|');
+ designId = parseInt(idStr, 10);
+ filename = lp ? path.basename(lp) : null;
+ domHex = dh || null;
+ } catch {}
+
+ const ok = r.status === 0;
+ psqlQuery(`UPDATE wallco_generator_runs SET
+ status='${ok ? 'done' : 'error'}',
+ design_id=${designId || 'NULL'},
+ error=${ok ? 'NULL' : "'"+((r.stderr||r.stdout||'').slice(-300).replace(/'/g,"''"))+"'"},
+ finished_at=now() WHERE id=${runId};`);
+
+ res.json({ ok, design_id: designId, run_id: parseInt(runId,10),
+ prompt, filename, dominant_hex: domHex,
+ error: ok ? null : (r.stderr || r.stdout || '').slice(-400) });
+ } catch (e) { res.status(500).json({ ok:false, error: e.message }); }
+});
+
app.post('/api/generator/run-now', (req, res) => {
const proc = spawn('node', [path.join(__dirname, 'scripts', 'generator_tick.js')], {
cwd: __dirname, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
@@ -2217,6 +2420,42 @@ ${htmlHeader('')}
</div>
</div>
+ <!-- ── ONE-STEP BATCH GENERATE ────────────────────────────────────────── -->
+ <div style="margin-bottom:28px;padding:20px;border:1px solid #1a1a1a;border-radius:10px;background:#1a1a1a;color:#f0eadc">
+ <h2 style="margin:0 0 4px;font-family:'Cormorant Garamond',serif;font-weight:300;font-size:24px;color:#d2b15c">Batch generate · one step</h2>
+ <p style="margin:0 0 16px;font-size:13px;opacity:.75">Pick how many · pick colors · pick elements · pick styles · pick base patterns · go.</p>
+
+ <div style="display:grid;grid-template-columns:80px 1fr;gap:10px 18px;align-items:center;margin-bottom:14px">
+ <label style="font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#aaa">Count</label>
+ <input id="batch-n" type="number" min="1" max="50" value="10" style="width:80px;padding:6px 10px;background:#0f0e0c;color:#fff;border:1px solid #444;border-radius:4px;font-family:inherit;font-size:14px">
+
+ <label style="font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#aaa">Colors</label>
+ <div>
+ <input id="batch-colors" type="text" placeholder="e.g. terracotta, sage, midnight blue, gold" style="width:100%;padding:8px 10px;background:#0f0e0c;color:#fff;border:1px solid #444;border-radius:4px;font-family:inherit;font-size:13px">
+ <div id="batch-color-suggest" style="font-size:11px;color:#888;margin-top:4px"></div>
+ </div>
+
+ <label style="font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#aaa">Elements</label>
+ <div id="batch-elements-picker" style="display:flex;flex-wrap:wrap;gap:5px;max-height:120px;overflow-y:auto;padding:4px"></div>
+
+ <label style="font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#aaa">Styles</label>
+ <div id="batch-styles-picker" style="display:flex;flex-wrap:wrap;gap:5px;max-height:80px;overflow-y:auto;padding:4px"></div>
+
+ <label style="font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#aaa">Patterns</label>
+ <div>
+ <input id="batch-patterns" type="text" placeholder="Search base patterns (peacock, baroque, Morris…)" style="width:100%;padding:6px 10px;background:#0f0e0c;color:#fff;border:1px solid #444;border-radius:4px;font-family:inherit;font-size:13px">
+ <div id="batch-patterns-results" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(72px,1fr));gap:4px;max-height:140px;overflow-y:auto;margin-top:6px"></div>
+ <div id="batch-patterns-picked" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px"></div>
+ </div>
+ </div>
+
+ <div style="display:flex;gap:10px;align-items:center;margin-top:12px">
+ <button id="btn-batch-go" style="padding:12px 28px;background:#d2b15c;color:#1a1a1a;border:0;border-radius:6px;font-weight:600;font-size:14px;cursor:pointer;font-family:inherit">Generate batch</button>
+ <span id="batch-status" style="font-size:12px;color:#aaa"></span>
+ </div>
+ <div id="batch-results" style="margin-top:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:8px"></div>
+ </div>
+
<h2 style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:22px;margin:24px 0 10px">Recipes</h2>
<div id="recipes" style="margin-bottom:16px"></div>
@@ -2353,6 +2592,122 @@ document.getElementById('recipe-form').addEventListener('submit', async function
loadRecipes();
});
+// ── ONE-STEP BATCH GENERATE ────────────────────────────────────────────
+var BATCH = { elements: new Set(), styles: new Set(), patterns: [] };
+
+async function loadBatchPickers(){
+ var [styles, elements] = await Promise.all([
+ fetch('/api/studio/styles').then(r=>r.json()),
+ fetch('/api/studio/elements').then(r=>r.json())
+ ]);
+ document.getElementById('batch-styles-picker').innerHTML = styles.map(function(s){
+ return '<button onclick="toggleBatchStyle(\\''+(s.name.replace(/[\\\\\\']/g,''))+'\\')" data-style="'+s.name+'" style="padding:3px 9px;font-size:11px;border-radius:11px;border:1px solid #555;background:#0f0e0c;color:#aaa;cursor:pointer">'+s.name+'</button>';
+ }).join('');
+ // Group elements by category for tighter display
+ var byCat = {};
+ elements.forEach(function(e){ (byCat[e.category]=byCat[e.category]||[]).push(e); });
+ document.getElementById('batch-elements-picker').innerHTML = Object.keys(byCat).sort().map(function(c){
+ return '<div style="width:100%;font-size:9px;text-transform:uppercase;color:#666;letter-spacing:.08em;margin-top:4px">'+c+'</div>' +
+ byCat[c].map(function(e){
+ return '<button onclick="toggleBatchElement(\\''+e.name+'\\')" data-el="'+e.name+'" style="padding:2px 8px;font-size:11px;border-radius:9px;border:1px solid #555;background:#0f0e0c;color:#aaa;cursor:pointer;margin:1px">'+e.name+'</button>';
+ }).join('');
+ }).join('');
+ // Suggest 3 starter palettes for color hint
+ var pj = await fetch('/api/studio/suggest-palettes',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json());
+ document.getElementById('batch-color-suggest').innerHTML =
+ 'Suggested: ' + (pj.palettes||[]).slice(0,3).map(function(p){
+ return '<span style="cursor:pointer;text-decoration:underline" onclick="document.getElementById(\\'batch-colors\\').value=\\''+(p.colors||[]).join(', ')+'\\'">'+p.name+'</span>';
+ }).join(' · ');
+}
+
+window.toggleBatchElement = function(name){
+ if (BATCH.elements.has(name)) BATCH.elements.delete(name); else BATCH.elements.add(name);
+ var btn = document.querySelector('button[data-el="'+name+'"]');
+ if (btn) {
+ var on = BATCH.elements.has(name);
+ btn.style.background = on ? '#d2b15c' : '#0f0e0c';
+ btn.style.color = on ? '#1a1a1a' : '#aaa';
+ btn.style.borderColor = on ? '#d2b15c' : '#555';
+ }
+};
+window.toggleBatchStyle = function(name){
+ if (BATCH.styles.has(name)) BATCH.styles.delete(name); else BATCH.styles.add(name);
+ var btn = document.querySelector('button[data-style="'+name.replace(/'/g,"'")+'"]');
+ if (btn) {
+ var on = BATCH.styles.has(name);
+ btn.style.background = on ? '#d2b15c' : '#0f0e0c';
+ btn.style.color = on ? '#1a1a1a' : '#aaa';
+ btn.style.borderColor = on ? '#d2b15c' : '#555';
+ }
+};
+
+document.getElementById('batch-patterns').addEventListener('input', async function(e){
+ var q = e.target.value.trim();
+ if (q.length < 2) { document.getElementById('batch-patterns-results').innerHTML=''; return; }
+ var r = await fetch('/api/studio/search?q='+encodeURIComponent(q)+'&limit=24').then(x=>x.json());
+ document.getElementById('batch-patterns-results').innerHTML = r.map(function(x){
+ return '<div onclick="pickBatchPattern('+JSON.stringify(x).replace(/"/g,'"').replace(/'/g,'\\\\\\'')+')" style="cursor:pointer;border:1px solid #444;border-radius:4px;padding:2px;background:#0f0e0c" title="'+((x.title||'').slice(0,60))+'">'+
+ (x.thumb_url?'<img src="'+x.thumb_url+'" loading="lazy" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:3px">':'<div style="aspect-ratio:1;background:'+(x.dominant_hex||'#222')+'"></div>')+
+ '</div>';
+ }).join('');
+});
+window.pickBatchPattern = function(p){
+ if (BATCH.patterns.find(function(x){return x.id===p.id && x.source===p.source;})) return;
+ BATCH.patterns.push(p);
+ renderBatchPicked();
+};
+window.unpickBatchPattern = function(idx){
+ BATCH.patterns.splice(idx, 1);
+ renderBatchPicked();
+};
+function renderBatchPicked(){
+ document.getElementById('batch-patterns-picked').innerHTML = BATCH.patterns.map(function(p,i){
+ return '<div style="display:flex;align-items:center;gap:5px;padding:3px 8px;background:#d2b15c;color:#1a1a1a;border-radius:11px;font-size:11px"><span>'+((p.title||'pattern').slice(0,18))+'</span><span onclick="unpickBatchPattern('+i+')" style="cursor:pointer;font-weight:700">✕</span></div>';
+ }).join('');
+}
+
+document.getElementById('btn-batch-go').addEventListener('click', async function(){
+ var n = Math.max(1, Math.min(50, parseInt(document.getElementById('batch-n').value, 10) || 1));
+ var colors = document.getElementById('batch-colors').value.trim();
+ var elements = Array.from(BATCH.elements);
+ var styles = Array.from(BATCH.styles);
+ var patterns = BATCH.patterns;
+ if (!colors && !elements.length && !styles.length && !patterns.length) {
+ if (!confirm('No selections — use the default Morris/Merian recipe for all ' + n + ' designs?')) return;
+ }
+ this.disabled = true;
+ var s = document.getElementById('batch-status');
+ document.getElementById('batch-results').innerHTML = '';
+ for (var i = 1; i <= n; i++) {
+ s.textContent = 'Generating ' + i + ' / ' + n + ' …';
+ try {
+ var j = await fetch('/api/generator/batch', {
+ method:'POST', headers:{'Content-Type':'application/json'},
+ body: JSON.stringify({ colors, elements, styles,
+ patterns: patterns.map(function(p){return {source:p.source, id:p.id, title:p.title, artist:p.artist, tags:p.tags, dominant_hex:p.dominant_hex};})
+ })
+ }).then(r=>r.json());
+ if (j.ok && j.design_id) {
+ var imgUrl = '/designs/img/'+(j.filename || '');
+ var html = '<a href="/design/'+j.design_id+'" target="_blank" style="text-decoration:none;color:inherit">'+
+ '<div style="border:1px solid #444;border-radius:4px;overflow:hidden;background:#0f0e0c">'+
+ (j.filename ? '<img src="'+imgUrl+'" style="width:100%;aspect-ratio:1;object-fit:cover">' : '<div style="aspect-ratio:1;background:'+(j.dominant_hex||'#222')+'"></div>')+
+ '<div style="padding:4px 6px;font-size:10px;color:#eee">#'+j.design_id+' '+(j.dominant_hex||'')+'</div>'+
+ '</div></a>';
+ document.getElementById('batch-results').insertAdjacentHTML('beforeend', html);
+ } else {
+ document.getElementById('batch-results').insertAdjacentHTML('beforeend', '<div style="border:1px solid #c00;border-radius:4px;padding:6px;font-size:10px;color:#faa">err: '+((j.error||'?').slice(0,80))+'</div>');
+ }
+ } catch (e) {
+ document.getElementById('batch-results').insertAdjacentHTML('beforeend', '<div style="border:1px solid #c00;border-radius:4px;padding:6px;font-size:10px;color:#faa">err: '+e.message+'</div>');
+ }
+ }
+ s.textContent = 'Done. ' + n + ' generated.';
+ this.disabled = false;
+ loadRuns();
+});
+
+loadBatchPickers();
loadCron(); loadPaletteStatus(); loadRecipes(); loadRuns();
setInterval(loadRuns, 30000);
</script>
← 5e65545 Add keyboard shortcuts to /designs (K/R/P/arrows/1-9/?) + np
·
back to Wallco Ai
·
wallco.ai: motif tagger via llava on Mac2 — extracts 3-6 vis 1f5e693 →