Headless components (#5727)

Co-authored-by: Mikael Finstad <finstaden@gmail.com>
This commit is contained in:
Merlijn Vos 2025-05-22 09:59:43 +02:00 committed by GitHub
parent d1a3345263
commit 0259b09d73
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
108 changed files with 4006 additions and 1004 deletions

View file

@ -0,0 +1,114 @@
---
description:
globs: packages/@uppy/components/src/**
alwaysApply: false
---
# Headless components
You are an expert at making headless UI components in Preact, similar to libraries like shadcn, except we don't rely on packages like radix.
## Goal
Making headless components in Preact for the open source Uppy file uploader and framework specific hooks. We want to give flexbility to users of this library by rendering sensible UI defaults and hooks that abstract Uppy functionality to completely build your own UI.
Another way to give flexibility is to add data attributes selectively to some HTML elements. These can be used with CSS selectors by users of this library to conditionally apply styles:
```html
<button
type="button"
data-uppy-element="upload-button"
data-state={ctx.status}
></button>
```
## How to build these components
It's important to understand that an automated build script (bin/build-components.mjs) generates framework-specific wrappers for these Preact components.
Here is an example from the React wrapper created by the script.
```tsx
import { useEffect, useRef, useContext, createElement as h } from 'react'
import {
Dropzone as PreactDropzone,
type DropzoneProps,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContext } from './UppyContextProvider.js'
export default function Dropzone(props: Omit<DropzoneProps, 'ctx' | 'render'>) {
const ref = useRef(null)
const ctx = useContext(UppyContext)
useEffect(() => {
if (ref.current) {
preactRender(
preactH(PreactDropzone, {
...props,
ctx,
} satisfies DropzoneProps),
ref.current,
)
}
}, [ctx, props])
return <div ref={ref} />
}
```
You don't have to worry about how these wrappers are created but it's important to know when building the Preact components that you will always receive a `ctx` prop to work with. This is its type:
```ts
import type Uppy from '@uppy/core'
export type UploadStatus =
| 'init'
| 'ready'
| 'uploading'
| 'paused'
| 'error'
| 'complete'
export type UppyContext = {
uppy: Uppy | undefined
status: UploadStatus
progress: number
}
```
## Styling
Styling is done with Tailwind CSS 4.x. There is no Tailwind config file.
**IMPORTANT**: all classes have the `uppy:` prefix. Example: `bg-red-500` should become `uppy:bg-red-500`.
Use `clsx` for conditional styles.
```js
import clsx from 'clsx';
// or
import { clsx } from 'clsx';
// Strings (variadic)
clsx('foo', true && 'bar', 'baz');
//=> 'foo bar baz'
// Objects
clsx({ foo:true, bar:false, baz:isTrue() });
//=> 'foo baz'
// Objects (variadic)
clsx({ foo:true }, { bar:false }, null, { '--foobar':'hello' });
//=> 'foo --foobar'
// Arrays
clsx(['foo', 0, false, 'bar']);
//=> 'foo bar'
// Arrays (variadic)
clsx(['foo'], ['', 0, false, 'bar'], [['baz', [['hello'], 'there']]]);
//=> 'foo bar baz hello there'
// Kitchen sink (with nesting)
clsx('foo', [1 && 'bar', { baz:false, bat:null }, ['hello', ['world']]], 'cya');
//=> 'foo bar hello world cya'
```

78
.cursor/rules/svelte.mdc Normal file
View file

@ -0,0 +1,78 @@
---
description:
globs: packages/@uppy/svelte/**,examples/sveltekit/**
alwaysApply: false
---
I'm using svelte 5 instead of svelte 4 here is an overview of the changes.
## Overview of Changes
Svelte 5 introduces runes, a set of advanced primitives for controlling reactivity. The runes replace certain non-runes features and provide more explicit control over state and effects.
Snippets, along with render tags, help create reusable chunks of markup inside your components, reducing duplication and enhancing maintainability.
## Event Handlers in Svelte 5
In Svelte 5, event handlers are treated as standard HTML properties rather than Svelte-specific directives, simplifying their use and integrating them more closely with the rest of the properties in the component.
### Svelte 4 vs. Svelte 5:
**Before (Svelte 4):**
```html
<script>
let count = 0;
$: double = count * 2;
$: {
if (count > 10) alert('Too high!');
}
</script>
<button on:click={() => count++}> {count} / {double}</button>
```
**After (Svelte 5):**
```html
<script>
import { $state, $effect, $derived } from 'svelte';
// Define state with runes
let count = $state(0);
// Option 1: Using $derived for computed values
let double = $derived(count * 2);
// Reactive effects using runes
$effect(() => {
if (count > 10) alert('Too high!');
});
</script>
<!-- Standard HTML event attributes instead of Svelte directives -->
<button onclick={() => count++}>
{count} / {double}
</button>
<!-- Alternatively, you can compute values inline -->
<!-- <button onclick={() => count++}>
{count} / {count * 2}
</button> -->
```
## Key Differences:
1. **Reactivity is Explicit**:
- Svelte 5 uses `$state()` to explicitly mark reactive variables
- `$derived()` replaces `$:` for computed values
- `$effect()` replaces `$: {}` blocks for side effects
2. **Event Handling is Standardized**:
- Svelte 4: `on:click={handler}`
- Svelte 5: `onclick={handler}`
3. **Import Runes**:
- All runes must be imported from 'svelte': `import { $state, $effect, $derived, $props, $slots } from 'svelte';`
4. **No More Event Modifiers**:
- Svelte 4: `on:click|preventDefault={handler}`
- Svelte 5: `onclick={e => { e.preventDefault(); handler(e); }}`
This creates clearer, more maintainable components compared to Svelte 4's previous syntax by making reactivity explicit and using standardized web platform features.

149
.cursor/rules/uppy-core.mdc Normal file
View file

@ -0,0 +1,149 @@
---
description: Read this when you are about to use the Uppy class from @uppy/core
globs:
alwaysApply: false
---
# Uppy Core Summary
If you need to reference or work with the `Uppy` class from `@uppy/core`, here is an high-level overview of the class.
## `Uppy<M extends Meta, B extends Body>` Class
A modular file uploader. Manages plugins, state, events, and file handling.
**Generics:**
* `M`: Metadata object shape associated with files (`Meta`).
* `B`: Body object shape associated with files (`Body`).
**Key Public Properties:**
* `opts: NonNullableUppyOptions<M, B>`: The fully resolved Uppy options.
* `store: Store<State<M, B>>`: The state management store (defaults to `DefaultStore`).
* `i18n: I18n`: The translation function.
* `i18nArray: Translator['translateArray']`: The array translation function.
* `locale: Locale`: The active locale object.
**Constructor:**
* `constructor(opts?: UppyOptionsWithOptionalRestrictions<M, B>)`
**Core Public Methods:**
* `use<T extends typeof BasePlugin<any, M, B>>(Plugin: T, ...args: OmitFirstArg<ConstructorParameters<T>>): this`: Adds a plugin instance.
* `getPlugin<T extends UnknownPlugin<M, B> = UnknownPlugin<M, B>>(id: string): T | undefined`: Retrieves a plugin instance by ID.
* `iteratePlugins(method: (plugin: UnknownPlugin<M, B>) => void): void`: Executes a function on all installed plugins.
* `removePlugin(instance: UnknownPlugin<M, B>): void`: Removes a plugin instance.
* `addFile(file: File | MinimalRequiredUppyFile<M, B>): UppyFile<M, B>['id']`: Adds a single file.
* `addFiles(fileDescriptors: MinimalRequiredUppyFile<M, B>[]): void`: Adds multiple files.
* `removeFile(fileID: string): void`: Removes a file by ID.
* `removeFiles(fileIDs: string[]): void`: Removes multiple files by ID.
* `getFile(fileID: string): UppyFile<M, B>`: Retrieves a file object by ID.
* `getFiles(): UppyFile<M, B>[]`: Retrieves all file objects as an array.
* `setOptions(newOpts: MinimalRequiredOptions<M, B>): void`: Updates Uppy options.
* `setState(patch?: Partial<State<M, B>>): void`: Updates the Uppy state.
* `getState(): State<M, B>`: Retrieves the current Uppy state.
* `setFileState(fileID: string, state: Partial<UppyFile<M, B>>): void`: Updates the state for a specific file.
* `setMeta(data: Partial<M>): void`: Merges metadata into the global `state.meta` and all file `meta` objects.
* `setFileMeta(fileID: string, data: Partial<M>): void`: Merges metadata into a specific file's `meta` object.
* `upload(): Promise<NonNullable<UploadResult<M, B>> | undefined>`: Starts the upload process for all new files.
* `retryUpload(fileID: string): Promise<UploadResult<M, B> | undefined>`: Retries a failed upload for a specific file.
* `retryAll(): Promise<UploadResult<M, B> | undefined>`: Retries all failed uploads.
* `cancelAll(): void`: Cancels all uploads and removes all files.
* `pauseResume(fileID: string): boolean | undefined`: Toggles pause/resume state for a resumable upload.
* `pauseAll(): void`: Pauses all resumable uploads.
* `resumeAll(): void`: Resumes all paused uploads.
* `info(message: string | { message: string; details?: string | Record<string, string> }, type?: LogLevel, duration?: number): void`: Displays an informational message via UI plugins.
* `hideInfo(): void`: Hides the oldest info message.
* `log(message: unknown, type?: 'error' | 'warning'): void`: Logs a message using the configured logger.
* `on<K extends keyof UppyEventMap<M, B>>(event: K, callback: UppyEventMap<M, B>[K]): this`: Registers an event listener.
* `off<K extends keyof UppyEventMap<M, B>>(event: K, callback: UppyEventMap<M, B>[K]): this`: Unregisters an event listener.
* `emit<T extends keyof UppyEventMap<M, B>>(event: T, ...args: Parameters<UppyEventMap<M, B>[T]>): void`: Emits an event.
* `destroy(): void`: Uninstalls all plugins and cleans up the Uppy instance.
**Internal Lifecycle Methods (Called by Uppy):**
* `addPreProcessor(fn: Processor): void`
* `removePreProcessor(fn: Processor): boolean`
* `addPostProcessor(fn: Processor): void`
* `removePostProcessor(fn: Processor): boolean`
* `addUploader(fn: Processor): void`
* `removeUploader(fn: Processor): boolean`
---
## Key Associated Types
* **`UppyFile<M extends Meta, B extends Body>`**: Represents a file within Uppy.
* `id: string`: Unique file ID.
* `source?: string`: Source plugin ID.
* `name: string`: File name.
* `type?: string`: MIME type.
* `data: Blob | File`: The actual file data.
* `meta: M & { name: string, type: string }`: User and Uppy metadata.
* `size: number | null`: File size in bytes.
* `isRemote: boolean`: If the file is from a remote source (Companion).
* `remote?: { requestClientId: string, ... }`: Remote source details.
* `progress: FileProgressStarted | FileProgressNotStarted`: Upload progress state.
* `error?: string`: Error message if upload failed.
* `isPaused?: boolean`: If upload is paused (for resumable uploads).
* `isGhost?: boolean`: If file is restored without data (Golden Retriever).
* `response?: { status: number, body: B, uploadURL?: string, ... }`: Upload response details.
* `preview?: string`: URL to a preview image.
* `[key: string]: any`: Extensible with plugin-specific state.
* **`State<M extends Meta, B extends Body>`**: The main Uppy state object.
* `files: { [fileID: string]: UppyFile<M, B> }`: Object map of files by ID.
* `currentUploads: { [uploadID: string]: CurrentUpload<M, B> }`: Map of active uploads.
* `capabilities: { uploadProgress: boolean, individualCancellation: boolean, resumableUploads: boolean, ... }`: Detected/configured capabilities.
* `totalProgress: number`: Overall upload progress (0-100).
* `meta: M`: Global metadata.
* `info: Array<{ type: LogLevel, message: string, details?: string | Record<string, string> }>`: Info messages for UI display.
* `error: string | null`: Global error message.
* `allowNewUpload: boolean`: Whether new uploads can be started.
* `plugins: { [pluginID: string]: Record<string, unknown> }`: State managed by plugins.
* `recoveredState: null | { files: ..., currentUploads: ... }`: State recovered by Golden Retriever.
* **`UppyOptions<M extends Meta, B extends Body>`**: Options for the Uppy constructor.
* `id?: string`: Instance ID (default: 'uppy').
* `autoProceed?: boolean`: Start upload automatically after files are added (default: false).
* `allowMultipleUploadBatches?: boolean`: Allow adding files while an upload is in progress (default: true).
* `debug?: boolean`: Enable debug logging (default: false).
* `restrictions: Restrictions`: File restrictions (type, size, number).
* `meta?: M`: Initial global metadata.
* `onBeforeFileAdded?: (currentFile: UppyFile<M, B>, files: { ... }) => UppyFile<M, B> | boolean | undefined`: Hook before a file is added.
* `onBeforeUpload?: (files: { ... }) => { ... } | boolean`: Hook before an upload starts.
* `locale?: Locale`: Custom locale strings.
* `store?: Store<State<M, B>>`: Custom state store.
* `logger?: { debug, warn, error }`: Custom logger.
* `infoTimeout?: number`: Duration for info messages (ms).
* **`Restrictions`**: File restriction options.
* `maxFileSize?: number | null`: Max individual file size (bytes).
* `minFileSize?: number | null`: Min individual file size (bytes).
* `maxTotalFileSize?: number | null`: Max total size of all files (bytes).
* `maxNumberOfFiles?: number | null`: Max number of files allowed.
* `minNumberOfFiles?: number | null`: Min number of files required.
* `allowedFileTypes?: string[] | null`: Allowed MIME types or extensions (e.g., `['image/*', '.pdf']`).
* `requiredMetaFields?: string[]`: Meta fields that must be present before upload.
* **`UppyEventMap<M extends Meta, B extends Body>`**: Map of event names to their callback signatures (includes events like `file-added`, `upload-progress`, `upload-success`, `complete`, `error`, `restriction-failed`, etc.).
* **`UploadResult<M extends Meta, B extends Body>`**: The object returned when an upload completes.
* `successful?: UppyFile<M, B>[]`: Array of successfully uploaded files.
* `failed?: UppyFile<M, B>[]`: Array of files that failed to upload.
* `uploadID?: string`: The ID of the upload batch.
* **`BasePlugin<...> `**: The base class for all Uppy plugins.
* `id: string`: Unique plugin ID.
* `type: string`: Plugin type ('acquirer', 'modifier', 'uploader', 'presenter', 'orchestrator', 'logger').
* `uppy: Uppy<M, B>`: Reference to the Uppy instance.
* `install(): void`: Called when the plugin is added.
* `uninstall(): void`: Called when the plugin is removed.
* `update(state: Partial<State<M, B>>): void`: Called on every Uppy state update.
* **`Processor`**: Type for pre/post/upload processor functions `(fileIDs: string[], uploadID: string) => Promise<unknown> | void`.
* **`LogLevel`**: `'info' | 'warning' | 'error' | 'success'`.
* **`PartialTree`**: Type used by Provider plugins (like Google Drive) to represent folder structures (`(PartialTreeFile | PartialTreeFolder)[]`).

View file

@ -288,6 +288,7 @@ module.exports = {
// TODO: update those to more modern code when switch to ESM is complete
'examples/react-native-expo/*.js',
'examples/svelte-example/**/*.js',
'examples/sveltekit/**/*.js',
'examples/vue/**/*.js',
'examples/vue3/**/*.js',
],

