clay-server 2.16.0 → 2.17.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,9 +4,10 @@
4
4
 
5
5
  import { iconHtml, refreshIcons } from './icons.js';
6
6
  import { setSTTLang, getSTTLang } from './stt.js';
7
+ import { avatarUrl, mateAvatarUrl, AVATAR_STYLES } from './avatar.js';
7
8
 
8
9
  var ctx;
9
- var profile = { name: '', lang: 'en-US', avatarStyle: 'thumbs', avatarSeed: '', avatarColor: '#7c3aed' };
10
+ var profile = { name: '', lang: 'en-US', avatarStyle: 'thumbs', avatarSeed: '', avatarColor: '#7c3aed', avatarCustom: '' };
10
11
  var profileUsername = '';
11
12
  var popoverEl = null;
12
13
  var saveTimer = null;
@@ -22,16 +23,7 @@ var LANGUAGES = [
22
23
  { code: 'de-DE', name: 'German' },
23
24
  ];
24
25
 
25
- var AVATAR_STYLES = [
26
- { id: 'thumbs', name: 'Thumbs' },
27
- { id: 'bottts', name: 'Bots' },
28
- { id: 'pixel-art', name: 'Pixel' },
29
- { id: 'adventurer', name: 'Adventurer' },
30
- { id: 'micah', name: 'Micah' },
31
- { id: 'lorelei', name: 'Lorelei' },
32
- { id: 'fun-emoji', name: 'Emoji' },
33
- { id: 'icons', name: 'Icons' },
34
- ];
26
+ // AVATAR_STYLES imported from avatar.js
35
27
 
