Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions web-common/src/components/forms/FileUploader.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<script lang="ts">
import { AlertCircleIcon, FileText, Upload, X } from "lucide-svelte";

export let files: FileList | undefined;
export let error: string | Record<string | number, string[]> | undefined =
undefined;
export let multiple: boolean = false;
export let accept: string | undefined = undefined;
export let hint: string = "SVG, PNG, JPG or PDF (max. 10 MB)";

let fileInput: HTMLInputElement;
let dragOver = false;

$: errors = error ? (multiple ? error : { 0: error }) : [];
$: errorMessages = Object.values({
...(errors as Record<string, any>),
})
.map((e, i) => (files?.[i] && e ? `${files[i].name}:${e}` : ""))
.filter(Boolean);

$: selectedFile = files?.[0];
$: hasError = errorMessages.length > 0;

function formatFileSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function clearFiles() {
if (fileInput) fileInput.value = "";
files = undefined;
}

function handleDrop(event: DragEvent) {
dragOver = false;
if (!event.dataTransfer?.files?.length) return;
files = event.dataTransfer.files;
}
</script>

{#if selectedFile}
<div class="file-wrapper">
<div class="file-row" class:has-error={hasError}>
<div class="file-icon" class:has-error={hasError}>
<FileText
size={24}
class={hasError ? "text-destructive" : "text-icon-default"}
strokeWidth={1.5}
/>
</div>
<div class="file-info">
<span class="file-name">{selectedFile.name}</span>
<span class="file-size" class:has-error={hasError}>
{formatFileSize(selectedFile.size)}
</span>
</div>
<button
type="button"
class="clear-button"
aria-label="Remove file"
onclick={clearFiles}
>
<X size={14} />
</button>
</div>
{#if hasError}
{#each errorMessages as message}
<div class="error-message">
<AlertCircleIcon size={12} class="shrink-0" />
<span>{message}</span>
</div>
{/each}
{/if}
</div>
{:else}
<button
type="button"
class="file-uploader"
class:drag-over={dragOver}
onclick={() => fileInput.click()}
ondragenter={(e) => {
e.preventDefault();
e.stopPropagation();
dragOver = true;
}}
ondragleave={(e) => {
e.preventDefault();
e.stopPropagation();
dragOver = false;
}}
ondragover={(e) => {
e.preventDefault();
e.stopPropagation();
}}
ondrop={(e) => {
e.preventDefault();
handleDrop(e);
}}
>
<div class="inner">
<div class="icon-wrapper">
<Upload size={24} class="text-fg-secondary" />
</div>
<div class="text-section">
<p class="upload-text">
<span class="upload-cta">Click to upload</span>
<span class="upload-drag"> or drag and drop</span>
</p>
<p class="upload-hint">{hint}</p>
</div>
</div>
</button>
{/if}

<input
type="file"
{accept}
hidden
{multiple}
bind:this={fileInput}
bind:files
/>

<style lang="postcss">
/* Upload prompt */
.file-uploader {
@apply w-full bg-surface-muted border border-dashed rounded-sm shadow-sm p-0.5;
@apply cursor-pointer transition-colors;
}

.file-uploader:hover,
.file-uploader.drag-over {
@apply bg-primary-50;
}

.inner {
@apply flex flex-col items-center justify-center gap-3 py-10 w-full;
}

.icon-wrapper {
@apply bg-surface-card rounded-lg size-10 flex items-center justify-center;
}

.text-section {
@apply flex flex-col items-center gap-1;
}

.upload-text {
@apply text-sm font-medium leading-5;
}

.upload-cta {
@apply text-accent-primary-action;
}

.upload-drag {
@apply text-fg-primary;
}

.upload-hint {
@apply text-xs text-fg-tertiary;
}

/* File selected state (shared) */
.file-wrapper {
@apply flex flex-col gap-1.5 w-full;
}

.file-row {
@apply flex items-center gap-3 w-full px-4 py-4;
@apply bg-surface-card border rounded-sm shadow-sm;
}

.file-row.has-error {
@apply bg-destructive-foreground border-destructive;
}

.file-icon {
@apply bg-surface-muted rounded-lg size-10 flex items-center justify-center shrink-0;
}

.file-icon.has-error {
@apply bg-destructive/15;
}

.file-info {
@apply flex flex-col gap-0.5 flex-1 min-w-0;
}

.file-name {
@apply text-sm font-medium text-fg-primary leading-5 truncate;
}

.file-size {
@apply text-xs text-fg-tertiary leading-[18px];
}

.file-size.has-error {
@apply text-destructive;
}

.clear-button {
@apply shrink-0 text-fg-secondary flex items-center justify-center cursor-pointer;
}

.error-message {
@apply flex items-center gap-1.5 text-xs text-destructive;
}
</style>
4 changes: 2 additions & 2 deletions web-common/src/features/add-data/class-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const AddDataClassByStepMap: Partial<Record<AddDataStep, string>> = {
[AddDataStep.Import]: "h-fit w-[550px]",
};
const AddDataClassBySchemaMap: Partial<Record<string, string>> = {
local_file: "h-[300px] my-auto w-[550px]",
local_file: " h-fit my-auto w-[550px]",
};
const DefaultAddDataClass = "h-[630px] md:w-[900px] w-[550px]";

Expand All @@ -21,7 +21,7 @@ export function getAddDataClass(addDataState: AddDataState) {
}

const FormClassBySchemaMap: Partial<Record<string, string>> = {
local_file: "px-6 my-auto h-fit",
local_file: "pt-6 pb-1 px-6 h-fit",
};
const DefaultFormClass = "p-6 flex-grow overflow-auto";

Expand Down
3 changes: 2 additions & 1 deletion web-common/src/features/templates/SchemaField.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import { normalizeErrors } from "./error-utils";
import { getFileAccept } from "./file-encoding";
import type { JSONSchemaField } from "./schemas/types";
import FileInput from "@rilldata/web-common/components/forms/FileInput.svelte";

Check failure on line 13 in web-common/src/features/templates/SchemaField.svelte

View workflow job for this annotation

GitHub Actions / build

'FileInput' is defined but never used
import FileUploader from "@rilldata/web-common/components/forms/FileUploader.svelte";

export let id: string;
export let prop: JSONSchemaField;
Expand Down Expand Up @@ -48,7 +49,7 @@
accept={getFileAccept(prop)}
/>
{:else}
<FileInput bind:files={value} accept={getFileAccept(prop)} />
<FileUploader bind:files={value} accept={getFileAccept(prop)} />
{/if}
{:else if prop["x-display"] === "toggle" && prop.type === "boolean"}
<div class="flex items-center justify-between gap-3">
Expand Down
Loading