feat(frontend): Add file input app component (#1211)

* feat(frontend): Add file input app component

* fix(frontend): Handle multiple file selects

* fix(frontend): File input styling
This commit is contained in:
Ádám Kovács
2023-02-20 18:05:15 +01:00
committed by GitHub
parent 75edeab35e
commit d4b6d69126
4 changed files with 202 additions and 3 deletions
@@ -0,0 +1,43 @@
<script lang="ts">
import { getContext } from 'svelte'
import { FileInput } from '../../../common'
import type { AppInput } from '../../inputType'
import type { Output } from '../../rx'
import type { AppEditorContext } from '../../types'
import InputValue from '../helpers/InputValue.svelte'
export let id: string
export let configuration: Record<string, AppInput>
export const staticOutputs: string[] = ['result']
const { worldStore } = getContext<AppEditorContext>('AppEditorContext')
let acceptedFileTypes: string[] | undefined = undefined
let allowMultiple: boolean | undefined = undefined
let text: string | undefined = undefined
$: outputs = $worldStore?.outputsById[id] as {
result: Output<string[] | undefined>
}
// Receives Base64 encoded strings from the input component
async function handleChange(files: string[] | undefined) {
outputs?.result.set(files)
}
</script>
<InputValue {id} input={configuration.acceptedFileTypes} bind:value={acceptedFileTypes} />
<InputValue {id} input={configuration.allowMultiple} bind:value={allowMultiple} />
<InputValue {id} input={configuration.text} bind:value={text} />
<div class="w-full h-full p-1">
<FileInput
accept={acceptedFileTypes?.length ? acceptedFileTypes?.join(', ') : undefined}
multiple={allowMultiple}
convertToBase64
on:change={({detail}) => {handleChange(detail)}}
class="w-full h-full"
>
{text}
</FileInput>
</div>
@@ -20,7 +20,8 @@
Smile,
DollarSign,
SeparatorHorizontal,
SeparatorVertical
SeparatorVertical,
Paperclip
} from 'lucide-svelte'
import type { Size } from 'svelte-grid'
import { twMerge } from 'tailwind-merge'
@@ -75,6 +76,7 @@
export type IconComponent = BaseComponent<'iconcomponent'>
export type HorizontalDividerComponent = BaseComponent<'horizontaldividercomponent'>
export type VerticalDividerComponent = BaseComponent<'verticaldividercomponent'>
export type FileInputComponent = BaseComponent<'fileinputcomponent'>
export type AppComponent = BaseAppComponent &
(
@@ -104,6 +106,7 @@
| IconComponent
| HorizontalDividerComponent
| VerticalDividerComponent
| FileInputComponent
)
// Dimensions key formula: <mobile width>:<mobile height>-<desktop width>:<desktop height>
@@ -1059,7 +1062,43 @@
customCss: {},
card: false
}
}
},
fileinputcomponent: {
name: 'File Input',
icon: Paperclip,
dims: '3:4-6:4',
data: {
id: '',
type: 'fileinputcomponent',
componentInput: undefined,
configuration: {
acceptedFileTypes: {
type: 'static',
value: [
'image/*',
'application/pdf'
],
fieldType: 'array',
onlyStatic: true
},
allowMultiple: {
type: 'static',
value: false,
fieldType: 'boolean',
tooltip: 'If allowed, the user will be able to select more than one file',
onlyStatic: true
},
text: {
type: 'static',
value: 'Drag and drop files or click to select them',
fieldType: 'text',
onlyStatic: true
},
},
customCss: {},
card: false
}
},
}
const inputs: ComponentSet = {
@@ -1072,6 +1111,7 @@
'slidercomponent',
'rangecomponent',
'dateinputcomponent',
'fileinputcomponent',
'checkboxcomponent',
'selectcomponent'
]
@@ -1441,7 +1481,6 @@
<script lang="ts">
import { getContext } from 'svelte'
import { fade } from 'svelte/transition'
import { classNames } from '$lib/utils'
import type { AppEditorContext, BaseAppComponent, ComponentSet } from '../types'
import { Loader2 } from 'lucide-svelte'
import AppBarChart from '../components/dataDisplay/AppBarChart.svelte'
@@ -1469,6 +1508,7 @@
import AppIcon from '../components/dataDisplay/AppIcon.svelte'
import AppCurrencyInput from '../components/numberInputs/AppCurrencyInput.svelte'
import AppDivider from '../components/AppDivider.svelte'
import AppFileInput from '../components/otherInputs/AppFileInput.svelte'
export let component: AppComponent
export let selected: boolean
@@ -1632,6 +1672,8 @@
<AppRangeInput {...component} bind:staticOutputs={$staticOutputs[component.id]} />
{:else if component.type === 'iconcomponent'}
<AppIcon {...component} bind:staticOutputs={$staticOutputs[component.id]} />
{:else if component.type === 'fileinputcomponent'}
<AppFileInput {...component} bind:staticOutputs={$staticOutputs[component.id]} />
{/if}
</div>
</div>
@@ -0,0 +1,113 @@
<script lang="ts">
import { createEventDispatcher } from "svelte"
import { FileUp } from "lucide-svelte"
import Button from "../../common/button/Button.svelte"
import { faTrash } from "@fortawesome/free-solid-svg-icons"
let c = ''
export { c as class }
export let accept = '*'
export let multiple = true
export let convertToBase64 = false
export let hideIcon = false
const dispatch = createEventDispatcher()
let input: HTMLInputElement
let files: File[] | undefined = undefined
async function onChange(fileList: FileList | null) {
if(!fileList || !fileList.length) {
files = undefined;
dispatch('change', files);
return;
}
if(!multiple || !files) {
files = []
}
for(let i = 0; i < fileList.length; i++) {
const file = fileList.item(i);
if(file) files.push(file);
}
// Needs to be reset so the same file can be selected
// multiple times in a row
input.value = ''
dispatchChange()
}
async function fileToBase64(file: File): Promise<string> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve(reader.result as string);
};
reader.readAsDataURL(file);
})
}
function removeFile(index: number) {
if(!files) return;
files.splice(index, 1)
files = files.length ? files : undefined
dispatchChange()
}
async function dispatchChange() {
files = files
if(convertToBase64 && files) {
const promises = files.map(fileToBase64);
const b64s = await Promise.all(promises);
dispatch('change', b64s);
} else {
dispatch('change', files);
}
}
</script>
<button
class="relative center-center flex-col text-center font-medium text-gray-600
border-2 border-dashed border-gray-400 hover:border-blue-500
focus-within:border-blue-500 hover:bg-blue-50 focus-within:bg-blue-50
duration-200 rounded-lg p-1 {c ?? ''}"
>
{#if !hideIcon && !files}
<FileUp size={36} class="mb-2" />
{/if}
{#if files}
<div class="w-full max-h-full overflow-auto px-6">
<div class="text-center mb-2 px-2">Selected file{files.length > 1 ? 's' : ''}:</div>
<ul class="relative z-20 max-w-[250px] bg-white rounded-lg overflow-hidden mx-auto">
{#each files as {name}, i}
<li class="flex justify-between items-center font-normal text-sm
hover:bg-gray-300/20 duration-200 py-1 px-2 cursor-default">
<span class="pr-2 ellipsize">{name}</span>
<Button
size="xs"
color="red"
variant="border"
iconOnly
btnClasses="bg-transparent"
startIcon={{ icon: faTrash }}
on:click={() => removeFile(i)}
/>
</li>
{/each}
</ul>
</div>
{:else}
<slot>
<span>Drag and drop files</span>
</slot>
{/if}
<input
class="!absolute !inset-0 !z-10 !opacity-0 !cursor-pointer"
type="file"
title={files ? `${files.length} file${files.length > 1 ? 's' : ''} chosen` : 'No file chosen'}
bind:this={input}
on:change={({currentTarget}) => {onChange(currentTarget.files)}}
{accept}
{multiple}
{...$$restProps}
/>
</button>
@@ -17,6 +17,7 @@ export { default as Tabs } from './tabs/Tabs.svelte'
export { default as ToggleButton } from './toggleButton/ToggleButton.svelte'
export { default as ToggleButtonGroup } from './toggleButton/ToggleButtonGroup.svelte'
export { default as Breadcrumb } from './breadcrumb/Breadcrumb.svelte'
export { default as FileInput } from './fileInput/FileInput.svelte'
export * from './alert/model'
export * from './badge/model'