├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ ├── CI.yml │ ├── release-embed.yml │ ├── release-native.yml │ └── release-standalone.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── angular.json ├── docs ├── CONTRIBUTING.md ├── SECURITY.md ├── embed.md ├── native.md ├── pwa.md ├── screenshots │ ├── backends.png │ ├── create-backend.png │ ├── explorer.png │ ├── index.md │ └── mounting.png └── zh │ └── Instructions.md ├── jest.config.js ├── ngsw-config.json ├── package-lock.json ├── package.json ├── src ├── app │ ├── app-routing.module.ts │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.module.ts │ ├── cores │ │ ├── not-found │ │ │ ├── not-found.component.html │ │ │ ├── not-found.component.scss │ │ │ ├── not-found.component.spec.ts │ │ │ └── not-found.component.ts │ │ ├── remote-control │ │ │ ├── connection.guard.spec.ts │ │ │ ├── connection.guard.ts │ │ │ ├── connection.service.spec.ts │ │ │ ├── connection.service.ts │ │ │ ├── remote-control.service.spec.ts │ │ │ └── remote-control.service.ts │ │ └── storage │ │ │ ├── app-storage.service.spec.ts │ │ │ ├── app-storage.service.ts │ │ │ ├── base-storage.ts │ │ │ └── index.ts │ ├── features │ │ ├── connection │ │ │ ├── connection-editor │ │ │ │ ├── connection-editor.component.html │ │ │ │ ├── connection-editor.component.scss │ │ │ │ ├── connection-editor.component.spec.ts │ │ │ │ └── connection-editor.component.ts │ │ │ ├── connection-routing.module.ts │ │ │ ├── connection.component.html │ │ │ ├── connection.component.scss │ │ │ ├── connection.component.spec.ts │ │ │ ├── connection.component.ts │ │ │ ├── connection.module.ts │ │ │ └── prompt-password │ │ │ │ ├── prompt-password.component.html │ │ │ │ ├── prompt-password.component.scss │ │ │ │ └── prompt-password.component.ts │ │ ├── dashboard │ │ │ ├── dashboard-routing.module.ts │ │ │ ├── dashboard.component.html │ │ │ ├── dashboard.component.scss │ │ │ ├── dashboard.component.spec.ts │ │ │ ├── dashboard.component.ts │ │ │ ├── dashboard.model.ts │ │ │ ├── dashboard.module.ts │ │ │ ├── dashboard.service.spec.ts │ │ │ └── dashboard.service.ts │ │ └── functions │ │ │ ├── backend │ │ │ ├── backend-info │ │ │ │ ├── backend-info.component.html │ │ │ │ ├── backend-info.component.scss │ │ │ │ ├── backend-info.component.spec.ts │ │ │ │ └── backend-info.component.ts │ │ │ ├── backend-routing.module.ts │ │ │ ├── backend.component.html │ │ │ ├── backend.component.scss │ │ │ ├── backend.component.spec.ts │ │ │ ├── backend.component.ts │ │ │ ├── backend.model.ts │ │ │ ├── backend.module.ts │ │ │ ├── backend.service.spec.ts │ │ │ ├── backend.service.ts │ │ │ ├── new-backend-name │ │ │ │ ├── new-backend-name.component.html │ │ │ │ ├── new-backend-name.component.scss │ │ │ │ ├── new-backend-name.component.spec.ts │ │ │ │ └── new-backend-name.component.ts │ │ │ └── new-backend │ │ │ │ ├── new-backend-routing.module.ts │ │ │ │ ├── new-backend.component.html │ │ │ │ ├── new-backend.component.scss │ │ │ │ ├── new-backend.component.spec.ts │ │ │ │ ├── new-backend.component.ts │ │ │ │ ├── new-backend.model.ts │ │ │ │ ├── new-backend.module.ts │ │ │ │ ├── new-backend.service.spec.ts │ │ │ │ └── new-backend.service.ts │ │ │ ├── cron │ │ │ ├── cron-editor │ │ │ │ ├── cron-editor.component.html │ │ │ │ ├── cron-editor.component.scss │ │ │ │ ├── cron-editor.component.spec.ts │ │ │ │ ├── cron-editor.component.ts │ │ │ │ └── easy-cron │ │ │ │ │ ├── easy-cron.component.html │ │ │ │ │ ├── easy-cron.component.scss │ │ │ │ │ ├── easy-cron.component.spec.ts │ │ │ │ │ └── easy-cron.component.ts │ │ │ ├── cron-routing.module.ts │ │ │ ├── cron.component.html │ │ │ ├── cron.component.scss │ │ │ ├── cron.component.spec.ts │ │ │ ├── cron.component.ts │ │ │ ├── cron.module.ts │ │ │ ├── cron.service.spec.ts │ │ │ └── cron.service.ts │ │ │ ├── explorer │ │ │ ├── explorer-routing.module.ts │ │ │ ├── explorer-viewer │ │ │ │ ├── copy-dialog │ │ │ │ │ ├── copy-dialog.component.html │ │ │ │ │ ├── copy-dialog.component.scss │ │ │ │ │ ├── copy-dialog.component.spec.ts │ │ │ │ │ └── copy-dialog.component.ts │ │ │ │ ├── delete-confirm-dialog │ │ │ │ │ ├── delete-confirm-dialog.component.html │ │ │ │ │ ├── delete-confirm-dialog.component.scss │ │ │ │ │ ├── delete-confirm-dialog.component.spec.ts │ │ │ │ │ └── delete-confirm-dialog.component.ts │ │ │ │ ├── explorer-viewer.component.html │ │ │ │ ├── explorer-viewer.component.scss │ │ │ │ ├── explorer-viewer.component.spec.ts │ │ │ │ ├── explorer-viewer.component.ts │ │ │ │ ├── file-icon.pipe.spec.ts │ │ │ │ ├── file-icon.pipe.ts │ │ │ │ ├── path-splitter │ │ │ │ │ ├── path-splitter.component.html │ │ │ │ │ ├── path-splitter.component.scss │ │ │ │ │ ├── path-splitter.component.spec.ts │ │ │ │ │ └── path-splitter.component.ts │ │ │ │ └── rename-dialog │ │ │ │ │ ├── rename-dialog.component.html │ │ │ │ │ ├── rename-dialog.component.scss │ │ │ │ │ ├── rename-dialog.component.spec.ts │ │ │ │ │ └── rename-dialog.component.ts │ │ │ ├── explorer.component.html │ │ │ ├── explorer.component.scss │ │ │ ├── explorer.component.spec.ts │ │ │ ├── explorer.component.ts │ │ │ ├── explorer.model.ts │ │ │ ├── explorer.module.ts │ │ │ ├── explorer.service.spec.ts │ │ │ └── explorer.service.ts │ │ │ ├── functions-routing.module.ts │ │ │ ├── functions.component.html │ │ │ ├── functions.component.scss │ │ │ ├── functions.component.spec.ts │ │ │ ├── functions.component.ts │ │ │ ├── functions.module.ts │ │ │ ├── is-electron.guard.spec.ts │ │ │ ├── is-electron.guard.ts │ │ │ ├── job │ │ │ ├── job-routing.module.ts │ │ │ ├── job.component.html │ │ │ ├── job.component.scss │ │ │ ├── job.component.spec.ts │ │ │ ├── job.component.ts │ │ │ ├── job.model.ts │ │ │ ├── job.module.ts │ │ │ ├── job.service.spec.ts │ │ │ └── job.service.ts │ │ │ ├── mount │ │ │ ├── mount-routing.module.ts │ │ │ ├── mount.component.html │ │ │ ├── mount.component.scss │ │ │ ├── mount.component.spec.ts │ │ │ ├── mount.component.ts │ │ │ ├── mount.module.ts │ │ │ ├── mount.service.spec.ts │ │ │ ├── mount.service.ts │ │ │ └── new-mount-dialog │ │ │ │ ├── new-mount-dialog.component.html │ │ │ │ ├── new-mount-dialog.component.scss │ │ │ │ ├── new-mount-dialog.component.spec.ts │ │ │ │ └── new-mount-dialog.component.ts │ │ │ └── serve │ │ │ └── serve.module.ts │ └── shared │ │ ├── bytes.pipe.spec.ts │ │ ├── bytes.pipe.ts │ │ ├── json-string-validator.directive.ts │ │ ├── result.ts │ │ ├── search.pipe.spec.ts │ │ ├── search.pipe.ts │ │ ├── simple-dialog │ │ ├── simple-dialog.component.html │ │ ├── simple-dialog.component.scss │ │ ├── simple-dialog.component.spec.ts │ │ └── simple-dialog.component.ts │ │ ├── single-click.directive.spec.ts │ │ ├── single-click.directive.ts │ │ └── utils.ts ├── assets │ └── icons │ │ ├── github-mark-white.svg │ │ └── icon-rclone.svg ├── environments │ ├── environment.embed.ts │ ├── environment.native.ts │ ├── environment.standalone.ts │ └── environment.ts ├── favicon.ico ├── i18n-index.html ├── index.html ├── locale │ ├── messages.de-DE.xlf │ ├── messages.tr-TR.xlf │ ├── messages.xlf │ └── messages.zh-CN.xlf ├── main.ts ├── manifest.webmanifest ├── material.scss ├── proxy.conf.mjs └── styles.scss ├── tsconfig.app.json ├── tsconfig.json └── tsconfig.spec.json /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rclone-webui-angular", 3 | "image": "mcr.microsoft.com/devcontainers/javascript-node:20", 4 | "customizations": { 5 | "vscode": { 6 | "extensions": [ 7 | "dbaeumer.vscode-eslint", 8 | "johnpapa.Angular2", 9 | "Angular.ng-template", 10 | "streetsidesoftware.code-spell-checker", 11 | "esbenp.prettier-vscode" 12 | ] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "projects/**/*" 5 | ], 6 | "overrides": [ 7 | { 8 | "files": [ 9 | "*.ts" 10 | ], 11 | "extends": [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/recommended", 14 | "plugin:@angular-eslint/recommended", 15 | "plugin:@angular-eslint/template/process-inline-templates" 16 | ], 17 | "rules": { 18 | "sort-imports": [ 19 | "off", 20 | { 21 | "allowSeparatedGroups": true 22 | } 23 | ], 24 | "@angular-eslint/directive-selector": [ 25 | "error", 26 | { 27 | "type": "attribute", 28 | "prefix": "app", 29 | "style": "camelCase" 30 | } 31 | ], 32 | "@angular-eslint/component-selector": [ 33 | "error", 34 | { 35 | "type": "element", 36 | "prefix": "app", 37 | "style": "kebab-case" 38 | } 39 | ] 40 | } 41 | }, 42 | { 43 | "files": [ 44 | "*.html" 45 | ], 46 | "extends": [ 47 | "plugin:@angular-eslint/template/recommended", 48 | "plugin:@angular-eslint/template/accessibility" 49 | ], 50 | "rules": { 51 | "@angular-eslint/template/i18n": [ 52 | "warn", 53 | { 54 | "checkId": false, 55 | "checkAttributes": false 56 | } 57 | ] 58 | } 59 | } 60 | ] 61 | } -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - development 8 | 9 | jobs: 10 | CI: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | pull-requests: write 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: 20 22 | 23 | - name: Cache npm 24 | uses: actions/cache@v2 25 | with: 26 | path: ~/.npm 27 | key: RcloneWebuiAngular-${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 28 | restore-keys: | 29 | RcloneWebuiAngular-${{ runner.os }}-node- 30 | 31 | - name: CI 32 | env: 33 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 34 | run: | 35 | echo "::group::Initialize" 36 | BRANCH=ci/${{ github.run_number }} 37 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 38 | git config --local user.name "github-actions[bot]" 39 | git checkout -b $BRANCH 40 | changed=false 41 | echo "::endgroup::" 42 | 43 | echo "::group::Lint" 44 | npm clean-install 45 | npm run lint 46 | if [ -n "$(git status --untracked-files=no --porcelain)" ]; then 47 | git add . 48 | git commit -m ":art: format code" 49 | changed=true 50 | fi 51 | echo "::endgroup::" 52 | 53 | echo "::group::i18n" 54 | npm run extract-i18n 55 | if [ -n "$(git status --untracked-files=no --porcelain)" ]; then 56 | git add . 57 | git commit -m ":globe_with_meridians: update i18n" 58 | changed=true 59 | fi 60 | echo "::endgroup::" 61 | 62 | if [ "$changed" = true ]; then 63 | echo "::group::Pull Request" 64 | git push --set-upstream origin $BRANCH 65 | gh pr create --title "CI" --body "CI" --base "development" --head "$BRANCH" --repo "$GITHUB_REPOSITORY" 66 | echo "::endgroup::" 67 | fi 68 | -------------------------------------------------------------------------------- /.github/workflows/release-embed.yml: -------------------------------------------------------------------------------- 1 | name: Release Embed 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 20 20 | 21 | - name: Cache npm 22 | uses: actions/cache@v2 23 | with: 24 | path: ~/.npm 25 | key: RcloneWebuiAngular-${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 26 | restore-keys: | 27 | RcloneWebuiAngular-${{ runner.os }}-node- 28 | 29 | - name: Build 30 | run: | 31 | npm clean-install 32 | npm run build:embed 33 | 34 | - name: Archive as Zip 35 | run: | 36 | cp src/i18n-index.html dist/build/index.html 37 | cd dist 38 | zip -r embed.zip build 39 | mkdir -p release/${{ github.ref_name }} 40 | mv embed.zip release/${{ github.ref_name }}/embed.zip 41 | echo '{"tag_name":"${{ github.ref_name }}","assets":[{"browser_download_url":"https://s3.yuudi.dev/rwa/embed/${{ github.ref_name }}/embed.zip"}]}' > release/version.json 42 | 43 | - name: Create Release 44 | id: create_release 45 | uses: actions/create-release@v1 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | tag_name: ${{ github.ref_name }} 50 | release_name: Release ${{ github.ref_name }} 51 | draft: false 52 | prerelease: false 53 | body: | 54 | Release ${{ github.ref_name }} 55 | 56 | Note: If you are looking for the desktop app, please go to [rwa desktop](https://github.com/yuudi/rwa-desktop/releases/latest) 57 | 注意:如果你在寻找桌面版,请前往 [rwa desktop](https://github.com/yuudi/rwa-desktop/releases/latest) 58 | 59 | - name: Upload Release Asset 60 | uses: actions/upload-release-asset@v1 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | with: 64 | upload_url: ${{ steps.create_release.outputs.upload_url }} 65 | asset_path: ./dist/release/${{ github.ref_name }}/embed.zip 66 | asset_name: embed.zip 67 | asset_content_type: application/zip 68 | 69 | - name: Upload to S3 70 | uses: docker://rclone/rclone:latest 71 | with: 72 | args: | 73 | ${{ secrets.S3_ARGS }} copy dist/release :s3:yuudi/rwa/embed 74 | -------------------------------------------------------------------------------- /.github/workflows/release-native.yml: -------------------------------------------------------------------------------- 1 | name: Release Native 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: write 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 20 20 | 21 | - name: Cache npm 22 | uses: actions/cache@v2 23 | with: 24 | path: ~/.npm 25 | key: RcloneWebuiAngular-${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 26 | restore-keys: | 27 | RcloneWebuiAngular-${{ runner.os }}-node- 28 | 29 | - name: Build 30 | run: | 31 | npm clean-install 32 | npm run build:native 33 | 34 | - name: Archive as Zip 35 | run: | 36 | cp src/i18n-index.html dist/build/index.html 37 | cd dist 38 | zip -r native.zip build 39 | mkdir -p release/${{ github.ref_name }} 40 | mv native.zip release/${{ github.ref_name }}/native.zip 41 | echo '{"tag_name":"${{ github.ref_name }}","assets":[{"browser_download_url":"https://s3.yuudi.dev/rwa/native/${{ github.ref_name }}/native.zip"}]}' > release/version.json 42 | 43 | - name: Upload to S3 44 | uses: docker://rclone/rclone:latest 45 | with: 46 | args: | 47 | ${{ secrets.S3_ARGS }} copy dist/release :s3:yuudi/rwa/native 48 | -------------------------------------------------------------------------------- /.github/workflows/release-standalone.yml: -------------------------------------------------------------------------------- 1 | name: Release Standalone 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | pages: write 14 | id-token: write 15 | 16 | steps: 17 | - uses: actions/checkout@v3 18 | 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: 20 22 | 23 | - name: Cache npm 24 | uses: actions/cache@v2 25 | with: 26 | path: ~/.npm 27 | key: RcloneWebuiAngular-${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 28 | restore-keys: | 29 | RcloneWebuiAngular-${{ runner.os }}-node- 30 | 31 | - name: Build 32 | run: | 33 | npm clean-install 34 | sed -i 's/"baseHref": "\(.*\)"/"baseHref": "rclone-webui-angular\/\1"/g' ./angular.json 35 | npm run build:standalone 36 | cp src/i18n-index.html dist/build/index.html 37 | 38 | - name: Upload artifact 39 | uses: actions/upload-pages-artifact@v1 40 | with: 41 | path: ./dist/build 42 | 43 | - name: Deploy to Github-Pages 44 | uses: actions/deploy-pages@v2 45 | with: 46 | token: ${{ secrets.GITHUB_TOKEN }} 47 | artifact_name: github-pages 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | /bazel-out 8 | 9 | # Node 10 | /node_modules 11 | npm-debug.log 12 | yarn-error.log 13 | 14 | # IDEs and editors 15 | .idea/ 16 | .project 17 | .classpath 18 | .c9/ 19 | *.launch 20 | .settings/ 21 | *.sublime-workspace 22 | 23 | # Visual Studio Code 24 | .vscode/* 25 | !.vscode/extensions.json 26 | !.vscode/settings.json 27 | .history/* 28 | 29 | # Miscellaneous 30 | /.angular/cache 31 | .sass-cache/ 32 | /connect.lock 33 | /coverage 34 | /libpeerconnection.log 35 | testem.log 36 | /typings 37 | 38 | # System files 39 | .DS_Store 40 | Thumbs.db 41 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/.prettierignore -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "johnpapa.angular2", 5 | "angular.ng-template", 6 | "streetsidesoftware.code-spell-checker", 7 | "esbenp.prettier-vscode" 8 | ] 9 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[html]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | }, 5 | "[typescript]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode", 7 | }, 8 | "[sass]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode", 10 | }, 11 | "cSpell.words": [ 12 | "bisync", 13 | "jobid", 14 | "listremotes", 15 | "ngsw", 16 | "noopauth", 17 | "rclone", 18 | "sidenav", 19 | "splitscreen", 20 | "webui" 21 | ] 22 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rclone-Webui-Angular 2 | 3 | This project is another webui for [rclone](https://github.com/rclone/rclone) 4 | 5 | WARNING: this project is still in development, please do not use it in production environment 6 | 7 | ## How to Use 8 | 9 | If you already have rclone installed in your local PC, just run: 10 | 11 | ```bash 12 | rclone rcd --rc-web-gui --rc-web-gui-update --rc-web-fetch-url="https://s3.yuudi.dev/rwa/embed/version.json" 13 | ``` 14 | 15 | If that is not your case, choose one that suits you: 16 | 17 | - [Desktop](./docs/native.md): Good for those who are not familiar with command line 18 | - [Embed](./docs/embed.md): Good for managing local instance 19 | - [PWA Standalone](./docs/pwa.md): Good for managing multiple remote servers 20 | 21 | Other languages: [中文使用说明](./docs/zh/Instructions.md) 22 | 23 | ## Screenshot 24 | 25 |
26 | Expend 27 | 28 | backends 29 | 30 | ![backends-screenshot](./docs/screenshots/backends.png) 31 | 32 | create backends 33 | 34 | ![create-backend-screenshot](./docs/screenshots/create-backend.png) 35 | 36 | explorer 37 | 38 | ![explorer-screenshot](./docs/screenshots/explorer.png) 39 | 40 | mounting 41 | 42 | ![mounting-screenshot](./docs/screenshots/mounting.png) 43 | 44 |
45 | 46 | ## Contribute 47 | 48 | If you feel like coding, translating or just want to help, please check [CONTRIBUTING.md](./docs/CONTRIBUTING.md) 49 | 50 | ### Development 51 | 52 | Run backend: `rclone rcd --rc-user="" --rc-pass="" --rc-addr=127.0.0.1:5572` 53 | 54 | Run frontend: `ng serve` 55 | 56 | Api calling will be proxied to backed [config](./src/proxy.conf.mjs) 57 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "rclone-webui-angular": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "i18n": { 17 | "sourceLocale": { 18 | "code": "en-US", 19 | "baseHref": "en-US/" 20 | }, 21 | "locales": { 22 | "de-DE":{ 23 | "translation": "src/locale/messages.de-DE.xlf", 24 | "baseHref": "de-DE/" 25 | }, 26 | "tr-TR": { 27 | "translation": "src/locale/messages.tr-TR.xlf", 28 | "baseHref": "tr-TR/" 29 | }, 30 | "zh-CN": { 31 | "translation": "src/locale/messages.zh-CN.xlf", 32 | "baseHref": "zh-CN/" 33 | } 34 | } 35 | }, 36 | "architect": { 37 | "build": { 38 | "builder": "@angular-devkit/build-angular:browser", 39 | "options": { 40 | "localize": true, 41 | "outputPath": "dist/build", 42 | "index": "src/index.html", 43 | "main": "src/main.ts", 44 | "polyfills": [ 45 | "zone.js" 46 | ], 47 | "tsConfig": "tsconfig.app.json", 48 | "inlineStyleLanguage": "scss", 49 | "assets": [ 50 | "src/favicon.ico", 51 | "src/assets", 52 | "src/manifest.webmanifest" 53 | ], 54 | "styles": [ 55 | "src/styles.scss" 56 | ], 57 | "scripts": [], 58 | "serviceWorker": true, 59 | "ngswConfigPath": "ngsw-config.json" 60 | }, 61 | "configurations": { 62 | "production": { 63 | "budgets": [ 64 | { 65 | "type": "initial", 66 | "maximumWarning": "500kb", 67 | "maximumError": "1mb" 68 | }, 69 | { 70 | "type": "anyComponentStyle", 71 | "maximumWarning": "2kb", 72 | "maximumError": "4kb" 73 | } 74 | ], 75 | "outputHashing": "all" 76 | }, 77 | "development": { 78 | "buildOptimizer": false, 79 | "optimization": false, 80 | "vendorChunk": true, 81 | "extractLicenses": false, 82 | "sourceMap": true, 83 | "namedChunks": true 84 | }, 85 | "no-localize": { 86 | "localize": false 87 | }, 88 | "embed": { 89 | "fileReplacements": [ 90 | { 91 | "replace": "src/environments/environment.ts", 92 | "with": "src/environments/environment.embed.ts" 93 | } 94 | ] 95 | }, 96 | "standalone": { 97 | "fileReplacements": [ 98 | { 99 | "replace": "src/environments/environment.ts", 100 | "with": "src/environments/environment.standalone.ts" 101 | } 102 | ] 103 | }, 104 | "native": { 105 | "fileReplacements": [ 106 | { 107 | "replace": "src/environments/environment.ts", 108 | "with": "src/environments/environment.native.ts" 109 | } 110 | ] 111 | } 112 | }, 113 | "defaultConfiguration": "production" 114 | }, 115 | "serve": { 116 | "builder": "@angular-devkit/build-angular:dev-server", 117 | "configurations": { 118 | "production": { 119 | "browserTarget": "rclone-webui-angular:build:production" 120 | }, 121 | "development": { 122 | "browserTarget": "rclone-webui-angular:build:development,no-localize" 123 | } 124 | }, 125 | "defaultConfiguration": "development", 126 | "options": { 127 | "proxyConfig": "src/proxy.conf.mjs" 128 | } 129 | }, 130 | "extract-i18n": { 131 | "builder": "ng-extract-i18n-merge:ng-extract-i18n-merge", 132 | "options": { 133 | "browserTarget": "rclone-webui-angular:build", 134 | "format": "xlf", 135 | "outputPath": "src/locale", 136 | "targetFiles": [ 137 | "messages.de-DE.xlf", 138 | "messages.tr-TR.xlf", 139 | "messages.zh-CN.xlf" 140 | ] 141 | } 142 | }, 143 | "test": { 144 | "builder": "@angular-devkit/build-angular:jest", 145 | "options": { 146 | "tsConfig": "tsconfig.spec.json", 147 | "polyfills": [ 148 | "zone.js", 149 | "zone.js/testing" 150 | ] 151 | } 152 | }, 153 | "lint": { 154 | "builder": "@angular-eslint/builder:lint", 155 | "options": { 156 | "lintFilePatterns": [ 157 | "src/**/*.ts", 158 | "src/**/*.html" 159 | ] 160 | } 161 | } 162 | } 163 | } 164 | }, 165 | "cli": { 166 | "analytics": false, 167 | "schematicCollections": [ 168 | "@angular-eslint/schematics" 169 | ] 170 | } 171 | } -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | ## Bug report 4 | 5 | Bug reports are welcome, please open an [issue](https://github.com/yuudi/rclone-webui-angular/issues/new/choose) or [discuss](https://github.com/yuudi/rclone-webui-angular/discussions/new/choose) 6 | 7 | ## Code 8 | 9 | For small bugfix, just open a [pull request](https://github.com/yuudi/rclone-webui-angular/pulls) 10 | 11 | For new feature or big changes, please open an [issue](https://github.com/yuudi/rclone-webui-angular/issues/new/choose) first to discuss 12 | 13 | ### Development environment 14 | 15 | Run backend: `rclone rcd --rc-user="" --rc-pass="" --rc-addr=127.0.0.1:5572` 16 | 17 | Run frontend: `ng serve` 18 | 19 | Api calling will be proxied to backed [config](../src/proxy.conf.mjs) 20 | 21 | ## Translation 22 | 23 | ### Application 24 | 25 | If you want to help translate, first search the issue to see if there is already a translation in progress, if not, open an issue to tell others you are working on it 26 | 27 | Please use translate tool like [Poedit](https://poedit.net/) to translate the [XLIFF file](../src/locale/messages.xlf), save as `messages..xlf` and open a pull request 28 | 29 | ### Documentation 30 | 31 | If you want to write documentation in your language, please create a folder named with your language code in [docs](../docs) and write the documents in it. Translating the "How to use" part is enough, other parts are not necessary. You can also organize the documents in your own way. 32 | -------------------------------------------------------------------------------- /docs/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Because this project is auto-updated. Only the latest version of the project is supported. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | If you want to report a vulnerability, please open an issue and tag it with `security` label. 10 | 11 | If you want to report a vulnerability in private, please encrypt your message with [this gpg key](https://github.com/yuudi.gpg) and open an issue. 12 | -------------------------------------------------------------------------------- /docs/embed.md: -------------------------------------------------------------------------------- 1 | # Using embed build of this project 2 | 3 | Embed build is suitable for managing local instance. 4 | 5 | If you want a native desktop application, please check [native build](./native.md) 6 | If you want to manage multiple remote servers, please check [PWA build](./pwa.md) 7 | 8 | ## Setting up 9 | 10 | 1. Install [rclone](https://rclone.org/downloads/) if you haven't 11 | 12 | 1. Then run the following command 13 | 14 | ```bash 15 | rclone rcd --rc-web-gui --rc-web-gui-update --rc-web-fetch-url="https://s3.yuudi.dev/rwa/embed/version.json" 16 | ``` 17 | 18 | If you have used [rclone-webui-react](https://github.com/rclone/rclone-webui-react) before, you need to force an update by appending `--rc-web-gui-force-update` to the command 19 | 20 | 1. Then the browser will open automatically, if not, follow the link in the terminal 21 | -------------------------------------------------------------------------------- /docs/native.md: -------------------------------------------------------------------------------- 1 | # Using native build of this project 2 | 3 | Native build is simple to use but lacks customization of rclone configuration. 4 | 5 | If you want to use browser instead of electron, please check [embed build](./embed.md) 6 | If you want to manage multiple remote servers, please check [PWA build](./pwa.md) 7 | 8 | ## Installation 9 | 10 | Download installer from [rwa-desktop release](https://github.com/yuudi/rwa-desktop/releases/latest) and choose one that suits your platform. 11 | 12 | Install, Done! 13 | -------------------------------------------------------------------------------- /docs/pwa.md: -------------------------------------------------------------------------------- 1 | # Using PWA build of this project 2 | 3 | PWA build is suitable for managing multiple remote servers. 4 | 5 | If you want to manage local instance, please check [Electron build](./native.md) or [embed build](./embed.md) 6 | 7 | ## Setting up server 8 | 9 | 1. Install [rclone](https://rclone.org/) if you haven't 10 | 11 | ```bash 12 | curl https://rclone.org/install.sh | sudo bash 13 | ``` 14 | 15 | 1. Create a username and password, and start rclone 16 | 17 | ```bash 18 | rclone rcd --rc-serve --rc-user="" --rc-pass="" --rc-addr=127.0.0.1:5572 19 | ``` 20 | 21 | 1. Use reverse proxy and enable HTTPS (important, PWA doesn't allow insecure connection) 22 | 23 | - If you are using Nginx, you can add the following configuration to your Nginx server block 24 | 25 | ```nginx 26 | server { 27 | listen 443 ssl http2; 28 | server_name ; 29 | ssl_certificate ; 30 | ssl_certificate_key ; 31 | location / { 32 | if ($request_method = OPTIONS ) { 33 | return 200; 34 | } 35 | proxy_pass http://127.0.0.1:5572; 36 | proxy_hide_header Access-Control-Allow-Origin; 37 | proxy_hide_header Access-Control-Allow-Headers; 38 | add_header Access-Control-Allow-Origin https://yuudi.github.io; 39 | add_header Access-Control-Allow-Headers "Authorization, Content-type"; 40 | } 41 | } 42 | ``` 43 | 44 | - If you are using Caddy, you can add the following configuration to your Caddyfile (HTTPS is enabled automatically) 45 | 46 | ```Caddyfile 47 | { 48 | @options method OPTIONS 49 | respond @options "" 50 | reverse_proxy 127.0.0.1:5572 { 51 | header_down -Access-Control-Allow-Origin 52 | header_down -Access-Control-Allow-Headers 53 | } 54 | header Access-Control-Allow-Origin https://yuudi.github.io 55 | header Access-Control-Allow-Headers "Authorization, Content-type" 56 | } 57 | ``` 58 | 59 | 1. After that, you can go to and enter your domain, username and password to access your rclone service 60 | -------------------------------------------------------------------------------- /docs/screenshots/backends.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/docs/screenshots/backends.png -------------------------------------------------------------------------------- /docs/screenshots/create-backend.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/docs/screenshots/create-backend.png -------------------------------------------------------------------------------- /docs/screenshots/explorer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/docs/screenshots/explorer.png -------------------------------------------------------------------------------- /docs/screenshots/index.md: -------------------------------------------------------------------------------- 1 | # Screenshot 2 | 3 | backends 4 | 5 | ![backends-screenshot](./backends.png) 6 | 7 | create backends 8 | 9 | ![create-backend-screenshot](./create-backend.png) 10 | 11 | explorer 12 | 13 | ![explorer-screenshot](./explorer.png) 14 | 15 | mounting 16 | 17 | ![mounting-screenshot](./mounting.png) 18 | -------------------------------------------------------------------------------- /docs/screenshots/mounting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/docs/screenshots/mounting.png -------------------------------------------------------------------------------- /docs/zh/Instructions.md: -------------------------------------------------------------------------------- 1 | # 使用方法 2 | 3 | 请根据需要选择[桌面版](#桌面版)或[内嵌至rclone](#内嵌)或[远程服务器使用](#远程服务器) 4 | 5 | 桌面版适合不熟悉命令行的用户管理本地 rclone,内嵌适合管理本地 rclone,远程服务器使用适合管理远程服务器上的 rclone 6 | 7 | ## 桌面版 8 | 9 | 下载 [rwa-desktop](https://github.com/yuudi/rwa-desktop/releases/latest) 并选择适合的安装包,如果不知道选择哪个,选择 `rwa-windows-x64-setup.exe` 即可 10 | 11 | ## 内嵌 12 | 13 | 首先安装 [rclone](https://rclone.org/downloads/) 14 | 15 | 然后运行以下命令获取图形界面 16 | 17 | ```bash 18 | rclone rcd --rc-web-gui --rc-web-gui-update --rc-web-fetch-url="https://s3.yuudi.dev/rwa/embed/version.json" 19 | ``` 20 | 21 | ## 远程服务器 22 | 23 | 安装 rclone 24 | 25 | ```bash 26 | curl https://rclone.org/install.sh | sudo bash 27 | ``` 28 | 29 | 创建一个用户名和密码,并启动 rclone 30 | 31 | ```bash 32 | rclone rcd --rc-serve --rc-user=用户名 --rc-pass=密码 --rc-addr=127.0.0.1:5572 33 | ``` 34 | 35 | 使用反向代理并启用 HTTPS(重要,如不设置你的所有数据将暴露给任何人,而且 PWA 不允许使用不安全的连接) 36 | 37 | 如果你使用 Nginx,你可以使用下面的配置 38 | 39 | ```nginx 40 | server { 41 | listen 443 ssl http2; 42 | server_name 你的域名; 43 | ssl_certificate 你的证书路径; 44 | ssl_certificate_key 你的证书密钥路径; 45 | 46 | location / { 47 | proxy_pass http://127.0.0.1:5572; 48 | 49 | # 如果你使用服务器管理面板,以上的配置可以由面板自动设置,你只需要添加以下内容 50 | 51 | if ($request_method = OPTIONS ) { 52 | return 200; 53 | } 54 | proxy_hide_header Access-Control-Allow-Origin; 55 | proxy_hide_header Access-Control-Allow-Headers; 56 | add_header Access-Control-Allow-Origin https://yuudi.github.io; 57 | add_header Access-Control-Allow-Headers "Authorization, Content-type"; 58 | } 59 | } 60 | ``` 61 | 62 | 完成后,你可以前往 输入你的域名、用户名和密码来访问你的 rclone 服务 63 | 64 | 这个页面使用 PWA 技术,只有首次访问需要魔法,之后不再需要,即使断网也可以使用 65 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: "node", 3 | }; 4 | -------------------------------------------------------------------------------- /ngsw-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/service-worker/config/schema.json", 3 | "index": "/index.html", 4 | "assetGroups": [ 5 | { 6 | "name": "app", 7 | "installMode": "prefetch", 8 | "resources": { 9 | "files": [ 10 | "/favicon.ico", 11 | "/index.html", 12 | "/manifest.webmanifest", 13 | "/*.css", 14 | "/*.js" 15 | ] 16 | } 17 | }, 18 | { 19 | "name": "assets", 20 | "installMode": "lazy", 21 | "updateMode": "prefetch", 22 | "resources": { 23 | "files": [ 24 | "/assets/**", 25 | "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" 26 | ] 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rclone-webui-angular", 3 | "version": "0.8.3", 4 | "private": true, 5 | "scripts": { 6 | "build:dev": "ng build --configuration development", 7 | "build:embed": "ng build --configuration production,embed", 8 | "build:native": "ng build --configuration production,native", 9 | "build:standalone": "ng build --configuration production,standalone", 10 | "check-updates": "npm-check-updates", 11 | "extract-i18n": "ng extract-i18n", 12 | "lint": "npm run lint:eslint && npm run lint:prettier", 13 | "lint:eslint": "ng lint --fix", 14 | "lint:prettier": "prettier ./src --write", 15 | "ng": "ng", 16 | "start": "ng serve", 17 | "test": "ng test", 18 | "watch": "ng build --watch --configuration development,embed" 19 | }, 20 | "dependencies": { 21 | "@angular/animations": "^16.2.0", 22 | "@angular/cdk": "^16.2.0", 23 | "@angular/common": "^16.2.0", 24 | "@angular/compiler": "^16.2.0", 25 | "@angular/core": "^16.2.0", 26 | "@angular/forms": "^16.2.0", 27 | "@angular/material": "^16.2.0", 28 | "@angular/platform-browser": "^16.2.0", 29 | "@angular/platform-browser-dynamic": "^16.2.0", 30 | "@angular/router": "^16.2.0", 31 | "@angular/service-worker": "^16.2.0", 32 | "normalize.css": "^8.0.1", 33 | "rxjs": "~7.8.1", 34 | "tslib": "^2.6.1", 35 | "uuid": "^9.0.0", 36 | "zone.js": "^0.13.1" 37 | }, 38 | "devDependencies": { 39 | "@angular-devkit/build-angular": "^16.2.0", 40 | "@angular-eslint/builder": "^16.1.0", 41 | "@angular-eslint/eslint-plugin": "^16.1.0", 42 | "@angular-eslint/eslint-plugin-template": "^16.1.0", 43 | "@angular-eslint/schematics": "^16.1.0", 44 | "@angular-eslint/template-parser": "^16.1.0", 45 | "@angular/cli": "~16.2.0", 46 | "@angular/compiler-cli": "^16.2.0", 47 | "@angular/localize": "^16.2.0", 48 | "@types/jasmine": "^4.3.5", 49 | "@types/uuid": "^9.0.2", 50 | "@typescript-eslint/eslint-plugin": "^6.3.0", 51 | "@typescript-eslint/parser": "^6.3.0", 52 | "eslint": "^8.47.0", 53 | "jasmine-core": "^5.1.0", 54 | "jest": "^29.6.2", 55 | "jest-environment-jsdom": "^29.6.2", 56 | "ng-extract-i18n-merge": "^2.7.0", 57 | "npm-check-updates": "^16.10.18", 58 | "prettier": "^3.0.1", 59 | "prettier-plugin-organize-imports": "^4.1.0", 60 | "prettier-plugin-packagejson": "^2.5.3", 61 | "typescript": "~5.1.6" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { 3 | NoPreloading, 4 | PreloadAllModules, 5 | RouterModule, 6 | Routes, 7 | } from '@angular/router'; 8 | 9 | import { environment } from 'src/environments/environment'; 10 | import { connectionGuard } from './cores/remote-control/connection.guard'; 11 | 12 | const routes: Routes = [ 13 | { path: '', redirectTo: '/dashboard', pathMatch: 'full' }, 14 | { 15 | path: 'dashboard', 16 | canActivate: [connectionGuard], 17 | loadChildren: () => 18 | import('./features/dashboard/dashboard.module').then( 19 | (m) => m.DashboardModule, 20 | ), 21 | }, 22 | { 23 | path: 'connection', 24 | loadChildren: () => 25 | import('./features/connection/connection.module').then( 26 | (m) => m.ConnectionModule, 27 | ), 28 | }, 29 | { 30 | path: 'rclone', 31 | canActivate: [connectionGuard], 32 | loadChildren: () => 33 | import('./features/functions/functions.module').then( 34 | (m) => m.FunctionsModule, 35 | ), 36 | }, 37 | { 38 | path: '**', 39 | loadComponent: () => 40 | import('./cores/not-found/not-found.component').then( 41 | (m) => m.NotFoundComponent, 42 | ), 43 | }, 44 | ]; 45 | 46 | @NgModule({ 47 | imports: [ 48 | RouterModule.forRoot(routes, { 49 | useHash: true, 50 | preloadingStrategy: environment.prefetch 51 | ? PreloadAllModules 52 | : NoPreloading, 53 | }), 54 | ], 55 | exports: [RouterModule], 56 | }) 57 | export class AppRoutingModule {} 58 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 10 | 11 | 12 | 15 | 16 | 17 | 20 | 28 | 29 | 30 | 36 | 37 | 38 | 43 | 49 | 50 | 51 | 57 | 61 | 62 | 69 | 70 | 71 | 72 | 73 |

Rclone WebUI

74 | 75 | 91 | 92 | 99 | 106 | 113 | 114 | 122 | 123 | 130 | 131 | 140 | 147 | 148 | 149 |
150 | 151 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | height: 100%; 3 | display: flex; 4 | flex-flow: column; 5 | overflow: scroll; 6 | } 7 | 8 | .spacer { 9 | flex: 1 1 auto; 10 | } 11 | 12 | @media (max-width: 499px) { 13 | .shortcuts { 14 | display: none; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { AppComponent } from './app.component'; 3 | 4 | describe('AppComponent', () => { 5 | beforeEach(() => 6 | TestBed.configureTestingModule({ 7 | declarations: [AppComponent], 8 | }), 9 | ); 10 | 11 | it('should create the app', () => { 12 | const fixture = TestBed.createComponent(AppComponent); 13 | const app = fixture.componentInstance; 14 | expect(app).toBeTruthy(); 15 | }); 16 | 17 | it('should render title', () => { 18 | const fixture = TestBed.createComponent(AppComponent); 19 | fixture.detectChanges(); 20 | const compiled = fixture.nativeElement as HTMLElement; 21 | expect(compiled.querySelector('h1')?.textContent).toContain('Rclone WebUI'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { OverlayContainer } from '@angular/cdk/overlay'; 2 | import { Component, HostBinding, OnInit } from '@angular/core'; 3 | import { DomSanitizer } from '@angular/platform-browser'; 4 | 5 | import { MatIconRegistry } from '@angular/material/icon'; 6 | 7 | import { environment } from 'src/environments/environment'; 8 | 9 | type Theme = 'light' | 'dark' | 'auto'; 10 | 11 | @Component({ 12 | selector: 'app-root', 13 | templateUrl: './app.component.html', 14 | styleUrls: ['./app.component.scss'], 15 | }) 16 | export class AppComponent implements OnInit { 17 | @HostBinding('class') className = ''; 18 | 19 | showRemoteSetting = environment.showRemoteSetting; 20 | showScheduledJobs = environment.electron; 21 | theme: Theme = 'auto'; 22 | languages = [ 23 | { 24 | display: 'English', 25 | code: 'en-US', 26 | }, 27 | { 28 | display: 'Deutsch', 29 | code: 'de-DE', 30 | }, 31 | { 32 | display: 'Türkçe', 33 | code: 'tr-TR', 34 | }, 35 | { 36 | display: '简体中文', 37 | code: 'zh-CN', 38 | }, 39 | ]; 40 | 41 | constructor( 42 | iconRegistry: MatIconRegistry, 43 | sanitizer: DomSanitizer, 44 | private overlay: OverlayContainer, 45 | ) { 46 | iconRegistry.addSvgIcon( 47 | 'github', 48 | sanitizer.bypassSecurityTrustResourceUrl( 49 | 'assets/icons/github-mark-white.svg', 50 | ), 51 | ); 52 | } 53 | 54 | ngOnInit(): void { 55 | const theme = (localStorage.getItem('rwa-theme') as Theme) || 'auto'; 56 | this.activateThemeSetting(theme); 57 | } 58 | 59 | activateThemeSetting(theme: Theme) { 60 | localStorage.setItem('rwa-theme', theme); 61 | this.theme = theme; 62 | if (theme === 'light') { 63 | this.activeLightTheme(); 64 | } else if (theme === 'dark') { 65 | this.activeDarkTheme(); 66 | } else if (theme === 'auto') { 67 | if (this.getBrowserPreferDarkTheme()) { 68 | this.activeDarkTheme(); 69 | } else { 70 | this.activeLightTheme(); 71 | } 72 | } 73 | } 74 | 75 | getBrowserPreferDarkTheme() { 76 | const darkQuery = window.matchMedia('(prefers-color-scheme: dark)'); 77 | return darkQuery.matches; 78 | } 79 | 80 | activeDarkTheme() { 81 | this.className = 'dark-theme dark-theme-basic'; 82 | this.overlay.getContainerElement().classList.add('dark-theme'); 83 | } 84 | 85 | activeLightTheme() { 86 | this.className = ''; 87 | this.overlay.getContainerElement().classList.remove('dark-theme'); 88 | } 89 | 90 | activateLanguage(languageCode: string) { 91 | localStorage.setItem('rwa-language', languageCode); 92 | const hashtag = window.location.hash; 93 | const newUrl = new URL( 94 | `../${languageCode}/${hashtag}`, 95 | window.location.href, 96 | ); 97 | window.location.href = newUrl.href; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpClientModule } from '@angular/common/http'; 2 | import { NgModule, isDevMode } from '@angular/core'; 3 | import { BrowserModule } from '@angular/platform-browser'; 4 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 5 | import { ServiceWorkerModule } from '@angular/service-worker'; 6 | 7 | import { MatButtonModule } from '@angular/material/button'; 8 | import { MatIconModule } from '@angular/material/icon'; 9 | import { MatMenuModule } from '@angular/material/menu'; 10 | import { MatToolbarModule } from '@angular/material/toolbar'; 11 | import { MatTooltipModule } from '@angular/material/tooltip'; 12 | 13 | import { environment } from 'src/environments/environment'; 14 | import { AppRoutingModule } from './app-routing.module'; 15 | import { AppComponent } from './app.component'; 16 | 17 | const serviceWorkerModuleWithProvider = ServiceWorkerModule.register( 18 | 'ngsw-worker.js', 19 | { 20 | enabled: !isDevMode() && environment.useServiceWorker, 21 | // Register the ServiceWorker as soon as the application is stable 22 | // or after 30 seconds (whichever comes first). 23 | registrationStrategy: 'registerWhenStable:30000', 24 | }, 25 | ); 26 | 27 | @NgModule({ 28 | declarations: [AppComponent], 29 | imports: [ 30 | BrowserModule, 31 | BrowserAnimationsModule, 32 | AppRoutingModule, 33 | serviceWorkerModuleWithProvider, 34 | HttpClientModule, 35 | MatToolbarModule, 36 | MatIconModule, 37 | MatButtonModule, 38 | MatTooltipModule, 39 | MatMenuModule, 40 | ], 41 | providers: [], 42 | bootstrap: [AppComponent], 43 | }) 44 | export class AppModule {} 45 | -------------------------------------------------------------------------------- /src/app/cores/not-found/not-found.component.html: -------------------------------------------------------------------------------- 1 |

Not Found

2 | return home 3 | -------------------------------------------------------------------------------- /src/app/cores/not-found/not-found.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/src/app/cores/not-found/not-found.component.scss -------------------------------------------------------------------------------- /src/app/cores/not-found/not-found.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { NotFoundComponent } from './not-found.component'; 4 | 5 | describe('NotFoundComponent', () => { 6 | let component: NotFoundComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [NotFoundComponent], 12 | }); 13 | fixture = TestBed.createComponent(NotFoundComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/cores/not-found/not-found.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-not-found', 5 | templateUrl: './not-found.component.html', 6 | styleUrls: ['./not-found.component.scss'], 7 | standalone: true, 8 | }) 9 | export class NotFoundComponent {} 10 | -------------------------------------------------------------------------------- /src/app/cores/remote-control/connection.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { CanActivateFn } from '@angular/router'; 3 | 4 | import { connectionGuard } from './connection.guard'; 5 | 6 | describe('connectionGuard', () => { 7 | const executeGuard: CanActivateFn = (...guardParameters) => 8 | TestBed.runInInjectionContext(() => connectionGuard(...guardParameters)); 9 | 10 | beforeEach(() => { 11 | TestBed.configureTestingModule({}); 12 | }); 13 | 14 | it('should be created', () => { 15 | expect(executeGuard).toBeTruthy(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/app/cores/remote-control/connection.guard.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { CanActivateFn, Router } from '@angular/router'; 3 | 4 | import { environment } from 'src/environments/environment'; 5 | import { 6 | ConnectionService, 7 | NoAuthentication, 8 | NotSaved, 9 | } from './connection.service'; 10 | 11 | export const connectionGuard: CanActivateFn = async () => { 12 | const connectionService = inject(ConnectionService); 13 | if (environment.connectSelf) { 14 | connectionService.activateConnection('self', NoAuthentication); 15 | return true; 16 | } 17 | const router = inject(Router); 18 | if (connectionService.getActiveConnection()) { 19 | return true; 20 | } 21 | const connections = await connectionService.getConnectionsValue(); 22 | if (connections.length === 1) { 23 | if (connections[0].authentication !== NotSaved) { 24 | connectionService.activateConnection(connections[0].id); 25 | return true; 26 | } 27 | } 28 | 29 | router.navigate(['connection']); 30 | return false; 31 | }; 32 | -------------------------------------------------------------------------------- /src/app/cores/remote-control/connection.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { ConnectionService } from './connection.service'; 4 | 5 | describe('ConnectionService', () => { 6 | let service: ConnectionService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(ConnectionService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/cores/remote-control/remote-control.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { RemoteControlService } from './remote-control.service'; 4 | 5 | describe('RemoteControlService', () => { 6 | let service: RemoteControlService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(RemoteControlService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/cores/remote-control/remote-control.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient, HttpErrorResponse } from '@angular/common/http'; 2 | import { Injectable } from '@angular/core'; 3 | import { catchError, lastValueFrom, map, of } from 'rxjs'; 4 | 5 | import { Err, Ok, Result } from 'src/app/shared/result'; 6 | import { ConnectionService, NoAuthentication } from './connection.service'; 7 | 8 | type ErrorResponse = { 9 | error: string; 10 | input: unknown; 11 | status: number; 12 | path: string; 13 | }; 14 | 15 | @Injectable({ 16 | providedIn: 'root', 17 | }) 18 | export class RemoteControlService { 19 | constructor( 20 | private http: HttpClient, 21 | private connectionService: ConnectionService, 22 | ) {} 23 | 24 | /** 25 | * call remote rclone instance, see: https://rclone.org/rc/ 26 | */ 27 | call( 28 | operation: string, 29 | params?: 30 | | { 31 | [key: string]: string | boolean | number | Record; 32 | } 33 | | FormData, 34 | ): Promise> { 35 | const remote = this.connectionService.getActiveConnection(); 36 | const headers: { [key: string]: string } = {}; 37 | if (remote && remote.authentication) { 38 | headers['Authorization'] = `Basic ${remote.authentication}`; 39 | } 40 | 41 | if (!remote) { 42 | throw new Error($localize`Remote address is not set`); 43 | } 44 | return lastValueFrom( 45 | this.http 46 | .post(remote.remoteAddress + '/' + operation, params, { 47 | headers, 48 | }) 49 | .pipe( 50 | map((result) => Ok(result)), 51 | catchError((error: HttpErrorResponse) => { 52 | this.logError(error); 53 | return of( 54 | Err(String(error?.error?.error ?? error?.error ?? error)), 55 | ); 56 | }), 57 | ), 58 | ); 59 | // http observable only emits once, so we can use lastValueFrom 60 | } 61 | 62 | getDownloadUrl(backend: string, file: string): Result { 63 | const remote = this.connectionService.getActiveConnection(); 64 | if (!remote) { 65 | return Err('Remote address is not set'); 66 | } 67 | return Ok( 68 | remote.remoteAddress + 69 | '/[' + 70 | (backend ? backend + ':' : '/') + 71 | ']/' + 72 | file, 73 | ); 74 | } 75 | 76 | downloadFile(backend: string, file: string): Result { 77 | const result = this.getDownloadUrl(backend, file); 78 | if (!result.ok) { 79 | return result; 80 | } 81 | const a = document.createElement('a'); 82 | a.href = result.value; 83 | a.download = file.split('/').pop() ?? file; 84 | a.click(); 85 | a.remove(); 86 | return Ok(); 87 | } 88 | 89 | testConnection( 90 | connection?: { 91 | remoteAddress: string; 92 | credential: 93 | | { 94 | username: string; 95 | password: string; 96 | } 97 | | NoAuthentication; 98 | }, 99 | testAuth = false, 100 | ): Promise { 101 | let remoteAddress: string, authentication: string | NoAuthentication; 102 | if (connection) { 103 | remoteAddress = connection.remoteAddress; 104 | authentication = connection.credential 105 | ? btoa( 106 | connection.credential.username + 107 | ':' + 108 | connection.credential.password, 109 | ) 110 | : NoAuthentication; 111 | } else { 112 | const remote = this.connectionService.getActiveConnection(); 113 | if (!remote) { 114 | return Promise.resolve(false); 115 | } 116 | remoteAddress = remote.remoteAddress; 117 | authentication = remote.authentication; 118 | } 119 | 120 | return lastValueFrom( 121 | this.http 122 | .post( 123 | remoteAddress + (testAuth ? '/rc/noopauth' : '/rc/noop'), 124 | undefined, 125 | { 126 | headers: authentication 127 | ? { Authorization: `Basic ${authentication}` } 128 | : undefined, 129 | observe: 'response', 130 | }, 131 | ) 132 | .pipe( 133 | map((response) => response.status === 200), 134 | catchError(() => of(false)), 135 | ), 136 | ); 137 | } 138 | 139 | private logError(error: HttpErrorResponse): void { 140 | if (error.status === 0) { 141 | // A client-side or network error occurred 142 | console.error('An error occurred:', error.error); 143 | } else { 144 | // The backend returned an unsuccessful response code. 145 | console.error( 146 | `Backend returned code ${error.status}, body was: `, 147 | JSON.stringify(error.error as ErrorResponse), 148 | ); 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/app/cores/storage/app-storage.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { AppStorageService } from './app-storage.service'; 4 | 5 | describe('AppStorageService', () => { 6 | let service: AppStorageService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(AppStorageService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/cores/storage/app-storage.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { BaseStorage } from './base-storage'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class AppStorageService extends BaseStorage { 9 | protected prefix = 'rwa'; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/cores/storage/base-storage.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, Observable } from 'rxjs'; 2 | 3 | export interface StorageItem extends AwaitableStorageItem { 4 | get(): T; 5 | set(v: T): void; 6 | } 7 | 8 | export interface AwaitableStorageItem { 9 | get(): Promise | T; 10 | set(v: T): void; 11 | } 12 | 13 | export interface ObservableAwaitableStorageItem 14 | extends AwaitableStorageItem { 15 | asObservable(): Observable; 16 | destructor(): void; 17 | } 18 | 19 | export abstract class BaseStorage { 20 | protected abstract prefix: string; 21 | 22 | getItem( 23 | localKey: string, 24 | defaultValueFn: () => S, 25 | ): AwaitableStorageItem { 26 | return new LocalStorageStorageItem( 27 | `${this.prefix}-${localKey}`, 28 | defaultValueFn, 29 | ); 30 | } 31 | 32 | getObservableItem( 33 | localKey: string, 34 | defaultValueFn: () => S, 35 | ): ObservableAwaitableStorageItem { 36 | const storageItem = this.getItem(localKey, defaultValueFn); 37 | const valueSubject = new BehaviorSubject(defaultValueFn()); 38 | (async () => { 39 | valueSubject.next(await storageItem.get()); 40 | })(); 41 | return { 42 | get(): S | Promise { 43 | return storageItem.get(); 44 | }, 45 | set(v: S) { 46 | storageItem.set(v); 47 | valueSubject.next(v); 48 | }, 49 | asObservable(): Observable { 50 | return valueSubject; 51 | }, 52 | destructor(): void { 53 | valueSubject.complete(); 54 | }, 55 | }; 56 | } 57 | } 58 | 59 | class LocalStorageStorageItem implements StorageItem { 60 | private _value: T; 61 | constructor( 62 | private key: string, 63 | private defaultFn: () => T, 64 | ) { 65 | const storageString = localStorage.getItem(this.key); 66 | if (storageString !== null) { 67 | this._value = JSON.parse(storageString) as T; 68 | return; 69 | } 70 | this._value = this.defaultFn(); 71 | } 72 | 73 | get(): T { 74 | return this._value; 75 | } 76 | 77 | set(v: T) { 78 | this._value = v; 79 | localStorage.setItem(this.key, JSON.stringify(v)); 80 | } 81 | } 82 | 83 | // class IndexedDbStorageItem implements AsyncStorageItem { 84 | -------------------------------------------------------------------------------- /src/app/cores/storage/index.ts: -------------------------------------------------------------------------------- 1 | export { AppStorageService } from './app-storage.service'; 2 | export { 3 | AwaitableStorageItem, 4 | ObservableAwaitableStorageItem, 5 | } from './base-storage'; 6 | -------------------------------------------------------------------------------- /src/app/features/connection/connection-editor/connection-editor.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | Display Name 4 | 5 | 9 | Please enter a display name 10 | 11 | 15 | A connection with this name already exists 16 | 17 | 18 | 19 | Remote Address 20 | 21 | 25 | Please enter a valid address 26 | 27 | 31 | This address is not secure, please set up an HTTPS connection on that host 32 | 33 | 34 | 35 | This connection is not protected by a password 36 | 37 | 38 | 39 | Username 40 | 41 | 42 | 43 | Password 44 | 45 | 46 | 47 | Remember Password 48 | 49 | 50 | 58 | 67 |
68 | -------------------------------------------------------------------------------- /src/app/features/connection/connection-editor/connection-editor.component.scss: -------------------------------------------------------------------------------- 1 | .connection__container { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | margin: 2em 0; 6 | 7 | .connection__field { 8 | width: 90%; 9 | margin: 1em; 10 | } 11 | 12 | .connection__button { 13 | max-width: 30em; 14 | width: 80%; 15 | margin: 1em; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/app/features/connection/connection-editor/connection-editor.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ConnectionEditorComponent } from './connection-editor.component'; 4 | 5 | describe('ConnectionEditorComponent', () => { 6 | let component: ConnectionEditorComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ConnectionEditorComponent], 12 | }); 13 | fixture = TestBed.createComponent(ConnectionEditorComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/connection/connection-editor/connection-editor.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Input, OnChanges, OnInit } from '@angular/core'; 2 | import { 3 | AsyncValidatorFn, 4 | FormBuilder, 5 | ValidatorFn, 6 | Validators, 7 | } from '@angular/forms'; 8 | import { Router } from '@angular/router'; 9 | 10 | import { MatSnackBar } from '@angular/material/snack-bar'; 11 | 12 | import { 13 | Connection, 14 | ConnectionService, 15 | NoAuthentication, 16 | NotSaved, 17 | } from 'src/app/cores/remote-control/connection.service'; 18 | import { RemoteControlService } from 'src/app/cores/remote-control/remote-control.service'; 19 | import { Err } from 'src/app/shared/result'; 20 | 21 | @Component({ 22 | selector: 'app-connection-editor', 23 | templateUrl: './connection-editor.component.html', 24 | styleUrls: ['./connection-editor.component.scss'], 25 | }) 26 | export class ConnectionEditorComponent implements OnInit, OnChanges { 27 | @Input() connection?: Connection; 28 | connectionForm = this.fb.nonNullable.group({ 29 | displayName: [ 30 | 'New Connection', 31 | [Validators.required], 32 | [this.uniqueNameValidator()], 33 | ], 34 | remoteAddress: [ 35 | ConnectionEditorComponent.getCurrentHost(), 36 | [ 37 | Validators.required, 38 | Validators.pattern(/^(http|https):\/\//), 39 | this.secureContextValidator(), 40 | ], 41 | ], 42 | notProtected: [false], 43 | username: ['', [Validators.required]], 44 | password: ['', [Validators.required]], 45 | remember: [false], 46 | }); 47 | 48 | testResultCache = new Map(); 49 | 50 | static getCurrentHost(): string { 51 | return window.location.origin; 52 | } 53 | 54 | constructor( 55 | private fb: FormBuilder, 56 | private router: Router, 57 | private snackBar: MatSnackBar, 58 | private connectionService: ConnectionService, 59 | private rc: RemoteControlService, 60 | ) {} 61 | 62 | ngOnInit() { 63 | this.updateFormFromInput(); 64 | } 65 | 66 | ngOnChanges() { 67 | this.updateFormFromInput(); 68 | } 69 | 70 | private updateFormFromInput() { 71 | if (this.connection) { 72 | this.connectionForm.patchValue({ 73 | displayName: this.connection.displayName, 74 | remoteAddress: this.connection.remoteAddress, 75 | }); 76 | } 77 | } 78 | 79 | uniqueNameValidator(): AsyncValidatorFn { 80 | return async (control) => { 81 | if (control.value === this.connection?.displayName) { 82 | // The name is not changed 83 | return null; 84 | } 85 | if (await this.connectionService.checkNameExists(control.value)) { 86 | return { nameExists: true }; 87 | } 88 | return null; 89 | }; 90 | } 91 | 92 | secureContextValidator(): ValidatorFn { 93 | return (control) => { 94 | let url; 95 | try { 96 | url = new URL(control.value); 97 | } catch (e) { 98 | return { pattern: true }; 99 | } 100 | if (url.protocol === 'https:') { 101 | return null; 102 | } 103 | if (['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) { 104 | return null; 105 | } 106 | return { insecureContext: true }; 107 | }; 108 | } 109 | 110 | async testConnectionClicked() { 111 | const result = await this.testConnection(); 112 | if (result) { 113 | this.snackBar.open($localize`Connection successful`, 'OK', { 114 | duration: 3000, 115 | }); 116 | } else { 117 | this.snackBar.open($localize`Connection failed`, 'OK', { 118 | duration: 3000, 119 | }); 120 | } 121 | } 122 | 123 | async testConnection(): Promise { 124 | const { remoteAddress, notProtected, username, password } = 125 | this.connectionForm.getRawValue(); 126 | 127 | const cacheIndex = `${remoteAddress}\n${username}\n${password}`; 128 | const success = this.testResultCache.get(cacheIndex); 129 | if (success !== undefined) { 130 | return success; 131 | } 132 | 133 | const credential = notProtected ? NoAuthentication : { username, password }; 134 | const result = await this.rc.testConnection({ remoteAddress, credential }); 135 | this.testResultCache.set(cacheIndex, result); 136 | return result; 137 | } 138 | 139 | async connectButtonClicked() { 140 | const connectResult = await this.testConnection(); 141 | if (!connectResult) { 142 | this.snackBar.open($localize`Connection failed`, 'OK', { 143 | duration: 3000, 144 | }); 145 | return; 146 | } 147 | let result; 148 | if (this.connection) { 149 | result = await this.updateConnectionAndActivate(); 150 | } else { 151 | result = await this.addConnectionAndActivate(); 152 | } 153 | if (result.ok) { 154 | this.router.navigate(['/dashboard']); 155 | } else { 156 | this.snackBar.open($localize`Connection failed:` + result.error, 'OK', { 157 | duration: 3000, 158 | }); 159 | } 160 | } 161 | 162 | async addConnectionAndActivate() { 163 | const { 164 | displayName, 165 | remoteAddress, 166 | notProtected, 167 | username, 168 | password, 169 | remember, 170 | } = this.connectionForm.getRawValue(); 171 | 172 | const credential = notProtected ? NoAuthentication : { username, password }; 173 | 174 | const saveResult = await this.connectionService.saveConnection( 175 | { displayName, remoteAddress }, 176 | remember ? credential : NotSaved, 177 | ); 178 | 179 | if (!saveResult.ok) { 180 | return saveResult; 181 | } 182 | 183 | return this.connectionService.activateConnection( 184 | saveResult.value.id, 185 | credential, 186 | ); 187 | } 188 | 189 | updateConnectionAndActivate() { 190 | const { 191 | displayName, 192 | remoteAddress, 193 | notProtected, 194 | username, 195 | password, 196 | remember, 197 | } = this.connectionForm.getRawValue(); 198 | 199 | const id = this.connection?.id; 200 | if (!id) { 201 | return Err('Connection ID is null'); 202 | } 203 | 204 | const credential = notProtected ? NoAuthentication : { username, password }; 205 | 206 | return this.connectionService.updateConnection( 207 | id, 208 | { displayName, remoteAddress }, 209 | remember ? credential : NotSaved, 210 | ); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/app/features/connection/connection-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { ConnectionEditorComponent } from './connection-editor/connection-editor.component'; 5 | import { ConnectionComponent } from './connection.component'; 6 | 7 | const routes: Routes = [ 8 | { 9 | path: '', 10 | component: ConnectionComponent, 11 | pathMatch: 'full', 12 | }, 13 | { 14 | path: 'new', 15 | component: ConnectionEditorComponent, 16 | }, 17 | ]; 18 | 19 | @NgModule({ 20 | imports: [RouterModule.forChild(routes)], 21 | exports: [RouterModule], 22 | }) 23 | export class ConnectionRoutingModule {} 24 | -------------------------------------------------------------------------------- /src/app/features/connection/connection.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 7 | 8 | 9 | 13 |
14 | {{ connection.displayName }} 15 | 16 | 17 | 25 | 33 | 34 |
35 |
36 | 37 |
38 |
39 | 40 | 41 |

No connections

42 |
43 | 46 |
47 |
48 | 52 |
53 |
54 | -------------------------------------------------------------------------------- /src/app/features/connection/connection.component.scss: -------------------------------------------------------------------------------- 1 | .connection__container { 2 | display: flex; 3 | flex-direction: column; 4 | > * { 5 | flex: 1; 6 | } 7 | } 8 | 9 | .connection__list-container { 10 | display: flex; 11 | flex-direction: column; 12 | align-items: center; 13 | margin: 2em 0; 14 | } 15 | 16 | .connection__list { 17 | max-width: 60em; 18 | width: 90%; 19 | } 20 | 21 | .connection__list-content { 22 | display: flex; 23 | align-items: center; 24 | } 25 | 26 | .spacer { 27 | flex: 1; 28 | } 29 | 30 | .connection__list-action { 31 | button { 32 | margin: 0.5em; 33 | } 34 | } 35 | 36 | @media screen and (min-width: 800px) { 37 | .connection__container { 38 | flex-direction: row; 39 | justify-content: space-around; 40 | align-items: flex-start; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/features/connection/connection.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ConnectionComponent } from './connection.component'; 4 | 5 | describe('ConnectionComponent', () => { 6 | let component: ConnectionComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ConnectionComponent], 12 | }); 13 | fixture = TestBed.createComponent(ConnectionComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/connection/connection.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { MatDialog } from '@angular/material/dialog'; 6 | import { MatSnackBar } from '@angular/material/snack-bar'; 7 | 8 | import { 9 | Connection, 10 | ConnectionService, 11 | NotSaved, 12 | } from 'src/app/cores/remote-control/connection.service'; 13 | import { PromptPasswordComponent } from './prompt-password/prompt-password.component'; 14 | 15 | @Component({ 16 | selector: 'app-connection', 17 | templateUrl: './connection.component.html', 18 | styleUrls: ['./connection.component.scss'], 19 | }) 20 | export class ConnectionComponent implements OnInit { 21 | connections$?: Observable; 22 | selectedConnection?: Connection; 23 | 24 | constructor( 25 | private route: Router, 26 | private snackBar: MatSnackBar, 27 | private dialog: MatDialog, 28 | private connectionService: ConnectionService, 29 | ) {} 30 | 31 | ngOnInit() { 32 | this.connections$ = this.connectionService.getConnections(); 33 | } 34 | 35 | connectSelected(connection: Connection) { 36 | this.selectedConnection = connection; 37 | } 38 | 39 | async connectClicked(connection: Connection) { 40 | if (connection.authentication === NotSaved) { 41 | this.dialog.open(PromptPasswordComponent, { 42 | data: connection, 43 | }); 44 | return; 45 | } 46 | const result = await this.connectionService.activateConnection( 47 | connection.id, 48 | ); 49 | if (!result.ok) { 50 | this.snackBar.open(result.error, $localize`Dismiss`); 51 | return; 52 | } 53 | this.route.navigate(['']); 54 | } 55 | 56 | connectDeleted(connection: Connection) { 57 | this.connectionService.deleteConnection(connection.id); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/app/features/connection/connection.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 4 | 5 | import { MatButtonModule } from '@angular/material/button'; 6 | import { MatCheckboxModule } from '@angular/material/checkbox'; 7 | import { MatDialogModule } from '@angular/material/dialog'; 8 | import { MatDividerModule } from '@angular/material/divider'; 9 | import { MatFormFieldModule } from '@angular/material/form-field'; 10 | import { MatIconModule } from '@angular/material/icon'; 11 | import { MatInputModule } from '@angular/material/input'; 12 | import { MatListModule } from '@angular/material/list'; 13 | import { MatSnackBarModule } from '@angular/material/snack-bar'; 14 | 15 | import { ConnectionEditorComponent } from './connection-editor/connection-editor.component'; 16 | import { ConnectionRoutingModule } from './connection-routing.module'; 17 | import { ConnectionComponent } from './connection.component'; 18 | import { PromptPasswordComponent } from './prompt-password/prompt-password.component'; 19 | 20 | @NgModule({ 21 | declarations: [ 22 | ConnectionEditorComponent, 23 | ConnectionComponent, 24 | PromptPasswordComponent, 25 | ], 26 | imports: [ 27 | CommonModule, 28 | ConnectionRoutingModule, 29 | FormsModule, 30 | ReactiveFormsModule, 31 | MatFormFieldModule, 32 | MatButtonModule, 33 | MatInputModule, 34 | MatCheckboxModule, 35 | MatSnackBarModule, 36 | MatListModule, 37 | MatDividerModule, 38 | MatDialogModule, 39 | MatIconModule, 40 | ], 41 | exports: [ConnectionEditorComponent], 42 | }) 43 | export class ConnectionModule {} 44 | -------------------------------------------------------------------------------- /src/app/features/connection/prompt-password/prompt-password.component.html: -------------------------------------------------------------------------------- 1 |

Enter Password

2 |
7 | 8 | This connection is not protected by a password 9 | 10 | 11 | 12 | Username 13 | 14 | 15 | 16 | Password 17 | 18 | 19 | 20 | Remember Password 21 | 22 | 23 |
24 |
25 | 26 | 35 |
36 | -------------------------------------------------------------------------------- /src/app/features/connection/prompt-password/prompt-password.component.scss: -------------------------------------------------------------------------------- 1 | .prompt__content { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | 6 | .connection__field { 7 | max-width: 40em; 8 | width: 90%; 9 | margin: 1em; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/app/features/connection/prompt-password/prompt-password.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject } from '@angular/core'; 2 | import { FormBuilder, Validators } from '@angular/forms'; 3 | 4 | import { MAT_DIALOG_DATA } from '@angular/material/dialog'; 5 | import { MatSnackBar } from '@angular/material/snack-bar'; 6 | import { Router } from '@angular/router'; 7 | 8 | import { 9 | Connection, 10 | ConnectionService, 11 | NoAuthentication, 12 | } from 'src/app/cores/remote-control/connection.service'; 13 | 14 | @Component({ 15 | templateUrl: './prompt-password.component.html', 16 | styleUrls: ['./prompt-password.component.scss'], 17 | }) 18 | export class PromptPasswordComponent { 19 | authenticationForm = this.fb.nonNullable.group({ 20 | notProtected: [false], 21 | username: ['', [Validators.required]], 22 | password: ['', [Validators.required]], 23 | remember: [false], 24 | }); 25 | 26 | constructor( 27 | private fb: FormBuilder, 28 | private router: Router, 29 | private snackBar: MatSnackBar, 30 | private connectionService: ConnectionService, 31 | @Inject(MAT_DIALOG_DATA) private data: Connection, 32 | ) {} 33 | 34 | async connectClicked() { 35 | if (this.authenticationForm.invalid) { 36 | return; 37 | } 38 | const { notProtected, username, password, remember } = 39 | this.authenticationForm.getRawValue(); 40 | let credentials; 41 | if (notProtected) { 42 | credentials = NoAuthentication; 43 | } else { 44 | credentials = { 45 | username, 46 | password, 47 | }; 48 | } 49 | 50 | if (remember) { 51 | const result = await this.connectionService.updateConnection( 52 | this.data.id, 53 | {}, 54 | credentials, 55 | ); 56 | if (!result.ok) { 57 | this.snackBar.open(result.error, $localize`Dismiss`); 58 | return; 59 | } 60 | } 61 | 62 | const result = await this.connectionService.activateConnection( 63 | this.data.id, 64 | credentials, 65 | ); 66 | if (!result.ok) { 67 | this.snackBar.open(result.error, $localize`Dismiss`); 68 | return; 69 | } 70 | 71 | this.router.navigate(['/dashboard']); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/features/dashboard/dashboard-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { DashboardComponent } from './dashboard.component'; 5 | 6 | const routes: Routes = [ 7 | { 8 | path: '', 9 | component: DashboardComponent, 10 | }, 11 | ]; 12 | 13 | @NgModule({ 14 | imports: [RouterModule.forChild(routes)], 15 | exports: [RouterModule], 16 | }) 17 | export class DashboardRoutingModule {} 18 | -------------------------------------------------------------------------------- /src/app/features/dashboard/dashboard.component.html: -------------------------------------------------------------------------------- 1 | 2 | Backends 3 | 4 | 5 |
...
6 | 16 | 27 | 32 |
33 |
34 |
35 | 36 | 37 | Mountpoints 38 | 39 | 40 | Manage Mountpoints 41 | 42 | 43 | 44 | 45 | 46 | Rclone 47 | 48 | 49 | 50 | 51 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
Version 52 | {{ version.version }} 53 | (Beta) 54 | (Git) 55 |
Platform{{ version.os }}-{{ version.arch }}
Go version{{ version.goVersion }}
66 |
67 |
68 | 69 | 70 | WebUI 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
Version{{ webUIVersion }}
Environment{{ webUIEnv }}
82 |
83 |
84 | 85 | 86 | Statistics 87 | 88 | 89 | 90 | 91 | 94 | 95 | 96 | 97 | 100 | 101 | 102 | 103 | 106 | 107 |
Transferring 92 | {{ stat.transferring?.length ?? 0 }} 93 |
Transferred 98 | {{ stat.transfers }} 99 |
Speed 104 | {{ stat.speed }} 105 |
108 |
109 |
110 | -------------------------------------------------------------------------------- /src/app/features/dashboard/dashboard.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | margin: 3em; 3 | display: flex; 4 | flex-wrap: wrap; 5 | align-items: stretch; 6 | } 7 | 8 | .dashboard__card { 9 | display: inline-block; 10 | margin: 2em; 11 | min-width: 20em; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/features/dashboard/dashboard.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { DashboardComponent } from './dashboard.component'; 4 | 5 | describe('DashboardComponent', () => { 6 | let component: DashboardComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [DashboardComponent], 12 | }); 13 | fixture = TestBed.createComponent(DashboardComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/dashboard/dashboard.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import packageJson from '../../../../package.json'; 5 | import { environment } from '../../../environments/environment'; 6 | 7 | import { RcloneVersionInfo, TransferStatus } from './dashboard.model'; 8 | import { DashboardService } from './dashboard.service'; 9 | 10 | @Component({ 11 | selector: 'app-dashboard', 12 | templateUrl: './dashboard.component.html', 13 | styleUrls: ['./dashboard.component.scss'], 14 | }) 15 | export class DashboardComponent implements OnInit { 16 | backends?: string[] | null; 17 | version?: RcloneVersionInfo; 18 | webUIVersion = packageJson.version; 19 | webUIEnv = environment.environment; 20 | stat$?: Observable; 21 | constructor(private dashboardService: DashboardService) {} 22 | async ngOnInit() { 23 | this.backends = ( 24 | await this.dashboardService.getBackends() 25 | ).orThrow().remotes; 26 | this.version = (await this.dashboardService.getVersion()).orThrow(); 27 | this.stat$ = this.dashboardService.getStat(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/features/dashboard/dashboard.model.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * see https://rclone.org/rc/#core-stats 3 | */ 4 | export interface TransferStatus { 5 | bytes: number; 6 | checks: number; 7 | deletes: number; 8 | elapsedTime: number; 9 | errors: number; 10 | eta: number; 11 | fatalError: boolean; 12 | lastError: string; 13 | renames: number; 14 | retryError: boolean; 15 | speed: number; 16 | totalBytes: number; 17 | totalChecks: number; 18 | totalTransfers: number; 19 | transferTime: number; 20 | transfers: number; 21 | transferring?: { 22 | bytes: number; 23 | eta: number; 24 | name: string; 25 | percentage: number; 26 | speed: number; 27 | speedAvg: number; 28 | size: number; 29 | }[]; 30 | checking: string[]; 31 | } 32 | 33 | /** 34 | * see https://rclone.org/rc/#core-version 35 | */ 36 | export interface RcloneVersionInfo { 37 | version: string; 38 | decomposed: [number, number, number]; 39 | isGit: boolean; 40 | isBeta: boolean; 41 | os: string; 42 | arch: string; 43 | goVersion: string; 44 | linking: string; 45 | goTags: string | 'none'; 46 | } 47 | -------------------------------------------------------------------------------- /src/app/features/dashboard/dashboard.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { MatCardModule } from '@angular/material/card'; 5 | 6 | import { MatButtonModule } from '@angular/material/button'; 7 | import { DashboardRoutingModule } from './dashboard-routing.module'; 8 | import { DashboardComponent } from './dashboard.component'; 9 | 10 | @NgModule({ 11 | declarations: [DashboardComponent], 12 | imports: [ 13 | CommonModule, 14 | DashboardRoutingModule, 15 | MatCardModule, 16 | MatButtonModule, 17 | ], 18 | exports: [DashboardComponent], 19 | }) 20 | export class DashboardModule {} 21 | -------------------------------------------------------------------------------- /src/app/features/dashboard/dashboard.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { DashboardService } from './dashboard.service'; 4 | 5 | describe('DashboardService', () => { 6 | let service: DashboardService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(DashboardService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/features/dashboard/dashboard.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable, map, switchMap, timer } from 'rxjs'; 3 | 4 | import { RemoteControlService } from 'src/app/cores/remote-control/remote-control.service'; 5 | import { RcloneVersionInfo, TransferStatus } from './dashboard.model'; 6 | 7 | @Injectable({ 8 | providedIn: 'root', 9 | }) 10 | export class DashboardService { 11 | constructor(private rc: RemoteControlService) {} 12 | 13 | getBackends() { 14 | return this.rc.call<{ remotes: string[] | null }>('config/listremotes'); 15 | } 16 | 17 | getVersion() { 18 | return this.rc.call('core/version'); 19 | } 20 | 21 | getStat(): Observable { 22 | return timer(0, 10000).pipe( 23 | switchMap(() => this.rc.call('core/stats')), 24 | map((result) => result.orThrow()), 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/backend-info/backend-info.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ backendName }} 4 | 5 | 6 | {{ backend.type }} 7 | 8 | 9 |
Not Measured
10 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 28 | 29 | 30 | 31 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 |
Used{{ usage.used | bytes }}
26 | Trash 27 | {{ usage.trashed | bytes }}
32 | Other 33 | {{ usage.other | bytes }}
Free{{ usage.free | bytes }}
Objects{{ usage.objects }}
46 |
47 |
48 | 49 | 53 | 57 | 58 | 59 | 60 | 63 | 64 | 65 |
66 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/backend-info/backend-info.component.scss: -------------------------------------------------------------------------------- 1 | .backend-info__container { 2 | height: 100%; 3 | } 4 | 5 | .backend-info__title { 6 | margin: 0.5em 1em; 7 | } 8 | 9 | .backend-info__subtitle { 10 | margin: 0 1em; 11 | } 12 | 13 | .backend-info__content { 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | flex-grow: 1; 18 | } 19 | 20 | .backend-info__field { 21 | text-align: center; 22 | } 23 | 24 | .backend-info__value { 25 | text-align: right; 26 | } 27 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/backend-info/backend-info.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { BackendInfoComponent } from './backend-info.component'; 4 | 5 | describe('BackendInfoComponent', () => { 6 | let component: BackendInfoComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [BackendInfoComponent], 12 | }); 13 | fixture = TestBed.createComponent(BackendInfoComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/backend-info/backend-info.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Input, Output } from '@angular/core'; 2 | 3 | import { Backend, BackendUsage } from '../backend.model'; 4 | 5 | @Component({ 6 | selector: 'app-backend-info[backendName][backend]', 7 | templateUrl: './backend-info.component.html', 8 | styleUrls: ['./backend-info.component.scss'], 9 | }) 10 | export class BackendInfoComponent { 11 | @Input() backendName!: string; 12 | @Input() backend!: Backend; 13 | @Input() usage?: BackendUsage | null; 14 | @Output() browse = new EventEmitter(); 15 | @Output() rename = new EventEmitter(); 16 | @Output() duplicate = new EventEmitter(); 17 | @Output() delete = new EventEmitter(); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/backend-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { BackendComponent } from './backend.component'; 4 | 5 | const routes: Routes = [ 6 | { path: '', component: BackendComponent }, 7 | { 8 | path: 'new', 9 | loadChildren: () => 10 | import('./new-backend/new-backend.module').then( 11 | (m) => m.NewBackendModule, 12 | ), 13 | }, 14 | ]; 15 | 16 | @NgModule({ 17 | imports: [RouterModule.forChild(routes)], 18 | exports: [RouterModule], 19 | }) 20 | export class BackendRoutingModule {} 21 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/backend.component.html: -------------------------------------------------------------------------------- 1 |
2 | New Backend 3 |
4 |
5 | 16 |
17 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/backend.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | margin-top: 2em; 3 | } 4 | 5 | .backend__new { 6 | display: flex; 7 | 8 | a { 9 | margin-left: auto; 10 | margin-right: 20%; 11 | } 12 | } 13 | 14 | .backends__container { 15 | margin: 0 3em; 16 | display: flex; 17 | flex-wrap: wrap; 18 | align-items: stretch; 19 | } 20 | 21 | .backends__item { 22 | width: 18em; 23 | display: inline-block; 24 | margin: 2em; 25 | } 26 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/backend.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { BackendComponent } from './backend.component'; 4 | 5 | describe('BackendComponent', () => { 6 | let component: BackendComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [BackendComponent], 12 | }); 13 | fixture = TestBed.createComponent(BackendComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/backend.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { Observable, lastValueFrom, of } from 'rxjs'; 4 | 5 | import { MatDialog } from '@angular/material/dialog'; 6 | 7 | import { Backend, BackendUsage } from './backend.model'; 8 | import { BackendService } from './backend.service'; 9 | import { NewBackendNameComponent } from './new-backend-name/new-backend-name.component'; 10 | 11 | type Unmeasured = null; 12 | const Unmeasured = null; 13 | 14 | @Component({ 15 | selector: 'app-backend', 16 | templateUrl: './backend.component.html', 17 | styleUrls: ['./backend.component.scss'], 18 | }) 19 | export class BackendComponent implements OnInit { 20 | backendList: { 21 | id: string; 22 | config: Backend; 23 | usage$?: Observable; 24 | }[] = []; 25 | 26 | constructor( 27 | private router: Router, 28 | private dialog: MatDialog, 29 | private backendService: BackendService, 30 | ) {} 31 | 32 | async ngOnInit() { 33 | const backends = (await this.backendService.getBackends()).orThrow(); 34 | for (const id in backends) { 35 | this.backendList.push({ 36 | id, 37 | config: backends[id], 38 | }); 39 | this.fetchUsage(id); 40 | } 41 | } 42 | 43 | async fetchUsage(id: string) { 44 | const ref = this.backendList.find((backend) => backend.id === id); 45 | if (!ref) { 46 | console.error(`Backend ${id} not found!`); 47 | return; 48 | } 49 | const info = (await this.backendService.getBackendInfo(id)).orThrow(); 50 | if (!info.Features.About) { 51 | ref.usage$ = of(Unmeasured); 52 | return; 53 | } 54 | ref.usage$ = this.backendService.getBackendUsage(id); 55 | } 56 | 57 | backendBrowse(id: string) { 58 | this.router.navigate(['rclone', 'explore'], { 59 | queryParams: { drive: id }, 60 | }); 61 | } 62 | 63 | async backendRename(backend: { id: string; config: Backend }) { 64 | // there is no API to rename, just to create a new one and delete old one 65 | const copied = await this.backendDuplicate(backend); 66 | if (copied) { 67 | await this.backendDelete(backend.id); 68 | } 69 | } 70 | 71 | async backendDuplicate(backend: { id: string; config: Backend }) { 72 | const dialog = this.dialog.open(NewBackendNameComponent, { 73 | data: { occupiedList: this.backendList.map((backend) => backend.id) }, 74 | }); 75 | const newName = await lastValueFrom(dialog.afterClosed()); 76 | if (!newName) { 77 | return 0; 78 | } 79 | const { type, ...options } = backend.config; 80 | (await this.backendService.createBackend(newName, type, options)).orThrow(); 81 | this.backendList.push({ ...backend, id: newName }); 82 | return 1; 83 | } 84 | 85 | async backendDelete(id: string) { 86 | (await this.backendService.deleteBackend(id)).orThrow(); 87 | const index = this.backendList.findIndex((backend) => backend.id === id); 88 | this.backendList.splice(index, 1); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/backend.model.ts: -------------------------------------------------------------------------------- 1 | export interface Backend { 2 | type: string; 3 | [key: string]: string; 4 | } 5 | 6 | export interface BackendUsage { 7 | total?: number; // if not set, it's unlimited 8 | used?: number; 9 | trashed?: number; 10 | other?: number; 11 | free?: number; 12 | objects?: number; // count of objects 13 | } 14 | 15 | export interface FsInfo { 16 | Features: { [key in FeatureType]: boolean }; // Thank you backend developers for this mess. 17 | Hashes: HashType[]; 18 | Name: string; 19 | } 20 | 21 | type FeatureType = 22 | | 'About' 23 | | 'BucketBased' 24 | | 'BucketBasedRootOK' 25 | | 'CanHaveEmptyDirectories' 26 | | 'CaseInsensitive' 27 | | 'ChangeNotify' 28 | | 'CleanUp' 29 | | 'Command' 30 | | 'Copy' 31 | | 'DirCacheFlush' 32 | | 'DirMove' 33 | | 'Disconnect' 34 | | 'DuplicateFiles' 35 | | 'GetTier' 36 | | 'IsLocal' 37 | | 'ListR' 38 | | 'MergeDirs' 39 | | 'MetadataInfo' 40 | | 'Move' 41 | | 'OpenWriterAt' 42 | | 'PublicLink' 43 | | 'Purge' 44 | | 'PutStream' 45 | | 'PutUnchecked' 46 | | 'ReadMetadata' 47 | | 'ReadMimeType' 48 | | 'ServerSideAcrossConfigs' 49 | | 'SetTier' 50 | | 'SetWrapper' 51 | | 'Shutdown' 52 | | 'SlowHash' 53 | | 'SlowModTime' 54 | | 'UnWrap' 55 | | 'UserInfo' 56 | | 'UserMetadata' 57 | | 'WrapFs' 58 | | 'WriteMetadata' 59 | | 'WriteMimeType'; 60 | 61 | type HashType = 62 | | 'md5' 63 | | 'sha1' 64 | | 'whirlpool' 65 | | 'crc32' 66 | | 'sha256' 67 | | 'dropbox' 68 | | 'hidrive' 69 | | 'mailru' 70 | | 'quickxor'; 71 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/backend.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MatCardModule } from '@angular/material/card'; 6 | import { MatDialogModule } from '@angular/material/dialog'; 7 | import { MatIconModule } from '@angular/material/icon'; 8 | import { MatMenuModule } from '@angular/material/menu'; 9 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 10 | import { MatSnackBarModule } from '@angular/material/snack-bar'; 11 | 12 | import { FormsModule } from '@angular/forms'; 13 | import { MatInputModule } from '@angular/material/input'; 14 | import { BytesPipe } from 'src/app/shared/bytes.pipe'; 15 | import { SingleClickDirective } from 'src/app/shared/single-click.directive'; 16 | import { BackendInfoComponent } from './backend-info/backend-info.component'; 17 | import { BackendRoutingModule } from './backend-routing.module'; 18 | import { BackendComponent } from './backend.component'; 19 | import { NewBackendNameComponent } from './new-backend-name/new-backend-name.component'; 20 | 21 | @NgModule({ 22 | declarations: [ 23 | BackendComponent, 24 | BackendInfoComponent, 25 | BytesPipe, 26 | SingleClickDirective, 27 | NewBackendNameComponent, 28 | ], 29 | imports: [ 30 | CommonModule, 31 | FormsModule, 32 | BackendRoutingModule, 33 | MatCardModule, 34 | MatInputModule, 35 | MatProgressSpinnerModule, 36 | MatButtonModule, 37 | MatMenuModule, 38 | MatIconModule, 39 | MatSnackBarModule, 40 | MatDialogModule, 41 | ], 42 | exports: [BackendComponent], 43 | }) 44 | export class BackendModule {} 45 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/backend.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { BackendService } from './backend.service'; 4 | 5 | describe('BackendService', () => { 6 | let service: BackendService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(BackendService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/backend.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Observable } from 'rxjs'; 3 | 4 | import { ConnectionService } from 'src/app/cores/remote-control/connection.service'; 5 | import { RemoteControlService } from 'src/app/cores/remote-control/remote-control.service'; 6 | import { AppStorageService, AwaitableStorageItem } from 'src/app/cores/storage'; 7 | import { Ok, Result } from 'src/app/shared/result'; 8 | import { Backend, BackendUsage, FsInfo } from './backend.model'; 9 | 10 | @Injectable({ 11 | providedIn: 'root', 12 | }) 13 | export class BackendService { 14 | backendUsageCacheStorage?: AwaitableStorageItem<{ 15 | [id: string]: { usage: BackendUsage | null; updated: number }; 16 | }>; 17 | 18 | backendInfoCacheStorage?: AwaitableStorageItem<{ 19 | [id: string]: FsInfo; 20 | }>; 21 | 22 | constructor( 23 | private AwaitableStorageItem: AppStorageService, 24 | private rc: RemoteControlService, 25 | connectionService: ConnectionService, 26 | ) { 27 | connectionService 28 | .getActiveConnectionObservable() 29 | .subscribe((connection) => { 30 | if (!connection) { 31 | return; 32 | } 33 | 34 | this.backendUsageCacheStorage = this.AwaitableStorageItem.getItem( 35 | `${connection.id}-backendUsageCache`, 36 | () => { 37 | return {}; 38 | }, 39 | ); 40 | 41 | this.backendInfoCacheStorage = this.AwaitableStorageItem.getItem( 42 | `${connection.id}-backendInfoCache`, 43 | () => { 44 | return {}; 45 | }, 46 | ); 47 | }); 48 | } 49 | 50 | async listBackends(): Promise> { 51 | const result = await this.rc.call<{ remotes: string[] }>( 52 | 'config/listremotes', 53 | ); 54 | if (!result.ok) { 55 | return result; 56 | } 57 | return Ok(result.value.remotes); 58 | } 59 | 60 | getBackends() { 61 | return this.rc.call<{ [id: string]: Backend }>('config/dump'); 62 | } 63 | 64 | getBackendById(id: string) { 65 | return this.rc.call('config/get', { name: id }); 66 | } 67 | 68 | getBackendUsage(id: string): Observable { 69 | if (!this.backendUsageCacheStorage) { 70 | throw new Error('backendUsageCacheStorage is not initialized'); 71 | } 72 | const storage = this.backendUsageCacheStorage; 73 | const now = new Date().getTime() / 1000; 74 | 75 | // this observable will emit once or twice 76 | // if the cache is not expired, it will emit once 77 | // then it will emit again after the remote call 78 | return new Observable((observer) => { 79 | // this construction need a void return 80 | (async () => { 81 | const cache = await storage.get(); 82 | const cachedUsage = cache[id]; 83 | if (cachedUsage) { 84 | observer.next(cachedUsage.usage); 85 | if (cachedUsage.updated > now - 60 * 60) { 86 | return observer.complete(); 87 | } 88 | } 89 | const usageResult = await this.rc.call( 90 | 'operations/about', 91 | { 92 | fs: id + ':', 93 | }, 94 | ); 95 | if (!usageResult.ok) { 96 | return observer.error(usageResult.error); 97 | } 98 | cache[id] = { 99 | usage: usageResult.value, 100 | updated: now, 101 | }; 102 | storage.set(cache); 103 | observer.next(usageResult.value); 104 | observer.complete(); 105 | })(); 106 | }); 107 | } 108 | 109 | async checkWindowsDriveExist(drive: string): Promise { 110 | const result = await this.rc.call('operations/about', { 111 | fs: drive + ':/', 112 | }); 113 | return result.ok; 114 | } 115 | 116 | async getBackendInfo(id: string): Promise> { 117 | if (!this.backendInfoCacheStorage) { 118 | throw new Error('backendUsageCacheStorage is not initialized'); 119 | } 120 | const cache = await this.backendInfoCacheStorage.get(); 121 | const cachedInfo = cache[id]; 122 | if (cachedInfo) { 123 | return Ok(cachedInfo); 124 | } 125 | const result = await this.rc.call('operations/fsinfo', { 126 | fs: id ? id + ':' : '/', 127 | }); 128 | if (!result.ok) { 129 | return result; 130 | } 131 | cache[id] = result.value; 132 | this.backendInfoCacheStorage.set(cache); 133 | return result; 134 | } 135 | 136 | createBackend( 137 | name: string, 138 | providerName: string, 139 | options: { [key: string]: string }, 140 | ): Promise> { 141 | return this.rc.call('config/create', { 142 | name, 143 | type: providerName, 144 | parameters: options, 145 | }); 146 | } 147 | 148 | deleteBackend(name: string): Promise> { 149 | return this.rc.call('config/delete', { name }); 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/new-backend-name/new-backend-name.component.html: -------------------------------------------------------------------------------- 1 |

Choose a new name

2 |
3 | 4 | Backend Name 5 | 6 | 7 | This name has been used 8 | 9 | 10 |
11 |
12 | 13 | 21 |
22 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/new-backend-name/new-backend-name.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/src/app/features/functions/backend/new-backend-name/new-backend-name.component.scss -------------------------------------------------------------------------------- /src/app/features/functions/backend/new-backend-name/new-backend-name.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { NewBackendNameComponent } from './new-backend-name.component'; 4 | 5 | describe('NewBackendNameComponent', () => { 6 | let component: NewBackendNameComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [NewBackendNameComponent], 12 | }); 13 | fixture = TestBed.createComponent(NewBackendNameComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/new-backend-name/new-backend-name.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject } from '@angular/core'; 2 | 3 | import { MAT_DIALOG_DATA } from '@angular/material/dialog'; 4 | 5 | @Component({ 6 | selector: 'app-new-backend-name', 7 | templateUrl: './new-backend-name.component.html', 8 | styleUrls: ['./new-backend-name.component.scss'], 9 | }) 10 | export class NewBackendNameComponent { 11 | newName = ''; 12 | constructor( 13 | @Inject(MAT_DIALOG_DATA) public data: { occupiedList: string[] }, 14 | ) {} 15 | } 16 | 17 | // function uniqueNameValidator(nameList: string[]): ValidatorFn { 18 | // return (control: AbstractControl) => { 19 | // return nameList.includes(control.value) ? { nameExist: true } : null; 20 | // }; 21 | // } 22 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/new-backend/new-backend-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { NewBackendComponent } from './new-backend.component'; 5 | 6 | const routes: Routes = [{ path: '', component: NewBackendComponent }]; 7 | 8 | @NgModule({ 9 | imports: [RouterModule.forChild(routes)], 10 | exports: [RouterModule], 11 | }) 12 | export class NewBackendRoutingModule {} 13 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/new-backend/new-backend.component.scss: -------------------------------------------------------------------------------- 1 | .new-backend__container { 2 | margin: 1em; 3 | } 4 | 5 | .new-backend__list-container { 6 | height: 25em; 7 | overflow-y: scroll; 8 | } 9 | 10 | .new-backend__list-card-container, 11 | .new-backend__provider-search-bar, 12 | .new-backend__name-input { 13 | width: 80%; 14 | max-width: 70em; 15 | margin: 1em auto; 16 | } 17 | 18 | .new-backend__button { 19 | display: flex; 20 | justify-content: center; 21 | } 22 | 23 | .new-backend__option-hint { 24 | width: 25em; 25 | margin: 1em auto; 26 | } 27 | 28 | .new-backend__option-form { 29 | display: flex; 30 | flex-direction: column; 31 | align-items: center; 32 | margin: 2em 0; 33 | } 34 | 35 | .new-backend__option-field { 36 | display: flex; 37 | flex-direction: row; 38 | align-items: center; 39 | max-width: 60em; 40 | width: 90%; 41 | margin: 1em; 42 | } 43 | 44 | .new-backend__option-field-input { 45 | flex-shrink: 0; 46 | width: 15em; 47 | margin: 1em; 48 | } 49 | 50 | .new-backend__option-autocomplete-value { 51 | margin: 0; 52 | } 53 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/new-backend/new-backend.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { NewBackendComponent } from './new-backend.component'; 4 | 5 | describe('NewBackendComponent', () => { 6 | let component: NewBackendComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [NewBackendComponent], 12 | }); 13 | fixture = TestBed.createComponent(NewBackendComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/new-backend/new-backend.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | import { FormControl, Validators } from '@angular/forms'; 3 | import { Router } from '@angular/router'; 4 | 5 | import { MatDialog } from '@angular/material/dialog'; 6 | import { MatSnackBar } from '@angular/material/snack-bar'; 7 | 8 | import { ConnectionService } from 'src/app/cores/remote-control/connection.service'; 9 | import { AppStorageService } from 'src/app/cores/storage'; 10 | import { Err, Ok, Result } from 'src/app/shared/result'; 11 | import { SimpleDialogComponent } from 'src/app/shared/simple-dialog/simple-dialog.component'; 12 | import { AppProvider } from './new-backend.model'; 13 | import { NewBackendService } from './new-backend.service'; 14 | 15 | @Component({ 16 | selector: 'app-new-backend', 17 | templateUrl: './new-backend.component.html', 18 | styleUrls: ['./new-backend.component.scss'], 19 | }) 20 | export class NewBackendComponent implements OnInit { 21 | stepperSelectedIndex = 0; 22 | providers?: Promise>; 23 | newBackendName = new FormControl('', [ 24 | Validators.required, 25 | Validators.min(2), 26 | Validators.pattern('^[a-zA-Z0-9_-]*$'), 27 | ]); 28 | providerSelected?: AppProvider; 29 | providerSearchString = ''; 30 | providerOptions: { [key: string]: string } = {}; 31 | providerNeedAuth = false; 32 | showAdvancedOptions = false; 33 | requiredFieldHint = false; 34 | requiredFieldHintStorage; 35 | waitingForBackend = false; 36 | constructor( 37 | private router: Router, 38 | private snackBar: MatSnackBar, 39 | private dialog: MatDialog, 40 | private appStorageService: AppStorageService, 41 | private newBackendService: NewBackendService, 42 | private connectionService: ConnectionService, 43 | ) { 44 | this.requiredFieldHintStorage = this.appStorageService.getItem( 45 | 'NewBackendRequiredFieldHint', 46 | () => true, 47 | ); 48 | } 49 | 50 | ngOnInit(): void { 51 | this.providers = this.newBackendService.getProviders(); 52 | (async () => { 53 | this.requiredFieldHint = await this.requiredFieldHintStorage.get(); 54 | })(); 55 | } 56 | 57 | hintDismissClicked() { 58 | this.requiredFieldHintStorage.set(false); 59 | this.requiredFieldHint = false; 60 | } 61 | 62 | providerSelectedChanged(provider: AppProvider) { 63 | this.providerSelected = provider; 64 | this.resetProviderOptions(); 65 | this.providerNeedAuth = provider.Options.some( 66 | (option) => option.Name === 'token', 67 | ); 68 | } 69 | 70 | providerSelectedConfirmed() { 71 | // warn if it is a remote backend and need auth 72 | const address = this.connectionService.getActiveConnection()?.remoteAddress; 73 | if (!address) { 74 | throw new Error('No active connection'); 75 | } 76 | const hostname = new URL(address).hostname; 77 | if ( 78 | hostname === 'localhost' || 79 | hostname === '127.0.0.1' || 80 | hostname === '[::1]' 81 | ) { 82 | return; 83 | } 84 | 85 | if (this.providerSelected === undefined) { 86 | throw new Error('No provider selected'); 87 | } 88 | const needAuth = this.providerSelected.Options.some( 89 | (option) => option.Name === 'token', 90 | ); 91 | if (!needAuth) { 92 | return; 93 | } 94 | 95 | // warn user 96 | const enum action { 97 | back, 98 | continue, 99 | } 100 | this.dialog 101 | .open(SimpleDialogComponent, { 102 | data: { 103 | title: $localize`Warning`, 104 | message: $localize`This provider requires authentication. Because you are using a remote backend, automatic authentication (OAuth) is not possible for you. You may need to authorize on local rclone instance and copy the token to the backend.`, 105 | actions: [ 106 | { label: $localize`Go back`, value: action.back }, 107 | { label: $localize`Continue Anyway`, value: action.continue }, 108 | ], 109 | }, 110 | }) 111 | .afterClosed() 112 | .subscribe((result) => { 113 | if (result !== action.continue) { 114 | this.providerSelected = undefined; 115 | this.stepperSelectedIndex = 0; 116 | } 117 | }); 118 | } 119 | 120 | resetProviderOptions() { 121 | if (!this.providerSelected) { 122 | return Err('No provider selected'); 123 | } 124 | this.providerOptions = Object.fromEntries( 125 | this.providerSelected.Options.map((option) => [ 126 | option.Name, 127 | option.DefaultStr, 128 | ]), 129 | ); 130 | return Ok(); 131 | } 132 | 133 | async saveClicked() { 134 | const options = Object.fromEntries( 135 | Object.entries(this.providerOptions).filter(([, value]) => value !== ''), 136 | ); 137 | if (this.providerNeedAuth && !options['token']) { 138 | options['token'] = ''; 139 | } 140 | const name = this.newBackendName.value; 141 | if (!name) { 142 | console.error('No name provided'); 143 | return; 144 | } 145 | const backend = this.providerSelected?.Name; 146 | if (!backend) { 147 | console.error('No backend selected'); 148 | return; 149 | } 150 | this.waitingForBackend = true; 151 | const result = await this.newBackendService.createBackend( 152 | name, 153 | backend, 154 | options, 155 | ); 156 | if (result.ok) { 157 | this.router.navigate(['rclone', 'drive']); 158 | } else { 159 | this.snackBar.open( 160 | $localize`Error creating backend: ` + result.error, 161 | $localize`Dismiss`, 162 | ); 163 | this.waitingForBackend = false; 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/new-backend/new-backend.model.ts: -------------------------------------------------------------------------------- 1 | export interface AppProvider { 2 | Name: string; 3 | Description: string; 4 | Prefix: string; 5 | Options: Option[]; 6 | CommandHelp: CommandHelp[] | null; 7 | } 8 | 9 | export interface CommandHelp { 10 | Name: string; 11 | Short: string; 12 | Long: string; 13 | Opts: Opts | null; 14 | } 15 | 16 | export interface Opts { 17 | chunk_size?: string; 18 | service_account_file?: string; 19 | target?: string; 20 | echo?: string; 21 | error?: string; 22 | description?: string; 23 | lifetime?: string; 24 | priority?: string; 25 | 'max-age'?: string; 26 | } 27 | 28 | type HttpHeaders = Record; 29 | 30 | export interface Option { 31 | Name: string; 32 | Help: string; 33 | Provider: string; 34 | Default: HttpHeaders | boolean | number | string; 35 | Value: null; 36 | ShortOpt: string; 37 | Hide: 0 | 2 | 3; 38 | Required: boolean; 39 | IsPassword: boolean; 40 | NoPrefix: boolean; 41 | Advanced: boolean; 42 | DefaultStr: string; 43 | ValueStr: string; 44 | Type: 45 | | 'bool' 46 | | 'CommaSepList' 47 | | 'Duration' 48 | | 'int' 49 | | 'MultiEncoder' 50 | | 'SizeSuffix' 51 | | 'string'; 52 | Examples?: Example[]; 53 | } 54 | 55 | export interface Example { 56 | Value: string; 57 | Help: string; 58 | Provider: string; 59 | } 60 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/new-backend/new-backend.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 4 | 5 | import { MatAutocompleteModule } from '@angular/material/autocomplete'; 6 | import { MatButtonModule } from '@angular/material/button'; 7 | import { MatCardModule } from '@angular/material/card'; 8 | import { MatDividerModule } from '@angular/material/divider'; 9 | import { MatFormFieldModule } from '@angular/material/form-field'; 10 | import { MatIconModule } from '@angular/material/icon'; 11 | import { MatInputModule } from '@angular/material/input'; 12 | import { MatListModule } from '@angular/material/list'; 13 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 14 | import { MatSelectModule } from '@angular/material/select'; 15 | import { MatSnackBarModule } from '@angular/material/snack-bar'; 16 | import { MatStepperModule } from '@angular/material/stepper'; 17 | 18 | import { SearchPipe } from 'src/app/shared/search.pipe'; 19 | import { NewBackendRoutingModule } from './new-backend-routing.module'; 20 | import { NewBackendComponent } from './new-backend.component'; 21 | 22 | @NgModule({ 23 | declarations: [NewBackendComponent, SearchPipe], 24 | imports: [ 25 | CommonModule, 26 | FormsModule, 27 | ReactiveFormsModule, 28 | MatFormFieldModule, 29 | MatInputModule, 30 | MatStepperModule, 31 | MatSelectModule, 32 | MatButtonModule, 33 | MatCardModule, 34 | MatListModule, 35 | MatIconModule, 36 | NewBackendRoutingModule, 37 | MatDividerModule, 38 | MatAutocompleteModule, 39 | MatSnackBarModule, 40 | MatProgressSpinnerModule, 41 | ], 42 | }) 43 | export class NewBackendModule {} 44 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/new-backend/new-backend.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { NewBackendService } from './new-backend.service'; 4 | 5 | describe('NewBackendService', () => { 6 | let service: NewBackendService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(NewBackendService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/features/functions/backend/new-backend/new-backend.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { RemoteControlService } from 'src/app/cores/remote-control/remote-control.service'; 4 | import { Ok, Result } from 'src/app/shared/result'; 5 | import { AppProvider } from './new-backend.model'; 6 | 7 | @Injectable({ 8 | providedIn: 'root', 9 | }) 10 | export class NewBackendService { 11 | providersCache?: AppProvider[]; 12 | 13 | constructor(private rc: RemoteControlService) {} 14 | 15 | async getProviders(): Promise> { 16 | if (this.providersCache) { 17 | return Ok(this.providersCache); 18 | } 19 | const result = await this.rc.call<{ 20 | providers: AppProvider[]; 21 | }>('config/providers'); 22 | if (!result.ok) { 23 | return result; 24 | } 25 | this.providersCache = result.value.providers; 26 | return Ok(this.providersCache); 27 | } 28 | 29 | createBackend( 30 | name: string, 31 | providerName: string, 32 | options: { [key: string]: string }, 33 | ): Promise> { 34 | return this.rc.call('config/create', { 35 | name, 36 | type: providerName, 37 | parameters: options, 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron-editor/cron-editor.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | Advanced Cron Expression 4 | 5 | 6 | 10 | 11 | 12 | CRON Expression 13 | 14 | 15 | Please enter a valid CRON expression 16 | 17 | 18 | 19 | 20 | Operation 21 | 22 | 23 | Sync (Delete items that is not present at source) 24 | 25 | 26 | Copy (Keep items that is not present at source) 27 | 28 | Bi-direction Sync 29 | 30 | 31 | 32 | 33 | from 34 | 35 | 36 | 37 | 38 | to 39 | 40 | 41 | 42 | 43 |
44 | -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron-editor/cron-editor.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/src/app/features/functions/cron/cron-editor/cron-editor.component.scss -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron-editor/cron-editor.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { CronEditorComponent } from './cron-editor.component'; 4 | 5 | describe('CronEditorComponent', () => { 6 | let component: CronEditorComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [CronEditorComponent], 12 | }); 13 | fixture = TestBed.createComponent(CronEditorComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron-editor/cron-editor.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | EventEmitter, 4 | Input, 5 | OnDestroy, 6 | OnInit, 7 | Output, 8 | } from '@angular/core'; 9 | import { 10 | AsyncValidatorFn, 11 | FormBuilder, 12 | ValidationErrors, 13 | Validators, 14 | } from '@angular/forms'; 15 | 16 | import { 17 | Subject, 18 | Subscription, 19 | debounceTime, 20 | distinctUntilChanged, 21 | first, 22 | map, 23 | switchMap, 24 | } from 'rxjs'; 25 | 26 | import { CronService, Task } from '../cron.service'; 27 | 28 | @Component({ 29 | selector: 'app-cron-editor', 30 | templateUrl: './cron-editor.component.html', 31 | styleUrls: ['./cron-editor.component.scss'], 32 | }) 33 | export class CronEditorComponent implements OnInit, OnDestroy { 34 | cronForm = this.fb.nonNullable.group({ 35 | advancedCron: [false], 36 | expression: ['', [Validators.required], [this.cronExpressionValidator()]], 37 | operation: [''], 38 | from: [''], 39 | to: [''], 40 | }); 41 | @Input() task?: Task; 42 | @Output() taskChange = new EventEmitter(); 43 | constructor( 44 | private fb: FormBuilder, 45 | private cronService: CronService, 46 | ) {} 47 | 48 | ngOnInit(): void { 49 | if (this.task) { 50 | this.patchTaskToForm(this.task); 51 | } 52 | } 53 | 54 | subscriptionGroup = new Subscription(); 55 | 56 | ngOnDestroy(): void { 57 | this.subscriptionGroup.unsubscribe(); 58 | } 59 | 60 | patchTaskToForm(task: Task): void { 61 | console.log(task); 62 | //TODO: implement 63 | } 64 | 65 | cronExpressionValidator(): AsyncValidatorFn { 66 | const inputSubject = new Subject(); 67 | const resultSubject = new Subject(); 68 | inputSubject 69 | .pipe( 70 | distinctUntilChanged(), 71 | debounceTime(1000), 72 | switchMap((expression) => 73 | this.cronService.validateExpression(expression), 74 | ), 75 | map((valid) => 76 | valid ? null : { invalidCronExpression: true }, 77 | ), 78 | ) 79 | .subscribe(resultSubject); 80 | this.subscriptionGroup.add(inputSubject); 81 | this.subscriptionGroup.add(resultSubject); 82 | return (control) => { 83 | inputSubject.next(control.value); 84 | return resultSubject.pipe(first()); 85 | }; 86 | } 87 | 88 | async saveClicked() { 89 | const expression = this.cronForm.value.expression; 90 | const operation = this.cronForm.value.operation; 91 | if (!expression || !operation) { 92 | throw new Error('Invalid cron expression or operation'); 93 | } 94 | let params; 95 | if (operation === 'sync') { 96 | params = { 97 | srcFs: this.cronForm.value.from, 98 | dstFs: this.cronForm.value.to, 99 | createEmptySrcDirs: true, 100 | }; 101 | } else if (operation === 'move') { 102 | params = { 103 | srcFs: this.cronForm.value.from, 104 | dstFs: this.cronForm.value.to, 105 | createEmptySrcDirs: true, 106 | deleteEmptySrcDirs: false, 107 | }; 108 | } else if (operation === 'bisync') { 109 | params = { 110 | path1: this.cronForm.value.from, 111 | path2: this.cronForm.value.to, 112 | resync: true, 113 | maxDelete: 100, 114 | }; 115 | } else { 116 | throw new Error('Invalid operation'); 117 | } 118 | const task = await this.cronService.toTask( 119 | expression, 120 | 'sync/' + operation, 121 | params, 122 | ); 123 | this.taskChange.emit(task); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron-editor/easy-cron/easy-cron.component.html: -------------------------------------------------------------------------------- 1 | every 2 | 10 | 11 | hour 12 | day 13 | 14 | at 15 | 21 | : 22 | 28 | -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron-editor/easy-cron/easy-cron.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/src/app/features/functions/cron/cron-editor/easy-cron/easy-cron.component.scss -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron-editor/easy-cron/easy-cron.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { EasyCronComponent } from './easy-cron.component'; 4 | 5 | describe('EasyCronComponent', () => { 6 | let component: EasyCronComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [EasyCronComponent], 12 | }); 13 | fixture = TestBed.createComponent(EasyCronComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron-editor/easy-cron/easy-cron.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, EventEmitter, Output } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-easy-cron', 5 | templateUrl: './easy-cron.component.html', 6 | styleUrls: ['./easy-cron.component.scss'], 7 | }) 8 | export class EasyCronComponent { 9 | @Output() valueChange = new EventEmitter(); 10 | 11 | fieldEveryCount = 1; 12 | fieldEveryUnit: 'hour' | 'day' = 'day'; 13 | fieldAtHour = 0; 14 | fieldAtMinute = 0; 15 | 16 | private toCronExpression(): string { 17 | if (this.fieldEveryUnit === 'hour') { 18 | return `0 ${this.fieldAtMinute} */${this.fieldEveryCount} * *`; 19 | } else if (this.fieldEveryUnit === 'day') { 20 | return `0 ${this.fieldAtMinute} ${this.fieldAtHour} */${this.fieldEveryCount} *`; 21 | } else { 22 | throw new Error('invalid fieldEveryUnit'); 23 | } 24 | } 25 | 26 | onValueChange(): void { 27 | this.valueChange.emit(this.toCronExpression()); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { CronComponent } from './cron.component'; 4 | 5 | const routes: Routes = [{ path: '', component: CronComponent }]; 6 | 7 | @NgModule({ 8 | imports: [RouterModule.forChild(routes)], 9 | exports: [RouterModule], 10 | }) 11 | export class CronRoutingModule {} 12 | -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron.component.html: -------------------------------------------------------------------------------- 1 |
2 | 5 |
6 | -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/src/app/features/functions/cron/cron.component.scss -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { CronComponent } from './cron.component'; 4 | 5 | describe('CronComponent', () => { 6 | let component: CronComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [CronComponent], 12 | }); 13 | fixture = TestBed.createComponent(CronComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, OnInit } from '@angular/core'; 2 | 3 | import { MatSnackBar } from '@angular/material/snack-bar'; 4 | 5 | import { CronService, Schedular, Task } from './cron.service'; 6 | 7 | @Component({ 8 | selector: 'app-cron', 9 | templateUrl: './cron.component.html', 10 | styleUrls: ['./cron.component.scss'], 11 | }) 12 | export class CronComponent implements OnInit { 13 | schedular!: Schedular; 14 | tasks!: Promise; 15 | constructor( 16 | private cronService: CronService, 17 | private snackBar: MatSnackBar, 18 | ) {} 19 | 20 | ngOnInit(): void { 21 | const schedular = this.cronService.getSchedular(); 22 | if (!schedular) { 23 | throw new Error('Schedular not available'); 24 | } 25 | this.schedular = schedular; 26 | this.tasks = schedular.getTasks(); 27 | } 28 | 29 | newCronClicked() { 30 | this.snackBar.open('Not implemented yet', 'OK'); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { FormsModule, ReactiveFormsModule } from '@angular/forms'; 4 | 5 | import { MatFormFieldModule } from '@angular/material/form-field'; 6 | import { MatInputModule } from '@angular/material/input'; 7 | import { MatSelectModule } from '@angular/material/select'; 8 | import { MatSlideToggleModule } from '@angular/material/slide-toggle'; 9 | import { MatSnackBarModule } from '@angular/material/snack-bar'; 10 | 11 | import { CronEditorComponent } from './cron-editor/cron-editor.component'; 12 | import { EasyCronComponent } from './cron-editor/easy-cron/easy-cron.component'; 13 | import { CronRoutingModule } from './cron-routing.module'; 14 | import { CronComponent } from './cron.component'; 15 | 16 | @NgModule({ 17 | declarations: [CronComponent, CronEditorComponent, EasyCronComponent], 18 | imports: [ 19 | CommonModule, 20 | CronRoutingModule, 21 | FormsModule, 22 | ReactiveFormsModule, 23 | MatFormFieldModule, 24 | MatSelectModule, 25 | MatSlideToggleModule, 26 | MatInputModule, 27 | MatSnackBarModule, 28 | ], 29 | }) 30 | export class CronModule {} 31 | -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { CronService } from './cron.service'; 4 | 5 | describe('CronService', () => { 6 | let service: CronService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(CronService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/features/functions/cron/cron.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | import { v4 as uuid } from 'uuid'; 4 | 5 | type CronExpression = string; 6 | 7 | export interface Task { 8 | id: string; 9 | active: boolean; 10 | schedule: CronExpression | '@startup'; 11 | operation: string; 12 | params: Record; 13 | } 14 | 15 | export interface Schedular { 16 | validate: (expression: string) => Promise; 17 | addTask: (t: Task) => Promise; 18 | getTasks: () => Promise; 19 | removeTask: (id: string) => Promise; 20 | activateTask: (id: string) => Promise; 21 | deactivateTask: (id: string) => Promise; 22 | } 23 | 24 | interface ElectronBridge { 25 | version: string; 26 | executeId: () => Promise; 27 | schedular: Schedular; 28 | } 29 | 30 | @Injectable({ 31 | providedIn: 'root', 32 | }) 33 | export class CronService { 34 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 35 | // @ts-ignore 36 | private electronBridge?: ElectronBridge = globalThis['RWA_DESKTOP']; 37 | 38 | getSchedular() { 39 | return this.electronBridge?.schedular; 40 | } 41 | 42 | validateExpression(expression: string): Promise { 43 | if (expression === '@startup') { 44 | return Promise.resolve(true); 45 | } 46 | const schedular = this.getSchedular(); 47 | if (!schedular) { 48 | throw new Error('Schedular not available'); 49 | } 50 | return schedular.validate(expression); 51 | } 52 | 53 | async toTask( 54 | expression: string, 55 | operation: string, 56 | params: Record, 57 | ): Promise { 58 | const valid = await this.validateExpression(expression); 59 | if (!valid) { 60 | throw new Error('Invalid cron expression'); 61 | } 62 | return { 63 | id: uuid(), 64 | active: true, 65 | schedule: expression, 66 | operation, 67 | params, 68 | }; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { ExplorerComponent } from './explorer.component'; 5 | 6 | const routes: Routes = [ 7 | { 8 | path: '', 9 | component: ExplorerComponent, 10 | }, 11 | ]; 12 | 13 | @NgModule({ 14 | imports: [RouterModule.forChild(routes)], 15 | exports: [RouterModule], 16 | }) 17 | export class ExplorerRoutingModule {} 18 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/copy-dialog/copy-dialog.component.html: -------------------------------------------------------------------------------- 1 |

Link Created

2 |
3 | 4 | 10 | 11 | 20 |
21 |
22 | 23 |
24 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/copy-dialog/copy-dialog.component.scss: -------------------------------------------------------------------------------- 1 | .copy__link { 2 | width: 40em; 3 | } 4 | 5 | .copy__button { 6 | margin: 1em; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/copy-dialog/copy-dialog.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { CopyDialogComponent } from './copy-dialog.component'; 4 | 5 | describe('CopyDialogComponent', () => { 6 | let component: CopyDialogComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [CopyDialogComponent], 12 | }); 13 | fixture = TestBed.createComponent(CopyDialogComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/copy-dialog/copy-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject } from '@angular/core'; 2 | 3 | import { MAT_DIALOG_DATA } from '@angular/material/dialog'; 4 | 5 | @Component({ 6 | selector: 'app-copy-dialog', 7 | templateUrl: './copy-dialog.component.html', 8 | styleUrls: ['./copy-dialog.component.scss'], 9 | }) 10 | export class CopyDialogComponent { 11 | constructor( 12 | @Inject(MAT_DIALOG_DATA) 13 | public data: { 14 | content: string; 15 | }, 16 | ) {} 17 | } 18 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/delete-confirm-dialog/delete-confirm-dialog.component.html: -------------------------------------------------------------------------------- 1 |

Delete

2 |
3 | Would you like to delete 4 | {{ data[0].Name }} 5 | 6 | these 7 | {data.length, plural, 8 | one {{{data.length}} item} 9 | other {{{data.length}} items} 10 | } 11 | 12 |
13 |
14 | 15 | 16 |
17 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/delete-confirm-dialog/delete-confirm-dialog.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/src/app/features/functions/explorer/explorer-viewer/delete-confirm-dialog/delete-confirm-dialog.component.scss -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/delete-confirm-dialog/delete-confirm-dialog.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { DeleteConfirmDialogComponent } from './delete-confirm-dialog.component'; 4 | 5 | describe('DeleteConfirmDialogComponent', () => { 6 | let component: DeleteConfirmDialogComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [DeleteConfirmDialogComponent], 12 | }); 13 | fixture = TestBed.createComponent(DeleteConfirmDialogComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/delete-confirm-dialog/delete-confirm-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject } from '@angular/core'; 2 | 3 | import { MAT_DIALOG_DATA } from '@angular/material/dialog'; 4 | 5 | import { DirectoryItem } from '../../explorer.model'; 6 | 7 | @Component({ 8 | selector: 'app-delete-confirm-dialog', 9 | templateUrl: './delete-confirm-dialog.component.html', 10 | styleUrls: ['./delete-confirm-dialog.component.scss'], 11 | }) 12 | export class DeleteConfirmDialogComponent { 13 | constructor(@Inject(MAT_DIALOG_DATA) public data: DirectoryItem[]) { 14 | // const length = data.length; 15 | // if (length == 0) { 16 | // this.description = 'No files selected'; 17 | // console.error('No files selected when delete confirm dialog is opened'); 18 | // } else if (length === 1) { 19 | // this.description = data[0].Name; 20 | // } else { 21 | // this.description = $localize`${length, plural, few {{{length}} items} other {{{length}} items}}`; 22 | // } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/explorer-viewer.component.html: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | 8 | 9 |
Empty
10 |
11 | 12 | 13 | 22 | 23 | 24 |
{{ item.Name }}
25 |
{{ item.ModTime | date }}
26 |
27 | 28 |
29 |
37 |
38 | 39 | 40 | 44 | 48 | 52 | 56 | 60 | 68 | 76 | 77 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/explorer-viewer.component.scss: -------------------------------------------------------------------------------- 1 | .explorer-view__container { 2 | height: 65vh; 3 | overflow-y: scroll; 4 | } 5 | 6 | .explorer-view__list-option--hide-checkbox { 7 | ::ng-deep .mdc-list-item__end { 8 | display: none; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/explorer-viewer.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ExplorerViewerComponent } from './explorer-viewer.component'; 4 | 5 | describe('ExplorerViewerComponent', () => { 6 | let component: ExplorerViewerComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ExplorerViewerComponent], 12 | }); 13 | fixture = TestBed.createComponent(ExplorerViewerComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/file-icon.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { FileIconPipe } from './file-icon.pipe'; 2 | 3 | describe('FileIconPipe', () => { 4 | it('create an instance', () => { 5 | const pipe = new FileIconPipe(); 6 | expect(pipe).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/file-icon.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | import { DirectoryItem } from '../explorer.model'; 3 | 4 | @Pipe({ 5 | name: 'fileIcon', 6 | }) 7 | export class FileIconPipe implements PipeTransform { 8 | transform(value: DirectoryItem): string { 9 | if (value.IsDir) { 10 | return 'folder'; 11 | } 12 | const mimeParts = value.MimeType.split('/'); 13 | switch (mimeParts[0]) { 14 | case 'text': 15 | return 'description'; 16 | case 'image': 17 | return 'image'; 18 | case 'audio': 19 | return 'audio_file'; 20 | case 'video': 21 | return 'video_file'; 22 | case 'application': 23 | switch (mimeParts[1]) { 24 | case 'msword': 25 | case 'vnd.openxmlformats-officedocument.wordprocessingml.document': 26 | case 'vnd.ms-powerpoint': 27 | case 'vnd.openxmlformats-officedocument.presentationml.presentation': 28 | case 'vnd.ms-excel': 29 | case 'vnd.openxmlformats-officedocument.spreadsheetml.sheet': 30 | case 'pdf': 31 | return 'docs'; 32 | case 'zip': 33 | case 'x-bzip': 34 | case 'x-bzip2': 35 | case 'x-tar': 36 | case 'gzip': 37 | case 'x-gzip': 38 | case 'vnd.rar': 39 | case 'x-7z-compressed': 40 | return 'folder_zip'; 41 | case 'octet-stream': 42 | return 'insert_drive_file'; 43 | default: 44 | return 'insert_drive_file'; 45 | } 46 | default: 47 | return 'insert_drive_file'; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/path-splitter/path-splitter.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | / 7 | 8 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/path-splitter/path-splitter.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/src/app/features/functions/explorer/explorer-viewer/path-splitter/path-splitter.component.scss -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/path-splitter/path-splitter.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PathSplitterComponent } from './path-splitter.component'; 4 | 5 | describe('PathSplitterComponent', () => { 6 | let component: PathSplitterComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [PathSplitterComponent], 12 | }); 13 | fixture = TestBed.createComponent(PathSplitterComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/path-splitter/path-splitter.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Component, 3 | EventEmitter, 4 | Input, 5 | OnChanges, 6 | OnInit, 7 | Output, 8 | } from '@angular/core'; 9 | 10 | @Component({ 11 | selector: 'app-path-splitter[path]', 12 | templateUrl: './path-splitter.component.html', 13 | styleUrls: ['./path-splitter.component.scss'], 14 | }) 15 | export class PathSplitterComponent implements OnInit, OnChanges { 16 | @Input() path!: string; 17 | @Output() pathChange = new EventEmitter(); 18 | 19 | pathParts: { 20 | name: string; 21 | fullPath: string; 22 | }[] = []; 23 | 24 | ngOnInit() { 25 | this.updatePath(); 26 | } 27 | 28 | ngOnChanges() { 29 | this.updatePath(); 30 | } 31 | 32 | updatePath() { 33 | this.pathParts = []; 34 | if (this.path === '') { 35 | return; 36 | } 37 | const paths = this.path.split('/'); 38 | const fullPathList = []; 39 | for (const path of paths) { 40 | fullPathList.push(path); 41 | this.pathParts.push({ 42 | name: path, 43 | fullPath: fullPathList.join('/'), 44 | }); 45 | } 46 | } 47 | 48 | pathClicked(fullPath: string) { 49 | this.pathChange.emit(fullPath); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/rename-dialog/rename-dialog.component.html: -------------------------------------------------------------------------------- 1 |

{{ data.title }}

2 |
3 | 4 | New name 5 | 6 | 7 | Name already exists 8 | 9 | 10 | 11 |
12 |
13 | 14 | 22 |
23 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/rename-dialog/rename-dialog.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/src/app/features/functions/explorer/explorer-viewer/rename-dialog/rename-dialog.component.scss -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/rename-dialog/rename-dialog.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { RenameDialogComponent } from './rename-dialog.component'; 4 | 5 | describe('RenameDialogComponent', () => { 6 | let component: RenameDialogComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [RenameDialogComponent], 12 | }); 13 | fixture = TestBed.createComponent(RenameDialogComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer-viewer/rename-dialog/rename-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject } from '@angular/core'; 2 | 3 | import { MAT_DIALOG_DATA } from '@angular/material/dialog'; 4 | 5 | @Component({ 6 | selector: 'app-rename-dialog', 7 | templateUrl: './rename-dialog.component.html', 8 | styleUrls: ['./rename-dialog.component.scss'], 9 | }) 10 | export class RenameDialogComponent { 11 | constructor( 12 | @Inject(MAT_DIALOG_DATA) 13 | public data: { 14 | title: string; 15 | name: string; 16 | existNames: string[]; 17 | }, 18 | ) {} 19 | } 20 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer.component.scss: -------------------------------------------------------------------------------- 1 | .group-container { 2 | display: flex; 3 | flex-direction: row; 4 | > * { 5 | flex: 1; 6 | } 7 | } 8 | 9 | .group-card { 10 | margin: 1em; 11 | } 12 | 13 | .new-group-hint { 14 | display: flex; 15 | align-items: center; 16 | margin: 1em; 17 | 18 | .mat-icon { 19 | margin: 0.5em; 20 | } 21 | } 22 | 23 | .spacer { 24 | flex: 1 1 auto; 25 | } 26 | 27 | .icon--rotate90 { 28 | transform: rotate(90deg); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ExplorerComponent } from './explorer.component'; 4 | 5 | describe('ExplorerComponent', () => { 6 | let component: ExplorerComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ExplorerComponent], 12 | }); 13 | fixture = TestBed.createComponent(ExplorerComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer.model.ts: -------------------------------------------------------------------------------- 1 | import { FsInfo } from '../backend/backend.model'; 2 | 3 | export interface ExplorerView { 4 | backend: string; 5 | path: string; 6 | info: Promise; 7 | actions: { 8 | refresh?: () => void; 9 | getPath?: () => string; 10 | getChildren?: () => DirectoryItem[] | undefined; 11 | addChild?: (name: string, isFolder: boolean) => void; 12 | }; 13 | } 14 | 15 | export interface AppClipboard { 16 | type: 'copy' | 'move'; 17 | backend: string; 18 | items: DirectoryItem[]; 19 | } 20 | 21 | export interface SyncClipboard { 22 | type: 'sync' | 'bisync'; 23 | backend: string; 24 | dirPath: string; 25 | } 26 | 27 | export interface FileItem extends DirectoryItem { 28 | IsDir: false; 29 | } 30 | 31 | export interface DirItem extends DirectoryItem { 32 | IsDir: true; 33 | } 34 | 35 | export interface DirectoryItem { 36 | IsDir: boolean; 37 | Path: string; 38 | Name: string; 39 | Size: number; 40 | MimeType: string; 41 | ModTime: Date; 42 | } 43 | 44 | export type EmptyObj = Record; 45 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer.module.ts: -------------------------------------------------------------------------------- 1 | import { ClipboardModule } from '@angular/cdk/clipboard'; 2 | import { TextFieldModule } from '@angular/cdk/text-field'; 3 | import { CommonModule } from '@angular/common'; 4 | import { NgModule } from '@angular/core'; 5 | import { FormsModule } from '@angular/forms'; 6 | 7 | import { MatBadgeModule } from '@angular/material/badge'; 8 | import { MatButtonModule } from '@angular/material/button'; 9 | import { MatCardModule } from '@angular/material/card'; 10 | import { MatDialogModule } from '@angular/material/dialog'; 11 | import { MatFormFieldModule } from '@angular/material/form-field'; 12 | import { MatIconModule } from '@angular/material/icon'; 13 | import { MatInputModule } from '@angular/material/input'; 14 | import { MatListModule } from '@angular/material/list'; 15 | import { MatMenuModule } from '@angular/material/menu'; 16 | import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; 17 | import { MatSnackBarModule } from '@angular/material/snack-bar'; 18 | import { MatTabsModule } from '@angular/material/tabs'; 19 | import { MatToolbarModule } from '@angular/material/toolbar'; 20 | import { MatTooltipModule } from '@angular/material/tooltip'; 21 | 22 | import { ExplorerRoutingModule } from './explorer-routing.module'; 23 | import { CopyDialogComponent } from './explorer-viewer/copy-dialog/copy-dialog.component'; 24 | import { DeleteConfirmDialogComponent } from './explorer-viewer/delete-confirm-dialog/delete-confirm-dialog.component'; 25 | import { ExplorerViewerComponent } from './explorer-viewer/explorer-viewer.component'; 26 | import { FileIconPipe } from './explorer-viewer/file-icon.pipe'; 27 | import { PathSplitterComponent } from './explorer-viewer/path-splitter/path-splitter.component'; 28 | import { RenameDialogComponent } from './explorer-viewer/rename-dialog/rename-dialog.component'; 29 | import { ExplorerComponent } from './explorer.component'; 30 | 31 | @NgModule({ 32 | declarations: [ 33 | ExplorerComponent, 34 | ExplorerViewerComponent, 35 | DeleteConfirmDialogComponent, 36 | PathSplitterComponent, 37 | RenameDialogComponent, 38 | CopyDialogComponent, 39 | FileIconPipe, 40 | ], 41 | imports: [ 42 | CommonModule, 43 | FormsModule, 44 | ClipboardModule, 45 | TextFieldModule, 46 | ExplorerRoutingModule, 47 | MatIconModule, 48 | MatButtonModule, 49 | MatTabsModule, 50 | MatListModule, 51 | MatMenuModule, 52 | MatSnackBarModule, 53 | MatCardModule, 54 | MatToolbarModule, 55 | MatTooltipModule, 56 | MatProgressSpinnerModule, 57 | MatDialogModule, 58 | MatFormFieldModule, 59 | MatInputModule, 60 | MatBadgeModule, 61 | ], 62 | }) 63 | export class ExplorerModule {} 64 | -------------------------------------------------------------------------------- /src/app/features/functions/explorer/explorer.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { ExplorerService } from './explorer.service'; 4 | 5 | describe('ExplorerService', () => { 6 | let service: ExplorerService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(ExplorerService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/features/functions/functions-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { FunctionsComponent } from './functions.component'; 5 | import { isElectronGuard } from './is-electron.guard'; 6 | 7 | const routes: Routes = [ 8 | { path: '', component: FunctionsComponent, pathMatch: 'full' }, 9 | { 10 | path: 'drive', 11 | loadChildren: () => 12 | import('./backend/backend.module').then((m) => m.BackendModule), 13 | }, 14 | { 15 | path: 'explore', 16 | loadChildren: () => 17 | import('./explorer/explorer.module').then((m) => m.ExplorerModule), 18 | }, 19 | { 20 | path: 'mount', 21 | loadChildren: () => 22 | import('./mount/mount.module').then((m) => m.MountModule), 23 | }, 24 | { 25 | path: 'job', 26 | loadChildren: () => import('./job/job.module').then((m) => m.JobModule), 27 | }, 28 | { 29 | path: 'cron', 30 | loadChildren: () => import('./cron/cron.module').then((m) => m.CronModule), 31 | canActivate: [isElectronGuard], 32 | }, 33 | ]; 34 | 35 | @NgModule({ 36 | imports: [RouterModule.forChild(routes)], 37 | exports: [RouterModule], 38 | }) 39 | export class FunctionsRoutingModule {} 40 | -------------------------------------------------------------------------------- /src/app/features/functions/functions.component.html: -------------------------------------------------------------------------------- 1 | Manage Drives 2 | Explore Drives 3 | Mount Manager 4 | Job Manager 5 | -------------------------------------------------------------------------------- /src/app/features/functions/functions.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/src/app/features/functions/functions.component.scss -------------------------------------------------------------------------------- /src/app/features/functions/functions.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FunctionsComponent } from './functions.component'; 4 | 5 | describe('FunctionsComponent', () => { 6 | let component: FunctionsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [FunctionsComponent], 12 | }); 13 | fixture = TestBed.createComponent(FunctionsComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/functions.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-functions', 5 | templateUrl: './functions.component.html', 6 | styleUrls: ['./functions.component.scss'], 7 | }) 8 | export class FunctionsComponent {} 9 | -------------------------------------------------------------------------------- /src/app/features/functions/functions.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { FunctionsRoutingModule } from './functions-routing.module'; 5 | import { FunctionsComponent } from './functions.component'; 6 | 7 | @NgModule({ 8 | declarations: [FunctionsComponent], 9 | imports: [CommonModule, FunctionsRoutingModule], 10 | }) 11 | export class FunctionsModule {} 12 | -------------------------------------------------------------------------------- /src/app/features/functions/is-electron.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { CanActivateFn } from '@angular/router'; 3 | 4 | import { isElectronGuard } from './is-electron.guard'; 5 | 6 | describe('isElectronGuard', () => { 7 | const executeGuard: CanActivateFn = (...guardParameters) => 8 | TestBed.runInInjectionContext(() => isElectronGuard(...guardParameters)); 9 | 10 | beforeEach(() => { 11 | TestBed.configureTestingModule({}); 12 | }); 13 | 14 | it('should be created', () => { 15 | expect(executeGuard).toBeTruthy(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/app/features/functions/is-electron.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivateFn } from '@angular/router'; 2 | 3 | export const isElectronGuard: CanActivateFn = () => { 4 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 5 | // @ts-ignore 6 | return globalThis['RWA_DESKTOP'] !== undefined; 7 | }; 8 | -------------------------------------------------------------------------------- /src/app/features/functions/job/job-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | import { JobComponent } from './job.component'; 4 | 5 | const routes: Routes = [{ path: '', component: JobComponent }]; 6 | 7 | @NgModule({ 8 | imports: [RouterModule.forChild(routes)], 9 | exports: [RouterModule], 10 | }) 11 | export class JobRoutingModule {} 12 | -------------------------------------------------------------------------------- /src/app/features/functions/job/job.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |
7 | 8 | 13 | 17 | 21 | 22 | {{ job.id }}: 23 | {{ job.summary }} 24 | 25 | 26 | 27 | 28 | 36 | 37 | 41 | 42 | 50 | 51 |
52 |
53 | 54 |
55 |
56 |
57 | 58 |

No jobs

59 |
60 |
61 | -------------------------------------------------------------------------------- /src/app/features/functions/job/job.component.scss: -------------------------------------------------------------------------------- 1 | .list-container { 2 | margin: 3em auto; 3 | min-height: 20em; 4 | width: 80%; 5 | max-width: 50em; 6 | } 7 | 8 | .list-line { 9 | display: flex; 10 | 11 | .job-info { 12 | display: flex; 13 | align-items: center; 14 | 15 | .mat-icon { 16 | margin: 0.5em; 17 | } 18 | } 19 | } 20 | .spacer { 21 | margin: 0 auto; 22 | } 23 | 24 | .icon--spinning { 25 | animation: spin 2s linear infinite; 26 | } 27 | 28 | .empty-hint { 29 | margin: 2em auto; 30 | text-align: center; 31 | } 32 | -------------------------------------------------------------------------------- /src/app/features/functions/job/job.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { JobComponent } from './job.component'; 4 | 5 | describe('JobComponent', () => { 6 | let component: JobComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [JobComponent], 12 | }); 13 | fixture = TestBed.createComponent(JobComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/job/job.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | import { JobService } from './job.service'; 4 | 5 | @Component({ 6 | selector: 'app-job', 7 | templateUrl: './job.component.html', 8 | styleUrls: ['./job.component.scss'], 9 | }) 10 | export class JobComponent { 11 | jobs$ = this.jobService.getJobs(); 12 | constructor(private jobService: JobService) {} 13 | 14 | removeJob(jobId: number) { 15 | this.jobService.removeJob(jobId); 16 | } 17 | 18 | killJob(jobId: number) { 19 | this.jobService.killJob(jobId); 20 | } 21 | 22 | removeFinishedJobs() { 23 | this.jobService.removeFinishedJobs(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/features/functions/job/job.model.ts: -------------------------------------------------------------------------------- 1 | interface BaseJobInfo { 2 | id: number; 3 | finished: boolean; 4 | success: boolean; // true for success false otherwise 5 | error: string; // empty string if no error 6 | duration: number; // in seconds 7 | startTime: Date; 8 | } 9 | 10 | interface PendingJobInfo extends BaseJobInfo { 11 | finished: false; 12 | success: false; 13 | error: ''; 14 | progress: unknown; 15 | } 16 | 17 | interface SuccessJobInfo extends BaseJobInfo { 18 | finished: true; 19 | success: true; 20 | error: ''; 21 | output: R; 22 | endTime: Date; 23 | } 24 | 25 | interface ErrorJobInfo extends BaseJobInfo { 26 | finished: true; 27 | success: false; 28 | error: string; 29 | duration: number; 30 | endTime: Date; 31 | } 32 | 33 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 34 | export type JobInfo = 35 | | PendingJobInfo 36 | | SuccessJobInfo 37 | | ErrorJobInfo; 38 | -------------------------------------------------------------------------------- /src/app/features/functions/job/job.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MatCardModule } from '@angular/material/card'; 6 | import { MatDividerModule } from '@angular/material/divider'; 7 | import { MatIconModule } from '@angular/material/icon'; 8 | import { MatListModule } from '@angular/material/list'; 9 | import { MatMenuModule } from '@angular/material/menu'; 10 | import { MatTooltipModule } from '@angular/material/tooltip'; 11 | 12 | import { JobRoutingModule } from './job-routing.module'; 13 | import { JobComponent } from './job.component'; 14 | 15 | @NgModule({ 16 | declarations: [JobComponent], 17 | imports: [ 18 | CommonModule, 19 | JobRoutingModule, 20 | MatCardModule, 21 | MatListModule, 22 | MatDividerModule, 23 | MatButtonModule, 24 | MatIconModule, 25 | MatMenuModule, 26 | MatTooltipModule, 27 | ], 28 | }) 29 | export class JobModule {} 30 | -------------------------------------------------------------------------------- /src/app/features/functions/job/job.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { JobService } from './job.service'; 4 | 5 | describe('JobService', () => { 6 | let service: JobService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(JobService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/features/functions/mount/mount-routing.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { RouterModule, Routes } from '@angular/router'; 3 | 4 | import { MountComponent } from './mount.component'; 5 | 6 | const routes: Routes = [ 7 | { 8 | path: '', 9 | component: MountComponent, 10 | }, 11 | ]; 12 | 13 | @NgModule({ 14 | imports: [RouterModule.forChild(routes)], 15 | exports: [RouterModule], 16 | }) 17 | export class MountRoutingModule {} 18 | -------------------------------------------------------------------------------- /src/app/features/functions/mount/mount.component.html: -------------------------------------------------------------------------------- 1 |
2 | 6 |
7 | 8 | 16 |
17 | 18 | 19 | 20 | 21 |
22 | 23 | {{ setting.Fs }}: 24 | 28 | {{ setting.MountPoint }} 29 | 30 |
31 | 32 | 37 | 40 | 41 | 45 | 49 | 50 | 51 |
52 |
53 | 54 |
55 |
56 |
57 | -------------------------------------------------------------------------------- /src/app/features/functions/mount/mount.component.scss: -------------------------------------------------------------------------------- 1 | .button-group { 2 | display: flex; 3 | margin: 3em auto; 4 | width: 90%; 5 | max-width: 65em; 6 | 7 | button { 8 | margin: 0 1em; 9 | } 10 | } 11 | 12 | .spacer { 13 | margin: 0 auto; 14 | } 15 | 16 | .list-container { 17 | margin: 3em auto; 18 | min-height: 20em; 19 | width: 80%; 20 | max-width: 50em; 21 | } 22 | 23 | .list-line { 24 | display: flex; 25 | 26 | .mount-info { 27 | display: flex; 28 | align-items: center; 29 | 30 | .mat-icon { 31 | margin: 0.5em; 32 | } 33 | } 34 | 35 | .mount-action { 36 | display: flex; 37 | align-items: center; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/features/functions/mount/mount.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { MountComponent } from './mount.component'; 4 | 5 | describe('MountComponent', () => { 6 | let component: MountComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [MountComponent], 12 | }); 13 | fixture = TestBed.createComponent(MountComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/mount/mount.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { ReactiveFormsModule } from '@angular/forms'; 4 | 5 | import { MatButtonModule } from '@angular/material/button'; 6 | import { MatCardModule } from '@angular/material/card'; 7 | import { MatCheckboxModule } from '@angular/material/checkbox'; 8 | import { MatDialogModule } from '@angular/material/dialog'; 9 | import { MatDividerModule } from '@angular/material/divider'; 10 | import { MatFormFieldModule } from '@angular/material/form-field'; 11 | import { MatIconModule } from '@angular/material/icon'; 12 | import { MatInputModule } from '@angular/material/input'; 13 | import { MatListModule } from '@angular/material/list'; 14 | import { MatMenuModule } from '@angular/material/menu'; 15 | import { MatSelectModule } from '@angular/material/select'; 16 | import { MatSlideToggleModule } from '@angular/material/slide-toggle'; 17 | import { MatSnackBarModule } from '@angular/material/snack-bar'; 18 | import { MatTooltipModule } from '@angular/material/tooltip'; 19 | 20 | import { MountRoutingModule } from './mount-routing.module'; 21 | import { MountComponent } from './mount.component'; 22 | import { NewMountDialogComponent } from './new-mount-dialog/new-mount-dialog.component'; 23 | 24 | @NgModule({ 25 | declarations: [MountComponent, NewMountDialogComponent], 26 | imports: [ 27 | CommonModule, 28 | ReactiveFormsModule, 29 | MountRoutingModule, 30 | MatTooltipModule, 31 | MatListModule, 32 | MatButtonModule, 33 | MatIconModule, 34 | MatCardModule, 35 | MatSnackBarModule, 36 | MatSlideToggleModule, 37 | MatDialogModule, 38 | MatFormFieldModule, 39 | MatInputModule, 40 | MatCheckboxModule, 41 | MatSelectModule, 42 | MatDividerModule, 43 | MatMenuModule, 44 | ], 45 | }) 46 | export class MountModule {} 47 | -------------------------------------------------------------------------------- /src/app/features/functions/mount/mount.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { MountService } from './mount.service'; 4 | 5 | describe('MountService', () => { 6 | let service: MountService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(MountService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/features/functions/mount/new-mount-dialog/new-mount-dialog.component.html: -------------------------------------------------------------------------------- 1 |

Create MountPoint

2 |
3 | 4 | Drive 5 | 6 | 7 | {{ fs }} 8 | 9 | 10 | 11 | 12 | Auto Mount on Startup 13 | 14 | 19 | Automatic MountPoint 20 | 21 | 22 | MountPoint 23 | 24 | 25 | Mount Now 26 |
27 | 36 |
37 | 38 | 39 | Mount as Readonly Drive 40 | 41 | 46 | Mount as Windows Network Drive 47 | 48 | 49 | Cache Mode 50 | 51 | Off 52 | 53 | Minimal (Only opened files are cached) 54 | 55 | 56 | Write (All non-readonly files are cached) 57 | 58 | Full 59 | 60 | 61 | 62 | Cache Max Age 63 | 64 | 65 | 66 | File Permissions 67 | 68 | 69 | 70 | Directory Permissions 71 | 72 | 73 | 74 | Skip modification time (can speed things up) 75 | 76 | 77 | Custom Mount Option (Json) 78 | 79 | 88 | 89 | 90 | Custom Vfs Option (Json) 91 | 92 | 101 | 102 | 103 |
104 |
105 | 106 | 115 |
116 | -------------------------------------------------------------------------------- /src/app/features/functions/mount/new-mount-dialog/new-mount-dialog.component.scss: -------------------------------------------------------------------------------- 1 | .mount-form { 2 | display: flex; 3 | flex-direction: column; 4 | } 5 | 6 | .button-advanced { 7 | display: flex; 8 | justify-content: center; 9 | } 10 | 11 | mat-slide-toggle { 12 | margin: 1em; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/features/functions/mount/new-mount-dialog/new-mount-dialog.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { NewMountDialogComponent } from './new-mount-dialog.component'; 4 | 5 | describe('NewMountDialogComponent', () => { 6 | let component: NewMountDialogComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [NewMountDialogComponent], 12 | }); 13 | fixture = TestBed.createComponent(NewMountDialogComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/features/functions/mount/new-mount-dialog/new-mount-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, Inject } from '@angular/core'; 2 | import { FormBuilder, Validators } from '@angular/forms'; 3 | 4 | import { MAT_DIALOG_DATA, MatDialog } from '@angular/material/dialog'; 5 | 6 | import { jsonStringValidator } from 'src/app/shared/json-string-validator.directive'; 7 | import { SimpleDialogComponent } from 'src/app/shared/simple-dialog/simple-dialog.component'; 8 | import { environment } from 'src/environments/environment'; 9 | 10 | @Component({ 11 | selector: 'app-new-mount-dialog', 12 | templateUrl: './new-mount-dialog.component.html', 13 | styleUrls: ['./new-mount-dialog.component.scss'], 14 | }) 15 | export class NewMountDialogComponent { 16 | mountForm = this.fb.nonNullable.group({ 17 | Fs: ['', Validators.required], 18 | AutoMountPoint: [true], // Only for Windows 19 | MountPoint: ['', Validators.required], 20 | enabled: [true], 21 | autoMount: [false], // Scheduled task 22 | readonly: [false], 23 | windowsNetworkMode: [true], 24 | filePerms: [ 25 | '0666', 26 | [Validators.required, Validators.pattern(/^0?[1-7][0-7]{2}$/)], // although something like 077 is valid, we don't want to allow it 27 | ], 28 | dirPerms: [ 29 | '0777', 30 | [Validators.required, Validators.pattern(/^0?[1-7][0-7]{2}$/)], 31 | ], 32 | noModTime: [false], 33 | vfsCacheMode: ['minimal'], 34 | vfsCacheMaxAge: [ 35 | '1h', 36 | [Validators.required, Validators.pattern(/^\d+[smhd]$/)], 37 | ], 38 | customMountOpt: ['{\n}', jsonStringValidator()], 39 | customVfsOpt: ['{\n}', jsonStringValidator()], 40 | }); 41 | showAdvancedOptions = false; 42 | hasCron = environment.electron; 43 | 44 | constructor( 45 | private dialog: MatDialog, 46 | private fb: FormBuilder, 47 | @Inject(MAT_DIALOG_DATA) 48 | public data: { 49 | osType: string; 50 | fsOptions: string[]; 51 | }, 52 | ) { 53 | if (data.osType === 'windows') { 54 | this.mountForm.controls.MountPoint.setValue('Z:'); 55 | } else { 56 | this.mountForm.controls.AutoMountPoint.setValue(false); 57 | this.mountForm.controls.MountPoint.setValue('/mnt/rclone'); 58 | } 59 | } 60 | 61 | getMountOptHelp() { 62 | this.dialog.open(SimpleDialogComponent, { 63 | data: { 64 | title: $localize`Information`, 65 | message: $localize`This is options for advanced user only!\nPlease input options in JSON format, keys are in PascalCase.\nAvailable options: please refer to https://github.com/rclone/rclone/blob/master/cmd/mountlib/mount.go\nfind "type Options struct" part`, 66 | actions: [{ label: $localize`Close`, value: 0 }], 67 | }, 68 | }); 69 | } 70 | getVfsOptHelp() { 71 | this.dialog.open(SimpleDialogComponent, { 72 | data: { 73 | title: $localize`Information`, 74 | message: $localize`This is options for advanced user only!\nPlease input options in JSON format, keys are in PascalCase.\nAvailable options: please refer to https://github.com/rclone/rclone/blob/master/vfs/vfscommon/options.go\nfind "type Options struct" part`, 75 | actions: [{ label: $localize`Close`, value: 0 }], 76 | }, 77 | }); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/app/features/functions/serve/serve.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | @NgModule({ 5 | declarations: [], 6 | imports: [CommonModule], 7 | }) 8 | export class ServeModule {} 9 | -------------------------------------------------------------------------------- /src/app/shared/bytes.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { BytesPipe } from './bytes.pipe'; 2 | 3 | describe('BytesPipe', () => { 4 | it('create an instance', () => { 5 | const pipe = new BytesPipe(); 6 | expect(pipe).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/shared/bytes.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'bytes', 5 | }) 6 | export class BytesPipe implements PipeTransform { 7 | transform( 8 | value: number, 9 | fractionDigits = 2, 10 | base: 1024 | 1000 = 1024, 11 | IEC = true, 12 | ): string { 13 | const units = 14 | IEC && base === 1024 15 | ? ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] 16 | : ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; 17 | let unit = 0; 18 | while (value >= base && unit < units.length - 1) { 19 | value /= base; 20 | unit++; 21 | } 22 | return `${value.toFixed(fractionDigits)} ${units[unit]}`; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/shared/json-string-validator.directive.ts: -------------------------------------------------------------------------------- 1 | import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; 2 | 3 | export function jsonStringValidator(): ValidatorFn { 4 | return (control: AbstractControl): ValidationErrors | null => { 5 | try { 6 | JSON.parse(control.value); 7 | return null; 8 | } catch { 9 | return { invalidJsonString: true }; 10 | } 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/app/shared/result.ts: -------------------------------------------------------------------------------- 1 | abstract class BaseResult { 2 | abstract readonly ok: boolean; 3 | 4 | abstract map(fn: (t: T) => U): Result; 5 | abstract or(t: T): T; 6 | abstract orElse(fn: (e: E) => T): T; 7 | abstract orThrow(): T; 8 | } 9 | 10 | class OkResult extends BaseResult implements Ok { 11 | readonly ok = true; 12 | constructor(readonly value: T) { 13 | super(); 14 | } 15 | map(fn: (t: T) => U): Result { 16 | return Ok(fn(this.value)); 17 | } 18 | or() { 19 | return this.value; 20 | } 21 | orElse() { 22 | return this.value; 23 | } 24 | orThrow() { 25 | return this.value; 26 | } 27 | } 28 | 29 | class ErrResult extends BaseResult implements Err { 30 | readonly ok = false; 31 | constructor(readonly error: E) { 32 | super(); 33 | } 34 | map() { 35 | return this; 36 | } 37 | or(t: T) { 38 | return t; 39 | } 40 | orElse(fn: (e: E) => T): T { 41 | return fn(this.error); 42 | } 43 | orThrow(): never { 44 | throw new Error(String(this.error)); 45 | } 46 | } 47 | 48 | interface Ok extends BaseResult { 49 | readonly ok: true; 50 | value: T; 51 | } 52 | 53 | function Ok(): Ok; 54 | function Ok(value: T): Ok; 55 | function Ok(value?: T) { 56 | return new OkResult(value); 57 | } 58 | 59 | interface Err extends BaseResult { 60 | readonly ok: false; 61 | error: E; 62 | } 63 | 64 | function Err(error: E): Err { 65 | return new ErrResult(error); 66 | } 67 | 68 | type Result = Ok | Err; 69 | 70 | export { Err, Ok, Result }; 71 | -------------------------------------------------------------------------------- /src/app/shared/search.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { SearchPipe } from './search.pipe'; 2 | 3 | describe('SearchPipe', () => { 4 | it('create an instance', () => { 5 | const pipe = new SearchPipe(); 6 | expect(pipe).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/shared/search.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'search', 5 | }) 6 | export class SearchPipe implements PipeTransform { 7 | transform( 8 | values: V[], 9 | searchString: string, 10 | searchKeys: K[], 11 | caseSensitive = false, 12 | ): V[] { 13 | if (!searchString) { 14 | return values; 15 | } 16 | if (searchKeys.length === 0) { 17 | return values; 18 | } 19 | return values.filter((v) => 20 | searchKeys.some((key) => 21 | SearchPipe.isSubSequence(searchString, v[key], caseSensitive), 22 | ), 23 | ); 24 | } 25 | 26 | private static isSubSequence( 27 | sub: string, 28 | str: string, 29 | caseSensitive = false, 30 | ): boolean { 31 | if (!caseSensitive) { 32 | sub = sub.toLowerCase(); 33 | str = str.toLowerCase(); 34 | } 35 | let j = 0; 36 | for (let i = 0; i < str.length && j < sub.length; i++) { 37 | if (sub[j] === str[i]) { 38 | j++; 39 | } 40 | } 41 | return j === sub.length; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/shared/simple-dialog/simple-dialog.component.html: -------------------------------------------------------------------------------- 1 |

{{ data.title }}

2 |
{{ data.message }}
3 |
4 | 11 |
12 | -------------------------------------------------------------------------------- /src/app/shared/simple-dialog/simple-dialog.component.scss: -------------------------------------------------------------------------------- 1 | .message { 2 | white-space: pre-wrap; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/shared/simple-dialog/simple-dialog.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { SimpleDialogComponent } from './simple-dialog.component'; 4 | 5 | describe('SimpleDialogComponent', () => { 6 | let component: SimpleDialogComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(() => { 10 | TestBed.configureTestingModule({ 11 | imports: [SimpleDialogComponent], 12 | }); 13 | fixture = TestBed.createComponent(SimpleDialogComponent); 14 | component = fixture.componentInstance; 15 | fixture.detectChanges(); 16 | }); 17 | 18 | it('should create', () => { 19 | expect(component).toBeTruthy(); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/shared/simple-dialog/simple-dialog.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component, Inject } from '@angular/core'; 3 | 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; 6 | 7 | @Component({ 8 | standalone: true, 9 | imports: [CommonModule, MatButtonModule, MatDialogModule], 10 | templateUrl: './simple-dialog.component.html', 11 | styleUrls: ['./simple-dialog.component.scss'], 12 | }) 13 | export class SimpleDialogComponent { 14 | constructor( 15 | @Inject(MAT_DIALOG_DATA) 16 | public data: { 17 | title: string; 18 | message: string; 19 | actions: { 20 | value: T; 21 | label: string; 22 | }[]; 23 | }, 24 | ) {} 25 | } 26 | -------------------------------------------------------------------------------- /src/app/shared/single-click.directive.spec.ts: -------------------------------------------------------------------------------- 1 | import { SingleClickDirective } from './single-click.directive'; 2 | 3 | describe('SingleClickDirective', () => { 4 | it('should create an instance', () => { 5 | const directive = new SingleClickDirective(); 6 | expect(directive).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/shared/single-click.directive.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Directive, 3 | EventEmitter, 4 | HostListener, 5 | Input, 6 | OnDestroy, 7 | OnInit, 8 | Output, 9 | } from '@angular/core'; 10 | 11 | import { Subject, bufferTime, filter } from 'rxjs'; 12 | 13 | @Directive({ 14 | selector: '[appSingleClick]', 15 | }) 16 | export class SingleClickDirective implements OnInit, OnDestroy { 17 | @Input() appSingleClickDelay = 500; 18 | @Output() appSingleClick = new EventEmitter(); 19 | click$ = new Subject(); 20 | 21 | @HostListener('click', ['$event']) 22 | onClick(event: MouseEvent) { 23 | event.stopPropagation(); 24 | event.preventDefault(); 25 | this.click$.next(event); 26 | } 27 | 28 | ngOnInit() { 29 | this.click$ 30 | .pipe( 31 | bufferTime(this.appSingleClickDelay), 32 | filter((clicks) => clicks.length === 1), 33 | ) 34 | .subscribe((clicks) => this.appSingleClick.emit(clicks[0])); 35 | } 36 | 37 | ngOnDestroy() { 38 | this.click$.unsubscribe(); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/shared/utils.ts: -------------------------------------------------------------------------------- 1 | export const trimEnding = (s: string, ending: string): string => { 2 | if (s.endsWith(ending)) { 3 | return s.slice(0, -ending.length); 4 | } 5 | return s; 6 | }; 7 | -------------------------------------------------------------------------------- /src/assets/icons/github-mark-white.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/icons/icon-rclone.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/environments/environment.embed.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | environment: 'embed', 3 | explorerCanDownload: true, 4 | connectSelf: true, 5 | useServiceWorker: false, 6 | showRemoteSetting: false, 7 | prefetch: false, 8 | reuseMissingExecuteId: false, 9 | electron: false, 10 | }; 11 | -------------------------------------------------------------------------------- /src/environments/environment.native.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | environment: 'native', 3 | explorerCanDownload: false, 4 | connectSelf: true, 5 | useServiceWorker: false, 6 | showRemoteSetting: false, 7 | prefetch: false, 8 | reuseMissingExecuteId: true, 9 | electron: true, 10 | }; 11 | -------------------------------------------------------------------------------- /src/environments/environment.standalone.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | environment: 'standalone', 3 | explorerCanDownload: false, 4 | connectSelf: false, 5 | useServiceWorker: true, 6 | showRemoteSetting: true, 7 | prefetch: true, 8 | reuseMissingExecuteId: false, 9 | electron: false, 10 | }; 11 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // this is debug environment 2 | export const environment = { 3 | environment: 'development', 4 | explorerCanDownload: true, 5 | connectSelf: false, 6 | useServiceWorker: false, 7 | showRemoteSetting: true, 8 | prefetch: false, 9 | reuseMissingExecuteId: true, 10 | electron: false, 11 | }; 12 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yuudi/rclone-webui-angular/1407be6d51636b25be232900caea15243bc892b7/src/favicon.ico -------------------------------------------------------------------------------- /src/i18n-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Rclone Webui 7 | 8 | 31 | 32 | 33 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Rclone 6 | 7 | 8 | 9 | 13 | 17 | 18 | 19 | 20 | 21 | 22 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 4 | 5 | import { AppModule } from './app/app.module'; 6 | 7 | platformBrowserDynamic() 8 | .bootstrapModule(AppModule) 9 | .catch((err) => console.error(err)); 10 | -------------------------------------------------------------------------------- /src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rclone-webui-angular", 3 | "short_name": "rclone-webui-angular", 4 | "theme_color": "#1976d2", 5 | "background_color": "#fafafa", 6 | "display": "standalone", 7 | "scope": "./", 8 | "start_url": "./", 9 | "icons": [ 10 | { 11 | "src": "assets/icons/icon-rclone.svg", 12 | "sizes": "150x150", 13 | "type": "image/svg+xml", 14 | "purpose": "any" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/material.scss: -------------------------------------------------------------------------------- 1 | @use "@angular/material" as mat; 2 | 3 | @import "@angular/material/theming"; 4 | 5 | @include mat.core(); 6 | 7 | $angular-primary: mat.define-palette(mat.$indigo-palette, 500, 100, 900); 8 | $angular-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400); 9 | $angular-warn: mat.define-palette(mat.$red-palette); 10 | 11 | $angular-default-theme: mat.define-light-theme( 12 | ( 13 | color: ( 14 | primary: $angular-primary, 15 | accent: $angular-accent, 16 | warn: $angular-warn, 17 | ), 18 | typography: mat.define-typography-config(), 19 | density: 0, 20 | ) 21 | ); 22 | 23 | $angular-dark-theme: mat.define-dark-theme( 24 | ( 25 | color: ( 26 | primary: $angular-primary, 27 | accent: $angular-accent, 28 | warn: $angular-warn, 29 | ), 30 | typography: mat.define-typography-config(), 31 | density: 0, 32 | ) 33 | ); 34 | 35 | @include mat.all-component-themes($angular-default-theme); 36 | 37 | .dark-theme-basic { 38 | background-color: #424242; 39 | color: aliceblue; 40 | } 41 | 42 | .dark-theme { 43 | @include mat.all-component-colors($angular-dark-theme); 44 | } 45 | 46 | // @media (prefers-color-scheme: dark) { 47 | // .auto-theme { 48 | // @extend .dark-theme; 49 | // } 50 | // } 51 | -------------------------------------------------------------------------------- /src/proxy.conf.mjs: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | context: [ 4 | "/job", 5 | "/cache", 6 | "/options", 7 | "/core", 8 | "/fscache", 9 | "/debug", 10 | "/config", 11 | "/operations", 12 | "/backend", 13 | "/mount", 14 | "/vfs", 15 | "/sync", 16 | "/rc", 17 | "/pluginsctl", 18 | ], 19 | target: "http://127.0.0.1:5572", 20 | secure: false, 21 | }, 22 | ]; 23 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | @import "./material.scss"; 2 | 3 | html, 4 | body { 5 | height: 100%; 6 | } 7 | body { 8 | margin: 0; 9 | font-family: Roboto, "Helvetica Neue", sans-serif; 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": [ 7 | "@angular/localize" 8 | ] 9 | }, 10 | "files": [ 11 | "src/main.ts" 12 | ], 13 | "include": [ 14 | "src/**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "resolveJsonModule": true, 10 | "allowSyntheticDefaultImports": true, 11 | "noImplicitOverride": true, 12 | "noPropertyAccessFromIndexSignature": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "sourceMap": true, 16 | "declaration": false, 17 | "downlevelIteration": true, 18 | "experimentalDecorators": true, 19 | "moduleResolution": "node", 20 | "importHelpers": true, 21 | "target": "ES2022", 22 | "module": "ES2022", 23 | "useDefineForClassFields": false, 24 | "lib": [ 25 | "ES2022", 26 | "dom" 27 | ] 28 | }, 29 | "angularCompilerOptions": { 30 | "enableI18nLegacyMessageIdFormat": false, 31 | "strictInjectionParameters": true, 32 | "strictInputAccessModifiers": true, 33 | "strictTemplates": true 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine", 8 | "@angular/localize" 9 | ] 10 | }, 11 | "include": [ 12 | "src/**/*.spec.ts", 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | --------------------------------------------------------------------------------