Merge branch 'feature/delete_files'
This commit is contained in:
commit
030b67844f
@ -1,33 +1,35 @@
|
||||
import express, {Express, Request, Response, Router} from "express";
|
||||
import {Healthcheck} from "./healthcheck";
|
||||
import {printEndpoints, validateHash} from "./utils";
|
||||
import {LogRequestMiddleware} from "./log";
|
||||
import express, { Express, Request, Response, Router } from "express";
|
||||
import { Healthcheck } from "./healthcheck";
|
||||
import { printEndpoints, validateHash } from "./utils";
|
||||
import { LogRequestMiddleware } from "./log";
|
||||
import * as fs from "node:fs";
|
||||
import {entry} from "./entry";
|
||||
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { entry } from "./entry";
|
||||
import cors from "cors";
|
||||
import archiver from "archiver";
|
||||
import {appendDirectoryToArchive, appendFileToArchive} from "./download";
|
||||
import { appendDirectoryToArchive, appendFileToArchive } from "./download";
|
||||
import path from "node:path";
|
||||
import {verifyToken} from "./authenicate";
|
||||
import { verifyToken } from "./authenicate";
|
||||
import jwt from "jsonwebtoken";
|
||||
import {config} from "dotenv";
|
||||
import { config } from "dotenv";
|
||||
import Multer from "multer";
|
||||
import {mkdirSync, readFileSync, rmSync, writeFileSync} from "fs";
|
||||
|
||||
/**
|
||||
* App details
|
||||
*/
|
||||
const PORT = 5000;
|
||||
const APP: Express = express();
|
||||
// TODO: BACK TO NORMAL PATH
|
||||
// const ROOT: string = "/media/vault";
|
||||
const ROOT: string = "/home/azpect";
|
||||
|
||||
/**
|
||||
* Configure the .env file, this is for testing only, should be ignored in production.
|
||||
*/
|
||||
try {
|
||||
config({path: ".env"});
|
||||
config({ path: ".env" });
|
||||
} catch (error) {
|
||||
console.error(`Could not load the .env file. If this is a production environment, this is normal!`);
|
||||
console.error(`Could not load the .env file. If this is a production environment, this is normal!`);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -39,18 +41,28 @@ const INVALID_EXTS: string[] = ["exe", "dll", "obj", "lib", "bin", "dat", "pdf",
|
||||
* Configure cors, this should work for both production and development.
|
||||
*/
|
||||
const corsOptions: cors.CorsOptions = {
|
||||
origin: ["http://localhost:3100", "https://file.gophernest.net"],
|
||||
methods: ["GET", "POST"]
|
||||
origin: ["http://localhost:3100", "https://file.gophernest.net"],
|
||||
methods: ["GET", "POST"]
|
||||
};
|
||||
APP.use(cors(corsOptions));
|
||||
|
||||
/**
|
||||
* File upload settings
|
||||
*/
|
||||
const upload = Multer({
|
||||
dest: "tmp/",
|
||||
limits: {
|
||||
fileSize: 1024 * 1024 * 100
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply middleware, this must be done before the routes are created.
|
||||
*/
|
||||
APP.use(verifyToken);
|
||||
APP.use(LogRequestMiddleware);
|
||||
APP.use(express.json());
|
||||
APP.use(express.urlencoded({extended: true}));
|
||||
APP.use(express.urlencoded({ extended: true }));
|
||||
|
||||
/**
|
||||
* Create routes for modular routing
|
||||
@ -61,140 +73,140 @@ const v1: Router = express.Router();
|
||||
* Return a healthcheck interface loaded with the server's health
|
||||
*/
|
||||
v1.get("/healthcheck", (req: Request, res: Response): void => {
|
||||
let hc: Healthcheck = {
|
||||
health: "Server is in bad health",
|
||||
errors: ["The root directory could not be found."],
|
||||
directory_found: false,
|
||||
};
|
||||
let hc: Healthcheck = {
|
||||
health: "Server is in bad health",
|
||||
errors: ["The root directory could not be found."],
|
||||
directory_found: false,
|
||||
};
|
||||
|
||||
res.status(200).json(hc);
|
||||
res.status(200).json(hc);
|
||||
});
|
||||
|
||||
/**
|
||||
* Make a log in attempt.
|
||||
*/
|
||||
v1.post("/login", (req: Request, res: Response): void => {
|
||||
// Get info from body
|
||||
const {username, password} = req.body;
|
||||
// Get info from body
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Get required info from the environment and validate
|
||||
if (process.env["FILE_GOPHERNEST_USER"] === username && validateHash(password, process.env["FILE_GOPHERNEST_PASSWORD"] as string)) {
|
||||
// Get the secret from the env
|
||||
const jwt_secret: string | undefined = process.env["FILE_GOPHERNEST_JWT_SECRET"];
|
||||
if (!jwt_secret) {
|
||||
res.status(500).json({code: 500, message: "JSON web tokens are not configured."});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the token
|
||||
const token: string = jwt.sign({username}, jwt_secret, {expiresIn: "30d"});
|
||||
res.status(200).json({code: 200, token});
|
||||
} else {
|
||||
res.status(404).json({code: 404, message: "Invalid credentials. Please try again!"});
|
||||
// Get required info from the environment and validate
|
||||
if (process.env["FILE_GOPHERNEST_USER"] === username && validateHash(password, process.env["FILE_GOPHERNEST_PASSWORD"] as string)) {
|
||||
// Get the secret from the env
|
||||
const jwt_secret: string | undefined = process.env["FILE_GOPHERNEST_JWT_SECRET"];
|
||||
if (!jwt_secret) {
|
||||
res.status(500).json({ code: 500, message: "JSON web tokens are not configured." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the token
|
||||
const token: string = jwt.sign({ username }, jwt_secret, { expiresIn: "30d" });
|
||||
res.status(200).json({ code: 200, token });
|
||||
} else {
|
||||
res.status(404).json({ code: 404, message: "Invalid credentials. Please try again!" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get the children of a directory provided in the path query.
|
||||
*/
|
||||
v1.get("/children", (req: Request, res: Response): void => {
|
||||
// Get the path, if it was not provided, use the root
|
||||
const path: string = (req.query.path || ROOT) as string;
|
||||
// Get the path, if it was not provided, use the root
|
||||
const path: string = (req.query.path || ROOT) as string;
|
||||
|
||||
// An array of names which are the children
|
||||
const children_paths: string[] = fs.readdirSync(path);
|
||||
// An array of names which are the children
|
||||
const children_paths: string[] = fs.readdirSync(path);
|
||||
|
||||
// Store a list of the children as entries
|
||||
const children: entry[] = [];
|
||||
// Store a list of the children as entries
|
||||
const children: entry[] = [];
|
||||
|
||||
for (const child of children_paths) {
|
||||
try {
|
||||
const isDir: boolean = fs.statSync(path.concat("/", child)).isDirectory();
|
||||
children.push({
|
||||
name: child,
|
||||
path: path.concat("/", child),
|
||||
directory: isDir
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
for (const child of children_paths) {
|
||||
try {
|
||||
const isDir: boolean = fs.statSync(path.concat("/", child)).isDirectory();
|
||||
children.push({
|
||||
name: child,
|
||||
path: path.concat("/", child),
|
||||
directory: isDir
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
res.status(200).json(children);
|
||||
}
|
||||
res.status(200).json(children);
|
||||
});
|
||||
|
||||
/**
|
||||
* Down a group of files provided in the body.
|
||||
*/
|
||||
v1.post("/download", (req: Request, res: Response): void => {
|
||||
// Get the files from the body
|
||||
const {filePaths} = req.body;
|
||||
// Get the files from the body
|
||||
const { filePaths } = req.body;
|
||||
|
||||
// Validate the path array
|
||||
if (!filePaths || !Array.isArray(filePaths) || filePaths.length === 0) {
|
||||
res.status(400).send({code: 400, error: 'Invalid file paths provided.'});
|
||||
return;
|
||||
// Validate the path array
|
||||
if (!filePaths || !Array.isArray(filePaths) || filePaths.length === 0) {
|
||||
res.status(400).send({ code: 400, error: 'Invalid file paths provided.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const archive: archiver.Archiver = archiver('zip', {
|
||||
zlib: { level: 9 }, // Compression leve
|
||||
});
|
||||
|
||||
// Set the file headers
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=donwloads.zip');
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
|
||||
archive.pipe(res);
|
||||
|
||||
// Add each file to the archive
|
||||
filePaths.forEach((filePath): void => {
|
||||
// This works for files, but for directories, we need to read the files
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
if (!stats.isDirectory()) {
|
||||
appendFileToArchive(filePath, archive);
|
||||
} else {
|
||||
// Call this with the name to include the name of the directory
|
||||
appendDirectoryToArchive(filePath, path.basename(filePath), archive);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error adding file to zip: ${err}`);
|
||||
}
|
||||
});
|
||||
|
||||
const archive: archiver.Archiver = archiver('zip', {
|
||||
zlib: {level: 9}, // Compression leve
|
||||
});
|
||||
// Return errors
|
||||
archive.on('error', (err): void => {
|
||||
res.status(500).send({ error: err.message });
|
||||
});
|
||||
|
||||
// Set the file headers
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=donwloads.zip');
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
|
||||
archive.pipe(res);
|
||||
|
||||
// Add each file to the archive
|
||||
filePaths.forEach((filePath): void => {
|
||||
// This works for files, but for directories, we need to read the files
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
if (!stats.isDirectory()) {
|
||||
appendFileToArchive(filePath, archive);
|
||||
} else {
|
||||
// Call this with the name to include the name of the directory
|
||||
appendDirectoryToArchive(filePath, path.basename(filePath), archive);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error adding file to zip: ${err}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Return errors
|
||||
archive.on('error', (err): void => {
|
||||
res.status(500).send({error: err.message});
|
||||
});
|
||||
|
||||
archive.finalize();
|
||||
archive.finalize();
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieve the content of a file provided in the path query.
|
||||
*/
|
||||
v1.get("/content", (req: Request, res: Response): void => {
|
||||
// Get the path, if it was not provided, return no content
|
||||
const tgtPath: string = (req.query.path || "") as string;
|
||||
if (!tgtPath) {
|
||||
res.status(204);
|
||||
return;
|
||||
}
|
||||
// Get the path, if it was not provided, return no content
|
||||
const tgtPath: string = (req.query.path || "") as string;
|
||||
if (!tgtPath) {
|
||||
res.status(204);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure the file isn't something we can't edit
|
||||
const ext: string = path.extname(tgtPath).slice(1);
|
||||
if (INVALID_EXTS.includes(ext)) {
|
||||
res.status(400).json({error: `Cannot edit files of type *.${ext}`, code: 400});
|
||||
return;
|
||||
}
|
||||
// Ensure the file isn't something we can't edit
|
||||
const ext: string = path.extname(tgtPath).slice(1);
|
||||
if (INVALID_EXTS.includes(ext)) {
|
||||
res.status(400).json({ error: `Cannot edit files of type *.${ext}`, code: 400 });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read the file and return it
|
||||
const content = fs.readFileSync(tgtPath, {encoding: "utf-8", flag: "r"})
|
||||
res.status(200).json({content, code: 200});
|
||||
} catch (err) {
|
||||
res.status(500).json({error: `An error occurred on the server. ${err}`, code: 500});
|
||||
}
|
||||
try {
|
||||
// Read the file and return it
|
||||
const content = fs.readFileSync(tgtPath, { encoding: "utf-8", flag: "r" })
|
||||
res.status(200).json({ content, code: 200 });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: `An error occurred on the server. ${err}`, code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@ -202,105 +214,152 @@ v1.get("/content", (req: Request, res: Response): void => {
|
||||
* On success, nothing should be sent back, 204.
|
||||
*/
|
||||
v1.post("/update", (req: Request, res: Response): void => {
|
||||
// Get path and content from the request
|
||||
const {path, content} = req.body;
|
||||
// Get path and content from the request
|
||||
const { path, content } = req.body;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path, content);
|
||||
res.status(200).json({code: 200, message: "Success"});
|
||||
} catch (error) {
|
||||
res.status(500).json({code: 500, error})
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(path, content);
|
||||
res.status(200).json({ code: 200, message: "Success" });
|
||||
} catch (error) {
|
||||
res.status(500).json({ code: 500, error })
|
||||
}
|
||||
});
|
||||
|
||||
const upload = Multer({
|
||||
dest: "tmp/",
|
||||
limits: {
|
||||
fileSize: 1024 * 1024 * 100
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Custom type for the multer uploads.
|
||||
*/
|
||||
interface UploadedFile {
|
||||
fieldname: string;
|
||||
originalname: string;
|
||||
encoding: string;
|
||||
mimetype: string;
|
||||
destination: string;
|
||||
filename: string;
|
||||
path: string;
|
||||
size: number;
|
||||
fieldname: string;
|
||||
originalname: string;
|
||||
encoding: string;
|
||||
mimetype: string;
|
||||
destination: string;
|
||||
filename: string;
|
||||
path: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// IMPORTANT! Calling this will expect sudo in places in the FS that require sudo
|
||||
v1.post("/upload", upload.array("files"), (req: Request, res: Response) => {
|
||||
if (!req.files) {
|
||||
res.status(400);
|
||||
if (!req.files) {
|
||||
res.status(400);
|
||||
}
|
||||
|
||||
// Directory to upload the files to
|
||||
const cwd: string[] = JSON.parse(req.body.path);
|
||||
const files = (req as any).files as UploadedFile[];
|
||||
files.forEach((file) => {
|
||||
try {
|
||||
// Get the data that was written to the local tmp path
|
||||
const data: Buffer = readFileSync(file.path);
|
||||
|
||||
// Generate the new path in the FS
|
||||
const newPath: string = path.join("/", ...cwd, file.originalname);
|
||||
|
||||
// Write the new file
|
||||
writeFileSync(newPath, data, { mode: "666" });
|
||||
|
||||
// Delete the tmp file using a relative path
|
||||
rmSync("./" + file.path);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'EACCES') {
|
||||
return res.status(403).json({ code: 403, error: "Permission denied." }); // Specific error
|
||||
} else if (error.code === 'ENOSPC') {
|
||||
return res.status(507).json({ code: 507, error: "Insufficient storage." }); // Specific error
|
||||
} else if (error instanceof TypeError) {
|
||||
return res.status(400).json({ code: 400, error: "Invalid data type." }); // Example of instance check
|
||||
} else {
|
||||
return res.status(500).json({ code: 500, error: "Error processing file." }); // Generic error
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Directory to upload the files to
|
||||
const cwd: string[] = JSON.parse(req.body.path);
|
||||
const files = (req as any).files as UploadedFile[];
|
||||
files.forEach((file) => {
|
||||
try {
|
||||
// Get the data that was written to the local tmp path
|
||||
const data: Buffer = readFileSync(file.path);
|
||||
|
||||
// Generate the new path in the FS
|
||||
const newPath: string = path.join("/", ...cwd, file.originalname);
|
||||
|
||||
// Write the new file
|
||||
writeFileSync(newPath, data);
|
||||
|
||||
// Delete the tmp file using a relative path
|
||||
rmSync("./" + file.path);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'EACCES') {
|
||||
return res.status(403).json({code: 403, error: "Permission denied."}); // Specific error
|
||||
} else if (error.code === 'ENOSPC') {
|
||||
return res.status(507).json({code: 507, error: "Insufficient storage."}); // Specific error
|
||||
} else if (error instanceof TypeError) {
|
||||
return res.status(400).json({code: 400, error: "Invalid data type."}); // Example of instance check
|
||||
} else {
|
||||
return res.status(500).json({code: 500, error: "Error processing file."}); // Generic error
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
res.status(200).json({code: 200, message: "Success"});
|
||||
res.status(200).json({ code: 200, message: "Success" });
|
||||
});
|
||||
|
||||
v1.post("/create", (req: Request, res: Response): void => {
|
||||
// Generate the path to create
|
||||
const {cwd, name} = req.body;
|
||||
// Generate the path to create
|
||||
const { cwd, name } = req.body;
|
||||
|
||||
try {
|
||||
const newPath: string = path.join("/", ...cwd, name);
|
||||
if (name.endsWith("/")) {
|
||||
mkdirSync(newPath, {mode: "644"})
|
||||
} else {
|
||||
console.log("NOT DIR");
|
||||
writeFileSync(newPath, "", {mode: "644"})
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'EACCES') {
|
||||
res.status(403).json({code: 403, error: "Permission denied."}); // Specific error
|
||||
return;
|
||||
} else if (error.code === 'ENOSPC') {
|
||||
res.status(507).json({code: 507, error: "Insufficient storage."}); // Specific error
|
||||
return;
|
||||
} else if (error instanceof TypeError) {
|
||||
res.status(400).json({code: 400, error: "Invalid data type."}); // Example of instance check
|
||||
return;
|
||||
} else {
|
||||
res.status(500).json({code: 500, error: "Error processing directory."}); // Generic error
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newPath: string = path.join("/", ...cwd, name);
|
||||
if (name.endsWith("/")) {
|
||||
mkdirSync(newPath, { recursive: true, mode: "666" })
|
||||
} else {
|
||||
writeFileSync(newPath, "", { mode: "666" })
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'EACCES') {
|
||||
res.status(403).json({ code: 403, error: "Permission denied." }); // Specific error
|
||||
return;
|
||||
} else if (error.code === 'ENOSPC') {
|
||||
res.status(507).json({ code: 507, error: "Insufficient storage." }); // Specific error
|
||||
return;
|
||||
} else if (error instanceof TypeError) {
|
||||
res.status(400).json({ code: 400, error: "Invalid data type." }); // Example of instance check
|
||||
return;
|
||||
} else {
|
||||
res.status(500).json({ code: 500, error: "Error processing directory." }); // Generic error
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({code: 201, message: "Success"});
|
||||
res.status(201).json({ code: 201, message: "Success" });
|
||||
});
|
||||
|
||||
v1.post("/delete", (req: Request, res: Response): void => {
|
||||
// Get the array of paths and the root
|
||||
const { files, root } = req.body;
|
||||
|
||||
// Delete the files
|
||||
// NOTE: These are not moved somewhere, they're just raw deleted
|
||||
for (const file of files) {
|
||||
const absPath = "/" + path.join(...file);
|
||||
rmSync(absPath, { recursive: true });
|
||||
}
|
||||
|
||||
res.status(201).json({ code: 201, message: `Deleted ${files.length} files.` });
|
||||
});
|
||||
|
||||
/**
|
||||
* Files are not deleted from the file system, just moved to a hidden folder where
|
||||
* they can be recovered if needed.
|
||||
*
|
||||
* The hidden folder will be called `.trash` stored in the root of the mount, and
|
||||
* the files entire paths will be created.
|
||||
*
|
||||
* e.g., Deleting /media/vault/main.txt will get moved to
|
||||
* `/media/vault/.trash/<timstamp>/media/vault/main.txt`
|
||||
* assuming /media/vault is the mounted root.
|
||||
* The timestamp will also be appended to allow files with the same path to be deleted.
|
||||
*/
|
||||
v1.post("/remove", (req: Request, res: Response): void => {
|
||||
// Get the array of paths and the root
|
||||
const { files, root } = req.body;
|
||||
|
||||
// Stores the name of the trash directory
|
||||
const trashDir: string = ".trash";
|
||||
const timestamp: string = (new Date()).toISOString();
|
||||
|
||||
console.log(timestamp);
|
||||
|
||||
for (const file of files) {
|
||||
const oldPath = path.join("/", ...file);
|
||||
const newPath = path.join("/", ...root, trashDir, timestamp, ...file);
|
||||
|
||||
console.log(oldPath, " -> ", newPath);
|
||||
try {
|
||||
mkdirSync(path.dirname(newPath), { recursive: true, mode: "666" });
|
||||
renameSync(oldPath, newPath)
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
res.status(500).json({ code: 500, message: `Failed to delete. ${error}` })
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({ code: 201, message: `Deleted ${files.length} files.` });
|
||||
});
|
||||
|
||||
/**
|
||||
@ -312,6 +371,6 @@ APP.use("/v1", v1);
|
||||
* Start the server
|
||||
*/
|
||||
APP.listen(PORT, (): void => {
|
||||
printEndpoints(APP);
|
||||
console.log(`Server listening on :${PORT}`);
|
||||
printEndpoints(APP);
|
||||
console.log(`Server listening on :${PORT}`);
|
||||
});
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
* Takes the user back to the home directory. The onClick prop
|
||||
* is called when the button is clicked.
|
||||
* @param onClick {function}
|
||||
* @param enabled
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
@ -50,6 +51,21 @@ function CreateButton({onClick}) {
|
||||
)
|
||||
}
|
||||
|
||||
function DeleteButton({onClick, enabled}) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
disabled={!enabled}
|
||||
title="Delete selected files/directories"
|
||||
className="hover:bg-gray-200 p-1.5 mr-1 rounded-full transition-colors duration-150 disabled:text-gray-500 disabled:hover:bg-red-300 disabled:cursor-not-allowed text-black">
|
||||
<svg className="h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M18 6L17.1991 18.0129C17.129 19.065 17.0939 19.5911 16.8667 19.99C16.6666 20.3412 16.3648 20.6235 16.0011 20.7998C15.588 21 15.0607 21 14.0062 21H9.99377C8.93927 21 8.41202 21 7.99889 20.7998C7.63517 20.6235 7.33339 20.3412 7.13332 19.99C6.90607 19.5911 6.871 19.065 6.80086 18.0129L6 6M4 6H20M16 6L15.7294 5.18807C15.4671 4.40125 15.3359 4.00784 15.0927 3.71698C14.8779 3.46013 14.6021 3.26132 14.2905 3.13878C13.9376 3 13.523 3 12.6936 3H11.3064C10.477 3 10.0624 3 9.70951 3.13878C9.39792 3.26132 9.12208 3.46013 8.90729 3.71698C8.66405 4.00784 8.53292 4.40125 8.27064 5.18807L8 6"
|
||||
stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param name {string}
|
||||
@ -73,10 +89,12 @@ function PathElement({name, index, onClick}) {
|
||||
* @param backArrow {function}
|
||||
* @param enabled {boolean}
|
||||
* @param create {function}
|
||||
* @param remove {function}
|
||||
* @param removeEnable {boolean}
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
export default function PathDisplay({path, updatePath, backHome, backArrow, enabled, create}) {
|
||||
export default function PathDisplay({path, updatePath, backHome, backArrow, enabled, create, remove, removeEnable}) {
|
||||
return (
|
||||
<div
|
||||
className="w-2/3 mt-8 border-b-1 border-gray-400 bg-white flex items-center truncate">
|
||||
@ -84,6 +102,9 @@ export default function PathDisplay({path, updatePath, backHome, backArrow, enab
|
||||
<BackButton onClick={backArrow} enabled={enabled}/>
|
||||
{path.map((seg, idx) => <PathElement name={seg} key={idx} index={idx} onClick={updatePath}/>)}
|
||||
<div className="ml-auto h-full flex items-center">
|
||||
<DeleteButton onClick={remove} enabled={removeEnable}/>
|
||||
</div>
|
||||
<div className="h-full flex items-center">
|
||||
<CreateButton onClick={create}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import {useEffect, useState} from "react";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import DirectoryList from "../components/DirectoryList.jsx";
|
||||
import PathDisplay from "../components/PathDisplay.jsx";
|
||||
import Navbar from "../components/Navbar.jsx";
|
||||
@ -11,412 +11,465 @@ import Uploader from "../components/Uploader.jsx";
|
||||
import CreateDirectory from "../components/CreateDirectory.jsx";
|
||||
|
||||
export default function Dashboard() {
|
||||
// Store the default path
|
||||
const defaultPath = ["media", "vault"];
|
||||
// Store the default path
|
||||
// TODO: BACK TO NORMAL PATH
|
||||
// const defaultPath = ["media", "vault"];
|
||||
const defaultPath = ["home", "azpect"];
|
||||
|
||||
/**
|
||||
* URL To the backend web server.
|
||||
* Uses the .env var in local development, but when
|
||||
* pushed to dockerhub, the .env is ignored and the real
|
||||
* backend URL is used.
|
||||
* @type {string}
|
||||
*/
|
||||
const backendUrl = import.meta.env.VITE_BACKEND_URL || "https://backend.gophernest.net";
|
||||
/**
|
||||
* URL To the backend web server.
|
||||
* Uses the .env var in local development, but when
|
||||
* pushed to dockerhub, the .env is ignored and the real
|
||||
* backend URL is used.
|
||||
* @type {string}
|
||||
*/
|
||||
const backendUrl = import.meta.env.VITE_BACKEND_URL || "https://backend.gophernest.net";
|
||||
|
||||
const [token, setToken] = useState(null);
|
||||
const [path, setPath] = useState([...defaultPath]);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [editing, setEditing] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [childrenLoading, setChildrenLoading] = useState(false);
|
||||
const [downloadLoading, setDownloadLoading] = useState(false);
|
||||
const [contentLoading, setContentLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const [token, setToken] = useState(null);
|
||||
const [path, setPath] = useState([...defaultPath]);
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [editing, setEditing] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [childrenLoading, setChildrenLoading] = useState(false);
|
||||
const [downloadLoading, setDownloadLoading] = useState(false);
|
||||
const [contentLoading, setContentLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
/**
|
||||
* The name of the value stored in local storage.
|
||||
* @type {string}
|
||||
*/
|
||||
const storage_id = "gophernest_credentials";
|
||||
/**
|
||||
* The name of the value stored in local storage.
|
||||
* @type {string}
|
||||
*/
|
||||
const storage_id = "gophernest_credentials";
|
||||
|
||||
/**
|
||||
* Update the token from the local or session storage.
|
||||
* This function assumes one of them exists.
|
||||
* If it does not, the token will be null.
|
||||
* This function will return the token as well, to allow for other usecases.
|
||||
*/
|
||||
const updateToken = () => {
|
||||
const t = localStorage.getItem(storage_id) ? localStorage.getItem(storage_id) : sessionStorage.getItem(storage_id);
|
||||
setToken(t);
|
||||
return t;
|
||||
/**
|
||||
* Update the token from the local or session storage.
|
||||
* This function assumes one of them exists.
|
||||
* If it does not, the token will be null.
|
||||
* This function will return the token as well, to allow for other usecases.
|
||||
*/
|
||||
const updateToken = () => {
|
||||
const t = localStorage.getItem(storage_id) ? localStorage.getItem(storage_id) : sessionStorage.getItem(storage_id);
|
||||
setToken(t);
|
||||
return t;
|
||||
};
|
||||
|
||||
const fetchFiles = () => {
|
||||
const getData = async (token) => {
|
||||
const response = await fetch(`${backendUrl}/v1/children?path=/${path.join("/")}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"authorization": `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error("Something went wrong");
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
setChildrenLoading(true);
|
||||
|
||||
// If the token doesnt exit, update it and use the return value.
|
||||
// This is a silly work around to prevent the first render from not working
|
||||
// TODO: Fix this shit.
|
||||
let tkn = token ? token : updateToken();
|
||||
|
||||
getData(tkn).then((data) => {
|
||||
setFiles(data);
|
||||
}).finally(() => {
|
||||
setChildrenLoading(false);
|
||||
}).catch((err) => {
|
||||
setError("Failed to fetch data from server.");
|
||||
console.error(err);
|
||||
});
|
||||
|
||||
setSelected([]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchFiles();
|
||||
}, [path, uploading, creating]);
|
||||
|
||||
|
||||
// Redirect if the user isn't logged in, otherwise update the state.
|
||||
// Store the token in the storage, it should be attached to every request.
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(storage_id) == null && sessionStorage.getItem(storage_id) == null) {
|
||||
navigate("/login");
|
||||
} else {
|
||||
updateToken();
|
||||
}
|
||||
}, [navigate]);
|
||||
|
||||
/**
|
||||
* Updates the path by slicing [0:index]
|
||||
* @param index {number} Index to slice to.
|
||||
*/
|
||||
const updatePath = (index) => {
|
||||
let newPath = path.slice(0, index + 1);
|
||||
if (newPath.length < defaultPath.length) {
|
||||
newPath = [...defaultPath];
|
||||
}
|
||||
setPath(newPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the path back to the default.
|
||||
*/
|
||||
const backHome = () => {
|
||||
// TODO: Fix this in production
|
||||
setPath([...defaultPath]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add name to the path.
|
||||
* @param name Target child
|
||||
*/
|
||||
const appendPath = (name) => {
|
||||
setPath([...path, name])
|
||||
};
|
||||
|
||||
/**
|
||||
* Back arrow, goes back one directory (cd ..)
|
||||
*/
|
||||
const backArrow = () => {
|
||||
if (path.length > defaultPath.length) {
|
||||
setPath(path.slice(0, path.length - 1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This isn't fast, but hopefully the use case will be small batches.
|
||||
* @param file {string} The file to toggle
|
||||
*/
|
||||
const toggleSelected = (file) => {
|
||||
if (!selected.includes(file)) {
|
||||
setSelected([...selected, file]);
|
||||
} else {
|
||||
const idx = selected.indexOf(file);
|
||||
setSelected([...selected.slice(0, idx), ...selected.slice(idx + 1, selected.length)])
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback function for when the download button is clicked.
|
||||
*/
|
||||
const downloadFiles = () => {
|
||||
// Do not allow empty downloads
|
||||
if (selected.length === 0) {
|
||||
setError("Please select files/directories to download.");
|
||||
return
|
||||
}
|
||||
|
||||
const download = async (paths) => {
|
||||
try {
|
||||
const resp = await fetch(`${backendUrl}/v1/download`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ filePaths: paths }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json();
|
||||
setError(`Error ${data.code}: ${data.error}`);
|
||||
} else {
|
||||
// TODO: Figure out how tf this works.
|
||||
const blob = await resp.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = "downloads.zip";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Download error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getData = async (token) => {
|
||||
const response = await fetch(`${backendUrl}/v1/children?path=/${path.join("/")}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"authorization": `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error("Something went wrong");
|
||||
}
|
||||
return await response.json();
|
||||
setDownloadLoading(true);
|
||||
|
||||
const targets = [];
|
||||
selected.forEach((file) => {
|
||||
targets.push(`/${path.join("/")}/${file}`);
|
||||
});
|
||||
|
||||
// TODO: Implement UI for errors
|
||||
download(targets).catch((err) => {
|
||||
setError(`Download error: ${err}.`)
|
||||
}).finally(() => {
|
||||
setDownloadLoading(false)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the error in the error state.
|
||||
*/
|
||||
const clearError = () => {
|
||||
setError(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle editing of a file.
|
||||
* @param path {string}
|
||||
*/
|
||||
const toggleEditing = (path) => {
|
||||
setEditing(path);
|
||||
};
|
||||
|
||||
const exitFile = () => {
|
||||
setEditing("");
|
||||
};
|
||||
|
||||
const exitAndSaveFile = (newContent) => {
|
||||
const updateContent = async (path, content) => {
|
||||
const resp = await fetch(`${backendUrl}/v1/update`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ path, content }),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
setError("An error occurred when saving the file. Please try again.");
|
||||
}
|
||||
return await resp.json();
|
||||
};
|
||||
|
||||
// Send request to server to update the file. This will return nothing
|
||||
// so no need for any promise handling.
|
||||
updateContent(editing, newContent).then((data) => {
|
||||
if (data.code === 200) {
|
||||
setEditing("");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle the state for the content being modified in the text editor.
|
||||
*/
|
||||
const [editingFileContent, setEditingFileContent] = useState("");
|
||||
useEffect(() => {
|
||||
const fetchContent = async (path) => {
|
||||
const resp = await fetch(`${backendUrl}/v1/content?path=${path}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!resp.ok) {
|
||||
// TODO: Add this back in, its broken right now.
|
||||
setError("Something went wrong! Failed to get file content.")
|
||||
}
|
||||
return await resp.json();
|
||||
};
|
||||
|
||||
setContentLoading(true);
|
||||
// Prevent running when nothing is being edited. Also prevents a call on mount.
|
||||
if (editing) {
|
||||
// Fetch the data and handle errors accordingly
|
||||
fetchContent(editing).then((data) => {
|
||||
if (data.code === 200) {
|
||||
setEditingFileContent(data.content);
|
||||
} else {
|
||||
// An error occurred, do not open the editor
|
||||
setEditing("");
|
||||
setError(data.error);
|
||||
}
|
||||
}).finally(() => {
|
||||
setContentLoading(false)
|
||||
});
|
||||
}
|
||||
|
||||
setChildrenLoading(true);
|
||||
}, [editing]);
|
||||
|
||||
// If the token doesnt exit, update it and use the return value.
|
||||
// This is a silly work around to prevent the first render from not working
|
||||
// TODO: Fix this shit.
|
||||
let tkn = token ? token : updateToken();
|
||||
const toggleHidden = (e) => {
|
||||
setShowHidden(e.target.checked);
|
||||
};
|
||||
|
||||
getData(tkn).then((data) => {
|
||||
setFiles(data);
|
||||
}).finally(() => {
|
||||
setChildrenLoading(false);
|
||||
}).catch((err) => {
|
||||
setError("Failed to fetch data from server.");
|
||||
console.error(err);
|
||||
});
|
||||
/**
|
||||
* Toggle the upload modal.
|
||||
* This can be used in the navbar and the close button!
|
||||
*/
|
||||
const toggleUploading = () => {
|
||||
setUploading(!uploading);
|
||||
};
|
||||
|
||||
/**
|
||||
* This will be where the magic happens, where the files are upload
|
||||
* @param files {object[]}
|
||||
* TODO: Actually do something here...
|
||||
*/
|
||||
const upload = (files) => {
|
||||
const uploadFiles = async (_files) => {
|
||||
const formData = new FormData();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append('files', _files[i]); // 'files' is the field name
|
||||
}
|
||||
|
||||
// Add the current path to the form data.
|
||||
// formData.append('path', "/" + path.join("/"));
|
||||
formData.append('path', JSON.stringify(path));
|
||||
|
||||
const resp = await fetch(`${backendUrl}/v1/upload`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json()
|
||||
setError(data.error);
|
||||
return data;
|
||||
}
|
||||
return await resp.json();
|
||||
};
|
||||
|
||||
if (files.length === 0) {
|
||||
setError("Cannot upload nothing. Please select files to upload.");
|
||||
return;
|
||||
}
|
||||
|
||||
uploadFiles(files).then((data) => {
|
||||
if (data.code === 200) {
|
||||
setUploading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createDir = () => {
|
||||
setCreating(true);
|
||||
};
|
||||
|
||||
const closeCreate = () => {
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a directory or file in the backend
|
||||
* @param name {string} Name of new directory/file
|
||||
*/
|
||||
const createDirectory = (name) => {
|
||||
const create = async (name, cwd) => {
|
||||
const resp = await fetch(`${backendUrl}/v1/create`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ name, cwd })
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json()
|
||||
setError(data.error);
|
||||
return data;
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
};
|
||||
|
||||
create(name, path).then((data) => {
|
||||
if (data.code === 201) {
|
||||
setCreating(false);
|
||||
}
|
||||
}).catch((error) => {
|
||||
setError(error);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove the selected files.
|
||||
*/
|
||||
const removeSelected = () => {
|
||||
if (selected.length === 0) {
|
||||
return setError("Please select files or directories to delete");
|
||||
}
|
||||
|
||||
const remove = async (files) => {
|
||||
const resp = await fetch(`${backendUrl}/v1/delete`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ files, root: defaultPath })
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json()
|
||||
setError(data.error);
|
||||
return data;
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
};
|
||||
|
||||
// Files are stored as arrays of paths
|
||||
const files = [];
|
||||
for (const file of selected) {
|
||||
files.push([...path, file]);
|
||||
}
|
||||
|
||||
remove(files).then((data) => {
|
||||
if (data.code === 201) {
|
||||
console.log(data);
|
||||
setSelected([]);
|
||||
|
||||
}, [path, uploading, creating]);
|
||||
// Fetch the new files & deselect everything
|
||||
fetchFiles();
|
||||
}
|
||||
}).catch((error) => {
|
||||
setError(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen h-screen pb-8">
|
||||
<Navbar downloadFiles={downloadFiles} uploadFiles={toggleUploading} />
|
||||
<div className="h-full w-full flex flex-col items-center justify-center pb-8">
|
||||
{downloadLoading && <DownloadLoading />}
|
||||
{creating && <CreateDirectory close={closeCreate} create={createDirectory} />}
|
||||
{uploading && <Uploader close={toggleUploading} upload={upload} />}
|
||||
|
||||
// Redirect if the user isn't logged in, otherwise update the state.
|
||||
// Store the token in the storage, it should be attached to every request.
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem(storage_id) == null && sessionStorage.getItem(storage_id) == null) {
|
||||
navigate("/login");
|
||||
} else {
|
||||
updateToken();
|
||||
}
|
||||
}, [navigate]);
|
||||
{error && <Error error={error} clear={clearError} />}
|
||||
{(editing !== "" && !error) &&
|
||||
<Editor content={editingFileContent} path={editing} exit={exitFile} saveExit={exitAndSaveFile}
|
||||
loading={contentLoading} />}
|
||||
|
||||
/**
|
||||
* Updates the path by slicing [0:index]
|
||||
* @param index {number} Index to slice to.
|
||||
*/
|
||||
const updatePath = (index) => {
|
||||
let newPath = path.slice(0, index + 1);
|
||||
if (newPath.length < defaultPath.length) {
|
||||
newPath = [...defaultPath];
|
||||
}
|
||||
setPath(newPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Set the path back to the default.
|
||||
*/
|
||||
const backHome = () => {
|
||||
// TODO: Fix this in production
|
||||
setPath([...defaultPath]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add name to the path.
|
||||
* @param name Target child
|
||||
*/
|
||||
const appendPath = (name) => {
|
||||
setPath([...path, name])
|
||||
};
|
||||
|
||||
/**
|
||||
* Back arrow, goes back one directory (cd ..)
|
||||
*/
|
||||
const backArrow = () => {
|
||||
if (path.length > defaultPath.length) {
|
||||
setPath(path.slice(0, path.length - 1));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This isn't fast, but hopefully the use case will be small batches.
|
||||
* @param file {string} The file to toggle
|
||||
*/
|
||||
const toggleSelected = (file) => {
|
||||
if (!selected.includes(file)) {
|
||||
setSelected([...selected, file]);
|
||||
} else {
|
||||
const idx = selected.indexOf(file);
|
||||
setSelected([...selected.slice(0, idx), ...selected.slice(idx + 1, selected.length)])
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Callback function for when the download button is clicked.
|
||||
*/
|
||||
const downloadFiles = () => {
|
||||
// Do not allow empty downloads
|
||||
if (selected.length === 0) {
|
||||
setError("Please select files/directories to download.");
|
||||
return
|
||||
}
|
||||
|
||||
const download = async (paths) => {
|
||||
try {
|
||||
const resp = await fetch(`${backendUrl}/v1/download`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({filePaths: paths}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json();
|
||||
setError(`Error ${data.code}: ${data.error}`);
|
||||
} else {
|
||||
// TODO: Figure out how tf this works.
|
||||
const blob = await resp.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = "downloads.zip";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Download error: ${err}`);
|
||||
}
|
||||
};
|
||||
|
||||
setDownloadLoading(true);
|
||||
|
||||
const targets = [];
|
||||
selected.forEach((file) => {
|
||||
targets.push(`/${path.join("/")}/${file}`);
|
||||
});
|
||||
|
||||
// TODO: Implement UI for errors
|
||||
download(targets).catch((err) => {
|
||||
setError(`Download error: ${err}.`)
|
||||
}).finally(() => {
|
||||
setDownloadLoading(false)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the error in the error state.
|
||||
*/
|
||||
const clearError = () => {
|
||||
setError(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle editing of a file.
|
||||
* @param path {string}
|
||||
*/
|
||||
const toggleEditing = (path) => {
|
||||
setEditing(path);
|
||||
};
|
||||
|
||||
const exitFile = () => {
|
||||
setEditing("");
|
||||
};
|
||||
|
||||
const exitAndSaveFile = (newContent) => {
|
||||
const updateContent = async (path, content) => {
|
||||
const resp = await fetch(`${backendUrl}/v1/update`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({path, content}),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
setError("An error occurred when saving the file. Please try again.");
|
||||
}
|
||||
return await resp.json();
|
||||
};
|
||||
|
||||
// Send request to server to update the file. This will return nothing
|
||||
// so no need for any promise handling.
|
||||
updateContent(editing, newContent).then((data) => {
|
||||
if (data.code === 200) {
|
||||
setEditing("");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle the state for the content being modified in the text editor.
|
||||
*/
|
||||
const [editingFileContent, setEditingFileContent] = useState("");
|
||||
useEffect(() => {
|
||||
const fetchContent = async (path) => {
|
||||
const resp = await fetch(`${backendUrl}/v1/content?path=${path}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
if (!resp.ok) {
|
||||
// TODO: Add this back in, its broken right now.
|
||||
setError("Something went wrong! Failed to get file content.")
|
||||
}
|
||||
return await resp.json();
|
||||
};
|
||||
|
||||
setContentLoading(true);
|
||||
// Prevent running when nothing is being edited. Also prevents a call on mount.
|
||||
if (editing) {
|
||||
// Fetch the data and handle errors accordingly
|
||||
fetchContent(editing).then((data) => {
|
||||
if (data.code === 200) {
|
||||
setEditingFileContent(data.content);
|
||||
} else {
|
||||
// An error occurred, do not open the editor
|
||||
setEditing("");
|
||||
setError(data.error);
|
||||
}
|
||||
}).finally(() => {
|
||||
setContentLoading(false)
|
||||
});
|
||||
}
|
||||
|
||||
}, [editing]);
|
||||
|
||||
const toggleHidden = (e) => {
|
||||
setShowHidden(e.target.checked);
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggle the upload modal.
|
||||
* This can be used in the navbar and the close button!
|
||||
*/
|
||||
const toggleUploading = () => {
|
||||
setUploading(!uploading);
|
||||
};
|
||||
|
||||
/**
|
||||
* This will be where the magic happens, where the files are upload
|
||||
* @param files {object[]}
|
||||
* TODO: Actually do something here...
|
||||
*/
|
||||
const upload = (files) => {
|
||||
const uploadFiles = async (_files) => {
|
||||
const formData = new FormData();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
formData.append('files', _files[i]); // 'files' is the field name
|
||||
}
|
||||
|
||||
// Add the current path to the form data.
|
||||
// formData.append('path', "/" + path.join("/"));
|
||||
formData.append('path', JSON.stringify(path));
|
||||
|
||||
const resp = await fetch(`${backendUrl}/v1/upload`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json()
|
||||
setError(data.error);
|
||||
return data;
|
||||
}
|
||||
return await resp.json();
|
||||
};
|
||||
|
||||
if (files.length === 0) {
|
||||
setError("Cannot upload nothing. Please select files to upload.");
|
||||
return;
|
||||
}
|
||||
|
||||
uploadFiles(files).then((data) => {
|
||||
if (data.code === 200) {
|
||||
setUploading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createDir = () => {
|
||||
setCreating(true);
|
||||
};
|
||||
|
||||
const closeCreate = () => {
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a directory or file in the backend
|
||||
* @param name {string} Name of new directory/file
|
||||
*/
|
||||
const createDirectory = (name) => {
|
||||
const create = async (name, cwd) => {
|
||||
const resp = await fetch(`${backendUrl}/v1/create`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({name, cwd})
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json()
|
||||
setError(data.error);
|
||||
return data;
|
||||
}
|
||||
|
||||
return await resp.json();
|
||||
};
|
||||
|
||||
create(name, path).then((data) => {
|
||||
if (data.code === 201) {
|
||||
setCreating(false);
|
||||
}
|
||||
}).catch((error) => {
|
||||
setError(error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-screen h-screen pb-8">
|
||||
<Navbar downloadFiles={downloadFiles} uploadFiles={toggleUploading}/>
|
||||
<div className="h-full w-full flex flex-col items-center justify-center pb-8">
|
||||
{downloadLoading && <DownloadLoading/>}
|
||||
{creating && <CreateDirectory close={closeCreate} create={createDirectory}/>}
|
||||
{uploading && <Uploader close={toggleUploading} upload={upload}/>}
|
||||
|
||||
{error && <Error error={error} clear={clearError}/>}
|
||||
{(editing !== "" && !error) &&
|
||||
<Editor content={editingFileContent} path={editing} exit={exitFile} saveExit={exitAndSaveFile}
|
||||
loading={contentLoading}/>}
|
||||
|
||||
<PathDisplay path={path} updatePath={updatePath} backHome={backHome} backArrow={backArrow}
|
||||
enabled={path.length > defaultPath.length} create={createDir}/>
|
||||
<div className="w-2/3 h-5/6 overflow-y-auto border-1 border-gray-300">
|
||||
{childrenLoading && <ChildrenLoading/>}
|
||||
<DirectoryList dirs={files} showHidden={showHidden} appendPath={appendPath}
|
||||
toggleSelected={toggleSelected} toggleEditing={toggleEditing}/>
|
||||
</div>
|
||||
<div className="w-2/3 flex justify-end items-center">
|
||||
<label className="text-sm mx-2" htmlFor="showHiddenItems">Show Hidden Items</label>
|
||||
<input
|
||||
className="p-2"
|
||||
name="showHiddenItems"
|
||||
type="checkbox"
|
||||
checked={showHidden}
|
||||
onClick={toggleHidden}/>
|
||||
</div>
|
||||
</div>
|
||||
<PathDisplay path={path} updatePath={updatePath} backHome={backHome} backArrow={backArrow}
|
||||
enabled={path.length > defaultPath.length} create={createDir} remove={removeSelected}
|
||||
removeEnable={selected.length > 0} />
|
||||
<div className="w-2/3 h-5/6 overflow-y-auto border-1 border-gray-300">
|
||||
{childrenLoading && <ChildrenLoading />}
|
||||
<DirectoryList dirs={files} showHidden={showHidden} appendPath={appendPath}
|
||||
// TODO: Rework the toggleSelected functionality
|
||||
toggleSelected={toggleSelected} toggleEditing={toggleEditing} />
|
||||
</div>
|
||||
)
|
||||
<div className="w-2/3 flex justify-end items-center">
|
||||
<label className="text-sm mx-2" htmlFor="showHiddenItems">Show Hidden Items</label>
|
||||
<input
|
||||
className="p-2"
|
||||
name="showHiddenItems"
|
||||
type="checkbox"
|
||||
checked={showHidden}
|
||||
onClick={toggleHidden} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user