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 |
+2
-2
@@ -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,14 +92,27 @@ 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
|
||||||
@@ -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
+9772
-21037
File diff suppressed because it is too large
Load Diff
+36
-31
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
+20
-28
@@ -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,44 +16,47 @@ 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>
|
||||||
|
<FileManagerProvider>
|
||||||
<FeedsProvider>
|
<FeedsProvider>
|
||||||
<PlatformProvider>
|
<PlatformProvider>
|
||||||
<SnackbarProvider preventDuplicate anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}>
|
<SnackbarProvider preventDuplicate anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}>
|
||||||
<Router>
|
<Router>
|
||||||
<>
|
<>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
<Dashboard>
|
<Dashboard errorReporting={errorReporting}>
|
||||||
<BaseRouter />
|
<BaseRouter />
|
||||||
</Dashboard>
|
</Dashboard>
|
||||||
</>
|
</>
|
||||||
@@ -61,6 +64,7 @@ const App = ({ beeApiUrl, beeDebugApiUrl, lockedApiSettings, isBeeDesktop }: Pro
|
|||||||
</SnackbarProvider>
|
</SnackbarProvider>
|
||||||
</PlatformProvider>
|
</PlatformProvider>
|
||||||
</FeedsProvider>
|
</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,6 +130,7 @@ 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>}
|
||||||
|
<Box mt={2}>
|
||||||
<ExpandableListItemActions>
|
<ExpandableListItemActions>
|
||||||
<SwarmButton
|
<SwarmButton
|
||||||
disabled={
|
disabled={
|
||||||
@@ -159,6 +156,7 @@ export default function ExpandableListItemKey({
|
|||||||
Cancel
|
Cancel
|
||||||
</SwarmButton>
|
</SwarmButton>
|
||||||
</ExpandableListItemActions>
|
</ExpandableListItemActions>
|
||||||
|
</Box>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,7 +77,6 @@ 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}>
|
||||||
@@ -87,10 +86,9 @@ export default function ExpandableListItemKey({ label, value, expanded }: Props)
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<IconButton size="small" className={classes.copyValue}>
|
<IconButton size="small" className={classes.copyValue} onClick={toggleOpen}>
|
||||||
{open ? <Minus onClick={toggleOpen} strokeWidth={1} /> : <Eye onClick={toggleOpen} strokeWidth={1} />}
|
{open ? <Minus strokeWidth={1} /> : <Eye strokeWidth={1} />}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||||
|
|||||||
@@ -82,7 +82,6 @@ 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}>
|
||||||
@@ -93,11 +92,10 @@ export default function ExpandableListItemLink({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!allowClipboard && <span onClick={onNavigation}>{displayValue}</span>}
|
{!allowClipboard && <span onClick={onNavigation}>{displayValue}</span>}
|
||||||
<IconButton size="small" className={classes.openLinkIcon}>
|
<IconButton size="small" className={classes.openLinkIcon} onClick={onNavigation}>
|
||||||
{navigationType === 'NEW_WINDOW' && <OpenInNewSharp onClick={onNavigation} strokeWidth={1} />}
|
{navigationType === 'NEW_WINDOW' && <OpenInNewSharp strokeWidth={1} />}
|
||||||
{navigationType === 'HISTORY_PUSH' && <ArrowForward onClick={onNavigation} strokeWidth={1} />}
|
{navigationType === 'HISTORY_PUSH' && <ArrowForward 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