1
.gitignore vendored
View file

@ -16,6 +16,7 @@ yarn-error.log
.env
tsconfig.tsbuildinfo
tsconfig.build.tsbuildinfo
.svelte-kit
dist/
lib/

266
bin/build-components.mjs Normal file
View file

@ -0,0 +1,266 @@
#!/usr/bin/env node
import fs from 'node:fs/promises'
import { existsSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Get the directory of this script
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
const rootDir = path.resolve(scriptDir, '..')
// Define paths
const COMPONENTS_DIR = path.join(rootDir, 'packages/@uppy/components/src')
const REACT_DIR = path.join(rootDir, 'packages/@uppy/react/src/headless')
const VUE_DIR = path.join(rootDir, 'packages/@uppy/vue/src/headless')
const SVELTE_DIR = path.join(
rootDir,
'packages/@uppy/svelte/src/lib/components/headless',
)
// Templates
const REACT_TEMPLATE = `import { useEffect, useRef, useContext, createElement as h } from 'react'
import {
%%ComponentName%% as %%PreactComponentName%%,
type %%PropsTypeName%%,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContext } from './UppyContextProvider.js'
export default function %%ComponentName%%(props: Omit<%%PropsTypeName%%, 'ctx'>) {
const ref = useRef(null)
const ctx = useContext(UppyContext)
useEffect(() => {
if (ref.current) {
preactRender(
preactH(%%PreactComponentName%%, {
...props,
ctx,
} satisfies %%PropsTypeName%%),
ref.current,
)
}
}, [ctx, props])
return <div ref={ref} />
}
`
const VUE_TEMPLATE = `import { defineComponent, ref, watch, onMounted, h } from 'vue'
import {
%%ComponentName%% as %%PreactComponentName%%,
type %%PropsTypeName%%,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { shallowEqualObjects } from 'shallow-equal'
import { useUppyContext } from './useUppyContext.js'
export default defineComponent<Omit<%%PropsTypeName%%, 'ctx'>>({
name: '%%ComponentName%%',
setup(props, { attrs }) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
function render%%ComponentName%%() {
if (containerRef.value) {
preactRender(
preactH(%%PreactComponentName%%, {
...(attrs as %%PropsTypeName%%),
ctx,
} satisfies %%PropsTypeName%%),
containerRef.value,
)
}
}
onMounted(() => {
render%%ComponentName%%()
})
watch(ctx, () => {
render%%ComponentName%%()
})
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
render%%ComponentName%%()
}
},
)
return () => h('div', { ref: containerRef })
},
})
`
const SVELTE_TEMPLATE = `<script lang="ts">
import { getContext, mount } from 'svelte'
import {
%%ComponentName%% as %%PreactComponentName%%,
type %%PropsTypeName%%,
type UppyContext,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContextKey } from './UppyContextProvider.svelte'
const props: Omit<%%PropsTypeName%%, 'ctx'> = $props()
const ctx = getContext<UppyContext>(UppyContextKey)
let container: HTMLElement
$effect(() => {
if (container) {
preactRender(
preactH(%%PreactComponentName%%, {
...props,
ctx,
} satisfies %%PropsTypeName%%),
container,
)
}
})
</script>
<div bind:this={container}></div>
`
try {
// Check if components directory exists
await fs.access(COMPONENTS_DIR).catch(() => {
throw new Error(`Components directory not found: ${COMPONENTS_DIR}`)
})
await Promise.all(
[REACT_DIR, VUE_DIR, SVELTE_DIR].map(async (dir) => {
if (!existsSync(dir)) {
await fs.mkdir(dir, { recursive: true })
}
}),
)
// Read all files in components directory
const files = await fs.readdir(COMPONENTS_DIR)
// Filter for .tsx files
const tsxFiles = files.filter((file) => file.endsWith('.tsx'))
console.log(`Found ${tsxFiles.length} Preact component(s) to process\n`)
// Track generated components for index files
const reactComponents = []
const vueComponents = []
const svelteComponents = []
// Process each tsx file
for (const file of tsxFiles) {
try {
const componentName = path.basename(file, '.tsx')
const propsTypeName = `${componentName}Props`
const preactComponentName = `Preact${componentName}`
// Generate React wrapper
const reactContent = REACT_TEMPLATE.replace(
/%%ComponentName%%/g,
componentName,
)
.replace(/%%PreactComponentName%%/g, preactComponentName)
.replace(/%%PropsTypeName%%/g, propsTypeName)
// Generate Vue wrapper
const vueContent = VUE_TEMPLATE.replace(
/%%ComponentName%%/g,
componentName,
)
.replace(/%%PreactComponentName%%/g, preactComponentName)
.replace(/%%PropsTypeName%%/g, propsTypeName)
// Generate Svelte wrapper
const svelteContent = SVELTE_TEMPLATE.replace(
/%%ComponentName%%/g,
componentName,
)
.replace(/%%PreactComponentName%%/g, preactComponentName)
.replace(/%%PropsTypeName%%/g, propsTypeName)
// Write files
const reactFilePath = path.join(REACT_DIR, `${componentName}.tsx`)
const vueFilePath = path.join(VUE_DIR, `${componentName}.ts`)
const svelteFilePath = path.join(SVELTE_DIR, `${componentName}.svelte`)
await fs.writeFile(reactFilePath, reactContent)
await fs.writeFile(vueFilePath, vueContent)
await fs.writeFile(svelteFilePath, svelteContent)
// Add to component lists for index files
reactComponents.push(componentName)
vueComponents.push(componentName)
svelteComponents.push(componentName)
console.log(`✔︎ ${componentName}`)
} catch (error) {
console.error(`Error processing component ${file}:`, error)
}
}
// Generate index files
if (reactComponents.length > 0) {
const reactIndexContent = reactComponents
.map((name) => `export { default as ${name} } from './${name}.js'`)
.join('\n')
await fs.writeFile(path.join(REACT_DIR, 'index.ts'), reactIndexContent)
console.log(`\nExporting React components from ${REACT_DIR}`)
}
if (vueComponents.length > 0) {
const vueIndexContent = vueComponents
.map((name) => `export { default as ${name} } from './${name}.js'`)
.join('\n')
await fs.writeFile(path.join(VUE_DIR, 'index.ts'), vueIndexContent)
console.log(`Exporting Vue components from ${VUE_DIR}`)
}
if (svelteComponents.length > 0) {
const svelteIndexContent = svelteComponents
.map((name) => `export { default as ${name} } from './${name}.svelte'`)
.join('\n')
await fs.writeFile(path.join(SVELTE_DIR, 'index.ts'), svelteIndexContent)
console.log(`Exporting Svelte components from ${SVELTE_DIR}`)
}
// Copy CSS file
const CSS_SOURCE_PATH = path.join(
rootDir,
'packages/@uppy/components/dist/styles.css',
)
const FRAMEWORK_DIRS_FOR_CSS = [
path.join(rootDir, 'packages/@uppy/react/dist'),
path.join(rootDir, 'packages/@uppy/vue/dist'),
path.join(rootDir, 'packages/@uppy/svelte/dist'),
]
try {
await fs.access(CSS_SOURCE_PATH) // Check if source CSS exists
await Promise.all(
FRAMEWORK_DIRS_FOR_CSS.map(async (destDir) => {
if (!existsSync(destDir)) {
await fs.mkdir(destDir, { recursive: true })
}
const destPath = path.join(destDir, 'styles.css')
await fs.copyFile(CSS_SOURCE_PATH, destPath)
}),
)
} catch (cssError) {
console.error(
`${CSS_SOURCE_PATH} does not exist yet. Run \`yarn build\` first.`,
)
}
console.log('\nAll wrappers and index files generated successfully!')
} catch (error) {
console.error('Error generating wrappers:', error)
process.exit(1)
}

View file

@ -1,22 +1,53 @@
<template>
<dashboard :uppy="uppy" />
<UppyContextProvider :uppy="uppy">
<main>
<h1>Vue Headless Components Test</h1>
<article id="files-list">
<h2>With list</h2>
<Dropzone />
<FilesList />
<UploadButton />
</article>
<article id="files-grid">
<h2>With grid</h2>
<Dropzone />
<FilesGrid :columns="2" />
<UploadButton />
</article>
</main>
</UppyContextProvider>
</template>
<script>
import Vue from 'vue'
import Uppy from '@uppy/core'
import { Dashboard } from '@uppy/vue'
import Tus from '@uppy/tus'
import {
UppyContextProvider,
Dropzone,
FilesList,
FilesGrid,
UploadButton,
} from '@uppy/vue'
export default {
name: 'App',
components: {
Dashboard,
UppyContextProvider,
Dropzone,
FilesList,
FilesGrid,
UploadButton,
},
computed: {
uppy: () => new Uppy(),
uppy: () =>
new Uppy().use(Tus, {
endpoint: 'https://tusd.tusdemo.net/files/',
}),
},
}
</script>
<style src="@uppy/core/dist/style.css"></style>
<style src="@uppy/dashboard/dist/style.css"></style>
<style src="@uppy/vue/dist/styles.css"></style>

View file

@ -1,11 +1,10 @@
describe('dashboard-vue', () => {
describe('@uppy/vue', () => {
beforeEach(() => {
cy.visit('/dashboard-vue')
cy.get('input[type="file"]').first().as('file-input')
})
// Only Vue 3 works in Parcel if you use SFC's but Vue 3 is broken in Uppy:
// https://github.com/transloadit/uppy/issues/2877
xit('should render in Vue 3 and show thumbnails', () => {
it('should render headless components in Vue 3 correctly', () => {
cy.get('@file-input').selectFile(
[
'cypress/fixtures/images/cat.jpg',
@ -13,8 +12,38 @@ describe('dashboard-vue', () => {
],
{ force: true },
)
cy.get('.uppy-Dashboard-Item-previewImg')
.should('have.length', 2)
.each((element) => expect(element).attr('src').to.include('blob:'))
// Test FilesList shows files correctly
cy.get('ul[data-uppy-element="files-list"]').should('exist')
cy.get('ul[data-uppy-element="files-list"] li').should('have.length', 2)
// Test FilesGrid shows files correctly
cy.get('div[data-uppy-element="files-grid"]').should('exist')
cy.get('div[data-uppy-element="files-grid"] div.uppy-reset').should(
'have.length',
2,
)
// Test UploadButton is functional
cy.get('#files-grid button[data-uppy-element="upload-button"]')
.should('exist')
.and('contain', 'Upload')
.and('not.be.disabled')
.click()
// Check if button shows progress during upload
cy.get('#files-grid button[data-uppy-element="upload-button"] span').should(
'contain',
'Uploaded',
)
// Check if cancel button appears during upload
cy.get('#files-grid button[data-uppy-element="cancel-button"]')
.should('exist')
.and('contain', 'Cancel')
cy.get('#files-grid button[data-uppy-element="upload-button"] span').should(
'contain',
'Complete',
)
})
})

View file

@ -1,91 +0,0 @@
/* eslint-disable */
import React from 'react'
import { createRoot } from 'react-dom/client'
import Uppy, {
UIPlugin,
type Meta,
type Body,
type UIPluginOptions,
type State,
} from '@uppy/core'
import Tus from '@uppy/tus'
import Webcam from '@uppy/webcam'
import { Dashboard, useUppyState } from '@uppy/react'
import '@uppy/core/dist/style.css'
import '@uppy/dashboard/dist/style.css'
import '@uppy/webcam/dist/style.css'
interface MyPluginOptions extends UIPluginOptions {}
interface MyPluginState extends Record<string, unknown> {}
// Custom plugin example inside React
class MyPlugin<M extends Meta, B extends Body> extends UIPlugin<
MyPluginOptions,
M,
B,
MyPluginState
> {
container!: HTMLElement
constructor(uppy: Uppy<M, B>, opts?: MyPluginOptions) {
super(uppy, opts)
this.type = 'acquirer'
this.id = this.opts.id || 'TEST'
this.title = 'Test'
}
override install() {
const { target } = this.opts
if (target) {
this.mount(target, this)
}
}
override uninstall() {
this.unmount()
}
override render(state: State<M, B>, container: HTMLElement) {
// Important: during the initial render is not defined. Safely return.
if (!container) return
createRoot(container).render(
<h2>React component inside Uppy's Preact UI</h2>,
)
}
}
const metaFields = [
{ id: 'license', name: 'License', placeholder: 'specify license' },
]
function createUppy() {
return new Uppy({ restrictions: { requiredMetaFields: ['license'] } })
.use(Tus, { endpoint: 'https://tusd.tusdemo.net/files/' })
.use(Webcam)
.use(MyPlugin)
}
export default function App() {
// IMPORTANT: passing an initaliser function to useState
// to prevent creating a new Uppy instance on every render.
// useMemo is a performance hint, not a guarantee.
const [uppy] = React.useState(createUppy)
// You can access state reactively with useUppyState
const fileCount = useUppyState(
uppy,
(state) => Object.keys(state.files).length,
)
const totalProgress = useUppyState(uppy, (state) => state.totalProgress)
// Also possible to get the state of all plugins.
const plugins = useUppyState(uppy, (state) => state.plugins)
return (
<>
<p>File count: {fileCount}</p>
<p>Total progress: {totalProgress}</p>
<Dashboard uppy={uppy} metaFields={metaFields} />
</>
)
}

View file

@ -1,30 +0,0 @@
# React example
This is minimal example created to demonstrate how to integrate Uppy in your
React app.
To spawn the demo, use the following commands:
```sh
corepack yarn install
corepack yarn build
corepack yarn workspace @uppy-example/react dev
```
If you'd like to use a different package manager than Yarn (e.g. npm) to work
with this example, you can extract it from the workspace like this:
```sh
corepack yarn workspace @uppy-example/react pack
# The above command should have create a .tgz file, we're going to extract it to
# a new directory outside of the Uppy workspace.
mkdir ../react-example
tar -xzf examples/react-example/package.tgz -C ../react-example --strip-components 1
rm -f examples/react-example/package.tgz
# Now you can leave the Uppy workspace and use the example as a standalone JS project:
cd ../react-example
npm i
npm run dev
```

View file

@ -1,13 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Uppy React Example</title>
<link href="uppy.min.css" rel="stylesheet" />
</head>
<body>
<div id="app"></div>
<script src="./main.tsx" type="module"></script>
</body>
</html>

View file

@ -1,6 +0,0 @@
/* eslint-disable */
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
createRoot(document.querySelector('#app')!).render(<App />)

View file

@ -1,24 +0,0 @@
{
"name": "@uppy-example/react",
"version": "0.0.0",
"type": "module",
"dependencies": {
"@uppy/core": "workspace:*",
"@uppy/dashboard": "workspace:*",
"@uppy/react": "workspace:*",
"@uppy/remote-sources": "workspace:*",
"@uppy/tus": "workspace:*",
"react": "^18.0.0",
"react-dom": "^18.0.0"
},
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5050"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.0.0",
"vite": "^5.4.17"
}
}

View file

@ -1,21 +0,0 @@
{
"compilerOptions": {
"jsxImportSource": "react",
"jsx": "react-jsx",
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"module": "NodeNext",
"outDir": "dist",
"sourceMap": true,
"lib": ["es2022", "dom", "dom.iterable"],
},
}

View file

@ -1,7 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})

12
examples/react/index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View file

@ -0,0 +1,27 @@
{
"name": "@uppy-example/react",
"private": true,
"type": "module",
"scripts": {
"build": "tsc && vite build",
"dev": "vite",
"serve": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.0.9",
"@uppy/core": "^4.4.2",
"@uppy/react": "workspace:^",
"@uppy/tus": "^4.2.2",
"@uppy/webcam": "^4.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.0.9"
},
"devDependencies": {
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.2.0"
}
}

View file

@ -0,0 +1,47 @@
/* eslint-disable react/react-in-jsx-scope */
import { useState } from 'react'
import {
Dropzone,
FilesGrid,
FilesList,
UploadButton,
UppyContextProvider,
} from '@uppy/react'
import Uppy from '@uppy/core'
import Tus from '@uppy/tus'
import './app.css'
import '@uppy/react/dist/styles.css'
function App() {
const [uppy] = useState(() =>
new Uppy().use(Tus, {
endpoint: 'https://tusd.tusdemo.net/files/',
}),
)
return (
<UppyContextProvider uppy={uppy}>
<main className="p-5 max-w-xl mx-auto">
<h1 className="text-4xl font-bold">Welcome to React.</h1>
<article>
<h2 className="text-2xl my-4">With list</h2>
<Dropzone />
<FilesList />
<UploadButton />
</article>
<article>
<h2 className="text-2xl my-4">With grid</h2>
<Dropzone />
<FilesGrid columns={2} />
<UploadButton />
</article>
</main>
</UppyContextProvider>
)
}
export default App

View file

@ -0,0 +1,6 @@
@import 'tailwindcss';
/* example how to use the data attributes to apply custom styles */
/* button[data-uppy-element="upload-button"][data-state="uploading"] {
background-color: red;
} */

View file

@ -0,0 +1,10 @@
/* eslint-disable react/react-in-jsx-scope */
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.js'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

1
examples/react/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
},
"include": ["src", "src/**/*.tsx"],
"references": [{ "path": "./tsconfig.node.json" }],
}

View file

@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "esnext",
"moduleResolution": "node"
},
"include": ["vite.config.ts"]
}

