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



<!-- CURSOR_SUMMARY -->
---

> [!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.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
4b1b729069. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Prakash <qxprakash@gmail.com>
This commit is contained in:
Merlijn Vos 2025-12-03 11:59:52 +01:00 committed by GitHub
parent 93ef1ba0e7
commit 5684efa64e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 895 additions and 205 deletions

View file

@ -0,0 +1,6 @@
---
"@uppy/provider-views": patch
"@uppy/webdav": patch
---
Refactor internal components

View file

@ -0,0 +1,5 @@
---
"@uppy/transloadit": minor
---
Export Assembly, AssemblyError, Client

View file

@ -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

View file

@ -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.

View file

@ -0,0 +1,49 @@
# @uppy/image-generator
<img src="https://uppy.io/img/logo.svg" width="120" alt="Uppy logo: a smiling puppy above a pink upwards arrow" align="right">
[![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
Transloadits 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).

View file

@ -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"
}
}

View file

@ -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<OptionsWithRestructuredFields>
}
interface PluginState extends Record<string, unknown> {
prompt: string
results: AssemblyResult[]
checkedResultIds: Set<AssemblyResult['id']>
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<ImageGeneratorOptions, M, B, PluginState> {
private loadingInterval: ReturnType<typeof setInterval> | null = null
private rateLimitedQueue: RateLimitedQueue
private client: Client<M, B>
private assembly: Assembly | null = null
icon: () => h.JSX.Element
constructor(uppy: Uppy<M, B>, 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 = () => (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
width="24"
height="24"
viewBox="0 0 24 24"
>
<title>ai-image-generator</title>
<defs>
<circle id="uppyImageGeneratorCircle" cx="12" cy="12" r="12" />
</defs>
<g fill="none" fillRule="evenodd">
<mask id="uppyImageGeneratorMask" fill="#fff">
<use xlinkHref="#uppyImageGeneratorCircle" />
</mask>
<use xlinkHref="#uppyImageGeneratorCircle" fill="#004b9d" />
<path
fill="#fff"
d="m21.98 15.453 2.793.254a.295.295 0 0 1 0 .586l-2.794.254a6 6 0 0 0-5.432 5.432l-.254 2.794a.295.295 0 0 1-.586 0l-.254-2.794a6 6 0 0 0-5.432-5.432l-2.794-.254a.295.295 0 0 1 0-.586l2.794-.254a6 6 0 0 0 5.432-5.432l.254-2.794a.295.295 0 0 1 .586 0l.254 2.794a6 6 0 0 0 5.432 5.432"
mask="url(#uppyImageGeneratorMask)"
/>
<path
fill="#fff"
d="m10.74 7.75 1.434.13a.121.121 0 0 1 0 .24l-1.433.13a2.75 2.75 0 0 0-2.49 2.49l-.13 1.434a.121.121 0 0 1-.242 0l-.13-1.433a2.75 2.75 0 0 0-2.49-2.49l-1.433-.13a.121.121 0 0 1 0-.242l1.433-.13a2.75 2.75 0 0 0 2.49-2.49l.13-1.433a.121.121 0 0 1 .242 0l.13 1.433a2.75 2.75 0 0 0 2.49 2.49"
mask="url(#uppyImageGeneratorMask)"
/>
</g>
</svg>
)
}
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<void>()
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<MinimalRequiredUppyFile<M, B>>[] = 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 (
<SearchView
value={prompt}
onChange={(prompt) => this.setPluginState({ prompt })}
onSubmit={this.generate}
inputLabel={i18n('generateImagePlaceholder')}
loading={loading}
>
{loading ? (
<span className="uppy-ImageGenerator-generating">
{currentLoadingMessage}
</span>
) : (
i18n('generateImage')
)}
</SearchView>
)
}
return (
<div className="uppy-ProviderBrowser uppy-ProviderBrowser-viewType--grid">
<FilterInput
value={prompt}
onChange={(prompt) => this.setPluginState({ prompt })}
onSubmit={this.generate}
inputLabel={i18n('search')}
i18n={i18n}
/>
{loading ? (
<div className="uppy-Provider-loading uppy-ImageGenerator-generating--darker">
{currentLoadingMessage}
</div>
) : results.length > 0 ? (
<div className="uppy-ProviderBrowser-body">
<ul className="uppy-ProviderBrowser-list" tabIndex={-1}>
{results.map((result) => (
<li
key={result.id}
className={`uppy-ProviderBrowserItem ${checkedResultIds.has(result.id) ? 'uppy-ProviderBrowserItem--is-checked' : ''}`}
>
<input
type="checkbox"
className="uppy-u-reset uppy-ProviderBrowserItem-checkbox uppy-ProviderBrowserItem-checkbox--grid"
onChange={(e) => this.onCheckboxChange(result, e)}
name="listitem"
id={result.id}
checked={checkedResultIds.has(result.id)}
data-uppy-super-focusable
/>
<label
htmlFor={result.id}
aria-label={prompt}
className="uppy-u-reset uppy-ProviderBrowserItem-inner"
>
<img
src={result.url!}
alt={prompt}
referrerPolicy="no-referrer"
loading="lazy"
/>
</label>
</li>
))}
</ul>
</div>
) : (
<div className="uppy-Provider-empty">{i18n('noSearchResults')}</div>
)}
{checkedResultIds.size > 0 && (
<div className="uppy-ProviderBrowser-footer">
<div className="uppy-ProviderBrowser-footer-buttons">
<button
className="uppy-u-reset uppy-c-btn uppy-c-btn-primary"
onClick={this.donePicking}
type="button"
>
{i18n('selectX', {
smart_count: checkedResultIds.size,
})}
</button>
<button
className="uppy-u-reset uppy-c-btn uppy-c-btn-link"
onClick={this.cancelSelection}
type="button"
>
{i18n('cancel')}
</button>
</div>
</div>
)}
</div>
)
}
}

