455 lines
12 KiB
TypeScript
455 lines
12 KiB
TypeScript
import {
|
|
customElement,
|
|
html,
|
|
property,
|
|
css,
|
|
cssManager,
|
|
state,
|
|
type TemplateResult,
|
|
} from '@design.estate/dees-element';
|
|
import { DeesInputBase } from '../dees-input-base.js';
|
|
import '../dees-icon.js';
|
|
import '../dees-label.js';
|
|
import { ProfilePictureModal } from './profilepicture.modal.js';
|
|
import { demoFunc } from './dees-input-profilepicture.demo.js';
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'dees-input-profilepicture': DeesInputProfilePicture;
|
|
}
|
|
}
|
|
|
|
export type ProfileShape = 'square' | 'round';
|
|
|
|
@customElement('dees-input-profilepicture')
|
|
export class DeesInputProfilePicture extends DeesInputBase<DeesInputProfilePicture> {
|
|
public static demo = demoFunc;
|
|
|
|
@property({ type: String })
|
|
public value: string = ''; // Base64 encoded image or URL
|
|
|
|
@property({ type: String })
|
|
public shape: ProfileShape = 'round';
|
|
|
|
@property({ type: Number })
|
|
public size: number = 120;
|
|
|
|
@property({ type: String })
|
|
public placeholder: string = '';
|
|
|
|
@property({ type: Boolean })
|
|
public allowUpload: boolean = true;
|
|
|
|
@property({ type: Boolean })
|
|
public allowDelete: boolean = true;
|
|
|
|
@property({ type: Number })
|
|
public maxFileSize: number = 5 * 1024 * 1024; // 5MB
|
|
|
|
@property({ type: Array })
|
|
public acceptedFormats: string[] = ['image/jpeg', 'image/png', 'image/webp'];
|
|
|
|
@property({ type: Number })
|
|
public outputSize: number = 800; // Output resolution in pixels
|
|
|
|
@property({ type: Number })
|
|
public outputQuality: number = 0.95; // 0-1 quality for JPEG
|
|
|
|
@state()
|
|
private isHovered: boolean = false;
|
|
|
|
@state()
|
|
private isDragging: boolean = false;
|
|
|
|
@state()
|
|
private isLoading: boolean = false;
|
|
|
|
private modalInstance: ProfilePictureModal | null = null;
|
|
|
|
public static styles = [
|
|
...DeesInputBase.baseStyles,
|
|
cssManager.defaultStyles,
|
|
css`
|
|
:host {
|
|
display: block;
|
|
position: relative;
|
|
}
|
|
|
|
.input-wrapper {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.profile-container {
|
|
position: relative;
|
|
display: inline-block;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.profile-container:hover {
|
|
transform: scale(1.02);
|
|
}
|
|
|
|
.profile-picture {
|
|
width: var(--size, 120px);
|
|
height: var(--size, 120px);
|
|
background: ${cssManager.bdTheme('#f5f5f5', '#18181b')};
|
|
border: 3px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
position: relative;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.profile-picture.round {
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.profile-picture.square {
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.profile-picture.dragging {
|
|
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
|
box-shadow: 0 0 0 4px ${cssManager.bdTheme('rgba(59, 130, 246, 0.15)', 'rgba(96, 165, 250, 0.15)')};
|
|
}
|
|
|
|
.profile-picture:hover {
|
|
border-color: ${cssManager.bdTheme('#d4d4d8', '#3f3f46')};
|
|
}
|
|
|
|
.profile-picture:disabled {
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.profile-image {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.placeholder-icon {
|
|
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
|
}
|
|
|
|
.overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
opacity: 0;
|
|
transition: opacity 0.3s ease;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.profile-container:hover .overlay {
|
|
opacity: 1;
|
|
}
|
|
|
|
.overlay-content {
|
|
display: flex;
|
|
gap: 12px;
|
|
}
|
|
|
|
.overlay-button {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 50%;
|
|
background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.95)', 'rgba(39, 39, 42, 0.95)')};
|
|
border: 1px solid ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
|
|
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
pointer-events: auto;
|
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
}
|
|
|
|
.overlay-button:hover {
|
|
background: ${cssManager.bdTheme('#ffffff', '#3f3f46')};
|
|
transform: scale(1.1);
|
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
|
}
|
|
|
|
.overlay-button.delete {
|
|
background: ${cssManager.bdTheme('rgba(239, 68, 68, 0.9)', 'rgba(220, 38, 38, 0.9)')};
|
|
color: white;
|
|
border-color: transparent;
|
|
}
|
|
|
|
.overlay-button.delete:hover {
|
|
background: ${cssManager.bdTheme('#ef4444', '#dc2626')};
|
|
}
|
|
|
|
.drop-zone-text {
|
|
position: absolute;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
text-align: center;
|
|
color: white;
|
|
font-weight: 500;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.hidden-input {
|
|
display: none;
|
|
}
|
|
|
|
/* Loading animation */
|
|
.loading-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: ${cssManager.bdTheme('rgba(255, 255, 255, 0.8)', 'rgba(0, 0, 0, 0.8)')};
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: inherit;
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transition: opacity 0.2s ease;
|
|
}
|
|
|
|
.loading-overlay.show {
|
|
opacity: 1;
|
|
pointer-events: auto;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
}
|
|
|
|
@keyframes pulse {
|
|
0% {
|
|
transform: scale(1);
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
transform: scale(1.05);
|
|
opacity: 0.8;
|
|
}
|
|
100% {
|
|
transform: scale(1);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
|
|
.profile-picture.clicking {
|
|
animation: pulse 0.3s ease-out;
|
|
}
|
|
`,
|
|
];
|
|
|
|
render(): TemplateResult {
|
|
return html`
|
|
<div class="input-wrapper">
|
|
<dees-label .label=${this.label} .description=${this.description} .required=${this.required}></dees-label>
|
|
|
|
<div
|
|
class="profile-container"
|
|
@click=${this.handleClick}
|
|
@dragover=${this.handleDragOver}
|
|
@dragleave=${this.handleDragLeave}
|
|
@drop=${this.handleDrop}
|
|
style="--size: ${this.size}px"
|
|
>
|
|
<div class="profile-picture ${this.shape} ${this.isDragging ? 'dragging' : ''} ${this.isLoading && !this.value ? 'clicking' : ''}">
|
|
${this.value ? html`
|
|
<img class="profile-image" src="${this.value}" alt="Profile picture" />
|
|
` : html`
|
|
<dees-icon class="placeholder-icon" icon="lucide:user" iconSize="${this.size * 0.5}"></dees-icon>
|
|
`}
|
|
|
|
${this.isDragging ? html`
|
|
<div class="overlay" style="opacity: 1">
|
|
<div class="drop-zone-text">
|
|
Drop image here
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
${this.value && !this.disabled ? html`
|
|
<div class="overlay">
|
|
<div class="overlay-content">
|
|
${this.allowUpload ? html`
|
|
<button class="overlay-button" @click=${(e: Event) => { e.stopPropagation(); this.openModal(); }} title="Change picture">
|
|
<dees-icon icon="lucide:pencil" iconSize="20"></dees-icon>
|
|
</button>
|
|
` : ''}
|
|
${this.allowDelete ? html`
|
|
<button class="overlay-button delete" @click=${(e: Event) => { e.stopPropagation(); this.deletePicture(); }} title="Delete picture">
|
|
<dees-icon icon="lucide:trash2" iconSize="20"></dees-icon>
|
|
</button>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
${this.isLoading && !this.value ? html`
|
|
<div class="loading-overlay show">
|
|
<div class="loading-spinner"></div>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
</div>
|
|
|
|
<input
|
|
type="file"
|
|
class="hidden-input"
|
|
accept="${this.acceptedFormats.join(',')}"
|
|
@change=${this.handleFileSelect}
|
|
/>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
private handleClick(): void {
|
|
if (this.disabled || !this.allowUpload) return;
|
|
|
|
if (!this.value) {
|
|
// If no image, open file picker
|
|
this.isLoading = true;
|
|
const input = this.shadowRoot!.querySelector('.hidden-input') as HTMLInputElement;
|
|
|
|
// Set up a focus handler to detect when the dialog is closed without selection
|
|
const handleFocus = () => {
|
|
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();
|
|
}
|
|
}
|
|
|
|
private handleFileSelect(event: Event): void {
|
|
const input = event.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
|
|
// Always reset loading state when file dialog interaction completes
|
|
this.isLoading = false;
|
|
|
|
if (file) {
|
|
this.processFile(file);
|
|
}
|
|
|
|
// Reset input to allow selecting the same file again
|
|
input.value = '';
|
|
}
|
|
|
|
private handleDragOver(event: DragEvent): void {
|
|
event.preventDefault();
|
|
if (!this.disabled && this.allowUpload) {
|
|
this.isDragging = true;
|
|
}
|
|
}
|
|
|
|
private handleDragLeave(): void {
|
|
this.isDragging = false;
|
|
}
|
|
|
|
private handleDrop(event: DragEvent): void {
|
|
event.preventDefault();
|
|
this.isDragging = false;
|
|
|
|
if (this.disabled || !this.allowUpload) return;
|
|
|
|
const file = event.dataTransfer?.files[0];
|
|
if (file) {
|
|
this.processFile(file);
|
|
}
|
|
}
|
|
|
|
private async processFile(file: File): Promise<void> {
|
|
// Validate file type
|
|
if (!this.acceptedFormats.includes(file.type)) {
|
|
console.error('Invalid file type:', file.type);
|
|
return;
|
|
}
|
|
|
|
// Validate file size
|
|
if (file.size > this.maxFileSize) {
|
|
console.error('File too large:', file.size);
|
|
return;
|
|
}
|
|
|
|
// Read file as base64
|
|
const reader = new FileReader();
|
|
reader.onload = async (e) => {
|
|
const base64 = e.target?.result as string;
|
|
|
|
// Open modal for cropping
|
|
await this.openModal(base64);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
private async openModal(initialImage?: string): Promise<void> {
|
|
const imageToEdit = initialImage || this.value;
|
|
|
|
if (!imageToEdit) {
|
|
// If no image provided, open file picker
|
|
const input = this.shadowRoot!.querySelector('.hidden-input') as HTMLInputElement;
|
|
input.click();
|
|
return;
|
|
}
|
|
|
|
// Create and show modal
|
|
this.modalInstance = new ProfilePictureModal();
|
|
this.modalInstance.shape = this.shape;
|
|
this.modalInstance.initialImage = imageToEdit;
|
|
this.modalInstance.outputSize = this.outputSize;
|
|
this.modalInstance.outputQuality = this.outputQuality;
|
|
|
|
this.modalInstance.addEventListener('save', (event: CustomEvent) => {
|
|
this.value = event.detail.croppedImage;
|
|
this.changeSubject.next(this);
|
|
});
|
|
|
|
document.body.appendChild(this.modalInstance);
|
|
}
|
|
|
|
private deletePicture(): void {
|
|
this.value = '';
|
|
this.changeSubject.next(this);
|
|
}
|
|
|
|
public getValue(): string {
|
|
return this.value;
|
|
}
|
|
|
|
public setValue(value: string): void {
|
|
this.value = value;
|
|
}
|
|
} |