View file

@ -0,0 +1,10 @@
/* eslint-disable import/no-extraneous-dependencies */
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// @ts-expect-error untyped for some reason but fine
import tailwindcss from '@tailwindcss/vite'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
})

View file

@ -1,12 +0,0 @@
/node_modules/
/uploads/
.DS_Store
/build/
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View file

@ -1,17 +0,0 @@
# Uppy with Svelte
## Run it
To run this example, make sure you've correctly installed the **repository
root**:
```sh
corepack yarn install
corepack yarn build
```
Then, again in the **repository root**, start this example by doing:
```sh
corepack yarn workspace @uppy-example/svelte-app dev
```

View file

@ -1,37 +0,0 @@
{
"name": "@uppy-example/svelte-app",
"version": "0.0.0",
"scripts": {
"dev": "npm-run-all --parallel dev:frontend dev:backend",
"dev:frontend": "vite dev",
"dev:backend": "node --watch ./server.js",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.1",
"@sveltejs/kit": "^2.8.3",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/formidable": "^3",
"npm-run-all": "^4.1.5",
"svelte": "^4.2.19",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
"typescript": "~5.4",
"vite": "^5.4.17"
},
"dependencies": {
"@uppy/core": "workspace:*",
"@uppy/dashboard": "workspace:*",
"@uppy/drag-drop": "workspace:*",
"@uppy/progress-bar": "workspace:*",
"@uppy/svelte": "workspace:*",
"@uppy/webcam": "workspace:*",
"@uppy/xhr-upload": "workspace:*",
"formidable": "^3.5.1"
},
"type": "module",
"private": true
}

View file

@ -1,64 +0,0 @@
#!/usr/bin/env node
/* eslint-disable no-console */
import http from 'node:http'
import { fileURLToPath } from 'node:url'
import { mkdir } from 'node:fs/promises'
import formidable from 'formidable'
const UPLOAD_DIR = new URL('./uploads/', import.meta.url)
await mkdir(UPLOAD_DIR, { recursive: true })
http
.createServer((req, res) => {
const headers = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'OPTIONS, POST, GET',
'Access-Control-Max-Age': 2592000, // 30 days
/** add other headers as per requirement */
}
if (req.method === 'OPTIONS') {
res.writeHead(204, headers)
res.end()
return
}
if (req.url === '/upload' && req.method.toLowerCase() === 'post') {
// parse a file upload
const form = formidable({
keepExtensions: true,
uploadDir: fileURLToPath(UPLOAD_DIR),
})
form.parse(req, (err, fields, files) => {
res.writeHead(200, headers)
if (err) {
console.log('some error', err)
res.write(JSON.stringify(err))
} else {
for (const {
filepath,
originalFilename,
mimetype,
size,
} of files.files) {
console.log('saved file', {
filepath,
originalFilename,
mimetype,
size,
})
}
res.write(JSON.stringify({ fields, files }))
}
res.end()
})
}
})
.listen(9967, () => {
console.log('server started')
})

View file

@ -1,6 +0,0 @@
import '@uppy/core/dist/style.min.css'
import '@uppy/dashboard/dist/style.min.css'
import '@uppy/drag-drop/dist/style.min.css'
import '@uppy/progress-bar/dist/style.min.css'
export const ssr = false

View file

@ -1,69 +0,0 @@
<script lang="ts">
import { Dashboard, DashboardModal, DragDrop, ProgressBar } from '@uppy/svelte'
import Uppy from '@uppy/core'
import Webcam from '@uppy/webcam'
import XHRUpload from '@uppy/xhr-upload'
const createUppy = () => {
return new Uppy().use(Webcam).use(XHRUpload, {
bundle: true,
endpoint: 'http://localhost:9967/upload',
allowedMetaFields: ['something'],
fieldName: 'files',
})
}
let uppy1 = createUppy()
let uppy2 = createUppy()
let open = false;
let showInlineDashboard = true;
</script>
<main>
<h1>Welcome to the <code>@uppy/svelte</code> demo!</h1>
<h2>Inline Dashboard</h2>
<label>
<input
type="checkbox"
bind:checked={showInlineDashboard}
/>
Show Dashboard
</label>
{#if showInlineDashboard}
<Dashboard
uppy={uppy1}
plugins={['Webcam']}
/>
{/if}
<h2>Modal Dashboard</h2>
<div>
<button on:click={() => open = true}>Show Dashboard</button>
<DashboardModal
uppy={uppy2}
open={open}
props={{
onRequestCloseModal: () => open = false,
plugins: ['Webcam']
}}
/>
</div>
<h2>Drag Drop Area</h2>
<DragDrop
uppy={uppy1}
/>
<h2>Progress Bar</h2>
<ProgressBar
uppy={uppy1}
props={{
hideAfterFinish: false
}}
/>
</main>
<style global>
input[type="checkbox"] {
user-select: none;
}
</style>

View file

@ -1,20 +0,0 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import adapter from '@sveltejs/adapter-static'
// eslint-disable-next-line import/no-unresolved
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(),
},
}
export default config

23
examples/sveltekit/.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

View file

@ -0,0 +1 @@
engine-strict=true

View file

@ -0,0 +1,41 @@
# sv
Everything you need to build a Svelte project, powered by
[`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or
`pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an
> [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View file

@ -0,0 +1,34 @@
{
"name": "@uppy-example/sveltekit",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"dependencies": {
"@uppy/core": "workspace:*",
"@uppy/dashboard": "workspace:*",
"@uppy/drag-drop": "workspace:*",
"@uppy/progress-bar": "workspace:*",
"@uppy/svelte": "workspace:*",
"@uppy/tus": "workspace:*",
"@uppy/webcam": "workspace:*"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/vite": "^4.0.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.2.5"
}
}

View file

@ -0,0 +1 @@
@import 'tailwindcss';

View file

@ -1,4 +1,4 @@
// See https://kit.svelte.dev/docs/types#app
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {

View file

@ -2,10 +2,11 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<app style="display: contents">%sveltekit.body%</app>
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,7 @@
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
{@render children()}

View file

@ -0,0 +1,36 @@
<script lang="ts">
import Uppy from '@uppy/core'
import Tus from '@uppy/tus'
import {
UppyContextProvider,
Dropzone,
FilesList,
FilesGrid,
UploadButton,
} from '@uppy/svelte'
import '@uppy/svelte/dist/styles.css'
const uppy = new Uppy().use(Tus, {
endpoint: 'https://tusd.tusdemo.net/files/',
})
</script>
<UppyContextProvider {uppy}>
<main class="p-5 max-w-xl mx-auto">
<h1 class="text-4xl font-bold">Welcome to SvelteKit.</h1>
<article>
<h2 class="text-2xl my-4">With list</h2>
<Dropzone />
<FilesList />
<UploadButton />
</article>
<article>
<h2 class="text-2xl my-4">With grid</h2>
<Dropzone />
<FilesGrid columns={2} />
<UploadButton />
</article>
</main>
</UppyContextProvider>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://svelte.dev/docs/kit/integrations
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

View file

@ -9,11 +9,11 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"module": "ESNext",
"module": "preserve",
"moduleResolution": "bundler",
},
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
// except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in

View file

@ -1,8 +1,8 @@
// eslint-disable-next-line import/no-unresolved
import tailwindcss from '@tailwindcss/vite'
import { sveltekit } from '@sveltejs/kit/vite'
// eslint-disable-next-line import/no-extraneous-dependencies
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [sveltekit()],
plugins: [tailwindcss(), sveltekit()],
})

View file

@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/src/app.css" />
<title>Vite App</title>
</head>
<body>

View file

@ -2,6 +2,7 @@
"name": "@uppy-example/vue3",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
@ -18,7 +19,9 @@
"vue": "^3.2.33"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.4.17"
"tailwindcss": "^4.0.0",
"vite": "^6.2.0"
}
}

View file

@ -1,115 +1,42 @@
<template>
<div id="app">
<!-- <HelloWorld msg="Welcome to Uppy Vue Demo"/> -->
<h1>Welcome to Uppy Vue Demo!</h1>
<h2>Inline Dashboard</h2>
<label>
<input
type="checkbox"
:checked="showInlineDashboard"
@change="
(event) => {
showInlineDashboard = event.target.checked
}
"
/>
Show Dashboard
</label>
<Dashboard
v-if="showInlineDashboard"
:uppy="uppy"
:props="{
metaFields: [{ id: 'name', name: 'Name', placeholder: 'File name' }],
}"
/>
<h2>Modal Dashboard</h2>
<div>
<button @click="open = true">Show Dashboard</button>
<DashboardModal
:uppy="uppy2"
:open="open"
:props="{
onRequestCloseModal: handleClose,
}"
/>
</div>
<UppyContextProvider :uppy="uppy">
<main class="p-5 max-w-xl mx-auto">
<h1 class="text-4xl font-bold">Welcome to Vue.</h1>
<h2>Drag Drop Area</h2>
<DragDrop
:uppy="uppy"
:props="{
locale: {
strings: {
chooseFile: 'Boop a file',
orDragDrop: 'or yoink it here',
},
},
}"
/>
<article>
<h2 class="text-2xl my-4">With list</h2>
<Dropzone />
<FilesList />
<UploadButton />
</article>
<h2>Progress Bar</h2>
<ProgressBar
:uppy="uppy"
:props="{
hideAfterFinish: false,
}"
/>
</div>
<article>
<h2 class="text-2xl my-4">With grid</h2>
<Dropzone />
<FilesGrid :columns="2" />
<UploadButton />
</article>
</main>
</UppyContextProvider>
</template>
<script setup>
import { Dashboard, DashboardModal, DragDrop, ProgressBar } from '@uppy/vue'
</script>
<script>
<script setup lang="ts">
import { computed } from 'vue'
import Uppy from '@uppy/core'
import Tus from '@uppy/tus'
import Webcam from '@uppy/webcam'
import { defineComponent } from 'vue'
import {
UppyContextProvider,
Dropzone,
FilesList,
FilesGrid,
UploadButton,
} from '@uppy/vue'
const { VITE_TUS_ENDPOINT: TUS_ENDPOINT } = import.meta.env
export default defineComponent({
computed: {
uppy: () =>
new Uppy({ id: 'uppy1', autoProceed: true, debug: true })
.use(Tus, {
endpoint: TUS_ENDPOINT,
})
.use(Webcam),
uppy2: () =>
new Uppy({ id: 'uppy2', autoProceed: false, debug: true })
.use(Tus, {
endpoint: TUS_ENDPOINT,
})
.use(Webcam),
},
data() {
return {
open: false,
showInlineDashboard: false,
}
},
methods: {
handleClose() {
this.open = false
},
},
})
const uppy = computed(() =>
new Uppy().use(Tus, {
endpoint: 'https://tusd.tusdemo.net/files/',
}),
)
</script>
<style src="@uppy/core/dist/style.css"></style>
<style src="@uppy/dashboard/dist/style.css"></style>
<style src="@uppy/drag-drop/dist/style.css"></style>
<style src="@uppy/progress-bar/dist/style.css"></style>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
<style src="@uppy/vue/dist/styles.css"></style>

View file

@ -0,0 +1 @@
@import 'tailwindcss';

View file

@ -1,11 +1,12 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
const ROOT = new URL('../../', import.meta.url)
// https://vitejs.dev/config/
export default defineConfig({
envDir: fileURLToPath(ROOT),
plugins: [vue()],
plugins: [vue(), tailwindcss()],
})

View file

