← back to Patterndesignlab
designer accounts: bcrypt login + JWT cookie, self-edit endpoint, image upload (upload/URL/generated), patternbank-style profile with socials + share, login + dashboard pages
a4e12d5fb1963473faebadcbf72f6bccc93cb1f3 · 2026-07-05 18:07:11 -0700 · Steve
Files touched
A public/designer-dashboard.htmlA public/designer-login.htmlM public/designer.htmlM server.js
Diff
commit a4e12d5fb1963473faebadcbf72f6bccc93cb1f3
Author: Steve <steve@designerwallcoverings.com>
Date: Sun Jul 5 18:07:11 2026 -0700
designer accounts: bcrypt login + JWT cookie, self-edit endpoint, image upload (upload/URL/generated), patternbank-style profile with socials + share, login + dashboard pages
---
public/designer-dashboard.html | 167 +++++++++++++++++++++++++++++++++++++++++
public/designer-login.html | 54 +++++++++++++
public/designer.html | 129 +++++++++++++++++++++++--------
server.js | 79 +++++++++++++++++++
4 files changed, 397 insertions(+), 32 deletions(-)
diff --git a/public/designer-dashboard.html b/public/designer-dashboard.html
new file mode 100644
index 0000000..72d224f
--- /dev/null
+++ b/public/designer-dashboard.html
@@ -0,0 +1,167 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Your studio · Pattern Design Lab</title>
+<link rel="stylesheet" href="/styles.css">
+<style>
+ .dash{max-width:760px;margin:32px auto 80px;padding:0 20px}
+ .dash h1{font-size:26px;margin:0 0 2px}
+ .dash .sub{color:#6b6b6b;font-size:13px;margin:0 0 22px}
+ .sec{border:1px solid #ebebeb;border-radius:14px;padding:20px 22px;margin:0 0 18px}
+ .sec h2{font-size:15px;text-transform:uppercase;letter-spacing:.05em;color:#444;margin:0 0 14px}
+ label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#555;margin:12px 0 5px}
+ input,textarea{width:100%;box-sizing:border-box;padding:10px 12px;border:1px solid #ddd;border-radius:9px;font-size:14px;font-family:inherit}
+ textarea{min-height:88px;resize:vertical}
+ input:focus,textarea:focus{outline:none;border-color:var(--accent-ink,#1c1c1c)}
+ .row2{display:grid;grid-template-columns:1fr 1fr;gap:14px}
+ .row3{display:grid;grid-template-columns:1fr 1fr 1fr;gap:14px}
+ .imgrow{display:flex;gap:18px;align-items:center;flex-wrap:wrap}
+ .prev{width:88px;height:88px;border-radius:12px;object-fit:cover;background:#f0f0f0;border:1px solid #e5e5e5}
+ .prev.logo{border-radius:8px;object-fit:contain;background:#fafafa}
+ .imgopts{flex:1;min-width:240px}
+ .hint{font-size:12px;color:#8a8a8a;margin:4px 0 0}
+ .bar2{position:sticky;bottom:0;background:#fff;border-top:1px solid #eee;padding:14px 0;display:flex;gap:10px;align-items:center;margin-top:8px}
+ .btn{display:inline-flex;align-items:center;gap:6px;font-size:14px;border:1px solid #ddd;background:#fff;color:#222;padding:9px 16px;border-radius:999px;cursor:pointer;text-decoration:none}
+ .btn:hover{background:#f6f6f6}
+ .btn.pri{background:var(--accent-ink,#1c1c1c);color:#fff;border-color:transparent}
+ .btn.pri:disabled{opacity:.6}
+ .ok{color:#1a7f4b;font-size:13px}
+ .err{color:#c0392b;font-size:13px}
+ .filebtn{position:relative;overflow:hidden;display:inline-block}
+ .filebtn input[type=file]{position:absolute;inset:0;opacity:0;cursor:pointer}
+ .topnav{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}
+</style>
+</head>
+<body>
+<header class="top"><div class="wrap">
+ <a class="brand" href="/">Pattern Design<span class="dot">.</span>Lab</a>
+ <nav class="navlinks"><a id="viewpub" href="#" target="_blank">View public page ↗</a> <a href="#" id="logout">Sign out</a></nav>
+</div></header>
+<div class="dash" id="dash"><p style="padding:40px 0;color:#6b6b6b">Loading…</p></div>
+<script>
+const $=id=>document.getElementById(id);
+const esc=s=>(s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+let D=null;
+
+async function load(){
+ const r=await fetch('/api/designer/me');
+ if(!r.ok){ location.href='/designer-login'; return; }
+ D=(await r.json()).designer;
+ $('viewpub').href='/designer/'+D.slug;
+ render();
+}
+function field(id,label,val,ph){ return `<label for="${id}">${label}</label><input id="${id}" value="${esc(val||'')}" placeholder="${ph||''}">`; }
+
+function render(){
+ $('dash').innerHTML=`
+ <h1>${esc(D.name)}</h1>
+ <p class="sub">Your studio profile — everything here shows on your public page.</p>
+
+ <div class="sec">
+ <h2>Photo & logo</h2>
+ <div class="imgrow" style="margin-bottom:16px">
+ <img class="prev" id="avPrev" src="${esc(D.avatar||'')}" alt="">
+ <div class="imgopts">
+ <strong style="font-size:14px">Studio photo</strong>
+ <div class="hint">Your generated photo is set. Replace it by uploading, or paste an image URL.</div>
+ <div style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap">
+ <span class="btn filebtn">Upload photo<input type="file" accept="image/*" onchange="doUpload(this,'avatar')"></span>
+ <input id="avUrl" placeholder="…or paste image URL" style="flex:1;min-width:180px" value="">
+ <button class="btn" onclick="setUrl('avatar')">Use URL</button>
+ </div>
+ </div>
+ </div>
+ <div class="imgrow">
+ <img class="prev logo" id="logoPrev" src="${esc(D.logo||'')}" alt="" ${D.logo?'':'style="display:none"'}>
+ <div class="imgopts">
+ <strong style="font-size:14px">Your logo</strong>
+ <div class="hint">Optional — upload your own studio logo (shows above your name).</div>
+ <div style="display:flex;gap:8px;margin-top:8px;flex-wrap:wrap">
+ <span class="btn filebtn">Upload logo<input type="file" accept="image/*" onchange="doUpload(this,'logo')"></span>
+ <input id="logoUrl" placeholder="…or paste logo URL" style="flex:1;min-width:180px" value="">
+ <button class="btn" onclick="setUrl('logo')">Use URL</button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="sec">
+ <h2>Studio details</h2>
+ ${field('name','Studio name',D.name)}
+ ${field('tagline','Tagline',D.tagline,'e.g. Botanicals from the forest floor')}
+ ${field('founder_name','Founder / lead designer',D.founder_name)}
+ <label for="bio">Bio</label><textarea id="bio" placeholder="Tell buyers about your studio…">${esc(D.bio||'')}</textarea>
+ <div class="row3">
+ <div>${field('city','City',D.city)}</div>
+ <div>${field('state_region','Region',D.state_region)}</div>
+ <div>${field('country','Country',D.country)}</div>
+ </div>
+ <label for="accent_hex">Accent color</label>
+ <input id="accent_hex" type="text" value="${esc(D.accent_hex||'#1c1c1c')}" placeholder="#1c1c1c" style="max-width:160px">
+ </div>
+
+ <div class="sec">
+ <h2>Social links & sharing</h2>
+ <div class="hint" style="margin-bottom:6px">Handles or full URLs — these become share/follow buttons on your public page.</div>
+ <div class="row2">
+ <div>${field('website','Website',D.website,'https://…')}</div>
+ <div>${field('instagram','Instagram',D.instagram,'@handle')}</div>
+ <div>${field('pinterest','Pinterest',D.pinterest,'handle')}</div>
+ <div>${field('tiktok','TikTok',D.tiktok,'@handle')}</div>
+ <div>${field('facebook','Facebook',D.facebook,'handle')}</div>
+ <div>${field('twitter','X / Twitter',D.twitter,'@handle')}</div>
+ <div>${field('youtube','YouTube',D.youtube,'@handle')}</div>
+ <div>${field('linkedin','LinkedIn',D.linkedin,'company')}</div>
+ </div>
+ </div>
+
+ <div class="bar2">
+ <button class="btn pri" id="save" onclick="save()">Save changes</button>
+ <a class="btn" href="/designer/${esc(D.slug)}" target="_blank">Preview ↗</a>
+ <span id="msg"></span>
+ </div>`;
+}
+
+const FIELDS=['name','tagline','founder_name','bio','city','state_region','country','accent_hex','website','instagram','pinterest','tiktok','facebook','twitter','youtube','linkedin'];
+async function save(){
+ $('save').disabled=true; $('msg').textContent='';
+ const body={}; FIELDS.forEach(k=>{const el=$(k); if(el) body[k]=el.value;});
+ try{
+ const r=await fetch('/api/designer/me',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
+ const j=await r.json();
+ if(!r.ok){ $('msg').innerHTML='<span class="err">'+(j.error||'Save failed')+'</span>'; }
+ else { D=j.designer; $('msg').innerHTML='<span class="ok">Saved ✓</span>'; }
+ }catch{ $('msg').innerHTML='<span class="err">Network error</span>'; }
+ $('save').disabled=false;
+}
+async function doUpload(input,type){
+ if(!input.files||!input.files[0]) return;
+ const fd=new FormData(); fd.append('file',input.files[0]);
+ $('msg').textContent='Uploading…';
+ try{
+ const r=await fetch('/api/designer/upload?type='+type,{method:'POST',body:fd});
+ const j=await r.json();
+ if(!r.ok){ $('msg').innerHTML='<span class="err">'+(j.error||'Upload failed')+'</span>'; return; }
+ D[type]=j.url; applyImg(type,j.url); $('msg').innerHTML='<span class="ok">'+type+' updated ✓</span>';
+ }catch{ $('msg').innerHTML='<span class="err">Upload error</span>'; }
+}
+async function setUrl(type){
+ const url=$(type==='avatar'?'avUrl':'logoUrl').value.trim(); if(!url) return;
+ try{
+ const r=await fetch('/api/designer/me',{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({[type]:url})});
+ const j=await r.json();
+ if(!r.ok){ $('msg').innerHTML='<span class="err">'+(j.error||'Failed')+'</span>'; return; }
+ D=j.designer; applyImg(type,url); $('msg').innerHTML='<span class="ok">'+type+' updated ✓</span>';
+ }catch{ $('msg').innerHTML='<span class="err">Error</span>'; }
+}
+function applyImg(type,url){
+ const el=$(type==='avatar'?'avPrev':'logoPrev');
+ if(el){ el.src=url; el.style.display=''; }
+}
+$('logout').addEventListener('click',async e=>{ e.preventDefault(); await fetch('/api/designer/logout',{method:'POST'}); location.href='/designer-login'; });
+load();
+</script>
+</body>
+</html>
diff --git a/public/designer-login.html b/public/designer-login.html
new file mode 100644
index 0000000..a135ec5
--- /dev/null
+++ b/public/designer-login.html
@@ -0,0 +1,54 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Designer sign in · Pattern Design Lab</title>
+<link rel="stylesheet" href="/styles.css">
+<style>
+ .authwrap{max-width:380px;margin:64px auto;padding:0 20px}
+ .card2{border:1px solid #e7e7e7;border-radius:16px;padding:28px;box-shadow:0 4px 18px rgba(0,0,0,.05)}
+ .card2 h1{font-size:22px;margin:0 0 4px}
+ .card2 p.sub{color:#6b6b6b;font-size:13px;margin:0 0 20px}
+ label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#555;margin:14px 0 5px}
+ input{width:100%;box-sizing:border-box;padding:11px 12px;border:1px solid #ddd;border-radius:10px;font-size:15px}
+ input:focus{outline:none;border-color:var(--accent-ink,#1c1c1c)}
+ .go{width:100%;margin-top:20px;padding:12px;border:0;border-radius:999px;background:var(--accent-ink,#1c1c1c);color:#fff;font-size:15px;cursor:pointer}
+ .go:disabled{opacity:.6;cursor:default}
+ .err{color:#c0392b;font-size:13px;margin-top:12px;min-height:16px}
+</style>
+</head>
+<body>
+<header class="top"><div class="wrap">
+ <a class="brand" href="/">Pattern Design<span class="dot">.</span>Lab</a>
+ <nav class="navlinks"><a href="/">Explore</a></nav>
+</div></header>
+<div class="authwrap">
+ <div class="card2">
+ <h1>Designer sign in</h1>
+ <p class="sub">Manage your studio profile and portfolio.</p>
+ <form id="f" onsubmit="return false">
+ <label for="email">Email</label>
+ <input id="email" type="email" autocomplete="username" placeholder="you@studio.com">
+ <label for="password">Password</label>
+ <input id="password" type="password" autocomplete="current-password" placeholder="••••••••">
+ <button class="go" id="go" type="submit">Sign in</button>
+ <div class="err" id="err"></div>
+ </form>
+ </div>
+</div>
+<script>
+const $=id=>document.getElementById(id);
+$('f').addEventListener('submit',async()=>{
+ $('err').textContent=''; $('go').disabled=true;
+ try{
+ const r=await fetch('/api/designer/login',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({email:$('email').value,password:$('password').value})});
+ const j=await r.json();
+ if(!r.ok){ $('err').textContent=j.error||'Sign in failed'; $('go').disabled=false; return; }
+ location.href='/designer-dashboard';
+ }catch(e){ $('err').textContent='Network error'; $('go').disabled=false; }
+});
+</script>
+</body>
+</html>
diff --git a/public/designer.html b/public/designer.html
index b2d99c4..59b5de6 100644
--- a/public/designer.html
+++ b/public/designer.html
@@ -5,11 +5,36 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Designer · Pattern Design Lab</title>
<link rel="stylesheet" href="/styles.css">
+<style>
+ .dprofile{padding:28px 0 8px}
+ .dhead{display:flex;gap:24px;align-items:flex-start;flex-wrap:wrap}
+ .dphoto{width:120px;height:120px;border-radius:50%;object-fit:cover;background:#eee;flex:0 0 auto;box-shadow:0 2px 10px rgba(0,0,0,.12)}
+ .dlogo{max-height:34px;max-width:160px;object-fit:contain;margin-bottom:8px;display:block}
+ .dname{font-size:30px;line-height:1.1;margin:0 0 4px}
+ .dtag{color:#6b6b6b;font-style:italic;margin:0 0 6px}
+ .dmeta{font-size:13px;color:#6b6b6b;margin:2px 0}
+ .dbio{max-width:620px;margin:14px 0 4px;line-height:1.5}
+ .dactions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0 4px}
+ .btn{display:inline-flex;align-items:center;gap:6px;font-size:13px;border:1px solid #ddd;background:#fff;color:#222;
+ padding:7px 12px;border-radius:999px;cursor:pointer;text-decoration:none}
+ .btn:hover{background:#f6f6f6}
+ .btn.pri{background:var(--accent-ink,#1c1c1c);color:#fff;border-color:transparent}
+ .socials{display:flex;gap:10px;flex-wrap:wrap;margin:10px 0}
+ .socials a{font-size:12px;letter-spacing:.03em;text-transform:uppercase;color:#444;text-decoration:none;border-bottom:1px solid #ddd;padding-bottom:1px}
+ .socials a:hover{color:var(--accent-ink,#1c1c1c);border-color:currentColor}
+ .share{position:relative}
+ .sharemenu{position:absolute;top:110%;left:0;background:#fff;border:1px solid #e5e5e5;border-radius:12px;padding:6px;
+ box-shadow:0 8px 24px rgba(0,0,0,.12);display:none;z-index:20;min-width:170px}
+ .sharemenu.open{display:block}
+ .sharemenu a,.sharemenu button{display:block;width:100%;text-align:left;background:none;border:0;padding:8px 10px;font-size:13px;
+ color:#222;border-radius:8px;cursor:pointer;text-decoration:none}
+ .sharemenu a:hover,.sharemenu button:hover{background:#f4f4f4}
+</style>
</head>
<body>
<header class="top"><div class="wrap">
<a class="brand" href="/">Pattern Design<span class="dot">.</span>Lab</a>
- <nav class="navlinks"><a href="/">Explore</a></nav>
+ <nav class="navlinks"><a href="/">Explore</a> <a href="/designer-login">Designer sign in</a></nav>
</div></header>
<div class="wrap" id="root"><p style="padding:40px 0;color:#6b6b6b">Loading…</p></div>
<footer><div class="wrap">Pattern Design Lab · owned, settlement-passed originals only</div></footer>
@@ -19,24 +44,33 @@ function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&
function fmtPrice(lo){ return lo==null?'Inquire':'$'+lo; }
let DESIGNS = [];
-// same sort vocabulary as the main grid, so every product grid behaves identically (DW standing rule)
+function socialUrl(type,h){
+ if(!h) return null;
+ h=String(h).trim(); if(/^https?:\/\//.test(h)&&type!=='website') return h;
+ const bare=h.replace(/^@/,'');
+ return ({
+ website:h, instagram:'https://instagram.com/'+bare, pinterest:'https://pinterest.com/'+bare,
+ tiktok:'https://tiktok.com/@'+bare, facebook:'https://facebook.com/'+bare, twitter:'https://twitter.com/'+bare,
+ youtube:'https://youtube.com/@'+bare, linkedin:'https://linkedin.com/company/'+bare
+ })[type]||null;
+}
+const SOCIAL_LABELS={website:'Website',instagram:'Instagram',pinterest:'Pinterest',tiktok:'TikTok',facebook:'Facebook',twitter:'X',youtube:'YouTube',linkedin:'LinkedIn'};
+
function sortDesigns(list, mode){
- const a = list.slice();
- const cmp = {
- newest: (x,y)=> new Date(y.created_at||0) - new Date(x.created_at||0),
- color: (x,y)=> String(x.dominant_hex||'').localeCompare(String(y.dominant_hex||'')),
- style: (x,y)=> String(x.style||'').localeCompare(String(y.style||'')),
- title_az:(x,y)=> String(x.title||'').localeCompare(String(y.title||'')),
- price_up:(x,y)=> (x.price_min??1e9) - (y.price_min??1e9),
- price_dn:(x,y)=> (y.price_min??-1) - (x.price_min??-1),
- }[mode] || (()=>0);
+ const a=list.slice();
+ const cmp={ newest:(x,y)=>new Date(y.created_at||0)-new Date(x.created_at||0),
+ color:(x,y)=>String(x.dominant_hex||'').localeCompare(String(y.dominant_hex||'')),
+ style:(x,y)=>String(x.style||'').localeCompare(String(y.style||'')),
+ title_az:(x,y)=>String(x.title||'').localeCompare(String(y.title||'')),
+ price_up:(x,y)=>(x.price_min??1e9)-(y.price_min??1e9),
+ price_dn:(x,y)=>(y.price_min??-1)-(x.price_min??-1) }[mode]||(()=>0);
return a.sort(cmp);
}
function renderGrid(){
- const mode = document.getElementById('sort').value;
- localStorage.setItem('pdl.sort', mode);
- const rows = sortDesigns(DESIGNS, mode);
- document.getElementById('grid').innerHTML = rows.map(x=>`
+ const mode=document.getElementById('sort').value;
+ localStorage.setItem('pdl.sort',mode);
+ const rows=sortDesigns(DESIGNS,mode);
+ document.getElementById('grid').innerHTML=rows.map(x=>`
<a class="card" href="/design/${x.slug}">
<div class="thumb"><div class="tile" style="background-image:url('${x.img}')"></div>
${x.seamless?'<span class="badge">Seamless</span>':'<span class="badge">Mural</span>'}</div>
@@ -45,37 +79,68 @@ function renderGrid(){
<div class="price">${fmtPrice(x.price_min)}</div></div>
</a>`).join('');
}
+function toggleShare(){ document.getElementById('sharemenu').classList.toggle('open'); }
+async function copyLink(){ try{ await navigator.clipboard.writeText(location.href); alert('Link copied'); }catch{ prompt('Copy link:',location.href);} }
+
(async()=>{
- const r = await fetch('/api/designers/'+encodeURIComponent(slug));
+ const r=await fetch('/api/designers/'+encodeURIComponent(slug));
if(!r.ok){ document.getElementById('root').innerHTML='<p style="padding:40px 0">Designer not found. <a href="/">Explore</a></p>'; return; }
- const {designer:d, designs, count} = await r.json();
- DESIGNS = designs || [];
- document.title = d.name + ' · Pattern Design Lab';
- document.getElementById('root').innerHTML = `
- <div class="dhero">
- <div class="av" style="background-image:url('${d.avatar||''}')"></div>
- <div><h1>${esc(d.name)}</h1>
- <p>${esc(d.bio||'')}</p>
- <p style="font-size:13px;color:#6b6b6b;margin-top:6px">${esc(d.country||'')} · ${count} design${count===1?'':'s'}</p></div>
+ const {designer:d, designs, count}=await r.json();
+ DESIGNS=designs||[];
+ document.title=d.name+' · Pattern Design Lab';
+
+ // owner check → show Edit
+ let isOwner=false;
+ try{ const me=await fetch('/api/designer/me'); if(me.ok){ const j=await me.json(); isOwner=j.designer&&j.designer.slug===slug; } }catch{}
+
+ const loc=[d.city,d.state_region,d.country].filter(Boolean).join(', ');
+ const socials=Object.keys(SOCIAL_LABELS).map(t=>{const u=socialUrl(t,d[t]);return u?`<a href="${esc(u)}" target="_blank" rel="noopener noreferrer">${SOCIAL_LABELS[t]}</a>`:''}).join('');
+ const shareText=encodeURIComponent((d.name||'')+' on Pattern Design Lab');
+ const shareUrl=encodeURIComponent(location.href);
+
+ document.getElementById('root').innerHTML=`
+ <div class="dprofile">
+ <div class="dhead">
+ <img class="dphoto" src="${esc(d.avatar||'')}" alt="${esc(d.name)}" onerror="this.style.background='${d.accent_hex||'#ddd'}';this.removeAttribute('src')">
+ <div style="flex:1;min-width:260px">
+ ${d.logo?`<img class="dlogo" src="${esc(d.logo)}" alt="${esc(d.name)} logo">`:''}
+ <h1 class="dname">${esc(d.name)}</h1>
+ ${d.tagline?`<p class="dtag">${esc(d.tagline)}</p>`:''}
+ <p class="dmeta">${esc(loc)}${loc?' · ':''}${count} design${count===1?'':'s'}${d.founder_name?' · '+esc(d.founder_name):''}</p>
+ ${socials?`<div class="socials">${socials}</div>`:''}
+ <div class="dactions">
+ <div class="share">
+ <button class="btn" onclick="toggleShare()">Share ▾</button>
+ <div class="sharemenu" id="sharemenu">
+ <button onclick="copyLink()">Copy link</button>
+ <a href="https://twitter.com/intent/tweet?url=${shareUrl}&text=${shareText}" target="_blank" rel="noopener noreferrer">Share on X</a>
+ <a href="https://www.facebook.com/sharer/sharer.php?u=${shareUrl}" target="_blank" rel="noopener noreferrer">Share on Facebook</a>
+ <a href="https://pinterest.com/pin/create/button/?url=${shareUrl}&description=${shareText}" target="_blank" rel="noopener noreferrer">Pin it</a>
+ <a href="https://www.linkedin.com/sharing/share-offsite/?url=${shareUrl}" target="_blank" rel="noopener noreferrer">Share on LinkedIn</a>
+ </div>
+ </div>
+ ${isOwner?`<a class="btn pri" href="/designer-dashboard">✎ Edit your page</a>`:''}
+ </div>
+ ${d.bio?`<p class="dbio">${esc(d.bio)}</p>`:''}
+ </div>
+ </div>
</div>
<div class="bar"><span class="count">${count} designs</span>
<div><label for="sort">Sort</label>
<select id="sort" onchange="renderGrid()">
- <option value="newest">Newest</option>
- <option value="color">Color</option>
- <option value="style">Style</option>
- <option value="title_az">Title A→Z</option>
- <option value="price_up">Price ↑</option>
- <option value="price_dn">Price ↓</option>
+ <option value="newest">Newest</option><option value="color">Color</option>
+ <option value="style">Style</option><option value="title_az">Title A→Z</option>
+ <option value="price_up">Price ↑</option><option value="price_dn">Price ↓</option>
</select></div>
<div><label for="density">Density</label>
<input id="density" type="range" min="2" max="6" step="1" value="4" oninput="document.documentElement.style.setProperty('--cols',this.value);localStorage.setItem('pdl.density',this.value)"></div></div>
<div class="grid" id="grid"></div>
<div style="padding:24px 0"><a href="/" style="color:var(--accent-ink)">← Back to all patterns</a></div>`;
- // restore persisted controls (DW spec)
+
const ss=localStorage.getItem('pdl.sort'); if(ss){const el=document.getElementById('sort'); if(el&&[...el.options].some(o=>o.value===ss)) el.value=ss;}
const dd=localStorage.getItem('pdl.density'); if(dd){document.documentElement.style.setProperty('--cols',dd);const el=document.getElementById('density');if(el)el.value=dd;}
renderGrid();
+ document.addEventListener('click',e=>{ const s=document.getElementById('sharemenu'); if(s&&!e.target.closest('.share')) s.classList.remove('open'); });
})();
</script>
</body>
diff --git a/server.js b/server.js
index dc43625..60a9c92 100644
--- a/server.js
+++ b/server.js
@@ -20,6 +20,40 @@ const pool = new Pool({
password: process.env.PGPASSWORD || undefined,
});
+// ---- designer accounts: bcrypt login + signed JWT cookie + image upload ----
+const bcrypt = require('bcryptjs');
+const jwt = require('jsonwebtoken');
+const multer = require('multer');
+const cookieLib = require('cookie');
+const JWT_SECRET = process.env.PDL_JWT_SECRET || 'pdl-designer-dev-secret-change-me';
+const DCOOKIE = 'pdl_designer';
+const UP_DIR = path.join(__dirname, 'public', 'assets', 'designers', 'uploads');
+fs.mkdirSync(UP_DIR, { recursive: true });
+const upload = multer({
+ storage: multer.diskStorage({
+ destination: (req, file, cb) => cb(null, UP_DIR),
+ filename: (req, file, cb) => {
+ const ext = (file.originalname.match(/\.[a-z0-9]+$/i) || ['.png'])[0].toLowerCase();
+ const kind = req.query.type === 'logo' ? 'logo' : 'avatar';
+ cb(null, `${req.designer ? req.designer.slug : 'd'}-${kind}-${Date.now()}${ext}`);
+ },
+ }),
+ limits: { fileSize: 6 * 1024 * 1024 },
+ fileFilter: (req, file, cb) => cb(null, /^image\//.test(file.mimetype)),
+});
+function readDesignerCookie(req) {
+ try { const t = cookieLib.parse(req.headers.cookie || '')[DCOOKIE]; return t ? jwt.verify(t, JWT_SECRET) : null; }
+ catch { return null; }
+}
+async function requireDesigner(req, res, next) {
+ const tok = readDesignerCookie(req);
+ if (!tok || !tok.id) return res.status(401).json({ error: 'sign in required' });
+ const r = await pool.query('SELECT * FROM designers WHERE id=$1', [tok.id]);
+ if (!r.rowCount) return res.status(401).json({ error: 'account not found' });
+ req.designer = r.rows[0]; next();
+}
+const publicDesigner = (row) => { const d = { ...row }; delete d.password_hash; return d; };
+
// ---- Basic Auth until PUBLIC=1 (healthz always open for probes) ----
app.use((req, res, next) => {
if (req.path === '/api/healthz') return next();
@@ -164,6 +198,49 @@ app.get('/api/designers/:slug', async (req, res) => {
} catch (e) { console.error(e); res.status(500).json({ error: 'query failed' }); }
});
+// ================= Designer sign-in + self-service profile editing =================
+app.post('/api/designer/login', async (req, res) => {
+ try {
+ const email = String((req.body || {}).email || '').trim().toLowerCase();
+ const password = String((req.body || {}).password || '');
+ if (!email || !password) return res.status(400).json({ error: 'email and password required' });
+ const r = await pool.query('SELECT * FROM designers WHERE lower(email)=$1', [email]);
+ if (!r.rowCount || !r.rows[0].password_hash) return res.status(401).json({ error: 'invalid credentials' });
+ if (!(await bcrypt.compare(password, r.rows[0].password_hash))) return res.status(401).json({ error: 'invalid credentials' });
+ const token = jwt.sign({ id: r.rows[0].id, slug: r.rows[0].slug }, JWT_SECRET, { expiresIn: '30d' });
+ res.setHeader('Set-Cookie', cookieLib.serialize(DCOOKIE, token, { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 24 * 30 }));
+ res.json({ ok: true, slug: r.rows[0].slug });
+ } catch (e) { console.error('login', e.message); res.status(500).json({ error: 'login failed' }); }
+});
+app.post('/api/designer/logout', (req, res) => {
+ res.setHeader('Set-Cookie', cookieLib.serialize(DCOOKIE, '', { httpOnly: true, path: '/', maxAge: 0 }));
+ res.json({ ok: true });
+});
+app.get('/api/designer/me', requireDesigner, (req, res) => res.json({ designer: publicDesigner(req.designer) }));
+
+const EDITABLE = ['name', 'founder_name', 'tagline', 'bio', 'city', 'state_region', 'country', 'accent_hex',
+ 'avatar', 'logo', 'website', 'instagram', 'pinterest', 'tiktok', 'facebook', 'twitter', 'youtube', 'linkedin'];
+app.patch('/api/designer/me', requireDesigner, async (req, res) => {
+ try {
+ const b = req.body || {}; const sets = [], vals = [];
+ for (const k of EDITABLE) if (k in b) { vals.push(b[k] === '' ? null : String(b[k]).slice(0, 4000)); sets.push(`${k}=$${vals.length}`); }
+ if (!sets.length) return res.json({ designer: publicDesigner(req.designer) });
+ vals.push(req.designer.id);
+ const r = await pool.query(`UPDATE designers SET ${sets.join(',')}, updated_at=now() WHERE id=$${vals.length} RETURNING *`, vals);
+ res.json({ designer: publicDesigner(r.rows[0]) });
+ } catch (e) { console.error('patch me', e.message); res.status(500).json({ error: 'update failed' }); }
+});
+// image upload — 'file' field, ?type=avatar|logo. (URL mode = PATCH avatar/logo directly; generated fallback = seeded portrait.)
+app.post('/api/designer/upload', requireDesigner, upload.single('file'), async (req, res) => {
+ try {
+ if (!req.file) return res.status(400).json({ error: 'no image uploaded' });
+ const kind = req.query.type === 'logo' ? 'logo' : 'avatar';
+ const url = `/assets/designers/uploads/${req.file.filename}`;
+ await pool.query(`UPDATE designers SET ${kind}=$1, updated_at=now() WHERE id=$2`, [url, req.designer.id]);
+ res.json({ ok: true, type: kind, url });
+ } catch (e) { console.error('upload', e.message); res.status(500).json({ error: 'upload failed' }); }
+});
+
// ---- license checkout — Stripe TEST-mode only; without keys it records an inquiry ----
app.post('/api/license/checkout', async (req, res) => {
const { designId, tier, email } = req.body || {};
@@ -244,6 +321,8 @@ app.get('/design/:id', (req, res) => res.sendFile(path.join(__dirname, 'public',
app.get('/designer/:slug', (req, res) => res.sendFile(path.join(__dirname, 'public', 'designer.html')));
app.get('/trends', (req, res) => res.sendFile(path.join(__dirname, 'public', 'trends.html')));
app.get('/designer-signup', (req, res) => res.sendFile(path.join(__dirname, 'public', 'designer-signup.html')));
+app.get('/designer-login', (req, res) => res.sendFile(path.join(__dirname, 'public', 'designer-login.html')));
+app.get('/designer-dashboard', (req, res) => res.sendFile(path.join(__dirname, 'public', 'designer-dashboard.html')));
app.get('/admin', (req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
app.get('/api/healthz', async (req, res) => {
← 53f0f05 designers: 15 studio personas + coherent balanced catalog sp
·
back to Patterndesignlab
·
security: harden designer auth + upload 2e4f761 →