first commit

This commit is contained in:
MCHost
2025-06-23 22:59:27 -04:00
commit b7c2fa6d19
18 changed files with 7675 additions and 0 deletions

488
web/assets/file.js Normal file
View File

@ -0,0 +1,488 @@
const elNavBar = $('#navbar');
const elControls = $('#controls');
const elPreview = $('#preview');
const btnDownload = $('#download');
const query = new URLSearchParams(window.location.search);
let path = query.get('path');
activeConnection = connections[query.get('con')];
let fileStats = null;
let editor;
const updatePreview = async() => {
// Make sure the file is viewable
const extInfo = getFileExtInfo(path);
if (!extInfo.isViewable) {
return setStatus(`Error: File isn't viewable!`, true);
}
let fileUrl;
try {
const startTime = Date.now();
let lastUpdate = 0;
const blob = await api.request('get', 'files/get/single', {
path: path
}, null, e => {
if ((Date.now()-lastUpdate) < 100) return;
lastUpdate = Date.now();
const progress = Math.round((e.loaded / fileStats.size) * 100);
const bps = Math.round(e.loaded / ((Date.now() - startTime) / 1000));
setStatus(`Downloaded ${formatSize(e.loaded)} of ${formatSize(fileStats.size)} (${formatSize(bps)}/s)`, false, progress);
}, 'blob');
fileUrl = URL.createObjectURL(blob);
} catch (error) {
return setStatus(`Error: ${error}`, true);
}
elPreview.classList.add(extInfo.type);
const statusHtmlSegments = [];
switch (extInfo.type) {
case 'image': {
const image = document.createElement('img');
image.src = fileUrl;
await new Promise(resolve => {
image.addEventListener('load', resolve);
});
elControls.insertAdjacentHTML('beforeend', `
<button class="zoomOut btn small secondary iconOnly" title="Zoom out">
<div class="icon">zoom_out</div>
</button>
<div class="zoom">0%</div>
<button class="zoomIn btn small secondary iconOnly" title="Zoom in">
<div class="icon">zoom_in</div>
</button>
<div class="sep"></div>
<button class="fit btn small secondary iconOnly" title="Fit">
<div class="icon">fit_screen</div>
</button>
<button class="real btn small secondary iconOnly" title="Actual size">
<div class="icon">fullscreen</div>
</button>
`);
const btnZoomOut = $('.btn.zoomOut', elControls);
const btnZoomIn = $('.btn.zoomIn', elControls);
const btnFit = $('.btn.fit', elControls);
const btnReal = $('.btn.real', elControls);
const elZoom = $('.zoom', elControls);
let fitPercent = 100;
const setZoom = percent => {
const minZoom = fitPercent;
const maxZoom = 1000;
const newZoom = Math.min(Math.max(percent, minZoom), maxZoom);
elZoom.innerText = `${Math.round(newZoom)}%`;
const scaledSize = {
width: image.naturalWidth * (newZoom/100),
height: image.naturalHeight * (newZoom/100)
};
image.style.width = `${scaledSize.width}px`;
image.style.height = `${scaledSize.height}px`;
};
const changeZoom = percentChange => {
const zoom = parseInt(elZoom.innerText.replace('%', ''));
setZoom(zoom+percentChange);
};
const fitImage = () => {
const previewRect = elPreview.getBoundingClientRect();
const previewRatio = previewRect.width / previewRect.height;
const imageRatio = image.naturalWidth / image.naturalHeight;
fitPercent = 100;
if (imageRatio > previewRatio) {
fitPercent = (previewRect.width / image.naturalWidth) * 100;
} else {
fitPercent = (previewRect.height / image.naturalHeight) * 100;
}
fitPercent = Math.min(fitPercent, 100);
setZoom(fitPercent);
image.style.marginTop = '';
image.style.marginLeft = '';
};
btnZoomIn.addEventListener('click', () => {
changeZoom(10);
});
btnZoomOut.addEventListener('click', () => {
changeZoom(-10);
});
btnFit.addEventListener('click', () => {
fitImage();
});
btnReal.addEventListener('click', () => {
setZoom(100);
});
elPreview.addEventListener('wheel', e => {
if (getIsMobileDevice()) return;
e.preventDefault();
const previewRect = elPreview.getBoundingClientRect();
const relativePos = {
x: (e.clientX - previewRect.left) + elPreview.scrollLeft,
y: (e.clientY - previewRect.top) + elPreview.scrollTop
};
const percentage = {
x: relativePos.x / elPreview.scrollWidth,
y: relativePos.y / elPreview.scrollHeight
};
changeZoom(e.deltaY > 0 ? -10 : 10);
const newScroll = {
x: (elPreview.scrollWidth * percentage.x) - relativePos.x,
y: (elPreview.scrollHeight * percentage.y) - relativePos.y
};
elPreview.scrollLeft += newScroll.x;
elPreview.scrollTop += newScroll.y;
});
/*
let startTouchDistance = 0;
elPreview.addEventListener('touchstart', e => {
if (!getIsMobileDevice()) return;
if (e.touches.length == 2) {
e.preventDefault();
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.sqrt(
Math.pow(touch1.clientX - touch2.clientX, 2) +
Math.pow(touch1.clientY - touch2.clientY, 2)
);
startTouchDistance = distance;
}
});
elPreview.addEventListener('touchmove', e => {
if (!getIsMobileDevice()) return;
if (e.touches.length == 2) {
e.preventDefault();
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const distance = Math.sqrt(
Math.pow(touch1.clientX - touch2.clientX, 2) +
Math.pow(touch1.clientY - touch2.clientY, 2)
);
const percentChange = (distance - startTouchDistance) / 10;
changeZoom(percentChange);
startTouchDistance = distance;
}
});
elPreview.addEventListener('touchend', e => {
if (!getIsMobileDevice()) return;
startTouchDistance = 0;
});
*/
let startCoords = {};
let startScroll = {};
let isMouseDown = false;
elPreview.addEventListener('mousedown', e => {
if (getIsMobileDevice()) return;
e.preventDefault();
startCoords = { x: e.clientX, y: e.clientY };
startScroll = { x: elPreview.scrollLeft, y: elPreview.scrollTop };
isMouseDown = true;
elPreview.style.cursor = 'grabbing';
});
elPreview.addEventListener('dragstart', e => {
if (getIsMobileDevice()) return;
e.preventDefault();
});
elPreview.addEventListener('mousemove', e => {
if (getIsMobileDevice()) return;
e.preventDefault();
if (!isMouseDown) return;
const newScroll = {
x: startCoords.x - e.clientX + startScroll.x,
y: startCoords.y - e.clientY + startScroll.y
};
// Update preview scroll
elPreview.scrollLeft = newScroll.x;
elPreview.scrollTop = newScroll.y;
});
elPreview.addEventListener('mouseup', e => {
if (getIsMobileDevice()) return;
e.preventDefault();
isMouseDown = false;
elPreview.style.cursor = '';
});
elPreview.addEventListener('mouseleave', e => {
if (getIsMobileDevice()) return;
e.preventDefault();
isMouseDown = false;
elPreview.style.cursor = '';
});
elControls.style.display = '';
elPreview.innerHTML = '';
elPreview.appendChild(image);
statusHtmlSegments.push(`<span>${image.naturalWidth}x${image.naturalHeight}</span>`);
fitImage();
window.addEventListener('resize', fitImage);
break;
}
case 'video': {
const video = document.createElement('video');
video.src = fileUrl;
await new Promise(resolve => {
video.addEventListener('loadedmetadata', resolve);
});
video.controls = true;
elPreview.innerHTML = '';
elPreview.appendChild(video);
video.play();
statusHtmlSegments.push(`<span>${formatSeconds(video.duration)}</span>`);
statusHtmlSegments.push(`<span>${video.videoWidth}x${video.videoHeight}</span>`);
break;
}
case 'audio': {
const audio = document.createElement('audio');
audio.src = fileUrl;
await new Promise(resolve => {
audio.addEventListener('loadedmetadata', resolve);
});
audio.controls = true;
elPreview.innerHTML = '';
elPreview.appendChild(audio);
audio.play();
statusHtmlSegments.push(`<span>${formatSeconds(audio.duration)}</span>`);
break;
}
case 'markdown':
case 'text': {
// Initialize the textarea
const text = await (await fetch(fileUrl)).text();
//const textarea = document.createElement('textarea');
// Initialize CodeMirror
elPreview.innerHTML = '';
editor = CodeMirror(elPreview, {
value: text,
lineNumbers: true,
lineWrapping: true,
scrollPastEnd: true,
styleActiveLine: true,
autoCloseBrackets: true,
mode: extInfo.codeMirrorMode
});
const elEditor = $('.CodeMirror', elPreview);
// Load CodeMirror mode
if (extInfo.codeMirrorMode) {
let mode;
CodeMirror.requireMode(extInfo.codeMirrorMode, () => {}, {
path: determinedMode => {
mode = determinedMode;
return `https://codemirror.net/5/mode/${determinedMode}/${determinedMode}.js`;
}
});
CodeMirror.autoLoadMode(editor, mode);
}
// Add HTML
elControls.insertAdjacentHTML('beforeend', `
<button class="save btn small secondary" disabled>
<div class="icon">save</div>
<span>Save</span>
</button>
<button class="view btn small secondary" style="display: none">
<div class="icon">visibility</div>
<span>View</span>
</button>
<button class="edit btn small secondary" style="display: none">
<div class="icon">edit</div>
<span>Edit</span>
</button>
<div class="sep"></div>
<button class="textSmaller btn small secondary iconOnly">
<div class="icon">text_decrease</div>
</button>
<div class="textSize">18</div>
<button class="textBigger btn small secondary iconOnly">
<div class="icon">text_increase</div>
</button>
<div class="sep"></div>
<label class="selectOption">
<input type="checkbox">
<span>Word wrap</span>
</label>
`);
// Set up the save button
const btnSave = $('.btn.save', elControls);
const btnSaveText = $('span', btnSave);
btnSave.addEventListener('click', async() => {
if (btnSave.disabled) return;
btnSaveText.innerText = 'Saving...';
btnSave.disabled = true;
btnSave.classList.remove('info');
// const res1 = await api.delete('files/delete', {
// path: path
// });
const res1 = {};
const res2 = await api.post('files/create', {
path: path
//}, textarea.value);
}, editor.getValue());
if (res1.error || res2.error) {
setStatus(`Error: ${res2.error || res1.error}`, true);
btnSaveText.innerText = 'Save';
btnSave.disabled = false;
btnSave.classList.add('info');
} else {
btnSaveText.innerText = 'Saved!';
await getUpdatedStats();
setStatusWithDetails();
}
});
//textarea.addEventListener('input', () => {
editor.on('change', () => {
btnSave.disabled = false;
btnSave.classList.add('info');
btnSaveText.innerText = 'Save';
});
window.addEventListener('keydown', e => {
if (e.ctrlKey && e.code == 'KeyS') {
e.preventDefault();
btnSave.click();
}
if (e.ctrlKey && e.code == 'Minus') {
e.preventDefault();
btnTextSmaller.click();
}
if (e.ctrlKey && e.code == 'Equal') {
e.preventDefault();
btnTextBigger.click();
}
});
window.addEventListener('beforeunload', e => {
if (!btnSave.disabled) {
e.preventDefault();
e.returnValue = '';
}
});
// Set up the word wrap checkbox
const wrapCheckbox = $('input[type="checkbox"]', elControls);
wrapCheckbox.addEventListener('change', () => {
const isChecked = wrapCheckbox.checked;
//textarea.style.whiteSpace = isChecked ? 'pre-wrap' : 'pre';
editor.setOption('lineWrapping', isChecked);
window.localStorage.setItem('wrapTextEditor', isChecked);
});
wrapCheckbox.checked = window.localStorage.getItem('wrapTextEditor') == 'true';
wrapCheckbox.dispatchEvent(new Event('change'));
// Set up markdown controls
let elRendered;
if (extInfo.type == 'markdown') {
elRendered = document.createElement('div');
elRendered.classList = 'rendered';
elRendered.style.display = 'none';
const btnPreview = $('.btn.view', elControls);
const btnEdit = $('.btn.edit', elControls);
// Set up the markdown preview button
btnPreview.addEventListener('click', async() => {
btnPreview.style.display = 'none';
btnEdit.style.display = '';
elRendered.style.display = '';
//textarea.style.display = 'none';
elEditor.style.display = 'none';
//elRendered.innerHTML = marked.parse(textarea.value);
elRendered.innerHTML = marked.parse(editor.getValue());
// Make all links open in a new tab
const links = $$('a', elRendered);
for (const link of links) {
link.target = '_blank';
}
});
// Set up the markdown edit button
btnEdit.addEventListener('click', async() => {
btnPreview.style.display = '';
btnEdit.style.display = 'none';
elRendered.style.display = 'none';
//textarea.style.display = '';
elEditor.style.display = '';
});
// View file by default
btnEdit.click();
}
// Set up text size buttons
const btnTextSmaller = $('.btn.textSmaller', elControls);
const btnTextBigger = $('.btn.textBigger', elControls);
const elTextSize = $('.textSize', elControls);
let size = parseInt(window.localStorage.getItem('textEditorSize')) || 18;
const updateTextSize = () => {
//textarea.style.fontSize = `${size}px`;
elEditor.style.fontSize = `${size}px`;
elTextSize.innerText = size;
window.localStorage.setItem('textEditorSize', size);
}
updateTextSize();
btnTextSmaller.addEventListener('click', () => {
size--;
updateTextSize();
});
btnTextBigger.addEventListener('click', () => {
size++;
updateTextSize();
});
// Finalize elements
elControls.style.display = '';
//elPreview.appendChild(textarea);
if (extInfo.type == 'markdown')
elPreview.appendChild(elRendered);
break;
}
default: {
elPreview.innerHTML = `<h1 class="text-danger">Error!</h1>`;
break;
}
}
const setStatusWithDetails = () => {
setStatus(`
<div class="row flex-wrap" style="gap: 2px 20px">
<span>${formatSize(fileStats.size)}</span>
${statusHtmlSegments.join('\n')}
<span>${extInfo.mime}</span>
<span>${getRelativeDate(fileStats.modifyTime)}</span>
</div>
`)
};
setStatusWithDetails();
setTimeout(setStatusWithDetails, 60*1000);
}
const getUpdatedStats = async() => {
// Stat file
const res = await api.get('files/stat', {
path: path
});
fileStats = res.stats;
return res;
}
window.addEventListener('load', async() => {
const res = await getUpdatedStats();
if (!res.error) {
// Update navbar
path = res.path;
document.title = `${activeConnection.name} - ${path}`;
const pathSplit = path.split('/');
const folderPath = `${pathSplit.slice(0, pathSplit.length - 1).join('/')}/`;
const fileName = pathSplit[pathSplit.length - 1];
$('.path', elNavBar).innerText = folderPath;
$('.name', elNavBar).innerText = fileName;
updatePreview(fileName);
} else {
return setStatus(`Error: ${res.error}`, true);
}
});
btnDownload.addEventListener('click', async() => {
const fileName = $('.name', elNavBar).innerText;
const elSrc = $('img, video, audio', elPreview);
const elText = $('textarea', elPreview);
if (elSrc) {
console.log(`Starting download using downloaded blob`);
return downloadUrl(elSrc.src, fileName);
} else if (elText && editor) {
console.log(`Starting download using text editor value`);
const value = editor.getValue();
const dataUrl = `data:text/plain;base64,${btoa(value)}`;
return downloadUrl(dataUrl, fileName);
} else {
console.log(`Starting download using URL API`);
const url = await getFileDownloadUrl(path)
downloadUrl(url);
}
});
// Let the window finish displaying itself before saving size
setTimeout(() => {
window.addEventListener('resize', () => {
window.localStorage.setItem('viewerWidth', window.innerWidth);
window.localStorage.setItem('viewerHeight', window.innerHeight);
});
}, 2000);

