diff --git a/.gitignore b/.gitignore index e590d097e2..45b61bdc79 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ joystream_runtime.wasm # Generated by yarn yarn* +!yarn.lock # JetBrains IDEs .idea @@ -24,4 +25,4 @@ yarn* .vscode # Compiled WASM code -*.wasm \ No newline at end of file +*.wasm diff --git a/.yarnclean b/.yarnclean new file mode 100644 index 0000000000..03fdc2d9d9 --- /dev/null +++ b/.yarnclean @@ -0,0 +1,2 @@ +@types/react-native +@polkadot/ts/node_modules diff --git a/cli/package.json b/cli/package.json index 942dfc1414..5187c11c3e 100644 --- a/cli/package.json +++ b/cli/package.json @@ -8,7 +8,7 @@ }, "bugs": "https://github.com/Joystream/substrate-runtime-joystream/issues", "dependencies": { - "@joystream/types": "^0.6.0", + "@joystream/types": "^0.9.1", "@oclif/command": "^1.5.19", "@oclif/config": "^1.14.0", "@oclif/plugin-help": "^2.2.3", diff --git a/cli/src/Api.ts b/cli/src/Api.ts index b499a50a42..948a411a71 100644 --- a/cli/src/Api.ts +++ b/cli/src/Api.ts @@ -1,5 +1,5 @@ import BN from 'bn.js'; -import { registerJoystreamTypes } from '@joystream/types'; +import { registerJoystreamTypes } from '@joystream/types/'; import { ApiPromise, WsProvider } from '@polkadot/api'; import { QueryableStorageMultiArg } from '@polkadot/api/types'; import { formatBalance } from '@polkadot/util'; diff --git a/cli/src/Types.ts b/cli/src/Types.ts index 5d38d06aab..1ada9d4dfe 100644 --- a/cli/src/Types.ts +++ b/cli/src/Types.ts @@ -1,5 +1,5 @@ import BN from 'bn.js'; -import { ElectionStage, Seat } from '@joystream/types'; +import { ElectionStage, Seat } from '@joystream/types/'; import { Option } from '@polkadot/types'; import { BlockNumber, Balance } from '@polkadot/types/interfaces'; import { DerivedBalances } from '@polkadot/api-derive/types'; diff --git a/cli/src/commands/council/info.ts b/cli/src/commands/council/info.ts index d68b0215ac..3bed6b8723 100644 --- a/cli/src/commands/council/info.ts +++ b/cli/src/commands/council/info.ts @@ -1,4 +1,4 @@ -import { ElectionStage } from '@joystream/types'; +import { ElectionStage } from '@joystream/types/'; import { formatNumber, formatBalance } from '@polkadot/util'; import { BlockNumber } from '@polkadot/types/interfaces'; import { CouncilInfoObj, NameValueObj } from '../../Types'; diff --git a/package.json b/package.json index 9f6df7a0b5..7c055bc55c 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,38 @@ -{ - "private": true, - "name": "joystream", - "license": "GPL-3.0-only", - "scripts": { - "test": "yarn && yarn workspaces run test", - "test-migration": "yarn && yarn workspaces run test-migration", - "cargo-checks": "devops/git-hooks/pre-commit && devops/git-hooks/pre-push", - "cargo-build": "scripts/cargo-build.sh" - }, - "workspaces": [ - "tests/network-tests" - ], - "devDependencies": { - "husky": "^4.2.5" - }, - "husky": { - "hooks": { - "pre-commit": "devops/git-hooks/pre-commit", - "pre-push": "devops/git-hooks/pre-push" - } - } -} +{ + "private": true, + "name": "joystream", + "license": "GPL-3.0-only", + "scripts": { + "test": "yarn && yarn workspaces run test", + "test-migration": "yarn && yarn workspaces run test-migration", + "postinstall": "yarn workspace @joystream/types build", + "cargo-checks": "devops/git-hooks/pre-commit && devops/git-hooks/pre-push", + "cargo-build": "scripts/cargo-build.sh" + }, + "workspaces": [ + "tests/network-tests", + "cli", + "types", + "pioneer", + "pioneer/packages/*" + ], + "resolutions": { + "@polkadot/api": "^0.96.1", + "@polkadot/api-contract": "^0.96.1", + "@polkadot/keyring": "^1.7.0-beta.5", + "@polkadot/types": "^0.96.1", + "@polkadot/util": "^1.7.0-beta.5", + "@polkadot/util-crypto": "^1.7.0-beta.5", + "babel-core": "^7.0.0-bridge.0", + "typescript": "^3.7.2" + }, + "devDependencies": { + "husky": "^4.2.5" + }, + "husky": { + "hooks": { + "pre-commit": "devops/git-hooks/pre-commit", + "pre-push": "devops/git-hooks/pre-push" + } + } +} diff --git a/pioneer/.123trigger b/pioneer/.123trigger new file mode 100644 index 0000000000..7ed6ff82de --- /dev/null +++ b/pioneer/.123trigger @@ -0,0 +1 @@ +5 diff --git a/pioneer/.babelrc.js b/pioneer/.babelrc.js new file mode 100644 index 0000000000..5188ea0422 --- /dev/null +++ b/pioneer/.babelrc.js @@ -0,0 +1 @@ +module.exports = require('./babel.config.js'); diff --git a/pioneer/.codeclimate.yml b/pioneer/.codeclimate.yml new file mode 100644 index 0000000000..534a13e57f --- /dev/null +++ b/pioneer/.codeclimate.yml @@ -0,0 +1,3 @@ +exclude_patterns: +- "**/*.spec.js" +- "**/*.spec.ts" diff --git a/pioneer/.dockerignore b/pioneer/.dockerignore new file mode 100644 index 0000000000..3c3629e647 --- /dev/null +++ b/pioneer/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/pioneer/.editorconfig b/pioneer/.editorconfig new file mode 100644 index 0000000000..3c229b5dc1 --- /dev/null +++ b/pioneer/.editorconfig @@ -0,0 +1,10 @@ +root = true +[*] +indent_style=space +indent_size=2 +tab_width=2 +end_of_line=lf +charset=utf-8 +trim_trailing_whitespace=true +max_line_length=120 +insert_final_newline=true diff --git a/pioneer/.eslintignore b/pioneer/.eslintignore new file mode 100644 index 0000000000..779cb10d5f --- /dev/null +++ b/pioneer/.eslintignore @@ -0,0 +1,4 @@ +**/build/* +**/coverage/* +**/node_modules/* +i18next-scanner.config.js diff --git a/pioneer/.eslintrc.js b/pioneer/.eslintrc.js new file mode 100644 index 0000000000..4288e158d0 --- /dev/null +++ b/pioneer/.eslintrc.js @@ -0,0 +1,16 @@ +const base = require('@polkadot/dev-react/config/eslint'); + +// add override for any (a metric ton of them, initial conversion) +module.exports = { + ...base, + parserOptions: { + ...base.parserOptions, + project: [ + './tsconfig.json' + ] + }, + rules: { + ...base.rules, + '@typescript-eslint/no-explicit-any': 'off' + } +}; diff --git a/pioneer/.github/workflows/pr-any.yml b/pioneer/.github/workflows/pr-any.yml new file mode 100644 index 0000000000..2341d6147c --- /dev/null +++ b/pioneer/.github/workflows/pr-any.yml @@ -0,0 +1,71 @@ +name: PR +on: [pull_request] + +jobs: + lint: + name: Linting + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [12.x] + steps: + - uses: actions/checkout@v1 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: lint + run: | + yarn install --frozen-lockfile + yarn lint + + test: + name: Testing + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [12.x] + steps: + - uses: actions/checkout@v1 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: test + run: | + yarn install --frozen-lockfile + yarn test + + build_code: + name: Build Code + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [12.x] + steps: + - uses: actions/checkout@v1 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: build + run: | + yarn install --frozen-lockfile + yarn build:code + + build_i18n: + name: Build i18n + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [12.x] + steps: + - uses: actions/checkout@v1 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: build + run: | + yarn install --frozen-lockfile + yarn build:i18n diff --git a/pioneer/.github/workflows/push-master.yml b/pioneer/.github/workflows/push-master.yml new file mode 100644 index 0000000000..cecfddca22 --- /dev/null +++ b/pioneer/.github/workflows/push-master.yml @@ -0,0 +1,32 @@ +name: Master +on: + push: + branches: + - master + +jobs: + build_code: + name: Build Code + if: "! contains(github.event.head_commit.message, '[CI Skip]')" + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [12.x] + steps: + - uses: actions/checkout@v1 + with: + token: ${{ secrets.GH_PAT }} + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: build + env: + CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} + GH_PAGES_SRC: packages/apps/build + GH_PAT: ${{ secrets.GH_PAT }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + yarn install --frozen-lockfile + yarn polkadot-dev-ghact-build + yarn polkadot-dev-ghact-docs diff --git a/pioneer/.gitignore b/pioneer/.gitignore new file mode 100644 index 0000000000..f24119b7cf --- /dev/null +++ b/pioneer/.gitignore @@ -0,0 +1,25 @@ +build/ +coverage/ +node_modules/ +tmp/ +.idea/ +.vscode/ +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local +.npmrc +cc-test-reporter +package-lock.json +npm-debug.log* +yarn-debug.log* +yarn-error.log* +!patches/** +.idea/ + +# Built Joystream types: +packages/joy-types/lib/ + +# Storybook +storybook-static/ diff --git a/pioneer/.gitlab-ci.yml b/pioneer/.gitlab-ci.yml new file mode 100644 index 0000000000..935bd94d54 --- /dev/null +++ b/pioneer/.gitlab-ci.yml @@ -0,0 +1,159 @@ +image: roffe/kubectl:latest +variables: + CI_REGISTRY: parity.azurecr.io + CI_REGISTRY_USER: parity + AUTO_DEVOPS_DOMAIN: poc-3.polkadot.io + +.kubernetes: &kubernetes + tags: + - kubernetes + +stages: + - dockerize + - test + - review + - staging + - production + - cleanup + +before_script: + - export DOCKER_IMAGE=$CI_REGISTRY/$CI_PROJECT_PATH_SLUG + - export DOCKER_TAG=$CI_COMMIT_REF_SLUG-$VERSION + - export DOCKER_IMAGE_FULL_NAME=$DOCKER_IMAGE:$DOCKER_TAG + +dockerize: + stage: dockerize + environment: + name: infrastructure_build + tags: + - kubernetes-parity-build + image: docker:git + services: + - docker:dind + variables: + DOCKER_DRIVER: overlay2 + DOCKER_HOST: tcp://localhost:2375 + script: + - echo $DOCKER_IMAGE + - echo $DOCKER_TAG + - echo $DOCKER_IMAGE_FULL_NAME + - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY" + - docker build -t "$DOCKER_IMAGE_FULL_NAME" . + - docker push "$DOCKER_IMAGE_FULL_NAME" + only: + - master + +review: + stage: review + <<: *kubernetes + script: + - setup_kubernetes + - deploy + environment: + name: review/$CI_COMMIT_REF_NAME + url: https://$CI_ENVIRONMENT_SLUG.$AUTO_DEVOPS_DOMAIN + on_stop: stop_review + only: + refs: + - branches + kubernetes: active + except: + - master + +stop_review: + stage: cleanup + <<: *kubernetes + variables: + GIT_STRATEGY: none + script: + - setup_kubernetes + - delete + environment: + name: review/$CI_COMMIT_REF_NAME + action: stop + when: manual + allow_failure: true + only: + refs: + - branches + kubernetes: active + except: + - master + +staging: + stage: staging + <<: *kubernetes + script: + - setup_kubernetes + - deploy + environment: + name: staging + url: https://staging.$AUTO_DEVOPS_DOMAIN + only: + refs: + - master + kubernetes: active + +production: + stage: production + <<: *kubernetes + script: + - setup_kubernetes + - deploy + environment: + name: production + url: https://$AUTO_DEVOPS_DOMAIN + when: manual + only: + refs: + - master + kubernetes: active + +# --------------------------------------------------------------------------- +.auto_devops: &auto_devops | + # Auto DevOps variables and functions + [[ "$TRACE" ]] && set -x + export DOCKER_IMAGE=$CI_REGISTRY/$CI_PROJECT_PATH_SLUG + export DOCKER_TAG=$CI_COMMIT_REF_SLUG-$CI_COMMIT_SHA + export DOCKER_IMAGE_FULL_NAME=$DOCKER_IMAGE:$DOCKER_TAG + + export AUTODEVOPS_HOST=$(echo $CI_ENVIRONMENT_URL | awk -F/ '{print $3}') + + function build() { + if [[ -n "$CI_REGISTRY_USER" ]]; then + echo "Logging to GitLab Container Registry with CI credentials..." + docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY" + echo "" + fi + + echo "Building Dockerfile-based application..." + docker build -t "$DOCKER_IMAGE_FULL_NAME" . + + echo "Pushing to GitLab Container Registry..." + docker push "$DOCKER_IMAGE_FULL_NAME" + echo "" + } + + function setup_kubernetes() { + kubectl describe namespace "$KUBE_NAMESPACE" || kubectl create namespace "$KUBE_NAMESPACE" + kubectl create secret -n "$KUBE_NAMESPACE" \ + docker-registry gitlab-registry \ + --docker-server="$CI_REGISTRY" \ + --docker-username="$CI_REGISTRY_USER" \ + --docker-password="$CI_REGISTRY_PASSWORD" \ + --docker-email="$GITLAB_USER_EMAIL" \ + -o yaml --dry-run | kubectl replace -n "$KUBE_NAMESPACE" --force -f - + } + + function deploy() { + cat ./deployment.template.yml | envsubst | kubectl apply -n "$KUBE_NAMESPACE" -f - + } + + function delete() { + kubectl -n "$KUBE_NAMESPACE" delete "deploy/$CI_ENVIRONMENT_SLUG-backend" + kubectl -n "$KUBE_NAMESPACE" delete "svc/$CI_ENVIRONMENT_SLUG-service" + kubectl -n "$KUBE_NAMESPACE" delete "ing/$CI_ENVIRONMENT_SLUG-ingress" + } + +before_script: + - *auto_devops diff --git a/pioneer/.npmignore b/pioneer/.npmignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pioneer/.nvmrc b/pioneer/.nvmrc new file mode 100644 index 0000000000..db24ab967f --- /dev/null +++ b/pioneer/.nvmrc @@ -0,0 +1 @@ +10.13.0 diff --git a/pioneer/.storybook/addons.ts b/pioneer/.storybook/addons.ts new file mode 100644 index 0000000000..49e8565cc6 --- /dev/null +++ b/pioneer/.storybook/addons.ts @@ -0,0 +1,3 @@ +import '@storybook/addon-knobs/register'; +import '@storybook/addon-actions/register'; +import '@storybook/addon-storysource/register'; diff --git a/pioneer/.storybook/config.tsx b/pioneer/.storybook/config.tsx new file mode 100644 index 0000000000..486a9a329d --- /dev/null +++ b/pioneer/.storybook/config.tsx @@ -0,0 +1,19 @@ +import React from 'react' +import { configure, addDecorator } from '@storybook/react'; +import '@storybook/addon-console'; +import StoryRouter from 'storybook-react-router'; + +import GlobalStyle from '@polkadot/react-components/styles'; +import 'semantic-ui-css/semantic.min.css' +import './style.css' + +addDecorator(StoryRouter()); + +addDecorator(story => ( +
+ + {story()} +
+)); + +configure(require.context('../packages', true, /\.stories\.tsx?$/), module) diff --git a/pioneer/.storybook/style.css b/pioneer/.storybook/style.css new file mode 100644 index 0000000000..ed3151fd12 --- /dev/null +++ b/pioneer/.storybook/style.css @@ -0,0 +1,4 @@ +.StorybookRoot { + background-color: #fafafa; + padding: 1rem 5rem; +} \ No newline at end of file diff --git a/pioneer/.storybook/webpack.config.js b/pioneer/.storybook/webpack.config.js new file mode 100644 index 0000000000..6c02952773 --- /dev/null +++ b/pioneer/.storybook/webpack.config.js @@ -0,0 +1,81 @@ +const path = require('path') +const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); +module.exports = ({ config }) => { + +// Post CSS loader for sources: +config.module.rules.push({ + test: /\.css$/, + include: path.resolve(__dirname, '../packages'), + exclude: /(node_modules)/, + use: [ + { + loader: require.resolve('postcss-loader'), + options: { + // Set postcss.config.js config path && ctx + config: { + path: '../postcss.config.js', + }, + ident: 'postcss', + plugins: () => [ + require('precss'), + require('autoprefixer'), + require('postcss-simple-vars'), + require('postcss-nested'), + require('postcss-import'), + require('postcss-clean')(), + require('postcss-flexbugs-fixes') + ] + } + } + ] +}); + +// TypeScript loader (via Babel to match polkadot/apps) +config.module.rules.push({ + test: /\.(js|ts|tsx)$/, + exclude: /(node_modules)/, + use: [ + { + loader: require.resolve('babel-loader'), + options: require('@polkadot/dev-react/config/babel') + }, + ], +}); +config.resolve.extensions.push('.js', '.ts', '.tsx'); + +// TSConfig, uses the same file as packages +config.resolve.plugins = config.resolve.plugins || []; +config.resolve.plugins.push( + new TsconfigPathsPlugin({ + configFile: path.resolve(__dirname, '../tsconfig.json'), + }) +); + +// Stories parser +config.module.rules.push({ + test: /\.stories\.tsx?$/, + loaders: [require.resolve('@storybook/source-loader')], + enforce: 'pre', +}); + +// CSS preprocessors +config.module.rules.push( + { + test: /\.s[ac]ss$/i, + use: [ + // Creates `style` nodes from JS strings + 'style-loader', + // Translates CSS into CommonJS + 'css-loader', + // Compiles Sass to CSS + 'sass-loader', + ], + }, + { + test: /\.less$/, + loaders: [ 'style-loader', 'css-loader', 'less-loader' ] + } +); + +return config; +}; diff --git a/pioneer/.travis.yml b/pioneer/.travis.yml new file mode 100644 index 0000000000..2a9e336eff --- /dev/null +++ b/pioneer/.travis.yml @@ -0,0 +1,13 @@ +language: node_js +node_js: + - "12" +cache: + yarn: true + directories: + - node_modules +before_install: + - curl -o- -L https://yarnpkg.com/install.sh | bash + - export PATH=$HOME/.yarn/bin:$PATH +script: + - yarn + - yarn build diff --git a/pioneer/BOUNTIES.md b/pioneer/BOUNTIES.md new file mode 100644 index 0000000000..bb6d10db13 --- /dev/null +++ b/pioneer/BOUNTIES.md @@ -0,0 +1,19 @@ +# Bounties + +From time-to-time we will add bounties for features. + +These are generously provided by the [Web3 Foundation](https://web3.foundation/) and as such employees of Parity or those of the W3F are ineligible for them. (This includes people being by either Party for development or community work, related or un-related to polkadot-js). + +The idea is that these bounties should be left open to community participation, so only if there is no outside interest for a specific issue, should those directly or indirectly paid by the W3F for work, attempt to close an issue. (in which case it will be "un-bountied") + +Current bounties are tracked by the [!bounty](https://github.com/polkadot-js/apps/labels/%21bounty) label. + +## Process + +Once listed, the normal [Gitcoin](https://gitcoin.co/) process kicks in. This means application, work and payment is managed by this tool. The values for bounties are determined by the size estimation done by the team. + +## Some small requests + +Please don't start work on an issue until you have been approved via the gitcoin interface. We generally love enthusiasm and code in the repo, however short-cutting the process does create some issues for the management of the bounties. We certainly don't want to be playing favorites if 2 PRs for the same issue are created at the same time. And in cases where somebody else has been approved and an unapproved PR comes in... well, it gets really murky. + +When making changes, please do not force push in your PRs, especially not after a review has been started. We will clone your repo and work from that, doing a simple `pull` on a force-pushed branch ends up being, well, less than simple. We squash merge all PRs, so you do not clutter up the history by using stock-standard pushes to your branch. diff --git a/pioneer/CHANGELOG.md b/pioneer/CHANGELOG.md new file mode 100644 index 0000000000..07acbeb0ef --- /dev/null +++ b/pioneer/CHANGELOG.md @@ -0,0 +1,124 @@ +# 0.36.1 + +- Api 0.95.1, Util 1.6.1, Extension 0.13.1 +- Support latest contracts ABI (via API), incl. rework of contracts UI +- Support for Kusama CC2 +- Support for Edgeware mainnet +- Experimental Ledger support +- Display forks on explorer (limited to Babe) +- Change settings to have Save as well as Save & Reload (depending on changes made) +- Updates to struct & enum rendering (as per extrinsic app) +- Backup, Password change & Delete don't show for built-in dev accounts +- Add commissions to the staking overview +- UI theme update +- A large number of components refactored for React functional components +- Allow dismiss of all notifications (via bounty) +- Migrate all buttons to have icons (via bounty) +- Proposal submission via modal (via bounty) +- i18n string extraction (via bounty) +- adjust signature validity (via bounty) +- Make the network selection clickable on network name (via bounty) +- ... and a number of cleanups all around + +# 0.35.1 + +- Api 0.91.1, Util 1.2.1, Extension 0.10.1 +- Support for accounts added via Qr (for instance, the Parity Signer) +- Support for accounts tied to specific chains (instead of just available to all) +- GenericAsset app transfers +- Support for Edgeware with default types +- Display received heartbeats for validators +- Allow optional params (really as optional) in RPC toolbox +- Add Polkascan for Kusama +- Fix account derivation with `///password` +- Lots of component & maintainability cleanups + +# 0.34.1 + +- Kusama support +- Full support for Substrate 2.x & Polkadot 0.5.0 networks +- Lots of UI updated to support both Substrate 1.x & 2.x chains +- Add of claims app for Kusama (and Polkadot) +- Basic Council, Parachains & Treasury apps +- Moved ui-* packages to react-* + +# 0.33.1 + +- Allow for externally injected accounts (i.e. via extension, polkadot-js & SingleSource) +- Links to extrnisics & addresses on Polkascan +- Rework Account & Address layouts with cards +- Transfer can happen from any point (via Transfer modal) +- Use new api.derive functions +- Introduce multi support (most via api.derive.*) +- Update all account and address modals +- Add seconding of proposals +- Staking updates, including un-bonding & withdrawals +- Update explorer with global query on hash/blocks +- Add filters on the staking page +- Vanitygen now supports sr25519 as well +- Fixes for importing of old JSON +- Latest @polkadot/util & @polkadot/api +- A large number of optimizations and smaller fixes + +# 0.32.1 + +- Support for Substrate 1.0 release & metadata v4 +- @polkadot/api 0.77.1 + +# 0.31.1 + +- Cleanups, fixes and features around the poc-4 staking module +- Number of UI enhancements + +# 0.30.1 + +- Staking page indicator for offline nodes (count & block) +- Rework page tabs and content layouts +- Cleanup of all UI summary headers +- Emberic Elem support (replaces Dried Danta) + +# 0.29.1 + +- @polkadot/util & @polkadot/api 0.75.1 + +# 0.28.1 + +- Support for substrate 1.0-rc + +# 0.27.1 + +- Bring in new staking & nominating functions +- Swap default keyring accounts (on creation) to sr25519 +- New faster crypto algorithms +- Misc. bug fixes all around + +# 0.26.1 + +- Swap keyring to HDKD derivation, mnemonic keys are now not backwards compatible with those created earlier. (Defaults are still for ed25519) +- Swap crypto to new WASM-backed version (and remove libsodium dependency) +- UI to allow for derived keys for ed25519 and sr25519 +- New mobile-friendly sidebar +- Fix issues with nominating (old non-bonds interface) + +# 0.25.1 + +- Swap to publishing -beta.x on merge (non-breaking testing) + + # 0.24.1 + + Storage now handles Option type properly + + # 0.23.1 + + JavaScript console introduced + +# 0.22.1 + +- Use new Compact transaction format - this requires the latest binaries from either Polkadot or Substrate + +# 0.21.1 + +- PoC-3 support with latest Substrate master & Polkadot master +- Add support for Charred Cherry (Substrate) and Alexander (Polkadot) testnets +- Too many changes to mention, master now only supports latest PoC-3 iteration +- Use https://poc-2.polkadot.io if access is required to PoC-2 era networks diff --git a/pioneer/CONTRIBUTING.md b/pioneer/CONTRIBUTING.md new file mode 100644 index 0000000000..947952e851 --- /dev/null +++ b/pioneer/CONTRIBUTING.md @@ -0,0 +1,45 @@ +# Contributing + +## What? + +Individuals making significant and valuable contributions are given commit-access to a project to contribute as they see fit. +A project is more like an open wiki than a standard guarded open source project. + +## Rules + +There are a few basic ground-rules for contributors (including the maintainer(s) of the project): + +1. **No `--force` pushes** or modifying the Git history in any way. If you need to rebase, ensure you do it in your own repo. +2. **Non-master branches**, prefixed with a short name moniker (e.g. `-`) must be used for ongoing work. +3. **All modifications** must be made in a **pull-request** to solicit feedback from other contributors. +4. A pull-request *must not be merged until CI* has finished successfully. + +#### Merging pull requests once CI is successful: +- A pull request with no large change to logic that is an urgent fix may be merged after a non-author contributor has reviewed it well. +- No PR should be merged until all reviews' comments are addressed. + +#### Reviewing pull requests: +When reviewing a pull request, the end-goal is to suggest useful changes to the author. Reviews should finish with approval unless there are issues that would result in: + +- Buggy behaviour. +- Undue maintenance burden. +- Breaking with house coding style. +- Pessimisation (i.e. reduction of speed as measured in the projects benchmarks). +- Feature reduction (i.e. it removes some aspect of functionality that a significant minority of users rely on). +- Uselessness (i.e. it does not strictly add a feature or fix a known issue). + +#### Reviews may not be used as an effective veto for a PR because: +- There exists a somewhat cleaner/better/faster way of accomplishing the same feature/fix. +- It does not fit well with some other contributors' longer-term vision for the project. + +## Releases + +Declaring formal releases remains the prerogative of the project maintainer(s). + +## Changes to this arrangement + +This is an experiment and feedback is welcome! This document may also be subject to pull-requests or changes by contributors where you believe you have something valuable to add or change. + +## Heritage + +These contributing guidelines are modified from the "OPEN Open Source Project" guidelines for the Level project: [https://github.com/Level/community/blob/master/CONTRIBUTING.md](https://github.com/Level/community/blob/master/CONTRIBUTING.md) diff --git a/pioneer/Dockerfile b/pioneer/Dockerfile new file mode 100644 index 0000000000..e728afda0c --- /dev/null +++ b/pioneer/Dockerfile @@ -0,0 +1,26 @@ +FROM ubuntu:18.04 as builder + +# Install any needed packages +RUN apt-get update && apt-get install -y curl git gnupg + +# install nodejs +RUN curl -sL https://deb.nodesource.com/setup_10.x | bash - +RUN apt-get install -y nodejs + +WORKDIR /app +RUN git clone https://github.com/polkadot-js/apps + +WORKDIR /app/apps +RUN npm install yarn -g +RUN yarn +RUN NODE_ENV=production yarn build + +FROM ubuntu:18.04 + +RUN apt-get update && apt-get -y install nginx + +COPY --from=builder /app/apps/packages/apps/build /var/www/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/pioneer/LICENSE b/pioneer/LICENSE new file mode 100644 index 0000000000..0d381b2e97 --- /dev/null +++ b/pioneer/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/pioneer/README.md b/pioneer/README.md new file mode 100644 index 0000000000..2e54f5f110 --- /dev/null +++ b/pioneer/README.md @@ -0,0 +1,54 @@ +

+ +![Content Directory](https://user-images.githubusercontent.com/4144334/67765742-bbfab280-fa44-11e9-8b13-494b1bfb6014.jpeg) + +A Portal into the Joystream network. Provides a view and interaction layer from a browser. + +This can be accessed as a hosted application via [https://testnet.joystream.org](https://testnet.joystream.org). + +## overview + +The repo is split into a number of packages, each representing an application. These are - + +- [apps](packages/apps/) This is the main entry point. It handles the selection sidebar and routing to the specific application being displayed. +- [app-accounts](packages/app-accounts/) A basic account management app. +- [app-address-book](packages/app-address-book/) A basic address management app. +- [app-explorer](packages/app-explorer/) A simple block explorer. It only shows the most recent blocks, updating as they become available. +- [app-extrinsics](packages/app-extrinsics/) Submission of extrinsics to a node. +- [app-js](packages/app-js/) An online code editor with [@polkadot-js/api](https://github.com/polkadot-js/api/tree/master/packages/api) access to the currently connected node. +- [app-settings](packages/app-settings/) A basic settings management app, allowing choice of language, node to connect to, and theme +- [app-staking](packages/app-staking/) A basic staking management app, allowing staking and nominations. +- [app-storage](packages/app-storage/) A simple node storage query application. Multiple queries can be queued and updates as new values become available. +- [app-toolbox](packages/app-toolbox/) Submission of raw data to RPC endpoints and utility hashing functions. +- [app-transfer](packages/app-transfer/) A basic account management app, allowing transfer of Units/DOTs between accounts. + +In addition the following libraries are also included in the repo. These are to be moved to the [@polkadot/ui](https://github.com/polkadot-js/ui/) repository once it reaches a base level of stability and usability. (At this point with the framework being tested on the apps above, it makes development easier having it close) + +- [react-components](packages/react-components/) A reactive (using RxJS) application framework with a number of useful shared components. +- [react-signer](packages/react-signer/) Signer implementation for apps. +- [react-query](packages/react-query) Base components that use the RxJS Observable APIs + +## development + +Contributions are welcome! + +To start off, this repo (along with others in the [@polkadot](https://github.com/polkadot-js/) family) uses yarn workspaces to organise the code. As such, after cloning dependencies _should_ be installed via `yarn`, not via npm, the latter will result in broken dependencies. + +To get started - + +1. Clone the repo locally, via `git clone https://github.com/joystream/apps ` +2. Ensure that you have a recent LTS version of Node.js, for development purposes [Node >=10.13.0](https://nodejs.org/en/) is recommended. +3. Ensure that you have a recent version of Yarn, for development purposes [Yarn >=1.10.1](https://yarnpkg.com/docs/install) is required. +4. Install the dependencies by running `yarn` +5. Ready! Now you can launch the UI (assuming you have a local Polkadot Node running), via `yarn run start` +6. Access the UI via [http://localhost:3000](http://localhost:3000) + +### Storybook + +There is a [StoryBook](https://storybook.js.org) implementation, the UI for which can be started with `yarn storybook` and then accessed in a browser via http://localhost:3001 (and the server will open a new browser tab by default when it starts). + +Story code can be placed anywhere in the `packages` directory, and will be detected as long as the file name ends in `.stories.tsx. Stories should be defined in the [Component Story Format (CSF)](https://storybook.js.org/docs/formats/component-story-format) for consistency. + +There are several StoryBook addons available, the most useful of which is [Knobs](https://www.npmjs.com/package/@storybook/addon-knobs), which allows props to be altered in real time. + +Note that currently StoryBook only allows for stateless components; it has no connection to polkadot.js or any Substrate node. This means that existing components, which are often tightly coupled with the Polkadot API, cannot be used in storybook. diff --git a/pioneer/babel.config.js b/pioneer/babel.config.js new file mode 100644 index 0000000000..07e6884f20 --- /dev/null +++ b/pioneer/babel.config.js @@ -0,0 +1,4 @@ +module.exports = { + extends: '@polkadot/dev-react/config/babel', + sourceType: 'unambiguous', +}; diff --git a/pioneer/deployment.extras.yml b/pioneer/deployment.extras.yml new file mode 100644 index 0000000000..6372814aeb --- /dev/null +++ b/pioneer/deployment.extras.yml @@ -0,0 +1,33 @@ +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: production-ingress-substrate-ui + namespace: poc3-122 + annotations: + kubernetes.io/ingress.class: traefik + traefik.frontend.entryPoints: "https,http" +spec: + rules: + - host: substrate-ui.parity.io + http: + paths: + - backend: + serviceName: production-service + servicePort: 80 +--- +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: production-ingress-substrate-ui-light + namespace: poc3-122 + annotations: + kubernetes.io/ingress.class: traefik + traefik.frontend.entryPoints: "https,http" +spec: + rules: + - host: substrate-ui-light.parity.io + http: + paths: + - backend: + serviceName: production-service + servicePort: 80 diff --git a/pioneer/deployment.template.yml b/pioneer/deployment.template.yml new file mode 100644 index 0000000000..e1be2eacef --- /dev/null +++ b/pioneer/deployment.template.yml @@ -0,0 +1,60 @@ +--- +apiVersion: v1 +data: +# AZURE_DOCKER_REGISTRY_CONFIG is base64 of this: +# {"auths":{"parity.azurecr.io":{"username":"parity","password":"","email":"admin@parity.io","auth":""}}} + .dockerconfigjson: $AZURE_DOCKER_REGISTRY_CONFIG +kind: Secret +metadata: + name: azure-docker-registry-key +type: kubernetes.io/dockerconfigjson +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: $CI_ENVIRONMENT_SLUG-backend +spec: + replicas: $REPLICAS + template: + metadata: + labels: + app: $CI_ENVIRONMENT_SLUG + component: backend + spec: + containers: + - name: $CI_ENVIRONMENT_SLUG-backend + image: $DOCKER_IMAGE_FULL_NAME + imagePullPolicy: Always + ports: + - containerPort: 80 + imagePullSecrets: + - name: azure-docker-registry-key +--- +apiVersion: v1 +kind: Service +metadata: + name: $CI_ENVIRONMENT_SLUG-service +spec: + selector: + app: $CI_ENVIRONMENT_SLUG + ports: + - name: http + port: 80 + targetPort: 80 + protocol: TCP +--- +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: $CI_ENVIRONMENT_SLUG-ingress + annotations: + kubernetes.io/ingress.class: traefik + traefik.frontend.entryPoints: "https,http" +spec: + rules: + - host: $AUTODEVOPS_HOST + http: + paths: + - backend: + serviceName: $CI_ENVIRONMENT_SLUG-service + servicePort: 80 diff --git a/pioneer/gh-pages-refresh.sh b/pioneer/gh-pages-refresh.sh new file mode 100755 index 0000000000..b4aac5b4c8 --- /dev/null +++ b/pioneer/gh-pages-refresh.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +exit 0 + +# checkout latest +git fetch +git checkout gh-pages +git pull +git checkout --orphan gh-pages-temp + +# cleanup +rm -rf node_modules +rm -rf coverage +rm -rf packages +rm -rf test + +# add +git add -A +git commit -am "refresh history" + +# danger, force new +git branch -D gh-pages +git branch -m gh-pages +git push -f origin gh-pages diff --git a/pioneer/i18next-scanner.config.js b/pioneer/i18next-scanner.config.js new file mode 100644 index 0000000000..fedbd1b1a4 --- /dev/null +++ b/pioneer/i18next-scanner.config.js @@ -0,0 +1,89 @@ +const fs = require('fs'); +const path = require('path'); +const typescript = require('typescript'); + +module.exports = { + input: [ + 'packages/*/src/**/*.{ts,tsx}', + // Use ! to filter out files or directories + '!packages/*/src/**/*.spec.{ts,tsx}', + '!packages/*/src/i18n/**', + '!**/node_modules/**' + ], + output: './', + options: { + debug: true, + func: { + list: ['t', 'i18next.t', 'i18n.t'], + extensions: ['.tsx'] + }, + trans: { + component: 'Trans' + }, + lngs: ['en'], + defaultLng: 'en', + ns: [ + 'app-123code', + 'app-accounts', + 'app-address-book', + 'app-claims', + 'app-contracts', + 'app-council', + 'app-dashboard', + 'app-democracy', + 'app-explorer', + 'app-extrinsics', + 'app-generic-asset', + 'app-js', + 'app-parachains', + 'app-settings', + 'app-staking', + 'app-storage', + 'app-sudo', + 'app-toolbox', + 'app-transfer', + 'app-treasury', + 'apps', + 'apps-routing', + 'react-api', + 'react-components', + 'react-params', + 'react-query', + 'react-signer', + 'ui' + ], + defaultNs: 'ui', + resource: { + loadPath: 'packages/apps/public/locales/{{lng}}/{{ns}}.json', + savePath: 'packages/apps/public/locales/{{lng}}/{{ns}}.json', + jsonIndent: 2, + lineEnding: '\n' + }, + nsSeparator: false, // namespace separator + keySeparator: false // key separator + }, + transform: function transform (file, enc, done) { + const { ext } = path.parse(file.path); + + if (ext === '.tsx') { + const content = fs.readFileSync(file.path, enc); + + const { outputText } = typescript.transpileModule(content, { + compilerOptions: { + target: 'es2018' + }, + fileName: path.basename(file.path) + }); + + const parserHandler = (key, options) => { + options.defaultValue = key; + options.ns = /packages\/(.*?)\/src/g.exec(file.path)[1]; + this.parser.set(key, options); + }; + + this.parser.parseFuncFromString(outputText, parserHandler); + } + + done(); + } +}; diff --git a/pioneer/img/pioneer_new.svg b/pioneer/img/pioneer_new.svg new file mode 100644 index 0000000000..550f529e35 --- /dev/null +++ b/pioneer/img/pioneer_new.svg @@ -0,0 +1,106 @@ + + + + Group + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/pioneer/jest.config.js b/pioneer/jest.config.js new file mode 100644 index 0000000000..643163e06d --- /dev/null +++ b/pioneer/jest.config.js @@ -0,0 +1,17 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const config = require('@polkadot/dev-react/config/jest'); +const findPackages = require('./scripts/findPackages'); + +const internalModules = findPackages().reduce((modules, { dir, name }) => { + modules[`${name}(.*)$`] = `/packages/${dir}/src/$1`; + + return modules; +}, {}); + +module.exports = Object.assign({}, config, { + moduleNameMapper: { + ...internalModules, + '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 'empty/object', + '\\.(css|less)$': 'empty/object' + } +}); diff --git a/pioneer/lerna.json b/pioneer/lerna.json new file mode 100644 index 0000000000..717f6b57c5 --- /dev/null +++ b/pioneer/lerna.json @@ -0,0 +1,14 @@ +{ + "lerna": "2.11.0", + "npmClient": "yarn", + "useWorkspaces": true, + "command": { + "publish": { + "allowBranch": "master" + } + }, + "packages": [ + "packages/*" + ], + "version": "0.37.0-beta.63" +} diff --git a/pioneer/package.json b/pioneer/package.json new file mode 100644 index 0000000000..3eecddcce6 --- /dev/null +++ b/pioneer/package.json @@ -0,0 +1,85 @@ +{ + "version": "0.37.0-beta.63", + "private": true, + "engines": { + "node": ">=10.13.0", + "yarn": "^1.10.1" + }, + "homepage": ".", + "name": "pioneer", + "scripts": { + "analyze": "yarn run build && cd packages/apps && yarn run source-map-explorer build/main.*.js", + "build": "yarn run build:code && yarn run build:i18n", + "build:code": "NODE_ENV=production polkadot-dev-build-ts", + "build:i18n": "i18next-scanner --config i18next-scanner.config.js", + "docs": "echo \"skipping docs\"", + "clean": "polkadot-dev-clean-build", + "clean:i18n": "rm -rf packages/apps/public/locales/en && mkdir -p packages/apps/public/locales/en", + "lint": "eslint --ext .js,.jsx,.ts,.tsx . && tsc --noEmit --pretty", + "postinstall": "polkadot-dev-yarn-only", + "test": "echo \"skipping tests\"", + "vanitygen": "node packages/app-accounts/scripts/vanitygen.js", + "start": "cd packages/apps && webpack --config webpack.config.js", + "generate-schemas": "json2ts -i packages/joy-types/src/schemas/role.schema.json -o packages/joy-types/src/schemas/role.schema.ts", + "build-storybook": "build-storybook -c .storybook", + "storybook": "start-storybook -s ./packages/apps/public -p 3001" + }, + "devDependencies": { + "@babel/core": "^7.7.0", + "@babel/runtime": "^7.7.1", + "@babel/cli": "^7.7.4", + "@polkadot/dev-react": "^0.32.0-beta.13", + "@polkadot/ts": "^0.1.84", + "@polkadot/dev": "^0.32.0-beta.15", + "@storybook/addon-knobs": "^5.2.5", + "@storybook/addon-storysource": "^5.2.5", + "@types/jest": "^24.0.22", + "@types/react-router-dom": "^5.1.4", + "@types/yup": "^0.26.36", + "autoprefixer": "^9.7.1", + "empty": "^0.10.1", + "html-loader": "^0.5.5", + "i18next-scanner": "^2.10.3", + "json-schema-to-typescript": "^7.1.0", + "markdown-loader": "^5.1.0", + "postcss": "^7.0.21", + "postcss-clean": "^1.1.0", + "postcss-flexbugs-fixes": "^4.1.0", + "postcss-import": "^12.0.0", + "postcss-loader": "^3.0.0", + "postcss-nested": "^4.2.1", + "postcss-sass": "^0.4.1", + "postcss-simple-vars": "^5.0.0", + "precss": "^4.0.0", + "source-map-explorer": "^2.0.1", + "storybook-react-router": "^1.0.8", + "ts-jest": "^24.1.0", + "tsconfig-paths-webpack-plugin": "^3.2.0", + "webpack": "^4.33.0", + "typescript": "3.7.2", + "cpx": "^1.5.0", + "eslint-config-semistandard": "^15.0.0", + "eslint-config-standard": "^14.1.1", + "eslint-plugin-import": "^2.20.2", + "eslint-plugin-node": "^11.1.0", + "eslint-plugin-promise": "^4.2.1", + "eslint-plugin-standard": "^4.0.1" + }, + "dependencies": { + "@polkadot/ui-settings": "^0.47.0-beta.3", + "@storybook/addon-actions": "^5.2.5", + "@storybook/addon-console": "^1.2.1", + "@storybook/react": "^5.2.5", + "@types/lodash": "^4.14.138", + "@types/marked": "^0.7.0", + "ajv": "^6.10.2", + "css-loader": "^3.2.0", + "less": "^3.10.3", + "less-loader": "^5.0.0", + "lodash": "^4.17.15", + "node-sass": "^4.13.0", + "sass-loader": "^8.0.0", + "style-loader": "^1.0.0", + "@joystream/types": "^0.9.1" + } +} diff --git a/pioneer/packages/app-123code/LICENSE b/pioneer/packages/app-123code/LICENSE new file mode 100644 index 0000000000..0d381b2e97 --- /dev/null +++ b/pioneer/packages/app-123code/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/pioneer/packages/app-123code/README.md b/pioneer/packages/app-123code/README.md new file mode 100644 index 0000000000..fe01a1661a --- /dev/null +++ b/pioneer/packages/app-123code/README.md @@ -0,0 +1,22 @@ +# @polkadot/app-123code + +A simple template to get started with adding an "app" to this UI. It contains the bare minimum for a nicely hackable app (if you just want to code _somewhere_) and the steps needed to create, add and register an new app that appears in the UI. + +## adding an app + +If you want to add a new app to the UI, this is the place to start. + +1. Duplicate this `app-123code` folder and give it an appropriate name, in this case we will select `app-example` to keep things clear. +2. Edit the `apps-example/package.json` app description, i.e. the name, author and relevant overview. + +And we have the basic app source setup, time to get the tooling correct. + +3. Add the new app to the TypeScript config in root, `tsconfig.json`, i.e. an entry such as `"@polkadot/app-example/*": [ "packages/app-example/src/*" ],` + +At this point the app should be buildable, but not quite reachable. The final step is to add it to the actual sidebar in `apps`. + +4. In `apps-routing/src` duplicate the `123code.ts` file to `example.ts` and edit it with the appropriate information, including the hash link, name and icon (any icon name from semantic-ui-react/font-awesome 4 should be appropriate). +5. In the above description file, the `isHidden` field needs to be toggled to make it appear - the base template is hidden by default. +6. Finally add the `template` to the `apps-routing/src/index.ts` file at the appropriate place for both full and light mode (either optional) + +Yes. After all that we have things hooked up. Run `yarn start` and your new app (non-coded) should show up. Now start having fun and building something great. diff --git a/pioneer/packages/app-123code/package.json b/pioneer/packages/app-123code/package.json new file mode 100644 index 0000000000..35ab2dfe86 --- /dev/null +++ b/pioneer/packages/app-123code/package.json @@ -0,0 +1,16 @@ +{ + "name": "@polkadot/app-123code", + "version": "0.37.0-beta.63", + "description": "A basic app that shows the ropes on customisation", + "main": "index.js", + "scripts": {}, + "author": "Jaco Greeff ", + "maintainers": [ + "Jaco Greeff " + ], + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.7.1", + "@polkadot/react-components": "^0.37.0-beta.63" + } +} diff --git a/pioneer/packages/app-123code/src/AccountSelector.tsx b/pioneer/packages/app-123code/src/AccountSelector.tsx new file mode 100644 index 0000000000..289b60b869 --- /dev/null +++ b/pioneer/packages/app-123code/src/AccountSelector.tsx @@ -0,0 +1,49 @@ +// Copyright 2017-2019 @polkadot/app-123code authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { Bubble, InputAddress } from '@polkadot/react-components'; +import { AccountIndex, Balance, Nonce } from '@polkadot/react-query'; + +interface Props { + className?: string; + onChange: (accountId: string | null) => void; +} + +function AccountSelector ({ className, onChange }: Props): React.ReactElement { + const [accountId, setAccountId] = useState(null); + + useEffect((): void => onChange(accountId), [accountId]); + + return ( +
+ +
+ + + + + + + + + +
+
+ ); +} + +export default styled(AccountSelector)` + align-items: flex-end; + + .summary { + text-align: center; + } +`; diff --git a/pioneer/packages/app-123code/src/Summary.tsx b/pioneer/packages/app-123code/src/Summary.tsx new file mode 100644 index 0000000000..3bd84f13db --- /dev/null +++ b/pioneer/packages/app-123code/src/Summary.tsx @@ -0,0 +1,28 @@ +// Copyright 2017-2019 @polkadot/app-123code authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { BareProps } from '@polkadot/react-components/types'; + +import React from 'react'; +import styled from 'styled-components'; + +interface Props extends BareProps { + children: React.ReactNode; +} + +function Summary ({ children, className, style }: Props): React.ReactElement { + return ( +
+ {children} +
+ ); +} + +export default styled(Summary)` + opacity: 0.5; + padding: 1rem 1.5rem; +`; diff --git a/pioneer/packages/app-123code/src/SummaryBar.tsx b/pioneer/packages/app-123code/src/SummaryBar.tsx new file mode 100644 index 0000000000..9242644597 --- /dev/null +++ b/pioneer/packages/app-123code/src/SummaryBar.tsx @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/camelcase */ +// Copyright 2017-2019 @polkadot/app-123code authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { AccountId } from '@polkadot/types/interfaces'; +import { BareProps, I18nProps } from '@polkadot/react-components/types'; + +import BN from 'bn.js'; +import React, { useContext } from 'react'; +import { ApiContext, withCalls } from '@polkadot/react-api'; +import { Bubble, IdentityIcon } from '@polkadot/react-components'; +import { formatBalance, formatNumber } from '@polkadot/util'; + +import translate from './translate'; + +interface Props extends BareProps, I18nProps { + balances_totalIssuance?: BN; + chain_bestNumber?: BN; + chain_bestNumberLag?: BN; + staking_validators?: AccountId[]; +} + +function SummaryBar ({ balances_totalIssuance, chain_bestNumber, chain_bestNumberLag, staking_validators }: Props): React.ReactElement { + const { api, systemChain, systemName, systemVersion } = useContext(ApiContext); + + return ( + +
+ + {systemName} v{systemVersion} + + + {systemChain} + + + {api.runtimeVersion.implName} v{api.runtimeVersion.implVersion} + + + {formatNumber(chain_bestNumber)} ({formatNumber(chain_bestNumberLag)} lag) + + {staking_validators && ( + { + staking_validators.map((accountId, index): React.ReactNode => ( + + )) + } + )} + + {formatBalance(balances_totalIssuance)} + +
+
+ ); +} + +// inject the actual API calls automatically into props +export default translate( + withCalls( + 'derive.chain.bestNumber', + 'derive.chain.bestNumberLag', + 'derive.staking.validators', + 'query.balances.totalIssuance' + )(SummaryBar) +); diff --git a/pioneer/packages/app-123code/src/Transfer.tsx b/pioneer/packages/app-123code/src/Transfer.tsx new file mode 100644 index 0000000000..35ccce6f8b --- /dev/null +++ b/pioneer/packages/app-123code/src/Transfer.tsx @@ -0,0 +1,47 @@ +// Copyright 2017-2019 @polkadot/app-123code authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import BN from 'bn.js'; +import React, { useState } from 'react'; +import { Button, InputAddress, InputBalance, TxButton } from '@polkadot/react-components'; + +import Summary from './Summary'; + +interface Props { + accountId?: string | null; +} + +export default function Transfer ({ accountId }: Props): React.ReactElement { + const [amount, setAmount] = useState(null); + const [recipientId, setRecipientId] = useState(null); + + return ( +
+

transfer

+
+
+ + + + + +
+ Make a transfer from any account you control to another account. Transfer fees and per-transaction fees apply and will be calculated upon submission. +
+
+ ); +} diff --git a/pioneer/packages/app-123code/src/index.tsx b/pioneer/packages/app-123code/src/index.tsx new file mode 100644 index 0000000000..de0de71b2c --- /dev/null +++ b/pioneer/packages/app-123code/src/index.tsx @@ -0,0 +1,38 @@ +// Copyright 2017-2019 @polkadot/app-123code authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +// some types, AppProps for the app and I18nProps to indicate +// translatable strings. Generally the latter is quite "light", +// `t` is inject into props (see the HOC export) and `t('any text') +// does the translation +import { AppProps, I18nProps } from '@polkadot/react-components/types'; + +// external imports (including those found in the packages/* +// of this repo) +import React, { useState } from 'react'; + +// local imports and components +import AccountSelector from './AccountSelector'; +import SummaryBar from './SummaryBar'; +import Transfer from './Transfer'; +import translate from './translate'; + +// define our internal types +interface Props extends AppProps, I18nProps {} + +function App ({ className }: Props): React.ReactElement { + const [accountId, setAccountId] = useState(null); + + return ( + // in all apps, the main wrapper is setup to allow the padding + // and margins inside the application. (Just from a consistent pov) +
+ + + +
+ ); +} + +export default translate(App); diff --git a/pioneer/packages/app-123code/src/translate.ts b/pioneer/packages/app-123code/src/translate.ts new file mode 100644 index 0000000000..417da6ef17 --- /dev/null +++ b/pioneer/packages/app-123code/src/translate.ts @@ -0,0 +1,7 @@ +// Copyright 2017-2019 @polkadot/app-123code authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { withTranslation } from 'react-i18next'; + +export default withTranslation(['app-123code']); diff --git a/pioneer/packages/app-accounts/LICENSE b/pioneer/packages/app-accounts/LICENSE new file mode 100644 index 0000000000..0d381b2e97 --- /dev/null +++ b/pioneer/packages/app-accounts/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/pioneer/packages/app-accounts/README.md b/pioneer/packages/app-accounts/README.md new file mode 100644 index 0000000000..6adfe80b58 --- /dev/null +++ b/pioneer/packages/app-accounts/README.md @@ -0,0 +1,5 @@ +# @polkadot/app-accounts + +## vanity + +Running `yarn run vanitygen --match ` runs the generator as a Node CLI app. (Orders of a magnitude faster due to the use of libsoldium bindings) diff --git a/pioneer/packages/app-accounts/package.json b/pioneer/packages/app-accounts/package.json new file mode 100644 index 0000000000..b878755133 --- /dev/null +++ b/pioneer/packages/app-accounts/package.json @@ -0,0 +1,23 @@ +{ + "name": "@polkadot/app-accounts", + "version": "0.37.0-beta.63", + "main": "index.js", + "repository": "https://github.com/polkadot-js/apps.git", + "author": "Jaco Greeff ", + "maintainers": [ + "Jaco Greeff " + ], + "contributors": [], + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.7.1", + "@polkadot/react-components": "^0.37.0-beta.63", + "@polkadot/react-qr": "^0.47.0-beta.3", + "@types/file-saver": "^2.0.0", + "@types/yargs": "^13.0.2", + "babel-plugin-module-resolver": "^3.1.1", + "detect-browser": "^4.8.0", + "file-saver": "^2.0.0", + "yargs": "^14.2.0" + } +} diff --git a/pioneer/packages/app-accounts/scripts/vanitygen.js b/pioneer/packages/app-accounts/scripts/vanitygen.js new file mode 100755 index 0000000000..32fc07b1d3 --- /dev/null +++ b/pioneer/packages/app-accounts/scripts/vanitygen.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node +/* eslint-disable @typescript-eslint/no-var-requires */ +// Copyright 2017-2019 @polkadot/app-accounts authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +const fs = require('fs'); +const path = require('path'); + +const [compiled] = ['../vanitygen/cli.js'] + .map((file) => path.join(__dirname, file)) + .filter((file) => fs.existsSync(file)); + +if (compiled) { + require(compiled); +} else { + require('@babel/register')({ + extensions: ['.js', '.ts'], + plugins: [ + ['module-resolver', { + alias: {} + }] + ] + }); + require('../src/vanitygen/cli.ts'); +} diff --git a/pioneer/packages/app-accounts/src/Account.tsx b/pioneer/packages/app-accounts/src/Account.tsx new file mode 100644 index 0000000000..91529679a9 --- /dev/null +++ b/pioneer/packages/app-accounts/src/Account.tsx @@ -0,0 +1,227 @@ +// Copyright 2017-2019 @polkadot/app-staking authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { ActionStatus } from '@polkadot/react-components/Status/types'; +import { I18nProps } from '@polkadot/react-components/types'; + +import React, { useState, useEffect } from 'react'; +import { Popup } from 'semantic-ui-react'; +import styled from 'styled-components'; +import { AddressCard, AddressInfo, Button, ChainLock, Forget, Menu } from '@polkadot/react-components'; +import keyring from '@polkadot/ui-keyring'; + +import Backup from './modals/Backup'; +import ChangePass from './modals/ChangePass'; +import Derive from './modals/Derive'; +import Transfer from './modals/Transfer'; +import translate from './translate'; + +interface Props extends I18nProps { + address: string; + className?: string; +} + +function Account ({ address, className, t }: Props): React.ReactElement { + const [genesisHash, setGenesisHash] = useState(null); + const [isBackupOpen, setIsBackupOpen] = useState(false); + const [{ isDevelopment, isEditable, isExternal }, setFlags] = useState({ isDevelopment: false, isEditable: false, isExternal: false }); + const [isDeriveOpen, setIsDeriveOpen] = useState(false); + const [isForgetOpen, setIsForgetOpen] = useState(false); + const [isPasswordOpen, setIsPasswordOpen] = useState(false); + const [isSettingPopupOpen, setIsSettingPopupOpen] = useState(false); + const [isTransferOpen, setIsTransferOpen] = useState(false); + + useEffect((): void => { + const account = keyring.getAccount(address); + + setGenesisHash((account && account.meta.genesisHash) || null); + setFlags({ + isDevelopment: (account && account.meta.isTesting) || false, + isEditable: (account && !(account.meta.isInjected || account.meta.isHardware)) || false, + isExternal: (account && account.meta.isExternal) || false + }); + }, [address]); + + const _toggleBackup = (): void => setIsBackupOpen(!isBackupOpen); + const _toggleDerive = (): void => setIsDeriveOpen(!isDeriveOpen); + const _toggleForget = (): void => setIsForgetOpen(!isForgetOpen); + const _togglePass = (): void => setIsPasswordOpen(!isPasswordOpen); + const _toggleTransfer = (): void => setIsTransferOpen(!isTransferOpen); + const _toggleSettingPopup = (): void => setIsSettingPopupOpen(!isSettingPopupOpen); + const _onForget = (): void => { + if (!address) { + return; + } + + const status: Partial = { + account: address, + action: 'forget' + }; + + try { + keyring.forgetAccount(address); + status.status = 'success'; + status.message = t('account forgotten'); + } catch (error) { + status.status = 'error'; + status.message = error.message; + } + }; + const _onGenesisChange = (genesisHash: string | null): void => { + const account = keyring.getPair(address); + + account && keyring.saveAccountMeta(account, { ...account.meta, genesisHash }); + + setGenesisHash(genesisHash); + }; + + // FIXME It is a bit heavy-handled switching of being editable here completely + // (and removing the tags, however the keyring cannot save these) + return ( + +
+ {isEditable && !isDevelopment && ( +
+ {isEditable && !isExternal && ( +
+ +
+ )} + + } + className={className} + isEditable={isEditable} + type='account' + value={address} + withExplorer + withIndexOrAddress={false} + withTags + > + {address && ( + <> + {isBackupOpen && ( + + )} + {isDeriveOpen && ( + + )} + {isForgetOpen && ( + + )} + {isPasswordOpen && ( + + )} + {isTransferOpen && ( + + )} + + )} + +
+ ); +} + +export default translate( + styled(Account)` + .accounts--Account-buttons { + text-align: right; + + .others { + margin-right: 0.125rem; + margin-top: 0.25rem; + } + } + ` +); diff --git a/pioneer/packages/app-accounts/src/Banner.tsx b/pioneer/packages/app-accounts/src/Banner.tsx new file mode 100644 index 0000000000..d2dad1eeb9 --- /dev/null +++ b/pioneer/packages/app-accounts/src/Banner.tsx @@ -0,0 +1,105 @@ +// Copyright 2017-2019 @polkadot/app-accounts authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { I18nProps } from '@polkadot/react-components/types'; + +import { detect } from 'detect-browser'; +import React from 'react'; +import styled from 'styled-components'; +import { isWeb3Injected } from '@polkadot/extension-dapp'; +import { stringUpperFirst } from '@polkadot/util'; + +import translate from './translate'; + +// it would have been really good to import this from detect, however... not exported +type Browser = 'chrome' | 'firefox'; + +interface Extension { + desc: string; + link: string; + name: string; +} + +interface Props extends I18nProps { + className?: string; +} + +const available: Record = { + chrome: [], + firefox: [] +}; + +[ + { + browsers: { + chrome: 'https://chrome.google.com/webstore/detail/polkadot%7Bjs%7D-extension/mopnmbcafieddcagagdcbnhejhlodfdd', + firefox: 'https://addons.mozilla.org/en-US/firefox/addon/polkadot-js-extension/' + }, + desc: 'Basic account injection and signer', + name: 'polkadot-js extension' + } +].forEach(({ browsers, desc, name }): void => { + Object.entries(browsers).forEach(([browser, link]): void => { + available[browser as Browser].push({ link, desc, name }); + }); +}); + +const browserInfo = detect(); +const browserName: Browser | null = (browserInfo && (browserInfo.name as Browser)) || null; +const isSupported = browserName && Object.keys(available).includes(browserName); + +function Banner ({ className, t }: Props): React.ReactElement | null { + if (isWeb3Injected || !isSupported || !browserName) { + return null; + } + + return ( +
+
+
+

{t('It is recommended that you create/store your accounts securely and externally from the app. On {{yourBrowser}} the following browser extensions are available for use -', { + replace: { + yourBrowser: stringUpperFirst(browserName) + } + })}

+
    {available[browserName].map(({ desc, name, link }): React.ReactNode => ( +
  • + + {name} + ({desc}) +
  • + )) + }
+

{t('Accounts injected from any of these extensions will appear in this application and be available for use. The above list is updated as more extensions with external signing capability become available.')} {t('Learn more...')}

+
+
+
+ ); +} + +export default translate( + styled(Banner)` + padding: 0 0.5rem 0.5rem; + + .box { + background: #fff6e5; + border-left: 0.25rem solid darkorange; + border-radius: 0 0.25rem 0.25rem 0; + box-sizing: border-box; + padding: 1rem 1.5rem; + + .info { + max-width: 50rem; + } + } + ` +); diff --git a/pioneer/packages/app-accounts/src/MemoForm.tsx b/pioneer/packages/app-accounts/src/MemoForm.tsx new file mode 100644 index 0000000000..d3c0eff408 --- /dev/null +++ b/pioneer/packages/app-accounts/src/MemoForm.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Labelled } from '@polkadot/react-components/index'; + +import MemoEdit from '@polkadot/joy-utils/memo/MemoEdit'; +import TxButton from '@polkadot/joy-utils/TxButton'; +import { withMyAccount, MyAccountProps } from '@polkadot/joy-utils/MyAccount'; +import { Text } from '@polkadot/types'; + +type Props = MyAccountProps & {}; + +type State = { + memo: string, + modified: boolean, +}; + +class Component extends React.PureComponent { + + state: State = { + memo: '', + modified: false, + }; + + render () { + const { myAddress } = this.props; + const { memo, modified } = this.state; + return ( + <> + + + + + + ); + } + + onChangeMemo = (memo: string): void => { + this.setState({ memo, modified: true }); + } + + onResetMemo = (memo: string): void => { + this.setState({ memo, modified: false }); + } +} + +export default withMyAccount(Component); diff --git a/pioneer/packages/app-accounts/src/Overview.tsx b/pioneer/packages/app-accounts/src/Overview.tsx new file mode 100644 index 0000000000..a8d4637140 --- /dev/null +++ b/pioneer/packages/app-accounts/src/Overview.tsx @@ -0,0 +1,104 @@ +// Copyright 2017-2019 @polkadot/app-staking authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { I18nProps } from '@polkadot/react-components/types'; +import { SubjectInfo } from '@polkadot/ui-keyring/observable/types'; +import { ComponentProps } from './types'; + +import React, { useState } from 'react'; +import keyring from '@polkadot/ui-keyring'; +import accountObservable from '@polkadot/ui-keyring/observable/accounts'; +import { getLedger, isLedger, withMulti, withObservable } from '@polkadot/react-api'; +import { Button, CardGrid } from '@polkadot/react-components'; + +import CreateModal from './modals/Create'; +import ImportModal from './modals/Import'; +import Account from './Account'; +import translate from './translate'; + +interface Props extends ComponentProps, I18nProps { + accounts?: SubjectInfo[]; +} + +// query the ledger for the address, adding it to the keyring +async function queryLedger (): Promise { + const ledger = getLedger(); + + try { + const { address } = await ledger.getAddress(); + + keyring.addHardware(address, 'ledger', { name: 'ledger' }); + } catch (error) { + console.error(error); + } +} + +function Overview ({ accounts, onStatusChange, t }: Props): React.ReactElement { + const [isCreateOpen, setIsCreateOpen] = useState(false); + const [isImportOpen, setIsImportOpen] = useState(false); + const emptyScreen = !(isCreateOpen || isImportOpen) && accounts && (Object.keys(accounts).length === 0); + + const _toggleCreate = (): void => setIsCreateOpen(!isCreateOpen); + const _toggleImport = (): void => setIsImportOpen(!isImportOpen); + + return ( + + + + + +
+ +
+ + this.onTxSuccess(buildNewVote() as NewVote, txResult)} + /> + + } + + ); + } + + private resetForm = (): void => { + this.onChangeStake(ZERO); + this.newRandomSalt(); + this.setState({ isFormSubmitted: false }); + } + + private onFormSubmitted = (): void => { + this.setState({ isFormSubmitted: true }); + } + + private onTxFailed: TxFailedCallback = (_txResult: SubmittableResult | null): void => { + // TODO Possible UX improvement: tell a user that his vote hasn't been accepted. + } + + private onTxSuccess = (vote: NewVote, txResult: SubmittableResult): void => { + let hasVotedEvent = false; + txResult.events.forEach((event, i) => { + const { section, method } = event.event; + if (section === 'councilElection' && method === 'Voted') { + hasVotedEvent = true; + } + }); + if (hasVotedEvent) { + saveVote(vote); + this.setState({ isFormSubmitted: true }); + } + } + + private newRandomSalt = (): void => { + this.setState({ salt: randomSalt() }); + } + + private minStake = (): BN => { + return this.props.minVotingStake || new BN(1); + } + + private onChangeStake = (stake?: BN) => { + const isStakeValid = stake && stake.gte(this.minStake()); + this.setState({ stake, isStakeValid }); + } + + private onChangeApplicant = (applicantId?: string | null) => { + this.setState({ applicantId }); + } + + private onChangeSalt = (salt?: string) => { + // TODO check that salt is unique by checking Substrate store. + this.setState({ salt }); + } +} + +export default withMulti( + Component, + translate, + withOnlyMembers, + withCalls( + queryToProp('query.councilElection.minVotingStake'), + queryToProp('query.councilElection.applicants') + ) +); diff --git a/pioneer/packages/joy-election/src/Votes.tsx b/pioneer/packages/joy-election/src/Votes.tsx new file mode 100644 index 0000000000..a1558406e6 --- /dev/null +++ b/pioneer/packages/joy-election/src/Votes.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { AppProps, I18nProps } from '@polkadot/react-components/types'; +import { ApiProps } from '@polkadot/react-api/types'; + +import translate from './translate'; +import SealedVotes from './SealedVotes'; +import Section from '@polkadot/joy-utils/Section'; +import { withMyAccount, MyAccountProps } from '@polkadot/joy-utils/MyAccount'; +import { getVotesByVoter } from './myVotesStore'; +import VoteForm from './VoteForm'; + +type Props = AppProps & ApiProps & I18nProps & MyAccountProps & {}; + +class Component extends React.PureComponent { + render () { + const { myAddress } = this.props; + const myVotes = myAddress ? getVotesByVoter(myAddress) : []; + + return <> +
+ +
+ + ; + } +} + +export default translate( + withMyAccount(Component) +); diff --git a/pioneer/packages/joy-election/src/index.css b/pioneer/packages/joy-election/src/index.css new file mode 100644 index 0000000000..5ad24c8384 --- /dev/null +++ b/pioneer/packages/joy-election/src/index.css @@ -0,0 +1,28 @@ + +.JoyElection--NotRunning { + /* nothing yet */ +} +.JoyElection--Running { + font-style: italic; + color: green; +} +.SealedVoteTable { + -webkit-box-shadow: 0 1px 2px 0 rgba(34,36,38,.15) !important; + box-shadow: 0 1px 2px 0 rgba(34,36,38,.15) !important; + tr td:first-child { + color: #999 !important; + font-weight: normal !important; + } +} + +.SidebarSubtitle { + &.Announcing { + color: #4caf50; /* green */ + } + &.Voting { + color: #2196f3; /* blue */ + } + &.Revealing { + color: #ff5722; /* red */ + } +} \ No newline at end of file diff --git a/pioneer/packages/joy-election/src/index.tsx b/pioneer/packages/joy-election/src/index.tsx new file mode 100644 index 0000000000..2a6d130e97 --- /dev/null +++ b/pioneer/packages/joy-election/src/index.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { Route, Switch } from 'react-router'; + +import { AppProps, I18nProps } from '@polkadot/react-components/types'; +import { ApiProps } from '@polkadot/react-api/types'; +import { withCalls } from '@polkadot/react-api/with'; +import { AccountId, Hash } from '@polkadot/types/interfaces'; +import Tabs, { TabItem } from '@polkadot/react-components/Tabs'; + +// our app-specific styles +import './index.css'; + +// local imports and components +import translate from './translate'; +import Dashboard from './Dashboard'; +import Council from './Council'; +import Applicants from './Applicants'; +import Votes from './Votes'; +import Reveals from './Reveals'; +import { queryToProp } from '@polkadot/joy-utils/index'; +import { Seat } from '@joystream/types/'; + +// define out internal types +type Props = AppProps & ApiProps & I18nProps & { + activeCouncil?: Seat[], + applicants?: AccountId[], + commitments?: Hash[] +}; + +type State = {}; + +class App extends React.PureComponent { + + state: State = {}; + + private buildTabs (): TabItem[] { + const { t, activeCouncil = [], applicants = [], commitments = [] } = this.props; + return [ + { + isRoot: true, + name: 'council', + text: t('Dashboard') + }, + { + name: 'members', + text: t(`Council members`) + ` (${activeCouncil.length})` + }, + { + name: 'applicants', + text: t(`Applicants`) + ` (${applicants.length})` + }, + { + name: 'votes', + text: t(`Votes`) + ` (${commitments.length})` + }, + { + name: 'reveals', + text: t('Reveal a vote') + } + ]; + } + + render () { + const { basePath } = this.props; + const tabs = this.buildTabs(); + return ( +
+
+ +
+ + + + + + + +
+ ); + } +} + +export default translate( + withCalls( + queryToProp('query.council.activeCouncil'), + queryToProp('query.councilElection.applicants'), + queryToProp('query.councilElection.commitments') + )(App) +); diff --git a/pioneer/packages/joy-election/src/myVotesStore.ts b/pioneer/packages/joy-election/src/myVotesStore.ts new file mode 100644 index 0000000000..83f8bcaa4e --- /dev/null +++ b/pioneer/packages/joy-election/src/myVotesStore.ts @@ -0,0 +1,54 @@ +import store from 'store'; +import { nonEmptyArr } from '@polkadot/joy-utils/index'; + +const MY_VOTES = 'joy.myVotes'; + +export type NewVote = { + voterId: string, + applicantId: string, + stake: string, // Actually this is a BN serialized to string. + salt: string, + hash: string +}; + +export type SavedVote = NewVote & { + isRevealed: boolean, + votedOnTime: number, + revealedOnTime?: number +}; + +/** Get all votes that are stored in a local sotrage. */ +export const getAllVotes = (): SavedVote[] => { + const votes = store.get(MY_VOTES); + return nonEmptyArr(votes) ? votes as SavedVote[] : []; +}; + +export const getVotesByVoter = (voterId: string): SavedVote[] => { + return getAllVotes().filter(v => v.voterId === voterId); +}; + +export const findVoteByHash = (hash: string): SavedVote | undefined => { + return getAllVotes().find(v => v.hash === hash); +}; + +export const saveVote = (vote: NewVote): void => { + const votes = getAllVotes(); + const similarVote = votes.find(v => v.hash === vote.hash); + if (similarVote) { + console.log('There is a vote with the same hash in a storage:', similarVote); + return; + } + + votes.push({ ...vote, votedOnTime: Date.now(), isRevealed: false }); + store.set(MY_VOTES, votes); +}; + +export const revealVote = (hash: string): void => { + const votes = getAllVotes(); + const savedVote = votes.find(v => v.hash === hash); + if (savedVote && !savedVote.isRevealed) { + savedVote.isRevealed = true; + savedVote.revealedOnTime = Date.now(); + store.set(MY_VOTES, votes); + } +}; diff --git a/pioneer/packages/joy-election/src/translate.ts b/pioneer/packages/joy-election/src/translate.ts new file mode 100644 index 0000000000..4f0ab6348a --- /dev/null +++ b/pioneer/packages/joy-election/src/translate.ts @@ -0,0 +1,3 @@ +import { withTranslation } from 'react-i18next'; + +export default withTranslation(['election', 'ui']); diff --git a/pioneer/packages/joy-election/src/utils.tsx b/pioneer/packages/joy-election/src/utils.tsx new file mode 100644 index 0000000000..8898a6e461 --- /dev/null +++ b/pioneer/packages/joy-election/src/utils.tsx @@ -0,0 +1,53 @@ +import { AccountId } from '@polkadot/types/interfaces'; + +export type HashedVote = { + applicantId: string, + salt: string, + hash: string +}; + +// Keyring / identicon / address +// ----------------------------------- + +import createItem from '@polkadot/ui-keyring/options/item'; +import { findNameByAddress } from '@polkadot/joy-utils/index'; + +const createAddressOption = (address: string) => { + let name = findNameByAddress(address); + return createItem(address, name); +}; + +export const accountIdsToOptions = (applicants: Array): any => { + if (applicants && applicants.length) { + return applicants.map(a => { + const addr = a.toString(); + return createAddressOption(addr); + }); + } + return []; +}; + +// Hash +// ----------------------------------- + +import { decodeAddress } from '@polkadot/keyring'; +import { stringToU8a } from '@polkadot/util'; +import { blake2AsHex } from '@polkadot/util-crypto'; + +/** hash(accountId + salt) */ +export const hashVote = (accountId?: string | null, salt?: string): string | null => { + if (!accountId || !salt) { + // console.log('Cannot hash a vote: either accountId or salt is undefined', { accountId, salt }); + return null; + } + + const accountU8a = decodeAddress(accountId); + const saltU8a = stringToU8a(salt); + const voteU8a = new Uint8Array(accountU8a.length + saltU8a.length); + voteU8a.set(accountU8a); + voteU8a.set(saltU8a, accountU8a.length); + + const hash = blake2AsHex(voteU8a, 256); + // console.log('Vote hash:', hash, 'for', { accountId, salt }); + return hash; +}; diff --git a/pioneer/packages/joy-forum/README.md b/pioneer/packages/joy-forum/README.md new file mode 100644 index 0000000000..7f2f1d04cb --- /dev/null +++ b/pioneer/packages/joy-forum/README.md @@ -0,0 +1,3 @@ +# Forum module for Joystream node + +Forum allows to create categories and subcategories, start discussion threads and reply to others. diff --git a/pioneer/packages/joy-forum/package.json b/pioneer/packages/joy-forum/package.json new file mode 100644 index 0000000000..77cc47c4a2 --- /dev/null +++ b/pioneer/packages/joy-forum/package.json @@ -0,0 +1,16 @@ +{ + "name": "@polkadot/joy-forum", + "version": "0.1.1", + "description": "Forum module for Joystream node", + "main": "index.js", + "scripts": {}, + "author": "Joystream contributors", + "maintainers": [], + "dependencies": { + "@babel/runtime": "^7.7.1", + "@polkadot/joy-utils": "^0.1.1", + "@polkadot/react-components": "0.37.0-beta.63", + "@polkadot/react-query": "0.37.0-beta.63", + "lodash": "^4.17.15" + } +} diff --git a/pioneer/packages/joy-forum/src/CategoryList.tsx b/pioneer/packages/joy-forum/src/CategoryList.tsx new file mode 100644 index 0000000000..3ff8421b61 --- /dev/null +++ b/pioneer/packages/joy-forum/src/CategoryList.tsx @@ -0,0 +1,411 @@ +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import ReactMarkdown from 'react-markdown'; +import { Table, Dropdown, Button, Segment, Label } from 'semantic-ui-react'; +import { History } from 'history'; +import orderBy from 'lodash/orderBy'; +import BN from 'bn.js'; + +import { Option, bool } from '@polkadot/types'; +import { CategoryId, Category, ThreadId, Thread } from '@joystream/types/forum'; +import { ViewThread } from './ViewThread'; +import { MutedSpan } from '@polkadot/joy-utils/MutedText'; +import { UrlHasIdProps, CategoryCrumbs, Pagination, ThreadsPerPage } from './utils'; +import Section from '@polkadot/joy-utils/Section'; +import { JoyWarn } from '@polkadot/joy-utils/JoyStatus'; +import { withForumCalls } from './calls'; +import { withMulti, withApi } from '@polkadot/react-api'; +import { ApiProps } from '@polkadot/react-api/types'; +import { bnToStr, isEmptyArr } from '@polkadot/joy-utils/index'; +import TxButton from '@polkadot/joy-utils/TxButton'; +import { IfIAmForumSudo } from './ForumSudo'; +import { MemberPreview } from '@polkadot/joy-members/MemberPreview'; + +type CategoryActionsProps = { + id: CategoryId + category: Category +}; + +function CategoryActions (props: CategoryActionsProps) { + const { id, category } = props; + const className = 'ui button ActionButton'; + + type BtnProps = { + label: string, + icon?: string, + archive?: boolean, + delete?: boolean + }; + + const UpdateCategoryButton = (btnProps: BtnProps) => { + return {btnProps.label}} + params={[id, new Option(bool, btnProps.archive), new Option(bool, btnProps.delete)]} + tx={'forum.updateCategory'} + />; + }; + + if (category.archived) { + return ( + + + + ); + } + + if (category.deleted) { + return ( + + ; + + ); + } + + return + + + New thread + + + + + {/* TODO show 'Edit' if I am moderator_id */} + {/* + + Edit + */} + + + }> + + + + Add subcategory + + + + + + + + + ; +} + +type InnerViewCategoryProps = { + category?: Category, + page?: number, + preview?: boolean, + history?: History +}; + +type ViewCategoryProps = InnerViewCategoryProps & { + id: CategoryId +}; + +const ViewCategory = withForumCalls( + ['categoryById', { propName: 'category', paramName: 'id' }] +)(InnerViewCategory); + +function InnerViewCategory (props: InnerViewCategoryProps) { + const { history, category, page = 1, preview = false } = props; + + if (!category) { + return Loading...; + } + + if (category.isEmpty) { + return preview ? null : Category not found; + } + + const { id } = category; + + const renderCategoryActions = () => { + return ; + }; + + if (preview) { + return ( + + + + {category.archived + ? {category.title} + : category.title + } + + + + {category.num_direct_unmoderated_threads.toString()} + + + {category.num_direct_subcategories.toString()} + + + {renderCategoryActions()} + + + + + + ); + } + + if (!history) { + return Error: history property was not found.; + } + + const renderSubCategoriesAndThreads = () => <> + {category.archived && + + No new subcategories, threads and posts can be added to it. + + } + + +
+ +
+
+ +
+
+ + {category.hasSubcategories && +
+ +
+ } + +
+ +
+ ; + + return (<> + +

+ {category.title} + {renderCategoryActions()} +

+ + {category.deleted + ? + : renderSubCategoriesAndThreads() + } + ); +} + +type InnerCategoryThreadsProps = { + category: Category, + page: number, + history: History +}; + +type CategoryThreadsProps = ApiProps & InnerCategoryThreadsProps & { + nextThreadId?: ThreadId +}; + +export const CategoryThreads = withMulti( + InnerCategoryThreads, + withApi, + withForumCalls( + ['nextThreadId', { propName: 'nextThreadId' }] + ) +); + +function InnerCategoryThreads (props: CategoryThreadsProps) { + const { api, category, nextThreadId, page, history } = props; + + if (!category.hasUnmoderatedThreads) { + return No threads in this category; + } + + const threadCount = category.num_threads_created.toNumber(); + const [loaded, setLoaded] = useState(false); + const [threads, setThreads] = useState(new Array()); + + useEffect(() => { + const loadThreads = async () => { + if (!nextThreadId || threadCount === 0) return; + + const newId = (id: number | BN) => new ThreadId(id); + const apiCalls: Promise[] = []; + let id = newId(1); + while (nextThreadId.gt(id)) { + apiCalls.push(api.query.forum.threadById(id) as Promise); + id = newId(id.add(newId(1))); + } + + const allThreads = await Promise.all(apiCalls); + const threadsInThisCategory = allThreads.filter(item => + !item.isEmpty && + item.category_id.eq(category.id) + ); + const sortedThreads = orderBy( + threadsInThisCategory, + // TODO UX: Replace sort by id with sort by blocktime of the last reply. + [ + x => x.moderated, + // x => x.pinned, + x => x.nr_in_category.toNumber() + ], + [ + 'asc', + // 'desc', + 'desc' + ] + ); + + setThreads(sortedThreads); + setLoaded(true); + }; + + loadThreads(); + }, [ bnToStr(category.id), bnToStr(nextThreadId) ]); + + // console.log({ nextThreadId: bnToStr(nextThreadId), loaded, threads }); + + if (!loaded) { + return Loading threads...; + } + + if (isEmptyArr(threads)) { + return No threads in this category; + } + + const onPageChange = (activePage?: string | number) => { + history.push(`/forum/categories/${category.id.toString()}/page/${activePage}`); + }; + + const itemsPerPage = ThreadsPerPage; + const minIdx = (page - 1) * itemsPerPage; + const maxIdx = minIdx + itemsPerPage - 1; + + const pagination = + ; + + const pageOfItems = threads + .filter((_thread, i) => i >= minIdx && i <= maxIdx) + .map((thread, i) => ); + + return <> + {pagination} + + + + Thread + Replies + Creator + + + + {pageOfItems} + +
+ {pagination} + ; +} + +type ViewCategoryByIdProps = UrlHasIdProps & { + history: History, + match: { + params: { + id: string + page?: string + } + } +}; + +export function ViewCategoryById (props: ViewCategoryByIdProps) { + const { history, match: { params: { id, page: pageStr } } } = props; + try { + // tslint:disable-next-line:radix + const page = pageStr ? parseInt(pageStr) : 1; + return ; + } catch (err) { + return Invalid category ID: {id}; + } +} + +type CategoryListProps = ApiProps & { + nextCategoryId?: CategoryId, + parentId?: CategoryId +}; + +export const CategoryList = withMulti( + InnerCategoryList, + withApi, + withForumCalls( + ['nextCategoryId', { propName: 'nextCategoryId' }] + ) +); + +function InnerCategoryList (props: CategoryListProps) { + const { api, parentId, nextCategoryId } = props; + const [loaded, setLoaded] = useState(false); + const [categories, setCategories] = useState(new Array()); + + useEffect(() => { + const loadCategories = async () => { + if (!nextCategoryId) return; + + const newId = (id: number | BN) => new CategoryId(id); + const apiCalls: Promise[] = []; + let id = newId(1); + while (nextCategoryId.gt(id)) { + apiCalls.push(api.query.forum.categoryById(id) as Promise); + id = newId(id.add(newId(1))); + } + + const allCats = await Promise.all(apiCalls); + const filteredCats = allCats.filter(cat => + !cat.isEmpty && + !cat.deleted && // TODO show deleted categories if current user is forum sudo + (parentId ? parentId.eq(cat.parent_id) : cat.isRoot) + ); + + setCategories(filteredCats); + setLoaded(true); + }; + + loadCategories(); + }, [ bnToStr(parentId), bnToStr(nextCategoryId) ]); + + // console.log({ nextCategoryId: bnToStr(nextCategoryId), loaded, categories }); + + if (!loaded) { + return Loading categories...; + } + + if (isEmptyArr(categories)) { + return Forum is empty; + } + + return ( + + + + Category + Threads + Subcategories + Actions + Creator + + + {categories.map((category, i) => ( + + ))} +
+ ); +} diff --git a/pioneer/packages/joy-forum/src/Context.tsx b/pioneer/packages/joy-forum/src/Context.tsx new file mode 100644 index 0000000000..80ef2e26b6 --- /dev/null +++ b/pioneer/packages/joy-forum/src/Context.tsx @@ -0,0 +1,339 @@ + +// NOTE: The purpose of this context is to immitate a Substrate storage for the forum until it's implemented as a substrate runtime module. + +import React, { useReducer, createContext, useContext } from 'react'; +import { Category, Thread, Reply, ModerationAction, BlockchainTimestamp } from '@joystream/types/forum'; +import { Option, Text } from '@polkadot/types'; +import { GenericAccountId } from '@polkadot/types'; + +type CategoryId = number; +type ThreadId = number; +type ReplyId = number; + +export type ForumState = { + sudo?: string, + + nextCategoryId: CategoryId, + categoryById: Map, + rootCategoryIds: CategoryId[], + categoryIdsByParentId: Map, + + nextThreadId: ThreadId, + threadById: Map, + threadIdsByCategoryId: Map, + + nextReplyId: ReplyId, + replyById: Map, + replyIdsByThreadId: Map +}; + +const initialState: ForumState = { + sudo: undefined, + + nextCategoryId: 1, + categoryById: new Map(), + rootCategoryIds: [], + categoryIdsByParentId: new Map(), + + nextThreadId: 1, + threadById: new Map(), + threadIdsByCategoryId: new Map(), + + nextReplyId: 1, + replyById: new Map(), + replyIdsByThreadId: new Map() +}; + +type SetForumSudo = { + type: 'SetForumSudo', + sudo?: string +}; + +type NewCategoryAction = { + type: 'NewCategory', + category: Category, + onCreated?: (newId: number) => void +}; + +type UpdateCategoryAction = { + type: 'UpdateCategory', + category: Category, + id: CategoryId +}; + +type NewThreadAction = { + type: 'NewThread', + thread: Thread, + onCreated?: (newId: number) => void +}; + +type UpdateThreadAction = { + type: 'UpdateThread', + thread: Thread, + id: ThreadId +}; + +type ModerateThreadAction = { + type: 'ModerateThread', + id: ThreadId, + moderator: string, + rationale: string +}; + +type NewReplyAction = { + type: 'NewReply', + reply: Reply, + onCreated?: (newId: number) => void +}; + +type UpdateReplyAction = { + type: 'UpdateReply', + reply: Reply, + id: ReplyId +}; + +type ModerateReplyAction = { + type: 'ModerateReply', + id: ReplyId, + moderator: string, + rationale: string +}; + +type ForumAction = + SetForumSudo | + NewCategoryAction | + UpdateCategoryAction | + NewThreadAction | + UpdateThreadAction | + ModerateThreadAction | + NewReplyAction | + UpdateReplyAction | + ModerateReplyAction; + +function reducer (state: ForumState, action: ForumAction): ForumState { + + switch (action.type) { + + case 'SetForumSudo': { + const { sudo } = action; + return { + ...state, + sudo + }; + } + + case 'NewCategory': { + const { category, onCreated } = action; + const { parent_id } = category; + + let { + nextCategoryId, + categoryById, + rootCategoryIds, + categoryIdsByParentId + } = state; + + if (parent_id) { + let childrenIds = categoryIdsByParentId.get(parent_id.toNumber()); + if (!childrenIds) { + childrenIds = []; + } + childrenIds.push(nextCategoryId); + categoryIdsByParentId.set(parent_id.toNumber(), childrenIds); + } else { + if (!rootCategoryIds) { + rootCategoryIds = []; + } + rootCategoryIds.push(nextCategoryId); + } + + const newId = nextCategoryId; + categoryById.set(newId, category); + nextCategoryId = nextCategoryId + 1; + + if (onCreated) onCreated(newId); + + return { + ...state, + nextCategoryId, + categoryById, + rootCategoryIds, + categoryIdsByParentId + }; + } + + case 'UpdateCategory': { + const { category, id } = action; + const { categoryById } = state; + + categoryById.set(id, category); + + return { + ...state, + categoryById + }; + } + + case 'NewThread': { + const { thread, onCreated } = action; + const { category_id } = thread; + + let { + nextThreadId, + threadById, + threadIdsByCategoryId + } = state; + + let threadIds = threadIdsByCategoryId.get(category_id.toNumber()); + if (!threadIds) { + threadIds = []; + threadIdsByCategoryId.set(category_id.toNumber(), threadIds); + } + threadIds.push(nextThreadId); + + const newId = nextThreadId; + threadById.set(newId, thread); + nextThreadId = nextThreadId + 1; + + if (onCreated) onCreated(newId); + + return { + ...state, + nextThreadId, + threadById, + threadIdsByCategoryId + }; + } + + case 'UpdateThread': { + const { thread, id } = action; + const { threadById } = state; + + threadById.set(id, thread); + + return { + ...state, + threadById + }; + } + + case 'ModerateThread': { + const { id, moderator, rationale } = action; + const { threadById } = state; + + const thread = threadById.get(id) as Thread; + const moderation = new ModerationAction({ + moderated_at: BlockchainTimestamp.newEmpty(), + moderator_id: new GenericAccountId(moderator), + rationale: new Text(rationale) + }); + const threadUpd = new Thread(Object.assign( + thread.cloneValues(), + { moderation: new Option(ModerationAction, moderation) } + )); + threadById.set(id, threadUpd); + + return { + ...state, + threadById + }; + } + + case 'NewReply': { + const { reply, onCreated } = action; + const { thread_id } = reply; + + let { + nextReplyId, + replyById, + replyIdsByThreadId + } = state; + + let replyIds = replyIdsByThreadId.get(thread_id.toNumber()); + if (!replyIds) { + replyIds = []; + replyIdsByThreadId.set(thread_id.toNumber(), replyIds); + } + replyIds.push(nextReplyId); + + const newId = nextReplyId; + replyById.set(newId, reply); + nextReplyId = nextReplyId + 1; + + if (onCreated) onCreated(newId); + + return { + ...state, + nextReplyId, + replyById, + replyIdsByThreadId + }; + } + + case 'UpdateReply': { + const { reply, id } = action; + const { replyById } = state; + + replyById.set(id, reply); + + return { + ...state, + replyById + }; + } + + case 'ModerateReply': { + const { id, moderator, rationale } = action; + const { replyById } = state; + + const reply = replyById.get(id) as Reply; + const moderation = new ModerationAction({ + moderated_at: BlockchainTimestamp.newEmpty(), + moderator_id: new GenericAccountId(moderator), + rationale: new Text(rationale) + }); + const replyUpd = new Reply(Object.assign( + reply.cloneValues(), + { moderation: new Option(ModerationAction, moderation) } + )); + replyById.set(id, replyUpd); + + return { + ...state, + replyById + }; + } + + default: + throw new Error('Unexptected action: ' + JSON.stringify(action)); + } +} + +function functionStub () { + throw new Error('Function needs to be set in ForumProvider'); +} + +export type ForumContextProps = { + state: ForumState, + dispatch: React.Dispatch +}; + +const contextStub: ForumContextProps = { + state: initialState, + dispatch: functionStub +}; + +export const ForumContext = createContext(contextStub); + +export function ForumProvider (props: React.PropsWithChildren<{}>) { + const [state, dispatch] = useReducer(reducer, initialState); + return ( + + {props.children} + + ); +} + +export function useForum () { + return useContext(ForumContext); +} diff --git a/pioneer/packages/joy-forum/src/EditCategory.tsx b/pioneer/packages/joy-forum/src/EditCategory.tsx new file mode 100644 index 0000000000..5f6e8b41c4 --- /dev/null +++ b/pioneer/packages/joy-forum/src/EditCategory.tsx @@ -0,0 +1,272 @@ +import React from 'react'; +import { Button, Message } from 'semantic-ui-react'; +import { Form, Field, withFormik, FormikProps } from 'formik'; +import * as Yup from 'yup'; +import { History } from 'history'; + +import TxButton from '@polkadot/joy-utils/TxButton'; +import { SubmittableResult } from '@polkadot/api'; +import { withMulti } from '@polkadot/react-api/with'; + +import * as JoyForms from '@polkadot/joy-utils/forms'; +import { Text } from '@polkadot/types'; +import { Option } from '@polkadot/types/codec'; +import { CategoryId, Category } from '@joystream/types/forum'; +import Section from '@polkadot/joy-utils/Section'; +import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext'; +import { UrlHasIdProps, CategoryCrumbs } from './utils'; +import { withOnlyForumSudo } from './ForumSudo'; +import { withForumCalls } from './calls'; +import { ValidationProps, withCategoryValidation } from './validation'; +import { TxFailedCallback, TxCallback } from '@polkadot/react-components/Status/types'; + +const buildSchema = (props: ValidationProps) => { + const { + categoryTitleConstraint, + categoryDescriptionConstraint + } = props; + + if (!categoryTitleConstraint || !categoryDescriptionConstraint) { + throw new Error('Missing some validation constraints'); + } + + const minTitle = categoryTitleConstraint.min.toNumber(); + const maxTitle = categoryTitleConstraint.max.toNumber(); + const minDescr = categoryDescriptionConstraint.min.toNumber(); + const maxDescr = categoryDescriptionConstraint.max.toNumber(); + + return Yup.object().shape({ + + title: Yup.string() + .min(minTitle, `Category title is too short. Minimum length is ${minTitle} chars.`) + .max(maxTitle, `Category title is too long. Maximum length is ${maxTitle} chars.`) + .required('Category title is required'), + + description: Yup.string() + .min(minDescr, `Category description is too short. Minimum length is ${minDescr} chars.`) + .max(maxDescr, `Category description is too long. Maximum length is ${maxDescr} chars.`) + .required('Category description is required') + }); +}; + +type OuterProps = ValidationProps & { + history?: History, + id?: CategoryId, + parentId?: CategoryId, + struct?: Category +}; + +type FormValues = { + title: string, + description: string +}; + +type FormProps = OuterProps & FormikProps; + +const LabelledField = JoyForms.LabelledField(); + +const LabelledText = JoyForms.LabelledText(); + +const InnerForm = (props: FormProps) => { + const { + history, + id, + parentId, + struct, + values, + dirty, + isValid, + isSubmitting, + setSubmitting, + resetForm + } = props; + + const { + title, + description + } = values; + + const onSubmit = (sendTx: () => void) => { + if (isValid) sendTx(); + }; + + const onTxFailed: TxFailedCallback = (txResult: SubmittableResult | null) => { + setSubmitting(false); + if (txResult == null) { + // Tx cancelled. + return; + } + }; + + const onTxSuccess: TxCallback = (_txResult: SubmittableResult) => { + setSubmitting(false); + if (!history) return; + + // Get id of newly created category: + let _id = id; + if (!_id) { + _txResult.events.find(event => { + const { event: { data, method } } = event; + if (method === 'CategoryCreated') { + _id = data.toArray()[0] as CategoryId; + } + return true; + }); + } + + // Redirect to category view: + if (_id) { + history.push('/forum/categories/' + _id.toString()); + } + }; + + const isNew = struct === undefined; + const isSubcategory = parentId !== undefined; + + const buildTxParams = () => { + if (!isValid) return []; + + if (isNew) { + return [ + new Option(CategoryId, parentId), + new Text(title), + new Text(description) + ]; + } else { + // NOTE: currently update_category doesn't support title and description updates. + return [ /* TODO add all required params */ ]; + } + }; + + const categoryWord = isSubcategory ? `subcategory` : `category`; + + const form = +
+ + + + + + + + + + +
+ ); + } + + private resetForm = () => { + const { cancelSource } = this.state; + this.setState({ + cancelSource, + ...defaultState() + }); + } + + private renderUploading () { + const { file, newContentId, progress, error } = this.state; + if (!file || !file.name) return ; + + const success = !error && progress >= 100; + const { history, match: { params: { channelId } } } = this.props + + return
+ {this.renderProgress()} + {success && + + } +
; + } + + private renderDiscovering () { + return Contacting storage provider.; + } + + private renderProgress () { + const { progress, error } = this.state; + const active = !error && progress < 100; + const success = !error && progress >= 100; + + let label = ''; + if (active) { + label = `Your file is uploading. Please keep this page open until it's done.`; + } else if (success) { + label = `Uploaded! Click "Publish" button to make your file live.`; + } + + return ; + } + + private renderFileInput () { + const { file } = this.state; + const file_size = file ? file.size : 0; + const file_name = file ? file.name : ''; + + return
+ +
+
{file_name + ? `${file_name} (${formatNumber(file_size)} bytes)` + : <> +
Drag and drop either video or audio file here.
+
Your file should not be more than {MAX_FILE_SIZE_MB} MB.
+ + }
+
+ } + onChange={this.onFileSelected} + /> + {file_name &&
+ +
} + ; + } + + private onFileSelected = async (file: File) => { + if (!file.size) { + this.setState({ error: `You cannot upload an empty file.` }); + } else if (file.size > MAX_FILE_SIZE_BYTES) { + this.setState({ error: + `You can't upload files larger than ${MAX_FILE_SIZE_MB} MBytes in size.` + }); + } else { + this.setState({ file, computingHash: true }) + this.startComputingHash(); + } + } + + private async startComputingHash() { + const { file } = this.state; + + if (!file) { + return this.hashComputationComplete(undefined, 'No file passed to hasher'); + } + + try { + const iterableFile = new IterableFile(file, { chunkSize: 65535 }); + const ipfs_cid = await IpfsHash.of(iterableFile); + + this.hashComputationComplete(ipfs_cid) + } catch (err) { + return this.hashComputationComplete(undefined, err); + } + } + + private hashComputationComplete(ipfs_cid: string | undefined, error?: string) { + if (!error) { + console.log('Computed IPFS hash:', ipfs_cid) + } + + this.setState({ + computingHash: false, + ipfs_cid, + error + }) + } + + private renderComputingHash() { + return + } + + private buildTxParams = () => { + const { file, newContentId, ipfs_cid } = this.state; + if (!file || !ipfs_cid) return []; + + // TODO get corresponding data type id based on file content + const dataObjectTypeId = new BN(1); + + return [ newContentId, dataObjectTypeId, new BN(file.size), ipfs_cid]; + } + + private onDataObjectCreated = async (_txResult: SubmittableResult) => { + this.setState({ discovering: true}); + + const { api } = this.props; + const { newContentId } = this.state; + try { + var dataObject = await api.query.dataDirectory.dataObjectByContentId(newContentId) as Option; + } catch (err) { + this.setState({ + error: err, + discovering: false + }); + return; + } + + const { discovering } = this.state; + + if (!discovering) { + return; + } + + if (dataObject.isSome) { + const storageProvider = dataObject.unwrap().liaison; + this.uploadFileTo(storageProvider); + } else { + this.setState({ + error: new Error('No Storage Provider assigned to process upload'), + discovering: false + }); + } + } + + private uploadFileTo = async (storageProvider: AccountId) => { + const { file, newContentId, cancelSource } = this.state; + if (!file || !file.size) { + this.setState({ + error: new Error('No file to upload!'), + discovering: false, + }); + return; + } + + const contentId = newContentId.encode(); + const config = { + headers: { + // TODO uncomment this once the issue fixed: + // https://github.com/Joystream/storage-node-joystream/issues/16 + // 'Content-Type': file.type + 'Content-Type': '' // <-- this is a temporary hack + }, + cancelToken: cancelSource.token, + onUploadProgress: (progressEvent: any) => { + const percentCompleted = Math.round( (progressEvent.loaded * 100) / progressEvent.total ); + this.setState({ + progress: percentCompleted + }); + } + }; + + const { discoveryProvider } = this.props; + + try { + var url = await discoveryProvider.resolveAssetEndpoint(storageProvider, contentId, cancelSource.token); + } catch (err) { + return this.setState({ + error: new Error(`Failed to contact storage provider: ${err.message}`), + discovering: false, + }); + } + + const { discovering } = this.state; + + if (!discovering) { + return; + } + + // TODO: validate url .. must start with http + + this.setState({ discovering: false, uploading: true, progress: 0 }); + + try { + await axios.put<{ message: string }>(url, file, config); + } catch(err) { + this.setState({ progress: 0, error: err, uploading: false }); + if (axios.isCancel(err)) { + return; + } + if (!err.response || (err.response.status >= 500 && err.response.status <= 504)) { + // network connection error + discoveryProvider.reportUnreachable(storageProvider); + } + } + } +} + +export const UploadWithRouter = withMulti( + Component, + translate, + withApi, + withMembershipRequired, + withDiscoveryProvider +) diff --git a/pioneer/packages/joy-media/src/channels/ChannelAvatar.tsx b/pioneer/packages/joy-media/src/channels/ChannelAvatar.tsx new file mode 100644 index 0000000000..ec2c640749 --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/ChannelAvatar.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { BgImg } from '../common/BgImg'; +import { DEFAULT_THUMBNAIL_URL } from '@polkadot/joy-utils/images'; + +const defaultSizePx = 75; + +export type ChannelAvatarSize = 'big' | 'default' | 'small'; + +type Props = { + channel: ChannelEntity, + size?: ChannelAvatarSize +} + +function sizeToPx (size: ChannelAvatarSize): number { + switch (size) { + case 'big': return 100; + case 'small': return 35; + case 'default': return defaultSizePx; + default: return defaultSizePx; + } +} + +export function ChannelAvatar (props: Props) { + const { channel, size = 'default' } = props; + + return ( + + + + ) +} \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/channels/ChannelAvatarAndName.tsx b/pioneer/packages/joy-media/src/channels/ChannelAvatarAndName.tsx new file mode 100644 index 0000000000..0f3d425a98 --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/ChannelAvatarAndName.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { ChannelAvatar } from './ChannelAvatar'; +import { ChannelNameAsLink } from './ChannelNameAsLink'; + +type Props = { + channel: ChannelEntity +} + +export const ChannelAvatarAndName = (props: Props) => { + const { channel } = props; + return ( +
+ +
+

+ +

+
+
+ ) +} diff --git a/pioneer/packages/joy-media/src/channels/ChannelHeader.tsx b/pioneer/packages/joy-media/src/channels/ChannelHeader.tsx new file mode 100644 index 0000000000..df9b71b673 --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/ChannelHeader.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { BgImg } from '../common/BgImg'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { ChannelPreview } from './ChannelPreview'; + +type Props = { + channel: ChannelEntity +} + +export function ChannelHeader (props: Props) { + const { channel } = props; + const { banner } = channel; + + return ( +
+ {banner && } + +
+ ); +} diff --git a/pioneer/packages/joy-media/src/channels/ChannelHelpers.ts b/pioneer/packages/joy-media/src/channels/ChannelHelpers.ts new file mode 100644 index 0000000000..a8c4a34b30 --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/ChannelHelpers.ts @@ -0,0 +1,34 @@ +import { AccountId } from '@polkadot/types/interfaces'; +import { ChannelType } from "../schemas/channel/Channel"; +import { ChannelPublicationStatusAllValues } from "@joystream/types/content-working-group"; + +export const ChannelPublicationStatusDropdownOptions = + ChannelPublicationStatusAllValues + .map(x => ({ key: x, value: x, text: x })) + +export const isVideoChannel = (channel: ChannelType) => { + return channel.content === 'Video'; +}; + +export const isMusicChannel = (channel: ChannelType) => { + return channel.content === 'Music'; +}; + +export const isAccountAChannelOwner = (channel?: ChannelType, account?: AccountId | string): boolean => { + return (channel && account) ? channel.roleAccount.eq(account) : false +}; + +export function isPublicChannel(channel: ChannelType): boolean { + return ( + channel.publicationStatus === 'Public' && + channel.curationStatus !== 'Censored' + ); +} + +export function isCensoredChannel(channel: ChannelType): boolean { + return channel.curationStatus == 'Censored' +} + +export function isVerifiedChannel(channel: ChannelType): boolean { + return channel.verified +} diff --git a/pioneer/packages/joy-media/src/channels/ChannelNameAsLink.tsx b/pioneer/packages/joy-media/src/channels/ChannelNameAsLink.tsx new file mode 100644 index 0000000000..d544f0ad7c --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/ChannelNameAsLink.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { ChannelEntity } from '../entities/ChannelEntity'; + +type Props = { + channel: ChannelEntity + className?: string + style?: React.CSSProperties +} + +export const ChannelNameAsLink = (props: Props) => { + const { channel, className, style } = props; + return ( + + {channel.title || channel.handle} + + ) +} diff --git a/pioneer/packages/joy-media/src/channels/ChannelPreview.tsx b/pioneer/packages/joy-media/src/channels/ChannelPreview.tsx new file mode 100644 index 0000000000..daa197783d --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/ChannelPreview.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import ReactMarkdown from 'react-markdown'; +import { Icon, Label, SemanticICONS, SemanticCOLORS } from 'semantic-ui-react'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { ChannelAvatar, ChannelAvatarSize } from './ChannelAvatar'; +import { isPublicChannel } from './ChannelHelpers'; +import { isMusicChannel, isVideoChannel, isAccountAChannelOwner, isVerifiedChannel } from './ChannelHelpers'; +import { useMyMembership } from '@polkadot/joy-utils/MyMembershipContext'; +import { nonEmptyStr } from '@polkadot/joy-utils/index'; +import { CurationPanel } from './CurationPanel'; +import { ChannelNameAsLink } from './ChannelNameAsLink'; + +type ChannelPreviewProps = { + channel: ChannelEntity + size?: ChannelAvatarSize + withSubtitle?: boolean + withDescription?: boolean +}; + +export const ChannelPreview = (props: ChannelPreviewProps) => { + const { myAccountId } = useMyMembership(); + const { channel, size, withSubtitle = true, withDescription } = props; + + let subtitle: string | undefined; + let icon: 'music' | 'film' | undefined; + + if (isMusicChannel(channel)) { + subtitle = 'Music channel', + icon = 'music' + } else if (isVideoChannel(channel)) { + subtitle = 'Video channel' + icon = 'film' + } + + let visibilityIcon: SemanticICONS = 'eye'; + let visibilityColor: SemanticCOLORS = 'green'; + let visibilityText = 'Public'; + + if (!isPublicChannel(channel)) { + visibilityIcon = 'eye slash'; + visibilityColor = 'orange'; + visibilityText = 'Unlisted'; + } + + return <> +
+ + + +
+

+ + + + + {isAccountAChannelOwner(channel, myAccountId) && +
+ + + + Edit + + + + + Upload {channel.content} + + +
+ } +

+ +
+ + {withSubtitle && subtitle && + + {icon && } + {subtitle} + + } + + + + {channel.curationStatus !== 'Normal' && + + } + + {isVerifiedChannel(channel) && + + } +
+ + {withDescription && nonEmptyStr(channel.description) && + + } +
+ + {/* // TODO uncomment when we calculate reward and count of videos in channel: */} + {/* */} + +
+ +} diff --git a/pioneer/packages/joy-media/src/channels/ChannelPreviewStats.tsx b/pioneer/packages/joy-media/src/channels/ChannelPreviewStats.tsx new file mode 100644 index 0000000000..328b876bf2 --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/ChannelPreviewStats.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Statistic } from 'semantic-ui-react'; + +import { ChannelEntity } from '../entities/ChannelEntity'; +import { formatNumber } from '@polkadot/util'; + +type Props = { + channel: ChannelEntity +}; + +export const ChannelPreviewStats = (props: Props) => { + const { channel } = props; + const statSize = 'tiny'; + + let itemsPublishedLabel = '' + if (channel.content === 'Video') { + itemsPublishedLabel = 'Videos' + } else if (channel.content === 'Music') { + itemsPublishedLabel = 'Music tracks' + } + + return ( +
+
+ + Reward earned + + {formatNumber(channel.rewardEarned)} +  JOY + + +
+ +
+ + {itemsPublishedLabel} + {formatNumber(channel.contentItemsCount)} + +
+
+ ) +} diff --git a/pioneer/packages/joy-media/src/channels/ChannelsByOwner.tsx b/pioneer/packages/joy-media/src/channels/ChannelsByOwner.tsx new file mode 100644 index 0000000000..20ba2e4677 --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/ChannelsByOwner.tsx @@ -0,0 +1,77 @@ +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { Segment, Tab } from 'semantic-ui-react'; +import { AccountId } from '@polkadot/types/interfaces'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { YouHaveNoChannels } from './YouHaveNoChannels'; +import { ChannelContentTypeValue } from '@joystream/types/content-working-group'; +import { ChannelPreview } from './ChannelPreview'; + +export type ChannelsByOwnerProps = { + accountId: AccountId, + suspended?: boolean, + channels?: ChannelEntity[] +}; + +const TabsAndChannels = (props: ChannelsByOwnerProps) => { + const { channels: allChannels = [] } = props; + const [ channels, setChannels ] = useState(allChannels); + + let videoChannelsCount = 0; + let musicChannelsCount = 0; + allChannels.forEach(x => { + if (x.content === 'Video') { + videoChannelsCount++; + } else if (x.content === 'Music') { + musicChannelsCount++; + } + }); + + const panes = [ + { menuItem: `All channels (${allChannels.length})` }, + { menuItem: `Video channels (${videoChannelsCount})` }, + { menuItem: `Music channels (${musicChannelsCount})` } + ]; + + const contentTypeByTabIndex: Array = + [ undefined, 'Video', 'Music' ]; + + const switchTab = (activeIndex: number) => { + const activeContentType = contentTypeByTabIndex[activeIndex]; + if (activeContentType === undefined) { + setChannels(allChannels) + } else { + setChannels(allChannels.filter( + (x) => x.content === activeContentType) + ) + } + } + + return <> + switchTab(data.activeIndex as number)} + /> + + + Create Channel + + {channels.map((channel) => + + + + )} + +} + +export function ChannelsByOwner (props: ChannelsByOwnerProps) { + const { suspended = false, channels = [] } = props; + + return
+ {!channels.length + ? + : + }
; +} diff --git a/pioneer/packages/joy-media/src/channels/ChannelsByOwner.view.tsx b/pioneer/packages/joy-media/src/channels/ChannelsByOwner.view.tsx new file mode 100644 index 0000000000..a5901244eb --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/ChannelsByOwner.view.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { RouteComponentProps } from 'react-router'; + +import { GenericAccountId } from '@polkadot/types'; +import { MediaView } from '../MediaView'; +import { ChannelsByOwnerProps, ChannelsByOwner } from './ChannelsByOwner'; +import { JoyError } from '@polkadot/joy-utils/JoyStatus'; + +type Props = ChannelsByOwnerProps; + +export const ChannelsByOwnerView = MediaView({ + component: ChannelsByOwner, + resolveProps: async (props) => { + const { transport, accountId } = props; + const channels = await transport.channelsByAccount(accountId); + return { channels }; + } +}); + +export const ChannelsByOwnerWithRouter = (props: Props & RouteComponentProps) => { + const { match: { params: { account }}} = props; + + if (account) { + try { + return ; + } catch (err) { + console.log('ChannelsByOwnerWithRouter failed:', err); + } + } + + return {account} +} \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/channels/CurationPanel.tsx b/pioneer/packages/joy-media/src/channels/CurationPanel.tsx new file mode 100644 index 0000000000..caf197c261 --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/CurationPanel.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { isVerifiedChannel, isCensoredChannel } from './ChannelHelpers'; +import { useMyMembership } from '@polkadot/joy-utils/MyMembershipContext'; +import TxButton from '@polkadot/joy-utils/TxButton'; +import { ChannelCurationStatus } from '@joystream/types/content-working-group'; +import { AccountId } from '@polkadot/types/interfaces'; + +type ChannelCurationPanelProps = { + channel: ChannelEntity +}; + +export const CurationPanel = (props: ChannelCurationPanelProps) => { + const { curationActor, allAccounts } = useMyMembership(); + const { channel } = props; + + const canUseAccount = (account: AccountId) => { + if (!allAccounts || !Object.keys(allAccounts).length) { + return false + } + + const ix = Object.keys(allAccounts).findIndex((key) => { + return account.eq(allAccounts[key].json.address) + }); + + return ix != -1 + } + + const renderToggleCensorshipButton = () => { + if (!curationActor) { return null } + + const [curation_actor, role_account] = curationActor; + const accountAvailable = canUseAccount(role_account); + + const isCensored = isCensoredChannel(channel); + + const new_curation_status = new ChannelCurationStatus( + isCensored ? 'Normal' : 'Censored' + ); + + return + } + + const renderToggleVerifiedButton = () => { + if (!curationActor) { return null } + + const [curation_actor, role_account] = curationActor; + const accountAvailable = canUseAccount(role_account); + const isVerified = isVerifiedChannel(channel); + + return + } + + return <> +
+ {renderToggleCensorshipButton()} + {renderToggleVerifiedButton()} +
+ +} diff --git a/pioneer/packages/joy-media/src/channels/EditChannel.tsx b/pioneer/packages/joy-media/src/channels/EditChannel.tsx new file mode 100644 index 0000000000..59839004ac --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/EditChannel.tsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { Button } from 'semantic-ui-react'; +import { Form, withFormik } from 'formik'; +import { History } from 'history'; + +import { Text, Option } from '@polkadot/types'; +import TxButton from '@polkadot/joy-utils/TxButton'; +import { onImageError } from '@polkadot/joy-utils/images'; +import { withMediaForm, MediaFormProps } from '../common/MediaForms'; +import { ChannelType, ChannelClass as Fields, buildChannelValidationSchema, ChannelFormValues, ChannelToFormValues, ChannelGenericProp } from '../schemas/channel/Channel'; +import { MediaDropdownOptions } from '../common/MediaDropdownOptions'; +import { ChannelId, ChannelContentType, ChannelPublicationStatus, OptionalText } from '@joystream/types/content-working-group'; +import { newOptionalText, findFirstParamOfSubstrateEvent } from '@polkadot/joy-utils/index'; +import { useMyMembership } from '@polkadot/joy-utils/MyMembershipContext'; +import { ChannelPublicationStatusDropdownOptions, isAccountAChannelOwner } from './ChannelHelpers'; +import { TxCallback } from '@polkadot/react-components/Status/types'; +import { SubmittableResult } from '@polkadot/api'; +import { ChannelValidationConstraints } from '../transport'; +import { JoyError } from '@polkadot/joy-utils/JoyStatus'; + +export type OuterProps = { + history?: History, + id?: ChannelId, + entity?: ChannelType, + constraints?: ChannelValidationConstraints, + opts?: MediaDropdownOptions +}; + +type FormValues = ChannelFormValues; + +const InnerForm = (props: MediaFormProps) => { + const { + // React components for form fields: + MediaText, + MediaDropdown, + LabelledField, + + // Callbacks: + onSubmit, + // onTxSuccess, + onTxFailed, + + history, + id: existingId, + entity, + isFieldChanged, + + // Formik stuff: + values, + dirty, + isValid, + isSubmitting, + setSubmitting, + resetForm + } = props; + + const { myAccountId, myMemberId } = useMyMembership(); + + if (entity && !isAccountAChannelOwner(entity, myAccountId)) { + return + } + + const { avatar } = values; + const isNew = !entity; + + // if user is not the channel owner don't render the edit form + // return null + + const onTxSuccess: TxCallback = (txResult: SubmittableResult) => { + setSubmitting(false) + if (!history) return + + const id = existingId + ? existingId + : findFirstParamOfSubstrateEvent(txResult, 'ChannelCreated') + + console.log('Channel id:', id?.toString()) + + if (id) { + history.push('/media/channels/' + id.toString()) + } + } + + const buildTxParams = () => { + if (!isValid) return []; + + // TODO get value from the form: + const publicationStatus = new ChannelPublicationStatus('Public'); + + if (!entity) { + + // Create a new channel + + const channelOwner = myMemberId; + const roleAccount = myAccountId; + const contentType = new ChannelContentType(values.content); + + return [ + channelOwner, + roleAccount, + contentType, + new Text(values.handle), + newOptionalText(values.title), + newOptionalText(values.description), + newOptionalText(values.avatar), + newOptionalText(values.banner), + publicationStatus + ]; + } else { + + // Update an existing channel + + const updOptText = (field: ChannelGenericProp): Option => { + return new Option(OptionalText, + isFieldChanged(field) + ? newOptionalText(values[field.id]) + : null + ) + } + + const updHandle = new Option(Text, + isFieldChanged(Fields.handle) + ? values[Fields.handle.id] + : null + ) + + const updPublicationStatus = new Option(ChannelPublicationStatus, + isFieldChanged(Fields.publicationStatus) + ? new ChannelPublicationStatus(values[Fields.publicationStatus.id] as any) + : null + ) + + return [ + new ChannelId(entity.id), + updHandle, + updOptText(Fields.title), + updOptText(Fields.description), + updOptText(Fields.avatar), + updOptText(Fields.banner), + updPublicationStatus + ]; + } + }; + + const formFields = () => <> + + + + + + + + ; + + const renderMainButton = () => + + + return
+
+ {avatar && } +
+ +
+ + {formFields()} + + + {renderMainButton()} +
; +}; + +export const EditForm = withFormik({ + + // Transform outer props into form values + mapPropsToValues: (props): FormValues => { + const { entity } = props; + return ChannelToFormValues(entity); + }, + + validationSchema: (props: OuterProps): any => { + const { constraints } = props + if (!constraints) return null + + return buildChannelValidationSchema(constraints) + }, + + handleSubmit: () => { + // do submitting things + } +})(withMediaForm(InnerForm) as any); + +export default EditForm; diff --git a/pioneer/packages/joy-media/src/channels/EditChannel.view.tsx b/pioneer/packages/joy-media/src/channels/EditChannel.view.tsx new file mode 100644 index 0000000000..b64cd16e41 --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/EditChannel.view.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { RouteComponentProps } from 'react-router'; +import { MediaView } from '../MediaView'; +import { OuterProps, EditForm } from './EditChannel'; +import { ChannelId } from '@joystream/types/content-working-group'; +import { JoyError } from '@polkadot/joy-utils/JoyStatus'; + +type Props = OuterProps; + +export const EditChannelView = MediaView({ + component: EditForm, + membersOnly: true, + triggers: [ 'id' ], + resolveProps: async (props) => { + const { transport, id } = props; + const entity = id && await transport.channelById(id); + const constraints = await transport.channelValidationConstraints() + return { entity, constraints }; + } +}) + +type WithRouterProps = Props & RouteComponentProps + +export const EditChannelWithRouter = (props: WithRouterProps) => { + const { match: { params: { id }}} = props; + + if (id) { + try { + return ; + } catch (err) { + console.log('EditChannelWithRouter failed:', err); + } + } + + return {id} +} diff --git a/pioneer/packages/joy-media/src/channels/ViewChannel.tsx b/pioneer/packages/joy-media/src/channels/ViewChannel.tsx new file mode 100644 index 0000000000..23446c740f --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/ViewChannel.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { ChannelId } from '@joystream/types/content-working-group'; +import { VideoType } from '../schemas/video/Video'; +import { MusicAlbumPreviewProps } from '../music/MusicAlbumPreview'; +import { MusicTrackReaderPreviewProps } from '../music/MusicTrackReaderPreview'; +import { ViewVideoChannel } from './ViewVideoChannel'; +import { ViewMusicChannel } from './ViewMusicChannel'; +import { toVideoPreviews } from '../video/VideoPreview'; +import { isVideoChannel, isMusicChannel } from './ChannelHelpers'; +import { JoyError } from '@polkadot/joy-utils/JoyStatus'; + +export type ViewChannelProps = { + id: ChannelId, + channel?: ChannelEntity, + + // Video channel specific: + videos?: VideoType[], + + // Music channel specific: + albums?: MusicAlbumPreviewProps[], + tracks?: MusicTrackReaderPreviewProps[] +} + +export function ViewChannel (props: ViewChannelProps) { + const { channel, videos = [], albums = [], tracks = [] } = props; + + if (!channel) { + return + } + + if (isVideoChannel(channel)) { + const previews = toVideoPreviews(videos); + return ; + } else if (isMusicChannel(channel)) { + return ; + } else { + return {channel.content} + } +} diff --git a/pioneer/packages/joy-media/src/channels/ViewChannel.view.tsx b/pioneer/packages/joy-media/src/channels/ViewChannel.view.tsx new file mode 100644 index 0000000000..e3ebb8be4c --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/ViewChannel.view.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { RouteComponentProps } from 'react-router'; +import { MediaView } from '../MediaView'; +import { ViewChannelProps, ViewChannel } from './ViewChannel'; +import { ChannelId } from '@joystream/types/content-working-group'; +import { JoyError } from '@polkadot/joy-utils/JoyStatus'; + +type Props = ViewChannelProps; + +export const ViewChannelView = MediaView({ + component: ViewChannel, + triggers: [ 'id' ], + resolveProps: async (props) => { + const { transport, id } = props; + const channel = await transport.channelById(id); + const videos = await transport.videosByChannelId(id); + return { channel, videos }; + } +}); + +export const ViewChannelWithRouter = (props: Props & RouteComponentProps) => { + const { match: { params: { id }}} = props; + + if (id) { + try { + return ; + } catch (err) { + console.log('ViewChannelWithRouter failed:', err); + } + } + + return {id} +} diff --git a/pioneer/packages/joy-media/src/channels/ViewMusicChannel.tsx b/pioneer/packages/joy-media/src/channels/ViewMusicChannel.tsx new file mode 100644 index 0000000000..80627466ae --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/ViewMusicChannel.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import Section from '@polkadot/joy-utils/Section'; +import { ChannelHeader } from './ChannelHeader'; +import { MusicAlbumPreviewProps, MusicAlbumPreview } from '../music/MusicAlbumPreview'; +import { MusicTrackReaderPreview, MusicTrackReaderPreviewProps } from '../music/MusicTrackReaderPreview'; +import NoContentYet from '../common/NoContentYet'; + +type Props = { + channel: ChannelEntity, + albums?: MusicAlbumPreviewProps[], + tracks?: MusicTrackReaderPreviewProps[] +}; + +function NoAlbums () { + return Channel has no music albums yet. +} + +function NoTracks () { + return Channel has no music tracks yet. +} + +export function ViewMusicChannel (props: Props) { + const { channel, albums = [], tracks = [] } = props; + + const renderAlbumsSection = () => ( + !albums.length + ? + :
+ {albums.map(x => )} +
+ ); + + const renderTracksSection = () => ( + !tracks.length + ? + :
+ {tracks.map(x => )} +
+ ); + + return
+ + {renderAlbumsSection()} + {renderTracksSection()} +
+} diff --git a/pioneer/packages/joy-media/src/channels/ViewVideoChannel.tsx b/pioneer/packages/joy-media/src/channels/ViewVideoChannel.tsx new file mode 100644 index 0000000000..fe287e6c36 --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/ViewVideoChannel.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import Section from '@polkadot/joy-utils/Section'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { ChannelHeader } from './ChannelHeader'; +import { VideoPreview, VideoPreviewProps } from '../video/VideoPreview'; +import NoContentYet from '../common/NoContentYet'; + +type Props = { + channel: ChannelEntity, + videos?: VideoPreviewProps[] +}; + +function NoVideosYet () { + return Channel has no videos yet. +} + +export function ViewVideoChannel (props: Props) { + const { channel, videos = [] } = props; + + const renderVideosSection = () => ( + !videos.length + ? + :
+ {videos.map((x) => + + )} +
+ ); + + return
+ + {renderVideosSection()} +
+} diff --git a/pioneer/packages/joy-media/src/channels/YouHaveNoChannels.tsx b/pioneer/packages/joy-media/src/channels/YouHaveNoChannels.tsx new file mode 100644 index 0000000000..7ec86743fa --- /dev/null +++ b/pioneer/packages/joy-media/src/channels/YouHaveNoChannels.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Message } from 'semantic-ui-react'; + +type Props = { + suspended?: boolean +}; + +export function YouHaveNoChannels (props: Props) { + const { suspended = false } = props; + + const renderSuspendedAlert = () => ( + + ) + + const renderCreateButton = () => ( + + + + ) + + return <> +

+ Build your following on Joystream +

+ +

+ A channel is a way to organize related content for the benefit + of both the publisher and the audience. +

+ + {suspended + ? renderSuspendedAlert() + : renderCreateButton() + } + ; +} \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/common/BgImg.tsx b/pioneer/packages/joy-media/src/common/BgImg.tsx new file mode 100644 index 0000000000..a2125de5ea --- /dev/null +++ b/pioneer/packages/joy-media/src/common/BgImg.tsx @@ -0,0 +1,43 @@ +import React, { CSSProperties } from 'react'; + +type Props = { + url: string, + size?: number, + width?: number, + height?: number, + circle?: boolean, + className?: string, + style?: CSSProperties +}; + +export function BgImg (props: Props) { + let { url, width, height, size, circle, className, style } = props; + + const fullClass = 'JoyBgImg ' + className; + + let fullStyle: CSSProperties = { + backgroundImage: `url(${url})`, + }; + + if (!width || !height) { + width = size; + height = size; + } + + fullStyle = Object.assign(fullStyle, { + width, + height, + minWidth: width, + minHeight: height + }) + + if (circle) { + fullStyle = Object.assign(fullStyle, { + borderRadius: '50%' + }) + } + + fullStyle = Object.assign(fullStyle, style); + + return
+ ; + } + + return
{ + !showSecondScreen + ? renderAllTracks() + : renderReorderTracks() + }
; +} diff --git a/pioneer/packages/joy-media/src/music/ReorderableTracks.tsx b/pioneer/packages/joy-media/src/music/ReorderableTracks.tsx new file mode 100644 index 0000000000..382e21357e --- /dev/null +++ b/pioneer/packages/joy-media/src/music/ReorderableTracks.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd'; +import { EditableMusicTrackPreviewProps, MusicTrackPreview } from './MusicTrackPreview'; + +// A little function to help us with reordering the result +const reorder = (list: OrderableItem[], startIndex: number, endIndex: number) => { + const result = Array.from(list); + const [removed] = result.splice(startIndex, 1); + result.splice(endIndex, 0, removed); + + return result; +}; + +type Props = { + tracks: EditableMusicTrackPreviewProps[], + onRemove?: (track: EditableMusicTrackPreviewProps) => void, + noTracksView?: React.ReactElement +} + +type OrderableItem = EditableMusicTrackPreviewProps; + +export const ReorderableTracks = (props: Props) => { + const { tracks = [], onRemove = () => {}, noTracksView = null } = props; + + const [items, setItems] = useState(tracks); + + if (!items.length) { + return noTracksView; + } + + const onDragEnd = (result: DropResult) => { + + // Dropped outside the list + if (!result.destination) { + return; + } + + const reorderedItems = reorder( + items, + result.source.index, + result.destination.index + ); + + setItems(reorderedItems); + } + + // Normally you would want to split things out into separate components. + // But in this example everything is just done in one place for simplicity + return ( + + + {(provided, _snapshot) => ( +
+ {items.map((item, index) => ( + + {(provided, snapshot) => ( +
+ { + onRemove(item); + const lessItems = items.filter(x => x.id !== item.id); + setItems(lessItems); + }} + /> +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+ ); +} diff --git a/pioneer/packages/joy-media/src/schemas/channel/Channel.ts b/pioneer/packages/joy-media/src/schemas/channel/Channel.ts new file mode 100644 index 0000000000..808c8ff73e --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/channel/Channel.ts @@ -0,0 +1,166 @@ + +import * as Yup from 'yup'; +import { BlockNumber, AccountId } from '@polkadot/types/interfaces'; +import { ChannelContentTypeValue, PrincipalId, Channel, ChannelId, ChannelPublicationStatusValue, ChannelCurationStatusValue } from '@joystream/types/content-working-group'; +import { MemberId } from '@joystream/types/members'; +import { ChannelValidationConstraints } from '@polkadot/joy-media/transport'; +import { ValidationConstraint } from '@polkadot/joy-utils/ValidationConstraint'; + +function textValidation (constraint?: ValidationConstraint) { + if (!constraint) { + return Yup.string() + } + + const { min, max } = constraint + return Yup.string() + .min(min, `Text is too short. Minimum length is ${min} chars.`) + .max(max, `Text is too long. Maximum length is ${max} chars.`) +} +export const buildChannelValidationSchema = (constraints?: ChannelValidationConstraints) => + Yup.object().shape({ + handle: textValidation(constraints?.handle).required('This field is required'), + title: textValidation(constraints?.title), + description: textValidation(constraints?.description), + avatar: textValidation(constraints?.avatar), + banner: textValidation(constraints?.banner) + }); + +export type ChannelFormValues = { + content: ChannelContentTypeValue + handle: string + title: string + description: string + avatar: string + banner: string + publicationStatus: ChannelPublicationStatusValue +}; + +export type ChannelType = { + id: number + verified: boolean + handle: string + title?: string + description?: string + avatar?: string + banner?: string + content: ChannelContentTypeValue + owner: MemberId + roleAccount: AccountId + publicationStatus: ChannelPublicationStatusValue + curationStatus: ChannelCurationStatusValue + created: BlockNumber + principalId: PrincipalId +}; + +export class ChannelCodec { + static fromSubstrate(id: ChannelId, sub: Channel): ChannelType { + return { + id: id.toNumber(), + verified: sub.getBoolean('verified'), + handle: sub.getString('handle'), + title: sub.getOptionalString('title'), + description: sub.getOptionalString('description'), + avatar: sub.getOptionalString('avatar'), + banner: sub.getOptionalString('banner'), + content: sub.getEnumAsString('content'), + owner: sub.getField('owner'), + roleAccount: sub.getField('role_account'), + publicationStatus: sub.getEnumAsString('publication_status'), + curationStatus: sub.getEnumAsString('curation_status'), + created: sub.getField('created'), + principalId: sub.getField('principal_id') + } + } +} + +export function ChannelToFormValues(entity?: ChannelType): ChannelFormValues { + return { + content: entity && entity.content || 'Video', + handle: entity && entity.handle || '', + title: entity && entity.title || '', + description: entity && entity.description || '', + avatar: entity && entity.avatar || '', + banner: entity && entity.banner || '', + publicationStatus: entity && entity.publicationStatus || 'Public' + } +} + +export type ChannelPropId = + 'content' | + 'handle' | + 'title' | + 'description' | + 'avatar' | + 'banner' | + 'publicationStatus' + ; + +export type ChannelGenericProp = { + id: ChannelPropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type ChannelClassType = { + [id in ChannelPropId]: ChannelGenericProp +}; + +export const ChannelClass: ChannelClassType = { + content: { + "id": "content", + "name": "Content", + "description": "The type of channel.", + "type": "Text", + "required": true, + "maxTextLength": 100 + }, + handle: { + "id": "handle", + "name": "Handle", + "description": "Unique URL handle of channel.", + "type": "Text", + "required": true, + "maxTextLength": 40 + }, + title: { + "id": "title", + "name": "Title", + "description": "Human readable title of channel.", + "type": "Text", + "maxTextLength": 100 + }, + description: { + "id": "description", + "name": "Description", + "description": "Human readable description of channel purpose and scope.", + "type": "Text", + "maxTextLength": 4000 + }, + avatar: { + "id": "avatar", + "name": "Avatar", + "description": "URL to avatar (logo) iamge: NOTE: Should be an https link to a square image.", + "type": "Text", + "maxTextLength": 1000 + }, + banner: { + "id": "banner", + "name": "Banner", + "description": "URL to banner image: NOTE: Should be an https link to a rectangular image, between 1400x1400 and 3000x3000 pixels, in JPEG or PNG format.", + "type": "Text", + "maxTextLength": 1000 + }, + publicationStatus: { + "id": "publicationStatus", + "name": "Publication Status", + "description": "The publication status of the channel.", + "required": true, + "type": "Internal", + "classId": "Publication Status" + } +}; diff --git a/pioneer/packages/joy-media/src/schemas/general/ContentLicense.ts b/pioneer/packages/joy-media/src/schemas/general/ContentLicense.ts new file mode 100644 index 0000000000..467c983e76 --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/general/ContentLicense.ts @@ -0,0 +1,60 @@ + +/** This file is generated based on JSON schema. Do not modify. */ + +import * as Yup from 'yup'; +import { EntityCodec } from '@joystream/types/versioned-store/EntityCodec'; + +export const ContentLicenseValidationSchema = Yup.object().shape({ + value: Yup.string() + .required('This field is required') + .max(200, 'Text is too long. Maximum length is 200 chars.') +}); + +export type ContentLicenseFormValues = { + value: string +}; + +export type ContentLicenseType = { + classId: number + inClassSchemaIndexes: number[] + id: number + value: string +}; + +export class ContentLicenseCodec extends EntityCodec { } + +export function ContentLicenseToFormValues(entity?: ContentLicenseType): ContentLicenseFormValues { + return { + value: entity && entity.value || '' + } +} + +export type ContentLicensePropId = + 'value' + ; + +export type ContentLicenseGenericProp = { + id: ContentLicensePropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type ContentLicenseClassType = { + [id in ContentLicensePropId]: ContentLicenseGenericProp +}; + +export const ContentLicenseClass: ContentLicenseClassType = { + value: { + "id": "value", + "name": "Value", + "description": "The license of which the content is originally published under.", + "type": "Text", + "required": true, + "maxTextLength": 200 + } +}; diff --git a/pioneer/packages/joy-media/src/schemas/general/CurationStatus.ts b/pioneer/packages/joy-media/src/schemas/general/CurationStatus.ts new file mode 100644 index 0000000000..8a4d9cd504 --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/general/CurationStatus.ts @@ -0,0 +1,60 @@ + +/** This file is generated based on JSON schema. Do not modify. */ + +import * as Yup from 'yup'; +import { EntityCodec } from '@joystream/types/versioned-store/EntityCodec'; + +export const CurationStatusValidationSchema = Yup.object().shape({ + value: Yup.string() + .required('This field is required') + .max(255, 'Text is too long. Maximum length is 255 chars.') +}); + +export type CurationStatusFormValues = { + value: string +}; + +export type CurationStatusType = { + classId: number + inClassSchemaIndexes: number[] + id: number + value: string +}; + +export class CurationStatusCodec extends EntityCodec { } + +export function CurationStatusToFormValues(entity?: CurationStatusType): CurationStatusFormValues { + return { + value: entity && entity.value || '' + } +} + +export type CurationStatusPropId = + 'value' + ; + +export type CurationStatusGenericProp = { + id: CurationStatusPropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type CurationStatusClassType = { + [id in CurationStatusPropId]: CurationStatusGenericProp +}; + +export const CurationStatusClass: CurationStatusClassType = { + value: { + "id": "value", + "name": "Value", + "description": "The curator publication status of the content in the content directory.", + "required": true, + "type": "Text", + "maxTextLength": 255 + } +}; diff --git a/pioneer/packages/joy-media/src/schemas/general/FeaturedContent.ts b/pioneer/packages/joy-media/src/schemas/general/FeaturedContent.ts new file mode 100644 index 0000000000..11b1fccd17 --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/general/FeaturedContent.ts @@ -0,0 +1,83 @@ + +/** This file is generated based on JSON schema. Do not modify. */ + +import * as Yup from 'yup'; +import { EntityCodec } from '@joystream/types/versioned-store/EntityCodec'; +import { VideoType } from '../video/Video'; +import { MusicAlbumType } from '../music/MusicAlbum'; + +export const FeaturedContentValidationSchema = Yup.object().shape({ + // No validation rules. +}); + +export type FeaturedContentFormValues = { + topVideo: number + featuredVideos: number[] + featuredAlbums: number[] +}; + +export type FeaturedContentType = { + classId: number + inClassSchemaIndexes: number[] + id: number + topVideo?: VideoType + featuredVideos?: VideoType[] + featuredAlbums?: MusicAlbumType[] +}; + +export class FeaturedContentCodec extends EntityCodec { } + +export function FeaturedContentToFormValues(entity?: FeaturedContentType): FeaturedContentFormValues { + return { + topVideo: entity && entity.topVideo?.id || 0, + featuredVideos: entity && entity.featuredVideos?.map(x => x.id) || [], + featuredAlbums: entity && entity.featuredAlbums?.map(x => x.id) || [] + } +} + +export type FeaturedContentPropId = + 'topVideo' | + 'featuredVideos' | + 'featuredAlbums' + ; + +export type FeaturedContentGenericProp = { + id: FeaturedContentPropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type FeaturedContentClassType = { + [id in FeaturedContentPropId]: FeaturedContentGenericProp +}; + +export const FeaturedContentClass: FeaturedContentClassType = { + topVideo: { + "id": "topVideo", + "name": "Top Video", + "description": "The video that has the most prominent position(s) on the platform.", + "type": "Internal", + "classId": "Video" + }, + featuredVideos: { + "id": "featuredVideos", + "name": "Featured Videos", + "description": "Videos featured in the Video tab.", + "type": "InternalVec", + "maxItems": 12, + "classId": "Video" + }, + featuredAlbums: { + "id": "featuredAlbums", + "name": "Featured Albums", + "description": "Music albums featured in the Music tab.", + "type": "InternalVec", + "maxItems": 12, + "classId": "Music Album" + } +}; diff --git a/pioneer/packages/joy-media/src/schemas/general/Language.ts b/pioneer/packages/joy-media/src/schemas/general/Language.ts new file mode 100644 index 0000000000..a3dd773cdf --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/general/Language.ts @@ -0,0 +1,60 @@ + +/** This file is generated based on JSON schema. Do not modify. */ + +import * as Yup from 'yup'; +import { EntityCodec } from '@joystream/types/versioned-store/EntityCodec'; + +export const LanguageValidationSchema = Yup.object().shape({ + value: Yup.string() + .required('This field is required') + .max(2, 'Text is too long. Maximum length is 2 chars.') +}); + +export type LanguageFormValues = { + value: string +}; + +export type LanguageType = { + classId: number + inClassSchemaIndexes: number[] + id: number + value: string +}; + +export class LanguageCodec extends EntityCodec { } + +export function LanguageToFormValues(entity?: LanguageType): LanguageFormValues { + return { + value: entity && entity.value || '' + } +} + +export type LanguagePropId = + 'value' + ; + +export type LanguageGenericProp = { + id: LanguagePropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type LanguageClassType = { + [id in LanguagePropId]: LanguageGenericProp +}; + +export const LanguageClass: LanguageClassType = { + value: { + "id": "value", + "name": "Value", + "description": "Language code following the ISO 639-1 two letter standard.", + "type": "Text", + "required": true, + "maxTextLength": 2 + } +}; diff --git a/pioneer/packages/joy-media/src/schemas/general/MediaObject.ts b/pioneer/packages/joy-media/src/schemas/general/MediaObject.ts new file mode 100644 index 0000000000..5cc97a45db --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/general/MediaObject.ts @@ -0,0 +1,60 @@ + +/** This file is generated based on JSON schema. Do not modify. */ + +import * as Yup from 'yup'; +import { EntityCodec } from '@joystream/types/versioned-store/EntityCodec'; + +export const MediaObjectValidationSchema = Yup.object().shape({ + value: Yup.string() + .required('This field is required') + .max(66, 'Text is too long. Maximum length is 66 chars.') +}); + +export type MediaObjectFormValues = { + value: string +}; + +export type MediaObjectType = { + classId: number + inClassSchemaIndexes: number[] + id: number + value: string +}; + +export class MediaObjectCodec extends EntityCodec { } + +export function MediaObjectToFormValues(entity?: MediaObjectType): MediaObjectFormValues { + return { + value: entity && entity.value || '' + } +} + +export type MediaObjectPropId = + 'value' + ; + +export type MediaObjectGenericProp = { + id: MediaObjectPropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type MediaObjectClassType = { + [id in MediaObjectPropId]: MediaObjectGenericProp +}; + +export const MediaObjectClass: MediaObjectClassType = { + value: { + "id": "value", + "name": "Value", + "description": "Content id of object in the data directory.", + "type": "Text", + "required": true, + "maxTextLength": 48 + } +}; diff --git a/pioneer/packages/joy-media/src/schemas/general/PublicationStatus.ts b/pioneer/packages/joy-media/src/schemas/general/PublicationStatus.ts new file mode 100644 index 0000000000..439b7ffade --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/general/PublicationStatus.ts @@ -0,0 +1,60 @@ + +/** This file is generated based on JSON schema. Do not modify. */ + +import * as Yup from 'yup'; +import { EntityCodec } from '@joystream/types/versioned-store/EntityCodec'; + +export const PublicationStatusValidationSchema = Yup.object().shape({ + value: Yup.string() + .required('This field is required') + .max(50, 'Text is too long. Maximum length is 50 chars.') +}); + +export type PublicationStatusFormValues = { + value: string +}; + +export type PublicationStatusType = { + classId: number + inClassSchemaIndexes: number[] + id: number + value: string +}; + +export class PublicationStatusCodec extends EntityCodec { } + +export function PublicationStatusToFormValues(entity?: PublicationStatusType): PublicationStatusFormValues { + return { + value: entity && entity.value || '' + } +} + +export type PublicationStatusPropId = + 'value' + ; + +export type PublicationStatusGenericProp = { + id: PublicationStatusPropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type PublicationStatusClassType = { + [id in PublicationStatusPropId]: PublicationStatusGenericProp +}; + +export const PublicationStatusClass: PublicationStatusClassType = { + value: { + "id": "value", + "name": "Value", + "description": "The publication status of the content in the content directory.", + "required": true, + "type": "Text", + "maxTextLength": 50 + } +}; diff --git a/pioneer/packages/joy-media/src/schemas/music/MusicAlbum.ts b/pioneer/packages/joy-media/src/schemas/music/MusicAlbum.ts new file mode 100644 index 0000000000..0253b5d278 --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/music/MusicAlbum.ts @@ -0,0 +1,308 @@ + +/** This file is generated based on JSON schema. Do not modify. */ + +import * as Yup from 'yup'; +import { EntityCodec } from '@joystream/types/versioned-store/EntityCodec'; +import moment from 'moment'; +import { MusicGenreType } from './MusicGenre'; +import { MusicMoodType } from './MusicMood'; +import { MusicThemeType } from './MusicTheme'; +import { MusicTrackType } from './MusicTrack'; +import { LanguageType } from '../general/Language'; +import { PublicationStatusType } from '../general/PublicationStatus'; +import { CurationStatusType } from '../general/CurationStatus'; +import { ContentLicenseType } from '../general/ContentLicense'; +import { ChannelEntity } from '@polkadot/joy-media/entities/ChannelEntity'; + +export const MusicAlbumValidationSchema = Yup.object().shape({ + title: Yup.string() + .required('This field is required') + .max(255, 'Text is too long. Maximum length is 255 chars.'), + artist: Yup.string() + .required('This field is required') + .max(255, 'Text is too long. Maximum length is 255 chars.'), + thumbnail: Yup.string() + .required('This field is required') + .max(255, 'Text is too long. Maximum length is 255 chars.'), + description: Yup.string() + .required('This field is required') + .max(4000, 'Text is too long. Maximum length is 4000 chars.'), + firstReleased: Yup.string() + .required('This field is required') + .test('valid-date', 'Invalid date. Valid date formats are yyyy-mm-dd or yyyy-mm or yyyy.', (val?: any) => { + return moment(val as any).isValid(); + }), + lyrics: Yup.string() + .max(255, 'Text is too long. Maximum length is 255 chars.'), + composerOrSongwriter: Yup.string() + .max(255, 'Text is too long. Maximum length is 255 chars.'), + attribution: Yup.string() + .max(255, 'Text is too long. Maximum length is 255 chars.') +}); + +export type MusicAlbumFormValues = { + title: string + artist: string + thumbnail: string + description: string + firstReleased: string + genre: number + mood: number + theme: number + tracks: number[] + language: number + links: string[] + lyrics: string + composerOrSongwriter: string + reviews: string[] + publicationStatus: number + curationStatus: number + explicit: boolean + license: number + attribution: string + channelId: number +}; + +export type MusicAlbumType = { + classId: number + inClassSchemaIndexes: number[] + id: number + title: string + artist: string + thumbnail: string + description: string + firstReleased: number + genre?: MusicGenreType + mood?: MusicMoodType + theme?: MusicThemeType + tracks?: MusicTrackType[] + language?: LanguageType + links?: string[] + lyrics?: string + composerOrSongwriter?: string + reviews?: string[] + publicationStatus: PublicationStatusType + curationStatus?: CurationStatusType + explicit: boolean + license: ContentLicenseType + attribution?: string + channelId?: number + channel?: ChannelEntity +}; + +export class MusicAlbumCodec extends EntityCodec { } + +export function MusicAlbumToFormValues(entity?: MusicAlbumType): MusicAlbumFormValues { + return { + title: entity && entity.title || '', + artist: entity && entity.artist || '', + thumbnail: entity && entity.thumbnail || '', + description: entity && entity.description || '', + firstReleased: entity && moment(entity.firstReleased * 1000).format('YYYY-MM-DD') || '', + genre: entity && entity.genre?.id || 0, + mood: entity && entity.mood?.id || 0, + theme: entity && entity.theme?.id || 0, + tracks: entity && entity.tracks?.map(x => x.id) || [], + language: entity && entity.language?.id || 0, + links: entity && entity.links || [], + lyrics: entity && entity.lyrics || '', + composerOrSongwriter: entity && entity.composerOrSongwriter || '', + reviews: entity && entity.reviews || [], + publicationStatus: entity && entity.publicationStatus.id || 0, + curationStatus: entity && entity.curationStatus?.id || 0, + explicit: entity && entity.explicit || false, + license: entity && entity.license?.id || 0, + attribution: entity && entity.attribution || '', + channelId: entity && entity.channelId || 0 + } +} + +export type MusicAlbumPropId = + 'title' | + 'artist' | + 'thumbnail' | + 'description' | + 'firstReleased' | + 'genre' | + 'mood' | + 'theme' | + 'tracks' | + 'language' | + 'links' | + 'lyrics' | + 'composerOrSongwriter' | + 'reviews' | + 'publicationStatus' | + 'curationStatus' | + 'explicit' | + 'license' | + 'attribution' | + 'channelId' + ; + +export type MusicAlbumGenericProp = { + id: MusicAlbumPropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type MusicAlbumClassType = { + [id in MusicAlbumPropId]: MusicAlbumGenericProp +}; + +export const MusicAlbumClass: MusicAlbumClassType = { + title: { + "id": "title", + "name": "Title", + "description": "The title of the album", + "type": "Text", + "required": true, + "maxTextLength": 255 + }, + artist: { + "id": "artist", + "name": "Artist", + "description": "The artist, composer, band or group that published the album.", + "type": "Text", + "required": true, + "maxTextLength": 255 + }, + thumbnail: { + "id": "thumbnail", + "name": "Thumbnail", + "description": "URL to album cover art thumbnail: NOTE: Should be an https link to a square image, between 1400x1400 and 3000x3000 pixels, in JPEG or PNG format.", + "required": true, + "type": "Text", + "maxTextLength": 255 + }, + description: { + "id": "description", + "name": "Description", + "description": "Information about the album and artist.", + "required": true, + "type": "Text", + "maxTextLength": 4000 + }, + firstReleased: { + "id": "firstReleased", + "name": "First Released", + "description": "When the album was first released", + "required": true, + "type": "Int64" + }, + genre: { + "id": "genre", + "name": "Genre", + "description": "The genre of the album.", + "type": "Internal", + "classId": "Music Genre" + }, + mood: { + "id": "mood", + "name": "Mood", + "description": "The mood of the album.", + "type": "Internal", + "classId": "Music Mood" + }, + theme: { + "id": "theme", + "name": "Theme", + "description": "The theme of the album.", + "type": "Internal", + "classId": "Music Theme" + }, + tracks: { + "id": "tracks", + "name": "Tracks", + "description": "The tracks of the album.", + "type": "InternalVec", + "maxItems": 100, + "classId": "Music Track" + }, + language: { + "id": "language", + "name": "Language", + "description": "The language of the song lyrics in the album.", + "required": false, + "type": "Internal", + "classId": "Language" + }, + links: { + "id": "links", + "name": "Links", + "description": "Links to the artist or album site, or social media pages.", + "type": "TextVec", + "maxItems": 5, + "maxTextLength": 255 + }, + lyrics: { + "id": "lyrics", + "name": "Lyrics", + "description": "Link to the album tracks lyrics.", + "type": "Text", + "maxTextLength": 255 + }, + composerOrSongwriter: { + "id": "composerOrSongwriter", + "name": "Composer or songwriter", + "description": "The composer(s) and/or songwriter(s) of the album.", + "type": "Text", + "maxTextLength": 255 + }, + reviews: { + "id": "reviews", + "name": "Reviews", + "description": "Links to reviews of the album.", + "type": "TextVec", + "maxItems": 5, + "maxTextLength": 255 + }, + publicationStatus: { + "id": "publicationStatus", + "name": "Publication Status", + "description": "The publication status of the album.", + "required": true, + "type": "Internal", + "classId": "Publication Status" + }, + curationStatus: { + "id": "curationStatus", + "name": "Curation Status", + "description": "The publication status of the album set by the a content curator on the platform.", + "type": "Internal", + "classId": "Curation Status" + }, + explicit: { + "id": "explicit", + "name": "Explicit", + "description": "Indicates whether the album contains explicit material.", + "required": true, + "type": "Bool" + }, + license: { + "id": "license", + "name": "License", + "description": "The license of which the album is released under.", + "required": true, + "type": "Internal", + "classId": "Content License" + }, + attribution: { + "id": "attribution", + "name": "Attribution", + "description": "If the License requires attribution, add this here.", + "type": "Text", + "maxTextLength": 255 + }, + channelId: { + "id": "channelId", + "name": "Channel Id", + "description": "Id of the channel this album is published under.", + "type": "Uint64" + } +}; diff --git a/pioneer/packages/joy-media/src/schemas/music/MusicGenre.ts b/pioneer/packages/joy-media/src/schemas/music/MusicGenre.ts new file mode 100644 index 0000000000..bfbba47b64 --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/music/MusicGenre.ts @@ -0,0 +1,60 @@ + +/** This file is generated based on JSON schema. Do not modify. */ + +import * as Yup from 'yup'; +import { EntityCodec } from '@joystream/types/versioned-store/EntityCodec'; + +export const MusicGenreValidationSchema = Yup.object().shape({ + value: Yup.string() + .required('This field is required') + .max(100, 'Text is too long. Maximum length is 100 chars.') +}); + +export type MusicGenreFormValues = { + value: string +}; + +export type MusicGenreType = { + classId: number + inClassSchemaIndexes: number[] + id: number + value: string +}; + +export class MusicGenreCodec extends EntityCodec { } + +export function MusicGenreToFormValues(entity?: MusicGenreType): MusicGenreFormValues { + return { + value: entity && entity.value || '' + } +} + +export type MusicGenrePropId = + 'value' + ; + +export type MusicGenreGenericProp = { + id: MusicGenrePropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type MusicGenreClassType = { + [id in MusicGenrePropId]: MusicGenreGenericProp +}; + +export const MusicGenreClass: MusicGenreClassType = { + value: { + "id": "value", + "name": "Value", + "description": "Genres for music.", + "required": true, + "type": "Text", + "maxTextLength": 100 + } +}; diff --git a/pioneer/packages/joy-media/src/schemas/music/MusicMood.ts b/pioneer/packages/joy-media/src/schemas/music/MusicMood.ts new file mode 100644 index 0000000000..c83a71b680 --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/music/MusicMood.ts @@ -0,0 +1,60 @@ + +/** This file is generated based on JSON schema. Do not modify. */ + +import * as Yup from 'yup'; +import { EntityCodec } from '@joystream/types/versioned-store/EntityCodec'; + +export const MusicMoodValidationSchema = Yup.object().shape({ + value: Yup.string() + .required('This field is required') + .max(100, 'Text is too long. Maximum length is 100 chars.') +}); + +export type MusicMoodFormValues = { + value: string +}; + +export type MusicMoodType = { + classId: number + inClassSchemaIndexes: number[] + id: number + value: string +}; + +export class MusicMoodCodec extends EntityCodec { } + +export function MusicMoodToFormValues(entity?: MusicMoodType): MusicMoodFormValues { + return { + value: entity && entity.value || '' + } +} + +export type MusicMoodPropId = + 'value' + ; + +export type MusicMoodGenericProp = { + id: MusicMoodPropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type MusicMoodClassType = { + [id in MusicMoodPropId]: MusicMoodGenericProp +}; + +export const MusicMoodClass: MusicMoodClassType = { + value: { + "id": "value", + "name": "Value", + "description": "Moods for music.", + "required": true, + "type": "Text", + "maxTextLength": 100 + } +}; diff --git a/pioneer/packages/joy-media/src/schemas/music/MusicTheme.ts b/pioneer/packages/joy-media/src/schemas/music/MusicTheme.ts new file mode 100644 index 0000000000..c9df59bc96 --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/music/MusicTheme.ts @@ -0,0 +1,60 @@ + +/** This file is generated based on JSON schema. Do not modify. */ + +import * as Yup from 'yup'; +import { EntityCodec } from '@joystream/types/versioned-store/EntityCodec'; + +export const MusicThemeValidationSchema = Yup.object().shape({ + value: Yup.string() + .required('This field is required') + .max(100, 'Text is too long. Maximum length is 100 chars.') +}); + +export type MusicThemeFormValues = { + value: string +}; + +export type MusicThemeType = { + classId: number + inClassSchemaIndexes: number[] + id: number + value: string +}; + +export class MusicThemeCodec extends EntityCodec { } + +export function MusicThemeToFormValues(entity?: MusicThemeType): MusicThemeFormValues { + return { + value: entity && entity.value || '' + } +} + +export type MusicThemePropId = + 'value' + ; + +export type MusicThemeGenericProp = { + id: MusicThemePropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type MusicThemeClassType = { + [id in MusicThemePropId]: MusicThemeGenericProp +}; + +export const MusicThemeClass: MusicThemeClassType = { + value: { + "id": "value", + "name": "Value", + "description": "Themes for music.", + "required": true, + "type": "Text", + "maxTextLength": 100 + } +}; diff --git a/pioneer/packages/joy-media/src/schemas/music/MusicTrack.ts b/pioneer/packages/joy-media/src/schemas/music/MusicTrack.ts new file mode 100644 index 0000000000..111150b0a2 --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/music/MusicTrack.ts @@ -0,0 +1,292 @@ + +/** This file is generated based on JSON schema. Do not modify. */ + +import * as Yup from 'yup'; +import { EntityCodec } from '@joystream/types/versioned-store/EntityCodec'; +import moment from 'moment'; +import { LanguageType } from '../general/Language'; +import { MusicGenreType } from './MusicGenre'; +import { MusicMoodType } from './MusicMood'; +import { MusicThemeType } from './MusicTheme'; +import { MediaObjectType } from '../general/MediaObject'; +import { PublicationStatusType } from '../general/PublicationStatus'; +import { CurationStatusType } from '../general/CurationStatus'; +import { ContentLicenseType } from '../general/ContentLicense'; +import { ChannelEntity } from '@polkadot/joy-media/entities/ChannelEntity'; + +export const MusicTrackValidationSchema = Yup.object().shape({ + title: Yup.string() + .required('This field is required') + .max(255, 'Text is too long. Maximum length is 255 chars.'), + artist: Yup.string() + .required('This field is required') + .max(255, 'Text is too long. Maximum length is 255 chars.'), + thumbnail: Yup.string() + .required('This field is required') + .max(255, 'Text is too long. Maximum length is 255 chars.'), + description: Yup.string() + .max(255, 'Text is too long. Maximum length is 255 chars.'), + firstReleased: Yup.string() + .required('This field is required') + .test('valid-date', 'Invalid date. Valid date formats are yyyy-mm-dd or yyyy-mm or yyyy.', (val?: any) => { + return moment(val as any).isValid(); + }), + composerOrSongwriter: Yup.string() + .max(255, 'Text is too long. Maximum length is 255 chars.'), + lyrics: Yup.string() + .max(255, 'Text is too long. Maximum length is 255 chars.'), + attribution: Yup.string() + .max(255, 'Text is too long. Maximum length is 255 chars.') +}); + +export type MusicTrackFormValues = { + title: string + artist: string + thumbnail: string + description: string + language: number + firstReleased: string + genre: number + mood: number + theme: number + links: string[] + composerOrSongwriter: string + lyrics: string + object: number + publicationStatus: number + curationStatus: number + explicit: boolean + license: number + attribution: string + channelId: number +}; + +export type MusicTrackType = { + classId: number + inClassSchemaIndexes: number[] + id: number + title: string + artist: string + thumbnail: string + description?: string + language?: LanguageType + firstReleased: number + genre?: MusicGenreType + mood?: MusicMoodType + theme?: MusicThemeType + links?: string[] + composerOrSongwriter?: string + lyrics?: string + object?: MediaObjectType + publicationStatus: PublicationStatusType + curationStatus?: CurationStatusType + explicit: boolean + license: ContentLicenseType + attribution?: string + channelId?: number + channel?: ChannelEntity +}; + +export class MusicTrackCodec extends EntityCodec { } + +export function MusicTrackToFormValues(entity?: MusicTrackType): MusicTrackFormValues { + return { + title: entity && entity.title || '', + artist: entity && entity.artist || '', + thumbnail: entity && entity.thumbnail || '', + description: entity && entity.description || '', + language: entity && entity.language?.id || 0, + firstReleased: entity && moment(entity.firstReleased * 1000).format('YYYY-MM-DD') || '', + genre: entity && entity.genre?.id || 0, + mood: entity && entity.mood?.id || 0, + theme: entity && entity.theme?.id || 0, + links: entity && entity.links || [], + composerOrSongwriter: entity && entity.composerOrSongwriter || '', + lyrics: entity && entity.lyrics || '', + object: entity && entity.object?.id || 0, + publicationStatus: entity && entity.publicationStatus?.id || 0, + curationStatus: entity && entity.curationStatus?.id || 0, + explicit: entity && entity.explicit || false, + license: entity && entity.license?.id || 0, + attribution: entity && entity.attribution || '', + channelId: entity && entity.channelId || 0 + } +} + +export type MusicTrackPropId = + 'title' | + 'artist' | + 'thumbnail' | + 'description' | + 'language' | + 'firstReleased' | + 'genre' | + 'mood' | + 'theme' | + 'links' | + 'composerOrSongwriter' | + 'lyrics' | + 'object' | + 'publicationStatus' | + 'curationStatus' | + 'explicit' | + 'license' | + 'attribution' | + 'channelId' + ; + +export type MusicTrackGenericProp = { + id: MusicTrackPropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type MusicTrackClassType = { + [id in MusicTrackPropId]: MusicTrackGenericProp +}; + +export const MusicTrackClass: MusicTrackClassType = { + title: { + "id": "title", + "name": "Title", + "description": "The title of the track", + "type": "Text", + "required": true, + "maxTextLength": 255 + }, + artist: { + "id": "artist", + "name": "Artist", + "description": "The artist, composer, band or group that published the track.", + "type": "Text", + "required": true, + "maxTextLength": 255 + }, + thumbnail: { + "id": "thumbnail", + "name": "Thumbnail", + "description": "URL to track cover art: NOTE: Should be an https link to a square image, between 1400x1400 and 3000x3000 pixels, in JPEG or PNG format.", + "required": true, + "type": "Text", + "maxTextLength": 255 + }, + description: { + "id": "description", + "name": "Description", + "description": "Information about the track.", + "type": "Text", + "maxTextLength": 255 + }, + language: { + "id": "language", + "name": "Language", + "description": "The language of the lyrics in the track.", + "type": "Internal", + "classId": "Language" + }, + firstReleased: { + "id": "firstReleased", + "name": "First Released", + "description": "When the track was first released", + "required": true, + "type": "Int64" + }, + genre: { + "id": "genre", + "name": "Genre", + "description": "The genre of the track.", + "type": "Internal", + "classId": "Music Genre" + }, + mood: { + "id": "mood", + "name": "Mood", + "description": "The mood of the track.", + "type": "Internal", + "classId": "Music Mood" + }, + theme: { + "id": "theme", + "name": "Theme", + "description": "The theme of the track.", + "type": "Internal", + "classId": "Music Theme" + }, + links: { + "id": "links", + "name": "Links", + "description": "Links to the artist site or social media pages.", + "type": "TextVec", + "maxItems": 5, + "maxTextLength": 255 + }, + composerOrSongwriter: { + "id": "composerOrSongwriter", + "name": "Composer or songwriter", + "description": "The composer(s) and/or songwriter(s) of the track.", + "type": "Text", + "maxTextLength": 255 + }, + lyrics: { + "id": "lyrics", + "name": "Lyrics", + "description": "Link to the track lyrics.", + "type": "Text", + "maxTextLength": 255 + }, + object: { + "id": "object", + "name": "Object", + "description": "The entityId of the object in the data directory.", + "type": "Internal", + "classId": "Media Object" + }, + publicationStatus: { + "id": "publicationStatus", + "name": "Publication Status", + "description": "The publication status of the track.", + "required": true, + "type": "Internal", + "classId": "Publication Status" + }, + curationStatus: { + "id": "curationStatus", + "name": "Curation Status", + "description": "The publication status of the track set by the a content curator on the platform.", + "type": "Internal", + "classId": "Curation Status" + }, + explicit: { + "id": "explicit", + "name": "Explicit", + "description": "Indicates whether the track contains explicit material.", + "required": true, + "type": "Bool" + }, + license: { + "id": "license", + "name": "License", + "description": "The license of which the track is released under.", + "required": true, + "type": "Internal", + "classId": "Content License" + }, + attribution: { + "id": "attribution", + "name": "Attribution", + "description": "If the License requires attribution, add this here.", + "type": "Text", + "maxTextLength": 255 + }, + channelId: { + "id": "channelId", + "name": "Channel Id", + "description": "Id of the channel this track is published under.", + "type": "Uint64" + } +}; diff --git a/pioneer/packages/joy-media/src/schemas/video/Video.ts b/pioneer/packages/joy-media/src/schemas/video/Video.ts new file mode 100644 index 0000000000..bb6780e7e7 --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/video/Video.ts @@ -0,0 +1,230 @@ + +/** This file is generated based on JSON schema. Do not modify. */ + +import * as Yup from 'yup'; +import { EntityCodec } from '@joystream/types/versioned-store/EntityCodec'; +import moment from 'moment'; +import { LanguageType } from '../general/Language'; +import { VideoCategoryType } from './VideoCategory'; +import { MediaObjectType } from '../general/MediaObject'; +import { PublicationStatusType } from '../general/PublicationStatus'; +import { CurationStatusType } from '../general/CurationStatus'; +import { ContentLicenseType } from '../general/ContentLicense'; +import { ChannelEntity } from '@polkadot/joy-media/entities/ChannelEntity'; + +export const VideoValidationSchema = Yup.object().shape({ + title: Yup.string() + .required('This field is required') + .max(255, 'Text is too long. Maximum length is 255 chars.'), + thumbnail: Yup.string() + .required('This field is required') + .max(255, 'Text is too long. Maximum length is 255 chars.'), + description: Yup.string() + .required('This field is required') + .max(4000, 'Text is too long. Maximum length is 4000 chars.'), + firstReleased: Yup.string() + .required('This field is required') + .test('valid-date', 'Invalid date. Valid date formats are yyyy-mm-dd or yyyy-mm or yyyy.', (val?: any) => { + return moment(val as any).isValid(); + }), + attribution: Yup.string() + .max(255, 'Text is too long. Maximum length is 255 chars.') +}); + +export type VideoFormValues = { + title: string + thumbnail: string + description: string + language: number + firstReleased: string + category: number + links: string[] + object: number + publicationStatus: number + curationStatus: number + explicit: boolean + license: number + attribution: string + channelId: number +}; + +export type VideoType = { + classId: number + inClassSchemaIndexes: number[] + id: number + title: string + thumbnail: string + description: string + language: LanguageType + firstReleased: number + category?: VideoCategoryType + links?: string[] + object?: MediaObjectType + publicationStatus: PublicationStatusType + curationStatus?: CurationStatusType + explicit: boolean + license: ContentLicenseType + attribution?: string + channelId?: number + channel?: ChannelEntity +}; + +export class VideoCodec extends EntityCodec { } + +export function VideoToFormValues(entity?: VideoType): VideoFormValues { + return { + title: entity && entity.title || '', + thumbnail: entity && entity.thumbnail || '', + description: entity && entity.description || '', + language: entity && entity.language?.id || 0, + firstReleased: entity && moment(entity.firstReleased * 1000).format('YYYY-MM-DD') || '', + category: entity && entity.category?.id || 0, + links: entity && entity.links || [], + object: entity && entity.object?.id || 0, + publicationStatus: entity && entity.publicationStatus?.id || 0, + curationStatus: entity && entity.curationStatus?.id || 0, + explicit: entity && entity.explicit || false, + license: entity && entity.license?.id || 0, + attribution: entity && entity.attribution || '', + channelId: entity && entity.channelId || 0 + } +} + +export type VideoPropId = + 'title' | + 'thumbnail' | + 'description' | + 'language' | + 'firstReleased' | + 'category' | + 'links' | + 'object' | + 'publicationStatus' | + 'curationStatus' | + 'explicit' | + 'license' | + 'attribution' | + 'channelId' + ; + +export type VideoGenericProp = { + id: VideoPropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type VideoClassType = { + [id in VideoPropId]: VideoGenericProp +}; + +export const VideoClass: VideoClassType = { + title: { + "id": "title", + "name": "Title", + "description": "The title of the video", + "type": "Text", + "required": true, + "maxTextLength": 255 + }, + thumbnail: { + "id": "thumbnail", + "name": "Thumbnail", + "description": "URL to video thumbnail: NOTE: Should be an https link to an image of ratio 16:9, ideally 1280 pixels wide by 720 pixels tall, with a minimum width of 640 pixels, in JPEG or PNG format.", + "required": true, + "type": "Text", + "maxTextLength": 255 + }, + description: { + "id": "description", + "name": "Description", + "description": "Information about the video.", + "required": true, + "type": "Text", + "maxTextLength": 4000 + }, + language: { + "id": "language", + "name": "Language", + "description": "The main language used in the video.", + "required": true, + "type": "Internal", + "classId": "Language" + }, + firstReleased: { + "id": "firstReleased", + "name": "First Released", + "description": "When the video was first released", + "required": true, + "type": "Int64" + }, + category: { + "id": "category", + "name": "Category", + "description": "The category of the video.", + "type": "Internal", + "classId": "Video Category" + }, + links: { + "id": "links", + "name": "Link", + "description": "A link to the creators page.", + "type": "TextVec", + "maxItems": 5, + "maxTextLength": 255 + }, + object: { + "id": "object", + "name": "Object", + "description": "The entityId of the object in the data directory.", + "type": "Internal", + "classId": "Media Object" + }, + publicationStatus: { + "id": "publicationStatus", + "name": "Publication Status", + "description": "The publication status of the video.", + "required": true, + "type": "Internal", + "classId": "Publication Status" + }, + curationStatus: { + "id": "curationStatus", + "name": "Curation Status", + "description": "The publication status of the video set by the a content curator on the platform.", + "type": "Internal", + "classId": "Curation Status" + }, + explicit: { + "id": "explicit", + "name": "Explicit", + "description": "Indicates whether the video contains explicit material.", + "required": true, + "type": "Bool" + }, + license: { + "id": "license", + "name": "License", + "description": "The license of which the video is released under.", + "required": true, + "type": "Internal", + "classId": "Content License" + }, + attribution: { + "id": "attribution", + "name": "Attribution", + "description": "If the License requires attribution, add this here.", + "type": "Text", + "maxTextLength": 255 + }, + channelId: { + "id": "channelId", + "name": "Channel Id", + "description": "Id of the channel this video is published under.", + "type": "Uint64" + } +}; diff --git a/pioneer/packages/joy-media/src/schemas/video/VideoCategory.ts b/pioneer/packages/joy-media/src/schemas/video/VideoCategory.ts new file mode 100644 index 0000000000..998d1bb42a --- /dev/null +++ b/pioneer/packages/joy-media/src/schemas/video/VideoCategory.ts @@ -0,0 +1,60 @@ + +/** This file is generated based on JSON schema. Do not modify. */ + +import * as Yup from 'yup'; +import { EntityCodec } from '@joystream/types/versioned-store/EntityCodec'; + +export const VideoCategoryValidationSchema = Yup.object().shape({ + value: Yup.string() + .required('This field is required') + .max(255, 'Text is too long. Maximum length is 255 chars.') +}); + +export type VideoCategoryFormValues = { + value: string +}; + +export type VideoCategoryType = { + classId: number + inClassSchemaIndexes: number[] + id: number + value: string +}; + +export class VideoCategoryCodec extends EntityCodec { } + +export function VideoCategoryToFormValues(entity?: VideoCategoryType): VideoCategoryFormValues { + return { + value: entity && entity.value || '' + } +} + +export type VideoCategoryPropId = + 'value' + ; + +export type VideoCategoryGenericProp = { + id: VideoCategoryPropId, + type: string, + name: string, + description?: string, + required?: boolean, + maxItems?: number, + maxTextLength?: number, + classId?: any +}; + +type VideoCategoryClassType = { + [id in VideoCategoryPropId]: VideoCategoryGenericProp +}; + +export const VideoCategoryClass: VideoCategoryClassType = { + value: { + "id": "value", + "name": "Value", + "description": "Categories for videos.", + "type": "Text", + "required": true, + "maxTextLength": 255 + } +}; diff --git a/pioneer/packages/joy-media/src/stories/ExploreContent.stories.tsx b/pioneer/packages/joy-media/src/stories/ExploreContent.stories.tsx new file mode 100644 index 0000000000..6055dc02f7 --- /dev/null +++ b/pioneer/packages/joy-media/src/stories/ExploreContent.stories.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import '../common/index.css'; + +import { ExploreContent } from '../explore/ExploreContent'; +import { withMockTransport } from './withMockTransport'; + +export default { + title: 'Media | Explore', + decorators: [ withMockTransport ], +}; + +export const DefaultState = () => + ; diff --git a/pioneer/packages/joy-media/src/stories/MusicAlbumTracks.stories.tsx b/pioneer/packages/joy-media/src/stories/MusicAlbumTracks.stories.tsx new file mode 100644 index 0000000000..c1fc962bc5 --- /dev/null +++ b/pioneer/packages/joy-media/src/stories/MusicAlbumTracks.stories.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import '../common/index.css'; + +import { EditForm } from '../music/EditMusicAlbum'; +import { MyMusicTracks } from '../music/MyMusicTracks'; +import { MusicAlbumSamples } from './data/MusicAlbumSamples'; +import { albumTracks, AllMusicTrackSamples } from './data/MusicTrackSamples'; +import { withMockTransport } from './withMockTransport'; +import { EditMusicAlbumView } from '../music/EditMusicAlbum.view'; +import EntityId from '@joystream/types/versioned-store/EntityId'; + +export default { + title: 'Media | My music tracks', + decorators: [ withMockTransport ], +}; + +export const DefaultState = () => + ; + +export const MockEditAlbumView = () => + ; + +export const MyMusicTracksStory = () => + ; diff --git a/pioneer/packages/joy-media/src/stories/MusicChannel.stories.tsx b/pioneer/packages/joy-media/src/stories/MusicChannel.stories.tsx new file mode 100644 index 0000000000..c5451418b0 --- /dev/null +++ b/pioneer/packages/joy-media/src/stories/MusicChannel.stories.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import '../common/index.css'; + +import { MockMusicChannel } from './data/ChannelSamples'; +import { ViewMusicChannel } from '../channels/ViewMusicChannel'; +import { MusicAlbumSamples } from './data/MusicAlbumSamples'; +import { AllMusicTrackSamples } from './data/MusicTrackSamples'; +import { withMockTransport } from './withMockTransport'; + +export default { + title: 'Media | Music channel', + decorators: [ withMockTransport ], +}; + +export const EmptyMusicChannel = () => + ; + +export const MusicChannelWithAlbumsOnly = () => + ; + +export const MusicChannelWithAlbumAndTracks = () => + ; diff --git a/pioneer/packages/joy-media/src/stories/MyChannels.stories.tsx b/pioneer/packages/joy-media/src/stories/MyChannels.stories.tsx new file mode 100644 index 0000000000..d471067dfd --- /dev/null +++ b/pioneer/packages/joy-media/src/stories/MyChannels.stories.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import '../common/index.css'; + +import { GenericAccountId } from '@polkadot/types'; +import { ChannelsByOwner } from '../channels/ChannelsByOwner'; +import { AllMockChannels } from './data/ChannelSamples'; +import { withMockTransport } from './withMockTransport'; +import EditForm from '../channels/EditChannel'; +import { EditChannelView } from '../channels/EditChannel.view'; +import { ChannelId } from '@joystream/types/content-working-group'; +import { AccountIdSamples } from './data/AccountIdSamples'; + +export default { + title: 'Media | My channels', + decorators: [ withMockTransport ], +}; + +// TODO pass to mocked MyMembershipContext provider via Stories decorators: +const accountId = new GenericAccountId(AccountIdSamples.Alice); + +export const DefaultState = () => + ; + +export const ChannelCreationSuspended = () => + ; + +export const YouHaveChannels = () => + ; + +export const DefaultEditForm = () => + ; + +export const MockEditFormView = () => + ; diff --git a/pioneer/packages/joy-media/src/stories/MyMusicAlbums.stories.tsx b/pioneer/packages/joy-media/src/stories/MyMusicAlbums.stories.tsx new file mode 100644 index 0000000000..ccad97c92f --- /dev/null +++ b/pioneer/packages/joy-media/src/stories/MyMusicAlbums.stories.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import '../common/index.css'; + +import { MyMusicAlbums } from '../music/MyMusicAlbums'; +import { MusicAlbumSamples } from './data/MusicAlbumSamples'; +import { withMockTransport } from './withMockTransport'; + +export default { + title: 'Media | My music albums', + decorators: [ withMockTransport ], +}; + +export const DefaultState = () => + ; + +export const WithState = () => + ; diff --git a/pioneer/packages/joy-media/src/stories/Playback.stories.tsx b/pioneer/packages/joy-media/src/stories/Playback.stories.tsx new file mode 100644 index 0000000000..0ffec0d3fb --- /dev/null +++ b/pioneer/packages/joy-media/src/stories/Playback.stories.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import '../common/index.css'; + +import { PlayContent } from '../explore/PlayContent'; +import { PlayVideo } from '../video/PlayVideo'; +import { FeaturedAlbums } from './data/MusicAlbumSamples'; +import { Album1TrackSamples } from './data/MusicTrackSamples'; +import { MockMusicChannel, MockVideoChannel } from './data/ChannelSamples'; +import { withMockTransport } from './withMockTransport'; +import { Video } from '../mocks'; +import { EntityId } from '@joystream/types/versioned-store'; + +export default { + title: 'Media | Playback', + decorators: [ withMockTransport ], +}; + +export const PlayVideoStory = () => + ; + +export const PlayAlbumStory = () => + ; diff --git a/pioneer/packages/joy-media/src/stories/UploadAudio.stories.tsx b/pioneer/packages/joy-media/src/stories/UploadAudio.stories.tsx new file mode 100644 index 0000000000..245900a43a --- /dev/null +++ b/pioneer/packages/joy-media/src/stories/UploadAudio.stories.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { EditForm } from '../upload/UploadAudio' +import '../index.css'; + +import { ContentId } from '@joystream/types/media'; +import EntityId from '@joystream/types/versioned-store/EntityId'; +import { UploadAudioView } from '../upload/UploadAudio.view'; +import { withMockTransport } from './withMockTransport'; + +export default { + title: 'Media | Upload audio', + decorators: [ withMockTransport ], +}; + +const contentId = ContentId.generate(); + +export const DefaultState = () => + ; + +export const MockEditFormView = () => + ; diff --git a/pioneer/packages/joy-media/src/stories/UploadVideo.stories.tsx b/pioneer/packages/joy-media/src/stories/UploadVideo.stories.tsx new file mode 100644 index 0000000000..e858290356 --- /dev/null +++ b/pioneer/packages/joy-media/src/stories/UploadVideo.stories.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { EditForm } from '../upload/UploadVideo' +import '../index.css'; + +import { ContentId } from '@joystream/types/media'; +import { withMockTransport } from './withMockTransport'; +import EditVideoView from '../upload/EditVideo.view'; +import EntityId from '@joystream/types/versioned-store/EntityId'; + +export default { + title: 'Media | Upload video', + decorators: [ withMockTransport ], +}; + +const contentId = ContentId.generate(); + +export const DefaultState = () => + ; + +export const MockEditFormView = () => + ; diff --git a/pioneer/packages/joy-media/src/stories/data/AccountIdSamples.ts b/pioneer/packages/joy-media/src/stories/data/AccountIdSamples.ts new file mode 100644 index 0000000000..74219c794f --- /dev/null +++ b/pioneer/packages/joy-media/src/stories/data/AccountIdSamples.ts @@ -0,0 +1,7 @@ +import { GenericAccountId } from '@polkadot/types'; + +export const AccountIdSamples = { + Alice: new GenericAccountId('5GNJqTPyNqANBkUVMN1LPPrxXnFouWXoe2wNSmmEoLctxiZY'), + Bob: new GenericAccountId('5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty'), + Charlie: new GenericAccountId('5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y') +}; diff --git a/pioneer/packages/joy-media/src/stories/data/ChannelSamples.ts b/pioneer/packages/joy-media/src/stories/data/ChannelSamples.ts new file mode 100644 index 0000000000..6aa25058af --- /dev/null +++ b/pioneer/packages/joy-media/src/stories/data/ChannelSamples.ts @@ -0,0 +1,60 @@ +import BN from 'bn.js'; +import { ChannelEntity } from '@polkadot/joy-media/entities/ChannelEntity'; +import { u32 } from '@polkadot/types'; +import { AccountIdSamples } from './AccountIdSamples'; +import { MemberId } from '@joystream/types/members'; +import { PrincipalId } from '@joystream/types/content-working-group'; + +let id = 0; +const nextId = () => ++id; + +export const MockMusicChannel: ChannelEntity = +{ + id: nextId(), + verified: true, + content: 'Music', + handle: 'easy_notes', + title: 'Easy Notes', + description: 'A fortepiano is an early piano. In principle, the word "fortepiano" can designate any piano dating from the invention of the instrument by Bartolomeo Cristofori around 1700 up to the early 19th century. Most typically, however, it is used to refer to the late-18th to early-19th century instruments for which Haydn, Mozart, and the younger Beethoven wrote their piano music.', + + avatar: 'https://images.unsplash.com/photo-1485561222814-e6c50477491b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60', + banner: 'https://images.unsplash.com/photo-1514119412350-e174d90d280e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=900&q=80', + + publicationStatus: 'Unlisted', + curationStatus: 'Censored', + owner: new MemberId(1), + roleAccount: AccountIdSamples.Alice, + principalId: new PrincipalId(1), + created: new u32(123456), + + rewardEarned: new BN('4587'), + contentItemsCount: 57, +}; + +export const MockVideoChannel: ChannelEntity = +{ + id: nextId(), + verified: true, + content: 'Video', + handle: 'bicycles_rocknroll', + title: 'Bicycles and Rock-n-Roll', + description: 'A bicycle, also called a cycle or bike, is a human-powered or motor-powered, pedal-driven, single-track vehicle, having two wheels attached to a frame, one behind the other. A is called a cyclist, or bicyclist.', + + avatar: 'https://images.unsplash.com/photo-1485965120184-e220f721d03e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60', + banner: 'https://images.unsplash.com/photo-1494488802316-82250d81cfcc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=900&q=60', + + publicationStatus: 'Public', + curationStatus: 'Normal', + owner: new MemberId(1), + roleAccount: AccountIdSamples.Alice, + principalId: new PrincipalId(1), + created: new u32(123456), + + rewardEarned: new BN('1820021'), + contentItemsCount: 1529, +}; + +export const AllMockChannels: ChannelEntity[] = [ + MockVideoChannel, + MockMusicChannel +]; diff --git a/pioneer/packages/joy-media/src/stories/data/MusicAlbumSamples.ts b/pioneer/packages/joy-media/src/stories/data/MusicAlbumSamples.ts new file mode 100644 index 0000000000..f4f18a7f1b --- /dev/null +++ b/pioneer/packages/joy-media/src/stories/data/MusicAlbumSamples.ts @@ -0,0 +1,60 @@ +import { MusicAlbumPreviewProps } from "@polkadot/joy-media/music/MusicAlbumPreview"; + +let id = 0; +const nextId = (): string => `${++id}`; + +export const MusicAlbumSample: MusicAlbumPreviewProps = { + id: nextId(), + title: 'Sound of the cold leaves', + artist: 'Man from the Woods', + cover: 'https://images.unsplash.com/photo-1477414348463-c0eb7f1359b6?ixlib=rb-1.2.1&auto=format&fit=crop&w=200&q=60', + tracksCount: 8 +}; + +export const MusicAlbumSamples = [ + MusicAlbumSample, + { + id: nextId(), + title: 'Riddle', + artist: 'Liquid Stone', + cover: 'https://images.unsplash.com/photo-1484352491158-830ef5692bb3?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60', + tracksCount: 1 + }, + { + id: nextId(), + title: 'Habitants of the silver water', + artist: 'Heavy Waves and Light Shells', + cover: 'https://images.unsplash.com/photo-1543467091-5f0406620f8b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60', + tracksCount: 12 + }, + { + id: nextId(), + title: 'Fresh and Green', + artist: 'Oldest Trees', + cover: 'https://images.unsplash.com/photo-1526749837599-b4eba9fd855e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60', + tracksCount: 9 + }, + { + id: nextId(), + title: 'Under the Sun, close to the Ground', + artist: 'Sunflower', + cover: 'https://images.unsplash.com/photo-1504567961542-e24d9439a724?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60', + tracksCount: 16 + }, + { + id: nextId(), + title: 'Concrete Jungle', + artist: 'Polis', + cover: 'https://images.unsplash.com/photo-1543716091-a840c05249ec?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60', + tracksCount: 21 + }, + { + id: nextId(), + title: 'Feeed the Bird', + artist: 'Smally', + cover: 'https://images.unsplash.com/photo-1444465693019-aa0b6392460d?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60', + tracksCount: 5 + } +]; + +export const FeaturedAlbums = MusicAlbumSamples.slice(0, 3); diff --git a/pioneer/packages/joy-media/src/stories/data/MusicTrackSamples.ts b/pioneer/packages/joy-media/src/stories/data/MusicTrackSamples.ts new file mode 100644 index 0000000000..48088b2559 --- /dev/null +++ b/pioneer/packages/joy-media/src/stories/data/MusicTrackSamples.ts @@ -0,0 +1,83 @@ +import { MusicAlbumSample } from "./MusicAlbumSamples"; +import { TracksOfMyMusicAlbumProps } from "@polkadot/joy-media/music/MusicAlbumTracks"; +import { MusicAlbumEntity } from "@polkadot/joy-media/entities/MusicAlbumEntity"; + +export const trackArtists = [ + 'Man from the Woods', + 'Liquid Stone' +]; + +export const trackThumbnails = [ + 'https://images.unsplash.com/photo-1477414348463-c0eb7f1359b6?ixlib=rb-1.2.1&auto=format&fit=crop&w=200&q=60', + 'https://images.unsplash.com/photo-1484352491158-830ef5692bb3?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60' +]; + +export const trackNames = [ + 'Arborvitae (Thuja occidentalis)', + 'Black Ash (Fraxinus nigra)', + 'White Ash (Fraxinus americana)', + 'Bigtooth Aspen (Populus grandidentata)', + 'Quaking Aspen (Populus tremuloides)', + 'Basswood (Tilia americana)', + 'American Beech (Fagus grandifolia)', + 'Black Birch (Betula lenta)', + 'Gray Birch (Betula populifolia)', + 'Paper Birch (Betula papyrifera)', + 'Yellow Birch (Betula alleghaniensis)', + 'Butternut (Juglans cinerea)', + 'Black Cherry (Prunus serotina)', + 'Pin Cherry (Prunus pensylvanica)' +] + +export const albumTracks = trackNames.map((title, i) => ({ + id: `${i}`, + title, + artist: trackArtists[0], + thumbnail: trackThumbnails[0] +})); + +export const Album1TrackSamples = trackNames + .slice(0, trackNames.length / 2) + .map((title, i) => ({ + id: `${100 + i}`, + title, + artist: trackArtists[0], + thumbnail: trackThumbnails[0] + })) + +export const Album2TrackSamples = trackNames + .slice(trackNames.length / 2) + .map((title, i) => ({ + id: `${200 + i}`, + title, + artist: trackArtists[1], + thumbnail: trackThumbnails[1], + })) + +export const AllMusicTrackSamples = + Album1TrackSamples + .concat(Album2TrackSamples) + +export const AlbumWithTracksProps: TracksOfMyMusicAlbumProps = { + album: MusicAlbumSample, + tracks: albumTracks +} + +export const MusicAlbumExample: MusicAlbumEntity = { + title: 'Requiem (Mozart)', + description: 'The Requiem in D minor, K. 626, is a requiem mass by Wolfgang Amadeus Mozart (1756–1791). Mozart composed part of the Requiem in Vienna in late 1791, but it was unfinished at his death on 5 December the same year. A completed version dated 1792 by Franz Xaver Süssmayr was delivered to Count Franz von Walsegg, who commissioned the piece for a Requiem service to commemorate the anniversary of his wifes death on 14 February.', + thumbnail: 'https://assets.classicfm.com/2017/36/mozart-1504532179-list-handheld-0.jpg', + year: 2019, + + // visibility: 'Public', + // album: 'Greatest Collection of Mozart', + + // Additional: + artist: 'Berlin Philharmonic', + composer: 'Wolfgang Amadeus Mozart', + genre: 'Classical Music', + mood: 'Relaxing', + theme: 'Dark', + explicit: false, + license: 'Public Domain', +}; diff --git a/pioneer/packages/joy-media/src/stories/withMockTransport.tsx b/pioneer/packages/joy-media/src/stories/withMockTransport.tsx new file mode 100644 index 0000000000..dba4e4617b --- /dev/null +++ b/pioneer/packages/joy-media/src/stories/withMockTransport.tsx @@ -0,0 +1,5 @@ +import React from 'react'; +import { MockTransportProvider } from '../TransportContext'; + +export const withMockTransport = (storyFn: () => React.ReactElement) => + {storyFn()}; diff --git a/pioneer/packages/joy-media/src/translate.ts b/pioneer/packages/joy-media/src/translate.ts new file mode 100644 index 0000000000..fe2a214153 --- /dev/null +++ b/pioneer/packages/joy-media/src/translate.ts @@ -0,0 +1,3 @@ +import { withTranslation } from 'react-i18next'; + +export default withTranslation(['media', 'ui']); diff --git a/pioneer/packages/joy-media/src/transport.mock.ts b/pioneer/packages/joy-media/src/transport.mock.ts new file mode 100644 index 0000000000..80fbb478d3 --- /dev/null +++ b/pioneer/packages/joy-media/src/transport.mock.ts @@ -0,0 +1,99 @@ +import { MediaTransport, ChannelValidationConstraints } from './transport'; +import { Entity, Class } from '@joystream/types/versioned-store'; +import { MusicTrackType } from './schemas/music/MusicTrack'; +import { MusicAlbumType } from './schemas/music/MusicAlbum'; +import { VideoType } from './schemas/video/Video'; + +import * as mocks from './mocks'; +import { ContentLicenseType } from './schemas/general/ContentLicense'; +import { CurationStatusType } from './schemas/general/CurationStatus'; +import { FeaturedContentType } from './schemas/general/FeaturedContent'; +import { LanguageType } from './schemas/general/Language'; +import { MediaObjectType } from './schemas/general/MediaObject'; +import { MusicGenreType } from './schemas/music/MusicGenre'; +import { MusicMoodType } from './schemas/music/MusicMood'; +import { MusicThemeType } from './schemas/music/MusicTheme'; +import { PublicationStatusType } from './schemas/general/PublicationStatus'; +import { VideoCategoryType } from './schemas/video/VideoCategory'; +import { ChannelEntity } from './entities/ChannelEntity'; +import { AllMockChannels } from './stories/data/ChannelSamples'; + +export class MockTransport extends MediaTransport { + + constructor() { + super(); + console.log('Create new MockTransport') + } + + protected notImplementedYet (): T { + throw new Error('Mock transport: Requested function is not implemented yet') + } + + allChannels(): Promise { + return this.promise(AllMockChannels); + } + + channelValidationConstraints(): Promise { + return this.notImplementedYet(); // TODO impl + } + + allClasses(): Promise { + return this.notImplementedYet(); // TODO impl + } + + allEntities (): Promise { + return this.notImplementedYet(); // TODO impl + } + + allVideos(): Promise { + return this.promise(mocks.AllVideos) + } + + allMusicTracks(): Promise { + return this.promise(mocks.AllMusicTracks) + } + + allMusicAlbums(): Promise { + return this.promise(mocks.AllMusicAlbums) + } + + featuredContent(): Promise { + return this.promise(mocks.FeaturedContent) + } + + allContentLicenses (): Promise { + return this.promise(mocks.AllContentLicenses); + } + + allCurationStatuses(): Promise { + return this.promise(mocks.AllCurationStatuses); + } + + allLanguages(): Promise { + return this.promise(mocks.AllLanguages); + } + + allMediaObjects(): Promise { + return this.promise(mocks.AllMediaObjects); + } + + allMusicGenres(): Promise { + return this.promise(mocks.AllMusicGenres); + } + + allMusicMoods(): Promise { + return this.promise(mocks.AllMusicMoods); + } + + allMusicThemes(): Promise { + return this.promise(mocks.AllMusicThemes); + } + + allPublicationStatuses(): Promise { + return this.promise(mocks.AllPublicationStatuses); + } + + allVideoCategories(): Promise { + return this.promise(mocks.AllVideoCategories); + } +} diff --git a/pioneer/packages/joy-media/src/transport.substrate.ts b/pioneer/packages/joy-media/src/transport.substrate.ts new file mode 100644 index 0000000000..62a72d758b --- /dev/null +++ b/pioneer/packages/joy-media/src/transport.substrate.ts @@ -0,0 +1,387 @@ +import BN from 'bn.js'; +import { MediaTransport, ChannelValidationConstraints } from './transport'; +import { ClassId, Class, EntityId, Entity, ClassName } from '@joystream/types/versioned-store'; +import { InputValidationLengthConstraint } from '@joystream/types/forum'; +import { PlainEntity, EntityCodecResolver } from '@joystream/types/versioned-store/EntityCodec'; +import { MusicTrackType } from './schemas/music/MusicTrack'; +import { MusicAlbumType } from './schemas/music/MusicAlbum'; +import { VideoType } from './schemas/video/Video'; +import { ContentLicenseType } from './schemas/general/ContentLicense'; +import { CurationStatusType } from './schemas/general/CurationStatus'; +import { LanguageType } from './schemas/general/Language'; +import { MediaObjectType } from './schemas/general/MediaObject'; +import { MusicGenreType } from './schemas/music/MusicGenre'; +import { MusicMoodType } from './schemas/music/MusicMood'; +import { MusicThemeType } from './schemas/music/MusicTheme'; +import { PublicationStatusType } from './schemas/general/PublicationStatus'; +import { VideoCategoryType } from './schemas/video/VideoCategory'; +import { ChannelEntity } from './entities/ChannelEntity'; +import { ChannelId, Channel } from '@joystream/types/content-working-group'; +import { ApiPromise } from '@polkadot/api/index'; +import { ApiProps } from '@polkadot/react-api/types'; +import { Vec } from '@polkadot/types'; +import { LinkageResult } from '@polkadot/types/codec/Linkage'; +import { ChannelCodec } from './schemas/channel/Channel'; +import { FeaturedContentType } from './schemas/general/FeaturedContent'; +import { AnyChannelId, asChannelId, AnyClassId, AnyEntityId } from './common/TypeHelpers'; +import { SimpleCache } from '@polkadot/joy-utils/SimpleCache'; +import { ValidationConstraint } from '@polkadot/joy-utils/ValidationConstraint'; + +const FIRST_CHANNEL_ID = 1; +const FIRST_CLASS_ID = 1; +const FIRST_ENTITY_ID = 1; + +/** + * There are entities that refer to other entities. + */ +const ClassNamesThatRequireLoadingInternals: ClassName[] = [ + 'Video', + 'MusicTrack', + 'MusicAlbum' +] + +/** + * There are such group of entities that are safe to cache + * becase they serve as utility entities. + * Very unlikely that their values will be changed frequently. + * Even if changed, this is not a big issue from UI point of view. + */ +const ClassNamesThatCanBeCached: ClassName[] = [ + 'ContentLicense', + 'CurationStatus', + 'Language', + 'MusicGenre', + 'MusicMood', + 'MusicTheme', + 'PublicationStatus', + 'VideoCategory', +] + +export class SubstrateTransport extends MediaTransport { + + protected api: ApiPromise + + private entityCodecResolver: EntityCodecResolver | undefined + + private channelCache: SimpleCache + private entityCache: SimpleCache + private classCache: SimpleCache + + // Ids of such entities as Language, Video Category, Music Mood, etc + // will be pushed to this array later in this transport class. + private idsOfEntitiesToKeepInCache: Set = new Set() + + constructor(api: ApiProps) { + super(); + console.log('Create new SubstrateTransport') + + if (!api) { + throw new Error('Cannot create SubstrateTransport: Substrate API is required'); + } else if (!api.isApiReady) { + throw new Error('Cannot create SubstrateTransport: Substrate API is not ready yet'); + } + + this.api = api.api + + const loadChannelsByIds = this.loadChannelsByIds.bind(this) + const loadEntitiesByIds = this.loadPlainEntitiesByIds.bind(this) + const loadClassesByIds = this.loadClassesByIds.bind(this) + + this.channelCache = new SimpleCache('Channel Cache', loadChannelsByIds) + this.entityCache = new SimpleCache('Entity Cache', loadEntitiesByIds) + this.classCache = new SimpleCache('Class Cache', loadClassesByIds) + } + + protected notImplementedYet (): T { + throw new Error('Substrate transport: Requested function is not implemented yet') + } + + /** Content Working Group query. */ + cwgQuery() { + return this.api.query.contentWorkingGroup + } + + /** Versioned Store query. */ + vsQuery() { + return this.api.query.versionedStore + } + + clearSessionCache() { + console.info(`Clear cache of Substrate Transport`) + this.channelCache.clear() + + this.entityCache.clearExcept( + this.idsOfEntitiesToKeepInCache + ) + + // Don't clean Class cache. It's safe to preserve it between transport sessions. + // this.classCache.clear() + } + + // Channels (Content Working Group module) + // ----------------------------------------------------------------- + + async nextChannelId(): Promise { + return await this.cwgQuery().nextChannelId() + } + + async allChannelIds(): Promise { + let nextId = (await this.nextChannelId()).toNumber() + if (nextId < 1) nextId = 1 + + const allIds: ChannelId[] = [] + for (let id = FIRST_CHANNEL_ID; id < nextId; id++) { + allIds.push(new ChannelId(id)) + } + + return allIds + } + + async loadChannelsByIds(ids: AnyChannelId[]): Promise { + const channelTuples = await this.cwgQuery().channelById.multi(ids) + + return channelTuples.map((tuple, i) => { + const channel = tuple[0] as Channel + const id = asChannelId(ids[i]) + const plain = ChannelCodec.fromSubstrate(id, channel) + + return { + ...plain, + rewardEarned: new BN(0), // TODO calc this value based on chain data + contentItemsCount: 0, // TODO calc this value based on chain data + } + }) + } + + async allChannels(): Promise { + const ids = await this.allChannelIds() + return await this.channelCache.getOrLoadByIds(ids) + } + + protected async getValidationConstraint(constraintName: string): Promise { + const constraint = await this.cwgQuery()[constraintName]() + return { + min: constraint.min.toNumber(), + max: constraint.max.toNumber() + } + } + + async channelValidationConstraints(): Promise { + const [ + handle, + title, + description, + avatar, + banner + ] = await Promise.all([ + this.getValidationConstraint('channelHandleConstraint'), + this.getValidationConstraint('channelTitleConstraint'), + this.getValidationConstraint('channelDescriptionConstraint'), + this.getValidationConstraint('channelAvatarConstraint'), + this.getValidationConstraint('channelBannerConstraint') + ]) + return { + handle, + title, + description, + avatar, + banner + } + } + + // Classes (Versioned Store module) + // ----------------------------------------------------------------- + + async nextClassId(): Promise { + return await this.vsQuery().nextClassId() + } + + async allClassIds(): Promise { + let nextId = (await this.nextClassId()).toNumber() + + const allIds: ClassId[] = [] + for (let id = FIRST_CLASS_ID; id < nextId; id++) { + allIds.push(new ClassId(id)) + } + + return allIds + } + + async loadClassesByIds(ids: AnyClassId[]): Promise { + return await this.vsQuery().classById.multi>(ids) as unknown as Class[] + } + + async allClasses(): Promise { + const ids = await this.allClassIds() + return await this.classCache.getOrLoadByIds(ids) + } + + async getEntityCodecResolver(): Promise { + if (!this.entityCodecResolver) { + const classes = await this.allClasses() + this.entityCodecResolver = new EntityCodecResolver(classes) + } + return this.entityCodecResolver + } + + async classNamesToIdSet(classNames: ClassName[]): Promise> { + const classNameToIdMap = await this.classIdByNameMap() + return new Set(classNames + .map(name => { + const classId = classNameToIdMap[name] + return classId ? classId.toString() : undefined + }) + .filter(classId => typeof classId !== 'undefined') as string[] + ) + } + + // Entities (Versioned Store module) + // ----------------------------------------------------------------- + + async nextEntityId(): Promise { + return await this.vsQuery().nextEntityId() + } + + async allEntityIds(): Promise { + let nextId = (await this.nextEntityId()).toNumber() + + const allIds: EntityId[] = [] + for (let id = FIRST_ENTITY_ID; id < nextId; id++) { + allIds.push(new EntityId(id)) + } + + return allIds + } + + private async loadEntitiesByIds(ids: AnyEntityId[]): Promise { + if (!ids || ids.length === 0) return [] + + return await this.vsQuery().entityById.multi>(ids) as unknown as Entity[] + } + + // TODO try to cache this func + private async loadPlainEntitiesByIds(ids: AnyEntityId[]): Promise { + const entities = await this.loadEntitiesByIds(ids) + const cacheClassIds = await this.classNamesToIdSet(ClassNamesThatCanBeCached) + entities.forEach(e => { + if (cacheClassIds.has(e.class_id.toString())) { + this.idsOfEntitiesToKeepInCache.add(e.id.toString()) + } + }) + + // Next logs are usefull for debug: + // console.log('cacheClassIds', cacheClassIds) + // console.log('idsOfEntitiesToKeepInCache', this.idsOfEntitiesToKeepInCache) + + return await this.toPlainEntitiesAndResolveInternals(entities) + } + + async allPlainEntities(): Promise { + const ids = await this.allEntityIds() + return await this.entityCache.getOrLoadByIds(ids) + } + + async findPlainEntitiesByClassName (className: ClassName): Promise { + const res: T[] = [] + const clazz = await this.classByName(className) + if (!clazz) { + console.warn(`No class found by name '${className}'`) + return res + } + + const allIds = await this.allEntityIds() + const filteredEntities = (await this.entityCache.getOrLoadByIds(allIds)) + .filter(entity => clazz.id.eq(entity.classId)) as T[] + + console.log(`Found ${filteredEntities.length} plain entities by class name '${className}'`) + + return filteredEntities + } + + async toPlainEntitiesAndResolveInternals(entities: Entity[]): Promise { + + const loadEntityById = this.entityCache.getOrLoadById.bind(this.entityCache) + const loadChannelById = this.channelCache.getOrLoadById.bind(this.channelCache) + + const entityCodecResolver = await this.getEntityCodecResolver() + const loadableClassIds = await this.classNamesToIdSet(ClassNamesThatRequireLoadingInternals) + + const convertions: Promise[] = [] + for (const entity of entities) { + const classIdStr = entity.class_id.toString() + const codec = entityCodecResolver.getCodecByClassId(entity.class_id) + + if (!codec) { + console.warn(`No entity codec found by class id: ${classIdStr}`) + continue + } + + const loadInternals = loadableClassIds.has(classIdStr) + convertions.push( + codec.toPlainObject( + entity, { + loadInternals, + loadEntityById, + loadChannelById + })) + } + + return Promise.all(convertions) + } + + // Load entities by class name: + // ----------------------------------------------------------------- + + async featuredContent(): Promise { + const arr = await this.findPlainEntitiesByClassName('FeaturedContent') + return arr && arr.length ? arr[0] : undefined + } + + async allMediaObjects(): Promise { + return await this.findPlainEntitiesByClassName('MediaObject') + } + + async allVideos(): Promise { + return await this.findPlainEntitiesByClassName('Video') + } + + async allMusicTracks(): Promise { + return await this.findPlainEntitiesByClassName('MusicTrack') + } + + async allMusicAlbums(): Promise { + return await this.findPlainEntitiesByClassName('MusicAlbum') + } + + async allContentLicenses (): Promise { + return await this.findPlainEntitiesByClassName('ContentLicense') + } + + async allCurationStatuses(): Promise { + return await this.findPlainEntitiesByClassName('CurationStatus') + } + + async allLanguages(): Promise { + return await this.findPlainEntitiesByClassName('Language') + } + + async allMusicGenres(): Promise { + return await this.findPlainEntitiesByClassName('MusicGenre') + } + + async allMusicMoods(): Promise { + return await this.findPlainEntitiesByClassName('MusicMood') + } + + async allMusicThemes(): Promise { + return await this.findPlainEntitiesByClassName('MusicTheme') + } + + async allPublicationStatuses(): Promise { + return await this.findPlainEntitiesByClassName('PublicationStatus') + } + + async allVideoCategories(): Promise { + return await this.findPlainEntitiesByClassName('VideoCategory') + } +} diff --git a/pioneer/packages/joy-media/src/transport.ts b/pioneer/packages/joy-media/src/transport.ts new file mode 100644 index 0000000000..5d86550e98 --- /dev/null +++ b/pioneer/packages/joy-media/src/transport.ts @@ -0,0 +1,293 @@ +import { Transport } from '@polkadot/joy-utils/index' +import { AccountId } from '@polkadot/types/interfaces'; +import { EntityId, Class, ClassName, unifyClassName, ClassIdByNameMap } from '@joystream/types/versioned-store'; +import { MusicTrackType, MusicTrackCodec } from './schemas/music/MusicTrack'; +import { MusicAlbumType, MusicAlbumCodec } from './schemas/music/MusicAlbum'; +import { VideoType, VideoCodec } from './schemas/video/Video'; +import { ContentLicenseType, ContentLicenseCodec } from './schemas/general/ContentLicense'; +import { CurationStatusType, CurationStatusCodec } from './schemas/general/CurationStatus'; +import { FeaturedContentType, FeaturedContentCodec } from './schemas/general/FeaturedContent'; +import { LanguageType, LanguageCodec } from './schemas/general/Language'; +import { MediaObjectType, MediaObjectCodec } from './schemas/general/MediaObject'; +import { MusicGenreType, MusicGenreCodec } from './schemas/music/MusicGenre'; +import { MusicMoodType, MusicMoodCodec } from './schemas/music/MusicMood'; +import { MusicThemeType, MusicThemeCodec } from './schemas/music/MusicTheme'; +import { PublicationStatusType, PublicationStatusCodec } from './schemas/general/PublicationStatus'; +import { VideoCategoryType, VideoCategoryCodec } from './schemas/video/VideoCategory'; +import { MediaDropdownOptions } from './common/MediaDropdownOptions'; +import { ChannelEntity } from './entities/ChannelEntity'; +import { ChannelId } from '@joystream/types/content-working-group'; +import { isVideoChannel, isPublicChannel } from './channels/ChannelHelpers'; +import { ValidationConstraint } from '@polkadot/joy-utils/ValidationConstraint'; + +export interface ChannelValidationConstraints { + handle: ValidationConstraint + title: ValidationConstraint + description: ValidationConstraint + avatar: ValidationConstraint + banner: ValidationConstraint +} + +export interface InternalEntities { + languages: LanguageType[] + contentLicenses: ContentLicenseType[] + curationStatuses: CurationStatusType[] + musicGenres: MusicGenreType[] + musicMoods: MusicMoodType[] + musicThemes: MusicThemeType[] + publicationStatuses: PublicationStatusType[] + videoCategories: VideoCategoryType[] +} + +export const EntityCodecByClassNameMap = { + ContentLicense: ContentLicenseCodec, + CurationStatus: CurationStatusCodec, + FeaturedContent: FeaturedContentCodec, + Language: LanguageCodec, + MediaObject: MediaObjectCodec, + MusicAlbum: MusicAlbumCodec, + MusicGenre: MusicGenreCodec, + MusicMood: MusicMoodCodec, + MusicTheme: MusicThemeCodec, + MusicTrack: MusicTrackCodec, + PublicationStatus: PublicationStatusCodec, + Video: VideoCodec, + VideoCategory: VideoCategoryCodec, +} + +function insensitiveEq(text1: string, text2: string): boolean { + const prepare = (txt: string) => txt.replace(/[\s]+/mg, '').toLowerCase() + return prepare(text1) === prepare(text2) +} + +export abstract class MediaTransport extends Transport { + + protected cachedClassIdByNameMap: ClassIdByNameMap | undefined + + protected sessionId: number = 0 + + protected abstract notImplementedYet (): T + + clearSessionCache(): void {} + + openSession(): void { + this.sessionId++ + console.info(`Open transport session no. ${this.sessionId}`) + } + + closeSession(): void { + this.clearSessionCache() + console.info(`Close transport session no. ${this.sessionId}`) + } + + async session(operation: () => R): Promise { + if (typeof operation !== 'function') { + throw new Error('Operation is not a function') + } + this.openSession() + const res = await operation() + this.closeSession() + return res + } + + abstract allChannels(): Promise + + async channelById(id: ChannelId): Promise { + return (await this.allChannels()) + .find(x => id && id.eq(x.id)) + } + + async channelsByAccount(accountId: AccountId): Promise { + return (await this.allChannels()) + .filter(x => accountId && accountId.eq(x.roleAccount)) + } + + abstract channelValidationConstraints(): Promise + + abstract allClasses(): Promise + + async classByName(className: ClassName): Promise { + return (await this.allClasses()) + .find((x) => className === unifyClassName(x.name)) + } + + async classIdByNameMap(): Promise { + if (!this.cachedClassIdByNameMap) { + const map: ClassIdByNameMap = {} + const classes = await this.allClasses() + classes.forEach((c) => { + const className = unifyClassName(c.name) + map[className] = c.id + }) + this.cachedClassIdByNameMap = map + } + return this.cachedClassIdByNameMap + } + + abstract featuredContent(): Promise + + async topVideo(): Promise { + const content = await this.featuredContent() + const topVideoId = content?.topVideo as unknown as number | undefined + return !topVideoId ? undefined : await this.videoById(new EntityId(topVideoId)) + } + + async featuredVideos(): Promise { + const content = await this.featuredContent() + const videoIds = (content?.featuredVideos || []) as unknown as number[] + const videos = await Promise.all(videoIds.map((id) => + this.videoById(new EntityId(id)))) + return videos.filter(x => x !== undefined) as VideoType[] + } + + async featuredAlbums(): Promise { + const content = await this.featuredContent() + const albumIds = (content?.featuredAlbums || []) as unknown as EntityId[] + const albums = await Promise.all(albumIds.map((id) => + this.musicAlbumById(new EntityId(id)))) + return albums.filter(x => x !== undefined) as MusicAlbumType[] + } + + abstract allMediaObjects(): Promise + + abstract allVideos(): Promise + + abstract allMusicTracks(): Promise + + abstract allMusicAlbums(): Promise + + async videosByChannelId(channelId: ChannelId, limit?: number, additionalFilter?: (x: VideoType) => boolean): Promise { + let videos = (await this.allVideos()) + .filter(x => channelId && channelId.eq(x.channelId) && (additionalFilter || (() => true))(x)) + .sort(x => -1 * x.id) + + if (limit && limit > 0) { + videos = videos.slice(0, limit) + } + + return videos + } + + async videosByAccount(accountId: AccountId): Promise { + const accountChannels = await this.channelsByAccount(accountId) + const accountChannelIds = new Set(accountChannels.map(x => x.id)) + + return (await this.allVideos()) + .filter(x => x.channelId && accountChannelIds.has(x.channelId)) + } + + async mediaObjectById(id: EntityId): Promise { + return (await this.allMediaObjects()) + .find(x => id && id.eq(x.id)) + } + + async videoById(id: EntityId): Promise { + return (await this.allVideos()) + .find(x => id && id.eq(x.id)) + } + + async musicTrackById(id: EntityId): Promise { + return (await this.allMusicTracks()) + .find(x => id && id.eq(x.id)) + } + + async musicAlbumById(id: EntityId): Promise { + return (await this.allMusicAlbums()) + .find(x => id && id.eq(x.id)) + } + + async allPublicChannels(): Promise { + return (await this.allChannels()) + .filter(isPublicChannel) + } + + async allVideoChannels(): Promise { + return (await this.allChannels()) + .filter(isVideoChannel) + } + + async allPublicVideoChannels(): Promise { + return (await this.allVideoChannels()) + .filter(isPublicChannel) + .sort(x => -1 * x.id) + } + + async latestPublicVideoChannels(limit: number = 6): Promise { + return (await this.allPublicVideoChannels()).slice(0, limit) + } + + async allPublicVideos(): Promise { + + const idOfPublicPS = (await this.allPublicationStatuses()) + .find(x => + insensitiveEq(x.value, 'Public') + )?.id + + const idsOfCuratedCS = (await this.allCurationStatuses()) + .filter(x => + insensitiveEq(x.value, 'Under review') || + insensitiveEq(x.value, 'Removed') + ).map(x => x.id) + + const isPublicAndNotCurated = (video: VideoType) => { + const isPublic = video.publicationStatus.id === idOfPublicPS + const isNotCurated = idsOfCuratedCS.indexOf(video.curationStatus?.id || -1) < 0 + const isPubChannel = video.channel ? isPublicChannel(video.channel) : true + return isPublic && isNotCurated && isPubChannel + } + + return (await this.allVideos()) + .filter(isPublicAndNotCurated) + .sort(x => -1 * x.id) + } + + async latestPublicVideos(limit: number = 12): Promise { + return (await this.allPublicVideos()).slice(0, limit) + } + + async mediaObjectClass() { + return await this.classByName('MediaObject') + } + + async videoClass() { + return await this.classByName('Video') + } + + async musicTrackClass() { + return await this.classByName('MusicTrack') + } + + async musicAlbumClass() { + return await this.classByName('MusicAlbum') + } + + abstract allContentLicenses(): Promise + abstract allCurationStatuses(): Promise + abstract allLanguages(): Promise + abstract allMusicGenres(): Promise + abstract allMusicMoods(): Promise + abstract allMusicThemes(): Promise + abstract allPublicationStatuses(): Promise + abstract allVideoCategories(): Promise + + async allInternalEntities(): Promise { + return { + contentLicenses: await this.allContentLicenses(), + curationStatuses: await this.allCurationStatuses(), + languages: await this.allLanguages(), + musicGenres: await this.allMusicGenres(), + musicMoods: await this.allMusicMoods(), + musicThemes: await this.allMusicThemes(), + publicationStatuses: await this.allPublicationStatuses(), + videoCategories: await this.allVideoCategories() + } + } + + async dropdownOptions(): Promise { + const res = new MediaDropdownOptions( + await this.allInternalEntities() + ) + //console.log('Transport.dropdownOptions', res) + return res + } +} diff --git a/pioneer/packages/joy-media/src/upload/EditVideo.view.tsx b/pioneer/packages/joy-media/src/upload/EditVideo.view.tsx new file mode 100644 index 0000000000..10f4b9031a --- /dev/null +++ b/pioneer/packages/joy-media/src/upload/EditVideo.view.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { RouteComponentProps } from 'react-router'; +import { MediaView } from '../MediaView'; +import { OuterProps, EditForm } from './UploadVideo'; +import EntityId from '@joystream/types/versioned-store/EntityId'; +import { ChannelId } from '@joystream/types/content-working-group'; +import { JoyError } from '@polkadot/joy-utils/JoyStatus'; + +type Props = OuterProps; + +export const EditVideoView = MediaView({ + component: EditForm, + membersOnly: true, + triggers: [ 'id' ], + resolveProps: async (props) => { + const { transport, id, channelId } = props; + const channel = channelId && await transport.channelById(channelId); + const mediaObjectClass = await transport.mediaObjectClass(); + const entityClass = await transport.videoClass(); + const entity = id && await transport.videoById(id); + const opts = await transport.dropdownOptions(); + return { channel, mediaObjectClass, entityClass, entity, opts }; + } +}) + +type WithRouterProps = Props & RouteComponentProps + +export const UploadVideoWithRouter = (props: WithRouterProps) => { + const { match: { params: { channelId }}} = props; + + if (channelId) { + try { + return ; + } catch (err) { + console.log('UploadVideoWithRouter failed:', err); + } + } + + return {channelId} +} + +export const EditVideoWithRouter = (props: WithRouterProps) => { + const { match: { params: { id }}} = props; + + if (id) { + try { + return ; + } catch (err) { + console.log('EditVideoWithRouter failed:', err); + } + } + + return {id} +} + +export default EditVideoView; diff --git a/pioneer/packages/joy-media/src/upload/UploadAudio.tsx b/pioneer/packages/joy-media/src/upload/UploadAudio.tsx new file mode 100644 index 0000000000..357f545479 --- /dev/null +++ b/pioneer/packages/joy-media/src/upload/UploadAudio.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { Button, Tab } from 'semantic-ui-react'; +import { Form, withFormik } from 'formik'; +import { History } from 'history'; + +import TxButton from '@polkadot/joy-utils/TxButton'; +import { ContentId } from '@joystream/types/media'; +import { onImageError } from '@polkadot/joy-utils/images'; +import { MusicTrackValidationSchema, MusicTrackType, MusicTrackClass as Fields, MusicTrackFormValues, MusicTrackToFormValues } from '../schemas/music/MusicTrack'; +import { withMediaForm, MediaFormProps, datePlaceholder } from '../common/MediaForms'; +import EntityId from '@joystream/types/versioned-store/EntityId'; +import { MediaDropdownOptions } from '../common/MediaDropdownOptions'; +import { FormTabs } from '../common/FormTabs'; + +export type OuterProps = { + history?: History, + contentId: ContentId, + fileName?: string, + id?: EntityId, + entity?: MusicTrackType + opts?: MediaDropdownOptions +}; + +type FormValues = MusicTrackFormValues; + +const InnerForm = (props: MediaFormProps) => { + const { + // React components for form fields: + MediaText, + MediaDropdown, + LabelledField, + + // Callbacks: + onSubmit, + onTxSuccess, + onTxFailed, + + // history, + // contentId, + entity, + opts = MediaDropdownOptions.Empty, + + // Formik stuff: + values, + dirty, + errors, + isValid, + isSubmitting, + resetForm + } = props; + + const { thumbnail } = values; + + const isNew = !entity; + + const buildTxParams = () => { + if (!isValid) return []; + + return [ /* TODO save entity to versioned store */ ]; + }; + + const basicInfoTab = () => + + + + + + + + + + const additionalTab = () => + + + + + + + + + + + const tabs = ; + + const renderMainButton = () => + + + return
+
+ {thumbnail && } +
+ +
+ + {tabs} + + + {renderMainButton()} +
; +}; + +export const EditForm = withFormik({ + + // Transform outer props into form values + mapPropsToValues: (props): FormValues => { + const { entity, fileName } = props; + const res = MusicTrackToFormValues(entity); + if (!res.title && fileName) { + res.title = fileName; + } + return res; + }, + + validationSchema: () => MusicTrackValidationSchema, + + handleSubmit: () => { + // do submitting things + } +})(withMediaForm(InnerForm) as any); + +export default EditForm; diff --git a/pioneer/packages/joy-media/src/upload/UploadAudio.view.tsx b/pioneer/packages/joy-media/src/upload/UploadAudio.view.tsx new file mode 100644 index 0000000000..b83a3e4a76 --- /dev/null +++ b/pioneer/packages/joy-media/src/upload/UploadAudio.view.tsx @@ -0,0 +1,14 @@ +import { MediaView } from '../MediaView'; +import { OuterProps, EditForm } from './UploadAudio'; + +export const UploadAudioView = MediaView({ + component: EditForm, + resolveProps: async (props) => { + const { transport, id } = props; + const entity = id ? await transport.musicTrackById(id) : undefined; + const opts = await transport.dropdownOptions(); + return { entity, opts }; + } +}); + +export default UploadAudioView; diff --git a/pioneer/packages/joy-media/src/upload/UploadVideo.tsx b/pioneer/packages/joy-media/src/upload/UploadVideo.tsx new file mode 100644 index 0000000000..d606b8cc32 --- /dev/null +++ b/pioneer/packages/joy-media/src/upload/UploadVideo.tsx @@ -0,0 +1,450 @@ +import React from 'react'; +import { Button, Tab } from 'semantic-ui-react'; +import { Form, withFormik } from 'formik'; +import { History } from 'history'; +import moment from 'moment'; + +import TxButton, { OnTxButtonClick } from '@polkadot/joy-utils/TxButton'; +import { ContentId } from '@joystream/types/media'; +import { onImageError } from '@polkadot/joy-utils/images'; +import { VideoValidationSchema, VideoType, VideoClass as Fields, VideoFormValues, VideoToFormValues, VideoCodec, VideoPropId } from '../schemas/video/Video'; +import { MediaFormProps, withMediaForm, datePlaceholder } from '../common/MediaForms'; +import EntityId from '@joystream/types/versioned-store/EntityId'; +import { MediaDropdownOptions } from '../common/MediaDropdownOptions'; +import { FormTabs } from '../common/FormTabs'; +import { ChannelId } from '@joystream/types/content-working-group'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { Credential } from '@joystream/types/versioned-store/permissions/credentials'; +import { Class, VecClassPropertyValue } from '@joystream/types/versioned-store'; +import { TxCallback } from '@polkadot/react-components/Status/types'; +import { SubmittableResult } from '@polkadot/api'; +import { nonEmptyStr, filterSubstrateEventsAndExtractData } from '@polkadot/joy-utils/index'; +import { u16, u32, bool, Option, Vec } from '@polkadot/types'; +import { isInternalProp } from '@joystream/types/versioned-store/EntityCodec'; +import { MediaObjectCodec } from '../schemas/general/MediaObject'; +import { Operation } from '@joystream/types/versioned-store/permissions/batching'; +import { OperationType } from '@joystream/types/versioned-store/permissions/batching/operation-types'; +import { ParametrizedEntity } from '@joystream/types/versioned-store/permissions/batching/parametrized-entity'; +import ParametrizedClassPropertyValue from '@joystream/types/versioned-store/permissions/batching/ParametrizedClassPropertyValue'; +import { ParametrizedPropertyValue } from '@joystream/types/versioned-store/permissions/batching/parametrized-property-value'; +import { ParameterizedClassPropertyValues } from '@joystream/types/versioned-store/permissions/batching/operations'; +import { useMyMembership } from '@polkadot/joy-utils/MyMembershipContext'; +import { isAccountAChannelOwner } from '../channels/ChannelHelpers'; +import { JoyError } from '@polkadot/joy-utils/JoyStatus'; + +/** Example: "2019-01-23" -> 1548201600 */ +function humanDateToUnixTs(humanFriendlyDate: string): number | undefined { + return nonEmptyStr(humanFriendlyDate) ? moment(humanFriendlyDate).unix() : undefined +} + +function isDateField(field: VideoPropId): boolean { + return field === Fields.firstReleased.id +} + +export type OuterProps = { + history?: History, + contentId?: ContentId, + fileName?: string, + channelId?: ChannelId, + channel?: ChannelEntity, + mediaObjectClass?: Class, + entityClass?: Class, + id?: EntityId, + entity?: VideoType + opts?: MediaDropdownOptions +}; + +type FormValues = VideoFormValues; + +const InnerForm = (props: MediaFormProps) => { + const { + // React components for form fields: + MediaText, + MediaDropdown, + LabelledField, + + // Callbacks: + onSubmit, + // onTxSuccess, + onTxFailed, + + history, + contentId, + mediaObjectClass, + entityClass, + id, + entity, + opts, + isFieldChanged, + + values, + dirty, + errors, + isValid, + isSubmitting, + setSubmitting, + resetForm, + } = props; + + const { myAccountId } = useMyMembership(); + + const { thumbnail } = values + + if (!mediaObjectClass) { + return + } + + if (!entityClass) { + return + } + + if (entity && !isAccountAChannelOwner(entity.channel, myAccountId)) { + return + } + + // Next consts are used in tx params: + const with_credential = new Option(Credential, new Credential(2)) + const as_entity_maintainer = new bool(false) + const schema_id = new u16(0) + + const entityCodec = new VideoCodec(entityClass!) + const mediaObjectCodec = new MediaObjectCodec(mediaObjectClass!) + + const getFieldsValues = (): Partial => { + const res: Partial = {} + + Object.keys(values).forEach((prop) => { + const fieldName = prop as VideoPropId + const field = Fields[fieldName] + let fieldValue = values[fieldName] as any + + let shouldIncludeValue = true + if (entity) { + // If we updating existing entity, then update only changed props: + shouldIncludeValue = isFieldChanged(fieldName) + } else if (field.required !== true) { + // If we creating a new entity, then provide all required props + // plus non empty non required props: + if (isInternalProp(field)) { + shouldIncludeValue = fieldValue > 0 + } else if (typeof fieldValue === 'string') { + shouldIncludeValue = nonEmptyStr(fieldValue) + } else if (Array.isArray(fieldValue) && fieldValue.length === 0) { + shouldIncludeValue = false + } + } + + // For debugging: + // const propForLog: any = { fieldName, fieldValue } + // if (shouldIncludeValue) { + // propForLog.shouldIncludeValue = shouldIncludeValue + // } + // console.log(propForLog) + + if (shouldIncludeValue) { + if (typeof fieldValue === 'string') { + fieldValue = fieldValue.trim() + } + if (isDateField(fieldName)) { + fieldValue = humanDateToUnixTs(fieldValue) + } + res[fieldName] = fieldValue + } + }) + + return res + } + + const indexOfCreateMediaObjectOperation = new u32(0) + + const indexOfCreateVideoEntityOperation = new u32(2) + + const referToIdOfCreatedMediaObjectEntity = () => + ParametrizedEntity.InternalEntityJustAdded(indexOfCreateMediaObjectOperation) + + const referToIdOfCreatedVideoEntity = () => + ParametrizedEntity.InternalEntityJustAdded(indexOfCreateVideoEntityOperation) + + const newlyCreatedMediaObjectProp = () => { + const inClassIndexOfMediaObject = entityCodec.inClassIndexOfProp(Fields.object.id) + if (!inClassIndexOfMediaObject) { + throw new Error('Cannot not find an in-class index of "object" prop on Video entity.') + } + + return new ParametrizedClassPropertyValue({ + in_class_index: new u16(inClassIndexOfMediaObject), + value: ParametrizedPropertyValue.InternalEntityJustAdded( + indexOfCreateMediaObjectOperation + ) + }) + } + + const toParametrizedPropValues = ( + props: VecClassPropertyValue, + extra: ParametrizedClassPropertyValue[] = [] + ): ParameterizedClassPropertyValues => { + + const parametrizedProps = props.map(p => { + const { in_class_index, value } = p + return new ParametrizedClassPropertyValue({ + in_class_index, + value: new ParametrizedPropertyValue({ PropertyValue: value }) + }) + }) + + if (extra && extra.length) { + extra.forEach(x => parametrizedProps.push(x)) + } + + return new ParameterizedClassPropertyValues(parametrizedProps) + } + + const newEntityOperation = (operation_type: OperationType) => { + return new Operation({ + with_credential, + as_entity_maintainer, + operation_type + }) + } + + const prepareTxParamsForCreateMediaObject = () => { + return newEntityOperation( + OperationType.CreateEntity( + mediaObjectClass!.id + ) + ) + } + + const prepareTxParamsForAddSchemaToMediaObject = () => { + const propValues = toParametrizedPropValues( + mediaObjectCodec.toSubstrateUpdate({ + value: contentId!.encode() + }) + ) + // console.log('prepareTxParamsForAddSchemaToMediaObject:', propValues) + + return newEntityOperation( + OperationType.AddSchemaSupportToEntity( + referToIdOfCreatedMediaObjectEntity(), + schema_id, + propValues + ) + ) + } + + const prepareTxParamsForCreateEntity = () => { + return newEntityOperation( + OperationType.CreateEntity( + entityClass!.id + ) + ) + } + + const prepareTxParamsForAddSchemaToEntity = () => { + const propValues = toParametrizedPropValues( + entityCodec.toSubstrateUpdate(getFieldsValues()), + [ newlyCreatedMediaObjectProp() ] + ) + + // console.log('prepareTxParamsForAddSchemaToEntity:', propValues) + + return newEntityOperation( + OperationType.AddSchemaSupportToEntity( + referToIdOfCreatedVideoEntity(), + schema_id, + propValues + ) + ) + } + + const canSubmitTx = () => dirty && isValid && !isSubmitting + + const buildTransactionTxParams = () => { + // No need to prepare tx params until the form is valid: + if (!canSubmitTx()) return [] + + const ops = [ + prepareTxParamsForCreateMediaObject(), + prepareTxParamsForAddSchemaToMediaObject(), + prepareTxParamsForCreateEntity(), + prepareTxParamsForAddSchemaToEntity() + ] + + // Use for debug: + // console.log('Batch entity operations:', ops) + + return [new Vec(Operation, ops)] + } + + const buildUpdateEntityTxParams = () => { + // No need to prepare tx params until the form is valid: + if (!canSubmitTx()) return [] + + const updatedPropValues = entityCodec.toSubstrateUpdate(getFieldsValues()) + // console.log('buildUpdateEntityTxParams:', updatedPropValues) + + return [ + with_credential, + as_entity_maintainer, + id, // Video Entity Id + updatedPropValues + ] + } + + const redirectToPlaybackPage = (newEntityId?: EntityId) => { + const entityId = newEntityId || id + if (history && entityId) { + history.push('/media/videos/' + entityId.toString()) + } + } + + const onCreateEntitySuccess: TxCallback = (txResult: SubmittableResult) => { + setSubmitting(false) + + // Get id of newly created video entity from the second 'EntityCreated' event, + // because the first 'EntityCreated' event corresponds to a Media Object Entity. + const events = filterSubstrateEventsAndExtractData(txResult, 'EntityCreated') + + // Return if there were less than two events: + if (!events || events.length < 2) return + + // Get the second 'EntityCreated' event: + const videoEntityCreatedEvent = events[1] + + // Extract id from from event: + const newId = videoEntityCreatedEvent[0] as EntityId + console.log('New video entity id:', newId && newId.toString()) + + redirectToPlaybackPage(newId) + } + + const onUpdateEntitySuccess: TxCallback = (_txResult: SubmittableResult) => { + setSubmitting(false) + redirectToPlaybackPage() + } + + const basicInfoTab = () => + + + + + + + + + + + const additionalTab = () => + + + + + + const tabs = ; + + const newOnSubmit: OnTxButtonClick = (sendTx: () => void) => { + + // TODO Switch to the first tab with errors if any + + if (onSubmit) { + onSubmit(sendTx); + } + } + + const renderTransactionButton = () => + + + const renderUpdateEntityButton = () => + + + return
+
+ {thumbnail && } +
+ +
+ {tabs} + + {!entity + ? renderTransactionButton() + : renderUpdateEntityButton() + } +
; +}; + +export const EditForm = withFormik({ + + // Transform outer props into form values + mapPropsToValues: (props): FormValues => { + const { entity, channelId, fileName } = props; + const res = VideoToFormValues(entity); + if (!res.title && fileName) { + res.title = fileName; + } + if (channelId) { + res.channelId = channelId.toNumber() + } + return res; + }, + + validationSchema: () => VideoValidationSchema, + + handleSubmit: () => { + // do submitting things + } +})(withMediaForm(InnerForm) as any); + +export default EditForm; diff --git a/pioneer/packages/joy-media/src/utils.ts b/pioneer/packages/joy-media/src/utils.ts new file mode 100644 index 0000000000..91d876568f --- /dev/null +++ b/pioneer/packages/joy-media/src/utils.ts @@ -0,0 +1,4 @@ +export function fileNameWoExt (fileName: string): string { + const lastDotIdx = fileName.lastIndexOf('.'); + return fileName.substring(0, lastDotIdx); +} diff --git a/pioneer/packages/joy-media/src/video/PlayVideo.tsx b/pioneer/packages/joy-media/src/video/PlayVideo.tsx new file mode 100644 index 0000000000..26114fa127 --- /dev/null +++ b/pioneer/packages/joy-media/src/video/PlayVideo.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import { Table } from 'semantic-ui-react'; +import { ApiProps } from '@polkadot/react-api/types'; +import { ApiConsumer } from '@polkadot/react-api/ApiContext'; +import EntityId from '@joystream/types/versioned-store/EntityId'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { ChannelPreview } from '../channels/ChannelPreview'; +import { VideoPreview } from './VideoPreview'; +import { VideoType, VideoClass as Fields, VideoGenericProp } from '../schemas/video/Video'; +import { printExplicit, printReleaseDate, printLanguage } from '../entities/EntityHelpers'; +import { MediaObjectType } from '../schemas/general/MediaObject'; +import { MediaPlayerWithResolver } from '../common/MediaPlayerWithResolver'; +import { ContentId } from '@joystream/types/media'; +import { JoyError } from '@polkadot/joy-utils/JoyStatus'; + +export type PlayVideoProps = { + channel?: ChannelEntity + mediaObject?: MediaObjectType + id: EntityId + video?: VideoType + moreChannelVideos?: VideoType[] + featuredVideos?: VideoType[] +} + +type ListOfVideoPreviewProps = { + videos?: VideoType[] +} + +function VertialListOfVideoPreviews(props: ListOfVideoPreviewProps) { + const { videos = [] } = props + return <>{videos.map((video) => + + )} +} + +export function PlayVideo (props: PlayVideoProps) { + const { channel, mediaObject, video, moreChannelVideos = [], featuredVideos = [] } = props; + + if (!mediaObject || !video) { + return + } + + if (!channel) { + return + } + + const metaField = (field: VideoGenericProp, value: React.ReactNode | string) => ( + typeof video[field.id] !== 'undefined' && + + {field.name} + {value} + + ) + + const printLinks = (links?: string[]) => { + return (links || []).map((x, i) => +
+ {x} +
+ ) + } + + const metaTable = <> +

Video details

+ + + {metaField(Fields.explicit, printExplicit(video.explicit))} + {metaField(Fields.firstReleased, printReleaseDate(video.firstReleased))} + {metaField(Fields.language, printLanguage(video.language))} + {metaField(Fields.category, video.category?.value)} + {metaField(Fields.license, video.license?.value)} + {metaField(Fields.attribution, video.attribution)} + {metaField(Fields.links, printLinks(video.links))} + {metaField(Fields.curationStatus, video.curationStatus?.value)} + +
+ + + // TODO show video only to its owner, if the video is not public. + // see isPublicVideo() function. + + const contentId = ContentId.decode(mediaObject.value) + + // console.log('PlayVideo: props', props) + + return
+
+
+
+ + + {(apiProps?: ApiProps): React.ReactNode => + + } + + + + + {video.description && + + } +
+
+ {metaTable} +
+
+
+ +
+ {featuredVideos.length > 0 && +
+

Featured videos

+ +
+ } + {moreChannelVideos.length > 0 && +
+

More from this channel

+ +
+ } +
+
; +} diff --git a/pioneer/packages/joy-media/src/video/PlayVideo.view.tsx b/pioneer/packages/joy-media/src/video/PlayVideo.view.tsx new file mode 100644 index 0000000000..6d25668d9c --- /dev/null +++ b/pioneer/packages/joy-media/src/video/PlayVideo.view.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { RouteComponentProps } from 'react-router'; +import { MediaView } from '../MediaView'; +import { PlayVideoProps, PlayVideo } from './PlayVideo'; +import { ChannelId } from '@joystream/types/content-working-group'; +import { EntityId } from '@joystream/types/versioned-store'; +import { JoyError } from '@polkadot/joy-utils/JoyStatus'; + +type Props = PlayVideoProps; + +export const PlayVideoView = MediaView({ + component: PlayVideo, + triggers: [ 'id' ], + resolveProps: async (props) => { + const { transport, id } = props + + const video = await transport.videoById(id) + if (!video) return {} + + const channelId = new ChannelId(video.channelId) + const channel = await transport.channelById(channelId) + const moreChannelVideos = (await transport.videosByChannelId(channelId, 5, x => x.id !== video.id)); + const featuredVideos = await transport.featuredVideos() + const mediaObject = video.object + + return { channel, mediaObject, video, moreChannelVideos, featuredVideos } + } +}); + +export const PlayVideoWithRouter = (props: Props & RouteComponentProps) => { + const { match: { params: { id }}} = props; + + if (id) { + try { + return + } catch (err) { + console.log('PlayVideoWithRouter failed:', err); + } + } + + return {id} +} diff --git a/pioneer/packages/joy-media/src/video/VideoPreview.tsx b/pioneer/packages/joy-media/src/video/VideoPreview.tsx new file mode 100644 index 0000000000..ba5520417e --- /dev/null +++ b/pioneer/packages/joy-media/src/video/VideoPreview.tsx @@ -0,0 +1,85 @@ +import React, { CSSProperties } from 'react'; +import { Link } from 'react-router-dom'; +import { BgImg } from '../common/BgImg'; +import { VideoType } from '../schemas/video/Video'; +import { useMyMembership } from '@polkadot/joy-utils/MyMembershipContext'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { isAccountAChannelOwner } from '../channels/ChannelHelpers'; +import { ChannelAvatarAndName } from '../channels/ChannelAvatarAndName'; + +export type VideoPreviewProps = { + id: number, + title: string, + thumbnail: string, + + channel?: ChannelEntity, + withChannel?: boolean, + + // Preview-specific props: + size?: 'normal' | 'small', + orientation?: 'vertical' | 'horizontal', +}; + +export function VideoPreview (props: VideoPreviewProps) { + const { myAccountId } = useMyMembership(); + const { id, channel, withChannel = false, title, size = 'normal', orientation = 'vertical' } = props; + + let width: number = 210; + let height: number = 118; + + if (size === 'small') { + width = 168; + height = 94; + } + + let descStyle: CSSProperties = { + maxWidth: orientation === 'vertical' + ? width + : width * 1.5 + }; + + const playbackUrl = `/media/videos/${id}` + const iAmOwner = isAccountAChannelOwner(channel, myAccountId) + + return ( +
+ + + + + +
+ + +

{title}

+ + + {withChannel && channel && + + } + + {iAmOwner && +
+ + + Edit + +
+ } +
+
+ ); +} + +export function toVideoPreviews(items: VideoType[]): VideoPreviewProps[] { + return items.map(x => ({ + id: x.id, + title: x.title, + thumbnail: x.thumbnail, + })); +} diff --git a/pioneer/packages/joy-members/README.md b/pioneer/packages/joy-members/README.md new file mode 100644 index 0000000000..0317053ead --- /dev/null +++ b/pioneer/packages/joy-members/README.md @@ -0,0 +1 @@ +# Membership module for Joystream node diff --git a/pioneer/packages/joy-members/package.json b/pioneer/packages/joy-members/package.json new file mode 100644 index 0000000000..ed610455d8 --- /dev/null +++ b/pioneer/packages/joy-members/package.json @@ -0,0 +1,15 @@ +{ + "name": "@polkadot/joy-members", + "version": "0.1.1", + "description": "Membership module for Joystream node", + "main": "index.js", + "scripts": {}, + "author": "Joystream contributors", + "maintainers": [], + "dependencies": { + "@babel/runtime": "^7.7.1", + "@polkadot/react-components": "0.37.0-beta.63", + "@polkadot/react-query": "0.37.0-beta.63", + "@polkadot/joy-utils": "^0.1.1" + } +} diff --git a/pioneer/packages/joy-members/src/Dashboard.tsx b/pioneer/packages/joy-members/src/Dashboard.tsx new file mode 100644 index 0000000000..9d1e4188d7 --- /dev/null +++ b/pioneer/packages/joy-members/src/Dashboard.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import BN from 'bn.js'; + +import { ApiProps } from '@polkadot/react-api/types'; +import { I18nProps } from '@polkadot/react-components/types'; +import { withCalls } from '@polkadot/react-api/with'; +import { Bubble } from '@polkadot/react-components/index'; +import { formatNumber } from '@polkadot/util'; +import { bool as Bool } from '@polkadot/types'; + +import Section from '@polkadot/joy-utils/Section'; +import translate from './translate'; +import { queryMembershipToProp } from './utils'; + +import { FIRST_MEMBER_ID } from './constants'; + +type Props = ApiProps & I18nProps & { + newMembershipsAllowed?: Bool, + membersCreated?: BN, + minHandleLength?: BN, + maxHandleLength?: BN, + maxAvatarUriLength?: BN, + maxAboutTextLength?: BN +}; + +class Dashboard extends React.PureComponent { + + renderGeneral () { + const p = this.props; + const { newMembershipsAllowed: isAllowed } = p; + let isAllowedColor = ''; + if (isAllowed) { + isAllowedColor = isAllowed.eq(true) ? 'green' : 'red'; + } + return
+ + {isAllowed && (isAllowed.eq(true) ? 'Yes' : 'No')} + + + {formatNumber(p.membersCreated)} + + + {formatNumber(FIRST_MEMBER_ID)} + +
; + } + + renderValidation () { + const p = this.props; + return
+ + {formatNumber(p.minHandleLength)} chars + + + {formatNumber(p.maxHandleLength)} chars + + + {formatNumber(p.maxAvatarUriLength)} chars + + + {formatNumber(p.maxAboutTextLength)} chars + +
; + } + + render () { + return ( +
+ {this.renderGeneral()} + {this.renderValidation()} +
+ ); + } +} + +export default translate( + withCalls( + queryMembershipToProp('newMembershipsAllowed'), + queryMembershipToProp('membersCreated'), + queryMembershipToProp('minHandleLength'), + queryMembershipToProp('maxHandleLength'), + queryMembershipToProp('maxAvatarUriLength'), + queryMembershipToProp('maxAboutTextLength') + )(Dashboard) +); diff --git a/pioneer/packages/joy-members/src/Details.tsx b/pioneer/packages/joy-members/src/Details.tsx new file mode 100644 index 0000000000..74f10129d8 --- /dev/null +++ b/pioneer/packages/joy-members/src/Details.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Table, Loader } from 'semantic-ui-react'; +import ReactMarkdown from 'react-markdown'; +import { IdentityIcon } from '@polkadot/react-components'; +import { ApiProps } from '@polkadot/react-api/types'; +import { I18nProps } from '@polkadot/react-components/types'; +import { withCalls } from '@polkadot/react-api/with'; +import { Option } from '@polkadot/types'; +import BalanceDisplay from '@polkadot/react-components/Balance'; +import AddressMini from '@polkadot/react-components/AddressMiniJoy'; +import { formatNumber } from '@polkadot/util'; + +import translate from './translate'; +import { MemberId, Profile, EntryMethod, Paid, Screening, Genesis, SubscriptionId } from '@joystream/types/members'; +import { queryMembershipToProp } from './utils'; +import { Seat } from '@joystream/types/'; +import { nonEmptyStr, queryToProp } from '@polkadot/joy-utils/index'; +import { MyAccountProps, withMyAccount } from '@polkadot/joy-utils/MyAccount'; + +type Props = ApiProps & I18nProps & MyAccountProps & { + preview?: boolean, + memberId: MemberId, + // This cannot be named just "memberProfile", since it will conflict with "withAccount's" memberProfile + // (which holds member profile associated with currently selected account) + detailsMemberProfile?: Option, // TODO refactor to Option + activeCouncil?: Seat[] +}; + +class Component extends React.PureComponent { + + render () { + const { detailsMemberProfile } = this.props; + return detailsMemberProfile + ? this.renderProfile(detailsMemberProfile.unwrap() as Profile) + : ( +
+ +
+ ); + } + + private renderProfile (memberProfile: Profile) { + const { + preview = false, + myAddress, + activeCouncil = [], + } = this.props; + + const { + handle, + avatar_uri, + root_account, + controller_account, + } = memberProfile; + + const hasAvatar = avatar_uri && nonEmptyStr(avatar_uri.toString()); + const isMyProfile = myAddress && (myAddress === root_account.toString() || myAddress === controller_account.toString()); + const isCouncilor: boolean = ( + (activeCouncil.find(x => root_account.eq(x.member)) !== undefined) || + (activeCouncil.find(x => controller_account.eq(x.member)) !== undefined) + ); + + return ( + <> +
+ {hasAvatar + ? + : + } +
+
+ {handle.toString()} + {isMyProfile && Edit my profile} +
+
+ {isCouncilor && + + + Council member + } + +
MemberId: {this.props.memberId.toString()}
+
+
+
+ {!preview && this.renderDetails(memberProfile, isCouncilor)} + + ); + } + + private renderDetails (memberProfile: Profile, isCouncilor: boolean) { + const { + about, + registered_at_block, + registered_at_time, + entry, + suspended, + subscription, + root_account, + controller_account, + + } = memberProfile; + + const { memberId } = this.props; + + return ( + + + + Membership ID + {memberId.toNumber()} + + + Root account + + + + Controller account + + + + Registered on + {new Date(registered_at_time.toNumber()).toLocaleString()} at block #{formatNumber(registered_at_block)} + + + Suspended? + {suspended.eq(true) ? 'Yes' : 'No'} + + + Council member? + {isCouncilor ? 'Yes' : 'No'} + + + Entry method + {this.renderEntryMethod(entry)} + + + Subscription ID + {this.renderSubscription(subscription)} + + + About + + + +
+ ); + } + + private renderEntryMethod (entry: EntryMethod) { + const etype = entry.type; + if (etype === Paid.name) { + const paid = entry.value as Paid; + return
Paid, terms ID: {paid.toNumber()}
; + } else if (etype === Screening.name) { + const accountId = entry.value as Screening; + return
Screened by
; + } else if (etype === Genesis.name) { + return
Created at Genesis
; + } else { + return Unknown; + } + } + + private renderSubscription (subscription: Option) { + return subscription.isNone + ? No subscription yet. + : subscription.value.toString(); + } +} + +export default translate(withMyAccount( + withCalls( + queryToProp('query.council.activeCouncil'), + queryMembershipToProp( + 'memberProfile', + { paramName: 'memberId', propName: 'detailsMemberProfile' } + ), + )(Component) +)); diff --git a/pioneer/packages/joy-members/src/DetailsByHandle.tsx b/pioneer/packages/joy-members/src/DetailsByHandle.tsx new file mode 100644 index 0000000000..f172de2ddb --- /dev/null +++ b/pioneer/packages/joy-members/src/DetailsByHandle.tsx @@ -0,0 +1,48 @@ +import React from 'react'; + +import { I18nProps } from '@polkadot/react-components/types'; +import { withCalls } from '@polkadot/react-api/with'; +import { stringToU8a, u8aToHex } from '@polkadot/util'; + +import translate from './translate'; +import Details from './Details'; +import { MemberId } from '@joystream/types/members'; +import { queryMembershipToProp } from './utils'; + +type DetailsByHandleProps = { + handle: string, + handles?: MemberId +}; + +function DetailsByHandleInner (p: DetailsByHandleProps) { + const { handles: memberId } = p; + return memberId !== undefined ? // here we can't make distinction value existing and loading +
+
+
+ : Member profile not found.; +} + +const DetailsByHandle = withCalls( + queryMembershipToProp('handles', 'handle') +)(DetailsByHandleInner); + +type Props = I18nProps & { + match: { + params: { + handle: string + } + } +}; + +class Component extends React.PureComponent { + render () { + const { match: { params: { handle } } } = this.props; + const handleHex = u8aToHex(stringToU8a(handle)); + return ( + + ); + } +} + +export default translate(Component); diff --git a/pioneer/packages/joy-members/src/EditForm.tsx b/pioneer/packages/joy-members/src/EditForm.tsx new file mode 100644 index 0000000000..32e41f293f --- /dev/null +++ b/pioneer/packages/joy-members/src/EditForm.tsx @@ -0,0 +1,304 @@ +import BN from 'bn.js'; +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Form, Field, withFormik, FormikProps } from 'formik'; +import * as Yup from 'yup'; + +import { Option, Vec } from '@polkadot/types'; +import Section from '@polkadot/joy-utils/Section'; +import TxButton from '@polkadot/joy-utils/TxButton'; +import * as JoyForms from '@polkadot/joy-utils/forms'; +import { SubmittableResult } from '@polkadot/api'; +import { MemberId, UserInfo, Profile, PaidTermId, PaidMembershipTerms } from '@joystream/types/members'; +import { OptionText } from '@joystream/types/'; +import { MyAccountProps, withMyAccount } from '@polkadot/joy-utils/MyAccount'; +import { queryMembershipToProp } from './utils'; +import { withCalls } from '@polkadot/react-api/index'; +import { Button, Message } from 'semantic-ui-react'; +import { formatBalance } from '@polkadot/util'; +import { TxFailedCallback, TxCallback } from '@polkadot/react-components/Status/types'; +import isEqual from 'lodash/isEqual'; + +// TODO get next settings from Substrate: +const HANDLE_REGEX = /^[a-z0-9_]+$/; + +const buildSchema = (p: ValidationProps) => + Yup.object().shape({ + handle: Yup.string() + .matches(HANDLE_REGEX, 'Handle can have only lowercase letters (a-z), numbers (0-9) and underscores (_).') + .min(p.minHandleLength, `Handle is too short. Minimum length is ${p.minHandleLength} chars.`) + .max(p.maxHandleLength, `Handle is too long. Maximum length is ${p.maxHandleLength} chars.`) + .required('Handle is required'), + avatar: Yup.string() + .url('Avatar must be a valid URL of an image.') + .max(p.maxAvatarUriLength, `Avatar URL is too long. Maximum length is ${p.maxAvatarUriLength} chars.`), + about: Yup.string().max(p.maxAboutTextLength, `Text is too long. Maximum length is ${p.maxAboutTextLength} chars.`) + }); + +type ValidationProps = { + minHandleLength: number; + maxHandleLength: number; + maxAvatarUriLength: number; + maxAboutTextLength: number; +}; + +type OuterProps = ValidationProps & { + profile?: Profile; + paidTerms: PaidMembershipTerms; + paidTermId: PaidTermId; + memberId?: MemberId; +}; + +type FormValues = { + handle: string; + avatar: string; + about: string; +}; + +type FieldName = keyof FormValues; + +type FormProps = OuterProps & FormikProps; + +const LabelledField = JoyForms.LabelledField(); + +const LabelledText = JoyForms.LabelledText(); + +const InnerForm = (props: FormProps) => { + const { + profile, + paidTerms, + paidTermId, + initialValues, + values, + touched, + dirty, + isValid, + isSubmitting, + setSubmitting, + resetForm, + memberId + } = props; + + const onSubmit = (sendTx: () => void) => { + if (isValid) sendTx(); + }; + + const onTxFailed: TxFailedCallback = (txResult: SubmittableResult | null) => { + setSubmitting(false); + if (txResult == null) { + // Tx cancelled. + return; + } + }; + + const onTxSuccess: TxCallback = (_txResult: SubmittableResult) => { + setSubmitting(false); + }; + + // TODO extract to forms.tsx + const isFieldChanged = (field: FieldName): boolean => { + return dirty && touched[field] === true && !isEqual(values[field], initialValues[field]); + }; + + // TODO extract to forms.tsx + const fieldToTextOption = (field: FieldName): OptionText => { + return isFieldChanged(field) ? OptionText.some(values[field]) : OptionText.none(); + }; + + const buildTxParams = () => { + if (!isValid) return []; + + const userInfo = new UserInfo({ + handle: fieldToTextOption('handle'), + avatar_uri: fieldToTextOption('avatar'), + about: fieldToTextOption('about') + }); + + if (profile) { + // update profile + return [memberId, userInfo]; + } else { + // register as new member + return [paidTermId, userInfo]; + } + }; + + // TODO show warning that you don't have enough balance to buy a membership + + return ( +
+
+ + + + + + {!profile && paidTerms && ( + +

+ Membership costs {formatBalance(paidTerms.fee)} tokens. +

+

+ By clicking the "Register" button you agree to our + Terms of Service + and + Privacy Policy. +

+
+ )} + + +
+ ); +}; + +const EditForm = withFormik({ + // Transform outer props into form values + mapPropsToValues: props => { + const { profile: p } = props; + return { + handle: p ? p.handle.toString() : '', + avatar: p ? p.avatar_uri.toString() : '', + about: p ? p.about.toString() : '' + }; + }, + + validationSchema: buildSchema, + + handleSubmit: values => { + // do submitting things + } +})(InnerForm); + +type WithMyProfileProps = { + memberId?: MemberId; + memberProfile?: Option; // TODO refactor to Option + paidTermsId: PaidTermId; + paidTerms?: Option; + minHandleLength?: BN; + maxHandleLength?: BN; + maxAvatarUriLength?: BN; + maxAboutTextLength?: BN; +}; + +function WithMyProfileInner(p: WithMyProfileProps) { + const triedToFindProfile = !p.memberId || p.memberProfile; + if ( + triedToFindProfile && + p.paidTerms && + p.minHandleLength && + p.maxHandleLength && + p.maxAvatarUriLength && + p.maxAboutTextLength + ) { + const profile = p.memberProfile ? p.memberProfile.unwrapOr(undefined) : undefined; + + if (!profile && p.paidTerms.isNone) { + console.error('Could not find active paid membership terms'); + } + + return ( + + ); + } else return Loading...; +} + +const WithMyProfile = withCalls( + queryMembershipToProp('minHandleLength'), + queryMembershipToProp('maxHandleLength'), + queryMembershipToProp('maxAvatarUriLength'), + queryMembershipToProp('maxAboutTextLength'), + queryMembershipToProp('memberProfile', 'memberId'), + queryMembershipToProp('paidMembershipTermsById', { paramName: 'paidTermsId', propName: 'paidTerms' }) +)(WithMyProfileInner); + +type WithMyMemberIdProps = MyAccountProps & { + memberIdsByRootAccountId?: Vec; + memberIdsByControllerAccountId?: Vec; + paidTermsIds?: Vec; +}; + +function WithMyMemberIdInner(p: WithMyMemberIdProps) { + if (p.allAccounts && !Object.keys(p.allAccounts).length) { + return ( + + Please create a key to get started. +
+ + Create key + +
+
+ ); + } + + if (p.memberIdsByRootAccountId && p.memberIdsByControllerAccountId && p.paidTermsIds) { + if (p.paidTermsIds.length) { + // let member_ids = p.memberIdsByRootAccountId.slice(); // u8a.subarray is not a function!! + p.memberIdsByRootAccountId.concat(p.memberIdsByControllerAccountId); + const memberId = p.memberIdsByRootAccountId.length ? p.memberIdsByRootAccountId[0] : undefined; + + return ; + } else { + console.error('Active paid membership terms is empty'); + } + } + + return Loading...; +} + +const WithMyMemberId = withMyAccount( + withCalls( + queryMembershipToProp('memberIdsByRootAccountId', 'myAddress'), + queryMembershipToProp('memberIdsByControllerAccountId', 'myAddress'), + queryMembershipToProp('activePaidMembershipTerms', { propName: 'paidTermsIds' }) + )(WithMyMemberIdInner) +); + +export default WithMyMemberId; diff --git a/pioneer/packages/joy-members/src/List.tsx b/pioneer/packages/joy-members/src/List.tsx new file mode 100644 index 0000000000..ccab50e995 --- /dev/null +++ b/pioneer/packages/joy-members/src/List.tsx @@ -0,0 +1,99 @@ +import BN from 'bn.js'; +import React from 'react'; + +import { ApiProps } from '@polkadot/react-api/types'; +import { I18nProps } from '@polkadot/react-components/types'; + +import Section from '@polkadot/joy-utils/Section'; +import translate from './translate'; +import Details from './Details'; +import { MemberId } from '@joystream/types/members'; +import { RouteComponentProps, Redirect } from 'react-router-dom'; +import { Pagination, Icon, PaginationProps } from 'semantic-ui-react'; +import styled from 'styled-components'; + +const StyledPagination = styled(Pagination)` + border-bottom: 1px solid #ddd !important; +`; + +type Props = ApiProps & I18nProps & RouteComponentProps & { + firstMemberId: BN, + membersCreated: BN, + match: { params: { page?: string } } +}; + +type State = {}; + +const MEMBERS_PER_PAGE = 20; + +class Component extends React.PureComponent { + + state: State = {}; + + onPageChange = (e: React.MouseEvent, data: PaginationProps) => { + const { history } = this.props; + history.push(`/members/list/${ data.activePage }`); + } + + renderPagination(currentPage:number, pagesCount: number) { + return ( + , icon: true }} + firstItem={{ content: , icon: true }} + lastItem={{ content: , icon: true }} + prevItem={{ content: , icon: true }} + nextItem={{ content: , icon: true }} + totalPages={ pagesCount } + onPageChange={ this.onPageChange } + /> + ) + } + + render () { + const { + firstMemberId, + membersCreated, + match: { params: { page } } + } = this.props; + + const membersCount = membersCreated.toNumber(); + const pagesCount = Math.ceil(membersCount / MEMBERS_PER_PAGE) || 1; + const currentPage = Math.min(parseInt(page || '1'), pagesCount); + + if (currentPage.toString() !== page) { + return ; + } + + const ids: MemberId[] = []; + if (membersCount > 0) { + const firstId = firstMemberId.toNumber() + (currentPage - 1) * MEMBERS_PER_PAGE; + const lastId = Math.min(firstId + MEMBERS_PER_PAGE, membersCount) - 1; + for (let i = firstId; i <= lastId; i++) { + ids.push(new MemberId(i)); + } + } + + return ( +
1 && this.renderPagination(currentPage, pagesCount)) || undefined }> + { + membersCount === 0 + ? No registered members yet. + : ( +
+ {ids.map((id, i) => +
+ )} +
+ ) + } +
+ ); + } +} + +export default translate(Component); diff --git a/pioneer/packages/joy-members/src/MemberPreview.tsx b/pioneer/packages/joy-members/src/MemberPreview.tsx new file mode 100644 index 0000000000..c9247f2bc4 --- /dev/null +++ b/pioneer/packages/joy-members/src/MemberPreview.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { ApiProps } from '@polkadot/react-api/types'; +import { I18nProps } from '@polkadot/react-components/types'; +import { withCalls, withMulti } from '@polkadot/react-api/with'; +import { Option, Vec } from '@polkadot/types'; +import { AccountId } from '@polkadot/types/interfaces'; +import IdentityIcon from '@polkadot/react-components/IdentityIcon'; + +import translate from './translate'; +import { MemberId, Profile } from '@joystream/types/members'; +import { queryMembershipToProp } from './utils'; +import { Seat } from '@joystream/types/'; +import { nonEmptyStr, queryToProp } from '@polkadot/joy-utils/index'; +import { FlexCenter } from '@polkadot/joy-utils/FlexCenter'; +import { MutedSpan } from '@polkadot/joy-utils/MutedText'; + +const AvatarSizePx = 36; + +type MemberPreviewProps = ApiProps & I18nProps & { + accountId: AccountId, + memberId?: MemberId, + memberProfile?: Option, // TODO refactor to Option + activeCouncil?: Seat[], + prefixLabel?: string, + className?: string, + style?: React.CSSProperties +}; + +class InnerMemberPreview extends React.PureComponent { + + render () { + const { memberProfile } = this.props; + return memberProfile + ? this.renderProfile(memberProfile.unwrap() as Profile) + : null; + } + + private renderProfile (memberProfile: Profile) { + const { activeCouncil = [], accountId, prefixLabel, className, style } = this.props; + const { handle, avatar_uri } = memberProfile; + + const hasAvatar = avatar_uri && nonEmptyStr(avatar_uri.toString()); + const isCouncilor: boolean = accountId !== undefined && activeCouncil.find(x => accountId.eq(x.member)) !== undefined; + + return
+ + {prefixLabel && + {prefixLabel} + } + {hasAvatar + ? + : + } +
+
+ {handle.toString()} +
+
+ {isCouncilor && + + + Council member + } +
+
+
+
; + } +} + +type WithMemberIdByAccountIdProps = { + memberIdsByRootAccountId?: Vec, + memberIdsByControllerAccountId?: Vec +}; + +const withMemberIdByAccountId = withCalls( + queryMembershipToProp('memberIdsByRootAccountId', 'accountId'), + queryMembershipToProp('memberIdsByControllerAccountId', 'accountId'), +); + +// Get first matching memberid controlled by an account +function setMemberIdByAccountId (Component: React.ComponentType) { + return function (props: WithMemberIdByAccountIdProps & MemberPreviewProps) { + const { memberIdsByRootAccountId, memberIdsByControllerAccountId } = props; + + if (memberIdsByRootAccountId && memberIdsByControllerAccountId) { + memberIdsByRootAccountId.concat(memberIdsByControllerAccountId); + + if (memberIdsByRootAccountId.length) { + return ; + } else { + return Member not found + } + + } else { + return null; + } + }; +} + +export const MemberPreview = withMulti( + InnerMemberPreview, + translate, + withMemberIdByAccountId, + setMemberIdByAccountId, + withCalls( + queryToProp('query.council.activeCouncil'), // TODO Refactor: extract ActiveCouncilContext + queryMembershipToProp('memberProfile', 'memberId') + ) +); diff --git a/pioneer/packages/joy-members/src/constants.ts b/pioneer/packages/joy-members/src/constants.ts new file mode 100644 index 0000000000..8bc49ee03b --- /dev/null +++ b/pioneer/packages/joy-members/src/constants.ts @@ -0,0 +1,3 @@ +import BN from 'bn.js'; + +export const FIRST_MEMBER_ID = new BN(0); diff --git a/pioneer/packages/joy-members/src/index.css b/pioneer/packages/joy-members/src/index.css new file mode 100644 index 0000000000..5215f350ee --- /dev/null +++ b/pioneer/packages/joy-members/src/index.css @@ -0,0 +1,58 @@ +.ProfilePreviews, +.FullProfile { + .item { + .image { + padding: 0 !important; + } + .description { + font-size: 1rem; + } + } +} +.ProfilePreviews { + &.ui.list>.item:first-child { + padding-top: .75rem; + } + &.ui.list>.item:last-child { + padding-bottom: .75rem; + } + .MyProfile { + background-color: #FFF8E1; + } +} +.ProfileDetails { + padding-left: 1rem !important; + .handle { + margin-right: 1rem; + .button { + padding: .5rem .75rem; + } + } +} +.ProfileDetailsTable { + font-size: 1rem !important; + tr td:first-child { + width: 1%; + white-space: nowrap; + } +} + +.JoyMemberPreview { + margin-right: .5rem; + .PrefixLabel { + margin-right: .5rem; + } + .Avatar { + margin-right: .5rem; + border-radius: 100%; + } + .Content { + .Username { + font-weight: bold; + } + .Details { + font-weight: 100; + opacity: .75; + } + } +} \ No newline at end of file diff --git a/pioneer/packages/joy-members/src/index.tsx b/pioneer/packages/joy-members/src/index.tsx new file mode 100644 index 0000000000..d9f44ee260 --- /dev/null +++ b/pioneer/packages/joy-members/src/index.tsx @@ -0,0 +1,85 @@ + +import BN from 'bn.js'; +import React from 'react'; +import { Route, Switch } from 'react-router'; + +import { AppProps, I18nProps } from '@polkadot/react-components/types'; +import { ApiProps } from '@polkadot/react-api/types'; +import { withCalls, withMulti } from '@polkadot/react-api/with'; +import Tabs, { TabItem } from '@polkadot/react-components/Tabs'; + +import './index.css'; + +import { queryMembershipToProp } from './utils'; +import translate from './translate'; +import Dashboard from './Dashboard'; +import List from './List'; +import DetailsByHandle from './DetailsByHandle'; +import EditForm from './EditForm'; +import { withMyAccount, MyAccountProps } from '@polkadot/joy-utils/MyAccount'; +import {FIRST_MEMBER_ID} from './constants'; +import { RouteComponentProps } from 'react-router-dom'; + +// define out internal types +type Props = AppProps & ApiProps & I18nProps & MyAccountProps & { + membersCreated?: BN +}; + +class App extends React.PureComponent { + + private buildTabs (): TabItem[] { + const { t, membersCreated: memberCount, iAmMember } = this.props; + + return [ + { + name: 'list', + text: t('All members') + ` (${memberCount})`, + forcedExact: false + }, + { + name: 'edit', + text: iAmMember ? t('Edit my profile') : t('Register') + }, + { + name: 'dashboard', + text: t('Dashboard') + } + ]; + } + + private renderList (routeProps: RouteComponentProps) { + const { membersCreated, ...otherProps } = this.props; + return membersCreated ? + + : Loading...; + } + + render () { + const { basePath } = this.props; + const tabs = this.buildTabs(); + + return ( +
+
+ +
+ + + + this.renderList(props) } /> + + this.renderList(props) } /> + +
+ ); + } +} + +export default withMulti( + App, + translate, + withMyAccount, + withCalls( + queryMembershipToProp('membersCreated') + ) +); diff --git a/pioneer/packages/joy-members/src/translate.ts b/pioneer/packages/joy-members/src/translate.ts new file mode 100644 index 0000000000..b684c8511e --- /dev/null +++ b/pioneer/packages/joy-members/src/translate.ts @@ -0,0 +1,3 @@ +import { withTranslation } from 'react-i18next'; + +export default withTranslation(['members', 'ui']); diff --git a/pioneer/packages/joy-members/src/utils.ts b/pioneer/packages/joy-members/src/utils.ts new file mode 100644 index 0000000000..12149af347 --- /dev/null +++ b/pioneer/packages/joy-members/src/utils.ts @@ -0,0 +1,6 @@ +import { queryToProp } from '@polkadot/joy-utils/index'; +import { Options as QueryOptions } from '@polkadot/react-api/with/types'; + +export const queryMembershipToProp = (storageItem: string, paramNameOrOpts?: string | QueryOptions) => { + return queryToProp(`query.members.${storageItem}`, paramNameOrOpts); +}; diff --git a/pioneer/packages/joy-pages/README.md b/pioneer/packages/joy-pages/README.md new file mode 100644 index 0000000000..ce2cd5446a --- /dev/null +++ b/pioneer/packages/joy-pages/README.md @@ -0,0 +1,3 @@ +# Joystream Pages Module + +Static pages rendered from markdown content. diff --git a/pioneer/packages/joy-pages/package.json b/pioneer/packages/joy-pages/package.json new file mode 100644 index 0000000000..eba65b99b1 --- /dev/null +++ b/pioneer/packages/joy-pages/package.json @@ -0,0 +1,15 @@ +{ + "name": "@polkadot/joy-pages", + "version": "0.1.1", + "description": "Joystream Pages module", + "main": "index.js", + "scripts": {}, + "author": "Joystream contributors", + "maintainers": [], + "dependencies": { + "@babel/runtime": "^7.7.1", + "@polkadot/react-components": "0.37.0-beta.63", + "@polkadot/react-query": "0.37.0-beta.63", + "@polkadot/joy-utils": "^0.1.1" + } +} diff --git a/pioneer/packages/joy-pages/src/Page.tsx b/pioneer/packages/joy-pages/src/Page.tsx new file mode 100644 index 0000000000..84070ee20e --- /dev/null +++ b/pioneer/packages/joy-pages/src/Page.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import './index.css'; + +type Props = { + md: string +}; + +export default class Page extends React.PureComponent { + render () { + return ( +
+ +
+ ); + } +} diff --git a/pioneer/packages/joy-pages/src/index.css b/pioneer/packages/joy-pages/src/index.css new file mode 100644 index 0000000000..b0fa7e8952 --- /dev/null +++ b/pioneer/packages/joy-pages/src/index.css @@ -0,0 +1,4 @@ +.JoyPage { + margin: 2rem 0; + max-width: 900px; +} diff --git a/pioneer/packages/joy-pages/src/index.tsx b/pioneer/packages/joy-pages/src/index.tsx new file mode 100644 index 0000000000..b87da81525 --- /dev/null +++ b/pioneer/packages/joy-pages/src/index.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Page from './Page'; + +import ToS_md from './md/ToS.md'; +export function ToS () { + return ; +} + +import Privacy_md from './md/Privacy.md'; +export function Privacy () { + return ; +} diff --git a/pioneer/packages/joy-pages/src/md/Privacy.md b/pioneer/packages/joy-pages/src/md/Privacy.md new file mode 100644 index 0000000000..9b262c970a --- /dev/null +++ b/pioneer/packages/joy-pages/src/md/Privacy.md @@ -0,0 +1,79 @@ +# Privacy and Cookies +**Last updated on the 17th of April 2019** + +Jsgenesis values your privacy. + +This Privacy Policy ("Privacy Policy") and Cookie Policy ("Cookie Policy") explains how Jsgenesis AS ("Jsgenesis", "Company", "We", "Us", "Our") collect and use data and information when you ("User) use on or any of the Joystream products, developed in the GitHub organization [Joystream](https://github.com/JoyStream). These products (collectively "Software") include, but are not limited to, [all pages under the joystream.org domain](https://www.joystream.org/) ("Website"), the [Joyful node](https://github.com/Joystream/substrate-node-joystream) ("Full Node"), the [Colossus Storage Node](https://github.com/Joystream/storage-node-joystream) ("Storage node"), and the Pioneer User Interface, either [self hosted](https://github.com/Joystream/apps) or [hosted by Us](http://testnet.joystream.org/) ("App"). + +Relevant to the Privacy Policy and Cookie Policy are the following terms: +* The term "Blockchain" refers to the blockchain(s) assembled by the Full Node. +* The term "Content" refers to media files accessible through our Software. +* The term "Keys" refers to a private/public cryptographic keypair, that Users can generate in order to write (and decrypt data) on the Blockchain. +* The term "Membership" refers to tying your Keys to a public profile, allowing users to access Content and interact with the Blockchain. +* The term "Memo" refers to a markdown enabled text field, where users can input data tied to their Keys. + + +# Privacy Policy +**Last updated on the 19th of May 2020** + +## 1. Agreement to the Policy +By using any of Our Software, the User are accepting this Privacy Policy. If you are acting on behalf of another company or an employer, you must have the rights to act on their behalf. The Privacy Policy is not extended to any of our newsletters, where Users are bound by the [privacy policy](https://mailchimp.com/legal/privacy/) of [Mailchimp](https://mailchimp.com/). + +The Privacy Policy does not apply to any other third party services including, but not limited to, applications, websites, tools or software, even if accessible through links or guides in our Software. + +## 2. Changes to Policy +This Privacy Policy may be changed at the sole discretion of Company. If any material changes are made, the User will be notified in the Service that is used. Note that adding new products to be included in the term Software , e.g. a new User facing product replacing the App or a new tool for uploading Content, is not considered material as it will not affect Users unless they adopt the new product. Changing softare names, terminology used in this Privacy Policy, and changin link locations are aslo examples of non-material changes. + +## 3. Information Collected +All data written to the Blockchain, is implicitly collected not only by Company, but also anyone else in the world that is running the Full Node locally, or accessed via the App or a third party. +This includes, but is not limited to, Content hashes, Membership profile, Memo field, and any other way a User can record data on the Blockchain. + +Company uses [Google Analytics](https://marketingplatform.google.com/about/analytics/), with IP anonymization, to collect statistics on Website and the version of App hosted by us. All customizable data sharing settings are turned off to improve the privacy of Users. + +Company will not sell your data for advertising, or other purposes. + + +# Cookie Policy +**Last updated on the 17th of April 2019** + +Company uses cookies on Website and App when hosted by Us (collectively "Service"). By using the Service, you consent to the use of cookies. + +Our Cookies Policy explains what cookies are, how we use cookies, how third-parties we partner with may use cookies on the Service, your choices regarding cookies and further information about cookies. + +## 1. What are Cookies? + +Cookies are small pieces of text sent by your web browser by a website you visit. A cookie file is stored in your web browser and allows the Service or a third-party to recognize you and make your next visit easier and the Service more useful to you. + +Cookies can be *persistent* or *session* cookies. + +## 2. How we use Cookies + +We use cookies for the following purposes our Service: + +* Provide Analytics +* Store preferences +* Persistant local storage of Keys and Membership. + +## 3. Third-party Cookies + +In addition to our own cookies, we also use various third-party cookies to report usage statistics of the Service, deliver advertisements on and through the Service, and so on. They include: + +* Google Analytics +* Mailchimp (Only when signing up for any of our newsletters) +* Godaddy + +Please see Item 3. of the Privacy Policy for more information on the extent of these providers. + +## Your Regarding Cookies + +If you would like to delete cookies or instruct your web browser to delete or refuse cookies, please visit the help pages of your web browser. + +Please note, however, that if you delete cookies or refuse to accept them, you might not be able to use all of the features we offer, you may not be able to store your preferences, and some of our pages might not display properly. + + +MORE INFORMATION About Cookies + +You can learn more about cookies and the following third-party websites: + +* [AllAboutCookies](http://www.allaboutcookies.org/) +* [Network Advertising Initiative](http://www.networkadvertising.org/) diff --git a/pioneer/packages/joy-pages/src/md/ToS.md b/pioneer/packages/joy-pages/src/md/ToS.md new file mode 100644 index 0000000000..b5084448dd --- /dev/null +++ b/pioneer/packages/joy-pages/src/md/ToS.md @@ -0,0 +1,40 @@ +# Terms of Service +**Last updated on the 17th of April 2019** + +The Terms of Service ("Agreement") is a binding obligation between you ("User") and Jsgenesis AS ("Company", "We", "Us", "Our") for use of our Products. These products (collectively "Software") include [all pages under the joystream.org domain](https://www.joystream.org/) ("Website"), the [Joyful node](https://github.com/Joystream/substrate-node-joystream) ("Full node"), the [Colossus Storage Node](https://github.com/Joystream/storage-node-joystream) ("Storage node"), and the Pioneer User Interface, either [self hosted ](https://github.com/Joystream/apps) or [hosted by Us](http://testnet.joystream.org/) ("App"). + +## 1. Agreement to Terms +By using this Software, the User are agreeing to be bound by this Agreement. If you are acting on behalf of another company or an employer, you must have the rights to act on their behalf. + +## 2. Changes to Terms +This Agreement may be changed at the sole discretion of Company without notice. Your continued use of our Software is a confirmation of Users acceptance of the newest Agreement. + +## 3. Privacy Policy +Please see our [privacy policy](https://joystream.org/privacy-cookies) ("Privacy Policy") for information regarding privacy. + +## 4. Membership +By generating private/public cryptographic keys ("Keys") or applying for a membership account ("Membershp"), you accept the risk of losing access to your Keys and Membership. Reasons include, but is not limited to: + 1. Losing passwords + 2. Losing recovery seeds or mnemonics + 3. Deleting accounts and backups + 4. Security breaches + +Under no circumstance will Company take any responsiblity for loss resulting of losing access to Membership or Keys. + +## 5. User Conduct + +By using any of Our Software, you agree to not state, write, link to, download, distribute, share or encourage other users to state, write, link to, download, distribute, share or encourage anything that: +1. breach or infringe any copyright or intellectual property of any third party. +2. is abusive, malicious, threatening or unlawful in any way. + +Company has not reviewed all content of this website, and is not responsible for content submitted or provided by individuals or groups not directly tied to them. + +## 6. Responsibilites and Risks + +In no event shall Company, its contractors, employees or owners be liable for any damage or loss of any kind to User arising out of the use or inability to use any Software made by Company. + +In no event shall Company, its contractors, employees or owners be liable for any damage or loss of any kind to User resulting of clicking links, following guides, using software or doing anything else recommended by Company. + +## 7. Governing Law + +These terms and conditions are governed by and construed in accordance with the laws of Norway. \ No newline at end of file diff --git a/pioneer/packages/joy-pages/src/translate.ts b/pioneer/packages/joy-pages/src/translate.ts new file mode 100644 index 0000000000..bf3239537b --- /dev/null +++ b/pioneer/packages/joy-pages/src/translate.ts @@ -0,0 +1,3 @@ +import { withTranslation } from 'react-i18next'; + +export default withTranslation(['pages', 'ui']); diff --git a/pioneer/packages/joy-proposals/README.md b/pioneer/packages/joy-proposals/README.md new file mode 100644 index 0000000000..c29c707284 --- /dev/null +++ b/pioneer/packages/joy-proposals/README.md @@ -0,0 +1 @@ +# Proposals module for Joystream node diff --git a/pioneer/packages/joy-proposals/package.json b/pioneer/packages/joy-proposals/package.json new file mode 100644 index 0000000000..145902f003 --- /dev/null +++ b/pioneer/packages/joy-proposals/package.json @@ -0,0 +1,16 @@ +{ + "name": "@polkadot/joy-proposals", + "version": "0.1.1", + "description": "Proposals module for Joystream node", + "main": "index.js", + "scripts": {}, + "author": "Joystream contributors", + "maintainers": [], + "dependencies": { + "@babel/runtime": "^7.7.1", + "@polkadot/joy-utils": "^0.1.1", + "@polkadot/react-components": "0.37.0-beta.63", + "@polkadot/react-query": "0.37.0-beta.63", + "react-dropzone": "^10.2.2" + } +} diff --git a/pioneer/packages/joy-proposals/src/NotDone.tsx b/pioneer/packages/joy-proposals/src/NotDone.tsx new file mode 100644 index 0000000000..8800f192cf --- /dev/null +++ b/pioneer/packages/joy-proposals/src/NotDone.tsx @@ -0,0 +1,11 @@ +import React from "react"; + +export default function NotDone(props: any) { + return ( + <> +

This is not implemented yet :(

+
however, here is your props.
+ {JSON.stringify(props)} + + ); +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/Body.tsx b/pioneer/packages/joy-proposals/src/Proposal/Body.tsx new file mode 100644 index 0000000000..10807d7070 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/Body.tsx @@ -0,0 +1,201 @@ +import React from "react"; +import { Card, Header, Button, Icon, Message } from "semantic-ui-react"; +import { ProposalType } from "../runtime/transport"; +import { blake2AsHex } from '@polkadot/util-crypto'; +import styled from 'styled-components'; +import AddressMini from '@polkadot/react-components/AddressMiniJoy'; +import TxButton from '@polkadot/joy-utils/TxButton'; +import { ProposalId } from "@joystream/types/proposals"; +import { MemberId } from "@joystream/types/members"; +import ProfilePreview from "@polkadot/joy-utils/MemberProfilePreview"; +import { useTransport } from "../runtime"; +import { usePromise } from "../utils"; +import { Profile } from "@joystream/types/members"; +import { Option } from "@polkadot/types/"; +import { formatBalance } from "@polkadot/util"; +import PromiseComponent from "./PromiseComponent"; + +type BodyProps = { + title: string; + description: string; + params: any[]; + type: ProposalType; + iAmProposer: boolean; + proposalId: number | ProposalId; + proposerId: number | MemberId; + isCancellable: boolean; + cancellationFee: number; +}; + +function ProposedAddress(props: { address?: string | null }) { + if (props.address === null || props.address === undefined) { + return <>NONE; + } + + return ( + + ); +} + +function ProposedMember(props: { memberId?: MemberId | number | null }) { + if (props.memberId === null || props.memberId === undefined) { + return <>NONE; + } + const memberId: MemberId | number = props.memberId; + + const transport = useTransport(); + const [ member, error, loading ] = usePromise | null>( + () => transport.memberProfile(memberId), + null + ); + + const profile = member && member.unwrapOr(null); + + return ( + + { profile ? ( + + ) : 'Profile not found' } + + ); +} + +// The methods for parsing params by Proposal type. +// They take the params as array and return { LABEL: VALUE } object. +const paramParsers: { [x in ProposalType]: (params: any[]) => { [key: string]: string | number | JSX.Element } } = { + Text: ([content]) => ({ + Content: content + }), + RuntimeUpgrade: ([wasm]) => { + const buffer: Buffer = Buffer.from(wasm.replace("0x", ""), "hex"); + return { + "Blake2b256 hash of WASM code": blake2AsHex(buffer, 256), + "File size": buffer.length + " bytes" + }; + }, + SetElectionParameters: ([params]) => ({ + "Announcing period": params.announcing_period + " blocks", + "Voting period": params.voting_period + " blocks", + "Revealing period": params.revealing_period + " blocks", + "Council size": params.council_size + " members", + "Candidacy limit": params.candidacy_limit + " members", + "New term duration": params.new_term_duration + " blocks", + "Min. council stake": formatBalance(params.min_council_stake), + "Min. voting stake": formatBalance(params.min_voting_stake) + }), + Spending: ([amount, account]) => ({ + Amount: formatBalance(amount), + Account: + }), + SetLead: ([memberId, accountId]) => ({ + "Member": , + "Account id": + }), + SetContentWorkingGroupMintCapacity: ([capacity]) => ({ + "Mint capacity": formatBalance(capacity) + }), + EvictStorageProvider: ([accountId]) => ({ + "Storage provider account": + }), + SetValidatorCount: ([count]) => ({ + "Validator count": count + }), + SetStorageRoleParameters: ([params]) => ({ + "Min. stake": formatBalance(params.min_stake), + // "Min. actors": params.min_actors, + "Max. actors": params.max_actors, + Reward: formatBalance(params.reward), + "Reward period": params.reward_period + " blocks", + // "Bonding period": params.bonding_period + " blocks", + "Unbonding period": params.unbonding_period + " blocks", + // "Min. service period": params.min_service_period + " blocks", + // "Startup grace period": params.startup_grace_period + " blocks", + "Entry request fee": formatBalance(params.entry_request_fee) + }) +}; + +const ProposalParams = styled.div` + display: grid; + font-weight: bold; + grid-template-columns: min-content 1fr; + grid-row-gap: 0.5rem; + @media screen and (max-width: 767px) { + grid-template-columns: 1fr; + } +`; +const ProposalParamName = styled.div` + margin-right: 1rem; + white-space: nowrap; +`; +const ProposalParamValue = styled.div` + color: black; + word-wrap: break-word; + word-break: break-all; + @media screen and (max-width: 767px) { + margin-top: -0.25rem; + } +`; + +export default function Body({ + type, + title, + description, + params = [], + iAmProposer, + proposalId, + proposerId, + isCancellable, + cancellationFee +}: BodyProps) { + const parseParams = paramParsers[type]; + const parsedParams = parseParams(params); + return ( + + + +
{title}
+
+ {description} +
Parameters:
+ + { Object.entries(parsedParams).map(([paramName, paramValue]) => ( + + {paramName}: + {paramValue} + + ))} + + { iAmProposer && isCancellable && (<> + + + Proposal cancellation +

+ You can only cancel your proposal while it's still in the Voting Period. +

+

+ The cancellation fee for this type of proposal is:  + { cancellationFee ? formatBalance(cancellationFee) : 'NONE' } +

+ + { sendTx(); } } + className={'icon left labeled'} + > + + Withdraw proposal + + +
+
+ ) } +
+
+ ); +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/ChooseProposalType.css b/pioneer/packages/joy-proposals/src/Proposal/ChooseProposalType.css new file mode 100644 index 0000000000..424821bb01 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/ChooseProposalType.css @@ -0,0 +1,6 @@ +.ChooseProposalType .filters { + text-align: right; +} +.ChooseProposalType .filters .dropdown { + width: 200px; +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/ChooseProposalType.tsx b/pioneer/packages/joy-proposals/src/Proposal/ChooseProposalType.tsx new file mode 100644 index 0000000000..4a5d30d2ed --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/ChooseProposalType.tsx @@ -0,0 +1,56 @@ +import React, { useState } from "react"; +import ProposalTypePreview from "./ProposalTypePreview"; +import { Item, Dropdown } from "semantic-ui-react"; + +import { useTransport } from "../runtime"; +import { usePromise } from "../utils"; +import Error from "./Error"; +import Loading from "./Loading"; +import "./ChooseProposalType.css"; +import { RouteComponentProps } from "react-router-dom"; + +export const Categories = { + storage: "Storage", + council: "Council", + validators: "Validators", + cwg: "Content Working Group", + other: "Other" +} as const; + +export type Category = typeof Categories[keyof typeof Categories]; + +export default function ChooseProposalType(props: RouteComponentProps) { + const transport = useTransport(); + + const [proposalTypes, error, loading] = usePromise(() => transport.proposalsTypesParameters(), []); + const [category, setCategory] = useState(""); + + if (loading && !error) { + return ; + } else if (error || proposalTypes == null) { + return ; + } + + console.log({ proposalTypes, loading, error }); + return ( +
+
+ ({ value: category, text: category }))} + value={category} + onChange={(e, data) => setCategory((data.value || "").toString())} + clearable + selection + /> +
+ + {proposalTypes + .filter(typeInfo => !category || typeInfo.category === category) + .map((typeInfo, idx) => ( + + ))} + +
+ ); +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/Details.tsx b/pioneer/packages/joy-proposals/src/Proposal/Details.tsx new file mode 100644 index 0000000000..30838a2c35 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/Details.tsx @@ -0,0 +1,71 @@ +import React from "react"; +import { Item, Header } from "semantic-ui-react"; +import { ParsedProposal } from "../runtime/transport"; +import { ExtendedProposalStatus } from "./ProposalDetails"; +import styled from 'styled-components'; + +import ProfilePreview from "@polkadot/joy-utils/MemberProfilePreview"; + +const BlockInfo = styled.div` + font-size: 0.9em; +`; + +type DetailProps = { + name: string, + value?: string +}; + +const Detail: React.FunctionComponent = ({name, value, children}) => ( + + + { name }: + { value &&
{value}
} + { children } +
+
+); + +type DetailsProps = { + proposal: ParsedProposal; + extendedStatus: ExtendedProposalStatus; + proposerLink?: boolean; +}; + +export default function Details({ proposal, extendedStatus, proposerLink = false }: DetailsProps) { + const { type, createdAt, createdAtBlock, proposer } = proposal; + const { displayStatus, periodStatus, expiresIn, finalizedAtBlock, executedAtBlock, executionFailReason } = extendedStatus; + console.log(proposal); + return ( + + + + { `${ createdAt.toLocaleString() }` } + + + + + { createdAtBlock && Created at block #{ createdAtBlock } } + { finalizedAtBlock && Finalized at block #{ finalizedAtBlock } } + { executedAtBlock && ( + + { displayStatus === "ExecutionFailed" ? 'Execution failed at' : 'Executed at' } block + #{ executedAtBlock } + + ) } + + + { (periodStatus !== null) && } + {expiresIn !== null && ( + + ) } + {executionFailReason && } + + ); +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/Error.tsx b/pioneer/packages/joy-proposals/src/Proposal/Error.tsx new file mode 100644 index 0000000000..11e2e7c667 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/Error.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { Container, Message } from "semantic-ui-react"; + +type ErrorProps = { + error: any; +}; +export default function Error({ error }: ErrorProps) { + console.error(error); + return ( + + + Oops! We got an error! +

{error.message}

+
+
+ ); +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/Loading.tsx b/pioneer/packages/joy-proposals/src/Proposal/Loading.tsx new file mode 100644 index 0000000000..96441923f9 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/Loading.tsx @@ -0,0 +1,14 @@ +import React from "react"; +import { Loader, Container } from "semantic-ui-react"; + +type LoadingProps = { + text: string; +}; + +export default function Loading({ text }: LoadingProps) { + return ( + + {text} + + ); +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/PromiseComponent.tsx b/pioneer/packages/joy-proposals/src/Proposal/PromiseComponent.tsx new file mode 100644 index 0000000000..a88926ede5 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/PromiseComponent.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Loading from "./Loading"; +import Error from "./Error"; + +type PromiseComponentProps = { + loading: boolean, + error: any, + message: string, +} +const PromiseComponent: React.FunctionComponent = ({ loading, error, message, children }) => { + if (loading && !error) { + return ; + } else if (error) { + return ; + } + + return <>{ children }; +} + +export default PromiseComponent; diff --git a/pioneer/packages/joy-proposals/src/Proposal/Proposal.css b/pioneer/packages/joy-proposals/src/Proposal/Proposal.css new file mode 100644 index 0000000000..0388a037a2 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/Proposal.css @@ -0,0 +1,56 @@ +.Proposal { + position: relative; + + .description { + word-wrap: break-word; + word-break: break-all; + } + + /* Ovverrides Semantic UI for the details page.*/ + .ui.items > .item:first-child { + margin: 1em 0; + } + + .details-container { + display: grid; + grid-template-columns: repeat(5, auto); + } + + .details-container .item .extra { + margin-bottom: 0.5em !important; + } + + .ui.items > .item .extra.proposed-by { + /* This is to ensure Proposed By: is above the name of the creator. The image is 50x50 and has 14pxs of margin right*/ + padding-left: 64px; + } + + .center-content { + justify-content: center; + } + + .bold { + font-weight: 700; + } + + .details-param { + display: flex; + } + + .ui.tabular.list-menu { + margin-bottom: 2rem; + } + + @media screen and (max-width: 767px) { + .details-container { + grid-template-columns: repeat(2, auto); + grid-template-rows: repeat(3, auto); + } + .details-container .item:first-child { + grid-column: 1/3; + } + .details-container .item { + margin: 0.5em 0 !important; + } + } +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/ProposalDetails.tsx b/pioneer/packages/joy-proposals/src/Proposal/ProposalDetails.tsx new file mode 100644 index 0000000000..2829144915 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/ProposalDetails.tsx @@ -0,0 +1,147 @@ +import React from "react"; + +import { Container } from "semantic-ui-react"; +import Details from "./Details"; +import Body from "./Body"; +import VotingSection from "./VotingSection"; +import Votes from "./Votes"; +import { MyAccountProps, withMyAccount } from "@polkadot/joy-utils/MyAccount" +import { ParsedProposal, ProposalVote } from "../runtime"; +import { withCalls } from '@polkadot/react-api'; +import { withMulti } from '@polkadot/react-api/with'; + +import "./Proposal.css"; +import { ProposalId, ProposalDecisionStatuses, ApprovedProposalStatuses, ExecutionFailedStatus } from "@joystream/types/proposals"; +import { BlockNumber } from '@polkadot/types/interfaces' +import { MemberId } from "@joystream/types/members"; +import { Seat } from "@joystream/types/"; +import PromiseComponent from './PromiseComponent'; + +type BasicProposalStatus = 'Active' | 'Finalized'; +type ProposalPeriodStatus = 'Voting period' | 'Grace period'; +type ProposalDisplayStatus = BasicProposalStatus | ProposalDecisionStatuses | ApprovedProposalStatuses; + +export type ExtendedProposalStatus = { + displayStatus: ProposalDisplayStatus, + periodStatus: ProposalPeriodStatus | null, + expiresIn: number | null, + finalizedAtBlock: number | null, + executedAtBlock: number | null, + executionFailReason: string | null +} + +export function getExtendedStatus(proposal: ParsedProposal, bestNumber: BlockNumber | undefined): ExtendedProposalStatus { + const basicStatus = Object.keys(proposal.status)[0] as BasicProposalStatus; + let expiresIn: number | null = null; + + let displayStatus: ProposalDisplayStatus = basicStatus; + let periodStatus: ProposalPeriodStatus | null = null; + let finalizedAtBlock: number | null = null; + let executedAtBlock: number | null = null; + let executionFailReason: string | null = null; + + let best = bestNumber ? bestNumber.toNumber() : 0; + + const { votingPeriod, gracePeriod } = proposal.parameters; + const blockAge = best - proposal.createdAtBlock; + + if (basicStatus === 'Active') { + periodStatus = 'Voting period'; + expiresIn = Math.max(votingPeriod - blockAge, 0) || null; + } + + if (basicStatus === 'Finalized') { + const { finalizedAt, proposalStatus } = proposal.status['Finalized']; + const decisionStatus: ProposalDecisionStatuses = Object.keys(proposalStatus)[0] as ProposalDecisionStatuses; + displayStatus = decisionStatus; + finalizedAtBlock = finalizedAt as number; + if (decisionStatus === 'Approved') { + const approvedStatus: ApprovedProposalStatuses = Object.keys(proposalStatus["Approved"])[0] as ApprovedProposalStatuses; + if (approvedStatus === 'PendingExecution') { + const finalizedAge = best - finalizedAt; + periodStatus = 'Grace period'; + expiresIn = Math.max(gracePeriod - finalizedAge, 0) || null; + } + else { + // Executed / ExecutionFailed + displayStatus = approvedStatus; + executedAtBlock = finalizedAtBlock + gracePeriod; + if (approvedStatus === 'ExecutionFailed') { + const executionFailedStatus = proposalStatus.Approved.ExecutionFailed as ExecutionFailedStatus; + executionFailReason = new Buffer(executionFailedStatus.error.toString().replace('0x', ''), 'hex').toString(); + } + } + } + } + + return { + displayStatus, + periodStatus, + expiresIn: best ? expiresIn : null, + finalizedAtBlock, + executedAtBlock, + executionFailReason + } +} + + +type ProposalDetailsProps = MyAccountProps & { + proposal: ParsedProposal, + proposalId: ProposalId, + votesListState: { data: ProposalVote[], error: any, loading: boolean }, + bestNumber?: BlockNumber, + council?: Seat[] +}; + +function ProposalDetails({ + proposal, + proposalId, + myAddress, + myMemberId, + iAmMember, + council, + bestNumber, + votesListState +}: ProposalDetailsProps) { + const iAmCouncilMember = Boolean(iAmMember && council && council.some(seat => seat.member.toString() === myAddress)); + const iAmProposer = Boolean(iAmMember && myMemberId !== undefined && proposal.proposerId === myMemberId.toNumber()); + const extendedStatus = getExtendedStatus(proposal, bestNumber); + const isVotingPeriod = extendedStatus.periodStatus === 'Voting period'; + return ( + +
+ + { iAmCouncilMember && ( + + ) } + + + + + ); +} + +export default withMulti( + ProposalDetails, + withMyAccount, + withCalls( + ['derive.chain.bestNumber', { propName: 'bestNumber' }], + ['query.council.activeCouncil', { propName: 'council' }], // TODO: Handle via transport? + ) +); diff --git a/pioneer/packages/joy-proposals/src/Proposal/ProposalFromId.tsx b/pioneer/packages/joy-proposals/src/Proposal/ProposalFromId.tsx new file mode 100644 index 0000000000..bce4ecac5b --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/ProposalFromId.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { RouteComponentProps } from "react-router-dom"; +import ProposalDetails from "./ProposalDetails"; +import { useProposalSubscription } from "../utils"; +import Error from "./Error"; +import Loading from "./Loading"; + + +export default function ProposalFromId(props: RouteComponentProps) { + const { + match: { + params: { id } + } + } = props; + + const { proposal: proposalState, votes: votesState } = useProposalSubscription(id); + + if (proposalState.loading && !proposalState.error) { + return ; + } else if (proposalState.error) { + return ; + } + + return ; +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/ProposalPreview.tsx b/pioneer/packages/joy-proposals/src/Proposal/ProposalPreview.tsx new file mode 100644 index 0000000000..aefa381e1c --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/ProposalPreview.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Header, Card } from "semantic-ui-react"; +import Details from "./Details"; +import { ParsedProposal } from "../runtime/transport"; +import { getExtendedStatus } from "./ProposalDetails"; +import { BlockNumber } from '@polkadot/types/interfaces'; +import styled from 'styled-components'; + +import "./Proposal.css"; + +const ProposalIdBox = styled.div` + position: absolute; + top: 0; + right: 0; + padding: 1rem; + color: rgba(0,0,0,0.4); + font-size: 1.1em; +`; + +export type ProposalPreviewProps = { + proposal: ParsedProposal, + bestNumber?: BlockNumber +}; +export default function ProposalPreview({ proposal, bestNumber }: ProposalPreviewProps) { + const extendedStatus = getExtendedStatus(proposal, bestNumber); + return ( + + { `#${proposal.id.toString()}` } + + +
{proposal.title}
+
+ {proposal.description} +
+ + + ); +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/ProposalPreviewList.tsx b/pioneer/packages/joy-proposals/src/Proposal/ProposalPreviewList.tsx new file mode 100644 index 0000000000..590ab0de54 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/ProposalPreviewList.tsx @@ -0,0 +1,90 @@ +import React, { useState } from "react"; +import { Card, Container, Menu } from "semantic-ui-react"; + +import ProposalPreview from "./ProposalPreview"; +import { useTransport, ParsedProposal } from "../runtime"; +import { usePromise } from "../utils"; +import PromiseComponent from './PromiseComponent'; +import { withCalls } from "@polkadot/react-api"; +import { BlockNumber } from "@polkadot/types/interfaces"; + +const filters = ["All", "Active", "Canceled", "Approved", "Rejected", "Slashed", "Expired"] as const; + +type ProposalFilter = typeof filters[number]; + +function filterProposals(filter: ProposalFilter, proposals: ParsedProposal[]) { + if (filter === "All") { + return proposals; + } else if (filter === "Active") { + return proposals.filter((prop: ParsedProposal) => { + const [activeOrFinalized] = Object.keys(prop.status); + return activeOrFinalized === "Active"; + }); + } + + return proposals.filter((prop: ParsedProposal) => { + if (prop.status.Finalized == null || prop.status.Finalized.proposalStatus == null) { + return false; + } + + const [finalStatus] = Object.keys(prop.status.Finalized.proposalStatus); + return finalStatus === filter; + }); +} + +function mapFromProposals(proposals: ParsedProposal[]) { + const proposalsMap = new Map(); + + proposalsMap.set("All", proposals); + proposalsMap.set("Canceled", filterProposals("Canceled", proposals)); + proposalsMap.set("Active", filterProposals("Active", proposals)); + proposalsMap.set("Approved", filterProposals("Approved", proposals)); + proposalsMap.set("Rejected", filterProposals("Rejected", proposals)); + proposalsMap.set("Slashed", filterProposals("Slashed", proposals)); + proposalsMap.set("Expired", filterProposals("Expired", proposals)); + + return proposalsMap; +} + +type ProposalPreviewListProps = { + bestNumber?: BlockNumber; +}; + +function ProposalPreviewList({ bestNumber }: ProposalPreviewListProps) { + const transport = useTransport(); + const [proposals, error, loading] = usePromise(() => transport.proposals(), []); + const [activeFilter, setActiveFilter] = useState("All"); + + const proposalsMap = mapFromProposals(proposals); + const filteredProposals = proposalsMap.get(activeFilter) as ParsedProposal[]; + + return ( + + + + {filters.map((filter, idx) => ( + setActiveFilter(filter)} + /> + ))} + + { + filteredProposals.length ? ( + + {filteredProposals.map((prop: ParsedProposal, idx: number) => ( + + ))} + + ) : `There are currently no ${ activeFilter !== 'All' ? activeFilter.toLocaleLowerCase() : 'submitted' } proposals.` + } + + + ); +} + +export default withCalls(["derive.chain.bestNumber", { propName: "bestNumber" }])( + ProposalPreviewList +); diff --git a/pioneer/packages/joy-proposals/src/Proposal/ProposalType.css b/pioneer/packages/joy-proposals/src/Proposal/ProposalType.css new file mode 100644 index 0000000000..764535e812 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/ProposalType.css @@ -0,0 +1,48 @@ +.ProposalType { + background: #fff !important; + box-shadow: 0 1px 3px 0 #d4d4d5, 0 0 0 1px #d4d4d5 !important; + padding: 1em !important; + border-radius: 0.3em !important; +} +.ProposalType .header { + font-size: 1.5em !important; + line-height: 1; +} +.ProposalType .description-text { + flex-grow: 1; +} +.ProposalType .actions { + margin: 0 2em; + padding-top: 1em; +} +.ProposalType .proposal-details { + display: flex; + margin: 0 -2em; +} +.ProposalType .proposal-detail { + margin: 1em 2em; +} +.ProposalType .detail-value { + font-size: 1.2em; + font-weight: 700; +} + +@media only screen and (max-width: 1199px) { + .ProposalType .proposal-details { + flex-direction: column; + margin: 1em 0; + } + .ProposalType .proposal-detail { + display: flex; + justify-content: space-between; + margin: .5em 0; + } +} + +@media only screen and (max-width: 767px) { + .ProposalType .actions { + padding: 0; + margin: 0; + text-align: right; + } +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/ProposalTypePreview.tsx b/pioneer/packages/joy-proposals/src/Proposal/ProposalTypePreview.tsx new file mode 100644 index 0000000000..f904ed923c --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/ProposalTypePreview.tsx @@ -0,0 +1,152 @@ +import React from "react"; + +import { History } from "history"; +import { Item, Icon, Button, Label } from "semantic-ui-react"; + +import { Category } from "./ChooseProposalType"; +import { ProposalType } from "../runtime"; +import { slugify, splitOnUpperCase } from "../utils"; +import styled from 'styled-components'; +import useVoteStyles from './useVoteStyles'; +import { formatBalance } from "@polkadot/util"; + +import "./ProposalType.css"; + +const QuorumsAndThresholds = styled.div` + display: grid; + grid-template-columns: min-content min-content; + grid-template-rows: auto auto; + grid-row-gap: 0.5rem; + grid-column-gap: 0.5rem; + margin-bottom: 1rem; + @media screen and (max-width: 480px) { + grid-template-columns: min-content; + } +`; + +const QuorumThresholdLabel = styled(Label)` + opacity: 0.75; + white-space: nowrap; + margin: 0 !important; + display: flex !important; + align-items: center; + & b { + font-size: 1.2em; + margin-left: auto; + padding-left: 0.3rem; + } +`; + +const CreateButton = styled(Button)` + font-size: 1.1em !important; + white-space: nowrap; + margin-right: 0; +`; + +export type ProposalTypeInfo = { + type: ProposalType; + category: Category; + image: string; + description: string; + stake: number; + cancellationFee?: number; + gracePeriod: number; + votingPeriod: number; + approvalQuorum: number; + approvalThreshold: number; + slashingQuorum: number; + slashingThreshold: number; +}; + +type ProposalTypePreviewProps = { + typeInfo: ProposalTypeInfo; + history: History; +}; + +const ProposalTypeDetail = (props: { title: string, value: string }) => ( +
+
{ `${props.title}:` }
+
{ props.value }
+
+); + +export default function ProposalTypePreview(props: ProposalTypePreviewProps) { + const { + typeInfo: { + type, + description, + stake, + cancellationFee, + gracePeriod, + votingPeriod, + approvalQuorum, + approvalThreshold, + slashingQuorum, + slashingThreshold + } + } = props; + + const handleClick = () => { + if (!props.history) return; + props.history.push(`/proposals/new/${slugify(type)}`); + }; + + return ( + + {/* + TODO: We can add it once we have the actual assets + + */} + + {splitOnUpperCase(type).join(" ")} + {description} +
+ + + 1 ? "s" : ""}` : "NONE" } /> + 1 ? "s" : ""}` : "NONE" } /> +
+ + { approvalQuorum && ( + + + Approval Quorum: { approvalQuorum }% + + ) } + { approvalThreshold && ( + + + Approval Threshold: { approvalThreshold }% + + ) } + { slashingQuorum && ( + + + Slashing Quorum: { slashingQuorum }% + + ) } + { slashingThreshold && ( + + + Slashing Threshold: { slashingThreshold }% + + ) } + +
+
+ + Create + + +
+
+ ); +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/Votes.tsx b/pioneer/packages/joy-proposals/src/Proposal/Votes.tsx new file mode 100644 index 0000000000..5ea1fddc46 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/Votes.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { Header, Divider, Table, Icon } from "semantic-ui-react"; +import useVoteStyles from "./useVoteStyles"; +import { ProposalVote } from "../runtime"; +import { VoteKind } from "@joystream/types/proposals"; +import { VoteKindStr } from "./VotingSection"; +import ProfilePreview from "@polkadot/joy-utils/MemberProfilePreview"; + + +type VotesProps = { + votes: ProposalVote[] +}; + +export default function Votes({ votes }: VotesProps) { + const nonEmptyVotes = votes.filter(proposalVote => proposalVote.vote !== null); + + if (!nonEmptyVotes.length) { + return
No votes submitted yet!
; + } + + return ( + <> +
+ All Votes: ({nonEmptyVotes.length} / {votes.length}) +
+ + + + {nonEmptyVotes.map((proposalVote, idx) => { + const { vote, member } = proposalVote; + const voteStr = (vote as VoteKind).type.toString() as VoteKindStr; + const { icon, textColor } = useVoteStyles(voteStr); + return ( + + + + {voteStr} + + + + + + ); + })} + +
+ + ); +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/VotingSection.tsx b/pioneer/packages/joy-proposals/src/Proposal/VotingSection.tsx new file mode 100644 index 0000000000..ead1092193 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/VotingSection.tsx @@ -0,0 +1,100 @@ +import React, { useState } from "react"; + +import { Icon, Button, Message, Divider, Header } from "semantic-ui-react"; +import useVoteStyles from "./useVoteStyles"; +import TxButton from "@polkadot/joy-utils/TxButton"; +import { MemberId } from "@joystream/types/members"; +import { ProposalId } from "@joystream/types/proposals"; +import { useTransport } from "../runtime"; +import { VoteKind } from '@joystream/types/proposals'; +import { usePromise } from "../utils"; +import { VoteKinds } from "@joystream/types/proposals"; + +export type VoteKindStr = typeof VoteKinds[number]; + +type VoteButtonProps = { + memberId: MemberId, + voteKind: VoteKindStr, + proposalId: ProposalId, + onSuccess: () => void +} +function VoteButton({ voteKind, proposalId, memberId, onSuccess }: VoteButtonProps) { + const { icon, color } = useVoteStyles(voteKind); + return ( + // Button.Group "cheat" to force TxButton color + + sendTx() } + txFailedCb={ () => null } + txSuccessCb={ onSuccess } + className={`icon left labeled`}> + + { voteKind } + + + ) +} + +type VotingSectionProps = { + memberId: MemberId, + proposalId: ProposalId, + isVotingPeriod: boolean, +}; + +export default function VotingSection({ + memberId, + proposalId, + isVotingPeriod +}: VotingSectionProps) { + const transport = useTransport(); + const [voted, setVoted] = useState(null); + const [vote] = usePromise( + () => transport.voteByProposalAndMember(proposalId, memberId), + undefined + ); + + if (vote === undefined) { + // Loading / error + return null; + } + + const voteStr: VoteKindStr | null = voted || (vote && vote.type.toString() as VoteKindStr); + + if (voteStr) { + const { icon, color } = useVoteStyles(voteStr); + + return ( + + + + You voted {`"${voteStr}"`} + + + ); + } + else if (!isVotingPeriod) { + return null; + } + + return ( + <> +
Sumbit your vote
+ + { VoteKinds.map((voteKind) => + setVoted(voteKind) }/> + ) } + + ); +} diff --git a/pioneer/packages/joy-proposals/src/Proposal/index.tsx b/pioneer/packages/joy-proposals/src/Proposal/index.tsx new file mode 100644 index 0000000000..122c46779c --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/index.tsx @@ -0,0 +1,5 @@ +export { default as ProposalDetails } from "./ProposalDetails"; +export { default as ProposalPreview } from "./ProposalPreview"; +export { default as ProposalPreviewList } from "./ProposalPreviewList"; +export { default as ProposalFromId } from "./ProposalFromId"; +export { default as ChooseProposalType } from "./ChooseProposalType"; diff --git a/pioneer/packages/joy-proposals/src/Proposal/useVoteStyles.tsx b/pioneer/packages/joy-proposals/src/Proposal/useVoteStyles.tsx new file mode 100644 index 0000000000..2537bd252f --- /dev/null +++ b/pioneer/packages/joy-proposals/src/Proposal/useVoteStyles.tsx @@ -0,0 +1,38 @@ +import { SemanticCOLORS, SemanticICONS } from "semantic-ui-react"; + +export default function useVoteStyles( + value: "Approve" | "Abstain" | "Reject" | "Slash" +): { textColor: string; icon: SemanticICONS; color: SemanticCOLORS } { + let textColor; + let icon: SemanticICONS; + let color: SemanticCOLORS; + + switch (value) { + case "Approve": { + icon = "smile"; + color = "green"; + textColor = "text-green"; + break; + } + case "Abstain": { + icon = "meh"; + color = "grey"; + textColor = "text-grey"; + break; + } + case "Reject": { + icon = "frown"; + color = "orange"; + textColor = "text-orange"; + break; + } + case "Slash": { + icon = "times"; + color = "red"; + textColor = "text-red"; + break; + } + } + + return { textColor, color, icon }; +} diff --git a/pioneer/packages/joy-proposals/src/forms/EvictStorageProviderForm.tsx b/pioneer/packages/joy-proposals/src/forms/EvictStorageProviderForm.tsx new file mode 100644 index 0000000000..b7b69788c1 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/EvictStorageProviderForm.tsx @@ -0,0 +1,89 @@ +import React from "react"; +import { getFormErrorLabelsProps } from "./errorHandling"; +import * as Yup from "yup"; +import { Label, Loader } from "semantic-ui-react"; +import { + GenericProposalForm, + GenericFormValues, + genericFormDefaultOptions, + genericFormDefaultValues, + withProposalFormData, + ProposalFormExportProps, + ProposalFormContainerProps, + ProposalFormInnerProps +} from "./GenericProposalForm"; +import Validation from "../validationSchema"; +import { FormField } from "./FormFields"; +import { withFormContainer } from "./FormContainer"; +import { InputAddress } from "@polkadot/react-components/index"; +import { accountIdsToOptions } from "@polkadot/joy-election/utils"; +import { AccountId } from "@polkadot/types/interfaces"; +import { useTransport } from "../runtime"; +import { usePromise } from "../utils"; +import "./forms.css"; + +type FormValues = GenericFormValues & { + storageProvider: any; +}; + +const defaultValues: FormValues = { + ...genericFormDefaultValues, + storageProvider: "" +}; + +type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form. +type ExportComponentProps = ProposalFormExportProps; +type FormContainerProps = ProposalFormContainerProps; +type FormInnerProps = ProposalFormInnerProps; + +const EvictStorageProviderForm: React.FunctionComponent = props => { + const { errors, touched, values, setFieldValue } = props; + const errorLabelsProps = getFormErrorLabelsProps(errors, touched); + const transport = useTransport(); + const [storageProviders /* error */, , loading] = usePromise(() => transport.storageProviders(), []); + const storageProvidersOptions = accountIdsToOptions(storageProviders); + return ( + + {loading ? ( + <> + Fetching storage providers... + + ) : ( + + setFieldValue("storageProvider", address)} + type="address" + placeholder="Select storage provider" + value={values.storageProvider} + options={storageProvidersOptions} + /> + {errorLabelsProps.storageProvider && + )} + + ); +}; + +const FormContainer = withFormContainer({ + mapPropsToValues: (props: FormContainerProps) => ({ + ...defaultValues, + ...(props.initialData || {}) + }), + validationSchema: Yup.object().shape({ + ...genericFormDefaultOptions.validationSchema, + storageProvider: Validation.EvictStorageProvider.storageProvider + }), + handleSubmit: genericFormDefaultOptions.handleSubmit, + displayName: "EvictStorageProvidersForm" +})(EvictStorageProviderForm); + +export default withProposalFormData(FormContainer); diff --git a/pioneer/packages/joy-proposals/src/forms/FileDropdown.tsx b/pioneer/packages/joy-proposals/src/forms/FileDropdown.tsx new file mode 100644 index 0000000000..f42be341b0 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/FileDropdown.tsx @@ -0,0 +1,138 @@ +import React, { useState } from "react"; +import { FormikProps } from "formik"; +import { Icon, Loader } from "semantic-ui-react"; +import Dropzone from "react-dropzone"; + +enum Status { + Accepted = "accepted", + Rejected = "rejected", + Active = "active", + Parsing = "parsing", + Default = "default" +} + +const determineStatus = ( + acceptedFiles: File[], + rejectedFiles: File[], + error: string | undefined, + isDragActive: boolean, + parsing: boolean +): Status => { + if (parsing) return Status.Parsing; + if (error || rejectedFiles.length) return Status.Rejected; + if (acceptedFiles.length) return Status.Accepted; + if (isDragActive) return Status.Active; + + return Status.Default; +}; + +// Get color by status (imporant to use #FFFFFF format, so we can easily swicth the opacity!) +const getStatusColor = (status: Status): string => { + switch (status) { + case Status.Accepted: + return "#00DBB0"; + case Status.Rejected: + return "#FF3861"; + case Status.Active: + case Status.Parsing: + return "#000000"; + default: + return "#333333"; + } +}; + +const dropdownDivStyle = (status: Status): React.CSSProperties => { + const mainColor = getStatusColor(status); + + return { + cursor: "pointer", + border: `1px solid ${mainColor + "30"}`, + borderRadius: "3px", + padding: "1.5em", + color: mainColor, + fontWeight: "bold", + transition: "color 0.5s, border-color 0.5s" + }; +}; + +const dropdownIconStyle = (): React.CSSProperties => { + return { + marginRight: "0.5em", + opacity: 0.5 + }; +}; + +const innerSpanStyle = (): React.CSSProperties => { + return { + display: "flex", + alignItems: "center" + }; +}; + +// Here we define a way of coverting the file into string for Formik purposes +// This may change depnding on how we decide to actually send the data +const parseFile = async (file: any): Promise => { + const text = await file.text(); + return text; +}; + +type FileDropdownProps = { + error: string | undefined; + name: keyof FormValuesT & string; + setFieldValue: FormikProps["setFieldValue"]; + acceptedFormats: string | string[]; + defaultText: string; +}; + +export default function FileDropdown(props: FileDropdownProps) { + const [parsing, setParsing] = useState(false); + const { error, name, setFieldValue, acceptedFormats, defaultText } = props; + return ( + { + setParsing(true); + const fileAsString: string = await parseFile(acceptedFiles[0]); + setFieldValue(name, fileAsString, true); + setParsing(false); + }} + multiple={false} + accept={acceptedFormats} + > + {({ getRootProps, getInputProps, acceptedFiles, rejectedFiles, isDragActive }) => { + const status = determineStatus(acceptedFiles, rejectedFiles, error, isDragActive, parsing); + return ( +
+
+ + { + + +

+ {status === Status.Parsing && ( + <> + Uploading... + + )} + {status === Status.Rejected && ( + <> + {error || "This is not a correct file!"} +
+ + )} + {status === Status.Accepted && ( + <> + {`Current file: ${acceptedFiles[0].name}`} +
+ + )} + {status !== Status.Parsing && defaultText} +

+
+ } +
+
+ ); + }} +
+ ); +} diff --git a/pioneer/packages/joy-proposals/src/forms/FormContainer.tsx b/pioneer/packages/joy-proposals/src/forms/FormContainer.tsx new file mode 100644 index 0000000000..f327d4e228 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/FormContainer.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { withFormik } from "formik"; + +export function withFormContainer(formikProps: any) { + return function(InnerForm: React.ComponentType) { + return withFormik(formikProps)(function(props) { + const handleBlur = (e: React.FocusEvent, data: any): void => { + if (data && data.name) { + props.setFieldValue(data.name, data.value); + props.setFieldTouched(data.name); + } + }; + const handleChange = (e: React.ChangeEvent, data: any): void => { + if (data && data.name) { + props.setFieldValue(data.name, data.value); + props.setFieldTouched(data.name); + } + }; + + return ; + }); + }; +} diff --git a/pioneer/packages/joy-proposals/src/forms/FormFields.tsx b/pioneer/packages/joy-proposals/src/forms/FormFields.tsx new file mode 100644 index 0000000000..79aa62dff3 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/FormFields.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { Form, FormInputProps, FormTextAreaProps } from "semantic-ui-react"; +import LabelWithHelp from './LabelWithHelp'; + +/* + * Generic form field components + * + * The idea is to provide an easy way of introducing new logic, + * that will affect all of the exsiting form fields (or all fields of given type) + * and to easily switch the structure/display of a typical form field. +*/ + +type InputFormFieldProps = FormInputProps & { + help?: string, + unit?: string +}; + +export function InputFormField(props:InputFormFieldProps) { + const { unit } = props; + const fieldProps = { ...props, label: undefined }; + return ( + + + + { unit &&
{unit}
} +
+
+ ); +} + +type TextareaFormFieldProps = FormTextAreaProps & { + help?: string, +}; + +export function TextareaFormField(props:TextareaFormFieldProps) { + const fieldProps = { ...props, label: undefined }; + return ( + + + + ); +} + +type FormFieldProps = InputFormFieldProps | TextareaFormFieldProps; + +export function FormField(props: React.PropsWithChildren) { + const { error, label, help, children } = props; + return ( + + { (label && help) ? + + : ( label ? : null ) + } + { children } + + ); +} + +export default FormField; diff --git a/pioneer/packages/joy-proposals/src/forms/GenericProposalForm.tsx b/pioneer/packages/joy-proposals/src/forms/GenericProposalForm.tsx new file mode 100644 index 0000000000..3b203752cf --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/GenericProposalForm.tsx @@ -0,0 +1,195 @@ +import React from "react"; +import { FormikProps, WithFormikConfig } from "formik"; +import { Form, Icon, Button, Message } from "semantic-ui-react"; +import { getFormErrorLabelsProps } from "./errorHandling"; +import Validation from "../validationSchema"; +import { InputFormField, TextareaFormField } from "./FormFields"; +import TxButton from "@polkadot/joy-utils/TxButton"; +import { SubmittableResult } from "@polkadot/api"; +import { TxFailedCallback, TxCallback } from "@polkadot/react-components/Status/types"; +import { MyAccountProps, withOnlyMembers } from "@polkadot/joy-utils/MyAccount"; +import { withMulti } from "@polkadot/react-api/with"; +import { withCalls } from "@polkadot/react-api"; +import { CallProps } from "@polkadot/react-api/types"; +import { Balance, Event } from "@polkadot/types/interfaces"; +import { RouteComponentProps } from "react-router"; +import { ProposalType } from "../runtime"; +import { calculateStake } from "../utils"; +import { formatBalance } from "@polkadot/util" +import "./forms.css"; +import { ProposalId } from "@joystream/types/proposals"; + + +// Generic form values +export type GenericFormValues = { + title: string; + rationale: string; +}; + +export const genericFormDefaultValues: GenericFormValues = { + title: "", + rationale: "" +}; + +// Helper generic types for defining form's Export, Container and Inner component prop types +export type ProposalFormExportProps = RouteComponentProps & + + AdditionalPropsT & { + initialData?: Partial; + }; +export type ProposalFormContainerProps = ExportPropsT & + MyAccountProps & + CallProps & { + balances_totalIssuance?: Balance; + }; + +export type ProposalFormInnerProps = ContainerPropsT & FormikProps; + +// Types only used in this file +type GenericProposalFormAdditionalProps = { + txMethod?: string; + submitParams?: any[]; + proposalType?: ProposalType; +}; + +type GenericFormContainerProps = ProposalFormContainerProps< + + ProposalFormExportProps + +>; +type GenericFormInnerProps = ProposalFormInnerProps; +type GenericFormDefaultOptions = WithFormikConfig; + +// Default "withFormik" options that can be extended in specific forms +export const genericFormDefaultOptions: GenericFormDefaultOptions = { + mapPropsToValues: (props: GenericFormContainerProps) => ({ + ...genericFormDefaultValues, + ...(props.initialData || {}) + }), + validationSchema: { + + title: Validation.All.title, + rationale: Validation.All.rationale + + }, + handleSubmit: (values, { setSubmitting, resetForm }) => { + // This is handled via TxButton + } +}; + +// Generic proposal form with basic structure, "Title" and "Rationale" fields +// Other fields can be passed as children +export const GenericProposalForm: React.FunctionComponent = props => { + const { + handleChange, + errors, + isSubmitting, + touched, + handleSubmit, + children, + handleReset, + values, + txMethod, + submitParams, + isValid, + setSubmitting, + history, + balances_totalIssuance, + proposalType + } = props; + const errorLabelsProps = getFormErrorLabelsProps(errors, touched); + + const onSubmit = (sendTx: () => void) => { + if (isValid) sendTx(); + }; + + const onTxFailed: TxFailedCallback = (txResult: SubmittableResult | null) => { + setSubmitting(false); + }; + + const onTxSuccess: TxCallback = (txResult: SubmittableResult) => { + if (!history) return; + // Determine proposal id + let createdProposalId: number | null = null; + for (let e of txResult.events) { + const event = e.get('event') as Event | undefined; + if (event !== undefined && event.method === 'ProposalCreated') { + createdProposalId = (event.data[1] as ProposalId).toNumber(); + break; + } + } + setSubmitting(false); + history.push(`/proposals/${ createdProposalId }`); + }; + + const requiredStake: number | undefined = + balances_totalIssuance && + proposalType && + calculateStake(proposalType, balances_totalIssuance.toNumber()); + + return ( +
+
+ + + {children} + + + + Required stake: { formatBalance(requiredStake) } + + +
+ {txMethod ? ( + (p === "{STAKE}" ? requiredStake : p))} + tx={`proposalsCodex.${txMethod}`} + onClick={onSubmit} + txFailedCb={onTxFailed} + txSuccessCb={onTxSuccess} + /> + ) : ( + + )} + + +
+ +
+ ); +}; + +// Helper that provides additional wrappers for proposal forms + +export function withProposalFormData( + FormContainerComponent: React.ComponentType +): React.ComponentType { + return withMulti(FormContainerComponent, withOnlyMembers, withCalls("query.balances.totalIssuance")); + +} diff --git a/pioneer/packages/joy-proposals/src/forms/LabelWithHelp.tsx b/pioneer/packages/joy-proposals/src/forms/LabelWithHelp.tsx new file mode 100644 index 0000000000..7be7261e8e --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/LabelWithHelp.tsx @@ -0,0 +1,26 @@ +import React, { useState } from "react"; +import { Icon, Label, Transition } from "semantic-ui-react"; + +type LabelWithHelpProps = { text:string, help: string }; + +export default function LabelWithHelp(props: LabelWithHelpProps) { + const [open, setOpen] = useState(false); + return ( + + ); +} diff --git a/pioneer/packages/joy-proposals/src/forms/MintCapacityForm.tsx b/pioneer/packages/joy-proposals/src/forms/MintCapacityForm.tsx new file mode 100644 index 0000000000..83a9301c03 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/MintCapacityForm.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import * as Yup from "yup"; +import { getFormErrorLabelsProps } from "./errorHandling"; +import { + GenericProposalForm, + GenericFormValues, + genericFormDefaultOptions, + genericFormDefaultValues, + withProposalFormData, + ProposalFormExportProps, + ProposalFormContainerProps, + ProposalFormInnerProps +} from "./GenericProposalForm"; +import Validation from "../validationSchema"; +import { InputFormField } from "./FormFields"; +import { withFormContainer } from "./FormContainer"; +import { ProposalType } from "../runtime"; +import { formatBalance } from "@polkadot/util"; +import "./forms.css"; + +type FormValues = GenericFormValues & { + capacity: string; +}; + +const defaultValues: FormValues = { + ...genericFormDefaultValues, + capacity: "" +}; + +type MintCapacityGroup = "Council" | "Content Working Group"; + +// Aditional props coming all the way from export comonent into the inner form. +type FormAdditionalProps = { + mintCapacityGroup: MintCapacityGroup; + txMethod: string; + proposalType: ProposalType; +}; +type ExportComponentProps = ProposalFormExportProps; +type FormContainerProps = ProposalFormContainerProps; +type FormInnerProps = ProposalFormInnerProps; + +const MintCapacityForm: React.FunctionComponent = props => { + const { handleChange, errors, touched, mintCapacityGroup, values, txMethod, initialData, proposalType } = props; + const errorLabelsProps = getFormErrorLabelsProps(errors, touched); + return ( + + + + ); +}; + +const FormContainer = withFormContainer({ + mapPropsToValues: (props: FormContainerProps) => ({ + ...defaultValues, + ...(props.initialData || {}) + }), + validationSchema: Yup.object().shape({ + ...genericFormDefaultOptions.validationSchema, + capacity: Validation.SetContentWorkingGroupMintCapacity.mintCapacity + }), + handleSubmit: genericFormDefaultOptions.handleSubmit, + displayName: "MintCapacityForm" +})(MintCapacityForm); + +export default withProposalFormData(FormContainer); diff --git a/pioneer/packages/joy-proposals/src/forms/RuntimeUpgradeForm.tsx b/pioneer/packages/joy-proposals/src/forms/RuntimeUpgradeForm.tsx new file mode 100644 index 0000000000..a8315ebca1 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/RuntimeUpgradeForm.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { Form } from "semantic-ui-react"; +import * as Yup from "yup"; +import { + GenericProposalForm, + GenericFormValues, + genericFormDefaultOptions, + genericFormDefaultValues, + withProposalFormData, + ProposalFormExportProps, + ProposalFormContainerProps, + ProposalFormInnerProps +} from "./GenericProposalForm"; +import Validation from "../validationSchema"; +import { withFormContainer } from "./FormContainer"; +import "./forms.css"; +import FileDropdown from "./FileDropdown"; + +type FormValues = GenericFormValues & { + WASM: string; +}; + +const defaultValues: FormValues = { + ...genericFormDefaultValues, + WASM: "" +}; + +type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form. +type ExportComponentProps = ProposalFormExportProps; +type FormContainerProps = ProposalFormContainerProps; +type FormInnerProps = ProposalFormInnerProps; + +const RuntimeUpgradeForm: React.FunctionComponent = props => { + const { errors, setFieldValue, values } = props; + return ( + + + + setFieldValue={setFieldValue} + defaultText="Drag-n-drop WASM bytecode of a runtime upgrade (*.wasm)" + acceptedFormats=".wasm" + name="WASM" + error={errors.WASM} + /> + + + ); +}; + +const FormContainer = withFormContainer({ + mapPropsToValues: (props: FormContainerProps) => ({ + ...defaultValues, + ...(props.initialData || {}) + }), + validationSchema: Yup.object().shape({ + ...genericFormDefaultOptions.validationSchema, + WASM: Validation.RuntimeUpgrade.WASM + }), + handleSubmit: genericFormDefaultOptions.handleSubmit, + displayName: "RuntimeUpgradeForm" +})(RuntimeUpgradeForm); + +export default withProposalFormData(FormContainer); diff --git a/pioneer/packages/joy-proposals/src/forms/SetContentWorkingGroupLeadForm.tsx b/pioneer/packages/joy-proposals/src/forms/SetContentWorkingGroupLeadForm.tsx new file mode 100644 index 0000000000..ab426f1f14 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/SetContentWorkingGroupLeadForm.tsx @@ -0,0 +1,188 @@ +import React, { useEffect, useState } from "react"; +import { Dropdown, Label, Loader, Message, Icon, DropdownItemProps, DropdownOnSearchChangeData, DropdownProps } from "semantic-ui-react"; +import { getFormErrorLabelsProps } from "./errorHandling"; +import * as Yup from "yup"; +import { + GenericProposalForm, + GenericFormValues, + genericFormDefaultOptions, + genericFormDefaultValues, + withProposalFormData, + ProposalFormExportProps, + ProposalFormContainerProps, + ProposalFormInnerProps +} from "./GenericProposalForm"; +import Validation from "../validationSchema"; +import { FormField } from "./FormFields"; +import { withFormContainer } from "./FormContainer"; +import { useTransport } from "../runtime"; +import { usePromise } from "../utils"; +import { Profile } from "@joystream/types/members"; +import PromiseComponent from "../Proposal/PromiseComponent"; +import _ from 'lodash'; +import "./forms.css"; + +type FormValues = GenericFormValues & { + workingGroupLead: any; +}; + +const defaultValues: FormValues = { + ...genericFormDefaultValues, + workingGroupLead: "" +}; + +type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form. +type ExportComponentProps = ProposalFormExportProps; +type FormContainerProps = ProposalFormContainerProps; +type FormInnerProps = ProposalFormInnerProps; + +function memberOptionKey(id: number, profile: Profile) { + return `${id}:${profile.root_account.toString()}`; +} + +const MEMBERS_QUERY_MIN_LENGTH = 4; +const MEMBERS_NONE_OPTION: DropdownItemProps = { + key: '- NONE -', + text: '- NONE -', + value: 'none' +} + +function membersToOptions(members: { id: number, profile: Profile }[]) { + return [MEMBERS_NONE_OPTION].concat( + members + .map(({ id, profile }) => ({ + key: profile.handle, + text: `${ profile.handle } (id:${ id })`, + value: memberOptionKey(id, profile), + image: profile.avatar_uri.toString() ? { avatar: true, src: profile.avatar_uri } : null + })) + ); +} + +function filterMembers(options: DropdownItemProps[], query: string) { + if (query.length < MEMBERS_QUERY_MIN_LENGTH) { + return [MEMBERS_NONE_OPTION]; + } + const regexp = new RegExp(_.escapeRegExp(query)); + return options.filter((opt) => regexp.test((opt.text || '').toString())) +} + +type MemberWithId = { id: number; profile: Profile }; + +const SetContentWorkingGroupsLeadForm: React.FunctionComponent = props => { + const { handleChange, errors, touched, values } = props; + const errorLabelsProps = getFormErrorLabelsProps(errors, touched); + // State + const [ membersOptions, setMembersOptions ] = useState([] as DropdownItemProps[]); + const [ filteredOptions, setFilteredOptions ] = useState([] as DropdownItemProps[]); + const [ membersSearchQuery, setMembersSearchQuery ] = useState(""); + // Transport + const transport = useTransport(); + const [members, /* error */, loading] = usePromise( + () => transport.membersExceptCouncil(), + [] + ); + const [currentLead, clError, clLoading] = usePromise( + () => transport.WGLead(), + null + ); + // Generate members options array on load + useEffect(() => { + if (members.length) { + setMembersOptions(membersToOptions(members)); + } + }, [members]); + // Filter options on search query change (we "pulled-out" this logic here to avoid lags) + useEffect(() => { + setFilteredOptions(filterMembers(membersOptions, membersSearchQuery)); + }, [membersSearchQuery]); + + return ( + + + {loading ? ( + <> + Fetching members... + + ) : (<> + + { + (!values.workingGroupLead || membersSearchQuery.length > 0) && + (MEMBERS_QUERY_MIN_LENGTH - membersSearchQuery.length) > 0 && ( + + ) + } + options } + // On search change we update it in our state + onSearchChange={ (e: React.SyntheticEvent, data: DropdownOnSearchChangeData) => { + setMembersSearchQuery(data.searchQuery); + } } + name="workingGroupLead" + placeholder={ "Start typing member handle or \"id:[ID]\" query..." } + fluid + selection + options={filteredOptions} + onChange={ + (e: React.ChangeEvent, data: DropdownProps) => { + // Fix TypeScript issue + const originalHandler = handleChange as (e: React.ChangeEvent, data: DropdownProps) => void; + originalHandler(e, data); + if (!data.value) { + setMembersSearchQuery(''); + } + } + } + value={values.workingGroupLead} + /> + {errorLabelsProps.workingGroupLead && + + + + Current Content Working Group lead: { (currentLead && currentLead.profile.handle) || 'NONE' } + + + )} + + + ); +}; + +const FormContainer = withFormContainer({ + mapPropsToValues: (props: FormContainerProps) => ({ + ...defaultValues, + ...(props.initialData || {}) + }), + validationSchema: Yup.object().shape({ + ...genericFormDefaultOptions.validationSchema, + workingGroupLead: Validation.SetLead.workingGroupLead + }), + handleSubmit: genericFormDefaultOptions.handleSubmit, + displayName: "SetContentWorkingGroupLeadForm" +})(SetContentWorkingGroupsLeadForm); + +export default withProposalFormData(FormContainer); diff --git a/pioneer/packages/joy-proposals/src/forms/SetContentWorkingGroupMintCapForm.tsx b/pioneer/packages/joy-proposals/src/forms/SetContentWorkingGroupMintCapForm.tsx new file mode 100644 index 0000000000..ce790ae061 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/SetContentWorkingGroupMintCapForm.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { default as MintCapacityForm } from './MintCapacityForm'; +import { RouteComponentProps } from 'react-router'; +import { useTransport } from "../runtime"; +import { usePromise } from "../utils"; +import PromiseComponent from '../Proposal/PromiseComponent'; + +const ContentWorkingGroupMintCapForm = (props: RouteComponentProps) => { + const transport = useTransport(); + const [ mintCapacity, error, loading ] = usePromise(() => transport.WGMintCap(), 0); + + return ( + + + + ); +}; + +export default ContentWorkingGroupMintCapForm; diff --git a/pioneer/packages/joy-proposals/src/forms/SetCouncilMintCapForm.tsx b/pioneer/packages/joy-proposals/src/forms/SetCouncilMintCapForm.tsx new file mode 100644 index 0000000000..bd8553ee62 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/SetCouncilMintCapForm.tsx @@ -0,0 +1,14 @@ +// import React from 'react'; +// import { default as MintCapacityForm } from './MintCapacityForm'; +import { RouteComponentProps } from 'react-router'; + +const CouncilMintCapForm = (props: RouteComponentProps) => ( + null + // +); + +export default CouncilMintCapForm; diff --git a/pioneer/packages/joy-proposals/src/forms/SetCouncilParamsForm.tsx b/pioneer/packages/joy-proposals/src/forms/SetCouncilParamsForm.tsx new file mode 100644 index 0000000000..86f5764281 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/SetCouncilParamsForm.tsx @@ -0,0 +1,219 @@ +import React, { useEffect, useState } from "react"; +import { getFormErrorLabelsProps } from "./errorHandling"; +import { Divider, Form } from "semantic-ui-react"; +import * as Yup from "yup"; +import { + GenericProposalForm, + GenericFormValues, + genericFormDefaultOptions, + genericFormDefaultValues, + withProposalFormData, + ProposalFormExportProps, + ProposalFormContainerProps, + ProposalFormInnerProps +} from "./GenericProposalForm"; +import Validation from "../validationSchema"; +import { InputFormField } from "./FormFields"; +import { withFormContainer } from "./FormContainer"; +import { createType } from "@polkadot/types"; +import "./forms.css"; +import { useTransport } from "../runtime"; +import { usePromise, snakeCaseToCamelCase } from "../utils"; +import { ElectionParameters } from "@joystream/types/proposals"; +import PromiseComponent from "../Proposal/PromiseComponent"; + +type FormValues = GenericFormValues & { + announcingPeriod: string; + votingPeriod: string; + minVotingStake: string; + revealingPeriod: string; + minCouncilStake: string; + newTermDuration: string; + candidacyLimit: string; + councilSize: string; +}; + +const defaultValues: FormValues = { + ...genericFormDefaultValues, + announcingPeriod: "", + votingPeriod: "", + minVotingStake: "", + revealingPeriod: "", + minCouncilStake: "", + newTermDuration: "", + candidacyLimit: "", + councilSize: "" +}; + +type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form. +type ExportComponentProps = ProposalFormExportProps; +type FormContainerProps = ProposalFormContainerProps; +type FormInnerProps = ProposalFormInnerProps; + +function createElectionParameters(values: FormValues): ElectionParameters { + return new ElectionParameters({ + announcing_period: createType("BlockNumber", parseInt(values.announcingPeriod)), + voting_period: createType("BlockNumber", parseInt(values.votingPeriod)), + revealing_period: createType("BlockNumber", parseInt(values.revealingPeriod)), + council_size: createType("u32", values.councilSize), + candidacy_limit: createType("u32", values.candidacyLimit), + new_term_duration: createType("BlockNumber", parseInt(values.newTermDuration)), + min_council_stake: createType("Balance", values.minCouncilStake), + min_voting_stake: createType("Balance", values.minVotingStake) + }); +} + +const SetCouncilParamsForm: React.FunctionComponent = props => { + const { handleChange, errors, touched, values, setFieldValue, setFieldError } = props; + const errorLabelsProps = getFormErrorLabelsProps(errors, touched); + const [ placeholders, setPlaceholders ] = useState<{ [k in keyof FormValues]: string }>(defaultValues); + + const transport = useTransport(); + const [ councilParams, error, loading ] = usePromise(() => transport.electionParameters(), null); + useEffect(() => { + if (councilParams) { + let fetchedPlaceholders = {...placeholders}; + const fieldsToPopulate = [ + "announcing_period", + "voting_period", + "min_voting_stake", + "revealing_period", + "min_council_stake", + "new_term_duration", + "candidacy_limit", + "council_size" + ] as const; + fieldsToPopulate.forEach(field => { + const camelCaseField = snakeCaseToCamelCase(field) as keyof FormValues; + setFieldValue(camelCaseField, councilParams[field].toString()); + fetchedPlaceholders[camelCaseField] = councilParams[field].toString(); + }); + setPlaceholders(fetchedPlaceholders); + } + }, [councilParams]); + + // This logic may be moved somewhere else in the future, but it's quite easy to enforce it here: + if (!errors.candidacyLimit && !errors.councilSize && parseInt(values.candidacyLimit) < parseInt(values.councilSize)) { + setFieldError('candidacyLimit', `Candidacy limit must be >= council size (${ values.councilSize })`); + } + + return ( + + + Voting + + + + + + + Council + + + + + + + + + ); +}; + +const FormContainer = withFormContainer({ + mapPropsToValues: (props: FormContainerProps) => ({ + ...defaultValues, + ...(props.initialData || {}) + }), + validationSchema: Yup.object().shape({ + ...genericFormDefaultOptions.validationSchema, + announcingPeriod: Validation.SetElectionParameters.announcingPeriod, + votingPeriod: Validation.SetElectionParameters.votingPeriod, + minVotingStake: Validation.SetElectionParameters.minVotingStake, + revealingPeriod: Validation.SetElectionParameters.revealingPeriod, + minCouncilStake: Validation.SetElectionParameters.minCouncilStake, + newTermDuration: Validation.SetElectionParameters.newTermDuration, + candidacyLimit: Validation.SetElectionParameters.candidacyLimit, + councilSize: Validation.SetElectionParameters.councilSize + }), + handleSubmit: genericFormDefaultOptions.handleSubmit, + displayName: "SetCouncilParamsForm" +})(SetCouncilParamsForm); + +export default withProposalFormData(FormContainer); diff --git a/pioneer/packages/joy-proposals/src/forms/SetMaxValidatorCountForm.tsx b/pioneer/packages/joy-proposals/src/forms/SetMaxValidatorCountForm.tsx new file mode 100644 index 0000000000..4519c8ea7a --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/SetMaxValidatorCountForm.tsx @@ -0,0 +1,79 @@ +import React, { useEffect } from "react"; +import { getFormErrorLabelsProps } from "./errorHandling"; +import * as Yup from "yup"; +import { + GenericProposalForm, + GenericFormValues, + genericFormDefaultOptions, + genericFormDefaultValues, + withProposalFormData, + ProposalFormExportProps, + ProposalFormContainerProps, + ProposalFormInnerProps +} from "./GenericProposalForm"; +import Validation from "../validationSchema"; +import { InputFormField } from "./FormFields"; +import { withFormContainer } from "./FormContainer"; +import { useTransport } from "../runtime"; +import { usePromise } from "../utils"; +import "./forms.css"; + +type FormValues = GenericFormValues & { + maxValidatorCount: string; +}; + +const defaultValues: FormValues = { + ...genericFormDefaultValues, + maxValidatorCount: "" +}; + +type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form. +type ExportComponentProps = ProposalFormExportProps; +type FormContainerProps = ProposalFormContainerProps; +type FormInnerProps = ProposalFormInnerProps; + +const SetMaxValidatorCountForm: React.FunctionComponent = props => { + const transport = useTransport(); + const [validatorCount] = usePromise(() => transport.maxValidatorCount(), NaN); + const { handleChange, errors, touched, values, setFieldValue } = props; + const errorLabelsProps = getFormErrorLabelsProps(errors, touched); + + useEffect(() => { + if (validatorCount) { + setFieldValue("maxValidatorCount", validatorCount); + } + }, [validatorCount]); + return ( + + + + ); +}; + +const FormContainer = withFormContainer({ + mapPropsToValues: (props: FormContainerProps) => ({ + ...defaultValues, + ...(props.initialData || {}) + }), + validationSchema: Yup.object().shape({ + ...genericFormDefaultOptions.validationSchema, + maxValidatorCount: Validation.SetValidatorCount.maxValidatorCount + }), + handleSubmit: genericFormDefaultOptions.handleSubmit, + displayName: "SetMaxValidatorCountForm" +})(SetMaxValidatorCountForm); + +export default withProposalFormData(FormContainer); diff --git a/pioneer/packages/joy-proposals/src/forms/SetStorageRoleParamsForm.tsx b/pioneer/packages/joy-proposals/src/forms/SetStorageRoleParamsForm.tsx new file mode 100644 index 0000000000..2a5436b013 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/SetStorageRoleParamsForm.tsx @@ -0,0 +1,256 @@ +import React, { useState, useEffect } from "react"; +import { Form, Divider } from "semantic-ui-react"; +import { getFormErrorLabelsProps } from "./errorHandling"; +import * as Yup from "yup"; +import { + GenericProposalForm, + GenericFormValues, + genericFormDefaultOptions, + genericFormDefaultValues, + withProposalFormData, + ProposalFormExportProps, + ProposalFormContainerProps, + ProposalFormInnerProps +} from "./GenericProposalForm"; +import Validation from "../validationSchema"; +import { InputFormField } from "./FormFields"; +import { withFormContainer } from "./FormContainer"; +import { BlockNumber, Balance } from "@polkadot/types/interfaces"; +import { u32 } from "@polkadot/types/primitive"; +import { createType } from "@polkadot/types"; +import { useTransport, StorageRoleParameters, IStorageRoleParameters } from "../runtime"; +import { usePromise } from "../utils"; +import { formatBalance } from "@polkadot/util"; +import "./forms.css"; + +// Move to joy-types? +type RoleParameters = { + min_stake: Balance; + min_actors: u32; + max_actors: u32; + reward: Balance; + reward_period: BlockNumber; + bonding_period: BlockNumber; + unbonding_period: BlockNumber; + min_service_period: BlockNumber; + startup_grace_period: BlockNumber; + entry_request_fee: Balance; +}; + +// All of those are strings, because that's how those values are beeing passed from inputs +type FormValues = GenericFormValues & + { + [K in keyof RoleParameters]: string; + }; + +const defaultValues: FormValues = { + ...genericFormDefaultValues, + min_stake: "", + min_actors: "", + max_actors: "", + reward: "", + reward_period: "", + bonding_period: "", + unbonding_period: "", + min_service_period: "", + startup_grace_period: "", + entry_request_fee: "" +}; + +type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form. +type ExportComponentProps = ProposalFormExportProps; +type FormContainerProps = ProposalFormContainerProps; +type FormInnerProps = ProposalFormInnerProps; + +function createRoleParameters(values: FormValues): RoleParameters { + return { + min_stake: createType("Balance", values.min_stake), + min_actors: createType("u32", values.min_actors), + max_actors: createType("u32", values.max_actors), + reward: createType("Balance", values.reward), + reward_period: createType("BlockNumber", values.reward_period), + bonding_period: createType("BlockNumber", values.bonding_period), + unbonding_period: createType("BlockNumber", values.unbonding_period), + min_service_period: createType("BlockNumber", values.min_service_period), + startup_grace_period: createType("BlockNumber", values.startup_grace_period), + entry_request_fee: createType("Balance", values.entry_request_fee) + }; +} + +const SetStorageRoleParamsForm: React.FunctionComponent = props => { + const transport = useTransport(); + const [params] = usePromise(() => transport.storageRoleParameters(), null); + const { handleChange, errors, touched, values, setFieldValue } = props; + const [placeholders, setPlaceholders] = useState<{ [k in keyof FormValues]: string }>(defaultValues); + const errorLabelsProps = getFormErrorLabelsProps(errors, touched); + + useEffect(() => { + if (params) { + const stringParams = Object.keys(params).reduce((obj, key) => { + return { ...obj, [`${key}`]: String(params[key as keyof IStorageRoleParameters]) }; + }, {}); + const fetchedPlaceholders = { ...placeholders, ...stringParams }; + + StorageRoleParameters.forEach(field => { + setFieldValue(field, params[field].toString()); + }); + setPlaceholders(fetchedPlaceholders); + } + }, [params]); + + return ( + + Parameters + + + + + + + + + + + + + + + + + + + + + + ); +}; + +const FormContainer = withFormContainer({ + mapPropsToValues: (props: FormContainerProps) => ({ + ...defaultValues, + ...(props.initialData || {}) + }), + validationSchema: Yup.object().shape({ + ...genericFormDefaultOptions.validationSchema, + min_stake: Validation.SetStorageRoleParameters.min_stake, + min_actors: Validation.SetStorageRoleParameters.min_actors, + max_actors: Validation.SetStorageRoleParameters.max_actors, + reward: Validation.SetStorageRoleParameters.reward, + reward_period: Validation.SetStorageRoleParameters.reward_period, + bonding_period: Validation.SetStorageRoleParameters.bonding_period, + unbonding_period: Validation.SetStorageRoleParameters.unbonding_period, + min_service_period: Validation.SetStorageRoleParameters.min_service_period, + startup_grace_period: Validation.SetStorageRoleParameters.startup_grace_period, + entry_request_fee: Validation.SetStorageRoleParameters.entry_request_fee + }), + handleSubmit: genericFormDefaultOptions.handleSubmit, + displayName: "SetStorageRoleParamsForm" +})(SetStorageRoleParamsForm); + +export default withProposalFormData(FormContainer); diff --git a/pioneer/packages/joy-proposals/src/forms/SignalForm.tsx b/pioneer/packages/joy-proposals/src/forms/SignalForm.tsx new file mode 100644 index 0000000000..87cde6f0ed --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/SignalForm.tsx @@ -0,0 +1,70 @@ +import React from "react"; +import { getFormErrorLabelsProps } from "./errorHandling"; +import * as Yup from "yup"; +import { + GenericProposalForm, + GenericFormValues, + genericFormDefaultOptions, + genericFormDefaultValues, + withProposalFormData, + ProposalFormExportProps, + ProposalFormContainerProps, + ProposalFormInnerProps +} from "./GenericProposalForm"; +import Validation from "../validationSchema"; +import { TextareaFormField } from "./FormFields"; +import { withFormContainer } from "./FormContainer"; +import "./forms.css"; + +type FormValues = GenericFormValues & { + description: string; +}; + +const defaultValues: FormValues = { + ...genericFormDefaultValues, + description: "" +}; + +type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form. +type ExportComponentProps = ProposalFormExportProps; +type FormContainerProps = ProposalFormContainerProps; +type FormInnerProps = ProposalFormInnerProps; + +const SignalForm: React.FunctionComponent = props => { + const { handleChange, errors, touched, values } = props; + const errorLabelsProps = getFormErrorLabelsProps(errors, touched); + + return ( + + + + ); +}; + +const FormContainer = withFormContainer({ + mapPropsToValues: (props: FormContainerProps) => ({ + ...defaultValues, + ...(props.initialData || {}) + }), + validationSchema: Yup.object().shape({ + ...genericFormDefaultOptions.validationSchema, + description: Validation.Text.description + }), + handleSubmit: genericFormDefaultOptions.handleSubmit, + displayName: "SignalForm" +})(SignalForm); + +export default withProposalFormData(FormContainer); diff --git a/pioneer/packages/joy-proposals/src/forms/SpendingProposalForm.tsx b/pioneer/packages/joy-proposals/src/forms/SpendingProposalForm.tsx new file mode 100644 index 0000000000..6928a72e68 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/SpendingProposalForm.tsx @@ -0,0 +1,96 @@ +import React from "react"; +import { getFormErrorLabelsProps } from "./errorHandling"; +import * as Yup from "yup"; +import { Label } from "semantic-ui-react"; +import { + GenericProposalForm, + GenericFormValues, + genericFormDefaultOptions, + genericFormDefaultValues, + withProposalFormData, + ProposalFormExportProps, + ProposalFormContainerProps, + ProposalFormInnerProps +} from "./GenericProposalForm"; +import Validation from "../validationSchema"; +import { InputFormField, FormField } from "./FormFields"; +import { withFormContainer } from "./FormContainer"; +import { InputAddress } from "@polkadot/react-components/index"; +import { formatBalance } from "@polkadot/util"; +import "./forms.css"; + +type FormValues = GenericFormValues & { + destinationAccount: any; + tokens: string; +}; + +const defaultValues: FormValues = { + ...genericFormDefaultValues, + destinationAccount: "", + tokens: "" +}; + +type FormAdditionalProps = {}; // Aditional props coming all the way from export comonent into the inner form. +type ExportComponentProps = ProposalFormExportProps; +type FormContainerProps = ProposalFormContainerProps; +type FormInnerProps = ProposalFormInnerProps; + +const SpendingProposalForm: React.FunctionComponent = props => { + const { handleChange, errors, touched, values, setFieldValue } = props; + const errorLabelsProps = getFormErrorLabelsProps(errors, touched); + return ( + + + + setFieldValue("destinationAccount", address)} + type="all" + placeholder="Select Destination Account" + value={values.destinationAccount} + /> + {errorLabelsProps.destinationAccount && + + ); +}; + +const FormContainer = withFormContainer({ + mapPropsToValues: (props: FormContainerProps) => ({ + ...defaultValues, + ...(props.initialData || {}) + }), + validationSchema: Yup.object().shape({ + ...genericFormDefaultOptions.validationSchema, + tokens: Validation.Spending.tokens, + destinationAccount: Validation.Spending.destinationAccount + }), + handleSubmit: genericFormDefaultOptions.handleSubmit, + displayName: "SpendingProposalsForm" +})(SpendingProposalForm); + +export default withProposalFormData(FormContainer); diff --git a/pioneer/packages/joy-proposals/src/forms/errorHandling.ts b/pioneer/packages/joy-proposals/src/forms/errorHandling.ts new file mode 100644 index 0000000000..effa6d94b5 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/errorHandling.ts @@ -0,0 +1,37 @@ +import { FormikErrors, FormikTouched } from "formik"; +import { LabelProps } from "semantic-ui-react"; + +type FieldErrorLabelProps = LabelProps | null; // This is used for displaying semantic-ui errors +type FormErrorLabelsProps = { [T in keyof ValuesT]: FieldErrorLabelProps }; + +// Single form field error state. +// Takes formik "errors" and "touched" objects and the field name as arguments. +// Returns value to use ie. in the semantic-ui Form.Input error prop. +export function getErrorLabelProps( + errors: FormikErrors, + touched: FormikTouched, + fieldName: keyof ValuesT, + pointing: LabelProps["pointing"] = undefined + +): FieldErrorLabelProps +{ + return (errors[fieldName] && touched[fieldName]) ? + { content: errors[fieldName], pointing } + : null; +} + +// All form fields error states (uses default value for "pointing"). +// Takes formik "errors" and "touched" objects as arguments. +// Returns object with field names as properties and values that can be used ie. for semantic-ui Form.Input error prop +export function getFormErrorLabelsProps( + errors: FormikErrors, + touched: FormikTouched +): FormErrorLabelsProps +{ + let errorStates: Partial> = {}; + for (let fieldName in errors) { + errorStates[fieldName] = getErrorLabelProps(errors, touched, fieldName); + } + + return > errorStates; +} diff --git a/pioneer/packages/joy-proposals/src/forms/forms.css b/pioneer/packages/joy-proposals/src/forms/forms.css new file mode 100644 index 0000000000..d800c9c497 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/forms.css @@ -0,0 +1,23 @@ +.Forms { + .proposal-form { + margin: 0 auto; + } + + .ui.form.proposal-form { + & label { + font-size: 1rem; + } + + & input[name="tokens"] { + max-width: 16rem; + } + } + + .form-buttons { + display: flex; + } + + .ui.dropdown .ui.avatar.image { + width: 2em !important; + } +} diff --git a/pioneer/packages/joy-proposals/src/forms/index.ts b/pioneer/packages/joy-proposals/src/forms/index.ts new file mode 100644 index 0000000000..487ccc2242 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/forms/index.ts @@ -0,0 +1,11 @@ +export { default as SignalForm } from "./SignalForm"; +export { default as SpendingProposalForm } from "./SpendingProposalForm"; +export { default as EvictStorageProviderForm } from "./EvictStorageProviderForm"; +export { default as MintCapacityForm } from "./MintCapacityForm"; +export { default as SetCouncilParamsForm } from "./SetCouncilParamsForm"; +export { default as SetContentWorkingGroupLeadForm } from "./SetContentWorkingGroupLeadForm"; +export { default as SetStorageRoleParamsForm } from "./SetStorageRoleParamsForm" +export { default as RuntimeUpgradeForm } from "./RuntimeUpgradeForm"; +export { default as SetContentWorkingGroupMintCapForm } from './SetContentWorkingGroupMintCapForm'; +export { default as SetCouncilMintCapForm } from './SetCouncilMintCapForm'; +export { default as SetMaxValidatorCountForm } from './SetMaxValidatorCountForm'; diff --git a/pioneer/packages/joy-proposals/src/index.css b/pioneer/packages/joy-proposals/src/index.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/pioneer/packages/joy-proposals/src/index.tsx b/pioneer/packages/joy-proposals/src/index.tsx new file mode 100644 index 0000000000..0c4e653b08 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/index.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import { Route, Switch } from "react-router"; + +import { AppProps, I18nProps } from "@polkadot/react-components/types"; +import Tabs, { TabItem } from "@polkadot/react-components/Tabs"; +import { SubstrateProvider } from "./runtime"; +import { ProposalPreviewList, ProposalFromId, ChooseProposalType } from "./Proposal"; + +import "./index.css"; + +import translate from "./translate"; +import NotDone from "./NotDone"; +import { + SignalForm, + EvictStorageProviderForm, + SpendingProposalForm, + SetContentWorkingGroupLeadForm, + SetContentWorkingGroupMintCapForm, + SetCouncilParamsForm, + SetStorageRoleParamsForm, + SetMaxValidatorCountForm, + RuntimeUpgradeForm +} from "./forms"; + +interface Props extends AppProps, I18nProps {} + +function App(props: Props): React.ReactElement { + const { t, basePath } = props; + + const tabs: TabItem[] = [ + { + isRoot: true, + name: "proposals", + text: t("Proposals") + }, + { + name: "new", + text: t("New Proposal") + } + ]; + + return ( + +
+
+ +
+ + + + + + + + + + + + + + + + +
+
+ ); +} + +export default translate(App); diff --git a/pioneer/packages/joy-proposals/src/runtime/TransportContext.tsx b/pioneer/packages/joy-proposals/src/runtime/TransportContext.tsx new file mode 100644 index 0000000000..6f6fc8e9a6 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/runtime/TransportContext.tsx @@ -0,0 +1,23 @@ +import React, { createContext, useContext } from "react"; +import { ApiContext } from "@polkadot/react-api"; +import { ApiProps } from "@polkadot/react-api/types"; +import { SubstrateTransport } from "./transport.substrate"; +import { MockTransport } from "./transport.mock"; +import { Transport } from "./transport"; + +const TransportContext = createContext((null as unknown) as Transport); + +export function MockProvider({ children }: { children: React.PropsWithChildren<{}> }) { + return {children}; +} + +export function SubstrateProvider({ children }: { children: React.PropsWithChildren<{}> }) { + const api: ApiProps = useContext(ApiContext); + const transport = new SubstrateTransport(api); + + return {children}; +} + +export function useTransport() { + return useContext(TransportContext) as SubstrateTransport; +} diff --git a/pioneer/packages/joy-proposals/src/runtime/cache.ts b/pioneer/packages/joy-proposals/src/runtime/cache.ts new file mode 100644 index 0000000000..56991a34cd --- /dev/null +++ b/pioneer/packages/joy-proposals/src/runtime/cache.ts @@ -0,0 +1,66 @@ +// Set does not do a deep equal when adding elements, so try to only use strings or another primitive for K + +export default class Cache extends Map { + protected neverClear: Set; + + constructor( + objects: Iterable, + protected loaderFn: (ids: K[]) => Promise, + neverClear: K[] | Set = [], + public name?: string + ) { + super(objects); + this.name = name; + this.neverClear = new Set(neverClear); + this.loaderFn = loaderFn; + } + + forceClear(): void { + const prevCacheSize = this.size; + this.clear(); + console.info(`Removed all ${prevCacheSize} entries from ${this.name}, including ${this.neverClear}`); + } + + clearExcept(keepIds: K[] | Set, force: boolean = false): void { + const prevCacheSize = this.size; + const keepIdsSet = force ? new Set(keepIds) : new Set([...keepIds, ...this.neverClear]); + + for (let key of this.keys()) { + if (!keepIdsSet.has(key)) { + this.delete(key); + } + } + + console.info(`Removed ${prevCacheSize - this.size} entries out of ${prevCacheSize} from ${this.name}`); + } + + clear(): void { + this.clearExcept([]); + } + + async load(ids: K[], force: boolean = false): Promise { + const idsNotInCache: K[] = []; + const cachedObjects: T[] = []; + + ids.forEach(id => { + let objFromCache = this.get(id); + if (objFromCache && !force) { + cachedObjects.push(objFromCache); + } else { + idsNotInCache.push(id); + } + }); + + let loadedObjects: T[] = []; + + if (idsNotInCache.length > 0) { + loadedObjects = await this.loaderFn(idsNotInCache); + loadedObjects.forEach(obj => { + const id = obj.id; + this.set(id, obj); + }); + } + + return [...cachedObjects, ...loadedObjects]; + } +} diff --git a/pioneer/packages/joy-proposals/src/runtime/index.ts b/pioneer/packages/joy-proposals/src/runtime/index.ts new file mode 100644 index 0000000000..2f5ad00418 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/runtime/index.ts @@ -0,0 +1,4 @@ +export { ParsedProposal, ProposalType, ProposalVote, IStorageRoleParameters, StorageRoleParameters } from "./transport"; +export { SubstrateTransport } from "./transport.substrate"; +export { MockTransport } from "./transport.mock"; +export { SubstrateProvider, useTransport } from "./TransportContext"; diff --git a/pioneer/packages/joy-proposals/src/runtime/transport.mock.ts b/pioneer/packages/joy-proposals/src/runtime/transport.mock.ts new file mode 100644 index 0000000000..359749c84e --- /dev/null +++ b/pioneer/packages/joy-proposals/src/runtime/transport.mock.ts @@ -0,0 +1,16 @@ +import { Transport, ParsedProposal } from "./transport"; + +function delay(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export class MockTransport extends Transport { + constructor() { + super(); + } + + async proposals() { + await delay(Math.random() * 2000); + return Promise.all((Array.from({ length: 5 }, (_, i) => "Not implemented") as unknown) as ParsedProposal[]); + } +} diff --git a/pioneer/packages/joy-proposals/src/runtime/transport.substrate.ts b/pioneer/packages/joy-proposals/src/runtime/transport.substrate.ts new file mode 100644 index 0000000000..7def7f34d5 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/runtime/transport.substrate.ts @@ -0,0 +1,328 @@ +import { + Transport, + ParsedProposal, + ProposalType, + ProposalTypes, + ParsedMember, + ProposalVote, + IStorageRoleParameters +} from "./transport"; +import { Proposal, ProposalId, Seats, VoteKind, ElectionParameters } from "@joystream/types/proposals"; +import { MemberId, Profile, ActorInRole, RoleKeys, Role } from "@joystream/types/members"; +import { ApiProps } from "@polkadot/react-api/types"; +import { u32, u128, Vec, Option } from "@polkadot/types/"; +import { Balance, Moment, AccountId, BlockNumber, BalanceOf } from "@polkadot/types/interfaces"; +import { ApiPromise } from "@polkadot/api"; + +import { FIRST_MEMBER_ID } from "@polkadot/joy-members/constants"; + +import { includeKeys, calculateStake, calculateMetaFromType, splitOnUpperCase } from "../utils"; +import { MintId, Mint } from "@joystream/types/mint"; +import { LeadId } from "@joystream/types/content-working-group"; + +export class SubstrateTransport extends Transport { + protected api: ApiPromise; + + constructor(api: ApiProps) { + super(); + + if (!api) { + throw new Error("Cannot create SubstrateTransport: A Substrate API is required"); + } else if (!api.isApiReady) { + throw new Error("Cannot create a SubstrateTransport: The Substrate API is not ready yet."); + } + + this.api = api.api; + } + + get proposalsEngine() { + return this.api.query.proposalsEngine; + } + + get proposalsCodex() { + return this.api.query.proposalsCodex; + } + + get members() { + return this.api.query.members; + } + + get council() { + return this.api.query.council; + } + + get councilElection() { + return this.api.query.councilElection; + } + + get actors() { + return this.api.query.actors; + } + + get contentWorkingGroup() { + return this.api.query.contentWorkingGroup; + } + + get minting() { + return this.api.query.minting; + } + + totalIssuance() { + return this.api.query.balances.totalIssuance(); + } + + async blockHash(height: number): Promise { + const blockHash = await this.api.rpc.chain.getBlockHash(height); + + return blockHash.toString(); + } + + async blockTimestamp(height: number): Promise { + const blockTime = (await this.api.query.timestamp.now.at(await this.blockHash(height))) as Moment; + + return new Date(blockTime.toNumber()); + } + + proposalCount() { + return this.proposalsEngine.proposalCount(); + } + + rawProposalById(id: ProposalId) { + return this.proposalsEngine.proposals(id); + } + + proposalDetailsById(id: ProposalId) { + return this.proposalsCodex.proposalDetailsByProposalId(id); + } + + memberProfile(id: MemberId | number): Promise> { + return this.members.memberProfile(id) as Promise>; + } + + async cancellationFee(): Promise { + return ((await this.api.consts.proposalsEngine.cancellationFee) as BalanceOf).toNumber(); + } + + async proposalById(id: ProposalId): Promise { + const rawDetails = (await this.proposalDetailsById(id)).toJSON() as { [k: string]: any }; + const type = Object.keys(rawDetails)[0] as ProposalType; + const details = Array.isArray(rawDetails[type]) ? rawDetails[type] : [rawDetails[type]]; + const rawProposal = await this.rawProposalById(id); + const proposer = (await this.memberProfile(rawProposal.proposerId)).toJSON() as ParsedMember; + const proposal = rawProposal.toJSON() as { + title: string; + description: string; + parameters: any; + votingResults: any; + proposerId: number; + status: any; + }; + const createdAtBlock = rawProposal.createdAt; + const createdAt = await this.blockTimestamp(createdAtBlock.toNumber()); + const cancellationFee = await this.cancellationFee(); + + return { + id, + ...proposal, + details, + type, + proposer, + createdAtBlock: createdAtBlock.toJSON(), + createdAt, + cancellationFee + }; + } + + async proposalsIds() { + const total: number = (await this.proposalCount()).toNumber(); + return Array.from({ length: total }, (_, i) => new ProposalId(i + 1)); + } + + async proposals() { + const ids = await this.proposalsIds(); + return Promise.all(ids.map(id => this.proposalById(id))); + } + + async activeProposals() { + const activeProposalIds = await this.proposalsEngine.activeProposalIds(); + + return Promise.all(activeProposalIds.map(id => this.proposalById(id))); + } + + async proposedBy(member: MemberId) { + const proposals = await this.proposals(); + return proposals.filter(({ proposerId }) => member.eq(proposerId)); + } + + async proposalDetails(id: ProposalId) { + return this.proposalsCodex.proposalDetailsByProposalId(id); + } + + async councilMembers(): Promise<(ParsedMember & { memberId: MemberId })[]> { + const council = (await this.council.activeCouncil()) as Seats; + return Promise.all( + council.map(async seat => { + const memberIds = (await this.members.memberIdsByControllerAccountId(seat.member)) as Vec; + const member = (await this.memberProfile(memberIds[0])).toJSON() as ParsedMember; + return { + ...member, + memberId: memberIds[0] + }; + }) + ); + } + + async voteByProposalAndMember(proposalId: ProposalId, voterId: MemberId): Promise { + const vote = await this.proposalsEngine.voteExistsByProposalByVoter(proposalId, voterId); + const hasVoted = (await this.proposalsEngine.voteExistsByProposalByVoter.size(proposalId, voterId)).toNumber(); + return hasVoted ? vote : null; + } + + async votes(proposalId: ProposalId): Promise { + const councilMembers = await this.councilMembers(); + return Promise.all( + councilMembers.map(async member => { + const vote = await this.voteByProposalAndMember(proposalId, member.memberId); + return { + vote, + member + }; + }) + ); + } + + async fetchProposalMethodsFromCodex(includeKey: string) { + const methods = includeKeys(this.proposalsCodex, includeKey); + // methods = [proposalTypeVotingPeriod...] + return methods.reduce(async (prevProm, method) => { + const obj = await prevProm; + const period = (await this.proposalsCodex[method]()) as u32; + // setValidatorCountProposalVotingPeriod to SetValidatorCount + const key = splitOnUpperCase(method) + .slice(0, -3) + .map((w, i) => (i === 0 ? w.slice(0, 1).toUpperCase() + w.slice(1) : w)) + .join("") as ProposalType; + + return { ...obj, [`${key}`]: period.toNumber() }; + }, Promise.resolve({}) as Promise<{ [k in ProposalType]: number }>); + } + + async proposalTypesGracePeriod(): Promise<{ [k in ProposalType]: number }> { + return this.fetchProposalMethodsFromCodex("GracePeriod"); + } + + async proposalTypesVotingPeriod(): Promise<{ [k in ProposalType]: number }> { + return this.fetchProposalMethodsFromCodex("VotingPeriod"); + } + + async parametersFromProposalType(type: ProposalType) { + const votingPeriod = (await this.proposalTypesVotingPeriod())[type]; + const gracePeriod = (await this.proposalTypesGracePeriod())[type]; + const issuance = (await this.totalIssuance()).toNumber(); + const stake = calculateStake(type, issuance); + const meta = calculateMetaFromType(type); + // Currently it's same for all types, but this will change soon + const cancellationFee = await this.cancellationFee(); + return { + type, + votingPeriod, + gracePeriod, + stake, + cancellationFee, + ...meta + }; + } + + async proposalsTypesParameters() { + return Promise.all(ProposalTypes.map(type => this.parametersFromProposalType(type))); + } + + async bestBlock() { + return await this.api.derive.chain.bestNumber(); + } + + async storageProviders(): Promise { + const providers = (await this.actors.accountIdsByRole(RoleKeys.StorageProvider)) as Vec; + return providers.toArray(); + } + + async membersExceptCouncil(): Promise<{ id: number; profile: Profile }[]> { + // Council members to filter out + const activeCouncil = (await this.council.activeCouncil()) as Seats; + const membersCount = ((await this.members.membersCreated()) as MemberId).toNumber(); + const profiles: { id: number; profile: Profile }[] = []; + for (let id = FIRST_MEMBER_ID.toNumber(); id < membersCount; ++id) { + const profile = (await this.memberProfile(new MemberId(id))).unwrapOr(null); + if ( + !profile || + // Filter out council members + activeCouncil.some( + seat => + seat.member.toString() === profile.controller_account.toString() || + seat.member.toString() === profile.root_account.toString() + ) + ) { + continue; + } + profiles.push({ id, profile }); + } + + return profiles; + } + + async storageRoleParameters(): Promise { + const params = ( + await this.api.query.actors.parameters(RoleKeys.StorageProvider) + ).toJSON() as IStorageRoleParameters; + return params; + } + + async maxValidatorCount(): Promise { + const count = ((await this.api.query.staking.validatorCount()) as u32).toNumber(); + return count; + } + + async electionParameters(): Promise { + const announcing_period = (await this.councilElection.announcingPeriod()) as BlockNumber; + const voting_period = (await this.councilElection.votingPeriod()) as BlockNumber; + const revealing_period = (await this.councilElection.revealingPeriod()) as BlockNumber; + const new_term_duration = (await this.councilElection.newTermDuration()) as BlockNumber; + const min_council_stake = (await this.councilElection.minCouncilStake()) as Balance; + const min_voting_stake = (await this.councilElection.minVotingStake()) as Balance; + const candidacy_limit = (await this.councilElection.candidacyLimit()) as u32; + const council_size = (await this.councilElection.councilSize()) as u32; + + return new ElectionParameters({ + announcing_period, + voting_period, + revealing_period, + new_term_duration, + min_council_stake, + min_voting_stake, + candidacy_limit, + council_size + }); + } + + async WGMintCap(): Promise { + const WGMintId = (await this.contentWorkingGroup.mint()) as MintId; + const WGMint = (await this.minting.mints(WGMintId)) as Vec; + return (WGMint[0].get("capacity") as u128).toNumber(); + } + + async WGLead(): Promise<{ id: number; profile: Profile } | null> { + const optLeadId = (await this.contentWorkingGroup.currentLeadId()) as Option; + const leadId = optLeadId.unwrapOr(null); + + if (!leadId) return null; + + const actorInRole = new ActorInRole({ + role: new Role(RoleKeys.CuratorLead), + actor_id: leadId + }); + const memberId = (await this.members.membershipIdByActorInRole(actorInRole)) as MemberId; + const profile = (await this.memberProfile(memberId)).unwrapOr(null); + + return profile && { id: memberId.toNumber(), profile }; + } +} diff --git a/pioneer/packages/joy-proposals/src/runtime/transport.ts b/pioneer/packages/joy-proposals/src/runtime/transport.ts new file mode 100644 index 0000000000..1f84314b94 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/runtime/transport.ts @@ -0,0 +1,79 @@ +import { ProposalId, VoteKind } from "@joystream/types/proposals"; +import { MemberId } from "@joystream/types/members"; +export const ProposalTypes = [ + "Text", + "RuntimeUpgrade", + "SetElectionParameters", + "Spending", + "SetLead", + "SetContentWorkingGroupMintCapacity", + "EvictStorageProvider", + "SetValidatorCount", + "SetStorageRoleParameters" +] as const; + +export type ProposalType = typeof ProposalTypes[number]; + +export type ParsedMember = { + about: string; + avatar_uri: string; + handle: string; + registered_at_block: number; + registered_at_time: number; + roles: any[]; + entry: { [k: string]: any }; + root_account: string; + controller_account: string; + subscription: any; + suspended: boolean; +}; + +export type ParsedProposal = { + id: ProposalId; + type: ProposalType; + title: string; + description: string; + status: any; + proposer: ParsedMember; + proposerId: number; + createdAtBlock: number; + createdAt: Date; + details: any[]; + votingResults: any; + parameters: { + approvalQuorumPercentage: number; + approvalThresholdPercentage: number; + gracePeriod: number; + requiredStake: number; + slashingQuorumPercentage: number; + slashingThresholdPercentage: number; + votingPeriod: number; + }; + cancellationFee: number; +}; + +export const StorageRoleParameters = [ + "min_stake", + "min_actors", + "max_actors", + "reward", + "reward_period", + "bonding_period", + "unbonding_period", + "min_service_period", + "startup_grace_period", + "entry_request_fee" +] as const; + +export type IStorageRoleParameters = { + [k in typeof StorageRoleParameters[number]]: number; +}; + +export type ProposalVote = { + vote: VoteKind | null; + member: ParsedMember & { memberId: MemberId }; +}; + +export abstract class Transport { + abstract proposals(): Promise; +} diff --git a/pioneer/packages/joy-proposals/src/stories/ProposalDetails.stories.tsx b/pioneer/packages/joy-proposals/src/stories/ProposalDetails.stories.tsx new file mode 100644 index 0000000000..22ef9228d7 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/stories/ProposalDetails.stories.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import "../index.css"; + +import MockProposalDetails from "./data/ProposalDetails.mock"; +import { ProposalDetails } from "../Proposal"; + +export default { + title: "Proposals | Details" +}; + +export const HasToVote = () => ; + +export const VotedApproved = () => ( + +); + +export const VotedAbstain = () => ( + +); + +export const VotedReject = () => ( + +); + +export const VotedSlash = () => ; diff --git a/pioneer/packages/joy-proposals/src/stories/ProposalForms.stories.tsx b/pioneer/packages/joy-proposals/src/stories/ProposalForms.stories.tsx new file mode 100644 index 0000000000..6361d3a39f --- /dev/null +++ b/pioneer/packages/joy-proposals/src/stories/ProposalForms.stories.tsx @@ -0,0 +1,38 @@ +import "../index.css"; +import { + SignalForm, + EvictStorageProviderForm, + SpendingProposalForm, + SetCouncilParamsForm, + SetContentWorkingGroupLeadForm, + SetStorageRoleParamsForm, + RuntimeUpgradeForm, + SetContentWorkingGroupMintCapForm, + SetCouncilMintCapForm, + SetMaxValidatorCountForm +} from "../forms"; +import withMock from './withMock'; + +export default { + title: "Proposals | Forms" +}; + +export const Signal = () => withMock(SignalForm); + +export const StorageProviders = () => withMock(EvictStorageProviderForm); + +export const SpendingProposal = () => withMock(SpendingProposalForm); + +export const SetCouncilParams = () => withMock(SetCouncilParamsForm); + +export const SetContentWorkingGroupLead = () => withMock(SetContentWorkingGroupLeadForm); + +export const SetStorageRoleParams = () => withMock(SetStorageRoleParamsForm); + +export const RuntimeUpgrade = () => withMock(RuntimeUpgradeForm); + +export const ContentWorkingGroupMintCap = () => withMock(SetContentWorkingGroupMintCapForm); + +export const CouncilMintCap = () => withMock(SetCouncilMintCapForm); + +export const SetMaxValidatorCount = () => withMock(SetMaxValidatorCountForm); diff --git a/pioneer/packages/joy-proposals/src/stories/ProposalPreview.stories.tsx b/pioneer/packages/joy-proposals/src/stories/ProposalPreview.stories.tsx new file mode 100644 index 0000000000..65e7e425cb --- /dev/null +++ b/pioneer/packages/joy-proposals/src/stories/ProposalPreview.stories.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import "../index.css"; + +import MockProposalPreview from "./data/ProposalPreview.mock"; +import { ProposalPreview } from "../Proposal"; + +export default { + title: "Proposals | Preview", +}; + +export const Default = () => ; diff --git a/pioneer/packages/joy-proposals/src/stories/ProposalPreviewList.stories.tsx b/pioneer/packages/joy-proposals/src/stories/ProposalPreviewList.stories.tsx new file mode 100644 index 0000000000..8d1f38a50e --- /dev/null +++ b/pioneer/packages/joy-proposals/src/stories/ProposalPreviewList.stories.tsx @@ -0,0 +1,9 @@ +import "../index.css"; +import { ProposalPreviewList } from "../Proposal"; +import withMock from './withMock'; + +export default { + title: "Proposals | Preview List", +}; + +export const Default = () => withMock(ProposalPreviewList); diff --git a/pioneer/packages/joy-proposals/src/stories/ProposalTypes.stories.tsx b/pioneer/packages/joy-proposals/src/stories/ProposalTypes.stories.tsx new file mode 100644 index 0000000000..d3e0797e2d --- /dev/null +++ b/pioneer/packages/joy-proposals/src/stories/ProposalTypes.stories.tsx @@ -0,0 +1,9 @@ +import "../index.css"; +import { ChooseProposalType } from "../Proposal"; +import withMock from './withMock'; + +export default { + title: "Proposals | Proposal Types", +}; + +export const Default = () => withMock(ChooseProposalType); diff --git a/pioneer/packages/joy-proposals/src/stories/data/ProposalDetails.mock.ts b/pioneer/packages/joy-proposals/src/stories/data/ProposalDetails.mock.ts new file mode 100644 index 0000000000..d65e5953a6 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/stories/data/ProposalDetails.mock.ts @@ -0,0 +1,52 @@ +import { ParsedProposal } from "../../runtime"; +import { ProposalId } from "@joystream/types/proposals" + +const mockedProposal: ParsedProposal = { + id: new ProposalId(100), + title: "Awesome Proposal", + description: "Please send me some tokens for coffee", + createdAtBlock: 36, + type: "Text", + details: ["Ciao"], + parameters: { + approvalQuorumPercentage: 66, + approvalThresholdPercentage: 80, + gracePeriod: 0, + requiredStake: 101520, + slashingQuorumPercentage: 60, + slashingThresholdPercentage: 80, + votingPeriod: 7200 + }, + proposerId: 303, + status: { + Active: { + stakeId: 0, + sourceAccountId: "5C4hrfkRjSLwQSFVtCvtbV6wctV1WFnkiexUZWLAh4Bc7jib" + } + }, + proposer: { + about: "Bob", + avatar_uri: "https://react.semantic-ui.com/images/avatar/large/steve.jpg", + controller_account: "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + handle: "bob55", + registered_at_block: 18, + registered_at_time: 1588087314000, + roles: [], + entry: { + Paid: 0 + }, + root_account: "5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty", + subscription: null, + suspended: false + }, + votingResults: { + abstensions: 3, + approvals: 0, + rejections: 1, + slashes: 0 + }, + createdAt: new Date("Mar 25, 2020 at 14:20"), + cancellationFee: 5 +}; + +export default mockedProposal; diff --git a/pioneer/packages/joy-proposals/src/stories/data/ProposalPreview.mock.ts b/pioneer/packages/joy-proposals/src/stories/data/ProposalPreview.mock.ts new file mode 100644 index 0000000000..260e7d787a --- /dev/null +++ b/pioneer/packages/joy-proposals/src/stories/data/ProposalPreview.mock.ts @@ -0,0 +1,8 @@ +import { ProposalPreviewProps } from "../../Proposal/ProposalPreview"; +import mockedProposal from "../data/ProposalDetails.mock"; + +const mockedProposalPreview: ProposalPreviewProps = { + proposal: mockedProposal +}; + +export default mockedProposalPreview; diff --git a/pioneer/packages/joy-proposals/src/stories/data/ProposalPreviewList.mock.ts b/pioneer/packages/joy-proposals/src/stories/data/ProposalPreviewList.mock.ts new file mode 100644 index 0000000000..c2f8a4bbe0 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/stories/data/ProposalPreviewList.mock.ts @@ -0,0 +1,95 @@ +import mockedProposal from "./ProposalDetails.mock"; + +// const MockProposalPreviewList: ParsedProposal[] = [ +// { +// title: "Send me some tokens for coffee", +// description: +// "Change the total reward across all validators in a given block. This is not the direct reward, but base reward for Pallet staking module. The minimum value must be greater than 450 tJOY based on current runtime. Also, coffee is getting expensive.", +// finalized: "approved", +// details: { +// createdBy: { +// name: "Satoshi", +// avatar: "https://react.semantic-ui.com/images/avatar/large/steve.jpg" +// }, +// stage: "Finalized", +// substage: "Grace Period", +// createdAt: "Mar 25, 2020 at 14:20", +// type: "Spending Proposal", +// expiresIn: 5678 +// } +// }, +// { +// title: "Send me some tokens for coffee", +// description: +// "Change the total reward across all validators in a given block. This is not the direct reward, but base reward for Pallet staking module. The minimum value must be greater than 450 tJOY based on current runtime. Also, coffee is getting expensive.", + +// finalized: "slashed", +// details: { +// createdBy: { +// name: "David Douglas", +// avatar: "https://react.semantic-ui.com/images/avatar/large/elliot.jpg" +// }, +// stage: "Active", +// substage: "Grace Period", +// createdAt: "Mar 25, 2020 at 14:20", +// type: "Spending Proposal", +// expiresIn: 5678 +// } +// }, +// { +// title: "Send me some tokens for coffee", +// description: +// "Change the total reward across all validators in a given block. This is not the direct reward, but base reward for Pallet staking module. The minimum value must be greater than 450 tJOY based on current runtime. Also, coffee is getting expensive.", + +// finalized: "approved", +// details: { +// createdBy: { +// name: "David Douglas", +// avatar: "https://react.semantic-ui.com/images/avatar/large/elliot.jpg" +// }, +// stage: "Active", +// substage: "Grace Period", +// createdAt: "Mar 25, 2020 at 14:20", +// type: "Spending Proposal", +// expiresIn: 5678 +// } +// }, +// { +// title: "Send me some tokens for coffee", +// description: +// "Change the total reward across all validators in a given block. This is not the direct reward, but base reward for Pallet staking module. The minimum value must be greater than 450 tJOY based on current runtime. Also, coffee is getting expensive.", + +// finalized: "approved", +// details: { +// createdBy: { +// name: "David Douglas", +// avatar: "https://react.semantic-ui.com/images/avatar/large/elliot.jpg" +// }, +// stage: "Active", +// substage: "Grace Period", +// createdAt: "Mar 25, 2020 at 14:20", +// type: "Spending Proposal", +// expiresIn: 5678 +// } +// }, +// { +// title: "Send me some tokens for coffee", +// description: +// "Change the total reward across all validators in a given block. This is not the direct reward, but base reward for Pallet staking module. The minimum value must be greater than 450 tJOY based on current runtime. Also, coffee is getting expensive.", + +// finalized: "withdrawn", +// details: { +// createdBy: { +// name: "David Douglas", +// avatar: "https://react.semantic-ui.com/images/avatar/large/elliot.jpg" +// }, +// stage: "Active", +// substage: "Grace Period", +// createdAt: "Mar 25, 2020 at 14:20", +// type: "Spending Proposal", +// expiresIn: 5678 +// } +// } +// ]; +const MockProposalPreviewList = Array.from({ length: 5 }, (_, i) => mockedProposal); +export default MockProposalPreviewList; diff --git a/pioneer/packages/joy-proposals/src/stories/data/ProposalTypesInfo.mock.ts b/pioneer/packages/joy-proposals/src/stories/data/ProposalTypesInfo.mock.ts new file mode 100644 index 0000000000..11af0fea74 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/stories/data/ProposalTypesInfo.mock.ts @@ -0,0 +1,160 @@ +import { ProposalTypeInfo } from "../../Proposal/ProposalTypePreview"; +import { Categories } from "../../Proposal/ChooseProposalType"; + +const MockProposalTypesInfo: ProposalTypeInfo[] = [ + { + type: "Text", + category: Categories.other, + image: "https://react.semantic-ui.com/images/wireframe/image.png", + description: + "Change the total reward across all validators in a given block."+ + "This is not the direct reward, but base reward for Pallet staking module."+ + "The minimum value must be greater than 450 tJOY based on current runtime.", + stake: 5, + cancellationFee: 0, + gracePeriod: 0, + votingPeriod: 10000, + approvalQuorum: 80, + approvalThreshold: 80, + slashingQuorum: 80, + slashingThreshold: 80, + }, + { + type: "Spending", + category: Categories.other, + image: "https://react.semantic-ui.com/images/wireframe/image.png", + description: + "Change the total reward across all validators in a given block."+ + "This is not the direct reward, but base reward for Pallet staking module."+ + "The minimum value must be greater than 450 tJOY based on current runtime.", + stake: 10, + cancellationFee: 5, + gracePeriod: 3, + votingPeriod: 10000, + approvalQuorum: 80, + approvalThreshold: 80, + slashingQuorum: 80, + slashingThreshold: 80, + }, + { + type: "RuntimeUpgrade", + category: Categories.other, + image: "https://react.semantic-ui.com/images/wireframe/image.png", + description: + "Change the total reward across all validators in a given block."+ + "This is not the direct reward, but base reward for Pallet staking module."+ + "The minimum value must be greater than 450 tJOY based on current runtime.", + stake: 100, + cancellationFee: 10, + gracePeriod: 14, + votingPeriod: 10000, + approvalQuorum: 80, + approvalThreshold: 80, + slashingQuorum: 80, + slashingThreshold: 80, + }, + { + type: "EvictStorageProvider", + category: Categories.storage, + image: "https://react.semantic-ui.com/images/wireframe/image.png", + description: + "Change the total reward across all validators in a given block."+ + "This is not the direct reward, but base reward for Pallet staking module."+ + "The minimum value must be greater than 450 tJOY based on current runtime.", + stake: 100, + cancellationFee: 10, + gracePeriod: 1, + votingPeriod: 10000, + approvalQuorum: 80, + approvalThreshold: 80, + slashingQuorum: 80, + slashingThreshold: 80, + }, + { + type: "SetStorageRoleParameters", + category: Categories.storage, + image: "https://react.semantic-ui.com/images/wireframe/image.png", + description: + "Change the total reward across all validators in a given block."+ + "This is not the direct reward, but base reward for Pallet staking module."+ + "The minimum value must be greater than 450 tJOY based on current runtime.", + stake: 500, + cancellationFee: 60, + gracePeriod: 14, + votingPeriod: 10000, + approvalQuorum: 80, + approvalThreshold: 80, + slashingQuorum: 80, + slashingThreshold: 80, + }, + { + type: "SetValidatorCount", + category: Categories.validators, + image: "https://react.semantic-ui.com/images/wireframe/image.png", + description: + "Change the total reward across all validators in a given block."+ + "This is not the direct reward, but base reward for Pallet staking module."+ + "The minimum value must be greater than 450 tJOY based on current runtime.", + stake: 45, + cancellationFee: 10, + gracePeriod: 5, + votingPeriod: 10000, + approvalQuorum: 80, + approvalThreshold: 80, + slashingQuorum: 80, + slashingThreshold: 80, + }, + { + type: "SetContentWorkingGroupMintCapacity", + category: Categories.cwg, + image: "https://react.semantic-ui.com/images/wireframe/image.png", + description: + "Change the total reward across all validators in a given block."+ + "This is not the direct reward, but base reward for Pallet staking module."+ + "The minimum value must be greater than 450 tJOY based on current runtime.", + stake: 90, + cancellationFee: 8, + gracePeriod: 5, + votingPeriod: 10000, + approvalQuorum: 80, + approvalThreshold: 80, + slashingQuorum: 80, + slashingThreshold: 80, + }, + { + type: "SetLead", + category: Categories.cwg, + image: "https://react.semantic-ui.com/images/wireframe/image.png", + description: + "Change the total reward across all validators in a given block."+ + "This is not the direct reward, but base reward for Pallet staking module."+ + "The minimum value must be greater than 450 tJOY based on current runtime.", + stake: 500, + cancellationFee: 50, + gracePeriod: 7, + votingPeriod: 10000, + approvalQuorum: 80, + approvalThreshold: 80, + slashingQuorum: 80, + slashingThreshold: 80, + }, + { + type: "SetElectionParameters", + category: Categories.council, + image: "https://react.semantic-ui.com/images/wireframe/image.png", + description: + "Change the total reward across all validators in a given block."+ + "This is not the direct reward, but base reward for Pallet staking module."+ + "The minimum value must be greater than 450 tJOY based on current runtime.", + stake: 1000, + cancellationFee: 100, + gracePeriod: 30, + votingPeriod: 10000, + approvalQuorum: 80, + approvalThreshold: 80, + slashingQuorum: 80, + slashingThreshold: 80, + }, +]; + +export default MockProposalTypesInfo; diff --git a/pioneer/packages/joy-proposals/src/stories/withMock.tsx b/pioneer/packages/joy-proposals/src/stories/withMock.tsx new file mode 100644 index 0000000000..4b12c0e01d --- /dev/null +++ b/pioneer/packages/joy-proposals/src/stories/withMock.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { createMemoryHistory, createLocation } from 'history'; +import { match, RouteComponentProps } from 'react-router'; + +const history = createMemoryHistory(); +const path = `/`; +const matchObj: match<{}> = { + isExact: false, + path, + url: path, + params: {} +}; +const location = createLocation(path); + +const MockRouteProps: RouteComponentProps = { + history, + match: matchObj, + location +} + +export default function withMock(Component: React.ComponentType) { + // TODO: Use mock transport + return ; +} diff --git a/pioneer/packages/joy-proposals/src/translate.ts b/pioneer/packages/joy-proposals/src/translate.ts new file mode 100644 index 0000000000..ebd6954450 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/translate.ts @@ -0,0 +1,3 @@ +import { withTranslation } from 'react-i18next'; + +export default withTranslation(['proposals', 'ui']); diff --git a/pioneer/packages/joy-proposals/src/utils.ts b/pioneer/packages/joy-proposals/src/utils.ts new file mode 100644 index 0000000000..94fbda071b --- /dev/null +++ b/pioneer/packages/joy-proposals/src/utils.ts @@ -0,0 +1,273 @@ +import { useState, useEffect, useCallback } from "react"; +import { ProposalType } from "./runtime"; +import { Category } from "./Proposal/ChooseProposalType"; +import { useTransport, ParsedProposal, ProposalVote } from "./runtime"; +import { ProposalId } from "@joystream/types/proposals"; + +type ProposalMeta = { + description: string; + category: Category; + image: string; + approvalQuorum: number; + approvalThreshold: number; + slashingQuorum: number; + slashingThreshold: number; +} + +export function includeKeys(obj: T, ...allowedKeys: string[]) { + return Object.keys(obj).filter(objKey => { + return allowedKeys.reduce( + (hasAllowed: boolean, allowedKey: string) => hasAllowed || objKey.includes(allowedKey), + false + ); + }); +} + +export function splitOnUpperCase(str: string) { + return str.split(/(?=[A-Z])/); +} + +export function slugify(str: string) { + return splitOnUpperCase(str) + .map(w => w.toLowerCase()) + .join("-") + .trim(); +} + +export function snakeCaseToCamelCase(str: string) { + return str + .split('_') + .map((w, i) => i ? w[0].toUpperCase() + w.substr(1) : w) + .join(''); +} + +export function camelCaseToSnakeCase(str: string) { + return splitOnUpperCase(str) + .map(w => w[0].toLocaleLowerCase() + w.substr(1)) + .join('_'); +} + +export function usePromise(promise: () => Promise, defaultValue: T): [T, any, boolean, () => Promise] { + const [state, setState] = useState<{ + value: T; + error: any; + isPending: boolean; + }>({ value: defaultValue, error: null, isPending: true }); + + let isSubscribed = true; + const execute = useCallback(() => { + return promise() + .then(value => (isSubscribed ? setState({ value, error: null, isPending: false }) : null)) + .catch(error => (isSubscribed ? setState({ value: defaultValue, error: error, isPending: false }) : null)); + }, [promise]); + + useEffect(() => { + execute(); + return () => { + isSubscribed = false; + }; + }, []); + + const { value, error, isPending } = state; + return [value, error, isPending, execute]; +} + +// Take advantage of polkadot api subscriptions to re-fetch proposal data and votes +// each time there is some runtime change in the proposal +export const useProposalSubscription = (id: ProposalId) => { + const transport = useTransport(); + // State holding an "unsubscribe method" + const [unsubscribeProposal, setUnsubscribeProposal] = useState<(() => void) | null>(null); + + const [proposal, proposalError, proposalLoading, refreshProposal] = usePromise( + () => transport.proposalById(id), + {} as ParsedProposal + ); + + const [votes, votesError, votesLoading, refreshVotes] = usePromise( + () => transport.votes(id), + [] + ); + + // Function to re-fetch the data using transport + const refreshProposalData = () => { + refreshProposal(); + refreshVotes(); + } + + useEffect(() => { + // onMount... + let unmounted = false; + // Create the subscription + transport.proposalsEngine.proposals(id, refreshProposalData) + .then(unsubscribe => { + if (!unmounted) { + setUnsubscribeProposal(() => unsubscribe); + } + else { + unsubscribe(); // If already unmounted - unsubscribe immedietally! + } + }); + return () => { + // onUnmount... + // Clean the subscription + unmounted = true; + if (unsubscribeProposal !== null) unsubscribeProposal(); + } + }, []); + + return { + proposal: { data: proposal, error: proposalError, loading: proposalLoading }, + votes: { data: votes, error: votesError, loading: votesLoading } + } +}; + + +export function calculateStake(type: ProposalType, issuance: number) { + let stake = NaN; + switch (type) { + case "EvictStorageProvider": { + stake = 25000; + break; + } + case "Text": + stake = 25000; + break; + case "SetStorageRoleParameters": + stake = 100000; + break; + case "SetValidatorCount": + stake = 100000; + break; + case "SetLead": + stake = 50000; + break; + case "SetContentWorkingGroupMintCapacity": + stake = 50000; + break; + case "Spending": { + stake = 25000; + break; + } + case "SetElectionParameters": { + stake = 200000; + break; + } + case "RuntimeUpgrade": { + stake = 1000000; + break; + } + default: { + throw new Error(`Proposal Type is invalid. Got ${type}. Can't calculate issuance.`); + } + } + return stake; +} + +export function calculateMetaFromType(type: ProposalType): ProposalMeta { + const image = ""; + switch (type) { + case "EvictStorageProvider": { + return { + description: "Evicting Storage Provider Proposal", + category: "Storage", + image, + approvalQuorum: 50, + approvalThreshold: 75, + slashingQuorum: 60, + slashingThreshold: 80, + } + } + case "Text": { + return { + description: "Signal Proposal", + category: "Other", + image, + approvalQuorum: 60, + approvalThreshold: 80, + slashingQuorum: 60, + slashingThreshold: 80, + } + } + case "SetStorageRoleParameters": { + return { + description: "Set Storage Role Params Proposal", + category: "Storage", + image, + approvalQuorum: 66, + approvalThreshold: 80, + slashingQuorum: 60, + slashingThreshold: 80, + } + } + case "SetValidatorCount": { + return { + description: "Set Max Validator Count Proposal", + category: "Validators", + image, + approvalQuorum: 66, + approvalThreshold: 80, + slashingQuorum: 60, + slashingThreshold: 80, + } + } + case "SetLead": { + return { + description: "Set Lead Proposal", + category: "Content Working Group", + image, + approvalQuorum: 60, + approvalThreshold: 75, + slashingQuorum: 60, + slashingThreshold: 80, + } + } + case "SetContentWorkingGroupMintCapacity": { + return { + description: "Set WG Mint Capacity Proposal", + category: "Content Working Group", + image, + approvalQuorum: 60, + approvalThreshold: 75, + slashingQuorum: 60, + slashingThreshold: 80, + } + } + case "Spending": { + return { + description: "Spending Proposal", + category: "Other", + image, + approvalQuorum: 60, + approvalThreshold: 80, + slashingQuorum: 60, + slashingThreshold: 80, + } + } + case "SetElectionParameters": { + return { + description: "Set Election Parameters Proposal", + category: "Council", + image, + approvalQuorum: 66, + approvalThreshold: 80, + slashingQuorum: 60, + slashingThreshold: 80, + } + } + case "RuntimeUpgrade": { + return { + description: "Runtime Upgrade Proposal", + category: "Other", + image, + approvalQuorum: 80, + approvalThreshold: 100, + slashingQuorum: 60, + slashingThreshold: 80, + } + } + default: { + throw new Error("'Proposal Type is invalid. Can't calculate metadata."); + } + } +} diff --git a/pioneer/packages/joy-proposals/src/validationSchema.ts b/pioneer/packages/joy-proposals/src/validationSchema.ts new file mode 100644 index 0000000000..9760648d95 --- /dev/null +++ b/pioneer/packages/joy-proposals/src/validationSchema.ts @@ -0,0 +1,354 @@ +import * as Yup from "yup"; +import { checkAddress } from "@polkadot/util-crypto"; + +// TODO: If we really need this (currency unit) we can we make "Validation" a functiction that returns an object. +// We could then "instantialize" it in "withFormContainer" where instead of passing +// "validationSchema" (in each form component file) we would just pass "validationSchemaKey" or just "proposalType" (ie. SetLead). +// Then we could let the "withFormContainer" handle the actual "validationSchema" for "withFormik". In that case it could easily +// pass stuff like totalIssuance or currencyUnit here (ie.: const validationSchema = Validation(currencyUnit, totalIssuance)[proposalType];) +const CURRENCY_UNIT = undefined; + +// All +const TITLE_MAX_LENGTH = 40; +const RATIONALE_MAX_LENGTH = 3000; + +// Text +const DESCRIPTION_MAX_LENGTH = 5000; + +// Runtime Upgrade +const FILE_SIZE_BYTES_MIN = 1; +const FILE_SIZE_BYTES_MAX = 2000000; + +// Set Election Parameters +const ANNOUNCING_PERIOD_MAX = 43200; +const ANNOUNCING_PERIOD_MIN = 14400; +const VOTING_PERIOD_MIN = 14400; +const VOTING_PERIOD_MAX = 28800; +const REVEALING_PERIOD_MIN = 14400; +const REVEALING_PERIOD_MAX = 28800; +const MIN_COUNCIL_STAKE_MIN = 1; +const MIN_COUNCIL_STAKE_MAX = 100000; +const NEW_TERM_DURATION_MIN = 14400; +const NEW_TERM_DURATION_MAX = 432000; +const CANDIDACY_LIMIT_MIN = 25; +const CANDIDACY_LIMIT_MAX = 100; +const COUNCIL_SIZE_MAX = 20; +const COUNCIL_SIZE_MIN = 4; +const MIN_VOTING_STAKE_MIN = 1; +const MIN_VOTING_STAKE_MAX = 100000; + +// Spending +const TOKENS_MIN = 0; +const TOKENS_MAX = 2000000; + +// Set Validator Count +const MAX_VALIDATOR_COUNT_MIN = 4; +const MAX_VALIDATOR_COUNT_MAX = 100; + +// Content Working Group Mint Capacity +const MINT_CAPACITY_MIN = 0; +const MINT_CAPACITY_MAX = 1000000; + +// Set Storage Role Parameters +const MIN_STAKE_MIN = 1; +const MIN_STAKE_MAX = 10000000; +const MIN_ACTORS_MIN = 0; +const MIN_ACTORS_MAX = 1; +const MAX_ACTORS_MIN = 2; +const MAX_ACTORS_MAX = 99; +const REWARD_MIN = 1; +const REWARD_MAX = 999; +const REWARD_PERIOD_MIN = 600; +const REWARD_PERIOD_MAX = 3600; +const BONDING_PERIOD_MIN = 600; +const BONDING_PERIOD_MAX = 28800; +const UNBONDING_PERIOD_MIN = 600; +const UNBONDING_PERIOD_MAX = 28800; +const MIN_SERVICE_PERIOD_MIN = 600; +const MIN_SERVICE_PERIOD_MAX = 28800; +const STARTUP_GRACE_PERIOD_MIN = 600; +const STARTUP_GRACE_PERIOD_MAX = 28800; +const ENTRY_REQUEST_FEE_MIN = 1; +const ENTRY_REQUEST_FEE_MAX = 100000; + +function errorMessage(name: string, min?: number | string, max?: number | string, unit?: string): string { + return `${name} should be at least ${min} and no more than ${max}${unit ? ` ${unit}.` : "."}`; +} + +/* +Validation is used to validate a proposal form. +Each proposal type should validate the fields of his form, anything is valid as long as it fits in a Yup Schema. +In a form, validation should be injected in the Yup Schema just by accessing it in this object. +Ex: +// EvictStorageProvider Form + +import Validation from 'path/to/validationSchema' +... + validationSchema: Yup.object().shape({ + ...genericFormDefaultOptions.validationSchema, + storageProvider: Validation.EvictStorageProvider.storageProvider + }), + +*/ + +type ValidationType = { + All: { + title: Yup.StringSchema; + rationale: Yup.StringSchema; + }; + Text: { + description: Yup.StringSchema; + }; + RuntimeUpgrade: { + WASM: Yup.StringSchema; + }; + SetElectionParameters: { + announcingPeriod: Yup.NumberSchema; + votingPeriod: Yup.NumberSchema; + minVotingStake: Yup.NumberSchema; + revealingPeriod: Yup.NumberSchema; + minCouncilStake: Yup.NumberSchema; + newTermDuration: Yup.NumberSchema; + candidacyLimit: Yup.NumberSchema; + councilSize: Yup.NumberSchema; + }; + Spending: { + tokens: Yup.NumberSchema; + destinationAccount: Yup.StringSchema; + }; + SetLead: { + workingGroupLead: Yup.StringSchema; + }; + SetContentWorkingGroupMintCapacity: { + mintCapacity: Yup.NumberSchema; + }; + EvictStorageProvider: { + storageProvider: Yup.StringSchema; + }; + SetValidatorCount: { + maxValidatorCount: Yup.NumberSchema; + }; + SetStorageRoleParameters: { + min_stake: Yup.NumberSchema; + min_actors: Yup.NumberSchema; + max_actors: Yup.NumberSchema; + reward: Yup.NumberSchema; + reward_period: Yup.NumberSchema; + bonding_period: Yup.NumberSchema; + unbonding_period: Yup.NumberSchema; + min_service_period: Yup.NumberSchema; + startup_grace_period: Yup.NumberSchema; + entry_request_fee: Yup.NumberSchema; + }; +}; + +const Validation: ValidationType = { + All: { + title: Yup.string() + .required("Title is required!") + .max(TITLE_MAX_LENGTH, `Title should be under ${TITLE_MAX_LENGTH} characters.`), + rationale: Yup.string() + .required("Rationale is required!") + .max(RATIONALE_MAX_LENGTH, `Rationale should be under ${RATIONALE_MAX_LENGTH} characters.`) + }, + Text: { + description: Yup.string() + .required("Description is required!") + .max(DESCRIPTION_MAX_LENGTH, `Description should be under ${DESCRIPTION_MAX_LENGTH}`) + }, + RuntimeUpgrade: { + WASM: Yup.string() + .required("A file is required") + .min(FILE_SIZE_BYTES_MIN, "File is empty.") + .max(FILE_SIZE_BYTES_MAX, `The maximum file size is ${FILE_SIZE_BYTES_MAX} bytes.`) + }, + SetElectionParameters: { + announcingPeriod: Yup.number() + .required("All fields must be filled!") + .integer("This field must be an integer.") + .min( + ANNOUNCING_PERIOD_MIN, + errorMessage("The announcing period", ANNOUNCING_PERIOD_MIN, ANNOUNCING_PERIOD_MAX, "blocks") + ) + .max( + ANNOUNCING_PERIOD_MAX, + errorMessage("The announcing period", ANNOUNCING_PERIOD_MIN, ANNOUNCING_PERIOD_MAX, "blocks") + ), + votingPeriod: Yup.number() + .required("All fields must be filled!") + .integer("This field must be an integer.") + .min(VOTING_PERIOD_MIN, errorMessage("The voting period", VOTING_PERIOD_MIN, VOTING_PERIOD_MAX, "blocks")) + .max(VOTING_PERIOD_MAX, errorMessage("The voting period", VOTING_PERIOD_MIN, VOTING_PERIOD_MAX, "blocks")), + minVotingStake: Yup.number() + .required("All fields must be filled!") + .integer("This field must be an integer.") + .min( + MIN_VOTING_STAKE_MIN, + errorMessage("The minimum voting stake", MIN_VOTING_STAKE_MIN, MIN_VOTING_STAKE_MAX, CURRENCY_UNIT) + ) + .max( + MIN_VOTING_STAKE_MAX, + errorMessage("The minimum voting stake", MIN_VOTING_STAKE_MIN, MIN_VOTING_STAKE_MAX, CURRENCY_UNIT) + ), + revealingPeriod: Yup.number() + .required("All fields must be filled!") + .integer("This field must be an integer.") + .min( + REVEALING_PERIOD_MIN, + errorMessage("The revealing period", REVEALING_PERIOD_MIN, REVEALING_PERIOD_MAX, "blocks") + ) + .max( + REVEALING_PERIOD_MAX, + errorMessage("The revealing period", REVEALING_PERIOD_MIN, REVEALING_PERIOD_MAX, "blocks") + ), + minCouncilStake: Yup.number() + .required("All fields must be filled!") + .integer("This field must be an integer.") + .min( + MIN_COUNCIL_STAKE_MIN, + errorMessage("The minimum council stake", MIN_COUNCIL_STAKE_MIN, MIN_COUNCIL_STAKE_MAX, CURRENCY_UNIT) + ) + .max( + MIN_COUNCIL_STAKE_MAX, + errorMessage("The minimum council stake", MIN_COUNCIL_STAKE_MIN, MIN_COUNCIL_STAKE_MAX, CURRENCY_UNIT) + ), + newTermDuration: Yup.number() + .required("All fields must be filled!") + .integer("This field must be an integer.") + .min( + NEW_TERM_DURATION_MIN, + errorMessage("The new term duration", NEW_TERM_DURATION_MIN, NEW_TERM_DURATION_MAX, "blocks") + ) + .max( + NEW_TERM_DURATION_MAX, + errorMessage("The new term duration", NEW_TERM_DURATION_MIN, NEW_TERM_DURATION_MAX, "blocks") + ), + candidacyLimit: Yup.number() + .required("All fields must be filled!") + .integer("This field must be an integer.") + .min(CANDIDACY_LIMIT_MIN, errorMessage("The candidacy limit", CANDIDACY_LIMIT_MIN, CANDIDACY_LIMIT_MAX)) + .max(CANDIDACY_LIMIT_MAX, errorMessage("The candidacy limit", CANDIDACY_LIMIT_MIN, CANDIDACY_LIMIT_MAX)), + councilSize: Yup.number() + .required("All fields must be filled!") + .integer("This field must be an integer.") + .min(COUNCIL_SIZE_MIN, errorMessage("The council size", COUNCIL_SIZE_MIN, COUNCIL_SIZE_MAX)) + .max(COUNCIL_SIZE_MAX, errorMessage("The council size", COUNCIL_SIZE_MIN, COUNCIL_SIZE_MAX)) + }, + Spending: { + tokens: Yup.number() + .positive("The token amount should be positive.") + .integer("This field must be an integer.") + .max(TOKENS_MAX, errorMessage("The amount of tokens", TOKENS_MIN, TOKENS_MAX)) + .required("You need to specify an amount of tokens."), + destinationAccount: Yup.string() + .required("Select a destination account!") + .test("address-test", "${account} is not a valid address.", account => !!checkAddress(account, 5)) + }, + SetLead: { + workingGroupLead: Yup.string().required("Select a proposed lead!") + }, + SetContentWorkingGroupMintCapacity: { + mintCapacity: Yup.number() + .positive("Mint capacity should be positive.") + .integer("This field must be an integer.") + .min(MINT_CAPACITY_MIN, errorMessage("Mint capacity", MINT_CAPACITY_MIN, MINT_CAPACITY_MAX, CURRENCY_UNIT)) + .max(MINT_CAPACITY_MAX, errorMessage("Mint capacity", MINT_CAPACITY_MIN, MINT_CAPACITY_MAX, CURRENCY_UNIT)) + .required("You need to specify a mint capacity.") + }, + EvictStorageProvider: { + storageProvider: Yup.string() + .nullable() + .required("Select a storage provider!") + }, + SetValidatorCount: { + maxValidatorCount: Yup.number() + .required("Enter the max validator count") + .integer("This field must be an integer.") + .min( + MAX_VALIDATOR_COUNT_MIN, + errorMessage("The max validator count", MAX_VALIDATOR_COUNT_MIN, MAX_VALIDATOR_COUNT_MAX) + ) + .max( + MAX_VALIDATOR_COUNT_MAX, + errorMessage("The max validator count", MAX_VALIDATOR_COUNT_MIN, MAX_VALIDATOR_COUNT_MAX) + ) + }, + SetStorageRoleParameters: { + min_stake: Yup.number() + .required("All parameters are required") + .positive("The minimum stake should be positive.") + .integer("This field must be an integer.") + .max(MIN_STAKE_MAX, errorMessage("Minimum stake", MIN_STAKE_MIN, MIN_STAKE_MAX, CURRENCY_UNIT)), + min_actors: Yup.number() + .required("All parameters are required") + .integer("This field must be an integer.") + .min(MIN_ACTORS_MIN, errorMessage("Minimum actors", MIN_ACTORS_MIN, MIN_ACTORS_MAX)) + .max(MIN_ACTORS_MAX, errorMessage("Minimum actors", MIN_ACTORS_MIN, MIN_ACTORS_MAX)), + max_actors: Yup.number() + .required("All parameters are required") + .integer("This field must be an integer.") + .min(MAX_ACTORS_MIN, errorMessage("Max actors", MAX_ACTORS_MIN, MAX_ACTORS_MAX)) + .max(MAX_ACTORS_MAX, errorMessage("Max actors", MAX_ACTORS_MIN, MAX_ACTORS_MAX)), + reward: Yup.number() + .required("All parameters are required") + .integer("This field must be an integer.") + .min(REWARD_MIN, errorMessage("Reward", REWARD_MIN, REWARD_MAX, CURRENCY_UNIT)) + .max(REWARD_MAX, errorMessage("Reward", REWARD_MIN, REWARD_MAX, CURRENCY_UNIT)), + reward_period: Yup.number() + .required("All parameters are required") + .integer("This field must be an integer.") + .min(REWARD_PERIOD_MIN, errorMessage("The reward period", REWARD_PERIOD_MIN, REWARD_PERIOD_MAX, "blocks")) + .max(REWARD_PERIOD_MAX, errorMessage("The reward period", REWARD_PERIOD_MIN, REWARD_PERIOD_MAX, "blocks")), + bonding_period: Yup.number() + .required("All parameters are required") + .integer("This field must be an integer.") + .min(BONDING_PERIOD_MIN, errorMessage("The bonding period", BONDING_PERIOD_MIN, BONDING_PERIOD_MAX, "blocks")) + .max(BONDING_PERIOD_MAX, errorMessage("The bonding period", BONDING_PERIOD_MIN, BONDING_PERIOD_MAX, "blocks")), + unbonding_period: Yup.number() + .required("All parameters are required") + .integer("This field must be an integer.") + .min( + UNBONDING_PERIOD_MIN, + errorMessage("The unbonding period", UNBONDING_PERIOD_MIN, UNBONDING_PERIOD_MAX, "blocks") + ) + .max( + UNBONDING_PERIOD_MAX, + errorMessage("The unbonding period", UNBONDING_PERIOD_MIN, UNBONDING_PERIOD_MAX, "blocks") + ), + min_service_period: Yup.number() + .required("All parameters are required") + .integer("This field must be an integer.") + .min( + MIN_SERVICE_PERIOD_MIN, + errorMessage("The minimum service period", MIN_SERVICE_PERIOD_MIN, MIN_SERVICE_PERIOD_MAX, "blocks") + ) + .max( + MIN_SERVICE_PERIOD_MAX, + errorMessage("The minimum service period", MIN_SERVICE_PERIOD_MIN, MIN_SERVICE_PERIOD_MAX, "blocks") + ), + startup_grace_period: Yup.number() + .required("All parameters are required") + .integer("This field must be an integer.") + .min( + STARTUP_GRACE_PERIOD_MIN, + errorMessage("The startup grace period", STARTUP_GRACE_PERIOD_MIN, STARTUP_GRACE_PERIOD_MAX, "blocks") + ) + .max( + STARTUP_GRACE_PERIOD_MAX, + errorMessage("The startup grace period", STARTUP_GRACE_PERIOD_MIN, STARTUP_GRACE_PERIOD_MAX, "blocks") + ), + entry_request_fee: Yup.number() + .required("All parameters are required") + .integer("This field must be an integer.") + .min( + ENTRY_REQUEST_FEE_MIN, + errorMessage("The entry request fee", ENTRY_REQUEST_FEE_MIN, ENTRY_REQUEST_FEE_MAX, CURRENCY_UNIT) + ) + .max( + STARTUP_GRACE_PERIOD_MAX, + errorMessage("The entry request fee", ENTRY_REQUEST_FEE_MIN, ENTRY_REQUEST_FEE_MAX, CURRENCY_UNIT) + ), + } +}; + +export default Validation; diff --git a/pioneer/packages/joy-roles/README.md b/pioneer/packages/joy-roles/README.md new file mode 100644 index 0000000000..ec3c03a581 --- /dev/null +++ b/pioneer/packages/joy-roles/README.md @@ -0,0 +1 @@ +# @polkadot/joy-roles \ No newline at end of file diff --git a/pioneer/packages/joy-roles/jest.config.js b/pioneer/packages/joy-roles/jest.config.js new file mode 100644 index 0000000000..4a3045541f --- /dev/null +++ b/pioneer/packages/joy-roles/jest.config.js @@ -0,0 +1,13 @@ +const { pathsToModuleNameMapper } = require('ts-jest/utils'); +// In the following statement, replace `./tsconfig` with the path to your `tsconfig` file +// which contains the path mapping (ie the `compilerOptions.paths` option): +const { compilerOptions } = require('../../tsconfig.json'); + +module.exports = { + "roots": [ + "/src" + ], + preset: 'ts-jest', + testEnvironment: 'node', + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths , { prefix: '/../../' } ), +}; diff --git a/pioneer/packages/joy-roles/package.json b/pioneer/packages/joy-roles/package.json new file mode 100644 index 0000000000..07bbeba30d --- /dev/null +++ b/pioneer/packages/joy-roles/package.json @@ -0,0 +1,28 @@ +{ + "name": "@polkadot/joy-roles", + "version": "0.0.1", + "description": "Staked roles module for Joystream node", + "main": "index.js", + "scripts": { + "test": "jest", + "test-watch": "jest --watch", + "lint": "eslint -c ../../tsconfig.eslint.json --ext .js,.jsx,.ts,.tsx . && tsc --noEmit --pretty" + }, + "author": "Joystream contributors", + "maintainers": [], + "dependencies": { + "@babel/runtime": "^7.7.1", + "@polkadot/joy-utils": "^0.1.1", + "@polkadot/react-components": "0.37.0-beta.63", + "@polkadot/react-query": "0.37.0-beta.63", + "@types/faker": "^4.1.8", + "faker": "^4.1.0", + "marked": "^0.7.0", + "moment": "^2.24.0", + "react-compound-slider": "^2.4.0", + "react-moment": "^0.9.6", + "react-number-format": "^4.3.1", + "react-semantic-ui-range": "^0.7.0", + "typescript-formatter": "^7.2.2" + } +} diff --git a/pioneer/packages/joy-roles/src/OpeningMetadata.ts b/pioneer/packages/joy-roles/src/OpeningMetadata.ts new file mode 100644 index 0000000000..86dd60b2f2 --- /dev/null +++ b/pioneer/packages/joy-roles/src/OpeningMetadata.ts @@ -0,0 +1,8 @@ +export type OpeningMetadata = { + id: string, + group: string, +} + +export type OpeningMetadataProps = { + meta: OpeningMetadata +} diff --git a/pioneer/packages/joy-roles/src/StakeRequirement.tsx b/pioneer/packages/joy-roles/src/StakeRequirement.tsx new file mode 100644 index 0000000000..0cabb99d55 --- /dev/null +++ b/pioneer/packages/joy-roles/src/StakeRequirement.tsx @@ -0,0 +1,81 @@ +import React from 'react' + +import { Balance } from '@polkadot/types/interfaces'; +import { formatBalance } from '@polkadot/util'; + +export enum StakeType { + Fixed = 0, + AtLeast, +} + +export interface IStakeRequirement { + anyRequirement(): boolean + qualifier(): string | null + value: Balance + fixed(): boolean + atLeast(): boolean + describe(): any +} + +export abstract class StakeRequirement { + hard: Balance + type: StakeType + + constructor(hard: Balance, stakeType: StakeType = StakeType.Fixed) { + this.hard = hard + this.type = stakeType + } + + anyRequirement(): boolean { + return !this.hard.isZero() + } + + qualifier(): string | null { + if (this.type == StakeType.AtLeast) { + return "at least" + } + return null + } + + get value(): Balance { + return this.hard + } + + fixed(): boolean { + return this.type === StakeType.Fixed + } + + atLeast(): boolean { + return this.type === StakeType.AtLeast + } +} + +export class ApplicationStakeRequirement extends StakeRequirement implements IStakeRequirement { + describe(): any { + if (!this.anyRequirement()) { + return null + } + + return ( +

+ You must stake {this.qualifier()} {formatBalance(this.hard)} to apply for this role. This stake will be returned to you when the hiring process is complete, whether or not you are hired, and will also be used to rank applications. +

+ ) + } +} + +export class RoleStakeRequirement extends StakeRequirement implements IStakeRequirement { + describe(): any { + if (!this.anyRequirement()) { + return null + } + + return ( +

+ You must stake {this.qualifier()} {formatBalance(this.hard)} to be eligible for this role. You may lose this stake if you're hired and then dismised from this role. This stake will be returned if your application is unsuccessful, and will also be used to rank applications. +

+ ) + } +} + + diff --git a/pioneer/packages/joy-roles/src/balances.spec.ts b/pioneer/packages/joy-roles/src/balances.spec.ts new file mode 100644 index 0000000000..cbdde3f371 --- /dev/null +++ b/pioneer/packages/joy-roles/src/balances.spec.ts @@ -0,0 +1,50 @@ +import { Balance } from '@polkadot/types/interfaces'; +import { u128 } from '@polkadot/types' +import { Avg, AvgDelta, Min, Step, Sum } from './balances' + +describe('Balance arithmetic', (): void => { + it("Can calculate a sum", (): void => { + const input: Balance[] = [] + for (let i = 0; i < 10; i++) { + input.push(new u128(i)) + } + + expect(Sum(input).toNumber()).toEqual(45) + }); + + it("Can calculate an average", (): void => { + const input: Balance[] = [] + for (let i = 0; i < 10; i++) { + input.push(new u128(i)) + } + + expect(Avg(input).toNumber()).toEqual(4) + }); + + it("Can calculate an average delta", (): void => { + const input: Balance[] = [] + for (let i = 0; i < 10; i++) { + input.push(new u128(i)) + } + + expect(AvgDelta(input).toNumber()).toEqual(1) + }); + + it("Can calculate a step value with large numbers", (): void => { + const input: Balance[] = [] + for (let i = 0; i < 10; i++) { + input.push(new u128(i * 10)) + } + + expect(Step(input).toNumber()).toEqual(4) + }); + + it("Can calculate a step value with small numbers", (): void => { + const input: Balance[] = [] + for (let i = 0; i < 10; i++) { + input.push(new u128(i)) + } + + expect(Min(Step(input)).toNumber()).toEqual(1) + }); +}) diff --git a/pioneer/packages/joy-roles/src/balances.ts b/pioneer/packages/joy-roles/src/balances.ts new file mode 100644 index 0000000000..21770b91c3 --- /dev/null +++ b/pioneer/packages/joy-roles/src/balances.ts @@ -0,0 +1,43 @@ +import { Balance } from '@polkadot/types/interfaces'; +import { u128 } from '@polkadot/types' + +export const Zero = new u128(0) +export const One = new u128(1) + +export const Add = (x: Balance, y: Balance): Balance => new u128(x.add(y)) +export const Sub = (x: Balance, y: Balance): Balance => new u128(x.sub(y)) +export const Sum = (balances: Balance[]): Balance => balances.reduce(Add, Zero) + +export const Avg = (xs: Balance[]): Balance => + xs[0] === undefined ? Zero : new u128(Sum(xs).divn(xs.length)) + +export const AvgDelta = (xs: Balance[]): Balance => { + if (xs.length < 2) { + return One + } + + const pairs: Balance[] = [] + + xs.forEach((x, i) => { + if (i > 0) { + pairs.push(Sub(x, xs[i - 1])) + } + }) + + return Avg(pairs) +} + +// An average value to 'step' up balances, like on the nudge controls for a slider +export const Step = (xs: Balance[], ticks: number = 10): Balance => new u128(Avg(xs).divn(ticks)) +export const Min = (x: Balance, min: Balance = One): Balance => x.gte(min) ? x : min +export const Sort = (xs: Balance[]): Balance[] => { + xs.sort((a, b): number => { + if (a.eq(b)) { + return 0 + } else if (a.gt(b)) { + return 1 + } + return -1 + }) + return xs +} diff --git a/pioneer/packages/joy-roles/src/classifiers.spec.ts b/pioneer/packages/joy-roles/src/classifiers.spec.ts new file mode 100644 index 0000000000..3976687ed7 --- /dev/null +++ b/pioneer/packages/joy-roles/src/classifiers.spec.ts @@ -0,0 +1,192 @@ +import { Option, Text, u32 } from '@polkadot/types'; +import { + AcceptingApplications, + ActiveOpeningStage, + ApplicationRationingPolicy, + StakingPolicy, + Opening, OpeningStage, + ReviewPeriod, +} from "@joystream/types/hiring" + +import { + OpeningState, + IBlockQueryer, + classifyOpeningStage, OpeningStageClassification, +} from './classifiers' + +class mockBlockQueryer { + hash: string + timestamp: Date + + constructor( + hash: string = "somehash", + timestamp: Date = new Date(), + ) { + this.hash = hash + this.timestamp = timestamp + } + + async blockHash(height: number): Promise { + return this.hash + } + + async blockTimestamp(height: number): Promise { + return this.timestamp + } + + async expectedBlockTime(): Promise { + return 6 + } +} + +type Test = { + description: string + input: { + queryer: IBlockQueryer, + opening: Opening, + } + output: OpeningStageClassification +} + +describe('hiring.Opening-> OpeningStageClassification', (): void => { + const now = new Date("2020-01-23T11:47:04.433Z") + + const cases: Test[] = [ + { + description: "WaitingToBegin", + input: { + opening: new Opening({ + created: new u32(100), + stage: new OpeningStage({ + 'WaitingToBegin': { + begins_at_block: new u32(100), + } + }), + max_review_period_length: new u32(100), + application_rationing_policy: new Option(ApplicationRationingPolicy), + application_staking_policy: new Option(StakingPolicy), + role_staking_policy: new Option(StakingPolicy), + human_readable_text: new Text(), + }), + queryer: new mockBlockQueryer("somehash", now), + }, + output: { + state: OpeningState.WaitingToBegin, + starting_block: 100, + starting_block_hash: "somehash", + starting_time: now, + }, + }, + { + description: "Accepting applications", + input: { + opening: new Opening({ + created: new u32(100), + stage: new OpeningStage({ + 'Active': { + stage: new ActiveOpeningStage({ + acceptingApplications: new AcceptingApplications({ + started_accepting_applicants_at_block: new u32(100), + }) + }) + } + }), + max_review_period_length: new u32(100), + application_rationing_policy: new Option(ApplicationRationingPolicy), + application_staking_policy: new Option(StakingPolicy), + role_staking_policy: new Option(StakingPolicy), + human_readable_text: new Text(), + }), + queryer: new mockBlockQueryer("somehash", now), + }, + output: { + state: OpeningState.AcceptingApplications, + starting_block: 100, + starting_block_hash: "somehash", + starting_time: now, + }, + }, + { + description: "In review period", + input: { + opening: new Opening({ + created: new u32(100), + stage: new OpeningStage({ + 'Active': { + stage: new ActiveOpeningStage({ + reviewPeriod: new ReviewPeriod({ + started_accepting_applicants_at_block: new u32(100), + started_review_period_at_block: new u32(100), + }) + }) + } + }), + max_review_period_length: new u32(14400), + application_rationing_policy: new Option(ApplicationRationingPolicy), + application_staking_policy: new Option(StakingPolicy), + role_staking_policy: new Option(StakingPolicy), + human_readable_text: new Text(), + }), + queryer: new mockBlockQueryer("somehash", now), + }, + output: { + state: OpeningState.InReview, + starting_block: 100, + starting_block_hash: "somehash", + starting_time: now, + review_end_block: 14500, + review_end_time: new Date("2020-01-24T11:47:04.433Z"), + }, + }, + /* + * jest is having trouble with the enum type + { + description: "Deactivated: cancelled", + input: { + opening: new Opening({ + created: new u32(100), + stage: new OpeningStage({ + 'Active': { + stage: new ActiveOpeningStage({ + reviewPeriod: new Deactivated({ + cause: new OpeningDeactivationCause( + OpeningDeactivationCauseKeys.CancelledBeforeActivation, + ), + deactivated_at_block: new u32(123), + started_accepting_applicants_at_block: new u32(100), + started_review_period_at_block: new Option(u32, 100), + }) + }) + } + }), + max_review_period_length: new u32(100), + application_rationing_policy: new Option(ApplicationRationingPolicy), + application_staking_policy: new Option(StakingPolicy), + role_staking_policy: new Option(StakingPolicy), + human_readable_text: new Text(), + }), + queryer: new mockBlockQueryer("somehash", now), + }, + output: { + state: OpeningState.Cancelled, + starting_block: 123, + starting_block_hash: "somehash", + starting_time: now, + }, + }, + */ + ] + + cases.forEach((test: Test) => { + it(test.description, async () => { + expect( + await classifyOpeningStage( + test.input.queryer, + test.input.opening, + ) + ).toEqual( + test.output + ) + }); + }) +}) diff --git a/pioneer/packages/joy-roles/src/classifiers.ts b/pioneer/packages/joy-roles/src/classifiers.ts new file mode 100644 index 0000000000..2aa043abc4 --- /dev/null +++ b/pioneer/packages/joy-roles/src/classifiers.ts @@ -0,0 +1,292 @@ +import moment from 'moment'; + +import { Option, u128 } from '@polkadot/types' +import { Balance } from '@polkadot/types/interfaces' + +import { + Application, + AcceptingApplications, ReviewPeriod, + WaitingToBeingOpeningStageVariant, + ActiveOpeningStageVariant, ActiveOpeningStageKeys, + Opening, + OpeningStageKeys, + Deactivated, OpeningDeactivationCauseKeys, + StakingPolicy, + StakingAmountLimitMode, StakingAmountLimitModeKeys, + ApplicationStageKeys, + ApplicationDeactivationCause, ApplicationDeactivationCauseKeys, + UnstakingApplicationStage, + InactiveApplicationStage, +} from "@joystream/types/hiring" + +import { + StakeRequirement, + ApplicationStakeRequirement, + RoleStakeRequirement, + StakeType, +} from './StakeRequirement' + +export enum CancelledReason { + ApplicantCancelled = 0, + HirerCancelledApplication, + HirerCancelledOpening, + NoOneHired, +} + +export enum OpeningState { + WaitingToBegin = 0, + AcceptingApplications, + InReview, + Complete, + Cancelled, +} + +export interface OpeningStageClassification { + state: OpeningState + starting_block: number + starting_block_hash: string + starting_time: Date + review_end_time?: Date + review_end_block?: number +} + +export interface IBlockQueryer { + blockHash(height: number): Promise + blockTimestamp(height: number): Promise + expectedBlockTime: () => Promise +} + +export async function classifyOpeningStage(queryer: IBlockQueryer, opening: Opening): Promise { + switch (opening.stage.type) { + case OpeningStageKeys.WaitingToBegin: + return classifyWaitingToBeginStage( + opening, + queryer, + opening.stage.value as WaitingToBeingOpeningStageVariant, + ) + + case OpeningStageKeys.Active: + return classifyActiveOpeningStage( + opening, + queryer, + opening.stage.value as ActiveOpeningStageVariant, + ) + } + + throw new Error('Unknown stage type: ' + opening.stage.type) +} + +async function classifyActiveOpeningStage( + opening: Opening, + queryer: IBlockQueryer, + stage: ActiveOpeningStageVariant, +): Promise { + + switch (stage.stage.type) { + case ActiveOpeningStageKeys.AcceptingApplications: + return classifyActiveOpeningStageAcceptingApplications( + queryer, + stage.stage.value as AcceptingApplications, + ) + + case ActiveOpeningStageKeys.ReviewPeriod: + return classifyActiveOpeningStageReviewPeriod( + opening, + queryer, + stage.stage.value as ReviewPeriod, + ) + + case ActiveOpeningStageKeys.Deactivated: + return classifyActiveOpeningStageDeactivated( + queryer, + stage.stage.value as Deactivated, + ) + } + + throw new Error('Unknown active opening stage: ' + stage.stage.type) +} + +async function classifyWaitingToBeginStage( + opening: Opening, + queryer: IBlockQueryer, + stage: WaitingToBeingOpeningStageVariant, +): Promise { + const blockNumber = opening.created.toNumber() + return { + state: OpeningState.WaitingToBegin, + starting_block: blockNumber, + starting_block_hash: await queryer.blockHash(blockNumber), + starting_time: await queryer.blockTimestamp(blockNumber), + } +} + +async function classifyActiveOpeningStageAcceptingApplications( + queryer: IBlockQueryer, + stage: AcceptingApplications, +): Promise { + const blockNumber = stage.started_accepting_applicants_at_block.toNumber() + return { + state: OpeningState.AcceptingApplications, + starting_block: blockNumber, + starting_block_hash: await queryer.blockHash(blockNumber), + starting_time: await queryer.blockTimestamp(blockNumber), + } +} + +async function classifyActiveOpeningStageReviewPeriod( + opening: Opening, + queryer: IBlockQueryer, + stage: ReviewPeriod, +): Promise { + const blockNumber = stage.started_review_period_at_block.toNumber() + const maxReviewLengthInBlocks = opening.max_review_period_length.toNumber() + const [startDate, blockTime] = await Promise.all([ + queryer.blockTimestamp(blockNumber), + queryer.expectedBlockTime(), + ]) + const endDate = moment(startDate).add(maxReviewLengthInBlocks * blockTime, 's') + + return { + state: OpeningState.InReview, + starting_block: blockNumber, + starting_block_hash: await queryer.blockHash(blockNumber), + starting_time: startDate, + review_end_time: endDate.toDate(), + review_end_block: blockNumber + maxReviewLengthInBlocks, + } +} + +async function classifyActiveOpeningStageDeactivated( + queryer: IBlockQueryer, + stage: Deactivated, +): Promise { + const blockNumber = stage.deactivated_at_block.toNumber() + const [startDate] = await Promise.all([ + queryer.blockTimestamp(blockNumber), + ]) + + let state: OpeningState + + switch (stage.cause.type) { + case OpeningDeactivationCauseKeys.CancelledBeforeActivation: + case OpeningDeactivationCauseKeys.CancelledAcceptingApplications: + case OpeningDeactivationCauseKeys.CancelledInReviewPeriod: + case OpeningDeactivationCauseKeys.ReviewPeriodExpired: + state = OpeningState.Cancelled + break + + case OpeningDeactivationCauseKeys.Filled: + state = OpeningState.Complete + break + + default: + state = OpeningState.Complete + break + } + + return { + state: state, + starting_block: blockNumber, + starting_block_hash: await queryer.blockHash(blockNumber), + starting_time: startDate, + } +} + +export type StakeRequirementSetClassification = { + application: ApplicationStakeRequirement + role: RoleStakeRequirement +} + +interface StakeRequirementConstructor { + new(hard: Balance, stakeType?: StakeType): T +} + +export function classifyOpeningStakes(opening: Opening): StakeRequirementSetClassification { + return { + application: classifyStakeRequirement( + ApplicationStakeRequirement, + opening.application_staking_policy, + ), + role: classifyStakeRequirement( + RoleStakeRequirement, + opening.role_staking_policy, + ), + } +} + +function classifyStakeRequirement( + constructor: StakeRequirementConstructor, + option: Option, +): T { + + if (option.isNone) { + return new constructor(new u128(0)) + } + + const policy = option.unwrap() + + return new constructor( + policy.amount, + classifyStakeType(policy.amount_mode), + ) +} + + +function classifyStakeType(mode: StakingAmountLimitMode): StakeType { + switch (mode.type) { + case StakingAmountLimitModeKeys.AtLeast: + return StakeType.AtLeast + + case StakingAmountLimitModeKeys.Exact: + return StakeType.Fixed + } + + throw new Error("Unknown stake type: " + mode.type) +} + +export function classifyApplicationCancellation(a: Application): CancelledReason | undefined { + switch (a.stage.type) { + case ApplicationStageKeys.Unstaking: + return classifyApplicationCancellationFromCause( + (a.stage.value as UnstakingApplicationStage).cause + ) + + case ApplicationStageKeys.Inactive: + return classifyApplicationCancellationFromCause( + (a.stage.value as InactiveApplicationStage).cause + ) + } + + return undefined +} + +function classifyApplicationCancellationFromCause(cause: ApplicationDeactivationCause): CancelledReason | undefined { + console.log(cause.type) + switch (cause.type) { + case ApplicationDeactivationCauseKeys.External: + return CancelledReason.ApplicantCancelled + + case ApplicationDeactivationCauseKeys.OpeningCancelled: + case ApplicationDeactivationCauseKeys.OpeningFilled: + return CancelledReason.HirerCancelledOpening + + case ApplicationDeactivationCauseKeys.ReviewPeriodExpired: + return CancelledReason.NoOneHired + } + + return undefined +} + +export function isApplicationHired(a: Application): boolean { + switch (a.stage.type) { + case ApplicationStageKeys.Unstaking: + return (a.stage.value as UnstakingApplicationStage).cause.type == ApplicationDeactivationCauseKeys.Hired + + case ApplicationStageKeys.Inactive: + return (a.stage.value as InactiveApplicationStage).cause.type == ApplicationDeactivationCauseKeys.Hired + } + + return false +} + + diff --git a/pioneer/packages/joy-roles/src/elements.stories.tsx b/pioneer/packages/joy-roles/src/elements.stories.tsx new file mode 100644 index 0000000000..001914ac39 --- /dev/null +++ b/pioneer/packages/joy-roles/src/elements.stories.tsx @@ -0,0 +1,85 @@ +// @ts-nocheck +import React from 'react' +import { boolean, number, text, withKnobs } from '@storybook/addon-knobs' +import { Table } from 'semantic-ui-react'; + +import { u128, Text } from '@polkadot/types' + +import { Actor } from '@joystream/types/roles' + +import { BalanceView, GroupMemberView, HandleView, MemberView, MemoView } from './elements' + +import 'semantic-ui-css/semantic.min.css' +import '@polkadot/joy-roles/index.sass' + +export default { + title: 'Roles / Elements', + decorators: [withKnobs], +} + +export const Balance = () => { + return ( + + ) +} + +export const Memo = () => { + const actor = new Actor({ member_id: 1, account: '5HZ6GtaeyxagLynPryM7ZnmLzoWFePKuDrkb4AT8rT4pU1fp' }) + const memo = new Text(text("Memo text", "This is a memo")) + + return ( + + ) +} + +export const Handle = () => { + const profile = { + handle: new Text(text("Handle", "benholdencrowther")), + } + + return ( + + ) +} + +export const Member = () => { + const actor = new Actor({ member_id: 1, account: '5HZ6GtaeyxagLynPryM7ZnmLzoWFePKuDrkb4AT8rT4pU1fp' }) + const profile = { + handle: new Text(text("Handle", "benholdencrowther")), + } + + return ( + + + + + + + + +
+ ) +} + +export const GroupMember = () => { + const actor = new Actor({ member_id: 1, account: '5HZ6GtaeyxagLynPryM7ZnmLzoWFePKuDrkb4AT8rT4pU1fp' }) + const profile = { + handle: new Text(text("Handle", "benholdencrowther")), + } + + return ( + + ) +} + diff --git a/pioneer/packages/joy-roles/src/elements.tsx b/pioneer/packages/joy-roles/src/elements.tsx new file mode 100644 index 0000000000..d4265adb19 --- /dev/null +++ b/pioneer/packages/joy-roles/src/elements.tsx @@ -0,0 +1,251 @@ +import React, { useEffect, useState } from 'react' +import moment from 'moment' +import { Header, Card, Icon, Image, Label, Statistic } from 'semantic-ui-react'; +import { Link } from 'react-router-dom'; + +import { Balance } from '@polkadot/types/interfaces'; +import { formatBalance } from '@polkadot/util'; +import Identicon from '@polkadot/react-identicon'; +import { Actor } from '@joystream/types/roles'; +import { IProfile, MemberId } from '@joystream/types/members'; +import { Text, GenericAccountId } from '@polkadot/types'; +import { LeadRoleState } from '@joystream/types/content-working-group'; + +type ActorProps = { + actor: Actor +} + +type BalanceProps = { + balance?: Balance +} + +export function BalanceView(props: BalanceProps) { + return ( +
+ Balance: {formatBalance(props.balance)} +
+ ) +} + +type MemoProps = ActorProps & { + memo?: Text +} + +export function MemoView(props: MemoProps) { + if (typeof props.memo === "undefined") { + return null + } + + return ( +
+ Memo: {props.memo.toString()} + {' view full memo'} +
+ ) +} + +type ProfileProps = { + profile: IProfile +} + +export function HandleView(props: ProfileProps) { + if (typeof props.profile === "undefined") { + return null + } + + return ( + {props.profile.handle.toString()} + ) +} + +type MemberProps = ActorProps & BalanceProps & ProfileProps + +export function MemberView(props: MemberProps) { + let avatar = + if (typeof props.profile.avatar_uri !== "undefined" && props.profile.avatar_uri.toString() != "") { + avatar = + } + + return ( +
+ {avatar} + + + + +
+ ) +} + +type ActorDetailsProps = MemoProps & BalanceProps + +export function ActorDetailsView(props: ActorDetailsProps) { + return ( +
+ {props.actor.account.toString()} + +
+ ) +} + +export type GroupMember = { + memberId: MemberId + roleAccount: GenericAccountId + profile: IProfile + title: string + stake?: Balance + earned?: Balance +} + +export type GroupLead = { + memberId: MemberId + roleAccount: GenericAccountId + profile: IProfile + title: string + stage: LeadRoleState +} + +type inset = { + inset?: boolean +} + +export function GroupLeadView(props: GroupLead & inset) { + let fluid = false + if (typeof props.inset !== "undefined") { + fluid = props.inset + } + + let avatar = + if (typeof props.profile.avatar_uri !== "undefined" && props.profile.avatar_uri.toString() != "") { + avatar = + } + + return ( + + + + {avatar} + + + {props.title} + + + + + {/* + + */} + + ) +} + +export function GroupMemberView(props: GroupMember & inset) { + let fluid = false + if (typeof props.inset !== "undefined") { + fluid = props.inset + } + + let stake = null + if (typeof props.stake !== "undefined" && props.stake.toNumber() !== 0) { + stake = ( + + ) + } + + let avatar = + if (typeof props.profile.avatar_uri !== "undefined" && props.profile.avatar_uri.toString() != "") { + avatar = + } + + let earned = null + if (typeof props.earned !== "undefined" && + props.earned.toNumber() > 0 && + !fluid) { + earned = ( + + + + ) + } + + return ( + + + + {avatar} + + + {props.title} + + {stake} + + + {earned} + + ) +} + +type CountdownProps = { + end: Date +} + +export function Countdown(props: CountdownProps) { + let interval: number = -1 + + const [days, setDays] = useState(undefined) + const [hours, setHours] = useState(undefined) + const [minutes, setMinutes] = useState(undefined) + const [seconds, setSeconds] = useState(undefined) + + const update = () => { + const then = moment(props.end) + const now = moment() + const d = moment.duration(then.diff(now)) + setDays(d.days()) + setHours(d.hours()) + setMinutes(d.minutes()) + setSeconds(d.seconds()) + } + + interval = window.setInterval(update, 1000); + + useEffect(() => { + update() + return () => { + clearInterval(interval); + }; + }, []); + + if (!seconds) { + return null; + } + + return ( +
+ + {days} + Days + + + {hours} + hours + + + {minutes} + minutes + + + {seconds} + seconds + +
+ ) +} diff --git a/pioneer/packages/joy-roles/src/flows/apply.controller.tsx b/pioneer/packages/joy-roles/src/flows/apply.controller.tsx new file mode 100644 index 0000000000..d84674de1a --- /dev/null +++ b/pioneer/packages/joy-roles/src/flows/apply.controller.tsx @@ -0,0 +1,232 @@ +import React from 'react'; + +import { formatBalance } from '@polkadot/util'; +import { u128 } from '@polkadot/types'; +import { Balance } from '@polkadot/types/interfaces'; +import AccountId from '@polkadot/types/primitive/Generic/AccountId'; + +import { Controller, View } from '@polkadot/joy-utils/index' + +import { GenericJoyStreamRoleSchema } from '@joystream/types/hiring/schemas/role.schema.typings' + +import { Container } from 'semantic-ui-react' + +import { ITransport } from '../transport' + +import { keyPairDetails, FlowModal, ProgressSteps } from './apply' + +import { OpeningStakeAndApplicationStatus } from '../tabs/Opportunities' +import { Min, Step, Sum } from "../balances" + +type State = { + // Input data from state + role?: GenericJoyStreamRoleSchema + applications?: OpeningStakeAndApplicationStatus + keypairs?: keyPairDetails[] // <- Where does this come from? + hasConfirmStep?: boolean + step?: Balance + slots?: Balance[] + + // Data captured from form + applicationStake: Balance + roleStake: Balance + appDetails: any + txKeyAddress: AccountId + activeStep: ProgressSteps + txInProgress: boolean + complete: boolean + + // Data generated for transaction + transactionDetails: Map + roleKeyName: string + + // Error capture and display + hasError: boolean +} + +const newEmptyState = (): State => { + return { + applicationStake: new u128(0), + roleStake: new u128(0), + appDetails: {}, + hasError: false, + transactionDetails: new Map(), + roleKeyName: "", + txKeyAddress: new AccountId(), + activeStep: 0, + txInProgress: false, + complete: false, + } +} + +export class ApplyController extends Controller { + protected currentOpeningId: number = -1 + + constructor(transport: ITransport, initialState: State = newEmptyState()) { + super(transport, initialState) + + this.transport.accounts().subscribe((keys) => this.updateAccounts(keys)) + } + + protected updateAccounts(keys: keyPairDetails[]) { + this.state.keypairs = keys + this.dispatch() + } + + findOpening(rawId: string | undefined) { + if (!rawId) { + return this.onError("ApplyController: no ID provided in params") + } + const id = parseInt(rawId) + + if (this.currentOpeningId == id) { + return + } + + Promise.all( + [ + this.transport.curationGroupOpening(id), + this.transport.openingApplicationRanks(id), + ], + ) + .then( + ([opening, ranks]) => { + const hrt = opening.opening.parse_human_readable_text() + if (typeof hrt !== "object") { + return this.onError("human_readable_text is not an object") + } + + this.state.role = hrt + this.state.applications = opening.applications + this.state.slots = ranks + this.state.step = Min(Step(ranks, ranks.length)) + this.state.hasConfirmStep = + opening.applications.requiredApplicationStake.anyRequirement() || + opening.applications.requiredRoleStake.anyRequirement() + + this.state.applicationStake = opening.applications.requiredApplicationStake.value + this.state.roleStake = opening.applications.requiredRoleStake.value + + this.state.activeStep = this.state.hasConfirmStep ? + ProgressSteps.ConfirmStakes : + ProgressSteps.ApplicationDetails + + this.state.roleKeyName = hrt.job.title + " role key" + + // When everything is collected, update the view + this.dispatch() + } + ) + .catch( + (err: any) => { + this.currentOpeningId = -1 + this.onError(err) + } + ) + + this.currentOpeningId = id + } + + setApplicationStake(b: Balance): void { + this.state.applicationStake = b + this.dispatch() + } + + setRoleStake(b: Balance): void { + this.state.roleStake = b + this.dispatch() + } + + setAppDetails(v: any): void { + this.state.appDetails = v + this.dispatch() + } + + setTxKeyAddress(v: any): void { + this.state.txKeyAddress = v + this.dispatch() + } + + setActiveStep(v: ProgressSteps): void { + this.state.activeStep = v + this.dispatch() + } + + setTxInProgress(v: boolean): void { + this.state.txInProgress = v + this.dispatch() + } + + setComplete(v: boolean): void { + this.state.complete = v + this.dispatch() + } + + async prepareApplicationTransaction( + applicationStake: Balance, + roleStake: Balance, + questionResponses: any, + txKeyAddress: AccountId, + ): Promise { + const totalCommitment = Sum([ + applicationStake, + roleStake, + ]) + + this.state.transactionDetails.set("Application stake", formatBalance(this.state.applicationStake)) + this.state.transactionDetails.set("Role stake", formatBalance(roleStake)) + this.state.transactionDetails.set("Total commitment", formatBalance(totalCommitment)) + + this.dispatch() + return true + } + + async makeApplicationTransaction(): Promise { + return this.transport.applyToCuratorOpening( + this.currentOpeningId, + this.state.roleKeyName, + this.state.txKeyAddress.toString(), + this.state.applicationStake, + this.state.roleStake, + JSON.stringify(this.state.appDetails), + ) + } +} + +export const ApplyView = View( + (state, controller, params) => { + controller.findOpening(params.get("id")) + return ( + +
+ // @ts-ignore + controller.prepareApplicationTransaction(...args)} + makeApplicationTransaction={() => controller.makeApplicationTransaction()} + applicationStake={state.applicationStake} + setApplicationStake={(v) => controller.setApplicationStake(v)} + roleStake={state.roleStake} + setRoleStake={(v) => controller.setRoleStake(v)} + appDetails={state.appDetails} + setAppDetails={(v) => controller.setAppDetails(v)} + txKeyAddress={state.txKeyAddress} + setTxKeyAddress={(v) => controller.setTxKeyAddress(v)} + activeStep={state.activeStep} + setActiveStep={(v) => controller.setActiveStep(v)} + txInProgress={state.txInProgress} + setTxInProgress={(v) => controller.setTxInProgress(v)} + complete={state.complete} + setComplete={(v) => controller.setComplete(v)} + /> +
+ ) + } +) diff --git a/pioneer/packages/joy-roles/src/flows/apply.elements.stories.tsx b/pioneer/packages/joy-roles/src/flows/apply.elements.stories.tsx new file mode 100644 index 0000000000..c532e885ce --- /dev/null +++ b/pioneer/packages/joy-roles/src/flows/apply.elements.stories.tsx @@ -0,0 +1,550 @@ +// @ts-nocheck +import React, { useState } from 'react' +import { number, object, withKnobs } from '@storybook/addon-knobs' +import { Card, Container, Message } from 'semantic-ui-react' + +import { u128, GenericAccountId } from '@polkadot/types' +import { Balance } from '@polkadot/types/interfaces'; + +import { + ApplicationDetails +} from '@joystream/types/schemas/role.schema' +import { + ConfirmStakesStage, ConfirmStakesStageProps, + ProgressStepsView, ProgressStepsProps, ProgressSteps, + ApplicationDetailsStage, ApplicationDetailsStageProps, + SubmitApplicationStage, SubmitApplicationStageProps, + DoneStage, DoneStageProps, + FundSourceSelector, + StakeRankSelector, StakeRankSelectorProps, + ConfirmStakes2Up, ConfirmStakes2UpProps, +} from "./apply" +import { + OpeningStakeAndApplicationStatus, +} from '../tabs/Opportunities' +import { + ApplicationStakeRequirement, + RoleStakeRequirement, + StakeType, +} from '../StakeRequirement' + +import 'semantic-ui-css/semantic.min.css' +import '@polkadot/joy-roles/index.sass' + +export default { + title: 'Roles / Components / Apply flow / Elements', + decorators: [withKnobs], +} + +const applicationSliderOptions = { + range: true, + min: 0, + max: 20, + step: 1, +} + +const moneySliderOptions = { + range: true, + min: 0, + max: 1000000, + step: 500, +} + +const applications: OpeningStakeAndApplicationStatus = { + numberOfApplications: number("Applications count", 0, applicationSliderOptions, "Role rationing policy"), + maxNumberOfApplications: number("Application max", 0, applicationSliderOptions, "Role rationing policy"), + requiredApplicationStake: new ApplicationStakeRequirement( + new u128(number("Application stake", 500, moneySliderOptions, "Role stakes")), + ), + requiredRoleStake: new RoleStakeRequirement( + new u128(number("Role stake", 0, moneySliderOptions, "Role stakes")), + ), +} + +type TestProps = { + _description: string +} + +export function ProgressIndicator() { + const permutations: (ProgressStepsProps & TestProps)[] = [ + { + _description: "Three steps, second active", + activeStep: ProgressSteps.SubmitApplication, + hasConfirmStep: false, + }, + { + _description: "Four steps, first active", + activeStep: ProgressSteps.ConfirmStakes, + hasConfirmStep: true, + }, + { + _description: "Four steps, second active", + activeStep: ProgressSteps.SubmitApplication, + hasConfirmStep: true, + }, + ] + + return ( + + {permutations.map((permutation, key) => ( + +

{permutation._description}

+ + + +
+ ))} +
+ ) +} + +export function FundSourceSelectorFragment() { + const [address, setAddress] = useState() + const [passphrase, setPassphrase] = useState("") + + const props = { + transactionFee: new u128(number("Transaction fee", 500, moneySliderOptions, "Application Tx")), + keypairs: [ + { + shortName: "KP1", + accountId: new GenericAccountId('5HZ6GtaeyxagLynPryM7ZnmLzoWFePKuDrkb4AT8rT4pU1fp'), + balance: new u128(23342), + }, + { + shortName: "KP2", + accountId: new GenericAccountId('5DQqNWRFPruFs9YKheVMqxUbqoXeMzAWfVfcJgzuia7NA3D3'), + balance: new u128(993342), + }, + { + shortName: "KP3", + accountId: new GenericAccountId('5DBaczGTDhcHgwsZzNE5qW15GrQxxdyros4pYkcKrSUovFQ9'), + balance: new u128(242), + }, + ], + } + + return ( + + + + + + + +

Address: {address ? address.toString() : 'not set'}

+

Passphrase: {passphrase}

+
+
+ ) +} + +export function StakeRankSelectorFragment() { + const [stake, setStake] = useState(new u128(0)) + + // List of the minimum stake required to beat each rank + const slots: Balance[] = [] + for (let i = 0; i < 10; i++) { + slots.push(new u128((i * 100) + 10 + i + 1)) + } + + const props: StakeRankSelectorProps = { + stake: stake, + setStake: setStake, + slots: slots, + step: new u128(10), + } + + return ( + + + + + + + + Slots: {JSON.stringify(slots)}
+ Stake: {stake.toString()} +
+
+ ) +} + +export function SelectTwoMinimumStakes() { + const [applicationStake, setApplicationStake] = useState(new u128(1)) + const [roleStake, setRoleStake] = useState(new u128(2)) + + // List of the minimum stake required to beat each rank + const slots: Balance[] = [] + for (let i = 0; i < 20; i++) { + slots.push(new u128((i * 100) + 10 + i + 1)) + } + + const props: ConfirmStakes2UpProps & TestProps = { + _description: "One fixed stake (application), no limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(1)), + requiredRoleStake: new RoleStakeRequirement(new u128(2)), + maxNumberOfApplications: 0, + numberOfApplications: 0, + }, + defactoMinimumStake: new u128(0), + step: new u128(5), + slots: slots, + selectedApplicationStake: applicationStake, setSelectedApplicationStake: setApplicationStake, + selectedRoleStake: roleStake, setSelectedRoleStake: setRoleStake, + } + + return ( + + + + + + + + ) +} + +export function StageAConfirmStakes() { + const permutations: (any & TestProps)[] = [ + { + _description: "One fixed stake (application), no limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(10)), + requiredRoleStake: new RoleStakeRequirement(new u128(0)), + maxNumberOfApplications: 0, + numberOfApplications: 0, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "One fixed stake (role), no limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(0)), + requiredRoleStake: new RoleStakeRequirement(new u128(1213)), + maxNumberOfApplications: 0, + numberOfApplications: 0, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "Two fixed stakes, no limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(10)), + requiredRoleStake: new RoleStakeRequirement(new u128(10)), + maxNumberOfApplications: 0, + numberOfApplications: 0, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "One fixed stake (application), 20 applicant limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(10)), + requiredRoleStake: new RoleStakeRequirement(new u128(0)), + maxNumberOfApplications: 20, + numberOfApplications: 0, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "One fixed stake (role), 20 applicant limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(456)), + requiredRoleStake: new RoleStakeRequirement(new u128(0)), + maxNumberOfApplications: 20, + numberOfApplications: 0, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "Two fixed stakes, 20 applicant limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(10)), + requiredRoleStake: new RoleStakeRequirement(new u128(10)), + maxNumberOfApplications: 20, + numberOfApplications: 0, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "One minimum stake (application), no limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(10), StakeType.AtLeast), + requiredRoleStake: new RoleStakeRequirement(new u128(0)), + maxNumberOfApplications: 0, + numberOfApplications: 20, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "One minimum stake (role), no limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(0)), + requiredRoleStake: new RoleStakeRequirement(new u128(10), StakeType.AtLeast), + maxNumberOfApplications: 0, + numberOfApplications: 20, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "Two minimum stakes, no limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(10), StakeType.AtLeast), + requiredRoleStake: new RoleStakeRequirement(new u128(10), StakeType.AtLeast), + maxNumberOfApplications: 0, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "Minimum application stake, fixed role stake, no limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(10), StakeType.AtLeast), + requiredRoleStake: new RoleStakeRequirement(new u128(10)), + maxNumberOfApplications: 0, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "Minimum role stake, fixed application stake, no limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(10)), + requiredRoleStake: new RoleStakeRequirement(new u128(10), StakeType.AtLeast), + maxNumberOfApplications: 0, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "One minimum stake (application), 20 applicant limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(10), StakeType.AtLeast), + requiredRoleStake: new RoleStakeRequirement(new u128(0)), + maxNumberOfApplications: 0, + numberOfApplications: 20, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "One minimum stake (role), 20 applicant limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(0)), + requiredRoleStake: new RoleStakeRequirement(new u128(10), StakeType.AtLeast), + maxNumberOfApplications: 0, + numberOfApplications: 20, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "Two minimum stakes, 20 applicant limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(10), StakeType.AtLeast), + requiredRoleStake: new RoleStakeRequirement(new u128(10), StakeType.AtLeast), + maxNumberOfApplications: 20, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "Minimum application stake, fixed role stake, 20 applicant limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(10), StakeType.AtLeast), + requiredRoleStake: new RoleStakeRequirement(new u128(10)), + maxNumberOfApplications: 0, + numberOfApplications: 20, + }, + defactoMinimumStake: new u128(0), + }, + { + _description: "Minimum role stake, fixed application stake, 20 applicant limit", + applications: { + requiredApplicationStake: new ApplicationStakeRequirement(new u128(10)), + requiredRoleStake: new RoleStakeRequirement(new u128(10), StakeType.AtLeast), + maxNumberOfApplications: 0, + numberOfApplications: 20, + }, + defactoMinimumStake: new u128(0), + }, + ] + + const keypairs = [ + { + shortName: "KP1", + accountId: new GenericAccountId('5HZ6GtaeyxagLynPryM7ZnmLzoWFePKuDrkb4AT8rT4pU1fp'), + balance: new u128(23342), + }, + { + shortName: "KP2", + accountId: new GenericAccountId('5DQqNWRFPruFs9YKheVMqxUbqoXeMzAWfVfcJgzuia7NA3D3'), + balance: new u128(993342), + }, + { + shortName: "KP3", + accountId: new GenericAccountId('5DBaczGTDhcHgwsZzNE5qW15GrQxxdyros4pYkcKrSUovFQ9'), + balance: new u128(242), + }, + ] + + + // List of the minimum stake required to beat each rank + const slots: Balance[] = [] + for (let i = 0; i < 20; i++) { + slots.push(new u128((i * 100) + 10 + i + 1)) + } + + + const renders = [] + permutations.map((permutation, key) => { + const [applicationStake, setApplicationStake] = useState(new u128(0)) + const [roleStake, setRoleStake] = useState(new u128(0)) + const [stakeKeyAddress, setStakeKeyAddress] = useState(null) + const [stakeKeyPassphrase, setStakeKeyPassphrase] = useState("") + + const [stake, setStake] = useState(new u128(0)) + const stakeRankSelectorProps: StakeRankSelectorProps = { + slots: slots, + step: new u128(10), + } + + renders.push( + ( + +

{key}. {permutation._description}

+ + + + + A: {applicationStake.toString()}, R: {roleStake.toString()} + +
+ ) + ) + }) + + return ( + + {renders.map((render, key) => ( +
{render}
+ ))} +
+ ) +} + +export function StageBApplicationDetails() { + const [data, setData] = useState({ + "About you": { + "Your e-mail address": "pre-filled" + } + }) + + const props: ApplicationDetailsStageProps = { + applicationDetails: object("JSON", { + sections: [ + { + title: "About you", + questions: [ + { + title: "Your name", + type: "text" + }, + { + title: "Your e-mail address", + type: "text" + } + ] + }, + { + title: "Your experience", + questions: [ + { + title: "Why would you be good for this role?", + type: "text area" + } + ] + } + ] + }, 'Application questions'), + data: data, + setData: setData, + nextTransition: () => { }, + } + + return ( + + + + + + + {JSON.stringify(data)} + + + + ) +} + +export function StageCSubmitApplication() { + const props: SubmitApplicationStageProps = { + nextTransition: () => { }, + applications: applications, + transactionFee: new u128(number("Transaction fee", 500, moneySliderOptions, "Application Tx")), + transactionDetails: new Map([ + ["Extrinsic hash", "0xae6d24d4d55020c645ddfe2e8d0faf93b1c0c9879f9bf2c439fb6514c6d1292e"], + ["SOmething else", "abc123"], + ]), + keypairs: [ + { + shortName: "KP1", + accountId: new GenericAccountId('5HZ6GtaeyxagLynPryM7ZnmLzoWFePKuDrkb4AT8rT4pU1fp'), + balance: new u128(23342), + }, + { + shortName: "KP2", + accountId: new GenericAccountId('5F5SwL7zwfdDN4UifacVrYKQVVYzoNcoDoGzmhVkaPN2ef8F'), + balance: new u128(993342), + }, + { + shortName: "KP3", + accountId: new GenericAccountId('5HmMiZSGnidr3AhUk7hhZa7wJrvYyKEiT8cneyavA1ALkfJc'), + balance: new u128(242), + }, + ], + } + + return ( + + + + + + + + ) +} + +export function StageDDone() { + const props: DoneStageProps = { + applications: applications, + roleKeyName: "NEW_ROLE_KEY", + } + + return ( + + + + + + ) +} diff --git a/pioneer/packages/joy-roles/src/flows/apply.sass b/pioneer/packages/joy-roles/src/flows/apply.sass new file mode 100644 index 0000000000..40e7c3c953 --- /dev/null +++ b/pioneer/packages/joy-roles/src/flows/apply.sass @@ -0,0 +1,162 @@ +.apply-flow + position: absolute + width: 100% !important + height: 100% !important + top: 0 + left: 0 + z-index: 999 + padding: 1em + + .dimmer + position: fixed + top: 0 + left: 0 + width: 100% + height: 100% + background: white + + .loading + position: fixed + top: 0 + left: 0 + width: 100% + height: 100% + background: rgba(255,255,255,0.85) + z-index: 99999 + display: flex + + .spinner + margin: auto + + .inner + min-height: 100% + + h5 + font-weight: bold + color: #0e566c + + h4 + color: inherit + font-weight: bold + margin-bottom: 0.5em + + .statistic + .label, .value + text-align: left + + .label + &.fluid + width: calc(100% + 1rem + 1.2em); + + &.standout + margin-bottom: 1.2em + + .column + &.cancel + text-align: right + a + color: colour(grey, light) + transition: 0.3s; + &:hover + color: colour(blue) + + .container + &.content + margin-top: 1em + &.cta + margin-top: 1.5em + + .accordion + background-color: colour(grey, trans) + .content + padding: 0 1em !important + + .stake + .header + margin-bottom: 0.75em + text-transform: capitalize + + .ranks-and-stake + margin-top: 1em + margin-bottom: 2em + +.keypair + display: flex + flex-wrap: nowrap + justify-content: space-between + position: relative + white-space: nowrap + + .address, .balance + display: inline-block + flex: 1 + font-family: monospace + margin-left: 1rem + opacity: 0.5 + overflow: hidden + text-overflow: ellipsis + + .name + display: inline-block + flex: 1 0 + overflow: hidden + text-overflow: ellipsis + margin-left: 0.5rem + + &.uppercase + text-transform: uppercase + +.stake-rank-selector, .confirm-stakes-2up + margin-top: 1.5em + + .controls + margin-bottom: 1em + + p + padding-top: 1em + + .input + width: 30% + + .input, .button + margin-right: 0.5em + + .ticks + margin-left: 10px + margin-right: 10px + + .label + position: relative + transform: translateX(-50%) + + .tick + display: inline-block + text-align: center + border-right: 1px dotted #ccc + + &:first-of-type + border-left: 1px dotted #ccc + +.confirm-stakes-2up + .header + margin-bottom: 1em !important + .two-up + margin-top: 1em + .ticks + margin-top: 1.5em + .center + text-align: center + .controls + margin-left: 0 !important + .input + width: 100% + .fluid + input + width: calc(100% - 4em) !important + +.application-questions + .section + margin-top: 2em + + h4 + padding-bottom: 0.5em diff --git a/pioneer/packages/joy-roles/src/flows/apply.stories.tsx b/pioneer/packages/joy-roles/src/flows/apply.stories.tsx new file mode 100644 index 0000000000..7e104dd9d3 --- /dev/null +++ b/pioneer/packages/joy-roles/src/flows/apply.stories.tsx @@ -0,0 +1,168 @@ +// @ts-nocheck +import React from 'react' +import { number, object, select, text, withKnobs } from '@storybook/addon-knobs' +import * as faker from 'faker' + +import { u128, GenericAccountId } from '@polkadot/types' +import { Balance } from '@polkadot/types/interfaces'; + +import { FlowModal } from "./apply" +import { + ApplicationStakeRequirement, RoleStakeRequirement, + StakeType, +} from '../StakeRequirement' + +import 'semantic-ui-css/semantic.min.css' +import '@polkadot/joy-roles/index.sass' + +export default { + title: 'Roles / Components / Apply flow', + decorators: [withKnobs], +} + +const applicationSliderOptions = { + range: true, + min: 0, + max: 20, + step: 1, +} + +const moneySliderOptions = { + range: true, + min: 0, + max: 1000000, + step: 500, +} + +const stakeTypeOptions = { + "Fixed": StakeType.Fixed, + "At least": StakeType.AtLeast, +} + +function mockPromise(): () => Promise { + return () => new Promise((resolve, reject) => { + resolve() + }) +} + +export const ApplicationSandbox = () => { + // List of the minimum stake required to beat each rank + const slots: Balance[] = [] + for (let i = 0; i < 20; i++) { + slots.push(new u128((i * 100) + 10 + i + 1)) + + } + const props = { + role: { + version: 1, + headline: text("Headline", "Help us curate awesome content", "Role"), + job: { + title: text("Job title", "Content curator", "Role"), + description: text("Job description", faker.lorem.paragraphs(4), "Role") + }, + application: { + sections: [ + { + title: "About you", + questions: [ + { + title: "Your name", + type: "text" + }, + { + title: "Your e-mail address", + type: "text" + } + ] + }, + { + title: "Your experience", + questions: [ + { + title: "Why would you be good for this role?", + type: "text area" + } + ] + } + ] + }, + reward: text("Reward", "10 JOY per block", "Role"), + creator: { + membership: { + handle: text("Creator handle", "ben", "Role") + } + }, + process: { + details: [ + "Some custom detail" + ] + } + }, + applications: { + numberOfApplications: number("Applications count", 0, applicationSliderOptions, "Role rationing policy"), + maxNumberOfApplications: number("Application max", 0, applicationSliderOptions, "Role rationing policy"), + requiredApplicationStake: new ApplicationStakeRequirement( + new u128(number("Application stake", 500, moneySliderOptions, "Role stakes")), + select("Application stake type", stakeTypeOptions, StakeType.AtLeast, "Role stakes"), + ), + requiredRoleStake: new RoleStakeRequirement( + new u128(number("Role stake", 500, moneySliderOptions, "Role stakes")), + select("Role stake type", stakeTypeOptions, StakeType.Fixed, "Role stakes"), + ), + defactoMinimumStake: new u128(0), + }, + creator: creator, + transactionFee: new u128(number("Transaction fee", 499, moneySliderOptions, "Application Tx")), + keypairs: [ + { + shortName: "KP1", + accountId: new GenericAccountId('5HZ6GtaeyxagLynPryM7ZnmLzoWFePKuDrkb4AT8rT4pU1fp'), + balance: new u128(23342), + }, + { + shortName: "KP2", + accountId: new GenericAccountId('5DQqNWRFPruFs9YKheVMqxUbqoXeMzAWfVfcJgzuia7NA3D3'), + balance: new u128(993342), + }, + { + shortName: "KP3", + accountId: new GenericAccountId('5DBaczGTDhcHgwsZzNE5qW15GrQxxdyros4pYkcKrSUovFQ9'), + balance: new u128(242), + }, + ], + prepareApplicationTransaction: mockPromise(), + makeApplicationTransaction: mockPromise(), + transactionDetails: new Map([["Detail title", "Detail value"]]), + hasConfirmStep: true, + step: new u128(5), + slots: slots, + applicationDetails: object('JSON', { + sections: [ + { + title: "About you", + questions: [ + { + title: "Your name", + type: "text" + }, + { + title: "Your e-mail address", + type: "text" + } + ] + }, + { + title: "Your experience", + questions: [ + { + title: "Why would you be good for this role?", + type: "text area" + } + ] + } + ] + }, "Application questions"), + } + + return +} diff --git a/pioneer/packages/joy-roles/src/flows/apply.tsx b/pioneer/packages/joy-roles/src/flows/apply.tsx new file mode 100644 index 0000000000..9dda6cad93 --- /dev/null +++ b/pioneer/packages/joy-roles/src/flows/apply.tsx @@ -0,0 +1,1218 @@ +import React, { useEffect, useReducer, useState } from 'react' +import { useHistory } from "react-router-dom" +import { Link } from 'react-router-dom'; + +import { formatBalance } from '@polkadot/util'; +import { Balance } from '@polkadot/types/interfaces'; +import { + GenericAccountId, + u128, +} from '@polkadot/types' + +import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext' + +import { + Accordion, + Button, + Container, + Form, + Grid, + Header, + Icon, + Input, + Label, + Message, + Segment, + SemanticICONS, + Step, + Table, +} from 'semantic-ui-react' + +// @ts-ignore +import { Slider } from "react-semantic-ui-range"; + +import Identicon from '@polkadot/react-identicon'; +import AccountId from '@polkadot/types/primitive/Generic/AccountId'; + +import { GenericJoyStreamRoleSchema } from '@joystream/types/hiring/schemas/role.schema.typings' + +import { + OpeningBodyApplicationsStatus, OpeningStakeAndApplicationStatus, + ApplicationCount, + StakeRequirementProps, +} from '../tabs/Opportunities' +import { IStakeRequirement } from '../StakeRequirement' +import { + ApplicationDetails, + QuestionField, + QuestionSection, +} from '@joystream/types/hiring/schemas/role.schema.typings' + +import { Loadable } from '@polkadot/joy-utils/index' +import { Add } from '../balances' + +type accordionProps = { + title: string +} + +function ModalAccordion(props: React.PropsWithChildren) { + const [open, setOpen] = useState(false) + return ( + + { setOpen(!open) }} > + + {props.title} + + {props.children} + + ) +} + +function KeyPair({ address, className, style, isUppercase, name, balance }: any): any { + return ( +
+ +
+ {name} +
+
+ {formatBalance(balance)} +
+
+ {address} +
+
+ ) +} + +export type keyPairDetails = { + shortName: string + accountId: AccountId + balance: Balance +} + +export type FundSourceSelectorProps = { + keypairs: keyPairDetails[] + totalStake?: Balance +} + +type FundSourceCallbackProps = { + addressCallback?: (address: AccountId) => void + passphraseCallback?: (passphrase: string) => void +} + +export function FundSourceSelector(props: FundSourceSelectorProps & FundSourceCallbackProps) { + const pairs: any[] = []; + + const onChangeDropdown = (e: any, { value }: any) => { + if (typeof props.addressCallback !== "undefined") { + props.addressCallback(new GenericAccountId(value)) + } + } + + const onChangeInput = (e: any, { value }: any) => { + if (props.passphraseCallback) { + props.passphraseCallback(value) + } + } + + props.keypairs.map((v) => { + if (props.totalStake && v.balance.lt(props.totalStake)) { + return + } + + pairs.push({ + key: v.shortName, + text: ( + + ), + value: v.accountId.toString(), + }) + }) + + useEffect(() => { + if (pairs.length > 0 && typeof props.addressCallback !== "undefined") { + props.addressCallback(new GenericAccountId(pairs[0].accountId)) + } + }, []) + + const accCtx = useMyAccount() + let passphraseCallback = null + if (props.passphraseCallback) { + passphraseCallback = ( + + + + + ) + } + + return ( +
+ + + + + {passphraseCallback} +
+ ) +} + +function rankIcon(place: number, slots: number): SemanticICONS { + if (place <= 1) { + return 'thermometer empty' + } else if (place <= (slots / 4)) { + return 'thermometer quarter' + } else if (place <= (slots / 2)) { + return 'thermometer half' + } else if (place > (slots / 2) && place < slots) { + return 'thermometer three quarters' + } + return 'thermometer' +} + +export type StakeRankSelectorProps = { + slots: Balance[] // List of stakes to beat + stake: Balance + setStake: (b: Balance) => void + step: Balance + otherStake: Balance + requirement: IStakeRequirement +} + +export function StakeRankSelector(props: StakeRankSelectorProps) { + const slotCount = props.slots.length + const [rank, setRank] = useState(1); + const minStake = props.requirement.value + + const ticks = [] + for (var i = 0; i < slotCount; i++) { + ticks.push(
{slotCount - i}
) + } + + const findRankValue = (newStake: Balance): number => { + if (newStake.add(props.otherStake).gt(props.slots[slotCount - 1])) { + return slotCount + } + + for (let i = slotCount; i--; i >= 0) { + if (newStake.add(props.otherStake).gt(props.slots[i])) { + return i + 1 + } + } + + return 0 + } + + const changeValue = (e: any, { value }: any) => { + const newStake = new u128(value) + props.setStake(newStake) + setRank(findRankValue(newStake)) + } + useEffect(() => { + props.setStake(props.slots[0]) + }, []) + + let slider = null + return ( + +

Choose a stake

+ + 1 ? props.step.toNumber() : 1} + value={props.stake.toNumber() > 0 ? props.stake.toNumber() : 0} + min={props.slots.length > 0 ? props.slots[0].sub(props.otherStake).toNumber() : 0} + error={props.stake.lt(minStake)} + /> + + + + {slider} +
+ ) +} + +export enum ProgressSteps { + ConfirmStakes = 0, + ApplicationDetails, + SubmitApplication, + Done, +} + +export type ProgressStepsProps = { + activeStep: ProgressSteps + hasConfirmStep: boolean +} + +interface ProgressStepDefinition { + name: string + display: boolean +} +interface ProgressStep extends ProgressStepDefinition { + active: boolean + disabled: boolean +} + +function ProgressStepView(props: ProgressStep) { + if (!props.display) { + return null + } + + return ( + + + {props.name} + + + ) +} + +export function ProgressStepsView(props: ProgressStepsProps) { + const steps: ProgressStepDefinition[] = [ + { + name: "Confirm stakes", + display: props.hasConfirmStep, + }, + { + name: "Application details", + display: true, + }, + { + name: "Submit application", + display: true, + }, + { + name: "Done", + display: true, + }, + ] + return ( + + {steps.map((step, key) => ( + props.activeStep} + /> + ))} + + ) +} + +type CTACallback = () => void + +type CTAProps = { + negativeLabel: string + negativeIcon: SemanticICONS + negativeCallback: CTACallback + positiveLabel: string + positiveIcon: SemanticICONS + positiveCallback?: CTACallback +} + +function CTA(props: CTAProps) { + return ( + +
; +} \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/common/FormTabs.tsx b/pioneer/packages/joy-media/src/common/FormTabs.tsx new file mode 100644 index 0000000000..ce5f09a758 --- /dev/null +++ b/pioneer/packages/joy-media/src/common/FormTabs.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Menu, Label, Tab } from 'semantic-ui-react'; +import { FormikErrors } from 'formik'; +import { GenericMediaProp } from './MediaForms'; + +type FormTab = { + id: string, + fields?: GenericMediaProp[], + renderTitle?: () => React.ReactNode, + render?: () => React.ReactNode, +} + +type FormTabsProps = { + errors: FormikErrors, + panes: FormTab[], +} + +export function FormTabs (props: FormTabsProps) { + const { panes, errors } = props; + + return { + + const { + id, + fields = [], + renderTitle = () => id, + render = () => null + } = tab; + + const tabErrors: any[] = []; + fields.forEach(f => { + const err = errors[f.id]; + if (err) { + tabErrors.push(err); + } + }) + + // Currently we don't show error counter because it's markup is broken: + // a red circle with a number is shifted quite far from the right border of its tab. + const showErrorCounter = false + + const errCount = tabErrors.length; + const errTooltip = 'Number of errors on this tab'; + + const menuItem = + + {renderTitle()} + {showErrorCounter && errCount > 0 && + + } + ; + + return { menuItem, render }; + })} + />; +} diff --git a/pioneer/packages/joy-media/src/common/MediaDropdownOptions.tsx b/pioneer/packages/joy-media/src/common/MediaDropdownOptions.tsx new file mode 100644 index 0000000000..b6e13ddef7 --- /dev/null +++ b/pioneer/packages/joy-media/src/common/MediaDropdownOptions.tsx @@ -0,0 +1,45 @@ +import ISO6391 from 'iso-639-1'; +import { DropdownItemProps } from 'semantic-ui-react'; +import { LanguageType } from '../schemas/general/Language'; +import { TextValueEntity } from '@joystream/types/versioned-store/EntityCodec'; +import { InternalEntities } from '../transport'; + +const buildOptions = (entities: TextValueEntity[]): DropdownItemProps[] => + entities.map(x => ({ key: x.id, value: x.id, text: x.value })) + +const buildLanguageOptions = (entities: LanguageType[]): DropdownItemProps[] => + entities.map(x => ({ key: x.id, value: x.id, text: ISO6391.getName(x.value) })) + +export class MediaDropdownOptions { + + public languageOptions: DropdownItemProps[] + public contentLicenseOptions: DropdownItemProps[] + public curationStatusOptions: DropdownItemProps[] + public musicGenreOptions: DropdownItemProps[] + public musicMoodOptions: DropdownItemProps[] + public musicThemeOptions: DropdownItemProps[] + public publicationStatusOptions: DropdownItemProps[] + public videoCategoryOptions: DropdownItemProps[] + + constructor (props: InternalEntities) { + this.languageOptions = buildLanguageOptions(props.languages); + this.contentLicenseOptions = buildOptions(props.contentLicenses); + this.curationStatusOptions = buildOptions(props.curationStatuses); + this.musicGenreOptions = buildOptions(props.musicGenres); + this.musicMoodOptions = buildOptions(props.musicMoods); + this.musicThemeOptions = buildOptions(props.musicThemes); + this.publicationStatusOptions = buildOptions(props.publicationStatuses); + this.videoCategoryOptions = buildOptions(props.videoCategories); + } + + static Empty = new MediaDropdownOptions({ + languages: [], + contentLicenses: [], + curationStatuses: [], + musicGenres: [], + musicMoods: [], + musicThemes: [], + publicationStatuses: [], + videoCategories: [], + }); +} diff --git a/pioneer/packages/joy-media/src/common/MediaForms.tsx b/pioneer/packages/joy-media/src/common/MediaForms.tsx new file mode 100644 index 0000000000..d6613db43e --- /dev/null +++ b/pioneer/packages/joy-media/src/common/MediaForms.tsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { Dropdown, DropdownItemProps, DropdownProps } from 'semantic-ui-react'; +import { FormikProps, Field } from 'formik'; +import * as JoyForms from '@polkadot/joy-utils/forms'; +import { SubmittableResult } from '@polkadot/api'; +import { TxFailedCallback, TxCallback } from '@polkadot/react-components/Status/types'; +import { MediaDropdownOptions } from './MediaDropdownOptions'; +import { OnTxButtonClick } from '@polkadot/joy-utils/TxButton'; +import isEqual from 'lodash/isEqual' + +export const datePlaceholder = 'Date in format yyyy-mm-dd'; + +export type FormCallbacks = { + onSubmit: OnTxButtonClick, + onTxSuccess: TxCallback, + onTxFailed: TxFailedCallback +}; + +export type GenericMediaProp = { + id: keyof FormValues, + type: string, + name: string, + description?: string, + required?: boolean, + minItems?: number, + maxItems?: number, + minTextLength?: number, + maxTextLength?: number, + classId?: any +}; + +type BaseFieldProps = OuterProps & FormikProps & { + field: GenericMediaProp +}; + +type MediaTextProps = + BaseFieldProps & JoyForms.LabelledProps; + +type MediaFieldProps = + BaseFieldProps & + JoyForms.LabelledProps & { + fieldProps: any + } + +type MediaDropdownProps = + BaseFieldProps & +{ + options: DropdownItemProps[] +}; + +type FormFields = { + LabelledText: React.FunctionComponent> + LabelledField: React.FunctionComponent> + MediaText: React.FunctionComponent> + MediaField: React.FunctionComponent> + MediaDropdown: React.FunctionComponent> +}; + +export type MediaFormProps = + OuterProps & + FormikProps & + FormFields & + FormCallbacks & { + opts: MediaDropdownOptions + isFieldChanged: (field: keyof FormValues | GenericMediaProp) => boolean + }; + +export function withMediaForm + (Component: React.ComponentType>) +{ + type FieldName = keyof FormValues + + type FieldObject = GenericMediaProp + + const LabelledText = JoyForms.LabelledText(); + + const LabelledField = JoyForms.LabelledField(); + + function MediaText (props: MediaTextProps) { + const { field: f } = props; + return !f ? null : ; + } + + const MediaField = (props: MediaFieldProps) => { + const { field: f, fieldProps = {}, placeholder, className, style, ...otherProps } = props; + + const { id } = f; + + const allFieldProps = { + name: id, id, placeholder, className, style, + disabled: otherProps.isSubmitting, + ...fieldProps + }; + + return !f ? null : ( + + + + ); + } + + const MediaDropdown = (props: MediaDropdownProps) => { + const { field: f, options = [] } = props; + const id = f.id as string; + const value = (props.values as any)[id] || ''; + + return { + props.setFieldTouched(id, true); + }, + onChange: (_event: any, data: DropdownProps) => { + props.setFieldValue(id, data.value); + } + }} /> + } + + return function (props: MediaFormProps) { + const { + initialValues, + values, + dirty, + touched, + errors, + isValid, + setSubmitting, + opts = MediaDropdownOptions.Empty, + } = props; + + const isFieldChanged = (field: FieldName | FieldObject): boolean => { + const fieldName = typeof field === 'string' ? field : (field as FieldObject).id + return ( + dirty && + touched[fieldName] === true && + !isEqual(values[fieldName], initialValues[fieldName]) + ); + }; + + const onSubmit = (sendTx: () => void) => { + if (isValid) { + sendTx(); + } else { + console.log('Form is invalid. Errors:', errors) + } + }; + + const onTxSuccess: TxCallback = (_txResult: SubmittableResult) => { + setSubmitting(false); + }; + + const onTxFailed: TxFailedCallback = (txResult: SubmittableResult | null) => { + setSubmitting(false); + if (txResult === null) { + // Tx cancelled + return; + } + }; + + const allProps = { + ...props, + + // Callbacks: + onSubmit, + onTxSuccess, + onTxFailed, + + // Components: + LabelledText, + LabelledField, + MediaText, + MediaField, + MediaDropdown, + + // Other + opts, + isFieldChanged + } + + return ; + }; +} diff --git a/pioneer/packages/joy-media/src/common/MediaPlayerView.tsx b/pioneer/packages/joy-media/src/common/MediaPlayerView.tsx new file mode 100644 index 0000000000..f7c8749703 --- /dev/null +++ b/pioneer/packages/joy-media/src/common/MediaPlayerView.tsx @@ -0,0 +1,143 @@ +import React, { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import DPlayer from 'react-dplayer'; +import APlayer from 'react-aplayer'; + +import { ApiProps } from '@polkadot/react-api/types'; +import { I18nProps } from '@polkadot/react-components/types'; +import { withCalls, withMulti } from '@polkadot/react-api/with'; +import { Option } from '@polkadot/types/codec'; + +import translate from '../translate'; +import { DiscoveryProviderProps } from '../DiscoveryProvider'; +import { DataObject, ContentId } from '@joystream/types/media'; +import { VideoType } from '../schemas/video/Video'; +import { isAccountAChannelOwner } from '../channels/ChannelHelpers'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { useMyMembership } from '@polkadot/joy-utils/MyMembershipContext'; +import { JoyError } from '@polkadot/joy-utils/JoyStatus'; + +const PLAYER_COMMON_PARAMS = { + lang: 'en', + autoplay: true, + theme: '#2185d0' +} + +// This is just a part of Player's methods that are used in this component. +// To see all the methods available on APlayer and DPlayer visit the next URLs: +// http://aplayer.js.org/#/home?id=api +// http://dplayer.js.org/#/home?id=api +interface PartOfPlayer { + pause: () => void + destroy: () => void +} + +export type RequiredMediaPlayerProps = { + channel: ChannelEntity + video: VideoType + contentId: ContentId +} + +type ContentProps = { + contentType?: string + dataObjectOpt?: Option + resolvedAssetUrl?: string +} + +type MediaPlayerViewProps = ApiProps & I18nProps & + DiscoveryProviderProps & RequiredMediaPlayerProps & ContentProps + +type PlayerProps = RequiredMediaPlayerProps & ContentProps + +function Player(props: PlayerProps) { + const { video, resolvedAssetUrl: url, contentType = 'video/video' } = props + const { thumbnail: cover } = video + const prefix = contentType.substring(0, contentType.indexOf('/')) + + const [ player, setPlayer ] = useState() + + const onPlayerCreated = (newPlayer: PartOfPlayer) => { + console.log('onPlayerCreated:', newPlayer) + setPlayer(newPlayer) + } + + const destroyPlayer = () => { + if (!player) return; + + console.log('Destroy the current player'); + player.pause(); + player.destroy(); + setPlayer(undefined) + } + + useEffect(() => { + return () => { + destroyPlayer() + } + }, [ url ]) + + if (prefix === 'video') { + const video = { url, name, pic: cover }; + return ; + } else if (prefix === 'audio') { + const audio = { url, name, cover }; + return ; + } + + return {contentType} +} + +function InnerComponent(props: MediaPlayerViewProps) { + const { video, resolvedAssetUrl: url } = props + + const { dataObjectOpt, channel } = props; + if (!dataObjectOpt || dataObjectOpt.isNone ) { + return null; + } + + // TODO extract and show the next info from dataObject: + // {"owner":"5GSMNn8Sy8k64mGUWPDafjMZu9bQNX26GujbBQ1LeJpNbrfg","added_at":{"block":2781,"time":1582750854000},"type_id":1,"size":3664485,"liaison":"5HN528fspu4Jg3KXWm7Pu7aUK64RSBz2ZSbwo1XKR9iz3hdY","liaison_judgement":1,"ipfs_content_id":"QmNk4QczoJyPTAKdfoQna6KhAz3FwfjpKyRBXAZHG5djYZ"} + + const { myAccountId } = useMyMembership() + const iAmOwner = isAccountAChannelOwner(channel, myAccountId) + + return ( + + ); +} + +export const MediaPlayerView = withMulti( + InnerComponent, + translate, + withCalls( + ['query.dataDirectory.dataObjectByContentId', + { paramName: 'contentId', propName: 'dataObjectOpt' } ] + ) +) diff --git a/pioneer/packages/joy-media/src/common/MediaPlayerWithResolver.tsx b/pioneer/packages/joy-media/src/common/MediaPlayerWithResolver.tsx new file mode 100644 index 0000000000..d5366bc0ff --- /dev/null +++ b/pioneer/packages/joy-media/src/common/MediaPlayerWithResolver.tsx @@ -0,0 +1,140 @@ +import React, { useState, useEffect } from 'react'; +import axios, { CancelTokenSource } from 'axios'; +import _ from 'lodash'; + +import { ApiProps } from '@polkadot/react-api/types'; +import { I18nProps } from '@polkadot/react-components/types'; +import { withMulti } from '@polkadot/react-api/with'; +import { Option } from '@polkadot/types/codec'; +import { AccountId } from '@polkadot/types/interfaces'; + +import translate from '../translate'; +import { DiscoveryProviderProps, withDiscoveryProvider } from '../DiscoveryProvider'; +import { DataObjectStorageRelationshipId, DataObjectStorageRelationship } from '@joystream/types/media'; +import { Message } from 'semantic-ui-react'; +import { MediaPlayerView, RequiredMediaPlayerProps } from './MediaPlayerView'; +import { JoyInfo } from '@polkadot/joy-utils/JoyStatus'; + +type Props = ApiProps & I18nProps & DiscoveryProviderProps & RequiredMediaPlayerProps; + +function newCancelSource(): CancelTokenSource { + return axios.CancelToken.source() +} + +function InnerComponent(props: Props) { + const { contentId, api, discoveryProvider } = props + + const [ error, setError ] = useState() + const [ resolvedAssetUrl, setResolvedAssetUrl ] = useState() + const [ contentType, setContentType ] = useState() + const [ cancelSource, setCancelSource ] = useState(newCancelSource()) + + useEffect(() => { + + resolveAsset() + + return () => { + cancelSource.cancel() + } + }, [ contentId.encode() ]) + + const resolveAsset = async () => { + setError(undefined) + setCancelSource(newCancelSource()) + + const rids: DataObjectStorageRelationshipId[] = await api.query.dataObjectStorageRegistry.relationshipsByContentId(contentId) as any; + + const allRelationships: Option[] = await Promise.all(rids.map((id) => api.query.dataObjectStorageRegistry.relationships(id))) as any; + + let readyProviders = allRelationships.filter(r => r.isSome).map(r => r.unwrap()) + .filter(r => r.ready) + .map(r => r.storage_provider); + + // runtime doesn't currently guarantee unique set + readyProviders = _.uniqBy(readyProviders, provider => provider.toString()); + + if (!readyProviders.length) { + setError(new Error('No Storage Providers found storing this content')) + return; + } + + // filter out providers no longer in actors list + const stakedActors = await api.query.actors.actorAccountIds() as unknown as AccountId[]; + + readyProviders = _.intersectionBy(stakedActors, readyProviders, provider => provider.toString()); + console.log(`Found ${readyProviders.length} providers ready to serve content: ${readyProviders}`); + + // shuffle to spread the load + readyProviders = _.shuffle(readyProviders); + + // TODO: prioritize already resolved providers, least reported unreachable, closest + // by geography etc.. + + // loop over providers until we find one that responds + while (readyProviders.length) { + const provider = readyProviders.shift(); + if (!provider) continue; + + let assetUrl: string | undefined + try { + assetUrl = await discoveryProvider.resolveAssetEndpoint(provider, contentId.encode(), cancelSource.token); + } catch (err) { + if (axios.isCancel(err)) { + return; + } else { + continue; + } + } + + try { + console.log('Check URL of resolved asset:', assetUrl); + const response = await axios.head(assetUrl, { cancelToken: cancelSource.token }); + + setContentType(response.headers['content-type'] || 'video/video') + setResolvedAssetUrl(assetUrl) + + return; + } catch (err) { + if (axios.isCancel(err)) { + return; + } else { + if (!err.response || (err.response.status >= 500 && err.response.status <= 504)) { + // network connection error + discoveryProvider.reportUnreachable(provider); + } + + // try next provider + continue; + } + } + } + + setError(new Error('Unable to reach any provider serving this content')) + } + + console.log('Content id:', contentId.encode()) + console.log('Resolved asset URL:', resolvedAssetUrl) + + if (error) { + return ( + + Error loading media content +

{error.toString()}

+ +
+ ); + } + + if (!resolvedAssetUrl) { + return Resolving media content. + } + + const playerProps = { ...props, contentType, resolvedAssetUrl } + return +} + +export const MediaPlayerWithResolver = withMulti( + InnerComponent, + translate, + withDiscoveryProvider +) diff --git a/pioneer/packages/joy-media/src/common/NoContentYet.tsx b/pioneer/packages/joy-media/src/common/NoContentYet.tsx new file mode 100644 index 0000000000..ba37da94ed --- /dev/null +++ b/pioneer/packages/joy-media/src/common/NoContentYet.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const NoContentYet: React.FunctionComponent = (props) => { + return
{props.children}
+}; + +export default NoContentYet; \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/common/TypeHelpers.ts b/pioneer/packages/joy-media/src/common/TypeHelpers.ts new file mode 100644 index 0000000000..948f38cbe9 --- /dev/null +++ b/pioneer/packages/joy-media/src/common/TypeHelpers.ts @@ -0,0 +1,43 @@ +import BN from 'bn.js' +import { ChannelId } from "@joystream/types/content-working-group" +import { EntityId, ClassId } from "@joystream/types/versioned-store" + +export type AnyChannelId = ChannelId | BN | number | string + +export type AnyEntityId = EntityId | BN | number | string + +export type AnyClassId = ClassId | BN | number | string + +function canBeId(id: BN | number | string): boolean { + return id instanceof BN || typeof id === 'number' || typeof id === 'string' +} + +export function asChannelId(id: AnyChannelId): ChannelId { + if (id instanceof ChannelId) { + return id + } else if (canBeId(id)) { + return new ChannelId(id) + } else { + throw new Error(`Not supported format for Channel id: ${id}`) + } +} + +export function asEntityId(id: AnyEntityId): EntityId { + if (id instanceof EntityId) { + return id + } else if (canBeId(id)) { + return new EntityId(id) + } else { + throw new Error(`Not supported format for Entity id: ${id}`) + } +} + +export function asClassId(id: AnyClassId): ClassId { + if (id instanceof ClassId) { + return id + } else if (canBeId(id)) { + return new ClassId(id) + } else { + throw new Error(`Not supported format for Class id: ${id}`) + } +} diff --git a/pioneer/packages/joy-media/src/common/index.css b/pioneer/packages/joy-media/src/common/index.css new file mode 100644 index 0000000000..5daaa16903 --- /dev/null +++ b/pioneer/packages/joy-media/src/common/index.css @@ -0,0 +1,405 @@ +$blackFont: #222; +$grayFont: #999; + +$bgHover: #f2f2f2; +$bgSelected: #c7e7fa; + +$borderColor: #e4e4e4; + +.NoContentYet { + padding: 2rem 0; + color: $grayFont; +} + +.JoyBgImg { + background-color: #ccc; + background-size: cover; + background-position: center; +} + +.JoySection { + .ViewAllLink { + float: right; + font-size: 1rem; + font-weight: normal; + text-transform: uppercase; + margin-left: 1rem; + } +} +.Ellipsis { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.JoyTopActionBar { + margin: 1rem 0; + + .button { + margin-right: .5rem; + } +} + +.JoyListOfPreviews { + background-color: #ddd; + + .CheckboxCell { + padding-left: 1rem; + } + + .NoItems { + display: block; + background-color: #fafafa; + padding: 1rem 0; + } +} + +.JoyMusicAlbumPreview, +.JoyMusicTrackPreview { + display: flex; + + &.vertical { + flex-direction: column; + } + + &.horizontal { + flex-direction: row; + + .AlbumCover { + margin-right: 1rem; + } + } + + .JoyListOfPreviews & { + background-color: #fafafa; + margin-top: 1px; + padding: .5rem 0; + + &:hover { + background-color: $bgHover; + } + + &.DraggableItem, + &.SelectedItem { + background-color: $bgSelected; + } + } + + .AlbumNumber { + color: $grayFont; + width: 3.5rem; + text-align: right; + padding-right: 1rem; + } + + .AlbumCover { + text-align: right; + white-space: nowrap; + + $size: 60px; + width: $size; + + img { + max-width: $size; + max-height: $size; + } + } + + .AlbumDescription { + padding: 0 1rem; + width: 100%; + + .AlbumTitle { + font-size: 1rem; + font-weight: bold; + color: $blackFont; + margin-top: 0; + margin-bottom: 0.5rem; + } + + .AlbumArtist, + .AlbumTracksCount { + color: $grayFont; + font-size: .9rem; + } + } + + .AlbumActions { + white-space: nowrap; + opacity: 0; + + .button { + margin-right: .5rem; + } + } + &:hover .AlbumActions { + opacity: 1; + } +} + +/* Music Album */ +/* ------------------------------------------------------ */ + +.JoyMusicAlbumPreview { + display: inline-flex; + flex-direction: column; + padding: 0 1rem 1rem 0; + $size: 200px; + + .AlbumDescription { + text-align: left; + padding: 0; + } + + .AlbumCover { + width: $size; + height: $size; + min-width: $size; + min-height: $size; + } + + .AlbumTitle { + @extend .Ellipsis; + + font-size: 1rem; + font-weight: bold; + color: $blackFont; + margin-top: 0; + margin-bottom: 0.5rem; + } + &.vertical { + .AlbumTitle { + margin-top: 0.5rem; + } + } + + .AlbumArtist { + color: $grayFont; + font-size: 1rem; + } + + .AlbumActions { + opacity: 1; + } +} + +/* Channels */ +/* ------------------------------------------------------ */ + +.ChannelTitle { + color: $blackFont; + display: flex; +} + +.ChannelAvatar { + display: table; + border-radius: 50%; + margin-right: 1rem; + + &.big { + margin-right: 2rem; + } +} + +.JoyChannels { + + .ui.message.JoyInlineMsg { + display: inline-flex; + width: auto; + margin-top: 0; + + &.CreateBtn { + cursor: pointer; + + /* Disable text selection by user: */ + -webkit-touch-callout: none; /* iOS Safari */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Old versions of Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ + user-select: none; /* Non-prefixed version, currently + supported by Chrome, Opera and Firefox */ + + &:hover, + &:focus { + background-color: #f3fce0; + } + &:active { + background-color: #e0f4b7; + } + } + } + + .ChannelPreview { + display: flex; + + .ChannelStats { + margin-left: 3rem; + text-align: right; + + .statistic { + .label, + .value { + text-align: right; + white-space: nowrap; + } + .label { + color: $grayFont; + font-weight: normal; + font-size: .9rem; + } + } + } + } +} + +.JoyViewChannel { + .ChannelHeader { + margin-bottom: 1rem; + } + .ChannelCover { + background-size: cover; + margin-bottom: 2rem; + height: 200px; + } +} + +.ChannelPreview { + display: flex; + align-items: normal; + + .ListOfChannels & { + margin-bottom: 1rem; + } + + .JoyPlayAlbum & { + margin: .5rem 0; + } + + .ChannelDetails { + width: 100%; + } + + .ChannelTitle { + font-size: 1.5rem; + font-weight: 500; + margin: 0; + margin-top: .75rem; + + .ChannelHeader & { + margin-top: 1rem; + } + } + + &.small { + align-items: center; + + .ChannelTitle { + margin-top: 0; + font-size: 1rem; + a { + color: $blackFont; + } + } + .ChannelAvatar { + margin-right: .5rem; + } + } + + &.big { + .ChannelTitle { + font-size: 2rem; + } + } + + .ChannelSubtitle { + color: $grayFont; + font-size: .9rem; + text-transform: uppercase; + margin-top: .5rem; + + .icon { + margin-right: .5rem; + } + } + + .ChannelDesc { + margin-top: .75rem; + } +} + +/* Play Album, Video */ +/* ------------------------------------------------------ */ + +.JoyPlayAlbum { + display: flex; + flex-direction: row; + + .JoyPlayAlbum_Main { + display: flex; + flex-direction: row; + margin-right: 1rem; + + .JoyPlayAlbum_CurrentTrack { + margin-right: 1rem; + + .AlbumTitle { + font-size: 1.5rem; + margin-top: 1rem; + } + .AlbumArtist { + font-size: 1.15rem; + } + } + + .JoyPlayAlbum_AlbumTracks { + .TrackRow { + cursor: pointer; + + &.Current { + background-color: $bgSelected; + } + + .JoyMusicAlbumPreview { + padding: 0; + } + .TrackNumber { + color: $grayFont; + width: 2rem; + padding-left: .5rem !important; + } + .TrackTitle { + padding-right: .5rem !important; + } + .AlbumDescription { + max-width: 300px; + } + } + } + + .JoyPlayAlbum_AlbumTracks, + .JoyPlayAlbum_MetaInfo { + td:first-child { + font-weight: bold; + color: $grayFont; + } + } + } + + .JoyPlayAlbum_RightSidePanel { + max-width: 450px; + } +} + +.ContentHeader { + border-bottom: 1px solid $borderColor; + padding-bottom: 1rem; + margin-bottom: 1rem; +} + +.PlayVideoDetails { + border-top: 1px solid $borderColor; + padding-top: 1rem; + margin-top: 1rem; + margin-bottom: 2rem; +} \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/entities/ChannelEntity.ts b/pioneer/packages/joy-media/src/entities/ChannelEntity.ts new file mode 100644 index 0000000000..b521c9f055 --- /dev/null +++ b/pioneer/packages/joy-media/src/entities/ChannelEntity.ts @@ -0,0 +1,10 @@ +import BN from 'bn.js'; +import { ChannelType } from '../schemas/channel/Channel'; + +// TODO rename to EnrichedChannelType +export type ChannelEntity = ChannelType & { + + // Stats: + rewardEarned: BN, + contentItemsCount: number, +}; \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/entities/EntityHelpers.ts b/pioneer/packages/joy-media/src/entities/EntityHelpers.ts new file mode 100644 index 0000000000..c065555122 --- /dev/null +++ b/pioneer/packages/joy-media/src/entities/EntityHelpers.ts @@ -0,0 +1,15 @@ +import moment from 'moment'; +import ISO6391 from 'iso-639-1'; +import { LanguageType } from '../schemas/general/Language'; + +export function printExplicit(explicit?: boolean): string { + return explicit === true ? 'Yes' : 'No' +} + +export function printReleaseDate(linuxTimestamp?: number): string { + return !linuxTimestamp ? '' : moment(linuxTimestamp * 1000).format('YYYY-MM-DD') +} + +export function printLanguage(language?: LanguageType): string { + return !language ? '' : ISO6391.getName(language.value) +} \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/entities/MusicAlbumEntity.ts b/pioneer/packages/joy-media/src/entities/MusicAlbumEntity.ts new file mode 100644 index 0000000000..0ac647a361 --- /dev/null +++ b/pioneer/packages/joy-media/src/entities/MusicAlbumEntity.ts @@ -0,0 +1,26 @@ +export type MusicAlbumEntity = { + title: string, + artist: string, + thumbnail: string, + description: string, + + explicit: boolean, + license: string, + + year: number, + month?: number, + date?: number, + + genre?: string, + mood?: string, + theme?: string, + + language?: string, + links?: string[], + lyrics?: string, + composer?: string, + reviews?: string, + + // publicationStatus: ... + // curationStatus: ... +}; diff --git a/pioneer/packages/joy-media/src/entities/MusicTrackEntity.ts b/pioneer/packages/joy-media/src/entities/MusicTrackEntity.ts new file mode 100644 index 0000000000..b54b720caf --- /dev/null +++ b/pioneer/packages/joy-media/src/entities/MusicTrackEntity.ts @@ -0,0 +1,18 @@ +export type MusicTrackEntity = { + + // Basic: + title: string, + description?: string, + thumbnail?: string, + visibility?: string, + album?: string, + + // Additional: + artist?: string, + composer?: string, + genre?: string, + mood?: string, + theme?: string, + explicit?: boolean, + license?: string, +}; diff --git a/pioneer/packages/joy-media/src/explore/AllChannels.tsx b/pioneer/packages/joy-media/src/explore/AllChannels.tsx new file mode 100644 index 0000000000..d870f27703 --- /dev/null +++ b/pioneer/packages/joy-media/src/explore/AllChannels.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Section from '@polkadot/joy-utils/Section'; +import { MediaView } from '../MediaView'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { ChannelPreview } from '../channels/ChannelPreview'; + +export type Props = { + channels?: ChannelEntity[] +} + +export function AllChannels (props: Props) { + const { channels = [] } = props; + + return channels.length === 0 + ? No channels found + :
+ {channels.map((x) => + + )} +
+} + +export const AllChannelsView = MediaView({ + component: AllChannels, + resolveProps: async ({ transport }) => ({ + channels: await transport.allPublicVideoChannels() + }) +}) diff --git a/pioneer/packages/joy-media/src/explore/AllVideos.tsx b/pioneer/packages/joy-media/src/explore/AllVideos.tsx new file mode 100644 index 0000000000..152a232108 --- /dev/null +++ b/pioneer/packages/joy-media/src/explore/AllVideos.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import Section from '@polkadot/joy-utils/Section'; +import { VideoPreviewProps, VideoPreview } from '../video/VideoPreview'; +import { MediaView } from '../MediaView'; + +export type Props = { + videos?: VideoPreviewProps[] +} + +export function AllVideos (props: Props) { + const { videos = [] } = props; + + return videos.length === 0 + ? No videos found + :
+ {videos.map((x) => + + )} +
+} + +export const AllVideosView = MediaView({ + component: AllVideos, + resolveProps: async ({ transport }) => ({ + videos: await transport.allPublicVideos() + }) +}) diff --git a/pioneer/packages/joy-media/src/explore/ExploreContent.tsx b/pioneer/packages/joy-media/src/explore/ExploreContent.tsx new file mode 100644 index 0000000000..eb222f092c --- /dev/null +++ b/pioneer/packages/joy-media/src/explore/ExploreContent.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import Section from '@polkadot/joy-utils/Section'; +import { VideoPreviewProps, VideoPreview } from '../video/VideoPreview'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { ChannelPreview } from '../channels/ChannelPreview'; + +const LatestVideosTitle = () => ( +
+ Latest videos + All videos +
+) + +const LatestChannelsTitle = () => ( +
+ Latest video channels + All channels +
+) + +export type ExploreContentProps = { + featuredVideos?: VideoPreviewProps[] + latestVideos?: VideoPreviewProps[] + latestVideoChannels?: ChannelEntity[] +} + +export function ExploreContent (props: ExploreContentProps) { + const { featuredVideos = [], latestVideos = [], latestVideoChannels = [] } = props + + return
+ {featuredVideos.length > 0 && +
+ {featuredVideos.map((x) => + + )} +
+ } + {latestVideos.length > 0 && +
}> + {latestVideos.map((x) => + + )} +
+ } + {latestVideoChannels.length > 0 && +
}> + {latestVideoChannels.map((x) => + + )} +
+ } +
+} diff --git a/pioneer/packages/joy-media/src/explore/ExploreContent.view.tsx b/pioneer/packages/joy-media/src/explore/ExploreContent.view.tsx new file mode 100644 index 0000000000..544745c3f3 --- /dev/null +++ b/pioneer/packages/joy-media/src/explore/ExploreContent.view.tsx @@ -0,0 +1,23 @@ +import { MediaView } from '../MediaView'; +import { ExploreContentProps, ExploreContent } from './ExploreContent'; + +export const ExploreContentView = MediaView({ + component: ExploreContent, + resolveProps: async (props) => { + const { transport } = props; + + const [ + latestVideoChannels, + latestVideos, + featuredVideos + ] = await Promise.all([ + transport.latestPublicVideoChannels(), + transport.latestPublicVideos(), + transport.featuredVideos() + ]) + + return { featuredVideos, latestVideos, latestVideoChannels } + } +}); + +export default ExploreContentView; \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/explore/PlayContent.tsx b/pioneer/packages/joy-media/src/explore/PlayContent.tsx new file mode 100644 index 0000000000..51c37bb495 --- /dev/null +++ b/pioneer/packages/joy-media/src/explore/PlayContent.tsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import { MusicAlbumPreviewProps, MusicAlbumPreview } from '../music/MusicAlbumPreview'; +import { MusicTrackReaderPreviewProps, MusicTrackReaderPreview } from '../music/MusicTrackReaderPreview'; +import { Pluralize } from '@polkadot/joy-utils/Pluralize'; +import { Table } from 'semantic-ui-react'; +import { ChannelEntity } from '../entities/ChannelEntity'; +import { ChannelPreview } from '../channels/ChannelPreview'; + +type Props = { + channel: ChannelEntity, + tracks: MusicTrackReaderPreviewProps[], + currentTrackIndex?: number, + featuredAlbums?: MusicAlbumPreviewProps[], +}; + +// TODO get meta from track item +const meta = { + artist: 'Berlin Philharmonic', + composer: 'Wolfgang Amadeus Mozart', + genre: 'Classical Music', + mood: 'Relaxing', + theme: 'Dark', + explicit: false, + license: 'Public Domain' +} + +export function PlayContent (props: Props) { + const { channel, tracks = [], currentTrackIndex = 0, featuredAlbums = [] } = props; + + const [currentTrack, setCurrentTrack] = useState(tracks[currentTrackIndex]); + + const metaField = (label: React.ReactNode, value: React.ReactNode) => + + {label} + {value} + + + const metaTable = <> +

Track Info

+ + + {metaField('Artist', meta.artist)} + {metaField('Composer', meta.composer)} + {metaField('Genre', meta.genre)} + {metaField('Mood', meta.mood)} + {metaField('Theme', meta.theme)} + {metaField('Explicit', meta.explicit ? 'Yes' : 'No')} + {metaField('License', meta.license)} + +
+ + + const albumTracks = ( +
+

+ + + {tracks.map((x, i) => { + const isCurrent = x.id === currentTrack.id; + const className = `TrackRow ` + (isCurrent ? 'Current' : ''); + + return ( + setCurrentTrack(x)}> + {i + 1} + {x.title} + + ); + })} + +
+
+ ); + + return
+
+
+ + +
+
+ {albumTracks} + {metaTable} +
+
+ {featuredAlbums.length > 0 && +
+

Featured albums

+ {featuredAlbums.map(x => )} +
+ } +
; +} \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/index.css b/pioneer/packages/joy-media/src/index.css new file mode 100644 index 0000000000..9abf0a9a20 --- /dev/null +++ b/pioneer/packages/joy-media/src/index.css @@ -0,0 +1,131 @@ +.JoyPaperWidth { + max-width: 900px; + margin: 0 auto; +} + +.UploadBox { + display: flex; + flex-direction: row; + justify-content: center; + + .UploadSelectForm { + max-width: 500px; + width: 100%; + } + + .UploadInputFile { + padding: 2rem 3rem; + margin-bottom: 1rem; + text-align: center; + height: auto; + + &:hover, + &.FileSelected { + border: 1px solid #2185d0 !important; + .label { + color: #2185d0 !important; + } + } + + i.cloud.icon { + font-size: 3rem; + } + } + + .UploadButtonBox { + text-align: center; + } + + .UploadProgress { + margin-left: calc(210px + 1rem) !important; + width: 100%; + max-width: 600px; + } +} + +.PlayBox { + max-width: 700px; + margin-bottom: 1rem; + h1, h2 { + text-transform: none; + margin: 0; + } + .ContentHeader { + margin-top: 1.5rem; + margin-bottom: .5rem; + } + .DownloadBtn { + float: right; + margin-left: .5rem; + } + .ContentDesc { + margin-top: 1rem; + } +} + +.MediaGrid { + display: flex; + flex-direction: row; + flex-wrap: wrap; + width: 880px; + + .MediaCell { + width: 25%; + + &.MyContent { + /* background-color: #fff8e1; */ + } + &:hover { + background-color: #deeffc; + } + .CellContent { + padding: 5px; + margin-bottom: 15px; + overflow: hidden; + + h3 { + font-size: 1rem; + font-weight: bold; + margin: 1rem 0 .5rem 0; + } + } + + .ThumbBox { + display: flex; + flex-direction: row; + justify-content: center; + margin-bottom: 5px; + + .ThumbImg { + width: 210px; + max-height: 118px; + display: block; + } + } + } +} + +.EditMetaBox { + display: flex; + flex-direction: row; + flex-wrap: wrap; + /* width: 880px; */ + + .EditMetaThumb { + width: 100%; + max-width: 210px; + max-height: 118px; + margin-right: 1rem; + + img { + width: 100%; + max-width: 210px; + max-height: 118px; + } + } + + .EditMetaForm { + width: 100%; + max-width: 600px; + } +} diff --git a/pioneer/packages/joy-media/src/index.tsx b/pioneer/packages/joy-media/src/index.tsx new file mode 100644 index 0000000000..b960a46512 --- /dev/null +++ b/pioneer/packages/joy-media/src/index.tsx @@ -0,0 +1,85 @@ + +import React from 'react'; +import { Route, Switch } from 'react-router'; + +import { AppProps, I18nProps } from '@polkadot/react-components/types'; +import Tabs, { TabItem } from '@polkadot/react-components/Tabs'; +import { ApiProps } from '@polkadot/react-api/types'; +import { withMulti } from '@polkadot/react-api/with'; + +import './index.css'; +import './common/index.css'; + +import translate from './translate'; +import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext'; +import { UploadWithRouter } from './Upload'; +import { DiscoveryProviderProps, DiscoveryProviderProvider } from './DiscoveryProvider'; +import { SubstrateTransportProvider } from './TransportContext'; +import { ChannelsByOwnerWithRouter } from './channels/ChannelsByOwner.view'; +import { EditChannelView, EditChannelWithRouter } from './channels/EditChannel.view'; +import { ExploreContentView } from './explore/ExploreContent.view'; +import { ViewChannelWithRouter } from './channels/ViewChannel.view'; +import { EditVideoWithRouter } from './upload/EditVideo.view'; +import { PlayVideoWithRouter } from './video/PlayVideo.view'; +import { AllVideosView } from './explore/AllVideos'; +import { AllChannelsView } from './explore/AllChannels'; +// import { VideosByOwner } from './video/VideosByOwner'; + +type Props = AppProps & I18nProps & ApiProps & DiscoveryProviderProps & {}; + +function App(props: Props) { + const { t, basePath } = props; + const { state: { address: myAddress } } = useMyAccount(); + + const tabs: TabItem[] = [ + { + isRoot: true, + name: 'explore', + text: t('Explore') + }, + !myAddress ? undefined : { + name: `account/${myAddress}/channels`, + text: t('My channels') + }, + { + name: 'channels/new', + text: t('New channel') + }, + // !myAddress ? undefined : { + // name: `account/${myAddress}/videos`, + // text: t('My videos') + // } + ].filter(x => x !== undefined) as TabItem[]; + + return ( + + +
+
+ +
+ + + + + + + + + {/* */} + + + + + + +
+
+
+ ); +} + +export default withMulti( + App, + translate +); diff --git a/pioneer/packages/joy-media/src/mocks/ContentLicense.mock.ts b/pioneer/packages/joy-media/src/mocks/ContentLicense.mock.ts new file mode 100644 index 0000000000..98b16c036e --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/ContentLicense.mock.ts @@ -0,0 +1,14 @@ +import { newEntityId } from './EntityId.mock'; +import { ContentLicenseType } from '../schemas/general/ContentLicense'; + +const values = [ + 'Public Domain', + 'Share Alike', + 'No Derivatives', + 'No Commercial', +]; + +export const AllContentLicenses: ContentLicenseType[] = + values.map(value => ({ id: newEntityId(), value })) as unknown as ContentLicenseType[] // A hack to fix TS compilation. + +export const ContentLicense = AllContentLicenses[0]; diff --git a/pioneer/packages/joy-media/src/mocks/CurationStatus.mock.ts b/pioneer/packages/joy-media/src/mocks/CurationStatus.mock.ts new file mode 100644 index 0000000000..e2bc986c07 --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/CurationStatus.mock.ts @@ -0,0 +1,18 @@ +import { newEntityId } from './EntityId.mock'; +import { CurationStatusType } from '../schemas/general/CurationStatus'; + +function newEntity (value: string): CurationStatusType { + return { id: newEntityId(), value } as unknown as CurationStatusType // A hack to fix TS compilation. +} + +export const CurationStatus = { + Edited: newEntity('Edited'), + UpdatedSchema: newEntity('Updated schema'), + UnderReview: newEntity('Under review'), + Removed: newEntity('Removed') +}; + +export const AllCurationStatuses: CurationStatusType[] = + Object.values(CurationStatus); + +export const DefaultCurationStatus = CurationStatus.Edited; diff --git a/pioneer/packages/joy-media/src/mocks/EntityId.mock.ts b/pioneer/packages/joy-media/src/mocks/EntityId.mock.ts new file mode 100644 index 0000000000..8b5e2b33c4 --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/EntityId.mock.ts @@ -0,0 +1,9 @@ +let value = 1; + +export function nextEntityId (): number { + return value; +} + +export function newEntityId (): number { + return value++; +} diff --git a/pioneer/packages/joy-media/src/mocks/FeaturedContent.mock.ts b/pioneer/packages/joy-media/src/mocks/FeaturedContent.mock.ts new file mode 100644 index 0000000000..928faa24bc --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/FeaturedContent.mock.ts @@ -0,0 +1,10 @@ +import { newEntityId } from './EntityId.mock'; +import { FeaturedContentType } from '../schemas/general/FeaturedContent'; +import { AllVideos, AllMusicAlbums, Video } from '.'; + +export const FeaturedContent: FeaturedContentType = { + id: newEntityId(), + topVideo: Video, + featuredVideos: AllVideos, + featuredAlbums: AllMusicAlbums +} as unknown as FeaturedContentType // A hack to fix TS compilation. \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/mocks/Language.mock.ts b/pioneer/packages/joy-media/src/mocks/Language.mock.ts new file mode 100644 index 0000000000..ed3457ac0e --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/Language.mock.ts @@ -0,0 +1,11 @@ +import { newEntityId } from './EntityId.mock'; +import { LanguageType } from '../schemas/general/Language'; + +const values = [ + 'aa', 'ab', 'ae', 'af', 'ak', 'am', 'an', 'ar', 'as', 'av', 'ay', 'az', 'ba', 'be', 'bg', 'bh', 'bi', 'bm', 'bn', 'bo', 'br', 'bs', 'ca', 'ce', 'ch', 'co', 'cr', 'cs', 'cu', 'cv', 'cy', 'da', 'de', 'dv', 'dz', 'ee', 'el', 'en', 'eo', 'es', 'et', 'eu', 'fa', 'ff', 'fi', 'fj', 'fo', 'fr', 'fy', 'ga', 'gd', 'gl', 'gn', 'gu', 'gv', 'ha', 'he', 'hi', 'ho', 'hr', 'ht', 'hu', 'hy', 'hz', 'ia', 'id', 'ie', 'ig', 'ii', 'ik', 'io', 'is', 'it', 'iu', 'ja', 'jv', 'ka', 'kg', 'ki', 'kj', 'kk', 'kl', 'km', 'kn', 'ko', 'kr', 'ks', 'ku', 'kv', 'kw', 'ky', 'la', 'lb', 'lg', 'li', 'ln', 'lo', 'lt', 'lu', 'lv', 'mg', 'mh', 'mi', 'mk', 'ml', 'mn', 'mr', 'ms', 'mt', 'my', 'na', 'nb', 'nd', 'ne', 'ng', 'nl', 'nn', 'no', 'nr', 'nv', 'ny', 'oc', 'oj', 'om', 'or', 'os', 'pa', 'pi', 'pl', 'ps', 'pt', 'qu', 'rm', 'rn', 'ro', 'ru', 'rw', 'sa', 'sc', 'sd', 'se', 'sg', 'si', 'sk', 'sl', 'sm', 'sn', 'so', 'sq', 'sr', 'ss', 'st', 'su', 'sv', 'sw', 'ta', 'te', 'tg', 'th', 'ti', 'tk', 'tl', 'tn', 'to', 'tr', 'ts', 'tt', 'tw', 'ty', 'ug', 'uk', 'ur', 'uz', 've', 'vi', 'vo', 'wa', 'wo', 'xh', 'yi', 'yo', 'za', 'zh', 'zu' +]; + +export const AllLanguages: LanguageType[] = + values.map(value => ({ id: newEntityId(), value })) as unknown as LanguageType[] // A hack to fix TS compilation. + +export const Language = AllLanguages[0]; diff --git a/pioneer/packages/joy-media/src/mocks/MediaObject.mock.ts b/pioneer/packages/joy-media/src/mocks/MediaObject.mock.ts new file mode 100644 index 0000000000..74f57a1f24 --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/MediaObject.mock.ts @@ -0,0 +1,17 @@ +import { newEntityId } from './EntityId.mock'; +import { MediaObjectType } from '../schemas/general/MediaObject'; + +const values = [ + '5Gm2XPvDm1RhYW2CEbVvrMbRFguL4TMmzP3tm72wvhCZdx5G', + '5GTGqmWTurJhYY5UoHzFrAqAxL5ry4Jegw9pmjKniQ3KWWww', + '5CbyRopmCNwLYyRCwHrmovoQ15MMCau9v9cmazbWuQjY9DG2', + '5GTXWLWgfCM6GpsBkeJQZvF6RFvZh3SjCsH8aGUY1WwV5YGU', + '5CSBeDZR5baBcnLYZsP839P1uqZKfz3D9Uip43uvhUd56XAq', + '5EXsnf4sS6wVsgjqQmT2jchP2LdGXLxZZSJijjTiUxcLm7Vg', + '5HRieqw8oRZfwc6paio4TrBeYvmdTstGB2KoKE9gL5qLnAQY', +]; + +export const AllMediaObjects: MediaObjectType[] = + values.map(value => ({ id: newEntityId(), value })) as unknown as MediaObjectType[] // A hack to fix TS compilation. + +export const MediaObject = AllMediaObjects[0]; diff --git a/pioneer/packages/joy-media/src/mocks/MusicAlbum.mock.ts b/pioneer/packages/joy-media/src/mocks/MusicAlbum.mock.ts new file mode 100644 index 0000000000..8207409b09 --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/MusicAlbum.mock.ts @@ -0,0 +1,34 @@ +import { newEntityId } from './EntityId.mock'; +import { MusicAlbumType } from '../schemas/music/MusicAlbum'; +import { MusicGenre } from './MusicGenre.mock'; +import { MusicMood } from './MusicMood.mock'; +import { MusicTheme } from './MusicTheme.mock'; +import { DefaultPublicationStatus } from './PublicationStatus.mock'; +import { DefaultCurationStatus } from './CurationStatus.mock'; +import { ContentLicense } from './ContentLicense.mock'; +import { Language } from './Language.mock'; + +export const MusicAlbum: MusicAlbumType = { + id: newEntityId(), + title: 'Riddle', + artist: 'Liquid Stone', + thumbnail: 'https://images.unsplash.com/photo-1484352491158-830ef5692bb3?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60', + description: 'Building material is any material which is used for construction purposes. Many naturally occurring substances, such as clay, rocks, sand, and wood, even twigs and leaves, have been used to construct buildings.\n\nApart from naturally occurring materials, many man-made products are in use, some more and some less synthetic.', + firstReleased: 567425123, // 1987 year. + genre: MusicGenre, + mood: MusicMood, + theme: MusicTheme, + tracks: [], + language: Language, + link: [], + lyrics: undefined, + composerOrSongwriter: 'Massive Sand', + reviews: [], + publicationStatus: DefaultPublicationStatus, + curationStatus: DefaultCurationStatus, + explicit: false, + license: ContentLicense, + attribution: undefined +} as unknown as MusicAlbumType // A hack to fix TS compilation. + +export const AllMusicAlbums: MusicAlbumType[] = [ MusicAlbum ] \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/mocks/MusicGenre.mock.ts b/pioneer/packages/joy-media/src/mocks/MusicGenre.mock.ts new file mode 100644 index 0000000000..4020443d5c --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/MusicGenre.mock.ts @@ -0,0 +1,31 @@ +import { newEntityId } from './EntityId.mock'; +import { MusicGenreType } from '../schemas/music/MusicGenre'; + +const values = [ + 'Avant-Garde', + 'Blues', + 'Children\'s', + 'Classical', + 'Comedy/Spoken', + 'Country', + 'Easy Listening', + 'Electronic', + 'Folk', + 'Holiday', + 'International', + 'Jazz', + 'Latin', + 'New Age', + 'Pop/Rock', + 'R&B', + 'Rap', + 'Reggae', + 'Religious', + 'Stage & Screen', + 'Vocal' +]; + +export const AllMusicGenres: MusicGenreType[] = + values.map(value => ({ id: newEntityId(), value })) as unknown as MusicGenreType[] // A hack to fix TS compilation. + +export const MusicGenre = AllMusicGenres[0]; diff --git a/pioneer/packages/joy-media/src/mocks/MusicMood.mock.ts b/pioneer/packages/joy-media/src/mocks/MusicMood.mock.ts new file mode 100644 index 0000000000..fe50b8f6d1 --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/MusicMood.mock.ts @@ -0,0 +1,299 @@ +import { newEntityId } from './EntityId.mock'; +import { MusicMoodType } from '../schemas/music/MusicMood'; + +const values = [ + 'Acerbic', + 'Aggressive', + 'Agreeable', + 'Airy', + 'Ambitious', + 'Amiable/Good-Natured', + 'Angry', + 'Angst-Ridden', + 'Anguished/Distraught', + 'Angular', + 'Animated', + 'Apocalyptic', + 'Arid', + 'Athletic', + 'Atmospheric', + 'Austere', + 'Autumnal', + 'Belligerent', + 'Benevolent', + 'Bitter', + 'Bittersweet', + 'Bleak', + 'Boisterous', + 'Bombastic', + 'Brash', + 'Brassy', + 'Bravado', + 'Bright', + 'Brittle', + 'Brooding', + 'Calm/Peaceful', + 'Campy', + 'Capricious', + 'Carefree', + 'Cartoonish', + 'Cathartic', + 'Celebratory', + 'Cerebral', + 'Cheerful', + 'Child-like', + 'Circular', + 'Clinical', + 'Cold', + 'Comic', + 'Complex', + 'Concise', + 'Confident', + 'Confrontational', + 'Cosmopolitan', + 'Crunchy', + 'Cynical/Sarcastic', + 'Dark', + 'Declamatory', + 'Defiant', + 'Delicate', + 'Demonic', + 'Desperate', + 'Detached', + 'Devotional', + 'Difficult', + 'Dignified/Noble', + 'Dramatic', + 'Dreamy', + 'Driving', + 'Druggy', + 'Earnest', + 'Earthy', + 'Ebullient', + 'Eccentric', + 'Ecstatic', + 'Eerie', + 'Effervescent', + 'Elaborate', + 'Elegant', + 'Elegiac', + 'Energetic', + 'Enigmatic', + 'Epic', + 'Erotic', + 'Ethereal', + 'Euphoric', + 'Exciting', + 'Exotic', + 'Explosive', + 'Extroverted', + 'Exuberant', + 'Fantastic/Fantasy-like', + 'Feral', + 'Feverish', + 'Fierce', + 'Fiery', + 'Flashy', + 'Flowing', + 'Fractured', + 'Freewheeling', + 'Fun', + 'Funereal', + 'Gentle', + 'Giddy', + 'Gleeful', + 'Gloomy', + 'Graceful', + 'Greasy', + 'Grim', + 'Gritty', + 'Gutsy', + 'Happy', + 'Harsh', + 'Hedonistic', + 'Heroic', + 'Hostile', + 'Humorous', + 'Hungry', + 'Hymn-like', + 'Hyper', + 'Hypnotic', + 'Improvisatory', + 'Indulgent', + 'Innocent', + 'Insular', + 'Intense', + 'Intimate', + 'Introspective', + 'Ironic', + 'Irreverent', + 'Jovial', + 'Joyous', + 'Kinetic', + 'Knotty', + 'Laid-Back/Mellow', + 'Languid', + 'Lazy', + 'Light', + 'Literate', + 'Lively', + 'Lonely', + 'Lush', + 'Lyrical', + 'Macabre', + 'Magical', + 'Majestic', + 'Malevolent', + 'Manic', + 'Marching', + 'Martial', + 'Meandering', + 'Mechanical', + 'Meditative', + 'Melancholy', + 'Menacing', + 'Messy', + 'Mighty', + 'Monastic', + 'Monumental', + 'Motoric', + 'Mysterious', + 'Mystical', + 'Naive', + 'Narcotic', + 'Narrative', + 'Negative', + 'Nervous/Jittery', + 'Nihilistic', + 'Nocturnal', + 'Nostalgic', + 'Ominous', + 'Optimistic', + 'Opulent', + 'Organic', + 'Ornate', + 'Outraged', + 'Outrageous', + 'Paranoid', + 'Passionate', + 'Pastoral', + 'Patriotic', + 'Perky', + 'Philosophical', + 'Plain', + 'Plaintive', + 'Playful', + 'Poignant', + 'Positive', + 'Powerful', + 'Precious', + 'Provocative', + 'Pulsing', + 'Pure', + 'Quirky', + 'Rambunctious', + 'Ramshackle', + 'Raucous', + 'Reassuring/Consoling', + 'Rebellious', + 'Reckless', + 'Refined', + 'Reflective', + 'Regretful', + 'Relaxed', + 'Reserved', + 'Resolute', + 'Restrained', + 'Reverent', + 'Rhapsodic', + 'Rollicking', + 'Romantic', + 'Rousing', + 'Rowdy', + 'Rustic', + 'Sacred', + 'Sad', + 'Sarcastic', + 'Sardonic', + 'Satirical', + 'Savage', + 'Scary', + 'Scattered', + 'Searching', + 'Self-Conscious', + 'Sensual', + 'Sentimental', + 'Serious', + 'Severe', + 'Sexual', + 'Sexy', + 'Shimmering', + 'Silly', + 'Sleazy', + 'Slick', + 'Smooth', + 'Snide', + 'Soft/Quiet', + 'Somber', + 'Soothing', + 'Sophisticated', + 'Spacey', + 'Sparkling', + 'Sparse', + 'Spicy', + 'Spiritual', + 'Spontaneous', + 'Spooky', + 'Sprawling', + 'Sprightly', + 'Springlike', + 'Stately', + 'Street-Smart', + 'Striding', + 'Strong', + 'Stylish', + 'Suffocating', + 'Sugary', + 'Summery', + 'Suspenseful', + 'Swaggering', + 'Sweet', + 'Swinging', + 'Technical', + 'Tender', + 'Tense/Anxious', + 'Theatrical', + 'Thoughtful', + 'Threatening', + 'Thrilling', + 'Thuggish', + 'Tragic', + 'Transparent/Translucent', + 'Trashy', + 'Trippy', + 'Triumphant', + 'Tuneful', + 'Turbulent', + 'Uncompromising', + 'Understated', + 'Unsettling', + 'Uplifting', + 'Urgent', + 'Virile', + 'Visceral', + 'Volatile', + 'Vulgar', + 'Warm', + 'Weary', + 'Whimsical', + 'Wintry', + 'Wistful', + 'Witty', + 'Wry', + 'Yearning' +]; + +export const AllMusicMoods: MusicMoodType[] = + values.map(value => ({ id: newEntityId(), value })) as unknown as MusicMoodType[] // A hack to fix TS compilation. + +export const MusicMood = AllMusicMoods[0]; diff --git a/pioneer/packages/joy-media/src/mocks/MusicTheme.mock.ts b/pioneer/packages/joy-media/src/mocks/MusicTheme.mock.ts new file mode 100644 index 0000000000..b9f8d72a8f --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/MusicTheme.mock.ts @@ -0,0 +1,192 @@ +import { newEntityId } from './EntityId.mock'; +import { MusicThemeType } from '../schemas/music/MusicTheme'; + +const values = [ + 'Adventure', + 'Affection/Fondness', + 'Affirmation', + 'Anger/Hostility', + 'Animals', + 'Anniversary', + 'Argument', + 'At the Beach', + 'At the Office', + 'Autumn', + 'Award Winners', + 'Awareness', + 'Background Music', + 'Biographical', + 'Birth', + 'Birthday', + 'Breakup', + 'Cars', + 'Celebration', + 'Celebrities', + 'Children', + 'Christmas', + 'Christmas Party', + 'City Life', + 'Classy Gatherings', + 'Club', + 'Comfort', + 'Conflict', + 'Cool & Cocky', + 'Country Life', + 'Crime', + 'D-I-V-O-R-C-E', + 'Dance Party', + 'Day Driving', + 'Daydreaming', + 'Death', + 'Despair', + 'Destiny', + 'Dinner Ambiance', + 'Disappointment', + 'Dreaming', + 'Drinking', + 'Drugs', + 'Early Morning', + 'Easter', + 'Empowering', + 'Everyday Life', + 'Exercise/Workout', + 'Family', + 'Family Gatherings', + 'Fantasy', + 'Fear', + 'Feeling Blue', + 'Flying', + 'Food/Eating', + 'Forgiveness', + 'Fourth of July', + 'Freedom', + 'Friendship', + 'Funeral', + 'Girls Night Out', + 'Good Times', + 'Goodbyes', + 'Graduation', + 'Guys Night Out', + 'Halloween', + 'Hanging Out', + 'Happiness', + 'Healing/Comfort', + 'Heartache', + 'Heartbreak', + 'High School', + 'Historical Events', + 'Holidays', + 'Home', + 'Homecoming', + 'Hope', + 'Housework', + 'Illness', + 'In Love', + 'Introspection', + 'Jealousy', + 'Joy', + 'Late Night', + 'Lifecycle', + 'Loneliness', + 'Long Walk', + 'Loss/Grief', + 'Lying', + 'Magic', + 'Maverick', + 'Meditation', + 'Memorial', + 'Military', + 'Mischievous', + 'Monday Morning', + 'Money', + 'Moon', + 'Morning', + 'Motivation', + 'Music', + 'Myths & Legends', + 'Nature', + 'New Love', + 'Night Driving', + 'Nighttime', + 'Open Road', + 'Other Times & Places', + 'Pain', + 'Parenthood', + 'Partying', + 'Passion', + 'Patriotism', + 'Peace', + 'Picnic', + 'Playful', + 'Poetry', + 'Politics/Society', + 'Pool Party', + 'Prom', + 'Promises', + 'Protest', + 'Rainy Day', + 'Reflection', + 'Regret', + 'Relationships', + 'Relaxation', + 'Religion', + 'Reminiscing', + 'Reunion', + 'Revolutionary', + 'Road Trip', + 'Romance', + 'Romantic Evening', + 'Scary Music', + 'School', + 'Science', + 'SciFi', + 'Seduction', + 'Separation', + 'Sex', + 'Slow Dance', + 'Small Gathering', + 'Solitude', + 'Sorrow', + 'Sports', + 'Spring', + 'Starry Sky', + 'Starting Out', + 'Stay in Bed', + 'Storms', + 'Street Life', + 'Summer', + 'Sun', + 'Sunday Afternoon', + 'Sweet Dreams', + 'Teenagers', + 'Temptation', + 'TGIF', + 'Thanksgiving', + 'The Creative Side', + 'The Great Outdoors', + 'Theme', + 'Tragedy', + 'Travel', + 'Truth', + 'Vacation', + 'Victory', + 'Violence', + 'Visions', + 'War', + 'Water', + 'Weather', + 'Wedding', + 'Winter', + 'Wisdom', + 'Word Play', + 'Work', + 'World View', + 'Yearning', + 'Youth', + 'Zeitgeist' +]; + +export const AllMusicThemes: MusicThemeType[] = + values.map(value => ({ id: newEntityId(), value })) as unknown as MusicThemeType[] // A hack to fix TS compilation. + +export const MusicTheme = AllMusicThemes[0]; diff --git a/pioneer/packages/joy-media/src/mocks/MusicTrack.mock.ts b/pioneer/packages/joy-media/src/mocks/MusicTrack.mock.ts new file mode 100644 index 0000000000..a5402a4c69 --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/MusicTrack.mock.ts @@ -0,0 +1,33 @@ +import { newEntityId } from './EntityId.mock'; +import { MusicTrackType } from '../schemas/music/MusicTrack'; +import { MusicGenre } from './MusicGenre.mock'; +import { MusicMood } from './MusicMood.mock'; +import { MusicTheme } from './MusicTheme.mock'; +import { DefaultPublicationStatus } from './PublicationStatus.mock'; +import { DefaultCurationStatus } from './CurationStatus.mock'; +import { ContentLicense } from './ContentLicense.mock'; +import { Language } from './Language.mock'; + +export const MusicTrack: MusicTrackType = { + id: newEntityId(), + title: 'Requiem (Mozart)', + artist: 'Berlin Philharmonic', + thumbnail: 'https://assets.classicfm.com/2017/36/mozart-1504532179-list-handheld-0.jpg', + description: 'The Requiem in D minor, K. 626, is a requiem mass by Wolfgang Amadeus Mozart (1756–1791). Mozart composed part of the Requiem in Vienna in late 1791, but it was unfinished at his death on 5 December the same year.', + language: Language, + firstReleased: 567425967, // 1987 year. + genre: MusicGenre, + mood: MusicMood, + theme: MusicTheme, + link: [], + composerOrSongwriter: 'Mozart', + lyrics: undefined, + object: undefined, + publicationStatus: DefaultPublicationStatus, + curationStatus: DefaultCurationStatus, + explicit: false, + license: ContentLicense, + attribution: undefined +} as unknown as MusicTrackType // A hack to fix TS compilation. + +export const AllMusicTracks: MusicTrackType[] = [ MusicTrack ] \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/mocks/PublicationStatus.mock.ts b/pioneer/packages/joy-media/src/mocks/PublicationStatus.mock.ts new file mode 100644 index 0000000000..3b3d61ac7c --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/PublicationStatus.mock.ts @@ -0,0 +1,16 @@ +import { newEntityId } from './EntityId.mock'; +import { PublicationStatusType } from '../schemas/general/PublicationStatus'; + +function newEntity (value: string): PublicationStatusType { + return { id: newEntityId(), value } as unknown as PublicationStatusType // A hack to fix TS compilation. +} + +export const PublicationStatus = { + Publiс: newEntity('Publiс'), + Unlisted: newEntity('Unlisted'), +}; + +export const AllPublicationStatuses: PublicationStatusType[] = + Object.values(PublicationStatus); + +export const DefaultPublicationStatus = PublicationStatus.Publiс; diff --git a/pioneer/packages/joy-media/src/mocks/Video.mock.ts b/pioneer/packages/joy-media/src/mocks/Video.mock.ts new file mode 100644 index 0000000000..9bff6335c6 --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/Video.mock.ts @@ -0,0 +1,53 @@ +import { newEntityId } from './EntityId.mock'; +import { VideoType } from '../schemas/video/Video'; +import { Language } from './Language.mock'; +import { VideoCategory } from './VideoCategory.mock'; +import { DefaultPublicationStatus } from './PublicationStatus.mock'; +import { DefaultCurationStatus } from './CurationStatus.mock'; +import { ContentLicense } from './ContentLicense.mock'; + +const titles = [ + 'Arborvitae (Thuja occidentalis)', + 'Black Ash (Fraxinus nigra)', + 'White Ash (Fraxinus americana)', + 'Bigtooth Aspen (Populus grandidentata)', + 'Quaking Aspen (Populus tremuloides)', + 'Basswood (Tilia americana)', + 'American Beech (Fagus grandifolia)', + 'Black Birch (Betula lenta)', + 'Gray Birch (Betula populifolia)', + 'Paper Birch (Betula papyrifera)', + 'Yellow Birch (Betula alleghaniensis)', + 'Butternut (Juglans cinerea)', + 'Black Cherry (Prunus serotina)', + 'Pin Cherry (Prunus pensylvanica)' +]; + +const thumbnails = [ + 'https://images.unsplash.com/photo-1477414348463-c0eb7f1359b6?ixlib=rb-1.2.1&auto=format&fit=crop&w=200&q=60', + 'https://images.unsplash.com/photo-1484352491158-830ef5692bb3?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60', + 'https://images.unsplash.com/photo-1543467091-5f0406620f8b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60', + 'https://images.unsplash.com/photo-1526749837599-b4eba9fd855e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60', + 'https://images.unsplash.com/photo-1504567961542-e24d9439a724?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60', + 'https://images.unsplash.com/photo-1543716091-a840c05249ec?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60', + 'https://images.unsplash.com/photo-1444465693019-aa0b6392460d?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60', +]; + +export const AllVideos: VideoType[] = thumbnails.map((thumbnail, i) => ({ + id: newEntityId(), + title: titles[i], + thumbnail, + description: 'Nature, in the broadest sense, is the natural, physical, or material world or universe. "Nature" can refer to the phenomena of the physical world, and also to life in general. The study of nature is a large, if not the only, part of science. Although humans are part of nature, human activity is often understood as a separate category from other natural phenomena.', + language: Language, + firstReleased: 567425543, // 1987 year. + category: VideoCategory, + link: [], + object: undefined, + publicationStatus: DefaultPublicationStatus, + curationStatus: DefaultCurationStatus, + explicit: true, + license: ContentLicense, + attribution: undefined +})) as unknown as VideoType[] // A hack to fix TS compilation. + +export const Video = AllVideos[0]; diff --git a/pioneer/packages/joy-media/src/mocks/VideoCategory.mock.ts b/pioneer/packages/joy-media/src/mocks/VideoCategory.mock.ts new file mode 100644 index 0000000000..0a47018a11 --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/VideoCategory.mock.ts @@ -0,0 +1,25 @@ +import { newEntityId } from './EntityId.mock'; +import { VideoCategoryType } from '../schemas/video/VideoCategory'; + +const values = [ + 'Film & Animation', + 'Autos & Vehicles', + 'Music', + 'Pets & Animals', + 'Sports', + 'Travel & Events', + 'Gaming', + 'People & Blogs', + 'Comedy', + 'Entertainment', + 'News & Politics', + 'Howto & Style', + 'Education', + 'Science & Technology', + 'Nonprofits & Activism' +]; + +export const AllVideoCategories: VideoCategoryType[] = + values.map(value => ({ id: newEntityId(), value })) as unknown as VideoCategoryType[] // A hack to fix TS compilation. + +export const VideoCategory = AllVideoCategories[0]; diff --git a/pioneer/packages/joy-media/src/mocks/index.ts b/pioneer/packages/joy-media/src/mocks/index.ts new file mode 100644 index 0000000000..8427d9995d --- /dev/null +++ b/pioneer/packages/joy-media/src/mocks/index.ts @@ -0,0 +1,14 @@ +export * from './ContentLicense.mock'; +export * from './CurationStatus.mock'; +export * from './EntityId.mock'; +export * from './FeaturedContent.mock'; +export * from './Language.mock'; +export * from './MediaObject.mock'; +export * from './MusicAlbum.mock'; +export * from './MusicGenre.mock'; +export * from './MusicMood.mock'; +export * from './MusicTheme.mock'; +export * from './MusicTrack.mock'; +export * from './PublicationStatus.mock'; +export * from './Video.mock'; +export * from './VideoCategory.mock'; diff --git a/pioneer/packages/joy-media/src/music/EditAlbumModal.tsx b/pioneer/packages/joy-media/src/music/EditAlbumModal.tsx new file mode 100644 index 0000000000..552dd2dc8f --- /dev/null +++ b/pioneer/packages/joy-media/src/music/EditAlbumModal.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { Button, Modal } from 'semantic-ui-react'; +import { TracksOfMyMusicAlbumProps, TracksOfMyMusicAlbum } from './MusicAlbumTracks'; + +export const EditAlbumModal = (props: TracksOfMyMusicAlbumProps) => { + return Edit album} centered={false}> + Edit My Album + + + + + + +} \ No newline at end of file diff --git a/pioneer/packages/joy-media/src/music/EditMusicAlbum.tsx b/pioneer/packages/joy-media/src/music/EditMusicAlbum.tsx new file mode 100644 index 0000000000..b78d1dd320 --- /dev/null +++ b/pioneer/packages/joy-media/src/music/EditMusicAlbum.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { Button, Tab } from 'semantic-ui-react'; +import { Form, withFormik } from 'formik'; +import { History } from 'history'; + +import TxButton from '@polkadot/joy-utils/TxButton'; +import { onImageError } from '@polkadot/joy-utils/images'; +import { ReorderableTracks } from './ReorderableTracks'; +import { MusicAlbumValidationSchema, MusicAlbumType, MusicAlbumClass as Fields, MusicAlbumFormValues, MusicAlbumToFormValues } from '../schemas/music/MusicAlbum'; +import { withMediaForm, MediaFormProps, datePlaceholder } from '../common/MediaForms'; +import EntityId from '@joystream/types/versioned-store/EntityId'; +import { MediaDropdownOptions } from '../common/MediaDropdownOptions'; +import { MusicTrackReaderPreviewProps } from './MusicTrackReaderPreview'; +import { FormTabs } from '../common/FormTabs'; + +export type OuterProps = { + history?: History, + id?: EntityId, + entity?: MusicAlbumType, + tracks?: MusicTrackReaderPreviewProps[] + opts?: MediaDropdownOptions +}; + +type FormValues = MusicAlbumFormValues; + +const InnerForm = (props: MediaFormProps) => { + const { + // React components for form fields: + MediaText, + MediaDropdown, + LabelledField, + + // Callbacks: + onSubmit, + onTxSuccess, + onTxFailed, + + // history, + entity, + tracks = [], + opts = MediaDropdownOptions.Empty, + + values, + dirty, + errors, + isValid, + isSubmitting, + resetForm + } = props; + + const { thumbnail } = values; + + const isNew = !entity; + + const buildTxParams = () => { + if (!isValid) return []; + + return [ /* TODO save entity to versioned store */ ]; + }; + + const basicInfoTab = () => + + + + + + + + + + + const additionalTab = () => + + + + + + + + + + const tracksTab = () => + This album has no tracks yet.} + /> + + + const tabs = ; + + const renderMainButton = () => + + + return
+
+ {thumbnail && } +
+ +
+ + {tabs} + + + {renderMainButton()} +
; +}; + +export const EditForm = withFormik({ + + // Transform outer props into form values + mapPropsToValues: (props): FormValues => { + const { entity } = props; + return MusicAlbumToFormValues(entity); + }, + + validationSchema: MusicAlbumValidationSchema, + + handleSubmit: () => { + // do submitting things + } +})(withMediaForm(InnerForm) as any); + +export default EditForm; diff --git a/pioneer/packages/joy-media/src/music/EditMusicAlbum.view.tsx b/pioneer/packages/joy-media/src/music/EditMusicAlbum.view.tsx new file mode 100644 index 0000000000..11b6f63a8c --- /dev/null +++ b/pioneer/packages/joy-media/src/music/EditMusicAlbum.view.tsx @@ -0,0 +1,15 @@ +import { MediaView } from '../MediaView'; +import { OuterProps, EditForm } from './EditMusicAlbum'; + +export const EditMusicAlbumView = MediaView({ + component: EditForm, + triggers: [ 'id' ], + resolveProps: async (props) => { + const { transport, id } = props; + const entity = id ? await transport.musicAlbumById(id) : undefined; + const opts = await transport.dropdownOptions(); + return { entity, opts }; + } +}); + +export default EditMusicAlbumView; diff --git a/pioneer/packages/joy-media/src/music/MusicAlbumPreview.tsx b/pioneer/packages/joy-media/src/music/MusicAlbumPreview.tsx new file mode 100644 index 0000000000..eb81def33d --- /dev/null +++ b/pioneer/packages/joy-media/src/music/MusicAlbumPreview.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Button } from 'semantic-ui-react'; +import { Pluralize } from '@polkadot/joy-utils/Pluralize'; +import { BgImg } from '../common/BgImg'; +import { ChannelEntity } from '../entities/ChannelEntity'; + +export type MusicAlbumPreviewProps = { + id: string, + title: string, + artist: string, + cover: string, + tracksCount: number, + + // Extra props: + channel?: ChannelEntity, + size?: number, + withActions?: boolean +}; + +export function MusicAlbumPreview (props: MusicAlbumPreviewProps) { + const { size = 200 } = props; + + // TODO show the channel this album belongs to. + + return
+ + + +
+

{props.title}

+
{props.artist}
+
+
+ + {props.withActions &&
+
} +
; +} diff --git a/pioneer/packages/joy-media/src/music/MusicAlbumTracks.tsx b/pioneer/packages/joy-media/src/music/MusicAlbumTracks.tsx new file mode 100644 index 0000000000..9d0f1c4f9f --- /dev/null +++ b/pioneer/packages/joy-media/src/music/MusicAlbumTracks.tsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import { Button, CheckboxProps } from 'semantic-ui-react'; +import { Pluralize } from '@polkadot/joy-utils/Pluralize'; +import { EditableMusicTrackPreviewProps, MusicTrackPreview } from './MusicTrackPreview'; +import { MusicAlbumPreviewProps, MusicAlbumPreview } from './MusicAlbumPreview'; + +export type TracksOfMyMusicAlbumProps = { + album: MusicAlbumPreviewProps, + tracks?: EditableMusicTrackPreviewProps[] +}; + +export function TracksOfMyMusicAlbum (props: TracksOfMyMusicAlbumProps) { + const [idxsOfSelectedTracks, setIdxsOfSelectedTracks] = useState(new Set()); + + const { album, tracks = [] } = props; + const tracksCount = tracks && tracks.length || 0; + + const onTrackSelect = ( + trackIdx: number, + _event: React.FormEvent, + data: CheckboxProps + ) => { + const set = new Set(idxsOfSelectedTracks); + data.checked + ? set.add(trackIdx) + : set.delete(trackIdx) + ; + setIdxsOfSelectedTracks(set); + } + + const selectedCount = idxsOfSelectedTracks.size; + + const removeButtonText = Remove from album + + return <> + + +
+
+ +
+ {tracksCount === 0 + ? This album has no tracks yet + : tracks.map((track, i) => + onTrackSelect(i, e, d)} + withRemoveButton + /> + ) + } +
+ ; +} diff --git a/pioneer/packages/joy-media/src/music/MusicTrackPreview.tsx b/pioneer/packages/joy-media/src/music/MusicTrackPreview.tsx new file mode 100644 index 0000000000..296ee7fa37 --- /dev/null +++ b/pioneer/packages/joy-media/src/music/MusicTrackPreview.tsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import { Button, Checkbox, CheckboxProps } from 'semantic-ui-react'; + +type OnCheckboxChange = (event: React.FormEvent, data: CheckboxProps) => void; + +export type EditableMusicTrackPreviewProps = { + id: string, + title: string, + artist: string, + thumbnail: string, + position?: number, + selected?: boolean, + onSelect?: OnCheckboxChange, + onEdit?: () => void, + onRemove?: () => void, + withEditButton?: boolean, + withRemoveButton?: boolean, + withActionLabels?: boolean + isDraggable?: boolean, +}; + +export function MusicTrackPreview (props: EditableMusicTrackPreviewProps) { + const { + withActionLabels = false, + selected = false, + onEdit = () => {}, + onRemove = () => {} + } = props; + + const [checked, setChecked] = useState(selected); + + const onChange: OnCheckboxChange = (e, d) => { + try { + props.onSelect && props.onSelect(e, d); + } catch (err) { + console.log('Error during checkbox change:', err); + } + setChecked(d.checked || false); + } + + return
+ {props.onSelect &&
+ +
} + {props.position &&
{props.position}
} +
+ +
+
+

{props.title}

+
{props.artist}
+
+
+ {props.withEditButton &&
+
; +} diff --git a/pioneer/packages/joy-media/src/music/MusicTrackReaderPreview.tsx b/pioneer/packages/joy-media/src/music/MusicTrackReaderPreview.tsx new file mode 100644 index 0000000000..03c2c05d97 --- /dev/null +++ b/pioneer/packages/joy-media/src/music/MusicTrackReaderPreview.tsx @@ -0,0 +1,30 @@ +import React, { CSSProperties } from 'react'; +import { BgImg } from '../common/BgImg'; + +export type MusicTrackReaderPreviewProps = { + id: string, + title: string, + artist: string, + thumbnail: string, + size?: number, + orientation?: 'vertical' | 'horizontal', +}; + +export function MusicTrackReaderPreview (props: MusicTrackReaderPreviewProps) { + const { size = 200, orientation = 'vertical' } = props; + + let descStyle: CSSProperties = {}; + if (orientation === 'vertical') { + descStyle.maxWidth = size; + } + + return
+ + + +
+

{props.title}

+
{props.artist}
+
+
; +} diff --git a/pioneer/packages/joy-media/src/music/MyMusicAlbums.tsx b/pioneer/packages/joy-media/src/music/MyMusicAlbums.tsx new file mode 100644 index 0000000000..85be880aef --- /dev/null +++ b/pioneer/packages/joy-media/src/music/MyMusicAlbums.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Button } from 'semantic-ui-react'; +import { MusicAlbumPreviewProps, MusicAlbumPreview } from './MusicAlbumPreview'; + +export type MyMusicAlbumsProps = { + albums?: MusicAlbumPreviewProps[] +}; + +export function MyMusicAlbums (props: MyMusicAlbumsProps) { + const { albums = [] } = props; + const albumCount = albums && albums.length || 0; + + return <> +

{`My music albums (${albumCount})`}

+
+
+
+ {albumCount === 0 + ? You don't have music albums yet + : albums.map((album, i) => + + ) + } +
+ ; +} diff --git a/pioneer/packages/joy-media/src/music/MyMusicTracks.tsx b/pioneer/packages/joy-media/src/music/MyMusicTracks.tsx new file mode 100644 index 0000000000..2406d37c3a --- /dev/null +++ b/pioneer/packages/joy-media/src/music/MyMusicTracks.tsx @@ -0,0 +1,160 @@ +import React, { useState } from 'react'; +import { Button, CheckboxProps, Dropdown, Message } from 'semantic-ui-react'; + +import { Pluralize } from '@polkadot/joy-utils/Pluralize'; +import Section from '@polkadot/joy-utils/Section'; +import { EditableMusicTrackPreviewProps, MusicTrackPreview } from './MusicTrackPreview'; +import { ReorderableTracks } from './ReorderableTracks'; +import { MusicAlbumPreviewProps } from './MusicAlbumPreview'; + +export type MyMusicTracksProps = { + albums?: MusicAlbumPreviewProps[], + tracks?: EditableMusicTrackPreviewProps[] +}; + +export function MyMusicTracks (props: MyMusicTracksProps) { + const [idsOfSelectedTracks, setIdsOfSelectedTracks] = useState(new Set()); + + const onTrackSelect = ( + track: EditableMusicTrackPreviewProps, + _event: React.FormEvent, + data: CheckboxProps + ) => { + const { id } = track; + const set = new Set(idsOfSelectedTracks); + + data.checked + ? set.add(id) + : set.delete(id) + ; + setIdsOfSelectedTracks(set); + } + + const { albums = [], tracks = [] } = props; + const albumsCount = albums.length; + const tracksCount = tracks.length; + const selectedCount = idsOfSelectedTracks.size; + + let longestAlbumName = ''; + albums.forEach(x => { + if (longestAlbumName.length < x.title.length) { + longestAlbumName = x.title; + } + }); + + const albumsDropdownOptions = albums.map(x => { + const { id } = x; + return { + key: id, + value: id, + text: x.title, + image: x.cover + }; + }); + + const [showSecondScreen, setShowSecondScreen] = useState(false); + const [albumName, setAlbumName] = useState(); + + const AlbumDropdown = () => { + const style = { + display: 'inline-block', + opacity: selectedCount ? 1 : 0, + + // This is a required hack to fit every dropdown items on a single line: + minWidth: `${longestAlbumName.length / 1.5}rem` + } + + return
+ { + const selectedAlbum = albums.find(x => x.id === id); + if (selectedAlbum) { + setAlbumName(selectedAlbum.title); + setShowSecondScreen(true); + } + }} + options={albumsDropdownOptions} + placeholder='Select an album' + search + selection + value={albumName} + /> +
; + } + + const AddTracksText = () => albumsCount + ? + Add to + + : + You have no albums. + + +