@ -111,10 +111,11 @@
"scripts": {
"start:companion": "bash bin/companion.sh",
"start:companion:with-loadbalancer": "e2e/start-companion-with-load-balancer.mjs",
"build:components": "node bin/build-components.mjs",
"build:bundle": "yarn node ./bin/build-bundle.mjs",
"build:clean": "cp .gitignore .gitignore.bak && printf '!node_modules\n!**/node_modules/**/*\n' >> .gitignore; git clean -Xfd packages e2e .parcel-cache coverage; mv .gitignore.bak .gitignore",
"build:companion": "yarn workspace @uppy/companion build",
"build:css": "yarn node ./bin/build-css.js",
"build:css": "yarn node ./bin/build-css.js && yarn workspace @uppy/components build:tailwind",
"build:svelte": "yarn workspace @uppy/svelte build",
"build:angular": "yarn workspace angular build",
"build:js:typeless": "npm-run-all build:lib build:companion build:svelte",
@ -122,7 +123,7 @@
"build:ts": "yarn tsc -b tsconfig.build.json && yarn workspace @uppy/svelte build",
"build:lib": "yarn node ./bin/build-lib.mjs",
"build:locale-pack": "yarn workspace @uppy-dev/locale-pack build && eslint packages/@uppy/locales/src/en_US.ts --fix && yarn workspace @uppy-dev/locale-pack test unused",
"build": "npm-run-all --serial build:ts --parallel build:js build:css --serial size",
"build": "npm-run-all --serial build:ts --parallel build:js build:css --serial build:components size",
"contributors:save": "yarn node ./bin/update-contributors.mjs",
"dev:with-companion": "npm-run-all --parallel start:companion dev",
"dev": "yarn workspace @uppy-dev/dev dev",
@ -156,7 +157,7 @@
"test": "npm-run-all lint test:locale-packs:unused test:unit test:companion",
"uploadcdn": "yarn node ./private/upload-to-cdn/index.js",
"version": "yarn node ./bin/after-version-bump.js",
"watch:css": "onchange 'packages/{@uppy/,}*/src/*.scss' --initial --verbose -- yarn run build:css",
"watch:css": "onchange 'packages/{@uppy/,}*/src/*.{scss,css}' --initial --verbose -- yarn run build:css",
"watch:js:bundle": "onchange 'packages/{@uppy/,}*/src/**/*.{js,ts,jsx,tsx}' --initial --verbose -- yarn run build:bundle",
"watch:js:lib": "onchange 'packages/{@uppy/,}*/src/**/*.{js,ts,jsx,tsx}' --initial --verbose -- yarn run build:lib",
"watch:js": "npm-run-all --parallel watch:js:bundle watch:js:lib",

View file

@ -9,6 +9,7 @@
"sourceMap": false,
"allowJs": true,
"checkJs": true,
"skipLibCheck": true,
"noEmitOnError": true,
"skipLibCheck": true,
},

View file

@ -0,0 +1,57 @@
{
"name": "@uppy/components",
"description": "Headless Uppy components, made in Preact",
"version": "0.0.0",
"license": "MIT",
"main": "lib/index.js",
"type": "module",
"keywords": [
"uppy",
"uppy-plugin",
"headless",
"components",
"preact"
],
"homepage": "https://uppy.io",
"bugs": {
"url": "https://github.com/transloadit/uppy/issues"
},
"repository": {
"type": "git",
"url": "git+https://github.com/transloadit/uppy.git"
},
"scripts": {
"build:tailwind": "npx @tailwindcss/cli -i ./src/input.css -o ./dist/styles.css"
},
"dependencies": {
"@tailwindcss/cli": "^4.0.6",
"clsx": "^2.1.1",
"preact": "^10.5.13",
"pretty-bytes": "^6.1.1",
"tailwindcss": "^4.0.6"
},
"peerDependencies": {
"@uppy/audio": "workspace:^",
"@uppy/core": "workspace:^",
"@uppy/image-editor": "workspace:^",
"@uppy/screen-capture": "workspace:^",
"@uppy/webcam": "workspace:^"
},
"peerDependenciesMeta": {
"@uppy/audio": {
"optional": true
},
"@uppy/google-drive-picker": {
"optional": true
},
"@uppy/image-editor": {
"optional": true
},
"@uppy/screen-capture": {
"optional": true
},
"@uppy/webcam": {
"optional": true
}
}
}

View file

@ -0,0 +1,119 @@
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { h } from 'preact'
import { useState, useRef } from 'preact/hooks'
import { clsx } from 'clsx'
import type { UppyContext } from './types.js'
export type DropzoneProps = {
width?: string
height?: string
note?: string
noClick?: boolean
ctx: UppyContext
}
export default function Dropzone(props: DropzoneProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(() => false)
const { width, height, note, noClick, ctx } = props
function handleDrop(event: DragEvent) {
event.preventDefault()
event.stopPropagation()
setIsDragging(false)
const files = Array.from(event.dataTransfer?.files || [])
if (!files.length) return
ctx.uppy?.addFiles(
files.map((file) => ({
source: 'drag-drop',
name: file.name,
type: file.type,
data: file,
})),
)
}
function handleDragOver(event: DragEvent) {
event.preventDefault()
event.stopPropagation()
}
function handleDragEnter(event: DragEvent) {
event.preventDefault()
event.stopPropagation()
setIsDragging(true)
}
function handleDragLeave(event: DragEvent) {
event.preventDefault()
event.stopPropagation()
setIsDragging(false)
}
function handleClick() {
if (noClick) return
fileInputRef.current?.click()
}
function handleFileInputChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files || [])
if (!files.length) return
ctx.uppy?.addFiles(
files.map((file) => ({
source: 'drag-drop',
name: file.name,
type: file.type,
data: file,
})),
)
// Reset the input value so the same file can be selected again
input.value = ''
}
return (
<div className="uppy-reset" data-uppy-element="dropzone">
<input
type="file"
className="uppy:hidden"
ref={fileInputRef}
multiple
onChange={handleFileInputChange}
/>
<div
role="button"
tabIndex={0}
style={{
width: width || '100%',
height: height || '100%',
}}
className={clsx(
'uppy:border-2 uppy:border-dashed uppy:border-gray-300',
'uppy:rounded-lg uppy:p-6 uppy:bg-gray-50',
'uppy:transition-colors uppy:duration-200',
{
'uppy:bg-blue-50': isDragging,
},
{
'uppy:cursor-pointer uppy:hover:bg-blue-50': !noClick,
},
)}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onClick={handleClick}
>
<div className="uppy:flex uppy:flex-col uppy:items-center uppy:justify-center uppy:h-full uppy:space-y-3">
<p className="uppy:text-gray-600">
Drop files here or click to add them
</p>
</div>
{note ?
<div className="uppy:text-sm uppy:text-gray-500">{note}</div>
: null}
</div>
</div>
)
}

View file

@ -0,0 +1,102 @@
/* eslint-disable react/destructuring-assignment */
import { Fragment, h } from 'preact'
import { useState, useEffect } from 'preact/hooks'
import type { Body, Meta, UppyEventMap, UppyFile } from '@uppy/core'
import prettyBytes from 'pretty-bytes'
import { clsx } from 'clsx'
import { Thumbnail } from './index.js'
import type { UppyContext } from './types.js'
export type FilesGridProps = {
editFile?: (file: UppyFile<Meta, Body>) => void
columns?: number
ctx: UppyContext
}
export default function FilesGrid(props: FilesGridProps) {
const [files, setFiles] = useState<UppyFile<Meta, Body>[]>(() => [])
const { ctx, editFile } = props
function gridColsClass() {
return (
{
1: 'uppy:grid-cols-1',
2: 'uppy:grid-cols-2',
3: 'uppy:grid-cols-3',
4: 'uppy:grid-cols-4',
5: 'uppy:grid-cols-5',
6: 'uppy:grid-cols-6',
}[props.columns || 2] || 'uppy:grid-cols-2'
)
}
useEffect(() => {
const onStateUpdate: UppyEventMap<any, any>['state-update'] = (
prev,
next,
patch,
) => {
if (patch?.files) {
setFiles(Object.values(patch.files))
}
}
ctx.uppy?.on('state-update', onStateUpdate)
return () => {
ctx.uppy?.off('state-update', onStateUpdate)
}
}, [ctx.uppy])
return (
<div
data-uppy-element="files-grid"
className={clsx(
'uppy:reset uppy:my-4 uppy:grid uppy:gap-4',
gridColsClass(),
)}
>
{files?.map((file) => (
<div
className="uppy:flex uppy:flex-col uppy:items-center uppy:gap-2"
key={file.id}
>
<Fragment>
<Thumbnail images file={file} />
<div className="uppy:w-full uppy-reset">
<p className="uppy:font-medium uppy:truncate" title={file.name}>
{file.name}
</p>
<div className="uppy:flex uppy:items-center uppy:gap-2">
<p className=" uppy:text-gray-500 uppy:tabular-nums ">
{prettyBytes(file.size || 0)}
</p>
{editFile && (
<button
type="button"
className="uppy:flex uppy:rounded uppy:text-blue-500 uppy:hover:text-blue-700 uppy:bg-transparent uppy:transition-colors"
onClick={() => {
editFile(file)
}}
>
edit
</button>
)}
<button
type="button"
className="uppy:flex uppy:rounded uppy:text-blue-500 uppy:hover:text-blue-700 uppy:bg-transparent uppy:transition-colors"
onClick={() => {
ctx.uppy?.removeFile(file.id)
}}
>
remove
</button>
</div>
</div>
</Fragment>
</div>
))}
</div>
)
}

View file

@ -0,0 +1,93 @@
/* eslint-disable react/destructuring-assignment */
import { Fragment, h } from 'preact'
import { useState, useEffect } from 'preact/hooks'
import type { Meta, Body, UppyEventMap, UppyFile } from '@uppy/core'
import prettyBytes from 'pretty-bytes'
import { clsx } from 'clsx'
import { Thumbnail, type UppyContext } from './index.js'
export type FilesListProps = {
editFile?: (file: UppyFile<Meta, Body>) => void
ctx: UppyContext
}
export default function FilesList(props: FilesListProps) {
const [files, setFiles] = useState<UppyFile<any, any>[]>(() => [])
const { ctx, editFile } = props
useEffect(() => {
const onStateUpdate: UppyEventMap<any, any>['state-update'] = (
prev,
next,
patch,
) => {
if (patch?.files) {
setFiles(Object.values(patch.files))
}
}
ctx.uppy?.on('state-update', onStateUpdate)
return () => {
ctx.uppy?.off('state-update', onStateUpdate)
}
}, [ctx.uppy])
return (
<ul data-uppy-element="files-list" className="uppy-reset uppy:my-4">
{files?.map((file) => (
<li key={file.id}>
<Fragment>
<div className="uppy:flex uppy:items-center uppy:gap-2">
<div className="uppy:w-[32px] uppy:h-[32px]">
<Thumbnail width="32px" height="32px" file={file} />
</div>
<p className="uppy:truncate">{file.name}</p>
<p className="uppy:text-gray-500 uppy:tabular-nums uppy:min-w-18 uppy:text-right uppy:ml-auto">
{prettyBytes(file.size || 0)}
</p>
<Fragment>
{editFile && (
<button
type="button"
className="uppy:flex uppy:rounded uppy:text-blue-500 uppy:hover:text-blue-700 uppy:bg-transparent uppy:transition-colors"
onClick={() => {
editFile(file)
}}
>
edit
</button>
)}
<button
type="button"
className="uppy:flex uppy:rounded uppy:text-blue-500 uppy:hover:text-blue-700 uppy:bg-transparent uppy:transition-colors"
onClick={() => {
ctx.uppy?.removeFile(file.id)
}}
>
remove
</button>
</Fragment>
</div>
<progress
max="100"
className={clsx(
'uppy:w-full uppy:h-[2px] uppy:appearance-none uppy:bg-gray-100 uppy:rounded-full uppy:overflow-hidden uppy:[&::-webkit-progress-bar]:bg-gray-100 uppy:block uppy:my-2',
{
'uppy:[&::-webkit-progress-value]:bg-green-500 uppy:[&::-moz-progress-bar]:bg-green-500':
file.progress?.uploadComplete,
'uppy:[&::-webkit-progress-value]:bg-red-500 uppy:[&::-moz-progress-bar]:bg-red-500':
file.error,
'uppy:[&::-webkit-progress-value]:bg-blue-500 uppy:[&::-moz-progress-bar]:bg-blue-500':
!file.progress?.uploadComplete && !file.error,
},
)}
value={file.progress?.percentage || 0}
/>
</Fragment>
</li>
))}
</ul>
)
}

View file

