feat: support uploading folders via drag-and-drop and toolbar button
This commit is contained in:
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "ファイルのアップロードに失敗しました",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user