diff --git a/app/Filament/Server/Resources/Files/Pages/ListFiles.php b/app/Filament/Server/Resources/Files/Pages/ListFiles.php index dcbe1469c..839afc9b1 100644 --- a/app/Filament/Server/Resources/Files/Pages/ListFiles.php +++ b/app/Filament/Server/Resources/Files/Pages/ListFiles.php @@ -45,6 +45,7 @@ use Filament\Tables\Columns\TextColumn; use Filament\Tables\Enums\PaginationMode; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\UploadedFile; use Illuminate\Routing\Route; use Illuminate\Support\Carbon; @@ -65,6 +66,7 @@ class ListFiles extends ListRecords private DaemonFileRepository $fileRepository; + public function getTitle(): string { return trans('server/file.title'); @@ -631,7 +633,7 @@ class ListFiles extends ListRecords }; } - public function getUploadUrl(): string + public function getUploadUrl(NodeJWTService $jwtService): string { /** @var Server $server */ $server = Filament::getTenant(); @@ -640,14 +642,11 @@ class ListFiles extends ListRecords abort(403, 'You do not have permission to upload files.'); } - $jwtService = app(NodeJWTService::class); - $user = user(); - $token = $jwtService ->setExpiresAt(CarbonImmutable::now()->addMinutes(15)) - ->setUser($user) + ->setUser(user()) ->setClaims(['server_uuid' => $server->uuid]) - ->handle($server->node, $user->id . $server->uuid); + ->handle($server->node, user()->id . $server->uuid); return sprintf( '%s/upload/file?token=%s', @@ -664,6 +663,32 @@ class ListFiles extends ListRecords return $server->node->upload_size * 1024 * 1024; } + /** + * @throws ConnectionException + * @throws FileExistsException + * @throws \Throwable + */ + public function createFolder(string $folderPath): void + { + /** @var Server $server */ + $server = Filament::getTenant(); + + if (!user()?->can(Permission::ACTION_FILE_CREATE, $server)) { + abort(403, 'You do not have permission to create folders.'); + } + + try { + $this->getDaemonFileRepository()->createDirectory($folderPath, $this->path); + + Activity::event('server:file.create-directory') + ->property(['directory' => $this->path, 'name' => $folderPath]) + ->log(); + + } catch (FileExistsException) { + // Ignore if the folder already exists. + } + } + private function getDaemonFileRepository(): DaemonFileRepository { /** @var Server $server */ diff --git a/resources/views/filament/server/pages/list-files.blade.php b/resources/views/filament/server/pages/list-files.blade.php index 886480b5c..9f8ffcffe 100644 --- a/resources/views/filament/server/pages/list-files.blade.php +++ b/resources/views/filament/server/pages/list-files.blade.php @@ -1,55 +1,136 @@
0 && items[0].webkitGetAsEntry) { + filesWithPaths = await this.extractFilesFromItems(items); + } + + if (files && files.length > 0 && filesWithPaths.length === 0) { + filesWithPaths = Array.from(files).map(f => ({ file: f, path: '' })); + } + + if (filesWithPaths.length > 0) { + await this.uploadFilesWithFolders(filesWithPaths); + } }, - async uploadFiles(files) { + + async extractFilesFromItems(items) { + const filesWithPaths = []; + const traversePromises = []; + + for (let i = 0; i < items.length; i++) { + const entry = items[i].webkitGetAsEntry?.(); + + if (entry) { + traversePromises.push(this.traverseFileTree(entry, '', filesWithPaths)); + } else if (items[i].kind === 'file') { + const file = items[i].getAsFile(); + if (file) { + filesWithPaths.push({ + file: file, + path: '', + }); + } + } + } + + await Promise.all(traversePromises); + + return filesWithPaths; + }, + + async traverseFileTree(entry, path, filesWithPaths) { + return new Promise((resolve) => { + if (entry.isFile) { + entry.file((file) => { + filesWithPaths.push({ + file: file, + path: path, + }); + resolve(); + }); + } else if (entry.isDirectory) { + const reader = entry.createReader(); + const readEntries = () => { + reader.readEntries(async (entries) => { + if (entries.length === 0) { + resolve(); + return; + } + + const subPromises = entries.map((e) => + this.traverseFileTree( + e, + path ? `${path}/${entry.name}` : entry.name, + filesWithPaths + ) + ); + + await Promise.all(subPromises); + readEntries(); + }); + }; + readEntries(); + } else { + resolve(); + } + }); + }, + async uploadFilesWithFolders(filesWithPaths) { this.isUploading = true; this.uploadQueue = []; - this.totalFiles = files.length; + this.totalFiles = filesWithPaths.length; this.currentFileIndex = 0; try { const uploadSizeLimit = await $wire.getUploadSizeLimit(); - for (let i = 0; i < files.length; i++) { - if (files[i].size > uploadSizeLimit) { + for (const { file } of filesWithPaths) { + if (file.size > uploadSizeLimit) { new window.FilamentNotification() - .title(`File ${files[i].name} exceeds the upload size limit of ${this.formatBytes(uploadSizeLimit)}`) + .title(`File ${file.name} exceeds the upload size limit of ${this.formatBytes(uploadSizeLimit)}`) .danger() .send(); this.isUploading = false; @@ -57,11 +138,32 @@ } } - for (let i = 0; i < files.length; i++) { + const folderPaths = new Set(); + for (const { path } of filesWithPaths) { + if (path) { + const parts = path.split('/').filter(Boolean); + let currentPath = ''; + for (const part of parts) { + currentPath += part + '/'; + folderPaths.add(currentPath); + } + } + } + + for (const folderPath of folderPaths) { + try { + await $wire.createFolder(folderPath.slice(0, -1)); + } catch (error) { + console.warn(`Folder ${folderPath} already exists or failed to create.`); + } + } + + for (const f of filesWithPaths) { this.uploadQueue.push({ - file: files[i], - name: files[i].name, - size: files[i].size, + file: f.file, + name: f.file.name, + path: f.path, + size: f.file.size, progress: 0, speed: 0, uploadedBytes: 0, @@ -74,67 +176,57 @@ let activeUploads = []; let completedCount = 0; - for (let i = 0; i < files.length; i++) { + for (let i = 0; i < this.uploadQueue.length; i++) { const uploadPromise = this.uploadFile(i) - .then(() => { - completedCount++; - this.currentFileIndex = completedCount; - }) - .catch((error) => { - completedCount++; - this.currentFileIndex = completedCount; - }); + .then(() => { completedCount++; this.currentFileIndex = completedCount; }) + .catch(() => { completedCount++; this.currentFileIndex = completedCount; }); activeUploads.push(uploadPromise); if (activeUploads.length >= maxConcurrent) { await Promise.race(activeUploads); - activeUploads = activeUploads.filter(p => { - let isPending = true; - p.then(() => { isPending = false; }).catch(() => { isPending = false; }); - return isPending; - }); + activeUploads = activeUploads.filter(p => p.status !== 'fulfilled' && p.status !== 'rejected'); } } await Promise.allSettled(activeUploads); - const failedUploads = this.uploadQueue.filter(f => f.status === 'error'); + + const failed = this.uploadQueue.filter(f => f.status === 'error'); await $wire.$refresh(); - if (failedUploads.length === 0) { - new window.FilamentNotification() - .title('{{ trans('server/file.actions.upload.success') }}') - .success() - .send(); - } else if (failedUploads.length < this.totalFiles) { - new window.FilamentNotification() - .title(`${this.totalFiles - failedUploads.length} of ${this.totalFiles} files uploaded successfully`) - .warning() - .send(); + if (failed.length === 0) { + new window.FilamentNotification().title('{{ trans('server/file.actions.upload.success') }}').success().send(); + } else if (failed.length === this.totalFiles) { + new window.FilamentNotification().title('{{ trans('server/file.actions.upload.error_all') }}').danger().send(); } else { - new window.FilamentNotification() - .title('{{ trans('server/file.actions.upload.failed') }}') - .danger() - .send(); + new window.FilamentNotification().title('{{ trans('server/file.actions.upload.error_partial') }}').warning().send(); } + if (this.autoCloseTimer) clearTimeout(this.autoCloseTimer); + this.autoCloseTimer = setTimeout(() => { + this.isUploading = false; + this.uploadQueue = []; + },1000); } catch (error) { - new window.FilamentNotification() - .title('{{ trans('server/file.actions.upload.failed') }}') - .danger() - .send(); - } finally { - this.closeUploadDialog(); + console.error('Upload error:', error); + new window.FilamentNotification().title('{{ trans('server/file.actions.upload.error') }}').danger().send(); + this.isUploading = false; } }, + async uploadFile(index) { const fileData = this.uploadQueue[index]; fileData.status = 'uploading'; - try { const uploadUrl = await $wire.getUploadUrl(); const url = new URL(uploadUrl); - url.searchParams.append('directory', @js($this->path)); + let basePath = @js($this->path); + + if (fileData.path && fileData.path.trim() !== '') { + basePath = basePath.replace(/\/+$/, '') + '/' + fileData.path.replace(/^\/+/, ''); + } + + url.searchParams.append('directory', basePath); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); @@ -149,19 +241,18 @@ fileData.uploadedBytes = e.loaded; fileData.progress = Math.round((e.loaded / e.total) * 100); - // Calculate upload speed - const currentTime = Date.now(); - const timeDiff = (currentTime - lastTime) / 1000; + const now = Date.now(); + const timeDiff = (now - lastTime) / 1000; if (timeDiff > 0.1) { const bytesDiff = e.loaded - lastLoaded; fileData.speed = bytesDiff / timeDiff; - lastTime = currentTime; + lastTime = now; lastLoaded = e.loaded; } } }); - xhr.addEventListener('load', () => { + xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { fileData.status = 'complete'; fileData.progress = 100; @@ -171,23 +262,24 @@ fileData.error = `Upload failed (${xhr.status})`; reject(new Error(fileData.error)); } - }); + }; - xhr.addEventListener('error', () => { + xhr.onerror = () => { fileData.status = 'error'; fileData.error = 'Network error'; - reject(new Error('Upload failed')); - }); + reject(new Error('Network error')); + }; xhr.open('POST', url.toString()); xhr.send(formData); }); - } catch (error) { + } catch (err) { fileData.status = 'error'; fileData.error = 'Failed to get upload token'; - throw error; + throw err; } }, + formatBytes(bytes) { if (bytes === 0) return '0.00 B'; const k = 1024; @@ -198,28 +290,20 @@ formatSpeed(bytesPerSecond) { return this.formatBytes(bytesPerSecond) + '/s'; }, - closeUploadDialog() { - if (this.autoCloseTimer) { - clearTimeout(this.autoCloseTimer); - this.autoCloseTimer = null; - } - this.isUploading = false; - this.uploadQueue = []; - }, handleEscapeKey(e) { if (e.key === 'Escape' && this.isUploading) { - this.closeUploadDialog(); + this.isUploading = false; + this.uploadQueue = []; } - } + }, }" @dragenter.window="handleDragEnter($event)" @dragleave.window="handleDragLeave($event)" - @dragover.window="handleDragOver($event)" s + @dragover.window="handleDragOver($event)" @drop.window="handleDrop($event)" @keydown.window="handleEscapeKey($event)" class="relative" > -
{{ trans('server/file.actions.upload.header') }} - - Of + of
@@ -275,7 +359,8 @@
+ x-text="(fileData.path ? fileData.path + '/' : '') + fileData.name"> +
@@ -288,16 +373,16 @@
- +
- — - + — +