Folders

So far we’ve only uploaded files, because that’s all you can do with <input type="file">. But we want to be able to upload folders as well.

For that, we need to go beyond <form>, and use JavaScript to implement drag and drop.

First, add a dropzone with event handlers:

<div class="files">
	{@render list(await getFiles('<root>'))}

+	<div
+		class="dropzone"
+		role="region"
+		aria-label="File upload drop zone"
+		ondragover={(e) => {
+			e.preventDefault();
+			e.stopPropagation();
+
+			if (e.dataTransfer && e.dataTransfer.items.length > 0) {
+				e.dataTransfer.dropEffect = 'copy';
+			}
+		}}
+		ondrop={async (e) => {
+			e.preventDefault();
+		}}
+	>
+		<span>Drop files here to upload</span>
+	</div>
</div>

Update the CSS:

.files {
	display: flex;
	flex-direction: column;
	height: 100vh;
	overflow-y: auto;
	padding: 0 0 6rem 0;

	.dropzone {
		display: flex;
		flex: 1;
		align-items: center;
		justify-content: center;
		min-height: 10rem;
	}
}

The user might drop a folder, so we’re going to need a new remote function. This time, we’re going to use command, which is similar to form in that it runs logic on the server, but you use it like a regular function:

export const createFolder = command(
	v.object({
		name: v.string(),
		parent: v.string()
	}),
	async (data) => {
		const user = await requireUser();

		let name = data.name;
		let i = 0;

		while (true) {
			const [inserted] = await db
				.insert(file)
				.values({
					user_id: user.id,
					name,
					parent: data.parent,
					size: 0,
					type: 'application/x-directory',
					status: 'ready'
				})
				.onConflictDoNothing()
				.returning();

			if (inserted) {
				getFiles(data.parent).refresh();
				return inserted.id;
			}

			name = `${data.name} (${++i})`;
		}
	}
);

We can also turn our existing uploadFile function into a command, so that we can call that from the browser as well:

+export const uploadFile = command(
+	v.object({
+		file: v.file(),
+		parent: v.string()
+	}),
	async (data) => {
		const user = await requireUser();

		let name = data.file.name;
		let i = 0;

		while (true) {
			const [inserted] = await db
				.insert(file)
				.values({
					user_id: user.id,
					name,
					parent: data.parent,
					size: data.file.size,
					type: data.file.type,
					status: 'pending'
				})
				.onConflictDoNothing()
				.returning();

			if (inserted) {
				await put(`${encodeURIComponent(user.id)}/${inserted.id}`, data.file);

				await db.update(file).set({ status: 'ready' }).where(eq(file.id, inserted.id));

+				getFiles(data.parent).refresh();
				return;
			}

			name = `${data.file.name} (${++i})`;
		}
	}
);

Because we can call it from the browser, we need to tell it which queries need to be refreshed — in our case, just getFiles.

Finally we just have to wire it up, using the web’s filesystem APIs, which — fair warning — are horrific. Add this function to the <script> element…

async function handleEntry(entry: FileSystemEntry, parent: string) {
	if (entry.isDirectory) {
		const id = await createFolder({ name: entry.name, parent });

		const reader = (entry as FileSystemDirectoryEntry).createReader();

		while (true) {
			const entries: FileSystemEntry[] = await new Promise((resolve, reject) => {
				reader.readEntries(resolve, reject);
			});

			if (entries.length === 0) {
				break;
			}

			for (const entry of entries) {
				await handleEntry(entry, id);
			}
		}
	}

	if (entry.isFile) {
		const file: File = await new Promise((resolve, reject) => {
			(entry as FileSystemFileEntry).file(resolve, reject);
		});

		await uploadFile({
			file,
			parent
		});
	}
}

…then call it from the ondrop handler:

e.preventDefault();

+if (e.dataTransfer?.items.length) {
+	const entries = Array.from(e.dataTransfer.items, (item) => item.webkitGetAsEntry());

+	for (const entry of entries) {
+		if (entry) await handleEntry(entry, '<root>');
+	}
+}