Improved file manager, switched to faster node/daemon file communication. Removed old components

This commit is contained in:
Marcel Baumgartner
2024-04-01 17:04:38 +02:00
parent 9abf32b288
commit 20fcd5015e
26 changed files with 680 additions and 1841 deletions

View File

@@ -1,282 +1,363 @@
@using Moonlight.Core.Configuration
@using MoonCore.Helpers
@using MoonCore.Services
@using MoonCoreUI.Services
@using Moonlight.Core.Configuration
@using Moonlight.Core.Services
@using Moonlight.Features.FileManager.Interfaces
@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess
@using Moonlight.Features.FileManager.Services
@inject AlertService AlertService
@inject ConfigService<CoreConfiguration> ConfigService
@inject ToastService ToastService
@inject FileManagerInteropService FileManagerInteropService
@inject SharedFileAccessService FileAccessService
@inject ConfigService<CoreConfiguration> ConfigService
@inject PluginService PluginService
@inject IServiceProvider ServiceProvider
<div class="card">
<div class="card-header">
<div class="card-title">
<div class="badge badge-primary badge-lg fs-5 py-2">
@implements IDisposable
<div class="card card-body px-5">
<div class="d-flex justify-content-center justify-content-md-between">
<div class="d-none d-md-flex justify-content-start align-items-center">
<div class="badge badge-primary badge-lg fs-5 py-2 text-center">
@{
var elements = Path
var parts = Path
.Split("/")
.Where(x => !string.IsNullOrEmpty(x))
.ToList();
.ToArray();
int i = 1;
var root = "/";
var i = 1;
}
<a href="#" @onclick:preventDefault @onclick="() => NavigateToPath(root)" class="invisible-a mx-2 text-white">/</a>
@foreach (var element in elements)
{
var pathToCd = "/" + string.Join('/', elements.Take(i));
<a href="#" class="text-white mx-1" @onclick:preventDefault @onclick="() => NavigateBackToLevel(0)">/</a>
<a href="#" @onclick:preventDefault @onclick="() => NavigateToPath(pathToCd)" class="invisible-a text-white">@(element)</a>
@foreach (var part in parts)
{
var x = i + 0;
<a href="#" class="text-white mx-1" @onclick:preventDefault @onclick="() => NavigateBackToLevel(x)">@(part)</a>
<div class="mx-2 text-white">/</div>
i++;
}
</div>
</div>
<div class="card-toolbar">
@if (ShowFileUploader)
<div class="d-flex justify-content-center justify-content-md-end align-items-center">
@if (View != null && View.Selection.Any())
{
<button type="button" @onclick="ToggleFileUploader" class="btn btn-light-primary me-3">
Back
</button>
foreach (var action in SelectionActions)
{
var cssClass = $"btn btn-{action.Color} mx-2";
<WButton Text="@action.Name" CssClasses="@cssClass" OnClick="() => InvokeSelectionAction(action)"/>
}
}
else
{
<a href="javascript:void(0)" class="btn btn-secondary me-3">
<i class="bx bx-sm bx-link-external me-2"></i>
Launch
</a>
<button type="button" @onclick="ToggleFileUploader" class="btn btn-light-primary me-3">
<i class="bx bx-sm bx-upload me-2"></i>
Upload
</button>
<WButton OnClick="ManualRefresh" CssClasses="btn btn-icon btn-light-info">
<i class="bx bx-sm bx-refresh"></i>
</WButton>
<label for="fileManagerSelect" class="btn btn-light-primary mx-2">Upload</label>
<input id="fileManagerSelect" type="file" hidden="hidden" multiple/>
<div class="dropdown">
<a class="btn btn-primary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
New
</a>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuLink">
<li>
<a href="#" @onclick:preventDefault @onclick="CreateFile" class="dropdown-item">
<i class="bx bx-sm bx-file text-primary me-2 align-middle"></i>
<span class="align-middle fs-6">File</span>
</a>
</li>
<li>
<a href="#" @onclick:preventDefault @onclick="CreateDirectory" class="dropdown-item">
<i class="bx bx-sm bx-folder text-primary me-2 align-middle"></i>
<span class="align-middle fs-6">Folder</span>
</a>
</li>
@foreach (var action in CreateActions)
{
<li>
<a href="#" class="dropdown-item" @onclick:preventDefault @onclick="() => InvokeCreateAction(action)">
<i class="bx bx-sm @action.Icon text-@action.Color me-2 align-middle"></i>
<span class="align-middle fs-6">@action.Name</span>
</a>
</li>
}
</ul>
</div>
}
</div>
</div>
<div class="card-body" @ondragenter="() => ToggleFileUploader(true)">
@if (ShowFileUploader)
{
<FileUploader @ref="FileUploader" FileAccess="FileAccess"/>
}
else if (ShowFileEditor)
{
<FileEditor File="EditorOpenFile" FileAccess="FileAccess" OnClosed="OnEditorClosed"/>
}
else
{
<FileView @ref="FileView"
FileAccess="FileAccess"
OnPathChanged="OnPathChanged"
OnFileClicked="OnFileClicked"
OnMoveRequested="StartMove"/>
}
</div>
</div>
<SmartModal @ref="MoveModal" CssClasses="modal-dialog-centered">
<div class="modal-header">
<h5 class="modal-title">Select the location to move '@(MoveEntry.Name)'</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
@if (ShowEditor)
{
<div class="card card-body px-2 py-2 mt-5">
<FileEditor @ref="Editor" FileAccess="FileAccess" File="FileToEdit" OnClosed="CloseEditor"/>
</div>
<div class="modal-body" style="overflow-y: scroll; max-height: 80vh">
<FileView
FileAccess="MoveAccess"
ShowActions="false"
ShowHeader="false"
ShowSelect="false"
ShowSize="false"
ShowLastModified="false"/>
}
else
{
<div id="fileManagerUpload" class="card card-body px-5 py-3 mt-5">
<FileView @ref="View"
FileAccess="FileAccess"
OnEntryClicked="OnEntryClicked"
OnNavigateUpClicked="OnNavigateUpClicked"
OnSelectionChanged="OnSelectionChanged"
EnableContextMenu="true">
<ContextMenuTemplate>
@foreach (var action in ContextActions)
{
if (!action.Filter.Invoke(context))
continue;
<a class="dropdown-item" href="#" @onclick:preventDefault @onclick="() => InvokeContextAction(action, context)">
<i class="bx bx-sm @action.Icon text-@action.Color align-middle"></i>
<span class="align-middle ms-3">@action.Name</span>
</a>
}
</ContextMenuTemplate>
</FileView>
</div>
<div class="modal-footer p-3">
<div class="btn-group w-100">
<WButton OnClick="FinishMove" Text="Move" CssClasses="btn btn-primary w-50 me-3"/>
<button class="btn btn-secondary w-50" data-bs-dismiss="modal">Cancel</button>
<SmartModal @ref="FolderSelectModal" CssClasses="modal-lg modal-dialog-centered">
<div class="modal-header">
<h5 class="modal-title">@FolderSelectTitle</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" @onclick="HideFolderSelect"></button>
</div>
</div>
</SmartModal>
<div class="modal-body">
<FileView @ref="FolderSelectView"
FileAccess="FolderSelectFileAccess"
Filter="FolderSelectFilter"
ShowDate="false"
ShowSelect="false"
ShowSize="false"
OnEntryClicked="EntryClickFolderSelect"
OnNavigateUpClicked="NavigateUpFolderSelect"
EnableContextMenu="false"/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @onclick="HideFolderSelect">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="SubmitFolderSelect">Submit</button>
</div>
</SmartModal>
}
@code
{
[Parameter] public BaseFileAccess FileAccess { get; set; }
// Navigation
public FileView View { get; private set; }
private string Path = "/";
private FileView? FileView;
// Uploading
private bool ShowFileUploader = false;
private FileUploader? FileUploader;
private IFileManagerContextAction[] ContextActions;
private IFileManagerSelectionAction[] SelectionActions;
private IFileManagerCreateAction[] CreateActions;
// Editing
private bool ShowFileEditor = false;
private FileEntry EditorOpenFile;
// Editor
private FileEditor Editor;
private FileEntry FileToEdit;
private bool ShowEditor = false;
// Move
private FileEntry MoveEntry;
private SmartModal MoveModal;
private BaseFileAccess MoveAccess;
// Folder select dialog
private bool FolderSelectIsOpen = false;
private SmartModal FolderSelectModal;
private BaseFileAccess FolderSelectFileAccess;
private string FolderSelectTitle;
private Func<string, Task> FolderSelectResult;
private FileView FolderSelectView;
private Func<FileEntry, bool> FolderSelectFilter => entry => entry.IsDirectory;
private async Task OnPathChanged(string path)
private Timer? UploadTokenTimer;
protected override async Task OnInitializedAsync()
{
Path = path;
await InvokeAsync(StateHasChanged);
// Load plugin ui and options
ContextActions = await PluginService.GetImplementations<IFileManagerContextAction>();
SelectionActions = await PluginService.GetImplementations<IFileManagerSelectionAction>();
CreateActions = await PluginService.GetImplementations<IFileManagerCreateAction>();
}
private async Task NavigateToPath(string path)
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (ShowFileUploader)
await ToggleFileUploader(false);
if (FileView == null)
if (!firstRender)
return;
await FileView.NavigateToPath(path);
}
#region Uploader
private async Task ToggleFileUploader() => await ToggleFileUploader(!ShowFileUploader);
private async Task ToggleFileUploader(bool b)
{
ShowFileUploader = b;
await InvokeAsync(StateHasChanged);
}
#endregion
#region mkdir / touch
private async Task CreateFile()
{
if (FileView == null)
return;
var name = await AlertService.Text("Enter the filename", "");
if(string.IsNullOrEmpty(name))
return;
if (name.Contains(".."))
// Setup upload url update timer
UploadTokenTimer = new(async _ =>
{
Logger.Warn($"Someone tried to use path transversal to create a file: '{name}'", "security");
return;
await FileAccessService.Register(FileAccess);
var token = await FileAccessService.GenerateToken(FileAccess);
var url = $"/api/upload?token={token}";
await FileManagerInteropService.UpdateUrl("fileManager", url);
}, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
// Create initial url
await FileAccessService.Register(FileAccess);
var token = await FileAccessService.GenerateToken(FileAccess);
var url = $"/api/upload?token={token}";
// Refresh the file view when a upload is completed
FileManagerInteropService.OnUploadStateChanged += async () => { await View.Refresh(); };
// Initialize drop area & file select
await FileManagerInteropService.UpdateUrl("fileManager", url);
await FileManagerInteropService.InitDropzone("fileManagerUpload", "fileManager");
await FileManagerInteropService.InitFileSelect("fileManagerSelect", "fileManager");
}
private async Task OnEntryClicked(FileEntry entry)
{
if (entry.IsFile)
{
var fileSizeInKilobytes = ByteSizeValue.FromBytes(entry.Size).KiloBytes;
if (fileSizeInKilobytes > ConfigService.Get().Customisation.FileManager.MaxFileOpenSize)
{
await ToastService.Danger("Unable to open file as it exceeds the max file size limit");
return;
}
await OpenEditor(entry);
}
await FileAccess.CreateFile(name);
await FileView.Refresh();
// Open editor to start editing
await OpenEditor(new FileEntry()
else
{
Size = 0,
Name = name,
IsFile = true,
IsDirectory = false,
LastModifiedAt = DateTime.UtcNow
});
}
await FileAccess.ChangeDirectory(entry.Name);
await View.Refresh();
private async Task CreateDirectory()
{
if (FileView == null)
return;
var name = await AlertService.Text("Enter the folder name", "");
if(string.IsNullOrEmpty(name))
return;
if (name.Contains(".."))
{
Logger.Warn($"Someone tried to use path transversal to create a file: '{name}'", "security");
return;
await Refresh();
}
await FileAccess.CreateDirectory(name);
await FileView.Refresh();
}
#endregion
#region Editor
private async Task OnFileClicked(FileEntry fileEntry) => await OpenEditor(fileEntry);
private async Task OpenEditor(FileEntry fileEntry)
private async Task InvokeContextAction(IFileManagerContextAction contextAction, FileEntry entry)
{
var fileSizeInKilobytes = ByteSizeValue.FromBytes(fileEntry.Size).KiloBytes;
await View.HideContextMenu();
if (fileSizeInKilobytes > ConfigService.Get().Customisation.FileManager.MaxFileOpenSize)
{
await ToastService.Danger("Unable to open file as it exceeds the max file size limit");
await contextAction.Execute(FileAccess, this, entry, ServiceProvider);
}
private async Task InvokeSelectionAction(IFileManagerSelectionAction action)
{
await action.Execute(FileAccess, this, View.Selection, ServiceProvider);
// Refresh resets the selection
await View.Refresh();
}
private async Task InvokeCreateAction(IFileManagerCreateAction action)
{
await action.Execute(FileAccess, this, ServiceProvider);
}
private async Task OnSelectionChanged(FileEntry[] _) => await InvokeAsync(StateHasChanged);
#region Navigation & Refreshing
private async Task OnNavigateUpClicked()
{
await FileAccess.ChangeDirectory("..");
await View.Refresh();
await Refresh();
}
private async Task NavigateBackToLevel(int level)
{
if (ShowEditor) // Ignore navigation events while the editor is open
return;
}
EditorOpenFile = fileEntry;
var path = await FileAccess.GetCurrentDirectory();
// Prepare editor
ShowFileEditor = true;
await InvokeAsync(StateHasChanged);
var parts = path.Split("/");
var pathToNavigate = string.Join("/", parts.Take(level + 1)) + "/";
await FileAccess.SetDirectory(pathToNavigate);
await View.Refresh();
await Refresh();
}
private async Task OnEditorClosed()
private async Task ManualRefresh()
{
ShowFileEditor = false;
if (ShowEditor) // Ignore refresh while editor is open
return;
await View.Refresh();
await Refresh();
await ToastService.Info("Refreshed");
}
private async Task Refresh()
{
Path = await FileAccess.GetCurrentDirectory();
await InvokeAsync(StateHasChanged);
}
#endregion
#region Move
#region File Editor
private async Task StartMove(FileEntry fileEntry)
public async Task OpenEditor(FileEntry entry)
{
MoveEntry = fileEntry;
MoveAccess = FileAccess.Clone();
FileToEdit = entry;
ShowEditor = true;
await MoveAccess.SetDirectory("/");
await MoveModal.Show();
await InvokeAsync(StateHasChanged);
}
private async Task FinishMove()
public async Task CloseEditor()
{
var pathToMove = await MoveAccess.GetCurrentDirectory();
MoveAccess.Dispose();
// Perform move and process ui updates
await FileAccess.Move(MoveEntry, pathToMove + MoveEntry.Name);
await MoveModal.Hide();
if (FileView == null)
return;
await FileView.Refresh();
ShowEditor = false;
await InvokeAsync(StateHasChanged);
}
#endregion
#region Selects
public async Task OpenFolderSelect(string title, Func<string, Task> onResult)
{
if (FolderSelectIsOpen)
await HideFolderSelect();
FolderSelectResult = onResult;
FolderSelectTitle = title;
FolderSelectFileAccess = FileAccess.Clone();
await FolderSelectFileAccess.SetDirectory("/");
await FolderSelectModal.Show();
}
public async Task HideFolderSelect()
{
await FolderSelectModal.Hide();
FolderSelectIsOpen = false;
FolderSelectFileAccess.Dispose();
}
private async Task SubmitFolderSelect()
{
var path = await FolderSelectFileAccess.GetCurrentDirectory();
await HideFolderSelect();
await FolderSelectResult.Invoke(path);
}
private async Task NavigateUpFolderSelect()
{
await FolderSelectFileAccess.ChangeDirectory("..");
await FolderSelectView.Refresh();
}
private async Task EntryClickFolderSelect(FileEntry entry)
{
await FolderSelectFileAccess.ChangeDirectory(entry.Name);
await FolderSelectView.Refresh();
}
#endregion
public async void Dispose()
{
if (UploadTokenTimer != null)
await UploadTokenTimer.DisposeAsync();
await FileAccessService.Unregister(FileAccess);
}
}

View File

@@ -1,94 +0,0 @@
@using Moonlight.Core.Services
@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess
@using Moonlight.Features.FileManager.Services
@inject DropzoneService DropzoneService
@inject SharedFileAccessService SharedFileAccessService
@implements IDisposable
<div class="d-flex justify-content-center">
<div class="col-md-8" @ondrop:preventDefault>
<div class="dropzone dropzone-queue" id="@DropzoneId">
<div class="dropzone-panel mb-lg-0 mb-2">
<div class="card border border-1 border-primary bg-secondary" style="pointer-events: none">
<div class="card-body">
<IconAlert Title="" Color="primary" Icon="bx-cloud-upload">
<div class="text-center fs-1 fw-bold">
Drag a file or folder or <a class="dropzone-select" style="pointer-events: all">click to upload files</a>
</div>
</IconAlert>
</div>
</div>
</div>
<div class="dropzone-items wm-200px">
<div class="dropzone-item" style="display:none">
<div class="dropzone-file">
<div class="dropzone-filename" title="some_image_file_name.jpg">
<span data-dz-name>some_image_file_name.jpg</span>
<strong>(<span data-dz-size>340kb</span>)</strong>
</div>
<div class="dropzone-error" data-dz-errormessage></div>
</div>
<div class="dropzone-progress">
<div class="progress">
<div
class="progress-bar bg-primary"
role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" data-dz-uploadprogress>
</div>
</div>
</div>
<div class="dropzone-toolbar">
<span class="dropzone-delete" data-dz-remove>
<i class="bx bx-x fs-1"></i>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
@code
{
[Parameter] public BaseFileAccess FileAccess { get; set; }
private CancellationTokenSource Cancellation = new();
private string DropzoneId;
protected override void OnInitialized()
{
DropzoneId = $"dropzone{GetHashCode()}";
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
//await SharedFileAccessService.Register(FileAccess);
//var token = await SharedFileAccessService.GenerateToken(FileAccess);
//var url = $"/api/upload?token={token}";
//await DropzoneService.Create(DropzoneId, url);
Task.Run(async () => // Update the dropzone url every 5 minutes so the token does not expire
{
while (!Cancellation.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromMinutes(5));
//var newToken = await SharedFileAccessService.GenerateToken(FileAccess);
//var newUrl = $"/api/upload?token={newToken}";
//await DropzoneService.UpdateUrl(DropzoneId, newUrl);
}
});
}
}
public async void Dispose()
{
Cancellation.Cancel();
//await SharedFileAccessService.Unregister(FileAccess);
}
}

