← back to CelebritySignatures
public/account.js
183 lines
/* Celebrity Signature Murals — accounts + save-your-placement.
Self-contained, injected by server.js into /murals so murals.html stays untouched.
Reads the wall-studio's existing globals (cur, pos, wallDims, selectMural, drawScene). */
(function () {
'use strict';
const $ = (s, r = document) => r.querySelector(s);
const el = (t, props = {}, html) => { const e = document.createElement(t); Object.assign(e, props); if (html != null) e.innerHTML = html; return e; };
const api = (p, opt = {}) => fetch(p, { credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, ...opt }).then(r => r.json());
let me = null;
// ---- styles ----
document.head.appendChild(el('style', {}, `
.acct-bar{position:fixed;top:14px;right:18px;z-index:60;display:flex;gap:8px;align-items:center;font:14px/1 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.acct-bar .pill{background:#fff;border:1px solid #e6e3dc;border-radius:999px;padding:8px 15px;cursor:pointer;color:#1a1a1a;box-shadow:0 1px 4px rgba(0,0,0,.06)}
.acct-bar .pill.dark{background:#1a1a1a;color:#fff;border-color:#1a1a1a}
.acct-menu{position:absolute;top:42px;right:0;background:#fff;border:1px solid #e6e3dc;border-radius:10px;box-shadow:0 8px 28px rgba(0,0,0,.14);overflow:hidden;display:none;min-width:170px}
.acct-menu.open{display:block}
.acct-menu button{display:block;width:100%;text-align:left;border:0;background:#fff;padding:11px 16px;font:inherit;cursor:pointer;color:#1a1a1a}
.acct-menu button:hover{background:#f7f5f0}
.acct-save{display:inline-flex;align-items:center;gap:7px;border:1px solid #1a1a1a;background:#1a1a1a;color:#fff;font:14px/1 inherit;padding:10px 16px;border-radius:9px;cursor:pointer;margin-top:6px}
.acct-save.saved{background:#1d7a36;border-color:#1d7a36}
.acct-ovl{position:fixed;inset:0;background:rgba(20,18,14,.45);z-index:70;display:none;align-items:center;justify-content:center}
.acct-ovl.open{display:flex}
.acct-modal{background:#fff;border-radius:14px;width:min(380px,92vw);padding:24px;font:15px/1.5 -apple-system,sans-serif;box-shadow:0 20px 60px rgba(0,0,0,.3)}
.acct-modal h3{font-family:Georgia,serif;margin:0 0 4px;font-size:21px}
.acct-modal p{color:#6b6b6b;margin:0 0 16px;font-size:13px}
.acct-modal label{display:block;font-size:12px;color:#6b6b6b;margin:0 0 10px}
.acct-modal input{width:100%;box-sizing:border-box;margin-top:5px;padding:10px 12px;border:1px solid #e6e3dc;border-radius:8px;font:inherit}
.acct-modal .go{width:100%;border:0;background:#1a1a1a;color:#fff;padding:12px;border-radius:9px;font:inherit;font-size:15px;cursor:pointer;margin-top:6px}
.acct-modal .alt{text-align:center;margin-top:12px;font-size:13px;color:#6b6b6b}
.acct-modal .alt a{color:#1d7a36;cursor:pointer;text-decoration:underline}
.acct-msg{font-size:13px;margin-top:10px;min-height:16px}
.acct-msg.err{color:#b3261e}.acct-msg.ok{color:#1d7a36}
.acct-draw{position:fixed;top:0;right:0;height:100%;width:min(380px,94vw);background:#f7f5f0;z-index:65;box-shadow:-8px 0 30px rgba(0,0,0,.18);transform:translateX(100%);transition:transform .25s;overflow:auto;padding:22px}
.acct-draw.open{transform:none}
.acct-draw h3{font-family:Georgia,serif;margin:0 0 14px;font-size:20px;display:flex;justify-content:space-between;align-items:center}
.acct-draw h3 .x{cursor:pointer;color:#6b6b6b;font-size:22px;border:0;background:0}
.save-card{background:#fff;border:1px solid #e6e3dc;border-radius:12px;overflow:hidden;margin-bottom:14px}
.save-card img{width:100%;height:120px;object-fit:cover;display:block;background:#fff;border-bottom:1px solid #eee}
.save-card .b{padding:11px 13px}
.save-card .t{font-family:Georgia,serif;font-size:15px}
.save-card .m{font-size:12px;color:#6b6b6b;margin:3px 0 9px}
.save-card .row{display:flex;gap:8px}
.save-card .row button{flex:1;border:1px solid #e6e3dc;background:#fff;border-radius:7px;padding:7px;font:inherit;font-size:13px;cursor:pointer}
.save-card .row button.load{border-color:#1a1a1a;background:#1a1a1a;color:#fff}
.acct-empty{color:#6b6b6b;font-size:14px;text-align:center;padding:30px 10px}
.acct-toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#1a1a1a;color:#fff;padding:11px 20px;border-radius:999px;z-index:80;font:14px sans-serif;opacity:0;transition:opacity .2s}
.acct-toast.show{opacity:1}
`));
// ---- elements ----
const bar = el('div', { className: 'acct-bar' });
const acctPill = el('button', { className: 'pill' }, 'Sign in');
const menu = el('div', { className: 'acct-menu' });
menu.appendChild(el('button', { onclick: openSaves }, '🖼 My saved murals'));
menu.appendChild(el('button', { onclick: logout }, 'Sign out'));
bar.appendChild(acctPill); bar.appendChild(menu); document.body.appendChild(bar);
const ovl = el('div', { className: 'acct-ovl' });
const modal = el('div', { className: 'acct-modal' });
ovl.appendChild(modal); document.body.appendChild(ovl);
ovl.addEventListener('click', e => { if (e.target === ovl) ovl.classList.remove('open'); });
const draw = el('div', { className: 'acct-draw' });
document.body.appendChild(draw);
const toast = el('div', { className: 'acct-toast' }); document.body.appendChild(toast);
const flash = m => { toast.textContent = m; toast.classList.add('show'); setTimeout(() => toast.classList.remove('show'), 1900); };
acctPill.onclick = () => { if (me) menu.classList.toggle('open'); else openAuth('login'); };
document.addEventListener('click', e => { if (!bar.contains(e.target)) menu.classList.remove('open'); });
// ---- save button into the studio ----
function mountSaveBtn() {
const studio = $('#studio'); if (!studio || $('#acctSaveBtn')) return;
const btn = el('button', { id: 'acctSaveBtn', className: 'acct-save' }, '💾 Save this placement to my account');
btn.onclick = savePlacement;
const anchor = $('#readout') || $('.stage-wrap', studio) || studio;
anchor.parentNode.insertBefore(btn, anchor.nextSibling);
}
// ---- read current placement from the studio's globals (resilient) ----
function snapshot() {
try {
if (typeof cur === 'undefined' || !cur) return null;
const wd = (typeof wallDims === 'function') ? wallDims() : { W: +($('#wallW') || {}).value || 0, H: +($('#wallH') || {}).value || 0 };
const p = (typeof pos !== 'undefined' && pos) ? pos : { x: 0, y: 0 };
return { mural: cur.slug, mural_title: cur.title, wall_w: wd.W, wall_h: wd.H,
placement: { from_left_ft: +(+p.x).toFixed(2), off_floor_ft: +(+p.y).toFixed(2) },
scene: (typeof curScene !== 'undefined') ? curScene : '' };
} catch (e) { return null; }
}
async function savePlacement() {
const snap = snapshot();
if (!snap) return flash('Pick a mural in the studio first');
if (!me) { openAuth('login', savePlacement); return; }
const r = await api('/api/saves', { method: 'POST', body: JSON.stringify(snap) });
if (r.ok) { const b = $('#acctSaveBtn'); b.classList.add('saved'); b.textContent = '✓ Saved to your account'; setTimeout(() => { b.classList.remove('saved'); b.innerHTML = '💾 Save this placement to my account'; }, 2200); flash('Saved to your account'); }
else flash(r.error || 'Could not save');
}
// ---- auth modal ----
function openAuth(mode, after) {
modal.innerHTML = '';
const isLogin = mode === 'login';
modal.appendChild(el('h3', {}, isLogin ? 'Sign in' : 'Create your account'));
modal.appendChild(el('p', {}, 'Save mural placements to your account and find them anytime.'));
const f = el('form');
if (!isLogin) f.appendChild(el('label', {}, 'Name<input name="name" autocomplete="name">'));
f.appendChild(el('label', {}, 'Email<input name="email" type="email" required autocomplete="email">'));
f.appendChild(el('label', {}, 'Password<input name="password" type="password" required minlength="6" autocomplete="' + (isLogin ? 'current-password' : 'new-password') + '">'));
const go = el('button', { className: 'go', type: 'submit' }, isLogin ? 'Sign in' : 'Create account');
f.appendChild(go);
const msg = el('div', { className: 'acct-msg' });
f.appendChild(msg);
const alt = el('div', { className: 'alt' }, isLogin ? 'No account? <a>Create one</a>' : 'Have an account? <a>Sign in</a>');
alt.querySelector('a').onclick = () => openAuth(isLogin ? 'signup' : 'login', after);
f.appendChild(alt);
f.onsubmit = async e => {
e.preventDefault(); msg.className = 'acct-msg'; msg.textContent = 'Working…';
const body = JSON.stringify({ name: f.name ? f.name.value : '', email: f.email.value, password: f.password.value });
const r = await api('/api/auth/' + (isLogin ? 'login' : 'signup'), { method: 'POST', body });
if (r.ok) { me = r.user; refreshBar(); ovl.classList.remove('open'); flash('Signed in as ' + me.email); if (after) after(); }
else { msg.className = 'acct-msg err'; msg.textContent = r.error || 'Something went wrong'; }
};
modal.appendChild(f);
ovl.classList.add('open');
setTimeout(() => { const em = f.querySelector('input[type=email]'); em && em.focus(); }, 50);
}
async function logout() { await api('/api/auth/logout', { method: 'POST' }); me = null; refreshBar(); menu.classList.remove('open'); flash('Signed out'); }
// ---- my saved murals drawer ----
async function openSaves() {
menu.classList.remove('open');
draw.innerHTML = '';
const h = el('h3', {}, 'My saved murals');
const x = el('button', { className: 'x' }, '×'); x.onclick = () => draw.classList.remove('open'); h.appendChild(x);
draw.appendChild(h);
draw.classList.add('open');
const r = await api('/api/saves');
if (!r.ok) { draw.appendChild(el('div', { className: 'acct-empty' }, 'Please sign in.')); return; }
if (!r.saves.length) { draw.appendChild(el('div', { className: 'acct-empty' }, 'No saved murals yet.<br>Place a mural on your wall and tap “Save”.')); return; }
r.saves.forEach(s => {
const card = el('div', { className: 'save-card' });
card.appendChild(el('img', { src: '/output/portrait-' + s.mural + '.png', loading: 'lazy', onerror: function () { this.src = '/output/collage-' + s.mural + '.png'; } }));
const b = el('div', { className: 'b' });
b.appendChild(el('div', { className: 't' }, s.mural_title || s.mural));
const pl = s.placement ? `${s.placement.from_left_ft} ft from left · ${s.placement.off_floor_ft} ft off floor` : '';
b.appendChild(el('div', { className: 'm' }, `${s.wall_w || '?'}×${s.wall_h || '?'} ft wall · ${pl}`));
const row = el('div', { className: 'row' });
const load = el('button', { className: 'load' }, 'Load'); load.onclick = () => loadSave(s);
const del = el('button', {}, 'Delete'); del.onclick = async () => { await api('/api/saves?id=' + s.id, { method: 'DELETE' }); openSaves(); };
row.appendChild(load); row.appendChild(del); b.appendChild(row); card.appendChild(b); draw.appendChild(card);
});
}
function loadSave(s) {
try {
const code = (typeof CAT !== 'undefined' && CAT) ? (CAT.murals.find(m => m.slug === s.mural) || {}).code : null;
if (code && $('#selMural')) $('#selMural').value = code;
if (s.wall_w && $('#wallW')) $('#wallW').value = s.wall_w;
if (s.wall_h && $('#wallH')) $('#wallH').value = s.wall_h;
if (typeof selectMural === 'function' && code) selectMural(code);
if (typeof pos !== 'undefined' && s.placement) { pos.x = s.placement.from_left_ft; pos.y = s.placement.off_floor_ft; }
if (typeof drawScene === 'function') drawScene();
draw.classList.remove('open');
const st = $('#studio'); st && st.scrollIntoView({ behavior: 'smooth' });
flash('Loaded “' + (s.mural_title || s.mural) + '”');
} catch (e) { flash('Could not load that placement'); }
}
function refreshBar() {
if (me) { acctPill.classList.add('dark'); acctPill.textContent = '👤 ' + me.email.split('@')[0]; }
else { acctPill.classList.remove('dark'); acctPill.textContent = 'Sign in'; }
}
// ---- init ----
api('/api/auth/me').then(r => { me = (r && r.ok) ? r.user : null; refreshBar(); });
const tryMount = () => { mountSaveBtn(); if (!$('#acctSaveBtn')) setTimeout(tryMount, 400); };
if (document.readyState !== 'loading') tryMount(); else document.addEventListener('DOMContentLoaded', tryMount);
})();