[object Object]

← 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

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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 &amp; 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 &amp; 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=>({'&':'&amp;
 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 →