Reimplementing file manager and code editor (untested)
TODO: Add ftp auth back
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
using System.ComponentModel;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Moonlight.Features.FileManager.Configuration;
|
||||
|
||||
public class FileManagerData
|
||||
{
|
||||
[JsonProperty("MaxFileOpenSize")]
|
||||
[Description(
|
||||
"This specifies the maximum file size a user will be able to open in the file editor in kilobytes")]
|
||||
public int MaxFileOpenSize { get; set; } = 1024 * 5; // 5 MB
|
||||
|
||||
[JsonProperty("OperationTimeout")]
|
||||
[Description("This specifies the general timeout for file manager operations. This can but has not to be used by file accesses")]
|
||||
public int OperationTimeout { get; set; } = 5;
|
||||
}
|
||||
210
Moonlight/Features/FileManager/Helpers/EditorModeDetector.cs
Normal file
210
Moonlight/Features/FileManager/Helpers/EditorModeDetector.cs
Normal file
@@ -0,0 +1,210 @@
|
||||
namespace Moonlight.Features.FileManager.Helpers;
|
||||
|
||||
public static class EditorModeDetector
|
||||
{
|
||||
// We probably will never need every of this modes ;)
|
||||
private static readonly Dictionary<string, string[]> ExtensionIndex = new()
|
||||
{
|
||||
{ "abap", new[] { "abap" } },
|
||||
{ "abc", new[] { "abc" } },
|
||||
{ "actionscript", new[] { "as" } },
|
||||
{ "ada", new[] { "ada", "adb" } },
|
||||
{ "alda", new[] { "alda" } },
|
||||
{ "apache_conf", new[] { "htaccess", "htgroups", "htpasswd", "conf", "htaccess", "htgroups", "htpasswd" } },
|
||||
{ "apex", new[] { "apex", "cls", "trigger", "tgr" } },
|
||||
{ "aql", new[] { "aql" } },
|
||||
{ "asciidoc", new[] { "asciidoc", "adoc" } },
|
||||
{ "asl", new[] { "dsl", "asl", "asl.json" } },
|
||||
{ "assembly_x86", new[] { "asm", "a" } },
|
||||
{ "astro", new[] { "astro" } },
|
||||
{ "autohotkey", new[] { "ahk" } },
|
||||
{ "batchfile", new[] { "bat", "cmd" } },
|
||||
{ "bibtex", new[] { "bib" } },
|
||||
{ "c_cpp", new[] { "cpp", "c", "cc", "cxx", "h", "hh", "hpp", "ino" } },
|
||||
{ "c9search", new[] { "c9search_results" } },
|
||||
{ "cirru", new[] { "cirru", "cr" } },
|
||||
{ "clojure", new[] { "clj", "cljs" } },
|
||||
{ "cobol", new[] { "cbl", "cob" } },
|
||||
{ "coffee", new[] { "coffee", "cf", "cson", "cakefile" } },
|
||||
{ "coldfusion", new[] { "cfm", "cfc" } },
|
||||
{ "crystal", new[] { "cr" } },
|
||||
{ "csharp", new[] { "cs" } },
|
||||
{ "csound_document", new[] { "csd" } },
|
||||
{ "csound_orchestra", new[] { "orc" } },
|
||||
{ "csound_score", new[] { "sco" } },
|
||||
{ "css", new[] { "css" } },
|
||||
{ "curly", new[] { "curly" } },
|
||||
{ "cuttlefish", new[] { "conf" } },
|
||||
{ "d", new[] { "d", "di" } },
|
||||
{ "dart", new[] { "dart" } },
|
||||
{ "diff", new[] { "diff", "patch" } },
|
||||
{ "django", new[] { "djt", "html.djt", "dj.html", "djhtml" } },
|
||||
{ "dockerfile", new[] { "dockerfile" } },
|
||||
{ "dot", new[] { "dot" } },
|
||||
{ "drools", new[] { "drl" } },
|
||||
{ "edifact", new[] { "edi" } },
|
||||
{ "eiffel", new[] { "e", "ge" } },
|
||||
{ "ejs", new[] { "ejs" } },
|
||||
{ "elixir", new[] { "ex", "exs" } },
|
||||
{ "elm", new[] { "elm" } },
|
||||
{ "erlang", new[] { "erl", "hrl" } },
|
||||
{ "flix", new[] { "flix" } },
|
||||
{ "forth", new[] { "frt", "fs", "ldr", "fth", "4th" } },
|
||||
{ "fortran", new[] { "f", "f90" } },
|
||||
{ "fsharp", new[] { "fsi", "fs", "ml", "mli", "fsx", "fsscript" } },
|
||||
{ "fsl", new[] { "fsl" } },
|
||||
{ "ftl", new[] { "ftl" } },
|
||||
{ "gcode", new[] { "gcode" } },
|
||||
{ "gherkin", new[] { "feature" } },
|
||||
{ "gitignore", new[] { ".gitignore" } },
|
||||
{ "glsl", new[] { "glsl", "frag", "vert" } },
|
||||
{ "gobstones", new[] { "gbs" } },
|
||||
{ "golang", new[] { "go" } },
|
||||
{ "graphqlschema", new[] { "gql" } },
|
||||
{ "groovy", new[] { "groovy" } },
|
||||
{ "haml", new[] { "haml" } },
|
||||
{ "handlebars", new[] { "hbs", "handlebars", "tpl", "mustache" } },
|
||||
{ "haskell", new[] { "hs" } },
|
||||
{ "haskell_cabal", new[] { "cabal" } },
|
||||
{ "haxe", new[] { "hx" } },
|
||||
{ "hjson", new[] { "hjson" } },
|
||||
{ "html", new[] { "html", "htm", "xhtml", "vue", "we", "wpy" } },
|
||||
{ "html_elixir", new[] { "eex", "html.eex" } },
|
||||
{ "html_ruby", new[] { "erb", "rhtml", "html.erb" } },
|
||||
{ "ini", new[] { "ini", "conf", "cfg", "prefs" } },
|
||||
{ "io", new[] { "io" } },
|
||||
{ "ion", new[] { "ion" } },
|
||||
{ "jack", new[] { "jack" } },
|
||||
{ "jade", new[] { "jade", "pug" } },
|
||||
{ "java", new[] { "java" } },
|
||||
{ "javascript", new[] { "js", "jsm", "jsx", "cjs", "mjs" } },
|
||||
{ "jexl", new[] { "jexl" } },
|
||||
{ "json", new[] { "json" } },
|
||||
{ "json5", new[] { "json5" } },
|
||||
{ "jsoniq", new[] { "jq" } },
|
||||
{ "jsp", new[] { "jsp" } },
|
||||
{ "jssm", new[] { "jssm", "jssm_state" } },
|
||||
{ "jsx", new[] { "jsx" } },
|
||||
{ "julia", new[] { "jl" } },
|
||||
{ "kotlin", new[] { "kt", "kts" } },
|
||||
{ "latex", new[] { "tex", "latex", "ltx", "bib" } },
|
||||
{ "latte", new[] { "latte" } },
|
||||
{ "less", new[] { "less" } },
|
||||
{ "liquid", new[] { "liquid" } },
|
||||
{ "lisp", new[] { "lisp" } },
|
||||
{ "livescript", new[] { "ls" } },
|
||||
{ "log", new[] { "log" } },
|
||||
{ "logiql", new[] { "logic", "lql" } },
|
||||
{ "logtalk", new[] { "lgt" } },
|
||||
{ "lsl", new[] { "lsl" } },
|
||||
{ "lua", new[] { "lua" } },
|
||||
{ "luapage", new[] { "lp" } },
|
||||
{ "lucene", new[] { "lucene" } },
|
||||
{ "makefile", new[] { "makefile", "gnumakefile", "makefile", "ocamlmakefile", "make" } },
|
||||
{ "markdown", new[] { "md", "markdown" } },
|
||||
{ "mask", new[] { "mask" } },
|
||||
{ "matlab", new[] { "matlab" } },
|
||||
{ "maze", new[] { "mz" } },
|
||||
{ "mediawiki", new[] { "wiki", "mediawiki" } },
|
||||
{ "mel", new[] { "mel" } },
|
||||
{ "mips", new[] { "s", "asm" } },
|
||||
{ "mixal", new[] { "mixal" } },
|
||||
{ "mushcode", new[] { "mc", "mush" } },
|
||||
{ "mysql", new[] { "mysql" } },
|
||||
{ "nasal", new[] { "nas" } },
|
||||
{ "nginx", new[] { "nginx", "conf" } },
|
||||
{ "nim", new[] { "nim" } },
|
||||
{ "nix", new[] { "nix" } },
|
||||
{ "nsis", new[] { "nsi", "nsh" } },
|
||||
{ "nunjucks", new[] { "nunjucks", "nunjs", "nj", "njk" } },
|
||||
{ "objectivec", new[] { "m", "mm" } },
|
||||
{ "ocaml", new[] { "ml", "mli" } },
|
||||
{ "odin", new[] { "odin" } },
|
||||
{ "partiql", new[] { "partiql", "pql" } },
|
||||
{ "pascal", new[] { "pas", "p" } },
|
||||
{ "perl", new[] { "pl", "pm" } },
|
||||
{ "pgsql", new[] { "pgsql" } },
|
||||
{ "php", new[] { "php", "inc", "phtml", "shtml", "php3", "php4", "php5", "phps", "phpt", "aw", "ctp", "module" } },
|
||||
{ "php_laravel_blade", new[] { "blade.php" } },
|
||||
{ "pig", new[] { "pig" } },
|
||||
{ "plsql", new[] { "plsql" } },
|
||||
{ "powershell", new[] { "ps1" } },
|
||||
{ "praat", new[] { "praat", "praatscript", "psc", "proc" } },
|
||||
{ "prisma", new[] { "prisma" } },
|
||||
{ "prolog", new[] { "plg", "prolog" } },
|
||||
{ "properties", new[] { "properties" } },
|
||||
{ "protobuf", new[] { "proto" } },
|
||||
{ "prql", new[] { "prql" } },
|
||||
{ "puppet", new[] { "epp", "pp" } },
|
||||
{ "python", new[] { "py" } },
|
||||
{ "qml", new[] { "qml" } },
|
||||
{ "r", new[] { "r" } },
|
||||
{ "raku", new[] { "raku", "rakumod", "rakutest", "p6", "pl6", "pm6" } },
|
||||
{ "razor", new[] { "cshtml", "asp" } },
|
||||
{ "rdoc", new[] { "rd" } },
|
||||
{ "red", new[] { "red", "reds" } },
|
||||
{ "rhtml", new[] { "rhtml" } },
|
||||
{ "robot", new[] { "robot", "resource" } },
|
||||
{ "rst", new[] { "rst" } },
|
||||
{ "ruby", new[] { "rb", "ru", "gemspec", "rake", "guardfile", "rakefile", "gemfile" } },
|
||||
{ "rust", new[] { "rs" } },
|
||||
{ "sac", new[] { "sac" } },
|
||||
{ "sass", new[] { "sass" } },
|
||||
{ "scad", new[] { "scad" } },
|
||||
{ "scala", new[] { "scala", "sbt" } },
|
||||
{ "scheme", new[] { "scm", "sm", "rkt", "oak", "scheme" } },
|
||||
{ "scrypt", new[] { "scrypt" } },
|
||||
{ "scss", new[] { "scss" } },
|
||||
{ "sh", new[] { "sh", "bash", ".bashrc" } },
|
||||
{ "sjs", new[] { "sjs" } },
|
||||
{ "slim", new[] { "slim", "skim" } },
|
||||
{ "smarty", new[] { "smarty", "tpl" } },
|
||||
{ "smithy", new[] { "smithy" } },
|
||||
{ "snippets", new[] { "snippets" } },
|
||||
{ "soy_template", new[] { "soy" } },
|
||||
{ "space", new[] { "space" } },
|
||||
{ "sparql", new[] { "rq" } },
|
||||
{ "sql", new[] { "sql" } },
|
||||
{ "sqlserver", new[] { "sqlserver" } },
|
||||
{ "stylus", new[] { "styl", "stylus" } },
|
||||
{ "svg", new[] { "svg" } },
|
||||
{ "swift", new[] { "swift" } },
|
||||
{ "tcl", new[] { "tcl" } },
|
||||
{ "terraform", new[] { "tf", "tfvars", "terragrunt" } },
|
||||
{ "tex", new[] { "tex" } },
|
||||
{ "text", new[] { "txt" } },
|
||||
{ "textile", new[] { "textile" } },
|
||||
{ "toml", new[] { "toml" } },
|
||||
{ "tsx", new[] { "tsx" } },
|
||||
{ "turtle", new[] { "ttl" } },
|
||||
{ "twig", new[] { "twig", "swig" } },
|
||||
{ "typescript", new[] { "ts", "mts", "cts", "typescript", "str" } },
|
||||
{ "vala", new[] { "vala" } },
|
||||
{ "vbscript", new[] { "vbs", "vb" } },
|
||||
{ "velocity", new[] { "vm" } },
|
||||
{ "verilog", new[] { "v", "vh", "sv", "svh" } },
|
||||
{ "vhdl", new[] { "vhd", "vhdl" } },
|
||||
{ "visualforce", new[] { "vfp", "component", "page" } },
|
||||
{ "wollok", new[] { "wlk", "wpgm", "wtest" } },
|
||||
{ "xml", new[] { "xml", "rdf", "rss", "wsdl", "xslt", "atom", "mathml", "mml", "xul", "xbl", "xaml" } },
|
||||
{ "xquery", new[] { "xq" } },
|
||||
{ "yaml", new[] { "yaml", "yml" } },
|
||||
{ "zeek", new[] { "zeek", "bro" } }
|
||||
};
|
||||
|
||||
public static string GetModeFromFile(string fileName)
|
||||
{
|
||||
var extension = Path.GetExtension(fileName).Replace(".", "");
|
||||
|
||||
if (string.IsNullOrEmpty(extension))
|
||||
return "text";
|
||||
|
||||
foreach (var entry in ExtensionIndex)
|
||||
{
|
||||
if (entry.Value.Any(x => string.Equals(x, extension, StringComparison.InvariantCultureIgnoreCase)))
|
||||
return entry.Key;
|
||||
}
|
||||
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moonlight.Core.Services.Utils;
|
||||
using Moonlight.Features.FileManager.Services;
|
||||
|
||||
namespace Moonlight.Features.FileManager.Http.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/upload")]
|
||||
public class UploadController : Controller
|
||||
{
|
||||
private readonly JwtService JwtService;
|
||||
private readonly SharedFileAccessService SharedFileAccessService;
|
||||
|
||||
public UploadController(
|
||||
JwtService jwtService,
|
||||
SharedFileAccessService sharedFileAccessService)
|
||||
{
|
||||
JwtService = jwtService;
|
||||
SharedFileAccessService = sharedFileAccessService;
|
||||
}
|
||||
|
||||
// The following method/api endpoint needs some explanation:
|
||||
// Because of blazor and dropzone.js, we need an api endpoint
|
||||
// to upload files via the built in file manager.
|
||||
// As we learned from user experiences in v1b
|
||||
// a large data transfer via the signal r connection might lead to
|
||||
// failing uploads for some users with a unstable connection. That's
|
||||
// why we implement this api endpoint. It can potentially prevent
|
||||
// upload from malicious scripts as well. To verify the user is
|
||||
// authenticated we use a jwt.
|
||||
// The jwt specifies what
|
||||
// connection we want to upload the file. This jwt
|
||||
// will be generated every 5 minutes in the file upload service
|
||||
// and only last 6 minutes.
|
||||
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult> Upload([FromQuery(Name = "token")] string uploadToken)
|
||||
{
|
||||
// Check if a file exist and if it is not too big
|
||||
if (!Request.Form.Files.Any())
|
||||
return BadRequest("File is missing in request");
|
||||
|
||||
if (Request.Form.Files.Count > 1)
|
||||
return BadRequest("Too many files sent");
|
||||
|
||||
// Validate request
|
||||
if (!await JwtService.Validate(uploadToken, "FileUpload"))
|
||||
return StatusCode(403);
|
||||
|
||||
var uploadContext = await JwtService.Decode(uploadToken);
|
||||
|
||||
if (!uploadContext.ContainsKey("FileAccessId"))
|
||||
return BadRequest();
|
||||
|
||||
if (!int.TryParse(uploadContext["FileAccessId"], out int fileAccessId))
|
||||
return BadRequest();
|
||||
|
||||
// Load file access for this file
|
||||
var fileAccess = await SharedFileAccessService.Get(fileAccessId);
|
||||
|
||||
if (fileAccess == null)
|
||||
return BadRequest("Invalid file access id");
|
||||
|
||||
// Actually upload the file
|
||||
var file = Request.Form.Files.First();
|
||||
await fileAccess.WriteFileStream(file.FileName, file.OpenReadStream());
|
||||
|
||||
// Cleanup
|
||||
fileAccess.Dispose();
|
||||
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
|
||||
|
||||
public class FileEntry
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public long Size { get; set; }
|
||||
public bool IsFile { get; set; }
|
||||
public bool IsDirectory { get; set; }
|
||||
public DateTime LastModifiedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
|
||||
|
||||
public interface IFileAccess : IDisposable
|
||||
{
|
||||
public Task<FileEntry[]> List();
|
||||
public Task ChangeDirectory(string relativePath);
|
||||
public Task SetDirectory(string path);
|
||||
public Task<string> GetCurrentDirectory();
|
||||
public Task Delete(string path);
|
||||
public Task Move(string from, string to);
|
||||
public Task CreateDirectory(string name);
|
||||
public Task CreateFile(string name);
|
||||
public Task<string> ReadFile(string name);
|
||||
public Task WriteFile(string name, string content);
|
||||
public Task<Stream> ReadFileStream(string name);
|
||||
public Task WriteFileStream(string name, Stream dataStream);
|
||||
public IFileAccess Clone();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
|
||||
|
||||
public interface IFileCompressAccess
|
||||
{
|
||||
public Task Compress(string[] names);
|
||||
public Task Decompress(string name);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
|
||||
|
||||
public interface IFileLaunchAccess
|
||||
{
|
||||
public Task<string> GetLaunchUrl();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace Moonlight.Features.FileManager.Models.Abstractions;
|
||||
|
||||
public class FileUpload
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public Stream Stream { get; set; }
|
||||
public long Size { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Moonlight.Features.FileManager.Models.Abstractions;
|
||||
|
||||
public class FileUploadConnection
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Url { get; set; }
|
||||
public Func<FileUpload, Task>? OnFileReceived { get; set; }
|
||||
public Func<string, Task>? OnUrlChanged { get; set; }
|
||||
public DateTime LastSeenAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
25
Moonlight/Features/FileManager/Services/DropzoneService.cs
Normal file
25
Moonlight/Features/FileManager/Services/DropzoneService.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using Microsoft.JSInterop;
|
||||
using MoonCore.Attributes;
|
||||
|
||||
namespace Moonlight.Features.FileManager.Services;
|
||||
|
||||
[Scoped]
|
||||
public class DropzoneService
|
||||
{
|
||||
private readonly IJSRuntime JsRuntime;
|
||||
|
||||
public DropzoneService(IJSRuntime jsRuntime)
|
||||
{
|
||||
JsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public async Task Create(string id, string initialUrl)
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("moonlight.dropzone.create", id, initialUrl);
|
||||
}
|
||||
|
||||
public async Task UpdateUrl(string id, string url)
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("moonlight.dropzone.updateUrl", id, url);
|
||||
}
|
||||
}
|
||||
35
Moonlight/Features/FileManager/Services/EditorService.cs
Normal file
35
Moonlight/Features/FileManager/Services/EditorService.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using Microsoft.JSInterop;
|
||||
using MoonCore.Attributes;
|
||||
|
||||
namespace Moonlight.Features.FileManager.Services;
|
||||
|
||||
[Scoped]
|
||||
public class EditorService
|
||||
{
|
||||
private readonly IJSRuntime JsRuntime;
|
||||
|
||||
public EditorService(IJSRuntime jsRuntime)
|
||||
{
|
||||
JsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public async Task Create(string mount, string theme = "one_dark", string mode = "text", string initialContent = "",
|
||||
int lines = 30, int fontSize = 15)
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync(
|
||||
"moonlight.editor.create",
|
||||
mount,
|
||||
theme,
|
||||
mode,
|
||||
initialContent,
|
||||
lines,
|
||||
fontSize
|
||||
);
|
||||
}
|
||||
|
||||
public async Task SetValue(string content) => await JsRuntime.InvokeVoidAsync("moonlight.editor.setValue", content);
|
||||
|
||||
public async Task<string> GetValue() => await JsRuntime.InvokeAsync<string>("moonlight.editor.getValue");
|
||||
|
||||
public async Task SetMode(string mode) => await JsRuntime.InvokeVoidAsync("moonlight.editor.setMode", mode);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using MoonCore.Attributes;
|
||||
using Moonlight.Core.Services.Utils;
|
||||
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
|
||||
|
||||
namespace Moonlight.Features.FileManager.Services;
|
||||
|
||||
[Singleton]
|
||||
public class SharedFileAccessService
|
||||
{
|
||||
private readonly JwtService JwtService;
|
||||
private readonly List<IFileAccess> FileAccesses = new();
|
||||
|
||||
public SharedFileAccessService(JwtService jwtService)
|
||||
{
|
||||
JwtService = jwtService;
|
||||
}
|
||||
|
||||
public Task<int> Register(IFileAccess fileAccess)
|
||||
{
|
||||
lock (FileAccesses)
|
||||
FileAccesses.Add(fileAccess);
|
||||
|
||||
return Task.FromResult(fileAccess.GetHashCode());
|
||||
}
|
||||
|
||||
public Task Unregister(IFileAccess fileAccess)
|
||||
{
|
||||
lock (FileAccesses)
|
||||
{
|
||||
if (FileAccesses.Contains(fileAccess))
|
||||
FileAccesses.Remove(fileAccess);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IFileAccess?> Get(int id)
|
||||
{
|
||||
lock (FileAccesses)
|
||||
{
|
||||
var fileAccess = FileAccesses.FirstOrDefault(x => x.GetHashCode() == id);
|
||||
|
||||
if (fileAccess == null)
|
||||
return Task.FromResult<IFileAccess?>(null);
|
||||
|
||||
return Task.FromResult<IFileAccess?>(fileAccess.Clone());
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GenerateUrl(IFileAccess fileAccess)
|
||||
{
|
||||
var token = await JwtService.Create(data =>
|
||||
{
|
||||
data.Add("FileAccessId", fileAccess.GetHashCode().ToString());
|
||||
}, "FileUpload", TimeSpan.FromMinutes(6));
|
||||
|
||||
return $"/api/upload?token={token}";
|
||||
}
|
||||
}
|
||||
48
Moonlight/Features/FileManager/UI/Components/Editor.razor
Normal file
48
Moonlight/Features/FileManager/UI/Components/Editor.razor
Normal file
@@ -0,0 +1,48 @@
|
||||
@using Moonlight.Features.FileManager.Services
|
||||
@inject EditorService EditorService
|
||||
|
||||
<div id="@Identifier"></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;
|
||||
|
||||
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);
|
||||
}
|
||||
115
Moonlight/Features/FileManager/UI/Components/FileEditor.razor
Normal file
115
Moonlight/Features/FileManager/UI/Components/FileEditor.razor
Normal file
@@ -0,0 +1,115 @@
|
||||
@inject ToastService ToastService
|
||||
@inject HotKeyService HotKeyService
|
||||
|
||||
@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess
|
||||
@using MoonCoreUI.Services
|
||||
@using Moonlight.Core.Services
|
||||
@using MoonCore.Helpers
|
||||
@using Moonlight.Features.FileManager.Helpers
|
||||
|
||||
@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 IFileAccess 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)
|
||||
{
|
||||
if (hotKey == "save")
|
||||
{
|
||||
await OnSave();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hotKey == "close")
|
||||
{
|
||||
await OnClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Define more hotkeys here
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
HotKeyService.HotKeyPressed -= OnHotKeyPressed;
|
||||
}
|
||||
}
|
||||
268
Moonlight/Features/FileManager/UI/Components/FileManager.razor
Normal file
268
Moonlight/Features/FileManager/UI/Components/FileManager.razor
Normal file
@@ -0,0 +1,268 @@
|
||||
@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess
|
||||
@using Moonlight.Core.Configuration
|
||||
@using MoonCore.Helpers
|
||||
@using MoonCore.Services
|
||||
@using MoonCoreUI.Services
|
||||
|
||||
@inject AlertService AlertService
|
||||
@inject ConfigService<ConfigV1> ConfigService
|
||||
@inject ToastService ToastService
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
<div class="badge badge-primary badge-lg fs-5 py-2">
|
||||
@{
|
||||
var elements = Path
|
||||
.Split("/")
|
||||
.Where(x => !string.IsNullOrEmpty(x))
|
||||
.ToList();
|
||||
|
||||
int i = 1;
|
||||
var root = "/";
|
||||
}
|
||||
|
||||
<a href="#" @onclick:preventDefault @onclick="() => NavigateToPath(root)" class="invisible-a mx-2">/</a>
|
||||
@foreach (var element in elements)
|
||||
{
|
||||
var pathToCd = "/" + string.Join('/', elements.Take(i));
|
||||
|
||||
<a href="#" @onclick:preventDefault @onclick="() => NavigateToPath(pathToCd)" class="invisible-a">@(element)</a>
|
||||
<div class="mx-2">/</div>
|
||||
|
||||
i++;
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-toolbar">
|
||||
@if (ShowFileUploader)
|
||||
{
|
||||
<button type="button" @onclick="ToggleFileUploader" class="btn btn-light-primary me-3">
|
||||
Back
|
||||
</button>
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
</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>
|
||||
</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"/>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</SmartModal>
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public IFileAccess FileAccess { get; set; }
|
||||
|
||||
// Navigation
|
||||
private string Path = "/";
|
||||
private FileView? FileView;
|
||||
|
||||
// Uploading
|
||||
private bool ShowFileUploader = false;
|
||||
private FileUploader? FileUploader;
|
||||
|
||||
// Editing
|
||||
private bool ShowFileEditor = false;
|
||||
private FileEntry EditorOpenFile;
|
||||
|
||||
// Move
|
||||
private FileEntry MoveEntry;
|
||||
private SmartModal MoveModal;
|
||||
private IFileAccess MoveAccess;
|
||||
|
||||
private async Task OnPathChanged(string path)
|
||||
{
|
||||
Path = path;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task NavigateToPath(string path)
|
||||
{
|
||||
if (ShowFileUploader)
|
||||
await ToggleFileUploader(false);
|
||||
|
||||
if (FileView == null)
|
||||
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", "");
|
||||
|
||||
await FileAccess.CreateFile(name);
|
||||
|
||||
await FileView.Refresh();
|
||||
|
||||
// Open editor to start editing
|
||||
await OpenEditor(new FileEntry()
|
||||
{
|
||||
Size = 0,
|
||||
Name = name,
|
||||
IsFile = true,
|
||||
IsDirectory = false,
|
||||
LastModifiedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private async Task CreateDirectory()
|
||||
{
|
||||
if (FileView == null)
|
||||
return;
|
||||
|
||||
var name = await AlertService.Text("Enter the foldername", "");
|
||||
|
||||
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)
|
||||
{
|
||||
var fileSizeInKilobytes = ByteSizeValue.FromBytes(fileEntry.Size).KiloBytes;
|
||||
|
||||
if (fileSizeInKilobytes > ConfigService.Get().FileManager.MaxFileOpenSize)
|
||||
{
|
||||
await ToastService.Danger("Unable to open file as it exceeds the max file size limit");
|
||||
return;
|
||||
}
|
||||
|
||||
EditorOpenFile = fileEntry;
|
||||
|
||||
// Prepare editor
|
||||
ShowFileEditor = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task OnEditorClosed()
|
||||
{
|
||||
ShowFileEditor = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Move
|
||||
|
||||
private async Task StartMove(FileEntry fileEntry)
|
||||
{
|
||||
MoveEntry = fileEntry;
|
||||
MoveAccess = FileAccess.Clone();
|
||||
|
||||
await MoveAccess.SetDirectory("/");
|
||||
await MoveModal.Show();
|
||||
}
|
||||
|
||||
private async Task FinishMove()
|
||||
{
|
||||
var pathToMove = await MoveAccess.GetCurrentDirectory();
|
||||
MoveAccess.Dispose();
|
||||
|
||||
// Ensure path ends with a /
|
||||
if (!pathToMove.EndsWith("/"))
|
||||
pathToMove += "/";
|
||||
|
||||
// Perform move and process ui updates
|
||||
await FileAccess.Move(MoveEntry.Name, pathToMove);
|
||||
|
||||
await MoveModal.Hide();
|
||||
|
||||
if (FileView == null)
|
||||
return;
|
||||
|
||||
await FileView.Refresh();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
@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">
|
||||
<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>
|
||||
</div>
|
||||
<img src="/svg/upload.svg" class="card-img-bottom" alt="Upload icon" style="max-height: 15vw">
|
||||
</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 IFileAccess 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 url = await SharedFileAccessService.GenerateUrl(FileAccess);
|
||||
|
||||
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 newUrl = await SharedFileAccessService.GenerateUrl(FileAccess);
|
||||
await DropzoneService.UpdateUrl(DropzoneId, newUrl);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async void Dispose()
|
||||
{
|
||||
Cancellation.Cancel();
|
||||
await SharedFileAccessService.Unregister(FileAccess);
|
||||
}
|
||||
}
|
||||
390
Moonlight/Features/FileManager/UI/Components/FileView.razor
Normal file
390
Moonlight/Features/FileManager/UI/Components/FileView.razor
Normal file
@@ -0,0 +1,390 @@
|
||||
@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess
|
||||
@using MoonCoreUI.Services
|
||||
@using MoonCore.Helpers
|
||||
|
||||
@inject ToastService ToastService
|
||||
@inject AlertService AlertService
|
||||
|
||||
<LazyLoader @ref="LazyLoader" Load="Load">
|
||||
<table class="w-100 table table-responsive table-row-bordered">
|
||||
<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
|
||||
</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>
|
||||
}
|
||||
|
||||
@if (ShowGoUp && Path != "/" && !DisableNavigation)
|
||||
{
|
||||
<tr>
|
||||
@if (ShowSelect)
|
||||
{
|
||||
<td class="w-10px align-middle">
|
||||
</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
|
||||
</a>
|
||||
</td>
|
||||
@if (ShowSize)
|
||||
{
|
||||
<td class="align-middle fs-6 d-none d-sm-table-cell text-end">
|
||||
<span>-</span>
|
||||
</td>
|
||||
}
|
||||
@if (ShowLastModified)
|
||||
{
|
||||
<td class="align-middle fs-6 d-none d-sm-table-cell text-end">
|
||||
-
|
||||
</td>
|
||||
}
|
||||
@if (ShowActions)
|
||||
{
|
||||
<td class="w-50 text-end">
|
||||
</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 (entry.IsFile)
|
||||
{
|
||||
<i class="bx bx-md bx-file"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bx bx-md bx-folder"></i>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td class="align-middle fs-6">
|
||||
@if (DisableNavigation)
|
||||
{
|
||||
<span>@(entry.Name)</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<a href="#"
|
||||
@onclick:preventDefault
|
||||
@onclick="() => HandleClick(entry)">
|
||||
@(entry.Name)
|
||||
</a>
|
||||
}
|
||||
</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>
|
||||
@if (OnMoveRequested != null)
|
||||
{
|
||||
<li>
|
||||
<a href="#" @onclick:preventDefault @onclick="() => RequestMove(entry)" class="dropdown-item">Move</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</LazyLoader>
|
||||
|
||||
|
||||
@code
|
||||
{
|
||||
[Parameter] public IFileAccess FileAccess { 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 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; }
|
||||
|
||||
public readonly List<FileEntry> SelectedEntries = new();
|
||||
|
||||
private LazyLoader LazyLoader;
|
||||
private FileEntry[] Entries;
|
||||
private string Path = "/";
|
||||
|
||||
private async Task Load(LazyLoader lazyLoader)
|
||||
{
|
||||
await lazyLoader.SetText("Loading files and folders");
|
||||
|
||||
// Load all entries
|
||||
Entries = await FileAccess.List();
|
||||
|
||||
await lazyLoader.SetText("Sorting files and folders");
|
||||
|
||||
// Perform sorting and filtering
|
||||
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();
|
||||
|
||||
SelectedEntries.Clear();
|
||||
|
||||
Path = await FileAccess.GetCurrentDirectory();
|
||||
|
||||
if (OnPathChanged != null)
|
||||
await OnPathChanged.Invoke(Path);
|
||||
}
|
||||
|
||||
private async Task HandleClick(FileEntry fileEntry)
|
||||
{
|
||||
if (fileEntry.IsDirectory && !DisableNavigation)
|
||||
{
|
||||
await Navigate(fileEntry.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (OnFileClicked != null)
|
||||
await OnFileClicked.Invoke(fileEntry);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Delete(params FileEntry[] entries)
|
||||
{
|
||||
if (entries.Length == 0)
|
||||
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.Name);
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
await ToastService.RemoveProgress(toastId);
|
||||
await ToastService.Success($"Successfully deleted {i} item(s)");
|
||||
|
||||
await LazyLoader.Reload();
|
||||
}
|
||||
|
||||
private async Task Rename(FileEntry fileEntry)
|
||||
{
|
||||
var name = await AlertService.Text($"Rename '{fileEntry.Name}'", "", fileEntry.Name);
|
||||
|
||||
if(string.IsNullOrEmpty(name))
|
||||
return;
|
||||
|
||||
await FileAccess.Move(fileEntry.Name, name);
|
||||
|
||||
await LazyLoader.Reload();
|
||||
}
|
||||
|
||||
private async Task RequestMove(FileEntry fileEntry)
|
||||
{
|
||||
if(OnMoveRequested == null)
|
||||
return;
|
||||
|
||||
await OnMoveRequested.Invoke(fileEntry);
|
||||
}
|
||||
|
||||
#region Selection
|
||||
|
||||
private async Task HandleSelected(FileEntry fileEntry, ChangeEventArgs args)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
if (OnSelectionChanged != null)
|
||||
await OnSelectionChanged.Invoke();
|
||||
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task ToggleAll(ChangeEventArgs args)
|
||||
{
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user