tspace-nfs
Version:
tspace-nfs is a Network File System (NFS) and provides both server and client capabilities for accessing files over a network.
1,117 lines (888 loc) • 44.8 kB
HTML
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script>
<title>NFS-Studio</title>
<style>
.folder-toggle {
cursor: pointer;
}
ul {
list-style: none;
padding-left: 20px;
}
svg {
width: 16px;
height: 16px;
}
.hidden {
display: none;
}
#loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.8);
opacity: 0.75;
justify-content: center;
align-items: center;
z-index: 9999;
}
</style>
</head>
<body class="bg-gray-300 dark:bg-gray-900">
<nav class="sticky top-0 bg-gray-300 border-gray-200 dark:bg-gray-900">
<div class="w-screen-xl flex items-center justify-between mx-auto p-4">
<div class="cursor-pointer flex items-center space-x-3 rtl:space-x-reverse" onclick="window.location.reload()">
<svg class="w-12 h-12 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linejoin="round" stroke-width="2" d="M15 4v3a1 1 0 0 1-1 1h-3m2 10v1a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7.13a1 1 0 0 1 .24-.65L6.7 8.35A1 1 0 0 1 7.46 8H9m-1 4H4m16-7v10a1 1 0 0 1-1 1h-7a1 1 0 0 1-1-1V7.87a1 1 0 0 1 .24-.65l2.46-2.87a1 1 0 0 1 .76-.35H19a1 1 0 0 1 1 1Z"/>
</svg>
<h1 class="self-center text-2xl whitespace-nowrap text-gray-800 dark:text-white">
NFS-Studio
</h1>
</div>
<div class="flex space-x-3 rtl:space-x-reverse">
<button id="themeToggle" type="button"
class="rounded-lg text-sm px-4 py-2 text-center bg-gray-100 dark:bg-gray-800"
>
<svg id="toggleIcon" class="w-4 h-4 xl:w-6 xl:h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.675 2.015a.998.998 0 0 0-.403.011C6.09 2.4 2 6.722 2 12c0 5.523 4.477 10 10 10 4.356 0 8.058-2.784 9.43-6.667a1 1 0 0 0-1.02-1.33c-.08.006-.105.005-.127.005h-.001l-.028-.002A5.227 5.227 0 0 0 20 14a8 8 0 0 1-8-8c0-.952.121-1.752.404-2.558a.996.996 0 0 0 .096-.428V3a1 1 0 0 0-.825-.985Z" clip-rule="evenodd"/>
</svg>
</button>
<button id="logout" class="text-gray-800 dark:text-white rounded-lg text-sm px-4 py-2 text-center text-lg bg-gray-100 dark:bg-gray-800 ml-3">
<svg class="w-4 h-4 xl:w-6 xl:h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 12H8m12 0-4 4m4-4-4-4M9 4H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h2"/>
</svg>
</button>
</div>
</div>
</nav>
<div id="notify-container" class="space-y-4 fixed right-0 top-0 m-2 items-end justify-end z-50 pointer-events-none"></div>
<div class="h-[100vh]">
<div class="grid grid-cols-12 h-[75vh] overflow-y-auto">
<div class="col-span-5 xl:col-span-3 bg-white dark:bg-gray-800 shadow-md border-r p-4">
<div class="border-b mb-3">
<div class="flex items-center justify-between w-full text-left ">
<h1 class="text-3xl mb-2 dark:text-gray-200">Buckets</h1>
<svg id="bucketCreate" class="w-8 h-8 text-gray-800 dark:text-white cursor-pointer" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-7 7V5"/>
</svg>
</div>
<h3 class="text-sm dark:text-gray-200">total : <span id="buckets"> </span> </h3>
<h3 class="text-sm mb-2 dark:text-gray-200">storaged : <span id="storaged"></span></h3>
</div>
<ul id="bucket-container" class="space-y-2 dark:text-gray-200"></ul>
</div>
<div class="col-span-7 xl:col-span-9 pl-2 xl:pl-4">
<div id="dropZone" class="h-full bg-white dark:bg-gray-800 hover:border-blue-500 dark:hover:border-blue-400">
<div id="noBucketMessage" class="text-center text-3xl justify-center items-center text-gray-800 dark:text-white pt-[25%] xl:pt-[15%]">
No Buckets available
</div>
<div id="tableContainer" class="hidden">
<div class="flex items-center space-x-3 p-4">
<svg onclick="window.location.reload()" class="w-6 h-6 text-gray-800 dark:text-white cursor-pointer" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.651 7.65a7.131 7.131 0 0 0-12.68 3.15M18.001 4v4h-4m-7.652 8.35a7.13 7.13 0 0 0 12.68-3.15M6 20v-4h4"/>
</svg>
<span class="font-semibold text-gray-800 dark:text-gray-200">Current Directory:</span>
<span class="text-blue-600 px-3 py-1 font-mono">
<span id="currentPath"></span>
</span>
</div>
<div class="overflow-x-auto">
<table class="table-auto w-full bg-white dark:bg-gray-800">
<thead>
<tr>
<th class="py-2 px-4 text-left text-sm font-medium text-gray-500 dark:text-gray-400">File Name</th>
<th class="py-2 px-4 text-left text-sm font-medium text-gray-500 dark:text-gray-400">File Size</th>
<th class="py-2 px-4 text-left text-sm font-medium text-gray-500 dark:text-gray-400">File Type</th>
<th class="py-2 px-4 text-left text-sm font-medium text-gray-500 dark:text-gray-400">Last Modified</th>
<th class="py-2 px-4 text-left text-sm font-medium text-gray-500 dark:text-gray-400">Actions</th>
</tr>
</thead>
<tbody id="fileTableBody" class="text-gray-700 dark:text-gray-300"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 w-full h-[25vh] overflow-y-auto border-t">
<div class="mt-2">
<table class="table-auto">
<thead>
<tr class="sticky top-0 bg-white dark:bg-gray-800">
<th class="py-2 px-4 text-left text-sm font-medium text-gray-500 dark:text-gray-400">Directory</th>
<th class="py-2 px-4 text-left text-sm font-medium text-gray-500 dark:text-gray-400">File Name</th>
<th class="py-2 px-4 text-left text-sm font-medium text-gray-500 dark:text-gray-400">File Size</th>
<th class="py-2 px-4 text-left text-sm font-medium text-gray-500 dark:text-gray-400">File Type</th>
<th class="py-2 px-4 text-left text-sm font-medium text-gray-500 dark:text-gray-400">Last Modified</th>
<th class="py-2 px-4 text-left text-sm font-medium text-gray-500 dark:text-gray-400">Progress</th>
</tr>
</thead>
<tbody id="fileProgressTableBody" class="text-gray-700 dark:text-gray-300"></tbody>
</table>
</div>
</div>
</div>
<div id="loading" class="flex">
<div class="flex flex-col items-center">
<div class="animate-spin rounded-full h-20 w-20 border-t-4 border-b-4 border-blue-600"></div>
<p class="text-gray-800 dark:text-gray-200 text-xl font-semibold mt-4">Loading...</p>
</div>
</div>
<div id="removeModal" class="fixed inset-0 z-50 hidden flex items-center justify-center bg-black bg-opacity-50">
<div class="bg-white p-6 rounded-lg shadow-lg w-full max-w-sm dark:bg-gray-800">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Confirm Remove</h3>
<p class="mb-6 text-gray-600 dark:text-gray-300">Are you sure you want to remove this file?</p>
<div class="flex justify-end space-x-4">
<button id="removeYesBtnModal" class="px-4 py-2 bg-gray-500 dark:bg-gray-900 text-gray-800 dark:text-white rounded-md">Ok</button>
<button id="removeNoBtnModal" class="px-4 py-2 bg-gray-300 dark:bg-gray-700 rounded-md text-gray-800 dark:text-white">Cancel</button>
</div>
</div>
</div>
<div id="editModal" class="fixed inset-0 z-50 hidden flex items-center justify-center bg-black bg-opacity-50">
<div class="bg-white p-6 rounded-lg shadow-lg w-full max-w-sm dark:bg-gray-800">
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Confirm Rename</h3>
<div class="mb-6">
<label for="rename" class="block text-sm font-medium text-gray-700 dark:text-gray-300">name</label>
<input id="rename" name="rename" required class="w-full p-2 mt-1 text-gray-900 border border-gray-300 rounded-md shadow-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="flex justify-end space-x-4">
<button id="editYesBtnModal" class="px-4 py-2 bg-gray-500 dark:bg-gray-900 text-gray-800 dark:text-white rounded-md">Ok</button>
<button id="editNoBtnModal" class="px-4 py-2 bg-gray-300 dark:bg-gray-700 rounded-md text-gray-800 dark:text-white">Cancel</button>
</div>
</div>
</div>
<div id="bucketModal" class="fixed inset-0 z-50 hidden flex items-center justify-center bg-black bg-opacity-50">
<div class="bg-white p-6 rounded-lg shadow-lg w-full max-w-sm dark:bg-gray-800">
<form id="bucketYesBtnModal" class="space-y-6" novalidate>
<h3 class="text-xl font-semibold mb-4 text-gray-900 dark:text-gray-100">Create Bucket</h3>
<div class="mb-6">
<label for="bucket" class="block text-sm font-medium text-gray-700 dark:text-gray-300">bucket name</label>
<input id="bucket" name="bucket" required class="w-full p-2 mt-1 text-gray-900 border border-gray-300 rounded-md shadow-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="mb-6">
<label for="token" class="block text-sm font-medium text-gray-700 dark:text-gray-300">token</label>
<input id="token" name="token" required class="w-full p-2 mt-1 text-gray-900 border border-gray-300 rounded-md shadow-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="mb-6">
<label for="secret" class="block text-sm font-medium text-gray-700 dark:text-gray-300">secret</label>
<input id="secret" name="secret" required class="w-full p-2 mt-1 text-gray-900 border border-gray-300 rounded-md shadow-sm dark:bg-gray-700 dark:border-gray-600 dark:text-white focus:ring-indigo-500 focus:border-indigo-500">
</div>
<div class="flex justify-end space-x-4">
<button type="submit" class="px-4 py-2 bg-gray-500 dark:bg-gray-900 text-gray-800 dark:text-white rounded-md">Ok</button>
<button id="bucketNoBtnModal" type="button" class="px-4 py-2 bg-gray-300 dark:bg-gray-700 rounded-md text-gray-800 dark:text-white">Cancel</button>
</div>
<div id="errorMessage" class="text-red-500 text-sm hidden"></div>
</form>
</div>
</div>
</body>
<script>
const $ = (element) => document.querySelector(element);
const $$ = (element) => document.querySelectorAll(element);
const $state = {
data : {
url : `${window.location.protocol}//${window.location.host}`,
theme : () => localStorage.getItem('theme') == null ? 'dark' : localStorage.getItem('theme'),
fileUpdated : {},
path : {
selected : localStorage.getItem('lastPathDirectory') ?? '',
remove : '',
edit : ''
}
},
el : {
removeModal : $('#removeModal'),
removeYesBtnModal : $('#removeYesBtnModal'),
removeNoBtnModal : $('#removeNoBtnModal'),
editModal : $('#editModal'),
editYesBtnModal : $('#editYesBtnModal'),
editNoBtnModal : $('#editNoBtnModal'),
bucketModal : $('#bucketModal'),
bucketYesBtnModal : $('#bucketYesBtnModal'),
bucketNoBtnModal : $('#bucketNoBtnModal'),
bucketCreate : $('#bucketCreate'),
rename : $('#rename'),
fileProgressTableBody : $('#fileProgressTableBody'),
fileTableBody : $('#fileTableBody'),
dropZone : $('#dropZone'),
bucketContainer : $('#bucket-container'),
tableContainer : $('#tableContainer'),
noBucketMessage : $('#noBucketMessage'),
loading : $('#loading'),
currentPath : $('#currentPath'),
themeToggle : $('#themeToggle'),
toggleIcon : $('#toggleIcon'),
logout : $('#logout'),
bucket : $('#bucket'),
token : $('#token'),
secret : $('#secret'),
buckets : $('#buckets'),
storaged : $('#storaged'),
notifyContainer : $('#notify-container'),
errorMessage : $('#errorMessage'),
bucketToggle : $$('.bucket-toggle'),
folderToggle : $$('.folder-toggle')
}
}
function addFolderToTable(folderName) {
const rows = Array.from(fileTableBody.children)
const find = rows.find(r => {
return String(r.cells[0].textContent).trim() === folderName
})
if(find) return
showTable();
const row = document.createElement('tr');
row.innerHTML += `
<td class="px-4 py-2">
<button class="text-blue-500" type="button" target="${$state.data.path.selected}/${folderName}" onclick="onLoadFile(event)"> ${folderName} </button>
</td>
<td class="px-4 py-2">-</td>
<td class="px-4 py-2">-</td>
<td class="px-4 py-2">${new Date().toLocaleDateString()}</td>
<td class="flex gap-3 px-4 py-2">
<svg target="${$state.data.path.selected}/${folderName}" onclick="onEdit(event)"}
class="w-5 h-5 cursor-pointer hover:scale-150 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.32 6.176H5c-1.105 0-2 .949-2 2.118v10.588C3 20.052 3.895 21 5 21h11c1.105 0 2-.948 2-2.118v-7.75l-3.914 4.144A2.46 2.46 0 0 1 12.81 16l-2.681.568c-1.75.37-3.292-1.263-2.942-3.115l.536-2.839c.097-.512.335-.983.684-1.352l2.914-3.086Z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M19.846 4.318a2.148 2.148 0 0 0-.437-.692 2.014 2.014 0 0 0-.654-.463 1.92 1.92 0 0 0-1.544 0 2.014 2.014 0 0 0-.654.463l-.546.578 2.852 3.02.546-.579a2.14 2.14 0 0 0 .437-.692 2.244 2.244 0 0 0 0-1.635ZM17.45 8.721 14.597 5.7 9.82 10.76a.54.54 0 0 0-.137.27l-.536 2.84c-.07.37.239.696.588.622l2.682-.567a.492.492 0 0 0 .255-.145l4.778-5.06Z" clip-rule="evenodd"/>
</svg>
<svg target="${$state.data.path.selected}/${folderName}" onclick="onRemove(event)" }
class="w-5 h-5 cursor-pointer hover:scale-150 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M8.586 2.586A2 2 0 0 1 10 2h4a2 2 0 0 1 2 2v2h3a1 1 0 1 1 0 2v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V8a1 1 0 0 1 0-2h3V4a2 2 0 0 1 .586-1.414ZM10 6h4V4h-4v2Zm1 4a1 1 0 1 0-2 0v8a1 1 0 1 0 2 0v-8Zm4 0a1 1 0 1 0-2 0v8a1 1 0 1 0 2 0v-8Z" clip-rule="evenodd"/>
</svg>
</td>
`;
$state.el.fileTableBody.appendChild(row);
themeToggleMode()
};
function addFileToTable(file) {
const rows = Array.from(fileTableBody.children)
const find = rows.find(r => {
return String(r.cells[0].textContent).trim() === file.name
})
if(find) return
showTable();
const row = document.createElement('tr');
row.innerHTML = `
<td class="px-4 py-2">
${file.isFolder
? `<button class="text-blue-500" type="button" target="${file.path}" onclick="onLoadFile(event)"> ${file.name} </button>`
: `<button class="cursor-pointer" type="button" target="${file.path}" onclick="onPreview(event)"> ${file.name} </button>`
}
</td>
<td class="px-4 py-2">${file.size != null ? `${(file.size / 1024 / 1024).toFixed(4)}MB` : '-'} </td>
<td class="px-4 py-2">${file.isFolder ? 'folder' : file.extension}</td>
<td class="px-4 py-2">${new Date(file.lastModified).toLocaleString()}</td>
<td class="flex gap-3 px-4 py-2">
${file.isFolder
? ''
: `
<svg target="${file.path}" onclick="onPreview(event)"}
class="w-5 h-5 cursor-pointer hover:scale-150 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M4.998 7.78C6.729 6.345 9.198 5 12 5c2.802 0 5.27 1.345 7.002 2.78a12.713 12.713 0 0 1 2.096 2.183c.253.344.465.682.618.997.14.286.284.658.284 1.04s-.145.754-.284 1.04a6.6 6.6 0 0 1-.618.997 12.712 12.712 0 0 1-2.096 2.183C17.271 17.655 14.802 19 12 19c-2.802 0-5.27-1.345-7.002-2.78a12.712 12.712 0 0 1-2.096-2.183 6.6 6.6 0 0 1-.618-.997C2.144 12.754 2 12.382 2 12s.145-.754.284-1.04c.153-.315.365-.653.618-.997A12.714 12.714 0 0 1 4.998 7.78ZM12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" clip-rule="evenodd"/>
</svg>
`
}
<svg target="${file.path}" onclick="onEdit(event)"}
class="w-5 h-5 cursor-pointer hover:scale-150 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M11.32 6.176H5c-1.105 0-2 .949-2 2.118v10.588C3 20.052 3.895 21 5 21h11c1.105 0 2-.948 2-2.118v-7.75l-3.914 4.144A2.46 2.46 0 0 1 12.81 16l-2.681.568c-1.75.37-3.292-1.263-2.942-3.115l.536-2.839c.097-.512.335-.983.684-1.352l2.914-3.086Z" clip-rule="evenodd"/>
<path fill-rule="evenodd" d="M19.846 4.318a2.148 2.148 0 0 0-.437-.692 2.014 2.014 0 0 0-.654-.463 1.92 1.92 0 0 0-1.544 0 2.014 2.014 0 0 0-.654.463l-.546.578 2.852 3.02.546-.579a2.14 2.14 0 0 0 .437-.692 2.244 2.244 0 0 0 0-1.635ZM17.45 8.721 14.597 5.7 9.82 10.76a.54.54 0 0 0-.137.27l-.536 2.84c-.07.37.239.696.588.622l2.682-.567a.492.492 0 0 0 .255-.145l4.778-5.06Z" clip-rule="evenodd"/>
</svg>
<svg target="${file.path}" onclick="onRemove(event)" }
class="w-5 h-5 cursor-pointer hover:scale-150 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd" d="M8.586 2.586A2 2 0 0 1 10 2h4a2 2 0 0 1 2 2v2h3a1 1 0 1 1 0 2v12a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V8a1 1 0 0 1 0-2h3V4a2 2 0 0 1 .586-1.414ZM10 6h4V4h-4v2Zm1 4a1 1 0 1 0-2 0v8a1 1 0 1 0 2 0v-8Zm4 0a1 1 0 1 0-2 0v8a1 1 0 1 0 2 0v-8Z" clip-rule="evenodd"/>
</svg>
</td>
`;
$state.el.fileTableBody.appendChild(row);
themeToggleMode()
};
function showTable() {
$state.el.tableContainer.classList.remove('hidden');
$state.el.noBucketMessage.classList.add('hidden');
};
function hideTable() {
$state.el.tableContainer.classList.add('hidden');
$state.el.noBucketMessage.classList.remove('hidden');
};
function checkTableStatus() {
if ($state.el.fileTableBody.children.length === 0) {
hideTable();
}
};
function showLoading () {
$state.el.loading.style.display = 'flex';
}
function hideLoading () {
$state.el.loading.style.display = 'none';
}
async function sleep(ms = 500) {
return await new Promise(r => setTimeout(r, ms))
}
function updateFileProgress(file, progress) {
const rows = Array.from(fileProgressTableBody.children);
const row = rows.find(r => {
return String(r.cells[0].textContent).trim() === file.directory && String(r.cells[1].textContent).trim() === file.name
})
if(row) {
row.cells[5].innerHTML = `<progress value="${progress}" max="100"></progress> ${progress}%`
return
}
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="px-4 py-2">
<button class="text-blue-500" type="button" target="${file.directory}" onclick="onLoadFile(event)"> ${file.directory} </button>
</td>
<td class="px-4 py-2">${file.name}</td>
<td class="px-4 py-2">${file.size != null ? `${(file.size / 1024 / 1024).toFixed(4)}MB` : '-'} </td>
<td class="px-4 py-2">${file.extension}</td>
<td class="px-4 py-2">${new Date(file.lastModified).toLocaleString()}</td>
<td class="px-4 py-2"> <progress value="${progress}" max="100"></progress> ${progress}% </td>
`;
$state.el.fileProgressTableBody.appendChild(tr);
};
async function fetchUploadFile(file , path) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("POST", `${$state.data.url}/studio/api/upload`, true);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const progress = ((e.loaded / e.total) * 100).toFixed(2);
updateFileProgress({
name: file.name,
size: file.size,
extension: String(file.name)?.split('.').pop(),
lastModified: new Date().toJSON(),
directory: path.replace(`/${file.name}`, '')
}, +progress);
}
};
xhr.onload = async () => {
if (xhr.status === 200) {
notify({ type: 'success', message: `The file '${file.name}' has been uploaded.` });
return resolve(true)
}
return reject(new Error(`Upload failed with status ${xhr.status}`));
};
xhr.onerror = () => {
notify({ type: 'error', message: `The file '${file.name}' has failed to upload.` });
reject(new Error(`Network error during upload`));
};
const formData = new FormData();
formData.append("file", file);
formData.append('path', path);
xhr.send(formData);
});
};
async function fetchFiles (target) {
if(target == null || target === '') return
showLoading();
const path = normalizePath(target)
const response = await fetch(`${$state.data.url}/studio/api/files/${path}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const res = await response.json();
const files = res.files;
$state.el.fileTableBody.innerHTML = ''
await sleep(300);
showTable();
for(const file of files) {
addFileToTable(file);
}
hideLoading()
let link = []
let currentTarget = []
for(const t of target.split('/')) {
currentTarget.push(t);
link.push(`<button class="text-blue-500" target="${currentTarget.join('/')}" onclick="onLoadFile(event)"> ${t} </button>`);
}
$state.el.currentPath.innerHTML = link.join(' / ');
$state.data.path.selected = target;
localStorage.setItem('lastPathDirectory', target)
}
async function fetchBucket() {
try {
showLoading();
const response = await fetch(`${$state.data.url}/studio/api/buckets`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const folderStructure = await response.json();
await sleep(300)
hideLoading()
$state.el.bucketContainer.innerHTML = generateBucketHTML(folderStructure.buckets);
addToggleFunctionality()
} catch (error) {
hideLoading()
notify({ type : 'error' , message :error.message })
}
}
async function fetchStorage() {
try {
showLoading();
const response = await fetch(`${$state.data.url}/studio/api/storage`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const res = await response.json();
await sleep(300)
hideLoading()
$state.el.buckets.textContent = `${res.buckets ?? 0} bucket(s)`
$state.el.storaged.textContent = (res.storage?.mb ?? 0) > 1024 ? `${res.storage?.gb} GB` : `${res.storage?.mb} MB`
} catch (error) {
hideLoading()
notify({ type : 'error' , message :error.message })
}
}
async function fetchRemove(target) {
const path = normalizePath(target)
const response = await fetch(`${$state.data.url}/studio/api/files/${path}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response
}
async function fetctEdit () {
const path = normalizePath($state.data.path.edit)
const response = await fetch(`${$state.data.url}/studio/api/files/${path}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ rename : $state.el.rename.value }),
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
}
async function onLoadFile (e) {
try {
const path = normalizePath(e.currentTarget.getAttribute('target'))
await fetchFiles(path)
await notify({
type : 'success',
message : `The directory '${path}' has been loaded!`
})
} catch (error) {
await notify({ type : 'error' , message : error.message })
}
}
function onPreview(e) {
const path = normalizePath(e.currentTarget.getAttribute('target'));
window.open(`${$state.data.url}/studio/preview/${path}`, '_blank');
}
function onEdit(e) {
const path = normalizePath(e.currentTarget.getAttribute('target'));
$state.el.editModal.classList.remove('hidden');
$state.data.path.edit = path;
}
function onRemove(e) {
const path = normalizePath(e.currentTarget.getAttribute('target'));
$state.el.removeModal.classList.remove('hidden');
$state.data.path.remove = path;
};
function generateBucketHTML(folderStructure) {
let html = '';
folderStructure.forEach((dev , index) => {
Object.keys(dev).forEach(key => {
const credentials = dev[key].credentials ?? null
const storage = dev[key].storage ?? 0
html += `
<li>
<div class="grid grid-cols-12">
<div class="col-span-2 xl:col-span-1">
<button class="credentials">
<svg class="w-4 h-4 xl:w-6 xl:h-6 mr-2 cursor-copy text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="currentColor" d="M6.94318 11h-.85227l.96023-2.90909h1.07954L9.09091 11h-.85227l-.63637-2.10795h-.02272L6.94318 11Zm-.15909-1.14773h1.60227v.59093H6.78409v-.59093ZM9.37109 11V8.09091h1.25571c.2159 0 .4048.04261.5667.12784.162.08523.2879.20502.3779.35937.0899.15436.1349.33476.1349.5412 0 .20833-.0464.38873-.1392.54119-.0918.15246-.2211.26989-.3878.35229-.1657.0824-.3593.1236-.5809.1236h-.75003v-.61367h.59093c.0928 0 .1719-.0161.2372-.0483.0663-.03314.1169-.08002.152-.14062.036-.06061.054-.13211.054-.21449 0-.08334-.018-.15436-.054-.21307-.0351-.05966-.0857-.10511-.152-.13636-.0653-.0322-.1444-.0483-.2372-.0483h-.2784V11h-.78981Zm3.41481-2.90909V11h-.7898V8.09091h.7898Z"/>
<path stroke="currentColor" stroke-linejoin="round" stroke-width="2" d="M8.31818 2c-.55228 0-1 .44772-1 1v.72878c-.06079.0236-.12113.04809-.18098.07346l-.55228-.53789c-.38828-.37817-1.00715-.37817-1.39543 0L3.30923 5.09564c-.19327.18824-.30229.44659-.30229.71638 0 .26979.10902.52813.30229.71637l.52844.51468c-.01982.04526-.03911.0908-.05785.13662H3c-.55228 0-1 .44771-1 1v2.58981c0 .5523.44772 1 1 1h.77982c.01873.0458.03802.0914.05783.1366l-.52847.5147c-.19327.1883-.30228.4466-.30228.7164 0 .2698.10901.5281.30228.7164l1.88026 1.8313c.38828.3781 1.00715.3781 1.39544 0l.55228-.5379c.05987.0253.12021.0498.18102.0734v.7288c0 .5523.44772 1 1 1h2.65912c.5523 0 1-.4477 1-1v-.7288c.1316-.0511.2612-.1064.3883-.1657l.5435.2614v.4339c0 .5523.4477 1 1 1H14v.0625c0 .5523.4477 1 1 1h.0909v.0625c0 .5523.4477 1 1 1h.6844l.4952.4823c1.1648 1.1345 3.0214 1.1345 4.1863 0l.2409-.2347c.1961-.191.3053-.454.3022-.7277-.0031-.2737-.1183-.5342-.3187-.7207l-6.2162-5.7847c.0173-.0398.0342-.0798.0506-.12h.7799c.5522 0 1-.4477 1-1V8.17969c0-.55229-.4478-1-1-1h-.7799c-.0187-.04583-.038-.09139-.0578-.13666l.5284-.51464c.1933-.18824.3023-.44659.3023-.71638 0-.26979-.109-.52813-.3023-.71637l-1.8803-1.8313c-.3883-.37816-1.0071-.37816-1.3954 0l-.5523.53788c-.0598-.02536-.1201-.04985-.1809-.07344V3c0-.55228-.4477-1-1-1H8.31818Z"/>
</svg>
<p class="hidden">${key}</p>
<span class="hidden">
${
credentials == null
? ''
: `
{bucket} : ${credentials.bucket}
{token} : ${credentials.token}
{secret} : ${credentials.secret}
`
}
</span>
</button>
</div>
<div class="col-span-10 xl:col-span-11">
<button class="flex items-center justify-between w-full text-left bucket-toggle">
<h2 class="text-xl" target="${key}">${key}</h2>
<span class="text-xs xl:text-sm"> ${(storage?.mb ?? 0) > 1024 ? `${storage?.gb} GB` : `${storage?.mb} MB`} </span>
</button>
</div>
</div>
</li>`;
});
});
return html;
}
function generateFoldersAndFilesHTML(folders) {
let html = '';
folders.forEach(item => {
if (item.isFolder) {
const hasSubfolders = item.folders && item.folders.length > 0;
html += `
<li>
<button class="flex items-center justify-between w-full text-left folder-toggle">
<div class="text-md" target="${item.path}">${item.name}</div>
${hasSubfolders ? `
<svg class="arrow-icon" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 12.5l-5-5h10l-5 5z" class="arrow-down" />
</svg>` : `
<span></span>`}
</button>
<ul class="ml-4 mt-2 ${hasSubfolders ? 'hidden' : ''}">
${generateFoldersAndFilesHTML(item.folders || [])}
</ul>
</li>`;
}
});
return html;
}
function addToggleFunctionality() {
$$('.bucket-toggle').forEach(button => {
button.addEventListener('click', async function(e) {
const target = e.target.getAttribute('target');
if(target === '' || target == null || target === 'null' || target === 'undefined') {
return
}
await fetchFiles(target)
await notify({
type : 'success',
message : `The directory '${target}' has been loaded!`
})
})
})
$$('.folder-toggle').forEach(button => {
button.addEventListener('click', async function(e) {
const target = e.target.getAttribute('target');
if(target === '' || target == null || target === 'null' || target === 'undefined') {
return
}
await fetchFiles(target);
await notify({
type : 'success',
message : `The directory '${target}' has been loaded!`
})
const sublist = this.nextElementSibling;
sublist.classList.toggle('hidden');
const arrowIcon = this.querySelector('.arrow-icon path');
if(arrowIcon) {
if (sublist.classList.contains('hidden')) {
arrowIcon.setAttribute('d', 'M8 12.5l-5-5h10l-5 5z')
} else {
arrowIcon.setAttribute('d', 'M8 3.5l5 5H3l5-5z')
}
}
})
})
$$('.credentials').forEach(button => {
button.addEventListener('click', async function(e) {
const bucket = e.currentTarget.querySelector('p')
const credentials = e.currentTarget.querySelector('span')
navigator.clipboard.writeText(credentials.textContent).then(() => {
notify({
type : 'success',
message : `The '${bucket.textContent}'' credentials copied to clipboard!`
})
}).catch(err => {
notify({
type : 'error',
message : `Failed to copy text: '${err.message}'`
})
});
})
})
}
function handleDropItems(items) {
const results = [];
const promises = [];
for (let i = 0; i < items.length; i++) {
const item = items[i].webkitGetAsEntry();
if (item) {
promises.push(traverseFileTree(item, results, ''))
}
}
return Promise.all(promises).then(() => results);
}
function traverseFileTree(item, results, currentDirName) {
return new Promise(resolve => {
if (item.isFile) {
item.file(file => {
results.push({ file, directory: currentDirName })
resolve();
});
} else if (item.isDirectory) {
const dirName = item.name;
traverseDirectory(item, results, `${currentDirName}/${dirName}`).then(resolve);
} else {
resolve();
}
})
}
function traverseDirectory(directory, results, parentDirName) {
return new Promise(resolve => {
const dirReader = directory.createReader();
dirReader.readEntries(entries => {
const promises = entries.map(entry => {
if (entry.isFile) {
return new Promise(fileResolve => {
entry.file(file => {
results.push({ file, directory: parentDirName })
fileResolve();
});
});
} else if (entry.isDirectory) {
return traverseDirectory(entry, results, `${parentDirName}/${entry.name}`)
}
});
Promise.all(promises).then(resolve);
}, error => {
resolve();
});
});
}
function normalizePath (path) {
return `${path.replace(/^\/+/, '')}`;
}
async function notify({ type, message }) {
const notifyContainer = $state.el.notifyContainer
const notify = document.createElement('div');
const capitalizedType = type.charAt(0).toUpperCase() + type.slice(1);
switch(type.toLowerCase()) {
case 'success':
notify.className = 'max-w-md text-center bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded relative';
break;
case 'error':
notify.className = 'max-w-md text-center bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative';
break;
case 'info':
notify.className = 'max-w-md text-center bg-blue-100 border border-blue-400 text-blue-700 px-4 py-3 rounded relative';
break;
case 'warning':
notify.className = 'max-w-md text-center bg-yellow-100 border border-yellow-400 text-yellow-700 px-4 py-3 rounded relative';
break;
default:
notify.className = 'max-w-md text-center bg-gray-100 border border-gray-400 text-gray-700 px-4 py-3 rounded relative';
}
notify.innerHTML = `
<strong class="font-bold">${capitalizedType}</strong>
<span class="block sm:inline">${message}</span>
`;
notifyContainer.appendChild(notify);
await sleep(5000)
notify.remove()
}
function themeToggleMode() {
const theme = $state.data.theme()
const isDark = theme === 'dark'
const prefixToReplace = isDark ? 'light:' : 'dark:';
const newPrefix = isDark ? 'dark:' : 'light:';
const body = document.body;
body.className = body.className.split(' ').map(className => {
return className.startsWith(prefixToReplace)
? className.replace(prefixToReplace, newPrefix)
: className
}).join(' ');
const elements = document.body.getElementsByTagName('*');
for (let element of elements) {
if(!element.classList) continue
element.classList.forEach(className => {
if (className.startsWith(prefixToReplace)) {
element.classList.replace(className, className.replace(prefixToReplace, newPrefix));
}
})
}
}
$state.el.bucketCreate.addEventListener('click', () => {
$state.el.bucketModal.classList.remove('hidden');
})
$state.el.themeToggle.addEventListener('click', () => {
const isDark = $state.data.theme() === 'dark'
console.log(isDark,$state.data.theme() , localStorage.getItem('theme'))
localStorage.setItem('theme', isDark ? 'light' : 'dark' );
console.log(isDark,$state.data.theme() , localStorage.getItem('theme'))
$state.el.toggleIcon.innerHTML = isDark
? `
<path fill-rule="evenodd" d="M11.675 2.015a.998.998 0 0 0-.403.011C6.09 2.4 2 6.722 2 12c0 5.523 4.477 10 10 10 4.356 0 8.058-2.784 9.43-6.667a1 1 0 0 0-1.02-1.33c-.08.006-.105.005-.127.005h-.001l-.028-.002A5.227 5.227 0 0 0 20 14a8 8 0 0 1-8-8c0-.952.121-1.752.404-2.558a.996.996 0 0 0 .096-.428V3a1 1 0 0 0-.825-.985Z" clip-rule="evenodd"/>
`
: `
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5V3m0 18v-2M7.05 7.05 5.636 5.636m12.728 12.728L16.95 16.95M5 12H3m18 0h-2M7.05 16.95l-1.414 1.414M18.364 5.636 16.95 7.05M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z"/>
`
themeToggleMode()
});
$state.el.dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.stopPropagation()
})
$state.el.dropZone.addEventListener('dragleave', () => {
});
$state.el.dropZone.addEventListener('drop', async (e) => {
e.preventDefault();
e.stopPropagation();
if($state.data.path.selected === '') {
notify({
type : 'warning',
message : 'Please select a bucket to use'
})
return
}
const items = e.dataTransfer.items;
const files = await handleDropItems(items)
const folders = [...new Set(files
.filter(v => v.directory !== '')
.map(v => {
const folder = String(v.directory).split('/')[1]
return folder
}))]
for(const folder of folders) {
addFolderToTable(folder.replace(/\//g, ""))
}
const tag = +new Date()
const promises = []
for(const v of files) {
const file = v.file;
const directory = `${v.directory.replace(/^\/+/, '')}`
const path = directory !== '' ? $state.data.path.selected+ '/' + directory : $state.data.path.selected
promises.push(() => {
return fetchUploadFile(
file,
path
)
})
if(directory) continue
addFileToTable({
name : file.name,
size : file.size,
extension : String(file.name)?.split('.').pop(),
lastModified : new Date().toJSON(),
path : `${path}/${file.name}`
})
}
await Promise.allSettled(promises.map(v => v()))
await fetchBucket()
await fetchStorage()
})
$state.el.logout.addEventListener('click', async () => {
showLoading()
const response = await fetch(`${$state.data.url}/studio/api/logout`, {
method: 'DELETE'
});
await sleep(500)
hideLoading()
window.location.reload()
return
})
$state.el.removeYesBtnModal.addEventListener('click', async () => {
try {
await fetchRemove($state.data.path.remove)
notify({
type : 'success',
message : `The '${$state.data.path.remove}' has been removed`
})
await fetchBucket()
await fetchStorage()
await fetchFiles($state.data.path.selected)
} catch (error) {
notify({
type : 'error',
message : error.message
})
} finally {
$state.data.path.remove = ''
$state.el.removeModal.classList.add('hidden');
}
});
$state.el.removeNoBtnModal.addEventListener('click', () => {
$state.data.path.remove = ''
$state.el.removeModal.classList.add('hidden');
});
$state.el.editYesBtnModal.addEventListener('click', async () => {
try {
await fetctEdit()
notify({
type : 'success',
message : `The '${$state.data.path.edit}' has been renamed`
})
await fetchFiles($state.data.path.selected)
} catch (error) {
notify({
type : 'error',
message : error.message
})
} finally {
$state.el.editModal.classList.add('hidden')
$state.data.path.edit = ''
}
});
$state.el.editNoBtnModal.addEventListener('click', () => {
$state.el.editModal.classList.add('hidden');
});
$state.el.bucketYesBtnModal.addEventListener('submit', async (event) => {
try {
event.preventDefault();
showLoading();
const payload = {
bucket : $state.el.bucket.value,
token : $state.el.token.value,
secret : $state.el.secret.value
}
if([payload.bucket ,payload.token , payload.secret].some(v => v == null || v ==='')) {
throw new Error('Please enter a bucket token and secret')
}
const response = await fetch(`${$state.data.url}/studio/api/buckets`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
bucket : $state.el.bucket.value,
token : $state.el.token.value,
secret : $state.el.secret.value
}),
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
hideLoading()
$state.el.bucketModal.classList.add('hidden');
await fetchBucket()
await notify({
type : 'success',
message : 'The Bucket has been created'
})
$state.el.bucket.value = ''
$state.el.token.value = ''
$state.el.secret.value = ''
} catch (error) {
$state.el.errorMessage.textContent = error.message;
$state.el.errorMessage.classList.remove('hidden');
hideLoading()
}
});
$state.el.bucketNoBtnModal.addEventListener('click', () => {
$state.el.bucketModal.classList.add('hidden');
});
document.addEventListener('DOMContentLoaded', async () => {
checkTableStatus();
await fetchBucket();
await fetchStorage();
themeToggleMode();
if($state.data.path.selected) await fetchFiles($state.data.path.selected)
})
</script>
</html>