import { DeesElement, css, cssManager, customElement, html, property, state, type TemplateResult, } from '@design.estate/dees-element'; import { mobileComponentStyles } from '../../00componentstyles.js'; import '../dees-mobile-icon/dees-mobile-icon.js'; import { demoFunc } from './dees-mobile-gallery.demo.js'; export interface IGalleryItem { id: string; url: string; thumbnailUrl?: string; filename?: string; mimeType?: string; metadata?: Record; } export interface IGalleryConfig { showFilename?: boolean; showActions?: boolean; allowDelete?: boolean; allowDownload?: boolean; allowShare?: boolean; startIndex?: number; } declare global { interface HTMLElementTagNameMap { 'dees-mobile-gallery': DeesMobileGallery; } } @customElement('dees-mobile-gallery') export class DeesMobileGallery extends DeesElement { public static demo = demoFunc; @property({ type: Array }) accessor items: IGalleryItem[] = []; @property({ type: Object }) accessor config: IGalleryConfig = {}; @state() accessor currentIndex: number = 0; @state() accessor isLoading: boolean = true; @state() accessor showThumbnails: boolean = false; // Touch/swipe state private touchStartX: number = 0; private touchStartY: number = 0; private touchDeltaX: number = 0; private isSwiping: boolean = false; private swipeThreshold: number = 50; public static styles = [ cssManager.defaultStyles, mobileComponentStyles, css` :host { position: fixed; inset: 0; z-index: var(--dees-z-modal, 500); display: flex; flex-direction: column; background: rgba(0, 0, 0, 0); animation: fadeInGallery 0.2s ease-out forwards; } @keyframes fadeInGallery { to { background: rgba(0, 0, 0, 0.95); } } .gallery-header { display: flex; align-items: center; justify-content: space-between; padding: var(--dees-space-md); padding-top: calc(var(--dees-space-md) + env(safe-area-inset-top, 0px)); background: linear-gradient(to bottom, rgba(0, 0, 0, 0.5), transparent); position: absolute; top: 0; left: 0; right: 0; z-index: 10; } .header-left { display: flex; align-items: center; gap: var(--dees-space-sm); flex: 1; min-width: 0; } .close-button { width: 40px; height: 40px; border: none; background: rgba(255, 255, 255, 0.1); border-radius: var(--dees-radius-full); color: white; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background var(--dees-transition-fast); flex-shrink: 0; } .close-button:hover { background: rgba(255, 255, 255, 0.2); } .filename { color: white; font-size: 0.875rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .counter { color: rgba(255, 255, 255, 0.7); font-size: 0.875rem; flex-shrink: 0; } .gallery-content { flex: 1; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden; touch-action: pan-y pinch-zoom; } .image-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; transition: transform 0.3s ease-out; } .image-container.swiping { transition: none; } .gallery-image { max-width: 100%; max-height: 100%; object-fit: contain; opacity: 0; transition: opacity 0.2s ease-out; } .gallery-image.loaded { opacity: 1; } .loading-spinner { position: absolute; width: 40px; height: 40px; border: 3px solid rgba(255, 255, 255, 0.2); border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .nav-button { position: absolute; top: 50%; transform: translateY(-50%); width: 48px; height: 48px; border: none; background: rgba(255, 255, 255, 0.1); border-radius: var(--dees-radius-full); color: white; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: background var(--dees-transition-fast), opacity var(--dees-transition-fast); z-index: 5; opacity: 0.7; } .nav-button:hover { background: rgba(255, 255, 255, 0.2); opacity: 1; } .nav-button.prev { left: var(--dees-space-md); } .nav-button.next { right: var(--dees-space-md); } .nav-button:disabled { opacity: 0.3; cursor: not-allowed; } /* Hide nav buttons on mobile - use swipe instead */ @media (max-width: 640px) { .nav-button { display: none; } } .gallery-footer { padding: var(--dees-space-md); padding-bottom: calc(var(--dees-space-md) + env(safe-area-inset-bottom, 0px)); background: linear-gradient(to top, rgba(0, 0, 0, 0.5), transparent); position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; } .actions { display: flex; justify-content: center; gap: var(--dees-space-lg); } .action-button { display: flex; flex-direction: column; align-items: center; gap: var(--dees-space-xs); border: none; background: none; color: white; cursor: pointer; padding: var(--dees-space-sm); border-radius: var(--dees-radius-md); transition: background var(--dees-transition-fast); min-width: 64px; } .action-button:hover { background: rgba(255, 255, 255, 0.1); } .action-button .icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: rgba(255, 255, 255, 0.15); border-radius: var(--dees-radius-full); } .action-button .label { font-size: 0.75rem; opacity: 0.9; } .action-button.danger .icon { background: rgba(220, 38, 38, 0.3); } .action-button.danger { color: #fca5a5; } .thumbnails { display: flex; gap: var(--dees-space-xs); justify-content: center; margin-bottom: var(--dees-space-md); overflow-x: auto; padding: 0 var(--dees-space-md); -webkit-overflow-scrolling: touch; } .thumbnail { width: 48px; height: 48px; border-radius: var(--dees-radius-sm); object-fit: cover; cursor: pointer; opacity: 0.5; transition: opacity var(--dees-transition-fast), transform var(--dees-transition-fast); flex-shrink: 0; border: 2px solid transparent; } .thumbnail:hover { opacity: 0.8; } .thumbnail.active { opacity: 1; border-color: white; } .pdf-preview { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--dees-space-md); color: white; } .pdf-icon { width: 80px; height: 80px; background: rgba(255, 255, 255, 0.1); border-radius: var(--dees-radius-lg); display: flex; align-items: center; justify-content: center; } .pdf-text { font-size: 0.875rem; opacity: 0.7; } `, ]; async connectedCallback() { await super.connectedCallback(); this.currentIndex = this.config.startIndex ?? 0; document.addEventListener('keydown', this.handleKeydown); } async disconnectedCallback() { await super.disconnectedCallback(); document.removeEventListener('keydown', this.handleKeydown); } private handleKeydown = (e: KeyboardEvent) => { switch (e.key) { case 'Escape': this.handleClose(); break; case 'ArrowLeft': this.goToPrevious(); break; case 'ArrowRight': this.goToNext(); break; } }; private handleClose() { this.dispatchEvent(new CustomEvent('close', { bubbles: true, composed: true, })); } private handleTouchStart = (e: TouchEvent) => { this.touchStartX = e.touches[0].clientX; this.touchStartY = e.touches[0].clientY; this.touchDeltaX = 0; this.isSwiping = false; }; private handleTouchMove = (e: TouchEvent) => { const deltaX = e.touches[0].clientX - this.touchStartX; const deltaY = e.touches[0].clientY - this.touchStartY; // Only swipe horizontally if horizontal movement is greater than vertical if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > 10) { this.isSwiping = true; this.touchDeltaX = deltaX; this.requestUpdate(); } }; private handleTouchEnd = () => { if (this.isSwiping) { if (this.touchDeltaX > this.swipeThreshold) { this.goToPrevious(); } else if (this.touchDeltaX < -this.swipeThreshold) { this.goToNext(); } } this.isSwiping = false; this.touchDeltaX = 0; this.requestUpdate(); }; private goToPrevious() { if (this.currentIndex > 0) { this.currentIndex--; this.isLoading = true; this.dispatchChangeEvent(); } } private goToNext() { if (this.currentIndex < this.items.length - 1) { this.currentIndex++; this.isLoading = true; this.dispatchChangeEvent(); } } private goToIndex(index: number) { if (index >= 0 && index < this.items.length && index !== this.currentIndex) { this.currentIndex = index; this.isLoading = true; this.dispatchChangeEvent(); } } private dispatchChangeEvent() { this.dispatchEvent(new CustomEvent('change', { detail: { index: this.currentIndex, item: this.items[this.currentIndex], }, bubbles: true, composed: true, })); } private handleImageLoad() { this.isLoading = false; } private handleDownload() { const item = this.items[this.currentIndex]; this.dispatchEvent(new CustomEvent('download', { detail: item, bubbles: true, composed: true, })); } private handleDelete() { const item = this.items[this.currentIndex]; this.dispatchEvent(new CustomEvent('delete', { detail: item, bubbles: true, composed: true, })); } private handleShare() { const item = this.items[this.currentIndex]; this.dispatchEvent(new CustomEvent('share', { detail: item, bubbles: true, composed: true, })); } private isPdf(item: IGalleryItem): boolean { return item.mimeType?.toLowerCase() === 'application/pdf' || item.filename?.toLowerCase().endsWith('.pdf') || item.url.toLowerCase().endsWith('.pdf'); } public render(): TemplateResult { const currentItem = this.items[this.currentIndex]; const showFilename = this.config.showFilename ?? true; const showActions = this.config.showActions ?? true; const allowDelete = this.config.allowDelete ?? false; const allowDownload = this.config.allowDownload ?? true; const allowShare = this.config.allowShare ?? false; const showThumbnails = this.items.length > 1; const swipeTransform = this.isSwiping ? `translateX(${this.touchDeltaX}px)` : ''; return html` `; } /** * Static factory method to show the gallery */ public static async show( items: IGalleryItem[], config: IGalleryConfig = {} ): Promise<{ action: 'close' | 'delete' | 'download' | 'share'; item?: IGalleryItem }> { return new Promise((resolve) => { const gallery = document.createElement('dees-mobile-gallery') as DeesMobileGallery; gallery.items = items; gallery.config = config; const cleanup = () => { gallery.remove(); }; gallery.addEventListener('close', () => { cleanup(); resolve({ action: 'close' }); }); gallery.addEventListener('delete', (e: CustomEvent) => { cleanup(); resolve({ action: 'delete', item: e.detail }); }); gallery.addEventListener('download', (e: CustomEvent) => { // Don't close on download - user might want to download multiple resolve({ action: 'download', item: e.detail }); }); gallery.addEventListener('share', (e: CustomEvent) => { resolve({ action: 'share', item: e.detail }); }); document.body.appendChild(gallery); }); } }