From 5684efa64ea13713e1a17e2e36cc1a7f10fe69ce Mon Sep 17 00:00:00 2001 From: Merlijn Vos Date: Wed, 3 Dec 2025 11:59:52 +0100 Subject: [PATCH] Introduce @uppy/image-generator (#6056) Closes #5378 - Introduce `@uppy/image-generator`, a new plugin to generate images based on a prompt via Transloadit - until we have "golden templates" the idea is to just send [steps](https://transloadit.com/docs/topics/templates/#overruling-templates-at-runtime) - because we must send steps and since we must use signature authentication for security, which is signed based on the params we send, we can't reuse the `assemblyOptions` the consumers is already passing to `@uppy/transloadit` (if they use that uploaders, not needed). - Remove `SearchInput` (this component was trying to be too many things, all with conditional boolean props, which is bad practise) in favor of `useSearchForm` and reuse this hook in two new components `SearchView` and `FilterInput` - Reuse all the styles from `SearchProviderView`. This deviates from the design in #5378. It felt too inconsistent to me to do another UI here again. For the initial version, I think it's best to stay consistent and then redesign with search providers taken into account too. - Because the service is so slow, I went a bit further with the loading state to show funny messages that rotate while loading mostly because users will start thinking it is broken after 5 seconds while it fact we are still loading. But open to ideas here. This unfortunately means the integration for the consumer is not as lean and pretty as you would hope. On the upside, it does give them complete freedom. ```ts .use(ImageGenerator, { assemblyOptions: async (prompt) => { const res = await fetch(`/assembly-options?prompt=${encodeURIComponent(prompt)}`) return res.json() } }) ``` on the consumer's server: ```ts import crypto from 'node:crypto' const utcDateString = (ms) => { return new Date(ms) .toISOString() .replace(/-/g, '/') .replace(/T/, ' ') .replace(/\.\d+Z$/, '+00:00') } // expire 1 hour from now (this must be milliseconds) const expires = utcDateString(Date.now() + 1 * 60 * 60 * 1000) const authKey = 'YOUR_TRANSLOADIT_KEY' const authSecret = 'YOUR_TRANSLOADIT_SECRET' const params = JSON.stringify({ auth: { key: authKey, expires, }, // can not contain any more steps, the only step must be /image/generate steps: { generated_image: { // can be named different robot: '/image/generate', result: true, // mandatory aspect_ratio: '2:3', // up to them model: 'flux-1.1-pro-ultra', // up to them prompt, // mandatory num_outputs: 2, // up to them }, }, }) const signatureBytes = crypto.createHmac('sha384', authSecret).update(Buffer.from(params, 'utf-8')) // The final signature needs the hash name in front, so // the hashing algorithm can be updated in a backwards-compatible // way when old algorithms become insecure. const signature = `sha384:${signatureBytes.digest('hex')}` // respond with { params, signature } JSON to the client ``` https://github.com/user-attachments/assets/9217e457-b38b-48ac-81f0-37a417309e98 --- > [!NOTE] > Adds AI image generation plugin using Transloadit, exports low-level Transloadit APIs, and replaces SearchInput with new FilterInput/SearchView + useSearchForm across provider views. > > - **New plugin: `@uppy/image-generator`** > - UI plugin to generate images from a prompt via Transloadit (`src/index.tsx`, styles, locale, build configs). > - Integrated into dev Dashboard and included in `uppy` bundle and global styles. > - **Provider Views refactor** > - Remove `SearchInput`; introduce `useSearchForm`, `SearchView`, and `FilterInput` components. > - Update `ProviderView`, `SearchProviderView`, and `Webdav` to use new components; export them from `@uppy/provider-views`. > - **Transloadit updates** > - Export `Assembly`, `AssemblyError`, and `Client` from `@uppy/transloadit`. > - Minor internal change: normalize `assemblyOptions.fields`. > - **Locales** > - Add strings for image generation and minor additions (e.g., `chooseFiles`). > - Ensure locales build depends on `@uppy/image-generator`. > - **Build config** > - Turborepo: add `uppy#build:css` and hook `image-generator` into locales build. > - **Changesets** > - `@uppy/image-generator` major; `@uppy/transloadit` minor; `@uppy/locales` and `uppy` minor; `@uppy/provider-views` and `@uppy/webdav` patch. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4b1b7290690fdc8265ffd89b2da24be5d98cfd18. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Prakash --- .changeset/afraid-seas-scream.md | 6 + .changeset/swift-flies-dance.md | 5 + .changeset/twelve-results-smoke.md | 7 + packages/@uppy/image-generator/LICENSE | 21 + packages/@uppy/image-generator/README.md | 49 +++ packages/@uppy/image-generator/package.json | 60 +++ packages/@uppy/image-generator/src/index.tsx | 381 ++++++++++++++++++ packages/@uppy/image-generator/src/locale.ts | 12 + packages/@uppy/image-generator/src/style.scss | 29 ++ .../@uppy/image-generator/tsconfig.build.json | 17 + packages/@uppy/image-generator/tsconfig.json | 22 + packages/@uppy/image-generator/turbo.json | 8 + packages/@uppy/locales/src/en_US.ts | 9 + packages/@uppy/locales/turbo.json | 1 + .../@uppy/provider-views/src/FilterInput.tsx | 68 ++++ .../src/ProviderView/ProviderView.tsx | 14 +- .../@uppy/provider-views/src/SearchInput.tsx | 122 ------ .../SearchProviderView/SearchProviderView.tsx | 32 +- .../@uppy/provider-views/src/SearchView.tsx | 53 +++ packages/@uppy/provider-views/src/index.ts | 4 +- .../@uppy/provider-views/src/useSearchForm.ts | 42 ++ packages/@uppy/transloadit/src/index.ts | 8 +- packages/@uppy/webdav/src/Webdav.tsx | 19 +- packages/uppy/package.json | 1 + packages/uppy/src/style.scss | 1 + private/dev/Dashboard.js | 20 + turbo.json | 6 + yarn.lock | 83 ++-- 28 files changed, 895 insertions(+), 205 deletions(-) create mode 100644 .changeset/afraid-seas-scream.md create mode 100644 .changeset/swift-flies-dance.md create mode 100644 .changeset/twelve-results-smoke.md create mode 100644 packages/@uppy/image-generator/LICENSE create mode 100644 packages/@uppy/image-generator/README.md create mode 100644 packages/@uppy/image-generator/package.json create mode 100644 packages/@uppy/image-generator/src/index.tsx create mode 100644 packages/@uppy/image-generator/src/locale.ts create mode 100644 packages/@uppy/image-generator/src/style.scss create mode 100644 packages/@uppy/image-generator/tsconfig.build.json create mode 100644 packages/@uppy/image-generator/tsconfig.json create mode 100644 packages/@uppy/image-generator/turbo.json create mode 100644 packages/@uppy/provider-views/src/FilterInput.tsx delete mode 100644 packages/@uppy/provider-views/src/SearchInput.tsx create mode 100644 packages/@uppy/provider-views/src/SearchView.tsx create mode 100644 packages/@uppy/provider-views/src/useSearchForm.ts 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 + +Uppy logo: a smiling puppy above a pink upwards arrow + +[![npm version](https://img.shields.io/npm/v/@uppy/image-generator.svg?style=flat-square)](https://www.npmjs.com/package/@uppy/image-generator) +![CI status for Uppy tests](https://github.com/transloadit/uppy/workflows/Tests/badge.svg) +![CI status for Companion tests](https://github.com/transloadit/uppy/workflows/Companion/badge.svg) +![CI status for browser tests](https://github.com/transloadit/uppy/workflows/End-to-end%20tests/badge.svg) + +**[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 && ( +
+
+ + +
+
+ )} +
+ ) + } +} 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 && ( + + )} +
+ ) +} + +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 && ( - // ❌ - - )} - {showButton && ( - - )} -
- ) -} - -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 + /> + +
+ ) +} + +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:*"