View file

@ -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...',
},
}

View file

@ -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;
}
}

View file

@ -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"
}
]
}

View file

@ -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"
}
]
}

View file

@ -0,0 +1,8 @@
{
"extends": ["//"],
"tasks": {
"build": {
"dependsOn": ["^build", "@uppy/core#build", "@uppy/transloadit#build"]
}
}
}

View file

@ -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:',

View file

@ -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",

View file

@ -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 (
<section className="uppy-ProviderBrowser-searchFilter">
<input
className="uppy-u-reset uppy-ProviderBrowser-searchFilterInput"
type="search"
aria-label={inputLabel}
placeholder={inputLabel}
value={value}
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
form={formId}
data-uppy-super-focusable
/>
<svg
aria-hidden="true"
focusable="false"
className="uppy-c-icon uppy-ProviderBrowser-searchFilterIcon"
width="12"
height="12"
viewBox="0 0 12 12"
>
<path d="M8.638 7.99l3.172 3.172a.492.492 0 1 1-.697.697L7.91 8.656a4.977 4.977 0 0 1-2.983.983C2.206 9.639 0 7.481 0 4.819 0 2.158 2.206 0 4.927 0c2.721 0 4.927 2.158 4.927 4.82a4.74 4.74 0 0 1-1.216 3.17zm-3.71.685c2.176 0 3.94-1.726 3.94-3.856 0-2.129-1.764-3.855-3.94-3.855C2.75.964.984 2.69.984 4.819c0 2.13 1.765 3.856 3.942 3.856z" />
</svg>
{value && (
<button
className="uppy-u-reset uppy-ProviderBrowser-searchFilterReset"
type="button"
aria-label={i18n('resetFilter')}
onClick={() => onChange('')}
>
<svg
aria-hidden="true"
focusable="false"
className="uppy-c-icon"
viewBox="0 0 19 19"
>
<path d="M17.318 17.232L9.94 9.854 9.586 9.5l-.354.354-7.378 7.378h.707l-.62-.62v.706L9.318 9.94l.354-.354-.354-.354L1.94 1.854v.707l.62-.62h-.706l7.378 7.378.354.354.354-.354 7.378-7.378h-.707l.622.62v-.706L9.854 9.232l-.354.354.354.354 7.378 7.378.708-.707-7.38-7.378v.708l7.38-7.38.353-.353-.353-.353-.622-.622-.353-.353-.354.352-7.378 7.38h.708L2.56 1.23 2.208.88l-.353.353-.622.62-.353.355.352.353 7.38 7.38v-.708l-7.38 7.38-.353.353.352.353.622.622.353.353.354-.353 7.38-7.38h-.708l7.38 7.38z" />
</svg>
</button>
)}
</section>
)
}
export default FilterInput

View file

