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:
parent
a60396545d
commit
f9d16ddce9
896
backend/package-lock.json
generated
896
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -12,10 +12,12 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"archiver": "^7.0.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.2"
|
"express": "^4.21.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/archiver": "^6.0.3",
|
||||||
"@types/cors": "^2.8.17",
|
"@types/cors": "^2.8.17",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/node": "^22.13.9",
|
"@types/node": "^22.13.9",
|
||||||
|
|||||||
57
backend/src/download.ts
Normal file
57
backend/src/download.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,6 +5,9 @@ import {LogRequestMiddleware} from "./log";
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import {entry} from "./entry";
|
import {entry} from "./entry";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
|
import archiver from "archiver";
|
||||||
|
import {appendDirectoryToArchive, appendFileToArchive} from "./download";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App details
|
* App details
|
||||||
@ -27,6 +30,7 @@ APP.use(cors(corsOptions));
|
|||||||
* Apply middleware, this must be done before the routes are created.
|
* Apply middleware, this must be done before the routes are created.
|
||||||
*/
|
*/
|
||||||
APP.use(LogRequestMiddleware);
|
APP.use(LogRequestMiddleware);
|
||||||
|
APP.use(express.json());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create routes for modular routing
|
* Create routes for modular routing
|
||||||
@ -53,6 +57,9 @@ v1.get("/", (req: Request, res: Response): void => {
|
|||||||
res.send("Hello world!");
|
res.send("Hello world!");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the children of a directory provided in the path query.
|
||||||
|
*/
|
||||||
v1.get("/children", (req: Request, res: Response): void => {
|
v1.get("/children", (req: Request, res: Response): void => {
|
||||||
// Get the path, if it was not provided, use the root
|
// Get the path, if it was not provided, use the root
|
||||||
const path: string = (req.query.path || ROOT) as string;
|
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);
|
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
|
* Apply the routes to the server
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -23,13 +23,12 @@ function DirectoryIcon() {
|
|||||||
*
|
*
|
||||||
* @param name {{name: string, path: string, directory: boolean}}
|
* @param name {{name: string, path: string, directory: boolean}}
|
||||||
* @param showHidden {boolean}
|
* @param showHidden {boolean}
|
||||||
* @param key {number}
|
|
||||||
* @param appendPath {function(string)}
|
* @param appendPath {function(string)}
|
||||||
* @param toggleSelected {function(string)}
|
* @param toggleSelected {function(string)}
|
||||||
* @returns {{}}
|
* @returns {{}}
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
export default function Directory({entry, showHidden, key, appendPath, toggleSelected}) {
|
export default function Directory({entry, showHidden, appendPath, toggleSelected}) {
|
||||||
const [selected, setSelected] = useState(false);
|
const [selected, setSelected] = useState(false);
|
||||||
|
|
||||||
const handleClick = () => {
|
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">
|
<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}>
|
<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" key={key}>{entry.name}</p>
|
<p className="p-2 hover:underline hover:text-blue-400">{entry.name}</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -11,8 +11,8 @@ import Directory from "./Directory.jsx";
|
|||||||
export default function DirectoryList({dirs, showHidden, appendPath, toggleSelected}) {
|
export default function DirectoryList({dirs, showHidden, appendPath, toggleSelected}) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{dirs.map((dir, idx) => <Directory entry={dir} showHidden={showHidden} key={idx} appendPath={appendPath}
|
{dirs.map((dir) => <Directory entry={dir} showHidden={showHidden} appendPath={appendPath}
|
||||||
toggleSelected={toggleSelected}/>)}
|
toggleSelected={toggleSelected}/>)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -18,12 +18,13 @@ function MainIcon() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Download button
|
* Download button
|
||||||
|
* @param downloadFiles {function}
|
||||||
* @returns {JSX.Element}
|
* @returns {JSX.Element}
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
function DownloadButton() {
|
function DownloadButton({downloadFiles}) {
|
||||||
return (
|
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"
|
<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"
|
viewBox="0 0 24 24" fill="currentColor"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
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">
|
return <nav className="absolute w-full p-2 flex items-center border-b-1 border-gray-400 bg-gray-100">
|
||||||
<MainIcon/>
|
<MainIcon/>
|
||||||
|
|
||||||
@ -147,7 +148,7 @@ export default function Navbar() {
|
|||||||
<SearchBar/>
|
<SearchBar/>
|
||||||
|
|
||||||
<div className="min-h-fit ml-auto flex">
|
<div className="min-h-fit ml-auto flex">
|
||||||
<DownloadButton/>
|
<DownloadButton downloadFiles={downloadFiles}/>
|
||||||
<UploadButton/>
|
<UploadButton/>
|
||||||
<InfoButton/>
|
<InfoButton/>
|
||||||
<LogoutButton/>
|
<LogoutButton/>
|
||||||
|
|||||||
@ -26,6 +26,8 @@ export default function Dashboard() {
|
|||||||
setFiles(data);
|
setFiles(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setSelected([]);
|
||||||
|
|
||||||
}, [path]);
|
}, [path]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -90,17 +92,47 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Example of getting absolute paths, for download
|
/**
|
||||||
useEffect(() => {
|
* Callback function for when the download button is clicked.
|
||||||
console.log(selected);
|
*/
|
||||||
|
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) => {
|
selected.forEach((file) => {
|
||||||
console.log(`/${path.join("/")}/${file}`);
|
targets.push(`/${path.join("/")}/${file}`);
|
||||||
});
|
});
|
||||||
}, [selected]);
|
console.log(targets);
|
||||||
|
download(targets);
|
||||||
|
};
|
||||||
|
|
||||||
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/>
|
<Navbar downloadFiles={downloadFiles}/>
|
||||||
<div className="h-full w-full flex flex-col items-center justify-center pb-8">
|
<div className="h-full w-full flex flex-col items-center justify-center pb-8">
|
||||||
|
|
||||||
<PathDisplay path={path} updatePath={updatePath} backHome={backHome} backArrow={backArrow}/>
|
<PathDisplay path={path} updatePath={updatePath} backHome={backHome} backArrow={backArrow}/>
|
||||||
|
|||||||
Reference in New Issue
Block a user