Compare commits
No commits in common. "f14e2c4781316265e079a8a3a72ac34b354dcbd2" and "a3ce8c8d60357bfecb4f5c031136e64fb386731d" have entirely different histories.
f14e2c4781
...
a3ce8c8d60
18
.github/workflows/deploy.yml
vendored
18
.github/workflows/deploy.yml
vendored
@ -36,3 +36,21 @@ jobs:
|
||||
file: ./backend/Dockerfile
|
||||
push: true
|
||||
tags: azpect3120/file.gophernest.backend:latest
|
||||
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@v1.2.1
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USERNAME }}
|
||||
password: ${{ secrets.SERVER_PASSWORD }}
|
||||
port: 22
|
||||
script: |
|
||||
docker-compose -f /home/azpect/Applications/file.gophernest/docker-compose.yml down
|
||||
docker-compose -f /home/azpect/Applications/file.gophernest/docker-compose.yml pull
|
||||
docker-compose -f /home/azpect/Applications/file.gophernest/docker-compose.yml up -d
|
||||
env:
|
||||
FILE_GOPHERNEST_USER: ${{ secrets.FILE_GOPHERNEST_USER }}
|
||||
FILE_GOPHERNEST_PASSWORD: ${{ secrets.FILE_GOPHERNEST_PASSWORD }}
|
||||
FILE_GOPHERNEST_JWT_SECRET: ${{ secrets.FILE_GOPHERNEST_JWT_SECRET }}
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@ -1,14 +1,12 @@
|
||||
# Future Plans
|
||||
|
||||
- [ ] Move files and directories
|
||||
- [x] Delete files and directories
|
||||
- [ ] Delete files and directories
|
||||
- [ ] Undo deleted files (using tmp files maybe, may require a DB)
|
||||
- [ ] Rename files
|
||||
- [ ] Dark/light mode toggle
|
||||
- [ ] Image display (will require some kind of file server)
|
||||
- [ ] Markdown rendering (quite a reach)
|
||||
- [x] Mobile friendly
|
||||
- [ ] Fix selection bug
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@ -1,33 +1,31 @@
|
||||
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 { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { entry } from "./entry";
|
||||
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";
|
||||
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!`);
|
||||
}
|
||||
@ -46,23 +44,13 @@ const corsOptions: cors.CorsOptions = {
|
||||
};
|
||||
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
|
||||
@ -87,22 +75,22 @@ v1.get("/healthcheck", (req: Request, res: Response): void => {
|
||||
*/
|
||||
v1.post("/login", (req: Request, res: Response): void => {
|
||||
// Get info from body
|
||||
const { username, password } = req.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." });
|
||||
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 });
|
||||
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!" });
|
||||
res.status(404).json({code: 404, message: "Invalid credentials. Please try again!"});
|
||||
}
|
||||
});
|
||||
|
||||
@ -139,16 +127,16 @@ v1.get("/children", (req: Request, res: Response): void => {
|
||||
*/
|
||||
v1.post("/download", (req: Request, res: Response): void => {
|
||||
// Get the files from the body
|
||||
const { filePaths } = req.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.' });
|
||||
res.status(400).send({code: 400, error: 'Invalid file paths provided.'});
|
||||
return;
|
||||
}
|
||||
|
||||
const archive: archiver.Archiver = archiver('zip', {
|
||||
zlib: { level: 9 }, // Compression leve
|
||||
zlib: {level: 9}, // Compression leve
|
||||
});
|
||||
|
||||
// Set the file headers
|
||||
@ -176,7 +164,7 @@ v1.post("/download", (req: Request, res: Response): void => {
|
||||
|
||||
// Return errors
|
||||
archive.on('error', (err): void => {
|
||||
res.status(500).send({ error: err.message });
|
||||
res.status(500).send({error: err.message});
|
||||
});
|
||||
|
||||
archive.finalize();
|
||||
@ -196,16 +184,16 @@ v1.get("/content", (req: Request, res: Response): void => {
|
||||
// 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 });
|
||||
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 });
|
||||
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 });
|
||||
res.status(500).json({error: `An error occurred on the server. ${err}`, code: 500});
|
||||
}
|
||||
});
|
||||
|
||||
@ -215,16 +203,22 @@ v1.get("/content", (req: Request, res: Response): void => {
|
||||
*/
|
||||
v1.post("/update", (req: Request, res: Response): void => {
|
||||
// Get path and content from the request
|
||||
const { path, content } = req.body;
|
||||
const {path, content} = req.body;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(path, content);
|
||||
res.status(200).json({ code: 200, message: "Success" });
|
||||
res.status(200).json({code: 200, message: "Success"});
|
||||
} catch (error) {
|
||||
res.status(500).json({ code: 500, error })
|
||||
res.status(500).json({code: 500, error})
|
||||
}
|
||||
});
|
||||
|
||||
const upload = Multer({
|
||||
dest: "tmp/",
|
||||
limits: {
|
||||
fileSize: 1024 * 1024 * 100
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Custom type for the multer uploads.
|
||||
@ -258,144 +252,55 @@ v1.post("/upload", upload.array("files"), (req: Request, res: Response) => {
|
||||
const newPath: string = path.join("/", ...cwd, file.originalname);
|
||||
|
||||
// Write the new file
|
||||
writeFileSync(newPath, data, { mode: "666" });
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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;
|
||||
const {cwd, name} = req.body;
|
||||
|
||||
try {
|
||||
const newPath: string = path.join("/", ...cwd, name);
|
||||
if (name.endsWith("/")) {
|
||||
mkdirSync(newPath, { recursive: true, mode: "666" })
|
||||
mkdirSync(newPath, {mode: "644"})
|
||||
} else {
|
||||
writeFileSync(newPath, "", { mode: "666" })
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
res.status(500).json({code: 500, error: "Error processing directory."}); // Generic error
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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.` });
|
||||
});
|
||||
|
||||
v1.post("/move", (req: Request, res: Response): void => {
|
||||
// Get the array of paths and the root
|
||||
const { oldPath, newPath } = req.body;
|
||||
|
||||
try {
|
||||
// Get path of new path
|
||||
const newDirectory = path.dirname(newPath);
|
||||
|
||||
// Ensure the directory exists
|
||||
if (!existsSync(newDirectory)) {
|
||||
mkdirSync(newDirectory, { recursive: true });
|
||||
console.log(`Created directory: ${newDirectory}`);
|
||||
}
|
||||
|
||||
// Move the file
|
||||
renameSync(oldPath, newPath);
|
||||
console.log(`File moved from ${oldPath} to ${newPath}`);
|
||||
|
||||
res.status(200).json({ code: 200 });
|
||||
} catch (err: any) {
|
||||
console.error("Error moving file:", err);
|
||||
// You might want to send a more specific error message based on 'err.code'
|
||||
if (err.code === 'EXDEV') {
|
||||
// This specific error means moving across different file systems.
|
||||
// Synchronous fallback would be more complex (read/write stream synchronously),
|
||||
// which is why async is preferred here. For synchronous, you'd generally
|
||||
// just fail or implement a blocking copy/delete.
|
||||
res.status(500).json({ code: 500, error: "Error: Cannot move across different file systems synchronously. Try copying and deleting." });
|
||||
} else if (err.code === 'ENOENT') {
|
||||
res.status(404).json({ code: 404, error: "Error: Source file or destination path not found." });
|
||||
} else {
|
||||
res.status(500).json({ code: 500, error: `Failed to move file: ${err.message}` });
|
||||
}
|
||||
}
|
||||
res.status(201).json({code: 201, message: "Success"});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -17,8 +17,7 @@ services:
|
||||
- "5000:5000"
|
||||
volumes:
|
||||
# TODO: This will need to be configured, need to rebuild part of the program for that though
|
||||
- /media/vault:/media/vault
|
||||
|
||||
- /home/azpect/Documents:/home/azpect/Documents
|
||||
environment:
|
||||
FILE_GOPHERNEST_USER: ${FILE_GOPHERNEST_USER}
|
||||
FILE_GOPHERNEST_PASSWORD: ${FILE_GOPHERNEST_PASSWORD}
|
||||
|
||||
@ -6,12 +6,12 @@ import "../index.css"
|
||||
* @constructor
|
||||
*/
|
||||
export default function ChildrenLoading() {
|
||||
return <>
|
||||
return (
|
||||
<div className="size-full flex items-center justify-center">
|
||||
<div
|
||||
className="animate-spin rounded-full border-blue-500 border-3 border-t-transparent size-6 mx-2 ">
|
||||
</div>
|
||||
<p className="text-lg text-black opacity-90">Content loading...</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -6,7 +6,7 @@ import "../index.css"
|
||||
* @constructor
|
||||
*/
|
||||
export default function ContentLoading() {
|
||||
return <>
|
||||
return (
|
||||
<div className="h-9/10 w-full flex flex-col items-center justify-center">
|
||||
<div className="flex">
|
||||
<div
|
||||
@ -16,5 +16,5 @@ export default function ContentLoading() {
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 my-2">For large files, this may take a while.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -7,23 +7,24 @@ import {useState} from "react";
|
||||
export default function CreateDirectory({close, create}) {
|
||||
const [dirName, setDirName] = useState("");
|
||||
|
||||
const createDirectory = () => create(dirName);
|
||||
const updateDirName = (e) => setDirName(e.target.value);
|
||||
const createDirectory = () => {
|
||||
create(dirName);
|
||||
}
|
||||
|
||||
const updateDirName = (e) => {
|
||||
setDirName(e.target.value);
|
||||
}
|
||||
|
||||
const closeWithoutSaving = () => {
|
||||
setDirName("");
|
||||
close();
|
||||
}
|
||||
|
||||
return <>
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Blured backdrop */}
|
||||
<div className="fixed -inset-10 bg-black opacity-50 blur-lg"></div>
|
||||
|
||||
<div className="relative z-50 bg-white p-8 rounded-lg shadow-lg w-9/10 lg:w-2/5 border-1 border-gray-400">
|
||||
<h2 className="text-2xl font-semibold mb-2 text-blue-400">
|
||||
Create a Directory
|
||||
</h2>
|
||||
<div className="relative z-50 bg-white p-8 rounded-lg shadow-lg w-2/5 border-1 border-gray-400">
|
||||
<h2 className="text-2xl font-semibold mb-2 text-blue-400">Create a Directory</h2>
|
||||
<p className="text-sm">
|
||||
Create a file or directory in the current directory. To create a directory, include a <span
|
||||
className="font-mono text-black bg-gray-300 p-1 rounded-md">/</span> at the end of the name.
|
||||
@ -50,5 +51,5 @@ export default function CreateDirectory({close, create}) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,26 +1,22 @@
|
||||
import "../index.css"
|
||||
import { useEffect, useState } from "react";
|
||||
import {useState} from "react";
|
||||
|
||||
function FileIcon() {
|
||||
return <>
|
||||
<svg className="text-black h-5 mx-2" stroke="currentColor" viewBox="0 0 24 24" fill="none"
|
||||
return <svg className="text-black h-5 mx-2" stroke="currentColor" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M19 9V17.8C19 18.9201 19 19.4802 18.782 19.908C18.5903 20.2843 18.2843 20.5903 17.908 20.782C17.4802 21 16.9201 21 15.8 21H8.2C7.07989 21 6.51984 21 6.09202 20.782C5.71569 20.5903 5.40973 20.2843 5.21799 19.908C5 19.4802 5 18.9201 5 17.8V6.2C5 5.07989 5 4.51984 5.21799 4.09202C5.40973 3.71569 5.71569 3.40973 6.09202 3.21799C6.51984 3 7.0799 3 8.2 3H13M19 9L13 3M19 9H14C13.4477 9 13 8.55228 13 8V3"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</>
|
||||
}
|
||||
|
||||
function DirectoryIcon() {
|
||||
return <>
|
||||
<svg className="text-black h-5 mx-2" stroke="currentColor" viewBox="0 0 24 24" fill="none"
|
||||
return <svg className="text-black h-5 mx-2" stroke="currentColor" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3 8.2C3 7.07989 3 6.51984 3.21799 6.09202C3.40973 5.71569 3.71569 5.40973 4.09202 5.21799C4.51984 5 5.0799 5 6.2 5H9.67452C10.1637 5 10.4083 5 10.6385 5.05526C10.8425 5.10425 11.0376 5.18506 11.2166 5.29472C11.4184 5.4184 11.5914 5.59135 11.9373 5.93726L12.0627 6.06274C12.4086 6.40865 12.5816 6.5816 12.7834 6.70528C12.9624 6.81494 13.1575 6.89575 13.3615 6.94474C13.5917 7 13.8363 7 14.3255 7H17.8C18.9201 7 19.4802 7 19.908 7.21799C20.2843 7.40973 20.5903 7.71569 20.782 8.09202C21 8.51984 21 9.0799 21 10.2V15.8C21 16.9201 21 17.4802 20.782 17.908C20.5903 18.2843 20.2843 18.5903 19.908 18.782C19.4802 19 18.9201 19 17.8 19H6.2C5.07989 19 4.51984 19 4.09202 18.782C3.71569 18.5903 3.40973 18.2843 3.21799 17.908C3 17.4802 3 16.9201 3 15.8V8.2Z"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -32,12 +28,8 @@ function DirectoryIcon() {
|
||||
* @returns {{}}
|
||||
* @constructor
|
||||
*/
|
||||
export default function Directory({ entry, selected, showHidden, appendPath, toggleSelected, toggleEditing }) {
|
||||
const [_selected, setSelected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(selected);
|
||||
}, [selected]);
|
||||
export default function Directory({entry, showHidden, appendPath, toggleSelected, toggleEditing}) {
|
||||
const [selected, setSelected] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
if (entry.directory) {
|
||||
@ -49,7 +41,7 @@ export default function Directory({ entry, selected, showHidden, appendPath, tog
|
||||
|
||||
const handleCheck = () => {
|
||||
toggleSelected(entry.name);
|
||||
setSelected(!_selected);
|
||||
setSelected(!selected);
|
||||
}
|
||||
|
||||
// This is temporary, eventually I will have a real data model that stores
|
||||
@ -59,20 +51,17 @@ export default function Directory({ entry, selected, showHidden, appendPath, tog
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <>
|
||||
<label className={`w-full hover:bg-gray-300 flex ${_selected ? "bg-blue-300" : "bg-gray-100"}`}>
|
||||
<input
|
||||
className="mx-2 peer checked:bg-blue-300"
|
||||
type="checkbox"
|
||||
checked={_selected}
|
||||
onChange={handleCheck} />
|
||||
return (
|
||||
<label className={`w-full hover:bg-gray-300 flex ${selected ? "bg-blue-300" : "bg-gray-100"}`}>
|
||||
<input className="mx-2 peer checked:bg-blue-300" type="checkbox" checked={selected}
|
||||
onChange={handleCheck}/>
|
||||
<div className="w-full flex bg-gray-100 peer-checked:bg-blue-300 hover:bg-gray-300 peer-hover:bg-gray-300">
|
||||
<button className="flex items-center" onClick={handleClick}>
|
||||
{entry.directory ? <DirectoryIcon /> : <FileIcon />}
|
||||
{entry.directory ? <DirectoryIcon/> : <FileIcon/>}
|
||||
<p className="p-2 hover:underline hover:text-blue-400">{entry.name}{entry.directory ? "/" : ""}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -2,25 +2,19 @@ import Directory from "./Directory.jsx";
|
||||
|
||||
/**
|
||||
* Display the directories in the current path.
|
||||
* @param diretories {string[]} - Children of the path.
|
||||
* @param showHidden {boolean} - Display hidden items.
|
||||
* @param appendPath {function(string)} - Function to add a child to the path.
|
||||
* @param toggleSelected {function(string)} - Function to toggle selection status.
|
||||
* @param selected {string[]} - List of selected names.
|
||||
* @param diretories {string[]} Children of the path.
|
||||
* @param showHidden {boolean} Display hidden items.
|
||||
* @param appendPath {function(string)} Function to add a child to the path.
|
||||
* @param toggleSelected {function(string)} Function to toggle selection status.
|
||||
* @constructor
|
||||
*/
|
||||
export default function DirectoryList({ dirs, showHidden, appendPath, toggleSelected, toggleEditing, selected }) {
|
||||
|
||||
return <>
|
||||
{dirs.map((dir, idx) =>
|
||||
<Directory
|
||||
key={idx}
|
||||
entry={dir}
|
||||
selected={selected.includes(dir.name)}
|
||||
showHidden={showHidden}
|
||||
appendPath={appendPath}
|
||||
toggleSelected={toggleSelected}
|
||||
toggleEditing={toggleEditing} />
|
||||
)}
|
||||
export default function DirectoryList({dirs, showHidden, appendPath, toggleSelected, toggleEditing}) {
|
||||
return (
|
||||
<>
|
||||
{dirs.map((dir, idx) => <Directory key={idx} entry={dir} showHidden={showHidden} appendPath={appendPath}
|
||||
toggleSelected={toggleSelected} toggleEditing={toggleEditing}/>)}
|
||||
</>
|
||||
)
|
||||
|
||||
|
||||
}
|
||||
@ -6,7 +6,7 @@ import "../index.css"
|
||||
* @constructor
|
||||
*/
|
||||
export default function DownloadLoading() {
|
||||
return <>
|
||||
return (
|
||||
<div className="absolute size-full flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black opacity-25 blur-lg"></div>
|
||||
<div
|
||||
@ -14,5 +14,5 @@ export default function DownloadLoading() {
|
||||
</div>
|
||||
<p className="text-lg text-black opacity-90">Preparing files...</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import ContentLoading from "./ContentLoading.jsx";
|
||||
|
||||
export default function Editor({ content, path, exit, saveExit, loading }) {
|
||||
export default function Editor({content, path, exit, saveExit, loading}) {
|
||||
const [text, setText] = useState("");
|
||||
/**
|
||||
* Store a reference to the text area object
|
||||
@ -9,7 +9,9 @@ export default function Editor({ content, path, exit, saveExit, loading }) {
|
||||
*/
|
||||
const textareaRef = useRef(null);
|
||||
|
||||
const updateText = (event) => setText(event.target.value);
|
||||
const updateText = (event) => {
|
||||
setText(event.target.value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setText(content);
|
||||
@ -35,7 +37,7 @@ export default function Editor({ content, path, exit, saveExit, loading }) {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + 2; // Move the cursor after the tab
|
||||
|
||||
// Trigger a change event so React knows the value changed.
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
textarea.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
}
|
||||
};
|
||||
|
||||
@ -43,20 +45,17 @@ export default function Editor({ content, path, exit, saveExit, loading }) {
|
||||
* Call the parent function with the new content which is
|
||||
* stored in the text state.
|
||||
*/
|
||||
const saveAndExit = () => saveExit(text);
|
||||
const saveAndExit = () => {
|
||||
saveExit(text);
|
||||
};
|
||||
|
||||
return <>
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Blured backdrop */}
|
||||
<div className="fixed inset-0 bg-black opacity-50 blur-lg"></div>
|
||||
|
||||
<div className="w-9/10 lg:w-3/4 h-5/6 relative z-10 bg-white p-4 lg:p-8 rounded-lg shadow-lg border-1 border-gray-400 flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4 text-blue-400">
|
||||
Editing File: <span className="font-mono text-black bg-gray-300 p-1 rounded-md">
|
||||
{path}
|
||||
</span>
|
||||
</h2>
|
||||
{loading && <ContentLoading />}
|
||||
<div className="relative z-10 bg-white p-8 rounded-lg shadow-lg w-3/4 h-5/6 border-1 border-gray-400">
|
||||
<h2 className="text-lg font-semibold mb-4 text-blue-400">Editing File: <span
|
||||
className="font-mono text-black bg-gray-300 p-1 rounded-md">{path}</span></h2>
|
||||
{loading && <ContentLoading/>}
|
||||
{loading ||
|
||||
<textarea
|
||||
onKeyDown={handleKeyPress}
|
||||
@ -64,10 +63,10 @@ export default function Editor({ content, path, exit, saveExit, loading }) {
|
||||
ref={textareaRef}
|
||||
onInput={updateText}
|
||||
value={text}
|
||||
className="border-1 border-gray-300 rounded-md w-full flex-grow p-1 resize-none text-sm font-mono">
|
||||
className="border-1 border-gray-300 rounded-md w-full h-9/10 p-1 resize-none text-sm font-mono">
|
||||
</textarea>
|
||||
}
|
||||
<div className="flex justify-end mt-4">
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
title="Exit without saving"
|
||||
onClick={exit}
|
||||
@ -83,5 +82,5 @@ export default function Editor({ content, path, exit, saveExit, loading }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -8,7 +8,7 @@ import "../index.css";
|
||||
* @constructor
|
||||
*/
|
||||
export default function Error({error, clear}) {
|
||||
return <>
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed inset-0 bg-black opacity-50 blur-lg"></div>
|
||||
<div className="relative z-50 bg-white p-8 rounded-lg shadow-lg w-96 border-1 border-gray-400">
|
||||
@ -24,5 +24,5 @@ export default function Error({error, clear}) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {useEffect, useState} from "react";
|
||||
import UserInput from "./UserInput.jsx";
|
||||
import PasswordInput from "./PasswordInput.jsx";
|
||||
import RememberMe from "./RememberMe.jsx";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
|
||||
|
||||
export default function LoginForm() {
|
||||
@ -68,7 +68,7 @@ export default function LoginForm() {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
body: JSON.stringify({username, password}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json();
|
||||
@ -80,7 +80,7 @@ export default function LoginForm() {
|
||||
};
|
||||
|
||||
sendAuthReq(username, password).then((data) => {
|
||||
const { code, token } = data;
|
||||
const {code, token} = data;
|
||||
|
||||
// Should always be 200, but just make sure it is
|
||||
if (code === 200) {
|
||||
@ -112,11 +112,10 @@ export default function LoginForm() {
|
||||
}, [loading]);
|
||||
|
||||
|
||||
return <>
|
||||
<form onSubmit={handleSubmit} className="w-full flex flex-col items-center justify-center">
|
||||
<UserInput onChange={updateUsername} />
|
||||
<PasswordInput onChange={updatePassword} />
|
||||
<RememberMe onChange={updateRemember} />
|
||||
return <form onSubmit={handleSubmit} className="w-full flex flex-col items-center justify-center">
|
||||
<UserInput onChange={updateUsername}/>
|
||||
<PasswordInput onChange={updatePassword}/>
|
||||
<RememberMe onChange={updateRemember}/>
|
||||
|
||||
{error && <p className="w-full text-red-500 text-sm my-2">{error}</p>}
|
||||
|
||||
@ -130,5 +129,4 @@ export default function LoginForm() {
|
||||
If you do not have an account, you're in the wrong place!
|
||||
</p>
|
||||
</form>
|
||||
</>
|
||||
};
|
||||
@ -1,57 +0,0 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Move a file or directory.
|
||||
* @constructor
|
||||
*/
|
||||
export default function MoveDirectory({ close, move, path }) {
|
||||
const [dirName, setDirName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setDirName("/" + path.join("/"));
|
||||
}, [path]);
|
||||
|
||||
const moveDirectory = () => move(dirName);
|
||||
const updateDirName = (e) => setDirName(e.target.value);
|
||||
|
||||
const closeWithoutSaving = () => {
|
||||
setDirName("");
|
||||
close();
|
||||
}
|
||||
|
||||
return <>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Blured backdrop */}
|
||||
<div className="fixed -inset-10 bg-black opacity-50 blur-lg"></div>
|
||||
|
||||
<div className="relative z-50 bg-white p-8 rounded-lg shadow-lg w-9/10 lg:w-2/5 border-1 border-gray-400">
|
||||
<h2 className="text-2xl font-semibold mb-2 text-blue-400">
|
||||
Move/Rename Directory
|
||||
</h2>
|
||||
<p className="text-sm">
|
||||
Move a file or directory from the current directory to a new location. This can be used to rename files
|
||||
if the base directory remains the same.
|
||||
</p>
|
||||
|
||||
<input className="border-b-2 border-blue-400 w-full p-2 my-4" type="text" name="directoryName"
|
||||
value={dirName}
|
||||
onInput={updateDirName}
|
||||
placeholder="Directory name" />
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={closeWithoutSaving}
|
||||
title="Close without creating"
|
||||
className="bg-red-500 hover:bg-red-600 duration-100 text-white text-sm font-semibold py-1.5 px-3 mt-2 mx-2 rounded hover:cursor-pointer">
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={moveDirectory}
|
||||
title="Move directory"
|
||||
className="bg-blue-400 hover:bg-blue-500 duration-100 text-white text-sm font-semibold py-1.5 px-3 mt-2 rounded hover:cursor-pointer">
|
||||
Move
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import "../index.css"
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {useState} from "react";
|
||||
import {useNavigate} from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Main navbar icon
|
||||
@ -9,13 +9,11 @@ import { useNavigate } from "react-router-dom";
|
||||
* @constructor
|
||||
*/
|
||||
function MainIcon() {
|
||||
return <>
|
||||
<svg className="h-8 lg:h-10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
return <svg className="h-10" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 9.5V15.5M12 9.5L10 11.5M12 9.5L14 11.5M8.4 19C5.41766 19 3 16.6044 3 13.6493C3 11.2001 4.8 8.9375 7.5 8.5C8.34694 6.48637 10.3514 5 12.6893 5C15.684 5 18.1317 7.32251 18.3 10.25C19.8893 10.9449 21 12.6503 21 14.4969C21 16.9839 18.9853 19 16.5 19L8.4 19Z"
|
||||
stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -24,19 +22,19 @@ function MainIcon() {
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
function DownloadButton({ downloadFiles }) {
|
||||
return <>
|
||||
function DownloadButton({downloadFiles}) {
|
||||
return (
|
||||
<button className="text-black" title="Download files" onClick={downloadFiles}>
|
||||
<svg className="hover:bg-gray-300 mx-1 transition-colors duration-200 p-1.5 rounded-full h-8"
|
||||
viewBox="0 0 24 24" fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12.5535 16.5061C12.4114 16.6615 12.2106 16.75 12 16.75C11.7894 16.75 11.5886 16.6615 11.4465 16.5061L7.44648 12.1311C7.16698 11.8254 7.18822 11.351 7.49392 11.0715C7.79963 10.792 8.27402 10.8132 8.55352 11.1189L11.25 14.0682V3C11.25 2.58579 11.5858 2.25 12 2.25C12.4142 2.25 12.75 2.58579 12.75 3V14.0682L15.4465 11.1189C15.726 10.8132 16.2004 10.792 16.5061 11.0715C16.8118 11.351 16.833 11.8254 16.5535 12.1311L12.5535 16.5061Z" />
|
||||
d="M12.5535 16.5061C12.4114 16.6615 12.2106 16.75 12 16.75C11.7894 16.75 11.5886 16.6615 11.4465 16.5061L7.44648 12.1311C7.16698 11.8254 7.18822 11.351 7.49392 11.0715C7.79963 10.792 8.27402 10.8132 8.55352 11.1189L11.25 14.0682V3C11.25 2.58579 11.5858 2.25 12 2.25C12.4142 2.25 12.75 2.58579 12.75 3V14.0682L15.4465 11.1189C15.726 10.8132 16.2004 10.792 16.5061 11.0715C16.8118 11.351 16.833 11.8254 16.5535 12.1311L12.5535 16.5061Z"/>
|
||||
<path
|
||||
d="M3.75 15C3.75 14.5858 3.41422 14.25 3 14.25C2.58579 14.25 2.25 14.5858 2.25 15V15.0549C2.24998 16.4225 2.24996 17.5248 2.36652 18.3918C2.48754 19.2919 2.74643 20.0497 3.34835 20.6516C3.95027 21.2536 4.70814 21.5125 5.60825 21.6335C6.47522 21.75 7.57754 21.75 8.94513 21.75H15.0549C16.4225 21.75 17.5248 21.75 18.3918 21.6335C19.2919 21.5125 20.0497 21.2536 20.6517 20.6516C21.2536 20.0497 21.5125 19.2919 21.6335 18.3918C21.75 17.5248 21.75 16.4225 21.75 15.0549V15C21.75 14.5858 21.4142 14.25 21 14.25C20.5858 14.25 20.25 14.5858 20.25 15C20.25 16.4354 20.2484 17.4365 20.1469 18.1919C20.0482 18.9257 19.8678 19.3142 19.591 19.591C19.3142 19.8678 18.9257 20.0482 18.1919 20.1469C17.4365 20.2484 16.4354 20.25 15 20.25H9C7.56459 20.25 6.56347 20.2484 5.80812 20.1469C5.07435 20.0482 4.68577 19.8678 4.40901 19.591C4.13225 19.3142 3.9518 18.9257 3.85315 18.1919C3.75159 17.4365 3.75 16.4354 3.75 15Z" />
|
||||
d="M3.75 15C3.75 14.5858 3.41422 14.25 3 14.25C2.58579 14.25 2.25 14.5858 2.25 15V15.0549C2.24998 16.4225 2.24996 17.5248 2.36652 18.3918C2.48754 19.2919 2.74643 20.0497 3.34835 20.6516C3.95027 21.2536 4.70814 21.5125 5.60825 21.6335C6.47522 21.75 7.57754 21.75 8.94513 21.75H15.0549C16.4225 21.75 17.5248 21.75 18.3918 21.6335C19.2919 21.5125 20.0497 21.2536 20.6517 20.6516C21.2536 20.0497 21.5125 19.2919 21.6335 18.3918C21.75 17.5248 21.75 16.4225 21.75 15.0549V15C21.75 14.5858 21.4142 14.25 21 14.25C20.5858 14.25 20.25 14.5858 20.25 15C20.25 16.4354 20.2484 17.4365 20.1469 18.1919C20.0482 18.9257 19.8678 19.3142 19.591 19.591C19.3142 19.8678 18.9257 20.0482 18.1919 20.1469C17.4365 20.2484 16.4354 20.25 15 20.25H9C7.56459 20.25 6.56347 20.2484 5.80812 20.1469C5.07435 20.0482 4.68577 19.8678 4.40901 19.591C4.13225 19.3142 3.9518 18.9257 3.85315 18.1919C3.75159 17.4365 3.75 16.4354 3.75 15Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -45,19 +43,19 @@ function DownloadButton({ downloadFiles }) {
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
function UploadButton({ uploadFiles }) {
|
||||
return <>
|
||||
function UploadButton({uploadFiles}) {
|
||||
return (
|
||||
<button className="text-black" title="Upload files" onClick={uploadFiles}>
|
||||
<svg className="hover:bg-gray-300 mx-1 transition-colors duration-200 p-1.5 rounded-full font-semibold h-8"
|
||||
viewBox="0 0 24 24" fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12.5535 2.49392C12.4114 2.33852 12.2106 2.25 12 2.25C11.7894 2.25 11.5886 2.33852 11.4465 2.49392L7.44648 6.86892C7.16698 7.17462 7.18822 7.64902 7.49392 7.92852C7.79963 8.20802 8.27402 8.18678 8.55352 7.88108L11.25 4.9318V16C11.25 16.4142 11.5858 16.75 12 16.75C12.4142 16.75 12.75 16.4142 12.75 16V4.9318L15.4465 7.88108C15.726 8.18678 16.2004 8.20802 16.5061 7.92852C16.8118 7.64902 16.833 7.17462 16.5535 6.86892L12.5535 2.49392Z" />
|
||||
d="M12.5535 2.49392C12.4114 2.33852 12.2106 2.25 12 2.25C11.7894 2.25 11.5886 2.33852 11.4465 2.49392L7.44648 6.86892C7.16698 7.17462 7.18822 7.64902 7.49392 7.92852C7.79963 8.20802 8.27402 8.18678 8.55352 7.88108L11.25 4.9318V16C11.25 16.4142 11.5858 16.75 12 16.75C12.4142 16.75 12.75 16.4142 12.75 16V4.9318L15.4465 7.88108C15.726 8.18678 16.2004 8.20802 16.5061 7.92852C16.8118 7.64902 16.833 7.17462 16.5535 6.86892L12.5535 2.49392Z"/>
|
||||
<path
|
||||
d="M3.75 15C3.75 14.5858 3.41422 14.25 3 14.25C2.58579 14.25 2.25 14.5858 2.25 15V15.0549C2.24998 16.4225 2.24996 17.5248 2.36652 18.3918C2.48754 19.2919 2.74643 20.0497 3.34835 20.6516C3.95027 21.2536 4.70814 21.5125 5.60825 21.6335C6.47522 21.75 7.57754 21.75 8.94513 21.75H15.0549C16.4225 21.75 17.5248 21.75 18.3918 21.6335C19.2919 21.5125 20.0497 21.2536 20.6517 20.6516C21.2536 20.0497 21.5125 19.2919 21.6335 18.3918C21.75 17.5248 21.75 16.4225 21.75 15.0549V15C21.75 14.5858 21.4142 14.25 21 14.25C20.5858 14.25 20.25 14.5858 20.25 15C20.25 16.4354 20.2484 17.4365 20.1469 18.1919C20.0482 18.9257 19.8678 19.3142 19.591 19.591C19.3142 19.8678 18.9257 20.0482 18.1919 20.1469C17.4365 20.2484 16.4354 20.25 15 20.25H9C7.56459 20.25 6.56347 20.2484 5.80812 20.1469C5.07435 20.0482 4.68577 19.8678 4.40901 19.591C4.13225 19.3142 3.9518 18.9257 3.85315 18.1919C3.75159 17.4365 3.75 16.4354 3.75 15Z" />
|
||||
d="M3.75 15C3.75 14.5858 3.41422 14.25 3 14.25C2.58579 14.25 2.25 14.5858 2.25 15V15.0549C2.24998 16.4225 2.24996 17.5248 2.36652 18.3918C2.48754 19.2919 2.74643 20.0497 3.34835 20.6516C3.95027 21.2536 4.70814 21.5125 5.60825 21.6335C6.47522 21.75 7.57754 21.75 8.94513 21.75H15.0549C16.4225 21.75 17.5248 21.75 18.3918 21.6335C19.2919 21.5125 20.0497 21.2536 20.6517 20.6516C21.2536 20.0497 21.5125 19.2919 21.6335 18.3918C21.75 17.5248 21.75 16.4225 21.75 15.0549V15C21.75 14.5858 21.4142 14.25 21 14.25C20.5858 14.25 20.25 14.5858 20.25 15C20.25 16.4354 20.2484 17.4365 20.1469 18.1919C20.0482 18.9257 19.8678 19.3142 19.591 19.591C19.3142 19.8678 18.9257 20.0482 18.1919 20.1469C17.4365 20.2484 16.4354 20.25 15 20.25H9C7.56459 20.25 6.56347 20.2484 5.80812 20.1469C5.07435 20.0482 4.68577 19.8678 4.40901 19.591C4.13225 19.3142 3.9518 18.9257 3.85315 18.1919C3.75159 17.4365 3.75 16.4354 3.75 15Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -66,19 +64,19 @@ function UploadButton({ uploadFiles }) {
|
||||
* @constructor
|
||||
*/
|
||||
function InfoButton() {
|
||||
return <>
|
||||
return (
|
||||
<button className="text-black" title="Filesystem information">
|
||||
<svg className="hover:bg-gray-300 mx-1 transition-colors duration-200 p-1.5 rounded-full font-semibold h-8"
|
||||
viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M12 17.75C12.4142 17.75 12.75 17.4142 12.75 17V11C12.75 10.5858 12.4142 10.25 12 10.25C11.5858 10.25 11.25 10.5858 11.25 11V17C11.25 17.4142 11.5858 17.75 12 17.75Z" />
|
||||
d="M12 17.75C12.4142 17.75 12.75 17.4142 12.75 17V11C12.75 10.5858 12.4142 10.25 12 10.25C11.5858 10.25 11.25 10.5858 11.25 11V17C11.25 17.4142 11.5858 17.75 12 17.75Z"/>
|
||||
<path
|
||||
d="M12 7C12.5523 7 13 7.44772 13 8C13 8.55228 12.5523 9 12 9C11.4477 9 11 8.55228 11 8C11 7.44772 11.4477 7 12 7Z" />
|
||||
d="M12 7C12.5523 7 13 7.44772 13 8C13 8.55228 12.5523 9 12 9C11.4477 9 11 8.55228 11 8C11 7.44772 11.4477 7 12 7Z"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd"
|
||||
d="M1.25 12C1.25 6.06294 6.06294 1.25 12 1.25C17.9371 1.25 22.75 6.06294 22.75 12C22.75 17.9371 17.9371 22.75 12 22.75C6.06294 22.75 1.25 17.9371 1.25 12ZM12 2.75C6.89137 2.75 2.75 6.89137 2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1086 2.75 12 2.75Z" />
|
||||
d="M1.25 12C1.25 6.06294 6.06294 1.25 12 1.25C17.9371 1.25 22.75 6.06294 22.75 12C22.75 17.9371 17.9371 22.75 12 22.75C6.06294 22.75 1.25 17.9371 1.25 12ZM12 2.75C6.89137 2.75 2.75 6.89137 2.75 12C2.75 17.1086 6.89137 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1086 2.75 12 2.75Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -102,17 +100,17 @@ function LogoutButton() {
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
return <>
|
||||
return (
|
||||
<button className="text-red-500" title="Logout" onClick={handleClick}>
|
||||
<svg
|
||||
className="text-red-500 hover:bg-red-200 mx-1 transition-colors duration-200 p-1.5 rounded-full font-semibold h-8"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M15 4H18C19.1046 4 20 4.89543 20 6V18C20 19.1046 19.1046 20 18 20H15M8 8L4 12M4 12L8 16M4 12L16 12"
|
||||
strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -127,35 +125,34 @@ function SearchBar() {
|
||||
* Update the controlled state.
|
||||
* @param event {InputEvent}
|
||||
*/
|
||||
const updateSearch = (event) => setSearch(event.target.value);
|
||||
const updateSearch = (event) => {
|
||||
setSearch(event.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-4 w-1/2 lg:w-1/4">
|
||||
<div className="mx-4 w-1/4">
|
||||
<input
|
||||
className="px-2 py-1 w-full text-sm focus:outline-none focus:shadow-sm shadow-blue-300 transition-shadow duration-200 rounded-sm border-1 border-gray-400"
|
||||
type="search"
|
||||
value={search}
|
||||
onInput={updateSearch}
|
||||
placeholder="Search filesystem" />
|
||||
placeholder="Search filesystem"/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Navbar({ downloadFiles, uploadFiles }) {
|
||||
return <>
|
||||
<nav className="absolute w-full p-2 flex items-center border-b-1 border-gray-400 bg-gray-100">
|
||||
<MainIcon />
|
||||
export default function Navbar({downloadFiles, uploadFiles}) {
|
||||
return <nav className="absolute w-full p-2 flex items-center border-b-1 border-gray-400 bg-gray-100">
|
||||
<MainIcon/>
|
||||
|
||||
<h3 className="hidden lg:block text-xl font-mono px-3">file.gophernest.net</h3>
|
||||
|
||||
<SearchBar />
|
||||
<h3 className="text-xl font-mono px-3">file.gophernest.net</h3>
|
||||
<SearchBar/>
|
||||
|
||||
<div className="min-h-fit ml-auto flex">
|
||||
<DownloadButton downloadFiles={downloadFiles} />
|
||||
<UploadButton uploadFiles={uploadFiles} />
|
||||
<InfoButton />
|
||||
<LogoutButton />
|
||||
<DownloadButton downloadFiles={downloadFiles}/>
|
||||
<UploadButton uploadFiles={uploadFiles}/>
|
||||
<InfoButton/>
|
||||
<LogoutButton/>
|
||||
</div>
|
||||
</nav>
|
||||
</>;
|
||||
}
|
||||
@ -18,7 +18,9 @@ export default function PasswordInput({onChange}) {
|
||||
/**
|
||||
* Toggle the hidden state which will determine the type of the input.
|
||||
*/
|
||||
const toggleHidden = () => setHidden(!hidden);
|
||||
const toggleHidden = () => {
|
||||
setHidden(!hidden);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the password
|
||||
|
||||
@ -2,12 +2,11 @@
|
||||
* 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
|
||||
*/
|
||||
function HomeButton({ onClick, enabled }) {
|
||||
return <>
|
||||
function HomeButton({onClick, enabled}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={!enabled}
|
||||
@ -16,28 +15,28 @@ function HomeButton({ onClick, enabled }) {
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor">
|
||||
<path d="M1 6V15H6V11C6 9.89543 6.89543 9 8 9C9.10457 9 10 9.89543 10 11V15H15V6L8 0L1 6Z" />
|
||||
<path d="M1 6V15H6V11C6 9.89543 6.89543 9 8 9C9.10457 9 10 9.89543 10 11V15H15V6L8 0L1 6Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BackButton({ onClick, enabled }) {
|
||||
return <>
|
||||
function BackButton({onClick, enabled}) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
disabled={!enabled}
|
||||
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="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M4 10L3.29289 10.7071L2.58579 10L3.29289 9.29289L4 10ZM21 18C21 18.5523 20.5523 19 20 19C19.4477 19 19 18.5523 19 18L21 18ZM8.29289 15.7071L3.29289 10.7071L4.70711 9.29289L9.70711 14.2929L8.29289 15.7071ZM3.29289 9.29289L8.29289 4.29289L9.70711 5.70711L4.70711 10.7071L3.29289 9.29289ZM4 9L14 9L14 11L4 11L4 9ZM21 16L21 18L19 18L19 16L21 16ZM14 9C17.866 9 21 12.134 21 16L19 16C19 13.2386 16.7614 11 14 11L14 9Z" />
|
||||
d="M4 10L3.29289 10.7071L2.58579 10L3.29289 9.29289L4 10ZM21 18C21 18.5523 20.5523 19 20 19C19.4477 19 19 18.5523 19 18L21 18ZM8.29289 15.7071L3.29289 10.7071L4.70711 9.29289L9.70711 14.2929L8.29289 15.7071ZM3.29289 9.29289L8.29289 4.29289L9.70711 5.70711L4.70711 10.7071L3.29289 9.29289ZM4 9L14 9L14 11L4 11L4 9ZM21 16L21 18L19 18L19 16L21 16ZM14 9C17.866 9 21 12.134 21 16L19 16C19 13.2386 16.7614 11 14 11L14 9Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function CreateButton({ onClick }) {
|
||||
return <>
|
||||
function CreateButton({onClick}) {
|
||||
return (
|
||||
<button onClick={onClick}
|
||||
title="Create a file/directory"
|
||||
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">
|
||||
@ -45,41 +44,10 @@ function CreateButton({ onClick }) {
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M13 3H8.2C7.0799 3 6.51984 3 6.09202 3.21799C5.71569 3.40973 5.40973 3.71569 5.21799 4.09202C5 4.51984 5 5.0799 5 6.2V17.8C5 18.9201 5 19.4802 5.21799 19.908C5.40973 20.2843 5.71569 20.5903 6.09202 20.782C6.51984 21 7.0799 21 8.2 21H10M13 3L19 9M13 3V7.4C13 7.96005 13 8.24008 13.109 8.45399C13.2049 8.64215 13.3578 8.79513 13.546 8.89101C13.7599 9 14.0399 9 14.6 9H19M19 9V10M14 21L16.025 20.595C16.2015 20.5597 16.2898 20.542 16.3721 20.5097C16.4452 20.4811 16.5147 20.4439 16.579 20.399C16.6516 20.3484 16.7152 20.2848 16.8426 20.1574L21 16C21.5523 15.4477 21.5523 14.5523 21 14C20.4477 13.4477 19.5523 13.4477 19 14L14.8426 18.1574C14.7152 18.2848 14.6516 18.3484 14.601 18.421C14.5561 18.4853 14.5189 18.5548 14.4903 18.6279C14.458 18.7102 14.4403 18.7985 14.405 18.975L14 21Z"
|
||||
stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
stroke="#000000" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
}
|
||||
|
||||
function MoveButton({ onClick, enabled }) {
|
||||
return <>
|
||||
<button onClick={onClick}
|
||||
disabled={!enabled}
|
||||
title="Moved a selected file/directory"
|
||||
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="M12 21V3M12 21L14 19M12 21L10 19M12 3L14 5M12 3L10 5M21 12H3M21 12L19 10M21 12L19 14M3 12L5 10M3 12L5 14"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@ -90,15 +58,11 @@ function MoveButton({ onClick, enabled }) {
|
||||
* @returns {JSX.Element}
|
||||
* @constructor
|
||||
*/
|
||||
function PathElement({ name, index, onClick }) {
|
||||
const handleClick = () => onClick(index);
|
||||
return <>
|
||||
<button onClick={handleClick}>
|
||||
/<span className="hover:underline cursor-pointer">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</>
|
||||
function PathElement({name, index, onClick}) {
|
||||
const handleClick = () => {
|
||||
onClick(index);
|
||||
};
|
||||
return <button onClick={handleClick}>/<span className="hover:underline cursor-pointer">{name}</span></button>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -109,26 +73,19 @@ 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, remove, removeEnable, move, moveEnabled }) {
|
||||
return <>
|
||||
<div className="w-9/10 lg:w-2/3 mt-8 border-b-1 border-gray-400 bg-white flex items-center truncate">
|
||||
<HomeButton onClick={backHome} enabled={enabled} />
|
||||
<BackButton onClick={backArrow} enabled={enabled} />
|
||||
{path.map((seg, idx) => <PathElement name={seg} key={idx} index={idx} onClick={updatePath} />)}
|
||||
export default function PathDisplay({path, updatePath, backHome, backArrow, enabled, create}) {
|
||||
return (
|
||||
<div
|
||||
className="w-2/3 mt-8 border-b-1 border-gray-400 bg-white flex items-center truncate">
|
||||
<HomeButton onClick={backHome} enabled={enabled}/>
|
||||
<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">
|
||||
<MoveButton onClick={move} enabled={moveEnabled} />
|
||||
</div>
|
||||
<div className="h-full flex items-center">
|
||||
<CreateButton onClick={create} />
|
||||
<CreateButton onClick={create}/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -20,10 +20,8 @@ export default function RememberMe({onChange}) {
|
||||
setRemember(!remember);
|
||||
};
|
||||
|
||||
return <>
|
||||
<div className="w-full flex items-center my-2" onClick={toggleRemember}>
|
||||
return <div className="w-full flex items-center my-2" onClick={toggleRemember}>
|
||||
<input name={rememberMeId} checked={remember} type="checkbox" className="mx-1"/>
|
||||
<label htmlFor={rememberMeId} className="text-sm select-none cursor-pointer">Remember me for 30 days</label>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
@ -46,7 +46,9 @@ export default function Uploader({close, upload}) {
|
||||
* Prevent the default drag behavior.
|
||||
* @param e {object} Event object from the div wrapper.
|
||||
*/
|
||||
const dragOver = (e) => e.preventDefault();
|
||||
const dragOver = (e) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
/**
|
||||
* When files are dropped into the div, this will be called, to append the files.
|
||||
@ -62,20 +64,26 @@ export default function Uploader({close, upload}) {
|
||||
* Click event for the input element.
|
||||
* Uses the ref hook to access the element.
|
||||
*/
|
||||
const click = () => inputElement.current.click();
|
||||
const click = () => {
|
||||
inputElement.current.click();
|
||||
};
|
||||
|
||||
/**
|
||||
* When the input changes, append the new files.
|
||||
* @param e {object} Event object from the input.
|
||||
*/
|
||||
const inputChange = (e) => setFiles([...files, ...e.target.files]);
|
||||
const inputChange = (e) => {
|
||||
setFiles([...files, ...e.target.files]);
|
||||
};
|
||||
|
||||
const uploadFiles = () => upload(files);
|
||||
const uploadFiles = () => {
|
||||
upload(files);
|
||||
};
|
||||
|
||||
return <>
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div className="fixed -inset-10 bg-black opacity-50 blur-lg"></div>
|
||||
<div className="relative z-50 bg-white p-8 rounded-lg shadow-lg w-9/10 lg:w-2/5 border-1 border-gray-400">
|
||||
<div className="relative z-50 bg-white p-8 rounded-lg shadow-lg w-2/5 border-1 border-gray-400">
|
||||
<h2 className="text-2xl font-semibold mb-2 text-blue-400">Upload Files</h2>
|
||||
<p className="text-sm">
|
||||
Files uploaded will be added to the current directory. Currently, directory
|
||||
@ -86,13 +94,7 @@ export default function Uploader({close, upload}) {
|
||||
className="my-5 border-2 border-dashed p-8 rounded-md text-center cursor-pointer border-gray-400 hover:bg-blue-100 hover:border-blue-500 transition-all duration-100"
|
||||
onDragEnter={dragEnter} onDragLeave={dragLeave} onDragOver={dragOver} onDrop={drop} onClick={click}
|
||||
>
|
||||
<input
|
||||
multiple
|
||||
type="file"
|
||||
className="hidden"
|
||||
ref={inputElement}
|
||||
onChange={inputChange}
|
||||
/>
|
||||
<input multiple type="file" className="hidden" ref={inputElement} onChange={inputChange}/>
|
||||
<p className="italic">Drag and drop files here or click to select</p>
|
||||
</div>
|
||||
<FileList files={files}/>
|
||||
@ -112,5 +114,5 @@ export default function Uploader({close, upload}) {
|
||||
</div>
|
||||
</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";
|
||||
@ -9,71 +9,46 @@ import ChildrenLoading from "../components/ChildrenLoading.jsx";
|
||||
import DownloadLoading from "../components/DownloadLoading.jsx";
|
||||
import Uploader from "../components/Uploader.jsx";
|
||||
import CreateDirectory from "../components/CreateDirectory.jsx";
|
||||
import MoveDirectory from "../components/MoveDirectory.jsx";
|
||||
|
||||
export default function Dashboard() {
|
||||
// ---- CONSTANTS ---- //
|
||||
/**
|
||||
* Default path
|
||||
* Uses the .env var in local development, but when
|
||||
* pushed to dockerhub, the .env is ignored and the
|
||||
* default path is used.
|
||||
*/
|
||||
const defaultPath = (import.meta.env.VITE_DEFAULT_PATH || "media,vault").split(',');
|
||||
// Store the default path
|
||||
const defaultPath = ["media", "vault"];
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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";
|
||||
|
||||
/**
|
||||
* The name of the value stored in local storage.
|
||||
*/
|
||||
const storage_id = "gophernest_credentials";
|
||||
|
||||
|
||||
// ---- STATE ---- //
|
||||
|
||||
// General state
|
||||
const [token, setToken] = useState(null);
|
||||
const [path, setPath] = useState([...defaultPath]);
|
||||
const [files, setFiles] = useState([]);
|
||||
|
||||
// User settings
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [selected, setSelected] = useState([]);
|
||||
|
||||
// Modals
|
||||
const [files, setFiles] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [editing, setEditing] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [moving, setMoving] = useState(false);
|
||||
|
||||
|
||||
// Loading spinners
|
||||
const [childrenLoading, setChildrenLoading] = useState(false);
|
||||
const [downloadLoading, setDownloadLoading] = useState(false);
|
||||
const [contentLoading, setContentLoading] = useState(false);
|
||||
|
||||
|
||||
// Handle the state for the content being modified in the text editor.
|
||||
const [editingFileContent, setEditingFileContent] = useState("");
|
||||
|
||||
// Other
|
||||
const navigate = useNavigate();
|
||||
|
||||
// ---- FUNCTIONS ---- //
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns {string | null} Token from browser storage
|
||||
*/
|
||||
const updateToken = () => {
|
||||
const t = localStorage.getItem(storage_id) ? localStorage.getItem(storage_id) : sessionStorage.getItem(storage_id);
|
||||
@ -81,194 +56,7 @@ export default function Dashboard() {
|
||||
return t;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the error in the error state.
|
||||
*/
|
||||
const clearError = () => setError(null);
|
||||
|
||||
/**
|
||||
* Toggle editing of a file.
|
||||
* @param path {string}
|
||||
*/
|
||||
const toggleEditing = (path) => setEditing(path);
|
||||
|
||||
/**
|
||||
* Close the file that is being edited.
|
||||
*/
|
||||
const exitFile = () => setEditing("");
|
||||
|
||||
/**
|
||||
* Show the hidden files
|
||||
*/
|
||||
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);
|
||||
|
||||
/**
|
||||
* Show the creating modal.
|
||||
*/
|
||||
const createDir = () => {
|
||||
setSelected([]);
|
||||
setCreating(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the creating modal.
|
||||
*/
|
||||
const closeCreate = () => setCreating(false);
|
||||
|
||||
/**
|
||||
* Hide the moving modal.
|
||||
*/
|
||||
const closeMoving = () => setMoving(false);
|
||||
|
||||
/**
|
||||
* Show the moving modal.
|
||||
*/
|
||||
const showMoving = () => setMoving(true);
|
||||
|
||||
// ---- HANDLERS --- //
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* TODO: Fix this in production? Not sure why
|
||||
*/
|
||||
const backHome = () => setPath([...defaultPath]);
|
||||
|
||||
/**
|
||||
* Add name to the path.
|
||||
* @param name {string} - 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));
|
||||
}
|
||||
}
|
||||
|
||||
const exitAndSaveFile = (newContent) => {
|
||||
// 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("");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
setDownloadLoading(true);
|
||||
|
||||
const targets = selected.map((file) => `/${path.join("/")}/${file}`);
|
||||
|
||||
download(targets).catch((err) => {
|
||||
setError(`Download error: ${err}.`)
|
||||
}).finally(() => {
|
||||
setDownloadLoading(false)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a directory or file in the backend
|
||||
* @param name {string} Name of new file or directory
|
||||
*/
|
||||
const createDirectory = (name) => {
|
||||
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) {
|
||||
setError("Please select files or directories to delete");
|
||||
return;
|
||||
}
|
||||
|
||||
// Files are stored as arrays of paths
|
||||
const files = selected.map((file) => [...path, file]);
|
||||
|
||||
remove(files).then((data) => {
|
||||
if (data.code === 201) {
|
||||
setSelected([]);
|
||||
|
||||
// Fetch the new files & deselect everything
|
||||
fetchFiles();
|
||||
}
|
||||
}).catch((error) => {
|
||||
setError(error);
|
||||
});
|
||||
};
|
||||
|
||||
const moveSelected = (newPath) => {
|
||||
const oldPath = "/" + [...path, selected].join("/");
|
||||
|
||||
console.log("@oldPath", oldPath);
|
||||
console.log("@newPath", newPath);
|
||||
|
||||
move(oldPath, newPath).then((data) => {
|
||||
if (data.code === 200) {
|
||||
setMoving(false);
|
||||
setSelected([]);
|
||||
fetchFiles();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// ---- SERVER FUNCTIONS ---- //
|
||||
|
||||
/**
|
||||
* Fetch a list of files in the current directory.
|
||||
*/
|
||||
const fetchFiles = () => {
|
||||
useEffect(() => {
|
||||
const getData = async (token) => {
|
||||
const response = await fetch(`${backendUrl}/v1/children?path=/${path.join("/")}`, {
|
||||
method: "GET",
|
||||
@ -300,35 +88,80 @@ export default function Dashboard() {
|
||||
});
|
||||
|
||||
setSelected([]);
|
||||
|
||||
}, [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);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the contents of a file.
|
||||
* @param path {string} - Path to the file.
|
||||
* @param content {string} - New content to add to the file.
|
||||
* @return {any} - Server response, nothing.
|
||||
* Set the path back to the default.
|
||||
*/
|
||||
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.");
|
||||
|
||||
const json = resp.json();
|
||||
return json;
|
||||
const backHome = () => {
|
||||
// TODO: Fix this in production
|
||||
setPath([...defaultPath]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Download a list of files.
|
||||
* @param paths {string[]} - List of paths to download.
|
||||
* @returns void
|
||||
* 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`, {
|
||||
@ -337,7 +170,7 @@ export default function Dashboard() {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ filePaths: paths }),
|
||||
body: JSON.stringify({filePaths: paths}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json();
|
||||
@ -359,11 +192,70 @@ export default function Dashboard() {
|
||||
}
|
||||
};
|
||||
|
||||
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)
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the content of a file.
|
||||
* @param path {string} - File content.
|
||||
* @returns {any} - Server response with content.
|
||||
* 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",
|
||||
@ -372,106 +264,14 @@ export default function Dashboard() {
|
||||
"Authorization": `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.ok) setError("Something went wrong! Failed to get file content.")
|
||||
|
||||
const json = await resp.json();
|
||||
return json;
|
||||
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();
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a file or directory.
|
||||
* @param name {string} - Name of the file or directory.
|
||||
* @param cwd {string[]} - Current directory as a list of paths.
|
||||
* @return {any} Server response.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
const json = resp.json();
|
||||
return json;
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a list of files.
|
||||
* @param files {string[][]} - List of file paths as list of sub-paths.
|
||||
* @returns {any} Server response.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
const json = await resp.json();
|
||||
return json;
|
||||
};
|
||||
|
||||
const move = async (oldPath, newPath) => {
|
||||
const resp = await fetch(`${backendUrl}/v1/move`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"authorization": `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ oldPath, newPath })
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json()
|
||||
setError(data.error);
|
||||
return data;
|
||||
}
|
||||
|
||||
const json = await resp.json();
|
||||
return json;
|
||||
|
||||
}
|
||||
// ---- EFFECTS ---- //
|
||||
|
||||
// Update the file list from the server each time an action requires
|
||||
// an update.
|
||||
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(() => {
|
||||
(localStorage.getItem(storage_id) == null && sessionStorage.getItem(storage_id) == null)
|
||||
? navigate("/login")
|
||||
: updateToken();
|
||||
}, [navigate]);
|
||||
|
||||
// Load the content from the file into the editor and allow the user to begin
|
||||
// editing the file.
|
||||
useEffect(() => {
|
||||
setContentLoading(true);
|
||||
|
||||
// Prevent running when nothing is being edited. Also prevents a call on mount.
|
||||
if (editing) {
|
||||
// Fetch the data and handle errors accordingly
|
||||
@ -490,10 +290,22 @@ export default function Dashboard() {
|
||||
|
||||
}, [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[]} - List of files to upload.
|
||||
* @param files {object[]}
|
||||
* TODO: Actually do something here...
|
||||
*/
|
||||
const upload = (files) => {
|
||||
const uploadFiles = async (_files) => {
|
||||
@ -534,61 +346,75 @@ export default function Dashboard() {
|
||||
});
|
||||
};
|
||||
|
||||
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">
|
||||
const createDir = () => {
|
||||
setCreating(true);
|
||||
};
|
||||
|
||||
{downloadLoading && <DownloadLoading />}
|
||||
{creating && <CreateDirectory close={closeCreate} create={createDirectory} />}
|
||||
{uploading && <Uploader close={toggleUploading} upload={upload} />}
|
||||
{moving && <MoveDirectory close={closeMoving} move={moveSelected} path={[...path, selected[0]]} />}
|
||||
const closeCreate = () => {
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
{error && <Error error={error} clear={clearError} />}
|
||||
{
|
||||
(editing !== "" && !error) &&
|
||||
<Editor
|
||||
content={editingFileContent}
|
||||
path={editing}
|
||||
exit={exitFile}
|
||||
saveExit={exitAndSaveFile}
|
||||
loading={contentLoading}
|
||||
/>
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
<PathDisplay
|
||||
path={path}
|
||||
updatePath={updatePath}
|
||||
backHome={backHome}
|
||||
backArrow={backArrow}
|
||||
enabled={path.length > defaultPath.length}
|
||||
create={createDir}
|
||||
remove={removeSelected}
|
||||
removeEnable={selected.length > 0}
|
||||
move={showMoving}
|
||||
moveEnabled={selected.length === 1}
|
||||
/>
|
||||
return await resp.json();
|
||||
};
|
||||
|
||||
<div className="w-9/10 lg: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} // TODO: Rework the toggleSelected functionality
|
||||
toggleEditing={toggleEditing}
|
||||
selected={selected}
|
||||
/>
|
||||
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-9/10 lg:w-2/3 flex justify-end items-center">
|
||||
<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} />
|
||||
onClick={toggleHidden}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -11,11 +11,10 @@ function Login() {
|
||||
return <>
|
||||
<div className="min-h-screen h-screen w-full bg-gray-200 flex items-center justify-center">
|
||||
<h2 className="absolute top-0 left-0 font-[550] font-mono italic text-xl m-6">{domain}</h2>
|
||||
|
||||
<div className="w-9/10 lg:w-2/7 h-fit bg-white border rounded-sm border-gray-300 p-12">
|
||||
<div className="w-2/7 h-fit bg-white border-1 rounded-sm border-gray-300 p-12">
|
||||
<p className="text-gray-400">Please enter your details</p>
|
||||
<h1 className="text-3xl font-semibold mt-2 mb-12"> Welcome Back </h1>
|
||||
<LoginForm />
|
||||
<LoginForm/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user