diff --git a/.changeset/afraid-seas-scream.md b/.changeset/afraid-seas-scream.md new file mode 100644 index 000000000..70a0c01d4 --- /dev/null +++ b/.changeset/afraid-seas-scream.md @@ -0,0 +1,6 @@ +--- +"@uppy/provider-views": patch +"@uppy/webdav": patch +--- + +Refactor internal components diff --git a/.changeset/swift-flies-dance.md b/.changeset/swift-flies-dance.md new file mode 100644 index 000000000..3b2719318 --- /dev/null +++ b/.changeset/swift-flies-dance.md @@ -0,0 +1,5 @@ +--- +"@uppy/transloadit": minor +--- + +Export Assembly, AssemblyError, Client diff --git a/.changeset/twelve-results-smoke.md b/.changeset/twelve-results-smoke.md new file mode 100644 index 000000000..928cb1a1e --- /dev/null +++ b/.changeset/twelve-results-smoke.md @@ -0,0 +1,7 @@ +--- +"@uppy/image-generator": major +"@uppy/locales": minor +"uppy": minor +--- + +Introduce @uppy/image-generator to generate images based on a prompt using Transloadit diff --git a/packages/@uppy/image-generator/LICENSE b/packages/@uppy/image-generator/LICENSE new file mode 100644 index 000000000..d074cabcb --- /dev/null +++ b/packages/@uppy/image-generator/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2020 Transloadit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/@uppy/image-generator/README.md b/packages/@uppy/image-generator/README.md new file mode 100644 index 000000000..0a35b9d95 --- /dev/null +++ b/packages/@uppy/image-generator/README.md @@ -0,0 +1,49 @@ +# @uppy/image-generator + + + +[](https://www.npmjs.com/package/@uppy/image-generator) + + + + +**[Read the docs](https://uppy.io/docs/image-generator)** | +**[Try it](https://uppy.io/examples/)** + +Uppy is being developed by the folks at [Transloadit](https://transloadit.com), +a versatile file encoding service. + +## Example + +```js +import Uppy from '@uppy/core' +import ImageGenerator from '@uppy/image-generator' + +const uppy = new Uppy() + .use(ImageGenerator, { + assemblyOptions: async (prompt) => { + const res = await fetch(`/assembly-options?prompt=${encodeURIComponent(prompt)}`) + return res.json() + } + }) +``` + +## Installation + +```bash +$ npm install @uppy/image-generator +``` + +Alternatively, you can also use this plugin in a pre-built bundle from +Transloadit’s CDN: Edgly. In that case `Uppy` will attach itself to the global +`window.Uppy` object. See the +[main Uppy documentation](https://uppy.io/docs/#Installation) for instructions. + +## Documentation + +Documentation for this plugin can be found on the +[Uppy website](https://uppy.io/docs/image-generator). + +## License + +[The MIT License](./LICENSE). diff --git a/packages/@uppy/image-generator/package.json b/packages/@uppy/image-generator/package.json new file mode 100644 index 000000000..c41b8cd80 --- /dev/null +++ b/packages/@uppy/image-generator/package.json @@ -0,0 +1,60 @@ +{ + "name": "@uppy/image-generator", + "description": "AI-powered image generation for Uppy", + "version": "0.0.0", + "license": "MIT", + "sideEffects": false, + "type": "module", + "keywords": [ + "file uploader", + "upload", + "uppy", + "uppy-plugin", + "ai", + "image generation" + ], + "homepage": "https://uppy.io", + "bugs": { + "url": "https://github.com/transloadit/uppy/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/transloadit/uppy.git" + }, + "files": [ + "src", + "lib", + "dist", + "CHANGELOG.md" + ], + "exports": { + ".": "./lib/index.js", + "./css/style.css": "./dist/style.css", + "./css/style.min.css": "./dist/style.min.css", + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsc --build tsconfig.build.json", + "build:css": "sass --load-path=../../ src/style.scss dist/style.css && postcss dist/style.css -u cssnano -o dist/style.min.css", + "typecheck": "tsc --build" + }, + "dependencies": { + "@uppy/provider-views": "workspace:^", + "@uppy/transloadit": "workspace:^", + "@uppy/utils": "workspace:^", + "preact": "^10.5.13" + }, + "devDependencies": { + "@uppy/core": "workspace:^", + "cssnano": "^7.0.7", + "postcss": "^8.5.6", + "postcss-cli": "^11.0.1", + "sass": "^1.89.2" + }, + "peerDependencies": { + "@uppy/core": "workspace:^" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@uppy/image-generator/src/index.tsx b/packages/@uppy/image-generator/src/index.tsx new file mode 100644 index 000000000..8fc002e32 --- /dev/null +++ b/packages/@uppy/image-generator/src/index.tsx @@ -0,0 +1,381 @@ +import type { Body, Meta, MinimalRequiredUppyFile, Uppy } from '@uppy/core' +import { UIPlugin, type UIPluginOptions } from '@uppy/core' +import { FilterInput, SearchView } from '@uppy/provider-views' +import { + Assembly, + type AssemblyResult, + Client, + type OptionsWithRestructuredFields, +} from '@uppy/transloadit' +import { RateLimitedQueue } from '@uppy/utils' +import type { h } from 'preact' +import locale from './locale.js' + +export interface ImageGeneratorOptions extends UIPluginOptions { + // OptionsWithRestructuredFields does not allow string[] for `fields`. + // in @uppy/transloadit we do accept that but then immediately use a type assertion to this type + // so that's why we just don't allow string[] from the start here + assemblyOptions: (prompt: string) => Promise +} + +interface PluginState extends Record { + prompt: string + results: AssemblyResult[] + checkedResultIds: Set + loading: boolean + loadingMessageIndex: number + firstRun: boolean +} + +const defaultState = { + prompt: '', + results: [], + checkedResultIds: new Set(), + loading: false, + loadingMessageIndex: 0, + firstRun: true, +} satisfies PluginState + +const LOADING_MESSAGES = [ + 'generating1', + 'generating2', + 'generating3', + 'generating4', + 'generating5', +] as const + +export default class ImageGenerator< + M extends Meta, + B extends Body, +> extends UIPlugin { + private loadingInterval: ReturnType | null = null + private rateLimitedQueue: RateLimitedQueue + private client: Client + private assembly: Assembly | null = null + icon: () => h.JSX.Element + + constructor(uppy: Uppy, opts: ImageGeneratorOptions) { + super(uppy, opts) + + this.id = this.opts.id || 'ImageGenerator' + this.title = 'AI image' + this.type = 'acquirer' + + this.defaultLocale = locale + + this.rateLimitedQueue = new RateLimitedQueue(10) + this.client = new Client({ + service: 'https://api2.transloadit.com', + rateLimitedQueue: this.rateLimitedQueue, + errorReporting: true, + }) + + this.setPluginState(defaultState) + + this.i18nInit() + + this.icon = () => ( + + ai-image-generator + + + + + + + + + + + + + ) + } + + install(): void { + const { target } = this.opts + if (target) { + this.mount(target, this) + } + } + + uninstall(): void { + this.clearLoadingInterval() + this.closeAssembly(true) // Cancel any in-progress assembly + this.unmount() + } + + private closeAssembly(cancel = false): void { + if (this.assembly) { + const { status } = this.assembly + this.assembly.close() + this.assembly = null + + // Cancel the assembly on the server to stop processing + if (cancel && status) { + this.client.cancelAssembly(status).catch(() => { + // If we can't cancel, there's not much we can do + }) + } + } + } + + private clearLoadingInterval(): void { + if (this.loadingInterval) { + clearInterval(this.loadingInterval) + this.loadingInterval = null + } + } + + private startLoadingAnimation(): void { + this.clearLoadingInterval() + this.loadingInterval = setInterval(() => { + const { loadingMessageIndex } = this.getPluginState() + const nextIndex = (loadingMessageIndex + 1) % LOADING_MESSAGES.length + this.setPluginState({ loadingMessageIndex: nextIndex }) + }, 4000) + } + + /** + * Creates a Transloadit assembly to generate AI images. + * + * Completion scenarios: + * - Success: assembly emits 'finished' → resolve() → finally cleans up, keeps results + * - Error: assembly emits 'error' → reject() → catch reports error, finally cleans up + * - Dashboard close: onDashboardClose sets cancelled=true, resolve() → finally resets state + * - Uninstall: closeAssembly(true) called directly, cancels server-side assembly + */ + generate = async () => { + const { loading, prompt } = this.getPluginState() + if (loading || prompt.trim() === '') return + + const { promise, resolve, reject } = Promise.withResolvers() + let cancelled = false + + const onDashboardClose = () => { + cancelled = true + resolve() + } + + // @ts-expect-error not typed because we do not depend on @uppy/dashboard + this.uppy.once('dashboard:close-panel', onDashboardClose) + + try { + this.setPluginState({ + loading: true, + results: [], + checkedResultIds: new Set(), + loadingMessageIndex: 0, + }) + this.startLoadingAnimation() + + const assemblyOptions = await this.opts.assemblyOptions( + this.getPluginState().prompt, + ) + + const assemblyResponse = await this.client.createAssembly({ + params: assemblyOptions.params, + fields: assemblyOptions.fields ?? {}, + signature: assemblyOptions.signature, + expectedFiles: 0, + }) + + const assembly = new Assembly(assemblyResponse, this.rateLimitedQueue) + this.assembly = assembly + + assembly.on('result', (stepName: string, result: AssemblyResult) => { + const { results } = this.getPluginState() + this.setPluginState({ + results: [...results, result], + firstRun: false, + }) + }) + + assembly.on('error', reject) + assembly.on('finished', resolve) + assembly.connect() + + await promise + } catch (error) { + this.client.submitError(error as Error).catch(() => {}) + this.uppy.info('Image could not be generated', 'error') + throw error + } finally { + // @ts-expect-error not typed because we do not depend on @uppy/dashboard + this.uppy.off('dashboard:close-panel', onDashboardClose) + this.clearLoadingInterval() + this.closeAssembly(true) + this.setPluginState(cancelled ? defaultState : { loading: false }) + } + } + + private onCheckboxChange = (result: AssemblyResult, event?: Event) => { + if (event) { + event.stopPropagation() + event.preventDefault() + // Prevent shift-clicking from highlighting file names + document.getSelection()?.removeAllRanges() + } + + const { checkedResultIds } = this.getPluginState() + + if (checkedResultIds.has(result.id)) { + checkedResultIds.delete(result.id) + } else { + checkedResultIds.add(result.id) + } + + this.setPluginState({ checkedResultIds }) + } + + private cancelSelection = () => { + this.setPluginState({ checkedResultIds: new Set() }) + } + + private donePicking = async () => { + const { checkedResultIds, results } = this.getPluginState() + const proms: Promise>[] = results + .filter((result) => checkedResultIds.has(result.id)) + .map(async (result) => { + const res = await fetch(result.ssl_url!) + const blob = await res.blob() + + return { + name: `ai-image-${result.id!}`, + type: result.mime ?? undefined, + source: 'Transloadit', + data: blob, + } + }) + const files = await Promise.all(proms) + + this.uppy.addFiles(files) + this.setPluginState(defaultState) + } + + render() { + const { + prompt, + results, + checkedResultIds, + loading, + loadingMessageIndex, + firstRun, + } = this.getPluginState() + const { i18n } = this.uppy + + const currentLoadingMessage = loading + ? i18n(LOADING_MESSAGES[loadingMessageIndex]) + : undefined + + if (firstRun) { + return ( + this.setPluginState({ prompt })} + onSubmit={this.generate} + inputLabel={i18n('generateImagePlaceholder')} + loading={loading} + > + {loading ? ( + + {currentLoadingMessage} + + ) : ( + i18n('generateImage') + )} + + ) + } + + return ( + + this.setPluginState({ prompt })} + onSubmit={this.generate} + inputLabel={i18n('search')} + i18n={i18n} + /> + + {loading ? ( + + {currentLoadingMessage} + + ) : results.length > 0 ? ( + + + {results.map((result) => ( + + this.onCheckboxChange(result, e)} + name="listitem" + id={result.id} + checked={checkedResultIds.has(result.id)} + data-uppy-super-focusable + /> + + + + + ))} + + + ) : ( + {i18n('noSearchResults')} + )} + + {checkedResultIds.size > 0 && ( + + + + {i18n('selectX', { + smart_count: checkedResultIds.size, + })} + + + {i18n('cancel')} + + + + )} + + ) + } +} diff --git a/packages/@uppy/image-generator/src/locale.ts b/packages/@uppy/image-generator/src/locale.ts new file mode 100644 index 000000000..39881d7cd --- /dev/null +++ b/packages/@uppy/image-generator/src/locale.ts @@ -0,0 +1,12 @@ +export default { + strings: { + generateImage: 'Generate image', + generateImagePlaceholder: + 'A serene sunset over a mountain lake, with pine trees reflecting in the water', + generating1: 'AI is thinking...', + generating2: 'Crunching pixels...', + generating3: 'Summoning images...', + generating4: 'AI is working...', + generating5: 'Creating magic...', + }, +} diff --git a/packages/@uppy/image-generator/src/style.scss b/packages/@uppy/image-generator/src/style.scss new file mode 100644 index 000000000..1053cf2e9 --- /dev/null +++ b/packages/@uppy/image-generator/src/style.scss @@ -0,0 +1,29 @@ +@use '@uppy/core/src/_variables.scss' as *; + +.uppy-ImageGenerator-generating { + background: linear-gradient(90deg, $gray-50, $gray-300, $gray-50); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: uppy-gradient-slide 3s ease-in-out infinite; +} + +.uppy-ImageGenerator-generating--darker { + background: linear-gradient(90deg, $gray-500, $gray-800, $gray-500); + background-size: 200% 100%; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: uppy-gradient-slide 3s ease-in-out infinite; +} + +@keyframes uppy-gradient-slide { + 0% { + background-position: -200% 0; + } + + 100% { + background-position: 200% 0; + } +} diff --git a/packages/@uppy/image-generator/tsconfig.build.json b/packages/@uppy/image-generator/tsconfig.build.json new file mode 100644 index 000000000..389eb1387 --- /dev/null +++ b/packages/@uppy/image-generator/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./src" + }, + "include": ["./src/**/*.*"], + "exclude": ["./src/**/*.test.ts"], + "references": [ + { + "path": "../utils/tsconfig.build.json" + }, + { + "path": "../core/tsconfig.build.json" + } + ] +} diff --git a/packages/@uppy/image-generator/tsconfig.json b/packages/@uppy/image-generator/tsconfig.json new file mode 100644 index 000000000..ee7d9ca9f --- /dev/null +++ b/packages/@uppy/image-generator/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.shared", + "compilerOptions": { + "emitDeclarationOnly": false, + "noEmit": true + }, + "include": ["./package.json", "./src/**/*.*"], + "references": [ + { + "path": "../utils/tsconfig.build.json" + }, + { + "path": "../core/tsconfig.build.json" + }, + { + "path": "../provider-views/tsconfig.build.json" + }, + { + "path": "../transloadit/tsconfig.build.json" + } + ] +} diff --git a/packages/@uppy/image-generator/turbo.json b/packages/@uppy/image-generator/turbo.json new file mode 100644 index 000000000..c7d5acc18 --- /dev/null +++ b/packages/@uppy/image-generator/turbo.json @@ -0,0 +1,8 @@ +{ + "extends": ["//"], + "tasks": { + "build": { + "dependsOn": ["^build", "@uppy/core#build", "@uppy/transloadit#build"] + } + } +} diff --git a/packages/@uppy/locales/src/en_US.ts b/packages/@uppy/locales/src/en_US.ts index fd8925bb9..cca5feb7b 100644 --- a/packages/@uppy/locales/src/en_US.ts +++ b/packages/@uppy/locales/src/en_US.ts @@ -44,6 +44,7 @@ en_US.strings = { browseFolders: 'browse folders', cancel: 'Cancel', cancelUpload: 'Cancel upload', + chooseFiles: 'Choose files', closeModal: 'Close Modal', companionError: 'Connection with Companion failed', companionUnauthorizeHint: @@ -100,6 +101,14 @@ en_US.strings = { '1': 'Added %{smart_count} files from %{folder}', }, folderAlreadyAdded: 'The folder "%{folder}" was already added', + generateImage: 'Generate image', + generateImagePlaceholder: + 'A serene sunset over a mountain lake, with pine trees reflecting in the water', + generating1: 'AI is thinking...', + generating2: 'Crunching pixels...', + generating3: 'Summoning images...', + generating4: 'AI is working...', + generating5: 'Creating magic...', generatingThumbnails: 'Generating thumbnails...', import: 'Import', importFiles: 'Import files from:', diff --git a/packages/@uppy/locales/turbo.json b/packages/@uppy/locales/turbo.json index 734ebe0e5..7c1a5e518 100644 --- a/packages/@uppy/locales/turbo.json +++ b/packages/@uppy/locales/turbo.json @@ -4,6 +4,7 @@ "build": { "dependsOn": [ "@uppy/image-editor#build", + "@uppy/image-generator#build", "@uppy/box#build", "@uppy/core#build", "@uppy/google-drive-picker#build", diff --git a/packages/@uppy/provider-views/src/FilterInput.tsx b/packages/@uppy/provider-views/src/FilterInput.tsx new file mode 100644 index 000000000..1fa9baabe --- /dev/null +++ b/packages/@uppy/provider-views/src/FilterInput.tsx @@ -0,0 +1,68 @@ +import type { I18n } from '@uppy/utils' +import { useSearchForm } from './useSearchForm.js' + +interface FilterInputProps { + value: string + onChange: (value: string) => void + onSubmit: () => void + inputLabel: string + i18n: I18n +} + +/** + * FilterInput component for client-side filtering with search icon and clear button. + * Supports Enter key submission via form element. + */ +function FilterInput({ + value, + onChange, + onSubmit, + inputLabel, + i18n, +}: FilterInputProps) { + const { formId } = useSearchForm(onSubmit) + + return ( + + onChange((e.target as HTMLInputElement).value)} + form={formId} + data-uppy-super-focusable + /> + + + + {value && ( + onChange('')} + > + + + + + )} + + ) +} + +export default FilterInput diff --git a/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx b/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx index b231340e9..4e010678e 100644 --- a/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx +++ b/packages/@uppy/provider-views/src/ProviderView/ProviderView.tsx @@ -17,8 +17,8 @@ import debounce from 'lodash/debounce.js' import type { h } from 'preact' import packageJson from '../../package.json' with { type: 'json' } import Browser from '../Browser.js' +import FilterInput from '../FilterInput.js' import FooterActions from '../FooterActions.js' -import SearchInput from '../SearchInput.js' import addFiles from '../utils/addFiles.js' import getClickedRange from '../utils/getClickedRange.js' import handleError from '../utils/handleError.js' @@ -678,14 +678,12 @@ export default class ProviderView { i18n={i18n} /> {opts.showFilter && ( - this.onSearchInput(s)} - submitSearchString={() => {}} + this.onSearchInput(s)} + onSubmit={() => {}} inputLabel={i18n('filter')} - clearSearchLabel={i18n('resetFilter')} - wrapperClassName="uppy-ProviderBrowser-searchFilter" - inputClassName="uppy-ProviderBrowser-searchFilterInput" + i18n={i18n} /> )} diff --git a/packages/@uppy/provider-views/src/SearchInput.tsx b/packages/@uppy/provider-views/src/SearchInput.tsx deleted file mode 100644 index a95622799..000000000 --- a/packages/@uppy/provider-views/src/SearchInput.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { nanoid } from 'nanoid/non-secure' -import { useCallback, useEffect, useState } from 'preact/hooks' - -type Props = { - searchString: string - setSearchString: (s: string) => void - submitSearchString: () => void - - wrapperClassName: string - inputClassName: string - - inputLabel: string - clearSearchLabel?: string - - showButton?: boolean - buttonLabel?: string - buttonCSSClassName?: string -} - -function SearchInput({ - searchString, - setSearchString, - submitSearchString, - - wrapperClassName, - inputClassName, - - inputLabel, - clearSearchLabel = '', - - showButton = false, - buttonLabel = '', - buttonCSSClassName = '', -}: Props) { - const onInput = (e: Event) => { - setSearchString((e.target as HTMLInputElement).value) - } - - const submit = useCallback( - (ev: Event) => { - ev.preventDefault() - submitSearchString() - }, - [submitSearchString], - ) - - // We do this to avoid nested s - // (See https://github.com/transloadit/uppy/pull/5050#discussion_r1640392516) - const [form] = useState(() => { - const formEl = document.createElement('form') - formEl.setAttribute('tabindex', '-1') - formEl.id = nanoid() - return formEl - }) - - useEffect(() => { - document.body.appendChild(form) - form.addEventListener('submit', submit) - return () => { - form.removeEventListener('submit', submit) - document.body.removeChild(form) - } - }, [form, submit]) - - return ( - - - {!showButton && ( - // 🔍 - - - - )} - {!showButton && searchString && ( - // ❌ - setSearchString('')} - > - - - - - )} - {showButton && ( - - {buttonLabel} - - )} - - ) -} - -export default SearchInput diff --git a/packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.tsx b/packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.tsx index 8de25d1fe..2de72ba92 100644 --- a/packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.tsx +++ b/packages/@uppy/provider-views/src/SearchProviderView/SearchProviderView.tsx @@ -16,8 +16,9 @@ import classNames from 'classnames' import type { h } from 'preact' import packageJson from '../../package.json' with { type: 'json' } import Browser from '../Browser.js' +import FilterInput from '../FilterInput.js' import FooterActions from '../FooterActions.js' -import SearchInput from '../SearchInput.js' +import SearchView from '../SearchView.js' import addFiles from '../utils/addFiles.js' import getClickedRange from '../utils/getClickedRange.js' import handleError from '../utils/handleError.js' @@ -278,17 +279,14 @@ export default class SearchProviderView { if (isInputMode) { return ( - + > + {i18n('searchImages')} + ) } @@ -300,14 +298,12 @@ export default class SearchProviderView { )} > {opts.showFilter && ( - )} diff --git a/packages/@uppy/provider-views/src/SearchView.tsx b/packages/@uppy/provider-views/src/SearchView.tsx new file mode 100644 index 000000000..4f25b7b84 --- /dev/null +++ b/packages/@uppy/provider-views/src/SearchView.tsx @@ -0,0 +1,53 @@ +import type { ComponentChild } from 'preact' +import { useSearchForm } from './useSearchForm.js' + +interface SearchViewProps { + value: string + onChange: (value: string) => void + onSubmit: () => void + inputLabel: string + loading?: boolean + children: ComponentChild +} + +/** + * SearchView component for search with a submit button. + * Typically used for initial search views or forms that require explicit submission. + * The children prop is rendered as the button content, allowing full control over button text and loading states. + */ +function SearchView({ + value, + onChange, + onSubmit, + inputLabel, + loading = false, + children, +}: SearchViewProps) { + const { formId } = useSearchForm(onSubmit) + + return ( + + onChange((e.target as HTMLInputElement).value)} + form={formId} + disabled={loading} + data-uppy-super-focusable + /> + + {children} + + + ) +} + +export default SearchView diff --git a/packages/@uppy/provider-views/src/index.ts b/packages/@uppy/provider-views/src/index.ts index 08cfcb498..6d23bb688 100644 --- a/packages/@uppy/provider-views/src/index.ts +++ b/packages/@uppy/provider-views/src/index.ts @@ -1,3 +1,4 @@ +export { default as FilterInput } from './FilterInput.js' export { default as GooglePickerView } from './GooglePicker/GooglePickerView.js' export type { MediaItem, @@ -24,5 +25,6 @@ export { default as ProviderViews, defaultPickerIcon, } from './ProviderView/index.js' -export { default as SearchInput } from './SearchInput.js' export { default as SearchProviderViews } from './SearchProviderView/index.js' +export { default as SearchView } from './SearchView.js' +export { useSearchForm } from './useSearchForm.js' diff --git a/packages/@uppy/provider-views/src/useSearchForm.ts b/packages/@uppy/provider-views/src/useSearchForm.ts new file mode 100644 index 000000000..2ca162744 --- /dev/null +++ b/packages/@uppy/provider-views/src/useSearchForm.ts @@ -0,0 +1,42 @@ +import { nanoid } from 'nanoid/non-secure' +import { useCallback, useEffect, useState } from 'preact/hooks' + +/** + * Hook to create a form element outside the component tree to avoid nested forms. + * Returns a formId that can be used with the `form` attribute on inputs and buttons. + * + * This allows form submission (Enter key) to work properly even when the component + * is rendered inside another form element. + * + * @param onSubmit - Callback to execute when the form is submitted + * @returns Object containing the formId to use with form attribute + */ +export function useSearchForm(onSubmit: () => void): { formId: string } { + const submit = useCallback( + (ev: Event) => { + ev.preventDefault() + onSubmit() + }, + [onSubmit], + ) + + // We create a form element and append it to document.body to avoid nested s + // (See https://github.com/transloadit/uppy/pull/5050#discussion_r1640392516) + const [form] = useState(() => { + const formEl = document.createElement('form') + formEl.setAttribute('tabindex', '-1') + formEl.id = nanoid() + return formEl + }) + + useEffect(() => { + document.body.appendChild(form) + form.addEventListener('submit', submit) + return () => { + form.removeEventListener('submit', submit) + document.body.removeChild(form) + } + }, [form, submit]) + + return { formId: form.id } +} diff --git a/packages/@uppy/transloadit/src/index.ts b/packages/@uppy/transloadit/src/index.ts index 21544ab52..9a628570a 100644 --- a/packages/@uppy/transloadit/src/index.ts +++ b/packages/@uppy/transloadit/src/index.ts @@ -819,7 +819,9 @@ export default class Transloadit< : this.opts.assemblyOptions ) as OptionsWithRestructuredFields - assemblyOptions.fields ??= {} + assemblyOptions.fields = { + ...(assemblyOptions.fields ?? {}), + } validateParams(assemblyOptions.params) try { @@ -1032,3 +1034,7 @@ export default class Transloadit< } export { COMPANION_URL, COMPANION_ALLOWED_HOSTS } + +// Low-level classes for advanced usage (e.g., creating assemblies without file uploads) +export { default as Assembly } from './Assembly.js' +export { AssemblyError, default as Client } from './Client.js' diff --git a/packages/@uppy/webdav/src/Webdav.tsx b/packages/@uppy/webdav/src/Webdav.tsx index acf6e12df..840abe605 100644 --- a/packages/@uppy/webdav/src/Webdav.tsx +++ b/packages/@uppy/webdav/src/Webdav.tsx @@ -14,7 +14,7 @@ import type { } from '@uppy/core' import { UIPlugin } from '@uppy/core' -import { ProviderViews, SearchInput } from '@uppy/provider-views' +import { ProviderViews, SearchView } from '@uppy/provider-views' import type { I18n, LocaleStrings } from '@uppy/utils' // biome-ignore lint/style/useImportType: h is not a type import { type ComponentChild, h } from 'preact' @@ -61,17 +61,14 @@ const AuthForm = ({ }, [onAuth, webdavUrl]) return ( - + > + {i18n('authenticate')} + ) } diff --git a/packages/uppy/package.json b/packages/uppy/package.json index d53696e36..e975437a1 100644 --- a/packages/uppy/package.json +++ b/packages/uppy/package.json @@ -54,6 +54,7 @@ "@uppy/google-drive-picker": "workspace:*", "@uppy/google-photos-picker": "workspace:*", "@uppy/image-editor": "workspace:*", + "@uppy/image-generator": "workspace:*", "@uppy/instagram": "workspace:*", "@uppy/locales": "workspace:*", "@uppy/onedrive": "workspace:*", diff --git a/packages/uppy/src/style.scss b/packages/uppy/src/style.scss index 99baf3f24..6b331ddd8 100644 --- a/packages/uppy/src/style.scss +++ b/packages/uppy/src/style.scss @@ -8,4 +8,5 @@ @use '@uppy/screen-capture/src/style.scss' as screen-capture; @use '@uppy/status-bar/src/style.scss' as status-bar; @use '@uppy/image-editor/src/style.scss' as image-editor; +@use '@uppy/image-generator/src/style.scss' as image-generator; @use '@uppy/drop-target/src/style.scss' as drop-target; diff --git a/private/dev/Dashboard.js b/private/dev/Dashboard.js index 456e457ab..f3b7ffdd5 100644 --- a/private/dev/Dashboard.js +++ b/private/dev/Dashboard.js @@ -10,6 +10,7 @@ import GoogleDrive from '@uppy/google-drive' import GoogleDrivePicker from '@uppy/google-drive-picker' import GooglePhotosPicker from '@uppy/google-photos-picker' import ImageEditor from '@uppy/image-editor' +import ImageGenerator from '@uppy/image-generator' import english from '@uppy/locales/lib/en_US.js' import RemoteSources from '@uppy/remote-sources' import ScreenCapture from '@uppy/screen-capture' @@ -174,6 +175,25 @@ export default () => { .use(ScreenCapture, { target: Dashboard }) .use(Form, { target: '#upload-form' }) .use(ImageEditor, { target: Dashboard }) + .use(ImageGenerator, { + target: Dashboard, + assemblyOptions: async (prompt) => + // never create a signature on the client in production! + // it will expose the secret on the client + generateSignatureIfSecret(TRANSLOADIT_SECRET, { + auth: { key: TRANSLOADIT_KEY }, + steps: { + generated_image: { + robot: '/image/generate', + result: true, + aspect_ratio: '2:3', + model: 'flux-1.1-pro-ultra', + prompt, + num_outputs: 2, + }, + }, + }), + }) .use(DropTarget, { target: document.body, }) diff --git a/turbo.json b/turbo.json index 0fec0fb4f..687e96dd8 100644 --- a/turbo.json +++ b/turbo.json @@ -23,6 +23,12 @@ ], "outputs": ["lib/**", "dist/**"] }, + "uppy#build:css": { + "outputLogs": "new-only", + "dependsOn": ["^build:css"], + "inputs": ["src/**/*.scss"], + "outputs": ["lib/**/*.css", "dist/**/*.css"] + }, "build:css": { "outputLogs": "new-only", "inputs": ["src/**/*.scss"], diff --git a/yarn.lock b/yarn.lock index f79d2aecf..0765603ca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4975,18 +4975,7 @@ __metadata: languageName: node linkType: hard -"@eslint-community/eslint-utils@npm:^4.2.0": - version: 4.7.0 - resolution: "@eslint-community/eslint-utils@npm:4.7.0" - dependencies: - eslint-visitor-keys: "npm:^3.4.3" - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - checksum: 10/43ed5d391526d9f5bbe452aef336389a473026fca92057cf97c576db11401ce9bcf8ef0bf72625bbaf6207ed8ba6bf0dcf4d7e809c24f08faa68a28533c491a7 - languageName: node - linkType: hard - -"@eslint-community/eslint-utils@npm:^4.4.0": +"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0": version: 4.4.0 resolution: "@eslint-community/eslint-utils@npm:4.4.0" dependencies: @@ -10983,6 +10972,24 @@ __metadata: languageName: unknown linkType: soft +"@uppy/image-generator@workspace:*, @uppy/image-generator@workspace:packages/@uppy/image-generator": + version: 0.0.0-use.local + resolution: "@uppy/image-generator@workspace:packages/@uppy/image-generator" + dependencies: + "@uppy/core": "workspace:^" + "@uppy/provider-views": "workspace:^" + "@uppy/transloadit": "workspace:^" + "@uppy/utils": "workspace:^" + cssnano: "npm:^7.0.7" + postcss: "npm:^8.5.6" + postcss-cli: "npm:^11.0.1" + preact: "npm:^10.5.13" + sass: "npm:^1.89.2" + peerDependencies: + "@uppy/core": "workspace:^" + languageName: unknown + linkType: soft + "@uppy/instagram@workspace:*, @uppy/instagram@workspace:^, @uppy/instagram@workspace:packages/@uppy/instagram": version: 0.0.0-use.local resolution: "@uppy/instagram@workspace:packages/@uppy/instagram" @@ -11206,7 +11213,7 @@ __metadata: languageName: unknown linkType: soft -"@uppy/transloadit@workspace:*, @uppy/transloadit@workspace:packages/@uppy/transloadit": +"@uppy/transloadit@workspace:*, @uppy/transloadit@workspace:^, @uppy/transloadit@workspace:packages/@uppy/transloadit": version: 0.0.0-use.local resolution: "@uppy/transloadit@workspace:packages/@uppy/transloadit" dependencies: @@ -12619,12 +12626,12 @@ __metadata: linkType: hard "brace-expansion@npm:^1.1.7": - version: 1.1.12 - resolution: "brace-expansion@npm:1.1.12" + version: 1.1.11 + resolution: "brace-expansion@npm:1.1.11" dependencies: balanced-match: "npm:^1.0.0" concat-map: "npm:0.0.1" - checksum: 10/12cb6d6310629e3048cadb003e1aca4d8c9bb5c67c3c321bafdd7e7a50155de081f78ea3e0ed92ecc75a9015e784f301efc8132383132f4f7904ad1ac529c562 + checksum: 10/faf34a7bb0c3fcf4b59c7808bc5d2a96a40988addf2e7e09dfbb67a2251800e0d14cd2bfc1aa79174f2f5095c54ff27f46fb1289fe2d77dac755b5eb3434cc07 languageName: node linkType: hard @@ -13283,7 +13290,7 @@ __metadata: languageName: node linkType: hard -"combined-stream@npm:^1.0.8": +"combined-stream@npm:^1.0.6, combined-stream@npm:^1.0.8": version: 1.0.8 resolution: "combined-stream@npm:1.0.8" dependencies: @@ -14264,9 +14271,9 @@ __metadata: linkType: hard "devalue@npm:^5.1.0": - version: 5.3.2 - resolution: "devalue@npm:5.3.2" - checksum: 10/2e15e9ed6844e86f1d086088c0f51e447e2300ee385ace02acca4691563ca13e5d0f5445603afc52653609078039c9dad69bd8204dadfac0919fe1ef65f08a88 + version: 5.1.1 + resolution: "devalue@npm:5.1.1" + checksum: 10/ff36fe61af61636419eb16692d2fe43d793cdfb17f868bb3560c5485e4c25bc38d472304e61efdec6e806a3c4b450c46247decf59968b4a05ddc7714ea64f885 languageName: node linkType: hard @@ -15296,11 +15303,11 @@ __metadata: linkType: hard "esquery@npm:^1.4.2": - version: 1.6.0 - resolution: "esquery@npm:1.6.0" + version: 1.5.0 + resolution: "esquery@npm:1.5.0" dependencies: estraverse: "npm:^5.1.0" - checksum: 10/c587fb8ec9ed83f2b1bc97cf2f6854cc30bf784a79d62ba08c6e358bf22280d69aee12827521cf38e69ae9761d23fb7fde593ce315610f85655c139d99b05e5a + checksum: 10/e65fcdfc1e0ff5effbf50fb4f31ea20143ae5df92bb2e4953653d8d40aa4bc148e0d06117a592ce4ea53eeab1dafdfded7ea7e22a5be87e82d73757329a1b01d languageName: node linkType: hard @@ -16322,16 +16329,13 @@ __metadata: linkType: hard "form-data@npm:^2.5.0": - version: 2.5.5 - resolution: "form-data@npm:2.5.5" + version: 2.5.1 + resolution: "form-data@npm:2.5.1" dependencies: asynckit: "npm:^0.4.0" - combined-stream: "npm:^1.0.8" - es-set-tostringtag: "npm:^2.1.0" - hasown: "npm:^2.0.2" - mime-types: "npm:^2.1.35" - safe-buffer: "npm:^5.2.1" - checksum: 10/4b6a8d07bb67089da41048e734215f68317a8e29dd5385a972bf5c458a023313c69d3b5d6b8baafbb7f808fa9881e0e2e030ffe61e096b3ddc894c516401271d + combined-stream: "npm:^1.0.6" + mime-types: "npm:^2.1.12" + checksum: 10/2e2e5e927979ba3623f9b4c4bcc939275fae3f2dea9dafc8db3ca656a3d75476605de2c80f0e6f1487987398e056f0b4c738972d6e1edd83392d5686d0952eed languageName: node linkType: hard @@ -17480,17 +17484,7 @@ __metadata: languageName: node linkType: hard -"import-fresh@npm:^3.2.1": - version: 3.3.1 - resolution: "import-fresh@npm:3.3.1" - dependencies: - parent-module: "npm:^1.0.0" - resolve-from: "npm:^4.0.0" - checksum: 10/a06b19461b4879cc654d46f8a6244eb55eb053437afd4cbb6613cad6be203811849ed3e4ea038783092879487299fda24af932b86bdfff67c9055ba3612b8c87 - languageName: node - linkType: hard - -"import-fresh@npm:^3.3.0": +"import-fresh@npm:^3.2.1, import-fresh@npm:^3.3.0": version: 3.3.0 resolution: "import-fresh@npm:3.3.0" dependencies: @@ -19627,7 +19621,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:2.1.35, mime-types@npm:^2.1.12, mime-types@npm:^2.1.26, mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:^2.1.35, mime-types@npm:~2.1.17, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": +"mime-types@npm:2.1.35, mime-types@npm:^2.1.12, mime-types@npm:^2.1.26, mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:~2.1.17, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -23366,7 +23360,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10/32872cd0ff68a3ddade7a7617b8f4c2ae8764d8b7d884c651b74457967a9e0e886267d3ecc781220629c44a865167b61c375d2da6c720c840ecd73f45d5d9451 @@ -25698,6 +25692,7 @@ __metadata: "@uppy/google-drive-picker": "workspace:*" "@uppy/google-photos-picker": "workspace:*" "@uppy/image-editor": "workspace:*" + "@uppy/image-generator": "workspace:*" "@uppy/instagram": "workspace:*" "@uppy/locales": "workspace:*" "@uppy/onedrive": "workspace:*"