(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) - [ ] Image display (will require some kind of file server)
- [ ] Markdown rendering (quite a reach) - [ ] Markdown rendering (quite a reach)
- [x] Mobile friendly - [x] Mobile friendly
- [ ] Fix selection bug
## Notes ## Notes

View File

@ -11,46 +11,66 @@ import Uploader from "../components/Uploader.jsx";
import CreateDirectory from "../components/CreateDirectory.jsx"; import CreateDirectory from "../components/CreateDirectory.jsx";
export default function Dashboard() { export default function Dashboard() {
// Store the default path // ---- CONSTANTS ---- //
// TODO: BACK TO NORMAL PATH /**
const defaultPath = ["media", "vault"]; * Default path
// const defaultPath = ["home", "azpect"]; * 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. * URL To the backend web server.
* Uses the .env var in local development, but when * Uses the .env var in local development, but when
* pushed to dockerhub, the .env is ignored and the real * pushed to dockerhub, the .env is ignored and the
* backend URL is used. * real backend URL is used.
* @type {string}
*/ */
const backendUrl = import.meta.env.VITE_BACKEND_URL || "https://backend.gophernest.net"; 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 [token, setToken] = useState(null);
const [path, setPath] = useState([...defaultPath]); const [path, setPath] = useState([...defaultPath]);
const [files, setFiles] = useState([]);
// User settings
const [showHidden, setShowHidden] = useState(false); const [showHidden, setShowHidden] = useState(false);
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const [files, setFiles] = useState([]);
// Modals
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [editing, setEditing] = useState(""); const [editing, setEditing] = useState("");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
// Loading spinners
const [childrenLoading, setChildrenLoading] = useState(false); const [childrenLoading, setChildrenLoading] = useState(false);
const [downloadLoading, setDownloadLoading] = useState(false); const [downloadLoading, setDownloadLoading] = useState(false);
const [contentLoading, setContentLoading] = 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(); 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. * Update the token from the local or session storage.
* This function assumes one of them exists. * This function assumes one of them exists.
* If it does not, the token will be null. * If it does not, the token will be null.
* This function will return the token as well, to allow for other usecases. * This function will return the token as well, to allow for other usecases.
* @returns {string | null} Token from browser storage
*/ */
const updateToken = () => { const updateToken = () => {
const t = localStorage.getItem(storage_id) ? localStorage.getItem(storage_id) : sessionStorage.getItem(storage_id); const t = localStorage.getItem(storage_id) ? localStorage.getItem(storage_id) : sessionStorage.getItem(storage_id);
@ -58,6 +78,166 @@ export default function Dashboard() {
return t; 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 fetchFiles = () => {
const getData = async (token) => { const getData = async (token) => {
const response = await fetch(`${backendUrl}/v1/children?path=/${path.join("/")}`, { const response = await fetch(`${backendUrl}/v1/children?path=/${path.join("/")}`, {
@ -92,81 +272,33 @@ export default function Dashboard() {
setSelected([]); setSelected([]);
}; };
useEffect(() => {
fetchFiles();
}, [path, uploading, creating]);
// Redirect if the user isn't logged in, otherwise update the state.
// Store the token in the storage, it should be attached to every request.
useEffect(() => {
if (localStorage.getItem(storage_id) == null && sessionStorage.getItem(storage_id) == null) {
navigate("/login");
} else {
updateToken();
}
}, [navigate]);
/** /**
* Updates the path by slicing [0:index] * Update the contents of a file.
* @param index {number} Index to slice to. * @param path {string} - Path to the file.
* @param content {string} - New content to add to the file.
* @return {any} - Server response, nothing.
*/ */
const updatePath = (index) => { const updateContent = async (path, content) => {
let newPath = path.slice(0, index + 1); const resp = await fetch(`${backendUrl}/v1/update`, {
if (newPath.length < defaultPath.length) { method: "POST",
newPath = [...defaultPath]; headers: {
} "Content-Type": "application/json",
setPath(newPath); "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;
}; };
/** /**
* Set the path back to the default. * Download a list of files.
* @param paths {string[]} - List of paths to download.
* @returns void
*/ */
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) => { const download = async (paths) => {
try { try {
const resp = await fetch(`${backendUrl}/v1/download`, { const resp = await fetch(`${backendUrl}/v1/download`, {
@ -197,70 +329,11 @@ 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)
});
};
/** /**
* Clear the error in the error state. * Fetch the content of a file.
* @param path {string} - File content.
* @returns {any} - Server response with content.
*/ */
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 fetchContent = async (path) => {
const resp = await fetch(`${backendUrl}/v1/content?path=${path}`, { const resp = await fetch(`${backendUrl}/v1/content?path=${path}`, {
method: "GET", method: "GET",
@ -269,14 +342,85 @@ export default function Dashboard() {
"Authorization": `Bearer ${token}`, "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) { if (!resp.ok) {
// TODO: Add this back in, its broken right now. const data = await resp.json()
setError("Something went wrong! Failed to get file content.") 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(); return await resp.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); setContentLoading(true);
// Prevent running when nothing is being edited. Also prevents a call on mount. // Prevent running when nothing is being edited. Also prevents a call on mount.
if (editing) { if (editing) {
// Fetch the data and handle errors accordingly // Fetch the data and handle errors accordingly
@ -295,17 +439,6 @@ export default function Dashboard() {
}, [editing]); }, [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 * 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 ( return (
<div className="w-full min-h-screen h-screen pb-8"> <div className="w-full min-h-screen h-screen pb-8">
<Navbar downloadFiles={downloadFiles} uploadFiles={toggleUploading} /> <Navbar downloadFiles={downloadFiles} uploadFiles={toggleUploading} />