FEAT: Downloading as a zip is working really well! some minor bug fixes are needed but that's about it!

This commit is contained in:
Hayden Hargreaves 2025-03-04 20:00:11 -07:00
parent a60396545d
commit f9d16ddce9
8 changed files with 1055 additions and 17 deletions

File diff suppressed because it is too large Load Diff

View File

@ -12,10 +12,12 @@
"license": "ISC",
"description": "",
"dependencies": {
"archiver": "^7.0.1",
"cors": "^2.8.5",
"express": "^4.21.2"
},
"devDependencies": {
"@types/archiver": "^6.0.3",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.13.9",

57
backend/src/download.ts Normal file
View File

@ -0,0 +1,57 @@
import path from "node:path";
import fs from "node:fs";
import archiver from "archiver";
/**
* Append a file from the path to the archiver. Errors will be bubbled out
* @param filePath Target path
* @param archive The archiver
* @constructor
*/
export function appendFileToArchive(filePath: string, archive: archiver.Archiver): void {
try {
const fileName = path.basename(filePath);
archive.append(fs.createReadStream(filePath), {name: fileName});
} catch (error) {
throw new Error(`Error appending file to archive: ${error}`);
}
}
/**
* Recursively append directories to
* @param filePath
* @param archiveRelPath
* @param archive
*/
export function appendDirectoryToArchive(filePath: string, archiveRelPath: string = "", archive: archiver.Archiver): void {
try {
const files = fs.readdirSync(filePath);
const relative = path.relative(path.dirname(filePath), filePath);
// Skip hidden folders, for now this is enabled
// TODO: Implement a selector for hidden folders
if (relative.startsWith(".")) {
return
}
files.forEach((file: string): void => {
const fullPath: string = path.join(filePath, file);
const relPath: string = path.join(archiveRelPath, file);
// Might need to skip symbolic links
// If it's file, append it the normal way, use the relative path as the name
if (!fs.statSync(fullPath).isDirectory()) {
archive.append(fs.createReadStream(fullPath), {name: relPath});
// Otherwise, call self with a new relative path
} else {
appendDirectoryToArchive(fullPath, relPath, archive);
}
});
} catch (error) {
throw new Error(`Error appending directory to archive: ${error}`);
}
}

View File

@ -5,6 +5,9 @@ import {LogRequestMiddleware} from "./log";
import * as fs from "node:fs";
import {entry} from "./entry";
import cors from "cors";
import archiver from "archiver";
import {appendDirectoryToArchive, appendFileToArchive} from "./download";
import path from "node:path";
/**
* App details
@ -27,6 +30,7 @@ APP.use(cors(corsOptions));
* Apply middleware, this must be done before the routes are created.
*/
APP.use(LogRequestMiddleware);
APP.use(express.json());
/**
* Create routes for modular routing
@ -53,6 +57,9 @@ v1.get("/", (req: Request, res: Response): void => {
res.send("Hello world!");
});
/**
* Get the children of a directory provided in the path query.
*/
v1.get("/children", (req: Request, res: Response): void => {
// Get the path, if it was not provided, use the root
const path: string = (req.query.path || ROOT) as string;
@ -82,6 +89,54 @@ v1.get("/children", (req: Request, res: Response): void => {
res.status(200).json(children);
});
/**
* Down a group of files provided in the body
*/
v1.post("/download", (req: Request, res: Response): void => {
// Get the files from the body
const {filePaths} = req.body;
// Validate the path array
if (!filePaths || !Array.isArray(filePaths) || filePaths.length === 0) {
res.status(400).send({error: 'Invalid file paths provided.'});
return;
}
const archive: archiver.Archiver = archiver('zip', {
zlib: {level: 9}, // Compression leve
});
// Set the file headers
res.setHeader('Content-Disposition', 'attachment; filename=donwloads.zip');
res.setHeader('Content-Type', 'application/zip');
archive.pipe(res);
// Add each file to the archive
filePaths.forEach((filePath): void => {
// This works for files, but for directories, we need to read the files
try {
const stats = fs.statSync(filePath);
if (!stats.isDirectory()) {
appendFileToArchive(filePath, archive);
} else {
// Call this with the name to include the name of the directory
appendDirectoryToArchive(filePath, path.basename(filePath), archive);
}
} catch (err) {
console.error(`Error adding file to zip: ${err}`);
}
});
// Return errors
archive.on('error', (err): void => {
res.status(500).send({error: err.message});
});
archive.finalize();
});
/**
* Apply the routes to the server
*/

View File

@ -23,13 +23,12 @@ function DirectoryIcon() {
*
* @param name {{name: string, path: string, directory: boolean}}
* @param showHidden {boolean}
* @param key {number}
* @param appendPath {function(string)}
* @param toggleSelected {function(string)}
* @returns {{}}
* @constructor
*/
export default function Directory({entry, showHidden, key, appendPath, toggleSelected}) {
export default function Directory({entry, showHidden, appendPath, toggleSelected}) {
const [selected, setSelected] = useState(false);
const handleClick = () => {
@ -59,7 +58,7 @@ export default function Directory({entry, showHidden, key, appendPath, toggleSel
<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/>}
<p className="p-2 hover:underline hover:text-blue-400" key={key}>{entry.name}</p>
<p className="p-2 hover:underline hover:text-blue-400">{entry.name}</p>
</button>
</div>

View File

@ -11,8 +11,8 @@ import Directory from "./Directory.jsx";
export default function DirectoryList({dirs, showHidden, appendPath, toggleSelected}) {
return (
<>
{dirs.map((dir, idx) => <Directory entry={dir} showHidden={showHidden} key={idx} appendPath={appendPath}
toggleSelected={toggleSelected}/>)}
{dirs.map((dir) => <Directory entry={dir} showHidden={showHidden} appendPath={appendPath}
toggleSelected={toggleSelected}/>)}
</>
)

View File

@ -18,12 +18,13 @@ function MainIcon() {
/**
* Download button
* @param downloadFiles {function}
* @returns {JSX.Element}
* @constructor
*/
function DownloadButton() {
function DownloadButton({downloadFiles}) {
return (
<button className="text-black" title="Download files">
<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">
@ -139,7 +140,7 @@ function SearchBar() {
)
}
export default function Navbar() {
export default function Navbar({downloadFiles}) {
return <nav className="absolute w-full p-2 flex items-center border-b-1 border-gray-400 bg-gray-100">
<MainIcon/>
@ -147,7 +148,7 @@ export default function Navbar() {
<SearchBar/>
<div className="min-h-fit ml-auto flex">
<DownloadButton/>
<DownloadButton downloadFiles={downloadFiles}/>
<UploadButton/>
<InfoButton/>
<LogoutButton/>

View File

@ -26,6 +26,8 @@ export default function Dashboard() {
setFiles(data);
});
setSelected([]);
}, [path]);
/**
@ -90,17 +92,47 @@ export default function Dashboard() {
}
};
// Example of getting absolute paths, for download
useEffect(() => {
console.log(selected);
/**
* Callback function for when the download button is clicked.
*/
const downloadFiles = () => {
const download = async (paths) => {
try {
const resp = await fetch("http://localhost:5000/v1/download", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({filePaths: paths}),
});
if (!resp.ok) {
throw new Error(`HTTP error! status: ${resp.status}`);
}
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}`);
}
};
const targets = [];
selected.forEach((file) => {
console.log(`/${path.join("/")}/${file}`);
targets.push(`/${path.join("/")}/${file}`);
});
}, [selected]);
console.log(targets);
download(targets);
};
return (
<div className="w-full min-h-screen h-screen pb-8">
<Navbar/>
<Navbar downloadFiles={downloadFiles}/>
<div className="h-full w-full flex flex-col items-center justify-center pb-8">
<PathDisplay path={path} updatePath={updatePath} backHome={backHome} backArrow={backArrow}/>