+541
src/components/molecules/grain-avatar-crop.js
+541
src/components/molecules/grain-avatar-crop.js
···
1
+
import { LitElement, html, css } from 'lit';
2
+
import '../atoms/grain-button.js';
3
+
import '../atoms/grain-icon.js';
4
+
import '../atoms/grain-spinner.js';
5
+
6
+
// Constants
7
+
const DEFAULT_CROP_SIZE = 280;
8
+
const MAX_ZOOM = 3;
9
+
const OUTPUT_SIZE = 1000;
10
+
const WHEEL_ZOOM_DELTA = 0.1;
11
+
const KEY_PAN_DELTA = 10;
12
+
13
+
/**
14
+
* Avatar crop modal - allows user to position and zoom an image within a square frame
15
+
*
16
+
* @fires crop - When user confirms crop, detail contains { dataUrl: string }
17
+
* @fires cancel - When user cancels
18
+
* @fires error - When image fails to load
19
+
*/
20
+
export class GrainAvatarCrop extends LitElement {
21
+
static properties = {
22
+
open: { type: Boolean, reflect: true },
23
+
imageUrl: { type: String, attribute: 'image-url' },
24
+
_scale: { state: true },
25
+
_translateX: { state: true },
26
+
_translateY: { state: true },
27
+
_isDragging: { state: true },
28
+
_cropSize: { state: true },
29
+
_loading: { state: true },
30
+
_ready: { state: true }
31
+
};
32
+
33
+
static styles = css`
34
+
:host {
35
+
display: none;
36
+
}
37
+
:host([open]) {
38
+
display: block;
39
+
}
40
+
.overlay {
41
+
position: fixed;
42
+
top: 48px;
43
+
bottom: calc(48px + env(safe-area-inset-bottom, 0px));
44
+
left: 50%;
45
+
transform: translateX(-50%);
46
+
width: 100%;
47
+
max-width: var(--feed-max-width);
48
+
background: var(--color-bg-primary);
49
+
border-top: 1px solid var(--color-border);
50
+
border-bottom: 1px solid var(--color-border);
51
+
z-index: 1000;
52
+
display: flex;
53
+
flex-direction: column;
54
+
align-items: center;
55
+
justify-content: center;
56
+
}
57
+
.header {
58
+
position: absolute;
59
+
top: 0;
60
+
left: 0;
61
+
right: 0;
62
+
display: flex;
63
+
align-items: center;
64
+
justify-content: space-between;
65
+
padding: var(--space-md);
66
+
color: var(--color-text-primary);
67
+
}
68
+
.header h2 {
69
+
margin: 0;
70
+
font-size: var(--font-size-md);
71
+
font-weight: var(--font-weight-semibold);
72
+
}
73
+
.header-button {
74
+
background: none;
75
+
border: none;
76
+
color: var(--color-text-primary);
77
+
padding: 8px;
78
+
cursor: pointer;
79
+
font-size: var(--font-size-md);
80
+
}
81
+
.header-button:disabled {
82
+
opacity: 0.5;
83
+
cursor: not-allowed;
84
+
}
85
+
.crop-area {
86
+
display: flex;
87
+
align-items: center;
88
+
justify-content: center;
89
+
position: relative;
90
+
overflow: hidden;
91
+
touch-action: none;
92
+
width: 100%;
93
+
max-width: 400px;
94
+
padding: var(--space-md);
95
+
outline: none;
96
+
}
97
+
.image-container {
98
+
position: relative;
99
+
display: flex;
100
+
align-items: center;
101
+
justify-content: center;
102
+
width: 100%;
103
+
}
104
+
.crop-image {
105
+
max-width: 100%;
106
+
max-height: 400px;
107
+
user-select: none;
108
+
-webkit-user-drag: none;
109
+
}
110
+
.mask {
111
+
position: absolute;
112
+
inset: 0;
113
+
pointer-events: none;
114
+
display: flex;
115
+
align-items: center;
116
+
justify-content: center;
117
+
}
118
+
.mask-square {
119
+
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.6);
120
+
border: 2px solid rgba(255, 255, 255, 0.3);
121
+
}
122
+
.controls {
123
+
position: absolute;
124
+
bottom: 0;
125
+
left: 0;
126
+
right: 0;
127
+
padding: var(--space-md);
128
+
display: flex;
129
+
flex-direction: column;
130
+
gap: var(--space-md);
131
+
}
132
+
.zoom-control {
133
+
display: flex;
134
+
align-items: center;
135
+
gap: var(--space-md);
136
+
padding: 0 var(--space-md);
137
+
}
138
+
.zoom-control grain-icon {
139
+
color: var(--color-text-secondary);
140
+
opacity: 0.7;
141
+
}
142
+
.zoom-slider {
143
+
flex: 1;
144
+
-webkit-appearance: none;
145
+
appearance: none;
146
+
height: 4px;
147
+
background: rgba(255, 255, 255, 0.3);
148
+
border-radius: 2px;
149
+
outline: none;
150
+
}
151
+
.zoom-slider::-webkit-slider-thumb {
152
+
-webkit-appearance: none;
153
+
appearance: none;
154
+
width: 20px;
155
+
height: 20px;
156
+
background: white;
157
+
border-radius: 50%;
158
+
cursor: pointer;
159
+
}
160
+
.zoom-slider::-moz-range-thumb {
161
+
width: 20px;
162
+
height: 20px;
163
+
background: white;
164
+
border-radius: 50%;
165
+
cursor: pointer;
166
+
border: none;
167
+
}
168
+
.loading-container {
169
+
display: flex;
170
+
align-items: center;
171
+
justify-content: center;
172
+
min-height: 200px;
173
+
}
174
+
`;
175
+
176
+
// Track load operations to handle race conditions
177
+
#loadId = 0;
178
+
#pinchStartDistance = 0;
179
+
#pinchStartScale = 1;
180
+
181
+
constructor() {
182
+
super();
183
+
this.open = false;
184
+
this.imageUrl = '';
185
+
this._scale = 1;
186
+
this._translateX = 0;
187
+
this._translateY = 0;
188
+
this._isDragging = false;
189
+
this._dragStart = { x: 0, y: 0 };
190
+
this._lastTranslate = { x: 0, y: 0 };
191
+
this._imageSize = { width: 0, height: 0 };
192
+
this._displayedSize = { width: 0, height: 0 };
193
+
this._minScale = 1;
194
+
this._cropSize = DEFAULT_CROP_SIZE;
195
+
this._loading = false;
196
+
this._ready = false;
197
+
}
198
+
199
+
updated(changedProps) {
200
+
if (changedProps.has('imageUrl') && this.imageUrl) {
201
+
this.#loadImage();
202
+
}
203
+
if (changedProps.has('open') && this.open) {
204
+
this.#reset();
205
+
// Focus crop area for keyboard navigation
206
+
requestAnimationFrame(() => {
207
+
this.shadowRoot.querySelector('.crop-area')?.focus();
208
+
});
209
+
}
210
+
}
211
+
212
+
#reset() {
213
+
this._scale = 1;
214
+
this._translateX = 0;
215
+
this._translateY = 0;
216
+
this._ready = false;
217
+
if (this.imageUrl) {
218
+
this.#loadImage();
219
+
}
220
+
}
221
+
222
+
async #loadImage() {
223
+
// Increment load ID to handle race conditions
224
+
const currentLoadId = ++this.#loadId;
225
+
226
+
this._loading = true;
227
+
this._ready = false;
228
+
229
+
try {
230
+
const img = new Image();
231
+
img.src = this.imageUrl;
232
+
await img.decode();
233
+
234
+
// Check if this load is still current (not stale)
235
+
if (currentLoadId !== this.#loadId) return;
236
+
237
+
this._imageSize = { width: img.naturalWidth, height: img.naturalHeight };
238
+
239
+
// Start at scale 1
240
+
this._minScale = 1;
241
+
this._scale = 1;
242
+
this._translateX = 0;
243
+
this._translateY = 0;
244
+
245
+
// Image loaded successfully
246
+
this._loading = false;
247
+
248
+
// Wait for render then get displayed image size
249
+
await this.updateComplete;
250
+
251
+
// Wait for layout to complete (double RAF for reliable dimensions)
252
+
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
253
+
254
+
// Check again after async operation
255
+
if (currentLoadId !== this.#loadId) return;
256
+
257
+
const displayedImg = this.shadowRoot.querySelector('.crop-image');
258
+
if (displayedImg && displayedImg.offsetWidth > 0) {
259
+
this._displayedSize = {
260
+
width: displayedImg.offsetWidth,
261
+
height: displayedImg.offsetHeight
262
+
};
263
+
// Crop size is the smaller of default or the smallest image dimension
264
+
const minDimension = Math.min(this._displayedSize.width, this._displayedSize.height);
265
+
this._cropSize = Math.min(DEFAULT_CROP_SIZE, minDimension);
266
+
this._ready = true;
267
+
}
268
+
} catch (err) {
269
+
console.error('Failed to load image:', err);
270
+
if (currentLoadId === this.#loadId) {
271
+
this._loading = false;
272
+
this.dispatchEvent(new CustomEvent('error', {
273
+
detail: { message: 'Failed to load image' },
274
+
bubbles: true,
275
+
composed: true
276
+
}));
277
+
}
278
+
}
279
+
}
280
+
281
+
#handlePointerDown(e) {
282
+
if (e.target.tagName === 'INPUT') return;
283
+
284
+
// Prevent default to avoid text selection and other browser behaviors
285
+
e.preventDefault();
286
+
287
+
this._isDragging = true;
288
+
this._dragStart = { x: e.clientX, y: e.clientY };
289
+
this._lastTranslate = { x: this._translateX, y: this._translateY };
290
+
291
+
// Capture pointer on the crop-area element for reliable drag tracking
292
+
const cropArea = this.shadowRoot.querySelector('.crop-area');
293
+
cropArea?.setPointerCapture?.(e.pointerId);
294
+
}
295
+
296
+
#handlePointerMove(e) {
297
+
if (!this._isDragging) return;
298
+
299
+
// Prevent scrolling while dragging
300
+
e.preventDefault();
301
+
302
+
const dx = e.clientX - this._dragStart.x;
303
+
const dy = e.clientY - this._dragStart.y;
304
+
305
+
this._translateX = this.#clampTranslateX(this._lastTranslate.x + dx);
306
+
this._translateY = this.#clampTranslateY(this._lastTranslate.y + dy);
307
+
}
308
+
309
+
#handlePointerUp() {
310
+
this._isDragging = false;
311
+
}
312
+
313
+
#handleTouchStart(e) {
314
+
if (e.touches.length === 2) {
315
+
// Pinch start - prevent default to avoid browser zoom
316
+
e.preventDefault();
317
+
const dx = e.touches[0].clientX - e.touches[1].clientX;
318
+
const dy = e.touches[0].clientY - e.touches[1].clientY;
319
+
this.#pinchStartDistance = Math.hypot(dx, dy);
320
+
this.#pinchStartScale = this._scale;
321
+
}
322
+
// Single touch is handled by pointer events
323
+
}
324
+
325
+
#handleTouchMove(e) {
326
+
if (e.touches.length === 2 && this.#pinchStartDistance > 0) {
327
+
e.preventDefault();
328
+
const dx = e.touches[0].clientX - e.touches[1].clientX;
329
+
const dy = e.touches[0].clientY - e.touches[1].clientY;
330
+
const distance = Math.hypot(dx, dy);
331
+
const scaleFactor = distance / this.#pinchStartDistance;
332
+
const newScale = Math.max(this._minScale, Math.min(MAX_ZOOM, this.#pinchStartScale * scaleFactor));
333
+
this._scale = newScale;
334
+
335
+
this._translateX = this.#clampTranslateX(this._translateX);
336
+
this._translateY = this.#clampTranslateY(this._translateY);
337
+
}
338
+
}
339
+
340
+
#handleTouchEnd() {
341
+
this.#pinchStartDistance = 0;
342
+
}
343
+
344
+
#clampTranslateX(x) {
345
+
// Allow free movement if image size not yet calculated
346
+
if (!this._displayedSize.width || !this._cropSize) return x;
347
+
const scaledWidth = this._displayedSize.width * this._scale;
348
+
const maxOffset = Math.max(0, (scaledWidth - this._cropSize) / 2);
349
+
return Math.max(-maxOffset, Math.min(maxOffset, x));
350
+
}
351
+
352
+
#clampTranslateY(y) {
353
+
// Allow free movement if image size not yet calculated
354
+
if (!this._displayedSize.height || !this._cropSize) return y;
355
+
const scaledHeight = this._displayedSize.height * this._scale;
356
+
const maxOffset = Math.max(0, (scaledHeight - this._cropSize) / 2);
357
+
return Math.max(-maxOffset, Math.min(maxOffset, y));
358
+
}
359
+
360
+
#handleZoom(e) {
361
+
const newScale = parseFloat(e.target.value);
362
+
this._scale = newScale;
363
+
364
+
// Re-clamp translation after scale change
365
+
this._translateX = this.#clampTranslateX(this._translateX);
366
+
this._translateY = this.#clampTranslateY(this._translateY);
367
+
}
368
+
369
+
#handleWheel(e) {
370
+
e.preventDefault();
371
+
const delta = e.deltaY > 0 ? -WHEEL_ZOOM_DELTA : WHEEL_ZOOM_DELTA;
372
+
const newScale = Math.max(this._minScale, Math.min(MAX_ZOOM, this._scale + delta));
373
+
this._scale = newScale;
374
+
375
+
this._translateX = this.#clampTranslateX(this._translateX);
376
+
this._translateY = this.#clampTranslateY(this._translateY);
377
+
}
378
+
379
+
#handleKeyDown(e) {
380
+
switch (e.key) {
381
+
case 'Escape':
382
+
e.preventDefault();
383
+
this.#handleCancel();
384
+
break;
385
+
case 'Enter':
386
+
e.preventDefault();
387
+
this.#handleConfirm();
388
+
break;
389
+
case 'ArrowLeft':
390
+
e.preventDefault();
391
+
this._translateX = this.#clampTranslateX(this._translateX + KEY_PAN_DELTA);
392
+
break;
393
+
case 'ArrowRight':
394
+
e.preventDefault();
395
+
this._translateX = this.#clampTranslateX(this._translateX - KEY_PAN_DELTA);
396
+
break;
397
+
case 'ArrowUp':
398
+
e.preventDefault();
399
+
this._translateY = this.#clampTranslateY(this._translateY + KEY_PAN_DELTA);
400
+
break;
401
+
case 'ArrowDown':
402
+
e.preventDefault();
403
+
this._translateY = this.#clampTranslateY(this._translateY - KEY_PAN_DELTA);
404
+
break;
405
+
case '+':
406
+
case '=':
407
+
e.preventDefault();
408
+
this._scale = Math.min(MAX_ZOOM, this._scale + WHEEL_ZOOM_DELTA);
409
+
this._translateX = this.#clampTranslateX(this._translateX);
410
+
this._translateY = this.#clampTranslateY(this._translateY);
411
+
break;
412
+
case '-':
413
+
case '_':
414
+
e.preventDefault();
415
+
this._scale = Math.max(this._minScale, this._scale - WHEEL_ZOOM_DELTA);
416
+
this._translateX = this.#clampTranslateX(this._translateX);
417
+
this._translateY = this.#clampTranslateY(this._translateY);
418
+
break;
419
+
}
420
+
}
421
+
422
+
#handleCancel() {
423
+
this.dispatchEvent(new CustomEvent('cancel', { bubbles: true, composed: true }));
424
+
}
425
+
426
+
async #handleConfirm() {
427
+
// Guard against calling before image is ready
428
+
if (!this._ready || !this._displayedSize.width || !this._displayedSize.height) {
429
+
console.error('Image not fully loaded');
430
+
return;
431
+
}
432
+
433
+
const canvas = document.createElement('canvas');
434
+
canvas.width = OUTPUT_SIZE;
435
+
canvas.height = OUTPUT_SIZE;
436
+
const ctx = canvas.getContext('2d');
437
+
438
+
const img = this.shadowRoot.querySelector('.crop-image');
439
+
440
+
// Calculate the ratio between natural and displayed size
441
+
const scaleRatioX = this._imageSize.width / this._displayedSize.width;
442
+
const scaleRatioY = this._imageSize.height / this._displayedSize.height;
443
+
444
+
// The visible crop area in displayed coordinates
445
+
const displayedCropWidth = this._cropSize / this._scale;
446
+
const displayedCropHeight = this._cropSize / this._scale;
447
+
448
+
// Center of displayed image adjusted by translation
449
+
const displayedCenterX = this._displayedSize.width / 2 - this._translateX / this._scale;
450
+
const displayedCenterY = this._displayedSize.height / 2 - this._translateY / this._scale;
451
+
452
+
// Convert to natural image coordinates
453
+
const sx = (displayedCenterX - displayedCropWidth / 2) * scaleRatioX;
454
+
const sy = (displayedCenterY - displayedCropHeight / 2) * scaleRatioY;
455
+
const sw = displayedCropWidth * scaleRatioX;
456
+
const sh = displayedCropHeight * scaleRatioY;
457
+
458
+
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
459
+
460
+
const dataUrl = canvas.toDataURL('image/jpeg', 0.9);
461
+
462
+
this.dispatchEvent(new CustomEvent('crop', {
463
+
detail: { dataUrl },
464
+
bubbles: true,
465
+
composed: true
466
+
}));
467
+
}
468
+
469
+
render() {
470
+
if (!this.open) return null;
471
+
472
+
return html`
473
+
<div class="overlay">
474
+
<div class="header">
475
+
<button class="header-button" @click=${this.#handleCancel}>Cancel</button>
476
+
<h2>Move and Scale</h2>
477
+
<button
478
+
class="header-button"
479
+
@click=${this.#handleConfirm}
480
+
?disabled=${!this._ready}
481
+
>Done</button>
482
+
</div>
483
+
484
+
${this._loading ? html`
485
+
<div class="loading-container">
486
+
<grain-spinner></grain-spinner>
487
+
</div>
488
+
` : html`
489
+
<div
490
+
class="crop-area"
491
+
tabindex="0"
492
+
@pointerdown=${this.#handlePointerDown}
493
+
@pointermove=${this.#handlePointerMove}
494
+
@pointerup=${this.#handlePointerUp}
495
+
@pointercancel=${this.#handlePointerUp}
496
+
@touchstart=${this.#handleTouchStart}
497
+
@touchmove=${this.#handleTouchMove}
498
+
@touchend=${this.#handleTouchEnd}
499
+
@wheel=${this.#handleWheel}
500
+
@keydown=${this.#handleKeyDown}
501
+
>
502
+
<div class="image-container">
503
+
<img
504
+
class="crop-image"
505
+
src=${this.imageUrl}
506
+
style="transform: translate(${this._translateX}px, ${this._translateY}px) scale(${this._scale})"
507
+
draggable="false"
508
+
alt="Image to crop"
509
+
/>
510
+
<div class="mask">
511
+
<div class="mask-square" style="width: ${this._cropSize}px; height: ${this._cropSize}px;"></div>
512
+
</div>
513
+
</div>
514
+
</div>
515
+
516
+
<div class="controls">
517
+
<div class="zoom-control">
518
+
<grain-icon name="image" size="16"></grain-icon>
519
+
<input
520
+
type="range"
521
+
class="zoom-slider"
522
+
min=${this._minScale}
523
+
max=${MAX_ZOOM}
524
+
step="0.01"
525
+
.value=${String(this._scale)}
526
+
@input=${this.#handleZoom}
527
+
aria-label="Zoom level"
528
+
aria-valuemin=${this._minScale}
529
+
aria-valuemax=${MAX_ZOOM}
530
+
aria-valuenow=${this._scale}
531
+
/>
532
+
<grain-icon name="image" size="24"></grain-icon>
533
+
</div>
534
+
</div>
535
+
`}
536
+
</div>
537
+
`;
538
+
}
539
+
}
540
+
541
+
customElements.define('grain-avatar-crop', GrainAvatarCrop);
+113
-12
src/components/organisms/grain-profile-header.js
+113
-12
src/components/organisms/grain-profile-header.js
···
3
3
import { auth } from '../../services/auth.js';
4
4
import { share } from '../../services/share.js';
5
5
import { mutations } from '../../services/mutations.js';
6
+
import { readFileAsDataURL, resizeImage } from '../../utils/image-resize.js';
6
7
import '../atoms/grain-avatar.js';
7
8
import '../atoms/grain-icon.js';
9
+
import '../atoms/grain-spinner.js';
8
10
import '../atoms/grain-toast.js';
9
11
import '../molecules/grain-profile-stats.js';
10
12
···
12
14
static properties = {
13
15
profile: { type: Object },
14
16
_user: { state: true },
15
-
_followLoading: { state: true }
17
+
_followLoading: { state: true },
18
+
_avatarUploading: { state: true }
16
19
};
17
20
18
21
static styles = css`
···
66
69
padding: 0;
67
70
cursor: pointer;
68
71
}
72
+
.avatar-wrapper {
73
+
position: relative;
74
+
}
75
+
.avatar-overlay {
76
+
position: absolute;
77
+
bottom: 0;
78
+
right: 0;
79
+
width: 28px;
80
+
height: 28px;
81
+
border-radius: 50%;
82
+
background: var(--color-bg-primary);
83
+
border: 2px solid var(--color-border);
84
+
display: flex;
85
+
align-items: center;
86
+
justify-content: center;
87
+
color: var(--color-text-primary);
88
+
}
89
+
.avatar-spinner {
90
+
position: absolute;
91
+
inset: 0;
92
+
display: flex;
93
+
align-items: center;
94
+
justify-content: center;
95
+
background: rgba(0, 0, 0, 0.5);
96
+
border-radius: 50%;
97
+
}
98
+
input[type="file"] {
99
+
display: none;
100
+
}
69
101
.menu-button {
70
102
background: none;
71
103
border: none;
···
99
131
super();
100
132
this._user = auth.user;
101
133
this._followLoading = false;
134
+
this._avatarUploading = false;
102
135
}
103
136
104
137
connectedCallback() {
···
139
172
} else if (action === 'share') {
140
173
const result = await share(window.location.href);
141
174
if (result.success && result.method === 'clipboard') {
142
-
this.shadowRoot.querySelector('grain-toast').show('Link copied');
175
+
this.shadowRoot.querySelector('grain-toast')?.show('Link copied');
143
176
}
144
177
}
145
178
}
146
179
147
180
#handleAvatarClick() {
148
-
this.dispatchEvent(new CustomEvent('avatar-click', {
149
-
bubbles: true,
150
-
composed: true
151
-
}));
181
+
if (this.#isOwnProfile) {
182
+
this.shadowRoot.querySelector('#avatar-input').click();
183
+
} else {
184
+
this.dispatchEvent(new CustomEvent('avatar-click', {
185
+
bubbles: true,
186
+
composed: true
187
+
}));
188
+
}
189
+
}
190
+
191
+
async #handleAvatarFileChange(e) {
192
+
const input = e.target;
193
+
const file = input.files?.[0];
194
+
if (!file) return;
195
+
196
+
// Reset input immediately so same file can be selected again
197
+
input.value = '';
198
+
199
+
try {
200
+
const dataUrl = await readFileAsDataURL(file);
201
+
const resized = await resizeImage(dataUrl, {
202
+
width: 2000,
203
+
height: 2000,
204
+
maxSize: 900000
205
+
});
206
+
// Emit event for parent to show crop modal
207
+
this.dispatchEvent(new CustomEvent('avatar-crop-start', {
208
+
detail: { imageUrl: resized.dataUrl },
209
+
bubbles: true,
210
+
composed: true
211
+
}));
212
+
} catch (err) {
213
+
console.error('Failed to process image:', err);
214
+
this.shadowRoot.querySelector('grain-toast')?.show('Failed to process image');
215
+
}
216
+
}
217
+
218
+
async uploadCroppedAvatar(dataUrl) {
219
+
this._avatarUploading = true;
220
+
221
+
try {
222
+
await mutations.updateAvatar(dataUrl, this.profile);
223
+
this.shadowRoot.querySelector('grain-toast')?.show('Avatar updated');
224
+
this.dispatchEvent(new CustomEvent('avatar-updated', {
225
+
bubbles: true,
226
+
composed: true
227
+
}));
228
+
} catch (err) {
229
+
console.error('Failed to update avatar:', err);
230
+
this.shadowRoot.querySelector('grain-toast')?.show('Failed to update avatar');
231
+
} finally {
232
+
this._avatarUploading = false;
233
+
}
152
234
}
153
235
154
236
async #handleFollowClick() {
···
171
253
};
172
254
} catch (err) {
173
255
console.error('Failed to toggle follow:', err);
174
-
this.shadowRoot.querySelector('grain-toast').show('Failed to update');
256
+
this.shadowRoot.querySelector('grain-toast')?.show('Failed to update');
175
257
} finally {
176
258
this._followLoading = false;
177
259
}
···
185
267
return html`
186
268
<div class="top-row">
187
269
<button class="avatar-button" @click=${this.#handleAvatarClick}>
188
-
<grain-avatar
189
-
src=${avatarUrl || ''}
190
-
alt=${handle || ''}
191
-
size="lg"
192
-
></grain-avatar>
270
+
<div class="avatar-wrapper">
271
+
<grain-avatar
272
+
src=${avatarUrl || ''}
273
+
alt=${handle || ''}
274
+
size="lg"
275
+
></grain-avatar>
276
+
${this.#isOwnProfile ? html`
277
+
<div class="avatar-overlay">
278
+
<grain-icon name="camera" size="14"></grain-icon>
279
+
</div>
280
+
` : ''}
281
+
${this._avatarUploading ? html`
282
+
<div class="avatar-spinner">
283
+
<grain-spinner size="24"></grain-spinner>
284
+
</div>
285
+
` : ''}
286
+
</div>
193
287
</button>
194
288
<div class="right-column">
195
289
<div class="handle-row">
···
218
312
${this.profile.viewerIsFollowing ? 'Following' : 'Follow'}
219
313
</button>
220
314
` : ''}
315
+
316
+
<input
317
+
type="file"
318
+
id="avatar-input"
319
+
accept="image/png,image/jpeg"
320
+
@change=${this.#handleAvatarFileChange}
321
+
/>
221
322
222
323
<grain-toast></grain-toast>
223
324
`;
+33
-1
src/components/pages/grain-profile.js
+33
-1
src/components/pages/grain-profile.js
···
5
5
import '../organisms/grain-profile-header.js';
6
6
import '../organisms/grain-gallery-grid.js';
7
7
import '../molecules/grain-pull-to-refresh.js';
8
+
import '../molecules/grain-avatar-crop.js';
8
9
import '../atoms/grain-spinner.js';
9
10
import '../organisms/grain-action-dialog.js';
10
11
···
17
18
_error: { state: true },
18
19
_menuOpen: { state: true },
19
20
_menuActions: { state: true },
20
-
_showAvatarFullscreen: { state: true }
21
+
_showAvatarFullscreen: { state: true },
22
+
_showAvatarCrop: { state: true },
23
+
_cropImageUrl: { state: true }
21
24
};
22
25
23
26
static styles = css`
···
61
64
this._menuOpen = false;
62
65
this._menuActions = [];
63
66
this._showAvatarFullscreen = false;
67
+
this._showAvatarCrop = false;
68
+
this._cropImageUrl = null;
64
69
}
65
70
66
71
#handleMenuOpen(e) {
···
86
91
this._showAvatarFullscreen = false;
87
92
}
88
93
94
+
#handleAvatarCropStart(e) {
95
+
this._cropImageUrl = e.detail.imageUrl;
96
+
this._showAvatarCrop = true;
97
+
}
98
+
99
+
#handleCropCancel() {
100
+
this._showAvatarCrop = false;
101
+
this._cropImageUrl = null;
102
+
}
103
+
104
+
async #handleCrop(e) {
105
+
this._showAvatarCrop = false;
106
+
this._cropImageUrl = null;
107
+
// Call the profile header's upload method
108
+
const header = this.shadowRoot.querySelector('grain-profile-header');
109
+
await header?.uploadCroppedAvatar(e.detail.dataUrl);
110
+
}
111
+
89
112
connectedCallback() {
90
113
super.connectedCallback();
91
114
// Check cache first to avoid flash
···
139
162
.profile=${this._profile}
140
163
@menu-open=${this.#handleMenuOpen}
141
164
@avatar-click=${this.#handleAvatarClick}
165
+
@avatar-crop-start=${this.#handleAvatarCropStart}
166
+
@avatar-updated=${this.#handleRefresh}
142
167
></grain-profile-header>
143
168
144
169
${this._profile.galleries.length > 0 ? html`
···
165
190
@action=${this.#handleMenuAction}
166
191
@close=${this.#handleMenuClose}
167
192
></grain-action-dialog>
193
+
194
+
<grain-avatar-crop
195
+
?open=${this._showAvatarCrop}
196
+
image-url=${this._cropImageUrl || ''}
197
+
@crop=${this.#handleCrop}
198
+
@cancel=${this.#handleCropCancel}
199
+
></grain-avatar-crop>
168
200
`;
169
201
}
170
202
}
+2
src/services/grain-api.js
+2
src/services/grain-api.js
···
258
258
actorHandle
259
259
displayName
260
260
description
261
+
createdAt
261
262
avatar { url(preset: "avatar") }
262
263
socialGrainGraphFollowByDid {
263
264
totalCount
···
370
371
handle: profile?.actorHandle || handle,
371
372
displayName: profile?.displayName || '',
372
373
description: profile?.description || '',
374
+
createdAt: profile?.createdAt || null,
373
375
avatarUrl: profile?.avatar?.url || '',
374
376
did: profile?.did || '',
375
377
galleryCount: galleriesConnection?.totalCount || 0,
+52
src/services/mutations.js
+52
src/services/mutations.js
···
134
134
}
135
135
`, { rkey });
136
136
}
137
+
138
+
async uploadBlob(base64Data, mimeType = 'image/jpeg') {
139
+
const client = auth.getClient();
140
+
const result = await client.mutate(`
141
+
mutation UploadBlob($data: String!, $mimeType: String!) {
142
+
uploadBlob(data: $data, mimeType: $mimeType) {
143
+
ref
144
+
mimeType
145
+
size
146
+
}
147
+
}
148
+
`, { data: base64Data, mimeType });
149
+
150
+
return result.uploadBlob;
151
+
}
152
+
153
+
async updateAvatar(dataUrl, profile) {
154
+
const client = auth.getClient();
155
+
156
+
// Upload the blob (already resized by crop component)
157
+
const base64Data = dataUrl.split(',')[1];
158
+
const blob = await this.uploadBlob(base64Data, 'image/jpeg');
159
+
160
+
if (!blob) {
161
+
throw new Error('Failed to upload avatar');
162
+
}
163
+
164
+
// Build input with all profile fields (update requires full object)
165
+
const input = {
166
+
displayName: profile.displayName || null,
167
+
description: profile.description || null,
168
+
createdAt: profile.createdAt || new Date().toISOString(),
169
+
avatar: {
170
+
$type: 'blob',
171
+
ref: { $link: blob.ref },
172
+
mimeType: blob.mimeType,
173
+
size: blob.size
174
+
}
175
+
};
176
+
177
+
// Update profile
178
+
await client.mutate(`
179
+
mutation UpdateProfile($rkey: String!, $input: SocialGrainActorProfileInput!) {
180
+
updateSocialGrainActorProfile(rkey: $rkey, input: $input) {
181
+
uri
182
+
}
183
+
}
184
+
`, { rkey: 'self', input });
185
+
186
+
// Refresh user data
187
+
await auth.refreshUser();
188
+
}
137
189
}
138
190
139
191
export const mutations = new MutationsService();