(REFACTOR): Moved all the code around and organized it.

It was a mess in here, holy shit.
This commit is contained in:
Hayden Hargreaves 2025-05-29 20:37:34 -07:00
parent 1dd9f4e38f
commit d8873e08f2
2 changed files with 346 additions and 299 deletions

View File

@ -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

View File

@ -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 (
<div className="w-full min-h-screen h-screen pb-8">
<Navbar downloadFiles={downloadFiles} uploadFiles={toggleUploading} />
@ -450,13 +496,13 @@ export default function Dashboard() {
{error && <Error error={error} clear={clearError} />}
{
(editing !== "" && !error) &&
<Editor
content={editingFileContent}
path={editing}
exit={exitFile}
saveExit={exitAndSaveFile}
loading={contentLoading}
/>
<Editor
content={editingFileContent}
path={editing}
exit={exitFile}
saveExit={exitAndSaveFile}
loading={contentLoading}
/>
}
<PathDisplay