feat: support uploading folders via drag-and-drop and toolbar button

This commit is contained in:
Linus Rath
2026-03-21 20:45:14 +01:00
parent 45a485a1fa
commit 0d28d811a8
12 changed files with 179 additions and 23 deletions
+31 -8
View File
@@ -21,6 +21,7 @@ import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog"; import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar"; import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar";
import { ResizeHandle } from "@/components/layout/resize-handle"; import { ResizeHandle } from "@/components/layout/resize-handle";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
import type { FileResource } from "@/stores/file-store"; import type { FileResource } from "@/stores/file-store";
type SortKey = "name" | "size" | "modified"; type SortKey = "name" | "size" | "modified";
@@ -624,16 +625,20 @@ export function FileBrowser({
e.stopPropagation(); e.stopPropagation();
setIsDraggingOver(false); setIsDraggingOver(false);
const files = Array.from(e.dataTransfer.files); setIsUploading(true);
if (files.length > 0) { try {
setIsUploading(true); const { files, hasDirectories } = await getDroppedFilesAndFolders(e.dataTransfer);
try { if (files.length > 0) {
await onUploadFiles(files); if (hasDirectories) {
} finally { await onUploadFolder(files);
setIsUploading(false); } else {
await onUploadFiles(files);
}
} }
} finally {
setIsUploading(false);
} }
}, [onUploadFiles]); }, [onUploadFiles, onUploadFolder]);
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
@@ -945,6 +950,16 @@ export function FileBrowser({
> >
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
</Button> </Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => folderInputRef.current?.click()}
title={t("upload_folder")}
disabled={isUploading}
>
<FolderUp className="w-4 h-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -1195,6 +1210,14 @@ export function FileBrowser({
setIsUploading(false); setIsUploading(false);
} }
}} }}
onUploadFolder={async (files: File[]) => {
setIsUploading(true);
try {
await onUploadFolder(files);
} finally {
setIsUploading(false);
}
}}
onCreateFolder={() => setShowNewFolder(true)} onCreateFolder={() => setShowNewFolder(true)}
onCreateTextFile={() => setShowNewTextFile(true)} onCreateTextFile={() => setShowNewTextFile(true)}
/> />
+11 -5
View File
@@ -2,16 +2,18 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Upload, FolderPlus, FilePlus } from "lucide-react"; import { Upload, FolderPlus, FilePlus, FolderUp } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
interface FileUploadAreaProps { interface FileUploadAreaProps {
onUpload: (files: File[]) => Promise<void>; onUpload: (files: File[]) => Promise<void>;
onUploadFolder?: (files: File[]) => Promise<void>;
onCreateFolder: () => void; onCreateFolder: () => void;
onCreateTextFile?: () => void; onCreateTextFile?: () => void;
} }
export function FileUploadArea({ onUpload, onCreateFolder, onCreateTextFile }: FileUploadAreaProps) { export function FileUploadArea({ onUpload, onUploadFolder, onCreateFolder, onCreateTextFile }: FileUploadAreaProps) {
const t = useTranslations("files"); const t = useTranslations("files");
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -32,11 +34,15 @@ export function FileUploadArea({ onUpload, onCreateFolder, onCreateTextFile }: F
e.stopPropagation(); e.stopPropagation();
setIsDragging(false); setIsDragging(false);
const files = Array.from(e.dataTransfer.files); const { files, hasDirectories } = await getDroppedFilesAndFolders(e.dataTransfer);
if (files.length > 0) { if (files.length > 0) {
await onUpload(files); if (hasDirectories && onUploadFolder) {
await onUploadFolder(files);
} else {
await onUpload(files);
}
} }
}, [onUpload]); }, [onUpload, onUploadFolder]);
return ( return (
<div className="flex items-center justify-center h-full p-8"> <div className="flex items-center justify-center h-full p-8">
+126
View File
@@ -0,0 +1,126 @@
/**
* Utilities for handling drag-and-drop of files and folders.
* Uses the File and Directory Entries API (webkitGetAsEntry) to
* recursively read dropped directory trees, preserving relative paths.
*/
interface FileWithPath extends File {
readonly webkitRelativePath: string;
}
/**
* Read all File entries from a FileSystemDirectoryEntry recursively.
* Each returned File has its webkitRelativePath set to the relative path
* within the dropped folder (e.g. "folder/sub/file.txt").
*/
function readDirectoryEntries(dirEntry: FileSystemDirectoryEntry): Promise<FileWithPath[]> {
return new Promise((resolve, reject) => {
const reader = dirEntry.createReader();
const allEntries: FileSystemEntry[] = [];
// readEntries may return results in batches; keep reading until empty
const readBatch = () => {
reader.readEntries(
(entries) => {
if (entries.length === 0) {
resolveFiles(allEntries).then(resolve, reject);
} else {
allEntries.push(...entries);
readBatch();
}
},
reject,
);
};
readBatch();
});
}
function resolveFiles(entries: FileSystemEntry[]): Promise<FileWithPath[]> {
const promises = entries.map((entry) => {
if (entry.isFile) {
return new Promise<FileWithPath[]>((resolve, reject) => {
(entry as FileSystemFileEntry).file(
(file) => {
// Set webkitRelativePath directly on the original File object.
// The property lives on File.prototype as a getter, so defining
// an own data property on the instance safely shadows it.
try {
Object.defineProperty(file, 'webkitRelativePath', {
value: entry.fullPath.replace(/^\//, ''),
writable: false,
configurable: true,
});
} catch {
// Fallback: some environments may prevent overriding.
// The store also falls back to file.name, which still works
// for flat files (though nested paths would be lost).
}
resolve([file as unknown as FileWithPath]);
},
reject,
);
});
} else if (entry.isDirectory) {
return readDirectoryEntries(entry as FileSystemDirectoryEntry);
}
return Promise.resolve([]);
});
return Promise.all(promises).then((arrays) => arrays.flat());
}
/**
* Result of processing a drop event's DataTransfer.
*/
export interface DropResult {
files: File[];
hasDirectories: boolean;
}
/**
* Process a drop event's DataTransfer, detecting folders and recursively
* reading their contents. Returns the list of files and whether any
* directories were found.
*
* Falls back to e.dataTransfer.files when webkitGetAsEntry is unavailable.
*/
export async function getDroppedFilesAndFolders(dataTransfer: DataTransfer): Promise<DropResult> {
const items = dataTransfer.items;
// Check if the browser supports webkitGetAsEntry
if (items && items.length > 0 && typeof items[0].webkitGetAsEntry === 'function') {
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry();
if (entry) entries.push(entry);
}
let hasDirectories = false;
const filePromises: Promise<FileWithPath[]>[] = [];
for (const entry of entries) {
if (entry.isDirectory) {
hasDirectories = true;
filePromises.push(readDirectoryEntries(entry as FileSystemDirectoryEntry));
} else if (entry.isFile) {
filePromises.push(
new Promise<FileWithPath[]>((resolve, reject) => {
(entry as FileSystemFileEntry).file(
(file) => resolve([file as FileWithPath]),
reject,
);
}),
);
}
}
const allFiles = (await Promise.all(filePromises)).flat();
return { files: allFiles, hasDirectories };
}
// Fallback: no entry API support
return {
files: Array.from(dataTransfer.files),
hasDirectories: false,
};
}
+1 -1
View File
@@ -2053,7 +2053,7 @@
"file": "Datei", "file": "Datei",
"parent_directory": "Übergeordnetes Verzeichnis", "parent_directory": "Übergeordnetes Verzeichnis",
"breadcrumb_root": "Startseite", "breadcrumb_root": "Startseite",
"drop_files_here": "Dateien hier ablegen zum Hochladen", "drop_files_here": "Dateien oder Ordner hier ablegen zum Hochladen",
"uploading": "Wird hochgeladen...", "uploading": "Wird hochgeladen...",
"upload_success": "{count, plural, one {1 Datei hochgeladen} other {# Dateien hochgeladen}}", "upload_success": "{count, plural, one {1 Datei hochgeladen} other {# Dateien hochgeladen}}",
"upload_error": "Datei konnte nicht hochgeladen werden", "upload_error": "Datei konnte nicht hochgeladen werden",
+1 -1
View File
@@ -2066,7 +2066,7 @@
"file": "File", "file": "File",
"parent_directory": "Parent directory", "parent_directory": "Parent directory",
"breadcrumb_root": "Home", "breadcrumb_root": "Home",
"drop_files_here": "Drop files here to upload", "drop_files_here": "Drop files or folders here to upload",
"uploading": "Uploading...", "uploading": "Uploading...",
"upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}", "upload_success": "{count, plural, one {1 file uploaded} other {# files uploaded}}",
"upload_error": "Failed to upload file", "upload_error": "Failed to upload file",
+1 -1
View File
@@ -2053,7 +2053,7 @@
"file": "Archivo", "file": "Archivo",
"parent_directory": "Directorio superior", "parent_directory": "Directorio superior",
"breadcrumb_root": "Inicio", "breadcrumb_root": "Inicio",
"drop_files_here": "Suelte los archivos aquí para subirlos", "drop_files_here": "Suelte archivos o carpetas aquí para subirlos",
"uploading": "Subiendo...", "uploading": "Subiendo...",
"upload_success": "{count, plural, one {1 archivo subido} other {# archivos subidos}}", "upload_success": "{count, plural, one {1 archivo subido} other {# archivos subidos}}",
"upload_error": "Error al subir el archivo", "upload_error": "Error al subir el archivo",
+1 -1
View File
@@ -2053,7 +2053,7 @@
"file": "Fichier", "file": "Fichier",
"parent_directory": "Répertoire parent", "parent_directory": "Répertoire parent",
"breadcrumb_root": "Accueil", "breadcrumb_root": "Accueil",
"drop_files_here": "Déposez les fichiers ici pour les téléverser", "drop_files_here": "Déposez des fichiers ou dossiers ici pour les téléverser",
"uploading": "Téléversement en cours...", "uploading": "Téléversement en cours...",
"upload_success": "{count, plural, one {1 fichier téléversé} other {# fichiers téléversés}}", "upload_success": "{count, plural, one {1 fichier téléversé} other {# fichiers téléversés}}",
"upload_error": "Échec du téléversement du fichier", "upload_error": "Échec du téléversement du fichier",
+1 -1
View File
@@ -2053,7 +2053,7 @@
"file": "File", "file": "File",
"parent_directory": "Directory superiore", "parent_directory": "Directory superiore",
"breadcrumb_root": "Home", "breadcrumb_root": "Home",
"drop_files_here": "Trascina i file qui per caricarli", "drop_files_here": "Trascina file o cartelle qui per caricarli",
"uploading": "Caricamento in corso...", "uploading": "Caricamento in corso...",
"upload_success": "{count, plural, one {1 file caricato} other {# file caricati}}", "upload_success": "{count, plural, one {1 file caricato} other {# file caricati}}",
"upload_error": "Caricamento del file non riuscito", "upload_error": "Caricamento del file non riuscito",
+1 -1
View File
@@ -2053,7 +2053,7 @@
"file": "ファイル", "file": "ファイル",
"parent_directory": "親ディレクトリ", "parent_directory": "親ディレクトリ",
"breadcrumb_root": "ホーム", "breadcrumb_root": "ホーム",
"drop_files_here": "ここにファイルをドロップしてアップロード", "drop_files_here": "ここにファイルまたはフォルダをドロップしてアップロード",
"uploading": "アップロード中...", "uploading": "アップロード中...",
"upload_success": "{count, plural, other {#件のファイルをアップロードしました}}", "upload_success": "{count, plural, other {#件のファイルをアップロードしました}}",
"upload_error": "ファイルのアップロードに失敗しました", "upload_error": "ファイルのアップロードに失敗しました",
+1 -1
View File
@@ -2053,7 +2053,7 @@
"file": "Bestand", "file": "Bestand",
"parent_directory": "Bovenliggende map", "parent_directory": "Bovenliggende map",
"breadcrumb_root": "Start", "breadcrumb_root": "Start",
"drop_files_here": "Sleep bestanden hierheen om te uploaden", "drop_files_here": "Sleep bestanden of mappen hierheen om te uploaden",
"uploading": "Uploaden...", "uploading": "Uploaden...",
"upload_success": "{count, plural, one {1 bestand geüpload} other {# bestanden geüpload}}", "upload_success": "{count, plural, one {1 bestand geüpload} other {# bestanden geüpload}}",
"upload_error": "Bestand uploaden mislukt", "upload_error": "Bestand uploaden mislukt",
+1 -1
View File
@@ -2053,7 +2053,7 @@
"file": "Ficheiro", "file": "Ficheiro",
"parent_directory": "Diretório superior", "parent_directory": "Diretório superior",
"breadcrumb_root": "Início", "breadcrumb_root": "Início",
"drop_files_here": "Largue os ficheiros aqui para carregar", "drop_files_here": "Largue ficheiros ou pastas aqui para carregar",
"uploading": "A carregar...", "uploading": "A carregar...",
"upload_success": "{count, plural, one {1 ficheiro carregado} other {# ficheiros carregados}}", "upload_success": "{count, plural, one {1 ficheiro carregado} other {# ficheiros carregados}}",
"upload_error": "Falha ao carregar o ficheiro", "upload_error": "Falha ao carregar o ficheiro",
+3 -2
View File
@@ -371,10 +371,11 @@ export const useFileStore = create<FileState>((set, get) => ({
} }
// Create directories as flat entries with prefixed names (no parentId nesting) // Create directories as flat entries with prefixed names (no parentId nesting)
// Convert "/" separators from webkitRelativePath to PATH_SEP () for server names
const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length); const sortedDirs = [...dirs].sort((a, b) => a.split('/').length - b.split('/').length);
for (const dir of sortedDirs) { for (const dir of sortedDirs) {
if (abortController.signal.aborted) break; if (abortController.signal.aborted) break;
const fullDirName = prefix + dir; const fullDirName = prefix + dir.replace(/\//g, PATH_SEP);
try { try {
await client.createFileDirectory(fullDirName, null); await client.createFileDirectory(fullDirName, null);
} catch { } catch {
@@ -387,7 +388,7 @@ export const useFileStore = create<FileState>((set, get) => ({
if (abortController.signal.aborted) break; if (abortController.signal.aborted) break;
const file = files[i]; const file = files[i];
const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; const relativePath = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name;
const fullName = prefix + relativePath; const fullName = prefix + relativePath.replace(/\//g, PATH_SEP);
set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } }); set({ uploadProgress: { name: relativePath, loaded: 0, total: file.size, current: i + 1, totalFiles } });