View File

@@ -1,247 +1,287 @@
@using MoonCoreUI.Services
@using MoonCore.Helpers
@using Moonlight.Core.Services
@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess
@using Moonlight.Features.FileManager.Services
@using BlazorContextMenu
@inject ToastService ToastService
@inject AlertService AlertService
@inject SharedFileAccessService SharedFileAccessService
@inject NavigationManager Navigation
@inject IJSRuntime JsRuntime
@implements IDisposable
<LazyLoader @ref="LazyLoader" Load="Load">
<table class="w-100 table table-responsive table-row-bordered">
<div class="@(IsLoading ? "table-loading" : "")">
@if (IsLoading)
{
<div class="table-loading-message table-loading-message fs-3 fw-bold text-white">
Loading...
</div>
}
<table class="w-100 table table-row-bordered @(IsLoading ? "blur" : "table-hover") fs-6">
<tbody>
@if (ShowHeader)
{
<tr>
@if (ShowSelect)
{
<td class="w-10px align-middle">
<div class="form-check">
<input class="form-check-input" type="checkbox" @oninput="args => ToggleAll(args)">
</div>
</td>
}
@if (ShowIcons)
{
<td></td>
}
<td class="align-middle fs-6 text-muted">
Name
<tr class="text-muted">
@if (ShowSelect)
{
<td class="w-10px align-middle">
<div class="form-check">
@if (IsAllSelected)
{
<input class="form-check-input" type="checkbox" value="1" checked="checked" @oninput="() => ChangeAllSelection(false)">
}
else
{
<input class="form-check-input" type="checkbox" value="0" @oninput="() => ChangeAllSelection(true)">
}
</div>
</td>
@if (ShowSize)
{
<td class="align-middle fs-6 text-muted d-none d-sm-table-cell text-end">
Size
</td>
}
@if (ShowLastModified)
{
<td class="align-middle fs-6 text-muted d-none d-sm-table-cell text-end">
Last modified at
</td>
}
@if (SelectedEntries.Count == 0)
{
<td></td>
}
else
{
<td class="w-50 fs-6 text-end">
<span class="text-primary">@SelectedEntries.Count</span> element(s) selected
<div class="ms-2 btn-group">
<WButton OnClick="() => Delete(SelectedEntries.ToArray())" CssClasses="btn btn-icon btn-danger">
<i class="text-white bx bx-sm bx-trash"></i>
</WButton>
</div>
</td>
}
</tr>
}
}
<td class="w-10px"></td>
<td>Name</td>
@if (ShowSize)
{
<td class="d-none d-md-table-cell">Size</td>
}
@if (ShowDate)
{
<td class="d-none d-md-table-cell">Last modified</td>
}
@if (EnableContextMenu)
{
<td></td>
}
@if (AdditionTemplate != null)
{
<td></td>
}
</tr>
@if (ShowGoUp && Path != "/" && !DisableNavigation)
@if (Path != "/" && ShowNavigateUp)
{
<tr>
<tr class="fw-semibold">
@if (ShowSelect)
{
<td class="w-10px align-middle">
</td>
<td class="align-middle w-10px"></td>
}
@if (ShowIcons)
{
<td class="w-10px align-middle">
</td>
}
<td class="align-middle fs-6">
@{
var upPath = "..";
}
<a href="#"
@onclick:preventDefault
@onclick="() => Navigate(upPath)">
Go up
<td class="w-10px">
<i class="bx bx-sm bx-chevrons-left"></i>
</td>
<td>
<a href="#" @onclick:preventDefault @onclick="NavigateUp">
Back to parent folder
</a>
</td>
@if (ShowSize)
{
<td class="align-middle fs-6 d-none d-sm-table-cell text-end">
<span>-</span>
</td>
<td></td>
}
@if (ShowLastModified)
@if (ShowDate)
{
<td class="align-middle fs-6 d-none d-sm-table-cell text-end">
-
</td>
<td></td>
}
@if (ShowActions)
@if (EnableContextMenu)
{
<td class="w-50 text-end">
</td>
<td></td>
}
@if (AdditionTemplate != null)
{
<td></td>
}
</tr>
}
@foreach (var entry in Entries)
{
<tr>
@if (ShowSelect)
{
<td class="w-10px align-middle">
<div class="form-check">
@if (SelectedEntries.Contains(entry))
{
<input class="form-check-input" type="checkbox" value="1" checked="checked" @oninput="args => HandleSelected(entry, args)">
}
else
{
<input class="form-check-input" type="checkbox" value="0" @oninput="args => HandleSelected(entry, args)">
}
</div>
</td>
}
@if (ShowIcons)
{
<td class="w-10px align-middle">
if (EnableContextMenu)
{
<ContextMenuTrigger MenuId="@ContextMenuId" WrapperTag="tr" Data="entry">
@if (ShowSelect)
{
<td class="w-10px align-middle">
<div class="form-check">
@if (SelectionCache.ContainsKey(entry) && SelectionCache[entry])
{
<input class="form-check-input" type="checkbox" value="1" checked="checked" @oninput="() => ChangeSelection(entry, false)">
}
else
{
<input class="form-check-input" type="checkbox" value="0" @oninput="() => ChangeSelection(entry, true)">
}
</div>
</td>
}
<td class="align-middle w-10px">
@if (entry.IsFile)
{
<i class="bx bx-md bx-file"></i>
<i class="bx bx-md bxs-file-blank text-white"></i>
}
else
{
<i class="bx bx-md bx-folder"></i>
<i class="bx bx-md bxs-folder text-primary"></i>
}
</td>
}
<td class="align-middle fs-6">
@if (DisableNavigation)
{
<span>@(entry.Name)</span>
}
else
{
<a href="#"
@onclick:preventDefault
@onclick="() => HandleClick(entry)">
@(entry.Name)
<td class="align-middle">
<a href="#" @onclick:preventDefault @onclick="() => HandleEntryClick(entry)">
@entry.Name
</a>
</td>
@if (ShowSize)
{
<td class="align-middle d-none d-md-table-cell">
@if (entry.IsFile)
{
@Formatter.FormatSize(entry.Size)
}
</td>
}
</td>
@if (ShowSize)
{
<td class="align-middle fs-6 d-none d-sm-table-cell text-end">
@if (entry.IsFile)
{
@(Formatter.FormatSize(entry.Size))
}
else
{
<span>-</span>
}
</td>
}
@if (ShowLastModified)
{
<td class="align-middle fs-6 d-none d-sm-table-cell text-end">
@(Formatter.FormatDate(entry.LastModifiedAt))
</td>
}
@if (ShowActions)
{
<td class="w-50 text-end">
<div class="btn-group">
<WButton OnClick="() => Delete(entry)" CssClasses="btn btn-icon btn-danger">
<i class="text-white bx bx-sm bx-trash"></i>
</WButton>
<div class="dropdown">
<button class="btn btn-icon btn-secondary rounded-start-0" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="text-white bx bx-sm bx-dots-horizontal-rounded"></i>
</button>
<ul class="dropdown-menu">
<li>
<a href="#" @onclick:preventDefault @onclick="() => Rename(entry)" class="dropdown-item">Rename</a>
</li>
<li>
<a href="#" @onclick:preventDefault @onclick="() => Download(entry)" class="dropdown-item">Download</a>
</li>
@if (OnMoveRequested != null)
{
<li>
<a href="#" @onclick:preventDefault @onclick="() => RequestMove(entry)" class="dropdown-item">Move</a>
</li>
}
</ul>
@if (ShowDate)
{
<td class="align-middle d-none d-md-table-cell">
@Formatter.FormatDate(entry.LastModifiedAt)
</td>
}
<td class="d-table-cell d-md-none">
<div class="dropstart">
<button class="btn btn-icon btn-secondary" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bx bx-sm bx-dots-horizontal"></i>
</button>
<div class="dropdown-menu fs-6">
@if (ContextMenuTemplate != null)
{
@ContextMenuTemplate.Invoke(entry)
}
</div>
</div>
</td>
}
</tr>
@if (AdditionTemplate != null)
{
@AdditionTemplate.Invoke(entry)
}
</ContextMenuTrigger>
}
else
{
<tr>
@if (ShowSelect)
{
<td class="w-10px align-middle">
<div class="form-check">
@if (SelectionCache.ContainsKey(entry) && SelectionCache[entry])
{
<input class="form-check-input" type="checkbox" value="1" checked="checked" @oninput="() => ChangeSelection(entry, false)">
}
else
{
<input class="form-check-input" type="checkbox" value="0" @oninput="() => ChangeSelection(entry, true)">
}
</div>
</td>
}
<td class="align-middle w-10px">
@if (entry.IsFile)
{
<i class="bx bx-md bxs-file-blank text-white"></i>
}
else
{
<i class="bx bx-md bxs-folder text-primary"></i>
}
</td>
<td class="align-middle">
<a href="#" @onclick:preventDefault @onclick="() => HandleEntryClick(entry)">
@entry.Name
</a>
</td>
@if (ShowSize)
{
<td class="align-middle d-none d-md-table-cell">
@if (entry.IsFile)
{
@Formatter.FormatSize(entry.Size)
}
</td>
}
@if (ShowDate)
{
<td class="align-middle d-none d-md-table-cell">
@Formatter.FormatDate(entry.LastModifiedAt)
</td>
}
@if (AdditionTemplate != null)
{
@AdditionTemplate.Invoke(entry)
}
</tr>
}
}
</tbody>
</table>
</LazyLoader>
</div>
@if (EnableContextMenu && ContextMenuTemplate != null)
{
<ContextMenu @ref="CurrentContextMenu" Id="@ContextMenuId" OnAppearing="OnContextMenuAppear" OnHiding="OnContextMenuHide">
@if (ShowContextMenu)
{
<div class="dropdown-menu show fs-6">
@ContextMenuTemplate.Invoke(ContextMenuItem)
</div>
}
</ContextMenu>
}
@code
{
[Parameter] public BaseFileAccess FileAccess { get; set; }
[Parameter] public RenderFragment<FileEntry>? AdditionTemplate { get; set; }
[Parameter] public Func<FileEntry, bool>? Filter { get; set; }
[Parameter] public bool ShowSize { get; set; } = true;
[Parameter] public bool ShowLastModified { get; set; } = true;
[Parameter] public bool ShowIcons { get; set; } = true;
[Parameter] public bool ShowActions { get; set; } = true;
[Parameter] public bool ShowDate { get; set; } = true;
[Parameter] public bool ShowSelect { get; set; } = true;
[Parameter] public bool ShowGoUp { get; set; } = true;
[Parameter] public bool ShowHeader { get; set; } = true;
[Parameter] public bool DisableNavigation { get; set; } = false;
[Parameter] public Func<FileEntry, Task>? OnFileClicked { get; set; }
[Parameter] public Func<Task>? OnSelectionChanged { get; set; }
[Parameter] public Func<string, Task>? OnPathChanged { get; set; }
[Parameter] public Func<FileEntry, Task>? OnMoveRequested { get; set; }
[Parameter] public bool ShowNavigateUp { get; set; } = true;
public readonly List<FileEntry> SelectedEntries = new();
[Parameter] public RenderFragment<FileEntry>? ContextMenuTemplate { get; set; }
[Parameter] public bool EnableContextMenu { get; set; } = false;
private bool ShowContextMenu = false;
private FileEntry ContextMenuItem;
private string ContextMenuId = "fileManagerContextMenu";
private ContextMenu? CurrentContextMenu;
private LazyLoader LazyLoader;
private FileEntry[] Entries;
[Parameter] public BaseFileAccess FileAccess { get; set; }
[Parameter] public Func<FileEntry, bool>? Filter { get; set; }
[Parameter] public Func<FileEntry, Task>? OnEntryClicked { get; set; }
[Parameter] public Func<FileEntry[], Task>? OnSelectionChanged { get; set; }
[Parameter] public Func<Task>? OnNavigateUpClicked { get; set; }
private bool IsLoading = false;
private string LoadingText = "";
private FileEntry[] Entries = Array.Empty<FileEntry>();
private string Path = "/";
private async Task Load(LazyLoader lazyLoader)
{
await lazyLoader.SetText("Loading files and folders");
private Dictionary<FileEntry, bool> SelectionCache = new();
public FileEntry[] Selection => SelectionCache.Where(x => x.Value).Select(x => x.Key).ToArray();
private bool IsAllSelected => Entries.Length != 0 && SelectionCache.Count(x => x.Value) == Entries.Length;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await Refresh();
}
public async Task Refresh()
{
IsLoading = true;
LoadingText = "Loading";
await InvokeAsync(StateHasChanged);
// Load current directory
Path = await FileAccess.GetCurrentDirectory();
// Load entries
LoadingText = "Loading files and folders";
await InvokeAsync(StateHasChanged);
// Load all entries
Entries = await FileAccess.List();
await lazyLoader.SetText("Sorting files and folders");
// Sort entries
LoadingText = "Sorting files and folders";
await InvokeAsync(StateHasChanged);
// Perform sorting and filtering
if (Filter != null)
{
Entries = Entries
@@ -255,180 +295,93 @@
.SelectMany(x => x.OrderBy(y => y.Name))
.ToArray();
SelectedEntries.Clear();
// Build selection cache
SelectionCache.Clear();
Path = await FileAccess.GetCurrentDirectory();
foreach (var entry in Entries)
SelectionCache.Add(entry, false);
if (OnPathChanged != null)
await OnPathChanged.Invoke(Path);
if (OnSelectionChanged != null)
await OnSelectionChanged.Invoke(Array.Empty<FileEntry>());
IsLoading = false;
await InvokeAsync(StateHasChanged);
}
private async Task HandleClick(FileEntry fileEntry)
private async Task HandleEntryClick(FileEntry entry)
{
if (fileEntry.IsDirectory && !DisableNavigation)
{
await Navigate(fileEntry.Name);
}
else
{
if (OnFileClicked != null)
await OnFileClicked.Invoke(fileEntry);
}
}
#region Actions
private async Task Delete(params FileEntry[] entries)
{
if (entries.Length == 0)
if (OnEntryClicked == null)
return;
var fileNameDesc = entries.Length == 1 ? entries.First().Name : $"{entries.Length} files";
var confirm = await AlertService.YesNo($"Do you really want to delete '{fileNameDesc}'?", "Yes", "No");
if(!confirm)
return;
var toastId = "fileDelete" + GetHashCode();
await ToastService.CreateProgress(toastId, $"[0/{entries.Length}] Deleting items");
int i = 0;
foreach (var entry in entries)
{
await ToastService.ModifyProgress(toastId, $"[{i + 1}/{entries.Length}] Deleting '{entry.Name}'");
await FileAccess.Delete(entry);
i++;
}
await ToastService.RemoveProgress(toastId);
await ToastService.Success($"Successfully deleted {i} item(s)");
await LazyLoader.Reload();
await OnEntryClicked.Invoke(entry);
}
private async Task Rename(FileEntry fileEntry)
private async Task NavigateUp()
{
var name = await AlertService.Text($"Rename '{fileEntry.Name}'", "", fileEntry.Name);
if (string.IsNullOrEmpty(name))
if (OnNavigateUpClicked == null)
return;
await FileAccess.Move(fileEntry, await FileAccess.GetCurrentDirectory() + name);
await LazyLoader.Reload();
await OnNavigateUpClicked.Invoke();
}
private async Task RequestMove(FileEntry fileEntry)
{
if (OnMoveRequested == null)
return;
await OnMoveRequested.Invoke(fileEntry);
}
private async Task Download(FileEntry fileEntry)
{
try
{
//await SharedFileAccessService.Register(FileAccess);
//var token = await SharedFileAccessService.GenerateToken(FileAccess);
//var url = $"/api/download?token={token}&name={fileEntry.Name}";
await ToastService.Info("Starting download...");
//Navigation.NavigateTo(url, true);
}
catch (Exception e)
{
Logger.Warn("Unable to start download");
Logger.Warn(e);
await ToastService.Danger("Failed to start download");
}
}
#endregion
#region Selection
private async Task HandleSelected(FileEntry fileEntry, ChangeEventArgs args)
private async Task ChangeSelection(FileEntry entry, bool selectionState)
{
if (args.Value == null) // This should never be called. Still i want to handle it
return;
if (args.Value.ToString() == "True")
{
if (!SelectedEntries.Contains(fileEntry))
SelectedEntries.Add(fileEntry);
}
else
{
if (SelectedEntries.Contains(fileEntry))
SelectedEntries.Remove(fileEntry);
}
SelectionCache[entry] = selectionState;
await InvokeAsync(StateHasChanged);
if (OnSelectionChanged != null)
await OnSelectionChanged.Invoke();
{
await OnSelectionChanged.Invoke(SelectionCache
.Where(x => x.Value)
.Select(x => x.Key)
.ToArray()
);
}
}
private async Task ChangeAllSelection(bool toggle)
{
foreach (var key in SelectionCache.Keys)
SelectionCache[key] = toggle;
await InvokeAsync(StateHasChanged);
if (OnSelectionChanged != null)
{
await OnSelectionChanged.Invoke(SelectionCache
.Where(x => x.Value)
.Select(x => x.Key)
.ToArray()
);
}
}
#endregion
#region Context Menu
private async Task OnContextMenuAppear(MenuAppearingEventArgs data)
{
ContextMenuItem = (data.Data as FileEntry)!;
ShowContextMenu = true;
await InvokeAsync(StateHasChanged);
}
private async Task ToggleAll(ChangeEventArgs args)
private async Task OnContextMenuHide()
{
if (args.Value == null)
return;
if (args.Value.ToString() == "True")
{
foreach (var entry in Entries)
{
if (!SelectedEntries.Contains(entry))
SelectedEntries.Add(entry);
}
}
else
{
SelectedEntries.Clear();
}
ShowContextMenu = false;
await InvokeAsync(StateHasChanged);
}
public async Task HideContextMenu()
{
ShowContextMenu = false;
await InvokeAsync(StateHasChanged);
}
#endregion
#region Navigation
public async Task Navigate(string name)
{
await LazyLoader.Reload(async loader =>
{
await loader.SetText("Switching directory on target");
await FileAccess.ChangeDirectory(name);
if (OnPathChanged != null)
await OnPathChanged.Invoke(await FileAccess.GetCurrentDirectory());
});
}
public async Task NavigateToPath(string path)
{
await LazyLoader.Reload(async loader =>
{
await loader.SetText("Switching directory on target");
await FileAccess.SetDirectory(path);
if (OnPathChanged != null)
await OnPathChanged.Invoke(await FileAccess.GetCurrentDirectory());
});
}
#endregion
public async Task Refresh() => await LazyLoader.Reload();
public async void Dispose()
{
//await SharedFileAccessService.Unregister(FileAccess);
}
}

View File

@@ -1,60 +0,0 @@
@using Moonlight.Features.FileManager.Services
@using MoonCore.Helpers
@inject EditorService EditorService
<div id="@Identifier" @onfocusout="FocusOut"></div>
@code
{
[Parameter] public string InitialContent { get; set; } = "";
[Parameter] public string Theme { get; set; } = "one_dark";
[Parameter] public string Mode { get; set; } = "text";
[Parameter] public int Lines { get; set; } = 30;
[Parameter] public int FontSize { get; set; } = 15;
[Parameter] public bool EnableAutoInit { get; set; } = false;
[Parameter] public Func<string, Task>? OnChanged { get; set; }
private string Identifier;
protected override void OnInitialized()
{
Identifier = "editor" + GetHashCode();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if(EnableAutoInit)
await Initialize();
}
}
public async Task Initialize()
{
await EditorService.Create(
Identifier,
Theme,
Mode,
InitialContent,
Lines,
FontSize
);
}
public async Task<string> GetContent() => await EditorService.GetValue();
public async Task SetContent(string content) => await EditorService.SetValue(content);
public async Task SetMode(string mode) => await EditorService.SetMode(mode);
private async Task FocusOut()
{
if (OnChanged != null)
{
var content = await GetContent();
await OnChanged.Invoke(content);
}
}
}

View File

@@ -1,112 +0,0 @@
@using MoonCoreUI.Services
@using Moonlight.Core.Services
@using MoonCore.Helpers
@using Moonlight.Core.Helpers
@using Moonlight.Features.FileManager.Helpers
@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess
@inject ToastService ToastService
@inject HotKeyService HotKeyService
@implements IDisposable
<div class="card mb-2 border-0 rounded">
<div class="card-body py-3 rounded" style="background-color: rgb(21, 21, 33)">
<div class="d-flex justify-content-between">
<div class="d-flex align-items-center">
<span class="fw-bold fs-5 align-middle">@(File.Name) (@(Formatter.FormatSize(File.Size)))</span>
</div>
<div class="btn-group">
<WButton OnClick="OnClose" CssClasses="btn btn-sm btn-primary">
<i class="bx bx-sm bx-arrow-back"></i>Back
</WButton>
<WButton OnClick="OnSave" CssClasses="btn btn-sm btn-success">
<i class="bx bx-sm bx-save"></i>Save
</WButton>
</div>
</div>
</div>
</div>
<Editor @ref="Editor" InitialContent="Loading file"/>
@code
{
[Parameter] public FileEntry File { get; set; }
[Parameter] public BaseFileAccess FileAccess { get; set; }
[Parameter] public bool CloseOnSave { get; set; } = false;
[Parameter] public Func<Task>? OnClosed { get; set; }
private Editor Editor;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// Initialize the editor
await Editor.Initialize();
// Load file and check the file type
var fileData = await FileAccess.ReadFile(File.Name);
var mode = EditorModeDetector.GetModeFromFile(File.Name);
// Finalize editor
await Editor.SetMode(mode);
await Editor.SetContent(fileData);
HotKeyService.HotKeyPressed += OnHotKeyPressed;
}
}
private async Task OnClose()
{
if (OnClosed != null)
await OnClosed.Invoke();
}
private async Task OnSave()
{
try
{
var content = await Editor.GetContent();
await FileAccess.WriteFile(File.Name, content);
}
catch (Exception e)
{
Logger.Warn($"An unhandled error has occured while saving a file using access type {FileAccess.GetType().FullName}");
Logger.Warn(e);
await ToastService.Danger("An unknown error has occured while saving the file. Please try again later");
return;
}
await ToastService.Success("Successfully saved file");
if (CloseOnSave)
{
if (OnClosed != null)
await OnClosed.Invoke();
}
}
private async Task OnHotKeyPressed(string hotKey)
{
switch (hotKey)
{
case "save":
await OnSave();
break;
case "close":
await OnClose();
break;
}
}
public void Dispose()
{
HotKeyService.HotKeyPressed -= OnHotKeyPressed;
}
}

