From a95d37546ffb99b8a790f588eb3345247bed28a4 Mon Sep 17 00:00:00 2001 From: Alex Auvolat Date: Mon, 28 Feb 2022 21:31:26 +0100 Subject: [PATCH] Ability to add and delete files --- package-lock.json | 57 ++++++++++++++ package.json | 4 + src/BucketList.tsx | 2 +- src/ObjectInfo.tsx | 84 +++++++++++++++++++++ src/ObjectList.tsx | 108 ++++++++++++++++++++++----- src/UploadFiles.tsx | 163 +++++++++++++++++++++++++++++++++++++++++ src/downloadFile.tsx | 34 +++++++++ src/fileSizePretty.tsx | 12 +++ src/index.tsx | 4 +- 9 files changed, 449 insertions(+), 19 deletions(-) create mode 100644 src/ObjectInfo.tsx create mode 100644 src/UploadFiles.tsx create mode 100644 src/downloadFile.tsx create mode 100644 src/fileSizePretty.tsx diff --git a/package-lock.json b/package-lock.json index e9e76ee..6dd2f46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,14 +17,18 @@ "@types/react": "^17.0.39", "@types/react-dom": "^17.0.11", "@types/react-router-bootstrap": "^0.24.5", + "@types/streamsaver": "^2.0.1", "bootstrap": "^5.1.3", "react": "^17.0.2", "react-bootstrap": "^2.1.2", "react-dom": "^17.0.2", + "react-icons": "^4.3.1", "react-router-bootstrap": "^0.26.0", "react-router-dom": "^6.2.1", "react-scripts": "5.0.0", + "streamsaver": "^2.0.6", "typescript": "^4.5.5", + "web-streams-polyfill": "^3.2.0", "web-vitals": "^2.1.4" } }, @@ -4982,6 +4986,11 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" }, + "node_modules/@types/streamsaver": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/streamsaver/-/streamsaver-2.0.1.tgz", + "integrity": "sha512-I49NtT8w6syBI3Zg3ixCyygTHoTVMY0z2TMRcTgccdIsVd2MwlKk7ITLHLsJtgchUHcOd7QEARG9h0ifcA6l2Q==" + }, "node_modules/@types/testing-library__jest-dom": { "version": "5.14.2", "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.2.tgz", @@ -15584,6 +15593,14 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.10.tgz", "integrity": "sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA==" }, + "node_modules/react-icons": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.3.1.tgz", + "integrity": "sha512-cB10MXLTs3gVuXimblAdI71jrJx8njrJZmNMEMC+sQu5B/BIOmlsAjskdqpn81y8UBVEGuHODd7/ci5DvoSzTQ==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -16637,6 +16654,17 @@ "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", "peer": true }, + "node_modules/streamsaver": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/streamsaver/-/streamsaver-2.0.6.tgz", + "integrity": "sha512-LK4e7TfCV8HzuM0PKXuVUfKyCB1FtT9L0EGxsFk5Up8njj0bXK8pJM9+Wq2Nya7/jslmCQwRK39LFm55h7NBTw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + } + ] + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -17777,6 +17805,14 @@ "minimalistic-assert": "^1.0.0" } }, + "node_modules/web-streams-polyfill": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.0.tgz", + "integrity": "sha512-EqPmREeOzttaLRm5HS7io98goBgZ7IVz79aDvqjD0kYXLtFZTc0T/U6wHTPKyIjb+MdN7DFIIX6hgdBEpWmfPA==", + "engines": { + "node": ">= 8" + } + }, "node_modules/web-vitals": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", @@ -22365,6 +22401,11 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==" }, + "@types/streamsaver": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/streamsaver/-/streamsaver-2.0.1.tgz", + "integrity": "sha512-I49NtT8w6syBI3Zg3ixCyygTHoTVMY0z2TMRcTgccdIsVd2MwlKk7ITLHLsJtgchUHcOd7QEARG9h0ifcA6l2Q==" + }, "@types/testing-library__jest-dom": { "version": "5.14.2", "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.2.tgz", @@ -30053,6 +30094,12 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.10.tgz", "integrity": "sha512-mKR90fX7Pm5seCOfz8q9F+66VCc1PGsWSBxKbITjfKVQHMNF2zudxHnMdJiB1fRCb+XsbQV9sO9DCkgsMQgBIA==" }, + "react-icons": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.3.1.tgz", + "integrity": "sha512-cB10MXLTs3gVuXimblAdI71jrJx8njrJZmNMEMC+sQu5B/BIOmlsAjskdqpn81y8UBVEGuHODd7/ci5DvoSzTQ==", + "requires": {} + }, "react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -30853,6 +30900,11 @@ "integrity": "sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==", "peer": true }, + "streamsaver": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/streamsaver/-/streamsaver-2.0.6.tgz", + "integrity": "sha512-LK4e7TfCV8HzuM0PKXuVUfKyCB1FtT9L0EGxsFk5Up8njj0bXK8pJM9+Wq2Nya7/jslmCQwRK39LFm55h7NBTw==" + }, "string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -31713,6 +31765,11 @@ "minimalistic-assert": "^1.0.0" } }, + "web-streams-polyfill": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.0.tgz", + "integrity": "sha512-EqPmREeOzttaLRm5HS7io98goBgZ7IVz79aDvqjD0kYXLtFZTc0T/U6wHTPKyIjb+MdN7DFIIX6hgdBEpWmfPA==" + }, "web-vitals": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-2.1.4.tgz", diff --git a/package.json b/package.json index e435672..3adebe5 100644 --- a/package.json +++ b/package.json @@ -12,14 +12,18 @@ "@types/react": "^17.0.39", "@types/react-dom": "^17.0.11", "@types/react-router-bootstrap": "^0.24.5", + "@types/streamsaver": "^2.0.1", "bootstrap": "^5.1.3", "react": "^17.0.2", "react-bootstrap": "^2.1.2", "react-dom": "^17.0.2", + "react-icons": "^4.3.1", "react-router-bootstrap": "^0.26.0", "react-router-dom": "^6.2.1", "react-scripts": "5.0.0", + "streamsaver": "^2.0.6", "typescript": "^4.5.5", + "web-streams-polyfill": "^3.2.0", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/src/BucketList.tsx b/src/BucketList.tsx index 922c374..58c5280 100644 --- a/src/BucketList.tsx +++ b/src/BucketList.tsx @@ -27,7 +27,7 @@ class BucketList extends React.Component { const data = await this.props.client.send(command); console.log("ok", data); const buckets = (data.Buckets || []).map((b) => (b.Name || 'aza')); - buckets.sort(); + buckets.sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'})); this.setState({buckets: buckets}); } catch(error) { console.log("err", error); diff --git a/src/ObjectInfo.tsx b/src/ObjectInfo.tsx new file mode 100644 index 0000000..136cae9 --- /dev/null +++ b/src/ObjectInfo.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { S3Client, ListObjectsV2Command, HeadObjectCommand } from '@aws-sdk/client-s3'; + +import Button from 'react-bootstrap/Button'; +import Modal from 'react-bootstrap/Modal'; +import { BsFileEarmarkText, BsDownload } from 'react-icons/bs'; + +import downloadFile from './downloadFile'; + +type Props = { + client: S3Client; + bucket: string; + s3key: string; + filename: string; +}; + +type State = { + show: boolean; + loaded: boolean; + size: number; + contentType: string; +}; + +class ObjectInfo extends React.Component { + state = { + show: true, + loaded: false, + size: 0, + contentType: "", + }; + + constructor(props: Props) { + super(props); + } + + async componentDidMount() { + let command = new HeadObjectCommand({ + Bucket: this.props.bucket, + Key: this.props.s3key, + }); + + const data = await this.props.client.send(command); + + this.setState({ + loaded: true, + size: data.ContentLength!, + contentType: data.ContentType || "", + }); + } + + handleClose() { + this.setState({show: false}); + } + + render() { + return <> + this.handleClose()}> + + { this.props.filename } + + +

Bucket: { this.props.bucket }

+

Key: { this.props.s3key }

+ { this.state.loaded ? + <> +

Size: { this.state.size }

+

Content type: { this.state.contentType }

+ + : <> } +
+ + + +
+ ; + } +}; + +export default ObjectInfo; diff --git a/src/ObjectList.tsx b/src/ObjectList.tsx index da72c6e..0492a5b 100644 --- a/src/ObjectList.tsx +++ b/src/ObjectList.tsx @@ -1,13 +1,22 @@ import React from 'react'; import { Link, useParams } from 'react-router-dom'; -import { S3Client, ListObjectsV2Command } from '@aws-sdk/client-s3'; +import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3'; import Alert from 'react-bootstrap/Alert'; import Breadcrumb from 'react-bootstrap/Breadcrumb'; +import Button from 'react-bootstrap/Button'; +import Container from 'react-bootstrap/Container'; import Card from 'react-bootstrap/Card'; import ListGroup from 'react-bootstrap/ListGroup'; +import Stack from 'react-bootstrap/Stack'; import { LinkContainer } from 'react-router-bootstrap' +import ObjectInfo from './ObjectInfo'; +import UploadFiles from './UploadFiles'; +import downloadFile from './downloadFile'; + +import { BsFolder, BsFileEarmarkText, BsDownload } from 'react-icons/bs'; + type Props = { client: S3Client; bucket: string; @@ -18,6 +27,9 @@ type State = { loaded: boolean; folders: string[]; files: string[]; + info: string | null; + iInfo: number; + iUpload: number; }; var cache: { [path: string]: State; } = {}; @@ -27,19 +39,26 @@ class ObjectList extends React.Component { loaded: false, folders: [], files: [], + info: null, + iInfo: 0, + iUpload: 0, }; constructor(props: Props) { super(props); } - async componentDidMount() { - console.log(this.props); + componentDidMount() { + //console.log(this.props); if (cache[this.path()]) { this.setState(cache[this.path()]); } + this.loadEntries(); + } + + async loadEntries() { let command = new ListObjectsV2Command({ Bucket: this.props.bucket, Prefix: this.props.prefix, @@ -49,13 +68,13 @@ class ObjectList extends React.Component { const pxlen = this.props.prefix.length; const data = await this.props.client.send(command); - console.log("ok", data); + //console.log("ok", data); const folders = (data.CommonPrefixes || []).map((cp) => cp.Prefix!.substring(pxlen)); const files = (data.Contents || []).map((obj) => obj.Key!.substring(pxlen)); - folders.sort(); - files.sort(); + folders.sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'})); + files.sort((a, b) => a.localeCompare(b, undefined, {sensitivity: 'base'})); this.setState({ loaded: true, @@ -76,27 +95,28 @@ class ObjectList extends React.Component { let spl = this.props.prefix.split("/"); let items = []; for (var i = 0; i < spl.length - 1; i++) { + let to = "/" + this.props.bucket + "/" + spl.slice(0, i+1).join("/") + "/" ; if (i < spl.length - 2) { items.push( - + { spl[i] } ); } else { items.push( - { spl[i] } + { spl[i] } ); } } return ( - + my buckets { this.props.prefix == "" ? { this.props.bucket } : - + { this.props.bucket } } @@ -105,8 +125,19 @@ class ObjectList extends React.Component { ); } - render() { + openInfo(f: string) { + this.setState({info: f, iInfo: this.state.iInfo + 1}); + } + openUpload() { + this.setState({iUpload: this.state.iUpload + 1}); + } + + onUploadComplete() { + this.loadEntries(); + } + + render() { if (!this.state.loaded) { return ( <> @@ -118,18 +149,56 @@ class ObjectList extends React.Component { return ( <> - { this.renderBreadcrumbs() } + { this.state.iUpload > 0 ? + this.onUploadComplete()} + /> + : <> } + { this.state.info ? + + : <> } + + +
+ { this.renderBreadcrumbs() } +
+
+ +
+
+
{ this.state.folders.map((f) => - - - { f } + + + { f } )} { this.state.files.map((f) => - - { f } + this.openInfo(f)}> + +
{ f }
+
+ +
+
)}
@@ -139,6 +208,11 @@ class ObjectList extends React.Component { } +function isBlob(obj: any): obj is Blob { + return !! obj; +} + + interface IClient { client: S3Client; } diff --git a/src/UploadFiles.tsx b/src/UploadFiles.tsx new file mode 100644 index 0000000..a5e0649 --- /dev/null +++ b/src/UploadFiles.tsx @@ -0,0 +1,163 @@ +import React from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { S3Client, ListObjectsV2Command, HeadObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3'; + +import Button from 'react-bootstrap/Button'; +import ListGroup from 'react-bootstrap/ListGroup'; +import Modal from 'react-bootstrap/Modal'; +import Stack from 'react-bootstrap/Stack'; + +import { BsFileEarmarkText, BsDownload, BsHourglassSplit, BsCheckCircle } from 'react-icons/bs'; +import { AiOutlineDelete } from 'react-icons/ai'; + +import downloadFile from './downloadFile'; +import fileSizePretty from './fileSizePretty'; + +interface Props { + client: S3Client; + bucket: string; + prefix: string; + onUploadComplete: () => void; +} + +type State = { + iFile: number; + show: boolean; + files: File[]; + processing: boolean; + canceled: boolean; + progressEach: number[]; +}; + +class UploadFiles extends React.Component { + file_input: HTMLInputElement | null = null; + + state = { + iFile: 0, + show: true, + files: [], + processing: false, + canceled: false, + progressEach: [], + }; + + constructor(props: Props) { + super(props); + } + + async componentDidMount() { + this.addFile(); + } + + handleClose() { + this.setState({show: false}); + } + + handleFileChange() { + let infiles = this.file_input!.files || []; + let files: File[] = this.state.files; + + for (var i = 0; i < infiles.length; i++) { + files.push(infiles[i]); + } + + this.setState({show: (files.length > 0), files: files, iFile: this.state.iFile + 1}); + } + + addFile() { + this.file_input!.click(); + } + + removeFile(i: number) { + let files: File[] = this.state.files; + files.splice(i, 1); + this.setState({files: files}); + } + + async processUpload() { + this.setState({canceled: false}); + + let progressEach: number[] = []; + for (var i = 0; i < this.state.files.length; i++) { + progressEach.push(0); + } + this.setState({canceled: false, processing: true, progressEach: progressEach}); + + for (var i = 0; i < this.state.files.length; i++) { + let file: File = this.state.files[i]; + let req = new PutObjectCommand({ + Bucket: this.props.bucket, + Key: this.props.prefix + file.name, + ContentType: file.type, + ContentLength: file.size, + Body: file, + }); + let resp = await this.props.client.send(req); + progressEach[i] = 100; + this.setState({progressEach: progressEach}); + } + + this.setState({show: false}); + this.props.onUploadComplete(); + } + + render() { + return <> + this.handleClose()}> + + + Upload files + + + + this.file_input = input} + onChange={() => this.handleFileChange()} + multiple + hidden /> + { this.state.files.length == 0 ?

No files added yet, click "add file" below.

: <> } + + { this.state.files.map((f: File, i: number) => + + +
+ { f.name } +
+
+ { this.state.processing ? + (this.state.progressEach[i] == 100 ? + : this.state.progressEach[i] > 0 ? this.state.progressEach[i] + "%" + : ) + : + + } +
+
+ +
 
+
+ { fileSizePretty(f.size) } - { f.type } +
+
+
+ )} +
+
+ + + + +
+ ; + } +}; + +export default UploadFiles; diff --git a/src/downloadFile.tsx b/src/downloadFile.tsx new file mode 100644 index 0000000..486e387 --- /dev/null +++ b/src/downloadFile.tsx @@ -0,0 +1,34 @@ +import StreamSaver from 'streamsaver'; +import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3'; + + +async function downloadFile(client: S3Client, bucket: string, key: string, filename: string) { + let command = new GetObjectCommand({ + Bucket: bucket, + Key: key, + }); + + const data = await client.send(command); + + console.log("body:", data.Body); + + const fileStream = StreamSaver.createWriteStream(filename); + const writer = fileStream.getWriter(); + + const reader = (data.Body! as ReadableStream).getReader(); + + const pump = (): Promise => reader.read() + .then(({ value, done }) => { + if (done) writer.close(); + else { + writer.write(value); + return writer.ready.then(pump); + } + }); + + await pump() + .then(() => console.log('Closed the stream, Done writing')) + .catch(err => console.log(err)); +} + +export default downloadFile; diff --git a/src/fileSizePretty.tsx b/src/fileSizePretty.tsx new file mode 100644 index 0000000..7998b59 --- /dev/null +++ b/src/fileSizePretty.tsx @@ -0,0 +1,12 @@ + +function fileSizePretty(nBytes: number): string { + let sOutput = nBytes + " bytes"; + // optional code for multiples approximation + const aMultiples = ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]; + for (var nMultiple = 0, nApprox = nBytes / 1024; nApprox > 1; nApprox /= 1024, nMultiple++) { + sOutput = nApprox.toFixed(3) + " " + aMultiples[nMultiple] + " (" + nBytes + " bytes)"; + } + return sOutput; +} + +export default fileSizePretty; diff --git a/src/index.tsx b/src/index.tsx index 7d79301..4561dea 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,10 +1,12 @@ +import 'web-streams-polyfill/dist/polyfill.js'; +import 'bootstrap/dist/css/bootstrap.min.css'; + import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; -import 'bootstrap/dist/css/bootstrap.min.css'; ReactDOM.render(