← 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
M public/css/app.cssM public/js/app.js
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 →