diff --git a/test/test.browser.ts b/test/test.browser.ts index 2e8333d..01b27b7 100644 --- a/test/test.browser.ts +++ b/test/test.browser.ts @@ -46,4 +46,34 @@ tap.test('render fab component', async () => { document.body.removeChild(fab); }); +tap.test('render image lightbox component', async () => { + // Create and add lightbox + const lightbox = new socialioCatalog.SioImageLightbox(); + document.body.appendChild(lightbox); + + await lightbox.updateComplete; + expect(lightbox).toBeInstanceOf(socialioCatalog.SioImageLightbox); + + // Check main elements + const overlay = lightbox.shadowRoot.querySelector('.overlay'); + expect(overlay).toBeTruthy(); + + const container = lightbox.shadowRoot.querySelector('.container'); + expect(container).toBeTruthy(); + + // Test opening with an image + await lightbox.open({ + url: 'https://picsum.photos/800/600', + name: 'Test Image', + size: 123456 + }); + + await lightbox.updateComplete; + expect(lightbox.isOpen).toEqual(true); + + console.log('Image lightbox component rendered successfully'); + + document.body.removeChild(lightbox); +}); + tap.start(); \ No newline at end of file diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 60ed8ee..e953800 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -1,8 +1,3 @@ -// Design tokens -export * from './00colors.js'; -export * from './00fonts.js'; -export * from './00tokens.js'; - // Core components export * from './sio-icon.js'; export * from './sio-button.js'; @@ -15,3 +10,4 @@ export * from './sio-combox.js'; // Other components export * from './sio-fab.js'; export * from './sio-recorder.js'; +export * from './sio-image-lightbox.js'; diff --git a/ts_web/elements/sio-combox.ts b/ts_web/elements/sio-combox.ts index 0c29345..1542f80 100644 --- a/ts_web/elements/sio-combox.ts +++ b/ts_web/elements/sio-combox.ts @@ -18,11 +18,13 @@ import { fontFamilies, typography } from './00fonts.js'; // Import components import { SioConversationSelector, type IConversation } from './sio-conversation-selector.js'; -import { SioConversationView, type IMessage, type IConversationData } from './sio-conversation-view.js'; +import { SioConversationView, type IMessage, type IConversationData, type IAttachment } from './sio-conversation-view.js'; +import { SioImageLightbox, type ILightboxImage } from './sio-image-lightbox.js'; // Make sure components are loaded SioConversationSelector; SioConversationView; +SioImageLightbox; declare global { interface HTMLElementTagNameMap { @@ -76,7 +78,20 @@ export class SioCombox extends DeesElement { { id: '2', text: 'I can help you with that. Can you tell me what error you\'re seeing?', sender: 'support', time: '10:02 AM' }, { id: '3', text: 'It says "Invalid credentials" but I\'m sure my password is correct', sender: 'user', time: '10:03 AM' }, { id: '4', text: 'Let me check your account. Please try resetting your password using the forgot password link.', sender: 'support', time: '10:05 AM' }, - { id: '5', text: 'Thanks for your help with the login issue!', sender: 'user', time: '10:10 AM' }, + { + id: '5', + text: 'Here\'s a screenshot of the error', + sender: 'user', + time: '10:08 AM', + attachments: [{ + id: 'att1', + name: 'error-screenshot.png', + size: 245780, + type: 'image/png', + url: 'https://picsum.photos/400/300?random=1' + }] + }, + { id: '6', text: 'Thanks for your help with the login issue!', sender: 'user', time: '10:10 AM' }, ], '2': [ { id: '1', text: 'I need help understanding my invoice', sender: 'user', time: '9:00 AM' }, @@ -235,8 +250,11 @@ export class SioCombox extends DeesElement { .conversation=${conversationData} @back=${this.handleBack} @send-message=${this.handleSendMessage} + @open-image=${this.handleOpenImage} > + + `; } @@ -296,4 +314,18 @@ export class SioCombox extends DeesElement { }, 3000); } } + + private handleOpenImage(event: CustomEvent) { + const attachment = event.detail.attachment as IAttachment; + const lightbox = this.shadowRoot?.querySelector('sio-image-lightbox') as SioImageLightbox; + + if (lightbox && attachment) { + const lightboxImage: ILightboxImage = { + url: attachment.url, + name: attachment.name, + size: attachment.size + }; + lightbox.open(lightboxImage); + } + } } \ No newline at end of file diff --git a/ts_web/elements/sio-conversation-view.ts b/ts_web/elements/sio-conversation-view.ts index 03efb55..40712e3 100644 --- a/ts_web/elements/sio-conversation-view.ts +++ b/ts_web/elements/sio-conversation-view.ts @@ -16,12 +16,22 @@ import { spacing, radius, shadows, transitions } from './00tokens.js'; import { fontFamilies, typography } from './00fonts.js'; // Types +export interface IAttachment { + id: string; + name: string; + size: number; + type: string; + url: string; + thumbnailUrl?: string; +} + export interface IMessage { id: string; text: string; sender: 'user' | 'support'; time: string; status?: 'sending' | 'sent' | 'delivered' | 'read'; + attachments?: IAttachment[]; } export interface IConversationData { @@ -51,6 +61,15 @@ export class SioConversationView extends DeesElement { @state() private isTyping: boolean = false; + @state() + private isDragging: boolean = false; + + @state() + private uploadingFiles: Map = new Map(); + + @state() + private pendingAttachments: IAttachment[] = []; + public static styles = [ cssManager.defaultStyles, css` @@ -284,6 +303,165 @@ export class SioConversationView extends DeesElement { .messages-container::-webkit-scrollbar-thumb:hover { background: ${bdTheme('mutedForeground')}; } + + /* File drop zone */ + .drop-overlay { + position: absolute; + inset: 0; + background: ${bdTheme('background')}95; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + pointer-events: none; + opacity: 0; + transition: opacity 200ms ease; + } + + .drop-overlay.active { + opacity: 1; + pointer-events: all; + } + + .drop-zone { + padding: ${unsafeCSS(spacing["8"])}; + border: 2px dashed ${bdTheme('border')}; + border-radius: ${unsafeCSS(radius.xl)}; + background: ${bdTheme('card')}; + text-align: center; + transition: ${unsafeCSS(transitions.all)}; + } + + .drop-overlay.active .drop-zone { + border-color: ${bdTheme('primary')}; + background: ${bdTheme('accent')}; + transform: scale(1.02); + } + + .drop-icon { + font-size: 48px; + color: ${bdTheme('primary')}; + margin-bottom: ${unsafeCSS(spacing["4"])}; + } + + .drop-text { + font-size: 1.125rem; + font-weight: 500; + color: ${bdTheme('foreground')}; + margin-bottom: ${unsafeCSS(spacing["2"])}; + } + + .drop-hint { + font-size: 0.875rem; + color: ${bdTheme('mutedForeground')}; + } + + /* File attachments */ + .message-attachments { + margin-top: ${unsafeCSS(spacing["2"])}; + display: flex; + flex-wrap: wrap; + gap: ${unsafeCSS(spacing["2"])}; + } + + .attachment-image { + max-width: 200px; + max-height: 200px; + border-radius: ${unsafeCSS(radius.lg)}; + overflow: hidden; + cursor: pointer; + transition: ${unsafeCSS(transitions.all)}; + box-shadow: ${unsafeCSS(shadows.sm)}; + } + + .attachment-image:hover { + transform: scale(1.02); + box-shadow: ${unsafeCSS(shadows.md)}; + } + + .attachment-image img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .attachment-file { + display: flex; + align-items: center; + gap: ${unsafeCSS(spacing["2"])}; + padding: ${unsafeCSS(spacing["2"])} ${unsafeCSS(spacing["3"])}; + background: ${bdTheme('secondary')}; + border: 1px solid ${bdTheme('border')}; + border-radius: ${unsafeCSS(radius.md)}; + font-size: 0.875rem; + cursor: pointer; + transition: ${unsafeCSS(transitions.all)}; + } + + .attachment-file:hover { + background: ${bdTheme('accent')}; + } + + .attachment-name { + font-weight: 500; + color: ${bdTheme('foreground')}; + } + + .attachment-size { + color: ${bdTheme('mutedForeground')}; + font-size: 0.75rem; + } + + /* Pending attachments */ + .pending-attachments { + padding: ${unsafeCSS(spacing["2"])} ${unsafeCSS(spacing["3"])}; + background: ${bdTheme('secondary')}; + border-radius: ${unsafeCSS(radius.md)}; + margin-bottom: ${unsafeCSS(spacing["2"])}; + } + + .pending-attachment { + display: flex; + align-items: center; + gap: ${unsafeCSS(spacing["2"])}; + padding: ${unsafeCSS(spacing["1"])} 0; + } + + .pending-attachment-info { + flex: 1; + min-width: 0; + } + + .pending-attachment-name { + font-size: 0.875rem; + font-weight: 500; + color: ${bdTheme('foreground')}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .pending-attachment-size { + font-size: 0.75rem; + color: ${bdTheme('mutedForeground')}; + } + + .remove-attachment { + padding: ${unsafeCSS(spacing["1"])}; + cursor: pointer; + color: ${bdTheme('mutedForeground')}; + transition: ${unsafeCSS(transitions.all)}; + } + + .remove-attachment:hover { + color: ${bdTheme('destructive')}; + } + + .file-input { + display: none; + } `, ]; @@ -319,13 +497,44 @@ export class SioConversationView extends DeesElement { -
- ${this.conversation.messages.map(msg => html` -
+
+
+ +
Drop files here
+
Images and documents up to 10MB
+
+
+ +
+ ${this.conversation.messages.map((msg, index) => html` +
${msg.text}
+ ${msg.attachments && msg.attachments.length > 0 ? html` +
+ ${msg.attachments.map(attachment => + this.isImage(attachment.type) ? html` +
this.openImage(attachment)}> + ${attachment.name} +
+ ` : html` +
this.downloadFile(attachment)}> + +
+
${attachment.name}
+
${this.formatFileSize(attachment.size)}
+
+
+ ` + )} +
+ ` : ''}
${msg.time}
@@ -343,6 +552,22 @@ export class SioConversationView extends DeesElement {
+ ${this.pendingAttachments.length > 0 ? html` +
+ ${this.pendingAttachments.map(attachment => html` +
+ +
+
${attachment.name}
+
${this.formatFileSize(attachment.size)}
+
+
this.removeAttachment(attachment.id)}> + +
+
+ `)} +
+ ` : ''}
- + + @@ -394,14 +627,15 @@ export class SioConversationView extends DeesElement { } private sendMessage() { - if (!this.messageText.trim()) return; + if (!this.messageText.trim() && this.pendingAttachments.length === 0) return; const message: IMessage = { id: Date.now().toString(), text: this.messageText.trim(), sender: 'user', time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), - status: 'sending' + status: 'sending', + attachments: [...this.pendingAttachments] }; // Dispatch event for parent to handle @@ -411,8 +645,9 @@ export class SioConversationView extends DeesElement { composed: true })); - // Clear input + // Clear input and attachments this.messageText = ''; + this.pendingAttachments = []; const textarea = this.shadowRoot?.querySelector('.message-input') as HTMLTextAreaElement; if (textarea) { textarea.style.height = 'auto'; @@ -434,4 +669,125 @@ export class SioConversationView extends DeesElement { container.scrollTop = container.scrollHeight; } } + + // File handling methods + private handleDragOver(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + this.isDragging = true; + } + + private handleDragLeave(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + + // Check if we're leaving the container entirely + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); + if ( + e.clientX <= rect.left || + e.clientX >= rect.right || + e.clientY <= rect.top || + e.clientY >= rect.bottom + ) { + this.isDragging = false; + } + } + + private handleDrop(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + this.isDragging = false; + + const files = Array.from(e.dataTransfer?.files || []); + this.processFiles(files); + } + + private openFileSelector() { + const fileInput = this.shadowRoot?.querySelector('#fileInput') as HTMLInputElement; + fileInput?.click(); + } + + private handleFileSelect(e: Event) { + const input = e.target as HTMLInputElement; + const files = Array.from(input.files || []); + this.processFiles(files); + input.value = ''; // Clear input for re-selection + } + + private async processFiles(files: File[]) { + const maxSize = 10 * 1024 * 1024; // 10MB + const validFiles = files.filter(file => { + if (file.size > maxSize) { + console.warn(`File ${file.name} exceeds 10MB limit`); + return false; + } + return true; + }); + + for (const file of validFiles) { + const id = `${Date.now()}-${Math.random()}`; + const url = await this.fileToDataUrl(file); + + const attachment: IAttachment = { + id, + name: file.name, + size: file.size, + type: file.type, + url, + thumbnailUrl: this.isImage(file.type) ? url : undefined + }; + + this.pendingAttachments = [...this.pendingAttachments, attachment]; + } + } + + private fileToDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + } + + private removeAttachment(id: string) { + this.pendingAttachments = this.pendingAttachments.filter(a => a.id !== id); + } + + private isImage(type: string): boolean { + return type.startsWith('image/'); + } + + private formatFileSize(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + private getFileIcon(type: string): string { + if (this.isImage(type)) return 'image'; + if (type.includes('pdf')) return 'file-text'; + if (type.includes('doc')) return 'file-text'; + if (type.includes('sheet') || type.includes('excel')) return 'table'; + return 'file'; + } + + private openImage(attachment: IAttachment) { + this.dispatchEvent(new CustomEvent('open-image', { + detail: { attachment }, + bubbles: true, + composed: true + })); + } + + private downloadFile(attachment: IAttachment) { + const a = document.createElement('a'); + a.href = attachment.url; + a.download = attachment.name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } } \ No newline at end of file diff --git a/ts_web/elements/sio-image-lightbox.ts b/ts_web/elements/sio-image-lightbox.ts new file mode 100644 index 0000000..ab83603 --- /dev/null +++ b/ts_web/elements/sio-image-lightbox.ts @@ -0,0 +1,449 @@ +import { + DeesElement, + property, + html, + customElement, + type TemplateResult, + cssManager, + css, + unsafeCSS, + state, +} from '@design.estate/dees-element'; + +// Import design tokens +import { colors, bdTheme } from './00colors.js'; +import { spacing, radius, shadows, transitions } from './00tokens.js'; +import { fontFamilies } from './00fonts.js'; + +export interface ILightboxImage { + url: string; + name: string; + size?: number; +} + +declare global { + interface HTMLElementTagNameMap { + 'sio-image-lightbox': SioImageLightbox; + } +} + +@customElement('sio-image-lightbox') +export class SioImageLightbox extends DeesElement { + public static demo = () => html` + + `; + + @property({ type: Boolean }) + public isOpen: boolean = false; + + @property({ type: Object }) + public image: ILightboxImage | null = null; + + @state() + private imageLoaded: boolean = false; + + @state() + private scale: number = 1; + + @state() + private translateX: number = 0; + + @state() + private translateY: number = 0; + + private isDragging: boolean = false; + private startX: number = 0; + private startY: number = 0; + + public static styles = [ + cssManager.defaultStyles, + css` + :host { + position: fixed; + inset: 0; + z-index: 10000; + pointer-events: none; + font-family: ${unsafeCSS(fontFamilies.sans)}; + } + + .overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0); + backdrop-filter: blur(0px); + -webkit-backdrop-filter: blur(0px); + transition: all 300ms ease; + pointer-events: none; + opacity: 0; + } + + .overlay.open { + background: rgba(0, 0, 0, 0.9); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + pointer-events: all; + opacity: 1; + } + + .container { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: ${unsafeCSS(spacing["8"])}; + pointer-events: none; + opacity: 0; + transform: scale(0.9); + transition: all 300ms cubic-bezier(0.34, 1.56, 0.64, 1); + } + + .container.open { + opacity: 1; + transform: scale(1); + pointer-events: all; + } + + .image-wrapper { + position: relative; + max-width: 90vw; + max-height: 90vh; + cursor: grab; + user-select: none; + transition: transform 100ms ease-out; + } + + .image-wrapper.dragging { + cursor: grabbing; + transition: none; + } + + .image { + display: block; + max-width: 100%; + max-height: 90vh; + border-radius: ${unsafeCSS(radius.lg)}; + box-shadow: ${unsafeCSS(shadows["2xl"])}; + opacity: 0; + transition: opacity 300ms ease; + } + + .image.loaded { + opacity: 1; + } + + .loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + display: flex; + align-items: center; + gap: ${unsafeCSS(spacing["2"])}; + } + + .spinner { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } + + .controls { + position: absolute; + top: ${unsafeCSS(spacing["4"])}; + right: ${unsafeCSS(spacing["4"])}; + display: flex; + gap: ${unsafeCSS(spacing["2"])}; + opacity: 0; + transition: opacity 200ms ease; + } + + .container.open .controls { + opacity: 1; + } + + .control-button { + width: 40px; + height: 40px; + border-radius: ${unsafeCSS(radius.full)}; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + color: white; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: ${unsafeCSS(transitions.all)}; + } + + .control-button:hover { + background: rgba(0, 0, 0, 0.7); + transform: scale(1.1); + } + + .control-button:active { + transform: scale(0.95); + } + + .info { + position: absolute; + bottom: ${unsafeCSS(spacing["4"])}; + left: ${unsafeCSS(spacing["4"])}; + right: ${unsafeCSS(spacing["4"])}; + background: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: ${unsafeCSS(radius.lg)}; + padding: ${unsafeCSS(spacing["3"])} ${unsafeCSS(spacing["4"])}; + color: white; + display: flex; + justify-content: space-between; + align-items: center; + opacity: 0; + transform: translateY(10px); + transition: all 200ms ease; + } + + .container.open .info { + opacity: 1; + transform: translateY(0); + } + + .info-name { + font-weight: 500; + font-size: 0.9375rem; + } + + .info-actions { + display: flex; + gap: ${unsafeCSS(spacing["3"])}; + } + + .info-button { + background: none; + border: none; + color: white; + opacity: 0.8; + cursor: pointer; + display: flex; + align-items: center; + gap: ${unsafeCSS(spacing["1"])}; + font-size: 0.875rem; + padding: ${unsafeCSS(spacing["1"])} ${unsafeCSS(spacing["2"])}; + border-radius: ${unsafeCSS(radius.md)}; + transition: ${unsafeCSS(transitions.all)}; + } + + .info-button:hover { + opacity: 1; + background: rgba(255, 255, 255, 0.1); + } + + @media (max-width: 600px) { + .container { + padding: ${unsafeCSS(spacing["4"])}; + } + + .controls { + top: ${unsafeCSS(spacing["2"])}; + right: ${unsafeCSS(spacing["2"])}; + } + + .info { + bottom: ${unsafeCSS(spacing["2"])}; + left: ${unsafeCSS(spacing["2"])}; + right: ${unsafeCSS(spacing["2"])}; + padding: ${unsafeCSS(spacing["2"])} ${unsafeCSS(spacing["3"])}; + } + } + `, + ]; + + public render(): TemplateResult { + const imageStyle = this.scale !== 1 || this.translateX !== 0 || this.translateY !== 0 + ? `transform: scale(${this.scale}) translate(${this.translateX}px, ${this.translateY}px)` + : ''; + + return html` +
+
+ ${this.image ? html` +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ ${!this.imageLoaded ? html` +
+ + Loading... +
+ ` : ''} + ${this.image.name} this.imageLoaded = true} + @error=${() => this.imageLoaded = false} + @click=${(e: Event) => e.stopPropagation()} + /> +
+ +
+
${this.image.name}
+
+ + +
+
+ ` : ''} +
+ `; + } + + public async open(image: ILightboxImage) { + this.image = image; + this.imageLoaded = false; + this.resetZoom(); + this.isOpen = true; + + // Add keyboard listener + document.addEventListener('keydown', this.handleKeyDown); + } + + private close = () => { + this.isOpen = false; + document.removeEventListener('keydown', this.handleKeyDown); + + // Dispatch close event + this.dispatchEvent(new CustomEvent('close', { + bubbles: true, + composed: true + })); + + // Clean up after animation + setTimeout(() => { + this.image = null; + this.imageLoaded = false; + this.resetZoom(); + }, 300); + } + + private handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this.close(); + } else if (e.key === '+' || e.key === '=') { + this.zoomIn(); + } else if (e.key === '-') { + this.zoomOut(); + } else if (e.key === '0') { + this.resetZoom(); + } + } + + private zoomIn() { + this.scale = Math.min(this.scale * 1.2, 3); + } + + private zoomOut() { + this.scale = Math.max(this.scale / 1.2, 0.5); + } + + private resetZoom() { + this.scale = 1; + this.translateX = 0; + this.translateY = 0; + } + + private handleWheel = (e: WheelEvent) => { + e.preventDefault(); + if (e.ctrlKey || e.metaKey) { + // Zoom with ctrl/cmd + scroll + if (e.deltaY < 0) { + this.zoomIn(); + } else { + this.zoomOut(); + } + } + } + + private startDrag = (e: MouseEvent) => { + if (this.scale > 1) { + this.isDragging = true; + this.startX = e.clientX - this.translateX; + this.startY = e.clientY - this.translateY; + e.preventDefault(); + } + } + + private drag = (e: MouseEvent) => { + if (this.isDragging && this.scale > 1) { + this.translateX = e.clientX - this.startX; + this.translateY = e.clientY - this.startY; + } + } + + private endDrag = () => { + this.isDragging = false; + } + + private download() { + if (!this.image) return; + + const a = document.createElement('a'); + a.href = this.image.url; + a.download = this.image.name; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } + + private openInNewTab() { + if (!this.image) return; + window.open(this.image.url, '_blank'); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + document.removeEventListener('keydown', this.handleKeyDown); + } +} \ No newline at end of file