@ -0,0 +1,231 @@
/* eslint-disable react/destructuring-assignment */
import { h } from 'preact'
import type { UppyContext } from './types'
export type ProviderIconProps = {
provider:
| 'device'
| 'camera'
| 'screen-capture'
| 'audio'
| 'dropbox'
| 'facebook'
| 'instagram'
| 'onedrive'
| 'googlephotos'
| 'googledrive'
fill?: string
// eslint-disable-next-line react/no-unused-prop-types
ctx?: UppyContext
}
export default function ProviderIcon(props: ProviderIconProps) {
switch (props.provider) {
case 'device':
return (
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 32 32">
<path
d="M8.45 22.087l-1.305-6.674h17.678l-1.572 6.674H8.45zm4.975-12.412l1.083 1.765a.823.823 0 00.715.386h7.951V13.5H8.587V9.675h4.838zM26.043 13.5h-1.195v-2.598c0-.463-.336-.75-.798-.75h-8.356l-1.082-1.766A.823.823 0 0013.897 8H7.728c-.462 0-.815.256-.815.718V13.5h-.956a.97.97 0 00-.746.37.972.972 0 00-.19.81l1.724 8.565c.095.44.484.755.933.755H24c.44 0 .824-.3.929-.727l2.043-8.568a.972.972 0 00-.176-.825.967.967 0 00-.753-.38z"
fill-rule="evenodd"
fill={props.fill || 'currentcolor'}
/>
</svg>
)
case 'camera':
return (
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 32 32">
<path
d="M23.5 9.5c1.417 0 2.5 1.083 2.5 2.5v9.167c0 1.416-1.083 2.5-2.5 2.5h-15c-1.417 0-2.5-1.084-2.5-2.5V12c0-1.417 1.083-2.5 2.5-2.5h2.917l1.416-2.167C13 7.167 13.25 7 13.5 7h5c.25 0 .5.167.667.333L20.583 9.5H23.5zM16 11.417a4.706 4.706 0 00-4.75 4.75 4.704 4.704 0 004.75 4.75 4.703 4.703 0 004.75-4.75c0-2.663-2.09-4.75-4.75-4.75zm0 7.825c-1.744 0-3.076-1.332-3.076-3.074 0-1.745 1.333-3.077 3.076-3.077 1.744 0 3.074 1.333 3.074 3.076s-1.33 3.075-3.074 3.075z"
fill-rule="nonzero"
fill={props.fill || '#02B383'}
/>
</svg>
)
case 'screen-capture':
return (
<svg aria-hidden="true" width="32" height="32" viewBox="0 0 32 32">
<g fill="currentcolor" fill-rule="evenodd">
<path d="M24.182 9H7.818C6.81 9 6 9.742 6 10.667v10c0 .916.81 1.666 1.818 1.666h4.546V24h7.272v-1.667h4.546c1 0 1.809-.75 1.809-1.666l.009-10C26 9.742 25.182 9 24.182 9zM24 21H8V11h16v10z" />
<circle cx="16" cy="16" r="2" />
</g>
</svg>
)
case 'audio':
return (
<svg aria-hidden="true" width="32px" height="32px" viewBox="0 0 32 32">
<path
d="M21.143 12.297c.473 0 .857.383.857.857v2.572c0 3.016-2.24 5.513-5.143 5.931v2.64h2.572a.857.857 0 110 1.714H12.57a.857.857 0 110-1.714h2.572v-2.64C12.24 21.24 10 18.742 10 15.726v-2.572a.857.857 0 111.714 0v2.572A4.29 4.29 0 0016 20.01a4.29 4.29 0 004.286-4.285v-2.572c0-.474.384-.857.857-.857zM16 6.5a3 3 0 013 3v6a3 3 0 01-6 0v-6a3 3 0 013-3z"
fill="currentcolor"
fill-rule="nonzero"
/>
</svg>
)
case 'dropbox':
return (
<svg
aria-hidden="true"
focusable="false"
width="32"
height="32"
viewBox="0 0 32 32"
>
<path
d="M10.5 7.5L5 10.955l5.5 3.454 5.5-3.454 5.5 3.454 5.5-3.454L21.5 7.5 16 10.955zM10.5 21.319L5 17.864l5.5-3.455 5.5 3.455zM16 17.864l5.5-3.455 5.5 3.455-5.5 3.455zM16 25.925l-5.5-3.455 5.5-3.454 5.5 3.454z"
fill={props.fill || 'currentcolor'}
fillRule="nonzero"
/>
</svg>
)
case 'facebook':
return (
<svg
aria-hidden="true"
focusable="false"
width="32"
height="32"
viewBox="0 0 32 32"
>
<g fill="none" fillRule="evenodd">
<path
d="M27 16c0-6.075-4.925-11-11-11S5 9.925 5 16c0 5.49 4.023 10.041 9.281 10.866V19.18h-2.793V16h2.793v-2.423c0-2.757 1.642-4.28 4.155-4.28 1.204 0 2.462.215 2.462.215v2.707h-1.387c-1.366 0-1.792.848-1.792 1.718V16h3.05l-.487 3.18h-2.563v7.686C22.977 26.041 27 21.49 27 16"
fill="#1777F2"
/>
<path
d="M20.282 19.18L20.77 16h-3.051v-2.063c0-.87.426-1.718 1.792-1.718h1.387V9.512s-1.258-.215-2.462-.215c-2.513 0-4.155 1.523-4.155 4.28V16h-2.793v3.18h2.793v7.686a11.082 11.082 0 003.438 0V19.18h2.563"
fill="#FFFFFE"
/>
</g>
</svg>
)
case 'instagram':
return (
<svg
aria-hidden="true"
focusable="false"
width="32"
height="32"
viewBox="0 0 32 32"
>
<defs>
<path
d="M16.825 5l.483-.001.799.002c1.168.005 1.598.021 2.407.057 1.17.05 1.97.235 2.67.506.725.28 1.34.655 1.951 1.265.613.61.99 1.223 1.273 1.946.273.7.46 1.498.516 2.67l.025.552.008.205c.029.748.037 1.51.042 3.777l.001.846v.703l-.001.398a50.82 50.82 0 01-.058 2.588c-.05 1.17-.235 1.97-.506 2.67a5.394 5.394 0 01-1.265 1.951c-.61.613-1.222.99-1.946 1.273-.699.273-1.498.46-2.668.516-.243.012-.451.022-.656.03l-.204.007c-.719.026-1.512.034-3.676.038l-.847.001h-1.1a50.279 50.279 0 01-2.587-.059c-1.171-.05-1.971-.235-2.671-.506a5.394 5.394 0 01-1.951-1.265 5.385 5.385 0 01-1.272-1.946c-.274-.699-.46-1.498-.517-2.668a88.15 88.15 0 01-.03-.656l-.007-.205c-.026-.718-.034-1.512-.038-3.674v-2.129c.006-1.168.022-1.597.058-2.406.051-1.171.235-1.971.506-2.672a5.39 5.39 0 011.265-1.95 5.381 5.381 0 011.946-1.272c.699-.274 1.498-.462 2.669-.517l.656-.03.204-.007c.718-.026 1.511-.034 3.674-.038zm.678 1.981h-1.226l-.295.001c-2.307.005-3.016.013-3.777.043l-.21.009-.457.02c-1.072.052-1.654.232-2.042.383-.513.2-.879.44-1.263.825a3.413 3.413 0 00-.82 1.267c-.15.388-.33.97-.375 2.043a48.89 48.89 0 00-.056 2.482v.398 1.565c.006 2.937.018 3.285.073 4.444.05 1.073.231 1.654.382 2.043.2.512.44.878.825 1.263.386.383.753.621 1.267.82.388.15.97.328 2.043.374.207.01.388.017.563.024l.208.007a63.28 63.28 0 002.109.026h1.564c2.938-.006 3.286-.019 4.446-.073 1.071-.051 1.654-.232 2.04-.383.514-.2.88-.44 1.264-.825.384-.386.622-.753.82-1.266.15-.389.328-.971.375-2.044.039-.88.054-1.292.057-2.723v-1.15-.572c-.006-2.936-.019-3.284-.074-4.445-.05-1.071-.23-1.654-.382-2.04-.2-.515-.44-.88-.825-1.264a3.405 3.405 0 00-1.267-.82c-.388-.15-.97-.328-2.042-.375a48.987 48.987 0 00-2.535-.056zm-1.515 3.37a5.65 5.65 0 11.021 11.299 5.65 5.65 0 01-.02-11.3zm.004 1.982a3.667 3.667 0 10.015 7.334 3.667 3.667 0 00-.015-7.334zm5.865-3.536a1.32 1.32 0 11.005 2.64 1.32 1.32 0 01-.005-2.64z"
id="a"
/>
</defs>
<g fill="none" fill-rule="evenodd">
<mask id="b" fill="#fff">
<use xlinkHref="#a" />
</mask>
<image
mask="url(#b)"
x="4"
y="4"
width="24"
height="24"
xlinkHref="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACwAAAAsCAYAAAAehFoBAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAALKADAAQAAAABAAAALAAAAAD8buejAAALZklEQVRYCVWZC2LbNhAFCRKykvP0bD1506SxRKIzbwHJoU3jv5h9WICU3P7+6zlG2zZvr8s/rW1tN7U0rMll8aDYufdzbLfc1JHmpv3jpPy8tsO+3O2s/O6YMSjTl/qdCds4mIIG60m8vdq2Z+phm2V4vAb9+o7BbZeuoM0NyYazvTvbvlN1MGjHUAesZ/IWWOsCeF0BOwAK4ITR0WYd/QKHEPv2DEymmorZtiubjOHEMYEzXmC9GMxu+95Kz+kuwxjDBKb8iUoCAdqZoAeyALreW6ZNx9Y4Jz8cLwjTZOEoR+HU05k2RzgP2iafGgfZiEdZbEr94zpX/xkPtDtGAxF+LRcgTsp9CAZg0rnEnXmPqFshY5vLnVWxLXO/bah2sZQgBZppGSe8NbjNPN5kc/WbIYEn8U+jXCOezT4zfgS1eoVEhceVeK74Fe4N6CoYEoLWykzHsd+GMAUqdTTVvvqT1uWqB3lVCLb12/ORAe8/5Zu9mp7lqoEFUCAFDIxqz7i1bq2AY1U9jqq2QK/7DYl+1AeZlAFcEc+U/jkRUqsvCHQ/nyGvjrOl6EuZWRWVGCKUMCkntQ5o+u2AZ3OxakbTcoBZnY0xhgGCUM4Kp1xtBTnBnXM5ASRms/Fs7d9OpX8bXN45pibQY/ML1MmA5G9CINBuCpdftexr6i2c5qd9J441LNJm3zk1GVusJ7v6mPJ7HPxJR0Li/vg9O1XHTEgvsQoSgExU0NnlLF0paK+6d06aOMKE2nCKV0ofNw4WsWmLsWrv6lPLnhGpr9E137QkHOMB/jh/T8MOqOadXarR44zPBW5NvDccnBxVmdK81+7RQ5p6qnQoRDZPh9+xWj0N2XpqxX1HzMty9UlFnKya/h3gulziAsyxwkSmpTIPB8vagKLyktRdDuBEHNGZMm4oCFWgjq31WPHpaC93gGNqpOpP4Ez4spa+nMNvhTWcuPKAJ79fqIxVoUvdjEG9qSy2WhpQlz61yG/gnKEA25IrIOYK6DIsQs2EE9LR/sTKq38Nd1y/X//FXG0QDHkEqSz3EYVV2dhb00rgLPSDcqmrScs55NNOD2zVqKmYnYTFnkACp520dkW5vBxK99BVzr792/iZ+VVo92UkKU2oG5WFTb6mNiA1H2C8KC0E44qaQleR3EQvQNwLrECOVAiSwM5gpF7nvDND0lZvYuQ9JbZfqdTrqCgwMcVrRS0z9QkLu9NWmkgEHb8p2zDRylj9VWA3lXD2vObEdWpT3w5MiFqQ1W/lteG4eipastxv2w+TeTBP0ypK84HiOW9fUzLcjRDwCW2b2VxmnGSKTX6uRSwMnC9YX4l05Mh2uwI+QVWdWUOSTWd5Xjjf7/tPYk2stSh053XTGN5RJMCMSajMcS8Trn3j/E1ajthlxCkmJXVi47PSUsyyq+jyexsayQNuv5GVYJaszprNsQD3RkgYiy49kFl2JlJJxlf8Uu/lpkq7+aWqzEzjr5cTVpFaJvSVr8AKRtiTlVPFk5t1nO30W+o6jrbAk76kxFa/tX+dom4C1wDPk03gqCw8HTBSxx4FHxIA+mh2pM3rKu5SNqBAuOSZnHzsB9JwW7DV/ge8dlVsOh375PvH8YO8EALU1HuecIC6qQgXifNuSx9XAoLaoGIYDjkWFrawX1U1XrknuMFw7QBSPtg79XovmBvwqnDICrhClEO6wgKFj9vPqJWlthUvdgH1DOA8+wFMexzQc5BUS1d1IsdBSjEv4Fe1LgBO1CpFPTpV1JuPSFNt4y/trzbtaUfwBWwM3/6JsrL6MSQYwLKXAm9YJBxsM8992MblZ63Gami0+rnwOMyPykVpQsyl9eYNOfVC6kRBkwaop//LgcAKWivkHF791g0JK5kMmCgKPas2QRkUFQsuTvm6R1946Wg95k764ZRLW59yO5UVGsawwELupCfAbdCuAwvcz5Xk18rIVEdgSRBRgO77R206QdXHuA2goaGiCQ0GmUfN1JlmFayjv0IcKGkfYt4HAj0yuQBRGDjzuS/rTmAf29Gov1S+FF7QBayNcpoBOEsMt3vFcIUC7VxOnE+pxmkgqEzduzwsPykrjBszCusgdarsRIAL6CM/KqsqcAf1vj8P1TXFyN6e5G8ao48fjKfDQJYizIdIfb+Xwp6Z2fE2C7mUfUEzMKqSBp4VUV1A49Sz1M2LzVzahEfyHUAcQNltR0nADYkBvHXDZQo8H9dQvHF7qhjPtSolBJ0A/vaLwdRz5YFFGoWBy8E/4aKcjqimaUBXXnjBpzOZnMlIVXsTVEBBUa+dD0BR0xVopgAD70psY0KjMHpmHB2kApea9o23NS83mpsref5OZet4U/0CMhSEDpwnxB9lVKSfk5djllXRFPizQmKcqMpnyZ3ycPntf96Ym9ChzU8vCQnhgWZ2UuySArw+cVBG4gqNCS6YoSEEziRWVStKUpe4FfCd91V0XA/qgOJuF7FpGjjyQgsFoNDtibp8cm+cyXxbB6zh4pMUO4H06yzsv4E/A6rg/uRJRnMRmrhMDIhyOjABX9CMDFhBFxx19KujjqWeim5PwVFU6IBiewfyk7IPETcg52kjXN7nsbaoEykKf/cjUgVxpTZZVtnqFMgv4FHa8oSOisawinMLHfUBzJcK1j8BeqquedKDtgcgnA4bym4P6gBWYVM3W/pn41ku5L4RElFWtlk5SXHEThhOWDiIyVROlQNM+wyHimlgATI/PPIm4BB8qfqwHnhgL89gzs+Ww1xQb4821SZ/4IwOJiRqH/X9u7Hj08JLSZfawOQcpRzwgk1oBNzzcgLn1FBNHspMENik9OG4awIDaUjw9rKNT1KXPl9neua6sSbkgqfs/CNfBdNfDDhQuL4AKXEXeOgZID91eOiRUnEFOIA5rnTkBU0/IT05gByoq5KBJF4Hym4Pxh3UcxZ7HjdhEhKWURbhavNR9rjLBwk3ryDcrGzfvk9I69b1yhMGWQ4bqMwv/RMSplQkjjVKXzZX8wESVcuB7QG0YUCMjk/aOmWgc/vC4oMCVYfghIGP6MT1zpeUhM1rQzOnGxmFKwTCir1Xaj5vN7T7nDZvnbDGHbCKnwji2zofNsOvbold3zlUtKGosBun3PbJSrrReHEaCQVCIDEMaCCBs+P+AbybkbIhmbNecGwF+E5/L2ECuPKCWsUESQkKnyyJ93TGACk7OrAY9P8XG//fGCoM7DAEUGnj5Mw7aQfelySWOm9iPuFyvrL8rKQR6mM6qdCUDQsfNPVu4yv/HaPOT1e/yDaviMKmTkg/I/F7MUG9OlrmDrBLRVd3c8KBJlPEKoVRcIJuhoQAmZDUkPC00W5OI1dOpQ1F61kFNqr9SmFcaHdBheOaDHF6QZMOP6QyiZ804oj98wLiAMIgcWw4UDYkDAWfR+4d5s0zP2GgUZX04i+NeSgYGokvbDhIZYUWHgd9K8zZzir264NxZUFbsfM1jdqpV2naA48tx6hsvBSabE4IMtlcOGgq8PqCjoly2rw2soqy4RJWQtPZl6PUCU14ZUWENuZV2Honn3f+k6R6wrkqgTStyQ0bFY+XAaafMRFgUlVeXxXFUcpLEYfZz3FrVUzZrOOJK+4B/wnIZ8TGRvb9OB8EUM0w8uNYj/oa9iK9AMoy6gA72o02srMxpAPUD+EDnVEF7P5xw896VyAbFk8MgnpVpR3gfLnt/wECq3rYFvYLcKCpqvcI+/hVl8AumXDeApklDRRKJSS+KOaq1Rgg4igOYtiQK1hJy46TBtDjznDp3iqJff5j0/LfSZbYVdauqXccJ9W+czupp0sU9gMlqkQ52lU1E6tUwoDUukAD6YRpAwqDrAErzA8QCRvXm98KEep0xIdY1CN1ye27IP0IHvvYIW18qGz8S7VWUZuMkUOb3P8DHTl67ur/i1UAAAAASUVORK5CYII="
/>
</g>
</svg>
)
case 'onedrive':
return (
<svg
aria-hidden="true"
focusable="false"
width="32"
height="32"
viewBox="0 0 32 32"
>
<g fill="none" fillRule="nonzero">
<path
d="M13.39 12.888l4.618 2.747 2.752-1.15a4.478 4.478 0 012.073-.352 6.858 6.858 0 00-5.527-5.04 6.895 6.895 0 00-6.876 2.982l.07-.002a5.5 5.5 0 012.89.815z"
fill="#0364B8"
/>
<path
d="M13.39 12.887v.001a5.5 5.5 0 00-2.89-.815l-.07.002a5.502 5.502 0 00-4.822 2.964 5.43 5.43 0 00.38 5.62l4.073-1.702 1.81-.757 4.032-1.685 2.105-.88-4.619-2.748z"
fill="#0078D4"
/>
<path
d="M22.833 14.133a4.479 4.479 0 00-2.073.352l-2.752 1.15.798.475 2.616 1.556 1.141.68 3.902 2.321a4.413 4.413 0 00-.022-4.25 4.471 4.471 0 00-3.61-2.284z"
fill="#1490DF"
/>
<path
d="M22.563 18.346l-1.141-.68-2.616-1.556-.798-.475-2.105.88L11.87 18.2l-1.81.757-4.073 1.702A5.503 5.503 0 0010.5 23h12.031a4.472 4.472 0 003.934-2.333l-3.902-2.321z"
fill="#28A8EA"
/>
</g>
</svg>
)
case 'googlephotos':
return (
<svg
aria-hidden="true"
focusable="false"
width="32"
height="32"
viewBox="-7 -7 73 73"
>
<g fill="none" fillRule="evenodd">
<path d="M-3-3h64v64H-3z" />
<g fillRule="nonzero">
<path
fill="#FBBC04"
d="M14.8 13.4c8.1 0 14.7 6.6 14.7 14.8v1.3H1.3c-.7 0-1.3-.6-1.3-1.3C0 20 6.6 13.4 14.8 13.4z"
/>
<path
fill="#EA4335"
d="M45.6 14.8c0 8.1-6.6 14.7-14.8 14.7h-1.3V1.3c0-.7.6-1.3 1.3-1.3C39 0 45.6 6.6 45.6 14.8z"
/>
<path
fill="#4285F4"
d="M44.3 45.6c-8.2 0-14.8-6.6-14.8-14.8v-1.3h28.2c.7 0 1.3.6 1.3 1.3 0 8.2-6.6 14.8-14.8 14.8z"
/>
<path
fill="#34A853"
d="M13.4 44.3c0-8.2 6.6-14.8 14.8-14.8h1.3v28.2c0 .7-.6 1.3-1.3 1.3-8.2 0-14.8-6.6-14.8-14.8z"
/>
</g>
</g>
</svg>
)
case 'googledrive':
return (
<svg
aria-hidden="true"
focusable="false"
width="32"
height="32"
viewBox="0 0 32 32"
>
<g fillRule="nonzero" fill="none">
<path
d="M6.663 22.284l.97 1.62c.202.34.492.609.832.804l3.465-5.798H5c0 .378.1.755.302 1.096l1.361 2.278z"
fill="#0066DA"
/>
<path
d="M16 12.09l-3.465-5.798c-.34.195-.63.463-.832.804l-6.4 10.718A2.15 2.15 0 005 18.91h6.93L16 12.09z"
fill="#00AC47"
/>
<path
d="M23.535 24.708c.34-.195.63-.463.832-.804l.403-.67 1.928-3.228c.201-.34.302-.718.302-1.096h-6.93l1.474 2.802 1.991 2.996z"
fill="#EA4335"
/>
<path
d="M16 12.09l3.465-5.798A2.274 2.274 0 0018.331 6h-4.662c-.403 0-.794.11-1.134.292L16 12.09z"
fill="#00832D"
/>
<path
d="M20.07 18.91h-8.14l-3.465 5.798c.34.195.73.292 1.134.292h12.802c.403 0 .794-.11 1.134-.292L20.07 18.91z"
fill="#2684FC"
/>
<path
d="M23.497 12.455l-3.2-5.359a2.252 2.252 0 00-.832-.804L16 12.09l4.07 6.82h6.917c0-.377-.1-.755-.302-1.096l-3.188-5.359z"
fill="#FFBA00"
/>
</g>
</svg>
)
default:
return null
}
}

