← back to Carmelwallpapers
snapshot: 2 file(s) changed, +2 new
1797e17ac243ce5e851ab26d8224c79c71addb14 · 2026-05-13 08:57:46 -0700 · Steve
Files touched
A ecosystem.config.cjsA server.js.pre-sort-skill
Diff
commit 1797e17ac243ce5e851ab26d8224c79c71addb14
Author: Steve <steve@designerwallcoverings.com>
Date: Wed May 13 08:57:46 2026 -0700
snapshot: 2 file(s) changed, +2 new
---
ecosystem.config.cjs | 19 ++
server.js.pre-sort-skill | 477 +++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 496 insertions(+)
diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
new file mode 100644
index 0000000..9aaab5c
--- /dev/null
+++ b/ecosystem.config.cjs
@@ -0,0 +1,19 @@
+module.exports = {
+ apps: [{
+ name: "carmelwallpapers",
+ script: "server.js",
+ cwd: __dirname,
+ env: {
+ NEXT_TELEMETRY_DISABLED: "1",
+ VITE_HOST: "0.0.0.0",
+ HOSTNAME: "0.0.0.0",
+ GUNICORN_BIND: "0.0.0.0:8000",
+ UVICORN_HOST: "0.0.0.0",
+ FLASK_RUN_HOST: "0.0.0.0",
+ BIND: "0.0.0.0",
+ HOST: "0.0.0.0",
+ },
+ autorestart: true,
+ max_memory_restart: "300M",
+ }],
+};
diff --git a/server.js.pre-sort-skill b/server.js.pre-sort-skill
new file mode 100644
index 0000000..05e067f
--- /dev/null
+++ b/server.js.pre-sort-skill
@@ -0,0 +1,477 @@
+'use strict';
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = process.env.PORT || 9832;
+const DATA_PATH = path.join(__dirname, 'data', 'products.json');
+
+// ── Junk filter ──────────────────────────────────────────────────────────────
+const JUNK_TITLE = /(lamp|rug|pillow|throw|tripod|frame|mirror|vase|candle|sculpture|figurine|stool|table|chair|sofa|curtain|bedding|towel|plate|bowl|mug)/i;
+function isJunk(p) {
+ if (!p.image_url) return true;
+ if (!p.product_type || !/wallcovering/i.test(p.product_type)) return true;
+ if (JUNK_TITLE.test(p.title || '')) return true;
+ return false;
+}
+
+// ── Sort helpers ─────────────────────────────────────────────────────────────
+const COLOR_RANK = {
+ black:0,gray:1,grey:1,silver:2,white:3,ivory:4,cream:5,beige:6,brown:7,tan:8,sand:9,stone:10,
+ copper:11,driftwood:12,taupe:13,oatmeal:14,linen:15,parchment:16,wheat:17,
+ red:20,pink:21,coral:22,orange:23,peach:24,salmon:25,
+ yellow:30,gold:31,mustard:32,olive:33,sage:34,
+ green:40,lime:41,mint:42,seafoam:43,teal:50,aqua:51,turquoise:52,cyan:53,
+ blue:60,navy:61,indigo:62,periwinkle:63,
+ purple:70,violet:71,lavender:72,plum:73,
+};
+function colorRank(p) {
+ const tags = (p.tags || []).map(t => String(t).toLowerCase());
+ for (const t of tags) for (const k of Object.keys(COLOR_RANK)) if (t.includes(k)) return COLOR_RANK[k];
+ return 999;
+}
+const STYLE_TAGS = ['grasscloth','linen','raffia','sisal','seagrass','jute','coastal','botanical','woven','natural','organic','bamboo','rattan','texture'];
+function styleKey(p) {
+ const tags = (p.tags || []).map(t => String(t).toLowerCase());
+ for (const s of STYLE_TAGS) if (tags.some(t => t.includes(s))) return s;
+ return 'zzz';
+}
+function sortProducts(list, mode) {
+ if (mode === 'color') return [...list].sort((a,b) => colorRank(a)-colorRank(b) || (a.title||'').localeCompare(b.title||''));
+ if (mode === 'style') return [...list].sort((a,b) => styleKey(a).localeCompare(styleKey(b)) || (a.title||'').localeCompare(b.title||''));
+ if (mode === 'title') return [...list].sort((a,b) => (a.title||'').localeCompare(b.title||''));
+ if (mode === 'vendor') return [...list].sort((a,b) => (a.vendor||'').localeCompare(b.vendor||'') || (a.title||'').localeCompare(b.title||''));
+ if (mode === 'price-asc') return [...list].sort((a,b) => (Number(a.price)||0)-(Number(b.price)||0));
+ if (mode === 'price-desc') return [...list].sort((a,b) => (Number(b.price)||0)-(Number(a.price)||0));
+ return list; // newest = file order
+}
+
+// ── Load + filter data ───────────────────────────────────────────────────────
+let PRODUCTS = [];
+let DROPPED = 0;
+let AESTHETICS = {};
+
+function loadData() {
+ try {
+ const raw = JSON.parse(fs.readFileSync(DATA_PATH, 'utf8'));
+ const clean = raw.filter(p => !isJunk(p));
+ DROPPED = raw.length - clean.length;
+ PRODUCTS = clean;
+ AESTHETICS = {};
+ for (const p of PRODUCTS) {
+ AESTHETICS[p.aesthetic] = (AESTHETICS[p.aesthetic] || 0) + 1;
+ }
+ console.log(`Loaded ${PRODUCTS.length} products (dropped ${DROPPED} junk)`);
+ } catch(e) {
+ console.error('Failed to load products.json:', e.message);
+ PRODUCTS = [];
+ }
+}
+loadData();
+
+const app = express();
+app.use(express.static(path.join(__dirname, 'public')));
+app.set('trust proxy', 1);
+
+// ── HTML helpers ─────────────────────────────────────────────────────────────
+const HTML_HEAD = (title, desc) => `<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<script>(function(){try{var t=localStorage.getItem('cw_theme')||((window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light');document.documentElement.setAttribute('data-theme',t);}catch(e){}})();</script>
+<!-- GA4 -->
+<script async src="https://www.googletagmanager.com/gtag/js?id=G-ENN0QV40F8"></script>
+<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','G-ENN0QV40F8');</script>
+<title>${title}</title>
+<meta name="description" content="${desc}">
+<meta name="theme-color" content="#f7f3ec" media="(prefers-color-scheme: light)">
+<meta name="theme-color" content="#12100d" media="(prefers-color-scheme: dark)">
+<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,300;1,400&family=Inter:wght@300;400;500&display=swap" rel="stylesheet">
+<link rel="stylesheet" href="/css/site.css">
+</head>`;
+
+const THEME_TOGGLE_SCRIPT = `<script>
+(function(){
+ var btn=document.getElementById('theme-toggle');if(!btn)return;
+ function sync(){var t=document.documentElement.getAttribute('data-theme')||'light';btn.setAttribute('aria-pressed',t==='dark'?'true':'false');}
+ sync();
+ btn.addEventListener('click',function(){
+ var cur=document.documentElement.getAttribute('data-theme')||'light';
+ var nxt=cur==='dark'?'light':'dark';
+ document.documentElement.setAttribute('data-theme',nxt);
+ try{localStorage.setItem('cw_theme',nxt);}catch(e){}
+ sync();
+ });
+})();
+</script>`;
+
+const SUN_MOON_SVG = `<button type="button" id="theme-toggle" class="theme-toggle" aria-label="Toggle dark mode" aria-pressed="false" title="Toggle dark / light">
+ <svg class="icon-sun" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
+ <svg class="icon-moon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"/></svg>
+</button>`;
+
+const HEADER_HTML = (q, sort, density) => `
+<header class="site-header">
+ <a href="/" class="brand">CARMEL<small>Wallpapers for the California Coast</small></a>
+ <nav class="site-nav">
+ <a href="/?type=grasscloth">Grasscloth</a>
+ <a href="/?type=linen">Linen</a>
+ <a href="/?type=botanical">Botanical</a>
+ <a href="/?type=coastal">Coastal</a>
+ <a href="/?type=raffia">Raffia</a>
+ <a href="mailto:info@carmelwallpapers.com" class="nav-contact">Contact</a>
+ ${SUN_MOON_SVG}
+ </nav>
+</header>`;
+
+// ── API: products (infinite scroll) ──────────────────────────────────────────
+app.get('/api/products', (req, res) => {
+ const { q, type, sort = 'newest', page = 1, limit = 24 } = req.query;
+ let list = PRODUCTS;
+ if (q) {
+ const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
+ list = list.filter(p => {
+ const hay = ((p.title||'') + ' ' + (p.vendor||'') + ' ' + (p.tags||[]).join(' ')).toLowerCase();
+ return tokens.every(tok => hay.includes(tok));
+ });
+ }
+ if (type && type !== 'all') {
+ list = list.filter(p => p.aesthetic === type);
+ }
+ list = sortProducts(list, sort);
+ const total = list.length;
+ const pageNum = Math.max(1, parseInt(page)||1);
+ const lim = Math.min(60, parseInt(limit)||24);
+ const start = (pageNum-1)*lim;
+ res.json({ total, page: pageNum, limit: lim, pages: Math.ceil(total/lim), sort, items: list.slice(start, start+lim) });
+});
+
+// ── API: sliders ─────────────────────────────────────────────────────────────
+app.get('/api/sliders', (req, res) => {
+ const RAIL_ORDER = ['grasscloth','linen','botanical','coastal','raffia','natural'];
+ const out = [];
+ for (const a of RAIL_ORDER) {
+ const items = PRODUCTS.filter(p => p.aesthetic === a).slice(0, 12);
+ if (items.length >= 4) out.push({ aesthetic: a, items });
+ }
+ res.json({ rails: out });
+});
+
+// ── Health ────────────────────────────────────────────────────────────────────
+app.get('/health', (req, res) => {
+ res.json({ ok: true, products: PRODUCTS.length, dropped: DROPPED, aesthetics: AESTHETICS });
+});
+
+// ── Product detail ────────────────────────────────────────────────────────────
+app.get('/p/:handle', (req, res) => {
+ const p = PRODUCTS.find(x => x.handle === req.params.handle);
+ if (!p) return res.status(404).send('<h1>Not found</h1>');
+
+ const title = `${p.title} — Carmel Wallpapers | Free Memo Samples, Trade Service`;
+ const desc = `${p.title} by ${p.vendor}. Order a free memo sample. Coastal California's curated wallpaper source — expert trade service, hand-holding from Carmel to Pebble Beach.`;
+
+ const imgs = (p.images||[p.image_url]).filter(Boolean);
+ const imgHtml = imgs.map((src,i) =>
+ `<img src="${src}" alt="${p.title}${i?` view ${i+1}`:''}${i===0?' (main)':''}" loading="${i===0?'eager':'lazy'}" class="detail-img${i===0?' detail-img--main':''}">`
+ ).join('\n');
+
+ const tags = (p.tags||[]).filter(t => !/^\d+$/.test(t));
+
+ res.send(`${HTML_HEAD(title, desc)}
+<body>
+${HEADER_HTML()}
+<main class="detail-wrap">
+ <a href="/" class="back-link">← Back to collection</a>
+ <div class="detail-grid">
+ <div class="detail-images">${imgHtml}</div>
+ <div class="detail-info">
+ <p class="detail-vendor">${p.vendor}</p>
+ <h1 class="detail-title">${p.title}</h1>
+ <div class="detail-tags">${tags.slice(0,10).map(t=>`<span class="tag">${t}</span>`).join('')}</div>
+ <div class="detail-ctas">
+ <a href="${p.sample_url}" class="btn btn-sample" target="_blank" rel="noopener">Order Memo Sample (free)</a>
+ <a href="${p.product_url}" class="btn btn-full" target="_blank" rel="noopener">View Full Product</a>
+ </div>
+ <div class="detail-service">
+ <h3>Carmel Trade Service</h3>
+ <p>Designer Wallcoverings provides complimentary trade hand-holding for every Carmel and Pebble Beach project — from sampling through installation. <a href="mailto:info@carmelwallpapers.com">Reach us at info@carmelwallpapers.com</a>.</p>
+ </div>
+ </div>
+ </div>
+</main>
+<footer class="site-footer">
+ <div class="footer-inner">
+ <p>Carmel Wallpapers — a Designer Wallcoverings service</p>
+ <p><a href="mailto:info@carmelwallpapers.com">info@carmelwallpapers.com</a></p>
+ <p class="footer-fine">Serving Carmel-by-the-Sea, Pebble Beach & Big Sur</p>
+ </div>
+</footer>
+<script type="application/ld+json">${JSON.stringify({
+ "@context":"https://schema.org","@type":"Product","name":p.title,"brand":{"@type":"Brand","name":p.vendor},
+ "image":imgs[0]||'',
+ "offers":{"@type":"Offer","priceCurrency":"USD","price":"0","description":"Free memo sample"},
+ "description":desc
+})}</script>
+${THEME_TOGGLE_SCRIPT}
+</body></html>`);
+});
+
+// ── Index (catalog) ──────────────────────────────────────────────────────────
+app.get('/', (req, res) => {
+ const { q='', type='', sort='newest', density='220' } = req.query;
+
+ const pageTitle = q
+ ? `"${q}" — Carmel Wallpapers`
+ : type
+ ? `${type.charAt(0).toUpperCase()+type.slice(1)} Wallpapers — Carmel, Pebble Beach & Big Sur`
+ : 'Carmel Wallpapers — Coastal California Luxury Wallcoverings';
+ const pageDesc = 'Curated grasscloth, linen, raffia and coastal-botanical wallcoverings for Carmel-by-the-Sea, Pebble Beach and Big Sur residences. Free memo samples. Expert trade service from Designer Wallcoverings.';
+
+ // Facet counts for filter chips
+ const aestheticCounts = {};
+ for (const p of PRODUCTS) {
+ aestheticCounts[p.aesthetic] = (aestheticCounts[p.aesthetic]||0)+1;
+ }
+
+ const aestheticChips = ['grasscloth','linen','botanical','coastal','raffia','natural']
+ .filter(a => aestheticCounts[a])
+ .map(a => {
+ const active = type === a ? ' chip--active' : '';
+ const href = type === a ? '/' : `/?type=${a}${q?'&q='+encodeURIComponent(q):''}`;
+ return `<a href="${href}" class="chip${active}">${a.charAt(0).toUpperCase()+a.slice(1)} <span class="chip-count">${aestheticCounts[a]}</span></a>`;
+ }).join('');
+
+ res.send(`${HTML_HEAD(pageTitle, pageDesc)}
+<body>
+${HEADER_HTML(q, sort, density)}
+
+<section class="hero" aria-label="Collection introduction">
+ <div class="hero-text">
+ <h1 class="hero-headline">CARMEL</h1>
+ <p class="hero-sub">Quiet luxury for the California coast.</p>
+ <p class="hero-body">Natural fibers. Coastal botanicals. The restraint of Carmel-by-the-Sea captured in texture and tone — curated from the world's finest wallcovering houses.</p>
+ <a href="#catalog" class="hero-cta">Explore the Collection</a>
+ </div>
+</section>
+
+<section class="sliders-section" id="sliders" aria-label="Browse by aesthetic">
+ <div class="sliders-wrap">
+ <div id="sliders-container"></div>
+ </div>
+</section>
+
+<section class="catalog-section" id="catalog">
+ <div class="catalog-controls">
+ <form class="search-form" method="GET" action="/" role="search">
+ ${type?`<input type="hidden" name="type" value="${type}">`:''}
+ <input type="search" name="q" value="${q.replace(/"/g,'"')}" placeholder="Search ${PRODUCTS.length.toLocaleString()} wallcoverings…" class="search-input" aria-label="Search wallcoverings">
+ <button type="submit" class="search-btn" aria-label="Search">
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
+ </button>
+ </form>
+ <div class="sort-density">
+ <select id="sortSelect" class="sort-select" aria-label="Sort products">
+ <option value="newest" ${sort==='newest'?'selected':''}>Newest</option>
+ <option value="color" ${sort==='color'?'selected':''}>Color</option>
+ <option value="style" ${sort==='style'?'selected':''}>Style</option>
+ <option value="title" ${sort==='title'?'selected':''}>Title A→Z</option>
+ <option value="vendor" ${sort==='vendor'?'selected':''}>Vendor A→Z</option>
+ <option value="price-asc" ${sort==='price-asc'?'selected':''}>Price ↑</option>
+ <option value="price-desc" ${sort==='price-desc'?'selected':''}>Price ↓</option>
+ </select>
+ <label class="density-label" aria-label="Grid density">
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><rect x="0" y="0" width="4" height="4"/><rect x="6" y="0" width="4" height="4"/><rect x="12" y="0" width="4" height="4"/><rect x="0" y="6" width="4" height="4"/><rect x="6" y="6" width="4" height="4"/><rect x="12" y="6" width="4" height="4"/></svg>
+ <input type="range" id="densitySlider" min="140" max="320" step="20" value="${density}" aria-label="Grid column width">
+ </label>
+ </div>
+ </div>
+ <div class="facet-chips" aria-label="Filter by aesthetic">
+ <a href="/${q?'?q='+encodeURIComponent(q):''}" class="chip${!type?' chip--active':''}">All <span class="chip-count">${PRODUCTS.length}</span></a>
+ ${aestheticChips}
+ </div>
+ <div id="grid" class="product-grid" role="list" style="--cols:${density}px"></div>
+ <div id="sentinel" class="sentinel" aria-hidden="true"></div>
+ <div id="grid-status" class="grid-status"></div>
+</section>
+
+<footer class="site-footer">
+ <div class="footer-inner">
+ <div class="footer-col">
+ <strong>Carmel Wallpapers</strong>
+ <p>A Designer Wallcoverings service for the Carmel Coast.</p>
+ <p><a href="mailto:info@carmelwallpapers.com">info@carmelwallpapers.com</a></p>
+ </div>
+ <div class="footer-col">
+ <strong>Our Service</strong>
+ <ul>
+ <li>Free memo samples on every pattern</li>
+ <li>Trade pricing available</li>
+ <li>Installation guidance</li>
+ <li>Serving Carmel, Pebble Beach & Big Sur</li>
+ </ul>
+ </div>
+ <div class="footer-col">
+ <strong>Aesthetics</strong>
+ <ul>
+ ${['grasscloth','linen','botanical','coastal','raffia','natural'].map(a=>`<li><a href="/?type=${a}">${a.charAt(0).toUpperCase()+a.slice(1)}</a></li>`).join('')}
+ </ul>
+ </div>
+ </div>
+ <p class="footer-legal">© 2026 Carmel Wallpapers · Curated by Designer Wallcoverings · <a href="mailto:info@carmelwallpapers.com">info@carmelwallpapers.com</a></p>
+</footer>
+
+<script type="application/ld+json">${JSON.stringify({
+ "@context":"https://schema.org",
+ "@type":"LocalBusiness",
+ "name":"Carmel Wallpapers",
+ "description":pageDesc,
+ "url":"https://carmelwallpapers.com",
+ "email":"info@carmelwallpapers.com",
+ "areaServed":["Carmel-by-the-Sea","Pebble Beach","Big Sur","Monterey County"],
+ "sameAs":["https://designerwallcoverings.com"]
+})}</script>
+
+<script>
+// ── State ────────────────────────────────────────────────────────────────────
+const STATE = {
+ q: ${JSON.stringify(q)},
+ type: ${JSON.stringify(type)},
+ sort: ${JSON.stringify(sort)},
+ page: 1,
+ pages: 1,
+ total: 0,
+ loading: false,
+ exhausted: false
+};
+
+// ── Persist sort + density ───────────────────────────────────────────────────
+const sortSel = document.getElementById('sortSelect');
+const densSlider = document.getElementById('densitySlider');
+const grid = document.getElementById('grid');
+
+const savedSort = localStorage.getItem('cw_sort');
+const savedDensity = localStorage.getItem('cw_density');
+if(savedSort && !${JSON.stringify(!!req.query.sort)}) { sortSel.value = savedSort; STATE.sort = savedSort; }
+if(savedDensity) { densSlider.value = savedDensity; grid.style.setProperty('--cols', savedDensity+'px'); }
+
+sortSel.addEventListener('change', () => {
+ STATE.sort = sortSel.value;
+ localStorage.setItem('cw_sort', STATE.sort);
+ const url = new URL(location.href);
+ url.searchParams.set('sort', STATE.sort);
+ location.href = url.toString();
+});
+densSlider.addEventListener('input', () => {
+ const v = densSlider.value;
+ grid.style.setProperty('--cols', v+'px');
+ localStorage.setItem('cw_density', v);
+});
+
+// ── Card renderer ────────────────────────────────────────────────────────────
+function renderCard(p) {
+ const div = document.createElement('div');
+ div.className = 'product-card';
+ div.setAttribute('role','listitem');
+ div.innerHTML = \`
+ <a href="/p/\${p.handle}" class="card-link" aria-label="\${p.title.replace(/"/g,'"')}">
+ <div class="card-img-wrap">
+ <img src="\${p.image_url}" alt="\${p.title.replace(/"/g,'"')}" loading="lazy" class="card-img">
+ </div>
+ <div class="card-body">
+ <p class="card-vendor">\${p.vendor}</p>
+ <p class="card-title">\${p.title}</p>
+ <p class="card-aesthetic">\${p.aesthetic}</p>
+ </div>
+ </a>
+ <a href="\${p.sample_url}" class="card-sample" target="_blank" rel="noopener" aria-label="Order free memo sample of \${p.title.replace(/"/g,'"')}">Order Memo Sample (free)</a>
+ \`;
+ return div;
+}
+
+// ── Load page ────────────────────────────────────────────────────────────────
+async function loadPage() {
+ if (STATE.loading || STATE.exhausted) return;
+ STATE.loading = true;
+ const status = document.getElementById('grid-status');
+ status.textContent = 'Loading…';
+ const params = new URLSearchParams({ sort: STATE.sort, page: STATE.page, limit: 24 });
+ if (STATE.q) params.set('q', STATE.q);
+ if (STATE.type) params.set('type', STATE.type);
+ try {
+ const res = await fetch('/api/products?' + params);
+ const data = await res.json();
+ STATE.pages = data.pages;
+ STATE.total = data.total;
+ if (STATE.page === 1) {
+ status.textContent = \`\${data.total.toLocaleString()} wallcoverings\`;
+ }
+ for (const p of data.items) grid.appendChild(renderCard(p));
+ if (STATE.page >= data.pages) {
+ STATE.exhausted = true;
+ status.textContent = \`— end of archive · \${data.total.toLocaleString()} patterns —\`;
+ } else {
+ STATE.page++;
+ status.textContent = \`\${data.total.toLocaleString()} wallcoverings · \${STATE.page-1} of \${data.pages} pages loaded\`;
+ }
+ } catch(e) {
+ status.textContent = 'Error loading products.';
+ console.error(e);
+ }
+ STATE.loading = false;
+}
+
+// ── Infinite scroll ──────────────────────────────────────────────────────────
+const sentinel = document.getElementById('sentinel');
+const observer = new IntersectionObserver((entries) => {
+ if (entries[0].isIntersecting) loadPage();
+}, { rootMargin: '600px 0px' });
+observer.observe(sentinel);
+
+// ── Grid sliders ─────────────────────────────────────────────────────────────
+async function loadSliders() {
+ try {
+ const res = await fetch('/api/sliders');
+ const data = await res.json();
+ const container = document.getElementById('sliders-container');
+ if (!data.rails || !data.rails.length) { container.closest('.sliders-section').style.display='none'; return; }
+ for (const rail of data.rails) {
+ const label = rail.aesthetic.charAt(0).toUpperCase() + rail.aesthetic.slice(1);
+ const trackId = 'track-' + rail.aesthetic;
+ const section = document.createElement('div');
+ section.className = 'rail';
+ section.innerHTML = \`
+ <div class="rail-header">
+ <h2 class="rail-title"><a href="/?type=\${rail.aesthetic}" class="rail-link">\${label}</a></h2>
+ <div class="rail-controls">
+ <button class="rail-btn rail-prev" aria-label="Scroll \${label} left">←</button>
+ <button class="rail-btn rail-next" aria-label="Scroll \${label} right">→</button>
+ </div>
+ </div>
+ <div class="rail-track" id="\${trackId}">
+ \${rail.items.map(p=>\`
+ <a href="/p/\${p.handle}" class="rail-card" aria-label="\${(p.title||'').replace(/"/g,'"')}">
+ <div class="rail-img-wrap"><img src="\${p.image_url}" alt="\${(p.title||'').replace(/"/g,'"')}" loading="lazy" class="rail-img"></div>
+ <p class="rail-card-title">\${p.title}</p>
+ <p class="rail-card-vendor">\${p.vendor}</p>
+ </a>
+ \`).join('')}
+ </div>
+ \`;
+ const track = section.querySelector('.rail-track');
+ section.querySelector('.rail-prev').addEventListener('click', () => track.scrollBy({ left: -track.clientWidth*.85, behavior:'smooth' }));
+ section.querySelector('.rail-next').addEventListener('click', () => track.scrollBy({ left: track.clientWidth*.85, behavior:'smooth' }));
+ container.appendChild(section);
+ }
+ } catch(e) { console.error('Sliders error:', e); }
+}
+
+loadSliders();
+</script>
+${THEME_TOGGLE_SCRIPT}
+</body></html>`);
+});
+
+app.listen(PORT, () => console.log(`carmelwallpapers listening on :${PORT}`));
← ade7d84 fleet-grid refine: all-pass (chip-ratio canonical reference
·
back to Carmelwallpapers
·
chore: untrack server.js.pre-sort-skill snapshot, broaden .g d43943c →