droppy
Version:
Self-hosted file storage
1,532 lines (1,373 loc) • 103 kB
JavaScript
"use strict";
function promisify(fn) {
return function() {
return new Promise(resolve => {
fn(result => resolve(result));
});
};
}
const droppy = Object.create(null);
/* {{ templates }} */
initVariables();
// ============================================================================
// Feature Detects
// ============================================================================
droppy.detects = {
directoryUpload: (function() {
const el = document.createElement("input");
return droppy.dir.some((prop) => {
return prop in el;
});
})(),
audioTypes: (function() {
const types = {}, el = document.createElement("audio");
Object.keys(droppy.audioTypes).forEach((type) => {
types[droppy.audioTypes[type]] = Boolean(el.canPlayType(droppy.audioTypes[type]).replace(/no/, ""));
});
return types;
})(),
videoTypes: (function() {
const types = {}, el = document.createElement("video");
Object.keys(droppy.videoTypes).forEach((type) => {
types[droppy.videoTypes[type]] = Boolean(el.canPlayType(droppy.videoTypes[type]).replace(/no/, ""));
});
return types;
})(),
webp: document.createElement("canvas").toDataURL("image/webp").indexOf("data:image/webp") === 0,
notification: "Notification" in window,
mobile: /Mobi/.test(navigator.userAgent),
safari: /Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent),
};
// Transition of freshly inserted elements
$.fn.transition = function(oldClass, newClass) {
if (!newClass) { newClass = oldClass; oldClass = null; }
// Force a reflow
// https://gist.github.com/paulirish/5d52fb081b3570c81e3a
this.r = this[0].offsetTop;
delete this.r;
if (oldClass) {
this.replaceClass(oldClass, newClass);
} else {
this.addClass(newClass);
}
return this;
};
// transitionend helper, makes sure the callback gets fired regardless if the transition gets cancelled
$.fn.transitionend = function(callback) {
if (!this.length) return;
let duration, called = false;
const el = this[0];
function doCallback(event) {
if (called) return;
called = true;
callback.apply(el, event);
}
duration = getComputedStyle(el).transitionDuration;
duration = (duration.includes("ms")) ? parseFloat(duration) : parseFloat(duration) * 1000;
setTimeout(() => { // Call back if "transitionend" hasn't fired in duration + 30
doCallback({target: el}); // Just mimic the event.target property on our fake event
}, duration + 30);
return this.one("transitionend", doCallback);
};
// Class swapping helper
$.fn.replaceClass = function(search, replacement) {
let el, classes, matches, i = this.length, hasClass = false;
while (--i >= 0) {
el = this[i];
if (el === undefined) return false;
classes = el.className.split(" ").filter((className) => {
if (className === search) return false;
if (className === replacement) hasClass = true;
matches = search instanceof RegExp ? search.exec(className) : className.match(search);
// filter out if the entire capture matches the entire className
if (matches) return matches[0] !== className || matches[0] === replacement;
else return true;
});
if (!hasClass) classes.push(replacement);
if (classes.length === 0 || (classes.length === 1 && classes[0] === "")) {
el.removeAttribute("class");
} else {
el.className = classes.join(" ");
}
}
return this;
};
Handlebars.registerHelper("select", function(sel, opts) {
return opts.fn(this).replace(new RegExp(` value="${sel}"`), "$& selected=");
});
Handlebars.registerHelper("is", function(a, b, opts) {
return a === b ? opts.fn(this) : opts.inverse(this);
});
function svg(which) {
// Manually clone instead of <use> because of a weird bug with media arrows in Firefox
const svg = document.getElementById(`i-${which}`).cloneNode(true);
svg.setAttribute("class", svg.id.replace("i-", ""));
svg.removeAttribute("id");
// Edge doesn't support outerHTML on SVG
const html = svg.outerHTML || document.createElement("div").appendChild(svg).parentNode.innerHTML;
return html.replace(/(?!<\/)?symbol/g, "svg");
}
Handlebars.registerHelper("svg", svg);
if (droppy.detects.mobile) {
document.documentElement.classList.add("mobile");
}
if (droppy.detects.webp) {
droppy.imageTypes.webp = "image/webp";
}
// ============================================================================
// localStorage wrapper functions
// ============================================================================
let prefs, doSave;
const defaults = {
volume: .5,
theme: "droppy",
editorFontSize: droppy.detects.mobile ? 12 : 16,
indentWithTabs: false,
indentUnit: 4,
lineWrapping: false,
loop: true,
autonext: false,
sharelinkDownload: true,
sortings: {},
};
function savePrefs(prefs) {
try {
localStorage.setItem("prefs", JSON.stringify(prefs));
} catch (err) {
console.error(err);
}
}
function loadPrefs() {
let prefs;
try {
prefs = JSON.parse(localStorage.getItem("prefs"));
} catch {}
return prefs || defaults;
}
// Load prefs and set missing ones to their default
prefs = loadPrefs();
Object.keys(defaults).forEach((pref) => {
if (prefs[pref] === undefined) {
doSave = true;
prefs[pref] = defaults[pref];
}
});
if (doSave) savePrefs(prefs);
droppy.get = function(pref) {
prefs = loadPrefs();
return prefs[pref];
};
droppy.set = function(pref, value) {
prefs[pref] = value;
savePrefs(prefs);
};
droppy.del = function(pref) {
delete prefs[pref];
savePrefs(prefs);
};
// ============================================================================
// Entry point
// ============================================================================
const type = document.body.dataset.type;
if (type === "m") {
render("main");
initMainPage();
} else {
render("login", {first: type === "f"});
initAuthPage(type === "f");
}
// ============================================================================
// <main> renderer
// ============================================================================
function render(page, args) {
$("main").replaceWith(Handlebars.templates[page](args));
}
// ============================================================================
// View handling
// ============================================================================
function getView(id) {
return $(droppy.views[id]);
}
function getOtherViews(id) {
return $(droppy.views.filter((_, i) => { return i !== id; }));
}
function getActiveView() {
return $(droppy.views[droppy.activeView]);
}
function newView(dest, vId) {
const view = $(Handlebars.templates.view());
getView(vId).remove();
droppy.views[vId] = view[0];
if (droppy.views.length > 1) {
droppy.views.forEach((view) => {
$(view).addClass(view.vId === 0 ? "left" : "right");
$(view).find(".newview svg").replaceWith(svg("window-cross"));
$(view).find(".newview")[0].setAttribute("aria-label", "Close this view");
});
}
view.appendTo("main");
view[0].vId = vId;
view[0].uploadId = 0;
if (dest) updateLocation(view, dest);
initButtons(view);
bindDropEvents(view);
bindHoverEvents(view);
allowDrop(view);
checkClipboard();
droppy.views.forEach((view) => {
checkPathOverflow($(view));
if (view.ps) view.ps.updateSize(true);
});
return getView(vId);
}
function destroyView(vId) {
getView(vId).remove();
droppy.views = droppy.views.filter((_, i) => { return i !== vId; });
droppy.views.forEach((view) => {
$(view).removeClass("left right");
$(view).find(".newview svg").replaceWith(svg("window"));
$(view).find(".newview")[0].setAttribute("aria-label", "Create new view");
checkPathOverflow($(view));
view.vId = 0;
if (view.ps) view.ps.updateSize(true);
});
sendMessage(vId, "DESTROY_VIEW");
}
// ============================================================================
// WebSocket handling
// ============================================================================
function init() {
droppy.wsRetries = 5; // reset retries on connection loss
if (!droppy.initialized) sendMessage(null, "REQUEST_SETTINGS");
if (droppy.queuedData) {
sendMessage();
} else {
// Create new view with initializing
getLocationsFromHash().forEach((string, index) => {
const dest = join(decodeURIComponent(string));
newView(dest, index);
});
}
}
function openSocket() {
droppy.socket = new WebSocket(
`${window.location.origin.replace(/^http/, "ws") + window.location.pathname}!/socket`
);
droppy.socket.addEventListener("open", (_event) => {
if (droppy.token) {
init();
} else {
ajax({url: "!/token", headers: {"x-app": "droppy"}}).then((res) => {
return res.text();
}).then((text) => {
droppy.token = text;
init();
});
}
});
// https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent#Close_codes
droppy.socket.addEventListener("close", (event) => {
if (event.code === 4000) return;
if (event.code === 1011) {
droppy.token = null;
openSocket();
} else if (event.code >= 1001 && event.code < 3999) {
if (droppy.wsRetries > 0) {
// Gracefully reconnect on abnormal closure of the socket, 1 retry every 4 seconds, 20 seconds total.
// TODO: Indicate connection drop in the UI, especially on close code 1006
setTimeout(() => {
openSocket();
droppy.wsRetries--;
}, droppy.wsRetryTimeout);
}
} else if (droppy.reopen) {
droppy.reopen = false;
openSocket();
}
});
droppy.socket.addEventListener("message", (event) => {
const msg = JSON.parse(event.data);
const vId = msg.vId;
const view = getView(vId);
droppy.socketWait = false;
switch (msg.type) {
case "UPDATE_DIRECTORY": {
if (typeof view[0].dataset.type === "undefined" || view[0].switchRequest) {
view[0].dataset.type = "directory"; // For initial loading
}
if (!view.length) return;
if (view[0].dataset.type === "directory") {
if (msg.folder !== getViewLocation(view)) {
view[0].currentFile = null;
view[0].currentFolder = msg.folder;
if (view[0].vId === 0) setTitle(basename(msg.folder));
replaceHistory(view, join(view[0].currentFolder, view[0].currentFile));
updatePath(view);
}
view[0].switchRequest = false;
view[0].currentData = msg.data;
openDirectory(view, view[0].currentData);
} else if (view[0].dataset.type === "media") {
view[0].currentData = msg.data;
// TODO: Update media array
}
break;
}
case "UPDATE_BE_FILE": {
openFile(getView(vId), msg.folder, msg.file);
break;
}
case "RELOAD": {
if (msg.css) {
$("#css").remove();
$(`<style id='css'>${msg.css}</style>`).appendTo($("head"));
} else window.location.reload(true);
break;
}
case "SHARELINK": {
hideSpinner(view);
if (view.find(".info-box.link.in").length) {
view.find(".link-out")[0].textContent = getFullLink(msg.link);
} else {
showLink(view, msg.link, msg.attachement);
}
break;
}
case "USER_LIST": {
updateUsers(msg.users);
break;
}
case "SAVE_STATUS": {
hideSpinner(view);
const file = view.find(".path li:last-child");
file.removeClass("dirty").addClass(msg.status === 0 ? "saved" : "save-failed");
setTimeout(() => {
file.removeClass("saved save-failed");
}, 3000);
break;
}
case "SETTINGS": {
Object.keys(msg.settings).forEach((setting) => {
droppy[setting] = msg.settings[setting];
});
$("#about-title")[0].textContent = `droppy ${droppy.version}`;
$("#about-engine")[0].textContent = droppy.engine;
droppy.themes = droppy.themes.split("|");
droppy.modes = droppy.modes.split("|");
// Move own theme to top of theme list
droppy.themes.pop();
droppy.themes.unshift("droppy");
// Insert plain mode on the top
droppy.modes.unshift("plain");
if (droppy.dev) {
window.droppy = droppy;
}
if (droppy.readOnly) {
document.documentElement.classList.add("readonly");
}
if (droppy.public) {
document.documentElement.classList.add("public");
}
if (!droppy.watch) {
document.documentElement.classList.add("nowatch");
}
break;
}
case "MEDIA_FILES": {
loadMedia(view, msg.files);
break;
}
case "SEARCH_RESULTS": {
openDirectory(view, msg.results, true);
break;
}
case "ERROR": {
showError(view, msg.text);
hideSpinner(view);
break;
}
}
});
}
function sendMessage(vId, type, data) {
const sendObject = {vId, type, data, token: droppy.token};
if (typeof sendObject.data === "string") {
sendObject.data = normalize(sendObject.data);
} else if (typeof sendObject.data === "object") {
Object.keys(sendObject.data).forEach((key) => {
if (typeof sendObject.data[key] === "string") {
sendObject.data[key] = normalize(sendObject.data[key]);
}
});
}
const json = JSON.stringify(sendObject);
if (droppy.socket.readyState === 1) { // open
// Lock the UI while we wait for a socket response
droppy.socketWait = true;
// Unlock the UI in case we get no socket resonse after waiting for 1 second
setTimeout(() => {
droppy.socketWait = false;
}, 1000);
if (droppy.queuedData) {
droppy.socket.send(droppy.queuedData);
droppy.queuedData = null;
}
droppy.socket.send(json);
} else {
// We can't send right now, so queue up the last added message to be sent later
droppy.queuedData = json;
if (droppy.socket.readyState === 2) { // closing
// Socket is closing, queue a re-opening
droppy.reopen = true;
} else if (droppy.socket.readyState === 3) { // closed
// Socket is closed, we can re-open it right now
openSocket();
}
}
}
// ============================================================================
// Authentication page
// ============================================================================
function initAuthPage(firstrun) {
$("#remember").off("click").on("click", function() {
$(this).toggleClass("checked");
});
$("#form").off("submit").on("submit", (e) => {
e.preventDefault();
ajax({
method: "POST",
url: firstrun ? "!/adduser" : "!/login",
data: {
username: $("#user")[0].value,
password: $("#pass")[0].value,
remember: $("#remember").hasClass("checked"),
path: getRootPath(),
}
}).then((res) => {
if (res.status === 200) {
render("main");
initMainPage();
} else {
const info = $("#login-info-box");
info.textContent = firstrun ? "Please fill both fields." : "Wrong login!";
if (info.hasClass("error")) {
info.addClass("shake");
setTimeout(() => {
info.removeClass("shake");
}, 500);
} else info[0].className = "error";
if (!firstrun) $("#pass")[0].focus();
}
});
});
}
// ============================================================================
// Main page
// ============================================================================
function initMainPage() {
droppy.initialized = false;
// Open the WebSocket
openSocket();
// Re-fit path line after 25ms of no resizing
$(window).off("resize").on("resize", () => {
clearTimeout(droppy.resizeTimer);
droppy.resizeTimer = setTimeout(() => {
$(".view").each(function() {
checkPathOverflow($(this));
});
}, 25);
});
Mousetrap.bind("escape", () => { // escape hides modals
toggleCatcher(false);
}).bind("mod+s", (e) => { // stop default browser save behaviour
e.preventDefault();
}).bind(["space", "down", "right", "return"], () => {
const view = getActiveView();
if (view[0].dataset.type === "media") view[0].ps.next();
}).bind(["shift+space", "up", "left", "backspace"], () => {
const view = getActiveView();
if (view[0].dataset.type === "media") view[0].ps.prev();
}).bind(["alt+enter", "f"], () => {
const view = getActiveView();
if (!view || view[0].dataset.type !== "media") return;
screenfull.toggle(view.find(".content")[0]);
});
// track active view
$(window).on("click dblclick contextmenu", (e) => {
const view = $(e.target).parents(".view");
if (!view.length) return;
droppy.activeView = view[0].vId;
toggleButtons(view, view[0].dataset.type);
});
// handle pasting text and images in directory view
window.addEventListener("paste", async e => {
const view = getActiveView();
if (view[0].dataset.type !== "directory") return;
if (e.clipboardData && e.clipboardData.items) { // modern browsers
const texts = [];
const images = [];
for (const item of e.clipboardData.items) {
if (item.kind === "file" || item.type.includes("image")) {
images.push(item.getAsFile());
}
}
// this API is weirdly implemented in Chrome. A pasted image consists of two items,
// if the text item is read first, the image item will not be available.
for (const item of e.clipboardData.items) {
if (item.kind === "string") {
const text = await promisify(item.getAsString.bind(item))();
texts.push(new Blob([text], {type: "text/plain"}));
}
}
// if a image is found, don't upload additional text blobs
if (images.length) {
images.forEach(image => uploadBlob(view, image));
} else {
texts.forEach((text) => uploadBlob(view, text));
}
} else if (e.clipboardData.types) { // Safari specific
if (e.clipboardData.types.includes("text/plain")) {
const blob = new Blob([e.clipboardData.getData("Text")], {type: "text/plain"});
uploadBlob(view, blob);
$(".ce").empty();
if (droppy.savedFocus) droppy.savedFocus.focus();
} else {
const start = performance.now();
(function findImages() {
const images = $(".ce img");
if (!images.length && performance.now() - start < 5000) {
return setTimeout(findImages, 25);
}
images.each(function() {
urlToPngBlob(this.src, (blob) => {
uploadBlob(view, blob);
$(".ce").empty();
if (droppy.savedFocus) droppy.savedFocus.focus();
});
});
})();
}
}
});
// Hacks for Safari to be able to paste
if (droppy.detects.safari) {
$("body").append('<div class="ce" contenteditable>');
window.addEventListener("keydown", (e) => {
if (e.metaKey && e.key === "v") {
if (e.target.nodeName.toLowerCase() !== "input") {
droppy.savedFocus = document.activeElement;
$(".ce")[0].focus();
}
}
});
}
screenfull.on("change", () => {
// unfocus the fullscreen button so the space key won't un-toggle fullscreen
document.activeElement.blur();
$("svg.fullscreen, svg.unfullscreen").replaceWith(
svg(screenfull.isFullscreen ? "unfullscreen" : "fullscreen")
);
});
initEntryMenu();
}
// ============================================================================
// Upload functions
// ============================================================================
function uploadBlob(view, blob) {
const fd = new FormData();
let name = "pasted-";
if (blob.type.startsWith("image")) {
name += `image-${dateFilename()}.${imgExtFromMime(blob.type)}`;
} else {
name += `text-${dateFilename()}.txt`;
}
fd.append("files[]", blob, name);
upload(view, fd, [name]);
}
function upload(view, fd, files) {
let rename = false;
if (view[0].currentData && Object.keys(view[0].currentData).length) {
let conflict = false;
const existingFiles = Object.keys(view[0].currentData);
files.some((file) => {
if (existingFiles.includes(file)) {
conflict = true;
return true;
}
});
if (conflict) {
rename = !window.confirm("Some of the uploaded files already exist. Overwrite them?");
}
}
if (!files || !files.length) return showError(view, "Unable to upload.");
const id = view[0].uploadId += 1;
const xhr = new XMLHttpRequest();
// Render upload bar
$(Handlebars.templates["upload-info"]({
id,
title: files.length === 1 ? basename(files[0]) : `${files.length} files`,
})).appendTo(view).transition("in").find(".upload-cancel").off("click").on("click", () => {
xhr.abort();
uploadCancel(view, id);
});
// Create the XHR2 and bind the progress events
xhr.upload.addEventListener("progress", throttle(e => {
if (e && e.lengthComputable) uploadProgress(view, id, e.loaded, e.total);
}, 100));
xhr.upload.addEventListener("error", () => {
showError(view, "An error occurred during upload.");
uploadCancel(view, id);
});
xhr.addEventListener("readystatechange", () => {
if (xhr.readyState !== 4) return;
if (xhr.status === 200) {
uploadSuccess(id);
} else if (xhr.status === 400) { // generic client error
uploadCancel(view, id);
} else {
if (xhr.status === 0) return; // cancelled by user
showError(view, `Server responded with HTTP ${xhr.status}`);
uploadCancel(view, id);
}
uploadFinish(view, id);
});
view[0].isUploading = true;
view[0].uploadStart = performance.now();
xhr.open("POST", `${getRootPath()}!/upload?vId=${view[0].vId
}&to=${encodeURIComponent(view[0].currentFolder)
}&rename=${rename ? "1" : "0"}`
);
xhr.responseType = "text";
xhr.send(fd);
}
function uploadSuccess(id) {
const info = $(`.upload-info[data-id="${id}"]`);
info.find(".upload-bar")[0].style.width = "100%";
info.find(".upload-percentage")[0].textContent = "100%";
info.find(".upload-title")[0].textContent = "Processing ...";
}
function uploadCancel(view, id) {
uploadFinish(view, id, true);
}
function uploadFinish(view, id, cancelled) {
view[0].isUploading = false;
setTitle(basename(view[0].currentFolder));
$(`.upload-info[data-id="${id}"]`).removeClass("in").transitionend(function() {
$(this).remove();
});
if (!cancelled) {
showNotification("Upload finished", `Upload to ${view[0].currentFolder} has finished!`);
}
}
function uploadProgress(view, id, sent, total) {
if (!view[0].isUploading) return;
const info = $(`.upload-info[data-id="${id}"]`);
const progress = `${(Math.round((sent / total) * 1000) / 10).toFixed(0)}%`;
const now = performance.now();
const speed = sent / ((now - view[0].uploadStart) / 1e3);
const elapsed = now - view[0].uploadStart;
const secs = ((total / (sent / elapsed)) - elapsed) / 1000;
if (Number(view.find(".upload-info")[0].dataset.id) === id) setTitle(progress);
info.find(".upload-bar")[0].style.width = progress;
info.find(".upload-percentage")[0].textContent = progress;
info.find(".upload-time")[0].textContent = [
secs > 60 ? `${Math.ceil(secs / 60)} mins` : `${Math.ceil(secs)} secs`,
`${formatBytes(Math.round(speed / 1e3) * 1e3)}/s`,
].join(" @ ");
}
// ============================================================================
// General helpers
// ============================================================================
function entryRename(view, entry, wasEmpty, callback) {
// Populate active files list
const activeFiles = []; // TODO: Update when files change
entry.siblings(".data-row").each(function() { // exclude existing entry for case-only rename
$(this).removeClass("editing invalid");
const name = droppy.caseSensitive ? this.dataset.name : this.dataset.name.toLowerCase();
if (name) activeFiles.push(name);
});
// Hide menu, overlay and the original link, stop any previous edits
toggleCatcher(false);
const link = entry.find(".entry-link");
const linkText = link[0].textContent;
let canSubmit = validFilename(linkText, droppy.platform);
entry.addClass("editing");
// Add inline element
const renamer = $(`<input type="text" class="inline-namer" value="${linkText
}" placeholder="${linkText}">`).insertAfter(link);
renamer.off("input").on("input", function() {
const input = this.value;
const valid = validFilename(input, droppy.platform);
const exists = activeFiles.some((file) => {
return file === (droppy.caseSensitive ? input : input.toLowerCase());
});
canSubmit = valid && !exists;
entry[canSubmit ? "removeClass" : "addClass"]("invalid");
}).off("blur focusout").on("blur focusout", submitEdit.bind(null, view, true, callback));
const nameLength = linkText.lastIndexOf(".");
renamer[0].setSelectionRange(0, nameLength > -1 ? nameLength : linkText.length);
renamer[0].focus();
Mousetrap(renamer[0])
.bind("escape", stopEdit.bind(null, view, entry, wasEmpty))
.bind("return", submitEdit.bind(null, view, false, callback));
function submitEdit(view, skipInvalid, callback) {
let success;
const oldVal = renamer[0].getAttribute("placeholder");
const newVal = renamer[0].value;
if (canSubmit) {
success = true;
stopEdit(view, entry, wasEmpty);
} else if (!skipInvalid) {
renamer.addClass("shake");
setTimeout(() => {
renamer.removeClass("shake");
}, 500);
} else {
success = false;
stopEdit(view, entry, wasEmpty);
}
if (typeof success === "boolean" && typeof callback === "function") {
callback(success, join(view[0].currentFolder, oldVal), join(view[0].currentFolder, newVal));
}
}
}
function stopEdit(view, entry, wasEmpty) {
entry.removeClass("editing invalid");
view.find(".inline-namer, .data-row.new-file, .data-row.new-folder").remove();
if (wasEmpty) view.find(".content").html(Handlebars.templates.directory({entries: []}));
}
function toggleCatcher(show) {
const cc = $("#overlay"), modals = ["#prefs-box", "#about-box", "#entry-menu", "#drop-select", ".info-box"];
if (show === undefined) {
show = modals.some((selector) => { return $(selector).hasClass("in"); });
}
if (!show) {
modals.forEach((selector) => { $(selector)[show ? "addClass" : "removeClass"]("in"); });
$(".data-row.active").removeClass("active");
}
cc.off("click").on("click", toggleCatcher.bind(null, false));
cc[show ? "addClass" : "removeClass"]("in");
}
// Update the page title
function setTitle(text) {
document.title = `${text || "/"} - droppy`;
}
// Listen for popstate events, which indicate the user navigated back
$(window).off("popstate").on("popstate", () => {
if (!droppy.socket) return;
const locs = getLocationsFromHash();
droppy.views.forEach((view) => {
const dest = locs[view.vId];
view.switchRequest = true;
setTimeout(() => { view.switchRequest = false; }, 1000);
if (dest) updateLocation($(view), dest, true);
});
});
function getViewLocation(view) {
if (view[0].currentFolder === undefined) {
return ""; // return an empty string so animDirection gets always set to 'forward' on launch
} else {
return join(view[0].currentFolder, view[0].currentFile);
}
}
function getLocationsFromHash() {
const locations = window.location.hash.split("#");
locations.shift();
if (locations.length === 0) {
locations.push("");
}
locations.forEach((part, i) => {
locations[i] = part.replace(/\/*$/g, "");
if (locations[i] === "") locations[i] = "/";
});
return locations;
}
function getHashPaths(modview, dest) {
let path = window.location.pathname;
droppy.views.forEach((view) => {
view = $(view);
if (modview && modview.is(view)) {
path += `/#${dest}`;
} else {
path += `/#${getViewLocation(view)}`;
}
});
return path.replace(/\/+/g, "/");
}
function pushHistory(view, dest) {
window.history.pushState(null, null, getHashPaths(view, dest));
}
function replaceHistory(view, dest) {
window.history.replaceState(null, null, getHashPaths(view, dest));
}
// Update our current location and change the URL to it
function updateLocation(view, destination, skipPush) {
if (typeof destination.length !== "number") throw new Error("Destination needs to be string or array");
// Queue the folder switching if we are mid-animation or waiting for the server
function sendReq(view, viewDest, time) {
(function queue(time) {
if ((!droppy.socketWait && !view[0].isAnimating) || time > 2000) {
const viewLoc = getViewLocation(view);
showSpinner(view);
// Find the direction in which we should animate
if (!viewLoc || viewDest.length === viewLoc.length) {
view[0].animDirection = "center";
} else if (viewDest.length > viewLoc.length) {
view[0].animDirection = "forward";
} else {
view[0].animDirection = "back";
}
sendMessage(view[0].vId, "REQUEST_UPDATE", viewDest);
// Skip the push if we're already navigating through history
if (!skipPush) pushHistory(view, viewDest);
} else setTimeout(queue, 50, time + 50);
})(time);
}
if (view === null) {
// Only when navigating backwards
for (let i = destination.length - 1; i >= 0; i--) {
if (destination[i].length && getViewLocation(getView(i)) !== destination[i]) {
sendReq(getView(i), destination[i], 0);
}
}
} else if (droppy.views[view[0].vId]) sendReq(view, destination, 0);
}
// Update the path indicator
function updatePath(view) {
let oldParts, pathStr = "";
let i = 1; // Skip the first element as it's always the same
const parts = join(view[0].currentFolder).split("/");
if (parts[parts.length - 1] === "") parts.pop();
if (view[0].currentFile !== null) parts.push(view[0].currentFile);
parts[0] = svg("home"); // Replace empty string with our home icon
if (view[0].savedParts) {
oldParts = view[0].savedParts;
while (parts[i] || oldParts[i]) {
pathStr += `/${parts[i]}`;
if (parts[i] !== oldParts[i]) {
if (!parts[i] && oldParts[i] !== parts[i]) { // remove this part
removePart(i);
} else if (!oldParts[i] && oldParts[i] !== parts[i]) { // Add a part
addPart(parts[i], pathStr);
} else { // rename part
const part = $(view.find(".path li")[i]);
part.html(`<a>${parts[i]}</a>${svg("triangle")}`);
part[0].dataset.destination = pathStr;
}
}
i++;
}
} else {
addPart(parts[0], "/");
for (let len = parts.length; i < len; i++) {
pathStr += `/${parts[i]}`;
addPart(parts[i], pathStr);
}
}
view.find(".path li:not(.gone)").transition("in");
setTimeout(() => {checkPathOverflow(view); }, 400);
view[0].savedParts = parts;
function addPart(name, path) {
const li = $(`<li><a>${name}</a></li>`);
li[0].dataset.destination = path;
li.off("click").on("click", function(event) {
const view = $(event.target).parents(".view");
if (droppy.socketWait) return;
if ($(this).is(":last-child")) {
if ($(this).parents(".view")[0].dataset.type === "directory") {
updateLocation(view, this.dataset.destination);
}
} else {
view[0].switchRequest = true; // This is set so we can switch out of a editor view
updateLocation(view, this.dataset.destination);
}
setTimeout(() => {checkPathOverflow(view); }, 400);
});
view.find(".path").append(li);
li.append(svg("triangle"));
}
function removePart(i) {
view.find(".path li").slice(i).replaceClass("in", "gone").transitionend(function() {
$(this).remove();
});
}
}
// Check if the path indicator overflows and scroll it if necessary
function checkPathOverflow(view) {
let width = 40;
const space = view[0].clientWidth;
view.find(".path li.in").each(function() {
width += $(this)[0].clientWidth;
});
view.find(".path li").each(function() {
this.style.left = width > space ? `${space - width}px` : 0;
});
}
function getTemplateEntries(view, data) {
const entries = [];
Object.keys(data).forEach((name) => {
const split = data[name].split("|");
const type = split[0];
const mtime = Number(split[1]) * 1e3;
const size = Number(split[2]);
name = normalize(name);
const entry = {
name,
sortname: name.replace(/['"]/g, "_").toLowerCase(),
type,
mtime,
age: timeDifference(mtime),
size,
psize: formatBytes(size),
id: ((view[0].currentFolder === "/") ? "/" : `${view[0].currentFolder}/`) + name,
sprite: getSpriteClass(fileExtension(name)),
classes: "",
};
if (Object.keys(droppy.audioTypes).includes(fileExtension(name))) {
entry.classes = "playable";
entry.playable = true;
} else if (Object.keys(droppy.videoTypes).includes(fileExtension(name))) {
entry.classes = "viewable viewable-video";
entry.viewableVideo = true;
} else if (Object.keys(droppy.imageTypes).includes(fileExtension(name))) {
entry.classes = "viewable viewable-image";
entry.viewableImage = true;
} else if (fileExtension(name) === "pdf") {
entry.classes = "viewable viewable-pdf";
entry.viewablePdf = true;
}
entries.push(entry);
});
return entries;
}
// Convert the received data into HTML
function openDirectory(view, data, isSearch) {
let entries = view[0].templateEntries = getTemplateEntries(view, data || []);
clearSearch(view);
// sorting
const sortings = droppy.get("sortings");
const savedSorting = sortings[view[0].currentFolder];
view[0].sortBy = savedSorting ? savedSorting.sortBy : "name";
view[0].sortAsc = savedSorting ? savedSorting.sortAsc : false;
const sortBy = view[0].sortBy === "name" ? "type" : view[0].sortBy;
entries = sortArrayByProp(entries, sortBy);
if (view[0].sortAsc) entries.reverse();
const sort = {type: "", mtime: "", size: ""};
sort[sortBy] = `active ${view[0].sortAsc ? "up" : "down"}`;
const html = Handlebars.templates.directory({entries, sort, isSearch});
loadContent(view, "directory", null, html).then(() => {
// Upload button on empty page
view.find(".empty").off("click").on("click", (e) => {
const view = $(e.target).parents(".view");
const inp = view.find(".file");
if (droppy.detects.directoryUpload) {
droppy.dir.forEach((attr) => {
inp[0].removeAttribute(attr);
});
}
inp[0].click();
});
// Switch into a folder
view.find(".folder-link").off("click").on("click", function(e) {
if (droppy.socketWait) return;
updateLocation(view, $(this).parents(".data-row")[0].dataset.id);
e.preventDefault();
});
// Click on a file link
view.find(".file-link").off("click").on("click", function(e) {
if (droppy.socketWait) return;
const view = $(e.target).parents(".view");
openFile(view, view[0].currentFolder, e.target.textContent.trim(), {ref: this});
e.preventDefault();
});
view.find(".data-row").each(function(index) {
this.setAttribute("order", index);
});
view.find(".data-row").off("contextmenu").on("contextmenu", (e) => {
const target = $(e.currentTarget);
if (target[0].dataset.type === "error") return;
showEntryMenu(target, e.clientX, e.clientY);
e.preventDefault();
});
view.find(".data-row .entry-menu").off("click").on("click", (e) => {
showEntryMenu($(e.target).parents(".data-row"), e.clientX, e.clientY);
});
// Stop navigation when clicking on an <a>
view.find(".data-row .zip, .data-row .download, .entry-link.file").off("click").on("click", (e) => {
e.stopPropagation();
if (droppy.socketWait) return;
// Some browsers (like IE) think that clicking on an <a> is real navigation
// and will close the WebSocket in turn. We'll reconnect if necessary.
// Firefox is not affected as long as the <a> bears a `download` attribute,
// if it's missing it will disconnect a WebSocket as long as
// https://bugzilla.mozilla.org/show_bug.cgi?id=896666 is not fixed.
droppy.reopen = true;
setTimeout(() => {
droppy.reopen = false;
}, 2000);
});
view.find(".share-file").off("click").on("click", function() {
if (droppy.socketWait) return;
requestLink(
$(this).parents(".view"),
$(this).parents(".data-row")[0].dataset.id,
droppy.get("sharelinkDownload")
);
});
view.find(".delete-file").off("click").on("click", function() {
if (droppy.socketWait) return;
showSpinner(view);
sendMessage(view[0].vId, "DELETE_FILE", $(this).parents(".data-row")[0].dataset.id);
});
view.find(".icon-play, .icon-view").off("click").on("click", function() {
$(this).parents(".data-row").find(".file-link")[0].click();
});
view.find(".header-name, .header-mtime, .header-size").off("click").on("click", function() {
sortByHeader(view, $(this));
});
hideSpinner(view);
});
}
// Load new view content
function loadContent(view, type, mediaType, content) {
return new Promise(((resolve) => {
if (view[0].isAnimating) return; // Ignore mid-animation updates. TODO: queue and update on animation-end
view[0].dataset.type = type;
mediaType = mediaType ? ` type-${mediaType}` : "";
content = `<div class="new content ${type}${mediaType} ${view[0].animDirection}">${content}</div>`;
const navRegex = /(forward|back|center)/;
if (view[0].animDirection === "center") {
view.find(".content").replaceClass(navRegex, "center").before(content);
view.find(".new").addClass(type);
finish();
} else {
view.children(".content-container").append(content);
view[0].isAnimating = true;
view.find(".data-row").addClass("animating");
view.find(".content:not(.new)").replaceClass(navRegex, (view[0].animDirection === "forward") ?
"back" : (view[0].animDirection === "back") ? "forward" : "center");
getOtherViews(view[0].vId).each(function() {
this.style.zIndex = "1";
});
view.find(".new").addClass(type).transition(navRegex, "center").transitionend(finish);
}
view[0].animDirection = "center";
function finish() {
view[0].isAnimating = false;
getOtherViews(view[0].vId).each(function() {
this.style.zIndex = "auto";
});
view.find(".content:not(.new)").remove();
view.find(".new").removeClass("new");
view.find(".data-row").removeClass("animating");
if (view[0].dataset.type === "directory") {
bindDragEvents(view);
}
toggleButtons(view, type);
resolve();
}
}));
}
function toggleButtons(view, type) {
view.find(".af, .ad, .cf, .cd")[type === "directory" ? "removeClass" : "addClass"]("disabled");
}
function handleDrop(view, event, src, dst, spinner) {
const dropSelect = $("#drop-select"), dragAction = view[0].dragAction;
droppy.dragTimer.clear();
delete view[0].dragAction;
$(".dropzone").removeClass("in");
if (dragAction === "copy" || event.ctrlKey || event.metaKey || event.altKey) {
sendDrop(view, "copy", src, dst, spinner);
} else if (dragAction === "cut" || event.shiftKey) {
sendDrop(view, "cut", src, dst, spinner);
} else {
const x = event.originalEvent.clientX, y = event.originalEvent.clientY;
// Keep the drop-select in view
const limit = dropSelect[0].offsetWidth / 2 - 20;
let left;
if (x < limit) {
left = x + limit;
} else if (x + limit > window.innerWidth) {
left = x - limit;
} else {
left = x;
}
dropSelect[0].style.left = `${left}px`;
dropSelect[0].style.top = `${event.originalEvent.clientY}px`;
dropSelect.addClass("in");
$(document.elementFromPoint(x, y)).addClass("active").one("mouseleave", function() {
$(this).removeClass("active");
});
toggleCatcher(true);
dropSelect.children(".movefile").off("click").one("click", () => {
sendDrop(view, "cut", src, dst, spinner);
toggleCatcher(false);
});
dropSelect.children(".copyfile").off("click").one("click", () => {
sendDrop(view, "copy", src, dst, spinner);
toggleCatcher(false);
});
dropSelect.children(".viewfile").off("click").one("click", () => {
updateLocation(view, src);
toggleCatcher(false);
});
return;
}
}
function sendDrop(view, type, src, dst, spinner) {
if (src !== dst || type === "copy") {
if (spinner) showSpinner(view);
sendMessage(view[0].vId, "CLIPBOARD", {
type,
src,
dst
});
}
}
// Set drag properties for internal drag sources
function bindDragEvents(view) {
view.find(".data-row .entry-link").each(function() {
this.setAttribute("draggable", "true");
});
view.off("dragstart").on("dragstart", (event) => {
const row = $(event.target).hasClass("data-row") ? $(event.target) : $(event.target).parents(".data-row");
if (event.ctrlKey || event.metaKey || event.altKey) {
view[0].dragAction = "copy";
} else if (event.shiftKey) {
view[0].dragAction = "cut";
}
droppy.dragTimer.refresh(row[0].dataset.id);
event.originalEvent.dataTransfer.setData("text", JSON.stringify({
type: row[0].dataset.type,
path: row[0].dataset.id,
}));
event.originalEvent.dataTransfer.effectAllowed = "copyMove";
if ("setDragImage" in event.originalEvent.dataTransfer) {
event.originalEvent.dataTransfer.setDragImage(row.find(".sprite")[0], 0, 0);
}
});
}
function DragTimer() {
this.timer = null;
this.data = "";
this.isInternal = false;
this.refresh = function(data) {
if (typeof data === "string") {
this.data = data;
this.isInternal = true;
}
clearTimeout(this.timer);
this.timer = setTimeout(this.clear, 1000);
};
this.clear = function() {
if (!this.isInternal) {
$(".dropzone").removeClass("in");
}
clearTimeout(this.timer);
this.isInternal = false;
this.data = "";
};
}
droppy.dragTimer = new DragTimer();
function allowDrop(el) {
el.off("dragover").on("dragover", (e) => {
e.preventDefault();
droppy.dragTimer.refresh();
});
}
function bindHoverEvents(view) {
const dropZone = view.find(".dropzone");
view.off("dragenter").on("dragenter", (event) => {
event.stopPropagation();
droppy.activeView = view[0].vId;
const isInternal = event.originalEvent.dataTransfer.effectAllowed === "copyMove";
let icon;
if (view[0].dataset.type === "directory" && isInternal) {
icon = "menu";
} else if (!isInternal) {
icon = "upload-cloud";
} else {
icon = "open";
}
view.find(".dropzone svg").replaceWith(svg(icon));
if (!dropZone.hasClass("in")) dropZone.addClass("in");
getOtherViews($(event.target).parents(".view")[0].vId).find(".dropzone").removeClass("in");
});
}
function bindDropEvents(view) {
// file drop
(new Uppie())(view[0], (e, fd, files) => {
if (!files.length) return;
if (droppy.readOnly) return showError(view, "Files are read-only.");
e.stopPropagation();
if (!validateFiles(files, view)) return;
upload(view, fd, files);
});
// drag between views
view.off("drop").on("drop", (e) => {
const view = $(e.target).parents(".view");
let dragData = e.originalEvent.dataTransfer.getData("text");
e.preventDefault();
$(".dropzone").removeClass("in");
if (!dragData) return;
e.stopPropagation();
dragData = JSON.parse(dragData);
if (view[0].dataset.type === "directory") { // dropping into a directory view
handleDrop(view, e, dragData.path, join(view[0].currentFolder, basename(dragData.path)), true);
} else { // dropping into a document/media view
if (dragData.type === "folder") {
view[0].dataset.type = "directory";
updateLocation(view, dragData.path);
} else {
if (join(view[0].currentFolder, view[0].currentFile) !== dragData.path) {
openFile(view, dirname(dragData.path), basename(dragData.path));
}
}
}
});
}
function initButtons(view) {
// Init upload <input>
view[0].fileInput = view.find(".file")[0];
(new Uppie())(view[0].fileInput, (e, fd, files) => {
const view = $(e.target).parents(".view");
e.preventDefault();
e.stopPropagation();
if (!validateFiles(files, view)) return;
upload(view, fd, files);
view[0].fileInput.value = "";
});
// File upload button
view.off("click", ".af").on("click", ".af", function(e) {
if ($(this).hasClass("disabled")) return;
const view = $(e.target).parents(".view");
// Remove the directory attributes so we get a file picker dialog
if (droppy.detects.directoryUpload) {
droppy.dir.forEach((attr) => {
view[0].fileInput.removeAttribute(attr);
});
}
view[0].fileInput.click();
});
// Disable the button when no directory upload is supported
if (droppy.detects.directoryUpload) {
view.find(".ad").addClass("disabled");
}
// Directory upload button
view.off("click", ".ad").on("click", ".ad", function(e) {
const view = $(e.target).parents(".view");
if ($(this).hasClass("disabled")) {
showError(getView(0), "Your browser doesn't support directory uploading");
} else {
// Set the directory attribute so we get a directory picker dialog
droppy.dir.forEach((attr) => {
view[0].fileInput.setAttribute(attr, attr);
});
// Click the button to trigger a dialog
if (view[0].fileInput.isFilesAndDirectoriesSupported) {
view[0].fileInput.click();
} else if (view[0].fileInput.chooseDirectory) {
view[0].fileInput.chooseDirectory();
} else {
view[0].fileInput.click();
}
}
});
view.off("click", ".cf, .cd").on("click", ".cf, .cd", function(e) {
if ($(this).hasClass("disabled")) return;
const view = $(e.target).parents(".view");
const content = view.find(".content");
const isFile = this.classList.contains("cf");
const isEmpty = Boolean(view.find(".empty").length);
const html = Handlebars.templates[isFile ? "new-file" : "new-folder"]();
stopEdit(view, view.find(".editing"), isEmpty);
if (isEmpty) content.html(Handlebars.templates["file-header"]());
content.prepend(html);
content[0].scrollTop = 0;
const dummy = $(`.data-row.new-${isFile ? "file" : "folder"}`);
entryRename(view, dummy, isEmpty, (success, _oldVal, newVal) => {
if (!success) return;
if (view[0].dataset.type === "directory") showSpinner(view);
sendMessage(view[0].vId, `CREATE_${isFile ? "FILE" : "FOLDER"}`, newVal);
});
});
view.off("click", ".newview").on("click", ".newview", () => {
if (droppy.views.length === 1) {
const dest = join(view[0].currentFolder, view[0].currentFile);
replaceHistory(newView(dest, 1), dest);
} else {
destroyView(view[0].vId);
replaceHistory(view, join(view[0].currentFolder, view[0].currentFile));
}
});
view.off("click", ".about").on("click", ".about", () => {
$("#about-box").addClass("in");
toggleCatcher();
});
view.off("click", ".prefs").on("click", ".prefs", () => {
showPrefs();
if (droppy.priv) sendMessage(null, "GET_USERS");
});
view.off("click", ".reload").on("click", ".reload", () => {
if (droppy.socketWait) return;
showSpinner(view);
sendMessage(view[0].vId, "RELOAD_DIRECTORY", {
dir: view[0].currentFolder
});
});
view.off("click", ".logout").on("click", ".logout", () => {
ajax({
method: "POST",
url: "!/logout",
data: {
path: getRootPath(),
},
}).then(() => {
droppy.socket.close(4000);
render("login");
initAuthPage();
});
});
// Search Box
function doSearch(e) {
if (e.target.value && String(e.target.value).trim()) {
sendMessage(view[0].vId, "SEARCH", {
query: e.target.value,
dir: view[0].currentFolder,
});
} else {
openDirectory(view, view[0].currentData);
}
}
view.off("click", ".search.toggled-off").on("click", ".search.toggled-off", function() {
const search = $(this);
search.removeClass("toggled-off").addClass("toggled-on");
setTimeout(() => {
search.find("input")[0].focus();
}, 0);
});
view.off("click", ".search.toggled-on svg").on("click", ".search.toggled-on svg", function() {
const view = $(this).parents(".view");
openDirectory(view, view[0].currentData);
});
view.off("keyup", ".search input").on("keyup", ".search input", function(e) {
if (e.keyCode === 27/* escape */) {
const view = $(this).parents(".view");
openDirectory(view, view[0].currentData);
this.value = "";
$(this).parent().removeClass("toggled-on").addClass("toggled-off");
} else if (e.keyCode === 13/* return */) {
doSearch(e);
}
});
view.off("input", ".search input").on("input", ".search input", debounce(doSearch, 1000));
view.off("click", ".globalsearch input").on("click", ".globalsearch input", (e) => {
e.stopPropagation();
});
}
function initEntryMenu() {
// Play an audio file
$("#entry-menu .play").off("click").on("click", (event) => {
event.stopPropagation();
const entry = $(`.data-row[data-id="${droppy.menuTargetId}"]`);
const view = entry.parents(".view");