View file

@ -0,0 +1,182 @@
/* eslint-disable react/destructuring-assignment */
import { h } from 'preact'
import { useMemo, useEffect } from 'preact/hooks'
import type { Body, Meta, UppyFile } from '@uppy/core'
import type { UppyContext } from './types'
export type ThumbnailProps = {
file: UppyFile<Meta, Body>
width?: string
height?: string
images?: boolean
// eslint-disable-next-line react/no-unused-prop-types
ctx?: UppyContext
}
export default function Thumbnail(props: ThumbnailProps) {
const width = props.width || '100%'
const height = props.height || '100%'
const fileTypeGeneral = props.file.type?.split('/')[0] || ''
const fileTypeSpecific = props.file.type?.split('/')[1] || ''
const isImage = props.file.type.startsWith('image/')
const isArchive =
fileTypeGeneral === 'application' &&
[
'zip',
'x-7z-compressed',
'x-zip-compressed',
'x-rar-compressed',
'x-tar',
'x-gzip',
'x-apple-diskimage',
].includes(fileTypeSpecific)
const isPDF = fileTypeGeneral === 'application' && fileTypeSpecific === 'pdf'
const objectUrl = useMemo(() => {
if (!props.images) return ''
return URL.createObjectURL(props.file.data)
}, [props.file.data, props.images])
const showThumbnail = props.images && isImage && objectUrl
useEffect(() => {
return () => {
if (objectUrl) {
URL.revokeObjectURL(objectUrl)
}
}
}, [objectUrl])
return (
<div
data-uppy-element="thumbnail"
className="uppy:relative uppy:overflow-hidden uppy:bg-gray-100 uppy:rounded-lg uppy:flex uppy:items-center uppy:justify-center"
style={{
width,
height,
aspectRatio: '1',
}}
>
{showThumbnail ?
<img
className="uppy:w-full uppy:h-full uppy:object-cover"
src={objectUrl}
alt={props.file.name}
/>
: null}
{!showThumbnail ?
<div className="uppy:flex uppy:flex-col uppy:items-center uppy:justify-center uppy:w-full uppy:h-full">
<div className="uppy:flex-1 uppy:flex uppy:items-center uppy:justify-center uppy:w-full">
{!props.file.type ?
<svg
aria-hidden="true"
className="uppy:w-3/4 uppy:h-3/4"
viewBox="0 0 25 25"
>
<g fill="#A7AFB7" fill-rule="nonzero">
<path d="M5.5 22a.5.5 0 0 1-.5-.5v-18a.5.5 0 0 1 .5-.5h10.719a.5.5 0 0 1 .367.16l3.281 3.556a.5.5 0 0 1 .133.339V21.5a.5.5 0 0 1-.5.5h-14zm.5-1h13V7.25L16 4H6v17z" />
<path d="M15 4v3a1 1 0 0 0 1 1h3V7h-3V4h-1z" />
</g>
</svg>
: null}
{fileTypeGeneral === 'text' ?
<svg
aria-hidden="true"
className="uppy:w-3/4 uppy:h-3/4"
viewBox="0 0 25 25"
>
<path
d="M4.5 7h13a.5.5 0 1 1 0 1h-13a.5.5 0 0 1 0-1zm0 3h15a.5.5 0 1 1 0 1h-15a.5.5 0 1 1 0-1zm0 3h15a.5.5 0 1 1 0 1h-15a.5.5 0 1 1 0-1zm0 3h10a.5.5 0 1 1 0 1h-10a.5.5 0 1 1 0-1z"
fill="#5A5E69"
fill-rule="nonzero"
/>
</svg>
: null}
{fileTypeGeneral === 'image' && !showThumbnail ?
<svg
aria-hidden="true"
className="uppy:w-3/4 uppy:h-3/4"
viewBox="0 0 25 25"
>
<g fill="#686DE0" fill-rule="evenodd">
<path
d="M5 7v10h15V7H5zm0-1h15a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"
fill-rule="nonzero"
/>
<path
d="M6.35 17.172l4.994-5.026a.5.5 0 0 1 .707 0l2.16 2.16 3.505-3.505a.5.5 0 0 1 .707 0l2.336 2.31-.707.72-1.983-1.97-3.505 3.505a.5.5 0 0 1-.707 0l-2.16-2.159-3.938 3.939-1.409.026z"
fill-rule="nonzero"
/>
<circle cx="7.5" cy="9.5" r="1.5" />
</g>
</svg>
: null}
{fileTypeGeneral === 'audio' ?
<svg
aria-hidden="true"
className="uppy:w-3/4 uppy:h-3/4"
viewBox="0 0 25 25"
>
<path
d="M9.5 18.64c0 1.14-1.145 2-2.5 2s-2.5-.86-2.5-2c0-1.14 1.145-2 2.5-2 .557 0 1.079.145 1.5.396V7.25a.5.5 0 0 1 .379-.485l9-2.25A.5.5 0 0 1 18.5 5v11.64c0 1.14-1.145 2-2.5 2s-2.5-.86-2.5-2c0-1.14 1.145-2 2.5-2 .557 0 1.079.145 1.5.396V8.67l-8 2v7.97zm8-11v-2l-8 2v2l8-2zM7 19.64c.855 0 1.5-.484 1.5-1s-.645-1-1.5-1-1.5.484-1.5 1 .645 1 1.5 1zm9-2c.855 0 1.5-.484 1.5-1s-.645-1-1.5-1-1.5.484-1.5 1 .645 1 1.5 1z"
fill="#049BCF"
fill-rule="nonzero"
/>
</svg>
: null}
{fileTypeGeneral === 'video' ?
<svg
aria-hidden="true"
className="uppy:w-3/4 uppy:h-3/4"
viewBox="0 0 25 25"
>
<path
d="M16 11.834l4.486-2.691A1 1 0 0 1 22 10v6a1 1 0 0 1-1.514.857L16 14.167V17a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v2.834zM15 9H5v8h10V9zm1 4l5 3v-6l-5 3z"
fill="#19AF67"
fill-rule="nonzero"
/>
</svg>
: null}
{isPDF ?
<svg
aria-hidden="true"
className="uppy:w-3/4 uppy:h-3/4"
viewBox="0 0 25 25"
>
<path
d="M9.766 8.295c-.691-1.843-.539-3.401.747-3.726 1.643-.414 2.505.938 2.39 3.299-.039.79-.194 1.662-.537 3.148.324.49.66.967 1.055 1.51.17.231.382.488.629.757 1.866-.128 3.653.114 4.918.655 1.487.635 2.192 1.685 1.614 2.84-.566 1.133-1.839 1.084-3.416.249-1.141-.604-2.457-1.634-3.51-2.707a13.467 13.467 0 0 0-2.238.426c-1.392 4.051-4.534 6.453-5.707 4.572-.986-1.58 1.38-4.206 4.914-5.375.097-.322.185-.656.264-1.001.08-.353.306-1.31.407-1.737-.678-1.059-1.2-2.031-1.53-2.91zm2.098 4.87c-.033.144-.068.287-.104.427l.033-.01-.012.038a14.065 14.065 0 0 1 1.02-.197l-.032-.033.052-.004a7.902 7.902 0 0 1-.208-.271c-.197-.27-.38-.526-.555-.775l-.006.028-.002-.003c-.076.323-.148.632-.186.8zm5.77 2.978c1.143.605 1.832.632 2.054.187.26-.519-.087-1.034-1.113-1.473-.911-.39-2.175-.608-3.55-.608.845.766 1.787 1.459 2.609 1.894zM6.559 18.789c.14.223.693.16 1.425-.413.827-.648 1.61-1.747 2.208-3.206-2.563 1.064-4.102 2.867-3.633 3.62zm5.345-10.97c.088-1.793-.351-2.48-1.146-2.28-.473.119-.564 1.05-.056 2.405.213.566.52 1.188.908 1.859.18-.858.268-1.453.294-1.984z"
fill="#E2514A"
fill-rule="nonzero"
/>
</svg>
: null}
{isArchive ?
<svg
aria-hidden="true"
className="uppy:w-3/4 uppy:h-3/4"
viewBox="0 0 25 25"
>
<path
d="M10.45 2.05h1.05a.5.5 0 0 1 .5.5v.024a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5V2.55a.5.5 0 0 1 .5-.5zm2.05 1.024h1.05a.5.5 0 0 1 .5.5V3.6a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5v-.001zM10.45 0h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5V.5a.5.5 0 0 1 .5-.5zm2.05 1.025h1.05a.5.5 0 0 1 .5.5v.024a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.024a.5.5 0 0 1 .5-.5zm-2.05 3.074h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm2.05 1.025h1.05a.5.5 0 0 1 .5.5v.024a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.024a.5.5 0 0 1 .5-.5zm-2.05 1.024h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm2.05 1.025h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm-2.05 1.025h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm2.05 1.025h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm-2.05 1.025h1.05a.5.5 0 0 1 .5.5v.025a.5.5 0 0 1-.5.5h-1.05a.5.5 0 0 1-.5-.5v-.025a.5.5 0 0 1 .5-.5zm2.05 1.025h1.05a.5.5 0 0 1 .5.5v.024a.5.5 0 0 1-.5.5H12.5a.5.5 0 0 1-.5-.5v-.024a.5.5 0 0 1 .5-.5zm-1.656 3.074l-.82 5.946c.52.302 1.174.458 1.976.458.803 0 1.455-.156 1.975-.458l-.82-5.946h-2.311zm0-1.025h2.312c.512 0 .946.378 1.015.885l.82 5.946c.056.412-.142.817-.501 1.026-.686.398-1.515.597-2.49.597-.974 0-1.804-.199-2.49-.597a1.025 1.025 0 0 1-.5-1.026l.819-5.946c.07-.507.503-.885 1.015-.885zm.545 6.6a.5.5 0 0 1-.397-.561l.143-.999a.5.5 0 0 1 .495-.429h.74a.5.5 0 0 1 .495.43l.143.998a.5.5 0 0 1-.397.561c-.404.08-.819.08-1.222 0z"
fill="#00C469"
fill-rule="nonzero"
/>
</svg>
: null}
{props.file.type && !fileTypeGeneral && !isPDF && !isArchive ?
<svg
aria-hidden="true"
className="uppy:w-3/4 uppy:h-3/4"
viewBox="0 0 25 25"
>
<g fill="#A7AFB7" fill-rule="nonzero">
<path d="M5.5 22a.5.5 0 0 1-.5-.5v-18a.5.5 0 0 1 .5-.5h10.719a.5.5 0 0 1 .367.16l3.281 3.556a.5.5 0 0 1 .133.339V21.5a.5.5 0 0 1-.5.5h-14zm.5-1h13V7.25L16 4H6v17z" />
<path d="M15 4v3a1 1 0 0 0 1 1h3V7h-3V4h-1z" />
</g>
</svg>
: null}
</div>
</div>
: null}
</div>
)
}

View file

