395 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			395 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import {
 | |
|   DeesElement,
 | |
|   customElement,
 | |
|   html,
 | |
|   property,
 | |
|   css,
 | |
|   cssManager,
 | |
|   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 type { ProfileShape } from './dees-input-profilepicture.js';
 | |
| 
 | |
| @customElement('dees-profilepicture-modal')
 | |
| export class ProfilePictureModal extends DeesElement {
 | |
|   @property({ type: String })
 | |
|   public initialImage: string = '';
 | |
| 
 | |
|   @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';
 | |
| 
 | |
|   @state()
 | |
|   private croppedImage: string = '';
 | |
| 
 | |
|   @state()
 | |
|   private isProcessing: boolean = false;
 | |
| 
 | |
|   private cropper: ImageCropper | null = null;
 | |
|   private windowLayer: any;
 | |
|   private zIndex: number = 0;
 | |
| 
 | |
|   public static styles = [
 | |
|     cssManager.defaultStyles,
 | |
|     css`
 | |
|       :host {
 | |
|         font-family: ${cssGeistFontFamily};
 | |
|         color: ${cssManager.bdTheme('#333', '#fff')};
 | |
|         position: fixed;
 | |
|         top: 0;
 | |
|         left: 0;
 | |
|         right: 0;
 | |
|         bottom: 0;
 | |
|         display: flex;
 | |
|         align-items: center;
 | |
|         justify-content: center;
 | |
|         z-index: var(--z-index);
 | |
|       }
 | |
| 
 | |
|       .modal-container {
 | |
|         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;
 | |
|         transform: translateY(10px) scale(0.98);
 | |
|         opacity: 0;
 | |
|         animation: modalShow 0.25s cubic-bezier(0.4, 0, 0.2, 1) forwards;
 | |
|       }
 | |
| 
 | |
|       @keyframes modalShow {
 | |
|         to {
 | |
|           opacity: 1;
 | |
|           transform: translateY(0px) scale(1);
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       .modal-header {
 | |
|         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: center;
 | |
|         position: relative;
 | |
|         flex-shrink: 0;
 | |
|       }
 | |
| 
 | |
|       .modal-title {
 | |
|         font-size: 15px;
 | |
|         font-weight: 600;
 | |
|         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;
 | |
|         background: transparent;
 | |
|         cursor: pointer;
 | |
|         border-radius: 8px;
 | |
|         display: flex;
 | |
|         align-items: center;
 | |
|         justify-content: center;
 | |
|         color: ${cssManager.bdTheme('#71717a', '#71717a')};
 | |
|         transition: all 0.15s ease;
 | |
|       }
 | |
| 
 | |
|       .close-button:hover {
 | |
|         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 {
 | |
|         flex: 1;
 | |
|         padding: 24px;
 | |
|         overflow-y: auto;
 | |
|         display: flex;
 | |
|         flex-direction: column;
 | |
|         align-items: center;
 | |
|         gap: 20px;
 | |
|       }
 | |
| 
 | |
|       .cropper-container {
 | |
|         width: 100%;
 | |
|         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: 20px;
 | |
|       }
 | |
| 
 | |
|       .preview-image {
 | |
|         width: 180px;
 | |
|         height: 180px;
 | |
|         object-fit: cover;
 | |
|         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 {
 | |
|         border-radius: 50%;
 | |
|       }
 | |
| 
 | |
|       .preview-image.square {
 | |
|         border-radius: 16px;
 | |
|       }
 | |
| 
 | |
|       .success-message {
 | |
|         display: flex;
 | |
|         align-items: center;
 | |
|         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: 20px 24px;
 | |
|         border-top: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.06)')};
 | |
|         display: flex;
 | |
|         gap: 10px;
 | |
|         justify-content: flex-end;
 | |
|       }
 | |
| 
 | |
|       .instructions {
 | |
|         text-align: center;
 | |
|         color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
 | |
|         font-size: 13px;
 | |
|         line-height: 1.5;
 | |
|         max-width: 320px;
 | |
|       }
 | |
| 
 | |
|       .loading-spinner {
 | |
|         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.6s linear infinite;
 | |
|       }
 | |
| 
 | |
|       @keyframes spin {
 | |
|         to {
 | |
|           transform: rotate(360deg);
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       @media (max-width: 768px) {
 | |
|         .modal-container {
 | |
|           width: calc(100vw - 32px);
 | |
|           margin: 16px;
 | |
|         }
 | |
|         
 | |
|         .modal-body {
 | |
|           padding: 24px;
 | |
|         }
 | |
|       }
 | |
|     `,
 | |
|   ];
 | |
| 
 | |
|   async connectedCallback() {
 | |
|     super.connectedCallback();
 | |
|     
 | |
|     // 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());
 | |
|     
 | |
|     // Register with z-index registry
 | |
|     zIndexRegistry.register(this, this.zIndex);
 | |
|   }
 | |
| 
 | |
|   async disconnectedCallback() {
 | |
|     super.disconnectedCallback();
 | |
|     
 | |
|     // Cleanup
 | |
|     if (this.cropper) {
 | |
|       this.cropper.destroy();
 | |
|     }
 | |
|     
 | |
|     if (this.windowLayer) {
 | |
|       await this.windowLayer.destroy();
 | |
|     }
 | |
|     
 | |
|     // Unregister from z-index registry
 | |
|     zIndexRegistry.unregister(this);
 | |
|   }
 | |
| 
 | |
|   render(): TemplateResult {
 | |
|     return html`
 | |
|       <div class="modal-container" @click=${(e: Event) => e.stopPropagation()}>
 | |
|         <div class="modal-header">
 | |
|           <h3 class="modal-title">
 | |
|             ${this.currentStep === 'crop' ? 'Adjust Image' : 'Success'}
 | |
|           </h3>
 | |
|           <button class="close-button" @click=${this.close} title="Close">
 | |
|             <dees-icon icon="lucide:x" iconSize="16"></dees-icon>
 | |
|           </button>
 | |
|         </div>
 | |
|         
 | |
|         <div class="modal-body">
 | |
|           ${this.currentStep === 'crop' ? html`
 | |
|             <div class="instructions">
 | |
|               Position and resize the square to select your profile area
 | |
|             </div>
 | |
|             <div class="cropper-container" id="cropperContainer"></div>
 | |
|           ` : html`
 | |
|             <div class="preview-container">
 | |
|               ${this.isProcessing ? html`
 | |
|                 <div class="loading-spinner"></div>
 | |
|                 <div class="instructions">Saving...</div>
 | |
|               ` : html`
 | |
|                 <img 
 | |
|                   class="preview-image ${this.shape}" 
 | |
|                   src="${this.croppedImage}" 
 | |
|                   alt="Cropped preview"
 | |
|                 />
 | |
|                 <div class="success-message">
 | |
|                   <dees-icon icon="lucide:check" iconSize="16"></dees-icon>
 | |
|                   <span>Looking good!</span>
 | |
|                 </div>
 | |
|               `}
 | |
|             </div>
 | |
|           `}
 | |
|         </div>
 | |
|         
 | |
|         <div class="modal-footer">
 | |
|           ${this.currentStep === 'crop' ? html`
 | |
|             <dees-button type="destructive" size="sm" @click=${this.close}>
 | |
|               Cancel
 | |
|             </dees-button>
 | |
|             <dees-button type="default" size="sm" @click=${this.handleCrop}>
 | |
|               Save
 | |
|             </dees-button>
 | |
|           ` : ''}
 | |
|         </div>
 | |
|       </div>
 | |
|     `;
 | |
|   }
 | |
| 
 | |
|   async firstUpdated() {
 | |
|     if (this.currentStep === 'crop') {
 | |
|       await this.initializeCropper();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private async initializeCropper(): Promise<void> {
 | |
|     await this.updateComplete;
 | |
|     
 | |
|     const container = this.shadowRoot!.getElementById('cropperContainer');
 | |
|     if (!container) return;
 | |
|     
 | |
|     this.cropper = new ImageCropper({
 | |
|       container,
 | |
|       image: this.initialImage,
 | |
|       shape: this.shape,
 | |
|       aspectRatio: 1,
 | |
|       outputSize: this.outputSize,
 | |
|       outputQuality: this.outputQuality,
 | |
|     });
 | |
|     
 | |
|     await this.cropper.initialize();
 | |
|   }
 | |
| 
 | |
|   private async handleCrop(): Promise<void> {
 | |
|     if (!this.cropper) return;
 | |
|     
 | |
|     try {
 | |
|       this.isProcessing = true;
 | |
|       this.currentStep = 'preview';
 | |
|       await this.updateComplete;
 | |
|       
 | |
|       // Get cropped image
 | |
|       const croppedData = await this.cropper.getCroppedImage();
 | |
|       this.croppedImage = croppedData;
 | |
|       
 | |
|       // Simulate processing time for better UX
 | |
|       await new Promise(resolve => setTimeout(resolve, 800));
 | |
|       
 | |
|       this.isProcessing = false;
 | |
|       
 | |
|       // Emit save event
 | |
|       this.dispatchEvent(new CustomEvent('save', {
 | |
|         detail: { croppedImage: this.croppedImage },
 | |
|         bubbles: true,
 | |
|         composed: true
 | |
|       }));
 | |
|       
 | |
|       // Auto close after showing success
 | |
|       setTimeout(() => {
 | |
|         this.close();
 | |
|       }, 1500);
 | |
|       
 | |
|     } catch (error) {
 | |
|       console.error('Error cropping image:', error);
 | |
|       this.isProcessing = false;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   private close(): void {
 | |
|     this.remove();
 | |
|   }
 | |
| } |