Skip to content
Merged
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
3 changes: 3 additions & 0 deletions web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@
* {
border-color: var(--color-edge);
}
textarea {
vertical-align: bottom;
}
}

@utility btn-ghost-hover {
Expand Down
2 changes: 1 addition & 1 deletion web/src/lib/components/files/DirectorySelect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
{#if directory}
{directory.fileName}
{:else}
{placeholder}
<span class="font-light">{placeholder}</span>
{/if}
<span class="iconify size-4 shrink-0 text-em-disabled octicon--triangle-down-16"></span>
{/snippet}
Expand Down
68 changes: 0 additions & 68 deletions web/src/lib/components/files/FileInput.svelte

This file was deleted.

152 changes: 152 additions & 0 deletions web/src/lib/components/files/MultimodalFileInput.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<script lang="ts">
import { type MultimodalFileInputProps, MultimodalFileInputState } from "$lib/components/files/index.svelte";
import { box } from "svelte-toolbelt";
import { RadioGroup } from "bits-ui";
import SingleFileInput from "$lib/components/files/SingleFileInput.svelte";

let { state = $bindable(), label = "File", required = false }: MultimodalFileInputProps = $props();

const instance = new MultimodalFileInputState({
state,
label: box.with(() => label),
required: box.with(() => required),
});
state = instance;

function handleDragOver(event: DragEvent) {
instance.dragActive = true;
event.preventDefault();
}

function handleDragLeave(event: DragEvent) {
if (event.currentTarget === event.target) {
instance.dragActive = false;
}
event.preventDefault();
}

async function handleDrop(event: DragEvent) {
instance.dragActive = false;
event.preventDefault();
if (!event.dataTransfer) {
return;
}

const types = event.dataTransfer.types;
const files = event.dataTransfer.files;

// Handle file drops
if (files.length > 1) {
alert("Only one file can be dropped at a time.");
return;
} else if (files.length === 1) {
instance.file = files[0];
instance.mode = "file";
return;
}

// Handle URL drops
if (types.includes("text/uri-list")) {
const urls = event.dataTransfer
.getData("text/uri-list")
.split("\n")
.filter((url) => url && !url.startsWith("#"));
if (urls.length > 1) {
alert("Only one URL can be dropped at a time.");
return;
} else if (urls.length === 1) {
instance.url = urls[0];
instance.mode = "url";
return;
}
}

// Handle plain text drops
if (types.includes("text/plain")) {
const text = event.dataTransfer.getData("text/plain");
if (text) {
instance.text = text;
instance.mode = "text";
return;
}
}
}
</script>

{#snippet radioItem(name: string)}
<RadioGroup.Item value={name.toLowerCase()}>
{#snippet children({ checked })}
<span class="rounded-sm px-1 py-0.5 text-sm" class:btn-ghost={!checked} class:border={!checked} class:btn-primary={checked}>
{name}
</span>
{/snippet}
</RadioGroup.Item>
{/snippet}

<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="file-drop-target w-full"
data-drag-active={instance.dragActive}
ondragover={handleDragOver}
ondrop={handleDrop}
ondragleavecapture={handleDragLeave}
>
<RadioGroup.Root class="mb-1 flex w-full gap-1" bind:value={instance.mode}>
{@render radioItem("File")}
{@render radioItem("URL")}
{@render radioItem("Text")}
</RadioGroup.Root>
{#if instance.mode === "file"}
{@render fileInput()}
{:else if instance.mode === "url"}
{@render urlInput()}
{:else if instance.mode === "text"}
{@render textInput()}
{/if}
</div>

<!-- TODO: Implement required prop for SingleFileInput -->
{#snippet fileInput()}
<SingleFileInput bind:file={instance.file} class="flex w-fit items-center gap-2 rounded-md border btn-ghost px-2 py-1 has-focus-visible:outline-2">
<span class="iconify size-4 shrink-0 text-em-disabled octicon--file-16"></span>
{#if instance.file}
{instance.file.name}
{:else}
<span class="font-light">{label}</span>
{/if}
<span class="iconify size-4 shrink-0 text-em-disabled octicon--triangle-down-16"></span>
</SingleFileInput>
{/snippet}

{#snippet urlInput()}
<input title="{label} URL" bind:value={instance.url} placeholder="Enter file URL" type="url" {required} class="w-full rounded-md border px-2 py-1" />
{/snippet}

{#snippet textInput()}
<textarea title="{label} Text" bind:value={instance.text} placeholder="Enter text here" {required} class="w-full rounded-md border px-2 py-1"></textarea>
{/snippet}

<style>
.file-drop-target {
position: relative;
}
.file-drop-target[data-drag-active="true"]::before {
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;

content: "Drop here";
font-size: var(--text-3xl);
color: var(--color-black);

background-color: rgba(255, 255, 255, 0.7);

border: dashed var(--color-primary);
border-radius: inherit;
}
</style>
32 changes: 32 additions & 0 deletions web/src/lib/components/files/SingleFileInput.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<script lang="ts">
import { useId } from "bits-ui";
import { type RestProps } from "$lib/types";
import { type Snippet } from "svelte";
import { watch } from "runed";

type Props = {
children?: Snippet<[{ file?: File }]>;
file?: File;
} & RestProps;

let { children, file = $bindable<File | undefined>(undefined), ...restProps }: Props = $props();

let files = $state<FileList | undefined>();

watch(
() => files,
(newFiles) => {
if (newFiles && newFiles.length > 0) {
file = newFiles[0];
}
},
);

const labelId = useId();
const inputId = useId();
</script>

<label id={labelId} for={inputId} {...restProps}>
{@render children?.({ file })}
<input id={inputId} aria-labelledby={labelId} type="file" bind:files class="sr-only" />
</label>
20 changes: 0 additions & 20 deletions web/src/lib/components/files/SingleFileSelect.svelte

This file was deleted.

Loading