From ab9b545c9abc3cff418bc7e79304a6c7ef20964d Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 26 Jun 2025 15:32:29 +0000 Subject: [PATCH] update file upload --- ts_web/elements/dees-input-fileupload.demo.ts | 140 +++-- ts_web/elements/dees-input-fileupload.ts | 551 +++++++++++++++--- 2 files changed, 575 insertions(+), 116 deletions(-) diff --git a/ts_web/elements/dees-input-fileupload.demo.ts b/ts_web/elements/dees-input-fileupload.demo.ts index 8132205..3fb2e0e 100644 --- a/ts_web/elements/dees-input-fileupload.demo.ts +++ b/ts_web/elements/dees-input-fileupload.demo.ts @@ -51,85 +51,151 @@ export const demoFunc = () => html`
- + - +
-

Profile Picture

+

Images Only

-

Cover Image

+

Documents Only

- + + + - + + + + + + + - - +

Job Application Form

+ + + + + + + + + + +
-

Features:

-
    -
  • Click to select files or drag & drop
  • -
  • Multiple file selection support
  • -
  • Visual feedback for drag operations
  • -
  • Right-click files to remove them
  • -
  • Integrates seamlessly with forms
  • +

    Enhanced Features:

    +
      +
    • Drag & drop with visual feedback
    • +
    • File type restrictions via accept attribute
    • +
    • File size validation with custom limits
    • +
    • Maximum file count restrictions
    • +
    • Image preview thumbnails
    • +
    • File type-specific icons
    • +
    • Clear all button for multiple files
    • +
    • Proper validation states and messages
    • +
    • Keyboard accessible
    • +
    • Single or multiple file modes
diff --git a/ts_web/elements/dees-input-fileupload.ts b/ts_web/elements/dees-input-fileupload.ts index 1a1433a..b860305 100644 --- a/ts_web/elements/dees-input-fileupload.ts +++ b/ts_web/elements/dees-input-fileupload.ts @@ -42,6 +42,21 @@ export class DeesInputFileupload extends DeesInputBase { }) public buttonText: string = 'Upload File...'; + @property({ type: String }) + public accept: string = ''; + + @property({ type: Boolean }) + public multiple: boolean = true; + + @property({ type: Number }) + public maxSize: number = 0; // 0 means no limit + + @property({ type: Number }) + public maxFiles: number = 0; // 0 means no limit + + @property({ type: String, reflect: true }) + public validationState: 'valid' | 'invalid' | 'warn' | 'pending' = null; + constructor() { super(); } @@ -52,7 +67,7 @@ export class DeesInputFileupload extends DeesInputBase { css` :host { position: relative; - display: grid; + display: block; color: ${cssManager.bdTheme('#333', '#ccc')}; } @@ -60,13 +75,42 @@ export class DeesInputFileupload extends DeesInputBase { display: none; } + .input-wrapper { + display: flex; + flex-direction: column; + gap: 8px; + } + .maincontainer { position: relative; - border-radius: 3px; - padding: 8px; - background: ${cssManager.bdTheme('#fafafa', '#222222')}; + border-radius: 8px; + padding: 16px; + background: ${cssManager.bdTheme('#f8f9fa', '#1a1a1a')}; color: ${cssManager.bdTheme('#333', '#ccc')}; - border-top: 1px solid #ffffff10; + border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')}; + transition: all 0.2s ease; + } + + .maincontainer:hover { + border-color: ${cssManager.bdTheme('#ccc', '#444')}; + } + + :host([disabled]) .maincontainer { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } + + :host([validationState="invalid"]) .maincontainer { + border-color: #e74c3c; + } + + :host([validationState="valid"]) .maincontainer { + border-color: #27ae60; + } + + :host([validationState="warn"]) .maincontainer { + border-color: #f39c12; } .maincontainer::after { @@ -78,115 +122,385 @@ export class DeesInputFileupload extends DeesInputBase { position: absolute; content: ''; display: block; - border: 2px dashed rgba(255, 255, 255, 0); - transition: all 0.2s; + border: 2px dashed transparent; + border-radius: 6px; + transition: all 0.3s ease; pointer-events: none; - background: #00000000; + background: transparent; } + + .maincontainer.dragOver { + border-color: ${cssManager.bdTheme('#0084ff', '#0084ff')}; + background: ${cssManager.bdTheme('#f0f8ff', '#001933')}; + } + .maincontainer.dragOver::after { transform: scale3d(1, 1, 1); - border: 2px dashed rgba(255, 255, 255, 0.3); - background: #00000080; + border: 2px dashed ${cssManager.bdTheme('#0084ff', '#0084ff')}; } .uploadButton { position: relative; - padding: 8px; - max-width: 600px; - background: ${cssManager.bdTheme('#fafafa', '#333333')}; - border-radius: 3px; + padding: 12px 24px; + background: ${cssManager.bdTheme('#0084ff', '#0084ff')}; + color: white; + border-radius: 6px; text-align: center; font-size: 14px; - cursor: default; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: none; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; } .uploadButton:hover { - color: #fff; - background: ${unsafeCSS(colors.dark.blue)}; + background: ${cssManager.bdTheme('#0073e6', '#0073e6')}; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 132, 255, 0.3); + } + + .uploadButton:active { + transform: translateY(0); + } + + .uploadButton dees-icon { + font-size: 16px; + } + + .files-container { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 12px; } .uploadCandidate { display: grid; - grid-template-columns: 48px auto; - background: #333; - padding: 8px 8px 8px 0px; - margin-bottom: 8px; + grid-template-columns: 40px 1fr auto; + background: ${cssManager.bdTheme('#ffffff', '#2a2a2a')}; + padding: 12px; text-align: left; - border-radius: 3px; - color: ${cssManager.bdTheme('#666', '#ccc')}; - font-family: 'Geist Sans', sans-serif; + border-radius: 6px; + color: ${cssManager.bdTheme('#333', '#ccc')}; cursor: default; transition: all 0.2s; - border-top: 1px solid #ffffff10; + border: 1px solid ${cssManager.bdTheme('#e0e0e0', '#333')}; + position: relative; + overflow: hidden; } - .uploadCandidate:last-child { - margin-bottom: 8px; + .uploadCandidate:hover { + background: ${cssManager.bdTheme('#f5f5f5', '#333')}; + border-color: ${cssManager.bdTheme('#ccc', '#444')}; } .uploadCandidate .icon { display: flex; align-items: center; justify-content: center; - font-size: 16px; + font-size: 20px; + color: ${cssManager.bdTheme('#666', '#999')}; } - .uploadCandidate:hover { - background: #393939; + .uploadCandidate.image-file .icon { + color: #4CAF50; + } + + .uploadCandidate.pdf-file .icon { + color: #f44336; + } + + .uploadCandidate.doc-file .icon { + color: #2196F3; } - .uploadCandidate .description { + .uploadCandidate .info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + .uploadCandidate .filename { + font-weight: 500; font-size: 14px; - border-left: 1px solid #ffffff10; - padding-left: 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .uploadCandidate .filesize { + font-size: 12px; + color: ${cssManager.bdTheme('#666', '#999')}; + } + + .uploadCandidate .actions { + display: flex; + align-items: center; + gap: 8px; + } + + .remove-button { + width: 32px; + height: 32px; + border-radius: 4px; + background: transparent; + border: none; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + color: ${cssManager.bdTheme('#666', '#999')}; + } + + .remove-button:hover { + background: ${cssManager.bdTheme('#fee', '#4a1c1c')}; + color: ${cssManager.bdTheme('#e74c3c', '#ff6b6b')}; + } + + .clear-all-button { + margin-bottom: 8px; + text-align: right; + } + + .clear-all-button button { + background: none; + border: none; + color: ${cssManager.bdTheme('#666', '#999')}; + cursor: pointer; + font-size: 12px; + padding: 4px 8px; + border-radius: 4px; + transition: all 0.2s; + } + + .clear-all-button button:hover { + background: ${cssManager.bdTheme('#fee', '#4a1c1c')}; + color: ${cssManager.bdTheme('#e74c3c', '#ff6b6b')}; + } + + .validation-message { + font-size: 12px; + margin-top: 4px; + color: #e74c3c; + } + + .drop-hint { + text-align: center; + padding: 40px 20px; + color: ${cssManager.bdTheme('#999', '#666')}; + font-size: 14px; + } + + .drop-hint dees-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.3; + } + + .image-preview { + width: 40px; + height: 40px; + object-fit: cover; + border-radius: 4px; + } + + .description-text { + font-size: 12px; + color: ${cssManager.bdTheme('#666', '#999')}; + margin-top: 4px; + line-height: 1.4; } `, ]; public render(): TemplateResult { + const hasFiles = this.value.length > 0; + const showClearAll = hasFiles && this.value.length > 1; + return html`
- + ${this.label ? html` + + ` : ''}
- ${this.value.map( - (fileArg) => html` -
{ - DeesContextmenu.openContextMenuWithOptions(eventArg, [{ - iconName: 'trash', - name: 'Remove', - action: async () => { - this.value.splice(this.value.indexOf(fileArg), 1); - this.requestUpdate(); - } - }]); - }}> -
- + ${hasFiles ? html` + ${showClearAll ? html` +
+ +
+ ` : ''} +
+ ${this.value.map((fileArg) => { + const fileType = this.getFileType(fileArg); + const isImage = fileType === 'image'; + return html` +
+
+ ${isImage && this.canShowPreview(fileArg) ? html` + ${fileArg.name} + ` : html` + + `} +
+
+
${fileArg.name}
+
${this.formatFileSize(fileArg.size)}
+
+
+ +
+
+ `; + })}
-
- ${fileArg.name}
- ${fileArg.size} + ` : html` +
+ +
Drag files here or click to browse
-
` - )} -
- ${this.buttonText} + `} +
+ + ${this.buttonText} +
-
+ ${this.description ? html` +
${this.description}
+ ` : ''} + ${this.validationState === 'invalid' && this.validationMessage ? html` +
${this.validationMessage}
+ ` : ''}
`; } + private validationMessage: string = ''; + + // Utility methods + private formatFileSize(bytes: number): string { + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + if (bytes === 0) return '0 Bytes'; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]; + } + + private getFileType(file: File): string { + const type = file.type.toLowerCase(); + if (type.startsWith('image/')) return 'image'; + if (type === 'application/pdf') return 'pdf'; + if (type.includes('word') || type.includes('document')) return 'doc'; + if (type.includes('sheet') || type.includes('excel')) return 'spreadsheet'; + if (type.includes('presentation') || type.includes('powerpoint')) return 'presentation'; + if (type.startsWith('video/')) return 'video'; + if (type.startsWith('audio/')) return 'audio'; + if (type.includes('zip') || type.includes('compressed')) return 'archive'; + return 'file'; + } + + private getFileIcon(file: File): string { + const type = this.getFileType(file); + const iconMap = { + 'image': 'lucide:image', + 'pdf': 'lucide:file-text', + 'doc': 'lucide:file-text', + 'spreadsheet': 'lucide:table', + 'presentation': 'lucide:presentation', + 'video': 'lucide:video', + 'audio': 'lucide:music', + 'archive': 'lucide:archive', + 'file': 'lucide:file' + }; + return iconMap[type] || 'lucide:file'; + } + + private canShowPreview(file: File): boolean { + return file.type.startsWith('image/') && file.size < 5 * 1024 * 1024; // 5MB limit for previews + } + + private validateFile(file: File): boolean { + // Check file size + if (this.maxSize > 0 && file.size > this.maxSize) { + this.validationMessage = `File "${file.name}" exceeds maximum size of ${this.formatFileSize(this.maxSize)}`; + this.validationState = 'invalid'; + return false; + } + + // Check file type + if (this.accept) { + const acceptedTypes = this.accept.split(',').map(s => s.trim()); + let isAccepted = false; + + for (const acceptType of acceptedTypes) { + if (acceptType.startsWith('.')) { + // Extension check + if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) { + isAccepted = true; + break; + } + } else if (acceptType.endsWith('/*')) { + // MIME type wildcard check + const mimePrefix = acceptType.slice(0, -2); + if (file.type.startsWith(mimePrefix)) { + isAccepted = true; + break; + } + } else if (file.type === acceptType) { + // Exact MIME type check + isAccepted = true; + break; + } + } + + if (!isAccepted) { + this.validationMessage = `File type not accepted. Please upload: ${this.accept}`; + this.validationState = 'invalid'; + return false; + } + } + + return true; + } + public async openFileSelector() { + if (this.disabled) return; const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]'); inputFile.click(); - this.state = 'idle'; - this.buttonText = 'Upload more files...'; + } + + private removeFile(file: File) { + const index = this.value.indexOf(file); + if (index > -1) { + this.value.splice(index, 1); + this.requestUpdate(); + this.validate(); + this.changeSubject.next(this); + } + } + + private clearAll() { + this.value = []; + this.requestUpdate(); + this.validate(); + this.changeSubject.next(this); } public async updateValue(eventArg: Event) { @@ -198,52 +512,131 @@ export class DeesInputFileupload extends DeesInputBase { public firstUpdated(_changedProperties: Map) { super.firstUpdated(_changedProperties); const inputFile: HTMLInputElement = this.shadowRoot.querySelector('input[type="file"]'); - inputFile.addEventListener('change', (event: Event) => { + inputFile.addEventListener('change', async (event: Event) => { const target = event.target as HTMLInputElement; - for (const file of Array.from(target.files)) { - this.value.push(file); - } - this.requestUpdate(); - console.log(`Got ${this.value.length} files!`); + const newFiles = Array.from(target.files); + await this.addFiles(newFiles); // Reset the input value to allow selecting the same file again if needed target.value = ''; }); - // lets handle drag and drop + // Handle drag and drop const dropArea = this.shadowRoot.querySelector('.maincontainer'); - const handlerFunction = (eventArg: DragEvent) => { + const handlerFunction = async (eventArg: DragEvent) => { eventArg.preventDefault(); + eventArg.stopPropagation(); + switch (eventArg.type) { + case 'dragenter': case 'dragover': this.state = 'dragOver'; - this.buttonText = 'release to upload file...'; break; case 'dragleave': - this.state = 'idle'; - this.buttonText = 'Upload File...'; + // Check if we're actually leaving the drop area + const rect = dropArea.getBoundingClientRect(); + const x = eventArg.clientX; + const y = eventArg.clientY; + if (x <= rect.left || x >= rect.right || y <= rect.top || y >= rect.bottom) { + this.state = 'idle'; + } break; case 'drop': this.state = 'idle'; - this.buttonText = 'Upload more files...'; + const files = Array.from(eventArg.dataTransfer.files); + await this.addFiles(files); + break; } - console.log(eventArg); - for (const file of Array.from(eventArg.dataTransfer.files)) { - this.value.push(file); - this.requestUpdate(); - } - console.log(`Got ${this.value.length} files!`); }; + dropArea.addEventListener('dragenter', handlerFunction, false); dropArea.addEventListener('dragleave', handlerFunction, false); dropArea.addEventListener('dragover', handlerFunction, false); dropArea.addEventListener('drop', handlerFunction, false); } + private async addFiles(files: File[]) { + const filesToAdd: File[] = []; + + for (const file of files) { + if (this.validateFile(file)) { + filesToAdd.push(file); + } + } + + if (filesToAdd.length === 0) return; + + // Check max files limit + if (this.maxFiles > 0) { + const totalFiles = this.value.length + filesToAdd.length; + if (totalFiles > this.maxFiles) { + const allowedCount = this.maxFiles - this.value.length; + if (allowedCount <= 0) { + this.validationMessage = `Maximum ${this.maxFiles} files allowed`; + this.validationState = 'invalid'; + return; + } + filesToAdd.splice(allowedCount); + this.validationMessage = `Only ${allowedCount} more file(s) can be added`; + this.validationState = 'warn'; + } + } + + // Add files + if (!this.multiple && filesToAdd.length > 0) { + this.value = [filesToAdd[0]]; + } else { + this.value.push(...filesToAdd); + } + + this.requestUpdate(); + this.validate(); + this.changeSubject.next(this); + + // Update button text + if (this.value.length > 0) { + this.buttonText = this.multiple ? 'Add more files' : 'Replace file'; + } + } + + public async validate(): Promise { + this.validationMessage = ''; + + if (this.required && this.value.length === 0) { + this.validationState = 'invalid'; + this.validationMessage = 'Please select at least one file'; + return false; + } + + // Validate all files + for (const file of this.value) { + if (!this.validateFile(file)) { + return false; + } + } + + this.validationState = 'valid'; + return true; + } + public getValue(): File[] { return this.value; } public setValue(value: File[]): void { this.value = value; + this.requestUpdate(); + if (value.length > 0) { + this.buttonText = this.multiple ? 'Add more files' : 'Replace file'; + } else { + this.buttonText = 'Upload File...'; + } + } + + public updated(changedProperties: Map) { + super.updated(changedProperties); + + if (changedProperties.has('value')) { + this.validate(); + } } }