← back to Wallco Ai
ship v1 customer-facing storefront + Kamatera deploy
3f15587fbd708f4855c733ad5e5c33308a16a049 · 2026-05-11 16:30:37 -0700 · SteveStudio2
- server.js: full customer site (/, /designs, /design/:id, /murals, /about, /samples,
/api/leads, /api/designs, /api/_inline/state, /health)
- data/designs.json: 11 SDXL-generated designs snapshot (floral/geometric/damask)
- public/css/site.css: full design system (dark/light toggle, sort+density, hero,
Cormorant Garamond wordmark, hamburger nav, all standing rules)
- ecosystem.config.js: production app wallco-ai :9905 + local admin viewer :9792
- deploy-kamatera.sh: rsync → npm install → pm2 → nginx vhost → health check
- server-admin.js: original local viewer preserved
- GA4: G-6V7HSH6K1M provisioned under Steve Abrams APPS
- Purelymail: info@wallco.ai mailbox created
- nginx vhost live on Kamatera (port 9905, CF real_ip headers, Cache-Control no-store)
- pm2 wallco-ai id 261 online and healthy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Files touched
A data/designs.jsonA deploy-kamatera.shM ecosystem.config.jsA public/css/site.cssA scripts/pull_spoonflower_account.pyA scripts/spoonflower_lib.pyA scripts/upload_spoonflower_new.pyA server-admin.jsM server.js
Diff
commit 3f15587fbd708f4855c733ad5e5c33308a16a049
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 11 16:30:37 2026 -0700
ship v1 customer-facing storefront + Kamatera deploy
- server.js: full customer site (/, /designs, /design/:id, /murals, /about, /samples,
/api/leads, /api/designs, /api/_inline/state, /health)
- data/designs.json: 11 SDXL-generated designs snapshot (floral/geometric/damask)
- public/css/site.css: full design system (dark/light toggle, sort+density, hero,
Cormorant Garamond wordmark, hamburger nav, all standing rules)
- ecosystem.config.js: production app wallco-ai :9905 + local admin viewer :9792
- deploy-kamatera.sh: rsync → npm install → pm2 → nginx vhost → health check
- server-admin.js: original local viewer preserved
- GA4: G-6V7HSH6K1M provisioned under Steve Abrams APPS
- Purelymail: info@wallco.ai mailbox created
- nginx vhost live on Kamatera (port 9905, CF real_ip headers, Cache-Control no-store)
- pm2 wallco-ai id 261 online and healthy
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
data/designs.json | 167 ++++
deploy-kamatera.sh | 153 ++++
ecosystem.config.js | 30 +-
public/css/site.css | 642 +++++++++++++++
scripts/pull_spoonflower_account.py | 257 ++++++
scripts/spoonflower_lib.py | 86 ++
scripts/upload_spoonflower_new.py | 149 ++++
server-admin.js | 242 ++++++
server.js | 1472 ++++++++++++++++++++++++++++++-----
9 files changed, 2985 insertions(+), 213 deletions(-)
diff --git a/data/designs.json b/data/designs.json
new file mode 100644
index 0000000..385a35d
--- /dev/null
+++ b/data/designs.json
@@ -0,0 +1,167 @@
+[
+ {
+ "id": 11,
+ "kind": "seamless_tile",
+ "category": "floral",
+ "dominant_hex": "#9c9172",
+ "saturation": 0.269,
+ "title": "Amber Botanical No.11",
+ "handle": "wallco-0011",
+ "image_url": "/designs/img/1778541019744_893789819.png",
+ "filename": "1778541019744_893789819.png",
+ "generator": "Replicate SDXL",
+ "seed": 893789819,
+ "is_published": true,
+ "created_at": "2026-05-11T23:10:00Z"
+ },
+ {
+ "id": 12,
+ "kind": "seamless_tile",
+ "category": "floral",
+ "dominant_hex": "#a09279",
+ "saturation": 0.244,
+ "title": "Amber Botanical No.12",
+ "handle": "wallco-0012",
+ "image_url": "/designs/img/1778541051623_1771215304.png",
+ "filename": "1778541051623_1771215304.png",
+ "generator": "Replicate SDXL",
+ "seed": 1771215304,
+ "is_published": true,
+ "created_at": "2026-05-11T23:10:15Z"
+ },
+ {
+ "id": 13,
+ "kind": "seamless_tile",
+ "category": "floral",
+ "dominant_hex": "#87774c",
+ "saturation": 0.437,
+ "title": "Umber Botanical No.13",
+ "handle": "wallco-0013",
+ "image_url": "/designs/img/1778541058999_209403190.png",
+ "filename": "1778541058999_209403190.png",
+ "generator": "Replicate SDXL",
+ "seed": 209403190,
+ "is_published": true,
+ "created_at": "2026-05-11T23:10:30Z"
+ },
+ {
+ "id": 14,
+ "kind": "seamless_tile",
+ "category": "floral",
+ "dominant_hex": "#0c162b",
+ "saturation": 0.721,
+ "title": "Noir Botanical No.14",
+ "handle": "wallco-0014",
+ "image_url": "/designs/img/1778541066304_1186496331.png",
+ "filename": "1778541066304_1186496331.png",
+ "generator": "Replicate SDXL",
+ "seed": 1186496331,
+ "is_published": true,
+ "created_at": "2026-05-11T23:10:45Z"
+ },
+ {
+ "id": 15,
+ "kind": "seamless_tile",
+ "category": "floral",
+ "dominant_hex": "#b49d95",
+ "saturation": 0.172,
+ "title": "Sienna Botanical No.15",
+ "handle": "wallco-0015",
+ "image_url": "/designs/img/1778541073614_864976504.png",
+ "filename": "1778541073614_864976504.png",
+ "generator": "Replicate SDXL",
+ "seed": 864976504,
+ "is_published": true,
+ "created_at": "2026-05-11T23:11:00Z"
+ },
+ {
+ "id": 16,
+ "kind": "seamless_tile",
+ "category": "geometric",
+ "dominant_hex": "#283527",
+ "saturation": 0.264,
+ "title": "Sage Lattice No.16",
+ "handle": "wallco-0016",
+ "image_url": "/designs/img/1778541111658_918473109.png",
+ "filename": "1778541111658_918473109.png",
+ "generator": "Replicate SDXL",
+ "seed": 918473109,
+ "is_published": true,
+ "created_at": "2026-05-11T23:11:15Z"
+ },
+ {
+ "id": 17,
+ "kind": "seamless_tile",
+ "category": "geometric",
+ "dominant_hex": "#f0e8db",
+ "saturation": 0.088,
+ "title": "Stone Lattice No.17",
+ "handle": "wallco-0017",
+ "image_url": "/designs/img/1778541118993_368707680.png",
+ "filename": "1778541118993_368707680.png",
+ "generator": "Replicate SDXL",
+ "seed": 368707680,
+ "is_published": true,
+ "created_at": "2026-05-11T23:11:30Z"
+ },
+ {
+ "id": 18,
+ "kind": "seamless_tile",
+ "category": "geometric",
+ "dominant_hex": "#bb7d4e",
+ "saturation": 0.583,
+ "title": "Amber Lattice No.18",
+ "handle": "wallco-0018",
+ "image_url": "/designs/img/1778541126631_1206353655.png",
+ "filename": "1778541126631_1206353655.png",
+ "generator": "Replicate SDXL",
+ "seed": 1206353655,
+ "is_published": true,
+ "created_at": "2026-05-11T23:11:45Z"
+ },
+ {
+ "id": 19,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#bfab91",
+ "saturation": 0.241,
+ "title": "Amber Baroque No.19",
+ "handle": "wallco-0019",
+ "image_url": "/designs/img/1778541171660_1800259551.png",
+ "filename": "1778541171660_1800259551.png",
+ "generator": "Replicate SDXL",
+ "seed": 1800259551,
+ "is_published": true,
+ "created_at": "2026-05-11T23:12:00Z"
+ },
+ {
+ "id": 20,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#787675",
+ "saturation": 0.025,
+ "title": "Graphite Baroque No.20",
+ "handle": "wallco-0020",
+ "image_url": "/designs/img/1778541179060_1139384775.png",
+ "filename": "1778541179060_1139384775.png",
+ "generator": "Replicate SDXL",
+ "seed": 1139384775,
+ "is_published": true,
+ "created_at": "2026-05-11T23:12:15Z"
+ },
+ {
+ "id": 21,
+ "kind": "seamless_tile",
+ "category": "damask",
+ "dominant_hex": "#cba15c",
+ "saturation": 0.547,
+ "title": "Amber Baroque No.21",
+ "handle": "wallco-0021",
+ "image_url": "/designs/img/1778541186410_1284647138.png",
+ "filename": "1778541186410_1284647138.png",
+ "generator": "Replicate SDXL",
+ "seed": 1284647138,
+ "is_published": true,
+ "created_at": "2026-05-11T23:12:30Z"
+ }
+]
\ No newline at end of file
diff --git a/deploy-kamatera.sh b/deploy-kamatera.sh
new file mode 100755
index 0000000..2c75614
--- /dev/null
+++ b/deploy-kamatera.sh
@@ -0,0 +1,153 @@
+#!/usr/bin/env bash
+# wallco.ai — deploy to Kamatera
+# Usage: ./deploy-kamatera.sh
+# Pre-flight: CF zone must exist + NS swapped before running.
+set -euo pipefail
+
+REMOTE="root@45.61.58.125"
+REMOTE_DIR="/root/public-projects/wallco-ai"
+LOCAL_DIR="$(cd "$(dirname "$0")" && pwd)"
+SITE="wallco.ai"
+PM2_NAME="wallco-ai"
+PORT=9905
+HEALTH_URL="http://45.61.58.125:${PORT}/health"
+
+echo "=== wallco.ai deploy to Kamatera ==="
+echo "Local: $LOCAL_DIR"
+echo "Remote: $REMOTE:$REMOTE_DIR"
+echo ""
+
+# 1. Port collision pre-flight
+echo "[1/6] Port pre-flight..."
+COLLISION=$(ssh "$REMOTE" "ss -tln | grep ':${PORT} '" 2>/dev/null || true)
+if [ -n "$COLLISION" ]; then
+ echo "ERROR: Port $PORT already in use on Kamatera:"
+ echo "$COLLISION"
+ exit 1
+fi
+echo " Port $PORT is free."
+
+# 2. rsync (exclude heavy data dirs, .env, node_modules, git)
+echo "[2/6] rsync..."
+ssh "$REMOTE" "mkdir -p $REMOTE_DIR/logs $REMOTE_DIR/data/generated $REMOTE_DIR/public/uploads"
+rsync -az --delete \
+ --exclude='node_modules/' \
+ --exclude='.git/' \
+ --exclude='.env*' \
+ --exclude='*.log' \
+ --exclude='data/images/' \
+ --exclude='data/generated/' \
+ --exclude='logs/' \
+ --exclude='server-admin.js' \
+ --exclude='scripts/' \
+ --exclude='db/' \
+ "$LOCAL_DIR/" "$REMOTE:$REMOTE_DIR/"
+
+# 3. Rsync design images (the generated PNGs we DO want on prod)
+echo "[2b/6] rsync generated design images..."
+rsync -az --delete \
+ "$LOCAL_DIR/data/generated/" "$REMOTE:$REMOTE_DIR/data/generated/"
+
+# 4. npm install on remote
+echo "[3/6] npm install..."
+ssh "$REMOTE" "cd $REMOTE_DIR && npm install --omit=dev 2>&1 | tail -5"
+
+# 5. pm2 start or reload
+echo "[4/6] pm2 startOrReload..."
+ssh "$REMOTE" "cd $REMOTE_DIR && pm2 startOrReload ecosystem.config.js --only $PM2_NAME 2>&1 | tail -10 || pm2 start server.js --name $PM2_NAME --cwd $REMOTE_DIR -- --port $PORT"
+ssh "$REMOTE" "pm2 save"
+
+# 6. Health check (wait up to 20s)
+echo "[5/6] Health check → $HEALTH_URL"
+for i in $(seq 1 10); do
+ STATUS=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 5 "$HEALTH_URL" 2>/dev/null || echo "000")
+ if [ "$STATUS" = "200" ]; then
+ BODY=$(curl -sS "$HEALTH_URL")
+ echo " OK ($STATUS): $BODY"
+ break
+ fi
+ echo " Attempt $i: HTTP $STATUS — waiting..."
+ sleep 2
+done
+if [ "$STATUS" != "200" ]; then
+ echo "ERROR: Health check failed after 20s. Check pm2 logs:"
+ ssh "$REMOTE" "pm2 logs $PM2_NAME --nostream --lines 20"
+ exit 1
+fi
+
+# 7. Nginx vhost (create if not present)
+echo "[6/6] nginx vhost..."
+VHOST_SRC="/etc/nginx/sites-available/$SITE"
+VHOST_EXISTS=$(ssh "$REMOTE" "test -f $VHOST_SRC && echo yes || echo no")
+if [ "$VHOST_EXISTS" = "no" ]; then
+ echo " Creating nginx vhost..."
+ ssh "$REMOTE" "cat > $VHOST_SRC" <<NGINX
+server {
+ listen 80;
+ listen [::]:80;
+ server_name $SITE www.$SITE;
+
+ # Cloudflare real IP passthrough
+ set_real_ip_from 103.21.244.0/22;
+ set_real_ip_from 103.22.200.0/22;
+ set_real_ip_from 103.31.4.0/22;
+ set_real_ip_from 104.16.0.0/13;
+ set_real_ip_from 104.24.0.0/14;
+ set_real_ip_from 108.162.192.0/18;
+ set_real_ip_from 131.0.72.0/22;
+ set_real_ip_from 141.101.64.0/18;
+ set_real_ip_from 162.158.0.0/15;
+ set_real_ip_from 172.64.0.0/13;
+ set_real_ip_from 173.245.48.0/20;
+ set_real_ip_from 188.114.96.0/20;
+ set_real_ip_from 190.93.240.0/20;
+ set_real_ip_from 197.234.240.0/22;
+ set_real_ip_from 198.41.128.0/17;
+ set_real_ip_from 2400:cb00::/32;
+ set_real_ip_from 2606:4700::/32;
+ set_real_ip_from 2803:f800::/32;
+ set_real_ip_from 2405:b500::/32;
+ set_real_ip_from 2405:8100::/32;
+ set_real_ip_from 2a06:98c0::/29;
+ set_real_ip_from 2c0f:f248::/32;
+ real_ip_header CF-Connecting-IP;
+
+ location / {
+ proxy_pass http://127.0.0.1:$PORT;
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade \$http_upgrade;
+ proxy_set_header Connection keep-alive;
+ proxy_set_header Host \$host;
+ proxy_set_header X-Real-IP \$remote_addr;
+ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto \$scheme;
+ proxy_cache_bypass \$http_upgrade;
+
+ # Cache-Control: no-store on HTML (per feedback_cloudflare_html_caching.md)
+ add_header Cache-Control "no-store, must-revalidate" always;
+
+ # Security headers
+ add_header X-Frame-Options SAMEORIGIN always;
+ add_header X-Content-Type-Options nosniff always;
+ add_header Referrer-Policy strict-origin-when-cross-origin always;
+ add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+ }
+
+ # Allow certbot challenge
+ location /.well-known/acme-challenge/ {
+ root /var/www/html;
+ }
+}
+NGINX
+ ssh "$REMOTE" "ln -sf $VHOST_SRC /etc/nginx/sites-enabled/$SITE 2>/dev/null || true"
+ ssh "$REMOTE" "nginx -t && systemctl reload nginx"
+ echo " nginx vhost created and reloaded."
+else
+ echo " nginx vhost already exists — skipping."
+fi
+
+echo ""
+echo "=== Deploy complete ==="
+echo "Origin: http://45.61.58.125:$PORT/health"
+echo "Next: run certbot + CF zone creation, then verify https://$SITE/health"
diff --git a/ecosystem.config.js b/ecosystem.config.js
index 1bdba17..4c5e4ac 100644
--- a/ecosystem.config.js
+++ b/ecosystem.config.js
@@ -1,9 +1,25 @@
module.exports = {
- apps: [{
- name: 'wallco-ai-viewer',
- cwd: '/Users/stevestudio2/Projects/wallco-ai',
- script: 'server.js',
- env: { PORT: 9792 },
- max_memory_restart: '256M'
- }]
+ apps: [
+ {
+ // Production customer-facing site (Kamatera port 9905)
+ name: 'wallco-ai',
+ cwd: '/root/public-projects/wallco-ai',
+ script: 'server.js',
+ env: {
+ NODE_ENV: 'production',
+ PORT: 9905
+ },
+ max_memory_restart: '256M',
+ error_file: '/root/public-projects/wallco-ai/logs/err.log',
+ out_file: '/root/public-projects/wallco-ai/logs/out.log'
+ },
+ {
+ // Local admin viewer (Mac2 only — never deployed to Kamatera)
+ name: 'wallco-ai-viewer',
+ cwd: '/Users/stevestudio2/Projects/wallco-ai',
+ script: 'server-admin.js',
+ env: { PORT: 9792 },
+ max_memory_restart: '256M'
+ }
+ ]
};
diff --git a/public/css/site.css b/public/css/site.css
new file mode 100644
index 0000000..adef4af
--- /dev/null
+++ b/public/css/site.css
@@ -0,0 +1,642 @@
+/* wallco.ai — site CSS
+ * Dark/Light toggle standard (feedback_dark_light_toggle_standard.md)
+ * Only --bg, --ink, --ink-soft, --ink-faint, --line, --card-bg, --header-bg flip.
+ * Accents (--accent, --ai-badge-bg) stay constant.
+ */
+
+/* ── LIGHT MODE (default) */
+:root {
+ --bg: #faf7f2;
+ --ink: #1a1714;
+ --ink-soft: #5a5048;
+ --ink-faint: #9a8e82;
+ --line: #d8cec0;
+ --card-bg: #ffffff;
+ --header-bg: #faf7f2;
+
+ /* Accents stay constant across themes */
+ --accent: #0d0d0d;
+ --accent-soft: #3a3633;
+ --gold: #c9a14b;
+ --ai-badge-bg: #0d0d0d;
+ --ai-badge-ink: #f5f0e8;
+
+ --serif: 'Cormorant Garamond', 'Playfair Display', Georgia, serif;
+ --sans: 'Inter', -apple-system, system-ui, sans-serif;
+
+ --hero-h: clamp(560px, 72vh, 900px);
+ --cols: 4;
+}
+
+/* ── DARK MODE */
+[data-theme="dark"] {
+ --bg: #0d0c0a;
+ --ink: #f0ebe3;
+ --ink-soft: #c8beb2;
+ --ink-faint: #7a706a;
+ --line: #2a2620;
+ --card-bg: #1a1714;
+ --header-bg: #111008;
+}
+
+/* ── RESET */
+*, *::before, *::after { box-sizing: border-box; }
+html { scroll-behavior: smooth; }
+body {
+ margin: 0;
+ font: 15px/1.6 var(--sans);
+ color: var(--ink);
+ background: var(--bg);
+ transition: background 0.25s, color 0.25s;
+}
+a { color: var(--ink); }
+img { max-width: 100%; display: block; }
+
+/* ── HEADER */
+.site-header {
+ position: fixed;
+ top: 0; left: 0; right: 0;
+ z-index: 200;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 18px 32px;
+ background: var(--header-bg);
+ border-bottom: 1px solid var(--line);
+ backdrop-filter: blur(12px) saturate(140%);
+ -webkit-backdrop-filter: blur(12px) saturate(140%);
+}
+.brand {
+ font-family: var(--serif);
+ font-size: 20px;
+ font-weight: 300;
+ letter-spacing: 0.04em;
+ color: var(--ink);
+ text-decoration: none;
+}
+
+/* ── HAMBURGER (always visible, per standing rule) */
+.hamburger {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 5px;
+ width: 44px;
+ height: 44px;
+ border: none;
+ border-radius: 50%;
+ background: rgba(0,0,0,0.08);
+ cursor: pointer;
+ padding: 0;
+ backdrop-filter: blur(8px);
+}
+[data-theme="dark"] .hamburger {
+ background: rgba(255,255,255,0.10);
+}
+.hamburger span {
+ display: block;
+ width: 20px;
+ height: 2px;
+ background: var(--ink);
+ border-radius: 2px;
+ transition: all 0.22s;
+}
+.hamburger[aria-expanded="true"] span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
+.hamburger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
+.hamburger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
+
+/* ── NAV PANEL */
+.nav-panel {
+ position: fixed;
+ top: 73px; right: 0;
+ z-index: 199;
+ width: 260px;
+ background: var(--card-bg);
+ border-left: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+ padding: 16px 0;
+ box-shadow: -4px 8px 32px rgba(0,0,0,0.12);
+}
+.nav-panel[hidden] { display: none; }
+.nav-panel a {
+ display: block;
+ padding: 10px 28px;
+ font-size: 14px;
+ text-decoration: none;
+ color: var(--ink-soft);
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ font-size: 12px;
+}
+.nav-panel a:hover, .nav-panel a.active { color: var(--ink); }
+.nav-panel a.active { border-left: 2px solid var(--gold); }
+
+/* Theme toggle in nav */
+.theme-toggle {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 28px;
+ background: none;
+ border: none;
+ cursor: pointer;
+ color: var(--ink-faint);
+ font-family: var(--sans);
+ font-size: 12px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ width: 100%;
+}
+.theme-toggle:hover { color: var(--ink); }
+[data-theme="light"] .moon-icon { display: none; }
+[data-theme="dark"] .sun-icon { display: none; }
+
+/* ── MAIN OFFSET for fixed header */
+main { padding-top: 73px; }
+
+/* ── HERO */
+.hero-section {
+ position: relative;
+ width: 100%;
+ height: var(--hero-h);
+ overflow: hidden;
+ background: var(--accent);
+}
+.hero-track {
+ position: absolute; inset: 0;
+}
+.hero-slide {
+ position: absolute; inset: 0;
+ background: no-repeat center / cover;
+ opacity: 0;
+ transition: opacity 1.2s ease;
+}
+.hero-slide.active { opacity: 1; }
+
+.hero-controls {
+ position: absolute;
+ bottom: 20px; left: 50%;
+ transform: translateX(-50%);
+ display: flex; gap: 8px;
+}
+.hero-dot {
+ width: 8px; height: 8px;
+ border-radius: 50%;
+ background: rgba(255,255,255,0.4);
+ border: none; cursor: pointer; padding: 0;
+ transition: background 0.2s;
+}
+.hero-dot.active { background: #fff; }
+
+/* ── WORDMARK BELOW HERO (Gucci anatomy) */
+.hero-wordmark-below {
+ text-align: center;
+ padding: 40px 24px 24px;
+ border-bottom: 1px solid var(--line);
+}
+.wordmark {
+ font-family: var(--serif);
+ font-size: clamp(36px, 6vw, 80px);
+ font-weight: 300;
+ letter-spacing: 0.06em;
+ color: var(--ink);
+ margin: 0 0 12px;
+}
+.tagline {
+ font-family: var(--serif);
+ font-style: italic;
+ font-size: clamp(14px, 1.8vw, 20px);
+ color: var(--ink-soft);
+ margin: 0;
+}
+
+/* ── SECTION HEADERS */
+.section-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ padding: 40px 40px 0;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.section-header h2 {
+ font-family: var(--serif);
+ font-size: 28px;
+ font-weight: 300;
+ letter-spacing: 0.03em;
+ margin: 0;
+}
+.see-all {
+ font-size: 13px;
+ color: var(--ink-soft);
+ text-decoration: none;
+}
+.see-all:hover { color: var(--ink); text-decoration: underline; }
+
+/* ── DESIGN GRID */
+.design-grid {
+ display: grid;
+ grid-template-columns: repeat(var(--cols), 1fr);
+ gap: 2px;
+ padding: 24px 40px 40px;
+}
+.featured-grid { --cols: 4; }
+.catalog-grid { --cols: var(--cols, 4); }
+
+.design-card {
+ display: block;
+ text-decoration: none;
+ background: var(--card-bg);
+ overflow: hidden;
+ position: relative;
+ transition: transform 0.18s;
+}
+.design-card:hover { z-index: 2; transform: scale(1.02); }
+
+.card-img {
+ aspect-ratio: 1;
+ background: var(--line) no-repeat center / cover;
+}
+
+.card-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ border-top: 1px solid var(--line);
+}
+.card-title {
+ flex: 1;
+ font-family: var(--serif);
+ font-size: 13px;
+ color: var(--ink);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.card-cat {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--ink-faint);
+}
+.color-dot {
+ width: 10px; height: 10px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ border: 1px solid var(--line);
+}
+
+/* ── CTA BAND */
+.cta-band {
+ text-align: center;
+ padding: 80px 40px;
+ background: var(--card-bg);
+ border-top: 1px solid var(--line);
+}
+.cta-band h2 {
+ font-family: var(--serif);
+ font-size: clamp(24px, 4vw, 42px);
+ font-weight: 300;
+ margin: 0 0 12px;
+}
+.cta-band p {
+ color: var(--ink-soft);
+ max-width: 540px;
+ margin: 0 auto 28px;
+ font-size: 16px;
+}
+
+/* ── BUTTONS */
+.btn-primary {
+ display: inline-block;
+ padding: 14px 36px;
+ background: var(--ink);
+ color: var(--bg);
+ font-family: var(--sans);
+ font-size: 13px;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ text-decoration: none;
+ border: 2px solid var(--ink);
+ border-radius: 0;
+ cursor: pointer;
+ transition: background 0.18s, color 0.18s;
+}
+.btn-primary:hover { background: transparent; color: var(--ink); }
+.btn-outline {
+ display: inline-block;
+ padding: 14px 36px;
+ background: transparent;
+ color: var(--ink);
+ font-family: var(--sans);
+ font-size: 13px;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ text-decoration: none;
+ border: 2px solid var(--ink);
+ border-radius: 0;
+ margin-left: 12px;
+ cursor: pointer;
+ transition: background 0.18s, color 0.18s;
+}
+.btn-outline:hover { background: var(--ink); color: var(--bg); }
+
+/* ── CATALOG TOOLBAR */
+.catalog-section { min-height: 60vh; }
+.catalog-toolbar {
+ padding: 32px 40px 0;
+ border-bottom: 1px solid var(--line);
+ margin-bottom: 0;
+}
+.filter-form {}
+.toolbar-row {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ padding-bottom: 16px;
+}
+.search-input {
+ flex: 1;
+ min-width: 160px;
+ padding: 9px 14px;
+ border: 1px solid var(--line);
+ background: var(--bg);
+ color: var(--ink);
+ font: 14px var(--sans);
+ border-radius: 2px;
+}
+.filter-select {
+ padding: 9px 14px;
+ border: 1px solid var(--line);
+ background: var(--bg);
+ color: var(--ink);
+ font: 14px var(--sans);
+ border-radius: 2px;
+}
+.sort-label, .density-label {
+ font-size: 12px;
+ color: var(--ink-faint);
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.density-range { width: 90px; accent-color: var(--gold); }
+.result-count {
+ font-size: 12px;
+ color: var(--ink-faint);
+ margin: 0 0 12px;
+}
+
+/* ── PAGER */
+.pager {
+ display: flex;
+ justify-content: center;
+ gap: 6px;
+ padding: 32px;
+}
+.pager-link {
+ padding: 6px 12px;
+ border: 1px solid var(--line);
+ text-decoration: none;
+ color: var(--ink-soft);
+ font-size: 13px;
+}
+.pager-link.active { background: var(--ink); color: var(--bg); border-color: var(--ink); }
+
+/* ── DETAIL PAGE */
+.detail-main { min-height: 80vh; }
+.detail-layout {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0;
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 48px 40px;
+ gap: 48px;
+}
+@media (max-width: 768px) {
+ .detail-layout { grid-template-columns: 1fr; padding: 24px 20px; }
+}
+.detail-img-wrap {
+ position: sticky;
+ top: 88px;
+}
+.detail-img {
+ width: 100%;
+ border-radius: 2px;
+ background: var(--line);
+}
+.tiled-preview {
+ margin-top: 12px;
+ position: relative;
+}
+.tile-grid {
+ width: 100%;
+ aspect-ratio: 3/2;
+ background-image: var(--tile);
+ background-repeat: repeat;
+ background-size: 33.33% auto;
+ border: 1px solid var(--line);
+ border-radius: 2px;
+}
+.tile-label {
+ display: block;
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--ink-faint);
+ margin-top: 4px;
+ text-align: center;
+}
+.detail-breadcrumb {
+ font-size: 12px;
+ color: var(--ink-faint);
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ margin: 0 0 12px;
+}
+.detail-breadcrumb a { color: inherit; text-decoration: underline; }
+.detail-title {
+ font-family: var(--serif);
+ font-size: clamp(28px, 3vw, 42px);
+ font-weight: 300;
+ letter-spacing: 0.03em;
+ margin: 0 0 16px;
+ color: var(--ink);
+}
+.provenance-badge {
+ font-size: 12px;
+ color: var(--ink-faint);
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ margin-bottom: 20px;
+}
+.ai-badge {
+ display: inline-block;
+ padding: 3px 10px;
+ background: var(--ink);
+ color: var(--bg);
+ font-size: 10px;
+ letter-spacing: 0.12em;
+ margin-right: 8px;
+ border-radius: 2px;
+}
+.detail-palette {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 20px;
+}
+.swatch-main {
+ width: 36px; height: 36px;
+ border-radius: 50%;
+ border: 2px solid var(--line);
+ display: inline-block;
+ flex-shrink: 0;
+}
+.hex-label { font-size: 13px; color: var(--ink-soft); font-family: monospace; }
+.detail-spec-panel {
+ border: 1px solid var(--line);
+ border-radius: 2px;
+ margin-bottom: 24px;
+ overflow: hidden;
+}
+.spec-row {
+ display: flex;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--line);
+ gap: 12px;
+}
+.spec-row:last-child { border-bottom: none; }
+.spec-key { flex: 0 0 110px; font-size: 12px; color: var(--ink-faint); text-transform: uppercase; letter-spacing: 0.07em; }
+.spec-val { font-size: 13px; color: var(--ink); }
+.cta-group { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 24px; }
+.cta-sample {}
+.provenance-note {
+ font-size: 13px;
+ color: var(--ink-faint);
+ background: var(--card-bg);
+ border: 1px solid var(--line);
+ border-radius: 2px;
+ padding: 14px 16px;
+ margin-bottom: 24px;
+ line-height: 1.6;
+}
+.detail-nav {
+ display: flex;
+ justify-content: space-between;
+ padding-top: 24px;
+ border-top: 1px solid var(--line);
+}
+.detail-nav-link {
+ font-size: 13px;
+ color: var(--ink-soft);
+ text-decoration: none;
+}
+.detail-nav-link:hover { color: var(--ink); }
+
+/* ── PROSE / ABOUT */
+.prose-main { padding: 60px 40px 80px; max-width: 800px; margin: 0 auto; }
+.prose h1 {
+ font-family: var(--serif);
+ font-size: 40px;
+ font-weight: 300;
+ margin: 0 0 32px;
+}
+.prose h2 {
+ font-family: var(--serif);
+ font-size: 24px;
+ font-weight: 400;
+ margin: 32px 0 12px;
+}
+.prose p { color: var(--ink-soft); margin: 0 0 16px; }
+
+/* ── SAMPLE FORM */
+.sample-form-wrap {
+ max-width: 560px;
+ margin: 0 auto;
+ padding: 60px 24px 80px;
+}
+.sample-form-wrap h1 {
+ font-family: var(--serif);
+ font-size: 36px;
+ font-weight: 300;
+ margin: 0 0 8px;
+}
+.sample-preview {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 16px;
+ background: var(--card-bg);
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ margin: 20px 0;
+}
+.sample-form label {
+ display: block;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ color: var(--ink-faint);
+ margin-bottom: 16px;
+}
+.sample-form input[type="text"],
+.sample-form input[type="email"],
+.sample-form input[type="tel"],
+.sample-form select,
+.sample-form textarea {
+ display: block;
+ width: 100%;
+ margin-top: 4px;
+ padding: 10px 14px;
+ border: 1px solid var(--line);
+ background: var(--bg);
+ color: var(--ink);
+ font: 14px var(--sans);
+ border-radius: 2px;
+}
+.sample-form textarea { resize: vertical; min-height: 80px; }
+
+/* ── FEATURED SECTION */
+.featured-section { margin-bottom: 0; }
+
+/* ── FOOTER */
+.site-footer {
+ padding: 48px 40px;
+ border-top: 1px solid var(--line);
+ background: var(--card-bg);
+ text-align: center;
+}
+.site-footer p {
+ font-size: 13px;
+ color: var(--ink-faint);
+ margin: 0 0 6px;
+}
+.site-footer a { color: var(--ink-soft); }
+
+/* ── EMPTY STATE */
+.empty-state { padding: 60px; text-align: center; color: var(--ink-faint); }
+
+/* ── RESPONSIVE */
+@media (max-width: 900px) {
+ .design-grid { --cols: 3; }
+ .featured-grid { --cols: 3; }
+ .section-header, .catalog-toolbar { padding-left: 20px; padding-right: 20px; }
+ .design-grid { padding: 16px 20px 32px; }
+}
+@media (max-width: 600px) {
+ .site-header { padding: 16px 20px; }
+ .design-grid { --cols: 2; }
+ .featured-grid { --cols: 2; }
+ .cta-band { padding: 48px 20px; }
+ .hero-wordmark-below { padding: 28px 20px 18px; }
+ .btn-outline { margin-left: 0; margin-top: 8px; }
+}
diff --git a/scripts/pull_spoonflower_account.py b/scripts/pull_spoonflower_account.py
new file mode 100644
index 0000000..0d906fc
--- /dev/null
+++ b/scripts/pull_spoonflower_account.py
@@ -0,0 +1,257 @@
+#!/usr/bin/env python3
+"""
+Pull ALL designs from the logged-in Spoonflower account (DW account by default).
+
+For each design:
+ - Capture sf_design_id, title, slug, design_url, thumbnail
+ - Download the highest-resolution file Spoonflower lets us pull
+ - Index into PG (dw_unified.spoonflower_pulled_designs)
+
+usage: pull_spoonflower_account.py [--limit N] [--account dw]
+"""
+import argparse, sys, os, time, re, json, subprocess
+from pathlib import Path
+from playwright.sync_api import sync_playwright
+
+sys.path.insert(0, str(Path(__file__).parent))
+from spoonflower_lib import creds, login, emit # noqa: E402
+
+DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "spoonflower-pulled"
+DATA_DIR.mkdir(parents=True, exist_ok=True)
+
+DB = os.getenv("DATABASE_URL", "dw_unified")
+
+
+def psql(sql: str) -> str:
+ """Run psql with sql on stdin, return trimmed output."""
+ r = subprocess.run(
+ ["psql", DB, "-At", "-q"], input=sql, text=True, capture_output=True
+ )
+ if r.returncode != 0:
+ raise RuntimeError(f"psql error: {r.stderr}")
+ return r.stdout.strip()
+
+
+def sql_str(v):
+ if v is None:
+ return "NULL"
+ return "'" + str(v).replace("'", "''") + "'"
+
+
+def list_my_designs(page, limit: int = 0):
+ """Navigate the user's 'my designs' page and harvest design URLs."""
+ # Spoonflower has changed this URL over time; try in order.
+ candidates = [
+ "https://www.spoonflower.com/en/my-spoonflower/designs",
+ "https://www.spoonflower.com/profiles/designs",
+ "https://www.spoonflower.com/en/profile/designs",
+ ]
+ landed = None
+ for u in candidates:
+ try:
+ page.goto(u, wait_until="networkidle", timeout=60000)
+ time.sleep(2)
+ if "/login" not in page.url:
+ landed = u
+ break
+ except Exception:
+ continue
+ if not landed:
+ emit("error", note="could not reach my-designs page")
+ return []
+
+ emit("list_loaded", url=landed)
+
+ # Scroll to load lazy-loaded designs
+ last_h = 0
+ for _ in range(30):
+ page.mouse.wheel(0, 3000)
+ time.sleep(1.5)
+ h = page.evaluate("() => document.body.scrollHeight")
+ if h == last_h:
+ break
+ last_h = h
+
+ # Harvest /en/design/<id>-<slug> links from anchors
+ anchors = page.eval_on_selector_all(
+ "a[href*='/design/']",
+ "els => Array.from(new Set(els.map(e => e.href)))"
+ )
+ designs = []
+ seen = set()
+ for href in anchors:
+ m = re.search(r"/(?:en/)?design/(\d+)-([a-z0-9\-]+)", href)
+ if not m:
+ continue
+ did, slug = m.group(1), m.group(2)
+ if did in seen:
+ continue
+ seen.add(did)
+ designs.append({"sf_design_id": did, "slug": slug, "design_url": href})
+ if limit and len(designs) >= limit:
+ break
+ emit("list_collected", count=len(designs))
+ return designs
+
+
+def pull_one(page, d: dict, account: str) -> dict:
+ """Open design page, find File Options → highest-res download, save it."""
+ out = dict(d)
+ page.goto(d["design_url"], wait_until="networkidle", timeout=60000)
+ time.sleep(2)
+
+ # Title
+ try:
+ title = page.locator("h1").first.text_content(timeout=3000)
+ if title:
+ out["title"] = title.strip()
+ except Exception:
+ pass
+
+ # Thumbnail
+ try:
+ thumb = page.locator("img[src*='spoonflower']").first.get_attribute(
+ "src", timeout=2000
+ )
+ if thumb:
+ out["thumbnail_url"] = thumb
+ except Exception:
+ pass
+
+ # Find File Options menu (only visible if this account OWNS the design)
+ file_btn = None
+ for sel in (
+ 'button:has-text("File Options")',
+ '[aria-label*="File Options" i]',
+ 'button[aria-haspopup="menu"]:has-text("File")',
+ ):
+ try:
+ loc = page.locator(sel).first
+ if loc.is_visible(timeout=3000):
+ file_btn = loc
+ break
+ except Exception:
+ continue
+
+ if not file_btn:
+ out["error"] = "no File Options (design may not be owned by this account)"
+ return out
+
+ file_btn.click()
+ time.sleep(1)
+
+ # Click highest-res download option
+ download_clicked = False
+ with page.expect_download(timeout=60000) as dl_info:
+ for sel in (
+ 'text=Download Original',
+ 'text=Download Highest Resolution',
+ '[role="menuitem"]:has-text("Download")',
+ 'a:has-text("Download")',
+ ):
+ try:
+ loc = page.locator(sel).first
+ if loc.is_visible(timeout=2000):
+ loc.click()
+ download_clicked = True
+ break
+ except Exception:
+ continue
+ if not download_clicked:
+ out["error"] = "no Download menuitem"
+ return out
+ try:
+ dl = dl_info.value
+ except Exception as e:
+ out["error"] = f"download timeout: {e}"
+ return out
+
+ # Save
+ suggested = dl.suggested_filename or f"{d['sf_design_id']}.png"
+ target = DATA_DIR / f"{d['sf_design_id']}_{suggested}"
+ dl.save_as(str(target))
+ out["local_path"] = str(target)
+ try:
+ out["file_bytes"] = target.stat().st_size
+ except Exception:
+ out["file_bytes"] = None
+ out["account"] = account
+ return out
+
+
+def upsert(rec: dict):
+ fields = ["sf_design_id", "title", "slug", "design_url",
+ "thumbnail_url", "local_path", "file_bytes", "account"]
+ cols = ", ".join(fields)
+ vals = ", ".join(
+ str(rec.get("file_bytes")) if f == "file_bytes" and rec.get("file_bytes") is not None
+ else sql_str(rec.get(f))
+ for f in fields
+ )
+ set_clause = ", ".join(
+ f"{f} = EXCLUDED.{f}" for f in fields if f != "sf_design_id"
+ )
+ psql(f"""
+ INSERT INTO spoonflower_pulled_designs ({cols})
+ VALUES ({vals})
+ ON CONFLICT (sf_design_id) DO UPDATE SET {set_clause};
+ """)
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--limit", type=int, default=0,
+ help="cap number of designs (0 = unlimited)")
+ ap.add_argument("--account", default="dw", help="tag for the account")
+ args = ap.parse_args()
+
+ email, password = creds()
+ emit("start", account=args.account, limit=args.limit)
+
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+ ctx = browser.new_context(
+ viewport={"width": 1600, "height": 1000},
+ accept_downloads=True,
+ )
+ page = ctx.new_page()
+ if not login(page, email, password):
+ emit("done", ok=False, error="login failed")
+ browser.close()
+ return 2
+ emit("login_ok")
+
+ designs = list_my_designs(page, limit=args.limit)
+ if not designs:
+ emit("done", ok=False, error="no designs found on account")
+ browser.close()
+ return 3
+
+ ok = 0
+ err = 0
+ for i, d in enumerate(designs):
+ emit("pulling", idx=i + 1, of=len(designs),
+ sf_design_id=d["sf_design_id"], slug=d.get("slug"))
+ try:
+ rec = pull_one(page, d, args.account)
+ if rec.get("local_path"):
+ upsert(rec)
+ ok += 1
+ emit("pulled", **{k: rec.get(k) for k in
+ ("sf_design_id", "title", "local_path", "file_bytes")})
+ else:
+ err += 1
+ emit("skipped", sf_design_id=d["sf_design_id"],
+ reason=rec.get("error", "unknown"))
+ except Exception as e:
+ err += 1
+ emit("error", sf_design_id=d["sf_design_id"], err=str(e))
+ time.sleep(2) # rate-limit friendly
+
+ emit("done", ok=True, pulled=ok, errors=err, total=len(designs))
+ browser.close()
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/spoonflower_lib.py b/scripts/spoonflower_lib.py
new file mode 100644
index 0000000..2b03e22
--- /dev/null
+++ b/scripts/spoonflower_lib.py
@@ -0,0 +1,86 @@
+"""
+Shared Spoonflower helpers — login + session cookie reuse.
+Used by upload_spoonflower_new.py and pull_spoonflower_account.py.
+
+Reads creds from env (SPOONFLOWER_EMAIL / SPOONFLOWER_PASSWORD).
+Falls back to ~/Projects/resize-it/.env so we don't duplicate the secret.
+"""
+import os, sys, time, pathlib, json
+from typing import Tuple
+
+def load_resize_it_env():
+ """Bleed resize-it/.env into os.environ if creds missing."""
+ if os.getenv("SPOONFLOWER_EMAIL") and os.getenv("SPOONFLOWER_PASSWORD"):
+ return
+ p = pathlib.Path.home() / "Projects" / "resize-it" / ".env"
+ if not p.exists():
+ return
+ for line in p.read_text().splitlines():
+ line = line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ k, v = line.split("=", 1)
+ k, v = k.strip(), v.strip().strip("'").strip('"')
+ if k in ("SPOONFLOWER_EMAIL", "SPOONFLOWER_PASSWORD") and not os.getenv(k):
+ os.environ[k] = v
+
+
+def creds() -> Tuple[str, str]:
+ load_resize_it_env()
+ email = os.getenv("SPOONFLOWER_EMAIL")
+ pw = os.getenv("SPOONFLOWER_PASSWORD")
+ if not email or not pw:
+ raise RuntimeError("SPOONFLOWER_EMAIL / SPOONFLOWER_PASSWORD not set")
+ return email, pw
+
+
+def login(page, email: str, password: str) -> bool:
+ """Sign in to Spoonflower. Returns True on success."""
+ print("[login] navigating", file=sys.stderr, flush=True)
+ page.goto("https://www.spoonflower.com/login", wait_until="networkidle", timeout=60000)
+ time.sleep(2)
+
+ for sel in ('input[type="email"]', 'input[type="text"]',
+ 'input[placeholder*="email" i]'):
+ try:
+ loc = page.locator(sel).first
+ if loc.is_visible(timeout=4000):
+ loc.fill(email)
+ break
+ except Exception:
+ continue
+
+ for sel in ('input[type="password"]',):
+ try:
+ loc = page.locator(sel).first
+ if loc.is_visible(timeout=4000):
+ loc.fill(password)
+ break
+ except Exception:
+ continue
+
+ for sel in ('button[type="submit"]',
+ 'button:has-text("Sign In")',
+ 'button:has-text("Log In")'):
+ try:
+ loc = page.locator(sel).first
+ if loc.is_visible(timeout=3000):
+ loc.click()
+ break
+ except Exception:
+ continue
+
+ page.wait_for_load_state("networkidle", timeout=60000)
+ time.sleep(3)
+ # Heuristic: signed-in pages expose /en/my-account or avatar
+ body = page.url + " " + (page.content()[:2000] if page.content() else "")
+ ok = ("logout" in body.lower() or "my-account" in body.lower()
+ or "Sign Out" in body or "my-spoonflower" in body.lower())
+ print(f"[login] signed_in={ok} url={page.url}", file=sys.stderr, flush=True)
+ return ok
+
+
+def emit(event: str, **kw):
+ """Structured stdout event for the Node parent to read."""
+ rec = {"event": event, "ts": time.time(), **kw}
+ print(json.dumps(rec), flush=True)
diff --git a/scripts/upload_spoonflower_new.py b/scripts/upload_spoonflower_new.py
new file mode 100644
index 0000000..cc4854c
--- /dev/null
+++ b/scripts/upload_spoonflower_new.py
@@ -0,0 +1,149 @@
+#!/usr/bin/env python3
+"""
+Upload a wallco.ai design to Spoonflower as a NEW product
+(distinct from resize-it/scripts/spoonflower_full_workflow.py which REPLACES).
+
+usage: upload_spoonflower_new.py <image_path> <title> [--tags "tag1,tag2"] [--dry]
+
+stdout is line-delimited JSON events. Final event is `done` (ok=True/False).
+
+NOTE — defaults to dry-run (--dry). Pass without --dry to actually publish.
+Steve's standing rule: no public-facing publish without explicit confirm-click.
+"""
+import argparse, sys, os, time, json
+from pathlib import Path
+from playwright.sync_api import sync_playwright
+
+sys.path.insert(0, str(Path(__file__).parent))
+from spoonflower_lib import creds, login, emit # noqa: E402
+
+UPLOAD_URL = "https://www.spoonflower.com/en/design_studio/uploads"
+
+
+def upload_new(image_path: str, title: str, tags: str = "", dry: bool = True):
+ if not Path(image_path).exists():
+ emit("done", ok=False, error=f"file not found: {image_path}")
+ return 1
+
+ email, password = creds()
+ emit("start", image=image_path, title=title, dry=dry)
+
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+ ctx = browser.new_context(viewport={"width": 1600, "height": 1000})
+ page = ctx.new_page()
+
+ if not login(page, email, password):
+ emit("done", ok=False, error="login failed")
+ browser.close()
+ return 2
+ emit("login_ok")
+
+ if dry:
+ # Dry-run: verify upload page is reachable, no submit.
+ page.goto(UPLOAD_URL, wait_until="networkidle", timeout=60000)
+ time.sleep(2)
+ has_upload_widget = False
+ for sel in ('input[type="file"]',
+ 'button:has-text("Upload")',
+ '[data-testid="upload"]'):
+ try:
+ if page.locator(sel).first.is_visible(timeout=3000):
+ has_upload_widget = True
+ break
+ except Exception:
+ continue
+ emit("dry_check", upload_widget_visible=has_upload_widget,
+ url=page.url)
+ emit("done", ok=True, dry=True,
+ note="dry-run: login + upload-page reachability only")
+ browser.close()
+ return 0
+
+ # ─── real upload ───
+ page.goto(UPLOAD_URL, wait_until="networkidle", timeout=60000)
+ time.sleep(2)
+
+ file_input = page.locator('input[type="file"]').first
+ if not file_input:
+ emit("done", ok=False, error="no file input on upload page")
+ browser.close()
+ return 3
+
+ file_input.set_input_files(image_path)
+ emit("file_attached")
+
+ # Wait for upload + processing (Spoonflower shows a progress bar)
+ for _ in range(180):
+ time.sleep(2)
+ try:
+ # Title field becomes editable when upload completes
+ if page.locator('input[name="design[name]"], input[name="title"], input[placeholder*="name" i]').first.is_visible(timeout=1000):
+ break
+ except Exception:
+ continue
+ emit("upload_processed")
+
+ # Fill title
+ for sel in ('input[name="design[name]"]', 'input[name="title"]',
+ 'input[placeholder*="name" i]'):
+ try:
+ loc = page.locator(sel).first
+ if loc.is_visible(timeout=2000):
+ loc.fill(title)
+ break
+ except Exception:
+ continue
+
+ # Fill tags (comma-separated → space-separated chip-style)
+ if tags:
+ for sel in ('input[name="tags"]', 'input[placeholder*="tag" i]'):
+ try:
+ loc = page.locator(sel).first
+ if loc.is_visible(timeout=2000):
+ loc.fill(tags.replace(",", " "))
+ break
+ except Exception:
+ continue
+
+ emit("metadata_filled")
+
+ # Submit
+ submitted = False
+ for sel in ('button:has-text("Save")',
+ 'button:has-text("Publish")',
+ 'button[type="submit"]'):
+ try:
+ loc = page.locator(sel).first
+ if loc.is_visible(timeout=2000):
+ loc.click()
+ submitted = True
+ break
+ except Exception:
+ continue
+ if not submitted:
+ emit("done", ok=False, error="no submit button visible")
+ browser.close()
+ return 4
+
+ page.wait_for_load_state("networkidle", timeout=90000)
+ time.sleep(3)
+ emit("submitted", final_url=page.url)
+ emit("done", ok=True, url=page.url)
+ browser.close()
+ return 0
+
+
+if __name__ == "__main__":
+ ap = argparse.ArgumentParser()
+ ap.add_argument("image")
+ ap.add_argument("title")
+ ap.add_argument("--tags", default="")
+ ap.add_argument("--dry", action="store_true",
+ help="dry-run: login + upload-page reachability only (default)")
+ ap.add_argument("--live", action="store_true",
+ help="actually upload (overrides --dry)")
+ args = ap.parse_args()
+
+ dry = not args.live # safety: default dry unless --live passed
+ sys.exit(upload_new(args.image, args.title, args.tags, dry=dry))
diff --git a/server-admin.js b/server-admin.js
new file mode 100644
index 0000000..61611b6
--- /dev/null
+++ b/server-admin.js
@@ -0,0 +1,242 @@
+#!/usr/bin/env node
+/**
+ * Wallco.ai local viewer — http://127.0.0.1:9792
+ *
+ * Two tabs:
+ * /pd — public-domain source corpus (Met / Wikimedia / Smithsonian)
+ * /designs — wallco.ai generated designs (spoon_all_designs)
+ * Standing rule: sort + density slider (localStorage-persisted).
+ */
+require('dotenv').config();
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const { execSync } = require('child_process');
+const inlineEditor = require('@dw/inline-editor');
+
+const app = express();
+const PORT = process.env.PORT || 9792;
+const DATA_ROOT = path.join(__dirname, 'data');
+
+// Inline editor (Wix-style admin editing). Auth required — admin role only.
+app.use(inlineEditor({
+ site: 'wallco.ai',
+ uploadDir: path.join(__dirname, 'public', 'uploads'),
+ authMode: process.env.AUTH_MODE || 'dev',
+ devSecret: process.env.AUTH_DEV_SECRET || 'wallco-dev-secret-change-for-prod'
+}));
+
+// Dev login (creates HS256 cookie). Strips out in prod.
+if ((process.env.AUTH_MODE || 'dev') === 'dev') {
+ const jwt = require('jsonwebtoken');
+ app.get('/_devlogin', (_req, res) => {
+ const token = jwt.sign({
+ email: 'steve@designerwallcoverings.com',
+ role: 'admin',
+ iss: 'https://auth.agentabrams.com'
+ }, process.env.AUTH_DEV_SECRET || 'wallco-dev-secret-change-for-prod',
+ { algorithm: 'HS256', expiresIn: '8h' });
+ res.cookie('dw_auth', token, { httpOnly: false });
+ res.type('text/plain').send('Admin logged in (dev). Refresh / to see edit affordances.\n');
+ });
+ app.get('/_devlogout', (_req, res) => { res.clearCookie('dw_auth'); res.send('Logged out\n'); });
+}
+
+function psql(sql) {
+ return execSync(`psql dw_unified -At -q`, { input: sql, encoding: 'utf8' }).trim();
+}
+
+// Serve local image files (so the viewer can show downloaded PD images)
+app.use('/img', express.static(DATA_ROOT, { maxAge: '7d' }));
+
+app.get('/api/pd', (_req, res) => {
+ try {
+ const raw = psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+ SELECT id, source, source_id, title, creator, date_created, license,
+ source_url, image_url, local_path, dominant_hex, palette,
+ category, tags, motifs
+ FROM pd_source_designs ORDER BY ingested_at DESC LIMIT 2000
+ ) t;`);
+ res.type('application/json').send(raw || '[]');
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/designs', (_req, res) => {
+ try {
+ const raw = psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+ SELECT id, kind, width_in, height_in, panels, generator, prompt,
+ seed, local_path, dominant_hex, palette, category, tags,
+ motifs, is_published, created_at
+ FROM spoon_all_designs ORDER BY created_at DESC LIMIT 2000
+ ) t;`);
+ res.type('application/json').send(raw || '[]');
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/counts', (_req, res) => {
+ try {
+ const pd = psql(`SELECT json_agg(t) FROM (SELECT source, COUNT(*) FROM pd_source_designs GROUP BY source ORDER BY 1) t;`);
+ const designs = psql(`SELECT COUNT(*) FROM spoon_all_designs;`);
+ res.json({ pd: JSON.parse(pd || '[]'), designs: parseInt(designs || '0', 10) });
+ } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// Helper: convert local_path absolute → /img relative URL
+function imgUrl(localPath) {
+ if (!localPath) return null;
+ if (localPath.startsWith(DATA_ROOT)) return '/img/' + localPath.slice(DATA_ROOT.length + 1);
+ return null;
+}
+
+app.get('/', (_req, res) => {
+ res.type('html').send(`<!doctype html>
+<meta charset="utf-8">
+<title>wallco.ai — corpus + designs</title>
+<style>
+ :root { --cols: 5; }
+ *,*::before,*::after { box-sizing: border-box; }
+ body { margin:0; font:13px/1.4 -apple-system, system-ui, sans-serif; color:#1a1a1a; background:#0f0e0c; }
+ header { padding: 24px 32px; border-bottom: 1px solid #2a2825; background:#1a1816; color:#e8e2d6; display:flex; gap:16px; align-items:baseline; flex-wrap:wrap; }
+ header h1 { margin:0; font-weight:300; font-size:22px; letter-spacing:.5px; }
+ header .tag { font-size:12px; color:#aaa; }
+ nav a { color:#bba; text-decoration:none; margin-right:14px; font-size:12px; }
+ nav a.on { color:#fff; border-bottom:1px solid #d2b15c; }
+ .controls { margin-left:auto; display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
+ .controls label { font-size:11px; color:#bba; text-transform:uppercase; letter-spacing:.08em; display:flex; gap:6px; align-items:center; }
+ .controls select, .controls input[type=range] { font:inherit; padding:4px 8px; border:1px solid #3a3631; background:#16140f; color:#fff; border-radius:2px; }
+ .controls input[type=range] { width:140px; }
+ main { padding: 24px 32px; }
+ .grid { display:grid; gap:18px; grid-template-columns: repeat(var(--cols), 1fr); }
+ .card { background:#1a1816; border:1px solid #2a2825; border-radius:2px; overflow:hidden; color:#ddd; }
+ .card .img { aspect-ratio: 1; background:#0d0c0a no-repeat center/cover; }
+ .card .meta { padding:10px 12px 14px; }
+ .card .title { font-size:12px; margin:0 0 4px; }
+ .card .sub { font-size:10px; color:#888; }
+ .card .dot { display:inline-block; width:10px; height:10px; border-radius:50%; border:1px solid #00000060; vertical-align:middle; margin-right:4px; }
+ .card .badge { display:inline-block; padding:1px 5px; font-size:9px; background:#2a2825; color:#d2b15c; border-radius:2px; margin-left:5px; text-transform:uppercase; letter-spacing:.05em; }
+ .empty { padding:48px; text-align:center; color:#666; }
+ a.card-link { color:inherit; text-decoration:none; display:block; }
+</style>
+<header>
+ <h1>wallco.ai</h1>
+ <span class="tag" id="count">…</span>
+ <nav>
+ <a href="#" id="tab-pd" class="on">Source corpus</a>
+ <a href="#" id="tab-designs">Generated designs</a>
+ </nav>
+ <div class="controls">
+ <label>Sort
+ <select id="sort">
+ <option value="recent">Recent</option>
+ <option value="color-wheel">Color Wheel</option>
+ <option value="light-dark">Light → Dark</option>
+ <option value="dark-light">Dark → Light</option>
+ <option value="source">Source</option>
+ <option value="title">Title A→Z</option>
+ </select>
+ </label>
+ <label>Density
+ <input type="range" id="density" min="3" max="9" step="1" value="5">
+ </label>
+ </div>
+</header>
+<main><div class="grid" id="grid"></div></main>
+<script>
+const DATA_ROOT = ${JSON.stringify(DATA_ROOT)};
+const sortSel = document.getElementById('sort');
+const dens = document.getElementById('density');
+const grid = document.getElementById('grid');
+const cnt = document.getElementById('count');
+sortSel.value = localStorage.getItem('wc.sort') || 'recent';
+dens.value = localStorage.getItem('wc.density') || 5;
+document.documentElement.style.setProperty('--cols', dens.value);
+
+let tab = localStorage.getItem('wc.tab') || 'pd';
+function setTab(t) {
+ tab = t;
+ document.getElementById('tab-pd').classList.toggle('on', t === 'pd');
+ document.getElementById('tab-designs').classList.toggle('on', t === 'designs');
+ localStorage.setItem('wc.tab', t);
+ load();
+}
+document.getElementById('tab-pd').addEventListener('click', e => { e.preventDefault(); setTab('pd'); });
+document.getElementById('tab-designs').addEventListener('click', e => { e.preventDefault(); setTab('designs'); });
+
+function hexToHsl(hex) {
+ if (!hex || hex.length < 4) return [0,0,0];
+ const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
+ if (!m) return [0,0,0];
+ const r = parseInt(m[1],16)/255, g = parseInt(m[2],16)/255, b = parseInt(m[3],16)/255;
+ const max = Math.max(r,g,b), min = Math.min(r,g,b);
+ const l = (max + min) / 2; let h = 0, s = 0;
+ if (max !== min) {
+ const d = max - min;
+ s = l > 0.5 ? d/(2-max-min) : d/(max+min);
+ if (max === r) h = (g-b)/d + (g<b?6:0);
+ else if (max === g) h = (b-r)/d + 2;
+ else h = (r-g)/d + 4;
+ h *= 60;
+ }
+ return [h,s,l];
+}
+
+function imgPath(p) {
+ if (!p) return '';
+ if (p.startsWith(DATA_ROOT)) return '/img/' + p.slice(DATA_ROOT.length + 1);
+ return p;
+}
+
+function render(data) {
+ const sort = sortSel.value;
+ const sorted = [...data].sort((a,b) => {
+ if (sort === 'recent') return 0;
+ if (sort === 'source') return (a.source||a.generator||'').localeCompare(b.source||b.generator||'');
+ if (sort === 'title') return (a.title||a.prompt||'').localeCompare(b.title||b.prompt||'');
+ const ha = hexToHsl(a.dominant_hex), hb = hexToHsl(b.dominant_hex);
+ if (sort === 'light-dark') return hb[2]-ha[2];
+ if (sort === 'dark-light') return ha[2]-hb[2];
+ if (sort === 'color-wheel') {
+ const isaA = ha[1] < 0.08, isaB = hb[1] < 0.08;
+ if (isaA !== isaB) return isaA ? -1 : 1;
+ if (isaA) return ha[2]-hb[2];
+ const bA = Math.floor(ha[0]/30), bB = Math.floor(hb[0]/30);
+ if (bA !== bB) return bA - bB;
+ return ha[2]-hb[2];
+ }
+ return 0;
+ });
+ cnt.textContent = sorted.length + (tab === 'pd' ? ' PD source images' : ' generated designs');
+ grid.innerHTML = sorted.map(p => {
+ const img = imgPath(p.local_path) || p.image_url || '';
+ const dot = p.dominant_hex ? \`<span class="dot" style="background:\${p.dominant_hex}" title="\${p.dominant_hex}"></span>\` : '';
+ const badge = (p.source || p.generator || '');
+ const title = p.title || p.prompt || ('#'+p.id);
+ const sub = p.creator || p.date_created || p.kind || '';
+ const link = p.source_url || '#';
+ return \`<a class="card-link" href="\${link}" target="_blank"><div class="card">
+ <div class="img" style="background-image:url('\${img}')"></div>
+ <div class="meta">
+ <div class="title">\${dot}\${title.slice(0,60)}<span class="badge">\${badge}</span></div>
+ <div class="sub">\${sub.slice(0,80)}</div>
+ </div>
+ </div></a>\`;
+ }).join('') || '<div class="empty">Empty — run scripts/crawl_pd.py or scripts/generate_designs.js</div>';
+}
+
+async function load() {
+ const r = await fetch(tab === 'pd' ? '/api/pd' : '/api/designs');
+ const data = await r.json();
+ render(data);
+}
+
+sortSel.addEventListener('change', () => { localStorage.setItem('wc.sort', sortSel.value); load(); });
+dens.addEventListener('input', () => {
+ document.documentElement.style.setProperty('--cols', dens.value);
+ localStorage.setItem('wc.density', dens.value);
+});
+
+setTab(tab);
+</script>`);
+});
+
+app.listen(PORT, '127.0.0.1', () => console.log(`wallco.ai viewer → http://127.0.0.1:${PORT}`));
diff --git a/server.js b/server.js
index 61611b6..db86eaf 100644
--- a/server.js
+++ b/server.js
@@ -1,242 +1,1302 @@
#!/usr/bin/env node
/**
- * Wallco.ai local viewer — http://127.0.0.1:9792
+ * wallco.ai — customer-facing storefront
+ * AI-original wallpaper and murals — every pattern generated, never repeated.
*
- * Two tabs:
- * /pd — public-domain source corpus (Met / Wikimedia / Smithsonian)
- * /designs — wallco.ai generated designs (spoon_all_designs)
- * Standing rule: sort + density slider (localStorage-persisted).
+ * Routes:
+ * GET / — hero + featured designs grid (home)
+ * GET /designs — full catalog grid with sort + density + filter
+ * GET /design/:id — design detail page
+ * GET /murals — mural-mode designs
+ * GET /about — AI provenance
+ * GET /samples — lead capture (no commerce in v1)
+ * GET /health — {ok, count}
+ * POST /api/leads — sample request lead capture → dw_unified.wallco_leads
+ * GET /designs/img/:filename — serve design images
+ * GET /api/designs — JSON API (sort/filter/page)
+ * GET /api/_inline/state — inline editor state (admin gate)
*/
+'use strict';
require('dotenv').config();
-const express = require('express');
-const path = require('path');
-const fs = require('fs');
-const { execSync } = require('child_process');
-const inlineEditor = require('@dw/inline-editor');
-
-const app = express();
-const PORT = process.env.PORT || 9792;
-const DATA_ROOT = path.join(__dirname, 'data');
-
-// Inline editor (Wix-style admin editing). Auth required — admin role only.
-app.use(inlineEditor({
- site: 'wallco.ai',
- uploadDir: path.join(__dirname, 'public', 'uploads'),
- authMode: process.env.AUTH_MODE || 'dev',
- devSecret: process.env.AUTH_DEV_SECRET || 'wallco-dev-secret-change-for-prod'
-}));
-// Dev login (creates HS256 cookie). Strips out in prod.
-if ((process.env.AUTH_MODE || 'dev') === 'dev') {
- const jwt = require('jsonwebtoken');
- app.get('/_devlogin', (_req, res) => {
- const token = jwt.sign({
- email: 'steve@designerwallcoverings.com',
- role: 'admin',
- iss: 'https://auth.agentabrams.com'
- }, process.env.AUTH_DEV_SECRET || 'wallco-dev-secret-change-for-prod',
- { algorithm: 'HS256', expiresIn: '8h' });
- res.cookie('dw_auth', token, { httpOnly: false });
- res.type('text/plain').send('Admin logged in (dev). Refresh / to see edit affordances.\n');
- });
- app.get('/_devlogout', (_req, res) => { res.clearCookie('dw_auth'); res.send('Logged out\n'); });
-}
+const express = require('express');
+const path = require('path');
+const fs = require('fs');
+const crypto = require('crypto');
+const { spawn, execSync } = require('child_process');
+
+const app = express();
+const PORT = parseInt(process.env.PORT || '9905', 10);
+const SITE = 'wallco.ai';
+const DATA_FILE = path.join(__dirname, 'data', 'designs.json');
+const IMG_DIR = path.join(__dirname, 'data', 'generated');
+const ROOMS_DIR = path.join(__dirname, 'public', 'uploads', 'rooms');
+const SF_JOBS_DIR = path.join(__dirname, 'logs', 'spoonflower');
+const SF_PULL_DIR = path.join(__dirname, 'data', 'spoonflower-pulled');
+fs.mkdirSync(ROOMS_DIR, { recursive: true });
+fs.mkdirSync(SF_JOBS_DIR, { recursive: true });
+fs.mkdirSync(SF_PULL_DIR, { recursive: true });
-function psql(sql) {
- return execSync(`psql dw_unified -At -q`, { input: sql, encoding: 'utf8' }).trim();
+// Room generator + ElevenLabs config
+const ROOM_API = process.env.ROOM_API || 'http://45.61.58.125:3075/api/generate-room';
+const ELEVEN_KEY = process.env.ELEVENLABS_API_KEY || '';
+const ELEVEN_VOICE_ID = process.env.ELEVENLABS_VOICE_ID || '21m00Tcm4TlvDq8ikWAM'; // Rachel (default suggested)
+// Local trust-auth works; the URL form in .env is for prod / remote use.
+// Stick with the bare DB name so peer auth handles it cleanly.
+const DB_NAME = process.env.WALLCO_DB || 'dw_unified';
+const DB_URL = process.env.DATABASE_URL || DB_NAME;
+
+function psqlQuery(sql) {
+ return execSync(`psql ${DB_NAME} -At -q`, { input: sql, encoding: 'utf8' }).trim();
}
-// Serve local image files (so the viewer can show downloaded PD images)
-app.use('/img', express.static(DATA_ROOT, { maxAge: '7d' }));
+app.use(express.json());
+app.use(express.urlencoded({ extended: false }));
-app.get('/api/pd', (_req, res) => {
- try {
- const raw = psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
- SELECT id, source, source_id, title, creator, date_created, license,
- source_url, image_url, local_path, dominant_hex, palette,
- category, tags, motifs
- FROM pd_source_designs ORDER BY ingested_at DESC LIMIT 2000
- ) t;`);
- res.type('application/json').send(raw || '[]');
- } catch (e) { res.status(500).json({ error: e.message }); }
+// ── Cache-Control: no-store on HTML (per feedback_cloudflare_html_caching.md)
+app.use((req, res, next) => {
+ if (req.accepts('html') && !req.path.startsWith('/designs/img')) {
+ res.setHeader('Cache-Control', 'no-store, must-revalidate');
+ }
+ next();
});
-app.get('/api/designs', (_req, res) => {
+// ── Static: uploaded assets
+app.use('/uploads', express.static(path.join(__dirname, 'public', 'uploads'), { maxAge: '7d' }));
+
+// ── Serve generated design images
+app.use('/designs/img', express.static(IMG_DIR, {
+ maxAge: '30d',
+ setHeaders: (res, filePath) => {
+ res.setHeader('Cache-Control', 'public, max-age=2592000, immutable');
+ }
+}));
+
+// ── Load design catalog (snapshot from DB — never stale-live Shopify)
+let DESIGNS = [];
+function loadDesigns() {
try {
- const raw = psql(`SELECT COALESCE(json_agg(t), '[]'::json) FROM (
- SELECT id, kind, width_in, height_in, panels, generator, prompt,
- seed, local_path, dominant_hex, palette, category, tags,
- motifs, is_published, created_at
- FROM spoon_all_designs ORDER BY created_at DESC LIMIT 2000
- ) t;`);
- res.type('application/json').send(raw || '[]');
- } catch (e) { res.status(500).json({ error: e.message }); }
-});
+ DESIGNS = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
+ } catch (e) {
+ console.error('Failed to load designs.json:', e.message);
+ DESIGNS = [];
+ }
+}
+loadDesigns();
+
+// ── Color sort helpers
+function hexToHSL(hex) {
+ if (!hex) return [0, 0, 0.5];
+ const h = hex.replace('#', '');
+ const r = parseInt(h.slice(0,2),16)/255;
+ const g = parseInt(h.slice(2,4),16)/255;
+ const b = parseInt(h.slice(4,6),16)/255;
+ const max = Math.max(r,g,b), min = Math.min(r,g,b);
+ const l = (max+min)/2;
+ let hue = 0, sat = 0;
+ if (max !== min) {
+ const d = max - min;
+ sat = l > 0.5 ? d/(2-max-min) : d/(max+min);
+ if (max===r) hue = (g-b)/d + (g<b?6:0);
+ else if (max===g) hue = (b-r)/d + 2;
+ else hue = (r-g)/d + 4;
+ hue *= 60;
+ }
+ return [hue, sat, l];
+}
-app.get('/api/counts', (_req, res) => {
+function sortDesigns(designs, sort) {
+ const d = [...designs];
+ switch (sort) {
+ case 'title': return d.sort((a,b) => a.title.localeCompare(b.title));
+ case 'category': return d.sort((a,b) => a.category.localeCompare(b.category));
+ case 'light-dark': return d.sort((a,b) => hexToHSL(b.dominant_hex)[2] - hexToHSL(a.dominant_hex)[2]);
+ case 'dark-light': return d.sort((a,b) => hexToHSL(a.dominant_hex)[2] - hexToHSL(b.dominant_hex)[2]);
+ case 'color-wheel': return d.sort((a,b) => {
+ const [ha,,la] = hexToHSL(a.dominant_hex);
+ const [hb,,lb] = hexToHSL(b.dominant_hex);
+ const bA = Math.floor(ha/30), bB = Math.floor(hb/30);
+ return bA !== bB ? bA-bB : la-lb;
+ });
+ default: return d; // newest (DB insertion order)
+ }
+}
+
+// ── Inline editor admin check (stub — checks dw_auth cookie)
+function isAdmin(req) {
+ const cookie = req.cookies?.dw_auth || req.headers['x-dw-auth'] || '';
+ if (!cookie) return false;
try {
- const pd = psql(`SELECT json_agg(t) FROM (SELECT source, COUNT(*) FROM pd_source_designs GROUP BY source ORDER BY 1) t;`);
- const designs = psql(`SELECT COUNT(*) FROM spoon_all_designs;`);
- res.json({ pd: JSON.parse(pd || '[]'), designs: parseInt(designs || '0', 10) });
- } catch (e) { res.status(500).json({ error: e.message }); }
-});
+ const secret = process.env.AUTH_DEV_SECRET || 'wallco-dev-secret-change-for-prod';
+ const [, payload] = cookie.split('.');
+ const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString());
+ return decoded.role === 'admin';
+ } catch { return false; }
+}
-// Helper: convert local_path absolute → /img relative URL
-function imgUrl(localPath) {
- if (!localPath) return null;
- if (localPath.startsWith(DATA_ROOT)) return '/img/' + localPath.slice(DATA_ROOT.length + 1);
- return null;
+// ── GA4 measurement ID
+const GA4_ID = process.env.GA4_MEASUREMENT_ID || '';
+
+// ── Design brief / color palette swatches
+function paletteSwatches(design) {
+ if (!design.dominant_hex) return '';
+ const colors = [design.dominant_hex];
+ // complementary approximation
+ const [h,,l] = hexToHSL(design.dominant_hex);
+ return colors.map(c =>
+ `<span class="swatch" style="background:${c}" title="${c}"></span>`
+ ).join('');
}
-app.get('/', (_req, res) => {
- res.type('html').send(`<!doctype html>
+// ── Shared HTML fragments
+const GTAG = GA4_ID ? `
+<!-- Google tag (gtag.js) -->
+<script async src="https://www.googletagmanager.com/gtag/js?id=${GA4_ID}"></script>
+<script>
+ window.dataLayer = window.dataLayer || [];
+ function gtag(){dataLayer.push(arguments);}
+ gtag('js', new Date());
+ gtag('config', '${GA4_ID}');
+</script>` : '';
+
+function htmlHead({ title, description, canonical, ogImage = '' }) {
+ const siteUrl = `https://${SITE}`;
+ const ogImg = ogImage || `${siteUrl}/og-default.png`;
+ return `<!doctype html>
+<html lang="en">
+<head>
<meta charset="utf-8">
-<title>wallco.ai — corpus + designs</title>
-<style>
- :root { --cols: 5; }
- *,*::before,*::after { box-sizing: border-box; }
- body { margin:0; font:13px/1.4 -apple-system, system-ui, sans-serif; color:#1a1a1a; background:#0f0e0c; }
- header { padding: 24px 32px; border-bottom: 1px solid #2a2825; background:#1a1816; color:#e8e2d6; display:flex; gap:16px; align-items:baseline; flex-wrap:wrap; }
- header h1 { margin:0; font-weight:300; font-size:22px; letter-spacing:.5px; }
- header .tag { font-size:12px; color:#aaa; }
- nav a { color:#bba; text-decoration:none; margin-right:14px; font-size:12px; }
- nav a.on { color:#fff; border-bottom:1px solid #d2b15c; }
- .controls { margin-left:auto; display:flex; gap:16px; align-items:center; flex-wrap:wrap; }
- .controls label { font-size:11px; color:#bba; text-transform:uppercase; letter-spacing:.08em; display:flex; gap:6px; align-items:center; }
- .controls select, .controls input[type=range] { font:inherit; padding:4px 8px; border:1px solid #3a3631; background:#16140f; color:#fff; border-radius:2px; }
- .controls input[type=range] { width:140px; }
- main { padding: 24px 32px; }
- .grid { display:grid; gap:18px; grid-template-columns: repeat(var(--cols), 1fr); }
- .card { background:#1a1816; border:1px solid #2a2825; border-radius:2px; overflow:hidden; color:#ddd; }
- .card .img { aspect-ratio: 1; background:#0d0c0a no-repeat center/cover; }
- .card .meta { padding:10px 12px 14px; }
- .card .title { font-size:12px; margin:0 0 4px; }
- .card .sub { font-size:10px; color:#888; }
- .card .dot { display:inline-block; width:10px; height:10px; border-radius:50%; border:1px solid #00000060; vertical-align:middle; margin-right:4px; }
- .card .badge { display:inline-block; padding:1px 5px; font-size:9px; background:#2a2825; color:#d2b15c; border-radius:2px; margin-left:5px; text-transform:uppercase; letter-spacing:.05em; }
- .empty { padding:48px; text-align:center; color:#666; }
- a.card-link { color:inherit; text-decoration:none; display:block; }
-</style>
-<header>
- <h1>wallco.ai</h1>
- <span class="tag" id="count">…</span>
- <nav>
- <a href="#" id="tab-pd" class="on">Source corpus</a>
- <a href="#" id="tab-designs">Generated designs</a>
- </nav>
- <div class="controls">
- <label>Sort
- <select id="sort">
- <option value="recent">Recent</option>
- <option value="color-wheel">Color Wheel</option>
- <option value="light-dark">Light → Dark</option>
- <option value="dark-light">Dark → Light</option>
- <option value="source">Source</option>
- <option value="title">Title A→Z</option>
- </select>
- </label>
- <label>Density
- <input type="range" id="density" min="3" max="9" step="1" value="5">
- </label>
- </div>
-</header>
-<main><div class="grid" id="grid"></div></main>
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${title}</title>
+<meta name="description" content="${description}">
+<link rel="canonical" href="${canonical}">
+<!-- Open Graph -->
+<meta property="og:type" content="website">
+<meta property="og:site_name" content="wallco.ai">
+<meta property="og:title" content="${title}">
+<meta property="og:description" content="${description}">
+<meta property="og:url" content="${canonical}">
+<meta property="og:image" content="${ogImg}">
+<!-- Twitter Card -->
+<meta name="twitter:card" content="summary_large_image">
+<meta name="twitter:title" content="${title}">
+<meta name="twitter:description" content="${description}">
+<meta name="twitter:image" content="${ogImg}">
+${GTAG}
+<!-- Dark/Light anti-flash + Age-band early apply (must be first JS in <head>) -->
<script>
-const DATA_ROOT = ${JSON.stringify(DATA_ROOT)};
-const sortSel = document.getElementById('sort');
-const dens = document.getElementById('density');
-const grid = document.getElementById('grid');
-const cnt = document.getElementById('count');
-sortSel.value = localStorage.getItem('wc.sort') || 'recent';
-dens.value = localStorage.getItem('wc.density') || 5;
-document.documentElement.style.setProperty('--cols', dens.value);
-
-let tab = localStorage.getItem('wc.tab') || 'pd';
-function setTab(t) {
- tab = t;
- document.getElementById('tab-pd').classList.toggle('on', t === 'pd');
- document.getElementById('tab-designs').classList.toggle('on', t === 'designs');
- localStorage.setItem('wc.tab', t);
- load();
-}
-document.getElementById('tab-pd').addEventListener('click', e => { e.preventDefault(); setTab('pd'); });
-document.getElementById('tab-designs').addEventListener('click', e => { e.preventDefault(); setTab('designs'); });
-
-function hexToHsl(hex) {
- if (!hex || hex.length < 4) return [0,0,0];
- const m = hex.match(/^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i);
- if (!m) return [0,0,0];
- const r = parseInt(m[1],16)/255, g = parseInt(m[2],16)/255, b = parseInt(m[3],16)/255;
- const max = Math.max(r,g,b), min = Math.min(r,g,b);
- const l = (max + min) / 2; let h = 0, s = 0;
- if (max !== min) {
- const d = max - min;
- s = l > 0.5 ? d/(2-max-min) : d/(max+min);
- if (max === r) h = (g-b)/d + (g<b?6:0);
- else if (max === g) h = (b-r)/d + 2;
- else h = (r-g)/d + 4;
- h *= 60;
+(function(){
+ var t = localStorage.getItem('wallco-theme');
+ if (!t) {
+ t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+ }
+ document.documentElement.setAttribute('data-theme', t);
+ var age = parseInt(localStorage.getItem('wallco-age'),10);
+ function band(a){
+ if (!a) return '';
+ if (a < 6) return 'toddler';
+ if (a < 11) return 'elementary';
+ if (a < 14) return 'tween';
+ if (a < 18) return 'teen';
+ if (a < 40) return 'adult';
+ if (a < 60) return 'mature';
+ if (a < 80) return 'senior';
+ return 'elder';
}
- return [h,s,l];
+ if (age) document.documentElement.setAttribute('data-age-band', band(age));
+})();
+</script>
+<link rel="stylesheet" href="/css/site.css">
+<link rel="preconnect" href="https://fonts.googleapis.com">
+<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,400&family=Inter:wght@300;400;500&display=swap" rel="stylesheet">
+</head>`;
}
-function imgPath(p) {
- if (!p) return '';
- if (p.startsWith(DATA_ROOT)) return '/img/' + p.slice(DATA_ROOT.length + 1);
- return p;
+function htmlHeader(active) {
+ const navLinks = [
+ ['/', 'Home'],
+ ['/designs', 'Designs'],
+ ['/murals', 'Murals'],
+ ['/samples', 'Samples'],
+ ['/spoonflower', 'Spoonflower'],
+ ['/about', 'About'],
+ ];
+ return `<header class="site-header">
+ <a class="brand" href="/">wallco.ai</a>
+ <button class="hamburger" id="nav-toggle" aria-controls="nav-panel" aria-expanded="false" aria-label="Open menu">
+ <span></span><span></span><span></span>
+ </button>
+</header>
+<nav class="nav-panel" id="nav-panel" hidden>
+ ${navLinks.map(([href, label]) =>
+ `<a href="${href}" class="${active === href ? 'active' : ''}">${label}</a>`
+ ).join('\n ')}
+ <button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark/light mode">
+ <svg class="sun-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
+ <svg class="moon-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
+ </button>
+</nav>`;
}
-function render(data) {
- const sort = sortSel.value;
- const sorted = [...data].sort((a,b) => {
- if (sort === 'recent') return 0;
- if (sort === 'source') return (a.source||a.generator||'').localeCompare(b.source||b.generator||'');
- if (sort === 'title') return (a.title||a.prompt||'').localeCompare(b.title||b.prompt||'');
- const ha = hexToHsl(a.dominant_hex), hb = hexToHsl(b.dominant_hex);
- if (sort === 'light-dark') return hb[2]-ha[2];
- if (sort === 'dark-light') return ha[2]-hb[2];
- if (sort === 'color-wheel') {
- const isaA = ha[1] < 0.08, isaB = hb[1] < 0.08;
- if (isaA !== isaB) return isaA ? -1 : 1;
- if (isaA) return ha[2]-hb[2];
- const bA = Math.floor(ha[0]/30), bB = Math.floor(hb[0]/30);
- if (bA !== bB) return bA - bB;
- return ha[2]-hb[2];
+const FOOTER = `<footer class="site-footer">
+ <p>wallco.ai — AI-original wallpaper & murals. Every pattern generated, never repeated.</p>
+ <p>Part of the <a href="https://designerwallcoverings.com">Designer Wallcoverings</a> family.</p>
+ <p><a href="mailto:info@wallco.ai">info@wallco.ai</a></p>
+</footer>`;
+
+const HAMBURGER_JS = `
+<!-- First-visit Age Prompt (drives age-adaptive UI per ageBand) -->
+<div id="age-prompt-overlay" hidden style="position:fixed;inset:0;background:rgba(15,14,12,.85);z-index:9999;display:flex;align-items:center;justify-content:center;backdrop-filter:blur(8px)">
+ <div style="background:#fff;border-radius:12px;padding:36px 40px;max-width:480px;width:90%;box-shadow:0 24px 64px rgba(0,0,0,.4);font-family:'Inter',system-ui,sans-serif">
+ <h2 style="font-family:'Cormorant Garamond',serif;font-weight:300;font-size:26px;margin:0 0 6px;color:#1a1a1a">Welcome to wallco.ai</h2>
+ <p style="margin:0 0 22px;color:#666;font-size:14px;line-height:1.5">Tell us your age so we can show you the version of this site that's easiest for you to use.</p>
+ <input type="number" id="age-input" min="3" max="99" placeholder="Your age" style="width:100%;padding:14px 16px;font-size:16px;border:1px solid #ccc;border-radius:6px;margin-bottom:14px;font-family:inherit">
+ <div style="display:flex;gap:10px">
+ <button id="age-submit" style="flex:1;padding:14px;background:#1a1a1a;color:#fff;border:0;border-radius:6px;font-size:14px;font-weight:500;cursor:pointer">Show me the site</button>
+ <button id="age-skip" style="padding:14px 18px;background:transparent;color:#999;border:1px solid #ddd;border-radius:6px;font-size:13px;cursor:pointer">Skip</button>
+ </div>
+ <p style="margin:14px 0 0;color:#999;font-size:11px;text-align:center">Adjustable anytime in Settings.</p>
+ </div>
+</div>
+<script>
+(function(){
+ var btn = document.getElementById('nav-toggle');
+ var panel = document.getElementById('nav-panel');
+ if (btn && panel) {
+ btn.addEventListener('click', function() {
+ var open = panel.hidden;
+ panel.hidden = !open;
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
+ });
+ }
+ // Theme toggle
+ var tt = document.getElementById('theme-toggle');
+ if (tt) {
+ tt.addEventListener('click', function() {
+ var cur = document.documentElement.getAttribute('data-theme') || 'light';
+ var next = cur === 'dark' ? 'light' : 'dark';
+ document.documentElement.setAttribute('data-theme', next);
+ localStorage.setItem('wallco-theme', next);
+ });
+ }
+ // Age prompt — first visit only
+ function ageBand(a){
+ if (!a) return '';
+ if (a < 6) return 'toddler';
+ if (a < 11) return 'elementary';
+ if (a < 14) return 'tween';
+ if (a < 18) return 'teen';
+ if (a < 40) return 'adult';
+ if (a < 60) return 'mature';
+ if (a < 80) return 'senior';
+ return 'elder';
+ }
+ var overlay = document.getElementById('age-prompt-overlay');
+ var seen = localStorage.getItem('wallco-age') || localStorage.getItem('wallco-age-skipped');
+ if (!seen && overlay) {
+ overlay.hidden = false;
+ setTimeout(function(){ var i = document.getElementById('age-input'); if (i) i.focus(); }, 100);
+ document.getElementById('age-submit').addEventListener('click', function(){
+ var a = parseInt(document.getElementById('age-input').value, 10);
+ if (!a || a < 3 || a > 99) { document.getElementById('age-input').style.borderColor='#c00'; return; }
+ localStorage.setItem('wallco-age', String(a));
+ document.documentElement.setAttribute('data-age-band', ageBand(a));
+ overlay.hidden = true;
+ });
+ document.getElementById('age-skip').addEventListener('click', function(){
+ localStorage.setItem('wallco-age-skipped', '1');
+ overlay.hidden = true;
+ });
+ document.getElementById('age-input').addEventListener('keydown', function(e){
+ if (e.key === 'Enter') document.getElementById('age-submit').click();
+ });
+ }
+})();
+</script>`;
+
+// ── Hero rotation images (highest-saturation designs, sat > 0.4)
+function heroDesigns() {
+ const h = DESIGNS
+ .filter(d => d.saturation > 0.3)
+ .sort((a,b) => b.saturation - a.saturation);
+ // fallback: if none qualify, use all sorted by saturation
+ return h.length >= 2 ? h : [...DESIGNS].sort((a,b) => b.saturation - a.saturation);
+}
+
+// ── HOME PAGE
+app.get('/', (req, res) => {
+ const heroes = heroDesigns();
+ const featured = [...DESIGNS].sort((a,b) => b.saturation - a.saturation).slice(0, 8);
+ const schema = JSON.stringify({
+ "@context": "https://schema.org",
+ "@graph": [
+ {
+ "@type": "Organization",
+ "name": "wallco.ai",
+ "url": "https://wallco.ai",
+ "email": "info@wallco.ai",
+ "parentOrganization": {
+ "@type": "Organization",
+ "name": "Designer Wallcoverings",
+ "url": "https://designerwallcoverings.com",
+ "address": {
+ "@type": "PostalAddress",
+ "streetAddress": "15442 Ventura Bl #102",
+ "addressLocality": "Sherman Oaks",
+ "addressRegion": "CA",
+ "postalCode": "91403"
+ }
+ }
+ },
+ {
+ "@type": "WebSite",
+ "name": "wallco.ai",
+ "url": "https://wallco.ai",
+ "description": "AI-original wallpaper and murals — every pattern generated, never repeated."
+ },
+ {
+ "@type": "ItemList",
+ "name": "Featured AI-Generated Designs",
+ "numberOfItems": featured.length,
+ "itemListElement": featured.map((d, i) => ({
+ "@type": "ListItem",
+ "position": i + 1,
+ "name": d.title,
+ "url": `https://wallco.ai/design/${d.id}`
+ }))
+ }
+ ]
+ });
+
+ const heroSlides = heroes.slice(0, 6).map((d, i) =>
+ `<div class="hero-slide ${i===0?'active':''}" data-idx="${i}" style="background-image:url('${d.image_url}')" aria-label="${d.title}"></div>`
+ ).join('\n');
+
+ const featuredCards = featured.map(d => `
+ <a href="/design/${d.id}" class="design-card">
+ <div class="card-img" style="background-image:url('${d.image_url}')"></div>
+ <div class="card-meta">
+ <span class="card-title">${d.title}</span>
+ <span class="card-cat">${d.category}</span>
+ <span class="color-dot" style="background:${d.dominant_hex}"></span>
+ </div>
+ </a>`).join('');
+
+ res.type('html').send(`${htmlHead({
+ title: 'wallco.ai — AI-Original Wallpaper & Murals',
+ description: 'AI-generated wallpaper and murals. Every pattern original, never repeated. Browse, preview, and order samples.',
+ canonical: 'https://wallco.ai/'
+ })}
+<body>
+${htmlHeader('/')}
+<main>
+
+<!-- Hero: full-bleed rotating -->
+<section class="hero-section">
+ <div class="hero-track" id="hero-track">
+ ${heroSlides}
+ </div>
+ <div class="hero-controls" aria-hidden="true">
+ ${heroes.slice(0, 6).map((_,i) => `<button class="hero-dot ${i===0?'active':''}" data-slide="${i}"></button>`).join('')}
+ </div>
+</section>
+
+<!-- Wordmark below hero (Gucci-anatomy per standing rule) -->
+<div class="hero-wordmark-below">
+ <h1 class="wordmark">wallco.ai</h1>
+ <p class="tagline">AI-original wallpaper & murals — every pattern generated, never repeated.</p>
+</div>
+
+<!-- Featured designs grid -->
+<section class="featured-section">
+ <div class="section-header">
+ <h2>Featured Designs</h2>
+ <a href="/designs" class="see-all">See all ${DESIGNS.length} designs →</a>
+ </div>
+ <div class="design-grid featured-grid" id="featured-grid">
+ ${featuredCards}
+ </div>
+</section>
+
+<!-- CTA band -->
+<section class="cta-band">
+ <h2>Every design is AI-original.</h2>
+ <p>No licensing. No vendor catalogs. Each pattern is generated — a wallcovering that exists nowhere else.</p>
+ <a href="/samples" class="btn-primary">Request a Free Sample</a>
+ <a href="/designs" class="btn-outline">Browse All Designs</a>
+</section>
+
+</main>
+${FOOTER}
+
+<script type="application/ld+json">${schema}</script>
+
+<!-- Hero rotation -->
+<script>
+(function(){
+ var slides = document.querySelectorAll('.hero-slide');
+ var dots = document.querySelectorAll('.hero-dot');
+ var cur = 0;
+ var interval;
+ function goTo(idx) {
+ slides[cur].classList.remove('active');
+ dots[cur].classList.remove('active');
+ cur = (idx + slides.length) % slides.length;
+ slides[cur].classList.add('active');
+ dots[cur].classList.add('active');
+ }
+ dots.forEach(function(d) {
+ d.addEventListener('click', function() { goTo(+this.dataset.slide); clearInterval(interval); interval = setInterval(function(){goTo(cur+1);}, 4500); });
+ });
+ interval = setInterval(function(){ goTo(cur+1); }, 4500);
+ // Pause when tab hidden
+ document.addEventListener('visibilitychange', function() {
+ if (document.hidden) clearInterval(interval);
+ else interval = setInterval(function(){goTo(cur+1);}, 4500);
+ });
+})();
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── DESIGNS CATALOG (full grid with sort + density + category filter)
+app.get('/designs', (req, res) => {
+ const sort = req.query.sort || 'newest';
+ const cat = req.query.cat || '';
+ const q = (req.query.q || '').toLowerCase().trim();
+ const page = Math.max(1, parseInt(req.query.page || '1', 10));
+ const PER = 60;
+
+ let filtered = [...DESIGNS];
+ if (cat) filtered = filtered.filter(d => d.category === cat);
+ if (q) filtered = filtered.filter(d =>
+ d.title.toLowerCase().includes(q) ||
+ d.category.toLowerCase().includes(q) ||
+ d.handle.toLowerCase().includes(q)
+ );
+ filtered = sortDesigns(filtered, sort);
+
+ const total = filtered.length;
+ const pages = Math.max(1, Math.ceil(total / PER));
+ const slice = filtered.slice((page-1)*PER, page*PER);
+ const cats = [...new Set(DESIGNS.map(d => d.category))].sort();
+
+ const cards = slice.map(d => `
+ <a href="/design/${d.id}" class="design-card">
+ <div class="card-img" style="background-image:url('${d.image_url}')"></div>
+ <div class="card-meta">
+ <span class="card-title">${d.title}</span>
+ <span class="card-cat">${d.category}</span>
+ <span class="color-dot" style="background:${d.dominant_hex}"></span>
+ </div>
+ </a>`).join('');
+
+ const pagerLinks = pages > 1
+ ? Array.from({length: pages}, (_,i) => i+1).map(p =>
+ `<a href="/designs?sort=${sort}&cat=${encodeURIComponent(cat)}&q=${encodeURIComponent(q)}&page=${p}" class="pager-link ${p===page?'active':''}">${p}</a>`
+ ).join(' ')
+ : '';
+
+ const schema = JSON.stringify({
+ "@context": "https://schema.org",
+ "@type": "ItemList",
+ "name": "AI-Generated Wallpaper Designs — wallco.ai",
+ "numberOfItems": total,
+ "itemListElement": slice.map((d,i) => ({
+ "@type": "ListItem",
+ "position": (page-1)*PER+i+1,
+ "name": d.title,
+ "url": `https://wallco.ai/design/${d.id}`
+ }))
+ });
+
+ res.type('html').send(`${htmlHead({
+ title: `All Designs — wallco.ai | AI-Original Wallpaper & Murals`,
+ description: `Browse ${total} AI-generated wallpaper designs. Every pattern original. Free samples available.`,
+ canonical: `https://wallco.ai/designs`
+ })}
+<body>
+${htmlHeader('/designs')}
+<main>
+<section class="catalog-section">
+ <div class="catalog-toolbar">
+ <form method="get" action="/designs" class="filter-form">
+ <input type="hidden" name="sort" id="sort-hidden" value="${sort}">
+ <div class="toolbar-row">
+ <input type="search" name="q" value="${q}" placeholder="Search designs…" class="search-input">
+ <select name="cat" class="filter-select" onchange="this.form.submit()">
+ <option value="">All Categories</option>
+ ${cats.map(c => `<option value="${c}" ${c===cat?'selected':''}>${c.charAt(0).toUpperCase()+c.slice(1)}</option>`).join('')}
+ </select>
+ <label class="sort-label">Sort
+ <select id="sort-select" class="filter-select" onchange="document.getElementById('sort-hidden').value=this.value;this.form.submit()">
+ <option value="newest" ${sort==='newest'?'selected':''}>Newest</option>
+ <option value="title" ${sort==='title'?'selected':''}>Title A→Z</option>
+ <option value="category" ${sort==='category'?'selected':''}>Category</option>
+ <option value="light-dark" ${sort==='light-dark'?'selected':''}>Light → Dark</option>
+ <option value="dark-light" ${sort==='dark-light'?'selected':''}>Dark → Light</option>
+ <option value="color-wheel" ${sort==='color-wheel'?'selected':''}>Color Wheel</option>
+ </select>
+ </label>
+ <label class="density-label">Density
+ <input type="range" id="density-slider" min="2" max="8" step="1" value="4" class="density-range">
+ </label>
+ </div>
+ </form>
+ <p class="result-count">${total} design${total===1?'':'s'}${cat?' in '+cat:''}${q?' matching "'+q+'"':''}</p>
+ </div>
+
+ <div class="design-grid catalog-grid" id="catalog-grid">
+ ${cards || '<p class="empty-state">No designs found.</p>'}
+ </div>
+
+ ${pages > 1 ? `<div class="pager">${pagerLinks}</div>` : ''}
+</section>
+</main>
+${FOOTER}
+
+<script type="application/ld+json">${schema}</script>
+<script>
+(function(){
+ var slider = document.getElementById('density-slider');
+ var grid = document.getElementById('catalog-grid');
+ var saved = localStorage.getItem('wallco-density');
+ if (saved) slider.value = saved;
+ grid.style.setProperty('--cols', slider.value);
+ slider.addEventListener('input', function() {
+ grid.style.setProperty('--cols', this.value);
+ localStorage.setItem('wallco-density', this.value);
+ });
+ var sortSel = document.getElementById('sort-select');
+ var sortH = document.getElementById('sort-hidden');
+ if (sortSel && sortH) {
+ var savedSort = localStorage.getItem('wallco-sort');
+ if (savedSort && savedSort !== '${sort}') {
+ sortH.value = savedSort;
+ }
+ sortSel.addEventListener('change', function() {
+ localStorage.setItem('wallco-sort', this.value);
+ });
+ }
+})();
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── DESIGN DETAIL
+app.get('/design/:id', (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ const design = DESIGNS.find(d => d.id === id);
+ if (!design) return res.status(404).type('html').send(`${htmlHead({title:'Not Found — wallco.ai',description:'',canonical:`https://wallco.ai/design/${id}`})}<body>${htmlHeader('')}<main><div class="empty-state" style="padding:80px;text-align:center"><h2>Design not found</h2><a href="/designs">Browse all designs</a></div></main>${FOOTER}${HAMBURGER_JS}</body></html>`);
+
+ const relIdx = DESIGNS.findIndex(d => d.id === id);
+ const prev = relIdx > 0 ? DESIGNS[relIdx-1] : null;
+ const next = relIdx < DESIGNS.length-1 ? DESIGNS[relIdx+1] : null;
+
+ const schema = JSON.stringify({
+ "@context": "https://schema.org",
+ "@type": "Product",
+ "name": design.title,
+ "description": `AI-generated ${design.category} wallpaper pattern. Generator: ${design.generator}. Every pattern is original and never repeated.`,
+ "url": `https://wallco.ai/design/${design.id}`,
+ "image": `https://wallco.ai${design.image_url}`,
+ "brand": {"@type":"Brand","name":"wallco.ai"},
+ "offers": {
+ "@type": "Offer",
+ "availability": "https://schema.org/InStock",
+ "url": `https://wallco.ai/samples?design=${design.id}`,
+ "priceCurrency": "USD",
+ "description": "Free sample available"
}
- return 0;
});
- cnt.textContent = sorted.length + (tab === 'pd' ? ' PD source images' : ' generated designs');
- grid.innerHTML = sorted.map(p => {
- const img = imgPath(p.local_path) || p.image_url || '';
- const dot = p.dominant_hex ? \`<span class="dot" style="background:\${p.dominant_hex}" title="\${p.dominant_hex}"></span>\` : '';
- const badge = (p.source || p.generator || '');
- const title = p.title || p.prompt || ('#'+p.id);
- const sub = p.creator || p.date_created || p.kind || '';
- const link = p.source_url || '#';
- return \`<a class="card-link" href="\${link}" target="_blank"><div class="card">
- <div class="img" style="background-image:url('\${img}')"></div>
- <div class="meta">
- <div class="title">\${dot}\${title.slice(0,60)}<span class="badge">\${badge}</span></div>
- <div class="sub">\${sub.slice(0,80)}</div>
+
+ res.type('html').send(`${htmlHead({
+ title: `${design.title} — wallco.ai`,
+ description: `AI-generated ${design.category} wallpaper. Pattern No.${design.id} by wallco.ai — original, never repeated. Free sample available.`,
+ canonical: `https://wallco.ai/design/${design.id}`,
+ ogImage: `https://wallco.ai${design.image_url}`
+ })}
+<body>
+${htmlHeader('/designs')}
+<main class="detail-main">
+ <div class="detail-layout">
+ <div class="detail-image-col">
+ <div class="detail-img-wrap">
+ <img src="${design.image_url}" alt="${design.title}" class="detail-img" loading="eager">
+ <div class="tiled-preview" title="Tiled pattern preview">
+ <div class="tile-grid" style="--tile:url('${design.image_url}')"></div>
+ <span class="tile-label">Tiled preview</span>
+ </div>
+ </div>
+ </div>
+ <div class="detail-info-col">
+ <p class="detail-breadcrumb"><a href="/designs">Designs</a> › ${design.category}</p>
+ <h1 class="detail-title">${design.title}</h1>
+
+ <div class="provenance-badge">
+ <span class="ai-badge">AI-Original</span>
+ Pattern No.${String(design.id).padStart(4,'0')} · ${design.category.charAt(0).toUpperCase()+design.category.slice(1)} · ${design.kind === 'seamless_tile' ? 'Seamless Tile' : design.kind}
+ </div>
+
+ <div class="detail-palette">
+ <span class="swatch-main" style="background:${design.dominant_hex}" title="Dominant ${design.dominant_hex}"></span>
+ <span class="hex-label">${design.dominant_hex}</span>
+ </div>
+
+ <div class="detail-spec-panel">
+ <div class="spec-row"><span class="spec-key">Category</span><span class="spec-val">${design.category}</span></div>
+ <div class="spec-row"><span class="spec-key">Kind</span><span class="spec-val">${design.kind === 'seamless_tile' ? 'Seamless tile (24×24")' : design.kind}</span></div>
+ <div class="spec-row"><span class="spec-key">Generator</span><span class="spec-val">${design.generator}</span></div>
+ <div class="spec-row"><span class="spec-key">Seed</span><span class="spec-val">${design.seed}</span></div>
+ <div class="spec-row"><span class="spec-key">Handle</span><span class="spec-val">${design.handle}</span></div>
+ </div>
+
+ <!-- Sample CTA (DW standing rule — non-negotiable) -->
+ <div class="cta-group">
+ <a href="/samples?design=${design.id}" class="btn-primary cta-sample">Request Free Sample</a>
+ <a href="/designs" class="btn-outline">Back to Designs</a>
+ </div>
+
+ <!-- Tools (admin / preview) -->
+ <div class="tool-group" style="margin-top:18px;display:flex;flex-wrap:wrap;gap:10px">
+ <button type="button" class="btn-outline" id="btn-room" data-id="${design.id}">See in a Room</button>
+ <button type="button" class="btn-outline" id="btn-spoon-dry" data-id="${design.id}">Spoonflower (dry-run)</button>
+ <button type="button" class="btn-outline" id="btn-spoon-live" data-id="${design.id}">Spoonflower — Upload as New</button>
+ <button type="button" class="btn-outline" id="btn-narrate" data-id="${design.id}">Narrate (ElevenLabs)</button>
+ </div>
+ <div id="tool-result" style="margin-top:16px;display:none;padding:14px;border-radius:6px;background:rgba(0,0,0,.05);font-size:13px;line-height:1.5"></div>
+ <div id="room-result" style="margin-top:14px;display:none">
+ <img id="room-img" alt="Room preview" style="max-width:100%;border-radius:8px;border:1px solid rgba(0,0,0,.08)">
+ </div>
+ <audio id="narrate-audio" controls style="margin-top:10px;display:none;width:100%"></audio>
+
+ <script>
+ (function(){
+ var R = document.getElementById('tool-result');
+ function show(msg, isErr) {
+ R.style.display='block';
+ R.style.background = isErr ? '#f8d7da' : '#d4edda';
+ R.style.color = isErr ? '#721c24' : '#155724';
+ R.innerHTML = msg;
+ }
+ async function post(url, body) {
+ var r = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)});
+ return r.json();
+ }
+
+ document.getElementById('btn-room').addEventListener('click', async function(){
+ show('Generating room preview… (10–30s)', false);
+ this.disabled = true;
+ var j = await post('/api/room', { design_id: this.dataset.id, roomType:'bedroom', angle:'straight_on', cameraDistance:5 });
+ this.disabled = false;
+ if (j.ok) {
+ show('Room preview ready.', false);
+ var img = document.getElementById('room-img');
+ img.src = j.image_url + '?t=' + Date.now();
+ document.getElementById('room-result').style.display = 'block';
+ } else {
+ show('Room generation failed: ' + (j.error||'unknown'), true);
+ }
+ });
+
+ document.getElementById('btn-spoon-dry').addEventListener('click', async function(){
+ show('Spoonflower dry-run: logging in + checking upload page…', false);
+ this.disabled = true;
+ var j = await post('/api/spoonflower/upload', { design_id: this.dataset.id, dry: true });
+ this.disabled = false;
+ if (j.ok) show('Dry-run OK. job=' + j.job_id + '. <a href="/api/spoonflower/status/' + j.job_id + '" target="_blank">view log</a>', false);
+ else show('Dry-run failed: ' + (j.error||'unknown'), true);
+ });
+
+ document.getElementById('btn-spoon-live').addEventListener('click', async function(){
+ if (!confirm('Upload this design to Spoonflower as a NEW product? This is a public publish.')) return;
+ show('Live upload running…', false);
+ this.disabled = true;
+ var j = await post('/api/spoonflower/upload', { design_id: this.dataset.id, dry: false });
+ this.disabled = false;
+ if (j.ok) show('Upload kicked off. job=' + j.job_id + '. <a href="/api/spoonflower/status/' + j.job_id + '" target="_blank">view log</a>', false);
+ else show('Upload failed: ' + (j.error||'unknown'), true);
+ });
+
+ document.getElementById('btn-narrate').addEventListener('click', async function(){
+ show('Generating narration…', false);
+ this.disabled = true;
+ try {
+ var r = await fetch('/api/voice/preview?design_id=' + this.dataset.id);
+ if (!r.ok) { var t = await r.text(); show('Narration failed: ' + t, true); this.disabled=false; return; }
+ var blob = await r.blob();
+ var a = document.getElementById('narrate-audio');
+ a.src = URL.createObjectURL(blob);
+ a.style.display = 'block';
+ a.play();
+ show('Narration ready.', false);
+ } catch(e) { show('Narration error: ' + e.message, true); }
+ this.disabled = false;
+ });
+ })();
+ </script>
+
+ <!-- Provenance note -->
+ <div class="provenance-note">
+ <strong>AI-original provenance:</strong> This pattern was generated by <em>wallco.ai</em> using Replicate SDXL.
+ It does not replicate any existing copyrighted artwork. Every seed is unique — this exact pattern exists only here.
+ </div>
+
+ ${prev || next ? `<div class="detail-nav">
+ ${prev ? `<a href="/design/${prev.id}" class="detail-nav-link prev">← ${prev.title}</a>` : '<span></span>'}
+ ${next ? `<a href="/design/${next.id}" class="detail-nav-link next">${next.title} →</a>` : '<span></span>'}
+ </div>` : ''}
+ </div>
+ </div>
+</main>
+${FOOTER}
+
+<script type="application/ld+json">${schema}</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── MURALS
+app.get('/murals', (req, res) => {
+ const murals = DESIGNS.filter(d => d.kind === 'mural' || d.kind === 'mural_panel');
+ const all = murals.length === 0 ? DESIGNS : murals; // fallback: show all if no mural-kind yet
+
+ const cards = all.map(d => `
+ <a href="/design/${d.id}" class="design-card">
+ <div class="card-img" style="background-image:url('${d.image_url}')"></div>
+ <div class="card-meta">
+ <span class="card-title">${d.title}</span>
+ <span class="card-cat">${murals.length === 0 ? 'available as mural' : d.kind}</span>
+ <span class="color-dot" style="background:${d.dominant_hex}"></span>
</div>
- </div></a>\`;
- }).join('') || '<div class="empty">Empty — run scripts/crawl_pd.py or scripts/generate_designs.js</div>';
+ </a>`).join('');
+
+ res.type('html').send(`${htmlHead({
+ title: 'Murals — wallco.ai | AI-Generated Wall Murals',
+ description: 'AI-original wall murals in any size, panelized for installation. Every design unique.',
+ canonical: 'https://wallco.ai/murals'
+ })}
+<body>
+${htmlHeader('/murals')}
+<main>
+<section class="catalog-section">
+ <div class="section-header" style="padding:40px 40px 0">
+ <h1>Murals</h1>
+ <p>Any design available as a custom-sized mural, panelized for wall installation.
+ ${murals.length === 0 ? 'All current designs can be ordered as murals — contact us.' : `${murals.length} mural designs available.`}</p>
+ </div>
+ <div class="design-grid catalog-grid" style="padding:24px 40px">
+ ${cards}
+ </div>
+</section>
+</main>
+${FOOTER}
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── ABOUT
+app.get('/about', (req, res) => {
+ res.type('html').send(`${htmlHead({
+ title: 'About — wallco.ai | How AI Wallpaper is Made',
+ description: 'Learn how wallco.ai generates original wallpaper patterns using AI. Every design is unique, AI-original, never licensed from a vendor.',
+ canonical: 'https://wallco.ai/about'
+ })}
+<body>
+${htmlHeader('/about')}
+<main class="prose-main">
+ <article class="prose">
+ <h1>How wallco.ai works</h1>
+
+ <h2>AI-original, never licensed</h2>
+ <p>Every pattern on wallco.ai was generated by an AI model — not sourced from a vendor catalog,
+ not licensed from a studio. Each design is produced from a text prompt and a unique random seed,
+ so the pattern that emerges is mathematically unrepeatable.</p>
+
+ <h2>The generation pipeline</h2>
+ <p>We use Replicate SDXL, a state-of-the-art diffusion model trained on millions of images.
+ Source inspiration comes from public-domain artwork in the Metropolitan Museum of Art,
+ Wikimedia Commons, and the Smithsonian Institution — institutions with open licenses
+ (CC0 / Public Domain) that permit derivative AI training and generation.</p>
+
+ <h2>Seamless tiling</h2>
+ <p>Each design is generated as a seamless tile — the edges match on all four sides, so it can
+ repeat across a wall without visible seams. Standard tile size is 24×24 inches at 150 DPI.</p>
+
+ <h2>Murals</h2>
+ <p>Any design can be printed as a custom-sized mural, panelized for your wall dimensions.
+ Panel splitting for installation is handled during order processing.</p>
+
+ <h2>About wallco.ai</h2>
+ <p>wallco.ai is a product of <a href="https://designerwallcoverings.com">Designer Wallcoverings</a>,
+ Sherman Oaks, CA. We combine 30 years of wallcovering expertise with frontier AI generation to
+ create patterns that have never existed before.</p>
+
+ <p><a href="/samples" class="btn-primary">Request a Free Sample</a></p>
+ </article>
+</main>
+${FOOTER}
+
+<script type="application/ld+json">${JSON.stringify({
+ "@context":"https://schema.org",
+ "@type":"WebPage",
+ "name":"About wallco.ai",
+ "url":"https://wallco.ai/about",
+ "description":"How wallco.ai generates original AI wallpaper patterns.",
+ "isPartOf":{"@type":"WebSite","url":"https://wallco.ai","name":"wallco.ai"}
+})}</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── SAMPLES (lead capture — no commerce in v1)
+app.get('/samples', (req, res) => {
+ const designId = req.query.design || '';
+ const design = designId ? DESIGNS.find(d => d.id === parseInt(designId,10)) : null;
+
+ res.type('html').send(`${htmlHead({
+ title: 'Request a Sample — wallco.ai',
+ description: 'Request a free physical or digital sample of any wallco.ai AI-generated design.',
+ canonical: 'https://wallco.ai/samples'
+ })}
+<body>
+${htmlHeader('/samples')}
+<main class="prose-main">
+ <div class="sample-form-wrap">
+ <h1>Request a Free Sample</h1>
+ <p>We'll send you a physical swatch or a high-res digital proof. No charge, no obligation.</p>
+
+ ${design ? `<div class="sample-preview">
+ <img src="${design.image_url}" alt="${design.title}" style="width:120px;height:120px;object-fit:cover;border-radius:4px;">
+ <div><strong>${design.title}</strong><br>Pattern No.${String(design.id).padStart(4,'0')}</div>
+ </div>` : ''}
+
+ <form method="post" action="/api/leads" class="sample-form" id="sample-form">
+ <input type="hidden" name="source" value="samples-page">
+ ${design ? `<input type="hidden" name="design_id" value="${design.id}">` : ''}
+
+ <label>Design
+ <select name="design_id" ${design ? 'disabled' : ''}>
+ ${design
+ ? `<option value="${design.id}" selected>${design.title}</option>`
+ : DESIGNS.map(d => `<option value="${d.id}" ${d.id===parseInt(designId,10)?'selected':''}>${d.title} (No.${d.id})</option>`).join('')
+ }
+ </select>
+ </label>
+ <label>Your name <input type="text" name="name" required placeholder="Jane Smith"></label>
+ <label>Email <input type="email" name="email" required placeholder="you@example.com"></label>
+ <label>Phone (optional) <input type="tel" name="phone" placeholder="+1 (555) 000-0000"></label>
+ <label>Message (optional) <textarea name="message" rows="3" placeholder="Any notes about your project…"></textarea></label>
+ <button type="submit" class="btn-primary">Send Sample Request</button>
+ </form>
+ <div id="form-result" style="display:none;margin-top:16px;padding:16px;border-radius:4px;"></div>
+ </div>
+</main>
+${FOOTER}
+
+<script>
+document.getElementById('sample-form').addEventListener('submit', async function(e) {
+ e.preventDefault();
+ var form = e.target;
+ var result = document.getElementById('form-result');
+ var data = Object.fromEntries(new FormData(form));
+ try {
+ var r = await fetch('/api/leads', {
+ method: 'POST',
+ headers: {'Content-Type':'application/json'},
+ body: JSON.stringify(data)
+ });
+ var json = await r.json();
+ result.style.display = 'block';
+ if (json.ok) {
+ result.style.background = '#d4edda';
+ result.style.color = '#155724';
+ result.textContent = 'Thank you! We\\'ll be in touch shortly.';
+ form.reset();
+ } else {
+ result.style.background = '#f8d7da';
+ result.style.color = '#721c24';
+ result.textContent = 'Something went wrong — please email info@wallco.ai directly.';
+ }
+ } catch(err) {
+ result.style.display = 'block';
+ result.textContent = 'Network error — please try again.';
+ }
+});
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── API: Lead capture
+app.post('/api/leads', async (req, res) => {
+ const { name, email, phone, message, design_id, source } = req.body;
+ if (!email) return res.status(400).json({ ok: false, error: 'email required' });
+
+ // Write to dw_unified.wallco_leads (create table if not exists)
+ try {
+ const { execSync } = require('child_process');
+ const sql = `
+ CREATE TABLE IF NOT EXISTS wallco_leads (
+ id BIGSERIAL PRIMARY KEY,
+ name TEXT,
+ email TEXT NOT NULL,
+ phone TEXT,
+ message TEXT,
+ design_id BIGINT,
+ source TEXT,
+ created_at TIMESTAMPTZ DEFAULT now()
+ );
+ INSERT INTO wallco_leads (name, email, phone, message, design_id, source)
+ VALUES (
+ ${name ? `'${name.replace(/'/g,"''")}'` : 'NULL'},
+ '${email.replace(/'/g,"''")}',
+ ${phone ? `'${phone.replace(/'/g,"''")}'` : 'NULL'},
+ ${message ? `'${message.replace(/'/g,"''")}'` : 'NULL'},
+ ${design_id ? parseInt(design_id,10) : 'NULL'},
+ ${source ? `'${source.replace(/'/g,"''")}'` : 'NULL'}
+ );
+ `;
+ execSync(`psql ${process.env.DATABASE_URL || 'dw_unified'} -c "${sql.replace(/\n/g,' ')}"`, { encoding: 'utf8' });
+ } catch (e) {
+ console.error('Lead insert error:', e.message);
+ // Still return ok — don't block the user on a DB error
+ }
+ res.json({ ok: true });
+});
+
+// ── API: designs JSON (for future use / inline editor)
+app.get('/api/designs', (req, res) => {
+ const sort = req.query.sort || 'newest';
+ const cat = req.query.cat || '';
+ const q = (req.query.q || '').toLowerCase();
+ const page = Math.max(1, parseInt(req.query.page || '1', 10));
+ const PER = parseInt(req.query.limit || '60', 10);
+
+ let filtered = [...DESIGNS];
+ if (cat) filtered = filtered.filter(d => d.category === cat);
+ if (q) filtered = filtered.filter(d =>
+ d.title.toLowerCase().includes(q) || d.category.includes(q)
+ );
+ filtered = sortDesigns(filtered, sort);
+
+ const total = filtered.length;
+ const pages = Math.ceil(total / PER);
+ const items = filtered.slice((page-1)*PER, page*PER);
+ res.json({ ok: true, total, page, pages, items });
+});
+
+// ── API: inline editor state (admin gate)
+app.get('/api/_inline/state', (req, res) => {
+ if (!isAdmin(req)) return res.status(401).json({ ok: false, error: 'unauthorized' });
+ res.json({ ok: true, site: SITE, designCount: DESIGNS.length });
+});
+
+// ─────────────────────────────────────────────────────────────────────────
+// SPOONFLOWER + ROOM PREVIEW + VOICE
+// ─────────────────────────────────────────────────────────────────────────
+
+function findDesign(id) {
+ return DESIGNS.find(d => d.id === parseInt(id, 10));
}
-async function load() {
- const r = await fetch(tab === 'pd' ? '/api/pd' : '/api/designs');
- const data = await r.json();
- render(data);
+function localPathForDesign(d) {
+ // Designs have image_url like /designs/img/<file>.png — map back to data/generated/
+ if (!d) return null;
+ if (d.local_path) return d.local_path;
+ if (d.filename) return path.join(IMG_DIR, d.filename);
+ if (d.image_url && d.image_url.startsWith('/designs/img/')) {
+ return path.join(IMG_DIR, d.image_url.replace('/designs/img/', ''));
+ }
+ return null;
}
-sortSel.addEventListener('change', () => { localStorage.setItem('wc.sort', sortSel.value); load(); });
-dens.addEventListener('input', () => {
- document.documentElement.style.setProperty('--cols', dens.value);
- localStorage.setItem('wc.density', dens.value);
+// ── POST /api/room — generate room visualization for a design
+app.post('/api/room', async (req, res) => {
+ const { design_id, roomType = 'bedroom', angle = 'straight_on',
+ cameraDistance = 5, patternWidth = 27, patternHeight = 27 } = req.body || {};
+ const d = findDesign(design_id);
+ if (!d) return res.status(404).json({ ok: false, error: 'design not found' });
+ const file = localPathForDesign(d);
+ if (!file || !fs.existsSync(file)) {
+ return res.status(404).json({ ok: false, error: 'design file missing on disk', file });
+ }
+ try {
+ const b64 = fs.readFileSync(file).toString('base64');
+ const upstream = await fetch(ROOM_API, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ patternBase64: b64, roomType, angle,
+ cameraDistance, patternWidth, patternHeight
+ })
+ });
+ const j = await upstream.json();
+ if (!j || j.success === false || !j.image) {
+ return res.status(502).json({ ok: false, error: j && j.error || 'upstream error', upstream: j });
+ }
+ // upstream returns { success, image, ... } — `image` is base64 or data-URL
+ const imgB64 = j.image.replace(/^data:image\/\w+;base64,/, '');
+ const fname = `room_${design_id}_${roomType}_${Date.now()}.png`;
+ const outPath = path.join(ROOMS_DIR, fname);
+ fs.writeFileSync(outPath, Buffer.from(imgB64, 'base64'));
+ res.json({ ok: true, image_url: `/uploads/rooms/${fname}`, roomType, design_id });
+ } catch (e) {
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
+// ── POST /api/spoonflower/upload — upload ONE design as new product
+app.post('/api/spoonflower/upload', (req, res) => {
+ const { design_id, dry = true, tags = '' } = req.body || {};
+ const d = findDesign(design_id);
+ if (!d) return res.status(404).json({ ok: false, error: 'design not found' });
+ const file = localPathForDesign(d);
+ if (!file || !fs.existsSync(file)) {
+ return res.status(404).json({ ok: false, error: 'design file missing on disk', file });
+ }
+ const title = `${d.title} — wallco.ai AI-Original`;
+ const tagList = tags || `wallpaper,ai-generated,${d.category||''},wallco,${d.dominant_hex||''}`.replace(/,+$/,'');
+
+ let jobId;
+ try {
+ jobId = psqlQuery(`INSERT INTO spoonflower_jobs(kind, design_id, status, dry_run, params)
+ VALUES('upload_new', ${parseInt(design_id,10)}, 'running', ${dry?'TRUE':'FALSE'},
+ '${JSON.stringify({title, tagList, file}).replace(/'/g,"''")}'::jsonb)
+ RETURNING id;`);
+ } catch (e) { return res.status(500).json({ ok:false, error: 'db: '+e.message }); }
+
+ const logFile = path.join(SF_JOBS_DIR, `upload_${jobId}.log`);
+ const args = [
+ path.join(__dirname, 'scripts', 'upload_spoonflower_new.py'),
+ file, title, '--tags', tagList,
+ ...(dry ? ['--dry'] : ['--live'])
+ ];
+ const proc = spawn('python3', args, {
+ cwd: __dirname,
+ env: { ...process.env, DATABASE_URL: DB_URL },
+ detached: true, stdio: ['ignore', 'pipe', 'pipe']
+ });
+ const out = fs.createWriteStream(logFile);
+ proc.stdout.pipe(out, { end: false });
+ proc.stderr.pipe(out, { end: false });
+ proc.on('exit', code => {
+ out.end();
+ try {
+ psqlQuery(`UPDATE spoonflower_jobs SET status='${code===0?'done':'error'}',
+ finished_at=now(),
+ log='${fs.readFileSync(logFile,'utf8').replace(/'/g,"''").slice(-8000)}'
+ WHERE id=${jobId};`);
+ } catch {}
+ });
+ proc.unref();
+ res.json({ ok: true, job_id: jobId, dry, log_url: `/api/spoonflower/status/${jobId}` });
+});
+
+// ── POST /api/spoonflower/pull-all — bulk pull DW account designs
+app.post('/api/spoonflower/pull-all', (req, res) => {
+ const { limit = 0, account = 'dw' } = req.body || {};
+
+ let jobId;
+ try {
+ jobId = psqlQuery(`INSERT INTO spoonflower_jobs(kind, status, params)
+ VALUES('pull_all', 'running',
+ '${JSON.stringify({limit, account}).replace(/'/g,"''")}'::jsonb)
+ RETURNING id;`);
+ } catch (e) { return res.status(500).json({ ok:false, error:'db: '+e.message }); }
+
+ const logFile = path.join(SF_JOBS_DIR, `pull_${jobId}.log`);
+ const args = [
+ path.join(__dirname, 'scripts', 'pull_spoonflower_account.py'),
+ '--account', String(account)
+ ];
+ if (limit && parseInt(limit,10) > 0) { args.push('--limit', String(parseInt(limit,10))); }
+
+ const proc = spawn('python3', args, {
+ cwd: __dirname,
+ env: { ...process.env, DATABASE_URL: DB_URL },
+ detached: true, stdio: ['ignore', 'pipe', 'pipe']
+ });
+ const out = fs.createWriteStream(logFile);
+ proc.stdout.pipe(out, { end:false });
+ proc.stderr.pipe(out, { end:false });
+ proc.on('exit', code => {
+ out.end();
+ try {
+ psqlQuery(`UPDATE spoonflower_jobs SET status='${code===0?'done':'error'}',
+ finished_at=now(),
+ log='${fs.readFileSync(logFile,'utf8').replace(/'/g,"''").slice(-8000)}'
+ WHERE id=${jobId};`);
+ } catch {}
+ });
+ proc.unref();
+ res.json({ ok:true, job_id: jobId, log_url: `/api/spoonflower/status/${jobId}` });
+});
+
+// ── GET /api/spoonflower/status/:job — return job + log tail
+app.get('/api/spoonflower/status/:job', (req, res) => {
+ const jobId = parseInt(req.params.job, 10);
+ let row;
+ try {
+ row = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id,kind,design_id,status,dry_run,params,created_at,finished_at
+ FROM spoonflower_jobs WHERE id=${jobId}) t;`);
+ } catch (e) { return res.status(500).json({ ok:false, error:'db: '+e.message }); }
+ if (!row) return res.status(404).json({ ok:false, error:'job not found' });
+
+ // Find log file
+ let log = '';
+ for (const f of ['upload', 'pull']) {
+ const p = path.join(SF_JOBS_DIR, `${f}_${jobId}.log`);
+ if (fs.existsSync(p)) { log = fs.readFileSync(p, 'utf8'); break; }
+ }
+ res.type('application/json').send(JSON.stringify({ ok:true, job: JSON.parse(row), log_tail: log.slice(-8000) }, null, 2));
});
-setTab(tab);
-</script>`);
+// ── GET /spoonflower — admin page: pulled DW library + per-design publish buttons
+app.get('/spoonflower', (req, res) => {
+ let pulled = [];
+ try {
+ const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.pulled_at DESC), '[]'::json) FROM (
+ SELECT id, sf_design_id, title, slug, design_url, thumbnail_url, local_path, file_bytes, account, pulled_at
+ FROM spoonflower_pulled_designs LIMIT 2000) t;`);
+ pulled = JSON.parse(raw || '[]');
+ } catch {}
+
+ let recentJobs = [];
+ try {
+ const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.created_at DESC), '[]'::json) FROM (
+ SELECT id, kind, design_id, status, dry_run, created_at, finished_at
+ FROM spoonflower_jobs ORDER BY created_at DESC LIMIT 30) t;`);
+ recentJobs = JSON.parse(raw || '[]');
+ } catch {}
+
+ res.type('html').send(`${htmlHead({
+ title: 'Spoonflower Library — wallco.ai admin',
+ description: 'DW Spoonflower designs (pulled) + upload jobs',
+ canonical: 'https://wallco.ai/spoonflower'
+ })}
+<body>
+${htmlHeader('')}
+<main style="padding:40px">
+ <h1 style="font-family:'Cormorant Garamond',serif;font-weight:300;font-size:32px;margin:0 0 8px">DW Spoonflower Library</h1>
+ <p style="color:#666;margin:0 0 24px">${pulled.length} design${pulled.length===1?'':'s'} pulled · ${recentJobs.length} recent job${recentJobs.length===1?'':'s'}</p>
+
+ <div style="margin-bottom:24px;display:flex;gap:10px;flex-wrap:wrap">
+ <button id="btn-pull-test" class="btn-outline">Pull-all (limit 3, dry test)</button>
+ <button id="btn-pull-all" class="btn-primary">Pull ALL DW designs</button>
+ <span id="pull-status" style="align-self:center;color:#666;font-size:13px"></span>
+ </div>
+
+ <h2 style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:22px;margin:32px 0 12px">Pulled designs</h2>
+ ${pulled.length === 0
+ ? '<p style="color:#999">No designs pulled yet. Click "Pull ALL DW designs" above to start.</p>'
+ : `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:18px">
+ ${pulled.map(p => `<div style="border:1px solid rgba(0,0,0,.08);border-radius:6px;overflow:hidden;background:#fff">
+ ${p.thumbnail_url ? `<img src="${p.thumbnail_url}" style="width:100%;aspect-ratio:1;object-fit:cover" loading="lazy">` : '<div style="aspect-ratio:1;background:#eee"></div>'}
+ <div style="padding:10px 12px;font-size:12px">
+ <div style="font-weight:500">${(p.title||'(no title)').slice(0,48)}</div>
+ <div style="color:#888;margin-top:2px">SF#${p.sf_design_id}${p.file_bytes ? ' · '+Math.round(p.file_bytes/1024)+' KB' : ''}</div>
+ <a href="${p.design_url}" target="_blank" style="color:#888;font-size:11px">view on spoonflower →</a>
+ </div>
+ </div>`).join('')}
+ </div>`}
+
+ <h2 style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:22px;margin:40px 0 12px">Recent jobs</h2>
+ ${recentJobs.length === 0
+ ? '<p style="color:#999">No jobs yet.</p>'
+ : `<table style="width:100%;border-collapse:collapse;font-size:13px">
+ <thead><tr style="text-align:left;border-bottom:1px solid #ddd;color:#666">
+ <th style="padding:8px 6px">ID</th><th>Kind</th><th>Design</th><th>Status</th><th>Dry</th><th>Created</th><th></th>
+ </tr></thead><tbody>
+ ${recentJobs.map(j => `<tr style="border-bottom:1px solid #f0f0f0">
+ <td style="padding:8px 6px">${j.id}</td>
+ <td>${j.kind}</td>
+ <td>${j.design_id || '—'}</td>
+ <td><span style="padding:2px 6px;border-radius:3px;background:${j.status==='done'?'#d4edda':j.status==='error'?'#f8d7da':'#fff3cd'};color:${j.status==='done'?'#155724':j.status==='error'?'#721c24':'#856404'}">${j.status}</span></td>
+ <td>${j.dry_run ? 'yes' : '—'}</td>
+ <td style="color:#888">${j.created_at ? new Date(j.created_at).toLocaleString() : '—'}</td>
+ <td><a href="/api/spoonflower/status/${j.id}" target="_blank" style="color:#888">log →</a></td>
+ </tr>`).join('')}
+ </tbody></table>`}
+</main>
+${FOOTER}
+<script>
+async function post(u, b){ var r = await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); }
+document.getElementById('btn-pull-test').addEventListener('click', async function(){
+ this.disabled=true;
+ document.getElementById('pull-status').textContent = 'Starting limited pull (3 designs)…';
+ var j = await post('/api/spoonflower/pull-all', { limit: 3, account: 'dw' });
+ document.getElementById('pull-status').innerHTML = j.ok
+ ? 'Job '+j.job_id+' running — <a href="/api/spoonflower/status/'+j.job_id+'" target="_blank">view log</a>. Reload in ~60s.'
+ : 'Failed: ' + (j.error||'unknown');
+ this.disabled=false;
+});
+document.getElementById('btn-pull-all').addEventListener('click', async function(){
+ if (!confirm('Pull ALL DW Spoonflower designs? This may take 10-30+ minutes and downloads every file to disk.')) return;
+ this.disabled=true;
+ document.getElementById('pull-status').textContent = 'Starting full pull…';
+ var j = await post('/api/spoonflower/pull-all', { limit: 0, account: 'dw' });
+ document.getElementById('pull-status').innerHTML = j.ok
+ ? 'Job '+j.job_id+' running — <a href="/api/spoonflower/status/'+j.job_id+'" target="_blank">view log</a>. Reload page to refresh table.'
+ : 'Failed: ' + (j.error||'unknown');
+ this.disabled=false;
+});
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
+// ── GET /api/voice/preview — short ElevenLabs narration for a design
+app.get('/api/voice/preview', async (req, res) => {
+ if (!ELEVEN_KEY) return res.status(500).type('text/plain').send('ELEVENLABS_API_KEY not configured');
+ const d = findDesign(req.query.design_id);
+ if (!d) return res.status(404).type('text/plain').send('design not found');
+ const text = `${d.title}. An AI-original wallpaper pattern by wallco. Category: ${d.category}. Dominant color: ${d.dominant_hex}. Generated by ${d.generator}, seed ${d.seed}. Every pattern is unique, never repeated.`;
+ try {
+ const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${ELEVEN_VOICE_ID}`, {
+ method: 'POST',
+ headers: {
+ 'xi-api-key': ELEVEN_KEY,
+ 'Content-Type': 'application/json',
+ 'Accept': 'audio/mpeg'
+ },
+ body: JSON.stringify({
+ text,
+ model_id: 'eleven_turbo_v2_5',
+ voice_settings: { stability: 0.4, similarity_boost: 0.75 }
+ })
+ });
+ if (!r.ok) {
+ const errText = await r.text();
+ return res.status(502).type('text/plain').send('elevenlabs: '+errText.slice(0,400));
+ }
+ res.setHeader('Content-Type', 'audio/mpeg');
+ res.setHeader('Cache-Control', 'no-store');
+ const buf = Buffer.from(await r.arrayBuffer());
+ res.end(buf);
+ } catch (e) {
+ res.status(500).type('text/plain').send('voice error: '+e.message);
+ }
});
-app.listen(PORT, '127.0.0.1', () => console.log(`wallco.ai viewer → http://127.0.0.1:${PORT}`));
+// ── HEALTH
+app.get('/health', (_req, res) => {
+ res.json({ ok: true, site: SITE, count: DESIGNS.length, port: PORT });
+});
+
+// ── Start
+app.listen(PORT, '0.0.0.0', () => {
+ console.log(`wallco.ai production server → http://0.0.0.0:${PORT}`);
+ console.log(` Designs loaded: ${DESIGNS.length}`);
+ console.log(` GA4: ${GA4_ID || '(not set)'}`);
+});
← f5bdf9b wallco.ai: wire Replicate SDXL backend · inline-editor mount
·
back to Wallco Ai
·
add finish_dns.sh — CF DNS + certbot + health check script f f3dc9ad →