2591
web/assets/index.js Normal file

File diff suppressed because it is too large Load Diff

406
web/assets/main.css Normal file
View File

@ -0,0 +1,406 @@
* {
min-width: 0px;
min-height: 0px;
}
.darkmuted {
/* Background and foreground */
--b0: hsl(215, 25%, 8%);
--b1: hsl(215, 25%, 12%);
--b2: hsl(215, 25%, 16%);
--b3: hsl(215, 25%, 20%);
--b4: hsl(215, 25%, 30%);
--b5: hsl(215, 25%, 40%);
--f4: hsl(215, 25%, 55%);
--f3: hsl(215, 25%, 70%);
--f2: hsl(215, 25%, 85%);
--f1: white;
}
.btn, .textbox {
border-radius: 8px;
}
.btn.iconOnly .icon {
margin: 0px;
}
.btn:focus-visible {
outline: 2px solid var(--f2);
}
.textbox,
.textbox.textarea > textarea {
padding: 0px 12px;
padding-top: 2px;
}
.textbox.textarea {
padding: 8px 0px;
}
.popup {
border-radius: 16px;
}
.context {
border-radius: 12px;
padding: 4px;
gap: 4px;
}
.context > .item {
border-radius: 8px;
}
label.selectOption input[type="radio"],
label.selectOption input[type="checkbox"] {
margin-top: -0.05em;
}
.tooltip {
/* padding: 6px 12px; */
padding: 8px 12px 5px 12px;
border-radius: 8px;
}
.toastOverlay > .toast > .body {
/* padding: 15px 5px; */
padding-top: 18px;
}
.popup > .body {
padding-top: 5px;
}
body {
--fontDefault: 'Comfortaa';
}
#main {
position: absolute;
top: 0px;
left: 0px;
width: 100%;
height: 100%;
overflow: hidden;
}
#navbar {
padding: 8px 16px;
border-bottom: 1px solid var(--b3);
}
#fileHeader .icon {
font-family: 'Material Symbols Filled Rounded';
font-size: 28px;
color: var(--f3);
user-select: none;
}
#fileHeader .path {
font-size: 14px;
color: var(--f4);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#fileHeader .name {
font-size: 18px;
color: var(--f1);
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#controls {
padding: 8px 16px;
border: 1px solid var(--b3);
border-width: 0px 0px 1px 0px;
overflow-x: auto;
overflow-y: hidden;
}
#controls::-webkit-scrollbar {
height: 3px;
background: transparent;
}
#controls::-webkit-scrollbar-thumb {
background: var(--b4);
}
#controls::-webkit-scrollbar-thumb:hover {
background: var(--b5);
}
#controls .sep {
width: 1px;
height: 20px;
margin: 0px 5px;
background-color: var(--b3);
}
#controls .selectOption {
font-size: 14px;
color: var(--f2);
margin-top: 5px;
}
#controls .selectOption input {
font-size: 28px;
}
#fileColHeadings {
padding: 10px 24px 6px 24px;
font-weight: bold;
color: var(--b5);
font-size: 14px;
user-select: none;
overflow-y: scroll;
scrollbar-gutter: stable;
}
#fileColHeadings.tiles {
display: none;
}
#files {
overflow-x: hidden;
overflow-y: auto;
height: 0px;
padding: 4px;
padding-top: 2px;
scrollbar-gutter: stable;
}
#files:not(.tiles) > .heading {
display: none;
}
#files.tiles > .heading {
display: block;
padding: 12px 20px 4px 20px;
font-weight: bold;
color: var(--b5);
font-size: 14px;
user-select: none;
flex-shrink: 0;
}
#files > .section {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 2px;
}
#files.tiles > .section {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
}
.fileEntry {
height: auto;
padding: 6px 20px 5px 20px;
justify-content: flex-start;
--bg: transparent;
--fg: var(--f2);
--bgHover: var(--b2);
--bgActive: var(--b3);
font-weight: normal;
text-align: left;
gap: 10px;
}
.fileEntry.search {
padding-top: 9px;
padding-bottom: 7px;
}
.fileEntry.search .nameCont .path {
margin-bottom: -2px;
}
#files.tiles .fileEntry {
height: auto;
padding-top: 11px;
padding-bottom: 9px;
gap: 12px;
}
#files:not(.showHidden) .fileEntry.hidden {
display: none;
}
.fileEntry > .icon {
color: var(--f3);
font-family: 'Material Symbols Filled Rounded';
}
#files.tiles .fileEntry > .icon {
font-size: 32px;
}
.fileEntry > .nameCont {
gap: 4px;
}
.fileEntry > .nameCont .name {
color: var(--f1);
}
.fileEntry > .nameCont .lower {
display: none;
font-size: 14px;
color: var(--f3);
}
#files.tiles .fileEntry > .nameCont .lower {
display: block;
}
.fileEntry * {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#files.tiles .fileEntry > :not(.icon):not(.nameCont) {
display: none;
}
.fileEntry > .date,
#fileColHeadings > .date {
width: 150px;
}
.fileEntry > .size,
#fileColHeadings > .size {
width: 100px;
}
.fileEntry > .perms,
#fileColHeadings > .perms {
width: 100px;
}
.fileEntry.selected {
--bg: var(--blue0);
--fg: var(--f1);
--bgHover: var(--blue1);
--bgActive: var(--blue2);
}
.fileEntry.selected > .icon,
.fileEntry.selected > .nameCont .lower {
color: var(--f1);
}
.fileEntry.hidden:not(.selected) > * {
opacity: 0.5;
}
.fileEntry.cut:not(.selected) {
opacity: 0.5;
}
.permsMatrix .header,
.permsMatrix .cell {
width: 70px;
height: 40px;
}
.permsMatrix .cell {
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.permsMatrix .header {
font-size: 14px;
color: var(--f3);
display: flex;
}
.permsMatrix .header.top {
height: 20px;
text-align: center;
justify-content: center;
padding-bottom: 3px;
}
.permsMatrix .header.left {
width: 50px;
text-align: right;
justify-content: flex-end;
align-items: center;
}
#preview {
overflow: auto;
}
#preview.image,
#preview.video {
background: black;
}
#preview.audio {
padding: 10px;
}
#preview.image {
justify-content: initial;
align-items: initial;
cursor: grab;
}
#preview img {
flex-shrink: 0;
margin: auto;
}
#preview video {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
#preview audio {
width: 500px;
}
#preview .CodeMirror {
width: 1200px;
height: 100%;
border: none;
border-left: 1px solid var(--b2);
border-right: 1px solid var(--b2);
}
#preview.markdown .rendered {
width: 1200px;
padding: 20px;
margin: auto;
}
#progressBar {
border-radius: 0px;
margin: 0px;
height: 3px;
display: none;
}
#progressBar.visible {
display: block;
}
#statusBar {
padding: 8px 10px 6px 10px;
font-size: 15px;
color: var(--f4);
border-top: 1px solid var(--b3);
line-height: 1.2;
}
#statusBar.error {
color: var(--red2);
}
#connectionManager .entry > .icon {
font-family: 'Material Symbols Outlined Rounded';
font-size: 32px;
color: var(--f3);
user-select: none;
}
#connectionManager .entry > .row {
gap: 8px 20px;
}
.moveFilesPicker .folders {
border-radius: 12px;
padding: 4px;
gap: 2px;
border: 1px solid var(--b3);
height: 300px;
overflow-y: auto;
}
/* 540px */
@media (max-width: 640px) {
.atLeast640px {
display: none;
}
}
@media (min-width: 641px) {
.atMost640px {
display: none;
}
}
/* 800px */
@media (max-width: 800px) {
.atLeast800px {
display: none;
}
}
@media (min-width: 801px) {
.atMost800px {
display: none;
}
}
/* 1000px */
@media (max-width: 1000px) {
.atLeast1000px {
display: none;
}
}
@media (min-width: 1001px) {
.atMost1000px {
display: none;
}
}

