[object Object]

← back to Whatsmystyle

feat(avatar): wire drag-drop staging + Google Drive picker + counter pill

e1aa122d9d3dc2568323c242204c9877abccfd99 · 2026-05-12 00:08:53 -0700 · SteveStudio2

Files touched

Diff

commit e1aa122d9d3dc2568323c242204c9877abccfd99
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date:   Tue May 12 00:08:53 2026 -0700

    feat(avatar): wire drag-drop staging + Google Drive picker + counter pill
---
 public/css/app.css |  57 ++++++++++++++++
 public/js/app.js   | 192 +++++++++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 244 insertions(+), 5 deletions(-)

diff --git a/public/css/app.css b/public/css/app.css
index 0ee560a..6e8b18b 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -327,5 +327,62 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
   margin-left: 6px;
 }
 
+/* avatar upload — drag-drop + thumbs + counter pill + GDrive */
+.dropzone {
+  margin: 18px 0 12px;
+  padding: 36px 22px;
+  border: 2px dashed var(--ink);
+  border-radius: var(--radius);
+  background: var(--paper);
+  text-align: center;
+  cursor: pointer;
+  transition: background .15s, border-color .15s, transform .12s;
+}
+.dropzone:hover, .dropzone:focus-visible { background: var(--paper-2); transform: translateY(-1px); outline: 0; }
+.dropzone.dragover { background: var(--paper-2); border-color: var(--accent); border-style: solid; }
+.dropzone .dz-icon { font-size: 38px; line-height: 1; margin-bottom: 10px; opacity: .55; }
+.dropzone .dz-headline { font-family: var(--font-display); font-size: 22px; font-weight: 600; margin-bottom: 6px; }
+.dropzone .dz-sub { font-size: 14px; }
+.avatar-actions { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin: 12px 0; }
+.avatar-actions .pill { padding: 14px 22px; min-height: 0; font-size: 15px; }
+.avatar-actions .pill:disabled { opacity: .35; cursor: not-allowed; background: var(--paper); color: var(--ink); }
+.counter-pill {
+  display: inline-flex; align-items: center;
+  padding: 8px 14px; border-radius: 999px;
+  font-size: 13px; font-weight: 600;
+  font-variant-numeric: tabular-nums;
+  border: 1px solid var(--line);
+}
+.counter-empty { background: var(--paper-2); color: #777; }
+.counter-ok    { background: var(--ok);      color: var(--paper); border-color: var(--ok); }
+.counter-over  { background: #c98e1a;        color: var(--paper); border-color: #c98e1a; }
+.thumb-row {
+  display: flex; gap: 10px; flex-wrap: wrap;
+  margin: 8px 0 12px;
+}
+.thumb-row:empty { margin: 0; }
+.thumb {
+  position: relative;
+  width: 80px; height: 80px;
+  border-radius: 12px; overflow: hidden;
+  background: var(--paper-2);
+  border: 1px solid var(--line);
+}
+.thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.thumb .x {
+  position: absolute; top: 4px; right: 4px;
+  width: 22px; height: 22px; border-radius: 999px;
+  background: rgba(0,0,0,.72); color: var(--paper);
+  border: 0; font-size: 14px; line-height: 1;
+  display: grid; place-items: center; cursor: pointer;
+  padding: 0;
+}
+.thumb .src-tag {
+  position: absolute; bottom: 0; left: 0; right: 0;
+  background: rgba(0,0,0,.6); color: var(--paper);
+  font-size: 9px; text-align: center; padding: 2px 0;
+  letter-spacing: .04em;
+}
+
 /* utils */
 [hidden] { display: none !important; }
diff --git a/public/js/app.js b/public/js/app.js
index 345af73..704fc11 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -376,18 +376,200 @@ $$('.conn').forEach(b => b.addEventListener('click', async () => {
 }));
 
 // ---------- Avatar --------------------------------------------------------
-const avatarForm = $('#avatar-form');
-avatarForm.addEventListener('submit', async e => {
+// Drag-drop friendly upload widget. Files staged client-side (drag-drop,
+// file picker, OR Google Drive) — user clicks Upload to send the batch as
+// multipart `photos[]` to POST /api/avatar/photos (unchanged backend contract).
+//
+// PICKER NEEDS: GOOGLE_PICKER_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_DRIVE_APP_ID
+// — see ~/Projects/secrets-manager. If env not configured, GDrive button hides.
+const avatarStaged = [];   // [{ file: File, url: ObjectURL, source: 'local'|'drive' }]
+let gpickerCfg = null;
+let gpickerLoaded = false;
+let gpickerTokenClient = null;
+let gpickerAccessToken = null;
+
+const dz = $('#avatar-dropzone');
+const fileInput = $('#avatar-file-input');
+const thumbRow = $('#avatar-thumbs');
+const counter = $('#avatar-counter');
+const uploadBtn = $('#avatar-upload-btn');
+const gdriveBtn = $('#avatar-gdrive-btn');
+
+function renderAvatarThumbs() {
+  thumbRow.innerHTML = '';
+  avatarStaged.forEach((s, i) => {
+    const t = document.createElement('div');
+    t.className = 'thumb';
+    t.innerHTML = `<img src="${s.url}" alt="staged photo ${i+1}"><button class="x" type="button" aria-label="Remove photo ${i+1}">×</button><span class="src-tag">${s.source === 'drive' ? 'DRIVE' : 'LOCAL'}</span>`;
+    t.querySelector('.x').onclick = () => {
+      try { URL.revokeObjectURL(s.url); } catch {}
+      avatarStaged.splice(i, 1);
+      renderAvatarThumbs();
+    };
+    thumbRow.appendChild(t);
+  });
+  const n = avatarStaged.length;
+  counter.classList.remove('counter-empty', 'counter-ok', 'counter-over');
+  if (n === 0)      { counter.textContent = '0 / 5 photos · drop or pick to add';    counter.classList.add('counter-empty'); }
+  else if (n < 3)   { counter.textContent = `${n} / 5 photos · add ${3-n} more`;     counter.classList.add('counter-empty'); }
+  else if (n <= 5)  { counter.textContent = `${n} / 5 photos · click upload to send`; counter.classList.add('counter-ok'); }
+  else              { counter.textContent = `${n} / 5 photos · over the limit — remove ${n-5}`; counter.classList.add('counter-over'); }
+  uploadBtn.disabled = n === 0;
+}
+
+function addAvatarFiles(files, source = 'local') {
+  for (const f of files) {
+    if (!f || !f.type || !f.type.startsWith('image/')) continue;
+    avatarStaged.push({ file: f, url: URL.createObjectURL(f), source });
+  }
+  renderAvatarThumbs();
+}
+
+// dropzone — click + keyboard open file picker, drag-drop accepts files
+dz.addEventListener('click', () => fileInput.click());
+dz.addEventListener('keydown', e => {
+  if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
+});
+fileInput.addEventListener('change', e => {
+  addAvatarFiles(e.target.files, 'local');
+  fileInput.value = '';
+});
+['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e => {
+  e.preventDefault(); e.stopPropagation();
+  dz.classList.add('dragover');
+}));
+['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e => {
+  e.preventDefault(); e.stopPropagation();
+  if (ev === 'dragleave' && dz.contains(e.relatedTarget)) return;
+  dz.classList.remove('dragover');
+}));
+dz.addEventListener('drop', e => {
+  const files = e.dataTransfer?.files || [];
+  if (files.length) addAvatarFiles(files, 'local');
+});
+// also catch document-level drag so the browser doesn't navigate away
+['dragover','drop'].forEach(ev => window.addEventListener(ev, e => {
+  if (e.target.closest && e.target.closest('#avatar-dropzone')) return;
   e.preventDefault();
-  const fd = new FormData(avatarForm);
-  const r = await fetch('/api/avatar/photos', { method: 'POST', body: fd }).then(r => r.json());
-  $('#avatar-status').textContent = `${r.photo_count} photo${r.photo_count===1?'':'s'} uploaded. Click "Build my avatar" when ready.`;
+}));
+
+// upload button — sends staged set as multipart photos[] (unchanged backend)
+uploadBtn.addEventListener('click', async () => {
+  if (!avatarStaged.length) return;
+  uploadBtn.disabled = true;
+  $('#avatar-status').textContent = `Uploading ${avatarStaged.length} photo${avatarStaged.length===1?'':'s'}…`;
+  const fd = new FormData();
+  avatarStaged.forEach((s, i) => {
+    // Server side reads `photos` field (FormData multiple). Use original name where possible.
+    const name = s.file.name || `photo-${i+1}.jpg`;
+    fd.append('photos', s.file, name);
+  });
+  try {
+    const r = await fetch('/api/avatar/photos', { method: 'POST', body: fd }).then(r => r.json());
+    $('#avatar-status').textContent = `${r.photo_count} photo${r.photo_count===1?'':'s'} uploaded. Click "Build my avatar" when ready.`;
+    // clear staging on success
+    avatarStaged.forEach(s => { try { URL.revokeObjectURL(s.url); } catch {} });
+    avatarStaged.length = 0;
+    renderAvatarThumbs();
+  } catch (err) {
+    $('#avatar-status').textContent = 'Upload failed — try again.';
+    uploadBtn.disabled = false;
+  }
 });
