import { css, html, type TemplateResult } from "lit"; import { property, state } from "lit/decorators.js"; import { BaseElement } from "../../core/base-element.ts"; import type { ButtonSize, ButtonVariant } from "../cf-button/cf-button.ts"; import type { RuntimeClient } from "@commonfabric/runtime-client"; import { consume } from "@lit/context"; import { applyThemeToElement, type CFTheme, cfThemeContext, defaultTheme, } from "../theme-context.ts"; import { formatFileSize } from "../../utils/image-compression.ts"; import { runtimeContext, spaceContext } from "../../runtime-context.ts"; import type { DID } from "@commonfabric/identity"; import { type StoredFile, type StoreFileOptions, uploadFile, } from "../../utils/file-cell-storage.ts"; import "../cf-button/index.ts"; export type FileData = StoredFile; /** * CFFileInput - Generic file upload component * * @element cf-file-input * * @attr {boolean} multiple - Allow multiple files (default: false) * @attr {number} maxFiles - Max number of files (default: unlimited) * @attr {string} accept - File types to accept (default: "*\/*") * @attr {string} buttonText - Custom button text (default: "📎 Add File") * @attr {string} variant - Button style variant * @attr {string} size - Button size * @attr {boolean} showPreview - Show file previews (default: true) * @attr {string} previewSize - Preview thumbnail size: "sm" | "md" | "lg" * @attr {boolean} removable - Allow removing files (default: true) * @attr {boolean} disabled - Disable the input * @attr {number} maxSizeBytes - Max size warning threshold (default: none) * @attr {boolean} includeData - Include a data URL in emitted file descriptors * * @fires cf-change - Fired when file(s) are added or removed. On add, files contains the newly added files. On remove, files contains the updated full list for compatibility. detail: { files: StoredFile[], allFiles: StoredFile[] } * @fires cf-remove - Fired when a file is removed. detail: { id: string, files: StoredFile[], allFiles: StoredFile[] } * @fires cf-error - Fired when an error occurs. detail: { error: Error, message: string } * * @example * * @example * */ export class CFFileInput extends BaseElement { static override styles = [ BaseElement.baseStyles, css` :host { display: block; box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; } .container { display: flex; flex-direction: column; gap: var(--cf-theme-spacing-normal, 0.75rem); } input[type="file"] { display: none; } .previews { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--cf-theme-spacing-normal, 0.75rem); } .preview-item { position: relative; border-radius: var( --cf-theme-border-radius, var(--cf-border-radius-md, 0.375rem) ); overflow: hidden; border: 1px solid var(--cf-theme-color-border, #e5e7eb); background: var( --cf-theme-color-background, #f9fafb ); } .preview-item img { width: 100%; height: 120px; object-fit: cover; display: block; } .preview-item.size-sm img, .preview-item.size-sm .file-preview { height: 80px; } .preview-item.size-lg img, .preview-item.size-lg .file-preview { height: 160px; } .file-preview { width: 100%; height: 120px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.5rem; padding: 1rem; background: var( --cf-theme-color-background, #f9fafb ); } .file-icon { font-size: 2rem; line-height: 1; } .file-name { font-size: 0.75rem; text-align: center; word-break: break-word; color: var( --cf-theme-color-text-muted, #4b5563 ); } .remove-button { position: absolute; top: 4px; right: 4px; background: rgba(0, 0, 0, 0.6); color: white; border: none; border-radius: 50%; width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; cursor: pointer; font-size: 16px; line-height: 1; padding: 0; transition: background 0.2s ease; } .remove-button:hover { background: rgba(0, 0, 0, 0.8); } .file-info { padding: 6px 8px; font-size: 0.75rem; color: var( --cf-theme-color-text-muted, #4b5563 ); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .loading { display: flex; align-items: center; justify-content: center; padding: 1rem; color: var( --cf-theme-color-text-muted, #4b5563 ); font-size: 0.875rem; } `, ]; @property({ type: Boolean }) accessor multiple = false; @property({ type: Number }) accessor maxFiles: number | undefined = undefined; @property({ type: String }) accessor accept = "*/*"; @property({ type: String }) accessor buttonText = "📎 Add File"; @property({ type: String }) accessor variant: ButtonVariant = "outline"; @property({ type: String }) accessor size: ButtonSize = "md"; @property({ type: Boolean }) accessor showPreview = true; @property({ type: String }) accessor previewSize: "sm" | "md" | "lg" = "md"; @property({ type: Boolean }) accessor removable = true; @property({ type: Boolean }) accessor disabled = false; @property({ type: Number }) accessor maxSizeBytes: number | undefined = undefined; @property({ type: Boolean }) accessor includeData = false; @property({ type: Boolean }) protected accessor loading = false; @state() protected accessor storedFiles: StoredFile[] = []; // Theme consumption @consume({ context: cfThemeContext, subscribe: true }) @property({ attribute: false }) accessor theme: CFTheme = defaultTheme; @consume({ context: runtimeContext, subscribe: true }) @property({ attribute: false }) accessor runtime: RuntimeClient | undefined = undefined; @consume({ context: spaceContext, subscribe: true }) @property({ attribute: false }) accessor space: DID | undefined = undefined; protected getFiles(): StoredFile[] { return [...this.storedFiles]; } protected setFiles(newFiles: StoredFile[]): void { this.storedFiles = newFiles; } /** * Process a file and return a stored file descriptor link. * Subclasses can override this to add custom processing */ protected async processFile(file: File): Promise { return await this.storeFile(file); } protected async storeFile( file: File, metadata?: Partial>, ): Promise { if (!this.runtime) { throw new Error("Runtime is not available for file storage"); } if (!this.space) { throw new Error("Space is not available for file storage"); } return await uploadFile({ file, runtime: this.runtime, space: this.space, includeDataUrl: this.includeData, ...metadata, }); } /** * Determine if a file should be compressed * Base class: never compress (subclasses override) */ protected shouldCompressFile(_file: File): boolean { return false; } /** * Compress a file * Subclasses override this for specific compression logic */ protected compressFile(file: File): Promise { return Promise.resolve(file); } /** * Render preview for a file * Subclasses can override for custom preview rendering */ protected renderPreview(file: StoredFile): TemplateResult { // Smart default preview based on MIME type if (file.type.startsWith("image/")) { return html` ${file.name} `; } // Generic file preview with icon const icon = this._getFileIcon(file.type); return html`
${icon}
${file.name}
`; } /** * Render the file input element * Subclasses can override to add custom attributes (e.g., capture) */ protected renderFileInput(): TemplateResult { return html` `; } protected renderButton(): TemplateResult { return html` ${this.loading ? "Loading..." : this.buttonText} `; } protected renderPreviews(): TemplateResult { const currentFiles = this.getFiles(); if (!this.showPreview || currentFiles.length === 0) { return html` `; } return html`
${currentFiles.map( (file) => html`
${this.renderPreview(file)} ${this.removable ? html` ` : ""}
${file.name} (${formatFileSize(file.size)})
`, )}
`; } private _getFileIcon(mimeType: string): string { if (mimeType.startsWith("image/")) return "🖼️"; if (mimeType === "application/pdf") return "📄"; if (mimeType.startsWith("video/")) return "🎬"; if (mimeType.startsWith("audio/")) return "🎵"; if (mimeType.startsWith("text/")) return "📝"; if ( mimeType.includes("word") || mimeType.includes("document") || mimeType.includes("openxmlformats") ) { return "📝"; } if (mimeType.includes("sheet") || mimeType.includes("excel")) return "📊"; if (mimeType.includes("presentation") || mimeType.includes("powerpoint")) { return "📽️"; } return "📎"; } private _handleButtonClick() { this.emit("cf-click"); // Emit before opening file picker const input = this.shadowRoot?.querySelector( 'input[type="file"]', ) as HTMLInputElement; input?.click(); } protected async _handleFileChange(event: Event) { const input = event.target as HTMLInputElement; const files = input.files; if (!files || files.length === 0) return; const currentFiles = this.getFiles(); // Check max files limit (only for multiple mode) // Single-file mode replaces existing files, so no max check needed if (this.multiple && this.maxFiles) { const totalFiles = currentFiles.length + files.length; if (totalFiles > this.maxFiles) { this.emit("cf-error", { error: new Error("Max files exceeded"), message: `Maximum ${this.maxFiles} files allowed`, }); return; } } this.loading = true; try { const newFiles: StoredFile[] = []; for (const file of Array.from(files)) { try { // Check if should compress (subclass decides) let fileToProcess: Blob = file; if (this.shouldCompressFile(file)) { fileToProcess = await this.compressFile(file); } // Check file size AFTER compression if maxSizeBytes is set if (this.maxSizeBytes && fileToProcess.size > this.maxSizeBytes) { console.warn( `File ${file.name} (${ formatFileSize(fileToProcess.size) }) exceeds maxSizeBytes (${ formatFileSize(this.maxSizeBytes) }) even after compression`, ); } // Process file (subclass can override) const fileData = await this.processFile( new File([fileToProcess], file.name, { type: file.type }), ); newFiles.push(fileData); } catch (error) { this.emit("cf-error", { error: error as Error, message: `Failed to process ${file.name}`, }); } } if (newFiles.length === 0) return; // When multiple is false, replace existing files instead of appending const updatedFiles = this.multiple ? [...currentFiles, ...newFiles] : newFiles; this.setFiles(updatedFiles); this.emit("cf-change", { files: newFiles, allFiles: updatedFiles }); } finally { this.loading = false; // Reset input so same file can be selected again input.value = ""; } } private _handleRemove(id: string) { const currentFiles = this.getFiles(); const updatedFiles = currentFiles.filter((file) => file.id !== id); this.setFiles(updatedFiles); this.emit("cf-remove", { id, files: updatedFiles, allFiles: updatedFiles }); this.emit("cf-change", { files: updatedFiles, allFiles: updatedFiles }); } override updated(changedProperties: Map) { super.updated(changedProperties); if (changedProperties.has("theme")) { applyThemeToElement(this, this.theme ?? defaultTheme); } } override firstUpdated() { // Apply theme after first render applyThemeToElement(this, this.theme ?? defaultTheme); } override render() { return html`
${this.renderFileInput()} ${this.renderButton()} ${this.loading ? html`
Processing files...
` : ""} ${this.renderPreviews()}
`; } }