@ -0,0 +1,105 @@
/* eslint-disable no-nested-ternary */
/* eslint-disable react/destructuring-assignment */
import { h } from 'preact'
import { clsx } from 'clsx'
import type { UppyContext } from './types'
export type UploadButtonProps = {
ctx: UppyContext
}
export default function UploadButton(props: UploadButtonProps) {
const { ctx } = props
return (
<div className="uppy-reset uppy:space-y-2">
<button
type="button"
data-uppy-element="upload-button"
data-state={ctx.status}
onClick={() => {
if (ctx.status === 'ready') {
ctx.uppy?.upload()
}
}}
className={clsx(
'uppy:relative uppy:w-full uppy:p-2 uppy:rounded-lg',
'uppy:text-white uppy:font-medium',
'uppy:transition-all uppy:overflow-hidden',
'uppy:bg-blue-500 uppy:hover:bg-blue-600',
{
'uppy:bg-red-500 uppy:hover:bg-red-600': ctx.status === 'error',
'uppy:bg-green-500 uppy:hover:bg-green-600':
ctx.status === 'complete',
},
'uppy:disabled:hover:bg-blue-500 uppy:disabled:cursor-not-allowed',
)}
disabled={
ctx.status === 'init' ||
ctx.status === 'uploading' ||
ctx.status === 'paused'
}
>
<div
className={clsx(
'uppy:absolute uppy:inset-0 uppy:origin-left uppy:transition-all',
{
'uppy:bg-red-700': ctx.status === 'error',
'uppy:bg-green-700': ctx.status === 'complete',
'uppy:bg-blue-700':
ctx.status !== 'error' && ctx.status !== 'complete',
},
)}
style={{
transform: `scaleX(${ctx.progress / 100})`,
}}
/>
<span className="uppy:relative uppy:z-10">
{ctx.status === 'uploading' || ctx.status === 'paused' ?
`Uploaded ${Math.round(ctx.progress)}%`
: ctx.status === 'error' ?
'Retry'
: ctx.status === 'complete' ?
'Complete'
: 'Upload'}
</span>
</button>
{ctx.status === 'uploading' || ctx.status === 'paused' ?
<div className="uppy:flex uppy:gap-2">
{ctx.uppy?.getState().capabilities.resumableUploads ?
<button
type="button"
data-uppy-element="pause-button"
data-state={ctx.status}
onClick={() => {
if (ctx.status === 'paused') {
ctx.uppy?.resumeAll()
} else {
ctx.uppy?.pauseAll()
}
}}
className={clsx(
'uppy:w-full uppy:p-2 uppy:rounded-lg uppy:text-amber-500 uppy:bg-gray-50 uppy:hover:bg-amber-50 uppy:font-medium uppy:transition-all',
{
'uppy:text-green-500 uppy:hover:bg-green-50':
ctx.status === 'paused',
},
)}
>
{ctx.status === 'paused' ? 'Resume' : 'Pause'}
</button>
: null}
<button
type="button"
data-uppy-element="cancel-button"
className="uppy:w-full uppy:p-2 uppy:rounded-lg uppy:text-red-500 uppy:bg-gray-50 uppy:hover:bg-red-50 uppy:font-medium uppy:transition-all"
data-state={ctx.status}
onClick={() => ctx.uppy?.cancelAll()}
>
Cancel
</button>
</div>
: null}
</div>
)
}

View file

@ -0,0 +1,17 @@
export { default as Thumbnail, type ThumbnailProps } from './Thumbnail.js'
export { default as FilesList, type FilesListProps } from './FilesList.js'
export { default as FilesGrid, type FilesGridProps } from './FilesGrid.js'
export { default as Dropzone, type DropzoneProps } from './Dropzone.js'
export {
default as UploadButton,
type UploadButtonProps,
} from './UploadButton.js'
export {
default as ProviderIcon,
type ProviderIconProps,
} from './ProviderIcon.js'
export type { UppyContext } from './types.js'
export { createUppyEventAdapter } from './uppyEventAdapter.js'
export type { UploadStatus } from './types.js'

View file

@ -0,0 +1,66 @@
@layer theme, base, components, utilities;
/*
* Split up tailwind imports to not import the preflight styles
* as we don't want our components to affect the entire website of the user.
*/
@import 'tailwindcss/theme.css' layer(theme) prefix(uppy);
@import 'tailwindcss/utilities.css' layer(utilities) prefix(uppy);
@import '@uppy/core/dist/style.min.css';
@import '@uppy/image-editor/dist/style.min.css';
@import '@uppy/webcam/dist/style.min.css';
@import '@uppy/audio/dist/style.min.css';
@import '@uppy/screen-capture/dist/style.min.css';
/*
* Instead we took the preflight styles from tailwind and apply it under a .uppy-reset class
* so we can enjoy the benefits of preflight styles without affecting the rest of the website.
* Put under a @layer to prevent specificity issues.
*/
@layer components {
.uppy-reset,
.uppy-reset * {
box-sizing: border-box;
margin: 0;
padding: 0;
border: 0 solid;
}
.uppy-reset {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
tab-size: 4;
font-family: ui-sans-serif, system-ui, sans-serif;
}
.uppy-reset h1,
.uppy-reset h2,
.uppy-reset h3,
.uppy-reset h4,
.uppy-reset h5,
.uppy-reset h6 {
font-size: inherit;
font-weight: inherit;
}
.uppy-reset ol,
.uppy-reset ul,
.uppy-reset menu {
list-style: none;
}
.uppy-reset img,
.uppy-reset video {
max-width: 100%;
height: auto;
}
.uppy-reset button,
.uppy-reset input,
.uppy-reset select,
.uppy-reset textarea {
font: inherit;
color: inherit;
}
}

View file

@ -0,0 +1,15 @@
import type Uppy from '@uppy/core'
export type UploadStatus =
| 'init'
| 'ready'
| 'uploading'
| 'paused'
| 'error'
| 'complete'
export type UppyContext = {
uppy: Uppy | undefined
status: UploadStatus
progress: number
}

View file

@ -0,0 +1,62 @@
import Uppy from '@uppy/core'
import type { UploadStatus } from './types'
export function createUppyEventAdapter({
uppy,
onStatusChange,
onProgressChange,
}: {
uppy: Uppy
onStatusChange: (status: UploadStatus) => void
onProgressChange: (progress: number) => void
}): { cleanup: () => void } {
const onFileAdded = () => {
onStatusChange('ready')
}
const onUploadStarted = () => {
onStatusChange('uploading')
}
const onResumeAll = () => {
onStatusChange('uploading')
}
const onComplete = () => {
onStatusChange('complete')
onProgressChange(0)
}
const onError = () => {
onStatusChange('error')
onProgressChange(0)
}
const onCancelAll = () => {
onStatusChange('init')
onProgressChange(0)
}
const onPauseAll = () => {
onStatusChange('paused')
}
const onProgress = (p: number) => {
onProgressChange(p)
}
uppy.on('file-added', onFileAdded)
uppy.on('progress', onProgress)
uppy.on('upload', onUploadStarted)
uppy.on('complete', onComplete)
uppy.on('error', onError)
uppy.on('cancel-all', onCancelAll)
uppy.on('pause-all', onPauseAll)
uppy.on('resume-all', onResumeAll)
return {
cleanup: () => {
uppy.off('file-added', onFileAdded)
uppy.off('progress', onProgress)
uppy.off('upload', onUploadStarted)
uppy.off('complete', onComplete)
uppy.off('error', onError)
uppy.off('cancel-all', onCancelAll)
uppy.off('pause-all', onPauseAll)
uppy.off('resume-all', onResumeAll)
},
}
}

View file

@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.shared",
"compilerOptions": {
"outDir": "./lib",
"resolveJsonModule": false,
"rootDir": "./src"
},
"include": ["./src/**/*.*"],
"exclude": ["./src/**/*.test.ts"],
"references": [
{
"path": "../audio/tsconfig.build.json"
},
{
"path": "../core/tsconfig.build.json"
},
{
"path": "../google-drive-picker/tsconfig.build.json"
},
{
"path": "../image-editor/tsconfig.build.json"
},
{
"path": "../utils/tsconfig.build.json"
},
{
"path": "../core/tsconfig.build.json"
}
]
}

View file

@ -0,0 +1,28 @@
{
"extends": "../../../tsconfig.shared",
"compilerOptions": {
"emitDeclarationOnly": false,
"noEmit": true,
},
"include": ["./package.json", "./src/**/*.*"],
"references": [
{
"path": "../audio/tsconfig.build.json",
},
{
"path": "../core/tsconfig.build.json",
},
{
"path": "../google-drive-picker/tsconfig.build.json",
},
{
"path": "../image-editor/tsconfig.build.json",
},
{
"path": "../utils/tsconfig.build.json",
},
{
"path": "../core/tsconfig.build.json",
},
],
}

View file

@ -1,5 +1,5 @@
/* eslint-disable class-methods-use-this */
import { render } from 'preact'
import { render } from 'preact/compat'
import findDOMElement from '@uppy/utils/lib/findDOMElement'
import getTextDirection from '@uppy/utils/lib/getTextDirection'

View file

@ -21,7 +21,9 @@
"url": "git+https://github.com/transloadit/uppy.git"
},
"dependencies": {
"@uppy/components": "workspace:^",
"@uppy/utils": "workspace:^",
"preact": "^10.5.13",
"use-sync-external-store": "^1.2.0"
},
"devDependencies": {
@ -38,8 +40,11 @@
"@uppy/drag-drop": "workspace:^",
"@uppy/file-input": "workspace:^",
"@uppy/progress-bar": "workspace:^",
"@uppy/screen-capture": "workspace:^",
"@uppy/status-bar": "workspace:^",
"react": "^18.0.0 || ^19.0.0"
"@uppy/webcam": "workspace:^",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@uppy/dashboard": {
@ -54,8 +59,14 @@
"@uppy/progress-bar": {
"optional": true
},
"@uppy/screen-capture": {
"optional": true
},
"@uppy/status-bar": {
"optional": true
},
"@uppy/webcam": {
"optional": true
}
}
}

View file

@ -0,0 +1,27 @@
import { useEffect, useRef, useContext, createElement as h } from 'react'
import {
Dropzone as PreactDropzone,
type DropzoneProps,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContext } from './UppyContextProvider.js'
export default function Dropzone(props: Omit<DropzoneProps, 'ctx'>) {
const ref = useRef(null)
const ctx = useContext(UppyContext)
useEffect(() => {
if (ref.current) {
preactRender(
preactH(PreactDropzone, {
...props,
ctx,
} satisfies DropzoneProps),
ref.current,
)
}
}, [ctx, props])
return <div ref={ref} />
}

View file

@ -0,0 +1,27 @@
import { useEffect, useRef, useContext, createElement as h } from 'react'
import {
FilesGrid as PreactFilesGrid,
type FilesGridProps,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContext } from './UppyContextProvider.js'
export default function FilesGrid(props: Omit<FilesGridProps, 'ctx'>) {
const ref = useRef(null)
const ctx = useContext(UppyContext)
useEffect(() => {
if (ref.current) {
preactRender(
preactH(PreactFilesGrid, {
...props,
ctx,
} satisfies FilesGridProps),
ref.current,
)
}
}, [ctx, props])
return <div ref={ref} />
}

View file

@ -0,0 +1,27 @@
import { useEffect, useRef, useContext, createElement as h } from 'react'
import {
FilesList as PreactFilesList,
type FilesListProps,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContext } from './UppyContextProvider.js'
export default function FilesList(props: Omit<FilesListProps, 'ctx'>) {
const ref = useRef(null)
const ctx = useContext(UppyContext)
useEffect(() => {
if (ref.current) {
preactRender(
preactH(PreactFilesList, {
...props,
ctx,
} satisfies FilesListProps),
ref.current,
)
}
}, [ctx, props])
return <div ref={ref} />
}

View file

@ -0,0 +1,27 @@
import { useEffect, useRef, useContext, createElement as h } from 'react'
import {
ProviderIcon as PreactProviderIcon,
type ProviderIconProps,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContext } from './UppyContextProvider.js'
export default function ProviderIcon(props: Omit<ProviderIconProps, 'ctx'>) {
const ref = useRef(null)
const ctx = useContext(UppyContext)
useEffect(() => {
if (ref.current) {
preactRender(
preactH(PreactProviderIcon, {
...props,
ctx,
} satisfies ProviderIconProps),
ref.current,
)
}
}, [ctx, props])
return <div ref={ref} />
}

View file

@ -0,0 +1,27 @@
import { useEffect, useRef, useContext, createElement as h } from 'react'
import {
Thumbnail as PreactThumbnail,
type ThumbnailProps,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContext } from './UppyContextProvider.js'
export default function Thumbnail(props: Omit<ThumbnailProps, 'ctx'>) {
const ref = useRef(null)
const ctx = useContext(UppyContext)
useEffect(() => {
if (ref.current) {
preactRender(
preactH(PreactThumbnail, {
...props,
ctx,
} satisfies ThumbnailProps),
ref.current,
)
}
}, [ctx, props])
return <div ref={ref} />
}

View file

@ -0,0 +1,27 @@
import { useEffect, useRef, useContext, createElement as h } from 'react'
import {
UploadButton as PreactUploadButton,
type UploadButtonProps,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContext } from './UppyContextProvider.js'
export default function UploadButton(props: Omit<UploadButtonProps, 'ctx'>) {
const ref = useRef(null)
const ctx = useContext(UppyContext)
useEffect(() => {
if (ref.current) {
preactRender(
preactH(PreactUploadButton, {
...props,
ctx,
} satisfies UploadButtonProps),
ref.current,
)
}
}, [ctx, props])
return <div ref={ref} />
}

View file

@ -0,0 +1,64 @@
import React, {
createContext,
useState,
useEffect,
createElement as h,
} from 'react'
import type Uppy from '@uppy/core'
import { createUppyEventAdapter, type UploadStatus } from '@uppy/components'
interface UppyContextValue {
uppy: Uppy | undefined
status: UploadStatus
progress: number
}
export const UppyContext = createContext<UppyContextValue>({
uppy: undefined,
status: 'init',
progress: 0,
})
interface Props {
uppy: Uppy
children: React.ReactNode
}
export function UppyContextProvider({ uppy, children }: Props) {
const [status, setStatus] = useState<UploadStatus>('init')
const [progress, setProgress] = useState(0)
useEffect(() => {
if (!uppy) {
throw new Error(
'UppyContextProvider: passing `uppy` as a prop is required',
)
}
const uppyEventAdapter = createUppyEventAdapter({
uppy,
onStatusChange: (newStatus: UploadStatus) => {
setStatus(newStatus)
},
onProgressChange: (newProgress: number) => {
setProgress(newProgress)
},
})
return () => uppyEventAdapter.cleanup()
}, [uppy])
return (
<UppyContext.Provider
value={{
uppy,
status,
progress,
}}
>
{children}
</UppyContext.Provider>
)
}
export default UppyContextProvider

View file

@ -0,0 +1,6 @@
export { default as Dropzone } from './Dropzone.js'
export { default as FilesGrid } from './FilesGrid.js'
export { default as FilesList } from './FilesList.js'
export { default as ProviderIcon } from './ProviderIcon.js'
export { default as Thumbnail } from './Thumbnail.js'
export { default as UploadButton } from './UploadButton.js'

View file

@ -6,3 +6,10 @@ export { default as StatusBar } from './StatusBar.js'
export { default as FileInput } from './FileInput.js'
export { default as useUppyState } from './useUppyState.js'
export { default as useUppyEvent } from './useUppyEvent.js'
// Headless components
export {
UppyContext,
UppyContextProvider,
} from './headless/UppyContextProvider.js'
export * from './headless/index.js'

View file

@ -16,6 +16,9 @@
{
"path": "../core/tsconfig.build.json"
},
{
"path": "../components/tsconfig.build.json"
},
{
"path": "../dashboard/tsconfig.build.json"
},
@ -30,6 +33,12 @@
},
{
"path": "../status-bar/tsconfig.build.json"
},
{
"path": "../screen-capture/tsconfig.build.json"
},
{
"path": "../webcam/tsconfig.build.json"
}
]
}