View File

@@ -1,364 +0,0 @@
@using MoonCore.Helpers
@using MoonCore.Services
@using MoonCoreUI.Services
@using Moonlight.Core.Configuration
@using Moonlight.Core.Services
@using Moonlight.Features.FileManager.Interfaces
@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess
@using Moonlight.Features.FileManager.Services
@inject AlertService AlertService
@inject ToastService ToastService
@inject FileManagerInteropService FileManagerInteropService
@inject SharedFileAccessService FileAccessService
@inject ConfigService<CoreConfiguration> ConfigService
@inject NavigationManager Navigation
@inject PluginService PluginService
@inject IServiceProvider ServiceProvider
@implements IDisposable
<div class="card card-body px-5">
<div class="d-flex justify-content-center justify-content-md-between">
<div class="d-none d-md-flex justify-content-start align-items-center">
<div class="badge badge-primary badge-lg fs-5 py-2 text-center">
@{
var parts = Path
.Split("/")
.Where(x => !string.IsNullOrEmpty(x))
.ToArray();
var i = 1;
}
<a href="#" class="text-white mx-1" @onclick:preventDefault @onclick="() => NavigateBackToLevel(0)">/</a>
@foreach (var part in parts)
{
var x = i + 0;
<a href="#" class="text-white mx-1" @onclick:preventDefault @onclick="() => NavigateBackToLevel(x)">@(part)</a>
<div class="mx-2 text-white">/</div>
i++;
}
</div>
</div>
<div class="d-flex justify-content-center justify-content-md-end align-items-center">
@if (View != null && View.Selection.Any())
{
foreach (var action in SelectionActions)
{
var cssClass = $"btn btn-{action.Color} mx-2";
<WButton Text="@action.Name" CssClasses="@cssClass" OnClick="() => InvokeSelectionAction(action)"/>
}
}
else
{
<WButton OnClick="ManualRefresh" CssClasses="btn btn-icon btn-light-info">
<i class="bx bx-sm bx-refresh"></i>
</WButton>
<label for="fileManagerSelect" class="btn btn-light-primary mx-2">Upload</label>
<input id="fileManagerSelect" type="file" hidden="hidden" multiple/>
<div class="dropdown">
<a class="btn btn-primary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
New
</a>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuLink">
@foreach (var action in CreateActions)
{
<li>
<a href="#" class="dropdown-item" @onclick:preventDefault @onclick="() => InvokeCreateAction(action)">
<i class="bx bx-sm @action.Icon text-@action.Color me-2 align-middle"></i>
<span class="align-middle fs-6">@action.Name</span>
</a>
</li>
}
</ul>
</div>
}
</div>
</div>
</div>
@if (ShowEditor)
{
<div class="card card-body px-2 py-2 mt-5">
<FileEditor @ref="Editor" FileAccess="FileAccess" File="FileToEdit" OnClosed="CloseEditor"/>
</div>
}
else
{
<div id="fileManagerUpload" class="card card-body px-5 py-3 mt-5">
<FileView @ref="View"
FileAccess="FileAccess"
OnEntryClicked="OnEntryClicked"
OnNavigateUpClicked="OnNavigateUpClicked"
OnSelectionChanged="OnSelectionChanged"
EnableContextMenu="true">
<ContextMenuTemplate>
@foreach (var action in ContextActions)
{
if (!action.Filter.Invoke(context))
continue;
<a class="dropdown-item" href="#" @onclick:preventDefault @onclick="() => InvokeContextAction(action, context)">
<i class="bx bx-sm @action.Icon text-@action.Color align-middle"></i>
<span class="align-middle ms-3">@action.Name</span>
</a>
}
</ContextMenuTemplate>
</FileView>
</div>
<SmartModal @ref="FolderSelectModal" CssClasses="modal-lg modal-dialog-centered">
<div class="modal-header">
<h5 class="modal-title">@FolderSelectTitle</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" @onclick="HideFolderSelect"></button>
</div>
<div class="modal-body">
<FileView @ref="FolderSelectView"
FileAccess="FolderSelectFileAccess"
Filter="FolderSelectFilter"
ShowDate="false"
ShowSelect="false"
ShowSize="false"
OnEntryClicked="EntryClickFolderSelect"
OnNavigateUpClicked="NavigateUpFolderSelect"
EnableContextMenu="false"/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @onclick="HideFolderSelect">Cancel</button>
<button type="button" class="btn btn-primary" @onclick="SubmitFolderSelect">Submit</button>
</div>
</SmartModal>
}
@code
{
[Parameter] public BaseFileAccess FileAccess { get; set; }
public FileView View { get; private set; }
private string Path = "/";
private IFileManagerContextAction[] ContextActions;
private IFileManagerSelectionAction[] SelectionActions;
private IFileManagerCreateAction[] CreateActions;
// Editor
private FileEditor Editor;
private FileEntry FileToEdit;
private bool ShowEditor = false;
// Folder select dialog
private bool FolderSelectIsOpen = false;
private SmartModal FolderSelectModal;
private BaseFileAccess FolderSelectFileAccess;
private string FolderSelectTitle;
private Func<string, Task> FolderSelectResult;
private FileView FolderSelectView;
private Func<FileEntry, bool> FolderSelectFilter => entry => entry.IsDirectory;
private Timer? UploadTokenTimer;
protected override async Task OnInitializedAsync()
{
// Load plugin ui and options
ContextActions = await PluginService.GetImplementations<IFileManagerContextAction>();
SelectionActions = await PluginService.GetImplementations<IFileManagerSelectionAction>();
CreateActions = await PluginService.GetImplementations<IFileManagerCreateAction>();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender)
return;
// Setup upload url update timer
UploadTokenTimer = new(async _ =>
{
await FileAccessService.Register(FileAccess);
var token = await FileAccessService.GenerateToken(FileAccess);
var url = $"/api/upload?token={token}";
await FileManagerInteropService.UpdateUrl("fileManager", url);
}, null, TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
// Create initial url
await FileAccessService.Register(FileAccess);
var token = await FileAccessService.GenerateToken(FileAccess);
var url = $"/api/upload?token={token}";
// Refresh the file view when a upload is completed
FileManagerInteropService.OnUploadStateChanged += async () => { await View.Refresh(); };
// Initialize drop area & file select
await FileManagerInteropService.UpdateUrl("fileManager", url);
await FileManagerInteropService.InitDropzone("fileManagerUpload", "fileManager");
await FileManagerInteropService.InitFileSelect("fileManagerSelect", "fileManager");
}
private async Task OnEntryClicked(FileEntry entry)
{
if (entry.IsFile)
{
var fileSizeInKilobytes = ByteSizeValue.FromBytes(entry.Size).KiloBytes;
if (fileSizeInKilobytes > ConfigService.Get().Customisation.FileManager.MaxFileOpenSize)
{
await ToastService.Danger("Unable to open file as it exceeds the max file size limit");
return;
}
await OpenEditor(entry);
}
else
{
await FileAccess.ChangeDirectory(entry.Name);
await View.Refresh();
await Refresh();
}
}
private async Task InvokeContextAction(IFileManagerContextAction contextAction, FileEntry entry)
{
await View.HideContextMenu();
await contextAction.Execute(FileAccess, this, entry, ServiceProvider);
}
private async Task InvokeSelectionAction(IFileManagerSelectionAction action)
{
await action.Execute(FileAccess, this, View.Selection, ServiceProvider);
// Refresh resets the selection
await View.Refresh();
}
private async Task InvokeCreateAction(IFileManagerCreateAction action)
{
await action.Execute(FileAccess, this, ServiceProvider);
}
private async Task OnSelectionChanged(FileEntry[] _) => await InvokeAsync(StateHasChanged);
#region Navigation & Refreshing
private async Task OnNavigateUpClicked()
{
await FileAccess.ChangeDirectory("..");
await View.Refresh();
await Refresh();
}
private async Task NavigateBackToLevel(int level)
{
if (ShowEditor) // Ignore navigation events while the editor is open
return;
var path = await FileAccess.GetCurrentDirectory();
var parts = path.Split("/");
var pathToNavigate = string.Join("/", parts.Take(level + 1)) + "/";
await FileAccess.SetDirectory(pathToNavigate);
await View.Refresh();
await Refresh();
}
private async Task ManualRefresh()
{
if (ShowEditor) // Ignore refresh while editor is open
return;
await View.Refresh();
await Refresh();
await ToastService.Info("Refreshed");
}
private async Task Refresh()
{
Path = await FileAccess.GetCurrentDirectory();
await InvokeAsync(StateHasChanged);
}
#endregion
#region File Editor
public async Task OpenEditor(FileEntry entry)
{
FileToEdit = entry;
ShowEditor = true;
await InvokeAsync(StateHasChanged);
}
public async Task CloseEditor()
{
ShowEditor = false;
await InvokeAsync(StateHasChanged);
}
#endregion
#region Selects
public async Task OpenFolderSelect(string title, Func<string, Task> onResult)
{
if (FolderSelectIsOpen)
await HideFolderSelect();
FolderSelectResult = onResult;
FolderSelectTitle = title;
FolderSelectFileAccess = FileAccess.Clone();
await FolderSelectFileAccess.SetDirectory("/");
await FolderSelectModal.Show();
}
public async Task HideFolderSelect()
{
await FolderSelectModal.Hide();
FolderSelectIsOpen = false;
FolderSelectFileAccess.Dispose();
}
private async Task SubmitFolderSelect()
{
var path = await FolderSelectFileAccess.GetCurrentDirectory();
await HideFolderSelect();
await FolderSelectResult.Invoke(path);
}
private async Task NavigateUpFolderSelect()
{
await FolderSelectFileAccess.ChangeDirectory("..");
await FolderSelectView.Refresh();
}
private async Task EntryClickFolderSelect(FileEntry entry)
{
await FolderSelectFileAccess.ChangeDirectory(entry.Name);
await FolderSelectView.Refresh();
}
#endregion
public async void Dispose()
{
if (UploadTokenTimer != null)
await UploadTokenTimer.DisposeAsync();
await FileAccessService.Unregister(FileAccess);
}
}

