diff --git a/ts_web/elements/profilepicture/dees-input-profilepicture.ts b/ts_web/elements/profilepicture/dees-input-profilepicture.ts index 707afd7..9c132d2 100644 --- a/ts_web/elements/profilepicture/dees-input-profilepicture.ts +++ b/ts_web/elements/profilepicture/dees-input-profilepicture.ts @@ -49,12 +49,21 @@ export class DeesInputProfilePicture extends DeesInputBase -
+
${this.value ? html` Profile picture ` : html` @@ -241,6 +310,12 @@ export class DeesInputProfilePicture extends DeesInputBase
` : ''} + + ${this.isLoading && !this.value ? html` +
+
+
+ ` : ''}
@@ -259,7 +334,21 @@ export class DeesInputProfilePicture extends DeesInputBase { + setTimeout(() => { + // Check if no file was selected + if (!input.files || input.files.length === 0) { + this.isLoading = false; + } + window.removeEventListener('focus', handleFocus); + }, 300); + }; + + window.addEventListener('focus', handleFocus); input.click(); } } @@ -268,6 +357,9 @@ export class DeesInputProfilePicture extends DeesInputBase { this.value = event.detail.croppedImage; diff --git a/ts_web/elements/profilepicture/profilepicture.cropper.ts b/ts_web/elements/profilepicture/profilepicture.cropper.ts index ae1da7a..d3840ba 100644 --- a/ts_web/elements/profilepicture/profilepicture.cropper.ts +++ b/ts_web/elements/profilepicture/profilepicture.cropper.ts @@ -6,6 +6,8 @@ export interface CropperOptions { shape: ProfileShape; aspectRatio: number; minSize?: number; + outputSize?: number; + outputQuality?: number; } export class ImageCropper { @@ -37,6 +39,8 @@ export class ImageCropper { constructor(options: CropperOptions) { this.options = { minSize: 50, + outputSize: 800, // Higher default resolution + outputQuality: 0.95, // Higher quality ...options }; @@ -96,8 +100,8 @@ export class ImageCropper { container.appendChild(this.canvas); container.appendChild(this.overlayCanvas); - // Calculate image scale and position - const scale = Math.max( + // Calculate image scale to fit within container (not fill) + const scale = Math.min( containerSize / this.img.width, containerSize / this.img.height ); @@ -107,7 +111,12 @@ export class ImageCropper { this.imageOffsetY = (containerSize - this.img.height * scale) / 2; // Initialize crop area - this.cropSize = containerSize * 0.8; + // Make the crop area fit within the actual image bounds + const scaledImageWidth = this.img.width * scale; + const scaledImageHeight = this.img.height * scale; + const maxCropSize = Math.min(scaledImageWidth, scaledImageHeight, containerSize * 0.8); + + this.cropSize = maxCropSize * 0.8; // Start at 80% of max possible size this.cropX = (containerSize - this.cropSize) / 2; this.cropY = (containerSize - this.cropSize) / 2; } @@ -162,8 +171,14 @@ export class ImageCropper { const dx = x - this.dragStartX; const dy = y - this.dragStartY; - this.cropX = Math.max(0, Math.min(this.canvas.width - this.cropSize, this.cropX + dx)); - this.cropY = Math.max(0, Math.min(this.canvas.height - this.cropSize, this.cropY + dy)); + // Constrain crop area to image bounds + const minX = this.imageOffsetX; + const maxX = this.imageOffsetX + this.img.width * this.imageScale - this.cropSize; + const minY = this.imageOffsetY; + const maxY = this.imageOffsetY + this.img.height * this.imageScale - this.cropSize; + + this.cropX = Math.max(minX, Math.min(maxX, this.cropX + dx)); + this.cropY = Math.max(minY, Math.min(maxY, this.cropY + dy)); this.dragStartX = x; this.dragStartY = y; @@ -247,36 +262,52 @@ export class ImageCropper { const dx = x - this.dragStartX; const dy = y - this.dragStartY; + // Get image bounds + const imgLeft = this.imageOffsetX; + const imgTop = this.imageOffsetY; + const imgRight = this.imageOffsetX + this.img.width * this.imageScale; + const imgBottom = this.imageOffsetY + this.img.height * this.imageScale; + switch (this.resizeHandle) { case 'se': this.cropSize = Math.max(this.minCropSize, Math.min( this.cropSize + Math.max(dx, dy), Math.min( - this.canvas.width - this.cropX, - this.canvas.height - this.cropY + imgRight - this.cropX, + imgBottom - this.cropY ) )); break; case 'nw': const newSize = Math.max(this.minCropSize, this.cropSize - Math.max(dx, dy)); const sizeDiff = this.cropSize - newSize; - this.cropX += sizeDiff; - this.cropY += sizeDiff; - this.cropSize = newSize; + const newX = this.cropX + sizeDiff; + const newY = this.cropY + sizeDiff; + if (newX >= imgLeft && newY >= imgTop) { + this.cropX = newX; + this.cropY = newY; + this.cropSize = newSize; + } break; case 'ne': const neSizeDx = Math.max(dx, -dy); const neNewSize = Math.max(this.minCropSize, this.cropSize + neSizeDx); const neSizeDiff = neNewSize - this.cropSize; - this.cropY -= neSizeDiff; - this.cropSize = neNewSize; + const neNewY = this.cropY - neSizeDiff; + if (neNewY >= imgTop && this.cropX + neNewSize <= imgRight) { + this.cropY = neNewY; + this.cropSize = neNewSize; + } break; case 'sw': const swSizeDx = Math.max(-dx, dy); const swNewSize = Math.max(this.minCropSize, this.cropSize + swSizeDx); const swSizeDiff = swNewSize - this.cropSize; - this.cropX -= swSizeDiff; - this.cropSize = swNewSize; + const swNewX = this.cropX - swSizeDiff; + if (swNewX >= imgLeft && this.cropY + swNewSize <= imgBottom) { + this.cropX = swNewX; + this.cropSize = swNewSize; + } break; } } @@ -286,6 +317,10 @@ export class ImageCropper { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.overlayCtx.clearRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height); + // Fill background + this.ctx.fillStyle = '#000000'; + this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + // Draw image this.ctx.drawImage( this.img, @@ -295,9 +330,14 @@ export class ImageCropper { this.img.height * this.imageScale ); - // Draw overlay + // Draw overlay only over the image area this.overlayCtx.fillStyle = 'rgba(0, 0, 0, 0.5)'; - this.overlayCtx.fillRect(0, 0, this.overlayCanvas.width, this.overlayCanvas.height); + this.overlayCtx.fillRect( + this.imageOffsetX, + this.imageOffsetY, + this.img.width * this.imageScale, + this.img.height * this.imageScale + ); // Clear crop area this.overlayCtx.save(); @@ -365,13 +405,21 @@ export class ImageCropper { const cropCanvas = document.createElement('canvas'); const cropCtx = cropCanvas.getContext('2d')!; - // Set output size - const outputSize = 400; + // Calculate the actual crop size in original image pixels + const scale = 1 / this.imageScale; + const originalCropSize = this.cropSize * scale; + + // Use requested output size, but warn if upscaling + const outputSize = this.options.outputSize!; + + if (outputSize > originalCropSize) { + console.info(`Profile picture: Upscaling from ${Math.round(originalCropSize)}px to ${outputSize}px`); + } + cropCanvas.width = outputSize; cropCanvas.height = outputSize; // Calculate source coordinates - const scale = 1 / this.imageScale; const sx = (this.cropX - this.imageOffsetX) * scale; const sy = (this.cropY - this.imageOffsetY) * scale; const sSize = this.cropSize * scale; @@ -383,6 +431,10 @@ export class ImageCropper { cropCtx.clip(); } + // Enable image smoothing for quality + cropCtx.imageSmoothingEnabled = true; + cropCtx.imageSmoothingQuality = 'high'; + // Draw cropped image cropCtx.drawImage( this.img, @@ -390,7 +442,11 @@ export class ImageCropper { 0, 0, outputSize, outputSize ); - return cropCanvas.toDataURL('image/jpeg', 0.9); + // Detect format from original image + const isPng = this.options.image.includes('image/png'); + const format = isPng ? 'image/png' : 'image/jpeg'; + + return cropCanvas.toDataURL(format, this.options.outputQuality); } destroy(): void { diff --git a/ts_web/elements/profilepicture/profilepicture.modal.ts b/ts_web/elements/profilepicture/profilepicture.modal.ts index 64928fd..8b92bd1 100644 --- a/ts_web/elements/profilepicture/profilepicture.modal.ts +++ b/ts_web/elements/profilepicture/profilepicture.modal.ts @@ -8,11 +8,14 @@ import { state, type TemplateResult, } from '@design.estate/dees-element'; +import * as colors from '../00colors.js'; +import { cssGeistFontFamily } from '../00fonts.js'; +import { zIndexRegistry } from '../00zindex.js'; import '../dees-icon.js'; import '../dees-button.js'; import '../dees-windowlayer.js'; +import { DeesWindowLayer } from '../dees-windowlayer.js'; import { ImageCropper } from './profilepicture.cropper.js'; -import { zIndexRegistry } from '../00zindex.js'; import type { ProfileShape } from './dees-input-profilepicture.js'; @customElement('dees-profilepicture-modal') @@ -23,6 +26,12 @@ export class ProfilePictureModal extends DeesElement { @property({ type: String }) public shape: ProfileShape = 'round'; + @property({ type: Number }) + public outputSize: number = 800; + + @property({ type: Number }) + public outputQuality: number = 0.95; + @state() private currentStep: 'crop' | 'preview' = 'crop'; @@ -40,6 +49,8 @@ export class ProfilePictureModal extends DeesElement { cssManager.defaultStyles, css` :host { + font-family: ${cssGeistFontFamily}; + color: ${cssManager.bdTheme('#333', '#fff')}; position: fixed; top: 0; left: 0; @@ -52,44 +63,53 @@ export class ProfilePictureModal extends DeesElement { } .modal-container { - background: ${cssManager.bdTheme('hsl(0 0% 100%)', 'hsl(224 71.4% 4.1%)')}; - border-radius: 16px; - box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); - width: 90%; - max-width: 600px; - max-height: 90vh; + background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')}; + border-radius: 12px; + border: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.08)')}; + box-shadow: ${cssManager.bdTheme( + '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)', + '0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2)' + )}; + width: 480px; + max-width: calc(100vw - 32px); display: flex; flex-direction: column; overflow: hidden; - animation: modalSlideIn 0.3s ease-out; + transform: translateY(10px) scale(0.98); + opacity: 0; + animation: modalShow 0.25s cubic-bezier(0.4, 0, 0.2, 1) forwards; } - @keyframes modalSlideIn { - from { - opacity: 0; - transform: translateY(20px); - } + @keyframes modalShow { to { opacity: 1; - transform: translateY(0); + transform: translateY(0px) scale(1); } } .modal-header { - padding: 24px; - border-bottom: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; + height: 52px; + padding: 0 20px; + border-bottom: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.06)')}; display: flex; align-items: center; - justify-content: space-between; + justify-content: center; + position: relative; + flex-shrink: 0; } .modal-title { - font-size: 20px; + font-size: 15px; font-weight: 600; - color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; + color: ${cssManager.bdTheme('#09090b', '#fafafa')}; + letter-spacing: -0.01em; } .close-button { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); width: 32px; height: 32px; border: none; @@ -99,13 +119,17 @@ export class ProfilePictureModal extends DeesElement { display: flex; align-items: center; justify-content: center; - color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; - transition: all 0.2s ease; + color: ${cssManager.bdTheme('#71717a', '#71717a')}; + transition: all 0.15s ease; } .close-button:hover { - background: ${cssManager.bdTheme('hsl(210 20% 98%)', 'hsl(215 27.9% 16.9%)')}; - color: ${cssManager.bdTheme('hsl(224 71.4% 4.1%)', 'hsl(210 20% 98%)')}; + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')}; + color: ${cssManager.bdTheme('#09090b', '#fafafa')}; + } + + .close-button:active { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.08)', 'rgba(255, 255, 255, 0.08)')}; } .modal-body { @@ -115,28 +139,39 @@ export class ProfilePictureModal extends DeesElement { display: flex; flex-direction: column; align-items: center; - gap: 24px; + gap: 20px; } .cropper-container { width: 100%; - max-width: 400px; + max-width: 360px; aspect-ratio: 1; position: relative; + background: ${cssManager.bdTheme('#000000', '#000000')}; + border-radius: 12px; + overflow: hidden; + box-shadow: ${cssManager.bdTheme( + 'inset 0 2px 4px rgba(0, 0, 0, 0.06)', + 'inset 0 2px 4px rgba(0, 0, 0, 0.2)' + )}; } .preview-container { display: flex; flex-direction: column; align-items: center; - gap: 24px; + gap: 20px; } .preview-image { - width: 200px; - height: 200px; + width: 180px; + height: 180px; object-fit: cover; - border: 3px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; + border: 4px solid ${cssManager.bdTheme('#ffffff', '#18181b')}; + box-shadow: ${cssManager.bdTheme( + '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)', + '0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2)' + )}; } .preview-image.round { @@ -144,41 +179,51 @@ export class ProfilePictureModal extends DeesElement { } .preview-image.square { - border-radius: 12px; + border-radius: 16px; } .success-message { display: flex; align-items: center; - gap: 12px; - padding: 12px 24px; - background: ${cssManager.bdTheme('hsl(142 69% 45% / 0.1)', 'hsl(142 69% 55% / 0.1)')}; - color: ${cssManager.bdTheme('hsl(142 69% 45%)', 'hsl(142 69% 55%)')}; - border-radius: 8px; + gap: 10px; + padding: 10px 20px; + background: ${cssManager.bdTheme('#10b981', '#10b981')}; + color: white; + border-radius: 100px; font-weight: 500; + font-size: 14px; + animation: successPulse 0.4s ease-out; + } + + @keyframes successPulse { + 0% { transform: scale(0.9); opacity: 0; } + 50% { transform: scale(1.02); } + 100% { transform: scale(1); opacity: 1; } } .modal-footer { - padding: 24px; - border-top: 1px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; + padding: 20px 24px; + border-top: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.06)')}; display: flex; - gap: 12px; + gap: 10px; justify-content: flex-end; } .instructions { text-align: center; - color: ${cssManager.bdTheme('hsl(220 8.9% 46.1%)', 'hsl(215 20.2% 65.1%)')}; - font-size: 14px; + color: ${cssManager.bdTheme('#71717a', '#a1a1aa')}; + font-size: 13px; + line-height: 1.5; + max-width: 320px; } .loading-spinner { - width: 48px; - height: 48px; - border: 3px solid ${cssManager.bdTheme('hsl(214.3 31.8% 91.4%)', 'hsl(217.2 32.6% 17.5%)')}; - border-top-color: ${cssManager.bdTheme('hsl(222.2 47.4% 11.2%)', 'hsl(210 20% 98%)')}; + width: 40px; + height: 40px; + border: 3px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')}; + border-top-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')}; border-radius: 50%; - animation: spin 0.8s linear infinite; + animation: spin 0.6s linear infinite; } @keyframes spin { @@ -186,21 +231,33 @@ export class ProfilePictureModal extends DeesElement { transform: rotate(360deg); } } + + @media (max-width: 768px) { + .modal-container { + width: calc(100vw - 32px); + margin: 16px; + } + + .modal-body { + padding: 24px; + } + } `, ]; async connectedCallback() { super.connectedCallback(); - // Get z-index from registry + // Create window layer first (it will get its own z-index) + this.windowLayer = await DeesWindowLayer.createAndShow({ + blur: true, + }); + this.windowLayer.addEventListener('click', () => this.close()); + + // Now get z-index for modal (will be above window layer) this.zIndex = zIndexRegistry.getNextZIndex(); this.style.setProperty('--z-index', this.zIndex.toString()); - // Add window layer - this.windowLayer = document.createElement('dees-windowlayer'); - this.windowLayer.addEventListener('click', () => this.close()); - document.body.appendChild(this.windowLayer); - // Register with z-index registry zIndexRegistry.register(this, this.zIndex); } @@ -214,7 +271,7 @@ export class ProfilePictureModal extends DeesElement { } if (this.windowLayer) { - this.windowLayer.remove(); + await this.windowLayer.destroy(); } // Unregister from z-index registry @@ -226,24 +283,24 @@ export class ProfilePictureModal extends DeesElement {