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`);
}
}

60
web/file.html Normal file
View File

@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<title>File Viewer</title>
<meta name="description" content="Connect to and manage files on your SFTP server with ease!">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1f2733">
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/icon.png">
<link rel="stylesheet" href="https://src.simplecyber.org/lib/codemirror5.css">
<link rel="stylesheet" href="https://src.simplecyber.org/v2/themes.css">
<link rel="stylesheet" href="https://src.simplecyber.org/v2/base.css">
<link rel="stylesheet" href="/assets/main.css">
<script defer src="https://src.simplecyber.org/lib/axios.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/tabbable.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/focus-trap.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/dayjs.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/marked.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/codemirror5.js"></script>
<script defer src="https://src.simplecyber.org/lib/codemirror5-scrollPastEnd.js"></script>
<script defer src="https://src.simplecyber.org/lib/codemirror5-activeLine.js"></script>
<script defer src="https://src.simplecyber.org/lib/codemirror5-loadMode.js"></script>
<script defer src="https://src.simplecyber.org/lib/codemirror5-closeBrackets.js"></script>
<script defer src="https://src.simplecyber.org/lib/codemirror5-overlay.js"></script>
<script defer src="https://src.simplecyber.org/v2/base.js"></script>
<script defer src="https://src.simplecyber.org/utils.js"></script>
<script defer src="/assets/main.js"></script>
<script defer src="/assets/file.js"></script>
</head>
<body class="darkmuted">
<div id="main" class="col">
<div id="navbar" class="row gap-20 align-center flex-no-shrink">
<button class="btn secondary iconOnly" onClick="window.close()" title="Close">
<div class="icon">close</div>
</button>
<div id="fileHeader" class="row gap-10 flex-grow align-center">
<div class="icon flex-no-shrink">insert_drive_file</div>
<div class="col gap-2">
<div class="path"></div>
<div class="name"></div>
</div>
</div>
<button id="download" class="btn iconOnly" title="Download file">
<div class="icon">download</div>
</button>
</div>
<div id="controls" class="row gap-10 align-center flex-no-shrink" style="display: none"></div>
<div id="preview" class="row flex-grow align-center justify-center">
<div class="spinner" style="margin: auto"></div>
</div>
<progress id="progressBar" min="0" max="100" value="0"></progress>
<div id="statusBar" class="row align-center flex-no-shrink">
Loading file...
</div>
</div>
</body>
</html>

BIN
web/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

143
web/index.html Normal file
View File

@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<title>SFTP Browser</title>
<meta name="description" content="Connect to and manage files on your SFTP server with ease!">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#1f2733">
<link rel="manifest" href="/manifest.json" />
<link rel="icon" href="/icon.png">
<link rel="stylesheet" href="https://src.simplecyber.org/v2/themes.css">
<link rel="stylesheet" href="https://src.simplecyber.org/v2/base.css">
<link rel="stylesheet" href="/assets/main.css">
<script defer src="https://src.simplecyber.org/lib/axios.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/tabbable.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/focus-trap.min.js"></script>
<script defer src="https://src.simplecyber.org/lib/dayjs.min.js"></script>
<script defer src="https://src.simplecyber.org/v2/base.js"></script>
<script defer src="https://src.simplecyber.org/utils.js"></script>
<script defer src="/assets/main.js"></script>
<script defer src="/assets/index.js"></script>
</head>
<body class="darkmuted">
<div id="main" class="col">
<div id="navbar" class="row gap-20 align-center flex-no-shrink">
<button id="connections" class="btn" title="Connections...<br><small>Ctrl + Shift + Space</small>">
<div class="icon">public</div>
<div class="icon" style="margin-top: 1px">expand_more</div>
</button>
<div id="inputPathCont" class="atLeast640px row gap-10 flex-grow">
<button id="navBack" class="btn iconOnly tertiary" title="Back<br><small>Alt + ArrowLeft</small>" disabled>
<div class="icon">arrow_back</div>
</button>
<button id="navForward" class="btn iconOnly tertiary" title="Forward<br><small>Alt + ArrowRight</small>" disabled>
<div class="icon">arrow_forward</div>
</button>
<input type="text" id="inputNavPath" class="textbox" placeholder="Enter a path...">
<button id="pathGo" class="btn iconOnly secondary" title="Go/Reload<br><small>Ctrl + R</small>">
<div class="icon">refresh</div>
</button>
</div>
<div id="inputSearchCont" class="atLeast1000px row gap-10" style="width: 320px">
<div class="row align-center flex-grow">
<input type="text" id="inputNavSearch" class="textbox" placeholder="Search within folder..." style="padding-right: calc(3px + 34px + 3px)">
<button id="navSearchCancel" class="btn small tertiary iconOnly" style="margin-left: calc(-34px - 3px)">
<div class="icon">close</div>
</button>
</div>
<button id="navSearchGo" class="btn iconOnly" title="Search">
<div class="icon">search</div>
</button>
</div>
<button id="pathPopup" class="btn secondary atMost640px" title="Go to folder...">
<div class="icon">folder_open</div>
<div class="icon" style="margin-top: 0px">expand_more</div>
</button>
</div>
<div class="col flex-grow">
<div id="controls" class="row gap-10 align-center flex-no-shrink">
<div class="row gap-10 align-center flex-no-shrink atLeast800px">
<button id="upload" class="btn small iconOnly secondary" title="Upload files...<br><small>Shift + U</small>" disabled>
<div class="icon">upload</div>
</button>
<button id="dirCreate" class="btn small iconOnly secondary" title="New folder...<br><small>Shift + N</small>" disabled>
<div class="icon">create_new_folder</div>
</button>
<button id="fileCreate" class="btn small iconOnly secondary" title="New file..." disabled>
<div class="icon">post_add</div>
</button>
<div class="sep"></div>
<button id="fileCut" class="btn small iconOnly secondary" title="Cut<br><small>Ctrl + X</small>" disabled>
<div class="icon">cut</div>
</button>
<button id="fileCopy" class="btn small iconOnly secondary" title="Copy<br><small>Ctrl + C</small>" disabled>
<div class="icon">file_copy</div>
</button>
<button id="filePaste" class="btn small iconOnly secondary" title="Paste<br><small>Ctrl + V</small>" disabled>
<div class="icon">content_paste</div>
</button>
<div class="sep"></div>
<button id="fileRename" class="btn small iconOnly secondary" title="Rename...<br><small>F2</small>" disabled>
<div class="icon">edit_note</div>
</button>
<button id="fileMoveTo" class="btn small iconOnly secondary" title="Move to...<br><small>Shift + M</small>" disabled>
<div class="icon">drive_file_move</div>
</button>
<button id="fileCopyTo" class="btn small iconOnly secondary" title="Copy to...<br><small>Shift + C</small>" disabled>
<div class="icon">move_group</div>
</button>
<button id="fileDelete" class="btn small iconOnly secondary" title="Delete...<br><small>Del</small>" disabled>
<div class="icon" style="color: var(--red2)">delete</div>
</button>
<button id="filePerms" class="btn small iconOnly secondary" title="Edit permissions..." disabled>
<div class="icon">admin_panel_settings</div>
</button>
<div class="sep"></div>
<button id="fileDownload" class="btn small iconOnly secondary" title="Download<br><small>Shift + D</small>" disabled>
<div class="icon">download</div>
</button>
<button id="fileShare" class="btn small iconOnly secondary" title="Copy download link..." disabled>
<div class="icon">share</div>
</button>
</div>
<button id="dirMenu" class="btn small secondary atMost800px" title="File...">
File
<div class="icon" style="margin-top: 1px">expand_more</div>
</button>
<button id="deselectAll" class="btn small iconOnly secondary atMost800px" title="Deselect all" style="display: none">
<div class="icon">close</div>
</button>
<div class="sep"></div>
<button id="dirView" class="btn small secondary" title="View...">
<div class="icon">visibility</div>
<div class="icon" style="margin-top: 1px">expand_more</div>
</button>
<button id="dirSort" class="btn small secondary" title="Sort..." disabled>
<div class="icon">sort</div>
<div class="icon" style="margin-top: 1px">expand_more</div>
</button>
<div class="row gap-10 align-center flex-no-shrink atLeast800px">
<button id="dirSelection" class="btn small secondary" title="Selection...">
<div class="icon" style="margin-top: 0px">select</div>
<div class="icon" style="margin-top: 1px">expand_more</div>
</button>
</div>
</div>
<div id="fileColHeadings" class="row gap-10">
<div class="name flex-grow">Name</div>
<div class="date">Modified</div>
<div class="size">Size</div>
<div class="perms">Permissions</div>
</div>
<div id="files" class="col flex-grow gap-2"></div>
<progress id="progressBar" min="0" max="100" value="0"></progress>
<div id="statusBar" class="row align-center flex-no-shrink">
Waiting for connection...
</div>
</div>
</div>
</body>
</html>

18
web/manifest.json Normal file
View File

@ -0,0 +1,18 @@
{
"id": "/",
"start_url": "/",
"scope": "/",
"name": "SFTP Browser",
"short_name": "SFTP",
"description": "Manage files on your SFTP server with ease!",
"categories": [ "utilities", "productivity" ],
"icons": [{
"src": "/icon.png",
"size": "256x256",
"type": "image/png",
"purpose": "maskable any"
}],
"background_color": "#1f2733",
"theme_color": "#1f2733",
"display": "standalone"
}

29
web/worker.js Normal file
View File

@ -0,0 +1,29 @@
self.addEventListener('activate', (e) => {
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
const reqUrl = e.request.url;
e.respondWith((async() => {
// Open asset cache and see if this request is in it
const cache = await caches.open('main');
const match = await caches.match(e.request);
// Request the resource from the network
const netRes = fetch(e.request).then((res) => {
// If the request was successful and this isn't an API call,
// update the cached resource
if (res.ok && !reqUrl.match(/\/api\/sftp\/.*$/)) {
cache.put(e.request, res.clone());
}
// Return the response
return res;
}).catch(e => {
console.error(e);
return match;
});
// Return the cached resource if it exists
// Otherwise, return the network request
return match || netRes;
})());
});