+
 $('#avatar-build-btn').addEventListener('click', async () => {
   $('#avatar-status').textContent = 'Building your digital twin… this can take 1–2 min.';
   await fetch('/api/avatar/build', { method: 'POST' });
   pollAvatar();
 });
+
+// ---------- Google Drive Picker ------------------------------------------
+// Loads gapi + GIS from CDN on demand. Requires GOOGLE_PICKER_API_KEY +
+// GOOGLE_CLIENT_ID + GOOGLE_DRIVE_APP_ID env-vars exposed via /api/public-config.
+// If any are missing, the GDrive button stays hidden (graceful degrade).
+async function bootGoogleDriveButton() {
+  try {
+    const cfg = await fetch('/api/public-config').then(r => r.json());
+    gpickerCfg = cfg.google_picker || {};
+    if (gpickerCfg.api_key && gpickerCfg.client_id && gpickerCfg.app_id) {
+      gdriveBtn.hidden = false;
+    }
+  } catch {}
+}
+bootGoogleDriveButton();
+
+function loadScript(src) {
+  return new Promise((resolve, reject) => {
+    if (document.querySelector(`script[src="${src}"]`)) return resolve();
+    const s = document.createElement('script');
+    s.src = src; s.async = true; s.defer = true;
+    s.onload = resolve; s.onerror = () => reject(new Error('script load failed: ' + src));
+    document.head.appendChild(s);
+  });
+}
+
+async function ensurePickerLoaded() {
+  if (gpickerLoaded) return;
+  await loadScript('https://apis.google.com/js/api.js');
+  await new Promise(resolve => window.gapi.load('picker', { callback: resolve }));
+  await loadScript('https://accounts.google.com/gsi/client');
+  gpickerTokenClient = window.google.accounts.oauth2.initTokenClient({
+    client_id: gpickerCfg.client_id,
+    scope: 'https://www.googleapis.com/auth/drive.readonly',
+    callback: () => {},  // overridden per-request
+  });
+  gpickerLoaded = true;
+}
+
+async function getDriveAccessToken() {
+  if (gpickerAccessToken) return gpickerAccessToken;
+  return new Promise((resolve, reject) => {
+    gpickerTokenClient.callback = (resp) => {
+      if (resp.error) return reject(new Error(resp.error));
+      gpickerAccessToken = resp.access_token;
+      resolve(gpickerAccessToken);
+    };
+    gpickerTokenClient.requestAccessToken({ prompt: '' });
+  });
+}
+
+async function openGoogleDrivePicker() {
+  if (!gpickerCfg || !gpickerCfg.api_key) return;
+  try {
+    $('#avatar-status').textContent = 'Opening Google Drive picker…';
+    await ensurePickerLoaded();
+    const token = await getDriveAccessToken();
+    const view = new window.google.picker.View(window.google.picker.ViewId.DOCS_IMAGES);
+    const picker = new window.google.picker.PickerBuilder()
+      .enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED)
+      .setOAuthToken(token)
+      .setDeveloperKey(gpickerCfg.api_key)
+      .setAppId(gpickerCfg.app_id)
+      .addView(view)
+      .setCallback(async (data) => {
+        if (data.action !== window.google.picker.Action.PICKED) return;
+        $('#avatar-status').textContent = `Downloading ${data.docs.length} from Drive…`;
+        for (const doc of data.docs) {
+          try {
+            const r = await fetch(`https://www.googleapis.com/drive/v3/files/${doc.id}?alt=media`, {
+              headers: { Authorization: `Bearer ${token}` },
+            });
+            const blob = await r.blob();
+            const file = new File([blob], doc.name || `drive-${doc.id}.jpg`, { type: blob.type || 'image/jpeg' });
+            addAvatarFiles([file], 'drive');
+          } catch (e) {
+            console.warn('Drive download failed for', doc.id, e);
+          }
+        }
+        $('#avatar-status').textContent = `Staged ${avatarStaged.length} photo${avatarStaged.length===1?'':'s'} — click Upload when ready.`;
+      })
+      .build();
+    picker.setVisible(true);
+  } catch (e) {
+    $('#avatar-status').textContent = 'Drive picker failed: ' + (e.message || e);
+  }
+}
+gdriveBtn.addEventListener('click', openGoogleDrivePicker);
 async function pollAvatar() {
   for (let i = 0; i < 60; i++) {
     const r = await fetch('/api/avatar').then(r => r.json());

← cf853c0 video: 16s FASHN carousel — you in cashmere, trench, denim  ·  back to Whatsmystyle  ·  yolo tick 11: cold-start hybrid duel + admin config 1d1201f →