View File

@@ -1,387 +0,0 @@
@using MoonCore.Helpers
@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess
@using BlazorContextMenu
@inject IJSRuntime JsRuntime
<div class="@(IsLoading ? "table-loading" : "")">
@if (IsLoading)
{
<div class="table-loading-message table-loading-message fs-3 fw-bold text-white">
Loading...
</div>
}
<table class="w-100 table table-row-bordered @(IsLoading ? "blur" : "table-hover") fs-6">
<tbody>
<tr class="text-muted">
@if (ShowSelect)
{
<td class="w-10px align-middle">
<div class="form-check">
@if (IsAllSelected)
{
<input class="form-check-input" type="checkbox" value="1" checked="checked" @oninput="() => ChangeAllSelection(false)">
}
else
{
<input class="form-check-input" type="checkbox" value="0" @oninput="() => ChangeAllSelection(true)">
}
</div>
</td>
}
<td class="w-10px"></td>
<td>Name</td>
@if (ShowSize)
{
<td class="d-none d-md-table-cell">Size</td>
}
@if (ShowDate)
{
<td class="d-none d-md-table-cell">Last modified</td>
}
@if (EnableContextMenu)
{
<td></td>
}
@if (AdditionTemplate != null)
{
<td></td>
}
</tr>
@if (Path != "/" && ShowNavigateUp)
{
<tr class="fw-semibold">
@if (ShowSelect)
{
<td class="align-middle w-10px"></td>
}
<td class="w-10px">
<i class="bx bx-sm bx-chevrons-left"></i>
</td>
<td>
<a href="#" @onclick:preventDefault @onclick="NavigateUp">
Back to parent folder
</a>
</td>
@if (ShowSize)
{
<td></td>
}
@if (ShowDate)
{
<td></td>
}
@if (EnableContextMenu)
{
<td></td>
}
@if (AdditionTemplate != null)
{
<td></td>
}
</tr>
}
@foreach (var entry in Entries)
{
if (EnableContextMenu)
{
<ContextMenuTrigger MenuId="@ContextMenuId" WrapperTag="tr" Data="entry">
@if (ShowSelect)
{
<td class="w-10px align-middle">
<div class="form-check">
@if (SelectionCache.ContainsKey(entry) && SelectionCache[entry])
{
<input class="form-check-input" type="checkbox" value="1" checked="checked" @oninput="() => ChangeSelection(entry, false)">
}
else
{
<input class="form-check-input" type="checkbox" value="0" @oninput="() => ChangeSelection(entry, true)">
}
</div>
</td>
}
<td class="align-middle w-10px">
@if (entry.IsFile)
{
<i class="bx bx-md bxs-file-blank text-white"></i>
}
else
{
<i class="bx bx-md bxs-folder text-primary"></i>
}
</td>
<td class="align-middle">
<a href="#" @onclick:preventDefault @onclick="() => HandleEntryClick(entry)">
@entry.Name
</a>
</td>
@if (ShowSize)
{
<td class="align-middle d-none d-md-table-cell">
@if (entry.IsFile)
{
@Formatter.FormatSize(entry.Size)
}
</td>
}
@if (ShowDate)
{
<td class="align-middle d-none d-md-table-cell">
@Formatter.FormatDate(entry.LastModifiedAt)
</td>
}
<td class="d-table-cell d-md-none">
<div class="dropstart">
<button class="btn btn-icon btn-secondary" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bx bx-sm bx-dots-horizontal"></i>
</button>
<div class="dropdown-menu fs-6">
@if (ContextMenuTemplate != null)
{
@ContextMenuTemplate.Invoke(entry)
}
</div>
</div>
</td>
@if (AdditionTemplate != null)
{
@AdditionTemplate.Invoke(entry)
}
</ContextMenuTrigger>
}
else
{
<tr>
@if (ShowSelect)
{
<td class="w-10px align-middle">
<div class="form-check">
@if (SelectionCache.ContainsKey(entry) && SelectionCache[entry])
{
<input class="form-check-input" type="checkbox" value="1" checked="checked" @oninput="() => ChangeSelection(entry, false)">
}
else
{
<input class="form-check-input" type="checkbox" value="0" @oninput="() => ChangeSelection(entry, true)">
}
</div>
</td>
}
<td class="align-middle w-10px">
@if (entry.IsFile)
{
<i class="bx bx-md bxs-file-blank text-white"></i>
}
else
{
<i class="bx bx-md bxs-folder text-primary"></i>
}
</td>
<td class="align-middle">
<a href="#" @onclick:preventDefault @onclick="() => HandleEntryClick(entry)">
@entry.Name
</a>
</td>
@if (ShowSize)
{
<td class="align-middle d-none d-md-table-cell">
@if (entry.IsFile)
{
@Formatter.FormatSize(entry.Size)
}
</td>
}
@if (ShowDate)
{
<td class="align-middle d-none d-md-table-cell">
@Formatter.FormatDate(entry.LastModifiedAt)
</td>
}
@if (AdditionTemplate != null)
{
@AdditionTemplate.Invoke(entry)
}
</tr>
}
}
</tbody>
</table>
</div>
@if (EnableContextMenu && ContextMenuTemplate != null)
{
<ContextMenu @ref="CurrentContextMenu" Id="@ContextMenuId" OnAppearing="OnContextMenuAppear" OnHiding="OnContextMenuHide">
@if (ShowContextMenu)
{
<div class="dropdown-menu show fs-6">
@ContextMenuTemplate.Invoke(ContextMenuItem)
</div>
}
</ContextMenu>
}
@code
{
[Parameter] public RenderFragment<FileEntry>? AdditionTemplate { get; set; }
[Parameter] public bool ShowSize { get; set; } = true;
[Parameter] public bool ShowDate { get; set; } = true;
[Parameter] public bool ShowSelect { get; set; } = true;
[Parameter] public bool ShowNavigateUp { get; set; } = true;
[Parameter] public RenderFragment<FileEntry>? ContextMenuTemplate { get; set; }
[Parameter] public bool EnableContextMenu { get; set; } = false;
private bool ShowContextMenu = false;
private FileEntry ContextMenuItem;
private string ContextMenuId = "fileManagerContextMenu";
private ContextMenu? CurrentContextMenu;
[Parameter] public BaseFileAccess FileAccess { get; set; }
[Parameter] public Func<FileEntry, bool>? Filter { get; set; }
[Parameter] public Func<FileEntry, Task>? OnEntryClicked { get; set; }
[Parameter] public Func<FileEntry[], Task>? OnSelectionChanged { get; set; }
[Parameter] public Func<Task>? OnNavigateUpClicked { get; set; }
private bool IsLoading = false;
private string LoadingText = "";
private FileEntry[] Entries = Array.Empty<FileEntry>();
private string Path = "/";
private Dictionary<FileEntry, bool> SelectionCache = new();
public FileEntry[] Selection => SelectionCache.Where(x => x.Value).Select(x => x.Key).ToArray();
private bool IsAllSelected => Entries.Length != 0 && SelectionCache.Count(x => x.Value) == Entries.Length;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
await Refresh();
}
public async Task Refresh()
{
IsLoading = true;
LoadingText = "Loading";
await InvokeAsync(StateHasChanged);
// Load current directory
Path = await FileAccess.GetCurrentDirectory();
// Load entries
LoadingText = "Loading files and folders";
await InvokeAsync(StateHasChanged);
Entries = await FileAccess.List();
// Sort entries
LoadingText = "Sorting files and folders";
await InvokeAsync(StateHasChanged);
if (Filter != null)
{
Entries = Entries
.Where(x => Filter.Invoke(x))
.ToArray();
}
Entries = Entries
.GroupBy(x => x.IsFile)
.OrderBy(x => x.Key)
.SelectMany(x => x.OrderBy(y => y.Name))
.ToArray();
// Build selection cache
SelectionCache.Clear();
foreach (var entry in Entries)
SelectionCache.Add(entry, false);
if (OnSelectionChanged != null)
await OnSelectionChanged.Invoke(Array.Empty<FileEntry>());
IsLoading = false;
await InvokeAsync(StateHasChanged);
}
private async Task HandleEntryClick(FileEntry entry)
{
if (OnEntryClicked == null)
return;
await OnEntryClicked.Invoke(entry);
}
private async Task NavigateUp()
{
if (OnNavigateUpClicked == null)
return;
await OnNavigateUpClicked.Invoke();
}
#region Selection
private async Task ChangeSelection(FileEntry entry, bool selectionState)
{
SelectionCache[entry] = selectionState;
await InvokeAsync(StateHasChanged);
if (OnSelectionChanged != null)
{
await OnSelectionChanged.Invoke(SelectionCache
.Where(x => x.Value)
.Select(x => x.Key)
.ToArray()
);
}
}
private async Task ChangeAllSelection(bool toggle)
{
foreach (var key in SelectionCache.Keys)
SelectionCache[key] = toggle;
await InvokeAsync(StateHasChanged);
if (OnSelectionChanged != null)
{
await OnSelectionChanged.Invoke(SelectionCache
.Where(x => x.Value)
.Select(x => x.Key)
.ToArray()
);
}
}
#endregion
#region Context Menu
private async Task OnContextMenuAppear(MenuAppearingEventArgs data)
{
ContextMenuItem = (data.Data as FileEntry)!;
ShowContextMenu = true;
await InvokeAsync(StateHasChanged);
}
private async Task OnContextMenuHide()
{
ShowContextMenu = false;
await InvokeAsync(StateHasChanged);
}
public async Task HideContextMenu()
{
ShowContextMenu = false;
await InvokeAsync(StateHasChanged);
}
#endregion
}