325
web/assets/main.js Normal file
View File

@ -0,0 +1,325 @@
const elProgressBar = $('#progressBar');
const elStatusBar = $('#statusBar');
const isElectron = window && window.process && window.process.type;
/**
* The hostname of the API
* @type {string}
*/
let apiHost = window.localStorage.getItem('apiHost') || window.location.host;
let isLocalhost = window.location.hostname == 'localhost';
let httpProtocol = isLocalhost ? 'http' : 'https';
let wsProtocol = httpProtocol == 'http' ? 'ws' : 'wss';
/** An object of saved connection information */
let connections = JSON.parse(window.localStorage.getItem('connections')) || {};
/** The current active connection */
let activeConnection = null;
/** The ID of the current active connection */
let activeConnectionId = null;
/**
* Checks if two HTML elements overlap
* @param {HTMLElement} el1 The first element
* @param {HTMLElement} el2 The second element
* @returns {boolean} True if the elements overlap, false otherwise
*/
function checkDoElementsOverlap(el1, el2) {
const rect1 = el1.getBoundingClientRect();
const rect2 = el2.getBoundingClientRect();
const overlap = !(rect1.right < rect2.left ||
rect1.left > rect2.right ||
rect1.bottom < rect2.top ||
rect1.top > rect2.bottom);
return overlap;
}
function permsStringToNum(str) {
let temp;
let result = '';
const user = str.substring(1, 4);
const group = str.substring(4, 7);
const other = str.substring(7, 10);
for (const perm of [user, group, other]) {
temp = 0;
if (perm.includes('r')) temp += 4;
if (perm.includes('w')) temp += 2;
if (perm.includes('x')) temp += 1;
result += temp;
}
return result;
}
const downloadUrl = (url, name) => {
const a = document.createElement('a');
a.href = url;
a.download = name || '';
a.click();
}
const getFileExtInfo = (path, size) => {
const ext = path.split('.').pop().toLowerCase();
const types = {
image: {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg',
webp: 'image/webp'
},
video: {
mp4: 'video/mp4',
webm: 'video/webm',
ogv: 'video/ogg'
},
audio: {
mp3: 'audio/mpeg',
wav: 'audio/wav'
},
text: {
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'text/javascript',
json: 'application/json',
py: 'text/x-python',
php: 'text/x-php',
java: 'text/x-java-source',
c: 'text/x-c',
cpp: 'text/x-c++',
cs: 'text/x-csharp',
rb: 'text/x-ruby',
go: 'text/x-go',
rs: 'text/x-rust',
swift: 'text/x-swift',
sh: 'text/x-shellscript',
bat: 'text/x-batch',
ps1: 'text/x-powershell',
sql: 'text/x-sql',
yaml: 'text/yaml',
yml: 'text/yaml',
ts: 'text/typescript',
properties: 'text/x-properties',
toml: 'text/x-toml',
cfg: 'text/x-properties',
conf: 'text/x-properties',
ini: 'text/x-properties',
log: 'text/x-log'
},
markdown: {
md: 'text/markdown',
markdown: 'text/markdown'
}
};
// https://codemirror.net/5/mode/index.html
// https://github.com/codemirror/codemirror5/tree/master/mode
const getKeywordsObject = keywords => {
const obj = {};
for (const word of keywords) obj[word] = true;
return obj;
}
const codeMirrorModes = {
html: 'htmlmixed',
css: 'css',
js: 'javascript',
json: {
name: 'javascript',
json: true
},
py: 'python',
php: 'php',
java: {
name: 'clike',
keywords: getKeywordsObject('abstract assert boolean break byte case catch char class const continue default do double else enum exports extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while'.split(' '))
},
c: {
name: 'clike',
keywords: getKeywordsObject('auto break case char const continue default do double else enum extern float for goto if int long register return short signed sizeof static struct switch typedef union unsigned void volatile while'.split(' '))
},
cpp: {
name: 'clike',
keywords: getKeywordsObject('asm auto break case catch char class const const_cast continue default delete do double dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace new operator private protected public register reinterpret_cast return short signed sizeof static static_cast struct switch template this throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while'.split(' ')),
useCPP: true
},
cs: {
name: 'clike',
keywords: getKeywordsObject('abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while'.split(' ')),
},
rb: 'ruby',
go: 'go',
rs: 'rust',
swift: 'swift',
sh: 'shell',
ps1: 'powershell',
sql: 'sql',
yaml: 'yaml',
yml: 'yaml',
ts: 'javascript',
properties: 'properties',
toml: 'toml',
cfg: 'properties',
conf: 'properties',
ini: 'properties',
md: 'gfm',
markdown: 'gfm'
};
const maxSizes = {
image: 1024*1024*16,
video: 1024*1024*16,
audio: 1024*1024*16,
text: 1024*1024*2,
markdown: 1024*1024*2
};
const data = { isViewable: false, type: null, mime: null };
if (!path.match(/\./g)) {
data.isViewable = true;
data.type = 'text';
data.mime = 'application/octet-stream';
data.codeMirrorMode = null;
} else {
for (const type in types) {
if (types[type][ext]) {
data.isViewable = true;
data.type = type;
data.mime = types[type][ext];
data.codeMirrorMode = codeMirrorModes[ext] || null;
break;
}
}
}
if (data.isViewable && size) {
if (size > maxSizes[data.type]) {
data.isViewable = false;
}
}
return data;
}
/**
* Returns a boolean representing if the device has limited input capabilities (no hover and coarse pointer)
*/
const getIsMobileDevice = () => {
const isPointerCoarse = window.matchMedia('(pointer: coarse)').matches;
const isHoverNone = window.matchMedia('(hover: none)').matches;
return isPointerCoarse && isHoverNone;
}
/**
* Returns an object of headers for API requests that interface with the current active server
*/
const getHeaders = () => {
const headers = {
'sftp-host': activeConnection.host,
'sftp-port': activeConnection.port,
'sftp-username': activeConnection.username
};
if (activeConnection.password)
headers['sftp-password'] = encodeURIComponent(activeConnection.password);
if (activeConnection.key)
headers['sftp-key'] = encodeURIComponent(activeConnection.key);
return headers;
}
const api = {
/**
* Makes requests to the API
* @param {'get'|'post'|'put'|'delete'} method The request method
* @param {string} url The sub-URL of an API endpoint
* @param {object|undefined} params An object of key-value query params
* @param {*} body The body of the request, if applicable
* @param {callback|undefined} onProgress A callback function that gets passed an Axios progress event
* @returns {object} An object representing the response data or error info
*/
request: async (method, url, params, body = null, onProgress = () => {}, responseType = 'json') => {
url = `${httpProtocol}://${apiHost}/api/sftp/${url}`;
try {
const opts = {
params, headers: getHeaders(),
onUploadProgress: onProgress,
onDownloadProgress: onProgress,
responseType: responseType
};
let res = null;
if (method == 'get' || method == 'delete') {
res = await axios[method](url, opts);
} else {
res = await axios[method](url, body, opts);
}
//console.log(`Response from ${url}:`, res.data);
return res.data;
} catch (error) {
if (responseType !== 'json') {
console.error(error);
return null;
}
if (error.response?.data) {
console.warn(`Error ${error.response.status} response from ${url}:`, error.response.data);
return error.response.data;
} else {
console.error(error);
return {
success: false,
error: `${error}`
};
}
}
},
get: (url, params) => api.request('get', url, params),
post: (url, params, body) => api.request('post', url, params, body),
put: (url, params, body) => api.request('put', url, params, body),
delete: (url, params) => api.request('delete', url, params)
};
/**
* Updates the bottom status bar.
* @param {string} html The status text
* @param {boolean} isError If `true`, turns the status red
* @param {number|null} progress A 0-100 whole number to be used for the progress bar, or `null` to hide it
* @returns {boolean} The negation of `isError`
*/
const setStatus = (html, isError = false, progress = null) => {
elStatusBar.innerHTML = html;
elStatusBar.classList.toggle('error', isError);
elProgressBar.classList.remove('visible');
if (progress !== null) {
elProgressBar.classList.add('visible');
if (progress >= 0 && progress <= 100)
elProgressBar.value = progress;
else
elProgressBar.removeAttribute('value');
}
return !isError;
}
/**
* Resolves with a download URL for a single file, or `false` if an error occurred.
* @param {string} path The file path
* @returns {Promise<string|boolean>}
*/
const getFileDownloadUrl = async path => {
setStatus(`Getting single file download URL...`);
const res = await api.get('files/get/single/url', {
path: path
});
if (res.error) {
return setStatus(`Error: ${res.error}`, true);
}
if (res.download_url) {
return res.download_url;
}
return false;
}
/**
* Starts a single-file download.
* @param {string} path The file path
*/
const downloadFile = async path => {
const url = await getFileDownloadUrl(path);
if (url) {
downloadUrl(url);
setStatus(`Single file download started`);
}
}