36
28
  var COLORS = [
37
29
  '#7c3aed', '#4f46e5', '#2563eb', '#0891b2',
@@ -41,11 +33,7 @@ var COLORS = [
41
33
  '#0369a1', '#15803d',
42
34
  ];
43
35
 
44
- // --- DiceBear URL builder ---
45
- function avatarUrl(style, seed, size) {
46
- var s = encodeURIComponent(seed || 'anonymous');
47
- return 'https://api.dicebear.com/9.x/' + style + '/svg?seed=' + s + '&size=' + (size || 64);
48
- }
36
+ // avatarUrl imported from avatar.js
49
37
 
50
38
  function getAvatarSeed() {
51
39
  return profile.avatarSeed || 'anonymous';
@@ -80,10 +68,12 @@ function applyToIsland() {
80
68
 
81
69
  var displayName = profile.name || 'Awesome Clay User';
82
70
 
83
- // Replace letter fallback with DiceBear img
71
+ // Replace letter fallback with avatar img
84
72
  var existingImg = avatarWrap.querySelector('img');
85
73
  var existingLetter = avatarWrap.querySelector('.user-island-avatar-letter');
86
- var url = avatarUrl(profile.avatarStyle || 'thumbs', getAvatarSeed(), 32);
74
+ var url = profile.avatarCustom
75
+ ? profile.avatarCustom
76
+ : avatarUrl(profile.avatarStyle || 'thumbs', getAvatarSeed(), 32);
87
77
 
88
78
  if (existingImg) {
89
79
  existingImg.src = url;
@@ -109,6 +99,188 @@ function applyToIsland() {
109
99
  }
110
100
  }
111
101
 
102
+ // --- Avatar position picker ---
103
+ export function showAvatarPositioner(img, objectUrl, onDone) {
104
+ var outputSize = 256;
105
+ var viewSize = 220;
106
+
107
+ // State
108
+ var scale = 1;
109
+ var offsetX = 0;
110
+ var offsetY = 0;
111
+
112
+ // Fit image so shorter side fills the circle
113
+ var baseScale = Math.max(viewSize / img.width, viewSize / img.height);
114
+
115
+ function clampOffsets() {
116
+ var sw = img.width * baseScale * scale;
117
+ var sh = img.height * baseScale * scale;
118
+ var maxX = Math.max(0, (sw - viewSize) / 2);
119
+ var maxY = Math.max(0, (sh - viewSize) / 2);
120
+ if (offsetX > maxX) offsetX = maxX;
121
+ if (offsetX < -maxX) offsetX = -maxX;
122
+ if (offsetY > maxY) offsetY = maxY;
123
+ if (offsetY < -maxY) offsetY = -maxY;
124
+ }
125
+
126
+ function updatePreview() {
127
+ var s = baseScale * scale;
128
+ var tx = (viewSize - img.width * s) / 2 + offsetX;
129
+ var ty = (viewSize - img.height * s) / 2 + offsetY;
130
+ previewImg.style.width = (img.width * s) + 'px';
131
+ previewImg.style.height = (img.height * s) + 'px';
132
+ previewImg.style.transform = 'translate(' + tx + 'px, ' + ty + 'px)';
133
+ }
134
+
135
+ // Build overlay
136
+ var overlay = document.createElement('div');
137
+ overlay.className = 'avatar-positioner-overlay';
138
+
139
+ var container = document.createElement('div');
140
+ container.className = 'avatar-positioner-container';
141
+
142
+ var closeBtn = document.createElement('button');
143
+ closeBtn.className = 'avatar-positioner-close';
144
+ closeBtn.innerHTML = '&times;';
145
+ container.appendChild(closeBtn);
146
+
147
+ var title = document.createElement('div');
148
+ title.className = 'avatar-positioner-title';
149
+ title.textContent = 'Position your avatar';
150
+ container.appendChild(title);
151
+
152
+ var viewport = document.createElement('div');
153
+ viewport.className = 'avatar-positioner-viewport';
154
+ viewport.style.width = viewSize + 'px';
155
+ viewport.style.height = viewSize + 'px';
156
+
157
+ var previewImg = document.createElement('img');
158
+ previewImg.src = objectUrl;
159
+ previewImg.className = 'avatar-positioner-img';
160
+ previewImg.draggable = false;
161
+ viewport.appendChild(previewImg);
162
+ container.appendChild(viewport);
163
+
164
+ // Zoom slider
165
+ var sliderWrap = document.createElement('div');
166
+ sliderWrap.className = 'avatar-positioner-slider-wrap';
167
+ var slider = document.createElement('input');
168
+ slider.type = 'range';
169
+ slider.min = '1';
170
+ slider.max = '6';
171
+ slider.step = '0.05';
172
+ slider.value = '1';
173
+ slider.className = 'avatar-positioner-slider';
174
+ sliderWrap.appendChild(slider);
175
+ container.appendChild(sliderWrap);
176
+
177
+ // Buttons
178
+ var btnRow = document.createElement('div');
179
+ btnRow.className = 'avatar-positioner-buttons';
180
+ var cancelBtn = document.createElement('button');
181
+ cancelBtn.className = 'avatar-positioner-btn avatar-positioner-btn-cancel';
182
+ cancelBtn.textContent = 'Cancel';
183
+ var doneBtn = document.createElement('button');
184
+ doneBtn.className = 'avatar-positioner-btn avatar-positioner-btn-done';
185
+ doneBtn.textContent = 'Done';
186
+ btnRow.appendChild(cancelBtn);
187
+ btnRow.appendChild(doneBtn);
188
+ container.appendChild(btnRow);
189
+
190
+ overlay.appendChild(container);
191
+ document.body.appendChild(overlay);
192
+ updatePreview();
193
+
194
+ // Drag
195
+ var dragging = false;
196
+ var dragStartX = 0;
197
+ var dragStartY = 0;
198
+ var startOffsetX = 0;
199
+ var startOffsetY = 0;
200
+
201
+ viewport.addEventListener('mousedown', startDrag);
202
+ viewport.addEventListener('touchstart', startDrag, { passive: false });
203
+
204
+ function startDrag(e) {
205
+ e.preventDefault();
206
+ dragging = true;
207
+ var pt = e.touches ? e.touches[0] : e;
208
+ dragStartX = pt.clientX;
209
+ dragStartY = pt.clientY;
210
+ startOffsetX = offsetX;
211
+ startOffsetY = offsetY;
212
+ document.addEventListener('mousemove', onDrag);
213
+ document.addEventListener('mouseup', endDrag);
214
+ document.addEventListener('touchmove', onDrag, { passive: false });
215
+ document.addEventListener('touchend', endDrag);
216
+ }
217
+
218
+ function onDrag(e) {
219
+ if (!dragging) return;
220
+ e.preventDefault();
221
+ var pt = e.touches ? e.touches[0] : e;
222
+ offsetX = startOffsetX + (pt.clientX - dragStartX);
223
+ offsetY = startOffsetY + (pt.clientY - dragStartY);
224
+ clampOffsets();
225
+ updatePreview();
226
+ }
227
+
228
+ function endDrag() {
229
+ dragging = false;
230
+ document.removeEventListener('mousemove', onDrag);
231
+ document.removeEventListener('mouseup', endDrag);
232
+ document.removeEventListener('touchmove', onDrag);
233
+ document.removeEventListener('touchend', endDrag);
234
+ }
235
+
236
+ // Zoom
237
+ slider.addEventListener('input', function() {
238
+ scale = parseFloat(slider.value);
239
+ clampOffsets();
240
+ updatePreview();
241
+ });
242
+
243
+ // Scroll to zoom
244
+ viewport.addEventListener('wheel', function(e) {
245
+ e.preventDefault();
246
+ scale += e.deltaY < 0 ? 0.1 : -0.1;
247
+ if (scale < 1) scale = 1;
248
+ if (scale > 6) scale = 6;
249
+ slider.value = scale;
250
+ clampOffsets();
251
+ updatePreview();
252
+ }, { passive: false });
253
+
254
+ function cleanup(revoke) {
255
+ overlay.remove();
256
+ if (revoke) URL.revokeObjectURL(objectUrl);
257
+ }
258
+
259
+ closeBtn.addEventListener('click', function() { cleanup(true); });
260
+ cancelBtn.addEventListener('click', function() { cleanup(true); });
261
+
262
+ doneBtn.addEventListener('click', function() {
263
+ // Render cropped area to canvas
264
+ var canvas = document.createElement('canvas');
265
+ canvas.width = outputSize;
266
+ canvas.height = outputSize;
267
+ var c = canvas.getContext('2d');
268
+ var s = baseScale * scale;
269
+ var tx = (viewSize - img.width * s) / 2 + offsetX;
270
+ var ty = (viewSize - img.height * s) / 2 + offsetY;
271
+ // Map from viewSize coords to outputSize
272
+ var ratio = outputSize / viewSize;
273
+ c.drawImage(img,
274
+ 0, 0, img.width, img.height,
275
+ tx * ratio, ty * ratio, img.width * s * ratio, img.height * s * ratio
276
+ );
277
+ canvas.toBlob(function(blob) {
278
+ cleanup(false);
279
+ if (blob) onDone(blob);
280
+ }, 'image/jpeg', 0.9);
281
+ });
282
+ }
283
+
112
284
  // --- Popover ---
113
285
  function showPopover() {
114
286
  if (popoverEl) {
@@ -136,7 +308,7 @@ function showPopover() {
136
308
  // Avatar row (overlapping banner)
137
309
  html += '<div class="profile-avatar-row">';
138
310
  html += '<div class="profile-popover-avatar">';
139
- html += '<img class="profile-popover-avatar-img" src="' + avatarUrl(currentStyle, seed, 80) + '" alt="avatar">';
311
+ html += '<img class="profile-popover-avatar-img" src="' + (profile.avatarCustom || avatarUrl(currentStyle, seed, 80)) + '" alt="avatar">';
140
312
  html += '</div>';
141
313
  html += '<div class="profile-name-display">' + escapeAttr(displayName || 'Awesome Clay User') + '</div>';
142
314
  html += '</div>';
@@ -166,9 +338,19 @@ function showPopover() {
166
338
  html += '<div class="profile-field">';
167
339
  html += '<label class="profile-field-label">Avatar <button class="profile-shuffle-btn" title="Shuffle">' + iconHtml('shuffle') + '</button></label>';
168
340
  html += '<div class="profile-avatar-grid">';
341
+ // Upload button as first cell
342
+ var uploadActive = profile.avatarCustom ? ' profile-avatar-option-active' : '';
343
+ html += '<button class="profile-avatar-option profile-avatar-upload' + uploadActive + '" title="Upload photo">';
344
+ if (profile.avatarCustom) {
345
+ html += '<img src="' + profile.avatarCustom + '" alt="Custom" class="profile-avatar-custom-preview">';
346
+ } else {
347
+ html += '<span class="profile-avatar-upload-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></span>';
348
+ }
349
+ html += '</button>';
350
+ html += '<input type="file" id="profile-avatar-file" accept="image/*" style="display:none">';
169
351
  for (var j = 0; j < AVATAR_STYLES.length; j++) {
170
352
  var st = AVATAR_STYLES[j];
171
- var activeS = (currentStyle === st.id) ? ' profile-avatar-option-active' : '';
353
+ var activeS = (!profile.avatarCustom && currentStyle === st.id) ? ' profile-avatar-option-active' : '';
172
354
  html += '<button class="profile-avatar-option' + activeS + '" data-style="' + st.id + '" title="' + st.name + '">';
173
355
  html += '<img src="' + avatarUrl(st.id, seed, 40) + '" alt="' + st.name + '">';
174
356
  html += '</button>';
@@ -223,9 +405,61 @@ function showPopover() {
223
405
  debouncedSave();
224
406
  });
225
407
 
408
+ // Avatar upload button
409
+ var uploadBtn = popoverEl.querySelector('.profile-avatar-upload');
410
+ var fileInput = popoverEl.querySelector('#profile-avatar-file');
411
+ if (uploadBtn && fileInput) {
412
+ uploadBtn.addEventListener('click', function() {
413
+ fileInput.click();
414
+ });
415
+ fileInput.addEventListener('change', function() {
416
+ var file = fileInput.files[0];
417
+ if (!file) return;
418
+
419
+ function uploadBlob(blob) {
420
+ blob.arrayBuffer().then(function(ab) {
421
+ var buf = new Uint8Array(ab);
422
+ fetch('/api/avatar', {
423
+ method: 'POST',
424
+ headers: { 'Content-Type': 'application/octet-stream' },
425
+ body: buf,
426
+ }).then(function(r) { return r.json(); }).then(function(data) {
427
+ if (data.ok) {
428
+ profile.avatarCustom = data.avatar;
429
+ applyToIsland();
430
+ updatePopoverHeader();
431
+ if (popoverEl) {
432
+ popoverEl.querySelectorAll('.profile-avatar-option').forEach(function(b) {
433
+ b.classList.remove('profile-avatar-option-active');
434
+ });
435
+ }
436
+ if (uploadBtn) {
437
+ uploadBtn.classList.add('profile-avatar-option-active');
438
+ uploadBtn.innerHTML = '<img src="' + data.avatar + '" alt="Custom" class="profile-avatar-custom-preview">';
439
+ }
440
+ debouncedSave();
441
+ }
442
+ });
443
+ });
444
+ }
445
+
446
+ // Load image and open position picker
447
+ var img = new Image();
448
+ var objectUrl = URL.createObjectURL(file);
449
+ img.onload = function() {
450
+ showAvatarPositioner(img, objectUrl, function(croppedBlob) {
451
+ URL.revokeObjectURL(objectUrl);
452
+ uploadBlob(croppedBlob);
453
+ });
454
+ };
455
+ img.src = objectUrl;
456
+ });
457
+ }
458
+
226
459
  // Avatar style — clicking confirms both the style and the current previewSeed
227
460
  popoverEl.querySelectorAll('.profile-avatar-option[data-style]').forEach(function(btn) {
228
461
  btn.addEventListener('click', function() {
462
+ profile.avatarCustom = '';
229
463
  profile.avatarStyle = btn.dataset.style;
230
464
  profile.avatarSeed = previewSeed;
231
465
  applyToIsland();
@@ -234,6 +468,9 @@ function showPopover() {
234
468
  b.classList.remove('profile-avatar-option-active');
235
469
  });
236
470
  btn.classList.add('profile-avatar-option-active');
471
+ // Reset upload button to + icon
472
+ var upBtn = popoverEl.querySelector('.profile-avatar-upload');
473
+ if (upBtn) upBtn.innerHTML = '<span class="profile-avatar-upload-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></span>';
237
474
  debouncedSave();
238
475
  });
239
476
  });
@@ -283,7 +520,9 @@ function updatePopoverHeader() {
283
520
  if (!popoverEl) return;
284
521
  var img = popoverEl.querySelector('.profile-popover-avatar-img');
285
522
  var nd = popoverEl.querySelector('.profile-name-display');
286
- if (img) img.src = avatarUrl(profile.avatarStyle || 'thumbs', getAvatarSeed(), 80);
523
+ if (img) img.src = profile.avatarCustom
524
+ ? profile.avatarCustom
525
+ : avatarUrl(profile.avatarStyle || 'thumbs', getAvatarSeed(), 80);
287
526
  if (nd) nd.textContent = profile.name || 'Awesome Clay User';
288
527
  }
289
528
 
@@ -351,6 +590,7 @@ export function initProfile(_ctx) {
351
590
  if (data.avatarColor) profile.avatarColor = data.avatarColor;
352
591
  if (data.avatarStyle) profile.avatarStyle = data.avatarStyle;
353
592
  if (data.avatarSeed) profile.avatarSeed = data.avatarSeed;
593
+ if (data.avatarCustom) profile.avatarCustom = data.avatarCustom;
354
594
  if (data.username) profileUsername = data.username;
355
595
 
356
596
  // Auto-generate seed if none exists
@@ -393,6 +633,7 @@ export function showMateProfilePopover(anchorEl, mateData, onUpdate) {
393
633
  var mateColor = mp.avatarColor || '#7c3aed';
394
634
  var mateStyle = mp.avatarStyle || 'bottts';
395
635
  var mateSeed = mp.avatarSeed || mateData.id || 'mate';
636
+ var mateCustom = mp.avatarCustom || '';
396
637
  matePreviewSeed = mateSeed;
397
638
 
398
639
  matePopoverEl = document.createElement('div');
@@ -406,9 +647,10 @@ export function showMateProfilePopover(anchorEl, mateData, onUpdate) {
406
647
  html += '</div>';
407
648
 
408
649
  // Avatar row
650
+ var mateAvatarSrc = mateCustom ? mateCustom : avatarUrl(mateStyle, mateSeed, 80);
409
651
  html += '<div class="profile-avatar-row">';
410
652
  html += '<div class="profile-popover-avatar">';
411
- html += '<img class="profile-popover-avatar-img" src="' + avatarUrl(mateStyle, mateSeed, 80) + '" alt="avatar">';
653
+ html += '<img class="profile-popover-avatar-img" src="' + mateAvatarSrc + '" alt="avatar">';
412
654
  html += '</div>';
413
655
  html += '<div class="profile-name-display">' + escapeAttr(mateName || 'New Mate') + '</div>';
414
656
  html += '</div>';
@@ -426,9 +668,19 @@ export function showMateProfilePopover(anchorEl, mateData, onUpdate) {
426
668
  html += '<div class="profile-field">';
427
669
  html += '<label class="profile-field-label">Avatar <button class="profile-shuffle-btn" title="Shuffle">' + iconHtml('shuffle') + '</button></label>';
428
670
  html += '<div class="profile-avatar-grid">';
671
+ // Upload button as first cell
672
+ var mateUploadActive = mateCustom ? ' profile-avatar-option-active' : '';
673
+ html += '<button class="profile-avatar-option profile-avatar-upload' + mateUploadActive + '" title="Upload photo">';
674
+ if (mateCustom) {
675
+ html += '<img src="' + mateCustom + '" alt="Custom" class="profile-avatar-custom-preview">';
676
+ } else {
677
+ html += '<span class="profile-avatar-upload-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></span>';
678
+ }
679
+ html += '</button>';
680
+ html += '<input type="file" id="mate-profile-avatar-file" accept="image/*" style="display:none">';
429
681
  for (var j = 0; j < AVATAR_STYLES.length; j++) {
430
682
  var st = AVATAR_STYLES[j];
431
- var activeS = (mateStyle === st.id) ? ' profile-avatar-option-active' : '';
683
+ var activeS = (!mateCustom && mateStyle === st.id) ? ' profile-avatar-option-active' : '';
432
684
  html += '<button class="profile-avatar-option' + activeS + '" data-style="' + st.id + '" title="' + st.name + '">';
433
685
  html += '<img src="' + avatarUrl(st.id, mateSeed, 40) + '" alt="' + st.name + '">';
434
686
  html += '</button>';
@@ -458,8 +710,25 @@ export function showMateProfilePopover(anchorEl, mateData, onUpdate) {
458
710
  avatarStyle: mateStyle,
459
711
  avatarSeed: mateSeed,
460
712
  avatarColor: mateColor,
713
+ avatarCustom: mateCustom,
461
714
  };
462
715
 
716
+ function refreshChatAvatars() {
717
+ var newUrl = mateAvatarUrl({ profile: mateProfile, id: mateData.id }, 64);
718
+ // Update all mate avatars in the chat area
719
+ var bubbles = document.querySelectorAll('.dm-bubble-avatar-mate');
720
+ for (var i = 0; i < bubbles.length; i++) {
721
+ bubbles[i].src = newUrl;
722
+ }
723
+ // Update body dataset so new messages use the updated URL
724
+ if (document.body.dataset.mateAvatarUrl) {
725
+ document.body.dataset.mateAvatarUrl = newUrl;
726
+ }
727
+ // Update mate sidebar avatar
728
+ var sidebarAvatar = document.querySelector('.mate-sidebar-avatar');
729
+ if (sidebarAvatar) sidebarAvatar.src = newUrl;
730
+ }
731
+
463
732
  function debouncedMateUpdate() {
464
733
  if (mateSaveTimer) clearTimeout(mateSaveTimer);
465
734
  mateSaveTimer = setTimeout(function() {
@@ -470,8 +739,10 @@ export function showMateProfilePopover(anchorEl, mateData, onUpdate) {
470
739
  avatarStyle: mateProfile.avatarStyle,
471
740
  avatarSeed: mateProfile.avatarSeed,
472
741
  avatarColor: mateProfile.avatarColor,
742
+ avatarCustom: mateProfile.avatarCustom,
473
743
  },
474
744
  });
745
+ refreshChatAvatars();
475
746
  mateSaveTimer = null;
476
747
  }, 400);
477
748
  }
@@ -480,7 +751,9 @@ export function showMateProfilePopover(anchorEl, mateData, onUpdate) {
480
751
  if (!matePopoverEl) return;
481
752
  var img = matePopoverEl.querySelector('.profile-popover-avatar-img');
482
753
  var nd = matePopoverEl.querySelector('.profile-name-display');
483
- if (img) img.src = avatarUrl(mateProfile.avatarStyle, mateProfile.avatarSeed, 80);
754
+ if (img) img.src = mateProfile.avatarCustom
755
+ ? mateProfile.avatarCustom
756
+ : avatarUrl(mateProfile.avatarStyle, mateProfile.avatarSeed, 80);
484
757
  if (nd) nd.textContent = mateProfile.displayName || 'New Mate';
485
758
  }
486
759
 
@@ -503,9 +776,60 @@ export function showMateProfilePopover(anchorEl, mateData, onUpdate) {
503
776
  nameInput.addEventListener('keyup', function(e) { e.stopPropagation(); });
504
777
  nameInput.addEventListener('keypress', function(e) { e.stopPropagation(); });
505
778
 
779
+ // Avatar upload button
780
+ var mateUploadBtn = matePopoverEl.querySelector('.profile-avatar-upload');
781
+ var mateFileInput = matePopoverEl.querySelector('#mate-profile-avatar-file');
782
+ if (mateUploadBtn && mateFileInput) {
783
+ mateUploadBtn.addEventListener('click', function() {
784
+ mateFileInput.click();
785
+ });
786
+ mateFileInput.addEventListener('change', function() {
787
+ var file = mateFileInput.files[0];
788
+ if (!file) return;
789
+
790
+ function uploadMateBlob(blob) {
791
+ blob.arrayBuffer().then(function(ab) {
792
+ var buf = new Uint8Array(ab);
793
+ fetch('/api/mate-avatar/' + encodeURIComponent(mateData.id), {
794
+ method: 'POST',
795
+ headers: { 'Content-Type': 'application/octet-stream' },
796
+ body: buf,
797
+ }).then(function(r) { return r.json(); }).then(function(data) {
798
+ if (data.ok) {
799
+ mateProfile.avatarCustom = data.avatar;
800
+ updateMatePopoverHeader();
801
+ if (matePopoverEl) {
802
+ matePopoverEl.querySelectorAll('.profile-avatar-option').forEach(function(b) {
803
+ b.classList.remove('profile-avatar-option-active');
804
+ });
805
+ }
806
+ if (mateUploadBtn) {
807
+ mateUploadBtn.classList.add('profile-avatar-option-active');
808
+ mateUploadBtn.innerHTML = '<img src="' + data.avatar + '" alt="Custom" class="profile-avatar-custom-preview">';
809
+ }
810
+ debouncedMateUpdate();
811
+ }
812
+ });
813
+ });
814
+ }
815
+
816
+ // Load image and open position picker
817
+ var img = new Image();
818
+ var objectUrl = URL.createObjectURL(file);
819
+ img.onload = function() {
820
+ showAvatarPositioner(img, objectUrl, function(croppedBlob) {
821
+ URL.revokeObjectURL(objectUrl);
822
+ uploadMateBlob(croppedBlob);
823
+ });
824
+ };
825
+ img.src = objectUrl;
826
+ });
827
+ }
828
+
506
829
  // Avatar style
507
830
  matePopoverEl.querySelectorAll('.profile-avatar-option[data-style]').forEach(function(btn) {
508
831
  btn.addEventListener('click', function() {
832
+ mateProfile.avatarCustom = '';
509
833
  mateProfile.avatarStyle = btn.dataset.style;
510
834
  mateProfile.avatarSeed = matePreviewSeed;
511
835
  updateMatePopoverHeader();
@@ -513,6 +837,9 @@ export function showMateProfilePopover(anchorEl, mateData, onUpdate) {
513
837
  b.classList.remove('profile-avatar-option-active');
514
838
  });
515
839
  btn.classList.add('profile-avatar-option-active');
840
+ // Reset upload button to + icon
841
+ var upBtn = matePopoverEl.querySelector('.profile-avatar-upload');
842
+ if (upBtn) upBtn.innerHTML = '<span class="profile-avatar-upload-icon"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></span>';
516
843
  debouncedMateUpdate();
517
844
  });
518
845
  });
@@ -12,16 +12,18 @@ function getShareUrl() {
12
12
  export function triggerShare() {
13
13
  var url = getShareUrl();
14
14
 
15
- // Use Web Share API if available
16
- if (navigator.share) {
15
+ // Use Web Share API on mobile only
16
+ var isMobile = window.innerWidth <= 768 || /Mobi|Android/i.test(navigator.userAgent);
17
+ if (isMobile && navigator.share) {
17
18
  navigator.share({ title: document.title || "Clay", url: url }).catch(function () {});
18
19
  return;
19
20
  }
20
21
 
21
- // Fallback: show QR overlay
22
+ // Show QR overlay
22
23
  var qrOverlay = document.getElementById("qr-overlay");
23
24
  var qrCanvas = document.getElementById("qr-canvas");
24
25
  var qrUrl = document.getElementById("qr-url");
26
+ var qrShareBtn = document.getElementById("qr-share-btn");
25
27
 
26
28
  var qr = qrcode(0, "M");
27
29
  qr.addData(url);
@@ -29,6 +31,15 @@ export function triggerShare() {
29
31
  qrCanvas.innerHTML = qr.createSvgTag(5, 0);
30
32
  qrUrl.innerHTML = url + '<span class="qr-hint">click to copy</span>';
31
33
 
34
+ // Show browser share button if Web Share API is available
35
+ if (qrShareBtn) {
36
+ if (navigator.share) {
37
+ qrShareBtn.classList.remove("hidden");
38
+ } else {
39
+ qrShareBtn.classList.add("hidden");
40
+ }
41
+ }
42
+
32
43
  qrOverlay.classList.remove("hidden");
33
44
  }
34
45
 
@@ -58,6 +69,15 @@ export function initQrCode() {
58
69
  e.stopPropagation();
59
70
  });
60
71
 
72
+ // Browser share button
73
+ var qrShareBtn = document.getElementById("qr-share-btn");
74
+ if (qrShareBtn) {
75
+ qrShareBtn.addEventListener("click", function () {
76
+ var url = getShareUrl();
77
+ navigator.share({ title: document.title || "Clay", url: url }).catch(function () {});
78
+ });
79
+ }
80
+
61
81
  // ESC to close
62
82
  document.addEventListener("keydown", function (e) {
63
83
  if (e.key === "Escape" && !qrOverlay.classList.contains("hidden")) {
@@ -1,3 +1,4 @@
1
+ import { avatarUrl, userAvatarUrl, mateAvatarUrl } from './avatar.js';
1
2
  import { escapeHtml, copyToClipboard } from './utils.js';
2
3
  import { iconHtml, refreshIcons } from './icons.js';
3
4
  import { openProjectSettings } from './project-settings.js';
@@ -572,9 +573,9 @@ export function updateSessionPresence(presence) {
572
573
  }
573
574
  }
574
575
 
575
- function presenceAvatarUrl(style, seed) {
576
- var s = encodeURIComponent(seed || "anonymous");
577
- return "https://api.dicebear.com/9.x/" + (style || "thumbs") + "/svg?seed=" + s + "&size=24";
576
+ function presenceAvatarUrl(userOrStyle, seed) {
577
+ if (userOrStyle && typeof userOrStyle === "object") return userAvatarUrl(userOrStyle, 24);
578
+ return avatarUrl(userOrStyle || "thumbs", seed, 24);
578
579
  }
579
580
 
580
581
  function renderPresenceAvatars(el, sessionId) {
@@ -594,7 +595,7 @@ function renderPresenceAvatars(el, sessionId) {
594
595
  var u = users[i];
595
596
  var img = document.createElement("img");
596
597
  img.className = "session-presence-avatar";
597
- img.src = presenceAvatarUrl(u.avatarStyle, u.avatarSeed);
598
+ img.src = presenceAvatarUrl(u);
598
599
  img.alt = u.displayName;
599
600
  img.dataset.tip = u.displayName + (u.username ? " (@" + u.username + ")" : "");
600
601
  if (i > 0) img.style.marginLeft = "-6px";
@@ -865,7 +866,7 @@ function renderSheetSessions(listEl) {
865
866
 
866
867
  var avatarEl = document.createElement("img");
867
868
  avatarEl.className = "mobile-chat-chip-avatar";
868
- avatarEl.src = "https://api.dicebear.com/9.x/" + (mp.avatarStyle || "bottts") + "/svg?seed=" + encodeURIComponent(mp.avatarSeed || mate.id) + "&size=20";
869
+ avatarEl.src = mateAvatarUrl(mate, 20);
869
870
  avatarEl.alt = mp.displayName || mate.name || "";
870
871
  chip.appendChild(avatarEl);
871
872
 
@@ -1329,6 +1330,22 @@ function renderSheetSettings(listEl) {
1329
1330
  });
1330
1331
 
1331
1332
  listEl.appendChild(themeBtn);
1333
+
1334
+ // "Open as app" — only show if not already in PWA standalone mode
1335
+ if (!document.documentElement.classList.contains("pwa-standalone")) {
1336
+ var pwaBtn = document.createElement("button");
1337
+ pwaBtn.className = "mobile-more-item";
1338
+ pwaBtn.innerHTML = '<i data-lucide="smartphone"></i><span class="mobile-more-item-label">Open as app</span>';
1339
+ pwaBtn.addEventListener("click", function () {
1340
+ closeMobileSheet();
1341
+ // Trigger the existing PWA install modal
1342
+ var installPill = document.getElementById("pwa-install-pill");
1343
+ if (installPill) {
1344
+ setTimeout(function () { installPill.click(); }, 250);
1345
+ }
1346
+ });
1347
+ listEl.appendChild(pwaBtn);
1348
+ }
1332
1349
  }
1333
1350
 
1334
1351
  export function initSidebar(_ctx) {
@@ -2762,7 +2779,7 @@ export function renderSidebarPresence(onlineUsers) {
2762
2779
  var ou = onlineUsers[i];
2763
2780
  var img = document.createElement("img");
2764
2781
  img.className = "sidebar-presence-avatar";
2765
- img.src = presenceAvatarUrl(ou.avatarStyle, ou.avatarSeed);
2782
+ img.src = presenceAvatarUrl(ou);
2766
2783
  img.alt = ou.displayName;
2767
2784
  img.dataset.tip = ou.displayName + " (@" + ou.username + ")";
2768
2785
  container.appendChild(img);
@@ -3337,7 +3354,7 @@ export function renderUserStrip(allUsers, onlineUserIds, myUserId, dmFavorites,
3337
3354
 
3338
3355
  var avatar = document.createElement("img");
3339
3356
  avatar.className = "icon-strip-user-avatar";
3340
- avatar.src = "https://api.dicebear.com/9.x/" + (u.avatarStyle || "thumbs") + "/svg?seed=" + encodeURIComponent(u.avatarSeed || u.username) + "&size=34";
3357
+ avatar.src = userAvatarUrl(u, 34);
3341
3358
  avatar.alt = u.displayName;
3342
3359
  el.appendChild(avatar);
3343
3360
 
@@ -3405,7 +3422,7 @@ export function renderUserStrip(allUsers, onlineUserIds, myUserId, dmFavorites,
3405
3422
 
3406
3423
  var avatar = document.createElement("img");
3407
3424
  avatar.className = "icon-strip-user-avatar";
3408
- avatar.src = "https://api.dicebear.com/9.x/" + (mp.avatarStyle || "bottts") + "/svg?seed=" + encodeURIComponent(mp.avatarSeed || mate.id) + "&size=34";
3425
+ avatar.src = mateAvatarUrl(mate, 34);
3409
3426
  avatar.alt = mp.displayName || mate.name || "Mate";
3410
3427
  el.appendChild(avatar);
3411
3428
 
@@ -3530,7 +3547,7 @@ function toggleDmUserPicker(anchorEl) {
3530
3547
 
3531
3548
  var av = document.createElement("img");
3532
3549
  av.className = "dm-user-picker-avatar";
3533
- av.src = "https://api.dicebear.com/9.x/" + (u.avatarStyle || "thumbs") + "/svg?seed=" + encodeURIComponent(u.avatarSeed || u.username) + "&size=28";
3550
+ av.src = userAvatarUrl(u, 28);
3534
3551
  av.alt = u.displayName;
3535
3552
  item.appendChild(av);
3536
3553