Reimplemented the file manager with a cleaner ui, a base path protection from the core and modular and expandable
This commit is contained in:
parent
44b2d07fdb
commit
49077e7023
28 changed files with 1986 additions and 41 deletions
|
@ -15,3 +15,15 @@ tr:hover .table-row-hover-content {
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
scrollbar-width: none; /* Firefox */
|
scrollbar-width: none; /* Firefox */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.blur-unless-hover {
|
||||||
|
filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.blur-unless-hover:hover {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blur {
|
||||||
|
filter: blur(5px);
|
||||||
|
}
|
198
Moonlight/Assets/FileManager/css/blazorContextMenu.css
Normal file
198
Moonlight/Assets/FileManager/css/blazorContextMenu.css
Normal file
|
@ -0,0 +1,198 @@
|
||||||
|
.blazor-context-menu--default {
|
||||||
|
position: fixed;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
padding: 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.blazor-context-menu__list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 5px;
|
||||||
|
margin: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-context-menu__seperator {
|
||||||
|
min-width: 120px;
|
||||||
|
color: #333;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-context-menu__seperator__hr {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5em;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
border-style: inset;
|
||||||
|
border-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-context-menu__item--default {
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 6px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-context-menu__item--default:hover {
|
||||||
|
background-color: rgba(0,0,0,.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.blazor-context-menu__item--with-submenu:after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: -8px;
|
||||||
|
top: 50%;
|
||||||
|
-webkit-transform: translateY(-50%);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-left-color: #615c5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-context-menu__item--default-disabled {
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 6px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-context-menu--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*============== ANIMATIONS ==============*/
|
||||||
|
/*-------------- FadeIn ------------------*/
|
||||||
|
.blazor-context-menu__animations--fadeIn {
|
||||||
|
animation-name: fadeIn;
|
||||||
|
animation-direction: reverse;
|
||||||
|
animation-duration: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-context-menu__animations--fadeIn-shown {
|
||||||
|
animation-name: fadeIn;
|
||||||
|
animation-duration: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*-------------- Grow ------------------*/
|
||||||
|
|
||||||
|
.blazor-context-menu__animations--grow {
|
||||||
|
animation-name: grow;
|
||||||
|
animation-direction: reverse;
|
||||||
|
animation-duration: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-context-menu__animations--grow-shown {
|
||||||
|
animation-name: grow;
|
||||||
|
animation-duration: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes grow {
|
||||||
|
0% {
|
||||||
|
transform: scale(0);
|
||||||
|
transform-origin: top left;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
transform-origin: top left;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-------------- Slide ------------------*/
|
||||||
|
|
||||||
|
.blazor-context-menu.blazor-context-menu__animations--slide {
|
||||||
|
animation-name: slide;
|
||||||
|
animation-direction: reverse;
|
||||||
|
animation-duration: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-context-menu.blazor-context-menu__animations--slide-shown {
|
||||||
|
animation-name: slide;
|
||||||
|
animation-duration: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-5px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translateX(0px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-context-submenu.blazor-context-menu__animations--slide {
|
||||||
|
animation-name: slide-submenu;
|
||||||
|
animation-direction: reverse;
|
||||||
|
animation-duration: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-context-submenu.blazor-context-menu__animations--slide-shown {
|
||||||
|
animation-name: slide-submenu;
|
||||||
|
animation-duration: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-submenu {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-25px);
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(0px);
|
||||||
|
z-index: unset;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*-------------- Zoom ------------------*/
|
||||||
|
|
||||||
|
.blazor-context-menu__animations--zoom {
|
||||||
|
animation-name: zoom;
|
||||||
|
animation-direction: reverse;
|
||||||
|
animation-duration: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blazor-context-menu__animations--zoom-shown {
|
||||||
|
animation-name: zoom;
|
||||||
|
animation-duration: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes zoom {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
313
Moonlight/Assets/FileManager/js/blazorContextMenu.js
Normal file
313
Moonlight/Assets/FileManager/js/blazorContextMenu.js
Normal file
|
@ -0,0 +1,313 @@
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var blazorContextMenu = function (blazorContextMenu) {
|
||||||
|
|
||||||
|
var closest = null;
|
||||||
|
if (window.Element && !Element.prototype.closest) {
|
||||||
|
closest = function (el, s) {
|
||||||
|
var matches = (el.document || el.ownerDocument).querySelectorAll(s), i;
|
||||||
|
do {
|
||||||
|
i = matches.length;
|
||||||
|
while (--i >= 0 && matches.item(i) !== el) { };
|
||||||
|
} while ((i < 0) && (el = el.parentElement));
|
||||||
|
return el;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
closest = function (el, s) {
|
||||||
|
return el.closest(s);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var openMenus = [];
|
||||||
|
|
||||||
|
//Helper functions
|
||||||
|
//========================================
|
||||||
|
function guid() {
|
||||||
|
function s4() {
|
||||||
|
return Math.floor((1 + Math.random()) * 0x10000)
|
||||||
|
.toString(16)
|
||||||
|
.substring(1);
|
||||||
|
}
|
||||||
|
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
|
||||||
|
}
|
||||||
|
|
||||||
|
function findFirstChildByClass(element, className) {
|
||||||
|
var foundElement = null;
|
||||||
|
function recurse(element, className, found) {
|
||||||
|
for (var i = 0; i < element.children.length && !found; i++) {
|
||||||
|
var el = element.children[i];
|
||||||
|
if (el.classList.contains(className)) {
|
||||||
|
found = true;
|
||||||
|
foundElement = element.children[i];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (found)
|
||||||
|
break;
|
||||||
|
recurse(element.children[i], className, found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recurse(element, className, false);
|
||||||
|
return foundElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAllChildsByClass(element, className) {
|
||||||
|
var foundElements = new Array();
|
||||||
|
function recurse(element, className) {
|
||||||
|
for (var i = 0; i < element.children.length; i++) {
|
||||||
|
var el = element.children[i];
|
||||||
|
if (el.classList.contains(className)) {
|
||||||
|
foundElements.push(element.children[i]);
|
||||||
|
}
|
||||||
|
recurse(element.children[i], className);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recurse(element, className);
|
||||||
|
return foundElements;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItemFromArray(array, item) {
|
||||||
|
for (var i = 0; i < array.length; i++) {
|
||||||
|
if (array[i] === item) {
|
||||||
|
array.splice(i, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sleepUntil = function (f, timeoutMs) {
|
||||||
|
return new Promise(function (resolve, reject){
|
||||||
|
var timeWas = new Date();
|
||||||
|
var wait = setInterval(function () {
|
||||||
|
if (f()) {
|
||||||
|
clearInterval(wait);
|
||||||
|
resolve();
|
||||||
|
} else if (new Date() - timeWas > timeoutMs) {
|
||||||
|
clearInterval(wait);
|
||||||
|
reject();
|
||||||
|
}
|
||||||
|
}, 20);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//===========================================
|
||||||
|
|
||||||
|
var menuHandlerReference = null;
|
||||||
|
//var openingMenu = false;
|
||||||
|
|
||||||
|
blazorContextMenu.SetMenuHandlerReference = function (dotnetRef) {
|
||||||
|
if (!menuHandlerReference) {
|
||||||
|
menuHandlerReference = dotnetRef;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var addToOpenMenus = function (menu, menuId, target) {
|
||||||
|
var instanceId = guid();
|
||||||
|
openMenus.push({
|
||||||
|
id: menuId,
|
||||||
|
target: target,
|
||||||
|
instanceId: instanceId
|
||||||
|
});
|
||||||
|
menu.dataset["instanceId"] = instanceId;
|
||||||
|
};
|
||||||
|
|
||||||
|
blazorContextMenu.ManualShow = function (menuId, x, y) {
|
||||||
|
//openingMenu = true;
|
||||||
|
var menu = document.getElementById(menuId);
|
||||||
|
if (!menu) throw new Error("No context menu with id '" + menuId + "' was found");
|
||||||
|
addToOpenMenus(menu, menuId, null);
|
||||||
|
showMenuCommon(menu, menuId, x, y, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
blazorContextMenu.OnContextMenu = function (e, menuId, stopPropagation) {
|
||||||
|
//openingMenu = true;
|
||||||
|
var menu = document.getElementById(menuId);
|
||||||
|
if (!menu) throw new Error("No context menu with id '" + menuId + "' was found");
|
||||||
|
addToOpenMenus(menu, menuId, e.target);
|
||||||
|
var triggerDotnetRef = JSON.parse(e.currentTarget.dataset["dotnetref"]);
|
||||||
|
showMenuCommon(menu, menuId, e.x, e.y, e.target, triggerDotnetRef);
|
||||||
|
e.preventDefault();
|
||||||
|
if (stopPropagation) {
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
var showMenuCommon = function (menu, menuId, x, y, target, triggerDotnetRef) {
|
||||||
|
return blazorContextMenu.Show(menuId, x, y, target, triggerDotnetRef).then(function () {
|
||||||
|
return sleepUntil(function () { return menu.clientWidth > 0 }, 1000); //Wait until the menu has spawned so clientWidth and offsetLeft report correctly
|
||||||
|
}).then(function () {
|
||||||
|
//check for overflow
|
||||||
|
var leftOverflownPixels = menu.offsetLeft + menu.clientWidth - window.innerWidth;
|
||||||
|
if (leftOverflownPixels > 0) {
|
||||||
|
menu.style.left = (menu.offsetLeft - menu.clientWidth) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
var topOverflownPixels = menu.offsetTop + menu.clientHeight - window.innerHeight;
|
||||||
|
if (topOverflownPixels > 0) {
|
||||||
|
menu.style.top = (menu.offsetTop - menu.clientHeight) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
//openingMenu = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
blazorContextMenu.Init = function () {
|
||||||
|
document.addEventListener("mouseup", function (e) {
|
||||||
|
handleAutoHideEvent(e, "mouseup");
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", function (e) {
|
||||||
|
handleAutoHideEvent(e, "mousedown");
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleAutoHideEvent(e, autoHideEvent) {
|
||||||
|
if (openMenus.length > 0) {
|
||||||
|
for (var i = 0; i < openMenus.length; i++) {
|
||||||
|
var currentMenu = openMenus[i];
|
||||||
|
var menuElement = document.getElementById(currentMenu.id);
|
||||||
|
if (menuElement && menuElement.dataset["autohide"] == "true" && menuElement.dataset["autohideevent"] == autoHideEvent) {
|
||||||
|
var clickedInsideMenu = menuElement.contains(e.target);
|
||||||
|
if (!clickedInsideMenu) {
|
||||||
|
blazorContextMenu.Hide(currentMenu.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
if (openMenus.length > 0) {
|
||||||
|
for (var i = 0; i < openMenus.length; i++) {
|
||||||
|
var currentMenu = openMenus[i];
|
||||||
|
var menuElement = document.getElementById(currentMenu.id);
|
||||||
|
if (menuElement && menuElement.dataset["autohide"] == "true") {
|
||||||
|
blazorContextMenu.Hide(currentMenu.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
blazorContextMenu.Show = function (menuId, x, y, target, triggerDotnetRef) {
|
||||||
|
var targetId = null;
|
||||||
|
if (target) {
|
||||||
|
if (!target.id) {
|
||||||
|
//add an id to the target dynamically so that it can be referenced later
|
||||||
|
//TODO: Rewrite this once this Blazor limitation is lifted
|
||||||
|
target.id = guid();
|
||||||
|
}
|
||||||
|
targetId = target.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return menuHandlerReference.invokeMethodAsync('ShowMenu', menuId, x.toString(), y.toString(), targetId, triggerDotnetRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
blazorContextMenu.Hide = function (menuId) {
|
||||||
|
var menuElement = document.getElementById(menuId);
|
||||||
|
var instanceId = menuElement.dataset["instanceId"];
|
||||||
|
return menuHandlerReference.invokeMethodAsync('HideMenu', menuId).then(function (hideSuccessful) {
|
||||||
|
if (menuElement.classList.contains("blazor-context-menu") && hideSuccessful) {
|
||||||
|
//this is a root menu. Remove from openMenus list
|
||||||
|
var openMenu = openMenus.find(function (item) {
|
||||||
|
return item.instanceId == instanceId;
|
||||||
|
});
|
||||||
|
if (openMenu) {
|
||||||
|
removeItemFromArray(openMenus, openMenu);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
blazorContextMenu.IsMenuShown = function (menuId) {
|
||||||
|
var menuElement = document.getElementById(menuId);
|
||||||
|
var instanceId = menuElement.dataset["instanceId"];
|
||||||
|
var menu = openMenus.find(function (item) {
|
||||||
|
return item.instanceId == instanceId;
|
||||||
|
});
|
||||||
|
return typeof(menu) != 'undefined' && menu != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var subMenuTimeout = null;
|
||||||
|
blazorContextMenu.OnMenuItemMouseOver = function (e, xOffset, currentItemElement) {
|
||||||
|
if (closest(e.target, ".blazor-context-menu__wrapper") != closest(currentItemElement, ".blazor-context-menu__wrapper")) {
|
||||||
|
//skip child menu mouseovers
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentItemElement.getAttribute("itemEnabled") != "true") return;
|
||||||
|
|
||||||
|
var subMenu = findFirstChildByClass(currentItemElement, "blazor-context-submenu");
|
||||||
|
if (!subMenu) return; //item does not contain a submenu
|
||||||
|
|
||||||
|
subMenuTimeout = setTimeout(function () {
|
||||||
|
subMenuTimeout = null;
|
||||||
|
|
||||||
|
var currentMenu = closest(currentItemElement, ".blazor-context-menu__wrapper");
|
||||||
|
var currentMenuList = currentMenu.children[0];
|
||||||
|
var rootMenu = closest(currentItemElement, ".blazor-context-menu");
|
||||||
|
var targetRect = currentItemElement.getBoundingClientRect();
|
||||||
|
var x = targetRect.left + currentMenu.clientWidth - xOffset;
|
||||||
|
var y = targetRect.top;
|
||||||
|
var instanceId = rootMenu.dataset["instanceId"];
|
||||||
|
|
||||||
|
var openMenu = openMenus.find(function (item) {
|
||||||
|
return item.instanceId == instanceId;
|
||||||
|
});
|
||||||
|
blazorContextMenu.Show(subMenu.id, x, y, openMenu.target).then(function () {
|
||||||
|
var leftOverflownPixels = subMenu.offsetLeft + subMenu.clientWidth - window.innerWidth;
|
||||||
|
if (leftOverflownPixels > 0) {
|
||||||
|
subMenu.style.left = (subMenu.offsetLeft - subMenu.clientWidth - currentMenu.clientWidth - xOffset) + "px"
|
||||||
|
}
|
||||||
|
|
||||||
|
var topOverflownPixels = subMenu.offsetTop + subMenu.clientHeight - window.innerHeight;
|
||||||
|
if (topOverflownPixels > 0) {
|
||||||
|
subMenu.style.top = (subMenu.offsetTop - topOverflownPixels) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
var closeSubMenus = function () {
|
||||||
|
var childSubMenus = findAllChildsByClass(currentItemElement, "blazor-context-submenu");
|
||||||
|
var i = childSubMenus.length;
|
||||||
|
while (i--) {
|
||||||
|
var subMenu = childSubMenus[i];
|
||||||
|
blazorContextMenu.Hide(subMenu.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
i = currentMenuList.childNodes.length;
|
||||||
|
while (i--) {
|
||||||
|
var child = currentMenuList.children[i];
|
||||||
|
if (child == currentItemElement) continue;
|
||||||
|
child.removeEventListener("mouseover", closeSubMenus);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var i = currentMenuList.childNodes.length;
|
||||||
|
while (i--) {
|
||||||
|
var child = currentMenuList.childNodes[i];
|
||||||
|
if (child == currentItemElement) continue;
|
||||||
|
|
||||||
|
child.addEventListener("mouseover", closeSubMenus);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
blazorContextMenu.OnMenuItemMouseOut = function (e) {
|
||||||
|
if (subMenuTimeout) {
|
||||||
|
clearTimeout(subMenuTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
blazorContextMenu.RegisterTriggerReference = function (triggerElement, triggerDotNetRef) {
|
||||||
|
if (triggerElement) {
|
||||||
|
triggerElement.dataset["dotnetref"] = JSON.stringify(triggerDotNetRef.serializeAsArg());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blazorContextMenu;
|
||||||
|
}({});
|
||||||
|
|
||||||
|
blazorContextMenu.Init();
|
199
Moonlight/Assets/FileManager/js/filemanager.js
Normal file
199
Moonlight/Assets/FileManager/js/filemanager.js
Normal file
|
@ -0,0 +1,199 @@
|
||||||
|
window.filemanager = {
|
||||||
|
|
||||||
|
urlCache: new Map(),
|
||||||
|
|
||||||
|
dropzone: {
|
||||||
|
init: function (id, urlId, progressReporter) {
|
||||||
|
function preventDefaults(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
|
||||||
|
|
||||||
|
moonlight.toasts.create("dropZoneUploadProgress", "Preparing to upload");
|
||||||
|
|
||||||
|
await performUpload(e.dataTransfer.items);
|
||||||
|
|
||||||
|
moonlight.toasts.remove("dropZoneUploadProgress");
|
||||||
|
moonlight.toasts.success("", "Successfully uploaded files", 5000);
|
||||||
|
|
||||||
|
progressReporter.invokeMethodAsync("UpdateStatus");
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: HANDLE UNSUPPORTED which would call else
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performUpload(items) {
|
||||||
|
const fileEntries = [];
|
||||||
|
|
||||||
|
// Collect file entries from DataTransferItemList
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
if (items[i].kind === 'file') {
|
||||||
|
const entry = items[i].webkitGetAsEntry();
|
||||||
|
if (entry.isFile) {
|
||||||
|
fileEntries.push(entry);
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
await readDirectory(entry, fileEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload files one by one
|
||||||
|
for (const fileEntry of fileEntries) {
|
||||||
|
moonlight.toasts.modify("dropZoneUploadProgress", `Uploading '${fileEntry.name}'`);
|
||||||
|
|
||||||
|
await uploadFile(fileEntry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readDirectory(directoryEntry, fileEntries = []) {
|
||||||
|
const directoryReader = directoryEntry.createReader();
|
||||||
|
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
const readBatch = async () => {
|
||||||
|
directoryReader.readEntries(async function (entries) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isFile) {
|
||||||
|
fileEntries.push(entry);
|
||||||
|
} else if (entry.isDirectory) {
|
||||||
|
await readDirectory(entry, fileEntries);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there are more entries to read, call readBatch again
|
||||||
|
if (entries.length === 100) {
|
||||||
|
await readBatch();
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, reject);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start reading the first batch
|
||||||
|
await readBatch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(file) {
|
||||||
|
// Upload the file to the server
|
||||||
|
let formData = new FormData();
|
||||||
|
formData.append('file', await getFile(file));
|
||||||
|
formData.append("path", file.fullPath);
|
||||||
|
|
||||||
|
var url = filemanager.urlCache.get(urlId);
|
||||||
|
|
||||||
|
// Create a new fetch request
|
||||||
|
let request = new Request(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
request.onprogress = function (event) {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
let percentComplete = (event.loaded / event.total) * 100;
|
||||||
|
console.log(`Upload progress: ${percentComplete.toFixed(2)}%`);
|
||||||
|
console.log(`Bytes transferred: ${event.loaded} of ${event.total}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the fetch API to send the request
|
||||||
|
var response = await fetch(request);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
var errorText = await response.text();
|
||||||
|
|
||||||
|
moonlight.toasts.danger(`Failed to upload '${file.name}'`, errorText, 5000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
moonlight.toasts.danger(`Failed to upload '${file.name}'`, error.toString(), 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFile(fileEntry) {
|
||||||
|
try {
|
||||||
|
return new Promise((resolve, reject) => fileEntry.file(resolve, reject));
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dropArea = document.getElementById(id);
|
||||||
|
|
||||||
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||||
|
dropArea.addEventListener(eventName, preventDefaults, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle dropped files and folders
|
||||||
|
dropArea.addEventListener('drop', handleDrop, false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fileselect: {
|
||||||
|
init: function (id, urlId, progressReporter) {
|
||||||
|
const inputElement = document.getElementById(id);
|
||||||
|
|
||||||
|
inputElement.addEventListener("change", handleFiles, false);
|
||||||
|
|
||||||
|
async function handleFiles() {
|
||||||
|
const fileList = inputElement.files;
|
||||||
|
|
||||||
|
moonlight.toasts.create("selectUploadProgress", "Preparing to upload");
|
||||||
|
|
||||||
|
for (const file of fileList) {
|
||||||
|
moonlight.toasts.modify("selectUploadProgress", `Uploading '${file.name}'`);
|
||||||
|
await uploadFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
moonlight.toasts.remove("selectUploadProgress");
|
||||||
|
moonlight.toasts.success("", "Successfully uploaded files", 5000);
|
||||||
|
|
||||||
|
progressReporter.invokeMethodAsync("UpdateStatus");
|
||||||
|
|
||||||
|
inputElement.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(file) {
|
||||||
|
// Upload the file to the server
|
||||||
|
let formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append("path", file.name);
|
||||||
|
|
||||||
|
var url = filemanager.urlCache.get(urlId);
|
||||||
|
|
||||||
|
// Create a new fetch request
|
||||||
|
let request = new Request(url, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
request.onprogress = function (event) {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
let percentComplete = (event.loaded / event.total) * 100;
|
||||||
|
console.log(`Upload progress: ${percentComplete.toFixed(2)}%`);
|
||||||
|
console.log(`Bytes transferred: ${event.loaded} of ${event.total}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the fetch API to send the request
|
||||||
|
var response = await fetch(request);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
var errorText = await response.text();
|
||||||
|
|
||||||
|
moonlight.toasts.danger(`Failed to upload '${file.name}'`, errorText, 5000);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
moonlight.toasts.danger(`Failed to upload '${file.name}'`, error.toString(), 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateUrl: function (urlId, url) {
|
||||||
|
filemanager.urlCache.set(urlId, url);
|
||||||
|
}
|
||||||
|
};
|
|
@ -1,4 +1,5 @@
|
||||||
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
|
using MoonCore.Helpers;
|
||||||
|
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
|
||||||
|
|
||||||
namespace Moonlight.Core.Helpers;
|
namespace Moonlight.Core.Helpers;
|
||||||
|
|
||||||
|
@ -87,6 +88,9 @@ public class HostFileActions : IFileActions
|
||||||
public Task WriteFile(string name, string content)
|
public Task WriteFile(string name, string content)
|
||||||
{
|
{
|
||||||
var fullPath = GetFullPath(name);
|
var fullPath = GetFullPath(name);
|
||||||
|
|
||||||
|
EnsureDir(fullPath);
|
||||||
|
|
||||||
File.WriteAllText(fullPath, content);
|
File.WriteAllText(fullPath, content);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
@ -101,12 +105,20 @@ public class HostFileActions : IFileActions
|
||||||
{
|
{
|
||||||
var fullPath = GetFullPath(name);
|
var fullPath = GetFullPath(name);
|
||||||
|
|
||||||
|
EnsureDir(fullPath);
|
||||||
|
|
||||||
using (var fileStream = File.Create(fullPath))
|
using (var fileStream = File.Create(fullPath))
|
||||||
dataStream.CopyTo(fileStream);
|
dataStream.CopyTo(fileStream);
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void EnsureDir(string path)
|
||||||
|
{
|
||||||
|
var pathWithoutFileName = Formatter.ReplaceEnd(path, Path.GetFileName(path), "");
|
||||||
|
Directory.CreateDirectory(pathWithoutFileName);
|
||||||
|
}
|
||||||
|
|
||||||
public IFileActions Clone()
|
public IFileActions Clone()
|
||||||
{
|
{
|
||||||
return new HostFileActions(RootDirectory);
|
return new HostFileActions(RootDirectory);
|
||||||
|
|
|
@ -127,7 +127,7 @@ public class PluginService
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var assembly = Assembly.LoadFile(dllFile);
|
var assembly = Assembly.LoadFile(Path.GetFullPath(dllFile));
|
||||||
|
|
||||||
var pluginTypes = assembly
|
var pluginTypes = assembly
|
||||||
.GetTypes()
|
.GetTypes()
|
||||||
|
|
|
@ -3,6 +3,8 @@ using MoonCore.Services;
|
||||||
using Moonlight.Core.Configuration;
|
using Moonlight.Core.Configuration;
|
||||||
using Moonlight.Core.Models.Abstractions.Feature;
|
using Moonlight.Core.Models.Abstractions.Feature;
|
||||||
using Moonlight.Core.Services;
|
using Moonlight.Core.Services;
|
||||||
|
using Moonlight.Features.FileManager.Implementations;
|
||||||
|
using Moonlight.Features.FileManager.Interfaces;
|
||||||
using Moonlight.Features.FileManager.Models.Enums;
|
using Moonlight.Features.FileManager.Models.Enums;
|
||||||
|
|
||||||
namespace Moonlight.Features.FileManager;
|
namespace Moonlight.Features.FileManager;
|
||||||
|
@ -25,14 +27,39 @@ public class FileManagerFeature : MoonlightFeature
|
||||||
context.Builder.Services.AddSingleton(new JwtService<FileManagerJwtType>(config.Get().Security.Token));
|
context.Builder.Services.AddSingleton(new JwtService<FileManagerJwtType>(config.Get().Security.Token));
|
||||||
|
|
||||||
context.AddAsset("FileManager", "js/dropzone.js");
|
context.AddAsset("FileManager", "js/dropzone.js");
|
||||||
|
context.AddAsset("FileManager", "js/filemanager.js");
|
||||||
context.AddAsset("FileManager", "editor/ace.css");
|
context.AddAsset("FileManager", "editor/ace.css");
|
||||||
context.AddAsset("FileManager", "editor/ace.js");
|
context.AddAsset("FileManager", "editor/ace.js");
|
||||||
|
|
||||||
|
// Add blazor context menu
|
||||||
|
context.Builder.Services.AddBlazorContextMenu(builder =>
|
||||||
|
{
|
||||||
|
/*
|
||||||
|
builder.ConfigureTemplate(template =>
|
||||||
|
{
|
||||||
|
|
||||||
|
});*/
|
||||||
|
});
|
||||||
|
|
||||||
|
context.AddAsset("FileManager", "js/blazorContextMenu.js");
|
||||||
|
context.AddAsset("FileManager", "css/blazorContextMenu.css");
|
||||||
|
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override async Task OnInitialized(InitContext context)
|
||||||
|
{
|
||||||
|
// Register default file manager actions in plugin service
|
||||||
|
var pluginService = context.Application.Services.GetRequiredService<PluginService>();
|
||||||
|
|
||||||
|
await pluginService.RegisterImplementation<IFileManagerAction>(new RenameFileManagerAction());
|
||||||
|
await pluginService.RegisterImplementation<IFileManagerAction>(new DownloadFileManagerAction());
|
||||||
|
await pluginService.RegisterImplementation<IFileManagerAction>(new DeleteFileManagerAction());
|
||||||
|
}
|
||||||
|
|
||||||
public override async Task OnSessionInitialized(SessionInitContext context)
|
public override async Task OnSessionInitialized(SessionInitContext context)
|
||||||
{
|
{
|
||||||
|
// Register hotkeys
|
||||||
var hotKeyService = context.ServiceProvider.GetRequiredService<HotKeyService>();
|
var hotKeyService = context.ServiceProvider.GetRequiredService<HotKeyService>();
|
||||||
|
|
||||||
await hotKeyService.RegisterHotkey("KeyS", "ctrl", "save");
|
await hotKeyService.RegisterHotkey("KeyS", "ctrl", "save");
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using MoonCore.Helpers;
|
||||||
using MoonCore.Services;
|
using MoonCore.Services;
|
||||||
using Moonlight.Core.Services;
|
using Moonlight.Core.Services;
|
||||||
using Moonlight.Features.FileManager.Models.Enums;
|
using Moonlight.Features.FileManager.Models.Enums;
|
||||||
|
@ -46,6 +47,20 @@ public class UploadController : Controller
|
||||||
if (Request.Form.Files.Count > 1)
|
if (Request.Form.Files.Count > 1)
|
||||||
return BadRequest("Too many files sent");
|
return BadRequest("Too many files sent");
|
||||||
|
|
||||||
|
if (!Request.Form.ContainsKey("path"))
|
||||||
|
return BadRequest("Path is missing");
|
||||||
|
|
||||||
|
var path = Request.Form["path"].First() ?? "";
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
return BadRequest("Path is missing");
|
||||||
|
|
||||||
|
if (path.Contains(".."))
|
||||||
|
{
|
||||||
|
Logger.Warn("A path transversal attack has been detected while processing upload path", "security");
|
||||||
|
return BadRequest("Invalid path. This attempt has been logged ;)");
|
||||||
|
}
|
||||||
|
|
||||||
// Validate request
|
// Validate request
|
||||||
if (!await JwtService.Validate(uploadToken, FileManagerJwtType.FileAccess))
|
if (!await JwtService.Validate(uploadToken, FileManagerJwtType.FileAccess))
|
||||||
return StatusCode(403);
|
return StatusCode(403);
|
||||||
|
@ -66,7 +81,7 @@ public class UploadController : Controller
|
||||||
|
|
||||||
// Actually upload the file
|
// Actually upload the file
|
||||||
var file = Request.Form.Files.First();
|
var file = Request.Form.Files.First();
|
||||||
await fileAccess.WriteFileStream(file.FileName, file.OpenReadStream());
|
await fileAccess.WriteFileStream(path, file.OpenReadStream());
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
fileAccess.Dispose();
|
fileAccess.Dispose();
|
||||||
|
|
|
@ -0,0 +1,24 @@
|
||||||
|
using MoonCoreUI.Services;
|
||||||
|
using Moonlight.Features.FileManager.Interfaces;
|
||||||
|
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
|
||||||
|
using Moonlight.Features.FileManager.UI.NewFileManager;
|
||||||
|
|
||||||
|
namespace Moonlight.Features.FileManager.Implementations;
|
||||||
|
|
||||||
|
public class DeleteFileManagerAction : IFileManagerAction
|
||||||
|
{
|
||||||
|
public string Name => "Delete";
|
||||||
|
public string Icon => "bxs-trash";
|
||||||
|
public string Color => "danger";
|
||||||
|
public Func<FileEntry, bool> Filter => _ => true;
|
||||||
|
|
||||||
|
public async Task Execute(BaseFileAccess access, FileView view, FileEntry entry, IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
await access.Delete(entry);
|
||||||
|
|
||||||
|
await view.Refresh();
|
||||||
|
|
||||||
|
var toastService = serviceProvider.GetRequiredService<ToastService>();
|
||||||
|
await toastService.Success("Successfully deleted item");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using MoonCore.Helpers;
|
||||||
|
using MoonCoreUI.Services;
|
||||||
|
using Moonlight.Features.FileManager.Interfaces;
|
||||||
|
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
|
||||||
|
using Moonlight.Features.FileManager.Services;
|
||||||
|
using Moonlight.Features.FileManager.UI.NewFileManager;
|
||||||
|
|
||||||
|
namespace Moonlight.Features.FileManager.Implementations;
|
||||||
|
|
||||||
|
public class DownloadFileManagerAction : IFileManagerAction
|
||||||
|
{
|
||||||
|
public string Name => "Download";
|
||||||
|
public string Icon => "bxs-cloud-download";
|
||||||
|
public string Color => "primary";
|
||||||
|
public Func<FileEntry, bool> Filter => entry => entry.IsFile;
|
||||||
|
|
||||||
|
public async Task Execute(BaseFileAccess access, FileView view, FileEntry entry, IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
var fileAccessService = serviceProvider.GetRequiredService<SharedFileAccessService>();
|
||||||
|
var navigation = serviceProvider.GetRequiredService<NavigationManager>();
|
||||||
|
var toastService = serviceProvider.GetRequiredService<ToastService>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var token = await fileAccessService.GenerateToken(access);
|
||||||
|
var url = $"/api/download?token={token}&name={entry.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
using MoonCoreUI.Services;
|
||||||
|
using Moonlight.Features.FileManager.Interfaces;
|
||||||
|
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
|
||||||
|
using Moonlight.Features.FileManager.UI.NewFileManager;
|
||||||
|
|
||||||
|
namespace Moonlight.Features.FileManager.Implementations;
|
||||||
|
|
||||||
|
public class RenameFileManagerAction : IFileManagerAction
|
||||||
|
{
|
||||||
|
public string Name => "Rename";
|
||||||
|
public string Icon => "bxs-rename";
|
||||||
|
public string Color => "info";
|
||||||
|
public Func<FileEntry, bool> Filter => _ => true;
|
||||||
|
|
||||||
|
public async Task Execute(BaseFileAccess access, FileView view, FileEntry entry, IServiceProvider provider)
|
||||||
|
{
|
||||||
|
var alertService = provider.GetRequiredService<AlertService>();
|
||||||
|
var toastService = provider.GetRequiredService<ToastService>();
|
||||||
|
|
||||||
|
var newName = await alertService.Text($"Enter a new name for '{entry.Name}'", "", entry.Name);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(newName))
|
||||||
|
return;
|
||||||
|
|
||||||
|
await access.Rename(entry.Name, newName);
|
||||||
|
|
||||||
|
await view.Refresh();
|
||||||
|
await toastService.Success("Successfully renamed file");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
using Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
|
||||||
|
using Moonlight.Features.FileManager.UI.NewFileManager;
|
||||||
|
|
||||||
|
namespace Moonlight.Features.FileManager.Interfaces;
|
||||||
|
|
||||||
|
public interface IFileManagerAction
|
||||||
|
{
|
||||||
|
public string Name { get; }
|
||||||
|
public string Icon { get; }
|
||||||
|
public string Color { get; }
|
||||||
|
public Func<FileEntry, bool> Filter { get; }
|
||||||
|
|
||||||
|
public Task Execute(BaseFileAccess access, FileView view, FileEntry entry, IServiceProvider provider);
|
||||||
|
}
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
namespace Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
|
namespace Moonlight.Features.FileManager.Models.Abstractions.FileAccess;
|
||||||
|
|
||||||
public class BaseFileAccess : IFileAccess
|
public class BaseFileAccess : IDisposable
|
||||||
{
|
{
|
||||||
private readonly IFileActions Actions;
|
private readonly IFileActions Actions;
|
||||||
|
|
||||||
|
@ -46,20 +46,28 @@ public class BaseFileAccess : IFileAccess
|
||||||
return Task.FromResult(CurrentDirectory);
|
return Task.FromResult(CurrentDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Delete(string path)
|
public async Task Delete(FileEntry entry)
|
||||||
{
|
{
|
||||||
var finalPath = CurrentDirectory + path;
|
var finalPath = CurrentDirectory + entry.Name;
|
||||||
|
|
||||||
await Actions.Delete(finalPath);
|
await Actions.Delete(finalPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task Move(string from, string to)
|
public async Task Move(FileEntry entry, string to)
|
||||||
{
|
{
|
||||||
var finalPathFrom = CurrentDirectory + from;
|
var finalPathFrom = CurrentDirectory + entry.Name;
|
||||||
|
|
||||||
await Actions.Move(finalPathFrom, to);
|
await Actions.Move(finalPathFrom, to);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task Rename(string from, string to)
|
||||||
|
{
|
||||||
|
var finalPathFrom = CurrentDirectory + from;
|
||||||
|
var finalPathTo = CurrentDirectory + to;
|
||||||
|
|
||||||
|
await Actions.Move(finalPathFrom, finalPathTo);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task CreateDirectory(string name)
|
public async Task CreateDirectory(string name)
|
||||||
{
|
{
|
||||||
var finalPath = CurrentDirectory + name;
|
var finalPath = CurrentDirectory + name;
|
||||||
|
@ -102,7 +110,7 @@ public class BaseFileAccess : IFileAccess
|
||||||
await Actions.WriteFileStream(finalPath, dataStream);
|
await Actions.WriteFileStream(finalPath, dataStream);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IFileAccess Clone()
|
public BaseFileAccess Clone()
|
||||||
{
|
{
|
||||||
return new BaseFileAccess(Actions.Clone())
|
return new BaseFileAccess(Actions.Clone())
|
||||||
{
|
{
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
using MoonCore.Attributes;
|
||||||
|
using MoonCore.Helpers;
|
||||||
|
|
||||||
|
namespace Moonlight.Features.FileManager.Services;
|
||||||
|
|
||||||
|
[Scoped]
|
||||||
|
public class FileManagerInteropService
|
||||||
|
{
|
||||||
|
private readonly IJSRuntime JsRuntime;
|
||||||
|
|
||||||
|
public SmartEventHandler OnUploadStateChanged { get; set; } = new();
|
||||||
|
|
||||||
|
public FileManagerInteropService(IJSRuntime jsRuntime)
|
||||||
|
{
|
||||||
|
JsRuntime = jsRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitDropzone(string id, string urlId)
|
||||||
|
{
|
||||||
|
var reference = DotNetObjectReference.Create(this);
|
||||||
|
await JsRuntime.InvokeVoidAsync("filemanager.dropzone.init", id, urlId, reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InitFileSelect(string id, string urlId)
|
||||||
|
{
|
||||||
|
var reference = DotNetObjectReference.Create(this);
|
||||||
|
await JsRuntime.InvokeVoidAsync("filemanager.fileselect.init", id, urlId, reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpdateUrl(string urlId, string url)
|
||||||
|
{
|
||||||
|
await JsRuntime.InvokeVoidAsync("filemanager.updateUrl", urlId, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
[JSInvokable]
|
||||||
|
public async Task UpdateStatus()
|
||||||
|
{
|
||||||
|
await OnUploadStateChanged.Invoke();
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,14 +10,14 @@ namespace Moonlight.Features.FileManager.Services;
|
||||||
public class SharedFileAccessService
|
public class SharedFileAccessService
|
||||||
{
|
{
|
||||||
private readonly JwtService<FileManagerJwtType> JwtService;
|
private readonly JwtService<FileManagerJwtType> JwtService;
|
||||||
private readonly List<IFileAccess> FileAccesses = new();
|
private readonly List<BaseFileAccess> FileAccesses = new();
|
||||||
|
|
||||||
public SharedFileAccessService(JwtService<FileManagerJwtType> jwtService)
|
public SharedFileAccessService(JwtService<FileManagerJwtType> jwtService)
|
||||||
{
|
{
|
||||||
JwtService = jwtService;
|
JwtService = jwtService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<int> Register(IFileAccess fileAccess)
|
public Task<int> Register(BaseFileAccess fileAccess)
|
||||||
{
|
{
|
||||||
lock (FileAccesses)
|
lock (FileAccesses)
|
||||||
{
|
{
|
||||||
|
@ -28,7 +28,7 @@ public class SharedFileAccessService
|
||||||
return Task.FromResult(fileAccess.GetHashCode());
|
return Task.FromResult(fileAccess.GetHashCode());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task Unregister(IFileAccess fileAccess)
|
public Task Unregister(BaseFileAccess fileAccess)
|
||||||
{
|
{
|
||||||
lock (FileAccesses)
|
lock (FileAccesses)
|
||||||
{
|
{
|
||||||
|
@ -39,20 +39,20 @@ public class SharedFileAccessService
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<IFileAccess?> Get(int id)
|
public Task<BaseFileAccess?> Get(int id)
|
||||||
{
|
{
|
||||||
lock (FileAccesses)
|
lock (FileAccesses)
|
||||||
{
|
{
|
||||||
var fileAccess = FileAccesses.FirstOrDefault(x => x.GetHashCode() == id);
|
var fileAccess = FileAccesses.FirstOrDefault(x => x.GetHashCode() == id);
|
||||||
|
|
||||||
if (fileAccess == null)
|
if (fileAccess == null)
|
||||||
return Task.FromResult<IFileAccess?>(null);
|
return Task.FromResult<BaseFileAccess?>(null);
|
||||||
|
|
||||||
return Task.FromResult<IFileAccess?>(fileAccess.Clone());
|
return Task.FromResult<BaseFileAccess?>(fileAccess.Clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GenerateToken(IFileAccess fileAccess)
|
public async Task<string> GenerateToken(BaseFileAccess fileAccess)
|
||||||
{
|
{
|
||||||
var token = await JwtService.Create(data =>
|
var token = await JwtService.Create(data =>
|
||||||
{
|
{
|
||||||
|
|
|
@ -34,7 +34,7 @@
|
||||||
{
|
{
|
||||||
[Parameter] public FileEntry File { get; set; }
|
[Parameter] public FileEntry File { get; set; }
|
||||||
|
|
||||||
[Parameter] public IFileAccess FileAccess { get; set; }
|
[Parameter] public BaseFileAccess FileAccess { get; set; }
|
||||||
|
|
||||||
[Parameter] public bool CloseOnSave { get; set; } = false;
|
[Parameter] public bool CloseOnSave { get; set; } = false;
|
||||||
|
|
||||||
|
|
|
@ -117,7 +117,7 @@
|
||||||
|
|
||||||
@code
|
@code
|
||||||
{
|
{
|
||||||
[Parameter] public IFileAccess FileAccess { get; set; }
|
[Parameter] public BaseFileAccess FileAccess { get; set; }
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
private string Path = "/";
|
private string Path = "/";
|
||||||
|
@ -134,7 +134,7 @@
|
||||||
// Move
|
// Move
|
||||||
private FileEntry MoveEntry;
|
private FileEntry MoveEntry;
|
||||||
private SmartModal MoveModal;
|
private SmartModal MoveModal;
|
||||||
private IFileAccess MoveAccess;
|
private BaseFileAccess MoveAccess;
|
||||||
|
|
||||||
private async Task OnPathChanged(string path)
|
private async Task OnPathChanged(string path)
|
||||||
{
|
{
|
||||||
|
@ -267,7 +267,7 @@
|
||||||
MoveAccess.Dispose();
|
MoveAccess.Dispose();
|
||||||
|
|
||||||
// Perform move and process ui updates
|
// Perform move and process ui updates
|
||||||
await FileAccess.Move(MoveEntry.Name, pathToMove + MoveEntry.Name);
|
await FileAccess.Move(MoveEntry, pathToMove + MoveEntry.Name);
|
||||||
|
|
||||||
await MoveModal.Hide();
|
await MoveModal.Hide();
|
||||||
|
|
||||||
|
|
|
@ -51,7 +51,7 @@
|
||||||
|
|
||||||
@code
|
@code
|
||||||
{
|
{
|
||||||
[Parameter] public IFileAccess FileAccess { get; set; }
|
[Parameter] public BaseFileAccess FileAccess { get; set; }
|
||||||
|
|
||||||
private CancellationTokenSource Cancellation = new();
|
private CancellationTokenSource Cancellation = new();
|
||||||
private string DropzoneId;
|
private string DropzoneId;
|
||||||
|
@ -65,12 +65,12 @@
|
||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
{
|
||||||
await SharedFileAccessService.Register(FileAccess);
|
//await SharedFileAccessService.Register(FileAccess);
|
||||||
|
|
||||||
var token = await SharedFileAccessService.GenerateToken(FileAccess);
|
//var token = await SharedFileAccessService.GenerateToken(FileAccess);
|
||||||
var url = $"/api/upload?token={token}";
|
//var url = $"/api/upload?token={token}";
|
||||||
|
|
||||||
await DropzoneService.Create(DropzoneId, url);
|
//await DropzoneService.Create(DropzoneId, url);
|
||||||
|
|
||||||
Task.Run(async () => // Update the dropzone url every 5 minutes so the token does not expire
|
Task.Run(async () => // Update the dropzone url every 5 minutes so the token does not expire
|
||||||
{
|
{
|
||||||
|
@ -78,9 +78,9 @@
|
||||||
{
|
{
|
||||||
await Task.Delay(TimeSpan.FromMinutes(5));
|
await Task.Delay(TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
var newToken = await SharedFileAccessService.GenerateToken(FileAccess);
|
//var newToken = await SharedFileAccessService.GenerateToken(FileAccess);
|
||||||
var newUrl = $"/api/upload?token={newToken}";
|
//var newUrl = $"/api/upload?token={newToken}";
|
||||||
await DropzoneService.UpdateUrl(DropzoneId, newUrl);
|
//await DropzoneService.UpdateUrl(DropzoneId, newUrl);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -89,6 +89,6 @@
|
||||||
public async void Dispose()
|
public async void Dispose()
|
||||||
{
|
{
|
||||||
Cancellation.Cancel();
|
Cancellation.Cancel();
|
||||||
await SharedFileAccessService.Unregister(FileAccess);
|
//await SharedFileAccessService.Unregister(FileAccess);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -210,7 +210,7 @@
|
||||||
|
|
||||||
@code
|
@code
|
||||||
{
|
{
|
||||||
[Parameter] public IFileAccess FileAccess { get; set; }
|
[Parameter] public BaseFileAccess FileAccess { get; set; }
|
||||||
|
|
||||||
[Parameter] public Func<FileEntry, bool>? Filter { get; set; }
|
[Parameter] public Func<FileEntry, bool>? Filter { get; set; }
|
||||||
[Parameter] public bool ShowSize { get; set; } = true;
|
[Parameter] public bool ShowSize { get; set; } = true;
|
||||||
|
@ -297,7 +297,7 @@
|
||||||
{
|
{
|
||||||
await ToastService.ModifyProgress(toastId, $"[{i + 1}/{entries.Length}] Deleting '{entry.Name}'");
|
await ToastService.ModifyProgress(toastId, $"[{i + 1}/{entries.Length}] Deleting '{entry.Name}'");
|
||||||
|
|
||||||
await FileAccess.Delete(entry.Name);
|
await FileAccess.Delete(entry);
|
||||||
|
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
@ -315,7 +315,7 @@
|
||||||
if (string.IsNullOrEmpty(name))
|
if (string.IsNullOrEmpty(name))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
await FileAccess.Move(fileEntry.Name, await FileAccess.GetCurrentDirectory() + name);
|
await FileAccess.Move(fileEntry, await FileAccess.GetCurrentDirectory() + name);
|
||||||
|
|
||||||
await LazyLoader.Reload();
|
await LazyLoader.Reload();
|
||||||
}
|
}
|
||||||
|
@ -332,12 +332,12 @@
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await SharedFileAccessService.Register(FileAccess);
|
//await SharedFileAccessService.Register(FileAccess);
|
||||||
var token = await SharedFileAccessService.GenerateToken(FileAccess);
|
//var token = await SharedFileAccessService.GenerateToken(FileAccess);
|
||||||
var url = $"/api/download?token={token}&name={fileEntry.Name}";
|
//var url = $"/api/download?token={token}&name={fileEntry.Name}";
|
||||||
|
|
||||||
await ToastService.Info("Starting download...");
|
await ToastService.Info("Starting download...");
|
||||||
Navigation.NavigateTo(url, true);
|
//Navigation.NavigateTo(url, true);
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
@ -429,6 +429,6 @@
|
||||||
|
|
||||||
public async void Dispose()
|
public async void Dispose()
|
||||||
{
|
{
|
||||||
await SharedFileAccessService.Unregister(FileAccess);
|
//await SharedFileAccessService.Unregister(FileAccess);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,112 @@
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,440 @@
|
||||||
|
@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">
|
||||||
|
<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" style="">
|
||||||
|
<li>
|
||||||
|
<a href="#" class="dropdown-item" @onclick:preventDefault @onclick="CreateFile">
|
||||||
|
<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="#" class="dropdown-item" @onclick:preventDefault @onclick="CreateDirectory">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
@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"
|
||||||
|
EnableContextMenu="true">
|
||||||
|
<ContextMenuTemplate>
|
||||||
|
@foreach (var action in Actions)
|
||||||
|
{
|
||||||
|
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>
|
||||||
|
}
|
||||||
|
|
||||||
|
<a class="dropdown-item" href="#" @onclick:preventDefault @onclick="() => Move(context)">
|
||||||
|
<i class="bx bx-sm bx-move text-info align-middle"></i>
|
||||||
|
<span class="align-middle ms-3">Move</span>
|
||||||
|
</a>
|
||||||
|
</ContextMenuTemplate>
|
||||||
|
</FileView>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SmartModal @ref="MoveModal" CssClasses="modal-lg modal-dialog-centered">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Select a new location</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" @onclick="HideMove"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<FileView @ref="MoveView"
|
||||||
|
FileAccess="MoveAccess"
|
||||||
|
Filter="FolderOnlyFilter"
|
||||||
|
ShowDate="false"
|
||||||
|
ShowSelect="false"
|
||||||
|
ShowSize="false"
|
||||||
|
OnEntryClicked="OnFolderClicked"
|
||||||
|
OnNavigateUpClicked="OnMoveUpClicked"
|
||||||
|
EnableContextMenu="false"/>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @onclick="HideMove">Close</button>
|
||||||
|
<button type="button" class="btn btn-primary" @onclick="FinishMove">Save changes</button>
|
||||||
|
</div>
|
||||||
|
</SmartModal>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code
|
||||||
|
{
|
||||||
|
[Parameter] public BaseFileAccess FileAccess { get; set; }
|
||||||
|
|
||||||
|
private FileView View;
|
||||||
|
private string Path = "/";
|
||||||
|
|
||||||
|
private IFileManagerAction[] Actions;
|
||||||
|
|
||||||
|
// Editor
|
||||||
|
private FileEditor Editor;
|
||||||
|
private FileEntry FileToEdit;
|
||||||
|
private bool ShowEditor = false;
|
||||||
|
|
||||||
|
// Move
|
||||||
|
private SmartModal MoveModal;
|
||||||
|
private BaseFileAccess MoveAccess;
|
||||||
|
private FileView MoveView;
|
||||||
|
private bool InMoveState = false;
|
||||||
|
private Func<FileEntry, bool> FolderOnlyFilter = entry => entry.IsDirectory;
|
||||||
|
private Func<FileEntry, Task> OnFolderClicked;
|
||||||
|
private Func<Task> OnMoveUpClicked;
|
||||||
|
private List<FileEntry> FilesToMove = new();
|
||||||
|
|
||||||
|
private Timer? UploadTokenTimer;
|
||||||
|
|
||||||
|
protected override void OnInitialized()
|
||||||
|
{
|
||||||
|
OnFolderClicked = async entry =>
|
||||||
|
{
|
||||||
|
await MoveAccess.ChangeDirectory(entry.Name);
|
||||||
|
await MoveView.Refresh();
|
||||||
|
};
|
||||||
|
OnMoveUpClicked = async () =>
|
||||||
|
{
|
||||||
|
await MoveAccess.ChangeDirectory("..");
|
||||||
|
await MoveView.Refresh();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
|
{
|
||||||
|
if (!firstRender)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Load plugin ui and options
|
||||||
|
Actions = await PluginService.GetImplementations<IFileManagerAction>();
|
||||||
|
|
||||||
|
|
||||||
|
// 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(IFileManagerAction action, FileEntry entry)
|
||||||
|
{
|
||||||
|
await View.HideContextMenu();
|
||||||
|
|
||||||
|
await action.Execute(FileAccess, View, entry, ServiceProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
#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 Actions
|
||||||
|
|
||||||
|
private async Task DeleteSelection()
|
||||||
|
{
|
||||||
|
var itemsToDelete = View.Selection;
|
||||||
|
|
||||||
|
await ToastService.CreateProgress("fileManagerDeleteFile", "Deleting items");
|
||||||
|
|
||||||
|
var i = 1;
|
||||||
|
foreach (var entry in itemsToDelete)
|
||||||
|
{
|
||||||
|
await ToastService.ModifyProgress("fileManagerDeleteFile", $"[{i}/{FilesToMove.Count}] Deleting items");
|
||||||
|
await FileAccess.Delete(entry);
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ToastService.RemoveProgress("fileManagerDeleteFile");
|
||||||
|
|
||||||
|
await ToastService.Success($"Successfully deleted {FilesToMove.Count} items");
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Create Dir / File
|
||||||
|
|
||||||
|
private async Task CreateDirectory()
|
||||||
|
{
|
||||||
|
var name = await AlertService.Text("Enter a name for the new directory");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(name) || name.Contains(".."))
|
||||||
|
return;
|
||||||
|
|
||||||
|
await FileAccess.CreateDirectory(name);
|
||||||
|
|
||||||
|
await ToastService.Success("Successfully created directory");
|
||||||
|
await View.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateFile()
|
||||||
|
{
|
||||||
|
var name = await AlertService.Text("Enter a name for the new file");
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(name) || name.Contains(".."))
|
||||||
|
return;
|
||||||
|
|
||||||
|
await FileAccess.CreateFile(name);
|
||||||
|
|
||||||
|
// We build a virtual entry here so we dont need to fetch one
|
||||||
|
await OpenEditor(new()
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Size = 0,
|
||||||
|
IsFile = true,
|
||||||
|
IsDirectory = false,
|
||||||
|
LastModifiedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region File Editor
|
||||||
|
|
||||||
|
private async Task OpenEditor(FileEntry entry)
|
||||||
|
{
|
||||||
|
FileToEdit = entry;
|
||||||
|
ShowEditor = true;
|
||||||
|
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CloseEditor()
|
||||||
|
{
|
||||||
|
ShowEditor = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Move
|
||||||
|
|
||||||
|
private async Task Move(FileEntry entry)
|
||||||
|
{
|
||||||
|
await View.HideContextMenu();
|
||||||
|
|
||||||
|
FilesToMove.Clear();
|
||||||
|
|
||||||
|
FilesToMove.Add(entry);
|
||||||
|
|
||||||
|
await StartMove();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MoveSelection()
|
||||||
|
{
|
||||||
|
FilesToMove.Clear();
|
||||||
|
|
||||||
|
FilesToMove.AddRange(View.Selection);
|
||||||
|
|
||||||
|
await StartMove();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StartMove()
|
||||||
|
{
|
||||||
|
// Cleanup if modal was removed in any other way
|
||||||
|
if (InMoveState)
|
||||||
|
await HideMove();
|
||||||
|
|
||||||
|
// Prepare file access and show modal
|
||||||
|
InMoveState = true;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
MoveAccess = FileAccess.Clone();
|
||||||
|
await MoveAccess.SetDirectory("/");
|
||||||
|
|
||||||
|
await MoveModal.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HideMove()
|
||||||
|
{
|
||||||
|
await MoveModal.Hide();
|
||||||
|
MoveAccess.Dispose();
|
||||||
|
|
||||||
|
InMoveState = false;
|
||||||
|
await InvokeAsync(StateHasChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FinishMove()
|
||||||
|
{
|
||||||
|
var target = await MoveAccess.GetCurrentDirectory();
|
||||||
|
|
||||||
|
await HideMove();
|
||||||
|
|
||||||
|
await ToastService.CreateProgress("fileManagerMoveFile", "Moving items");
|
||||||
|
|
||||||
|
var i = 1;
|
||||||
|
foreach (var entry in FilesToMove)
|
||||||
|
{
|
||||||
|
await ToastService.ModifyProgress("fileManagerMoveFile", $"[{i}/{FilesToMove.Count}] Moving items");
|
||||||
|
await FileAccess.Move(entry, target + entry.Name);
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ToastService.RemoveProgress("fileManagerMoveFile");
|
||||||
|
|
||||||
|
await ToastService.Success($"Successfully moved {FilesToMove.Count} items");
|
||||||
|
|
||||||
|
await View.Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
public async void Dispose()
|
||||||
|
{
|
||||||
|
if (UploadTokenTimer != null)
|
||||||
|
await UploadTokenTimer.DisposeAsync();
|
||||||
|
|
||||||
|
await FileAccessService.Unregister(FileAccess);
|
||||||
|
}
|
||||||
|
}
|
387
Moonlight/Features/FileManager/UI/NewFileManager/FileView.razor
Normal file
387
Moonlight/Features/FileManager/UI/NewFileManager/FileView.razor
Normal file
|
@ -0,0 +1,387 @@
|
||||||
|
@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
|
||||||
|
|
||||||
|
}
|
|
@ -208,7 +208,7 @@ public class ServerService
|
||||||
await Backup.Delete(serverWithBackups, backup, false);
|
await Backup.Delete(serverWithBackups, backup, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IFileAccess> OpenFileAccess(Server s)
|
public async Task<BaseFileAccess> OpenFileAccess(Server s)
|
||||||
{
|
{
|
||||||
using var scope = ServiceProvider.CreateScope();
|
using var scope = ServiceProvider.CreateScope();
|
||||||
|
|
||||||
|
|
|
@ -19,7 +19,7 @@
|
||||||
{
|
{
|
||||||
[CascadingParameter] public Server Server { get; set; }
|
[CascadingParameter] public Server Server { get; set; }
|
||||||
|
|
||||||
private IFileAccess FileAccess;
|
private BaseFileAccess FileAccess;
|
||||||
|
|
||||||
private async Task Load(LazyLoader lazyLoader)
|
private async Task Load(LazyLoader lazyLoader)
|
||||||
{
|
{
|
||||||
|
|
|
@ -101,7 +101,7 @@
|
||||||
i++;
|
i++;
|
||||||
|
|
||||||
await ToastService.ModifyProgress("serverReset", $"Reset: Deleting files [{i} / {files.Length}]");
|
await ToastService.ModifyProgress("serverReset", $"Reset: Deleting files [{i} / {files.Length}]");
|
||||||
await fileAccess.Delete(fileEntry.Name);
|
await fileAccess.Delete(fileEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ToastService.ModifyProgress("serverReset", "Reset: Starting install script");
|
await ToastService.ModifyProgress("serverReset", "Reset: Starting install script");
|
||||||
|
|
12
Moonlight/Features/Servers/UI/Views/Test.razor
Normal file
12
Moonlight/Features/Servers/UI/Views/Test.razor
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
@page "/test"
|
||||||
|
|
||||||
|
@using Moonlight.Features.FileManager.UI.NewFileManager
|
||||||
|
@using Moonlight.Features.FileManager.Models.Abstractions.FileAccess
|
||||||
|
@using Moonlight.Core.Helpers
|
||||||
|
|
||||||
|
<FileManager FileAccess="FileAccess" />
|
||||||
|
|
||||||
|
@code
|
||||||
|
{
|
||||||
|
private BaseFileAccess FileAccess = new(new HostFileActions("storage"));
|
||||||
|
}
|
|
@ -74,7 +74,7 @@
|
||||||
<Folder Include="Features\FileManager\Http\Requests\" />
|
<Folder Include="Features\FileManager\Http\Requests\" />
|
||||||
<Folder Include="Features\FileManager\Http\Resources\" />
|
<Folder Include="Features\FileManager\Http\Resources\" />
|
||||||
<Folder Include="Features\Servers\Http\Resources\" />
|
<Folder Include="Features\Servers\Http\Resources\" />
|
||||||
<Folder Include="storage\assetOverrides\" />
|
<Folder Include="storage\assetOverrides\x\y\" />
|
||||||
<Folder Include="storage\logs\" />
|
<Folder Include="storage\logs\" />
|
||||||
<Folder Include="Styles\" />
|
<Folder Include="Styles\" />
|
||||||
<Folder Include="wwwroot\css\" />
|
<Folder Include="wwwroot\css\" />
|
||||||
|
@ -83,6 +83,7 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
|
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
|
||||||
<PackageReference Include="Blazor-ApexCharts" Version="2.3.3" />
|
<PackageReference Include="Blazor-ApexCharts" Version="2.3.3" />
|
||||||
|
<PackageReference Include="Blazor.ContextMenu" Version="1.17.0" />
|
||||||
<PackageReference Include="BlazorTable" Version="1.17.0" />
|
<PackageReference Include="BlazorTable" Version="1.17.0" />
|
||||||
<PackageReference Include="FluentFTP" Version="49.0.2" />
|
<PackageReference Include="FluentFTP" Version="49.0.2" />
|
||||||
<PackageReference Include="JWT" Version="10.1.1" />
|
<PackageReference Include="JWT" Version="10.1.1" />
|
||||||
|
|
Loading…
Reference in a new issue