Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e52adddf6 | |||
| 096522aa8a | |||
| 5fdcdfb444 | |||
| 5bfe2a0331 | |||
| 1249c0df71 | |||
| 082a8f52ef | |||
| bcd3d50b42 | |||
| f695ac3a1c | |||
| a6125b3d0b | |||
| e01d9fe3d7 | |||
| 6294bb0a7b | |||
| fbb2ed8a57 | |||
| aef6c07371 | |||
| ed75198528 | |||
| d0c94b7316 | |||
| 63f338075b | |||
| 4cb0bcd3b9 | |||
| 01b1b39c42 | |||
| 8558860f0a | |||
| b4ebfc7c3f | |||
| a47de8fcb5 | |||
| e9ebe33d51 | |||
| 4c06ff5d8e | |||
| 999399fb08 | |||
| a00ca77b3e | |||
| cae90c1a82 | |||
| 7f169bbabd | |||
| a5d4ecf045 | |||
| 1e67de0242 | |||
| 8cbd812a2c | |||
| b3f521ca20 | |||
| 79bb315401 | |||
| 5871223203 | |||
| cc91f1d64c | |||
| e287845f7c | |||
| 16ffffb0c4 | |||
| 080d9f2c2a | |||
| 4f9abc614e | |||
| 20a051b658 | |||
| 0c2ac0c454 | |||
| 8802d20555 | |||
| 7fa1cb0ccf | |||
| bab08e1df2 | |||
| d91c334cf8 | |||
| bce93ce3cd | |||
| 8367f2b76a | |||
| 055a3002b3 | |||
| c9c4e7d7d1 | |||
| d97bc27c14 | |||
| e215c61ea1 | |||
| 8298d0bc66 | |||
| fac72b1299 | |||
| e780b971d9 | |||
| 90f9f91ddb | |||
| 01838dccd1 | |||
| 42b7f080b0 | |||
| a88e78e748 | |||
| 665ae063fa | |||
| dc04e26db4 | |||
| b798fa0e68 | |||
| 4e564dd5c0 | |||
| 1c53364fcd | |||
| 848e61a7a0 | |||
| c3a940c8d7 | |||
| 02469046b0 | |||
| 1ce4a47495 | |||
| 9a8520eb6f | |||
| ec8fdf0315 | |||
| a4b8e7ca25 | |||
| 693609810d | |||
| 73f845a73a | |||
| b6419297f4 | |||
| 9d2d271c20 | |||
| c0456a3bf6 | |||
| 463622c297 | |||
| e2dd077118 | |||
| 5295bd5b01 | |||
| 0592995564 | |||
| da0ae9cd94 | |||
| 528a810690 | |||
| 0c74dae4e8 | |||
| d42d440f85 | |||
| 0c262a4811 | |||
| 0603018f09 | |||
| 677b6de0f8 | |||
| 27f965ef63 | |||
| e72347d87a | |||
| 0260df61de | |||
| e986d7ca22 | |||
| df925b013b |
+3
-3
@@ -9,12 +9,12 @@
|
|||||||
"file-loader",
|
"file-loader",
|
||||||
"ts-node",
|
"ts-node",
|
||||||
"webpack-cli",
|
"webpack-cli",
|
||||||
"assert",
|
|
||||||
"buffer",
|
"buffer",
|
||||||
"crypto*",
|
"crypto*",
|
||||||
"stream*",
|
"stream*",
|
||||||
"env-paths",
|
"env-paths",
|
||||||
"open",
|
"open",
|
||||||
"base64-inline-loader"
|
"base64-inline-loader",
|
||||||
|
"sass"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-7
@@ -1,7 +1 @@
|
|||||||
PORT=3001
|
PORT=3002
|
||||||
REACT_APP_BEE_HOST=http://localhost:1633
|
|
||||||
REACT_APP_BEE_DEBUG_HOST=http://localhost:1635
|
|
||||||
REACT_APP_BEE_DOCS_HOST=https://docs.ethswarm.org/docs/
|
|
||||||
REACT_APP_BEE_DISCORD_HOST=https://discord.gg/eKr9XPv7
|
|
||||||
REACT_APP_BLOCKCHAIN_EXPLORER_URL=https://blockscout.com/xdai/mainnet
|
|
||||||
REACT_APP_BEE_GITHUB_REPO_URL=https://api.github.com/repos/ethersphere/bee
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
REACT_APP_BEE_HOST=http://localhost:1633
|
|
||||||
REACT_APP_BEE_DEBUG_HOST=http://localhost:1635
|
|
||||||
REACT_APP_BEE_DOCS_HOST=https://docs.ethswarm.org/docs/
|
|
||||||
REACT_APP_BEE_DISCORD_HOST=https://discord.gg/eKr9XPv7
|
|
||||||
REACT_APP_BLOCKCHAIN_EXPLORER_URL=https://blockscout.com/xdai/mainnet
|
|
||||||
REACT_APP_BEE_GITHUB_REPO_URL=https://api.github.com/repos/ethersphere/bee
|
|
||||||
@@ -18,10 +18,6 @@ jobs:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
REACT_APP_BEE_HOST: https://api.test-node.staging.ethswarm.org/
|
REACT_APP_BEE_HOST: https://api.test-node.staging.ethswarm.org/
|
||||||
REACT_APP_BEE_DEBUG_HOST: https://debug.test-node.staging.ethswarm.org/
|
|
||||||
REACT_APP_DEV_MODE: 1
|
|
||||||
REACT_APP_SENTRY_KEY: ${{ secrets.SENTRY_KEY }}
|
|
||||||
REACT_APP_SENTRY_ENVIRONMENT: 'preview'
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
@@ -32,18 +28,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
## Try getting the node modules from cache, if failed npm ci
|
|
||||||
- uses: actions/cache@v2
|
|
||||||
id: cache-npm
|
|
||||||
with:
|
|
||||||
path: node_modules
|
|
||||||
key: ${{ runner.os }}-node-${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.OS }}-node-${{ matrix.node-version }}-${{ env.cache-name }}-
|
|
||||||
${{ runner.OS }}-node-${{ matrix.node-version }}-
|
|
||||||
|
|
||||||
- name: Install npm deps
|
- name: Install npm deps
|
||||||
if: steps.cache-npm.outputs.cache-hit != 'true'
|
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Commit linting
|
- name: Commit linting
|
||||||
@@ -60,15 +45,6 @@ jobs:
|
|||||||
- name: Types check
|
- name: Types check
|
||||||
run: npm run check:types
|
run: npm run check:types
|
||||||
|
|
||||||
- name: Types build
|
|
||||||
run: npm run compile:types
|
|
||||||
|
|
||||||
- name: Update supported Bee action
|
|
||||||
uses: ethersphere/update-supported-bee-action@v1
|
|
||||||
if: github.ref == 'refs/heads/master'
|
|
||||||
with:
|
|
||||||
token: ${{ secrets.GHA_PAT_BASIC }}
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
@@ -76,15 +52,16 @@ jobs:
|
|||||||
run: npm run build:component
|
run: npm run build:component
|
||||||
|
|
||||||
- name: Create preview
|
- name: Create preview
|
||||||
uses: ethersphere/swarm-actions/pr-preview@v0
|
uses: ethersphere/swarm-actions/pr-preview@v1
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
bee-url: https://unlimited.gateway.ethswarm.org
|
bee-url: https://unlimited.gateway.ethswarm.org
|
||||||
token: ${{ secrets.GHA_PAT_BASIC }}
|
token: ${{ secrets.GHA_PAT_BASIC }}
|
||||||
error-document: index.html
|
error-document: index.html
|
||||||
headers: "${{ secrets.GATEWAY_AUTHORIZATION_HEADER }}"
|
headers: '${{ secrets.GATEWAY_AUTHORIZATION_HEADER }}'
|
||||||
|
|
||||||
- name: Upload to testnet
|
- name: Upload to testnet
|
||||||
uses: ethersphere/swarm-actions/upload-dir@v0
|
uses: ethersphere/swarm-actions/upload-dir@v1
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
index-document: index.html
|
index-document: index.html
|
||||||
|
|||||||
@@ -15,20 +15,6 @@ jobs:
|
|||||||
node-version: 18
|
node-version: 18
|
||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run compile:types
|
|
||||||
- run: npm publish --access public
|
- run: npm publish --access public
|
||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
||||||
- id: cleanVersion
|
|
||||||
run: |
|
|
||||||
version="${{ github.event.release.release.tag_name }}"
|
|
||||||
echo "::set-output name=value::${version/v}"
|
|
||||||
- name: Create Sentry release
|
|
||||||
uses: getsentry/action-release@v1
|
|
||||||
env:
|
|
||||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
|
||||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
|
||||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
|
||||||
with:
|
|
||||||
sourcemaps: ./build/static/js
|
|
||||||
version: ${{ steps.cleanVersion.outputs.value }}
|
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ jobs:
|
|||||||
registry-url: 'https://registry.npmjs.org'
|
registry-url: 'https://registry.npmjs.org'
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npm run build
|
- run: npm run build
|
||||||
env:
|
|
||||||
REACT_APP_SENTRY_KEY: ${{ secrets.SENTRY_KEY }}
|
|
||||||
REACT_APP_SENTRY_ENVIRONMENT: 'pages'
|
|
||||||
- run: echo "dashboard.ethswarm.org" > ./build/CNAME
|
- run: echo "dashboard.ethswarm.org" > ./build/CNAME
|
||||||
- name: Deploy to gh-pages
|
- name: Deploy to gh-pages
|
||||||
uses: peaceiris/actions-gh-pages@v3
|
uses: peaceiris/actions-gh-pages@v3
|
||||||
|
|||||||
@@ -25,21 +25,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
## Try getting the node modules from cache, if failed npm ci
|
|
||||||
- uses: actions/cache@v2
|
|
||||||
id: cache-npm
|
|
||||||
with:
|
|
||||||
path: node_modules
|
|
||||||
key: ${{ runner.os }}-node-${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.OS }}-node-${{ matrix.node-version }}-${{ env.cache-name }}-
|
|
||||||
${{ runner.OS }}-node-${{ matrix.node-version }}-
|
|
||||||
|
|
||||||
- name: Install npm deps
|
- name: Install npm deps
|
||||||
if: steps.cache-npm.outputs.cache-hit != 'true'
|
|
||||||
run: npm ci
|
run: npm ci
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: npm run test
|
run: npm run test
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
# production
|
# production
|
||||||
/build
|
/build
|
||||||
/lib
|
/lib
|
||||||
|
.env
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -22,3 +23,6 @@
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
|
|
||||||
|
settings.json
|
||||||
+218
@@ -1,5 +1,223 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.33.1](https://github.com/ethersphere/bee-dashboard/compare/v0.33.0...v0.33.1) (2025-11-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* put back external wallet balance context ([#704](https://github.com/ethersphere/bee-dashboard/issues/704)) ([096522a](https://github.com/ethersphere/bee-dashboard/commit/096522aa8a2f11afb0061a6fedbae241967408ef))
|
||||||
|
|
||||||
|
## [0.33.0](https://github.com/ethersphere/bee-dashboard/compare/v0.32.0...v0.33.0) (2025-11-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* bee-js revamp ([#690](https://github.com/ethersphere/bee-dashboard/issues/690)) ([1249c0d](https://github.com/ethersphere/bee-dashboard/commit/1249c0df71baec331cb3f2661e0a08648d924406))
|
||||||
|
* FileManager ([#98](https://github.com/ethersphere/bee-dashboard/issues/98)) ([#703](https://github.com/ethersphere/bee-dashboard/issues/703)) ([5bfe2a0](https://github.com/ethersphere/bee-dashboard/commit/5bfe2a033118dde43b4cd221830741a427882922))
|
||||||
|
|
||||||
|
## [0.32.0](https://github.com/ethersphere/bee-dashboard/compare/v0.31.0...v0.32.0) (2025-02-12)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* vod display ([#686](https://github.com/ethersphere/bee-dashboard/issues/686)) ([bcd3d50](https://github.com/ethersphere/bee-dashboard/commit/bcd3d50b4209a4f66a259b8a3f6ea5ffd908471f))
|
||||||
|
|
||||||
|
## [0.31.0](https://github.com/ethersphere/bee-dashboard/compare/v0.30.0...v0.31.0) (2025-01-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* remove experimental FDP menu item ([#687](https://github.com/ethersphere/bee-dashboard/issues/687)) ([a6125b3](https://github.com/ethersphere/bee-dashboard/commit/a6125b3d0b0b680a9fa61a8edcd75b2ae6c153e0))
|
||||||
|
|
||||||
|
## [0.30.0](https://github.com/ethersphere/bee-dashboard/compare/v0.29.0...v0.30.0) (2024-11-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add experimental fdp ([#681](https://github.com/ethersphere/bee-dashboard/issues/681)) ([d0c94b7](https://github.com/ethersphere/bee-dashboard/commit/d0c94b7316ea2b139bddc5481132ea7de7cb840d))
|
||||||
|
* update map data ([#684](https://github.com/ethersphere/bee-dashboard/issues/684)) ([fbb2ed8](https://github.com/ethersphere/bee-dashboard/commit/fbb2ed8a576f3519883e71382b7f4e8505fbe139))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* allow changing api url ([#676](https://github.com/ethersphere/bee-dashboard/issues/676)) ([6294bb0](https://github.com/ethersphere/bee-dashboard/commit/6294bb0a7be6b9b82354c42da8c84e767fad899e))
|
||||||
|
* explicitly define type 0 transaction ([#674](https://github.com/ethersphere/bee-dashboard/issues/674)) ([63f3380](https://github.com/ethersphere/bee-dashboard/commit/63f338075b919cb70d79665c3d86537f2ac1d2e9))
|
||||||
|
|
||||||
|
## [0.29.0](https://github.com/ethersphere/bee-dashboard/compare/v0.28.0...v0.29.0) (2024-07-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* clarify labels and syncing ([#670](https://github.com/ethersphere/bee-dashboard/issues/670)) ([01b1b39](https://github.com/ethersphere/bee-dashboard/commit/01b1b39c42cc5b68a0132c3696c3c42a27ea2ee4))
|
||||||
|
* polish app ([#669](https://github.com/ethersphere/bee-dashboard/issues/669)) ([8558860](https://github.com/ethersphere/bee-dashboard/commit/8558860f0a3baa82c31c091a44c78bb8e97de70d))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* clarify withdraw and deposit message ([#654](https://github.com/ethersphere/bee-dashboard/issues/654)) ([b4ebfc7](https://github.com/ethersphere/bee-dashboard/commit/b4ebfc7c3fd449807db47fa25763df464cc45618))
|
||||||
|
|
||||||
|
## [0.28.0](https://github.com/ethersphere/bee-dashboard/compare/v0.27.0...v0.28.0) (2024-06-18)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* upgrade bee-js to 7.0.3 ([#666](https://github.com/ethersphere/bee-dashboard/issues/666)) ([e9ebe33](https://github.com/ethersphere/bee-dashboard/commit/e9ebe33d51aa525921eacfad683577605e591531))
|
||||||
|
|
||||||
|
## [0.27.0](https://github.com/ethersphere/bee-dashboard/compare/v0.26.2...v0.27.0) (2024-06-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add redeem shortcut to sidebar ([999399f](https://github.com/ethersphere/bee-dashboard/commit/999399fb08c1a47a671ba0ad50409624654a1082))
|
||||||
|
|
||||||
|
## [0.26.2](https://github.com/ethersphere/bee-dashboard/compare/v0.26.1...v0.26.2) (2024-06-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* merge version and health check ([#662](https://github.com/ethersphere/bee-dashboard/issues/662)) ([cae90c1](https://github.com/ethersphere/bee-dashboard/commit/cae90c1a82e16ee8c7908c43e2fd17f7130eb89d))
|
||||||
|
|
||||||
|
## [0.26.1](https://github.com/ethersphere/bee-dashboard/compare/v0.26.0...v0.26.1) (2024-06-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add bee version ([#659](https://github.com/ethersphere/bee-dashboard/issues/659)) ([a5d4ecf](https://github.com/ethersphere/bee-dashboard/commit/a5d4ecf045f691b9059fcca925d0f30675d12db0))
|
||||||
|
|
||||||
|
## [0.26.0](https://github.com/ethersphere/bee-dashboard/compare/v0.25.0...v0.26.0) (2024-06-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* display effective capacity ([#643](https://github.com/ethersphere/bee-dashboard/issues/643)) ([5871223](https://github.com/ethersphere/bee-dashboard/commit/58712232031e084195adf92c40cd41a98eaf16cf))
|
||||||
|
* merge api ([#658](https://github.com/ethersphere/bee-dashboard/issues/658)) ([8cbd812](https://github.com/ethersphere/bee-dashboard/commit/8cbd812a2c04706f8f46de5355209b96783723b9))
|
||||||
|
* show syncing info ([#647](https://github.com/ethersphere/bee-dashboard/issues/647)) ([cc91f1d](https://github.com/ethersphere/bee-dashboard/commit/cc91f1d64cd48a845fa9fa45ec4b58335eab3893))
|
||||||
|
* wait for upload sync ([#649](https://github.com/ethersphere/bee-dashboard/issues/649)) ([79bb315](https://github.com/ethersphere/bee-dashboard/commit/79bb31540196b74f3bc0220b8c844fbd5aaaf488))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* correct the bee version detection ([#645](https://github.com/ethersphere/bee-dashboard/issues/645)) ([b3f521c](https://github.com/ethersphere/bee-dashboard/commit/b3f521ca2055b91d7adddf96563cca6bf92e3d59))
|
||||||
|
|
||||||
|
## [0.25.0](https://github.com/ethersphere/bee-dashboard/compare/v0.24.1...v0.25.0) (2023-12-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* improve topup and dilute ux ([0c2ac0c](https://github.com/ethersphere/bee-dashboard/commit/0c2ac0c454ad02200a2762958c5bc5abbdfe8005))
|
||||||
|
* update postage stamp creation screen ([#641](https://github.com/ethersphere/bee-dashboard/issues/641)) ([4f9abc6](https://github.com/ethersphere/bee-dashboard/commit/4f9abc614eedd5ce3a279a4686cc832c4d1e62c7))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add missing stamp labels and fix inputs ([#634](https://github.com/ethersphere/bee-dashboard/issues/634)) ([7fa1cb0](https://github.com/ethersphere/bee-dashboard/commit/7fa1cb0ccf9f2a32263e84aa76732ebd2fc7fb22))
|
||||||
|
* put stamp input error handling in state ([#640](https://github.com/ethersphere/bee-dashboard/issues/640)) ([20a051b](https://github.com/ethersphere/bee-dashboard/commit/20a051b6589c22397a7305d722a56df0604ff7a4))
|
||||||
|
|
||||||
|
## [0.24.1](https://github.com/ethersphere/bee-dashboard/compare/v0.24.0...v0.24.1) (2023-10-18)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* update `swap-endpoint` to `blockchain-rpc-endpoint` ([#628](https://github.com/ethersphere/bee-dashboard/issues/628)) ([bce93ce](https://github.com/ethersphere/bee-dashboard/commit/bce93ce3cdc1ef4b1f50fcf274591ba00726be16))
|
||||||
|
|
||||||
|
## [0.24.0](https://github.com/ethersphere/bee-dashboard/compare/v0.23.0...v0.24.0) (2023-08-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add stamp dilute and topup ([#619](https://github.com/ethersphere/bee-dashboard/issues/619)) ([055a300](https://github.com/ethersphere/bee-dashboard/commit/055a3002b303df45c7010ef4d365e14b979e9084))
|
||||||
|
|
||||||
|
## [0.23.0](https://github.com/ethersphere/bee-dashboard/compare/v0.22.0...v0.23.0) (2023-02-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add staking for full nodes ([#590](https://github.com/ethersphere/bee-dashboard/issues/590)) ([fac72b1](https://github.com/ethersphere/bee-dashboard/commit/fac72b1299353c104231aa038c1bab9df78c1355))
|
||||||
|
* upgrade bee-js to 5.2.0 ([#611](https://github.com/ethersphere/bee-dashboard/issues/611)) ([e215c61](https://github.com/ethersphere/bee-dashboard/commit/e215c61ea1619fc388fe8b1904d160b04a1a5c0d))
|
||||||
|
|
||||||
|
## [0.22.0](https://github.com/ethersphere/bee-dashboard/compare/v0.21.1...v0.22.0) (2023-01-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add node connecting status ([#603](https://github.com/ethersphere/bee-dashboard/issues/603)) ([90f9f91](https://github.com/ethersphere/bee-dashboard/commit/90f9f91ddbefb47b40c7e567125972b800d81972))
|
||||||
|
|
||||||
|
## [0.21.1](https://github.com/ethersphere/bee-dashboard/compare/v0.21.0...v0.21.1) (2022-12-21)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* do not require chequebook funding ([#599](https://github.com/ethersphere/bee-dashboard/issues/599)) ([42b7f08](https://github.com/ethersphere/bee-dashboard/commit/42b7f080b00a94f068d2fad4779d02ddcf58e27d))
|
||||||
|
|
||||||
|
## [0.21.0](https://github.com/ethersphere/bee-dashboard/compare/v0.20.2...v0.21.0) (2022-12-01)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add prerequisite checks before swap ([#588](https://github.com/ethersphere/bee-dashboard/issues/588)) ([4e564dd](https://github.com/ethersphere/bee-dashboard/commit/4e564dd5c08b938c95f07818bc60957a7df4f5bb))
|
||||||
|
* add starting state to sidebar indicator ([#587](https://github.com/ethersphere/bee-dashboard/issues/587)) ([848e61a](https://github.com/ethersphere/bee-dashboard/commit/848e61a7a0fc9b31cae4f603473b37d467f9e914))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add loading state to info page ([#584](https://github.com/ethersphere/bee-dashboard/issues/584)) ([0246904](https://github.com/ethersphere/bee-dashboard/commit/02469046b05512d6617d8b21ca93b41d6a8a6827))
|
||||||
|
* always consider user input when performing swap ([#572](https://github.com/ethersphere/bee-dashboard/issues/572)) ([ec8fdf0](https://github.com/ethersphere/bee-dashboard/commit/ec8fdf0315ed7ee75c7612780c602cba49a2321d))
|
||||||
|
* always set rpc to newly provided value in desktop ([#591](https://github.com/ethersphere/bee-dashboard/issues/591)) ([b798fa0](https://github.com/ethersphere/bee-dashboard/commit/b798fa0e68b367fe324ef64507b1405b642da6e0))
|
||||||
|
* change status page depending on desktop mode ([#573](https://github.com/ethersphere/bee-dashboard/issues/573)) ([a4b8e7c](https://github.com/ethersphere/bee-dashboard/commit/a4b8e7ca2596028e7c8192c92202c0361610e307))
|
||||||
|
* change version mismatch to a warning ([#594](https://github.com/ethersphere/bee-dashboard/issues/594)) ([dc04e26](https://github.com/ethersphere/bee-dashboard/commit/dc04e26db4fe6beb9e76fad79c732794b0b7f77d))
|
||||||
|
* fix conditional rendering for blockchain network ([#583](https://github.com/ethersphere/bee-dashboard/issues/583)) ([1ce4a47](https://github.com/ethersphere/bee-dashboard/commit/1ce4a474954a5ba4debee53b40bb66a46fb19ffc))
|
||||||
|
* handle auth and server error during swap ([#593](https://github.com/ethersphere/bee-dashboard/issues/593)) ([665ae06](https://github.com/ethersphere/bee-dashboard/commit/665ae063fa49bc94762ea10a9098b57e95327d9c))
|
||||||
|
* hide swap in standalone mode ([#582](https://github.com/ethersphere/bee-dashboard/issues/582)) ([9a8520e](https://github.com/ethersphere/bee-dashboard/commit/9a8520eb6fe9f40a77c4230ab79d3731ebdd4b42))
|
||||||
|
* refresh after chequebook withdraw deposit ([#576](https://github.com/ethersphere/bee-dashboard/issues/576)) ([6936098](https://github.com/ethersphere/bee-dashboard/commit/693609810d735d1e54691b13ea0e4db33e678a53))
|
||||||
|
|
||||||
|
## [0.20.2](https://github.com/ethersphere/bee-dashboard/compare/v0.20.1...v0.20.2) (2022-09-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* stamp purchasing ([#551](https://github.com/ethersphere/bee-dashboard/issues/551)) ([c0456a3](https://github.com/ethersphere/bee-dashboard/commit/c0456a3bf6d541457b706670b1a757d2b1d70f10))
|
||||||
|
|
||||||
|
## [0.20.1](https://github.com/ethersphere/bee-dashboard/compare/v0.20.0...v0.20.1) (2022-09-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* revert bee env. variable names and add default rpc var ([#545](https://github.com/ethersphere/bee-dashboard/issues/545)) ([5295bd5](https://github.com/ethersphere/bee-dashboard/commit/5295bd5b012962846aa15ff12ca4234f0c8b37f7))
|
||||||
|
* rpc endpoint setting ultra-light mode logic ([#547](https://github.com/ethersphere/bee-dashboard/issues/547)) ([e2dd077](https://github.com/ethersphere/bee-dashboard/commit/e2dd077118faf3b6071fc8327e37e317e0174975))
|
||||||
|
|
||||||
|
## [0.20.0](https://github.com/ethersphere/bee-dashboard/compare/v0.19.3...v0.20.0) (2022-09-14)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* error reporting callback ([#530](https://github.com/ethersphere/bee-dashboard/issues/530)) ([0c74dae](https://github.com/ethersphere/bee-dashboard/commit/0c74dae4e88916cf54c3c0500b37203b865e48a7))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* show update notifications only on non-auto-updating Swarm Desktops ([#543](https://github.com/ethersphere/bee-dashboard/issues/543)) ([528a810](https://github.com/ethersphere/bee-dashboard/commit/528a8106907ef176bcdb68b3386c2f3f9ea98a47))
|
||||||
|
|
||||||
|
## [0.19.3](https://github.com/ethersphere/bee-dashboard/compare/v0.19.2...v0.19.3) (2022-08-24)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* pass isBeeDesktop from provider to hook ([#525](https://github.com/ethersphere/bee-dashboard/issues/525)) ([677b6de](https://github.com/ethersphere/bee-dashboard/commit/677b6de0f82b02e1487420e3c08fbd19a949f97b))
|
||||||
|
|
||||||
|
## [0.19.2](https://github.com/ethersphere/bee-dashboard/compare/v0.19.1...v0.19.2) (2022-08-08)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* remove sentry ([#520](https://github.com/ethersphere/bee-dashboard/issues/520)) ([0260df6](https://github.com/ethersphere/bee-dashboard/commit/0260df61de0619202a819b79820cfbef6e3757ae))
|
||||||
|
|
||||||
|
## [0.19.1](https://github.com/ethersphere/bee-dashboard/compare/v0.19.0...v0.19.1) (2022-08-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* compile types when building the library ([#516](https://github.com/ethersphere/bee-dashboard/issues/516)) ([df925b0](https://github.com/ethersphere/bee-dashboard/commit/df925b013bb02a16d308a86050ec8e0e0e361ff7))
|
||||||
|
|
||||||
## [0.19.0](https://github.com/ethersphere/bee-dashboard/compare/v0.18.2...v0.19.0) (2022-08-03)
|
## [0.19.0](https://github.com/ethersphere/bee-dashboard/compare/v0.18.2...v0.19.0) (2022-08-03)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
* @Cafe137 @vojtechsimetka
|
* @Cafe137
|
||||||
|
|||||||
@@ -13,15 +13,13 @@
|
|||||||
**Warning: This project is in alpha state. There might (and most probably will) be changes in the future to its API and
|
**Warning: This project is in alpha state. There might (and most probably will) be changes in the future to its API and
|
||||||
working. Also, no guarantees can be made about its stability, efficiency, and security at this stage.**
|
working. Also, no guarantees can be made about its stability, efficiency, and security at this stage.**
|
||||||
|
|
||||||
This project is intended to be used with **Bee version <!-- SUPPORTED_BEE_START -->1.7.0-bbf13011<!-- SUPPORTED_BEE_END -->**.
|
Stay up to date by joining the [official Discord](https://discord.gg/GU22h2utj6) and by keeping an eye on the
|
||||||
Using it with older or newer Bee versions is not recommended and may not work. Stay up to date by joining the
|
|
||||||
[official Discord](https://discord.gg/GU22h2utj6) and by keeping an eye on the
|
|
||||||
[releases tab](https://github.com/ethersphere/bee-dashboard/releases).
|
[releases tab](https://github.com/ethersphere/bee-dashboard/releases).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
| Node Setup | Upload Files | Download Content | Accounting | Settings |
|
| Node Setup | Upload Files | Download Content | Accounting | Settings |
|
||||||
| ------------------------------------ | -------------------------------------- | ------------------------------------------ | ----------------------------------------- | ---------------------------------------- |
|
| ------------------------------------ | -------------------------------------- | ------------------------------------------ | ----------------------------------------- | ------------------------------------- |
|
||||||
|  |  |  |  |  |
|
|  |  |  |  |  |
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
@@ -45,9 +43,9 @@ npm install -g @ethersphere/bee-dashboard
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
:warning: To successfully connect to the Bee node, you will need to enable the Debug API and CORS. You can do so by
|
:warning: To successfully connect to the Bee node, you will need to enable CORS. You can do so by setting
|
||||||
setting `cors-allowed-origins: ['*']` and `debug-api-enable: true` in the Bee config file and then restart the Bee node.
|
`cors-allowed-origins: ['*']` in the Bee config file and then restart the Bee node. To see where the config file is,
|
||||||
To see where the config file is, consult the
|
consult the
|
||||||
[official Bee documentation](https://docs.ethswarm.org/docs/working-with-bee/configuration#configuring-bee-installed-using-a-package-manager)
|
[official Bee documentation](https://docs.ethswarm.org/docs/working-with-bee/configuration#configuring-bee-installed-using-a-package-manager)
|
||||||
|
|
||||||
### Terminal
|
### Terminal
|
||||||
@@ -94,18 +92,31 @@ npm start
|
|||||||
|
|
||||||
The Bee Dashboard runs in development mode on [http://localhost:3031/](http://localhost:3031/)
|
The Bee Dashboard runs in development mode on [http://localhost:3031/](http://localhost:3031/)
|
||||||
|
|
||||||
> Setting the `REACT_APP_DEV_MODE=1` environment variable, or opening Bee Dashboard with the query string `?devMode=1` loosens some checks. This makes it possible to develop Bee Dashboard without having connected peers and chequebook properly set up, effectively supporting the dev mode of Bee itself.
|
#### Environmental variables
|
||||||
|
|
||||||
|
The CRA supports to specify "environmental variables" during build time which are then hardcoded into the served static
|
||||||
|
files. We support following variables:
|
||||||
|
|
||||||
|
- `REACT_APP_BEE_DESKTOP_ENABLED` (`boolean`) that toggles if the Dashboard is in Desktop mode or not.
|
||||||
|
- `REACT_APP_BEE_DESKTOP_URL` (`string`) defines custom URL where the Desktop API is expected. By default, it is same
|
||||||
|
origin under which the Dashboard is served.
|
||||||
|
- `REACT_APP_BEE_HOST` (`string`) defines custom Bee API URL to be used as default one. By default, the
|
||||||
|
`http://localhost:1633` is used.
|
||||||
|
- `REACT_APP_DEFAULT_RPC_URL` (`string`) defines the default RPC provider URL. Be aware, that his only configures the
|
||||||
|
default value. The user can override this in Settings, which is then persisted in local store and has priority over
|
||||||
|
the value set in this env. variable. By default `https://xdai.fairdatasociety.org` is used.
|
||||||
|
|
||||||
#### Swarm Desktop development
|
#### Swarm Desktop development
|
||||||
|
|
||||||
If you want to develop Bee Dashboard in the Swarm Desktop mode, then spin up `swarm-desktop` to the point where you see Bee Dashboard (eq. install Bee etc.) and:
|
If you want to develop Bee Dashboard in the Swarm Desktop mode, then spin up `swarm-desktop` to the point where Desktop
|
||||||
|
is initialized (eq. the splash screen disappear) and:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
echo "REACT_APP_BEE_DESKTOP_URL=http://localhost:3000
|
echo "REACT_APP_BEE_DESKTOP_URL=http://localhost:3054
|
||||||
REACT_APP_BEE_DESKTOP_ENABLED=true" > .env.development.local
|
REACT_APP_BEE_DESKTOP_ENABLED=true" > .env.development.local
|
||||||
|
|
||||||
npm start
|
npm start
|
||||||
npm run desktop # This will inject the API key to the Dashboard
|
npm run desktop # This will inject the API key to the Dashboard
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contribute
|
## Contribute
|
||||||
@@ -119,7 +130,6 @@ There are some ways you can make this module better:
|
|||||||
|
|
||||||
## Maintainers
|
## Maintainers
|
||||||
|
|
||||||
- [vojtechsimetka](https://github.com/vojtechsimetka)
|
|
||||||
- [Cafe137](https://github.com/Cafe137)
|
- [Cafe137](https://github.com/Cafe137)
|
||||||
|
|
||||||
See what "Maintainer" means [here](https://github.com/ethersphere/repo-maintainer).
|
See what "Maintainer" means [here](https://github.com/ethersphere/repo-maintainer).
|
||||||
@@ -128,5 +138,4 @@ See what "Maintainer" means [here](https://github.com/ethersphere/repo-maintaine
|
|||||||
|
|
||||||
[BSD-3-Clause](./LICENSE)
|
[BSD-3-Clause](./LICENSE)
|
||||||
|
|
||||||
|
|
||||||
[](https://app.fossa.com/projects/git%2Bgithub.com%2Fethersphere%2Fbee-dashboard?ref=badge_large)
|
[](https://app.fossa.com/projects/git%2Bgithub.com%2Fethersphere%2Fbee-dashboard?ref=badge_large)
|
||||||
|
|||||||
Generated
+9778
-21043
File diff suppressed because it is too large
Load Diff
+37
-32
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@ethersphere/bee-dashboard",
|
"name": "@ethersphere/bee-dashboard",
|
||||||
"version": "0.19.0",
|
"version": "0.33.1",
|
||||||
"description": "An app which helps users to setup their Bee node and do actions like cash out cheques",
|
"description": "An app which helps users to setup their Bee node and do actions like cash out cheques",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"bee",
|
"bee",
|
||||||
@@ -25,51 +25,55 @@
|
|||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/ethersphere/bee-dashboard.git"
|
"url": "https://github.com/ethersphere/bee-dashboard.git"
|
||||||
},
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@fairdatasociety/fdp-storage": {
|
||||||
|
"@ethersphere/bee-js": "^10.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ethersphere/bee-js": "^5.0.0",
|
"@ethersphere/bee-js": "^10.1.1",
|
||||||
"@ethersphere/manifest-js": "1.2.1",
|
"@ethersproject/keccak256": "^5.7.0",
|
||||||
"@ethersphere/swarm-cid": "^0.1.0",
|
"@ethersproject/strings": "^5.7.0",
|
||||||
|
"@fairdatasociety/fdp-storage": "^0.19.0",
|
||||||
|
"@formbricks/js": "^4.2.1",
|
||||||
"@material-ui/core": "4.12.3",
|
"@material-ui/core": "4.12.3",
|
||||||
"@material-ui/icons": "4.11.2",
|
"@material-ui/icons": "4.11.2",
|
||||||
"@material-ui/lab": "4.0.0-alpha.57",
|
"@material-ui/lab": "4.0.0-alpha.57",
|
||||||
"@sentry/react": "^7.1.1",
|
"@solarpunkltd/file-manager-lib": "^1.0.0",
|
||||||
"@sentry/tracing": "^7.1.1",
|
"axios": "^0.28.1",
|
||||||
"assert": "^2.0.0",
|
"bignumber.js": "^9.1.2",
|
||||||
"axios": "0.24.0",
|
|
||||||
"bignumber.js": "9.0.1",
|
|
||||||
"buffer": "^6.0.3",
|
"buffer": "^6.0.3",
|
||||||
"crypto": "npm:crypto-browserify",
|
"crypto": "npm:crypto-browserify",
|
||||||
"crypto-browserify": "^3.12.0",
|
"crypto-browserify": "^3.12.0",
|
||||||
"dotted-map": "^2.2.3",
|
"dotted-map": "^2.2.3",
|
||||||
"ethers": "^5.6.4",
|
"ethers": "^5.7.2",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"formik": "2.2.9",
|
"formik": "2.2.9",
|
||||||
"formik-material-ui": "3.0.1",
|
"formik-material-ui": "3.0.1",
|
||||||
"jszip": "^3.7.1",
|
"jszip": "^3.10.1",
|
||||||
"material-ui-dropzone": "3.5.0",
|
"material-ui-dropzone": "3.5.0",
|
||||||
"notistack": "1.0.10",
|
"notistack": "^3.0.1",
|
||||||
"opener": "1.5.2",
|
"opener": "1.5.2",
|
||||||
"qrcode.react": "1.0.1",
|
"qrcode.react": "1.0.1",
|
||||||
"react": ">=17.0.0 || >=18.0.0",
|
"react": ">= 17.0.2",
|
||||||
"react-copy-to-clipboard": "5.0.4",
|
"react-copy-to-clipboard": "^5.1.0",
|
||||||
"react-dom": ">=17.0.0 || >=18.0.0",
|
"react-dom": ">= 17.0.2",
|
||||||
"react-identicons": "1.2.5",
|
"react-identicons": "1.2.5",
|
||||||
"react-router": "6.2.1",
|
"react-router": "6.2.1",
|
||||||
"react-router-dom": "6.2.1",
|
"react-router-dom": "6.2.1",
|
||||||
"react-syntax-highlighter": "15.4.4",
|
"react-syntax-highlighter": "15.4.4",
|
||||||
"remixicon-react": "^1.0.0",
|
"remixicon-react": "^1.0.0",
|
||||||
"semver": "7.3.5",
|
|
||||||
"serve-handler": "6.1.3",
|
"serve-handler": "6.1.3",
|
||||||
"stream": "npm:stream-browserify",
|
"stream": "npm:stream-browserify",
|
||||||
"stream-browserify": "^3.0.0"
|
"stream-browserify": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.16.0",
|
"@babel/core": "^7.22.0",
|
||||||
"@babel/plugin-proposal-class-properties": "7.16.0",
|
"@babel/plugin-proposal-class-properties": "^7.18.6",
|
||||||
"@babel/plugin-transform-runtime": "7.16.4",
|
"@babel/plugin-transform-runtime": "^7.22.0",
|
||||||
"@babel/preset-env": "7.16.4",
|
"@babel/preset-env": "^7.22.0",
|
||||||
"@babel/preset-react": "7.16.7",
|
"@babel/preset-react": "^7.22.0",
|
||||||
"@babel/preset-typescript": "7.16.0",
|
"@babel/preset-typescript": "^7.22.0",
|
||||||
"@commitlint/config-conventional": "14.1.0",
|
"@commitlint/config-conventional": "14.1.0",
|
||||||
"@testing-library/jest-dom": "5.16.4",
|
"@testing-library/jest-dom": "5.16.4",
|
||||||
"@testing-library/react": "12.1.2",
|
"@testing-library/react": "12.1.2",
|
||||||
@@ -80,16 +84,15 @@
|
|||||||
"@types/jest": "27.0.2",
|
"@types/jest": "27.0.2",
|
||||||
"@types/qrcode.react": "1.0.2",
|
"@types/qrcode.react": "1.0.2",
|
||||||
"@types/react": "17.0.34",
|
"@types/react": "17.0.34",
|
||||||
"@types/react-copy-to-clipboard": "5.0.2",
|
"@types/react-copy-to-clipboard": "^5.0.2",
|
||||||
"@types/react-dom": "17.0.11",
|
"@types/react-dom": "17.0.11",
|
||||||
"@types/react-router": "5.1.18",
|
"@types/react-router": "5.1.18",
|
||||||
"@types/react-router-dom": "5.3.2",
|
"@types/react-router-dom": "5.3.2",
|
||||||
"@types/react-syntax-highlighter": "13.5.2",
|
"@types/react-syntax-highlighter": "13.5.2",
|
||||||
"@types/semver": "7.3.9",
|
|
||||||
"@typescript-eslint/eslint-plugin": "5.28.0",
|
"@typescript-eslint/eslint-plugin": "5.28.0",
|
||||||
"@typescript-eslint/parser": "5.28.0",
|
"@typescript-eslint/parser": "5.28.0",
|
||||||
"babel-eslint": "10.1.0",
|
"babel-eslint": "^10.1.0",
|
||||||
"babel-loader": "8.1.0",
|
"babel-loader": "^8.1.0",
|
||||||
"babel-plugin-syntax-dynamic-import": "6.18.0",
|
"babel-plugin-syntax-dynamic-import": "6.18.0",
|
||||||
"babel-plugin-tsconfig-paths": "1.0.2",
|
"babel-plugin-tsconfig-paths": "1.0.2",
|
||||||
"base64-inline-loader": "^2.0.1",
|
"base64-inline-loader": "^2.0.1",
|
||||||
@@ -114,10 +117,11 @@
|
|||||||
"puppeteer": "^15.4.0",
|
"puppeteer": "^15.4.0",
|
||||||
"react-scripts": "^5.0.1",
|
"react-scripts": "^5.0.1",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
|
"sass": "^1.91.0",
|
||||||
"ts-node": "^10.8.1",
|
"ts-node": "^10.8.1",
|
||||||
"typescript": "4.7.3",
|
"typescript": "4.8.3",
|
||||||
"web-vitals": "2.1.2",
|
"web-vitals": "2.1.2",
|
||||||
"webpack": "^5.73.0",
|
"webpack": "^5.93.0",
|
||||||
"webpack-cli": "^4.10.0"
|
"webpack-cli": "^4.10.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -129,7 +133,7 @@
|
|||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"desktop": "node ./desktop.mjs",
|
"desktop": "node ./desktop.mjs",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
"build:component": "rimraf ./lib && webpack --mode=production",
|
"build:component": "rimraf ./lib && webpack --mode=production && npm run compile:types",
|
||||||
"compile:types": "tsc --project tsconfig.lib.json --emitDeclarationOnly --declaration",
|
"compile:types": "tsc --project tsconfig.lib.json --emitDeclarationOnly --declaration",
|
||||||
"test": "react-scripts test",
|
"test": "react-scripts test",
|
||||||
"test:ui": "node ui-test/index.js",
|
"test:ui": "node ui-test/index.js",
|
||||||
@@ -138,7 +142,8 @@
|
|||||||
"lint": "eslint --fix \"src/**/*.ts\" \"src/**/*.tsx\" && prettier --write \"src/**/*.ts\" \"src/**/*.tsx\"",
|
"lint": "eslint --fix \"src/**/*.ts\" \"src/**/*.tsx\" && prettier --write \"src/**/*.ts\" \"src/**/*.tsx\"",
|
||||||
"lint:check": "eslint \"src/**/*.ts\" \"src/**/*.tsx\" && prettier --check \"src/**/*.ts\" \"src/**/*.tsx\"",
|
"lint:check": "eslint \"src/**/*.ts\" \"src/**/*.tsx\" && prettier --check \"src/**/*.ts\" \"src/**/*.tsx\"",
|
||||||
"check:types": "tsc --project tsconfig.lib.json",
|
"check:types": "tsc --project tsconfig.lib.json",
|
||||||
"update-map-data": "node ./utils/update-map-data.js"
|
"update-map-data": "node ./utils/update-map-data.js",
|
||||||
|
"bee": "npx bee-factory start"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"lib",
|
"lib",
|
||||||
@@ -160,6 +165,6 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0",
|
"node": ">=14.0.0",
|
||||||
"npm": ">=6.9.0",
|
"npm": ">=6.9.0",
|
||||||
"bee": ">=0.6.0"
|
"bee": "1.16.1-8e269c8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,7 @@ button {
|
|||||||
font-family: 'iAWriterMonoV' !important;
|
font-family: 'iAWriterMonoV' !important;
|
||||||
color: #dd7700;
|
color: #dd7700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.MuiContainer-maxWidthLg > .fm {
|
||||||
|
padding: 0px !important;
|
||||||
|
}
|
||||||
|
|||||||
+33
-41
@@ -1,14 +1,14 @@
|
|||||||
import CssBaseline from '@material-ui/core/CssBaseline'
|
import CssBaseline from '@material-ui/core/CssBaseline'
|
||||||
import { ThemeProvider } from '@material-ui/core/styles'
|
import { ThemeProvider } from '@material-ui/core/styles'
|
||||||
import { SnackbarProvider } from 'notistack'
|
import { SnackbarProvider } from 'notistack'
|
||||||
import React, { ReactElement } from 'react'
|
import { ReactElement } from 'react'
|
||||||
import { HashRouter as Router } from 'react-router-dom'
|
import { HashRouter as Router } from 'react-router-dom'
|
||||||
import * as Sentry from '@sentry/react'
|
|
||||||
import './App.css'
|
import './App.css'
|
||||||
import Dashboard from './layout/Dashboard'
|
import Dashboard from './layout/Dashboard'
|
||||||
import { Provider as BeeProvider } from './providers/Bee'
|
import { Provider as BeeProvider } from './providers/Bee'
|
||||||
import { Provider as FeedsProvider } from './providers/Feeds'
|
import { Provider as FeedsProvider } from './providers/Feeds'
|
||||||
import { Provider as FileProvider } from './providers/File'
|
import { Provider as FileProvider } from './providers/File'
|
||||||
|
import { Provider as FileManagerProvider } from './providers/FileManager'
|
||||||
import { Provider as PlatformProvider } from './providers/Platform'
|
import { Provider as PlatformProvider } from './providers/Platform'
|
||||||
import { Provider as SettingsProvider } from './providers/Settings'
|
import { Provider as SettingsProvider } from './providers/Settings'
|
||||||
import { Provider as StampsProvider } from './providers/Stamps'
|
import { Provider as StampsProvider } from './providers/Stamps'
|
||||||
@@ -16,51 +16,55 @@ import { Provider as TopUpProvider } from './providers/TopUp'
|
|||||||
import { Provider as BalanceProvider } from './providers/WalletBalance'
|
import { Provider as BalanceProvider } from './providers/WalletBalance'
|
||||||
import BaseRouter from './routes'
|
import BaseRouter from './routes'
|
||||||
import { theme } from './theme'
|
import { theme } from './theme'
|
||||||
import { config } from './config'
|
|
||||||
import ItsBroken from './layout/ItsBroken'
|
|
||||||
import { initSentry } from './utils/sentry'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
beeApiUrl?: string
|
beeApiUrl?: string
|
||||||
beeDebugApiUrl?: string
|
defaultRpcUrl?: string
|
||||||
lockedApiSettings?: boolean
|
lockedApiSettings?: boolean
|
||||||
isBeeDesktop?: boolean
|
isDesktop?: boolean
|
||||||
|
desktopUrl?: string
|
||||||
|
errorReporting?: (err: Error) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.SENTRY_KEY) {
|
const App = ({
|
||||||
// eslint-disable-next-line no-console
|
beeApiUrl,
|
||||||
initSentry().catch(e => console.error(e))
|
defaultRpcUrl,
|
||||||
}
|
lockedApiSettings,
|
||||||
|
isDesktop,
|
||||||
const App = ({ beeApiUrl, beeDebugApiUrl, lockedApiSettings, isBeeDesktop }: Props): ReactElement => {
|
desktopUrl,
|
||||||
|
errorReporting,
|
||||||
|
}: Props): ReactElement => {
|
||||||
const mainApp = (
|
const mainApp = (
|
||||||
<div className="App">
|
<div className="App">
|
||||||
<ThemeProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<SettingsProvider
|
<SettingsProvider
|
||||||
beeApiUrl={beeApiUrl}
|
beeApiUrl={beeApiUrl}
|
||||||
beeDebugApiUrl={beeDebugApiUrl}
|
defaultRpcUrl={defaultRpcUrl}
|
||||||
lockedApiSettings={lockedApiSettings}
|
lockedApiSettings={lockedApiSettings}
|
||||||
isBeeDesktop={isBeeDesktop}
|
isDesktop={isDesktop}
|
||||||
|
desktopUrl={desktopUrl}
|
||||||
>
|
>
|
||||||
<TopUpProvider>
|
<TopUpProvider>
|
||||||
<BeeProvider>
|
<BeeProvider>
|
||||||
<BalanceProvider>
|
<BalanceProvider>
|
||||||
<StampsProvider>
|
<StampsProvider>
|
||||||
<FileProvider>
|
<FileProvider>
|
||||||
<FeedsProvider>
|
<FileManagerProvider>
|
||||||
<PlatformProvider>
|
<FeedsProvider>
|
||||||
<SnackbarProvider preventDuplicate anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}>
|
<PlatformProvider>
|
||||||
<Router>
|
<SnackbarProvider preventDuplicate anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}>
|
||||||
<>
|
<Router>
|
||||||
<CssBaseline />
|
<>
|
||||||
<Dashboard>
|
<CssBaseline />
|
||||||
<BaseRouter />
|
<Dashboard errorReporting={errorReporting}>
|
||||||
</Dashboard>
|
<BaseRouter />
|
||||||
</>
|
</Dashboard>
|
||||||
</Router>
|
</>
|
||||||
</SnackbarProvider>
|
</Router>
|
||||||
</PlatformProvider>
|
</SnackbarProvider>
|
||||||
</FeedsProvider>
|
</PlatformProvider>
|
||||||
|
</FeedsProvider>
|
||||||
|
</FileManagerProvider>
|
||||||
</FileProvider>
|
</FileProvider>
|
||||||
</StampsProvider>
|
</StampsProvider>
|
||||||
</BalanceProvider>
|
</BalanceProvider>
|
||||||
@@ -71,18 +75,6 @@ const App = ({ beeApiUrl, beeDebugApiUrl, lockedApiSettings, isBeeDesktop }: Pro
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
// Displays Report Dialog when some component crashes
|
|
||||||
if (config.SENTRY_KEY) {
|
|
||||||
return (
|
|
||||||
<Sentry.ErrorBoundary
|
|
||||||
showDialog
|
|
||||||
fallback={({ error, componentStack, resetError }) => <ItsBroken message={error.message} />}
|
|
||||||
>
|
|
||||||
{mainApp}
|
|
||||||
</Sentry.ErrorBoundary>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return mainApp
|
return mainApp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+51739
-7115
File diff suppressed because it is too large
Load Diff
+16
-2
@@ -2,6 +2,8 @@ import { createStyles, makeStyles, Theme, Typography } from '@material-ui/core'
|
|||||||
import { ReactElement } from 'react'
|
import { ReactElement } from 'react'
|
||||||
import Check from 'remixicon-react/CheckLineIcon'
|
import Check from 'remixicon-react/CheckLineIcon'
|
||||||
import AlertCircle from 'remixicon-react/ErrorWarningFillIcon'
|
import AlertCircle from 'remixicon-react/ErrorWarningFillIcon'
|
||||||
|
import Connecting from 'remixicon-react/LinksLineIcon'
|
||||||
|
import RefreshLine from 'remixicon-react/RefreshLineIcon'
|
||||||
import { SwarmButton, SwarmButtonProps } from './SwarmButton'
|
import { SwarmButton, SwarmButtonProps } from './SwarmButton'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -9,7 +11,7 @@ interface Props {
|
|||||||
title: string
|
title: string
|
||||||
subtitle: string
|
subtitle: string
|
||||||
buttonProps: SwarmButtonProps
|
buttonProps: SwarmButtonProps
|
||||||
status: 'ok' | 'error'
|
status: 'ok' | 'error' | 'loading' | 'connecting'
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = (backgroundColor: string) =>
|
const useStyles = (backgroundColor: string) =>
|
||||||
@@ -56,12 +58,24 @@ export default function Card({ buttonProps, icon, title, subtitle, status }: Pro
|
|||||||
const { className, ...rest } = buttonProps
|
const { className, ...rest } = buttonProps
|
||||||
const classes = useStyles(backgroundColor)()
|
const classes = useStyles(backgroundColor)()
|
||||||
|
|
||||||
|
let statusIcon = null
|
||||||
|
|
||||||
|
if (status === 'ok') {
|
||||||
|
statusIcon = <Check size="13" color="#09ca6c" />
|
||||||
|
} else if (status === 'error') {
|
||||||
|
statusIcon = <AlertCircle size="13" color="#f44336" />
|
||||||
|
} else if (status === 'loading') {
|
||||||
|
statusIcon = <RefreshLine size="13" color="orange" />
|
||||||
|
} else if (status === 'connecting') {
|
||||||
|
statusIcon = <Connecting size="13" color="#0074D9" />
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.root}>
|
<div className={classes.root}>
|
||||||
<div className={classes.wrapper}>
|
<div className={classes.wrapper}>
|
||||||
<div className={classes.iconWrapper}>
|
<div className={classes.iconWrapper}>
|
||||||
{icon}
|
{icon}
|
||||||
{status === 'ok' ? <Check size="13" color="#09ca6c" /> : <AlertCircle size="13" color="#f44336" />}
|
{statusIcon}
|
||||||
</div>
|
</div>
|
||||||
<Typography variant="h2" style={{ marginBottom: '8px' }}>
|
<Typography variant="h2" style={{ marginBottom: '8px' }}>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { useSnackbar } from 'notistack'
|
|||||||
import { ReactElement, useContext, useState } from 'react'
|
import { ReactElement, useContext, useState } from 'react'
|
||||||
import Zap from 'remixicon-react/FlashlightLineIcon'
|
import Zap from 'remixicon-react/FlashlightLineIcon'
|
||||||
import { Context as SettingsContext } from '../providers/Settings'
|
import { Context as SettingsContext } from '../providers/Settings'
|
||||||
import EthereumAddress from './EthereumAddress'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
peerId: string
|
peerId: string
|
||||||
@@ -19,8 +18,8 @@ interface Props {
|
|||||||
export default function CheckoutModal({ peerId, uncashedAmount }: Props): ReactElement {
|
export default function CheckoutModal({ peerId, uncashedAmount }: Props): ReactElement {
|
||||||
const [open, setOpen] = useState<boolean>(false)
|
const [open, setOpen] = useState<boolean>(false)
|
||||||
const [loadingCashout, setLoadingCashout] = useState<boolean>(false)
|
const [loadingCashout, setLoadingCashout] = useState<boolean>(false)
|
||||||
|
const { beeApi } = useContext(SettingsContext)
|
||||||
const { enqueueSnackbar } = useSnackbar()
|
const { enqueueSnackbar } = useSnackbar()
|
||||||
const { beeDebugApi } = useContext(SettingsContext)
|
|
||||||
|
|
||||||
const handleClickOpen = () => {
|
const handleClickOpen = () => {
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
@@ -31,21 +30,15 @@ export default function CheckoutModal({ peerId, uncashedAmount }: Props): ReactE
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCashout = () => {
|
const handleCashout = () => {
|
||||||
if (!beeDebugApi) return
|
if (peerId && beeApi) {
|
||||||
|
|
||||||
if (peerId) {
|
|
||||||
setLoadingCashout(true)
|
setLoadingCashout(true)
|
||||||
beeDebugApi
|
beeApi
|
||||||
.cashoutLastCheque(peerId)
|
.cashoutLastCheque(peerId)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
enqueueSnackbar(
|
enqueueSnackbar(<span>Successfully cashed out cheque. Transaction {res.toHex()}</span>, {
|
||||||
<span>
|
variant: 'success',
|
||||||
Successfully cashed out cheque. Transaction
|
})
|
||||||
<EthereumAddress hideBlockie transaction address={res} />
|
|
||||||
</span>,
|
|
||||||
{ variant: 'success' },
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
.catch((e: Error) => {
|
.catch((e: Error) => {
|
||||||
console.error(e) // eslint-disable-line
|
console.error(e) // eslint-disable-line
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ChainState } from '@ethersphere/bee-js'
|
||||||
|
import { useContext, useEffect, useState } from 'react'
|
||||||
|
import { Context } from '../providers/Settings'
|
||||||
|
import ExpandableListItem from './ExpandableListItem'
|
||||||
|
|
||||||
|
export function ChainSync() {
|
||||||
|
const { beeApi } = useContext(Context)
|
||||||
|
const [chainState, setChainState] = useState<ChainState | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (!beeApi) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
beeApi.getChainState().then(setChainState).catch(console.error) // eslint-disable-line
|
||||||
|
}, 3_000)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ExpandableListItem label="Chain state" value={chainState ? `${chainState.block} / ${chainState.chainTip}` : '-'} />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Component, ErrorInfo, ReactElement } from 'react'
|
import { Component, ErrorInfo, ReactElement } from 'react'
|
||||||
import ItsBroken from '../layout/ItsBroken'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: ReactElement
|
children: ReactElement
|
||||||
|
errorReporting?: (err: Error) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
@@ -10,8 +10,11 @@ interface State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default class ErrorBoundary extends Component<Props, State> {
|
export default class ErrorBoundary extends Component<Props, State> {
|
||||||
|
private errorReporting?: (err: Error) => void
|
||||||
|
|
||||||
constructor(props: Props) {
|
constructor(props: Props) {
|
||||||
super(props)
|
super(props)
|
||||||
|
this.errorReporting = props.errorReporting
|
||||||
this.state = { error: null }
|
this.state = { error: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,13 +24,17 @@ export default class ErrorBoundary extends Component<Props, State> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
|
||||||
// You can also log the error to an error reporting service
|
if (this.errorReporting) {
|
||||||
|
this.errorReporting(error)
|
||||||
|
}
|
||||||
|
|
||||||
console.error({ error, errorInfo }) // eslint-disable-line
|
console.error({ error, errorInfo }) // eslint-disable-line
|
||||||
}
|
}
|
||||||
|
|
||||||
render(): ReactElement {
|
render(): ReactElement {
|
||||||
if (this.state.error) {
|
if (this.state.error) {
|
||||||
return <ItsBroken message={this.state.error.message} />
|
// You can render any custom fallback UI
|
||||||
|
return <h1>Something went wrong. Error: {this.state.error.message}</h1>
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.props.children
|
return this.props.children
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { Typography } from '@material-ui/core/'
|
import { Typography } from '@material-ui/core/'
|
||||||
|
import { EthAddress } from '@ethersphere/bee-js'
|
||||||
import { ReactElement } from 'react'
|
import { ReactElement } from 'react'
|
||||||
import Identicon from 'react-identicons'
|
import Identicon from 'react-identicons'
|
||||||
import { config } from '../config'
|
import { BLOCKCHAIN_EXPLORER_URL } from '../constants'
|
||||||
import ClipboardCopy from './ClipboardCopy'
|
import ClipboardCopy from './ClipboardCopy'
|
||||||
|
import { Flex } from './Flex'
|
||||||
import QRCodeModal from './QRCodeModal'
|
import QRCodeModal from './QRCodeModal'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
address: string | undefined
|
address: EthAddress | undefined
|
||||||
hideBlockie?: boolean
|
hideBlockie?: boolean
|
||||||
transaction?: boolean
|
transaction?: boolean
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
@@ -16,10 +18,10 @@ export default function EthereumAddress(props: Props): ReactElement {
|
|||||||
return (
|
return (
|
||||||
<Typography component="div" variant="subtitle1">
|
<Typography component="div" variant="subtitle1">
|
||||||
{props.address ? (
|
{props.address ? (
|
||||||
<div style={{ display: 'flex' }}>
|
<Flex>
|
||||||
{props.hideBlockie ? null : (
|
{props.hideBlockie ? null : (
|
||||||
<div style={{ paddingTop: '5px', marginRight: '10px' }}>
|
<div style={{ paddingTop: '5px', marginRight: '10px' }}>
|
||||||
<Identicon size={20} string={props.address} />
|
<Identicon size={20} string={props.address.toChecksum()} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
@@ -36,16 +38,16 @@ export default function EthereumAddress(props: Props): ReactElement {
|
|||||||
}
|
}
|
||||||
: { marginRight: '7px' }
|
: { marginRight: '7px' }
|
||||||
}
|
}
|
||||||
href={`${config.BLOCKCHAIN_EXPLORER_URL}/${props.transaction ? 'tx' : 'address'}/${props.address}`}
|
href={`${BLOCKCHAIN_EXPLORER_URL}/${props.transaction ? 'tx' : 'address'}/${props.address}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
>
|
||||||
{props.address}
|
{props.address}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<QRCodeModal value={props.address} label={'Ethereum Address'} />
|
<QRCodeModal value={props.address.toChecksum()} label={'Ethereum Address'} />
|
||||||
<ClipboardCopy value={props.address} />
|
<ClipboardCopy value={props.address.toChecksum()} />
|
||||||
</div>
|
</Flex>
|
||||||
) : (
|
) : (
|
||||||
'-'
|
'-'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ const useStyles = makeStyles((theme: Theme) =>
|
|||||||
},
|
},
|
||||||
contentLevel12: {
|
contentLevel12: {
|
||||||
marginTop: theme.spacing(0.25),
|
marginTop: theme.spacing(0.25),
|
||||||
|
'& > li:last-of-type': {
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
infoText: {
|
infoText: {
|
||||||
color: '#c9c9c9',
|
color: '#c9c9c9',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ReactElement, ReactNode, useState } from 'react'
|
|
||||||
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
|
|
||||||
import { Collapse, ListItem, ListItemText, Typography } from '@material-ui/core'
|
import { Collapse, ListItem, ListItemText, Typography } from '@material-ui/core'
|
||||||
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
import { ExpandLess, ExpandMore } from '@material-ui/icons'
|
import { ExpandLess, ExpandMore } from '@material-ui/icons'
|
||||||
|
import { ReactElement, ReactNode, useState } from 'react'
|
||||||
|
import { Flex } from './Flex'
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
createStyles({
|
createStyles({
|
||||||
@@ -65,14 +66,14 @@ export default function ExpandableList({ children, label, level, defaultOpen, in
|
|||||||
<div className={`${classes.root} ${rootLevelClass}`}>
|
<div className={`${classes.root} ${rootLevelClass}`}>
|
||||||
<ListItem button onClick={handleClick} className={classes.header}>
|
<ListItem button onClick={handleClick} className={classes.header}>
|
||||||
<ListItemText primary={<Typography variant={typographyVariant}>{label}</Typography>} />
|
<ListItemText primary={<Typography variant={typographyVariant}>{label}</Typography>} />
|
||||||
<div style={{ display: 'flex' }}>
|
<Flex>
|
||||||
{!open && (
|
{!open && (
|
||||||
<Typography variant="body2" className={classes.infoText}>
|
<Typography variant="body2" className={classes.infoText}>
|
||||||
{info}
|
{info}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{open ? <ExpandLess /> : <ExpandMore />}
|
{open ? <ExpandLess /> : <ExpandMore />}
|
||||||
</div>
|
</Flex>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||||
<div className={contentLevelClass}>{children}</div>
|
<div className={contentLevelClass}>{children}</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ReactElement, ReactNode } from 'react'
|
import { Grid, IconButton, Tooltip, Typography } from '@material-ui/core'
|
||||||
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'
|
|
||||||
import { Typography, Grid, IconButton, Tooltip } from '@material-ui/core'
|
|
||||||
import Info from 'remixicon-react/InformationLineIcon'
|
|
||||||
import ListItem from '@material-ui/core/ListItem'
|
import ListItem from '@material-ui/core/ListItem'
|
||||||
|
import { Theme, createStyles, makeStyles } from '@material-ui/core/styles'
|
||||||
|
import { ReactElement, ReactNode } from 'react'
|
||||||
|
import Info from 'remixicon-react/InformationLineIcon'
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
createStyles({
|
createStyles({
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { Grid, IconButton, InputBase, ListItem, Typography } from '@material-ui/core'
|
import { Box, Grid, IconButton, InputBase, ListItem, Typography } from '@material-ui/core'
|
||||||
import Collapse from '@material-ui/core/Collapse'
|
import Collapse from '@material-ui/core/Collapse'
|
||||||
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
import { ChangeEvent, ReactElement, useState } from 'react'
|
import { ChangeEvent, ReactElement, useState } from 'react'
|
||||||
|
import type { RemixiconReactIconProps } from 'remixicon-react'
|
||||||
import Check from 'remixicon-react/CheckLineIcon'
|
import Check from 'remixicon-react/CheckLineIcon'
|
||||||
|
import X from 'remixicon-react/CloseLineIcon'
|
||||||
import Edit from 'remixicon-react/PencilLineIcon'
|
import Edit from 'remixicon-react/PencilLineIcon'
|
||||||
import Minus from 'remixicon-react/SubtractLineIcon'
|
import Minus from 'remixicon-react/SubtractLineIcon'
|
||||||
import X from 'remixicon-react/CloseLineIcon'
|
|
||||||
import ExpandableListItemActions from './ExpandableListItemActions'
|
import ExpandableListItemActions from './ExpandableListItemActions'
|
||||||
import ExpandableListItemNote from './ExpandableListItemNote'
|
import ExpandableListItemNote from './ExpandableListItemNote'
|
||||||
import { SwarmButton } from './SwarmButton'
|
import { SwarmButton } from './SwarmButton'
|
||||||
import type { RemixiconReactIconProps } from 'remixicon-react'
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
createStyles({
|
createStyles({
|
||||||
@@ -89,9 +89,9 @@ export default function ExpandableListItemKey({
|
|||||||
e.target.value = mapperFn(e.target.value)
|
e.target.value = mapperFn(e.target.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputValue(e.target.value)
|
setInputValue(e.target.value.trim())
|
||||||
|
|
||||||
if (onChange) onChange(e.target.value)
|
if (onChange) onChange(e.target.value.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -108,12 +108,8 @@ export default function ExpandableListItemKey({
|
|||||||
<div>
|
<div>
|
||||||
{!open && value}
|
{!open && value}
|
||||||
{!expandedOnly && !locked && (
|
{!expandedOnly && !locked && (
|
||||||
<IconButton size="small" className={classes.copyValue}>
|
<IconButton size="small" className={classes.copyValue} onClick={toggleOpen}>
|
||||||
{open ? (
|
{open ? <Minus strokeWidth={1} /> : <Edit strokeWidth={1} />}
|
||||||
<Minus onClick={toggleOpen} strokeWidth={1} />
|
|
||||||
) : (
|
|
||||||
<Edit onClick={toggleOpen} strokeWidth={1} />
|
|
||||||
)}
|
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,31 +130,33 @@ export default function ExpandableListItemKey({
|
|||||||
</ListItem>
|
</ListItem>
|
||||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||||
{helperText && <ExpandableListItemNote>{helperText}</ExpandableListItemNote>}
|
{helperText && <ExpandableListItemNote>{helperText}</ExpandableListItemNote>}
|
||||||
<ExpandableListItemActions>
|
<Box mt={2}>
|
||||||
<SwarmButton
|
<ExpandableListItemActions>
|
||||||
disabled={
|
<SwarmButton
|
||||||
loading ||
|
disabled={
|
||||||
inputValue === value ||
|
loading ||
|
||||||
Boolean(confirmLabelDisabled) || // Disable if external validation is provided
|
inputValue === value ||
|
||||||
(inputValue === '' && value === undefined) // Disable if no initial value was not provided and the field is empty. The undefined check is improtant so that it is possible to submit with empty input in other cases
|
Boolean(confirmLabelDisabled) || // Disable if external validation is provided
|
||||||
}
|
(inputValue === '' && value === undefined) // Disable if no initial value was not provided and the field is empty. The undefined check is improtant so that it is possible to submit with empty input in other cases
|
||||||
loading={loading}
|
}
|
||||||
iconType={confirmIcon ?? Check}
|
loading={loading}
|
||||||
onClick={() => {
|
iconType={confirmIcon ?? Check}
|
||||||
if (onConfirm) onConfirm(inputValue)
|
onClick={() => {
|
||||||
}}
|
if (onConfirm) onConfirm(inputValue)
|
||||||
>
|
}}
|
||||||
{confirmLabel || 'Save'}
|
>
|
||||||
</SwarmButton>
|
{confirmLabel || 'Save'}
|
||||||
<SwarmButton
|
</SwarmButton>
|
||||||
disabled={loading || inputValue === value || inputValue === ''}
|
<SwarmButton
|
||||||
iconType={X}
|
disabled={loading || inputValue === value || inputValue === ''}
|
||||||
onClick={() => setInputValue(value || '')}
|
iconType={X}
|
||||||
cancel
|
onClick={() => setInputValue(value || '')}
|
||||||
>
|
cancel
|
||||||
Cancel
|
>
|
||||||
</SwarmButton>
|
Cancel
|
||||||
</ExpandableListItemActions>
|
</SwarmButton>
|
||||||
|
</ExpandableListItemActions>
|
||||||
|
</Box>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,20 +77,18 @@ export default function ExpandableListItemKey({ label, value, expanded }: Props)
|
|||||||
<Grid container direction="row" justifyContent="space-between" alignItems="center">
|
<Grid container direction="row" justifyContent="space-between" alignItems="center">
|
||||||
{label && <Typography variant="body1">{label}</Typography>}
|
{label && <Typography variant="body1">{label}</Typography>}
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
<div>
|
{!open && (
|
||||||
{!open && (
|
<span className={classes.copyValue}>
|
||||||
<span className={classes.copyValue}>
|
<Tooltip title={copied ? 'Copied' : 'Copy'} placement="top" arrow onClose={tooltipCloseHandler}>
|
||||||
<Tooltip title={copied ? 'Copied' : 'Copy'} placement="top" arrow onClose={tooltipCloseHandler}>
|
<CopyToClipboard text={value}>
|
||||||
<CopyToClipboard text={value}>
|
<span onClick={tooltipClickHandler}>{value ? spanText : ''}</span>
|
||||||
<span onClick={tooltipClickHandler}>{value ? spanText : ''}</span>
|
</CopyToClipboard>
|
||||||
</CopyToClipboard>
|
</Tooltip>
|
||||||
</Tooltip>
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
<IconButton size="small" className={classes.copyValue} onClick={toggleOpen}>
|
||||||
<IconButton size="small" className={classes.copyValue}>
|
{open ? <Minus strokeWidth={1} /> : <Eye strokeWidth={1} />}
|
||||||
{open ? <Minus onClick={toggleOpen} strokeWidth={1} /> : <Eye onClick={toggleOpen} strokeWidth={1} />}
|
</IconButton>
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||||
|
|||||||
@@ -82,22 +82,20 @@ export default function ExpandableListItemLink({
|
|||||||
<Grid container direction="row" justifyContent="space-between" alignItems="center">
|
<Grid container direction="row" justifyContent="space-between" alignItems="center">
|
||||||
{label && <Typography variant="body1">{label}</Typography>}
|
{label && <Typography variant="body1">{label}</Typography>}
|
||||||
<Typography variant="body2">
|
<Typography variant="body2">
|
||||||
<div>
|
{allowClipboard && (
|
||||||
{allowClipboard && (
|
<span className={classes.copyValue}>
|
||||||
<span className={classes.copyValue}>
|
<Tooltip title={copied ? 'Copied' : 'Copy'} placement="top" arrow onClose={tooltipCloseHandler}>
|
||||||
<Tooltip title={copied ? 'Copied' : 'Copy'} placement="top" arrow onClose={tooltipCloseHandler}>
|
<CopyToClipboard text={value}>
|
||||||
<CopyToClipboard text={value}>
|
<span onClick={tooltipClickHandler}>{displayValue}</span>
|
||||||
<span onClick={tooltipClickHandler}>{displayValue}</span>
|
</CopyToClipboard>
|
||||||
</CopyToClipboard>
|
</Tooltip>
|
||||||
</Tooltip>
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
{!allowClipboard && <span onClick={onNavigation}>{displayValue}</span>}
|
||||||
{!allowClipboard && <span onClick={onNavigation}>{displayValue}</span>}
|
<IconButton size="small" className={classes.openLinkIcon} onClick={onNavigation}>
|
||||||
<IconButton size="small" className={classes.openLinkIcon}>
|
{navigationType === 'NEW_WINDOW' && <OpenInNewSharp strokeWidth={1} />}
|
||||||
{navigationType === 'NEW_WINDOW' && <OpenInNewSharp onClick={onNavigation} strokeWidth={1} />}
|
{navigationType === 'HISTORY_PUSH' && <ArrowForward strokeWidth={1} />}
|
||||||
{navigationType === 'HISTORY_PUSH' && <ArrowForward onClick={onNavigation} strokeWidth={1} />}
|
</IconButton>
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import { ReactElement, useEffect, useState } from 'react'
|
|
||||||
import * as Sentry from '@sentry/react'
|
|
||||||
import { Link } from '@material-ui/core'
|
|
||||||
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
|
||||||
import MessageSquare from 'remixicon-react/Message2LineIcon'
|
|
||||||
|
|
||||||
import config from '../config'
|
|
||||||
import SideBarItem from './SideBarItem'
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
|
||||||
createStyles({
|
|
||||||
link: {
|
|
||||||
color: '#9f9f9f',
|
|
||||||
textDecoration: 'none',
|
|
||||||
'&:hover': {
|
|
||||||
textDecoration: 'none',
|
|
||||||
|
|
||||||
// https://github.com/mui-org/material-ui/issues/22543
|
|
||||||
'@media (hover: none)': {
|
|
||||||
textDecoration: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
icon: {
|
|
||||||
height: theme.spacing(4),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses Sentry DNS so it could be transformed into API call
|
|
||||||
* Sentry DNS like https://1asfasdf2312asdf3@o132123.ingest.sentry.io/13123123
|
|
||||||
*/
|
|
||||||
const SENTRY_PARSING_REGEX = /^https:\/\/(?<key>\w+)@(?<sub>\w+)\.ingest\.sentry\.io\/(?<path>\d+)$/gm
|
|
||||||
|
|
||||||
async function isSentryReachable(): Promise<boolean> {
|
|
||||||
const key = config.SENTRY_KEY
|
|
||||||
|
|
||||||
if (!key) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = SENTRY_PARSING_REGEX.exec(key)
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `https://${match.groups?.sub}.ingest.sentry.io/api/${match.groups?.path}/envelope/?sentry_key=${match.groups?.key}`
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch(url, { method: 'POST' })
|
|
||||||
|
|
||||||
// Since we got some reply (even though most probably with some error) that means Sentry is reachable ==> lets provide the Feedback form
|
|
||||||
return true
|
|
||||||
} catch (e) {
|
|
||||||
// If an error was thrown than the request was blocked by the browser so Sentry is not accessible to us
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showFeedbackForm(): void {
|
|
||||||
const eventId = Sentry.captureMessage('User feedback')
|
|
||||||
Sentry.showReportDialog({
|
|
||||||
eventId,
|
|
||||||
title: 'Provide us feedback!',
|
|
||||||
subtitle: 'Share with us what you like and/or dislike.',
|
|
||||||
subtitle2: 'We will be very happy.',
|
|
||||||
labelComments: 'What is your impression about this app?',
|
|
||||||
labelSubmit: 'Send Feedback',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Feedback(): ReactElement {
|
|
||||||
const [sentryEnabled, setSentryEnabled] = useState(false)
|
|
||||||
const classes = useStyles()
|
|
||||||
|
|
||||||
// Run this only on component mount to verify once that Sentry is reachable
|
|
||||||
useEffect(() => {
|
|
||||||
isSentryReachable().then(result => {
|
|
||||||
setSentryEnabled(result)
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (sentryEnabled) {
|
|
||||||
return (
|
|
||||||
<Link onClick={showFeedbackForm} className={classes.link}>
|
|
||||||
<SideBarItem iconStart={<MessageSquare className={classes.icon} />} label={<span>Send feedback</span>} />
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <></>
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { createStyles, makeStyles } from '@material-ui/core'
|
||||||
|
import { ReactElement } from 'react'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(() =>
|
||||||
|
createStyles({
|
||||||
|
video: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
interface VideoProps {
|
||||||
|
src: string | undefined
|
||||||
|
maxHeight?: string
|
||||||
|
maxWidth?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FitVideo(props: VideoProps): ReactElement {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const inlineStyles: Record<string, string> = {}
|
||||||
|
|
||||||
|
props.maxHeight && (inlineStyles.maxHeight = props.maxHeight)
|
||||||
|
props.maxWidth && (inlineStyles.maxWidth = props.maxWidth)
|
||||||
|
|
||||||
|
return <video className={classes.video} src={props.src} style={inlineStyles} controls />
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Flex({ children }: Props) {
|
||||||
|
return <div style={{ display: 'flex' }}>{children}</div>
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ReactElement, CSSProperties, useContext, useState, useEffect } from 'react'
|
|
||||||
import type { Peer } from '@ethersphere/bee-js'
|
import type { Peer } from '@ethersphere/bee-js'
|
||||||
import DottedMap, { DottedMapWithoutCountriesLib } from 'dotted-map/without-countries'
|
import DottedMap, { DottedMapWithoutCountriesLib } from 'dotted-map/without-countries'
|
||||||
|
import { CSSProperties, ReactElement, useContext, useEffect, useState } from 'react'
|
||||||
|
import mapData from '../assets/data/map-data.json'
|
||||||
import nodesDb from '../assets/data/nodes-db.json'
|
import nodesDb from '../assets/data/nodes-db.json'
|
||||||
import { Context } from '../providers/Bee'
|
import { Context } from '../providers/Bee'
|
||||||
import mapData from '../assets/data/map-data.json'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
style?: CSSProperties
|
style?: CSSProperties
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import React, { ReactElement } from 'react'
|
||||||
|
import LinearProgress, { LinearProgressProps } from '@material-ui/core/LinearProgress'
|
||||||
|
import Typography from '@material-ui/core/Typography'
|
||||||
|
import Box from '@material-ui/core/Box'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
linearProgressProps?: LinearProgressProps
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinearProgressWithLabel(props: Props): ReactElement {
|
||||||
|
return (
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
|
<Box width="100%" mr={1}>
|
||||||
|
<LinearProgress variant="determinate" {...props} />
|
||||||
|
</Box>
|
||||||
|
<Box minWidth={35}>
|
||||||
|
<Typography variant="body2" color="textSecondary">{`${Math.round(props.value)}%`}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
+33
-11
@@ -1,23 +1,24 @@
|
|||||||
import { Divider, Drawer, Grid, Link as MUILink, List } from '@material-ui/core'
|
import { Box, Divider, Drawer, Grid, List, Link as MUILink, Typography } from '@material-ui/core'
|
||||||
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
import { Theme, createStyles, makeStyles } from '@material-ui/core/styles'
|
||||||
|
import { BeeModes } from '@ethersphere/bee-js'
|
||||||
import { ReactElement, useContext } from 'react'
|
import { ReactElement, useContext } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import FilesIcon from 'remixicon-react/ArrowUpDownLineIcon'
|
import FilesIcon from 'remixicon-react/ArrowUpDownLineIcon'
|
||||||
|
import FileManagerIcon from 'remixicon-react/FolderOpenLineIcon'
|
||||||
import DocsIcon from 'remixicon-react/BookOpenLineIcon'
|
import DocsIcon from 'remixicon-react/BookOpenLineIcon'
|
||||||
import ExternalLinkIcon from 'remixicon-react/ExternalLinkLineIcon'
|
import ExternalLinkIcon from 'remixicon-react/ExternalLinkLineIcon'
|
||||||
|
import GithubIcon from 'remixicon-react/GithubFillIcon'
|
||||||
import HomeIcon from 'remixicon-react/Home3LineIcon'
|
import HomeIcon from 'remixicon-react/Home3LineIcon'
|
||||||
import SettingsIcon from 'remixicon-react/Settings2LineIcon'
|
import SettingsIcon from 'remixicon-react/Settings2LineIcon'
|
||||||
import AccountIcon from 'remixicon-react/Wallet3LineIcon'
|
import AccountIcon from 'remixicon-react/Wallet3LineIcon'
|
||||||
import { Context as BeeContext } from '../providers/Bee'
|
|
||||||
import { Context as SettingsContext } from '../providers/Settings'
|
|
||||||
import DashboardLogo from '../assets/dashboard-logo.svg'
|
import DashboardLogo from '../assets/dashboard-logo.svg'
|
||||||
import DesktopLogo from '../assets/desktop-logo.svg'
|
import DesktopLogo from '../assets/desktop-logo.svg'
|
||||||
import { config } from '../config'
|
import { BEE_DOCS_HOST, GITHUB_BEE_DASHBOARD_URL } from '../constants'
|
||||||
|
import { Context as BeeContext } from '../providers/Bee'
|
||||||
|
import { Context as SettingsContext } from '../providers/Settings'
|
||||||
import { ROUTES } from '../routes'
|
import { ROUTES } from '../routes'
|
||||||
import Feedback from './Feedback'
|
|
||||||
import SideBarItem from './SideBarItem'
|
import SideBarItem from './SideBarItem'
|
||||||
import SideBarStatus from './SideBarStatus'
|
import SideBarStatus from './SideBarStatus'
|
||||||
import { BeeModes } from '@ethersphere/bee-js'
|
|
||||||
|
|
||||||
const drawerWidth = 300
|
const drawerWidth = 300
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ const useStyles = makeStyles((theme: Theme) =>
|
|||||||
|
|
||||||
export default function SideBar(): ReactElement {
|
export default function SideBar(): ReactElement {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const { isBeeDesktop } = useContext(SettingsContext)
|
const { isDesktop } = useContext(SettingsContext)
|
||||||
const { nodeInfo } = useContext(BeeContext)
|
const { nodeInfo } = useContext(BeeContext)
|
||||||
|
|
||||||
const navBarItems = [
|
const navBarItems = [
|
||||||
@@ -82,6 +83,12 @@ export default function SideBar(): ReactElement {
|
|||||||
icon: FilesIcon,
|
icon: FilesIcon,
|
||||||
pathMatcherSubstring: '/files/',
|
pathMatcherSubstring: '/files/',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'File Manager',
|
||||||
|
path: ROUTES.FILEMANAGER,
|
||||||
|
icon: FileManagerIcon,
|
||||||
|
pathMatcherSubstring: '/filemanager/',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Account',
|
label: 'Account',
|
||||||
path: ROUTES.ACCOUNT_WALLET,
|
path: ROUTES.ACCOUNT_WALLET,
|
||||||
@@ -100,7 +107,7 @@ export default function SideBar(): ReactElement {
|
|||||||
<Grid container direction="column" justifyContent="space-between" className={classes.root}>
|
<Grid container direction="column" justifyContent="space-between" className={classes.root}>
|
||||||
<Grid className={classes.logo}>
|
<Grid className={classes.logo}>
|
||||||
<Link to={ROUTES.INFO}>
|
<Link to={ROUTES.INFO}>
|
||||||
<img alt="swarm" src={isBeeDesktop ? DesktopLogo : DashboardLogo} />
|
<img alt="swarm" src={isDesktop ? DesktopLogo : DashboardLogo} />
|
||||||
</Link>
|
</Link>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
@@ -119,7 +126,7 @@ export default function SideBar(): ReactElement {
|
|||||||
</List>
|
</List>
|
||||||
<Divider className={classes.divider} />
|
<Divider className={classes.divider} />
|
||||||
<List>
|
<List>
|
||||||
<MUILink href={config.BEE_DOCS_HOST} target="_blank" className={classes.link}>
|
<MUILink href={BEE_DOCS_HOST} target="_blank" className={classes.link}>
|
||||||
<SideBarItem
|
<SideBarItem
|
||||||
iconStart={<DocsIcon className={classes.icon} />}
|
iconStart={<DocsIcon className={classes.icon} />}
|
||||||
iconEnd={<ExternalLinkIcon className={classes.icon} color="#595959" />}
|
iconEnd={<ExternalLinkIcon className={classes.icon} color="#595959" />}
|
||||||
@@ -127,13 +134,28 @@ export default function SideBar(): ReactElement {
|
|||||||
/>
|
/>
|
||||||
</MUILink>
|
</MUILink>
|
||||||
</List>
|
</List>
|
||||||
|
<Divider className={classes.divider} />
|
||||||
|
<List>
|
||||||
|
<MUILink href={GITHUB_BEE_DASHBOARD_URL} target="_blank" className={classes.link}>
|
||||||
|
<SideBarItem
|
||||||
|
iconStart={<GithubIcon className={classes.icon} />}
|
||||||
|
iconEnd={<ExternalLinkIcon className={classes.icon} color="#595959" />}
|
||||||
|
label={<span>GitHub</span>}
|
||||||
|
/>
|
||||||
|
</MUILink>
|
||||||
|
</List>
|
||||||
|
<Divider className={classes.divider} />
|
||||||
|
<Box mt={4}>
|
||||||
|
<Link to={ROUTES.TOP_UP_GIFT_CODE}>
|
||||||
|
<Typography align="center">Redeem gift code</Typography>
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid>
|
<Grid>
|
||||||
<List>
|
<List>
|
||||||
<Link to={ROUTES.STATUS} className={classes.link}>
|
<Link to={ROUTES.STATUS} className={classes.link}>
|
||||||
<SideBarStatus path={ROUTES.STATUS} />
|
<SideBarStatus path={ROUTES.STATUS} />
|
||||||
</Link>
|
</Link>
|
||||||
<Feedback />
|
|
||||||
</List>
|
</List>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ReactElement, useContext } from 'react'
|
import { ReactElement, useContext } from 'react'
|
||||||
import { useLocation, matchPath } from 'react-router-dom'
|
import { matchPath, useLocation } from 'react-router-dom'
|
||||||
import ArrowRight from 'remixicon-react/ArrowRightLineIcon'
|
import ArrowRight from 'remixicon-react/ArrowRightLineIcon'
|
||||||
|
|
||||||
import { createStyles, Theme, makeStyles } from '@material-ui/core/styles'
|
import { ListItem, ListItemIcon, ListItemText, Typography } from '@material-ui/core'
|
||||||
import { ListItemText, ListItemIcon, ListItem, Typography } from '@material-ui/core'
|
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
||||||
import { Context } from '../providers/Bee'
|
import { Context } from '../providers/Bee'
|
||||||
import StatusIcon from './StatusIcon'
|
import StatusIcon from './StatusIcon'
|
||||||
|
|
||||||
@@ -44,6 +44,7 @@ const useStyles = makeStyles((theme: Theme) =>
|
|||||||
},
|
},
|
||||||
smallerText: {
|
smallerText: {
|
||||||
fontSize: '0.9rem',
|
fontSize: '0.9rem',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { Box } from '@material-ui/core'
|
||||||
|
import Button from '@material-ui/core/Button'
|
||||||
|
import Dialog from '@material-ui/core/Dialog'
|
||||||
|
import DialogActions from '@material-ui/core/DialogActions'
|
||||||
|
import DialogContent from '@material-ui/core/DialogContent'
|
||||||
|
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||||
|
import Input from '@material-ui/core/Input'
|
||||||
|
import { BatchId, Bee } from '@ethersphere/bee-js'
|
||||||
|
import { useSnackbar } from 'notistack'
|
||||||
|
import { ReactElement, ReactNode, useState } from 'react'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
type: 'Topup' | 'Dilute'
|
||||||
|
icon: ReactNode
|
||||||
|
bee: Bee
|
||||||
|
stamp: BatchId
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StampExtensionModal({ type, icon, bee, stamp }: Props): ReactElement {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [amount, setAmount] = useState('')
|
||||||
|
const { enqueueSnackbar } = useSnackbar()
|
||||||
|
const label = `${type} ${stamp.toHex().substring(0, 8)}`
|
||||||
|
|
||||||
|
const handleClickOpen = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setOpen(true)
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAction = async () => {
|
||||||
|
if (type === 'Topup') {
|
||||||
|
try {
|
||||||
|
await bee.topUpBatch(stamp, amount)
|
||||||
|
enqueueSnackbar(`Successfully topped up stamp, your changes will appear soon`, { variant: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
enqueueSnackbar(`Failed to topup stamp: ${error || 'Unknown reason'}`, { variant: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'Dilute') {
|
||||||
|
try {
|
||||||
|
await bee.diluteBatch(stamp, parseInt(amount, 10))
|
||||||
|
enqueueSnackbar(`Successfully diluted stamp, your changes will appear soon`, { variant: 'success' })
|
||||||
|
} catch (error) {
|
||||||
|
enqueueSnackbar(`Failed to dilute stamp: ${error || 'Unknown reason'}`, { variant: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||||
|
setAmount(event.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box mb={2}>
|
||||||
|
<Button variant="contained" onClick={handleClickOpen} startIcon={icon}>
|
||||||
|
{type}
|
||||||
|
</Button>
|
||||||
|
<Dialog open={open} onClose={handleClose} aria-labelledby="form-dialog-title">
|
||||||
|
<DialogTitle id="form-dialog-title">{label}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
margin="dense"
|
||||||
|
id="name"
|
||||||
|
type="text"
|
||||||
|
placeholder={type === 'Topup' ? 'Amount to add' : 'New depth to dilute'}
|
||||||
|
fullWidth
|
||||||
|
value={amount}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleClose} color="primary">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button disabled={amount === ''} onClick={handleAction} color="primary">
|
||||||
|
{type}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactElement } from 'react'
|
|
||||||
import { CircularProgress } from '@material-ui/core'
|
import { CircularProgress } from '@material-ui/core'
|
||||||
|
import type { ReactElement } from 'react'
|
||||||
import { CheckState } from '../providers/Bee'
|
import { CheckState } from '../providers/Bee'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,6 +25,12 @@ export default function StatusIcon({ checkState, size, className, isLoading }: P
|
|||||||
case CheckState.ERROR:
|
case CheckState.ERROR:
|
||||||
backgroundColor = '#ff3a52'
|
backgroundColor = '#ff3a52'
|
||||||
break
|
break
|
||||||
|
case CheckState.STARTING:
|
||||||
|
backgroundColor = 'orange'
|
||||||
|
break
|
||||||
|
case CheckState.CONNECTING:
|
||||||
|
backgroundColor = '#0074D9'
|
||||||
|
break
|
||||||
default:
|
default:
|
||||||
// Default is error
|
// Default is error
|
||||||
backgroundColor = '#ff3a52'
|
backgroundColor = '#ff3a52'
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface Props {
|
|||||||
formik?: boolean
|
formik?: boolean
|
||||||
defaultValue?: string
|
defaultValue?: string
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
@@ -60,6 +61,7 @@ export function SwarmSelect({
|
|||||||
onChange,
|
onChange,
|
||||||
label,
|
label,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
disabled = false,
|
||||||
}: Props): ReactElement {
|
}: Props): ReactElement {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
|
|
||||||
@@ -69,6 +71,7 @@ export function SwarmSelect({
|
|||||||
{label && <FormHelperText>{label}</FormHelperText>}
|
{label && <FormHelperText>{label}</FormHelperText>}
|
||||||
<Field
|
<Field
|
||||||
required
|
required
|
||||||
|
disabled={disabled}
|
||||||
component={Select}
|
component={Select}
|
||||||
name={name}
|
name={name}
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -94,6 +97,7 @@ export function SwarmSelect({
|
|||||||
{label && <FormHelperText>{label}</FormHelperText>}
|
{label && <FormHelperText>{label}</FormHelperText>}
|
||||||
<MuiSelect
|
<MuiSelect
|
||||||
required
|
required
|
||||||
|
disabled={disabled}
|
||||||
name={name}
|
name={name}
|
||||||
fullWidth
|
fullWidth
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
|||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import Activity from 'remixicon-react/PulseLineIcon'
|
import Activity from 'remixicon-react/PulseLineIcon'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { config } from '../config'
|
|
||||||
import { ROUTES } from '../routes'
|
import { ROUTES } from '../routes'
|
||||||
|
import { BEE_DISCORD_HOST, BEE_DOCS_HOST } from '../constants'
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
createStyles({
|
createStyles({
|
||||||
@@ -37,11 +37,11 @@ export default function TroubleshootConnectionCard(): ReactElement {
|
|||||||
<Grid item className={classes.content}>
|
<Grid item className={classes.content}>
|
||||||
<Typography align="center">
|
<Typography align="center">
|
||||||
Please check your node status to fix the problem. You can also check out the{' '}
|
Please check your node status to fix the problem. You can also check out the{' '}
|
||||||
<MuiLink href={config.BEE_DOCS_HOST} target="_blank" rel="noreferrer">
|
<MuiLink href={BEE_DOCS_HOST} target="_blank" rel="noreferrer">
|
||||||
Swarm Bee Docs
|
Swarm Bee Docs
|
||||||
</MuiLink>{' '}
|
</MuiLink>{' '}
|
||||||
or ask for support on the{' '}
|
or ask for support on the{' '}
|
||||||
<MuiLink href={config.BEE_DISCORD_HOST} target="_blank" rel="noreferrer">
|
<MuiLink href={BEE_DISCORD_HOST} target="_blank" rel="noreferrer">
|
||||||
Ethereum Swarm Discord
|
Ethereum Swarm Discord
|
||||||
</MuiLink>
|
</MuiLink>
|
||||||
.
|
.
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
import { ReactElement, ReactNode, useState } from 'react'
|
|
||||||
import Button from '@material-ui/core/Button'
|
import Button from '@material-ui/core/Button'
|
||||||
import Input from '@material-ui/core/Input'
|
|
||||||
import Dialog from '@material-ui/core/Dialog'
|
import Dialog from '@material-ui/core/Dialog'
|
||||||
import DialogActions from '@material-ui/core/DialogActions'
|
import DialogActions from '@material-ui/core/DialogActions'
|
||||||
import DialogContent from '@material-ui/core/DialogContent'
|
import DialogContent from '@material-ui/core/DialogContent'
|
||||||
import DialogContentText from '@material-ui/core/DialogContentText'
|
import DialogContentText from '@material-ui/core/DialogContentText'
|
||||||
import DialogTitle from '@material-ui/core/DialogTitle'
|
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||||
import FormHelperText from '@material-ui/core/FormHelperText'
|
import FormHelperText from '@material-ui/core/FormHelperText'
|
||||||
import { Token } from '../models/Token'
|
import Input from '@material-ui/core/Input'
|
||||||
import type { BigNumber } from 'bignumber.js'
|
import { BZZ, TransactionId } from '@ethersphere/bee-js'
|
||||||
import { useSnackbar } from 'notistack'
|
import { useSnackbar } from 'notistack'
|
||||||
|
import { ReactElement, ReactNode, useState } from 'react'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
successMessage: string
|
successMessage: string
|
||||||
errorMessage: string
|
errorMessage: string
|
||||||
dialogMessage: string
|
dialogMessage: string
|
||||||
label: string
|
label: string
|
||||||
max?: BigNumber
|
max?: BZZ
|
||||||
min?: BigNumber
|
min?: BZZ
|
||||||
action: (amount: bigint) => Promise<string>
|
action: (amount: BZZ) => Promise<TransactionId>
|
||||||
icon?: ReactNode
|
icon?: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +33,7 @@ export default function WithdrawDepositModal({
|
|||||||
}: Props): ReactElement {
|
}: Props): ReactElement {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [amount, setAmount] = useState('')
|
const [amount, setAmount] = useState('')
|
||||||
const [amountToken, setAmountToken] = useState<Token | null>(null)
|
const [amountToken, setAmountToken] = useState<BZZ | null>(null)
|
||||||
const [amountError, setAmountError] = useState<Error | null>(null)
|
const [amountError, setAmountError] = useState<Error | null>(null)
|
||||||
const { enqueueSnackbar } = useSnackbar()
|
const { enqueueSnackbar } = useSnackbar()
|
||||||
|
|
||||||
@@ -51,7 +50,7 @@ export default function WithdrawDepositModal({
|
|||||||
if (amountToken === null) return
|
if (amountToken === null) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const transactionHash = await action(amountToken.toBigInt as bigint)
|
const transactionHash = await action(amountToken)
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
enqueueSnackbar(`${successMessage} Transaction ${transactionHash}`, { variant: 'success' })
|
enqueueSnackbar(`${successMessage} Transaction ${transactionHash}`, { variant: 'success' })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -65,12 +64,12 @@ export default function WithdrawDepositModal({
|
|||||||
setAmount(value)
|
setAmount(value)
|
||||||
setAmountError(null)
|
setAmountError(null)
|
||||||
try {
|
try {
|
||||||
const t = Token.fromDecimal(value)
|
const t = BZZ.fromDecimalString(value)
|
||||||
setAmountToken(t)
|
setAmountToken(t)
|
||||||
|
|
||||||
if (min && t.toDecimal.isLessThan(min)) setAmountError(new Error(`Needs to be more than ${min}`))
|
if (min && t.lt(min)) setAmountError(new Error(`Needs to be more than ${min.toSignificantDigits(4)}`))
|
||||||
|
|
||||||
if (max && t.toDecimal.isGreaterThan(max)) setAmountError(new Error(`Needs to be less than ${max}`))
|
if (max && t.gt(max)) setAmountError(new Error(`Needs to be less than ${max.toSignificantDigits(4)}`))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setAmountError(e as Error)
|
setAmountError(e as Error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
class Config {
|
|
||||||
public readonly BEE_API_HOST: string
|
|
||||||
public readonly BEE_DEBUG_API_HOST: string
|
|
||||||
public readonly BLOCKCHAIN_EXPLORER_URL: string
|
|
||||||
public readonly BEE_DOCS_HOST: string
|
|
||||||
public readonly BEE_DISCORD_HOST: string
|
|
||||||
public readonly GITHUB_REPO_URL: string
|
|
||||||
public readonly BEE_DESKTOP_ENABLED: boolean
|
|
||||||
public readonly BEE_DESKTOP_URL: string
|
|
||||||
public readonly SENTRY_KEY: string | undefined
|
|
||||||
public readonly SENTRY_ENVIRONMENT: string | undefined
|
|
||||||
public readonly DEFAULT_RPC_URL: string
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.BEE_API_HOST = sessionStorage.getItem('api_host') ?? process.env.REACT_APP_BEE_HOST ?? 'http://localhost:1633'
|
|
||||||
this.SENTRY_KEY = process.env.REACT_APP_SENTRY_KEY
|
|
||||||
this.SENTRY_ENVIRONMENT = process.env.REACT_APP_SENTRY_ENVIRONMENT
|
|
||||||
this.BEE_DEBUG_API_HOST =
|
|
||||||
sessionStorage.getItem('debug_api_host') ?? process.env.REACT_APP_BEE_DEBUG_HOST ?? 'http://localhost:1635'
|
|
||||||
this.BLOCKCHAIN_EXPLORER_URL =
|
|
||||||
process.env.REACT_APP_BLOCKCHAIN_EXPLORER_URL ?? 'https://blockscout.com/xdai/mainnet'
|
|
||||||
this.BEE_DOCS_HOST = process.env.REACT_APP_BEE_DOCS_HOST ?? 'https://docs.ethswarm.org/docs/'
|
|
||||||
this.BEE_DISCORD_HOST = process.env.REACT_APP_BEE_DISCORD_HOST ?? 'https://discord.gg/eKr9XPv7'
|
|
||||||
this.GITHUB_REPO_URL = process.env.REACT_APP_BEE_GITHUB_REPO_URL ?? 'https://api.github.com/repos/ethersphere/bee'
|
|
||||||
this.BEE_DESKTOP_ENABLED = process.env.REACT_APP_BEE_DESKTOP_ENABLED === 'true'
|
|
||||||
this.BEE_DESKTOP_URL = process.env.REACT_APP_BEE_DESKTOP_URL ?? window.location.origin
|
|
||||||
this.DEFAULT_RPC_URL = process.env.REACT_APP_DEFAULT_RPC_URL ?? 'https://xdai.fairdatasociety.org'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const config = new Config()
|
|
||||||
|
|
||||||
export default config
|
|
||||||
+12
-3
@@ -1,4 +1,13 @@
|
|||||||
export const META_FILE_NAME = '.swarmgatewaymeta.json'
|
export const META_FILE_NAME = 'metadata'
|
||||||
export const PREVIEW_FILE_NAME = '.swarmgatewaypreview.jpeg'
|
|
||||||
export const PREVIEW_DIMENSIONS = { maxWidth: 250, maxHeight: 175 }
|
export const PREVIEW_DIMENSIONS = { maxWidth: 250, maxHeight: 175 }
|
||||||
export const BZZ_LINK_DOMAIN = process.env.REACT_APP_BZZ_LINK_DOMAIN || 'bzz.link'
|
export const BZZ_LINK_DOMAIN = 'bzz.link'
|
||||||
|
export const BLOCKCHAIN_EXPLORER_URL = 'https://blockscout.com/xdai/mainnet'
|
||||||
|
export const BEE_DOCS_HOST = 'https://docs.ethswarm.org/docs/'
|
||||||
|
export const BEE_DISCORD_HOST = 'https://discord.gg/eKr9XPv7'
|
||||||
|
export const GITHUB_REPO_URL = 'https://api.github.com/repos/ethersphere/bee'
|
||||||
|
export const GITHUB_BEE_DASHBOARD_URL = 'https://github.com/ethersphere/bee-dashboard.git'
|
||||||
|
export const BEE_DESKTOP_LATEST_RELEASE_PAGE = 'https://github.com/ethersphere/bee-desktop/releases/latest'
|
||||||
|
export const BEE_DESKTOP_LATEST_RELEASE_PAGE_API =
|
||||||
|
'https://api.github.com/repos/ethersphere/bee-desktop/releases/latest'
|
||||||
|
export const DEFAULT_BEE_API_HOST = 'http://localhost:1633'
|
||||||
|
export const DEFAULT_RPC_URL = 'https://xdai.fairdatasociety.org'
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
|
import { BZZ } from '@ethersphere/bee-js'
|
||||||
import { ReactElement, useContext } from 'react'
|
import { ReactElement, useContext } from 'react'
|
||||||
import Download from 'remixicon-react/DownloadLineIcon'
|
import Download from 'remixicon-react/DownloadLineIcon'
|
||||||
|
import WithdrawDepositModal from '../components/WithdrawDepositModal'
|
||||||
|
import { Context as BeeContext } from '../providers/Bee'
|
||||||
import { Context as SettingsContext } from '../providers/Settings'
|
import { Context as SettingsContext } from '../providers/Settings'
|
||||||
|
|
||||||
import WithdrawDepositModal from '../components/WithdrawDepositModal'
|
|
||||||
import { BigNumber } from 'bignumber.js'
|
|
||||||
|
|
||||||
export default function DepositModal(): ReactElement {
|
export default function DepositModal(): ReactElement {
|
||||||
const { beeDebugApi } = useContext(SettingsContext)
|
const { beeApi } = useContext(SettingsContext)
|
||||||
|
const { refresh } = useContext(BeeContext)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WithdrawDepositModal
|
<WithdrawDepositModal
|
||||||
successMessage="Successful deposit."
|
successMessage="Successful deposit."
|
||||||
errorMessage="Error with depositing"
|
errorMessage="Error with depositing"
|
||||||
dialogMessage="Specify the amount of xBZZ you would like to deposit to your node."
|
dialogMessage="Amount of xBZZ to deposit to the checkbook, from your node."
|
||||||
label="Deposit"
|
label="Deposit"
|
||||||
icon={<Download size="1rem" />}
|
icon={<Download size="1rem" />}
|
||||||
min={new BigNumber(0)}
|
min={BZZ.fromPLUR('1')}
|
||||||
action={(amount: bigint) => {
|
action={async (amount: BZZ) => {
|
||||||
if (!beeDebugApi) throw new Error('Bee Debug URL is not valid')
|
if (!beeApi) {
|
||||||
|
throw new Error('Bee URL is not valid')
|
||||||
|
}
|
||||||
|
|
||||||
return beeDebugApi.depositTokens(amount.toString())
|
const transactionHash = await beeApi.depositTokens(amount)
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
return transactionHash
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { BZZ } from '@ethersphere/bee-js'
|
||||||
|
import { ReactElement, useContext } from 'react'
|
||||||
|
import Download from 'remixicon-react/DownloadLineIcon'
|
||||||
|
import WithdrawDepositModal from '../components/WithdrawDepositModal'
|
||||||
|
import { Context as BeeContext } from '../providers/Bee'
|
||||||
|
import { Context as SettingsContext } from '../providers/Settings'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onStarted: () => void
|
||||||
|
onFinished: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StakeModal({ onStarted, onFinished }: Props): ReactElement {
|
||||||
|
const { beeApi } = useContext(SettingsContext)
|
||||||
|
const { refresh } = useContext(BeeContext)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WithdrawDepositModal
|
||||||
|
successMessage="Successfully deposited stake."
|
||||||
|
errorMessage="Error with depositing"
|
||||||
|
dialogMessage="Specify the amount of xBZZ you would like to stake. Your first stake must be at least 10 xBZZ. This will lock your tokens."
|
||||||
|
label="Stake"
|
||||||
|
icon={<Download size="1rem" />}
|
||||||
|
min={BZZ.fromPLUR('1')}
|
||||||
|
action={async (amount: BZZ) => {
|
||||||
|
if (!beeApi) {
|
||||||
|
throw new Error('Bee URL is not valid')
|
||||||
|
}
|
||||||
|
|
||||||
|
onStarted()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transactionHash = await beeApi.depositStake(amount)
|
||||||
|
|
||||||
|
return transactionHash
|
||||||
|
} finally {
|
||||||
|
refresh()
|
||||||
|
onFinished()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,24 +1,31 @@
|
|||||||
import { BigNumber } from 'bignumber.js'
|
import { BZZ } from '@ethersphere/bee-js'
|
||||||
import { ReactElement, useContext } from 'react'
|
import { ReactElement, useContext } from 'react'
|
||||||
import Upload from 'remixicon-react/UploadLineIcon'
|
import Upload from 'remixicon-react/UploadLineIcon'
|
||||||
import WithdrawDepositModal from '../components/WithdrawDepositModal'
|
import WithdrawDepositModal from '../components/WithdrawDepositModal'
|
||||||
|
import { Context as BeeContext } from '../providers/Bee'
|
||||||
import { Context as SettingsContext } from '../providers/Settings'
|
import { Context as SettingsContext } from '../providers/Settings'
|
||||||
|
|
||||||
export default function WithdrawModal(): ReactElement {
|
export default function WithdrawModal(): ReactElement {
|
||||||
const { beeDebugApi } = useContext(SettingsContext)
|
const { beeApi } = useContext(SettingsContext)
|
||||||
|
const { refresh } = useContext(BeeContext)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WithdrawDepositModal
|
<WithdrawDepositModal
|
||||||
successMessage="Successful withdrawal."
|
successMessage="Successful withdrawal."
|
||||||
errorMessage="Error with withdrawing."
|
errorMessage="Error with withdrawing."
|
||||||
dialogMessage="Specify the amount of xBZZ you would like to withdraw from your node."
|
dialogMessage="Amount of xBZZ to withdraw from the checkbook to your node."
|
||||||
label="Withdraw"
|
label="Withdraw"
|
||||||
icon={<Upload size="1rem" />}
|
icon={<Upload size="1rem" />}
|
||||||
min={new BigNumber(0)}
|
min={BZZ.fromPLUR('1')}
|
||||||
action={(amount: bigint) => {
|
action={async (amount: BZZ) => {
|
||||||
if (!beeDebugApi) throw new Error('Bee Debug URL is not valid')
|
if (!beeApi) {
|
||||||
|
throw new Error('Bee URL is not valid')
|
||||||
|
}
|
||||||
|
|
||||||
return beeDebugApi.withdrawTokens(amount.toString())
|
const transactionHash = await beeApi.withdrawTokens(amount)
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
return transactionHash
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
+27
-29
@@ -1,22 +1,20 @@
|
|||||||
import { LastCashoutActionResponse, BeeDebug } from '@ethersphere/bee-js'
|
import { AllSettlements, Bee, BZZ, LastCashoutActionResponse, PeerBalance, Settlements } from '@ethersphere/bee-js'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Token } from '../models/Token'
|
|
||||||
import { makeRetriablePromise, unwrapPromiseSettlements } from '../utils'
|
import { makeRetriablePromise, unwrapPromiseSettlements } from '../utils'
|
||||||
import { Balance, Settlements, Settlement } from '../types'
|
|
||||||
|
|
||||||
interface UseAccountingHook {
|
interface UseAccountingHook {
|
||||||
isLoadingUncashed: boolean
|
isLoadingUncashed: boolean
|
||||||
totalUncashed: Token
|
totalUncashed: BZZ
|
||||||
accounting: Accounting[] | null
|
accounting: Accounting[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Accounting {
|
export interface Accounting {
|
||||||
peer: string
|
peer: string
|
||||||
uncashedAmount: Token
|
uncashedAmount: BZZ
|
||||||
balance: Token
|
balance: BZZ
|
||||||
received: Token
|
received: BZZ
|
||||||
sent: Token
|
sent: BZZ
|
||||||
total: Token
|
total: BZZ
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,8 +27,8 @@ export interface Accounting {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
function mergeAccounting(
|
function mergeAccounting(
|
||||||
balances: Balance[] | null,
|
balances: PeerBalance[] | null,
|
||||||
settlements?: Settlement[],
|
settlements?: Settlements[],
|
||||||
uncashedAmounts?: LastCashoutActionResponse[],
|
uncashedAmounts?: LastCashoutActionResponse[],
|
||||||
): Accounting[] | null {
|
): Accounting[] | null {
|
||||||
// Settlements or balances are still loading or there is an error -> return null
|
// Settlements or balances are still loading or there is an error -> return null
|
||||||
@@ -44,9 +42,9 @@ function mergeAccounting(
|
|||||||
(accounting[peer] = {
|
(accounting[peer] = {
|
||||||
peer,
|
peer,
|
||||||
balance,
|
balance,
|
||||||
sent: new Token('0'),
|
sent: BZZ.fromPLUR('0'),
|
||||||
received: new Token('0'),
|
received: BZZ.fromPLUR('0'),
|
||||||
uncashedAmount: new Token('0'),
|
uncashedAmount: BZZ.fromPLUR('0'),
|
||||||
total: balance,
|
total: balance,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -57,7 +55,7 @@ function mergeAccounting(
|
|||||||
...accounting[peer],
|
...accounting[peer],
|
||||||
sent,
|
sent,
|
||||||
received,
|
received,
|
||||||
total: new Token(accounting[peer].balance.toBigNumber.plus(received.toBigNumber).minus(sent.toBigNumber)),
|
total: accounting[peer].balance.plus(received).minus(sent),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -65,49 +63,49 @@ function mergeAccounting(
|
|||||||
if (!uncashedAmounts) return Object.values(accounting).sort((a, b) => (a.peer < b.peer ? -1 : 1))
|
if (!uncashedAmounts) return Object.values(accounting).sort((a, b) => (a.peer < b.peer ? -1 : 1))
|
||||||
|
|
||||||
uncashedAmounts?.forEach(({ peer, uncashedAmount }) => {
|
uncashedAmounts?.forEach(({ peer, uncashedAmount }) => {
|
||||||
accounting[peer].uncashedAmount = new Token(uncashedAmount)
|
accounting[peer].uncashedAmount = uncashedAmount
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return sorted by the uncashed amount first and then by the peer id
|
// Return sorted by the uncashed amount first and then by the peer id
|
||||||
return Object.values(accounting).sort((a, b) => {
|
return Object.values(accounting).sort((a, b) => {
|
||||||
const diff = b.uncashedAmount.toBigNumber.minus(a.uncashedAmount.toBigNumber).toNumber()
|
const diff = Number(b.uncashedAmount.minus(a.uncashedAmount))
|
||||||
|
|
||||||
if (diff !== 0) return diff
|
if (diff !== 0) {
|
||||||
|
return diff
|
||||||
|
}
|
||||||
|
|
||||||
return a.peer < b.peer ? -1 : 1
|
return a.peer < b.peer ? -1 : 1
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAccounting = (
|
export const useAccounting = (
|
||||||
beeDebugApi: BeeDebug | null,
|
beeApi: Bee | null,
|
||||||
settlements: Settlements | null,
|
settlements: AllSettlements | null,
|
||||||
balances: Balance[] | null,
|
balances: PeerBalance[] | null,
|
||||||
): UseAccountingHook => {
|
): UseAccountingHook => {
|
||||||
const [isLoadingUncashed, setIsloadingUncashed] = useState<boolean>(false)
|
const [isLoadingUncashed, setIsloadingUncashed] = useState<boolean>(false)
|
||||||
const [uncashedAmounts, setUncashedAmounts] = useState<LastCashoutActionResponse[] | undefined>(undefined)
|
const [uncashedAmounts, setUncashedAmounts] = useState<LastCashoutActionResponse[] | undefined>(undefined)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// We don't have any settlements loaded yet or we are already loading/have loaded the uncashed amounts
|
// We don't have any settlements loaded yet or we are already loading/have loaded the uncashed amounts
|
||||||
if (isLoadingUncashed || !beeDebugApi || !settlements || uncashedAmounts) return
|
if (isLoadingUncashed || !beeApi || !settlements || uncashedAmounts) return
|
||||||
|
|
||||||
setIsloadingUncashed(true)
|
setIsloadingUncashed(true)
|
||||||
const promises = settlements.settlements
|
const promises = settlements.settlements
|
||||||
.filter(({ received }) => received.toBigNumber.gt('0'))
|
.filter(({ received }) => received.gt(BZZ.fromPLUR('0')))
|
||||||
.map(({ peer }) => makeRetriablePromise(() => beeDebugApi.getLastCashoutAction(peer)))
|
.map(({ peer }) => makeRetriablePromise(() => beeApi.getLastCashoutAction(peer)))
|
||||||
|
|
||||||
Promise.allSettled(promises).then(settlements => {
|
Promise.allSettled(promises).then(settlements => {
|
||||||
const results = unwrapPromiseSettlements(settlements)
|
const results = unwrapPromiseSettlements(settlements)
|
||||||
setUncashedAmounts(results.fulfilled)
|
setUncashedAmounts(results.fulfilled)
|
||||||
setIsloadingUncashed(false)
|
setIsloadingUncashed(false)
|
||||||
})
|
})
|
||||||
}, [settlements, isLoadingUncashed, uncashedAmounts, beeDebugApi])
|
}, [settlements, isLoadingUncashed, uncashedAmounts, beeApi])
|
||||||
|
|
||||||
const accounting = mergeAccounting(balances, settlements?.settlements, uncashedAmounts)
|
const accounting = mergeAccounting(balances, settlements?.settlements, uncashedAmounts)
|
||||||
|
|
||||||
let totalUncashed: Token = new Token('0')
|
let totalUncashed = BZZ.fromPLUR('0')
|
||||||
accounting?.forEach(
|
accounting?.forEach(({ uncashedAmount }) => (totalUncashed = totalUncashed.plus(uncashedAmount)))
|
||||||
({ uncashedAmount }) => (totalUncashed = new Token(totalUncashed.toBigNumber.plus(uncashedAmount.toBigNumber))),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isLoadingUncashed,
|
isLoadingUncashed,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { renderHook } from '@testing-library/react-hooks'
|
import { renderHook } from '@testing-library/react-hooks'
|
||||||
import express from 'express'
|
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
|
import express from 'express'
|
||||||
import type { Server } from 'http'
|
import type { Server } from 'http'
|
||||||
import { useIsBeeDesktop } from './apiHooks'
|
import { useBeeDesktop } from './apiHooks'
|
||||||
|
|
||||||
interface AddressInfo {
|
interface AddressInfo {
|
||||||
address: string
|
address: string
|
||||||
@@ -39,9 +39,9 @@ afterAll(async () => {
|
|||||||
await new Promise(resolve => serverCorrect.close(resolve))
|
await new Promise(resolve => serverCorrect.close(resolve))
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('useIsBeeDesktop', () => {
|
describe('useBeeDesktop', () => {
|
||||||
it('should not have error when connected to bee-desktop', async () => {
|
it('should not have error when connected to bee-desktop', async () => {
|
||||||
const { result, waitFor } = renderHook(() => useIsBeeDesktop(true, { BEE_DESKTOP_URL: serverCorrectURL }))
|
const { result, waitFor } = renderHook(() => useBeeDesktop(true, serverCorrectURL))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.isLoading).toBe(false)
|
expect(result.current.isLoading).toBe(false)
|
||||||
|
|||||||
+45
-46
@@ -1,8 +1,7 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { config } from '../config'
|
import { GITHUB_REPO_URL } from '../constants'
|
||||||
import { getJson } from '../utils/net'
|
import { BeeConfig, getDesktopConfiguration, getLatestBeeDesktopVersion } from '../utils/desktop'
|
||||||
import { getLatestBeeDesktopVersion } from '../utils/desktop'
|
|
||||||
|
|
||||||
export interface LatestBeeReleaseHook {
|
export interface LatestBeeReleaseHook {
|
||||||
latestBeeRelease: LatestBeeRelease | null
|
latestBeeRelease: LatestBeeRelease | null
|
||||||
@@ -10,7 +9,8 @@ export interface LatestBeeReleaseHook {
|
|||||||
error: Error | null
|
error: Error | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IsBeeDesktopHook {
|
export interface BeeDesktopHook {
|
||||||
|
reachable: boolean
|
||||||
error: Error | null
|
error: Error | null
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
beeDesktopVersion: string
|
beeDesktopVersion: string
|
||||||
@@ -21,28 +21,42 @@ export interface NewDesktopVersionHook {
|
|||||||
newBeeDesktopVersion: string
|
newBeeDesktopVersion: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Config {
|
export const useBeeDesktop = (isBeeDesktop = false, desktopUrl: string): BeeDesktopHook => {
|
||||||
BEE_DESKTOP_URL: string
|
const [reachable, setReachable] = useState(false)
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect if the dashboard is run within bee-desktop
|
|
||||||
*
|
|
||||||
* @returns isBeeDesktop true if this is run within bee-desktop
|
|
||||||
*/
|
|
||||||
export const useIsBeeDesktop = (isBeeDesktop = false, conf: Config = config): IsBeeDesktopHook => {
|
|
||||||
const [desktopAutoUpdateEnabled, setDesktopAutoUpdateEnabled] = useState<boolean>(true)
|
const [desktopAutoUpdateEnabled, setDesktopAutoUpdateEnabled] = useState<boolean>(true)
|
||||||
const [beeDesktopVersion, setBeeDesktopVersion] = useState<string>('')
|
const [beeDesktopVersion, setBeeDesktopVersion] = useState<string>('')
|
||||||
const [isLoading, setLoading] = useState<boolean>(true)
|
const [isLoading, setLoading] = useState<boolean>(true)
|
||||||
const [error, setError] = useState<Error | null>(null)
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isBeeDesktop) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
function runReachabilityCheck() {
|
||||||
|
axios
|
||||||
|
.get(`${desktopUrl}/info`)
|
||||||
|
.then(() => {
|
||||||
|
setReachable(true)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setReachable(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
runReachabilityCheck()
|
||||||
|
const interval = setInterval(runReachabilityCheck, 10_000)
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [desktopUrl, isBeeDesktop])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isBeeDesktop) {
|
if (!isBeeDesktop) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setError(null)
|
setError(null)
|
||||||
} else {
|
} else {
|
||||||
axios
|
axios
|
||||||
.get(`${conf.BEE_DESKTOP_URL}/info`)
|
.get(`${desktopUrl}/info`)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
setBeeDesktopVersion(res.data?.version)
|
setBeeDesktopVersion(res.data?.version)
|
||||||
setDesktopAutoUpdateEnabled(res.data?.autoUpdateEnabled)
|
setDesktopAutoUpdateEnabled(res.data?.autoUpdateEnabled)
|
||||||
@@ -55,13 +69,13 @@ export const useIsBeeDesktop = (isBeeDesktop = false, conf: Config = config): Is
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [conf, isBeeDesktop])
|
}, [desktopUrl, isBeeDesktop])
|
||||||
|
|
||||||
return { error, isLoading, beeDesktopVersion, desktopAutoUpdateEnabled }
|
return { error, isLoading, beeDesktopVersion, desktopAutoUpdateEnabled, reachable }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkNewVersion(conf: Config): Promise<string> {
|
async function checkNewVersion(desktopUrl: string): Promise<string> {
|
||||||
const resJson = await (await fetch(`${conf.BEE_DESKTOP_URL}/info`)).json()
|
const resJson = await (await fetch(`${desktopUrl}/info`)).json()
|
||||||
const currentVersion = resJson.version
|
const currentVersion = resJson.version
|
||||||
const latestVersion = await getLatestBeeDesktopVersion()
|
const latestVersion = await getLatestBeeDesktopVersion()
|
||||||
|
|
||||||
@@ -72,56 +86,41 @@ async function checkNewVersion(conf: Config): Promise<string> {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useNewBeeDesktopVersion(isBeeDesktop: boolean, conf: Config = config): NewDesktopVersionHook {
|
export function useNewBeeDesktopVersion(
|
||||||
|
isBeeDesktop: boolean,
|
||||||
|
desktopUrl: string,
|
||||||
|
desktopAutoUpdateEnabled: boolean,
|
||||||
|
): NewDesktopVersionHook {
|
||||||
const [newBeeDesktopVersion, setNewBeeDesktopVersion] = useState<string>('')
|
const [newBeeDesktopVersion, setNewBeeDesktopVersion] = useState<string>('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isBeeDesktop) {
|
if (!isBeeDesktop || desktopAutoUpdateEnabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
checkNewVersion(conf).then(version => {
|
checkNewVersion(desktopUrl).then(version => {
|
||||||
if (version !== '') {
|
if (version !== '') {
|
||||||
setNewBeeDesktopVersion(version)
|
setNewBeeDesktopVersion(version)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [isBeeDesktop, conf])
|
}, [isBeeDesktop, desktopUrl, desktopAutoUpdateEnabled])
|
||||||
|
|
||||||
return { newBeeDesktopVersion }
|
return { newBeeDesktopVersion }
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BeeConfig {
|
|
||||||
'api-addr': string
|
|
||||||
'debug-api-addr': string
|
|
||||||
'debug-api-enable': boolean
|
|
||||||
password: string
|
|
||||||
'swap-enable': boolean
|
|
||||||
'swap-initial-deposit': bigint
|
|
||||||
mainnet: boolean
|
|
||||||
'full-node': boolean
|
|
||||||
'chain-enable': boolean
|
|
||||||
'cors-allowed-origins': string
|
|
||||||
'resolver-options': string
|
|
||||||
'use-postage-snapshot': boolean
|
|
||||||
'data-dir': string
|
|
||||||
transaction: string
|
|
||||||
'block-hash': string
|
|
||||||
'swap-endpoint'?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GetBeeConfig {
|
export interface GetBeeConfig {
|
||||||
config: BeeConfig | null
|
config: BeeConfig | null
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
error: Error | null
|
error: Error | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useGetBeeConfig = (conf: Config = config): GetBeeConfig => {
|
export const useGetBeeConfig = (desktopUrl: string): GetBeeConfig => {
|
||||||
const [beeConfig, setBeeConfig] = useState<BeeConfig | null>(null)
|
const [beeConfig, setBeeConfig] = useState<BeeConfig | null>(null)
|
||||||
const [isLoading, setLoading] = useState<boolean>(true)
|
const [isLoading, setLoading] = useState<boolean>(true)
|
||||||
const [error, setError] = useState<Error | null>(null)
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getJson<BeeConfig>(`${conf.BEE_DESKTOP_URL}/config`)
|
getDesktopConfiguration(desktopUrl)
|
||||||
.then(beeConf => {
|
.then(beeConf => {
|
||||||
setBeeConfig(beeConf)
|
setBeeConfig(beeConf)
|
||||||
setError(null)
|
setError(null)
|
||||||
@@ -133,7 +132,7 @@ export const useGetBeeConfig = (conf: Config = config): GetBeeConfig => {
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
})
|
})
|
||||||
}, [conf])
|
}, [desktopUrl])
|
||||||
|
|
||||||
return { config: beeConfig, isLoading, error }
|
return { config: beeConfig, isLoading, error }
|
||||||
}
|
}
|
||||||
@@ -145,7 +144,7 @@ export const useLatestBeeRelease = (): LatestBeeReleaseHook => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
axios
|
axios
|
||||||
.get(`${config.GITHUB_REPO_URL}/releases/latest`)
|
.get(`${GITHUB_REPO_URL}/releases/latest`)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
setLatestBeeRelease(res.data)
|
setLatestBeeRelease(res.data)
|
||||||
})
|
})
|
||||||
|
|||||||
+7
-2
@@ -1,12 +1,17 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import './index.css'
|
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
import reportWebVitals from './reportWebVitals'
|
import reportWebVitals from './reportWebVitals'
|
||||||
|
|
||||||
|
const desktopEnabled = Boolean(process.env.REACT_APP_BEE_DESKTOP_ENABLED)
|
||||||
|
const desktopUrl = process.env.REACT_APP_BEE_DESKTOP_URL
|
||||||
|
const beeApiUrl = process.env.REACT_APP_BEE_HOST
|
||||||
|
const defaultRpcUrl = process.env.REACT_APP_DEFAULT_RPC_URL
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App isDesktop={desktopEnabled} desktopUrl={desktopUrl} beeApiUrl={beeApiUrl} defaultRpcUrl={defaultRpcUrl} />
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
document.getElementById('root'),
|
document.getElementById('root'),
|
||||||
)
|
)
|
||||||
|
|||||||
+28
-68
@@ -1,17 +1,16 @@
|
|||||||
import { Button, CircularProgress, Container, IconButton } from '@material-ui/core'
|
import { Button, CircularProgress, Container, IconButton } from '@material-ui/core'
|
||||||
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
import { Theme, createStyles, makeStyles } from '@material-ui/core/styles'
|
||||||
import React, { ReactElement, useContext, useEffect } from 'react'
|
|
||||||
import { useSnackbar } from 'notistack'
|
import { useSnackbar } from 'notistack'
|
||||||
|
import React, { ReactElement, useContext, useEffect } from 'react'
|
||||||
import CloseIcon from 'remixicon-react/CloseCircleLineIcon'
|
import CloseIcon from 'remixicon-react/CloseCircleLineIcon'
|
||||||
import ErrorBoundary from '../components/ErrorBoundary'
|
import ErrorBoundary from '../components/ErrorBoundary'
|
||||||
|
import { Flex } from '../components/Flex'
|
||||||
import SideBar from '../components/SideBar'
|
import SideBar from '../components/SideBar'
|
||||||
|
import { BEE_DESKTOP_LATEST_RELEASE_PAGE } from '../constants'
|
||||||
|
import { useBeeDesktop, useNewBeeDesktopVersion } from '../hooks/apiHooks'
|
||||||
import { Context as BeeContext } from '../providers/Bee'
|
import { Context as BeeContext } from '../providers/Bee'
|
||||||
import { Context as SettingsContext } from '../providers/Settings'
|
import { Context as SettingsContext } from '../providers/Settings'
|
||||||
import config from '../config'
|
import { useLocation } from 'react-router-dom'
|
||||||
import * as Sentry from '@sentry/react'
|
|
||||||
import ItsBroken from './ItsBroken'
|
|
||||||
import { useNewBeeDesktopVersion } from '../hooks/apiHooks'
|
|
||||||
import { BEE_DESKTOP_LATEST_RELEASE_PAGE } from '../utils/desktop'
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
const useStyles = makeStyles((theme: Theme) =>
|
||||||
createStyles({
|
createStyles({
|
||||||
@@ -19,63 +18,36 @@ const useStyles = makeStyles((theme: Theme) =>
|
|||||||
backgroundColor: theme.palette.background.default,
|
backgroundColor: theme.palette.background.default,
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
fileManagerOn: {
|
||||||
|
padding: '0px',
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: ReactElement
|
children?: ReactElement
|
||||||
|
errorReporting?: (err: Error) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Dashboard = (props: Props): ReactElement => {
|
const Dashboard = (props: Props): ReactElement => {
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
const isFileManagerOn = location.pathname.startsWith('/filemanager')
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
|
|
||||||
const { isLoading, isLatestBeeVersion, latestBeeRelease, latestBeeVersionUrl, latestUserVersion } =
|
const { isLoading } = useContext(BeeContext)
|
||||||
useContext(BeeContext)
|
const { isDesktop, desktopUrl } = useContext(SettingsContext)
|
||||||
const { isBeeDesktop } = useContext(SettingsContext)
|
const { desktopAutoUpdateEnabled } = useBeeDesktop(isDesktop, desktopUrl)
|
||||||
const { newBeeDesktopVersion } = useNewBeeDesktopVersion(isBeeDesktop)
|
const { newBeeDesktopVersion } = useNewBeeDesktopVersion(isDesktop, desktopUrl, desktopAutoUpdateEnabled)
|
||||||
const { enqueueSnackbar, closeSnackbar } = useSnackbar()
|
const { enqueueSnackbar, closeSnackbar } = useSnackbar()
|
||||||
|
|
||||||
// New version of Bee client notification
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isBeeDesktop && !isLatestBeeVersion && latestBeeRelease && latestUserVersion) {
|
// When autoupdate is enabled then we leave the version check for the built-in Electron update mechanism
|
||||||
enqueueSnackbar(`There is new Bee version ${latestBeeRelease?.name}!`, {
|
if (desktopAutoUpdateEnabled) {
|
||||||
variant: 'warning',
|
return
|
||||||
preventDuplicate: true,
|
|
||||||
key: 'beeNewVersion',
|
|
||||||
persist: true,
|
|
||||||
action: key => (
|
|
||||||
<React.Fragment>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
window.open(latestBeeVersionUrl)
|
|
||||||
closeSnackbar(key)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Download release
|
|
||||||
</Button>
|
|
||||||
<IconButton
|
|
||||||
onClick={() => {
|
|
||||||
closeSnackbar(key)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</IconButton>
|
|
||||||
</React.Fragment>
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}, [
|
|
||||||
closeSnackbar,
|
|
||||||
enqueueSnackbar,
|
|
||||||
isLatestBeeVersion,
|
|
||||||
isBeeDesktop,
|
|
||||||
latestBeeRelease,
|
|
||||||
latestBeeVersionUrl,
|
|
||||||
isLoading,
|
|
||||||
latestUserVersion,
|
|
||||||
])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (newBeeDesktopVersion !== '') {
|
if (newBeeDesktopVersion !== '') {
|
||||||
enqueueSnackbar(`There is new Swarm Dashboard version ${newBeeDesktopVersion}!`, {
|
enqueueSnackbar(`There is new Swarm Dashboard version ${newBeeDesktopVersion}!`, {
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
@@ -103,7 +75,7 @@ const Dashboard = (props: Props): ReactElement => {
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [enqueueSnackbar, closeSnackbar, newBeeDesktopVersion])
|
}, [enqueueSnackbar, closeSnackbar, newBeeDesktopVersion, desktopAutoUpdateEnabled])
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
@@ -117,26 +89,14 @@ const Dashboard = (props: Props): ReactElement => {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
let errorBoundaryWithContent
|
|
||||||
|
|
||||||
if (config.SENTRY_KEY) {
|
|
||||||
errorBoundaryWithContent = (
|
|
||||||
<Sentry.ErrorBoundary
|
|
||||||
showDialog
|
|
||||||
fallback={({ error, componentStack, resetError }) => <ItsBroken message={error.message} />}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</Sentry.ErrorBoundary>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
errorBoundaryWithContent = <ErrorBoundary>{content}</ErrorBoundary>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex' }}>
|
<Flex>
|
||||||
<SideBar />
|
<SideBar />
|
||||||
<Container className={classes.content}>{errorBoundaryWithContent}</Container>
|
<Container className={`${classes.content} ${isFileManagerOn ? classes.fileManagerOn : ''}`}>
|
||||||
</div>
|
{' '}
|
||||||
|
<ErrorBoundary errorReporting={props.errorReporting}>{content}</ErrorBoundary>
|
||||||
|
</Container>
|
||||||
|
</Flex>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { Container } from '@material-ui/core'
|
|
||||||
import { createStyles, makeStyles, Theme } from '@material-ui/core/styles'
|
|
||||||
import { ReactElement } from 'react'
|
|
||||||
|
|
||||||
const useStyles = makeStyles((theme: Theme) =>
|
|
||||||
createStyles({
|
|
||||||
content: {
|
|
||||||
backgroundColor: theme.palette.background.default,
|
|
||||||
minHeight: '100vh',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
errorMsg: {
|
|
||||||
marginTop: '30px',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Provide some nicer design
|
|
||||||
const ItsBroken = ({ message }: Props): ReactElement => {
|
|
||||||
const classes = useStyles()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Container className={classes.content}>
|
|
||||||
<h1>Ups, there was a problem 😅</h1>
|
|
||||||
<h3 className={classes.errorMsg}>Error: {message}</h3>
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ItsBroken
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { BigNumber } from 'bignumber.js'
|
|
||||||
import { Token } from './Token'
|
|
||||||
|
|
||||||
export class BzzToken extends Token {
|
|
||||||
constructor(amount: BigNumber | string | bigint) {
|
|
||||||
super(amount, 16)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
import { BigNumber } from 'bignumber.js'
|
|
||||||
import { Token } from './Token'
|
|
||||||
|
|
||||||
export class DaiToken extends Token {
|
|
||||||
constructor(amount: BigNumber | string | bigint) {
|
|
||||||
super(amount, 18)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import BigNumber from 'bignumber.js'
|
|
||||||
import { Token } from './Token'
|
|
||||||
|
|
||||||
describe('models/Token', () => {
|
|
||||||
describe('Token.fromDecimal', () => {
|
|
||||||
const values = [
|
|
||||||
{ bzz: '0', baseUnits: '0' },
|
|
||||||
{ bzz: '0.1', baseUnits: BigInt('1000000000000000') },
|
|
||||||
{ bzz: '9.9', baseUnits: BigInt('99000000000000000') },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Test with default 16 decimal places
|
|
||||||
values.forEach(({ bzz, baseUnits }) => {
|
|
||||||
test(`converting ${bzz} => ${baseUnits}`, () => {
|
|
||||||
expect(Token.fromDecimal(bzz).toBigNumber.eq(baseUnits.toString())).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test with 0 decimal places
|
|
||||||
values.forEach(({ baseUnits }) => {
|
|
||||||
test(`converting ${baseUnits} => ${baseUnits} with 0 decimals`, () => {
|
|
||||||
expect(Token.fromDecimal(baseUnits, 0).toBigNumber.eq(baseUnits.toString())).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('new Token', () => {
|
|
||||||
const cs = ['0', '1234567890', '99000000000000000']
|
|
||||||
const correctValues = [...cs, ...cs.map(BigInt), ...cs.map(v => new BigNumber(v))]
|
|
||||||
|
|
||||||
correctValues.forEach(v => {
|
|
||||||
test(`New Token ${v} of type ${typeof v}`, () => {
|
|
||||||
const t = new Token(v)
|
|
||||||
|
|
||||||
expect(t.toBigNumber.eq(v.toString())).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { BigNumber } from 'bignumber.js'
|
|
||||||
import { isInteger, makeBigNumber } from '../utils'
|
|
||||||
|
|
||||||
const POSSIBLE_DECIMALS = [18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
|
|
||||||
type digits = typeof POSSIBLE_DECIMALS[number]
|
|
||||||
|
|
||||||
const BZZ_DECIMALS = 16
|
|
||||||
|
|
||||||
export class Token {
|
|
||||||
private amount: BigNumber // Represented in the base units, so it is always an integer value
|
|
||||||
private readonly decimals: digits
|
|
||||||
|
|
||||||
constructor(amount: BigNumber | string | bigint, decimals: digits = BZZ_DECIMALS) {
|
|
||||||
const a = makeBigNumber(amount)
|
|
||||||
|
|
||||||
if (!isInteger(a) || !POSSIBLE_DECIMALS.includes(decimals)) {
|
|
||||||
throw new TypeError(`Not a valid token values: ${amount} ${decimals}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.amount = a
|
|
||||||
this.decimals = decimals
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Construct new Token from a digit representation
|
|
||||||
*
|
|
||||||
* @param amount Amount of a token in the digits (1 token = 10^decimals)
|
|
||||||
* @param decimals Number of decimals for the token (must be integer)
|
|
||||||
*
|
|
||||||
* @throws {TypeError} If the decimals is not an integer or the amount after conversion is not an integer
|
|
||||||
*
|
|
||||||
* @returns new Token
|
|
||||||
*/
|
|
||||||
static fromDecimal(amount: BigNumber | string | bigint, decimals: digits = BZZ_DECIMALS): Token | never {
|
|
||||||
const a = makeBigNumber(amount)
|
|
||||||
|
|
||||||
// No need to do any validation here, it is done when the new token is created
|
|
||||||
const t = a.multipliedBy(new BigNumber(10).pow(decimals))
|
|
||||||
|
|
||||||
return new Token(t, decimals)
|
|
||||||
}
|
|
||||||
|
|
||||||
get toBigInt(): bigint {
|
|
||||||
return BigInt(this.amount.toFixed(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
get toString(): string {
|
|
||||||
return this.amount.toFixed(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
get toBigNumber(): BigNumber {
|
|
||||||
return new BigNumber(this.amount)
|
|
||||||
}
|
|
||||||
|
|
||||||
get toDecimal(): BigNumber {
|
|
||||||
return this.amount.dividedBy(new BigNumber(10).pow(this.decimals))
|
|
||||||
}
|
|
||||||
|
|
||||||
toFixedDecimal(digits = 7): string {
|
|
||||||
return this.toDecimal.toFixed(digits)
|
|
||||||
}
|
|
||||||
|
|
||||||
toSignificantDigits(digits = 4): string {
|
|
||||||
const asString = this.toDecimal.toFixed(this.decimals)
|
|
||||||
|
|
||||||
let indexOfSignificantDigit = -1
|
|
||||||
let reachedDecimalPoint = false
|
|
||||||
|
|
||||||
for (let i = 0; i < asString.length; i++) {
|
|
||||||
const char = asString[i]
|
|
||||||
|
|
||||||
if (char === '.') {
|
|
||||||
reachedDecimalPoint = true
|
|
||||||
indexOfSignificantDigit = i + 1
|
|
||||||
} else if (reachedDecimalPoint && char !== '0') {
|
|
||||||
indexOfSignificantDigit = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return asString.slice(0, indexOfSignificantDigit + digits)
|
|
||||||
}
|
|
||||||
|
|
||||||
minusBaseUnits(amount: string): Token {
|
|
||||||
return new Token(
|
|
||||||
this.toBigNumber.minus(new BigNumber(amount).multipliedBy(new BigNumber(10).pow(this.decimals))),
|
|
||||||
this.decimals,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
.fm-admin-status-bar-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: rgb(33, 33, 33);
|
||||||
|
height: 60px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
|
||||||
|
&.is-loading {
|
||||||
|
filter: blur(1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-admin-status-bar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
color: rgb(229, 231, 235);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-admin-status-bar-upgrade-button {
|
||||||
|
padding: 6px;
|
||||||
|
background-color: rgb(237, 237, 237);
|
||||||
|
border-radius: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[aria-disabled='true'] {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-admin-status-bar-loader {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.04), transparent);
|
||||||
|
animation: fmShimmer 1.2s infinite;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fmShimmer {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-admin-status-bar-progress-pill-container {
|
||||||
|
position: absolute;
|
||||||
|
height: 60px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-admin-status-progress-pill {
|
||||||
|
position: absolute;
|
||||||
|
right: 16px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
background: rgb(255, 255, 255);
|
||||||
|
color: #000000;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
z-index: 999;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgb(221, 221, 221);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '›';
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover::after {
|
||||||
|
transform: translateX(2px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-admin-status-bar-container.is-loading .fm-admin-status-progress-pill {
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { ReactElement, useState, useMemo, useEffect, useRef, useContext } from 'react'
|
||||||
|
import './AdminStatusBar.scss'
|
||||||
|
import { ProgressBar } from '../ProgressBar/ProgressBar'
|
||||||
|
import { Tooltip } from '../Tooltip/Tooltip'
|
||||||
|
import { PostageBatch } from '@ethersphere/bee-js'
|
||||||
|
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||||
|
import { UpgradeDriveModal } from '../UpgradeDriveModal/UpgradeDriveModal'
|
||||||
|
import { calculateStampCapacityMetrics } from '../../utils/bee'
|
||||||
|
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||||
|
import { ConfirmModal } from '../ConfirmModal/ConfirmModal'
|
||||||
|
|
||||||
|
interface AdminStatusBarProps {
|
||||||
|
adminStamp: PostageBatch | null
|
||||||
|
adminDrive: DriveInfo | null
|
||||||
|
loading: boolean
|
||||||
|
isCreationInProgress: boolean
|
||||||
|
setErrorMessage?: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminStatusBar({
|
||||||
|
adminStamp,
|
||||||
|
adminDrive,
|
||||||
|
loading,
|
||||||
|
isCreationInProgress,
|
||||||
|
setErrorMessage,
|
||||||
|
}: AdminStatusBarProps): ReactElement {
|
||||||
|
const { setShowError, refreshStamp } = useContext(FMContext)
|
||||||
|
|
||||||
|
const [isUpgradeDriveModalOpen, setIsUpgradeDriveModalOpen] = useState(false)
|
||||||
|
const [isUpgrading, setIsUpgrading] = useState(false)
|
||||||
|
const [actualStamp, setActualStamp] = useState<PostageBatch | null>(adminStamp)
|
||||||
|
const [showProgressModal, setShowProgressModal] = useState(true)
|
||||||
|
|
||||||
|
const isMountedRef = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setShowProgressModal(isCreationInProgress || loading)
|
||||||
|
}, [isCreationInProgress, loading, setShowProgressModal])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setActualStamp(adminStamp)
|
||||||
|
}, [adminStamp, setActualStamp])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!adminDrive) return
|
||||||
|
|
||||||
|
const id = adminDrive.id.toString()
|
||||||
|
const batchId = adminStamp?.batchID.toString() || ''
|
||||||
|
|
||||||
|
const onStart = (e: Event) => {
|
||||||
|
const { driveId } = (e as CustomEvent).detail || {}
|
||||||
|
|
||||||
|
if (driveId === id) {
|
||||||
|
setIsUpgrading(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEnd = async (e: Event) => {
|
||||||
|
const { driveId, success, error } = (e as CustomEvent).detail || {}
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
if (error) {
|
||||||
|
setErrorMessage?.(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowError(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (driveId === id && batchId) {
|
||||||
|
setIsUpgrading(false)
|
||||||
|
|
||||||
|
const upgradedStamp = await refreshStamp(batchId)
|
||||||
|
|
||||||
|
if (!isMountedRef.current) return
|
||||||
|
|
||||||
|
if (upgradedStamp) {
|
||||||
|
setActualStamp(upgradedStamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('fm:drive-upgrade-start', onStart as EventListener)
|
||||||
|
window.addEventListener('fm:drive-upgrade-end', onEnd as EventListener)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('fm:drive-upgrade-start', onStart as EventListener)
|
||||||
|
window.removeEventListener('fm:drive-upgrade-end', onEnd as EventListener)
|
||||||
|
}
|
||||||
|
}, [adminDrive, adminStamp?.batchID, setErrorMessage, setShowError, refreshStamp, setIsUpgrading])
|
||||||
|
|
||||||
|
const { capacityPct, usedSize, totalSize } = useMemo(
|
||||||
|
() => calculateStampCapacityMetrics(actualStamp, adminDrive),
|
||||||
|
[actualStamp, adminDrive],
|
||||||
|
)
|
||||||
|
|
||||||
|
const expiresAt = useMemo(
|
||||||
|
() => (actualStamp ? actualStamp.duration.toEndDate().toLocaleDateString() : '—'),
|
||||||
|
[actualStamp],
|
||||||
|
)
|
||||||
|
|
||||||
|
const isBusy = loading || isUpgrading || isCreationInProgress
|
||||||
|
const blurCls = isBusy ? ' is-loading' : ''
|
||||||
|
const statusVerb = isCreationInProgress ? 'Creating' : 'Loading'
|
||||||
|
const statusText = statusVerb + ' admin drive, please do not reload'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={`fm-admin-status-bar-container${blurCls}`} aria-busy={isBusy ? 'true' : 'false'}>
|
||||||
|
<div className="fm-admin-status-bar-left">
|
||||||
|
<div className="fm-drive-item-capacity">
|
||||||
|
Capacity <ProgressBar value={capacityPct} width="150px" /> {usedSize} / {totalSize}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>File Manager Available: Until: {expiresAt}</div>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
label="The File Manager works only while your storage remains valid. If it expires, all catalogue metadata is
|
||||||
|
permanently lost."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isUpgradeDriveModalOpen && actualStamp && adminDrive && (
|
||||||
|
<UpgradeDriveModal
|
||||||
|
stamp={actualStamp}
|
||||||
|
drive={adminDrive}
|
||||||
|
onCancelClick={() => setIsUpgradeDriveModalOpen(false)}
|
||||||
|
setErrorMessage={setErrorMessage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="fm-admin-status-bar-upgrade-button"
|
||||||
|
onClick={() => !isBusy && actualStamp && adminDrive && setIsUpgradeDriveModalOpen(true)}
|
||||||
|
aria-disabled={isBusy ? 'true' : 'false'}
|
||||||
|
>
|
||||||
|
{isBusy ? 'Working…' : 'Manage'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isUpgrading && (
|
||||||
|
<div className="fm-drive-item-creating-overlay" aria-live="polite">
|
||||||
|
<div className="fm-mini-spinner" />
|
||||||
|
<span>Upgrading admin drive…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showProgressModal && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Admin Drive Creation"
|
||||||
|
isProgress
|
||||||
|
spinnerMessage={statusText}
|
||||||
|
showFooter={false}
|
||||||
|
showMinimize={true}
|
||||||
|
onMinimize={() => setShowProgressModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!showProgressModal && (loading || isCreationInProgress) && (
|
||||||
|
<div className="fm-admin-status-bar-progress-pill-container">
|
||||||
|
<div className="fm-admin-status-progress-pill" onClick={() => setShowProgressModal(true)}>
|
||||||
|
{statusText}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
.fm-button {
|
||||||
|
border-radius: 0px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-button-primary {
|
||||||
|
background-color: rgb(237, 129, 49);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-button-secondary {
|
||||||
|
background-color: rgb(255, 255, 255);
|
||||||
|
color: rgb(55, 65, 81);
|
||||||
|
border: 1px solid rgb(209, 213, 219);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-button-danger {
|
||||||
|
background-color: #dc2626;
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #dc2626;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-button-small {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-button-medium {
|
||||||
|
width: 100%;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-button-disabled {
|
||||||
|
background-color: rgb(156, 163, 175);
|
||||||
|
border: 1px solid rgb(156, 163, 175);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-button-icon {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ReactElement } from 'react'
|
||||||
|
import './Button.scss'
|
||||||
|
|
||||||
|
interface ButtonProps {
|
||||||
|
label: string
|
||||||
|
onClick?: () => void
|
||||||
|
icon?: ReactElement
|
||||||
|
size?: 'small' | 'medium'
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger'
|
||||||
|
disabled?: boolean
|
||||||
|
width?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
size = 'medium',
|
||||||
|
variant = 'primary',
|
||||||
|
disabled,
|
||||||
|
width,
|
||||||
|
}: ButtonProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`fm-button fm-button-${variant} fm-button-${size}${icon ? ' fm-button-icon' : ''}${
|
||||||
|
disabled ? ' fm-button-disabled' : ''
|
||||||
|
}`}
|
||||||
|
onClick={disabled ? undefined : onClick}
|
||||||
|
style={{ width: width ? `${width}px` : undefined }}
|
||||||
|
>
|
||||||
|
{icon} {label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
.fm-modal-container .fm-modal-window {
|
||||||
|
width: min(560px, 92vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-modal-container .fm-modal-window-header {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-modal-container .fm-modal-window-body .fm-modal-white-section {
|
||||||
|
max-height: 50vh;
|
||||||
|
overflow: auto;
|
||||||
|
word-break: break-word;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-modal-container .fm-modal-window-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.fm-modal-container .fm-modal-window {
|
||||||
|
width: 94vw;
|
||||||
|
}
|
||||||
|
.fm-modal-container .fm-modal-window-body .fm-modal-white-section {
|
||||||
|
max-height: 56vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-modal-no-background {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-spinner-message {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { ReactElement } from 'react'
|
||||||
|
import '../../styles/global.scss'
|
||||||
|
import './ConfirmModal.scss'
|
||||||
|
import { Button } from '../Button/Button'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
interface ConfirmModalProps {
|
||||||
|
title?: string
|
||||||
|
message?: React.ReactNode
|
||||||
|
confirmLabel?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
onConfirm?: () => void | Promise<void>
|
||||||
|
onCancel?: () => void
|
||||||
|
showFooter?: boolean
|
||||||
|
isProgress?: boolean
|
||||||
|
spinnerMessage?: string
|
||||||
|
showMinimize?: boolean
|
||||||
|
onMinimize?: () => void
|
||||||
|
background?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmModal({
|
||||||
|
title = 'Are you sure?',
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
showFooter = true,
|
||||||
|
isProgress = false,
|
||||||
|
spinnerMessage,
|
||||||
|
showMinimize = true,
|
||||||
|
onMinimize,
|
||||||
|
background = true,
|
||||||
|
}: ConfirmModalProps): ReactElement {
|
||||||
|
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className={`fm-modal-container fm-confirm-modal ${background ? '' : 'fm-modal-no-background'}`}>
|
||||||
|
<div className="fm-modal-window">
|
||||||
|
<div className="fm-modal-window-header">{title}</div>
|
||||||
|
|
||||||
|
<div className="fm-modal-window-body">
|
||||||
|
{isProgress ? (
|
||||||
|
<div className="fm-spinner-center">
|
||||||
|
<div className="fm-spinner-message">
|
||||||
|
<div>{spinnerMessage || 'Working…'}</div>
|
||||||
|
<div className="fm-mini-spinner" />
|
||||||
|
</div>
|
||||||
|
{showMinimize && <Button label="Minimize" variant="secondary" onClick={onMinimize} />}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="fm-modal-white-section">{message}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFooter && (onCancel || onConfirm) && (
|
||||||
|
<div className="fm-modal-window-footer">
|
||||||
|
{onCancel && <Button label={cancelLabel} variant="secondary" onClick={onCancel} />}
|
||||||
|
{onConfirm && <Button label={confirmLabel} variant="primary" onClick={() => onConfirm()} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
modalRoot,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.fm-context-menu {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { ReactElement } from 'react'
|
||||||
|
|
||||||
|
import './ContextMenu.scss'
|
||||||
|
|
||||||
|
interface ContextMenuProps {
|
||||||
|
children?: ReactElement | ReactElement[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContextMenu({ children }: ContextMenuProps): ReactElement {
|
||||||
|
return <div className="fm-context-menu">{children}</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
.fm-create-drive-modal-container {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
background: #000c;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
z-index: 1300;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-create-drive-modal {
|
||||||
|
width: 450px;
|
||||||
|
padding: 24px;
|
||||||
|
background-color: rgb(255, 255, 255);
|
||||||
|
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-create-drive-modal-header {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: start;
|
||||||
|
font-size: 18px;
|
||||||
|
color: rgb(237, 129, 49);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-create-drive-modal-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgb(229, 231, 235);
|
||||||
|
padding: 24px;
|
||||||
|
background-color: rgb(249, 250, 251);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-create-drive-modal-input-container {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
color: rgb(55, 65, 81);
|
||||||
|
|
||||||
|
& input {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: 'iAWriterQuattroV';
|
||||||
|
border: 1px solid rgb(209, 213, 219);
|
||||||
|
|
||||||
|
line-height: 21px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& input::placeholder {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
& input:focus {
|
||||||
|
border: 1px solid rgb(237, 129, 49) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-create-drive-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-modal-info-note {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { ReactElement, useContext, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { BZZ, Duration, RedundancyLevel, Size, Utils } from '@ethersphere/bee-js'
|
||||||
|
import './CreateDriveModal.scss'
|
||||||
|
import { CustomDropdown } from '../CustomDropdown/CustomDropdown'
|
||||||
|
import { Button } from '../Button/Button'
|
||||||
|
import { fmFetchCost, handleCreateDrive } from '../../utils/bee'
|
||||||
|
import { getExpiryDateByLifetime } from '../../utils/common'
|
||||||
|
import { erasureCodeMarks } from '../../constants/common'
|
||||||
|
import { desiredLifetimeOptions } from '../../constants/stamps'
|
||||||
|
import { Context as BeeContext } from '../../../../providers/Bee'
|
||||||
|
import { Context as SettingsContext } from '../../../../providers/Settings'
|
||||||
|
import { FMSlider } from '../Slider/Slider'
|
||||||
|
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||||
|
import { getHumanReadableFileSize } from '../../../../utils/file'
|
||||||
|
import { Tooltip } from '../Tooltip/Tooltip'
|
||||||
|
import { TOOLTIPS } from '../../constants/tooltips'
|
||||||
|
|
||||||
|
const minMarkValue = Math.min(...erasureCodeMarks.map(mark => mark.value))
|
||||||
|
const maxMarkValue = Math.max(...erasureCodeMarks.map(mark => mark.value))
|
||||||
|
|
||||||
|
interface CreateDriveModalProps {
|
||||||
|
onCancelClick: () => void
|
||||||
|
onDriveCreated: () => void
|
||||||
|
onCreationStarted: () => void
|
||||||
|
onCreationError: (name: string) => void
|
||||||
|
}
|
||||||
|
// TODO: select existing batch id or create a new one - just like in InitialModal
|
||||||
|
export function CreateDriveModal({
|
||||||
|
onCancelClick,
|
||||||
|
onDriveCreated,
|
||||||
|
onCreationStarted,
|
||||||
|
onCreationError,
|
||||||
|
}: CreateDriveModalProps): ReactElement {
|
||||||
|
const [isCreateEnabled, setIsCreateEnabled] = useState(false)
|
||||||
|
const [isBalanceSufficient, setIsBalanceSufficient] = useState(true)
|
||||||
|
const [capacity, setCapacity] = useState(0)
|
||||||
|
const [lifetimeIndex, setLifetimeIndex] = useState(-1)
|
||||||
|
const [validityEndDate, setValidityEndDate] = useState(new Date())
|
||||||
|
const [driveName, setDriveName] = useState('')
|
||||||
|
const [capacityIndex, setCapacityIndex] = useState(-1)
|
||||||
|
const [encryptionEnabled] = useState(false)
|
||||||
|
const [erasureCodeLevel, setErasureCodeLevel] = useState(RedundancyLevel.OFF)
|
||||||
|
const [cost, setCost] = useState('0')
|
||||||
|
|
||||||
|
const [sizeMarks, setSizeMarks] = useState<{ value: number; label: string }[]>([])
|
||||||
|
const { walletBalance } = useContext(BeeContext)
|
||||||
|
const { beeApi } = useContext(SettingsContext)
|
||||||
|
const { fm } = useContext(FMContext)
|
||||||
|
const currentFetch = useRef<Promise<void> | null>(null)
|
||||||
|
const isMountedRef = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCapacityChange = (value: number, index: number) => {
|
||||||
|
setCapacityIndex(index)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newSizes = Array.from(Utils.getStampEffectiveBytesBreakpoints(encryptionEnabled, erasureCodeLevel).values())
|
||||||
|
|
||||||
|
setSizeMarks(
|
||||||
|
newSizes.map(size => ({
|
||||||
|
value: size,
|
||||||
|
label: getHumanReadableFileSize(size),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
setCapacity(newSizes[capacityIndex])
|
||||||
|
}, [encryptionEnabled, erasureCodeLevel, capacityIndex])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (capacity > 0 && validityEndDate.getTime() > new Date().getTime()) {
|
||||||
|
fmFetchCost(
|
||||||
|
capacity,
|
||||||
|
validityEndDate,
|
||||||
|
false,
|
||||||
|
erasureCodeLevel,
|
||||||
|
beeApi,
|
||||||
|
(cost: BZZ) => {
|
||||||
|
if (!isMountedRef.current) return
|
||||||
|
|
||||||
|
setIsBalanceSufficient(true)
|
||||||
|
|
||||||
|
if ((walletBalance && cost.gte(walletBalance.bzzBalance)) || !walletBalance) {
|
||||||
|
setIsBalanceSufficient(false)
|
||||||
|
}
|
||||||
|
setCost(cost.toSignificantDigits(2))
|
||||||
|
},
|
||||||
|
currentFetch,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (driveName && driveName.trim().length > 0) {
|
||||||
|
setIsCreateEnabled(true)
|
||||||
|
} else {
|
||||||
|
setIsCreateEnabled(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCost('0')
|
||||||
|
setIsCreateEnabled(false)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [capacity, validityEndDate, beeApi, driveName, walletBalance])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValidityEndDate(getExpiryDateByLifetime(lifetimeIndex))
|
||||||
|
}, [lifetimeIndex])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fm-modal-container">
|
||||||
|
<div className="fm-modal-window">
|
||||||
|
<div className="fm-modal-window-header">Create new drive</div>
|
||||||
|
<div className="fm-modal-window-body">
|
||||||
|
<div className="fm-modal-window-input-container">
|
||||||
|
<label htmlFor="drive-name" className="fm-input-label">
|
||||||
|
Drive name: <Tooltip label={TOOLTIPS.DRIVE_NAME} />
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="drive-name"
|
||||||
|
placeholder="My important files"
|
||||||
|
value={driveName}
|
||||||
|
onChange={e => setDriveName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="fm-modal-window-input-container">
|
||||||
|
<label htmlFor="drive-initial-capacity" className="fm-input-label">
|
||||||
|
Initial capacity: <Tooltip label={TOOLTIPS.DRIVE_INITIAL_CAPACITY} />
|
||||||
|
</label>
|
||||||
|
<CustomDropdown
|
||||||
|
id="drive-initial-capacity"
|
||||||
|
options={sizeMarks}
|
||||||
|
value={capacity}
|
||||||
|
onChange={handleCapacityChange}
|
||||||
|
placeholder="Select a value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="fm-modal-info-warning">
|
||||||
|
Drive sizes shown above are system-calculated based on your current stamp configuration
|
||||||
|
</div>
|
||||||
|
<div className="fm-modal-window-input-container">
|
||||||
|
<label htmlFor="drive-desired-lifetime" className="fm-input-label">
|
||||||
|
Desired lifetime: <Tooltip label={TOOLTIPS.DRIVE_DESIRED_LIFETIME} />
|
||||||
|
</label>
|
||||||
|
<CustomDropdown
|
||||||
|
id="drive-desired-lifetime"
|
||||||
|
options={desiredLifetimeOptions}
|
||||||
|
value={lifetimeIndex}
|
||||||
|
onChange={setLifetimeIndex}
|
||||||
|
placeholder="Select a value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="fm-modal-window-input-container">
|
||||||
|
<label htmlFor="drive-security-level" className="fm-input-label">
|
||||||
|
Security Level <Tooltip label={TOOLTIPS.DRIVE_SECURITY_LEVEL} />
|
||||||
|
</label>
|
||||||
|
<FMSlider
|
||||||
|
id="drive-security-level"
|
||||||
|
defaultValue={0}
|
||||||
|
marks={erasureCodeMarks}
|
||||||
|
onChange={value => setErasureCodeLevel(value)}
|
||||||
|
minValue={minMarkValue}
|
||||||
|
maxValue={maxMarkValue}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="fm-modal-estimated-cost-container">
|
||||||
|
<div className="fm-emphasized-text">Estimated Cost:</div>
|
||||||
|
<div>
|
||||||
|
{cost} BZZ {isBalanceSufficient ? '' : '(Insufficient balance)'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tooltip label={TOOLTIPS.DRIVE_ESTIMATED_COST} bottomTooltip={true} />
|
||||||
|
</div>
|
||||||
|
<div>(Based on current network conditions)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="fm-modal-window-footer">
|
||||||
|
<Button
|
||||||
|
label="Create drive"
|
||||||
|
variant="primary"
|
||||||
|
disabled={!isCreateEnabled || !isBalanceSufficient}
|
||||||
|
onClick={async () => {
|
||||||
|
if (isCreateEnabled && fm && beeApi && walletBalance) {
|
||||||
|
onCreationStarted()
|
||||||
|
onCancelClick()
|
||||||
|
|
||||||
|
await handleCreateDrive(
|
||||||
|
beeApi,
|
||||||
|
fm,
|
||||||
|
Size.fromBytes(capacity),
|
||||||
|
Duration.fromEndDate(validityEndDate),
|
||||||
|
driveName,
|
||||||
|
encryptionEnabled,
|
||||||
|
erasureCodeLevel,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
() => onDriveCreated(), // onSuccess
|
||||||
|
() => onCreationError(driveName), // onError
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button label="Cancel" variant="secondary" onClick={onCancelClick} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
.fm-custom-dropdown {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 14px;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.fm-custom-dropdown-selected {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid rgb(209, 213, 219);
|
||||||
|
border-radius: 0px;
|
||||||
|
background: #fff;
|
||||||
|
color: rgb(55, 65, 81);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
outline: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 40px;
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
& .arrow {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%) rotate(0deg);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
display: inline-block;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.open .arrow {
|
||||||
|
transform: translateY(-50%) rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&.open {
|
||||||
|
border-color: rgb(237, 129, 49);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-custom-dropdown-list {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid rgb(209, 213, 219);
|
||||||
|
border-radius: 0px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 20;
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px 0;
|
||||||
|
list-style: none;
|
||||||
|
max-height: 220px;
|
||||||
|
overflow-y: auto;
|
||||||
|
animation: fadeIn 0.15s;
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 10px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgb(55, 65, 81);
|
||||||
|
transition: background 0.15s;
|
||||||
|
&:hover,
|
||||||
|
&.selected {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useRef } from 'react'
|
||||||
|
import './CustomDropdown.scss'
|
||||||
|
import ArrowDropdown from 'remixicon-react/ArrowDropDownLineIcon'
|
||||||
|
import { useClickOutside } from '../../hooks/useClickOutside'
|
||||||
|
import { Tooltip } from '../Tooltip/Tooltip'
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
value: number
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomDropdownProps {
|
||||||
|
options: Option[]
|
||||||
|
value: number
|
||||||
|
onChange: (value: number, index: number) => void
|
||||||
|
placeholder?: string
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
icon?: React.ReactNode
|
||||||
|
infoText?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomDropdown({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
icon,
|
||||||
|
infoText,
|
||||||
|
}: CustomDropdownProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useClickOutside(ref, () => setOpen(false), open)
|
||||||
|
|
||||||
|
const selectedLabel = options.find(opt => opt.value === value)?.label || ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fm-custom-dropdown" ref={ref}>
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={id} className="fm-input-label">
|
||||||
|
{icon} {label} {infoText && <Tooltip label={infoText ? infoText : ''} iconSize="14px" />}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={`fm-custom-dropdown-selected${open ? ' open' : ''}`}
|
||||||
|
id={id}
|
||||||
|
onClick={() => setOpen(prev => !prev)}
|
||||||
|
>
|
||||||
|
{selectedLabel || <span className="placeholder">{placeholder} </span>}
|
||||||
|
|
||||||
|
<ArrowDropdown className="arrow" />
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<ul className="fm-custom-dropdown-list">
|
||||||
|
{options.map((opt, index) => (
|
||||||
|
<li
|
||||||
|
key={opt.value}
|
||||||
|
className={opt.value === value ? 'selected' : ''}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(opt.value, index)
|
||||||
|
setOpen(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
.fm-delete-file-modal {
|
||||||
|
width: 510px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-delete-file-modal-list {
|
||||||
|
margin: 0 0 12px 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-delete-file-modal-list-item {
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { ReactElement, useState } from 'react'
|
||||||
|
import './DeleteFileModal.scss'
|
||||||
|
import { Button } from '../Button/Button'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import TrashIcon from 'remixicon-react/DeleteBin6LineIcon'
|
||||||
|
import AlertIcon from 'remixicon-react/AlertLineIcon'
|
||||||
|
|
||||||
|
import Radio from '@material-ui/core/Radio'
|
||||||
|
import FormControlLabel from '@material-ui/core/FormControlLabel'
|
||||||
|
import FormControl from '@material-ui/core/FormControl'
|
||||||
|
|
||||||
|
import { FileAction } from '../../constants/transfers'
|
||||||
|
|
||||||
|
interface DeleteFileModalProps {
|
||||||
|
name?: string
|
||||||
|
names?: string[]
|
||||||
|
currentDriveName?: string
|
||||||
|
onCancelClick: () => void
|
||||||
|
onProceed: (action: FileAction) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteFileModal({
|
||||||
|
name,
|
||||||
|
names,
|
||||||
|
currentDriveName,
|
||||||
|
onCancelClick,
|
||||||
|
onProceed,
|
||||||
|
}: DeleteFileModalProps): ReactElement {
|
||||||
|
const [value, setValue] = useState<FileAction>(FileAction.Trash)
|
||||||
|
|
||||||
|
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||||
|
const isBulk = Array.isArray(names) && names.length > 0
|
||||||
|
const count = isBulk ? names.length : 1
|
||||||
|
const headerText = isBulk ? `Delete ${count} file${count > 1 ? 's' : ''}?` : `Delete ${name}?`
|
||||||
|
const subjectNoun = isBulk ? 'selected file(s)' : 'this file'
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fm-modal-container">
|
||||||
|
<div className="fm-modal-window fm-delete-file-modal">
|
||||||
|
<div className="fm-modal-window-header">
|
||||||
|
<TrashIcon /> <span className="fm-main-font-color">{headerText}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-modal-window-body">
|
||||||
|
{isBulk && (
|
||||||
|
<ul className="fm-delete-file-modal-list">
|
||||||
|
{names.map(n => (
|
||||||
|
<li key={n} className="fm-delete-file-modal-list-item" title={n}>
|
||||||
|
{n}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<FormControl component="fieldset">
|
||||||
|
<div className="fm-radio-group">
|
||||||
|
<div className="fm-form-control-label">
|
||||||
|
<FormControlLabel
|
||||||
|
value={FileAction.Trash}
|
||||||
|
control={<Radio checked={value === FileAction.Trash} onChange={() => setValue(FileAction.Trash)} />}
|
||||||
|
label={
|
||||||
|
<div className="fm-radio-label">
|
||||||
|
<div className="fm-radio-label-header fm-main-font-color fm-line-height-fit">Move to Trash</div>
|
||||||
|
<div onClick={e => e.preventDefault()}>
|
||||||
|
Moves {subjectNoun} to the trash. It will still take up space on{' '}
|
||||||
|
{currentDriveName ?? 'this drive'} and expire along with it. You can restore it later.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-form-control-label">
|
||||||
|
<FormControlLabel
|
||||||
|
value={FileAction.Forget}
|
||||||
|
control={<Radio checked={value === FileAction.Forget} onChange={() => setValue(FileAction.Forget)} />}
|
||||||
|
label={
|
||||||
|
<div className="fm-radio-label">
|
||||||
|
<div className="fm-radio-label-header fm-main-font-color fm-line-height-fit">Forget</div>
|
||||||
|
<div onClick={e => e.preventDefault()}>
|
||||||
|
Removes {subjectNoun} from your view. The data will remain on Swarm until{' '}
|
||||||
|
{currentDriveName ?? 'the drive'} expires. This action cannot be easily undone.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-form-control-label">
|
||||||
|
<FormControlLabel
|
||||||
|
value={FileAction.Destroy}
|
||||||
|
control={
|
||||||
|
<Radio checked={value === FileAction.Destroy} onChange={() => setValue(FileAction.Destroy)} />
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<div className="fm-radio-label">
|
||||||
|
<div className="fm-radio-label-header fm-main-font-color fm-line-height-fit">
|
||||||
|
Destroy entire drive {currentDriveName ? `‘${currentDriveName}’` : ''} to delete this{' '}
|
||||||
|
{subjectNoun}
|
||||||
|
</div>
|
||||||
|
<div className="fm-red-font" onClick={e => e.preventDefault()}>
|
||||||
|
<AlertIcon size="14px" className="fm-alert-icon-inline" />
|
||||||
|
Warning: This will make all files on this drive inaccessible. This action is irreversible.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-modal-window-footer">
|
||||||
|
<Button label="Proceed" variant="primary" onClick={() => onProceed(value)} />
|
||||||
|
<Button label="Cancel" variant="secondary" onClick={onCancelClick} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
modalRoot,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.fm-modal-body-destroy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { ReactElement, useState } from 'react'
|
||||||
|
import '../../styles/global.scss'
|
||||||
|
import './DestroyDriveModal.scss'
|
||||||
|
import { Button } from '../Button/Button'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||||
|
|
||||||
|
interface DestroyDriveModalProps {
|
||||||
|
drive: DriveInfo
|
||||||
|
onCancelClick: () => void
|
||||||
|
doDestroy: () => void | Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DestroyDriveModal({ drive, onCancelClick, doDestroy }: DestroyDriveModalProps): ReactElement {
|
||||||
|
const [driveNameInput, setDriveNameInput] = useState('')
|
||||||
|
const destroyDriveText = `DESTROY DRIVE ${drive.name}`
|
||||||
|
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fm-modal-container">
|
||||||
|
<div className="fm-modal-window">
|
||||||
|
<div className="fm-modal-window-header fm-red-font">Destroy entire drive</div>
|
||||||
|
<div className="fm-modal-window-body">
|
||||||
|
<div className="fm-modal-body-destroy">
|
||||||
|
<div className="fm-emphasized-text">Destroy Drive? This Action Is Permanent</div>
|
||||||
|
<div>All files stored only on this drive will become inaccessible.</div>
|
||||||
|
<div>
|
||||||
|
While the data may still temporarily persist on Swarm, it will be permanently removed once the storage
|
||||||
|
expires and the data is garbage collected by the network. The File Manager will no longer recognise or
|
||||||
|
recover these files.
|
||||||
|
</div>
|
||||||
|
<div>Confirmation:</div>
|
||||||
|
<div>Requires typing a fixed expression to prevent accidental deletion. This action cannot be undone.</div>
|
||||||
|
<div>
|
||||||
|
Type: <span className="fm-emphasized-text">{destroyDriveText}</span>
|
||||||
|
</div>
|
||||||
|
<div className="fm-modal-window-input-container">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="drive-name"
|
||||||
|
placeholder={destroyDriveText}
|
||||||
|
value={driveNameInput}
|
||||||
|
onChange={e => setDriveNameInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="fm-modal-window-footer">
|
||||||
|
<Button
|
||||||
|
label="Destroy entire drive"
|
||||||
|
variant="danger"
|
||||||
|
disabled={destroyDriveText !== driveNameInput}
|
||||||
|
onClick={() => doDestroy()}
|
||||||
|
/>
|
||||||
|
<Button label="Cancel" variant="secondary" onClick={onCancelClick} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
modalRoot,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
.fm-error-modal-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1500;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-error-modal-message {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-error-modal-button-container {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: right;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ReactElement } from 'react'
|
||||||
|
import './ErrorModal.scss'
|
||||||
|
import { Button } from '../Button/Button'
|
||||||
|
|
||||||
|
interface ErrorModalProps {
|
||||||
|
label: string
|
||||||
|
onClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorModal({ label, onClick }: ErrorModalProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="fm-error-modal-container">
|
||||||
|
<div className="fm-modal-window">
|
||||||
|
<div className="fm-error-modal-message">{label}</div>
|
||||||
|
<div className="fm-error-modal-button-container">
|
||||||
|
<Button variant="primary" label="OK" width={100} onClick={onClick} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
.fm-expiring-notification-modal-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-expiring-notification-modal-section-left {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-expiring-notification-modal-section-right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-expiring-notification-modal-section-left-header {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-expiring-notification-modal-section-right-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-expiring-notification-modal-section-right-button {
|
||||||
|
margin-top: 20px;
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-expiring-notification-modal-footer-one-button {
|
||||||
|
width: 33%;
|
||||||
|
}
|
||||||
+149
@@ -0,0 +1,149 @@
|
|||||||
|
import { ReactElement, useState, useMemo, useEffect } from 'react'
|
||||||
|
import { Warning } from '@material-ui/icons'
|
||||||
|
import './ExpiringNotificationModal.scss'
|
||||||
|
import '../../styles/global.scss'
|
||||||
|
|
||||||
|
import { Button } from '../Button/Button'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import DriveIcon from 'remixicon-react/HardDrive2LineIcon'
|
||||||
|
import CalendarIcon from 'remixicon-react/CalendarLineIcon'
|
||||||
|
import AlertIcon from 'remixicon-react/AlertLineIcon'
|
||||||
|
import { UpgradeDriveModal } from '../UpgradeDriveModal/UpgradeDriveModal'
|
||||||
|
import { getDaysLeft } from '../../utils/common'
|
||||||
|
|
||||||
|
import { PostageBatch, Size } from '@ethersphere/bee-js'
|
||||||
|
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||||
|
|
||||||
|
const EXPIRING_ITEMS_PAGE_SIZE = 3
|
||||||
|
|
||||||
|
interface ExpiringNotificationModalProps {
|
||||||
|
stamps: PostageBatch[]
|
||||||
|
drives: DriveInfo[]
|
||||||
|
onCancelClick: () => void
|
||||||
|
setErrorMessage?: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExpiringNotificationModal({
|
||||||
|
stamps,
|
||||||
|
drives,
|
||||||
|
onCancelClick,
|
||||||
|
setErrorMessage,
|
||||||
|
}: ExpiringNotificationModalProps): ReactElement {
|
||||||
|
const [showUpgradeDriveModal, setShowUpgradeDriveModal] = useState(false)
|
||||||
|
const [actualStamp, setActualStamp] = useState<PostageBatch | undefined>(undefined)
|
||||||
|
const [actualDrive, setActualDrive] = useState<DriveInfo | undefined>(undefined)
|
||||||
|
const [currentPage, setCurrentPage] = useState(0)
|
||||||
|
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||||
|
|
||||||
|
const sortedStamps = useMemo(() => {
|
||||||
|
return [...stamps].sort((a, b) => {
|
||||||
|
const daysLeftA = getDaysLeft(a.duration.toEndDate())
|
||||||
|
const daysLeftB = getDaysLeft(b.duration.toEndDate())
|
||||||
|
|
||||||
|
return daysLeftA - daysLeftB
|
||||||
|
})
|
||||||
|
}, [stamps])
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(sortedStamps.length / EXPIRING_ITEMS_PAGE_SIZE)
|
||||||
|
const startIndex = currentPage * EXPIRING_ITEMS_PAGE_SIZE
|
||||||
|
const paginatedStamps = sortedStamps.slice(startIndex, startIndex + EXPIRING_ITEMS_PAGE_SIZE)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(0)
|
||||||
|
}, [stamps])
|
||||||
|
|
||||||
|
if (stamps.length === 0) return <></>
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fm-modal-container">
|
||||||
|
<div className="fm-modal-window fm-upgrade-drive-modal">
|
||||||
|
<div className="fm-modal-window-header fm-red-font">
|
||||||
|
<AlertIcon size="21px" /> Drives Expiring soon
|
||||||
|
</div>
|
||||||
|
<div>The following drives will expire soon. Extend them to keep your data accessible.</div>
|
||||||
|
|
||||||
|
<div className="fm-modal-window-body fm-expiring-notification-modal-body">
|
||||||
|
{paginatedStamps.map((stamp, index) => {
|
||||||
|
const daysLeft = getDaysLeft(stamp.duration.toEndDate())
|
||||||
|
let daysClass = ''
|
||||||
|
|
||||||
|
const drive = drives.find(d => d.batchId.toString() === stamp.batchID.toString())
|
||||||
|
|
||||||
|
if (!drive) return null
|
||||||
|
|
||||||
|
if (daysLeft < 10) {
|
||||||
|
daysClass = 'fm-red-font'
|
||||||
|
} else if (daysLeft < 30) {
|
||||||
|
daysClass = 'fm-swarm-orange-font'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${stamp.batchID.toString()}-${currentPage}-${index}`}
|
||||||
|
className="fm-modal-white-section fm-space-between"
|
||||||
|
>
|
||||||
|
<div className="fm-expiring-notification-modal-section-left fm-space-between">
|
||||||
|
<DriveIcon size="20" color="rgb(237, 129, 49)" />
|
||||||
|
<div>
|
||||||
|
<div className="fm-expiring-notification-modal-section-left-header fm-emphasized-text">
|
||||||
|
{stamp.label} {drive.isAdmin && <Warning style={{ fontSize: '16px' }} />}
|
||||||
|
</div>
|
||||||
|
<div className="fm-expiring-notification-modal-section-left-value">
|
||||||
|
{Size.fromBytes(stamp.size.toBytes() * stamp.usage).toFormattedString()} /{' '}
|
||||||
|
{stamp.size.toFormattedString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="fm-expiring-notification-modal-section-right">
|
||||||
|
<div className="fm-expiring-notification-modal-section-right-header">
|
||||||
|
<CalendarIcon size="14" /> Expiry date: {stamp.duration.toEndDate().toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
<div className={daysClass}>{daysLeft} days left</div>
|
||||||
|
<div className="fm-expiring-notification-modal-section-right-button">
|
||||||
|
<Button
|
||||||
|
label="Upgrade"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setActualStamp(stamp)
|
||||||
|
setActualDrive(drive)
|
||||||
|
setShowUpgradeDriveModal(true)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="fm-modal-window-footer">
|
||||||
|
<div className="fm-expiring-notification-modal-footer-one-button">
|
||||||
|
<Button label="Cancel" variant="secondary" onClick={onCancelClick} />
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<span>
|
||||||
|
Page {currentPage + 1} / {totalPages} · total {sortedStamps.length}
|
||||||
|
</span>
|
||||||
|
{currentPage > 0 && (
|
||||||
|
<Button label="Previous" variant="secondary" onClick={() => setCurrentPage(prev => prev - 1)} />
|
||||||
|
)}
|
||||||
|
{currentPage + 1 < totalPages && (
|
||||||
|
<Button label="Next" variant="primary" onClick={() => setCurrentPage(prev => prev + 1)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showUpgradeDriveModal && actualStamp && actualDrive && (
|
||||||
|
<UpgradeDriveModal
|
||||||
|
stamp={actualStamp}
|
||||||
|
onCancelClick={onCancelClick}
|
||||||
|
containerColor="none"
|
||||||
|
drive={actualDrive}
|
||||||
|
setErrorMessage={setErrorMessage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
modalRoot,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
.fm-file-browser-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100vh - 120px);
|
||||||
|
background-color: white;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-content {
|
||||||
|
position: relative;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-content-header {
|
||||||
|
display: grid;
|
||||||
|
padding: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid rgb(226, 232, 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-content[data-search-mode='false'] .fm-file-browser-content-header {
|
||||||
|
grid-template-columns: 32px 2fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-content[data-search-mode='true'] .fm-file-browser-content-header {
|
||||||
|
grid-template-columns: 32px 2fr 1.1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-content-header-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
|
||||||
|
& input {
|
||||||
|
margin: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: rgb(237, 129, 49);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-content-header-item.fm-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-content-header-item-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: rgb(237, 129, 49);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-content-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
max-height: 45px;
|
||||||
|
padding: 12px;
|
||||||
|
border-top: 1px solid #929292;
|
||||||
|
background-color: #ededed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-footer > * {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.fm-file-browser-footer > :nth-child(1) {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
.fm-file-browser-footer > :nth-child(3) {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.fm-file-browser-footer {
|
||||||
|
column-gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-context-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.fm-file-browser-context-menu[data-drop='up'] {
|
||||||
|
transform-origin: bottom left;
|
||||||
|
}
|
||||||
|
.fm-file-browser-context-menu[data-drop='up'] .caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
bottom: -6px;
|
||||||
|
top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-context-item {
|
||||||
|
margin: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: #d1d1d1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
&.red {
|
||||||
|
color: rgb(220, 38, 38);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-context-item[aria-disabled="true"] {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-context-item-border {
|
||||||
|
border-bottom: 1px solid #d1d1d1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-upload-download-indicator {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-drag-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 1500;
|
||||||
|
background: rgba(255, 255, 255, 0.6);
|
||||||
|
-webkit-backdrop-filter: blur(4px);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-drag-text {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-drop-hint {
|
||||||
|
padding: 24px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-context-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-info {
|
||||||
|
font-weight: 600;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
opacity: .6;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-info--inline {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-info--inline::after {
|
||||||
|
content: attr(data-tip);
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 8px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
max-width: 280px;
|
||||||
|
white-space: normal;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(17, 24, 39, 0.98);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity .08s ease, visibility .08s ease;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-info--inline::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: calc(100% + 2px);
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border: 6px solid transparent;
|
||||||
|
border-right-color: rgba(17, 24, 39, 0.98);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity .08s ease, visibility .08s ease;
|
||||||
|
z-index: 2001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-info--inline:hover::after,
|
||||||
|
.fm-info--inline:focus-visible::after,
|
||||||
|
.fm-info--inline:hover::before,
|
||||||
|
.fm-info--inline:focus-visible::before {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-context-menu {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.fm-refresh-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(255, 255, 255, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-refresh-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-refresh-text {
|
||||||
|
animation: fadeIn 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-content-header-item {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-header-cell {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px; /* space between sort button and the × bubble */
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-header-button {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-header-button[data-dir='asc'] .fm-file-browser-content-header-item-icon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-content-header-item-icon.is-inactive {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-sort-clear {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-sort-clear:hover,
|
||||||
|
.fm-sort-clear:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(237, 129, 49, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(2px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
import { ReactElement, useEffect, useLayoutEffect, useRef, useState, useContext } from 'react'
|
||||||
|
import './FileBrowser.scss'
|
||||||
|
import { FileBrowserHeader } from './FileBrowserHeader/FileBrowserHeader'
|
||||||
|
import { FileBrowserContent } from './FileBrowserContent/FileBrowserContent'
|
||||||
|
import { useContextMenu } from '../../hooks/useContextMenu'
|
||||||
|
import { NotificationBar } from '../NotificationBar/NotificationBar'
|
||||||
|
import { FileAction, FileTransferType, TransferStatus, ViewType } from '../../constants/transfers'
|
||||||
|
import { FileProgressNotification } from '../FileProgressNotification/FileProgressNotification'
|
||||||
|
import { useView } from '../../../../pages/filemanager/ViewContext'
|
||||||
|
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||||
|
import { useTransfers } from '../../hooks/useTransfers'
|
||||||
|
import { useSearch } from '../../../../pages/filemanager/SearchContext'
|
||||||
|
import { useFileFiltering } from '../../hooks/useFileFiltering'
|
||||||
|
import { useDragAndDrop } from '../../hooks/useDragAndDrop'
|
||||||
|
import { useBulkActions } from '../../hooks/useBulkActions'
|
||||||
|
import { SortKey, SortDir, useSorting } from '../../hooks/useSorting'
|
||||||
|
|
||||||
|
import { Point, Dir, safeSetState } from '../../utils/common'
|
||||||
|
import { computeContextMenuPosition } from '../../utils/ui'
|
||||||
|
import { FileBrowserTopBar } from './FileBrowserTopBar/FileBrowserTopBar'
|
||||||
|
import { handleDestroyDrive } from '../../utils/bee'
|
||||||
|
import { Context as SettingsContext } from '../../../../providers/Settings'
|
||||||
|
import { ErrorModal } from '../ErrorModal/ErrorModal'
|
||||||
|
import { FileBrowserModals } from './FileBrowserModals'
|
||||||
|
import { FileBrowserContextMenu } from './FileBrowserMenu/FileBrowserContextMenu'
|
||||||
|
import { FileInfo } from '@solarpunkltd/file-manager-lib'
|
||||||
|
|
||||||
|
const extractFilesFromClipboardEvent = (e: React.ClipboardEvent): File[] => {
|
||||||
|
const out: File[] = []
|
||||||
|
const items = e.clipboardData?.items ?? []
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const it = items[i]
|
||||||
|
|
||||||
|
if (it.kind === 'file') {
|
||||||
|
const f = it.getAsFile()
|
||||||
|
|
||||||
|
if (f) out.push(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileBrowserProps {
|
||||||
|
errorMessage?: string
|
||||||
|
setErrorMessage?: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps): ReactElement {
|
||||||
|
const { showContext, pos, contextRef, handleContextMenu, handleCloseContext } = useContextMenu<HTMLDivElement>()
|
||||||
|
const { view, setActualItemView } = useView()
|
||||||
|
const { beeApi } = useContext(SettingsContext)
|
||||||
|
const { files, currentDrive, resync, drives, fm, showError, setShowError } = useContext(FMContext)
|
||||||
|
const {
|
||||||
|
uploadFiles,
|
||||||
|
isUploading,
|
||||||
|
uploadItems,
|
||||||
|
isDownloading,
|
||||||
|
downloadItems,
|
||||||
|
trackDownload,
|
||||||
|
conflictPortal,
|
||||||
|
cancelOrDismissUpload,
|
||||||
|
cancelOrDismissDownload,
|
||||||
|
dismissAllUploads,
|
||||||
|
dismissAllDownloads,
|
||||||
|
} = useTransfers({ setErrorMessage })
|
||||||
|
|
||||||
|
const { query, scope, includeActive, includeTrashed } = useSearch()
|
||||||
|
|
||||||
|
const [safePos, setSafePos] = useState<Point>(pos)
|
||||||
|
const [dropDir, setDropDir] = useState<Dir>(Dir.Down)
|
||||||
|
|
||||||
|
const legacyUploadRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
const contentRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const bodyRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const isMountedRef = useRef(true)
|
||||||
|
const rafIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
|
||||||
|
const [showDestroyDriveModal, setShowDestroyDriveModal] = useState(false)
|
||||||
|
const [confirmBulkForget, setConfirmBulkForget] = useState(false)
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
|
const [pendingCancelUpload, setPendingCancelUpload] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const q = query.trim().toLowerCase()
|
||||||
|
const isSearchMode = q.length > 0
|
||||||
|
|
||||||
|
const getDriveName = (fi: FileInfo): string => {
|
||||||
|
const match = drives.find(d => d.id.toString() === fi.driveId.toString())
|
||||||
|
|
||||||
|
return match?.name ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTopbarMenu = (anchorEl: HTMLElement) => {
|
||||||
|
const r = anchorEl.getBoundingClientRect()
|
||||||
|
const bodyRect = bodyRef.current?.getBoundingClientRect()
|
||||||
|
const clickX = Math.round(r.right - 6)
|
||||||
|
const minY = (bodyRect?.top ?? 0) + 8
|
||||||
|
const clickY = Math.max(Math.round(r.bottom + 6), minY)
|
||||||
|
const fakeEvt = {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
preventDefault: () => {},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
stopPropagation: () => {},
|
||||||
|
clientX: clickX,
|
||||||
|
clientY: clickY,
|
||||||
|
} as React.MouseEvent<HTMLDivElement>
|
||||||
|
handleContextMenu(fakeEvt)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { listToRender } = useFileFiltering({
|
||||||
|
files,
|
||||||
|
currentDrive: currentDrive || null,
|
||||||
|
view,
|
||||||
|
isSearchMode,
|
||||||
|
query: q,
|
||||||
|
scope,
|
||||||
|
includeActive,
|
||||||
|
includeTrashed,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { sorted, sort, toggle, reset } = useSorting(listToRender, {
|
||||||
|
persist: false,
|
||||||
|
defaultState: { key: SortKey.Timestamp, dir: SortDir.Desc },
|
||||||
|
getDriveName,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bulk = useBulkActions({
|
||||||
|
listToRender,
|
||||||
|
trackDownload,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { isDragging, handleDragEnter, handleDragOver, handleDragLeave, handleDrop, handleOverlayDrop } =
|
||||||
|
useDragAndDrop({
|
||||||
|
onFilesDropped: uploadFiles,
|
||||||
|
currentDrive: currentDrive || null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const onFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = e.target.files
|
||||||
|
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
uploadFiles(files)
|
||||||
|
}
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const onContextUploadFile = () => {
|
||||||
|
const el = legacyUploadRef.current
|
||||||
|
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof (el as HTMLInputElement).showPicker === 'function') {
|
||||||
|
;(el as HTMLInputElement).showPicker()
|
||||||
|
} else {
|
||||||
|
el.click()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
el.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(() => handleCloseContext())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
|
const files = extractFilesFromClipboardEvent(e)
|
||||||
|
|
||||||
|
if (files.length === 0) return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
uploadFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileBrowserContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
|
||||||
|
if (t.closest('.fm-file-item-context-menu, .fm-file-browser-context-menu')) return
|
||||||
|
|
||||||
|
if (!e.shiftKey && t.closest('.fm-file-item-content')) return
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
handleContextMenu(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteModalProceed = async (action: FileAction) => {
|
||||||
|
setShowBulkDeleteModal(false)
|
||||||
|
|
||||||
|
if (action === FileAction.Trash) {
|
||||||
|
return await bulk.bulkTrash(bulk.selectedFiles)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === FileAction.Forget) {
|
||||||
|
return setConfirmBulkForget(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === FileAction.Destroy) {
|
||||||
|
return setShowDestroyDriveModal(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDestroyDriveConfirm = async () => {
|
||||||
|
if (!currentDrive) return
|
||||||
|
|
||||||
|
setShowDestroyDriveModal(false)
|
||||||
|
|
||||||
|
await handleDestroyDrive(
|
||||||
|
beeApi,
|
||||||
|
fm,
|
||||||
|
currentDrive,
|
||||||
|
() => {
|
||||||
|
setShowDestroyDriveModal(false)
|
||||||
|
},
|
||||||
|
e => {
|
||||||
|
setErrorMessage?.(`Error destroying drive: ${currentDrive.name}: ${e}`)
|
||||||
|
setShowError(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUploadClose = (name: string) => {
|
||||||
|
const row = uploadItems.find(i => i.name === name)
|
||||||
|
|
||||||
|
if (row?.status === TransferStatus.Uploading) {
|
||||||
|
setPendingCancelUpload(name)
|
||||||
|
} else {
|
||||||
|
cancelOrDismissUpload(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateContextMenuPosition = () => {
|
||||||
|
const menu = contextRef.current
|
||||||
|
const body = bodyRef.current
|
||||||
|
|
||||||
|
if (!menu) return
|
||||||
|
|
||||||
|
const rect = menu.getBoundingClientRect()
|
||||||
|
const containerRect = body?.getBoundingClientRect() ?? null
|
||||||
|
|
||||||
|
const { safePos: sp, dropDir: dd } = computeContextMenuPosition({
|
||||||
|
clickPos: pos,
|
||||||
|
menuRect: rect,
|
||||||
|
viewport: { w: window.innerWidth, h: window.innerHeight },
|
||||||
|
margin: 8,
|
||||||
|
containerRect,
|
||||||
|
})
|
||||||
|
|
||||||
|
const topLeft = containerRect
|
||||||
|
? { x: Math.round(sp.x - containerRect.left), y: Math.round(sp.y - containerRect.top + 2) }
|
||||||
|
: sp
|
||||||
|
|
||||||
|
setSafePos(topLeft)
|
||||||
|
setDropDir(dd)
|
||||||
|
rafIdRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!showContext) return
|
||||||
|
|
||||||
|
if (rafIdRef.current) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
rafIdRef.current = requestAnimationFrame(updateContextMenuPosition)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafIdRef.current) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current)
|
||||||
|
rafIdRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [showContext, pos, contextRef])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let title = currentDrive?.name || ''
|
||||||
|
|
||||||
|
if (isSearchMode) {
|
||||||
|
title = 'Search results'
|
||||||
|
|
||||||
|
if (scope === 'selected' && currentDrive?.name) {
|
||||||
|
title += ` — ${currentDrive.name}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setActualItemView?.(title)
|
||||||
|
}, [isSearchMode, scope, currentDrive, setActualItemView])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSearchMode) {
|
||||||
|
bulk.clearAll()
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isSearchMode])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false
|
||||||
|
|
||||||
|
if (rafIdRef.current) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const doRefresh = async () => {
|
||||||
|
handleCloseContext()
|
||||||
|
|
||||||
|
if (isRefreshing) return
|
||||||
|
|
||||||
|
setIsRefreshing(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await resync()
|
||||||
|
} finally {
|
||||||
|
safeSetState(isMountedRef, setIsRefreshing)(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const showDeleteModal = showBulkDeleteModal && bulk.selectedFiles.length > 0 && view === ViewType.File
|
||||||
|
const showDragOverlay = isDragging && Boolean(currentDrive)
|
||||||
|
const fileCountText = bulk.selectedFiles.length === 1 ? 'file' : 'files'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{conflictPortal}
|
||||||
|
|
||||||
|
<input type="file" ref={legacyUploadRef} style={{ display: 'none' }} onChange={onFileSelected} multiple />
|
||||||
|
<input type="file" ref={bulk.fileInputRef} style={{ display: 'none' }} onChange={onFileSelected} multiple />
|
||||||
|
|
||||||
|
<div className="fm-file-browser-container" data-search-mode={isSearchMode ? 'true' : 'false'}>
|
||||||
|
<FileBrowserTopBar onOpenMenu={openTopbarMenu} canOpen={!isSearchMode && Boolean(currentDrive)} />
|
||||||
|
<div
|
||||||
|
className="fm-file-browser-content"
|
||||||
|
data-search-mode={isSearchMode ? 'true' : 'false'}
|
||||||
|
ref={contentRef}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onPaste={handlePaste}
|
||||||
|
onContextMenu={handleFileBrowserContextMenu}
|
||||||
|
>
|
||||||
|
<FileBrowserHeader
|
||||||
|
key={isSearchMode ? 'hdr-search' : 'hdr-normal'}
|
||||||
|
isSearchMode={isSearchMode}
|
||||||
|
bulk={bulk}
|
||||||
|
sortKey={sort.key}
|
||||||
|
sortDir={sort.dir}
|
||||||
|
onSortName={() => toggle(SortKey.Name)}
|
||||||
|
onSortSize={() => toggle(SortKey.Size)}
|
||||||
|
onSortDate={() => toggle(SortKey.Timestamp)}
|
||||||
|
onSortDrive={() => toggle(SortKey.Drive)}
|
||||||
|
onClearSort={reset}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="fm-file-browser-content-body"
|
||||||
|
ref={bodyRef}
|
||||||
|
onMouseDown={e => {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
handleCloseContext()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileBrowserContent
|
||||||
|
key={isSearchMode ? `content-search` : `content-${currentDrive?.id.toString() ?? 'none'}`}
|
||||||
|
listToRender={sorted}
|
||||||
|
drives={drives}
|
||||||
|
currentDrive={currentDrive || null}
|
||||||
|
view={view}
|
||||||
|
isSearchMode={isSearchMode}
|
||||||
|
trackDownload={trackDownload}
|
||||||
|
selectedIds={bulk.selectedIds}
|
||||||
|
onToggleSelected={bulk.toggleOne}
|
||||||
|
bulkSelectedCount={bulk.selectedCount}
|
||||||
|
onBulk={{
|
||||||
|
download: () => bulk.bulkDownload(bulk.selectedFiles),
|
||||||
|
restore: () => bulk.bulkRestore(bulk.selectedFiles),
|
||||||
|
forget: () => bulk.bulkForget(bulk.selectedFiles),
|
||||||
|
destroy: () => setShowDestroyDriveModal(true),
|
||||||
|
delete: () => setShowBulkDeleteModal(true),
|
||||||
|
}}
|
||||||
|
setErrorMessage={setErrorMessage}
|
||||||
|
/>
|
||||||
|
{showError && (
|
||||||
|
<ErrorModal
|
||||||
|
label={errorMessage || 'An error occurred'}
|
||||||
|
onClick={() => {
|
||||||
|
setShowError(false)
|
||||||
|
setErrorMessage?.('')
|
||||||
|
|
||||||
|
return
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showContext && (
|
||||||
|
<div
|
||||||
|
ref={contextRef}
|
||||||
|
className="fm-file-browser-context-menu fm-context-menu"
|
||||||
|
style={{ top: safePos.y, left: safePos.x }}
|
||||||
|
data-drop={dropDir}
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<FileBrowserContextMenu
|
||||||
|
drives={drives}
|
||||||
|
view={view}
|
||||||
|
selectedFilesCount={bulk.selectedFiles.length}
|
||||||
|
onRefresh={doRefresh}
|
||||||
|
enableRefresh={Boolean(fm?.adminStamp)}
|
||||||
|
onUploadFile={onContextUploadFile}
|
||||||
|
onBulkDownload={() => bulk.bulkDownload(bulk.selectedFiles)}
|
||||||
|
onBulkRestore={() => bulk.bulkRestore(bulk.selectedFiles)}
|
||||||
|
onBulkDelete={() => setShowBulkDeleteModal(true)}
|
||||||
|
onBulkDestroy={() => setShowDestroyDriveModal(true)}
|
||||||
|
onBulkForget={() => bulk.bulkForget(bulk.selectedFiles)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDragOverlay && (
|
||||||
|
<div
|
||||||
|
className="fm-drag-overlay"
|
||||||
|
onDragOver={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
}}
|
||||||
|
onDrop={handleOverlayDrop}
|
||||||
|
>
|
||||||
|
<div className="fm-drag-text">Drop file(s) to upload</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FileBrowserModals
|
||||||
|
showDeleteModal={showDeleteModal}
|
||||||
|
selectedFiles={bulk.selectedFiles}
|
||||||
|
fileCountText={fileCountText}
|
||||||
|
currentDrive={currentDrive || null}
|
||||||
|
confirmBulkForget={confirmBulkForget}
|
||||||
|
showDestroyDriveModal={showDestroyDriveModal}
|
||||||
|
pendingCancelUpload={pendingCancelUpload}
|
||||||
|
onDeleteCancel={() => setShowBulkDeleteModal(false)}
|
||||||
|
onDeleteProceed={handleDeleteModalProceed}
|
||||||
|
onForgetConfirm={async () => {
|
||||||
|
await bulk.bulkForget(bulk.selectedFiles)
|
||||||
|
setConfirmBulkForget(false)
|
||||||
|
}}
|
||||||
|
onForgetCancel={() => setConfirmBulkForget(false)}
|
||||||
|
onDestroyCancel={() => setShowDestroyDriveModal(false)}
|
||||||
|
onDestroyConfirm={handleDestroyDriveConfirm}
|
||||||
|
onCancelUploadConfirm={() => {
|
||||||
|
if (pendingCancelUpload) {
|
||||||
|
cancelOrDismissUpload(pendingCancelUpload)
|
||||||
|
setPendingCancelUpload(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancelUploadCancel={() => setPendingCancelUpload(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isRefreshing && (
|
||||||
|
<div className="fm-refresh-overlay" aria-busy="true" aria-live="polite">
|
||||||
|
<div className="fm-refresh-content">
|
||||||
|
<div className="fm-mini-spinner" role="status" aria-label="Syncing…" />
|
||||||
|
<span className="fm-refresh-text">Syncing latest files…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-file-browser-footer">
|
||||||
|
<FileProgressNotification
|
||||||
|
label="Uploading files"
|
||||||
|
type={FileTransferType.Upload}
|
||||||
|
open={isUploading}
|
||||||
|
count={uploadItems.length}
|
||||||
|
items={uploadItems}
|
||||||
|
onRowClose={handleUploadClose}
|
||||||
|
onCloseAll={() => dismissAllUploads()}
|
||||||
|
/>
|
||||||
|
<FileProgressNotification
|
||||||
|
label="Downloading files"
|
||||||
|
type={FileTransferType.Download}
|
||||||
|
open={isDownloading}
|
||||||
|
count={downloadItems.length}
|
||||||
|
items={downloadItems}
|
||||||
|
onRowClose={name => cancelOrDismissDownload(name)}
|
||||||
|
onCloseAll={() => dismissAllDownloads()}
|
||||||
|
/>
|
||||||
|
<NotificationBar setErrorMessage={setErrorMessage} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
import { ReactElement, useCallback } from 'react'
|
||||||
|
import { FileItem } from '../FileItem/FileItem'
|
||||||
|
import { FileInfo, DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||||
|
import { DownloadProgress, TrackDownloadProps, ViewType } from '../../../constants/transfers'
|
||||||
|
import { getFileId } from '../../../utils/common'
|
||||||
|
|
||||||
|
interface FileBrowserContentProps {
|
||||||
|
listToRender: FileInfo[]
|
||||||
|
drives: DriveInfo[]
|
||||||
|
currentDrive: DriveInfo | null
|
||||||
|
view: ViewType
|
||||||
|
isSearchMode: boolean
|
||||||
|
trackDownload: (props: TrackDownloadProps) => (dp: DownloadProgress) => void
|
||||||
|
selectedIds?: Set<string>
|
||||||
|
onToggleSelected?: (fi: FileInfo, checked: boolean) => void
|
||||||
|
bulkSelectedCount?: number
|
||||||
|
onBulk: {
|
||||||
|
download?: () => void
|
||||||
|
restore?: () => void
|
||||||
|
forget?: () => void
|
||||||
|
destroy?: () => void
|
||||||
|
delete?: () => void
|
||||||
|
}
|
||||||
|
setErrorMessage?: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileBrowserContent({
|
||||||
|
listToRender,
|
||||||
|
drives,
|
||||||
|
currentDrive,
|
||||||
|
view,
|
||||||
|
isSearchMode,
|
||||||
|
trackDownload,
|
||||||
|
selectedIds,
|
||||||
|
onToggleSelected,
|
||||||
|
bulkSelectedCount,
|
||||||
|
onBulk,
|
||||||
|
setErrorMessage,
|
||||||
|
}: FileBrowserContentProps): ReactElement {
|
||||||
|
const renderEmptyState = useCallback((): ReactElement => {
|
||||||
|
if (drives.length === 0) {
|
||||||
|
return <div className="fm-drop-hint">Create a drive to start using the file manager</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentDrive) {
|
||||||
|
return <div className="fm-drop-hint">Select a drive to upload or view its files</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === ViewType.Trash) {
|
||||||
|
return (
|
||||||
|
<div className="fm-drop-hint">
|
||||||
|
Files from "{currentDrive?.name}" that are trashed can be viewed here
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="fm-drop-hint">Drag & drop files here into "{currentDrive?.name}"</div>
|
||||||
|
}, [drives, currentDrive, view])
|
||||||
|
|
||||||
|
const renderFileList = useCallback(
|
||||||
|
(filesToRender: FileInfo[], showDriveColumn = false): ReactElement[] => {
|
||||||
|
return filesToRender
|
||||||
|
.map(fi => {
|
||||||
|
const drive = drives.find(d => d.id.toString() === fi.driveId.toString())
|
||||||
|
|
||||||
|
return drive ? { fi, driveName: drive.name } : null
|
||||||
|
})
|
||||||
|
.filter((item): item is { fi: FileInfo; driveName: string } => item !== null)
|
||||||
|
.map(({ fi, driveName }) => {
|
||||||
|
const key = `${getFileId(fi)}::${fi.version ?? ''}::${showDriveColumn ? 'search' : 'normal'}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileItem
|
||||||
|
key={key}
|
||||||
|
fileInfo={fi}
|
||||||
|
onDownload={trackDownload}
|
||||||
|
showDriveColumn={showDriveColumn}
|
||||||
|
driveName={driveName}
|
||||||
|
selected={Boolean(selectedIds?.has(getFileId(fi)))}
|
||||||
|
onToggleSelected={onToggleSelected}
|
||||||
|
bulkSelectedCount={bulkSelectedCount}
|
||||||
|
onBulk={onBulk}
|
||||||
|
setErrorMessage={setErrorMessage}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[trackDownload, drives, selectedIds, onToggleSelected, bulkSelectedCount, onBulk, setErrorMessage],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (drives.length === 0) {
|
||||||
|
return renderEmptyState()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSearchMode) {
|
||||||
|
if (!currentDrive) {
|
||||||
|
return <div className="fm-drop-hint">Select a drive to upload or view its files</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === ViewType.Expired) {
|
||||||
|
return (
|
||||||
|
<div className="fm-drop-hint">
|
||||||
|
The stamp for drive "{currentDrive?.name}" is expired, no files can be found
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listToRender.length === 0) {
|
||||||
|
if (view === ViewType.Trash) {
|
||||||
|
return (
|
||||||
|
<div className="fm-drop-hint">
|
||||||
|
Files from "{currentDrive?.name}" that are trashed can be viewed here
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="fm-drop-hint">Drag & drop files here into "{currentDrive?.name}"</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{renderFileList(listToRender)}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listToRender.length === 0) {
|
||||||
|
return <div className="fm-drop-hint">No results found.</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{renderFileList(listToRender, true)}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileBrowserContent
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
import { ReactElement } from 'react'
|
||||||
|
import DownIcon from 'remixicon-react/ArrowDownSLineIcon'
|
||||||
|
import { useBulkActions } from '../../../hooks/useBulkActions'
|
||||||
|
import { SortDir, SortKey } from '../../../hooks/useSorting'
|
||||||
|
import { capitalizeFirstLetter } from '../../../../../../src/modules/filemanager/utils/common'
|
||||||
|
|
||||||
|
interface FileBrowserHeaderProps {
|
||||||
|
isSearchMode: boolean
|
||||||
|
bulk: ReturnType<typeof useBulkActions>
|
||||||
|
sortKey: SortKey
|
||||||
|
sortDir: SortDir
|
||||||
|
onSortName: () => void
|
||||||
|
onSortSize: () => void
|
||||||
|
onSortDate: () => void
|
||||||
|
onSortDrive: () => void
|
||||||
|
onClearSort: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AriaSortValue {
|
||||||
|
Ascending = 'ascending',
|
||||||
|
Descending = 'descending',
|
||||||
|
None = 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Arrow = ({ active, dir }: { active: boolean; dir: SortDir }) => {
|
||||||
|
let title: string | undefined
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
const sortValue = dir === SortDir.Asc ? AriaSortValue.Ascending : AriaSortValue.Descending
|
||||||
|
title = capitalizeFirstLetter(sortValue)
|
||||||
|
} else {
|
||||||
|
title = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={'fm-file-browser-content-header-item-icon' + (active ? '' : ' is-inactive')}
|
||||||
|
aria-hidden={title ? 'false' : 'true'}
|
||||||
|
aria-label={title}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<DownIcon size="16px" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderCell({
|
||||||
|
label,
|
||||||
|
isActive,
|
||||||
|
dir,
|
||||||
|
onToggle,
|
||||||
|
onClear,
|
||||||
|
ariaSort,
|
||||||
|
'data-testid': testId,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
isActive: boolean
|
||||||
|
dir: SortDir
|
||||||
|
onToggle: () => void
|
||||||
|
onClear: () => void
|
||||||
|
ariaSort: AriaSortValue
|
||||||
|
'data-testid'?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="fm-header-cell" role="columnheader" aria-sort={ariaSort} data-testid={testId}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fm-header-button"
|
||||||
|
onClick={onToggle}
|
||||||
|
data-dir={isActive ? dir : undefined}
|
||||||
|
aria-label={
|
||||||
|
isActive
|
||||||
|
? `Sort by ${label.toLowerCase()}, currently ${
|
||||||
|
dir === SortDir.Asc ? AriaSortValue.Ascending : AriaSortValue.Descending
|
||||||
|
}`
|
||||||
|
: `Sort by ${label.toLowerCase()}`
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
isActive
|
||||||
|
? `Currently ${capitalizeFirstLetter(
|
||||||
|
dir === SortDir.Asc ? AriaSortValue.Ascending : AriaSortValue.Descending,
|
||||||
|
)}`
|
||||||
|
: 'Click to sort'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
<Arrow active={isActive} dir={dir} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fm-sort-clear"
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
onClear()
|
||||||
|
}}
|
||||||
|
aria-label="Reset sorting to default"
|
||||||
|
title="Clear sorting"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileBrowserHeader({
|
||||||
|
isSearchMode,
|
||||||
|
bulk,
|
||||||
|
sortKey,
|
||||||
|
sortDir,
|
||||||
|
onSortName,
|
||||||
|
onSortSize,
|
||||||
|
onSortDate,
|
||||||
|
onSortDrive,
|
||||||
|
onClearSort,
|
||||||
|
}: FileBrowserHeaderProps): ReactElement {
|
||||||
|
const ariaSort = (thisKey: SortKey): AriaSortValue => {
|
||||||
|
if (sortKey !== thisKey) return AriaSortValue.None
|
||||||
|
|
||||||
|
return sortDir === SortDir.Asc ? AriaSortValue.Ascending : AriaSortValue.Descending
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fm-file-browser-content-header" role="row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={bulk.allChecked}
|
||||||
|
ref={el => {
|
||||||
|
if (el) el.indeterminate = bulk.someChecked
|
||||||
|
}}
|
||||||
|
onChange={e => (e.target.checked ? bulk.selectAll() : bulk.clearAll())}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fm-file-browser-content-header-item fm-name">
|
||||||
|
<HeaderCell
|
||||||
|
label="Name"
|
||||||
|
isActive={sortKey === SortKey.Name}
|
||||||
|
dir={sortDir}
|
||||||
|
onToggle={onSortName}
|
||||||
|
onClear={onClearSort}
|
||||||
|
ariaSort={ariaSort(SortKey.Name)}
|
||||||
|
data-testid="hdr-name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSearchMode && (
|
||||||
|
<div className="fm-file-browser-content-header-item fm-drive">
|
||||||
|
<HeaderCell
|
||||||
|
label="Drive"
|
||||||
|
isActive={sortKey === SortKey.Drive}
|
||||||
|
dir={sortDir}
|
||||||
|
onToggle={onSortDrive}
|
||||||
|
onClear={onClearSort}
|
||||||
|
ariaSort={ariaSort(SortKey.Drive)}
|
||||||
|
data-testid="hdr-drive"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="fm-file-browser-content-header-item fm-size">
|
||||||
|
<HeaderCell
|
||||||
|
label="Size"
|
||||||
|
isActive={sortKey === SortKey.Size}
|
||||||
|
dir={sortDir}
|
||||||
|
onToggle={onSortSize}
|
||||||
|
onClear={onClearSort}
|
||||||
|
ariaSort={ariaSort(SortKey.Size)}
|
||||||
|
data-testid="hdr-size"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-file-browser-content-header-item fm-date-mod">
|
||||||
|
<HeaderCell
|
||||||
|
label="Date mod."
|
||||||
|
isActive={sortKey === SortKey.Timestamp}
|
||||||
|
dir={sortDir}
|
||||||
|
onToggle={onSortDate}
|
||||||
|
onClear={onClearSort}
|
||||||
|
ariaSort={ariaSort(SortKey.Timestamp)}
|
||||||
|
data-testid="hdr-date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
import { ContextMenu } from '../../ContextMenu/ContextMenu'
|
||||||
|
import { ReactElement } from 'react'
|
||||||
|
import '../FileBrowser.scss'
|
||||||
|
import { ViewType } from '../../../constants/transfers'
|
||||||
|
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||||
|
import { Tooltip } from '../../Tooltip/Tooltip'
|
||||||
|
|
||||||
|
interface FileBrowserContextMenuProps {
|
||||||
|
drives: DriveInfo[]
|
||||||
|
view: ViewType
|
||||||
|
selectedFilesCount: number
|
||||||
|
onRefresh: () => void
|
||||||
|
onUploadFile: () => void
|
||||||
|
onBulkDownload: () => void
|
||||||
|
onBulkRestore: () => void
|
||||||
|
onBulkDelete: () => void
|
||||||
|
onBulkDestroy: () => void
|
||||||
|
onBulkForget: () => void
|
||||||
|
enableRefresh?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileBrowserContextMenu({
|
||||||
|
drives,
|
||||||
|
view,
|
||||||
|
selectedFilesCount,
|
||||||
|
onRefresh,
|
||||||
|
onUploadFile,
|
||||||
|
onBulkDownload,
|
||||||
|
onBulkRestore,
|
||||||
|
onBulkDelete,
|
||||||
|
onBulkDestroy,
|
||||||
|
onBulkForget,
|
||||||
|
enableRefresh,
|
||||||
|
}: FileBrowserContextMenuProps): ReactElement {
|
||||||
|
if (drives.length === 0) {
|
||||||
|
if (!enableRefresh) {
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<div className="fm-context-item" onClick={onRefresh}>
|
||||||
|
Refresh
|
||||||
|
</div>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedFilesCount > 1) {
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<div className="fm-context-item" onClick={onBulkDownload}>
|
||||||
|
Download
|
||||||
|
</div>
|
||||||
|
{view === ViewType.File ? (
|
||||||
|
<div className="fm-context-item red" onClick={onBulkDelete}>
|
||||||
|
Delete…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="fm-context-item" onClick={onBulkRestore}>
|
||||||
|
Restore
|
||||||
|
</div>
|
||||||
|
<div className="fm-context-item red" onClick={onBulkDestroy}>
|
||||||
|
Destroy
|
||||||
|
</div>
|
||||||
|
<div className="fm-context-item red" onClick={onBulkForget}>
|
||||||
|
Forget permanently
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view === ViewType.Trash) {
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<div className="fm-context-item" onClick={onRefresh}>
|
||||||
|
Refresh
|
||||||
|
</div>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ContextMenu>
|
||||||
|
<div className="fm-context-item" style={{ display: 'none' }}>
|
||||||
|
New folder
|
||||||
|
</div>
|
||||||
|
<div className="fm-context-item" onClick={onUploadFile}>
|
||||||
|
Upload file(s)
|
||||||
|
</div>
|
||||||
|
<div className="fm-context-item" style={{ display: 'none' }}>
|
||||||
|
Upload folder
|
||||||
|
</div>
|
||||||
|
<div className="fm-context-item-border" />
|
||||||
|
<div
|
||||||
|
className="fm-context-item"
|
||||||
|
role="menuitem"
|
||||||
|
aria-disabled="true"
|
||||||
|
tabIndex={-1}
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={e => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<Tooltip label="Tip: Use ⌘V / Ctrl+V or Browser → Edit → Paste." iconSize="14px" gapPx={6} disableMargin>
|
||||||
|
Paste
|
||||||
|
</Tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="fm-context-item-border" />
|
||||||
|
<div className="fm-context-item" onClick={onRefresh}>
|
||||||
|
Refresh
|
||||||
|
</div>
|
||||||
|
</ContextMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { ReactElement } from 'react'
|
||||||
|
import type { FileInfo, DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||||
|
import { ConfirmModal } from '../ConfirmModal/ConfirmModal'
|
||||||
|
import { DeleteFileModal } from '../DeleteFileModal/DeleteFileModal'
|
||||||
|
import { DestroyDriveModal } from '../DestroyDriveModal/DestroyDriveModal'
|
||||||
|
import { FileAction } from '../../constants/transfers'
|
||||||
|
|
||||||
|
interface FileBrowserModalsProps {
|
||||||
|
showDeleteModal: boolean
|
||||||
|
selectedFiles: FileInfo[]
|
||||||
|
fileCountText: string
|
||||||
|
currentDrive: DriveInfo | null
|
||||||
|
confirmBulkForget: boolean
|
||||||
|
showDestroyDriveModal: boolean
|
||||||
|
pendingCancelUpload: string | null
|
||||||
|
onDeleteCancel: () => void
|
||||||
|
onDeleteProceed: (action: FileAction) => void
|
||||||
|
onForgetConfirm: () => Promise<void>
|
||||||
|
onForgetCancel: () => void
|
||||||
|
onDestroyCancel: () => void
|
||||||
|
onDestroyConfirm: () => Promise<void>
|
||||||
|
onCancelUploadConfirm: () => void
|
||||||
|
onCancelUploadCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileBrowserModals({
|
||||||
|
showDeleteModal,
|
||||||
|
selectedFiles,
|
||||||
|
fileCountText,
|
||||||
|
currentDrive,
|
||||||
|
confirmBulkForget,
|
||||||
|
showDestroyDriveModal,
|
||||||
|
pendingCancelUpload,
|
||||||
|
onDeleteCancel,
|
||||||
|
onDeleteProceed,
|
||||||
|
onForgetConfirm,
|
||||||
|
onForgetCancel,
|
||||||
|
onDestroyCancel,
|
||||||
|
onDestroyConfirm,
|
||||||
|
onCancelUploadConfirm,
|
||||||
|
onCancelUploadCancel,
|
||||||
|
}: FileBrowserModalsProps): ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showDeleteModal && (
|
||||||
|
<DeleteFileModal
|
||||||
|
names={selectedFiles.map(f => f.name)}
|
||||||
|
currentDriveName={currentDrive?.name}
|
||||||
|
onCancelClick={onDeleteCancel}
|
||||||
|
onProceed={onDeleteProceed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmBulkForget && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Forget permanently?"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
This removes <b>{selectedFiles.length}</b> {fileCountText} from your view.
|
||||||
|
<br />
|
||||||
|
The data remains on Swarm until the drive expires.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="Forget"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
onConfirm={onForgetConfirm}
|
||||||
|
onCancel={onForgetCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDestroyDriveModal && currentDrive && (
|
||||||
|
<DestroyDriveModal drive={currentDrive} onCancelClick={onDestroyCancel} doDestroy={onDestroyConfirm} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pendingCancelUpload && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Cancel upload?"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
Stopping now will cancel the network request. Data already transmitted cannot be reverted.{' '}
|
||||||
|
<b>We will try our best to clean up the transmitted data.</b>
|
||||||
|
<br />
|
||||||
|
To remove any (remaining) cancelled items from your browser view later, use{' '}
|
||||||
|
<i>Right-click → Delete → Forget</i>.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="Cancel upload"
|
||||||
|
cancelLabel="Keep uploading"
|
||||||
|
onConfirm={onCancelUploadConfirm}
|
||||||
|
onCancel={onCancelUploadCancel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
.fm-file-browser-top-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
background-color: rgb(237, 129, 49);
|
||||||
|
padding: 12px;
|
||||||
|
gap: 8px;
|
||||||
|
color: rgb(255, 255, 255);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-container[data-search-mode="true"] .fm-file-browser-top-bar {
|
||||||
|
background-color: rgb(37, 99, 235);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-top-bar__title {
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-topbar-kebab {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: rgb(255, 255, 255);
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color .12s ease, opacity .12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-topbar-kebab:hover,
|
||||||
|
.fm-topbar-kebab:focus-visible {
|
||||||
|
background: rgba(255,255,255,.12);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-topbar-kebab:active {
|
||||||
|
background: rgba(255,255,255,.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-topbar-kebab:disabled {
|
||||||
|
opacity: .5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import { ReactElement } from 'react'
|
||||||
|
import './FileBrowserTopBar.scss'
|
||||||
|
import { useView } from '../../../../../pages/filemanager/ViewContext'
|
||||||
|
import { ViewType } from '../../../constants/transfers'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onOpenMenu?: (anchorEl: HTMLElement) => void
|
||||||
|
canOpen?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileBrowserTopBar({ onOpenMenu, canOpen = true }: Props): ReactElement {
|
||||||
|
const { view, actualItemView } = useView()
|
||||||
|
|
||||||
|
const viewText = view === ViewType.Trash ? ' Trash' : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fm-file-browser-top-bar">
|
||||||
|
<div className="fm-file-browser-top-bar__title">
|
||||||
|
{actualItemView}
|
||||||
|
{viewText}
|
||||||
|
</div>
|
||||||
|
{canOpen && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fm-topbar-kebab"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-label="More actions"
|
||||||
|
onClick={e => onOpenMenu?.(e.currentTarget)}
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
.fm-file-item-content {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
padding: 12px;
|
||||||
|
|
||||||
|
& input {
|
||||||
|
margin: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover { background-color: #d1d1d1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-content[data-search-mode='false'] .fm-file-item-content {
|
||||||
|
grid-template-columns: 32px 2fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-content[data-search-mode='true'] .fm-file-item-content {
|
||||||
|
grid-template-columns: 32px 2fr 1.1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-item-content-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: start;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
& input {
|
||||||
|
accent-color: var(--fm-accent, rgb(237, 129, 49));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-item-content-item.fm-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-item-name,
|
||||||
|
.fm-file-item-content-item.fm-name {
|
||||||
|
font-weight: 400;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
& svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
color: var(--fm-accent, #ed8131);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-item-content-item.fm-drive {
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 240px;
|
||||||
|
flex: 0 0 180px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.fm-drive-name { opacity: 0.9; }
|
||||||
|
|
||||||
|
.fm-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fm-pill--active {
|
||||||
|
background: #e0f2fe;
|
||||||
|
color: #075985;
|
||||||
|
border-color: #bae6fd;
|
||||||
|
}
|
||||||
|
.fm-pill--trash {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #b91c1c;
|
||||||
|
border-color: #fecaca;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-browser-content[data-search-mode='true'] .fm-file-item-content::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 0; bottom: 0;
|
||||||
|
width: 3px;
|
||||||
|
background: var(--fm-accent, #2563eb);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-item-context-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-item-context-menu[data-drop='up'] {
|
||||||
|
transform-origin: bottom left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-item-context-menu[data-drop='up'] .caret {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
bottom: -6px;
|
||||||
|
top: auto;
|
||||||
|
}
|
||||||
@@ -0,0 +1,599 @@
|
|||||||
|
import { ReactElement, useContext, useLayoutEffect, useMemo, useState, useRef, useEffect, useCallback } from 'react'
|
||||||
|
import './FileItem.scss'
|
||||||
|
import { GetIconElement } from '../../../utils/GetIconElement'
|
||||||
|
import { ContextMenu } from '../../ContextMenu/ContextMenu'
|
||||||
|
import { useContextMenu } from '../../../hooks/useContextMenu'
|
||||||
|
import { Context as SettingsContext } from '../../../../../providers/Settings'
|
||||||
|
import { ActionTag, DownloadProgress, TrackDownloadProps, ViewType } from '../../../constants/transfers'
|
||||||
|
import { GetInfoModal } from '../../GetInfoModal/GetInfoModal'
|
||||||
|
import { VersionHistoryModal } from '../../VersionHistoryModal/VersionHistoryModal'
|
||||||
|
import { DeleteFileModal } from '../../DeleteFileModal/DeleteFileModal'
|
||||||
|
import { RenameFileModal } from '../../RenameFileModal/RenameFileModal'
|
||||||
|
import { buildGetInfoGroups } from '../../../utils/infoGroups'
|
||||||
|
import type { FilePropertyGroup } from '../../../utils/infoGroups'
|
||||||
|
import { useView } from '../../../../../pages/filemanager/ViewContext'
|
||||||
|
import type { DriveInfo, FileInfo } from '@solarpunkltd/file-manager-lib'
|
||||||
|
import { Context as FMContext } from '../../../../../providers/FileManager'
|
||||||
|
import { DestroyDriveModal } from '../../DestroyDriveModal/DestroyDriveModal'
|
||||||
|
import { ConfirmModal } from '../../ConfirmModal/ConfirmModal'
|
||||||
|
|
||||||
|
import { capitalizeFirstLetter, Dir, formatBytes, isTrashed, safeSetState } from '../../../utils/common'
|
||||||
|
import { FileAction } from '../../../constants/transfers'
|
||||||
|
import { startDownloadingQueue, createDownloadAbort } from '../../../utils/download'
|
||||||
|
import { computeContextMenuPosition } from '../../../utils/ui'
|
||||||
|
import { getUsableStamps, handleDestroyDrive } from '../../../utils/bee'
|
||||||
|
import { PostageBatch } from '@ethersphere/bee-js'
|
||||||
|
|
||||||
|
interface FileItemProps {
|
||||||
|
fileInfo: FileInfo
|
||||||
|
onDownload: (props: TrackDownloadProps) => (dp: DownloadProgress) => void
|
||||||
|
showDriveColumn?: boolean
|
||||||
|
driveName: string
|
||||||
|
selected?: boolean
|
||||||
|
onToggleSelected?: (fi: FileInfo, checked: boolean) => void
|
||||||
|
bulkSelectedCount?: number
|
||||||
|
onBulk: {
|
||||||
|
download?: () => void
|
||||||
|
restore?: () => void
|
||||||
|
forget?: () => void
|
||||||
|
destroy?: () => void
|
||||||
|
delete?: () => void
|
||||||
|
}
|
||||||
|
setErrorMessage?: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileItem({
|
||||||
|
fileInfo,
|
||||||
|
onDownload,
|
||||||
|
showDriveColumn,
|
||||||
|
driveName,
|
||||||
|
selected = false,
|
||||||
|
onToggleSelected,
|
||||||
|
bulkSelectedCount,
|
||||||
|
onBulk,
|
||||||
|
setErrorMessage,
|
||||||
|
}: FileItemProps): ReactElement {
|
||||||
|
const { showContext, pos, contextRef, handleContextMenu, handleCloseContext } = useContextMenu<HTMLDivElement>()
|
||||||
|
const { fm, currentDrive, files, drives, setShowError } = useContext(FMContext)
|
||||||
|
const { beeApi } = useContext(SettingsContext)
|
||||||
|
const { view } = useView()
|
||||||
|
|
||||||
|
const [driveStamp, setDriveStamp] = useState<PostageBatch | undefined>(undefined)
|
||||||
|
const [safePos, setSafePos] = useState(pos)
|
||||||
|
const [dropDir, setDropDir] = useState<Dir>(Dir.Down)
|
||||||
|
const [showGetInfoModal, setShowGetInfoModal] = useState(false)
|
||||||
|
const [infoGroups, setInfoGroups] = useState<FilePropertyGroup[] | null>(null)
|
||||||
|
const [showVersionHistory, setShowVersionHistory] = useState(false)
|
||||||
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||||
|
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||||
|
const [showDestroyDriveModal, setShowDestroyDriveModal] = useState(false)
|
||||||
|
const [destroyDrive, setDestroyDrive] = useState<DriveInfo | null>(null)
|
||||||
|
const [confirmForget, setConfirmForget] = useState(false)
|
||||||
|
|
||||||
|
const isMountedRef = useRef(true)
|
||||||
|
const rafIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const size = formatBytes(fileInfo.customMetadata?.size)
|
||||||
|
const dateMod = new Date(fileInfo.timestamp || 0).toLocaleDateString()
|
||||||
|
const isTrashedFile = isTrashed(fileInfo)
|
||||||
|
const statusLabel = isTrashedFile ? 'Trash' : 'Active'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true
|
||||||
|
|
||||||
|
const getStamps = async () => {
|
||||||
|
const stamps = await getUsableStamps(beeApi)
|
||||||
|
const driveStamp = stamps.find(s =>
|
||||||
|
drives.some(d => d.batchId.toString() === s.batchID.toString() && d.id === fileInfo.driveId),
|
||||||
|
)
|
||||||
|
|
||||||
|
safeSetState(isMountedRef, setDriveStamp)(driveStamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
getStamps()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false
|
||||||
|
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [beeApi, drives, fileInfo.driveId])
|
||||||
|
|
||||||
|
const openGetInfo = useCallback(async () => {
|
||||||
|
if (!fm || !isMountedRef.current) return
|
||||||
|
|
||||||
|
const groups = await buildGetInfoGroups(fm, fileInfo, driveName, driveStamp)
|
||||||
|
setInfoGroups(groups)
|
||||||
|
setShowGetInfoModal(true)
|
||||||
|
}, [fm, fileInfo, driveName, driveStamp])
|
||||||
|
|
||||||
|
const takenNames = useMemo(() => {
|
||||||
|
if (!currentDrive || !files) return new Set<string>()
|
||||||
|
const wanted = currentDrive.batchId.toString()
|
||||||
|
const sameDrive = files.filter(fi => fi.batchId.toString() === wanted)
|
||||||
|
const out = new Set<string>()
|
||||||
|
sameDrive.forEach(fi => {
|
||||||
|
if (fi.topic.toString() !== fileInfo.topic.toString()) out.add(fi.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
return out
|
||||||
|
}, [files, currentDrive, fileInfo.topic])
|
||||||
|
|
||||||
|
const handleItemContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.shiftKey) return
|
||||||
|
handleContextMenu(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: handleOpen shall only be available for images, videos etc... -> do not download 10GB into memory
|
||||||
|
const handleDownload = useCallback(
|
||||||
|
async (isNewWindow?: boolean) => {
|
||||||
|
if (!fm || !beeApi) return
|
||||||
|
|
||||||
|
handleCloseContext()
|
||||||
|
|
||||||
|
const rawSize = fileInfo.customMetadata?.size
|
||||||
|
const expectedSize = rawSize ? Number(rawSize) : undefined
|
||||||
|
|
||||||
|
createDownloadAbort(fileInfo.name)
|
||||||
|
|
||||||
|
await startDownloadingQueue(
|
||||||
|
fm,
|
||||||
|
[fileInfo],
|
||||||
|
[onDownload({ name: fileInfo.name, size: formatBytes(rawSize), expectedSize })],
|
||||||
|
isNewWindow,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
[handleCloseContext, fm, beeApi, fileInfo, onDownload],
|
||||||
|
)
|
||||||
|
// TODO: refactor doTrash, doRecover, doForget to a single function with action param and remove switch case mybe
|
||||||
|
const doTrash = useCallback(async () => {
|
||||||
|
if (!fm) return
|
||||||
|
|
||||||
|
const withMeta: FileInfo = {
|
||||||
|
...fileInfo,
|
||||||
|
customMetadata: {
|
||||||
|
...(fileInfo.customMetadata ?? {}),
|
||||||
|
lifecycle: capitalizeFirstLetter(ActionTag.Trashed),
|
||||||
|
lifecycleAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await fm.trashFile(withMeta)
|
||||||
|
}, [fm, fileInfo])
|
||||||
|
|
||||||
|
const doRecover = useCallback(async () => {
|
||||||
|
if (!fm) return
|
||||||
|
|
||||||
|
const withMeta: FileInfo = {
|
||||||
|
...fileInfo,
|
||||||
|
customMetadata: {
|
||||||
|
...(fileInfo.customMetadata ?? {}),
|
||||||
|
lifecycle: capitalizeFirstLetter(ActionTag.Recovered),
|
||||||
|
lifecycleAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await fm.recoverFile(withMeta)
|
||||||
|
}, [fm, fileInfo])
|
||||||
|
|
||||||
|
const doForget = useCallback(async () => {
|
||||||
|
if (!fm) return
|
||||||
|
|
||||||
|
await fm.forgetFile(fileInfo)
|
||||||
|
}, [fm, fileInfo])
|
||||||
|
|
||||||
|
const showDestroyDrive = useCallback(() => {
|
||||||
|
setDestroyDrive(currentDrive || null)
|
||||||
|
setShowDestroyDriveModal(true)
|
||||||
|
}, [currentDrive])
|
||||||
|
|
||||||
|
const doRename = useCallback(
|
||||||
|
async (newName: string) => {
|
||||||
|
if (!fm || !currentDrive) return
|
||||||
|
|
||||||
|
if (takenNames.has(newName)) throw new Error('name-taken')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fm.upload(
|
||||||
|
currentDrive,
|
||||||
|
{
|
||||||
|
name: newName,
|
||||||
|
topic: fileInfo.topic,
|
||||||
|
file: {
|
||||||
|
reference: fileInfo.file.reference,
|
||||||
|
historyRef: fileInfo.file.historyRef,
|
||||||
|
},
|
||||||
|
customMetadata: fileInfo.customMetadata,
|
||||||
|
files: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
actHistoryAddress: fileInfo.file.historyRef,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
setErrorMessage?.(`Error renaming file ${fileInfo.name}`)
|
||||||
|
setShowError(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
[fm, currentDrive, fileInfo, takenNames, setErrorMessage, setShowError],
|
||||||
|
)
|
||||||
|
|
||||||
|
const MenuItem = ({
|
||||||
|
disabled,
|
||||||
|
danger,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
disabled?: boolean
|
||||||
|
danger?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
children: React.ReactNode
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
className={`fm-context-item${danger ? ' red' : ''}`}
|
||||||
|
aria-disabled={disabled ? 'true' : 'false'}
|
||||||
|
style={disabled ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
|
||||||
|
onClick={disabled ? undefined : onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const isBulk = (bulkSelectedCount ?? 0) > 1
|
||||||
|
|
||||||
|
const renderContextMenuItems = useCallback(() => {
|
||||||
|
const viewItem = (
|
||||||
|
<MenuItem disabled={isBulk} onClick={() => handleDownload(true)}>
|
||||||
|
View / Open
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
const downloadItem = isBulk ? (
|
||||||
|
<MenuItem onClick={onBulk.download}>Download</MenuItem>
|
||||||
|
) : (
|
||||||
|
<MenuItem onClick={() => handleDownload(false)}>Download</MenuItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
const getInfoItem = (
|
||||||
|
<MenuItem
|
||||||
|
disabled={isBulk}
|
||||||
|
onClick={() => {
|
||||||
|
handleCloseContext()
|
||||||
|
openGetInfo()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Get info
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (view === ViewType.File) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{viewItem}
|
||||||
|
{downloadItem}
|
||||||
|
<MenuItem
|
||||||
|
disabled={isBulk}
|
||||||
|
onClick={() => {
|
||||||
|
handleCloseContext()
|
||||||
|
setShowRenameModal(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</MenuItem>
|
||||||
|
<div className="fm-context-item-border" />
|
||||||
|
<MenuItem
|
||||||
|
disabled={isBulk}
|
||||||
|
onClick={() => {
|
||||||
|
handleCloseContext()
|
||||||
|
setShowVersionHistory(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Version history
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
handleCloseContext()
|
||||||
|
|
||||||
|
if (isBulk) onBulk.delete?.()
|
||||||
|
else setShowDeleteModal(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</MenuItem>
|
||||||
|
<div className="fm-context-item-border" />
|
||||||
|
{getInfoItem}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{viewItem}
|
||||||
|
{downloadItem}
|
||||||
|
<div className="fm-context-item-border" />
|
||||||
|
{isBulk ? (
|
||||||
|
<>
|
||||||
|
<MenuItem danger onClick={onBulk.restore}>
|
||||||
|
Restore
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem danger onClick={onBulk.destroy}>
|
||||||
|
Destroy
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem danger onClick={onBulk.forget}>
|
||||||
|
Forget permanently
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
handleCloseContext()
|
||||||
|
doRecover()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Restore
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
handleCloseContext()
|
||||||
|
|
||||||
|
const parentDrive = drives.find(d => d.id.toString() === fileInfo.driveId.toString())
|
||||||
|
|
||||||
|
if (parentDrive) {
|
||||||
|
setDestroyDrive(parentDrive)
|
||||||
|
setShowDestroyDriveModal(true)
|
||||||
|
} else if (currentDrive) {
|
||||||
|
setDestroyDrive(currentDrive)
|
||||||
|
setShowDestroyDriveModal(true)
|
||||||
|
} else {
|
||||||
|
setErrorMessage?.('Unable to resolve drive for this file.')
|
||||||
|
setShowError(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Destroy
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
handleCloseContext()
|
||||||
|
setConfirmForget(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Forget permanently
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="fm-context-item-border" />
|
||||||
|
{getInfoItem}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
isBulk,
|
||||||
|
view,
|
||||||
|
handleDownload,
|
||||||
|
handleCloseContext,
|
||||||
|
openGetInfo,
|
||||||
|
doRecover,
|
||||||
|
onBulk,
|
||||||
|
currentDrive,
|
||||||
|
drives,
|
||||||
|
fileInfo.driveId,
|
||||||
|
setErrorMessage,
|
||||||
|
setShowError,
|
||||||
|
])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!showContext) return
|
||||||
|
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
rafIdRef.current = requestAnimationFrame(() => {
|
||||||
|
const menu = contextRef.current
|
||||||
|
|
||||||
|
if (!menu) return
|
||||||
|
|
||||||
|
const menuRect = menu.getBoundingClientRect()
|
||||||
|
const containerEl = (menu.offsetParent as HTMLElement) ?? null
|
||||||
|
const containerRect = containerEl?.getBoundingClientRect() ?? null
|
||||||
|
|
||||||
|
const { safePos: s, dropDir: d } = computeContextMenuPosition({
|
||||||
|
clickPos: pos,
|
||||||
|
menuRect: menuRect,
|
||||||
|
viewport: { w: window.innerWidth, h: window.innerHeight },
|
||||||
|
margin: 8,
|
||||||
|
containerRect,
|
||||||
|
})
|
||||||
|
|
||||||
|
const topLeft = containerRect
|
||||||
|
? { x: Math.round(s.x - containerRect.left), y: Math.round(s.y - containerRect.top) }
|
||||||
|
: s
|
||||||
|
setSafePos(topLeft)
|
||||||
|
setDropDir(d)
|
||||||
|
|
||||||
|
rafIdRef.current = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rafIdRef.current !== null) {
|
||||||
|
cancelAnimationFrame(rafIdRef.current)
|
||||||
|
rafIdRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showContext, pos, contextRef])
|
||||||
|
|
||||||
|
if (!currentDrive || !fm || !beeApi) {
|
||||||
|
return <div className="fm-file-item-content">Error</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fm-file-item-content" onContextMenu={handleItemContextMenu} onClick={handleCloseContext}>
|
||||||
|
<div className="fm-file-item-content-item fm-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected}
|
||||||
|
onChange={e => onToggleSelected?.(fileInfo, e.target.checked)}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-file-item-content-item fm-name" onDoubleClick={() => handleDownload(true)}>
|
||||||
|
<GetIconElement icon={fileInfo.name} />
|
||||||
|
{fileInfo.name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDriveColumn && (
|
||||||
|
<div className="fm-file-item-content-item fm-drive">
|
||||||
|
<span className="fm-drive-name">{driveName}</span>
|
||||||
|
<span className={`fm-pill ${isTrashedFile ? 'fm-pill--trash' : 'fm-pill--active'}`} title={statusLabel}>
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="fm-file-item-content-item fm-size">{size}</div>
|
||||||
|
<div className="fm-file-item-content-item fm-date-mod">{dateMod}</div>
|
||||||
|
|
||||||
|
{showContext && (
|
||||||
|
<div
|
||||||
|
ref={contextRef}
|
||||||
|
className="fm-file-item-context-menu"
|
||||||
|
style={{ top: safePos.y, left: safePos.x }}
|
||||||
|
data-drop={dropDir}
|
||||||
|
onMouseDown={e => e.stopPropagation()}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ContextMenu>{renderContextMenuItems()}</ContextMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showGetInfoModal && infoGroups && (
|
||||||
|
<GetInfoModal
|
||||||
|
name={fileInfo.name}
|
||||||
|
properties={infoGroups}
|
||||||
|
onCancelClick={() => {
|
||||||
|
setShowGetInfoModal(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showVersionHistory && (
|
||||||
|
<VersionHistoryModal
|
||||||
|
fileInfo={fileInfo}
|
||||||
|
onCancelClick={() => {
|
||||||
|
setShowVersionHistory(false)
|
||||||
|
}}
|
||||||
|
onDownload={onDownload}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDeleteModal && (
|
||||||
|
<DeleteFileModal
|
||||||
|
name={fileInfo.name}
|
||||||
|
currentDriveName={currentDrive.name}
|
||||||
|
onCancelClick={() => {
|
||||||
|
setShowDeleteModal(false)
|
||||||
|
}}
|
||||||
|
onProceed={action => {
|
||||||
|
setShowDeleteModal(false)
|
||||||
|
switch (action) {
|
||||||
|
case FileAction.Trash:
|
||||||
|
doTrash()
|
||||||
|
break
|
||||||
|
case FileAction.Forget:
|
||||||
|
setConfirmForget(true)
|
||||||
|
break
|
||||||
|
case FileAction.Destroy:
|
||||||
|
showDestroyDrive()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRenameModal && (
|
||||||
|
<RenameFileModal
|
||||||
|
currentName={fileInfo.name}
|
||||||
|
takenNames={(() => {
|
||||||
|
const sameDrive = files.filter(fi => fi.driveId.toString() === currentDrive.id.toString())
|
||||||
|
const names = sameDrive.map(fi => fi.name).filter(n => n && n !== fileInfo.name)
|
||||||
|
|
||||||
|
return new Set(names)
|
||||||
|
})()}
|
||||||
|
onCancelClick={() => {
|
||||||
|
setShowRenameModal(false)
|
||||||
|
}}
|
||||||
|
onProceed={async newName => {
|
||||||
|
try {
|
||||||
|
setShowRenameModal(false)
|
||||||
|
await doRename(newName)
|
||||||
|
} catch {
|
||||||
|
safeSetState(isMountedRef, setShowRenameModal)(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{confirmForget && (
|
||||||
|
<ConfirmModal
|
||||||
|
title="Forget permanently?"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
This removes <b title={fileInfo.name}>{fileInfo.name}</b> from your view.
|
||||||
|
<br />
|
||||||
|
The data remains on Swarm until the drive expires.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmLabel="Forget"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
onConfirm={async () => {
|
||||||
|
await doForget()
|
||||||
|
|
||||||
|
safeSetState(isMountedRef, setConfirmForget)(false)
|
||||||
|
}}
|
||||||
|
onCancel={() => {
|
||||||
|
setConfirmForget(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showDestroyDriveModal && destroyDrive && (
|
||||||
|
<DestroyDriveModal
|
||||||
|
drive={destroyDrive}
|
||||||
|
onCancelClick={() => {
|
||||||
|
setShowDestroyDriveModal(false)
|
||||||
|
setDestroyDrive(null)
|
||||||
|
}}
|
||||||
|
doDestroy={async () => {
|
||||||
|
setShowDestroyDriveModal(false)
|
||||||
|
|
||||||
|
await handleDestroyDrive(
|
||||||
|
beeApi,
|
||||||
|
fm,
|
||||||
|
destroyDrive,
|
||||||
|
() => {
|
||||||
|
setShowDestroyDriveModal(false)
|
||||||
|
setDestroyDrive(null)
|
||||||
|
},
|
||||||
|
e => {
|
||||||
|
setShowDestroyDriveModal(false)
|
||||||
|
setErrorMessage?.(`Error destroying drive: ${destroyDrive.name}: ${e}`)
|
||||||
|
setShowError(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
.fm-file-progress-notification {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
+104
@@ -0,0 +1,104 @@
|
|||||||
|
import { ReactElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import './FileProgressNotification.scss'
|
||||||
|
import UpIcon from 'remixicon-react/ArrowUpSLineIcon'
|
||||||
|
import DownIcon from 'remixicon-react/ArrowDownSLineIcon'
|
||||||
|
import { FileProgressWindow } from '../FileProgressWindow/FileProgressWindow'
|
||||||
|
import { FileTransferType, TransferStatus } from '../../constants/transfers'
|
||||||
|
|
||||||
|
type ProgressItem = {
|
||||||
|
name: string
|
||||||
|
size?: string
|
||||||
|
percent?: number
|
||||||
|
kind?: FileTransferType
|
||||||
|
status?: TransferStatus
|
||||||
|
driveName?: string
|
||||||
|
etaSec?: number
|
||||||
|
elapsedSec?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileProgressNotificationProps {
|
||||||
|
label?: string
|
||||||
|
type: FileTransferType
|
||||||
|
open?: boolean
|
||||||
|
count?: number
|
||||||
|
items?: ProgressItem[]
|
||||||
|
onRowClose?: (name: string) => void
|
||||||
|
onCloseAll?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileProgressNotification({
|
||||||
|
label,
|
||||||
|
type,
|
||||||
|
open,
|
||||||
|
count,
|
||||||
|
items,
|
||||||
|
onRowClose,
|
||||||
|
onCloseAll,
|
||||||
|
}: FileProgressNotificationProps): ReactElement | null {
|
||||||
|
const [showFileProgressWindow, setShowFileProgressWindow] = useState(Boolean(open))
|
||||||
|
const [openedByUser, setOpenedByUser] = useState(false)
|
||||||
|
const autoHideTimer = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const allDone = useMemo(() => {
|
||||||
|
if (!items || items.length === 0) return false
|
||||||
|
|
||||||
|
return items.every(i => (typeof i.percent === 'number' ? i.percent >= 100 : i.status === TransferStatus.Done))
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setShowFileProgressWindow(true)
|
||||||
|
setOpenedByUser(false)
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoHideTimer.current) {
|
||||||
|
window.clearTimeout(autoHideTimer.current)
|
||||||
|
autoHideTimer.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showFileProgressWindow && allDone && !openedByUser) {
|
||||||
|
autoHideTimer.current = window.setTimeout(() => {
|
||||||
|
setShowFileProgressWindow(false)
|
||||||
|
autoHideTimer.current = null
|
||||||
|
}, 3000) as unknown as number
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (autoHideTimer.current) {
|
||||||
|
window.clearTimeout(autoHideTimer.current)
|
||||||
|
autoHideTimer.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [showFileProgressWindow, allDone, openedByUser])
|
||||||
|
|
||||||
|
const handleOpenClick = () => {
|
||||||
|
setOpenedByUser(true)
|
||||||
|
setShowFileProgressWindow(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<div className="fm-file-progress-notification" onClick={handleOpenClick} role="button" aria-label={label}>
|
||||||
|
<span>{label}</span>
|
||||||
|
{type === FileTransferType.Upload && <UpIcon size="16px" style={{ marginLeft: 6 }} />}
|
||||||
|
{type === FileTransferType.Download && <DownIcon size="16px" style={{ marginLeft: 6 }} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showFileProgressWindow && (
|
||||||
|
<FileProgressWindow
|
||||||
|
numberOfFiles={items && items.length ? undefined : count}
|
||||||
|
items={items}
|
||||||
|
type={type}
|
||||||
|
onCancelClick={() => setShowFileProgressWindow(false)}
|
||||||
|
onRowClose={onRowClose}
|
||||||
|
onCloseAll={() => {
|
||||||
|
onCloseAll?.()
|
||||||
|
setShowFileProgressWindow(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
.fm-file-progress-window {
|
||||||
|
position: absolute;
|
||||||
|
border: 1px solid rgb(209, 213, 219);
|
||||||
|
width: 275px;
|
||||||
|
bottom: 45px;
|
||||||
|
background-color: white;
|
||||||
|
z-index: 1200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-progress-window-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid rgb(209, 213, 219);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-progress-window-header-actions { display: inline-flex; gap: 6px; }
|
||||||
|
|
||||||
|
.fm-file-progress-window-header-btn {
|
||||||
|
width: 22px; height: 22px; display: inline-grid; place-items: center;
|
||||||
|
padding: 0; margin: 0; background: #f0f0f0; color: #4b5563;
|
||||||
|
border: none; border-radius: 4px; cursor: pointer;
|
||||||
|
&:hover { background: #e5e7eb; } &:active { background: #d1d5db; }
|
||||||
|
&:disabled { cursor: default; opacity: .6; filter: grayscale(.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-progress-window-file-item {
|
||||||
|
display: flex; align-items: flex-start; gap: 8px;
|
||||||
|
padding: 12px; border-bottom: 1px solid rgb(243, 244, 246);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-progress-window-file-type-icon { margin-top: 4px; }
|
||||||
|
|
||||||
|
.fm-file-progress-window-file-datas {
|
||||||
|
display: flex; flex-direction: column; gap: 8px; width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-progress-window-file-item-header {
|
||||||
|
display: grid; grid-template-columns: 1fr auto auto;
|
||||||
|
align-items: center; gap: 8px; min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-file-progress-window-name { min-width: 0; }
|
||||||
|
.fm-file-progress-window-name-text {
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.fm-drive-line { margin-top: 2px; }
|
||||||
|
|
||||||
|
.fm-file-progress-window-percent { white-space: nowrap; }
|
||||||
|
|
||||||
|
.fm-file-progress-window-file-item-footer {
|
||||||
|
display: grid; grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center; column-gap: 8px; font-size: 11px;
|
||||||
|
}
|
||||||
|
.fm-file-progress-window-size { white-space: nowrap; }
|
||||||
|
.fm-file-progress-window-center { justify-self: center; white-space: nowrap; }
|
||||||
|
.fm-file-progress-window-status { justify-self: end; white-space: nowrap; }
|
||||||
|
|
||||||
|
.fm-file-progress-window-row-close {
|
||||||
|
width: 20px; height: 20px; display: inline-grid; place-items: center;
|
||||||
|
padding: 0; margin: 0; background: #f0f0f0; color: #4b5563;
|
||||||
|
border: none; border-radius: 4px; cursor: pointer;
|
||||||
|
&:hover { background: #e5e7eb; } &:active { background: #d1d5db; }
|
||||||
|
&:disabled { cursor: default; opacity: .6; filter: grayscale(.3); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-drive-chip {
|
||||||
|
display: inline-block; margin-left: 0; padding: 2px 6px;
|
||||||
|
border-radius: 999px; font-size: 11px; line-height: 1;
|
||||||
|
background: rgba(0,0,0,.06); color: #333; vertical-align: middle;
|
||||||
|
}
|
||||||
|
.fm-eta { font-size: 12px; opacity: .8; }
|
||||||
|
.fm-file-subtext { line-height: 1.2; }
|
||||||
|
|
||||||
|
.fm-file-progress-window-list {
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { ReactElement, useLayoutEffect, useRef } from 'react'
|
||||||
|
import CloseIcon from 'remixicon-react/CloseLineIcon'
|
||||||
|
import ArrowDownIcon from 'remixicon-react/ArrowDownSLineIcon'
|
||||||
|
import './FileProgressWindow.scss'
|
||||||
|
import { GetIconElement } from '../../utils/GetIconElement'
|
||||||
|
import { ProgressBar } from '../ProgressBar/ProgressBar'
|
||||||
|
import { FileTransferType, TransferBarColor, TransferStatus } from '../../constants/transfers'
|
||||||
|
import { capitalizeFirstLetter } from '../../utils/common'
|
||||||
|
|
||||||
|
type ProgressItem = {
|
||||||
|
name: string
|
||||||
|
percent?: number
|
||||||
|
size?: string
|
||||||
|
kind?: FileTransferType
|
||||||
|
status?: TransferStatus
|
||||||
|
driveName?: string
|
||||||
|
etaSec?: number
|
||||||
|
elapsedSec?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileProgressWindowProps {
|
||||||
|
numberOfFiles?: number
|
||||||
|
items?: ProgressItem[]
|
||||||
|
type: FileTransferType
|
||||||
|
onCancelClick: () => void
|
||||||
|
onRowClose?: (name: string) => void
|
||||||
|
onCloseAll?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatEta = (sec?: number) => {
|
||||||
|
if (sec === undefined || sec === null) return ''
|
||||||
|
|
||||||
|
if (sec <= 0) return 'Done'
|
||||||
|
const s = Math.ceil(sec)
|
||||||
|
const mm = Math.floor(s / 60)
|
||||||
|
const ss = s % 60
|
||||||
|
|
||||||
|
return mm > 0 ? `${mm}m ${ss}s left` : `${ss}s left`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDuration = (sec?: number) => {
|
||||||
|
if (sec === undefined || sec === null) return ''
|
||||||
|
const s = Math.max(0, Math.round(sec))
|
||||||
|
const mm = Math.floor(s / 60)
|
||||||
|
const ss = s % 60
|
||||||
|
|
||||||
|
return mm > 0 ? `${mm}m ${ss}s` : `${ss}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileProgressWindow({
|
||||||
|
numberOfFiles,
|
||||||
|
items,
|
||||||
|
type,
|
||||||
|
onCancelClick,
|
||||||
|
onRowClose,
|
||||||
|
onCloseAll,
|
||||||
|
}: FileProgressWindowProps): ReactElement | null {
|
||||||
|
const listRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const firstRowRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const count = items?.length ?? numberOfFiles ?? 0
|
||||||
|
const rows: ProgressItem[] =
|
||||||
|
items && items.length > 0
|
||||||
|
? items
|
||||||
|
: Array.from({ length: count }, (_, i) => ({ name: `Pending file ${i + 1}`, percent: 0, size: '' }))
|
||||||
|
|
||||||
|
const getTransferInfo = (item: ProgressItem, pct?: number) => {
|
||||||
|
const transferType = capitalizeFirstLetter(item?.kind ?? type)
|
||||||
|
const verb = `${transferType}ing`
|
||||||
|
const actualStatus = item.status || (pct && pct >= 100 ? TransferStatus.Done : verb)
|
||||||
|
|
||||||
|
return {
|
||||||
|
statusText: capitalizeFirstLetter(actualStatus),
|
||||||
|
barColor: TransferBarColor[transferType as keyof typeof TransferBarColor],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allDone =
|
||||||
|
rows.length > 0 &&
|
||||||
|
rows.every(r => {
|
||||||
|
const pct = Number.isFinite(r.percent) ? Math.round(r.percent as number) : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
r.status === TransferStatus.Done ||
|
||||||
|
r.status === TransferStatus.Error ||
|
||||||
|
r.status === TransferStatus.Cancelled ||
|
||||||
|
(typeof pct === 'number' && pct >= 100)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const rowEl = firstRowRef.current
|
||||||
|
const listEl = listRef.current
|
||||||
|
|
||||||
|
if (!rowEl || !listEl) return
|
||||||
|
const rowH = rowEl.getBoundingClientRect().height
|
||||||
|
const safeRowH = rowH > 0 ? rowH : 72
|
||||||
|
listEl.style.maxHeight = `${safeRowH * 5}px`
|
||||||
|
}, [rows.length])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fm-file-progress-window">
|
||||||
|
<div className="fm-file-progress-window-header">
|
||||||
|
<div className="fm-emphasized-text">
|
||||||
|
{count} {type}
|
||||||
|
{count === 1 ? '' : 's'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-file-progress-window-header-actions">
|
||||||
|
<button
|
||||||
|
className="fm-file-progress-window-header-btn fm-file-progress-window-header-dismiss"
|
||||||
|
aria-label="Dismiss all"
|
||||||
|
type="button"
|
||||||
|
disabled={!allDone}
|
||||||
|
onClick={() => onCloseAll?.()}
|
||||||
|
>
|
||||||
|
<CloseIcon size="16" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="fm-file-progress-window-header-btn fm-file-progress-window-header-hide"
|
||||||
|
aria-label="Hide"
|
||||||
|
type="button"
|
||||||
|
onClick={onCancelClick}
|
||||||
|
>
|
||||||
|
<ArrowDownIcon size="16" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="fm-file-progress-window-list" ref={listRef}>
|
||||||
|
{rows.map((item, idx) => {
|
||||||
|
const pctNum = Number.isFinite(item.percent)
|
||||||
|
? Math.max(0, Math.min(100, Math.round(item.percent as number)))
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const isComplete = (pctNum ?? 0) >= 100 || item.status === TransferStatus.Done
|
||||||
|
const isActive =
|
||||||
|
item.status === TransferStatus.Uploading ||
|
||||||
|
item.status === TransferStatus.Downloading ||
|
||||||
|
item.status === TransferStatus.Queued
|
||||||
|
|
||||||
|
const rowActionLabel = isActive ? 'Cancel' : 'Dismiss'
|
||||||
|
|
||||||
|
const transferInfo = getTransferInfo(item, pctNum)
|
||||||
|
|
||||||
|
const getCenterText = () => {
|
||||||
|
if (!isComplete && typeof item.etaSec === 'number') return formatEta(item.etaSec)
|
||||||
|
|
||||||
|
if (isComplete && typeof item.elapsedSec === 'number') return formatDuration(item.elapsedSec)
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const centerDisplay = getCenterText() || '\u00A0'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fm-file-progress-window-file-item"
|
||||||
|
key={`${item.name}`}
|
||||||
|
ref={idx === 0 ? firstRowRef : undefined}
|
||||||
|
>
|
||||||
|
<div className="fm-file-progress-window-file-type-icon">
|
||||||
|
<GetIconElement size="14" icon={item.name} color="black" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-file-progress-window-file-datas">
|
||||||
|
<div className="fm-file-progress-window-file-item-header">
|
||||||
|
<div className="fm-file-progress-window-name" title={item.name}>
|
||||||
|
<div className="fm-file-progress-window-name-text">{item.name}</div>
|
||||||
|
{item.driveName && (
|
||||||
|
<div className="fm-drive-line">
|
||||||
|
<span className="fm-drive-chip" title={`Drive: ${item.driveName}`}>
|
||||||
|
{item.driveName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-file-progress-window-percent" aria-live="polite">
|
||||||
|
{typeof pctNum === 'number' ? `${pctNum}%` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="fm-file-progress-window-row-close"
|
||||||
|
aria-label={rowActionLabel}
|
||||||
|
onClick={() => onRowClose?.(item.name)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<CloseIcon size="14" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
value={typeof pctNum === 'number' ? pctNum : 0}
|
||||||
|
width="100%"
|
||||||
|
backgroundColor="rgb(229, 231, 235)"
|
||||||
|
color={transferInfo.barColor}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="fm-file-progress-window-file-item-footer">
|
||||||
|
<div className="fm-file-progress-window-size">{item.size || '—'}</div>
|
||||||
|
<div className="fm-file-progress-window-center">{centerDisplay}</div>
|
||||||
|
<div className="fm-file-progress-window-status">{transferInfo.statusText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
import formbricks from '@formbricks/js'
|
||||||
|
|
||||||
|
const FM_CLICK_STORAGE_KEY = 'fm_click_count_v1'
|
||||||
|
const FM_SURVEY_TRIGGERED_KEY = 'fm_survey_triggered_v1'
|
||||||
|
const FM_CLICK_THRESHOLD = 25
|
||||||
|
|
||||||
|
interface FormbricksIntegrationProps {
|
||||||
|
isActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormbricksIntegration({ isActive }: FormbricksIntegrationProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
const formbricksInitRef = useRef(false)
|
||||||
|
const formbricksReadyRef = useRef(false)
|
||||||
|
const pendingEventRef = useRef(false)
|
||||||
|
|
||||||
|
const environmentId = process.env.REACT_APP_FORMBRICKS_ENV_ID
|
||||||
|
const appUrl = process.env.REACT_APP_FORMBRICKS_APP_URL
|
||||||
|
|
||||||
|
const flushPendingEvent = useCallback(() => {
|
||||||
|
if (pendingEventRef.current && localStorage.getItem(FM_SURVEY_TRIGGERED_KEY) !== 'true') {
|
||||||
|
try {
|
||||||
|
formbricks.track('file_manager_engagement_25_clicks')
|
||||||
|
localStorage.setItem(FM_SURVEY_TRIGGERED_KEY, 'true')
|
||||||
|
pendingEventRef.current = false
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!environmentId || !appUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const initializeFormbricks = async () => {
|
||||||
|
try {
|
||||||
|
await formbricks.setup({
|
||||||
|
environmentId,
|
||||||
|
appUrl,
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
formbricksReadyRef.current = true
|
||||||
|
formbricksInitRef.current = true
|
||||||
|
flushPendingEvent()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
formbricksReadyRef.current = false
|
||||||
|
formbricksInitRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void initializeFormbricks()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [environmentId, appUrl, flushPendingEvent])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!formbricksInitRef.current) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
formbricks?.registerRouteChange()
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}, [location])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return
|
||||||
|
|
||||||
|
const handleClick = async () => {
|
||||||
|
if (localStorage.getItem(FM_SURVEY_TRIGGERED_KEY) === 'true') return
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(FM_CLICK_STORAGE_KEY)
|
||||||
|
|
||||||
|
if (stored) count = parseInt(stored, 10) || 0
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
count += 1
|
||||||
|
try {
|
||||||
|
localStorage.setItem(FM_CLICK_STORAGE_KEY, String(count))
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count >= FM_CLICK_THRESHOLD) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent('filemanager-25-clicks', {
|
||||||
|
detail: { count, formbricksReady: formbricksReadyRef.current },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!formbricksReadyRef.current) {
|
||||||
|
pendingEventRef.current = true
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await formbricks.track('file_manager_engagement_25_clicks')
|
||||||
|
localStorage.setItem(FM_SURVEY_TRIGGERED_KEY, 'true')
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootEl = document.querySelector('.fm-main')
|
||||||
|
|
||||||
|
if (rootEl) {
|
||||||
|
rootEl.addEventListener('click', handleClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (rootEl) {
|
||||||
|
rootEl.removeEventListener('click', handleClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isActive])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
.fm-modal-window.fm-get-info-modal {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: clamp(320px, calc(100vh - 96px), 90vh);
|
||||||
|
}
|
||||||
|
.fm-modal-window.fm-get-info-modal .fm-modal-window-header,
|
||||||
|
.fm-modal-window.fm-get-info-modal .fm-modal-window-footer {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.fm-modal-window.fm-get-info-modal .fm-modal-window-body,
|
||||||
|
.fm-get-info-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
.fm-modal-window.fm-get-info-modal .fm-modal-window-body::-webkit-scrollbar,
|
||||||
|
.fm-get-info-body::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
.fm-modal-window.fm-get-info-modal .fm-modal-window-body::-webkit-scrollbar-thumb,
|
||||||
|
.fm-get-info-body::-webkit-scrollbar-thumb {
|
||||||
|
background: #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.fm-modal-window.fm-get-info-modal .fm-modal-window-body::-webkit-scrollbar-track,
|
||||||
|
.fm-get-info-body::-webkit-scrollbar-track {
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-get-info-modal-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-get-info-modal-group-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-get-info-modal-group-properties {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-get-info-modal-property-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-get-info-modal-property-label {
|
||||||
|
color: #555;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-get-info-modal-property-value {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
max-width: 60%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-copy-btn {
|
||||||
|
margin-left: 6px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-copy-btn:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { ReactElement, useState } from 'react'
|
||||||
|
import './GetInfoModal.scss'
|
||||||
|
import { Button } from '../Button/Button'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import InfoIcon from 'remixicon-react/InformationLineIcon'
|
||||||
|
import ClipboardIcon from 'remixicon-react/FileCopyLineIcon'
|
||||||
|
|
||||||
|
import type { FileProperty, FilePropertyGroup } from '../../utils/infoGroups'
|
||||||
|
|
||||||
|
interface GetInfoModalProps {
|
||||||
|
name: string
|
||||||
|
properties: FilePropertyGroup[]
|
||||||
|
onCancelClick: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetInfoModal({ name, onCancelClick, properties }: GetInfoModalProps): ReactElement {
|
||||||
|
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||||
|
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||||
|
const handleCopy = async (prop: FileProperty) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(prop.raw ?? prop.value)
|
||||||
|
setCopiedKey(prop.key)
|
||||||
|
window.setTimeout(() => setCopiedKey(null), 1200)
|
||||||
|
} catch {
|
||||||
|
/* noop */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fm-modal-container">
|
||||||
|
<div className="fm-modal-window fm-get-info-modal">
|
||||||
|
<div className="fm-modal-window-header">
|
||||||
|
<InfoIcon /> <span className="fm-main-font-color">File Information - {name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-modal-window-body fm-get-info-body">
|
||||||
|
{properties.map(group => (
|
||||||
|
<div key={group.title} className="fm-get-info-modal-group">
|
||||||
|
<div className="fm-get-info-modal-group-title">
|
||||||
|
{group.icon}
|
||||||
|
{group.title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-get-info-modal-group-properties">
|
||||||
|
{group.properties.map(prop => (
|
||||||
|
<div key={prop.key} className="fm-get-info-modal-property-row">
|
||||||
|
<span className="fm-get-info-modal-property-label">{prop.label}</span>
|
||||||
|
<span className="fm-get-info-modal-property-value">
|
||||||
|
{prop.value}
|
||||||
|
{(prop.raw || prop.value.includes('...')) && (
|
||||||
|
<button
|
||||||
|
className="fm-copy-btn"
|
||||||
|
onClick={() => handleCopy(prop)}
|
||||||
|
aria-label={`Copy ${prop.label}`}
|
||||||
|
type="button"
|
||||||
|
title={copiedKey === prop.key ? 'Copied!' : 'Copy'}
|
||||||
|
>
|
||||||
|
<ClipboardIcon size="14px" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-modal-window-footer">
|
||||||
|
<div className="fm-get-info-modal-footer-one-button">
|
||||||
|
<Button label="Close" variant="secondary" onClick={onCancelClick} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
modalRoot,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
$bg-900: #212121;
|
||||||
|
$bg-800: #262626;
|
||||||
|
$bg-700: #3e3e3e;
|
||||||
|
$border-400: #9da3ae;
|
||||||
|
$text-100: #e5e7eb;
|
||||||
|
$text-300: #c7ccd4;
|
||||||
|
$accent: #ed8131;
|
||||||
|
|
||||||
|
.fm-header-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
height: 60px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: $bg-900;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.fm-header-logo {
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: $accent;
|
||||||
|
color: $text-100;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
font-weight: 700;
|
||||||
|
svg { width: 18px; height: 18px; }
|
||||||
|
}
|
||||||
|
.fm-header-title {
|
||||||
|
color: $text-100;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-header-search {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
max-width: 900px;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
background: $bg-700;
|
||||||
|
border: 1px solid $border-400;
|
||||||
|
color: $text-300;
|
||||||
|
height: 36px; padding: 0 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&:focus-within {
|
||||||
|
border-color: $accent;
|
||||||
|
box-shadow: 0 0 0 2px rgba(237,129,49,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-header-search-icon { flex: 0 0 auto; }
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
background: transparent; border: none; outline: none;
|
||||||
|
height: 100%;
|
||||||
|
color: $text-100; font-size: 14px;
|
||||||
|
|
||||||
|
&::placeholder { color: $text-300; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-header-search-clear {
|
||||||
|
appearance: none; border: none; background: transparent;
|
||||||
|
color: $text-300; font-size: 18px; line-height: 1;
|
||||||
|
padding: 0 2px; cursor: pointer;
|
||||||
|
&:hover { color: $text-100; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-header-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-filter-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid $border-400;
|
||||||
|
background: $bg-800;
|
||||||
|
color: $text-100;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover { background: mix($bg-800, #fff, 92%); }
|
||||||
|
&:focus-visible { outline: 2px solid rgba(237,129,49,0.4); outline-offset: 2px; }
|
||||||
|
|
||||||
|
&[aria-expanded="true"] {
|
||||||
|
border-color: $accent;
|
||||||
|
box-shadow: 0 0 0 2px rgba(237,129,49,0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-filter-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0; top: calc(100% + 6px);
|
||||||
|
min-width: 260px;
|
||||||
|
background: $bg-800;
|
||||||
|
border: 1px solid $border-400;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 24px rgba(0,0,0,0.25);
|
||||||
|
padding: 10px;
|
||||||
|
z-index: 2000;
|
||||||
|
color: $text-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-filter-group + .fm-filter-group { margin-top: 10px; }
|
||||||
|
|
||||||
|
.fm-filter-group-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: $text-300;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-filter-row {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 4px; border-radius: 6px;
|
||||||
|
cursor: default;
|
||||||
|
color: $text-100;
|
||||||
|
|
||||||
|
input[type="checkbox"], input[type="radio"] {
|
||||||
|
width: 14px; height: 14px; margin: 0;
|
||||||
|
accent-color: $accent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover { background: rgba(255,255,255,0.05); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-filter-sep {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-header-filters { display: none; }
|
||||||
|
.fm-header-filters-label { display: none; }
|
||||||
|
.fm-header-chip-group { display: none; }
|
||||||
|
.fm-chip { display: none; }
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { ReactElement, useMemo, useState, useEffect, useRef, useContext } from 'react'
|
||||||
|
import SearchIcon from 'remixicon-react/SearchLineIcon'
|
||||||
|
import FileIcon from 'remixicon-react/File2LineIcon'
|
||||||
|
import FilterIcon from 'remixicon-react/FilterLineIcon'
|
||||||
|
import './Header.scss'
|
||||||
|
import { useSearch } from '../../../../pages/filemanager/SearchContext'
|
||||||
|
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||||
|
|
||||||
|
// Defaults used to determine “active filters”
|
||||||
|
const DEFAULT_FILTERS = {
|
||||||
|
scope: 'selected' as 'selected' | 'all',
|
||||||
|
includeActive: true,
|
||||||
|
includeTrashed: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header(): ReactElement {
|
||||||
|
const {
|
||||||
|
query,
|
||||||
|
setQuery,
|
||||||
|
clear,
|
||||||
|
scope,
|
||||||
|
setScope,
|
||||||
|
includeActive,
|
||||||
|
setIncludeActive,
|
||||||
|
includeTrashed,
|
||||||
|
setIncludeTrashed,
|
||||||
|
} = useSearch()
|
||||||
|
|
||||||
|
const { currentDrive } = useContext(FMContext)
|
||||||
|
|
||||||
|
const currentDriveName = useMemo(() => {
|
||||||
|
return currentDrive?.name || ''
|
||||||
|
}, [currentDrive])
|
||||||
|
|
||||||
|
const [openFilters, setOpenFilters] = useState(false)
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const btnRef = useRef<HTMLButtonElement | null>(null)
|
||||||
|
|
||||||
|
const filtersActive = useMemo(() => {
|
||||||
|
return (
|
||||||
|
scope !== DEFAULT_FILTERS.scope ||
|
||||||
|
includeActive !== DEFAULT_FILTERS.includeActive ||
|
||||||
|
includeTrashed !== DEFAULT_FILTERS.includeTrashed
|
||||||
|
)
|
||||||
|
}, [scope, includeActive, includeTrashed])
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setScope(DEFAULT_FILTERS.scope)
|
||||||
|
setIncludeActive(DEFAULT_FILTERS.includeActive)
|
||||||
|
setIncludeTrashed(DEFAULT_FILTERS.includeTrashed)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!openFilters) return
|
||||||
|
const onDocClick = (e: MouseEvent) => {
|
||||||
|
const t = e.target as Node
|
||||||
|
|
||||||
|
if (menuRef.current?.contains(t) || btnRef.current?.contains(t)) return
|
||||||
|
setOpenFilters(false)
|
||||||
|
}
|
||||||
|
const onEsc = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setOpenFilters(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onDocClick)
|
||||||
|
document.addEventListener('keydown', onEsc)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocClick)
|
||||||
|
document.removeEventListener('keydown', onEsc)
|
||||||
|
}
|
||||||
|
}, [openFilters])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fm-header-container">
|
||||||
|
<div className="fm-header-left">
|
||||||
|
<div className="fm-header-logo" aria-hidden>
|
||||||
|
<FileIcon />
|
||||||
|
</div>
|
||||||
|
<div className="fm-header-title">File Manager</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-header-search">
|
||||||
|
<SearchIcon className="fm-header-search-icon" size="16px" aria-hidden />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search files by name or type…"
|
||||||
|
value={query}
|
||||||
|
onChange={e => setQuery(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
if (e.key === 'Escape') clear()
|
||||||
|
}}
|
||||||
|
aria-label="Search files"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="fm-header-search-clear"
|
||||||
|
aria-label="Clear search"
|
||||||
|
onClick={clear}
|
||||||
|
title="Clear"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-header-actions">
|
||||||
|
<button
|
||||||
|
ref={btnRef}
|
||||||
|
type="button"
|
||||||
|
className="fm-filter-btn"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={openFilters}
|
||||||
|
onClick={() => setOpenFilters(v => !v)}
|
||||||
|
title={filtersActive ? 'Filters (active)' : 'Filters'}
|
||||||
|
style={{ color: filtersActive ? 'orange' : undefined }}
|
||||||
|
>
|
||||||
|
<FilterIcon size="16px" />
|
||||||
|
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
Filters
|
||||||
|
{filtersActive && (
|
||||||
|
<span
|
||||||
|
aria-label="Filters active"
|
||||||
|
title="Filters active"
|
||||||
|
// tiny inline badge, no external CSS
|
||||||
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 1,
|
||||||
|
padding: '0 4px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid orange',
|
||||||
|
color: 'orange',
|
||||||
|
marginLeft: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
!
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{openFilters && (
|
||||||
|
<div className="fm-filter-menu" role="menu" ref={menuRef}>
|
||||||
|
<div className="fm-filter-group" role="radiogroup" aria-label="Search scope">
|
||||||
|
<div className="fm-filter-group-title">Scope</div>
|
||||||
|
<label className="fm-filter-row">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="fm-scope"
|
||||||
|
checked={scope === 'selected'}
|
||||||
|
onChange={() => setScope('selected')}
|
||||||
|
/>
|
||||||
|
<span title={currentDriveName ? `Search in ${currentDriveName}` : 'Search in selected drive'}>
|
||||||
|
Selected{currentDriveName ? ` — ${currentDriveName}` : ''}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label className="fm-filter-row">
|
||||||
|
<input type="radio" name="fm-scope" checked={scope === 'all'} onChange={() => setScope('all')} />
|
||||||
|
<span>All drives</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-filter-sep" />
|
||||||
|
|
||||||
|
<div className="fm-filter-group" aria-label="Status">
|
||||||
|
<div className="fm-filter-group-title">Status</div>
|
||||||
|
<label className="fm-filter-row">
|
||||||
|
<input type="checkbox" checked={includeActive} onChange={e => setIncludeActive(e.target.checked)} />
|
||||||
|
<span>Active</span>
|
||||||
|
</label>
|
||||||
|
<label className="fm-filter-row">
|
||||||
|
<input type="checkbox" checked={includeTrashed} onChange={e => setIncludeTrashed(e.target.checked)} />
|
||||||
|
<span>Trash</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-filter-sep" />
|
||||||
|
|
||||||
|
<div className="fm-filter-group" role="group" aria-label="Reset">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetFilters}
|
||||||
|
title="Reset filters to default"
|
||||||
|
style={{
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid #ccc',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset to default
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
.fm-initialization-modal-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(237, 237, 237);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
z-index: 1300;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-initilization-progress-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import { ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { BZZ, DAI, Duration, PostageBatch, RedundancyLevel, Size, Utils } from '@ethersphere/bee-js'
|
||||||
|
import './InitialModal.scss'
|
||||||
|
import { CustomDropdown } from '../CustomDropdown/CustomDropdown'
|
||||||
|
import { Button } from '../Button/Button'
|
||||||
|
import { calculateStampCapacityMetrics, fmFetchCost, getUsableStamps, handleCreateDrive } from '../../utils/bee'
|
||||||
|
import { getExpiryDateByLifetime, safeSetState } from '../../utils/common'
|
||||||
|
import { erasureCodeMarks } from '../../constants/common'
|
||||||
|
import { desiredLifetimeOptions } from '../../constants/stamps'
|
||||||
|
import { Context as SettingsContext } from '../../../../providers/Settings'
|
||||||
|
import { Context as BeeContext } from '../../../../providers/Bee'
|
||||||
|
|
||||||
|
import { FMSlider } from '../Slider/Slider'
|
||||||
|
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||||
|
import { ADMIN_STAMP_LABEL } from '@solarpunkltd/file-manager-lib'
|
||||||
|
import { ProgressBar } from '../ProgressBar/ProgressBar'
|
||||||
|
import { Tooltip } from '../Tooltip/Tooltip'
|
||||||
|
import { TOOLTIPS } from '../../constants/tooltips'
|
||||||
|
|
||||||
|
interface InitialModalProps {
|
||||||
|
resetState: boolean
|
||||||
|
handleVisibility: (isVisible: boolean) => void
|
||||||
|
handleShowError: (flag: boolean) => void
|
||||||
|
setIsCreationInProgress: (isCreating: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const minMarkValue = Math.min(...erasureCodeMarks.map(mark => mark.value))
|
||||||
|
const maxMarkValue = Math.max(...erasureCodeMarks.map(mark => mark.value))
|
||||||
|
|
||||||
|
const BATCH_ID_PLACEHOLDER = 'Choose a saved Drive, or leave blank to create a new one'
|
||||||
|
|
||||||
|
const createBatchIdOptions = (stamps: PostageBatch[]) => [
|
||||||
|
{ label: BATCH_ID_PLACEHOLDER, value: -1 },
|
||||||
|
...stamps.map((stamp, index) => {
|
||||||
|
const batchId = stamp.batchID.toHex().slice(0, 8)
|
||||||
|
const label = `${batchId}${stamp.label ? ` - ${stamp.label}` : ''}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
label,
|
||||||
|
value: index,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
export function InitialModal({
|
||||||
|
resetState,
|
||||||
|
setIsCreationInProgress,
|
||||||
|
handleVisibility,
|
||||||
|
handleShowError,
|
||||||
|
}: InitialModalProps): ReactElement {
|
||||||
|
const [isCreateEnabled, setIsCreateEnabled] = useState(false)
|
||||||
|
const [isBalanceSufficient, setIsBalanceSufficient] = useState(true)
|
||||||
|
const [isxDaiBalanceSufficient, setIsxDaiBalanceSufficient] = useState(true)
|
||||||
|
const [capacity, setCapacity] = useState(0)
|
||||||
|
const [lifetimeIndex, setLifetimeIndex] = useState(0)
|
||||||
|
const [validityEndDate, setValidityEndDate] = useState(new Date())
|
||||||
|
const [erasureCodeLevel, setErasureCodeLevel] = useState(RedundancyLevel.OFF)
|
||||||
|
const [cost, setCost] = useState('0')
|
||||||
|
const [usableStamps, setUsableStamps] = useState<PostageBatch[]>([])
|
||||||
|
const [selectedBatch, setSelectedBatch] = useState<PostageBatch | null>(null)
|
||||||
|
const [selectedBatchIndex, setSelectedBatchIndex] = useState<number>(-1)
|
||||||
|
|
||||||
|
const { walletBalance } = useContext(BeeContext)
|
||||||
|
const { beeApi } = useContext(SettingsContext)
|
||||||
|
const { fm } = useContext(FMContext)
|
||||||
|
|
||||||
|
const currentFetch = useRef<Promise<void> | null>(null)
|
||||||
|
const isMountedRef = useRef(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const createAdminDrive = useCallback(async () => {
|
||||||
|
setIsCreationInProgress?.(true)
|
||||||
|
handleVisibility(false)
|
||||||
|
|
||||||
|
await handleCreateDrive(
|
||||||
|
beeApi,
|
||||||
|
fm,
|
||||||
|
Size.fromBytes(capacity),
|
||||||
|
Duration.fromEndDate(validityEndDate),
|
||||||
|
ADMIN_STAMP_LABEL,
|
||||||
|
false,
|
||||||
|
erasureCodeLevel,
|
||||||
|
true,
|
||||||
|
resetState,
|
||||||
|
selectedBatch,
|
||||||
|
() => {
|
||||||
|
handleVisibility(false)
|
||||||
|
setIsCreationInProgress(false)
|
||||||
|
}, // onSuccess
|
||||||
|
() => {
|
||||||
|
handleShowError(true)
|
||||||
|
setIsCreationInProgress(false)
|
||||||
|
}, // onError
|
||||||
|
)
|
||||||
|
}, [
|
||||||
|
beeApi,
|
||||||
|
fm,
|
||||||
|
capacity,
|
||||||
|
validityEndDate,
|
||||||
|
erasureCodeLevel,
|
||||||
|
selectedBatch,
|
||||||
|
handleVisibility,
|
||||||
|
handleShowError,
|
||||||
|
setIsCreationInProgress,
|
||||||
|
resetState,
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getStamps = async () => {
|
||||||
|
const stamps = (await getUsableStamps(beeApi)).filter(s => {
|
||||||
|
const { capacityPct } = calculateStampCapacityMetrics(s)
|
||||||
|
|
||||||
|
return capacityPct < 100
|
||||||
|
})
|
||||||
|
|
||||||
|
safeSetState(isMountedRef, setUsableStamps)([...stamps])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (beeApi) {
|
||||||
|
getStamps()
|
||||||
|
}
|
||||||
|
}, [beeApi])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newSizes = Array.from(Utils.getStampEffectiveBytesBreakpoints(false, erasureCodeLevel).values())
|
||||||
|
|
||||||
|
setCapacity(newSizes[2])
|
||||||
|
}, [erasureCodeLevel])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (validityEndDate.getTime() > new Date().getTime()) {
|
||||||
|
fmFetchCost(
|
||||||
|
capacity,
|
||||||
|
validityEndDate,
|
||||||
|
false,
|
||||||
|
erasureCodeLevel,
|
||||||
|
beeApi,
|
||||||
|
(cost: BZZ) => {
|
||||||
|
setIsBalanceSufficient(true)
|
||||||
|
setIsxDaiBalanceSufficient(true)
|
||||||
|
|
||||||
|
if ((walletBalance && cost.gte(walletBalance.bzzBalance)) || !walletBalance) {
|
||||||
|
safeSetState(isMountedRef, setIsBalanceSufficient)(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zeroDAI = DAI.fromDecimalString('0')
|
||||||
|
|
||||||
|
if ((walletBalance && zeroDAI.eq(walletBalance.nativeTokenBalance)) || !walletBalance) {
|
||||||
|
safeSetState(isMountedRef, setIsxDaiBalanceSufficient)(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
safeSetState(isMountedRef, setCost)(cost.toSignificantDigits(2))
|
||||||
|
},
|
||||||
|
currentFetch,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (lifetimeIndex >= 0) {
|
||||||
|
setIsCreateEnabled(true)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCost('0')
|
||||||
|
setIsCreateEnabled(false)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [validityEndDate, beeApi, capacity, lifetimeIndex, walletBalance])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setValidityEndDate(getExpiryDateByLifetime(lifetimeIndex))
|
||||||
|
}, [lifetimeIndex])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedBatchIndex >= 0 && selectedBatchIndex < usableStamps.length) {
|
||||||
|
setSelectedBatch(usableStamps[selectedBatchIndex])
|
||||||
|
} else {
|
||||||
|
setSelectedBatch(null)
|
||||||
|
}
|
||||||
|
}, [usableStamps, selectedBatchIndex])
|
||||||
|
|
||||||
|
const { capacityPct, usedSize, totalSize } = useMemo(
|
||||||
|
() => calculateStampCapacityMetrics(selectedBatch),
|
||||||
|
[selectedBatch],
|
||||||
|
)
|
||||||
|
|
||||||
|
const initText = resetState ? 'Resetting' : 'Initializing'
|
||||||
|
const createText = resetState ? 'Reset' : 'Create'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fm-initialization-modal-container">
|
||||||
|
<div className="fm-modal-window">
|
||||||
|
<div className="fm-modal-window-header">Welcome to your Swarm File Manager</div>
|
||||||
|
<div>{initText} the File Manager</div>
|
||||||
|
{usableStamps.length > 0 && (
|
||||||
|
<div className="fm-modal-window-body">
|
||||||
|
<div className="fm-modal-window-input-container">
|
||||||
|
{/* <label htmlFor="admin-desired-lifetime" className="fm-input-label">
|
||||||
|
Link an existing Admin Drive (optional)
|
||||||
|
</label>
|
||||||
|
<br /> */}
|
||||||
|
<CustomDropdown
|
||||||
|
id="batch-id-selector"
|
||||||
|
options={createBatchIdOptions(usableStamps)}
|
||||||
|
value={selectedBatchIndex}
|
||||||
|
label="Link an existing Admin Drive (optional)"
|
||||||
|
onChange={(index: number) => {
|
||||||
|
setSelectedBatchIndex(index)
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
setSelectedBatch(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={BATCH_ID_PLACEHOLDER}
|
||||||
|
/>
|
||||||
|
{selectedBatch && (
|
||||||
|
<div className="fm-drive-item-content">
|
||||||
|
<div className="fm-drive-item-capacity">
|
||||||
|
Capacity <ProgressBar value={capacityPct} width="64px" /> {usedSize} / {totalSize}
|
||||||
|
</div>
|
||||||
|
<div className="fm-drive-item-capacity">
|
||||||
|
Expiry date: {selectedBatch.duration.toEndDate().toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!selectedBatch && (
|
||||||
|
<div className="fm-modal-window-body">
|
||||||
|
<div className="fm-modal-window-input-container">
|
||||||
|
<label htmlFor="admin-desired-lifetime" className="fm-input-label">
|
||||||
|
Create a new Admin Drive with desired lifetime: <Tooltip label={TOOLTIPS.ADMIN_DESIRED_LIFETIME} />
|
||||||
|
</label>
|
||||||
|
<CustomDropdown
|
||||||
|
id="admin-desired-lifetime"
|
||||||
|
options={desiredLifetimeOptions}
|
||||||
|
value={lifetimeIndex}
|
||||||
|
onChange={setLifetimeIndex}
|
||||||
|
placeholder="Select a value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="fm-modal-window-input-container">
|
||||||
|
<label htmlFor="admin-security-level" className="fm-input-label">
|
||||||
|
Security Level <Tooltip label={TOOLTIPS.ADMIN_SECURITY_LEVEL} />
|
||||||
|
</label>
|
||||||
|
<FMSlider
|
||||||
|
id="admin-security-level"
|
||||||
|
defaultValue={0}
|
||||||
|
marks={erasureCodeMarks}
|
||||||
|
onChange={value => setErasureCodeLevel(value)}
|
||||||
|
minValue={minMarkValue}
|
||||||
|
maxValue={maxMarkValue}
|
||||||
|
step={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="fm-modal-window-input-container">
|
||||||
|
<div className="fm-modal-estimated-cost-container">
|
||||||
|
<div className="fm-emphasized-text">Estimated Cost:</div>
|
||||||
|
<div>
|
||||||
|
{cost} BZZ {isBalanceSufficient ? '' : '(Insufficient balance)'}
|
||||||
|
{isxDaiBalanceSufficient ? '' : ' (Insufficient xDAI balance)'}
|
||||||
|
</div>
|
||||||
|
<Tooltip label={TOOLTIPS.ADMIN_ESTIMATED_COST} />
|
||||||
|
</div>
|
||||||
|
<div>(Based on current network conditions)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="fm-modal-window-footer">
|
||||||
|
<Button
|
||||||
|
label={selectedBatch ? `${createText} Drive` : `Purchase Stamp & ${createText} Drive`}
|
||||||
|
variant="primary"
|
||||||
|
disabled={selectedBatch ? false : !isCreateEnabled || !isBalanceSufficient || !isxDaiBalanceSufficient}
|
||||||
|
onClick={createAdminDrive}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
selectedBatch
|
||||||
|
? TOOLTIPS.ADMIN_PURCHASE_BUTTON_ALREADY_EXISTED_ADMIN_DRIVE
|
||||||
|
: TOOLTIPS.ADMIN_PURCHASE_BUTTON
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.fm-notification-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.8;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { ReactElement, useContext, useEffect, useState } from 'react'
|
||||||
|
import './NotificationBar.scss'
|
||||||
|
import UpIcon from 'remixicon-react/ArrowUpSLineIcon'
|
||||||
|
import { ExpiringNotificationModal } from '../ExpiringNotificationModal/ExpiringNotificationModal'
|
||||||
|
import { getUsableStamps } from '../../utils/bee'
|
||||||
|
import { Context as SettingsContext } from '../../../../providers/Settings'
|
||||||
|
import { PostageBatch } from '@ethersphere/bee-js'
|
||||||
|
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||||
|
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||||
|
|
||||||
|
const NUMBER_OF_DAYS_WARNING = 7
|
||||||
|
const DAYS_TO_MILLISECONDS_MULTIPLIER = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
interface NotificationBarProps {
|
||||||
|
setErrorMessage?: (error: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationBar({ setErrorMessage }: NotificationBarProps): ReactElement | null {
|
||||||
|
const [showExpiringModal, setShowExpiringModal] = useState(false)
|
||||||
|
const [stampsToExpire, setStampsToExpire] = useState<PostageBatch[]>([])
|
||||||
|
const [drivesToExpire, setDrivesToExpire] = useState<DriveInfo[]>([])
|
||||||
|
const { beeApi } = useContext(SettingsContext)
|
||||||
|
const { drives, adminDrive } = useContext(FMContext)
|
||||||
|
|
||||||
|
const showExpiration = stampsToExpire.length > 0
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true
|
||||||
|
|
||||||
|
const getStamps = async () => {
|
||||||
|
const allStamps = await getUsableStamps(beeApi)
|
||||||
|
const expiringStamps: PostageBatch[] = []
|
||||||
|
const expiringDrives: DriveInfo[] = []
|
||||||
|
|
||||||
|
allStamps.forEach(stamp => {
|
||||||
|
const matchingDrive =
|
||||||
|
drives.find(d => d.batchId.toString() === stamp.batchID.toString()) ||
|
||||||
|
(adminDrive?.batchId.toString() === stamp.batchID.toString() ? adminDrive : null)
|
||||||
|
|
||||||
|
if (matchingDrive) {
|
||||||
|
const isExpiring =
|
||||||
|
stamp.duration &&
|
||||||
|
stamp.duration.toEndDate().getTime() <=
|
||||||
|
Date.now() + NUMBER_OF_DAYS_WARNING * DAYS_TO_MILLISECONDS_MULTIPLIER
|
||||||
|
|
||||||
|
if (isExpiring) {
|
||||||
|
expiringStamps.push(stamp)
|
||||||
|
expiringDrives.push(matchingDrive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setStampsToExpire(expiringStamps)
|
||||||
|
setDrivesToExpire(expiringDrives)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStamps()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false
|
||||||
|
}
|
||||||
|
}, [beeApi, drives, adminDrive])
|
||||||
|
|
||||||
|
if (!showExpiration) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="fm-notification-bar fm-red-font" onClick={() => setShowExpiringModal(true)}>
|
||||||
|
{stampsToExpire.length} drive{stampsToExpire.length > 1 ? 's' : ''} expiring soon <UpIcon size="16px" />
|
||||||
|
</div>
|
||||||
|
{showExpiringModal && (
|
||||||
|
<ExpiringNotificationModal
|
||||||
|
stamps={stampsToExpire}
|
||||||
|
drives={drivesToExpire}
|
||||||
|
onCancelClick={() => {
|
||||||
|
setShowExpiringModal(false)
|
||||||
|
}}
|
||||||
|
setErrorMessage={setErrorMessage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
.fm-private-key-label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-generate-btn {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.fm-generate-btn:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-private-key-input-row {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-private-key-input {
|
||||||
|
padding-right: 37px !important;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding-right: 40px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-confirm-key-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-confirm-key-input {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-confirm-key-hint {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.fm-input.has-error {
|
||||||
|
border-color: #ef4444;
|
||||||
|
}
|
||||||
|
.fm-input-hint-error {
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
min-height: 24px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.fm-input-hint {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
right: 27px;
|
||||||
|
margin-left: 6px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
line-height: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-copy-btn:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-initialization-modal-container {
|
||||||
|
height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-initialization-modal-container .fm-modal-window {
|
||||||
|
width: 600px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
import { useState, ReactElement, useEffect } from 'react'
|
||||||
|
import './PrivateKeyModal.scss'
|
||||||
|
import { Button } from '../Button/Button'
|
||||||
|
import { setSignerPk, getSigner } from '../../utils/common'
|
||||||
|
import { PrivateKey } from '@ethersphere/bee-js'
|
||||||
|
import ClipboardIcon from 'remixicon-react/FileCopyLineIcon'
|
||||||
|
import CheckDoubleLineIcon from 'remixicon-react/CheckDoubleLineIcon'
|
||||||
|
import { Tooltip } from '../Tooltip/Tooltip'
|
||||||
|
import { TOOLTIPS } from '../../constants/tooltips'
|
||||||
|
|
||||||
|
type Props = { onSaved: () => void }
|
||||||
|
|
||||||
|
export function PrivateKeyModal({ onSaved }: Props): ReactElement {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
const [confirmValue, setConfirmValue] = useState('')
|
||||||
|
const [showError, setShowError] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleGenerateNew()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleCopyPrivateKey = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
setCopied(true)
|
||||||
|
} catch {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.debug('Failed to copy private key to clipboard')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGenerateNew = () => {
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const signer = getSigner(id)
|
||||||
|
const privKey = signer.toHex()
|
||||||
|
|
||||||
|
setValue(privKey)
|
||||||
|
setConfirmValue('')
|
||||||
|
setCopied(false)
|
||||||
|
setShowError(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
if (!value.trim()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
new PrivateKey(value)
|
||||||
|
setShowError(false)
|
||||||
|
} catch {
|
||||||
|
setShowError(true)
|
||||||
|
setCopied(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
try {
|
||||||
|
new PrivateKey(value)
|
||||||
|
setSignerPk(value)
|
||||||
|
onSaved()
|
||||||
|
} catch {
|
||||||
|
setShowError(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fm-initialization-modal-container">
|
||||||
|
<div className="fm-modal-window">
|
||||||
|
<div className="fm-modal-window-header">
|
||||||
|
<div>Create Private Key</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Using a private key ensures that only you can access this File Manager instance. Save it securely before
|
||||||
|
continuing.
|
||||||
|
</div>
|
||||||
|
<div className="fm-modal-info-warning flex-column">
|
||||||
|
<span className="fm-modal-info-warning-text-header">IMPORTANT: Lost keys cannot be recovered</span>
|
||||||
|
<span>
|
||||||
|
Swarm never stores private keys. If you lose this key, access to this File Manager instance will be
|
||||||
|
permanently lost.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="fm-modal-window-body">
|
||||||
|
<div className="fm-modal-window-input-container">
|
||||||
|
<label htmlFor="fm-private-key" className="fm-emphasized-text fm-private-key-label">
|
||||||
|
<span>New Private key</span>
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateNew}
|
||||||
|
type="button"
|
||||||
|
className="fm-generate-btn"
|
||||||
|
onMouseEnter={e => (e.currentTarget.style.background = '#e5e7eb')}
|
||||||
|
onMouseLeave={e => (e.currentTarget.style.background = '#f3f4f6')}
|
||||||
|
>
|
||||||
|
Generate New
|
||||||
|
</button>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="fm-private-key-input-row">
|
||||||
|
<input
|
||||||
|
id="fm-private-key"
|
||||||
|
type="text"
|
||||||
|
className={`fm-input${showError ? ' has-error' : ''} fm-private-key-input`}
|
||||||
|
autoComplete="off"
|
||||||
|
value={value}
|
||||||
|
onChange={e => {
|
||||||
|
setValue(e.target.value)
|
||||||
|
setCopied(false)
|
||||||
|
setShowError(false)
|
||||||
|
}}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
{
|
||||||
|
<button
|
||||||
|
className="fm-copy-btn"
|
||||||
|
onClick={handleCopyPrivateKey}
|
||||||
|
aria-label="Copy private key"
|
||||||
|
type="button"
|
||||||
|
title={copied ? 'Copied!' : 'Copy'}
|
||||||
|
>
|
||||||
|
{copied ? <CheckDoubleLineIcon size="16px" /> : <ClipboardIcon size="16px" />}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<Tooltip label={TOOLTIPS.PRIVATE_KEY_MODAL_GENERATED_KEY} />
|
||||||
|
</div>
|
||||||
|
<div className="fm-input-hint-error">{showError ? 'Invalid private key.' : ''}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-modal-window-input-container">
|
||||||
|
<label htmlFor="fm-private-key-confirm" className="fm-emphasized-text fm-confirm-key-label">
|
||||||
|
Confirm Private Key
|
||||||
|
</label>
|
||||||
|
<div className="fm-private-key-input-row">
|
||||||
|
<input
|
||||||
|
id="fm-private-key-confirm"
|
||||||
|
type="text"
|
||||||
|
className="fm-input fm-confirm-key-input"
|
||||||
|
placeholder="Paste or type your private key again"
|
||||||
|
autoComplete="off"
|
||||||
|
value={confirmValue}
|
||||||
|
onChange={e => setConfirmValue(e.target.value)}
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="fm-input-hint fm-confirm-key-hint">
|
||||||
|
{confirmValue && value === confirmValue ? '✓ Private keys match!' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-modal-window-body">
|
||||||
|
<div className="flex-row">
|
||||||
|
<div>
|
||||||
|
<b>Safety Reminder:</b>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span>
|
||||||
|
A copy of your private key is stored in this browser for convenience, but it’s not a backup - clearing
|
||||||
|
browser data or switching devices will remove it.{' '}
|
||||||
|
<b>Make sure you’ve saved your private key before continuing.</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="fm-modal-window-footer">
|
||||||
|
<Button
|
||||||
|
label="Save"
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!value || !confirmValue || value !== confirmValue || showError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PrivateKeyModal
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
.fm-progress-bar {
|
||||||
|
width: 20%;
|
||||||
|
height: 6px;
|
||||||
|
background: rgb(255, 255, 255);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fm-progress-bar-fill {
|
||||||
|
width: '20px';
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user