From d8873e08f2b72f1bd20c009bfba010c0cf62ee13 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Thu, 29 May 2025 20:37:34 -0700 Subject: [PATCH] (REFACTOR): Moved all the code around and organized it. It was a mess in here, holy shit. --- README.md | 1 + frontend/src/pages/Dashboard.jsx | 644 +++++++++++++++++-------------- 2 files changed, 346 insertions(+), 299 deletions(-) diff --git a/README.md b/README.md index 8a715ed..2ba8cf5 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - [ ] Image display (will require some kind of file server) - [ ] Markdown rendering (quite a reach) - [x] Mobile friendly +- [ ] Fix selection bug ## Notes diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index eea0561..703632f 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -11,46 +11,66 @@ import Uploader from "../components/Uploader.jsx"; import CreateDirectory from "../components/CreateDirectory.jsx"; export default function Dashboard() { - // Store the default path - // TODO: BACK TO NORMAL PATH - const defaultPath = ["media", "vault"]; - // const defaultPath = ["home", "azpect"]; + // ---- 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(','); /** * 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} + * pushed to dockerhub, the .env is ignored and the + * real backend URL is used. */ 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([]); - const [files, setFiles] = useState([]); + + // Modals const [error, setError] = useState(null); const [editing, setEditing] = useState(""); const [uploading, setUploading] = useState(false); const [creating, setCreating] = 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(); - - /** - * The name of the value stored in local storage. - * @type {string} - */ - const storage_id = "gophernest_credentials"; + // ---- FUNCTIONS ---- // /** * 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); @@ -58,6 +78,166 @@ 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 = () => setCreating(true); + + /** + * Hide the creating modal. + */ + const closeCreate = () => setCreating(false); + + // ---- 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) { + console.log(data); + setSelected([]); + + // Fetch the new files & deselect everything + fetchFiles(); + } + }).catch((error) => { + setError(error); + }); + }; + + // ---- SERVER FUNCTIONS ---- // + + /** + * Fetch a list of files in the current directory. + */ const fetchFiles = () => { const getData = async (token) => { const response = await fetch(`${backendUrl}/v1/children?path=/${path.join("/")}`, { @@ -83,15 +263,146 @@ export default function Dashboard() { getData(tkn).then((data) => { setFiles(data); }).finally(() => { - setChildrenLoading(false); - }).catch((err) => { - setError("Failed to fetch data from server."); - console.error(err); - }); + setChildrenLoading(false); + }).catch((err) => { + setError("Failed to fetch data from server."); + console.error(err); + }); setSelected([]); }; + /** + * 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. + */ + 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; + }; + + /** + * Download a list of files. + * @param paths {string[]} - List of paths to download. + * @returns void + */ + 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}`); + } + }; + + /** + * Fetch the content of a file. + * @param path {string} - File content. + * @returns {any} - Server response with content. + */ + 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) setError("Something went wrong! Failed to get file content.") + + const json = await resp.json(); + return 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; + } + + return await resp.json(); + }; + + // ---- EFFECTS ---- // + + // Update the file list from the server each time an action requires + // an update. useEffect(() => { fetchFiles(); }, [path, uploading, creating]); @@ -100,183 +411,16 @@ export default function Dashboard() { // 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(); - } + (localStorage.getItem(storage_id) == null && sessionStorage.getItem(storage_id) == null) + ? navigate("/login") + : 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}`); - } - }; - - 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(""); + // Load the content from the file into the editor and allow the user to begin + // editing the file. 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 @@ -289,23 +433,12 @@ export default function Dashboard() { setError(data.error); } }).finally(() => { - setContentLoading(false) - }); + 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 @@ -351,93 +484,6 @@ export default function Dashboard() { }); }; - 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([]); - - // Fetch the new files & deselect everything - fetchFiles(); - } - }).catch((error) => { - setError(error); - }); - }; - return (
@@ -450,13 +496,13 @@ export default function Dashboard() { {error && } { (editing !== "" && !error) && - + }