325
web/assets/main.js_ Normal file
View File

@ -0,0 +1,325 @@
const elProgressBar = $('#progressBar');
const elStatusBar = $('#statusBar');
const isElectron = window && window.process && window.process.type;
/**
* The hostname of the API
* @type {string}
*/
let apiHost = window.localStorage.getItem('apiHost') || window.location.host;
let isLocalhost = window.location.hostname == 'localhost';
let httpProtocol = isLocalhost ? 'http' : 'https';
let wsProtocol = httpProtocol == 'http' ? 'ws' : 'wss';
/** An object of saved connection information */
let connections = JSON.parse(window.localStorage.getItem('connections')) || {};
/** The current active connection */
let activeConnection = null;
/** The ID of the current active connection */
let activeConnectionId = null;
/**
* Checks if two HTML elements overlap
* @param {HTMLElement} el1 The first element
* @param {HTMLElement} el2 The second element
* @returns {boolean} True if the elements overlap, false otherwise
*/
function checkDoElementsOverlap(el1, el2) {
const rect1 = el1.getBoundingClientRect();
const rect2 = el2.getBoundingClientRect();
const overlap = !(rect1.right < rect2.left ||
rect1.left > rect2.right ||
rect1.bottom < rect2.top ||
rect1.top > rect2.bottom);
return overlap;
}
function permsStringToNum(str) {
let temp;
let result = '';
const user = str.substring(1, 4);
const group = str.substring(4, 7);
const other = str.substring(7, 10);
for (const perm of [user, group, other]) {
temp = 0;
if (perm.includes('r')) temp += 4;
if (perm.includes('w')) temp += 2;
if (perm.includes('x')) temp += 1;
result += temp;
}
return result;
}
const downloadUrl = (url, name) => {
const a = document.createElement('a');
a.href = url;
a.download = name || '';
a.click();
}
const getFileExtInfo = (path, size) => {
const ext = path.split('.').pop().toLowerCase();
const types = {
image: {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
svg: 'image/svg',
webp: 'image/webp'
},
video: {
mp4: 'video/mp4',
webm: 'video/webm',
ogv: 'video/ogg'
},
audio: {
mp3: 'audio/mpeg',
wav: 'audio/wav'
},
text: {
txt: 'text/plain',
html: 'text/html',
css: 'text/css',
js: 'text/javascript',
json: 'application/json',
py: 'text/x-python',
php: 'text/x-php',
java: 'text/x-java-source',
c: 'text/x-c',
cpp: 'text/x-c++',
cs: 'text/x-csharp',
rb: 'text/x-ruby',
go: 'text/x-go',
rs: 'text/x-rust',
swift: 'text/x-swift',
sh: 'text/x-shellscript',
bat: 'text/x-batch',
ps1: 'text/x-powershell',
sql: 'text/x-sql',
yaml: 'text/yaml',
yml: 'text/yaml',
ts: 'text/typescript',
properties: 'text/x-properties',
toml: 'text/x-toml',
cfg: 'text/x-properties',
conf: 'text/x-properties',
ini: 'text/x-properties',
log: 'text/x-log'
},
markdown: {
md: 'text/markdown',
markdown: 'text/markdown'
}
};
// https://codemirror.net/5/mode/index.html
// https://github.com/codemirror/codemirror5/tree/master/mode
const getKeywordsObject = keywords => {
const obj = {};
for (const word of keywords) obj[word] = true;
return obj;
}
const codeMirrorModes = {
html: 'htmlmixed',
css: 'css',
js: 'javascript',
json: {
name: 'javascript',
json: true
},
py: 'python',
php: 'php',
java: {
name: 'clike',
keywords: getKeywordsObject('abstract assert boolean break byte case catch char class const continue default do double else enum exports extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while'.split(' '))
},
c: {
name: 'clike',
keywords: getKeywordsObject('auto break case char const continue default do double else enum extern float for goto if int long register return short signed sizeof static struct switch typedef union unsigned void volatile while'.split(' '))
},
cpp: {
name: 'clike',
keywords: getKeywordsObject('asm auto break case catch char class const const_cast continue default delete do double dynamic_cast else enum explicit export extern false float for friend goto if inline int long mutable namespace new operator private protected public register reinterpret_cast return short signed sizeof static static_cast struct switch template this throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while'.split(' ')),
useCPP: true
},
cs: {
name: 'clike',
keywords: getKeywordsObject('abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while'.split(' ')),
},
rb: 'ruby',
go: 'go',
rs: 'rust',
swift: 'swift',
sh: 'shell',
ps1: 'powershell',
sql: 'sql',
yaml: 'yaml',
yml: 'yaml',
ts: 'javascript',
properties: 'properties',
toml: 'toml',
cfg: 'properties',
conf: 'properties',
ini: 'properties',
md: 'gfm',
markdown: 'gfm'
};
const maxSizes = {
image: 1024*1024*16,
video: 1024*1024*16,
audio: 1024*1024*16,
text: 1024*1024*2,
markdown: 1024*1024*2
};
const data = { isViewable: false, type: null, mime: null };
if (!path.match(/\./g)) {
data.isViewable = true;
data.type = 'text';
data.mime = 'application/octet-stream';
data.codeMirrorMode = null;
} else {
for (const type in types) {
if (types[type][ext]) {
data.isViewable = true;
data.type = type;
data.mime = types[type][ext];
data.codeMirrorMode = codeMirrorModes[ext] || null;
break;
}
}
}
if (data.isViewable && size) {
if (size > maxSizes[data.type]) {
data.isViewable = false;
}
}
return data;
}
/**
* Returns a boolean representing if the device has limited input capabilities (no hover and coarse pointer)
*/
const getIsMobileDevice = () => {
const isPointerCoarse = window.matchMedia('(pointer: coarse)').matches;
const isHoverNone = window.matchMedia('(hover: none)').matches;
return isPointerCoarse && isHoverNone;
}
/**
* Returns an object of headers for API requests that interface with the current active server
*/
const getHeaders = () => {
const headers = {
'sftp-host': activeConnection.host,
'sftp-port': activeConnection.port,
'sftp-username': activeConnection.username
};
if (activeConnection.password)
headers['sftp-password'] = encodeURIComponent(activeConnection.password);
if (activeConnection.key)
headers['sftp-key'] = encodeURIComponent(activeConnection.key);
return headers;
}
const api = {
/**
* Makes requests to the API
* @param {'get'|'post'|'put'|'delete'} method The request method
* @param {string} url The sub-URL of an API endpoint
* @param {object|undefined} params An object of key-value query params
* @param {*} body The body of the request, if applicable
* @param {callback|undefined} onProgress A callback function that gets passed an Axios progress event
* @returns {object} An object representing the response data or error info
*/
request: async (method, url, params, body = null, onProgress = () => {}, responseType = 'json') => {
url = `${httpProtocol}://${apiHost}/api/sftp/${url}`;
try {
const opts = {
params, headers: getHeaders(),
onUploadProgress: onProgress,
onDownloadProgress: onProgress,
responseType: responseType
};
let res = null;
if (method == 'get' || method == 'delete') {
res = await axios[method](url, opts);
} else {
res = await axios[method](url, body, opts);
}
//console.log(`Response from ${url}:`, res.data);
return res.data;
} catch (error) {
if (responseType !== 'json') {
console.error(error);
return null;
}
if (error.response?.data) {
console.warn(`Error ${error.response.status} response from ${url}:`, error.response.data);
return error.response.data;
} else {
console.error(error);
return {
success: false,
error: `${error}`
};
}
}
},
get: (url, params) => api.request('get', url, params),
post: (url, params, body) => api.request('post', url, params, body),
put: (url, params, body) => api.request('put', url, params, body),
delete: (url, params) => api.request('delete', url, params)
};
/**
* Updates the bottom status bar.
* @param {string} html The status text
* @param {boolean} isError If `true`, turns the status red
* @param {number|null} progress A 0-100 whole number to be used for the progress bar, or `null` to hide it
* @returns {boolean} The negation of `isError`
*/
const setStatus = (html, isError = false, progress = null) => {
elStatusBar.innerHTML = html;
elStatusBar.classList.toggle('error', isError);
elProgressBar.classList.remove('visible');
if (progress !== null) {
elProgressBar.classList.add('visible');
if (progress >= 0 && progress <= 100)
elProgressBar.value = progress;
else
elProgressBar.removeAttribute('value');
}
return !isError;
}
/**
* Resolves with a download URL for a single file, or `false` if an error occurred.
* @param {string} path The file path
* @returns {Promise<string|boolean>}
*/
const getFileDownloadUrl = async path => {
setStatus(`Getting single file download URL...`);
const res = await api.get('files/get/single/url', {
path: path
});
if (res.error) {
return setStatus(`Error: ${res.error}`, true);
}
if (res.download_url) {
return res.download_url;
}
return false;
}
/**
* Starts a single-file download.
* @param {string} path The file path
*/
const downloadFile = async path => {
const url = await getFileDownloadUrl(path);
if (url) {
downloadUrl(url);
setStatus(`Single file download started`);
}
}