@ -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<M extends Meta, B extends Body> {
i18n={i18n}
/>
{opts.showFilter && (
<SearchInput
searchString={searchString}
setSearchString={(s: string) => this.onSearchInput(s)}
submitSearchString={() => {}}
<FilterInput
value={searchString}
onChange={(s: string) => this.onSearchInput(s)}
onSubmit={() => {}}
inputLabel={i18n('filter')}
clearSearchLabel={i18n('resetFilter')}
wrapperClassName="uppy-ProviderBrowser-searchFilter"
inputClassName="uppy-ProviderBrowser-searchFilterInput"
i18n={i18n}
/>
)}

View file

@ -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 <form>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 (
<section className={wrapperClassName}>
<input
className={`uppy-u-reset ${inputClassName}`}
type="search"
aria-label={inputLabel}
placeholder={inputLabel}
value={searchString}
onInput={onInput}
form={form.id}
data-uppy-super-focusable
/>
{!showButton && (
// 🔍
<svg
aria-hidden="true"
focusable="false"
className="uppy-c-icon uppy-ProviderBrowser-searchFilterIcon"
width="12"
height="12"
viewBox="0 0 12 12"
>
<path d="M8.638 7.99l3.172 3.172a.492.492 0 1 1-.697.697L7.91 8.656a4.977 4.977 0 0 1-2.983.983C2.206 9.639 0 7.481 0 4.819 0 2.158 2.206 0 4.927 0c2.721 0 4.927 2.158 4.927 4.82a4.74 4.74 0 0 1-1.216 3.17zm-3.71.685c2.176 0 3.94-1.726 3.94-3.856 0-2.129-1.764-3.855-3.94-3.855C2.75.964.984 2.69.984 4.819c0 2.13 1.765 3.856 3.942 3.856z" />
</svg>
)}
{!showButton && searchString && (
// ❌
<button
className="uppy-u-reset uppy-ProviderBrowser-searchFilterReset"
type="button"
aria-label={clearSearchLabel}
title={clearSearchLabel}
onClick={() => setSearchString('')}
>
<svg
aria-hidden="true"
focusable="false"
className="uppy-c-icon"
viewBox="0 0 19 19"
>
<path d="M17.318 17.232L9.94 9.854 9.586 9.5l-.354.354-7.378 7.378h.707l-.62-.62v.706L9.318 9.94l.354-.354-.354-.354L1.94 1.854v.707l.62-.62h-.706l7.378 7.378.354.354.354-.354 7.378-7.378h-.707l.622.62v-.706L9.854 9.232l-.354.354.354.354 7.378 7.378.708-.707-7.38-7.378v.708l7.38-7.38.353-.353-.353-.353-.622-.622-.353-.353-.354.352-7.378 7.38h.708L2.56 1.23 2.208.88l-.353.353-.622.62-.353.355.352.353 7.38 7.38v-.708l-7.38 7.38-.353.353.352.353.622.622.353.353.354-.353 7.38-7.38h-.708l7.38 7.38z" />
</svg>
</button>
)}
{showButton && (
<button
className={`uppy-u-reset uppy-c-btn uppy-c-btn-primary ${buttonCSSClassName}`}
type="submit"
form={form.id}
>
{buttonLabel}
</button>
)}
</section>
)
}
export default SearchInput

View file

@ -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<M extends Meta, B extends Body> {
if (isInputMode) {
return (
<SearchInput
searchString={searchString}
setSearchString={this.setSearchString}
submitSearchString={this.search}
<SearchView
value={searchString}
onChange={this.setSearchString}
onSubmit={this.search}
inputLabel={i18n('enterTextToSearch')}
buttonLabel={i18n('searchImages')}
wrapperClassName="uppy-SearchProvider"
inputClassName="uppy-c-textInput uppy-SearchProvider-input"
showButton
buttonCSSClassName="uppy-SearchProvider-searchButton"
/>
>
{i18n('searchImages')}
</SearchView>
)
}
@ -300,14 +298,12 @@ export default class SearchProviderView<M extends Meta, B extends Body> {
)}
>
{opts.showFilter && (
<SearchInput
searchString={searchString}
setSearchString={this.setSearchString}
submitSearchString={this.search}
<FilterInput
value={searchString}
onChange={this.setSearchString}
onSubmit={this.search}
inputLabel={i18n('search')}
clearSearchLabel={i18n('resetSearch')}
wrapperClassName="uppy-ProviderBrowser-searchFilter"
inputClassName="uppy-ProviderBrowser-searchFilterInput"
i18n={i18n}
/>
)}

View file

@ -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 (
<section className="uppy-SearchProvider">
<input
className="uppy-u-reset uppy-c-textInput uppy-SearchProvider-input"
type="search"
aria-label={inputLabel}
placeholder={inputLabel}
value={value}
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
form={formId}
disabled={loading}
data-uppy-super-focusable
/>
<button
disabled={loading}
className="uppy-u-reset uppy-c-btn uppy-c-btn-primary uppy-SearchProvider-searchButton"
type="submit"
form={formId}
>
{children}
</button>
</section>
)
}
export default SearchView

View file

@ -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'

View file

@ -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 <form>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 }
}

View file

@ -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'

View file

@ -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 (
<SearchInput
searchString={webdavUrl}
setSearchString={setWebdavUrl}
submitSearchString={onSubmit}
<SearchView
value={webdavUrl}
onChange={setWebdavUrl}
onSubmit={onSubmit}
inputLabel={i18n('pluginWebdavInputLabel')}
buttonLabel={i18n('authenticate')}
wrapperClassName="uppy-SearchProvider"
inputClassName="uppy-c-textInput uppy-SearchProvider-input"
showButton
buttonCSSClassName="uppy-SearchProvider-searchButton"
/>
>
{i18n('authenticate')}
</SearchView>
)
}

View file

@ -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:*",

View file

@ -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;

View file

@ -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,
})

View file

@ -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"],

View file

@ -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:*"