View file

@ -14,6 +14,9 @@
{
"path": "../core/tsconfig.build.json",
},
{
"path": "../components/tsconfig.build.json",
},
{
"path": "../dashboard/tsconfig.build.json",
},
@ -29,5 +32,11 @@
{
"path": "../status-bar/tsconfig.build.json",
},
{
"path": "../screen-capture/tsconfig.build.json",
},
{
"path": "../webcam/tsconfig.build.json",
},
],
}

View file

@ -1,5 +1,4 @@
.DS_Store
/dist/
/src/empty.*
.svelte-kit
!src/lib

View file

@ -25,6 +25,7 @@
"dist"
],
"exports": {
"./dist/styles.css": "./dist/styles.css",
".": {
"types": "./dist/index.d.ts",
"svelte": "./dist/index.js"
@ -35,16 +36,20 @@
"prepublishOnly": "yarn run package",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"@uppy/components": "workspace:^",
"preact": "^10.26.5"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.8.3",
"@sveltejs/package": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^5.4.17"
"@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.20.7",
"@sveltejs/package": "^2.3.11",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"svelte": "^5.27.0",
"svelte-check": "^4.1.6",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"vite": "^6.3.0"
},
"peerDependencies": {
"@uppy/core": "workspace:^",

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { getContext, mount } from 'svelte'
import {
Dropzone as PreactDropzone,
type DropzoneProps,
type UppyContext,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContextKey } from './UppyContextProvider.svelte'
const props: Omit<DropzoneProps, 'ctx'> = $props()
const ctx = getContext<UppyContext>(UppyContextKey)
let container: HTMLElement
$effect(() => {
if (container) {
preactRender(
preactH(PreactDropzone, {
...props,
ctx,
} satisfies DropzoneProps),
container,
)
}
})
</script>
<div bind:this={container}></div>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { getContext, mount } from 'svelte'
import {
FilesGrid as PreactFilesGrid,
type FilesGridProps,
type UppyContext,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContextKey } from './UppyContextProvider.svelte'
const props: Omit<FilesGridProps, 'ctx'> = $props()
const ctx = getContext<UppyContext>(UppyContextKey)
let container: HTMLElement
$effect(() => {
if (container) {
preactRender(
preactH(PreactFilesGrid, {
...props,
ctx,
} satisfies FilesGridProps),
container,
)
}
})
</script>
<div bind:this={container}></div>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { getContext, mount } from 'svelte'
import {
FilesList as PreactFilesList,
type FilesListProps,
type UppyContext,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContextKey } from './UppyContextProvider.svelte'
const props: Omit<FilesListProps, 'ctx'> = $props()
const ctx = getContext<UppyContext>(UppyContextKey)
let container: HTMLElement
$effect(() => {
if (container) {
preactRender(
preactH(PreactFilesList, {
...props,
ctx,
} satisfies FilesListProps),
container,
)
}
})
</script>
<div bind:this={container}></div>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { getContext, mount } from 'svelte'
import {
ProviderIcon as PreactProviderIcon,
type ProviderIconProps,
type UppyContext,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContextKey } from './UppyContextProvider.svelte'
const props: Omit<ProviderIconProps, 'ctx'> = $props()
const ctx = getContext<UppyContext>(UppyContextKey)
let container: HTMLElement
$effect(() => {
if (container) {
preactRender(
preactH(PreactProviderIcon, {
...props,
ctx,
} satisfies ProviderIconProps),
container,
)
}
})
</script>
<div bind:this={container}></div>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { getContext, mount } from 'svelte'
import {
Thumbnail as PreactThumbnail,
type ThumbnailProps,
type UppyContext,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContextKey } from './UppyContextProvider.svelte'
const props: Omit<ThumbnailProps, 'ctx'> = $props()
const ctx = getContext<UppyContext>(UppyContextKey)
let container: HTMLElement
$effect(() => {
if (container) {
preactRender(
preactH(PreactThumbnail, {
...props,
ctx,
} satisfies ThumbnailProps),
container,
)
}
})
</script>
<div bind:this={container}></div>

View file

@ -0,0 +1,29 @@
<script lang="ts">
import { getContext, mount } from 'svelte'
import {
UploadButton as PreactUploadButton,
type UploadButtonProps,
type UppyContext,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { UppyContextKey } from './UppyContextProvider.svelte'
const props: Omit<UploadButtonProps, 'ctx'> = $props()
const ctx = getContext<UppyContext>(UppyContextKey)
let container: HTMLElement
$effect(() => {
if (container) {
preactRender(
preactH(PreactUploadButton, {
...props,
ctx,
} satisfies UploadButtonProps),
container,
)
}
})
</script>
<div bind:this={container}></div>

View file

@ -0,0 +1,48 @@
<script context="module" lang="ts">
import type Uppy from '@uppy/core';
import { createUppyEventAdapter, type UploadStatus } from '@uppy/components'
export const UppyContextKey = 'uppy-context';
export type { UppyContext } from '@uppy/components';
</script>
<script lang="ts">
import { setContext, onMount } from 'svelte';
export let uppy: Uppy;
let status: UploadStatus = 'init';
let progress = 0;
onMount(() => {
if (!uppy) {
throw new Error(
'ContextProvider: passing `uppy` as a prop is required',
);
}
const uppyEventAdapter = createUppyEventAdapter({
uppy,
onStatusChange: (newStatus: UploadStatus) => {
status = newStatus
},
onProgressChange: (newProgress: number) => {
progress = newProgress
},
})
return () => uppyEventAdapter.cleanup()
});
// Create a reactive store from our context values
$: contextValue = {
uppy,
status,
progress,
};
// Set the context for child components to use
$: setContext(UppyContextKey, contextValue);
</script>
<slot />

View file

@ -0,0 +1,6 @@
export { default as Dropzone } from './Dropzone.svelte'
export { default as FilesGrid } from './FilesGrid.svelte'
export { default as FilesList } from './FilesList.svelte'
export { default as ProviderIcon } from './ProviderIcon.svelte'
export { default as Thumbnail } from './Thumbnail.svelte'
export { default as UploadButton } from './UploadButton.svelte'

View file

@ -3,3 +3,7 @@ export { default as DashboardModal } from './components/DashboardModal.svelte'
export { default as DragDrop } from './components/DragDrop.svelte'
export { default as ProgressBar } from './components/ProgressBar.svelte'
export { default as StatusBar } from './components/StatusBar.svelte'
// Headless components
export { default as UppyContextProvider } from './components/headless/UppyContextProvider.svelte'
export * from './components/headless/index.js'

View file

@ -1,6 +1,5 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import adapter from '@sveltejs/adapter-auto'
// eslint-disable-next-line import/no-unresolved
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import('@sveltejs/kit').Config} */

View file

@ -1,4 +1,3 @@
// eslint-disable-next-line import/no-unresolved
import { sveltekit } from '@sveltejs/kit/vite'
// eslint-disable-next-line import/no-extraneous-dependencies
import { defineConfig } from 'vite'

View file

@ -5,6 +5,8 @@
"type": "module",
"main": "lib/index.js",
"dependencies": {
"@uppy/components": "workspace:^",
"preact": "^10.5.13",
"shallow-equal": "^3.0.0"
},
"devDependencies": {

View file

@ -0,0 +1,48 @@
import { defineComponent, ref, watch, onMounted, h } from 'vue'
import {
Dropzone as PreactDropzone,
type DropzoneProps,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { shallowEqualObjects } from 'shallow-equal'
import { useUppyContext } from './useUppyContext.js'
export default defineComponent<Omit<DropzoneProps, 'ctx'>>({
name: 'Dropzone',
setup(props, { attrs }) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
function renderDropzone() {
if (containerRef.value) {
preactRender(
preactH(PreactDropzone, {
...(attrs as DropzoneProps),
ctx,
} satisfies DropzoneProps),
containerRef.value,
)
}
}
onMounted(() => {
renderDropzone()
})
watch(ctx, () => {
renderDropzone()
})
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
renderDropzone()
}
},
)
return () => h('div', { ref: containerRef })
},
})

View file

@ -0,0 +1,48 @@
import { defineComponent, ref, watch, onMounted, h } from 'vue'
import {
FilesGrid as PreactFilesGrid,
type FilesGridProps,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { shallowEqualObjects } from 'shallow-equal'
import { useUppyContext } from './useUppyContext.js'
export default defineComponent<Omit<FilesGridProps, 'ctx'>>({
name: 'FilesGrid',
setup(props, { attrs }) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
function renderFilesGrid() {
if (containerRef.value) {
preactRender(
preactH(PreactFilesGrid, {
...(attrs as FilesGridProps),
ctx,
} satisfies FilesGridProps),
containerRef.value,
)
}
}
onMounted(() => {
renderFilesGrid()
})
watch(ctx, () => {
renderFilesGrid()
})
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
renderFilesGrid()
}
},
)
return () => h('div', { ref: containerRef })
},
})

View file

@ -0,0 +1,48 @@
import { defineComponent, ref, watch, onMounted, h } from 'vue'
import {
FilesList as PreactFilesList,
type FilesListProps,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { shallowEqualObjects } from 'shallow-equal'
import { useUppyContext } from './useUppyContext.js'
export default defineComponent<Omit<FilesListProps, 'ctx'>>({
name: 'FilesList',
setup(props, { attrs }) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
function renderFilesList() {
if (containerRef.value) {
preactRender(
preactH(PreactFilesList, {
...(attrs as FilesListProps),
ctx,
} satisfies FilesListProps),
containerRef.value,
)
}
}
onMounted(() => {
renderFilesList()
})
watch(ctx, () => {
renderFilesList()
})
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
renderFilesList()
}
},
)
return () => h('div', { ref: containerRef })
},
})

View file

@ -0,0 +1,48 @@
import { defineComponent, ref, watch, onMounted, h } from 'vue'
import {
ProviderIcon as PreactProviderIcon,
type ProviderIconProps,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { shallowEqualObjects } from 'shallow-equal'
import { useUppyContext } from './useUppyContext.js'
export default defineComponent<Omit<ProviderIconProps, 'ctx'>>({
name: 'ProviderIcon',
setup(props, { attrs }) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
function renderProviderIcon() {
if (containerRef.value) {
preactRender(
preactH(PreactProviderIcon, {
...(attrs as ProviderIconProps),
ctx,
} satisfies ProviderIconProps),
containerRef.value,
)
}
}
onMounted(() => {
renderProviderIcon()
})
watch(ctx, () => {
renderProviderIcon()
})
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
renderProviderIcon()
}
},
)
return () => h('div', { ref: containerRef })
},
})

View file

@ -0,0 +1,48 @@
import { defineComponent, ref, watch, onMounted, h } from 'vue'
import {
Thumbnail as PreactThumbnail,
type ThumbnailProps,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { shallowEqualObjects } from 'shallow-equal'
import { useUppyContext } from './useUppyContext.js'
export default defineComponent<Omit<ThumbnailProps, 'ctx'>>({
name: 'Thumbnail',
setup(props, { attrs }) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
function renderThumbnail() {
if (containerRef.value) {
preactRender(
preactH(PreactThumbnail, {
...(attrs as ThumbnailProps),
ctx,
} satisfies ThumbnailProps),
containerRef.value,
)
}
}
onMounted(() => {
renderThumbnail()
})
watch(ctx, () => {
renderThumbnail()
})
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
renderThumbnail()
}
},
)
return () => h('div', { ref: containerRef })
},
})

View file

@ -0,0 +1,48 @@
import { defineComponent, ref, watch, onMounted, h } from 'vue'
import {
UploadButton as PreactUploadButton,
type UploadButtonProps,
} from '@uppy/components'
import { h as preactH } from 'preact'
import { render as preactRender } from 'preact/compat'
import { shallowEqualObjects } from 'shallow-equal'
import { useUppyContext } from './useUppyContext.js'
export default defineComponent<Omit<UploadButtonProps, 'ctx'>>({
name: 'UploadButton',
setup(props, { attrs }) {
const containerRef = ref<HTMLElement | null>(null)
const ctx = useUppyContext()
function renderUploadButton() {
if (containerRef.value) {
preactRender(
preactH(PreactUploadButton, {
...(attrs as UploadButtonProps),
ctx,
} satisfies UploadButtonProps),
containerRef.value,
)
}
}
onMounted(() => {
renderUploadButton()
})
watch(ctx, () => {
renderUploadButton()
})
watch(
() => props,
(current, old) => {
if (!shallowEqualObjects(current, old)) {
renderUploadButton()
}
},
)
return () => h('div', { ref: containerRef })
},
})

View file

@ -0,0 +1,73 @@
import {
defineComponent,
provide,
reactive,
ref,
onMounted,
onBeforeUnmount,
} from 'vue'
import type { PropType } from 'vue'
import type Uppy from '@uppy/core'
import { createUppyEventAdapter } from '@uppy/components'
import type { UploadStatus } from '@uppy/components'
export interface UppyContext {
uppy: Uppy | undefined
status: UploadStatus
progress: number
}
export const UppyContextSymbol = Symbol('uppy')
export const UppyContextProvider = defineComponent({
name: 'UppyContextProvider',
props: {
uppy: {
type: Object as PropType<Uppy>,
required: true,
},
},
setup(props, { slots }) {
const status = ref<UploadStatus>('init')
const progress = ref(0)
const uppyContext = reactive<UppyContext>({
uppy: props.uppy,
status: 'init',
progress: 0,
})
// Provide the context immediately instead of in onMounted
provide(UppyContextSymbol, uppyContext)
let uppyEventAdapter: ReturnType<typeof createUppyEventAdapter> | undefined
onMounted(() => {
if (!props.uppy) {
throw new Error(
'UppyContextProvider: passing `uppy` as a prop is required',
)
}
uppyEventAdapter = createUppyEventAdapter({
uppy: props.uppy,
onStatusChange: (newStatus: UploadStatus) => {
status.value = newStatus
uppyContext.status = newStatus
},
onProgressChange: (newProgress: number) => {
progress.value = newProgress
uppyContext.progress = newProgress
},
})
})
onBeforeUnmount(() => {
uppyEventAdapter?.cleanup()
})
return () => slots.default?.()
},
})
export default UppyContextProvider

View file

@ -0,0 +1,6 @@
export { default as Dropzone } from './Dropzone.js'
export { default as FilesGrid } from './FilesGrid.js'
export { default as FilesList } from './FilesList.js'
export { default as ProviderIcon } from './ProviderIcon.js'
export { default as Thumbnail } from './Thumbnail.js'
export { default as UploadButton } from './UploadButton.js'

View file

@ -0,0 +1,16 @@
import { inject } from 'vue'
import { UppyContextSymbol, type UppyContext } from './context-provider.js'
export function useUppyContext(): UppyContext {
const context = inject<UppyContext>(UppyContextSymbol)
if (!context) {
return {
uppy: undefined,
status: 'init',
progress: 0,
}
}
return context
}

Some files were not shown because too many files have changed in this diff Show more