Browse Source

Merge branch 'alex-storybook' of https://github.com/siman/joystream-apps into siman-alex-storybook

Paul M Fox 5 years ago
parent
commit
cd49090f3f
71 changed files with 4659 additions and 66 deletions
  1. 5 2
      .gitignore
  2. 9 2
      .storybook/config.tsx
  3. 4 0
      .storybook/style.css
  4. 28 0
      .storybook/webpack.config.js
  5. 2 1
      package.json
  6. 3 1
      packages/joy-media/package.json
  7. 3 5
      packages/joy-media/src/EditMeta.tsx
  8. 41 41
      packages/joy-media/src/View.tsx
  9. 31 0
      packages/joy-media/src/channels/ChannelAvatar.tsx
  10. 19 0
      packages/joy-media/src/channels/ChannelHeader.tsx
  11. 29 0
      packages/joy-media/src/channels/ChannelPreview.tsx
  12. 133 0
      packages/joy-media/src/channels/MyChannels.tsx
  13. 46 0
      packages/joy-media/src/channels/ViewMusicChannel.tsx
  14. 48 0
      packages/joy-media/src/channels/YouHaveNoChannels.tsx
  15. 38 0
      packages/joy-media/src/common/BgImg.tsx
  16. 43 0
      packages/joy-media/src/common/DropdownOptions.tsx
  17. 86 0
      packages/joy-media/src/common/MediaForms.tsx
  18. 324 0
      packages/joy-media/src/common/index.css
  19. 26 0
      packages/joy-media/src/entities/MusicAlbumEntity.ts
  20. 18 0
      packages/joy-media/src/entities/MusicChannelEntity.ts
  21. 18 0
      packages/joy-media/src/entities/MusicTrackEntity.ts
  22. 25 0
      packages/joy-media/src/explore/ExploreContent.tsx
  23. 92 0
      packages/joy-media/src/explore/PlayContent.tsx
  24. 5 0
      packages/joy-media/src/index.css
  25. 1 0
      packages/joy-media/src/index.tsx
  26. 14 0
      packages/joy-media/src/music/EditAlbumModal.tsx
  27. 283 0
      packages/joy-media/src/music/EditMusicAlbum.tsx
  28. 38 0
      packages/joy-media/src/music/MusicAlbumPreview.tsx
  29. 58 0
      packages/joy-media/src/music/MusicAlbumTracks.tsx
  30. 58 0
      packages/joy-media/src/music/MusicTrackPreview.tsx
  31. 30 0
      packages/joy-media/src/music/MusicTrackReaderPreview.tsx
  32. 27 0
      packages/joy-media/src/music/MyMusicAlbums.tsx
  33. 160 0
      packages/joy-media/src/music/MyMusicTracks.tsx
  34. 83 0
      packages/joy-media/src/music/ReorderableTracks.tsx
  35. 165 0
      packages/joy-media/src/schemas/book/Book.ts
  36. 44 0
      packages/joy-media/src/schemas/book/BookCategory.ts
  37. 66 0
      packages/joy-media/src/schemas/book/BookEntryFormat.ts
  38. 182 0
      packages/joy-media/src/schemas/book/BookItem.ts
  39. 51 0
      packages/joy-media/src/schemas/book/BookItemEntry.ts
  40. 141 0
      packages/joy-media/src/schemas/book/BookSeries.ts
  41. 42 0
      packages/joy-media/src/schemas/book/BookSeriesEntry.ts
  42. 44 0
      packages/joy-media/src/schemas/general/ContentLicense.ts
  43. 44 0
      packages/joy-media/src/schemas/general/CurationStatus.ts
  44. 61 0
      packages/joy-media/src/schemas/general/FeaturedContent.ts
  45. 44 0
      packages/joy-media/src/schemas/general/Language.ts
  46. 44 0
      packages/joy-media/src/schemas/general/MediaObject.ts
  47. 44 0
      packages/joy-media/src/schemas/general/PublicationStatus.ts
  48. 234 0
      packages/joy-media/src/schemas/music/MusicAlbum.ts
  49. 44 0
      packages/joy-media/src/schemas/music/MusicGenre.ts
  50. 44 0
      packages/joy-media/src/schemas/music/MusicMood.ts
  51. 44 0
      packages/joy-media/src/schemas/music/MusicTheme.ts
  52. 207 0
      packages/joy-media/src/schemas/music/MusicTrack.ts
  53. 177 0
      packages/joy-media/src/schemas/video/Video.ts
  54. 44 0
      packages/joy-media/src/schemas/video/VideoCategory.ts
  55. 33 0
      packages/joy-media/src/stories/ExploreContent.stories.tsx
  56. 40 0
      packages/joy-media/src/stories/MusicAlbumTracks.stories.tsx
  57. 29 0
      packages/joy-media/src/stories/MusicChannel.stories.tsx
  58. 20 0
      packages/joy-media/src/stories/MyChannels.stories.tsx
  59. 19 0
      packages/joy-media/src/stories/MyMusicAlbums.stories.tsx
  60. 47 0
      packages/joy-media/src/stories/UploadAudio.stories.tsx
  61. 46 0
      packages/joy-media/src/stories/UploadVideo.stories.tsx
  62. 5 0
      packages/joy-media/src/stories/data/AccountIdSamples.ts
  63. 37 0
      packages/joy-media/src/stories/data/ChannelSamples.ts
  64. 58 0
      packages/joy-media/src/stories/data/MusicAlbumSamples.ts
  65. 73 0
      packages/joy-media/src/stories/data/MusicTrackSamples.ts
  66. 45 0
      packages/joy-media/src/upload/ContentMetadataHelper.tsx
  67. 199 0
      packages/joy-media/src/upload/UploadAudio.tsx
  68. 284 0
      packages/joy-media/src/upload/UploadVideo.tsx
  69. 30 0
      packages/joy-utils/src/Pluralize.tsx
  70. 28 12
      packages/joy-utils/src/forms.tsx
  71. 72 2
      yarn.lock

+ 5 - 2
.gitignore

@@ -15,8 +15,11 @@ package-lock.json
 npm-debug.log*
 yarn-debug.log*
 yarn-error.log*
-
 !patches/**
+.idea/
 
-# Compiled Joystream types:
+# Built Joystream types:
 packages/joy-types/lib/
+
+# Storybook
+storybook-static/

+ 9 - 2
.storybook/config.tsx

@@ -2,11 +2,18 @@ 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 => (
-  <div style={{padding: '1em 2em 2em 2em', backgroundColor: '#fafafa'}}>{story()}</div>
+  <div className='StorybookRoot'>
+    <GlobalStyle />
+    {story()}
+  </div>
 ));
 
 configure(require.context('../packages', true, /\.stories\.tsx?$/), module)

+ 4 - 0
.storybook/style.css

@@ -0,0 +1,4 @@
+.StorybookRoot {
+  background-color: #fafafa;
+  padding: 1rem 5rem;
+}

+ 28 - 0
.storybook/webpack.config.js

@@ -2,6 +2,34 @@ 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: /\.(ts|tsx)$/,

+ 2 - 1
package.json

@@ -34,7 +34,8 @@
     "start": "cd packages/apps && webpack --config webpack.config.js",
     "storybook": "start-storybook -p 3001",
     "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"
+    "build-storybook": "build-storybook -c .storybook",
+    "storybook": "start-storybook -s ./packages/apps/public -p 3001"
   },
   "devDependencies": {
     "@babel/core": "^7.7.0",

+ 3 - 1
packages/joy-media/package.json

@@ -11,13 +11,15 @@
     "@polkadot/react-components": "0.37.0-beta.63",
     "@polkadot/react-query": "0.37.0-beta.63",
     "@polkadot/joy-utils": "^0.1.1",
-    "@types/mime-types": "^2.1.0",
+   "@types/mime-types": "^2.1.0",
+    "@types/react-beautiful-dnd": "^11.0.3",
     "aplayer": "^1.10.1",
     "dplayer": "^1.25.0",
     "ipfs-only-hash": "^1.0.2",
     "lodash": "^4.17.11",
     "mime-types": "^2.1.22",
     "react-aplayer": "^1.0.0",
+    "react-beautiful-dnd": "^12.0.0",
     "react-dplayer": "^0.2.3"
   }
 }

+ 3 - 5
packages/joy-media/src/EditMeta.tsx

@@ -15,7 +15,7 @@ import { ContentId, ContentMetadata, ContentMetadataUpdate, SchemaId, ContentVis
 import { OptionText } from '@joystream/types/';
 import { withOnlyMembers } from '@polkadot/joy-utils/MyAccount';
 import Section from '@polkadot/joy-utils/Section';
-import { onImageError } from './utils';
+import { onImageError, DEFAULT_THUMBNAIL_URL } from './utils';
 import { useMyAccount } from '@polkadot/joy-utils/MyAccountContext';
 
 const buildSchema = (p: ValidationProps) => Yup.object().shape({
@@ -135,9 +135,7 @@ const InnerForm = (props: FormProps) => {
 
   return <div className='EditMetaBox'>
     <div className='EditMetaThumb'>
-    {thumbnail &&
-      <img src={thumbnail} onError={onImageError} />
-    }
+      {thumbnail && <img src={thumbnail} onError={onImageError} />}
     </div>
     <Form className='ui form JoyForm EditMetaForm'>
       <LabelledText name='name' placeholder={`Name`} {...props} />
@@ -190,7 +188,7 @@ const EditForm = withFormik<OuterProps, FormValues>({
     return {
       name: json && json.name || fileName || '',
       description: json && json.description || '',
-      thumbnail: json && json.thumbnail || '',
+      thumbnail: json && json.thumbnail || DEFAULT_THUMBNAIL_URL,
       keywords: json && json.keywords || ''
     };
   },

+ 41 - 41
packages/joy-media/src/View.tsx

@@ -16,21 +16,14 @@ import translate from './translate';
 import { DiscoveryProviderProps } from './DiscoveryProvider';
 import { DataObject, ContentMetadata, ContentId, DataObjectStorageRelationshipId, DataObjectStorageRelationship } from '@joystream/types/media';
 import { MutedDiv } from '@polkadot/joy-utils/MutedText';
-import { DEFAULT_THUMBNAIL_URL, onImageError } from './utils';
-import { isEmptyStr } from '@polkadot/joy-utils/';
+import { onImageError, DEFAULT_THUMBNAIL_URL } from './utils';
+import { isEmptyStr } from '@polkadot/joy-utils/index';
 import { MyAccountContext, MyAccountContextProps } from '@polkadot/joy-utils/MyAccountContext';
 import { Message } from 'semantic-ui-react';
 import { MemberPreview } from '@polkadot/joy-members/MemberPreview';
 
 import _ from 'lodash';
 
-type Asset = {
-  iAmOwner: boolean,
-  contentId: string,
-  data: DataObject,
-  meta: ContentMetadata
-};
-
 const PLAYER_COMMON_PARAMS = {
   lang: 'en',
   autoplay: true,
@@ -46,6 +39,41 @@ type PartOfPlayer = {
   destroy: () => void
 };
 
+type Asset = {
+  iAmOwner: boolean,
+  contentId: ContentId,
+  data?: DataObject,
+  meta: ContentMetadata
+};
+
+export function ContentPreview ({ iAmOwner, contentId, data, meta }: Asset) {
+  const { added_at } = meta;
+  let { name, thumbnail } = meta.parseJson();
+
+  if (isEmptyStr(thumbnail)) {
+    thumbnail = DEFAULT_THUMBNAIL_URL;
+  }
+
+  return (
+    <Link className={`MediaCell ${iAmOwner ? 'MyContent' : ''}`} to={`/media/play/${contentId}`}>
+      <div className='CellContent'>
+        <div className='ThumbBox'>
+          <img className='ThumbImg' src={thumbnail} onError={onImageError} />
+        </div>
+        {iAmOwner &&
+          <Link className='ui small circular icon inverted primary button' style={{ float: 'right' }} title='Edit' to={`/media/edit/${contentId.encode()}`}>
+            <i className='pencil alternate icon'></i>
+          </Link>
+        }
+        <div><h3>{name}</h3></div>
+        <MemberPreview accountId={meta.owner} style={{ marginBottom: '.5rem' }} />
+        <MutedDiv smaller>{new Date(added_at.time.toNumber()).toLocaleString()}</MutedDiv>
+        {data && <MutedDiv smaller>{formatNumber(data.size_in_bytes)} bytes</MutedDiv>}
+      </div>
+    </Link>
+  );
+}
+
 type ViewProps = ApiProps & I18nProps & DiscoveryProviderProps & {
   contentId: ContentId,
   contentType?: string,
@@ -72,46 +100,18 @@ class InnerView extends React.PureComponent<ViewProps> {
     const meta = metadataOpt.unwrap();
     const iAmOwner: boolean = myAddress !== undefined && myAddress === meta.owner.toString();
 
-    const asset = {
+    const asset: Asset = {
       iAmOwner,
-      contentId: this.props.contentId.encode(),
-      data: dataObjectOpt.unwrap(),
+      contentId: this.props.contentId,
+      data: dataObjectOpt.unwrapOr(undefined),
       meta
     };
 
     return preview
-      ? this.renderPreview(asset)
+      ? ContentPreview(asset)
       : this.renderPlayer(asset);
   }
 
-  private renderPreview ({ iAmOwner, contentId, data, meta }: Asset) {
-    const { added_at } = meta;
-    let { name, thumbnail } = meta.parseJson();
-
-    if (isEmptyStr(thumbnail)) {
-      thumbnail = DEFAULT_THUMBNAIL_URL;
-    }
-
-    return (
-      <Link className={`MediaCell ${iAmOwner ? 'MyContent' : ''}`} to={`/media/play/${contentId}`}>
-        <div className='CellContent'>
-          <div className='ThumbBox'>
-            <img className='ThumbImg' src={thumbnail} onError={onImageError} />
-          </div>
-          {iAmOwner &&
-            <Link className='ui small circular icon inverted primary button' style={{ float: 'right' }} title='Edit' to={`/media/edit/${contentId}`}>
-              <i className='pencil alternate icon'></i>
-            </Link>
-          }
-          <div><h3>{name}</h3></div>
-          <MemberPreview accountId={meta.owner} style={{ marginBottom: '.5rem' }} />
-          <MutedDiv smaller>{new Date(added_at.time.toNumber()).toLocaleString()}</MutedDiv>
-          <MutedDiv smaller>{formatNumber(data.size_in_bytes)} bytes</MutedDiv>
-        </div>
-      </Link>
-    );
-  }
-
   private player?: PartOfPlayer = undefined;
 
   private onPlayerCreated = (player: PartOfPlayer) => {

+ 31 - 0
packages/joy-media/src/channels/ChannelAvatar.tsx

@@ -0,0 +1,31 @@
+import React from 'react';
+import { ChannelEntity } from '../entities/MusicChannelEntity';
+import { BgImg } from '../common/BgImg';
+
+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 <BgImg
+    className={`ChannelAvatar ` + size}
+    url={channel.avatarUrl}
+    size={sizeToPx(size)}
+  />
+}

+ 19 - 0
packages/joy-media/src/channels/ChannelHeader.tsx

@@ -0,0 +1,19 @@
+import React from 'react';
+import { ChannelEntity } from '../entities/MusicChannelEntity';
+import { BgImg } from '../common/BgImg';
+import { ChannelPreview } from './ChannelPreview';
+
+type Props = {
+  channel: ChannelEntity
+}
+
+export function ChannelHeader (props: Props) {
+  const { channel } = props;
+
+  return (
+    <div className='ChannelHeader'>
+      <BgImg className='ChannelCover' url={channel.coverUrl} />
+      <ChannelPreview channel={channel} size='big' />
+    </div>
+  );
+}

+ 29 - 0
packages/joy-media/src/channels/ChannelPreview.tsx

@@ -0,0 +1,29 @@
+import React from 'react';
+import { ChannelEntity } from '../entities/MusicChannelEntity';
+import { ChannelAvatar, ChannelAvatarSize } from './ChannelAvatar';
+
+type Props = {
+  channel: ChannelEntity,
+  size?: ChannelAvatarSize
+}
+
+export function ChannelPreview (props: Props) {
+  const { channel, size } = props;
+
+  let subtitle: string | undefined;
+  if (channel.contentType === 'music') {
+    subtitle = 'Music channel'
+  } else if (channel.contentType === 'video') {
+    subtitle = 'Video channel'
+  }
+
+  return (
+    <div className={`ChannelPreview ` + (size ? size : '')}>
+      <ChannelAvatar channel={channel} size={size} />
+      <div>
+        <h2 className='ChannelTitle'>{channel.title}</h2>
+        {subtitle && <div className='ChannelSubtitle'>{subtitle}</div>}
+      </div>
+    </div>
+  );
+}

+ 133 - 0
packages/joy-media/src/channels/MyChannels.tsx

@@ -0,0 +1,133 @@
+import React, { useState } from 'react';
+import { Segment, Statistic, Icon, Label, SemanticICONS, SemanticCOLORS, Button, Tab } from 'semantic-ui-react';
+import { ChannelEntity } from '../entities/MusicChannelEntity';
+import { YouHaveNoChannels } from './YouHaveNoChannels';
+import Section from '@polkadot/joy-utils/Section';
+import { formatNumber } from '@polkadot/util';
+import { ChannelAvatar } from './ChannelAvatar';
+
+type Props = {
+  suspended?: boolean,
+  channels?: ChannelEntity[]
+};
+
+const TabsAndChannels = (props: Props) => {
+  const { channels: allChannels = [] } = props;
+  const [ channels, setChannels ] = useState(allChannels);
+
+  let videoChannelsCount = 0;
+  let musicChannelsCount = 0;
+  allChannels.forEach(x => {
+    if (x.contentType === 'video') {
+      videoChannelsCount++;
+    } else if (x.contentType === 'music') {
+      musicChannelsCount++;
+    }
+  });
+
+  const panes = [
+    { menuItem: `All (${allChannels.length})` },
+    { menuItem: `Video (${videoChannelsCount})` },
+    { menuItem: `Music (${musicChannelsCount})` }
+  ];
+
+  const contentTypeByTabIndex = [ undefined, 'video', 'music' ];
+
+  const switchTab = (activeIndex: number) => {
+    const activeContentType = contentTypeByTabIndex[activeIndex];
+    if (activeContentType === undefined) {
+      setChannels(allChannels)
+    } else {
+      setChannels(allChannels.filter(
+        x => x.contentType === activeContentType)
+      )
+    }
+  }
+
+  return <>
+    <Tab
+      panes={panes}
+      menu={{ secondary: true }}
+      style={{ display: 'inline-flex', margin: '1rem 2rem 1rem 0' }}
+      onTabChange={(_e, data) => switchTab(data.activeIndex as number)}
+    />
+    <Button color='blue' icon='plus' content='Create Channel' />
+    {channels.map(x => <ChannelPreview channel={x} />)}
+  </>
+}
+
+type ChannelPreviewProps = {
+  channel: ChannelEntity
+};
+
+const ChannelPreview = (props: ChannelPreviewProps) => {
+  const { channel } = props;
+
+  const statSize = 'tiny';
+
+  let itemsPublishedLabel = ''
+  if (channel.contentType === 'video') {
+    itemsPublishedLabel = 'Videos'
+  } else if (channel.contentType === 'music') {
+    itemsPublishedLabel = 'Music tracks'
+  }
+
+  let visibilityIcon: SemanticICONS = 'eye';
+  let visibilityColor: SemanticCOLORS = 'green';
+  if (channel.visibility === 'Unlisted') {
+    visibilityIcon = 'eye slash';
+    visibilityColor = 'orange'
+  }
+
+  return <Segment padded>
+    <div className='ChannelPreview'>
+
+      <ChannelAvatar channel={channel} size='big' />
+
+      <div className='ChannelDetails'>
+        <h2 className='ChannelTitle'>{channel.title}</h2>
+        <p>{channel.description}</p>
+
+        <Label basic size='large' color={visibilityColor} style={{ marginRight: '1rem' }}>
+          <Icon name={visibilityIcon} />
+          {channel.visibility}
+        </Label>
+
+        {channel.blocked && <Label basic size='large' color='red'>
+          <Icon name='dont' />
+          Channel blocked
+          {' '}<Icon name='question circle outline' size='small' />
+        </Label>}
+      </div>
+
+      <div className='ChannelStats'>
+        <div>
+          <Statistic size={statSize}>
+            <Statistic.Label>Reward earned</Statistic.Label>
+            <Statistic.Value>
+              {formatNumber(channel.rewardEarned)}
+              &nbsp;<span style={{ fontSize: '1.5rem' }}>JOY</span>
+            </Statistic.Value>
+          </Statistic>
+        </div>
+
+        <div style={{ marginTop: '1rem' }}>
+          <Statistic size={statSize}>
+            <Statistic.Label>{itemsPublishedLabel}</Statistic.Label>
+            <Statistic.Value>{formatNumber(channel.contentItemsCount)}</Statistic.Value>
+          </Statistic>
+        </div>
+      </div>
+    </div>
+  </Segment>
+}
+
+export function MyChannels (props: Props) {
+  const { suspended = false, channels = [] } = props;
+
+  return <Section title='My Channels' className='JoyChannels'>
+    {!channels.length
+      ? <YouHaveNoChannels suspended={suspended} />
+      : <TabsAndChannels {...props} />
+    }</Section>;
+}

+ 46 - 0
packages/joy-media/src/channels/ViewMusicChannel.tsx

@@ -0,0 +1,46 @@
+import React from 'react';
+import { ChannelEntity } from '../entities/MusicChannelEntity';
+import Section from '@polkadot/joy-utils/Section';
+import { ChannelHeader } from './ChannelHeader';
+import { MusicAlbumPreviewProps, MusicAlbumPreview } from '../music/MusicAlbumPreview';
+import { MusicTrackReaderPreview, MusicTrackReaderPreviewProps } from '../music/MusicTrackReaderPreview';
+
+type Props = {
+  channel: ChannelEntity,
+  albums?: MusicAlbumPreviewProps[],
+  tracks?: MusicTrackReaderPreviewProps[]
+};
+
+function NoAlbums () {
+  return null
+}
+
+function NoTracks () {
+  return null
+}
+
+export function ViewMusicChannel (props: Props) {
+  const { channel, albums = [], tracks = [] } = props;
+  
+  const renderAlbumsSection = () => (
+    !albums.length
+      ? <NoAlbums />
+      : <Section title={`Music albums`}>
+          {albums.map(x => <MusicAlbumPreview {...x} />)}
+        </Section>
+  );
+
+  const renderTracksSection = () => (
+    !tracks.length
+      ? <NoTracks />
+      : <Section title={`Music tracks`}>
+          {tracks.map(x => <MusicTrackReaderPreview {...x} />)}
+        </Section>
+  );
+  
+  return <div className='JoyViewChannel'>
+    <ChannelHeader channel={channel} />
+    {renderAlbumsSection()}
+    {renderTracksSection()}
+  </div>
+}

+ 48 - 0
packages/joy-media/src/channels/YouHaveNoChannels.tsx

@@ -0,0 +1,48 @@
+import React from 'react';
+import { Message } from 'semantic-ui-react';
+
+type Props = {
+  suspended?: boolean
+};
+
+export function YouHaveNoChannels (props: Props) {
+  const { suspended = false } = props;
+
+  const renderSuspendedAlert = () => (
+    <Message
+      compact
+      error
+      icon='warning sign'
+      header='Channel Creation Suspended'
+      content='Please try again later'
+      className='JoyInlineMsg'
+    />
+  )
+
+  const renderCreateButton = () => (
+    <Message
+      compact
+      success
+      icon='plus circle'
+      header='Create Channel'
+      content='and start publishing'
+      className='JoyInlineMsg CreateBtn'
+    />
+  )
+
+  return <>
+    <h3 style={{ marginTop: '2rem', marginBottom: '.5rem' }}>
+      Build your following on Joystream
+    </h3>
+    
+    <p style={{ marginBottom: '2rem' }}>
+      A channel is a way to organize related content for the benefit 
+      of both the publisher and the audience.
+    </p>
+
+    {suspended
+      ? renderSuspendedAlert()
+      : renderCreateButton()
+    }
+  </>;
+}

+ 38 - 0
packages/joy-media/src/common/BgImg.tsx

@@ -0,0 +1,38 @@
+import React, { CSSProperties } from 'react';
+
+type Props = {
+  url: string,
+  size?: number,
+  circle?: boolean,
+  className?: string,
+  style?: CSSProperties
+};
+
+export function BgImg (props: Props) {
+  const { url, size, circle, className, style } = props;
+
+  const fullClass = 'JoyBgImg ' + className;
+
+  let fullStyle: CSSProperties = {
+    backgroundImage: `url(${url})`,
+  };
+
+  if (size) {
+    fullStyle = Object.assign(fullStyle, {
+      width: size,
+      height: size,
+      minWidth: size,
+      minHeight: size
+    })
+  }
+
+  if (circle) {
+    fullStyle = Object.assign(fullStyle, {
+      borderRadius: '50%'
+    })
+  }
+
+  fullStyle = Object.assign(fullStyle, style);
+
+  return <div className={fullClass} style={fullStyle} />;
+}

+ 43 - 0
packages/joy-media/src/common/DropdownOptions.tsx

@@ -0,0 +1,43 @@
+// TODO get from verstore
+export const visibilityOptions = [
+  'Public',
+  'Unlisted'
+].map(x => ({
+  key: x, text: x, value: x,
+}));
+
+// TODO get from verstore
+export const genreOptions = [
+  'Classical Music',
+  'Metal',
+  'Rock',
+  'Rap',
+  'Techno',
+].map(x => ({
+  key: x, text: x, value: x,
+}));
+
+// TODO get from verstore
+export const moodOptions = [
+  'Relaxing',
+].map(x => ({
+  key: x, text: x, value: x,
+}));
+
+// TODO get from verstore
+export const themeOptions = [
+  'Dark',
+  'Light',
+].map(x => ({
+  key: x, text: x, value: x,
+}));
+
+// TODO get from verstore
+export const licenseOptions = [
+  'Public Domain',
+  'Share Alike',
+  'No Derivatives',
+  'No Commercial'
+].map(x => ({
+  key: x, text: x, value: x,
+}));

+ 86 - 0
packages/joy-media/src/common/MediaForms.tsx

@@ -0,0 +1,86 @@
+import React from 'react';
+import { DropdownItemProps, Dropdown } from 'semantic-ui-react';
+import { FormikProps, Field } from 'formik';
+import * as JoyForms from '@polkadot/joy-utils/forms';
+
+type GenericMediaProp<FormValues> = {
+  id: keyof FormValues,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type BaseFieldProps<OuterProps, FormValues> = OuterProps & FormikProps<FormValues> & {
+  field: GenericMediaProp<FormValues>
+};
+
+type MediaTextProps<OuterProps, FormValues> =
+  BaseFieldProps<OuterProps, FormValues> & JoyForms.LabelledProps<FormValues>;
+
+type MediaFieldProps<OuterProps, FormValues> =
+  BaseFieldProps<OuterProps, FormValues> & any;
+
+type MediaDropdownProps<OuterProps, FormValues> =
+  BaseFieldProps<OuterProps, FormValues> &
+{
+  options: DropdownItemProps[]
+};
+
+export type MediaFormProps<OuterProps, FormValues> = OuterProps & FormikProps<FormValues> & {
+  LabelledText: React.FunctionComponent<JoyForms.LabelledProps<FormValues>>
+  LabelledField: React.FunctionComponent<JoyForms.LabelledProps<FormValues>>
+  MediaText: React.FunctionComponent<MediaTextProps<OuterProps, FormValues>>
+  MediaField: React.FunctionComponent<MediaFieldProps<OuterProps, FormValues>>
+  MediaDropdown: React.FunctionComponent<MediaDropdownProps<OuterProps, FormValues>>
+};
+
+export function withMediaForm<OuterProps, FormValues>
+  (Component: React.ComponentType<MediaFormProps<OuterProps, FormValues>>)
+{
+  type FormProps = OuterProps & FormikProps<FormValues>;
+
+  const LabelledText = JoyForms.LabelledText<FormValues>();
+  
+  const LabelledField = JoyForms.LabelledField<FormValues>();
+  
+  function MediaText (props: MediaTextProps<OuterProps, FormValues>) {
+    const { field: f } = props;
+    return !f ? null : <LabelledText name={f.id} label={f.name} tooltip={f.description} {...props} />;
+  }
+
+  const MediaField = (props: MediaFieldProps<OuterProps, FormValues>) => {
+    const { field: f, ...otherProps } = props;
+    return !f ? null : (
+      <LabelledField name={f.id} label={f.name} tooltip={f.description} {...props}>
+        <Field name={f.id} id={f.id} {...otherProps} />
+      </LabelledField>
+    );
+  }
+
+  const MediaDropdown = (props: MediaDropdownProps<OuterProps, FormValues>) => {
+    return !props.field ? null : (
+      <MediaField
+        component={Dropdown}
+        selection
+        disabled={props.isSubmitting}
+        {...props}
+      />
+    );
+  }
+
+  const components = {
+    LabelledText,
+    LabelledField,
+    MediaText,
+    MediaField,
+    MediaDropdown
+  }
+
+  return function (props: FormProps) {
+    return <Component {...props} {...components} />;
+  };
+}

+ 324 - 0
packages/joy-media/src/common/index.css

@@ -0,0 +1,324 @@
+$blackFont: #222;
+$grayFont: #999;
+
+$bgHover: #f2f2f2;
+$bgSelected: #c7e7fa;
+
+.JoyBgImg {
+  background-color: #ccc;
+  background-size: cover;
+  background-position: center;
+}
+
+.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-bottom: 0.25rem;
+    }
+    .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: 1.15rem;
+    font-weight: bold;
+    color: $blackFont;
+    margin: .25rem 0;
+  }
+  .AlbumArtist {
+    color: $grayFont;
+    font-size: 1rem;
+  }
+  .AlbumActions {
+    opacity: 1;
+  }
+}
+
+/* Channels */
+/* ------------------------------------------------------ */
+
+.ChannelTitle {
+  color: $blackFont;
+}
+
+.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 {
+  .ChannelCover {
+    background-size: cover;
+    margin-bottom: 2rem;
+    height: 200px;
+  }
+}
+
+.ChannelPreview {
+  display: flex;
+
+  .JoyPlayAlbum & {
+    margin: .5rem 0;
+  }
+
+  .ChannelTitle {
+    font-size: 2rem;
+    margin-bottom: .5rem;
+
+    .ChannelHeader & {
+      margin-top: 1rem;
+    }
+
+    .JoyPlayAlbum & {
+      font-size: 1.25rem;
+      margin-top: 1rem;
+    }
+  }
+
+  .ChannelSubtitle {
+    color: $grayFont;
+    font-size: .9rem;
+    text-transform: uppercase;
+  }
+}
+
+/* Play Album */
+/* ------------------------------------------------------ */
+
+.JoyPlayAlbum {
+  display: flex;
+  flex-direction: row;
+
+  .JoyPlayAlbum_Main {
+    display: flex;
+    flex-direction: row;
+    margin-right: 2rem;
+
+    .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_Featured {
+    max-width: 200px;
+  }
+}

+ 26 - 0
packages/joy-media/src/entities/MusicAlbumEntity.ts

@@ -0,0 +1,26 @@
+export type MusicAlbumEntity = {
+  title: string,
+  artist: string,
+  cover: string,
+  about: 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: ...
+};

+ 18 - 0
packages/joy-media/src/entities/MusicChannelEntity.ts

@@ -0,0 +1,18 @@
+import BN from 'bn.js';
+
+export type ChannelEntity = {
+  contentType: 'video' | 'music',
+  
+  title: string,
+  description: string,
+  avatarUrl: string,
+  coverUrl: string,
+  
+  revenueAccountId: string,
+  visibility: 'Public' | 'Unlisted',
+  blocked: boolean,
+
+  // Stats:
+  rewardEarned: BN,
+  contentItemsCount: number,
+};

+ 18 - 0
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,
+};

+ 25 - 0
packages/joy-media/src/explore/ExploreContent.tsx

@@ -0,0 +1,25 @@
+import React from 'react';
+import Section from '@polkadot/joy-utils/Section';
+import { MusicAlbumPreviewProps, MusicAlbumPreview } from '../music/MusicAlbumPreview';
+
+type Props = {
+  featuredAlbums?: MusicAlbumPreviewProps[],
+  latestAlbums?: MusicAlbumPreviewProps[],
+};
+
+export function ExploreContent (props: Props) {
+  const { featuredAlbums = [], latestAlbums = [] } = props;
+
+  return <div>
+    {featuredAlbums.length > 0 &&
+      <Section title={`Featured albums`}>
+        {featuredAlbums.map(x => <MusicAlbumPreview {...x} size={300} />)}
+      </Section>
+    }
+    {latestAlbums.length > 0 &&
+      <Section title={`Latest albums`}>
+        {latestAlbums.map(x => <MusicAlbumPreview {...x} size={170} />)}
+      </Section>
+    }
+  </div>
+}

+ 92 - 0
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/MusicChannelEntity';
+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) =>
+    <Table.Row>
+      <Table.Cell width={2}>{label}</Table.Cell>
+      <Table.Cell>{value}</Table.Cell>
+    </Table.Row>
+
+  const metaTable = <>
+    <h3>Track Info</h3>
+    <Table basic='very' compact className='JoyPlayAlbum_MetaInfo'>
+      <Table.Body>
+        {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)}
+      </Table.Body>
+    </Table>
+  </>
+
+  const albumTracks = (
+    <div className='JoyPlayAlbum_AlbumTracks'>
+      <h3><Pluralize count={tracks.length} singularText='Track' /></h3>
+      <Table basic='very' compact>
+        <Table.Body>
+          {tracks.map((x, i) => {
+            const isCurrent = x.id === currentTrack.id;
+            const className = `TrackRow ` + (isCurrent ? 'Current' : '');
+
+            return (
+              <Table.Row className={className} onClick={() => setCurrentTrack(x)}>
+                <Table.Cell className='TrackNumber' width={1}>{i + 1}</Table.Cell>
+                <Table.Cell className='TrackTitle'>{x.title}</Table.Cell>
+              </Table.Row>
+            );
+          })}
+        </Table.Body>
+      </Table>
+    </div>
+  );
+
+  return <div className='JoyPlayAlbum'>
+    <div className='JoyPlayAlbum_Main'>
+      <div className='JoyPlayAlbum_CurrentTrack'>
+        <MusicTrackReaderPreview {...currentTrack} size={400} />
+        <ChannelPreview channel={channel} />
+      </div>
+      <div>
+        {albumTracks}
+        {metaTable}
+      </div>
+    </div>
+    {featuredAlbums.length > 0 &&
+      <div className='JoyPlayAlbum_Featured'>
+        <h3>Featured albums</h3>
+        {featuredAlbums.map(x => <MusicAlbumPreview {...x} size={170} />)}
+      </div>
+    }
+  </div>;
+}

+ 5 - 0
packages/joy-media/src/index.css

@@ -1,3 +1,8 @@
+.JoyPaperWidth {
+  max-width: 900px;
+  margin: 0 auto;
+}
+
 .UploadBox {
   display: flex;
   flex-direction: row;

+ 1 - 0
packages/joy-media/src/index.tsx

@@ -6,6 +6,7 @@ import { AppProps, I18nProps } from '@polkadot/react-components/types';
 import Tabs, { TabItem } from '@polkadot/react-components/Tabs';
 
 import './index.css';
+import './common/index.css';
 
 import translate from './translate';
 import Upload from './Upload';

+ 14 - 0
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 <Modal trigger={<Button icon='pencil'>Edit album</Button>} centered={false}>
+    <Modal.Header>Edit My Album</Modal.Header>
+    <Modal.Content image>
+      <Modal.Description>
+        <TracksOfMyMusicAlbum {...props} />
+      </Modal.Description>
+    </Modal.Content>
+  </Modal>
+}

+ 283 - 0
packages/joy-media/src/music/EditMusicAlbum.tsx

@@ -0,0 +1,283 @@
+import React from 'react';
+import { Button, Tab, Dropdown } from 'semantic-ui-react';
+import { Form, Field, withFormik, FormikProps } from 'formik';
+import { History } from 'history';
+
+import TxButton from '@polkadot/joy-utils/TxButton';
+import { SubmittableResult } from '@polkadot/api';
+
+import * as JoyForms from '@polkadot/joy-utils/forms';
+import { ContentId } from '@joystream/types/media';
+import { onImageError, DEFAULT_THUMBNAIL_URL } from '../utils';
+import { ReorderableTracks } from './ReorderableTracks';
+import { MusicAlbumPreviewProps } from './MusicAlbumPreview';
+import { MusicAlbumValidationSchema, MusicAlbumType, MusicAlbumPropNames, MusicAlbumPropDescriptions } from '../schemas/music/MusicAlbum';
+import { MusicTrackType } from '../schemas/music/MusicTrack';
+
+// TODO get from verstore
+const visibilityOptions = [
+  'Public',
+  'Unlisted'
+].map(x => ({
+  key: x, text: x, value: x,
+}));
+
+// TODO get from verstore
+const genreOptions = [
+  'Classical Music',
+  'Metal',
+  'Rock',
+  'Rap',
+  'Techno',
+].map(x => ({
+  key: x, text: x, value: x,
+}));
+
+// TODO get from verstore
+const moodOptions = [
+  'Relaxing',
+].map(x => ({
+  key: x, text: x, value: x,
+}));
+
+// TODO get from verstore
+const themeOptions = [
+  'Dark',
+  'Light',
+].map(x => ({
+  key: x, text: x, value: x,
+}));
+
+// TODO get from verstore
+const licenseOptions = [
+  'Public Domain',
+  'Share Alike',
+  'No Derivatives',
+  'No Commercial'
+].map(x => ({
+  key: x, text: x, value: x,
+}));
+
+type OuterProps = {
+  isStorybook?: boolean,
+  history?: History,
+  contentId: ContentId,
+  fileName?: string,
+  entity?: MusicAlbumType,
+  tracks?: MusicTrackType[]
+};
+
+const FormLabels = MusicAlbumPropNames;
+
+const FormTooltips = MusicAlbumPropDescriptions;
+
+type FormValues = MusicAlbumType;
+
+type FormProps = OuterProps & FormikProps<FormValues>;
+
+const LabelledField = JoyForms.LabelledField<FormValues>();
+
+const LabelledText = JoyForms.LabelledText<FormValues>();
+
+const InnerForm = (props: FormProps) => {
+  const {
+    isStorybook = false,
+    history,
+    contentId,
+    entity,
+    tracks = [],
+    values,
+    dirty,
+    isValid,
+    isSubmitting,
+    setSubmitting,
+    resetForm
+  } = props;
+
+  const { albumCover } = values;
+
+  const onSubmit = (sendTx: () => void) => {
+    if (isValid) sendTx();
+  };
+
+  const onTxCancelled = () => {
+    // Nothing yet
+  };
+
+  const onTxFailed = (txResult: SubmittableResult) => {
+    setSubmitting(false);
+    if (txResult == null) {
+      return onTxCancelled();
+    }
+  };
+
+  const onTxSuccess = (_txResult: SubmittableResult) => {
+    setSubmitting(false);
+    goToPlayerPage();
+  };
+
+  const goToPlayerPage = () => {
+    if (history) {
+      history.push('/media/play/' + contentId.encode());
+    }
+  };
+
+  const isNew = !entity;
+
+  const buildTxParams = () => {
+    if (!isValid) return [];
+
+    return [ /* TODO save entity to versioned store */ ];
+  };
+
+  const basicInfoTab = () => <Tab.Pane as='div'>
+    <LabelledText name='albumTitle' label={fieldName('albumTitle')} tooltip={tooltip('albumTitle')} {...props} />
+    
+    <LabelledText name='albumCover' label={fieldName('albumCover')} tooltip={tooltip('albumCover')} {...props} />
+    
+    <LabelledField name='aboutTheAlbum' label={fieldName('aboutTheAlbum')} tooltip={tooltip('aboutTheAlbum')} {...props}>
+      <Field component='textarea' id='aboutTheAlbum' name='aboutTheAlbum' disabled={isSubmitting} rows={3} />
+    </LabelledField>
+
+    <LabelledField name='publicationStatus' label={fieldName('publicationStatus')} tooltip={tooltip('publicationStatus')} {...props}>
+      <Field component={Dropdown} id='publicationStatus' name='publicationStatus' disabled={isSubmitting} selection options={visibilityOptions} />
+    </LabelledField>
+    
+  </Tab.Pane>
+
+  const additionalTab = () => <Tab.Pane as='div'>
+    <LabelledText name='albumArtist' label={fieldName('albumArtist')} tooltip={tooltip('albumArtist')} {...props} />
+
+    <LabelledText name='composerOrSongwriter' label={fieldName('composerOrSongwriter')} tooltip={tooltip('composerOrSongwriter')} {...props} />
+
+    <LabelledField name='genre' label={fieldName('genre')} tooltip={tooltip('genre')} {...props}>
+      <Field component={Dropdown} id='genre' name='genre' disabled={isSubmitting} search selection options={genreOptions} />
+    </LabelledField>
+
+    <LabelledField name='mood' label={fieldName('mood')} tooltip={tooltip('mood')} {...props}>
+      <Field component={Dropdown} id='mood' name='mood' disabled={isSubmitting} search selection options={moodOptions} />
+    </LabelledField>
+
+    <LabelledField name='theme' label={fieldName('theme')} tooltip={tooltip('theme')} {...props}>
+      <Field component={Dropdown} id='theme' name='theme' disabled={isSubmitting} search selection options={themeOptions} />
+    </LabelledField>
+
+    <LabelledField name='license' label={fieldName('license')} tooltip={tooltip('license')} {...props}>
+      <Field component={Dropdown} id='license' name='license' disabled={isSubmitting} search selection options={licenseOptions} />
+    </LabelledField>
+  </Tab.Pane>
+
+  const tracksTab = () => {
+    const album: MusicAlbumPreviewProps = {
+      albumTitle,
+      albumArtist,
+      albumCover,
+      tracksCount: tracks.length
+    }
+
+    return <Tab.Pane as='div'>
+      <ReorderableTracks 
+        album={album} tracks={tracks}
+      />
+    </Tab.Pane>
+  }
+
+  const tabs = () => <Tab
+    menu={{ secondary: true, pointing: true, color: 'blue' }}
+    panes={[
+      { menuItem: 'Basic info', render: basicInfoTab },
+      { menuItem: 'Additional', render: additionalTab },
+      { menuItem: `Tracks (${tracks.length})`, render: tracksTab },
+    ]}
+  />;
+
+  const MainButton = () => {
+    const isDisabled = !dirty || isSubmitting;
+
+    const label = isNew
+      ? 'Publish'
+      : 'Update';
+
+    if (isStorybook) return (
+      <Button
+        primary
+        type='button'
+        size='large'
+        disabled={isDisabled}
+        content={label}
+      />
+    );
+
+    return <TxButton
+      type='submit'
+      size='large'
+      isDisabled={isDisabled}
+      label={label}
+      params={buildTxParams()}
+      tx={isNew
+        ? 'dataDirectory.addMetadata'
+        : 'dataDirectory.updateMetadata'
+      }
+      onClick={onSubmit}
+      txFailedCb={onTxFailed}
+      txSuccessCb={onTxSuccess}
+    />
+  }
+
+  return <div className='EditMetaBox'>
+    <div className='EditMetaThumb'>
+      {albumCover && <img src={albumCover} onError={onImageError} />}
+    </div>
+
+    <Form className='ui form JoyForm EditMetaForm'>
+      
+      {tabs()}
+
+      {/* TODO add metadata status dropdown: Draft, Published */}
+
+      <LabelledField style={{ marginTop: '1rem' }} {...props}>
+        <MainButton />
+        <Button
+          type='button'
+          size='large'
+          disabled={!dirty || isSubmitting}
+          onClick={() => resetForm()}
+          content='Reset form'
+        />
+      </LabelledField>
+    </Form>
+  </div>;
+};
+
+export const EditMusicAlbum = withFormik<OuterProps, FormValues>({
+
+  // Transform outer props into form values
+  mapPropsToValues: props => {
+    const { entity, fileName } = props;
+
+    return {
+      // Basic:
+      albumTitle: entity && entity.albumTitle || fileName || '',
+      aboutTheAlbum: entity && entity.aboutTheAlbum || '',
+      albumCover: entity && entity.albumCover || DEFAULT_THUMBNAIL_URL,
+      // publicationStatus: entity && entity.publicationStatus || visibilityOptions[0].value,
+
+      // Additional:
+      albumArtist: entity && entity.albumArtist || '',
+      composerOrSongwriter: entity && entity.composerOrSongwriter || '',
+      genre: entity && entity.genre || genreOptions[0].value,
+      mood: entity && entity.mood || moodOptions[0].value,
+      theme: entity && entity.theme || themeOptions[0].value,
+      explicit: entity && entity.explicit || false, // TODO explicitOptions[0].value,
+      license: entity && entity.license || licenseOptions[0].value,
+    };
+  },
+
+  validationSchema: MusicAlbumValidationSchema,
+
+  handleSubmit: () => {
+    // do submitting things
+  }
+})(InnerForm);
+
+export default EditMusicAlbum;

+ 38 - 0
packages/joy-media/src/music/MusicAlbumPreview.tsx

@@ -0,0 +1,38 @@
+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/MusicChannelEntity';
+
+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 { channel, size = 200 } = props;
+
+  return <div className='JoyMusicAlbumPreview'>
+  
+    <BgImg className='AlbumCover' url={props.cover} size={size} />
+
+    <div className='AlbumDescription' style={{ maxWidth: size }}>
+      <h3 className='AlbumTitle'>{props.title}</h3>
+      <div className='AlbumArtist'>{props.artist}</div>
+      <div className='AlbumTracksCount'><Pluralize count={props.tracksCount} singularText='track' /></div>
+    </div>
+
+    {props.withActions && <div className='AlbumActions'>
+      <Button content='Edit' icon='pencil' />
+      <Button content='Add track' icon='plus' />
+    </div>}
+  </div>;
+}

+ 58 - 0
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<number>());
+
+  const { album, tracks = [] } = props;
+  const tracksCount = tracks && tracks.length || 0;
+
+  const onTrackSelect = (
+    trackIdx: number,
+    _event: React.FormEvent<HTMLInputElement>,
+    data: CheckboxProps
+  ) => {
+    const set = new Set(idxsOfSelectedTracks);
+    data.checked
+      ? set.add(trackIdx)
+      : set.delete(trackIdx)
+    ;
+    setIdxsOfSelectedTracks(set);
+  }
+
+  const selectedCount = idxsOfSelectedTracks.size;
+
+  const removeButtonText = <span>Remove <Pluralize count={selectedCount} singularText='track' /> from album</span>
+
+  return <>
+    <MusicAlbumPreview {...album} tracksCount={tracksCount} />
+
+    <div className='JoyTopActionBar'>
+      <Button content='Add track' icon='plus' />
+      {selectedCount > 0 && <Button content={removeButtonText} icon='trash' />}
+    </div>
+
+    <div className='JoyListOfPreviews'>
+      {tracksCount === 0
+        ? <em className='NoItems'>This album has no tracks yet</em>
+        : tracks.map((track, i) =>
+          <MusicTrackPreview
+            key={i}
+            {...track}
+            position={i + 1}
+            onSelect={(e, d) => onTrackSelect(i, e, d)}
+            withRemoveButton
+          />
+        )
+      }
+    </div>
+  </>;
+}

+ 58 - 0
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<HTMLInputElement>, data: CheckboxProps) => void;
+
+export type EditableMusicTrackPreviewProps = {
+  id: string,
+  title: string,
+  artist: string,
+  cover: 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 <div className={`JoyMusicTrackPreview ${checked && `SelectedItem`} ${props.isDraggable && `DraggableItem`}`}>
+    {props.onSelect && <div className='CheckboxCell'>
+      <Checkbox checked={checked} onChange={onChange} />
+    </div>}
+    {props.position && <div className='AlbumNumber'>{props.position}</div>}
+    <div className='AlbumCover'>
+      <img src={props.cover} />
+    </div>
+    <div className='AlbumDescription'>
+      <h3 className='AlbumTitle'>{props.title}</h3>
+      <div className='AlbumArtist'>{props.artist}</div>
+    </div>
+    <div className='AlbumActions'>
+      {props.withEditButton && <Button icon='pencil' content={withActionLabels ? 'Edit' : null} onClick={onEdit} />}
+      {props.withRemoveButton && <Button icon='trash' content={withActionLabels ? 'Remove' : null} onClick={onRemove} />}
+    </div>
+  </div>;
+}

+ 30 - 0
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,
+  cover: 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 <div className={`JoyMusicAlbumPreview ` + orientation}>
+
+    <BgImg className='AlbumCover' url={props.cover} size={size} />
+
+    <div className='AlbumDescription' style={descStyle}>
+      <h3 className='AlbumTitle'>{props.title}</h3>
+      <div className='AlbumArtist'>{props.artist}</div>
+    </div>
+  </div>;
+}

+ 27 - 0
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 <>
+    <h2>{`My music albums (${albumCount})`}</h2>
+    <div className='JoyTopActionBar'>
+      <Button content='New album' icon='plus' />
+    </div>
+    <div>
+      {albumCount === 0
+        ? <em className='NoItems'>You don't have music albums yet</em>
+        : albums.map((album, i) =>
+          <MusicAlbumPreview key={i} {...album} />
+        )
+      }
+    </div>
+  </>;
+}

+ 160 - 0
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<string>());
+
+  const onTrackSelect = (
+    track: EditableMusicTrackPreviewProps,
+    _event: React.FormEvent<HTMLInputElement>,
+    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<string | undefined>();
+
+  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 <div style={style}>
+      <Dropdown
+        onChange={(_e, { value: id }) => {
+          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}
+      />
+    </div>;
+  }
+
+  const AddTracksText = () => albumsCount
+    ? <span style={{ marginRight: '1rem' }}>
+        Add <Pluralize count={selectedCount} singularText='track' /> to
+      </span>
+    : <em>
+        You have no albums.
+        <Button content='Create first album' icon='plus' />
+      </em>
+
+  const goBack = () => {
+    setAlbumName('');
+    setShowSecondScreen(false);
+  }
+
+  const renderAllTracks = () => {
+    return <Section title={`My Music Tracks (${tracksCount})`}>
+
+      <div className='JoyTopActionBar'>
+        {selectedCount
+          ? <><AddTracksText /></>
+          : <span>Select tracks to add them to your album</span>
+        }
+        <AlbumDropdown />
+      </div>
+
+      <div className='JoyListOfPreviews'>
+        {tracksCount === 0
+          ? <em className='NoItems'>You have no music tracks yet</em>
+          : tracks.map((track, i) =>
+              <MusicTrackPreview
+                key={i}
+                {...track}
+                position={i + 1}
+                selected={idsOfSelectedTracks.has(track.id)}
+                onSelect={(e, d) => onTrackSelect(track, e, d)}
+                withEditButton
+              />
+            )
+        }
+      </div>
+    </Section>;
+  }
+
+  const selectedTracks = tracks.filter(track => idsOfSelectedTracks.has(track.id))
+
+  const renderReorderTracks = () => {
+    return <Section title={`Add tracks to album "${albumName}"`}>
+
+      <Message
+        info
+        icon='info'
+        content='You can reorder tracks before adding them to this album.'
+      />
+
+      <ReorderableTracks
+        tracks={selectedTracks}
+        onRemove={track => {
+          const set = new Set(idsOfSelectedTracks);
+          set.delete(track.id);
+          setIdsOfSelectedTracks(set);
+        }}
+      />
+
+      <div style={{ marginTop: '1rem' }}>
+        <Button size='large' onClick={goBack}>&lt; Back to my tracks</Button>
+        <Button size='large' primary style={{ float: 'right' }} onClick={() => alert('Not implemented yet')}>Add to album &gt;</Button>
+      </div>
+    </Section>;
+  }
+
+  return <div className='JoyPaperWidth'>{
+    !showSecondScreen
+      ? renderAllTracks()
+      : renderReorderTracks()
+    }</div>;
+}

+ 83 - 0
packages/joy-media/src/music/ReorderableTracks.tsx

@@ -0,0 +1,83 @@
+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
+}
+
+type OrderableItem = EditableMusicTrackPreviewProps;
+
+export const ReorderableTracks = (props: Props) => {
+  const { tracks = [], onRemove = () => {} } = props;
+
+  const [items, setItems] = useState(tracks);
+
+  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 (
+    <DragDropContext onDragEnd={onDragEnd}>
+      <Droppable droppableId='droppable'>
+        {(provided, _snapshot) => (
+          <div
+            {...provided.droppableProps}
+            ref={provided.innerRef}
+            className='JoyListOfPreviews'
+          >
+            {items.map((item, index) => (
+              <Draggable key={item.id} draggableId={item.id} index={index}>
+                {(provided, snapshot) => (
+                  <div
+                    ref={provided.innerRef}
+                    {...provided.draggableProps}
+                    {...provided.dragHandleProps}
+                  >
+                    <MusicTrackPreview
+                      key={index} 
+                      {...item}
+                      position={index + 1}
+                      isDraggable={snapshot.isDragging}
+                      withRemoveButton
+                      onRemove={() => {
+                        onRemove(item);
+                        const lessItems = items.filter(x => x.id !== item.id);
+                        setItems(lessItems);
+                      }}
+                    />
+                  </div>
+                )}
+              </Draggable>
+            ))}
+            {provided.placeholder}
+          </div>
+        )}
+      </Droppable>
+    </DragDropContext>
+  );
+}

+ 165 - 0
packages/joy-media/src/schemas/book/Book.ts

@@ -0,0 +1,165 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const BookValidationSchema = Yup.object().shape({
+  titleInEnglish: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  originalTitle: Yup.string()
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  internationalBookCover: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  aboutTheBook: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  aboutTheAuthor: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.')
+});
+
+export type BookType = {
+  titleInEnglish: string
+  originalTitle?: string
+  authorOfBook: string[]
+  yearOfRelease: any
+  originalLanguage: any
+  internationalBookCover: string
+  aboutTheBook: string
+  aboutTheAuthor: string
+  bookCategory: any
+  bookItem?: any[]
+  publicationStatus: any
+  curationStatus?: any
+};
+
+export type BookPropId =
+  'titleInEnglish' |
+  'originalTitle' |
+  'authorOfBook' |
+  'yearOfRelease' |
+  'originalLanguage' |
+  'internationalBookCover' |
+  'aboutTheBook' |
+  'aboutTheAuthor' |
+  'bookCategory' |
+  'bookItem' |
+  'publicationStatus' |
+  'curationStatus'
+  ;
+
+export type BookGenericProp = {
+  id: BookPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type BookClassType = {
+  [id in keyof BookType]: BookGenericProp
+};
+
+export const BookClass: BookClassType = {
+  titleInEnglish: {
+    "id": "titleInEnglish",
+    "name": "Title in English",
+    "description": "Title of the book in English.",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 255
+  },
+  originalTitle: {
+    "id": "originalTitle",
+    "name": "Original Title",
+    "description": "Title of the book in the language was originally written.",
+    "type": "Text",
+    "required": false,
+    "maxTextLength": 255
+  },
+  authorOfBook: {
+    "id": "authorOfBook",
+    "name": "Author of Book",
+    "description": "The author or authors of the book",
+    "type": "TextVec",
+    "required": true,
+    "maxItems": 10,
+    "maxTextLength": 100
+  },
+  yearOfRelease: {
+    "id": "yearOfRelease",
+    "name": "Year of Release",
+    "description": "The year the book was first published in its original language.",
+    "required": true,
+    "type": "Internal",
+    "classId": "Year"
+  },
+  originalLanguage: {
+    "id": "originalLanguage",
+    "name": "Original Language",
+    "description": "Title of the book in the language was originally written.",
+    "type": "Internal",
+    "required": true,
+    "classId": "Language"
+  },
+  internationalBookCover: {
+    "id": "internationalBookCover",
+    "name": "International Book Cover",
+    "description": "URL to book a thumbnail of the book cover. First edition in English if available, first edition in original language otherwise: NOTE: Should be an https link to an image of ratio 2:3, at least 500 pixels wide, in JPEG or PNG format.",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 255
+  },
+  aboutTheBook: {
+    "id": "aboutTheBook",
+    "name": "About the Book",
+    "description": "Information about the book in English",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 255
+  },
+  aboutTheAuthor: {
+    "id": "aboutTheAuthor",
+    "name": "About the Author",
+    "description": "About the author or authors of the book in English",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 255
+  },
+  bookCategory: {
+    "id": "bookCategory",
+    "name": "Book Category",
+    "description": "About the author or authors of the book in English",
+    "required": true,
+    "type": "Internal",
+    "classId": "Book Category"
+  },
+  bookItem: {
+    "id": "bookItem",
+    "name": "Book Item",
+    "description": "A specific publication of the book. Ie. translation, illustrated version, etc.",
+    "type": "InternalVec",
+    "maxItems": 100,
+    "classId": "Book Item"
+  },
+  publicationStatus: {
+    "id": "publicationStatus",
+    "name": "Publication Status",
+    "description": "The publication status of the book.",
+    "required": true,
+    "type": "Internal",
+    "classId": "Publication Status"
+  },
+  curationStatus: {
+    "id": "curationStatus",
+    "name": "Curation Status",
+    "description": "The publication status of the book set by the a content curator on the platform.",
+    "type": "Internal",
+    "classId": "Curation Status"
+  }
+};

+ 44 - 0
packages/joy-media/src/schemas/book/BookCategory.ts

@@ -0,0 +1,44 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const BookCategoryValidationSchema = Yup.object().shape({
+  category: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.')
+});
+
+export type BookCategoryType = {
+  category: string
+};
+
+export type BookCategoryPropId =
+  'category'
+  ;
+
+export type BookCategoryGenericProp = {
+  id: BookCategoryPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type BookCategoryClassType = {
+  [id in keyof BookCategoryType]: BookCategoryGenericProp
+};
+
+export const BookCategoryClass: BookCategoryClassType = {
+  category: {
+    "id": "category",
+    "name": "Category",
+    "description": "Categories for books and book Series.",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 255
+  }
+};

+ 66 - 0
packages/joy-media/src/schemas/book/BookEntryFormat.ts

@@ -0,0 +1,66 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const BookEntryFormatValidationSchema = Yup.object().shape({
+  format: Yup.string()
+    .required('This field is required')
+    .max(5, 'Text is too long. Maximum length is 5 chars.'),
+  extension: Yup.string()
+    .required('This field is required')
+    .max(5, 'Text is too long. Maximum length is 5 chars.')
+});
+
+export type BookEntryFormatType = {
+  format: string
+  extension: string
+  images: boolean
+};
+
+export type BookEntryFormatPropId =
+  'format' |
+  'extension' |
+  'images'
+  ;
+
+export type BookEntryFormatGenericProp = {
+  id: BookEntryFormatPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type BookEntryFormatClassType = {
+  [id in keyof BookEntryFormatType]: BookEntryFormatGenericProp
+};
+
+export const BookEntryFormatClass: BookEntryFormatClassType = {
+  format: {
+    "id": "format",
+    "name": "Format",
+    "description": "The name of the file format of the book item.",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 5
+  },
+  extension: {
+    "id": "extension",
+    "name": "Extension",
+    "description": "The file extension of the book item.",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 5
+  },
+  images: {
+    "id": "images",
+    "name": "Images",
+    "description": "Wether the book item contains images or not.",
+    "required": true,
+    "type": "Bool"
+  }
+};

+ 182 - 0
packages/joy-media/src/schemas/book/BookItem.ts

@@ -0,0 +1,182 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const BookItemValidationSchema = Yup.object().shape({
+  title: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  bookItemCover: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  edition: Yup.string()
+    .required('This field is required')
+    .max(100, 'Text is too long. Maximum length is 100 chars.'),
+  aboutTheBook: Yup.string()
+    .required('This field is required')
+    .max(4000, 'Text is too long. Maximum length is 4000 chars.'),
+  aboutTheAuthor: Yup.string()
+    .required('This field is required')
+    .max(4000, 'Text is too long. Maximum length is 4000 chars.')
+});
+
+export type BookItemType = {
+  title: string
+  language: any
+  bookItemCover: string
+  edition: string
+  aboutTheBook: string
+  aboutTheAuthor: string
+  isbn?: number
+  entries?: any[]
+  link?: string[]
+  reviews?: string[]
+  publicationStatus: any
+  curationStatus?: any
+  explicit: boolean
+  license: any
+};
+
+export type BookItemPropId =
+  'title' |
+  'language' |
+  'bookItemCover' |
+  'edition' |
+  'aboutTheBook' |
+  'aboutTheAuthor' |
+  'isbn' |
+  'entries' |
+  'link' |
+  'reviews' |
+  'publicationStatus' |
+  'curationStatus' |
+  'explicit' |
+  'license'
+  ;
+
+export type BookItemGenericProp = {
+  id: BookItemPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type BookItemClassType = {
+  [id in keyof BookItemType]: BookItemGenericProp
+};
+
+export const BookItemClass: BookItemClassType = {
+  title: {
+    "id": "title",
+    "name": "Title",
+    "description": "Title of the book item in the language of publication.",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 255
+  },
+  language: {
+    "id": "language",
+    "name": "Language",
+    "description": "The language of the book item.",
+    "required": true,
+    "type": "Internal",
+    "classId": "Language"
+  },
+  bookItemCover: {
+    "id": "bookItemCover",
+    "name": "Book Item Cover",
+    "description": "URL to book a thumbnail of the book cover. Cover should align with language and edition of the book item: NOTE: Should be an https link to an image of ratio 2:3, at least 500 pixels wide, in JPEG or PNG format.",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 255
+  },
+  edition: {
+    "id": "edition",
+    "name": "Edition",
+    "description": "The edition of the book.",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 100
+  },
+  aboutTheBook: {
+    "id": "aboutTheBook",
+    "name": "About the Book",
+    "description": "Information about the book in the language of the book item",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 4000
+  },
+  aboutTheAuthor: {
+    "id": "aboutTheAuthor",
+    "name": "About the Author",
+    "description": "About the author or authors of the book in the language of the book item",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 4000
+  },
+  isbn: {
+    "id": "isbn",
+    "name": "ISBN",
+    "description": "The ISBN of the book.",
+    "type": "Uint16"
+  },
+  entries: {
+    "id": "entries",
+    "name": "Entries",
+    "description": "All entries of this book item.",
+    "type": "InternalVec",
+    "maxItems": 100,
+    "classId": "Book Item Entry"
+  },
+  link: {
+    "id": "link",
+    "name": "Link",
+    "description": "A link to the author or publisher page.",
+    "type": "TextVec",
+    "maxItems": 5,
+    "maxTextLength": 255
+  },
+  reviews: {
+    "id": "reviews",
+    "name": "Reviews",
+    "description": "Links to reviews of the book in language of the book item.",
+    "type": "TextVec",
+    "maxItems": 5,
+    "maxTextLength": 255
+  },
+  publicationStatus: {
+    "id": "publicationStatus",
+    "name": "Publication Status",
+    "description": "The publication status of the book item.",
+    "required": true,
+    "type": "Internal",
+    "classId": "Publication Status"
+  },
+  curationStatus: {
+    "id": "curationStatus",
+    "name": "Curation Status",
+    "description": "The publication status of the book item set by the a content curator on the platform.",
+    "type": "Internal",
+    "classId": "Curation Status"
+  },
+  explicit: {
+    "id": "explicit",
+    "name": "Explicit",
+    "description": "Indicates whether the book item contains explicit material.",
+    "required": true,
+    "type": "Bool"
+  },
+  license: {
+    "id": "license",
+    "name": "License",
+    "description": "The license of which the book item is released under.",
+    "required": true,
+    "type": "Internal",
+    "classId": "Content License"
+  }
+};

+ 51 - 0
packages/joy-media/src/schemas/book/BookItemEntry.ts

@@ -0,0 +1,51 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const BookItemEntryValidationSchema = Yup.object().shape({
+  // No validation rules.
+});
+
+export type BookItemEntryType = {
+  formatAndFileType: any
+  object?: any
+};
+
+export type BookItemEntryPropId =
+  'formatAndFileType' |
+  'object'
+  ;
+
+export type BookItemEntryGenericProp = {
+  id: BookItemEntryPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type BookItemEntryClassType = {
+  [id in keyof BookItemEntryType]: BookItemEntryGenericProp
+};
+
+export const BookItemEntryClass: BookItemEntryClassType = {
+  formatAndFileType: {
+    "id": "formatAndFileType",
+    "name": "Format and File Type",
+    "description": "The file format, extension and additional metadata for the book item entry.",
+    "required": true,
+    "type": "Internal",
+    "classId": "Book Entry Format"
+  },
+  object: {
+    "id": "object",
+    "name": "Object",
+    "description": "The entityId of the object in the data directory.",
+    "type": "Internal",
+    "classId": "Media Object"
+  }
+};

+ 141 - 0
packages/joy-media/src/schemas/book/BookSeries.ts

@@ -0,0 +1,141 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const BookSeriesValidationSchema = Yup.object().shape({
+  title: 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.'),
+  bookCover: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  author: Yup.string()
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  synopsis: Yup.string()
+    .max(255, 'Text is too long. Maximum length is 255 chars.')
+});
+
+export type BookSeriesType = {
+  title: string
+  description: string
+  bookCover: string
+  language: any[]
+  booksInTheSeries?: any[]
+  author?: string
+  synopsis?: string
+  publicationStatus: any
+  curationStatus?: any
+  explicit: boolean
+};
+
+export type BookSeriesPropId =
+  'title' |
+  'description' |
+  'bookCover' |
+  'language' |
+  'booksInTheSeries' |
+  'author' |
+  'synopsis' |
+  'publicationStatus' |
+  'curationStatus' |
+  'explicit'
+  ;
+
+export type BookSeriesGenericProp = {
+  id: BookSeriesPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type BookSeriesClassType = {
+  [id in keyof BookSeriesType]: BookSeriesGenericProp
+};
+
+export const BookSeriesClass: BookSeriesClassType = {
+  title: {
+    "id": "title",
+    "name": "Title",
+    "description": "The title of the book series",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 255
+  },
+  description: {
+    "id": "description",
+    "name": "Description",
+    "description": "Description of the book series",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 4000
+  },
+  bookCover: {
+    "id": "bookCover",
+    "name": "Book Cover",
+    "description": "URL to book thumbnail: TODO",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 255
+  },
+  language: {
+    "id": "language",
+    "name": "Language",
+    "description": "The language(s) the book series is available in.",
+    "required": true,
+    "type": "InternalVec",
+    "maxItems": 184,
+    "classId": "Language"
+  },
+  booksInTheSeries: {
+    "id": "booksInTheSeries",
+    "name": "Books in the series",
+    "description": "The books that are in the series",
+    "type": "InternalVec",
+    "maxItems": 2000,
+    "classId": "Book Series Entry"
+  },
+  author: {
+    "id": "author",
+    "name": "Author",
+    "description": "The author or authors of the series",
+    "type": "Text",
+    "maxTextLength": 255
+  },
+  synopsis: {
+    "id": "synopsis",
+    "name": "Synopsis",
+    "description": "A short description of the series",
+    "type": "Text",
+    "maxTextLength": 255
+  },
+  publicationStatus: {
+    "id": "publicationStatus",
+    "name": "Publication Status",
+    "description": "The publication status of the book item.",
+    "required": true,
+    "type": "Internal",
+    "classId": "Publication Status"
+  },
+  curationStatus: {
+    "id": "curationStatus",
+    "name": "Curation Status",
+    "description": "The publication status of the book item set by the a content curator on the platform.",
+    "type": "Internal",
+    "classId": "Curation Status"
+  },
+  explicit: {
+    "id": "explicit",
+    "name": "Explicit",
+    "description": "Indicates whether the book item contains explicit material.",
+    "required": true,
+    "type": "Bool"
+  }
+};

+ 42 - 0
packages/joy-media/src/schemas/book/BookSeriesEntry.ts

@@ -0,0 +1,42 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const BookSeriesEntryValidationSchema = Yup.object().shape({
+  // No validation rules.
+});
+
+export type BookSeriesEntryType = {
+  bookItem?: any[]
+};
+
+export type BookSeriesEntryPropId =
+  'bookItem'
+  ;
+
+export type BookSeriesEntryGenericProp = {
+  id: BookSeriesEntryPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type BookSeriesEntryClassType = {
+  [id in keyof BookSeriesEntryType]: BookSeriesEntryGenericProp
+};
+
+export const BookSeriesEntryClass: BookSeriesEntryClassType = {
+  bookItem: {
+    "id": "bookItem",
+    "name": "Book Item",
+    "description": "A specific publication of the book. Ie. translation, illustrated version, etc.",
+    "type": "InternalVec",
+    "maxItems": 100,
+    "classId": "Book Item"
+  }
+};

+ 44 - 0
packages/joy-media/src/schemas/general/ContentLicense.ts

@@ -0,0 +1,44 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const ContentLicenseValidationSchema = Yup.object().shape({
+  license: Yup.string()
+    .required('This field is required')
+    .max(200, 'Text is too long. Maximum length is 200 chars.')
+});
+
+export type ContentLicenseType = {
+  license: string
+};
+
+export type ContentLicensePropId =
+  'license'
+  ;
+
+export type ContentLicenseGenericProp = {
+  id: ContentLicensePropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type ContentLicenseClassType = {
+  [id in keyof ContentLicenseType]: ContentLicenseGenericProp
+};
+
+export const ContentLicenseClass: ContentLicenseClassType = {
+  license: {
+    "id": "license",
+    "name": "License",
+    "description": "The license of which the content is originally published under.",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 200
+  }
+};

+ 44 - 0
packages/joy-media/src/schemas/general/CurationStatus.ts

@@ -0,0 +1,44 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const CurationStatusValidationSchema = Yup.object().shape({
+  status: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.')
+});
+
+export type CurationStatusType = {
+  status: string
+};
+
+export type CurationStatusPropId =
+  'status'
+  ;
+
+export type CurationStatusGenericProp = {
+  id: CurationStatusPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type CurationStatusClassType = {
+  [id in keyof CurationStatusType]: CurationStatusGenericProp
+};
+
+export const CurationStatusClass: CurationStatusClassType = {
+  status: {
+    "id": "status",
+    "name": "Status",
+    "description": "The curator publication status of the content in the content directory.",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 255
+  }
+};

+ 61 - 0
packages/joy-media/src/schemas/general/FeaturedContent.ts

@@ -0,0 +1,61 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const FeaturedContentValidationSchema = Yup.object().shape({
+  // No validation rules.
+});
+
+export type FeaturedContentType = {
+  topVideo?: any
+  featuredVideos?: any[]
+  featuredAlbums?: any[]
+};
+
+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 keyof FeaturedContentType]: 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": 6,
+    "classId": "Video"
+  },
+  featuredAlbums: {
+    "id": "featuredAlbums",
+    "name": "Featured Albums",
+    "description": "Music albums featured in the Music tab.",
+    "type": "InternalVec",
+    "maxItems": 6,
+    "classId": "Music Album"
+  }
+};

+ 44 - 0
packages/joy-media/src/schemas/general/Language.ts

@@ -0,0 +1,44 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const LanguageValidationSchema = Yup.object().shape({
+  languageCode: Yup.string()
+    .required('This field is required')
+    .max(2, 'Text is too long. Maximum length is 2 chars.')
+});
+
+export type LanguageType = {
+  languageCode: string
+};
+
+export type LanguagePropId =
+  'languageCode'
+  ;
+
+export type LanguageGenericProp = {
+  id: LanguagePropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type LanguageClassType = {
+  [id in keyof LanguageType]: LanguageGenericProp
+};
+
+export const LanguageClass: LanguageClassType = {
+  languageCode: {
+    "id": "languageCode",
+    "name": "Language code",
+    "description": "Language code following the ISO 639-1 two letter standard.",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 2
+  }
+};

+ 44 - 0
packages/joy-media/src/schemas/general/MediaObject.ts

@@ -0,0 +1,44 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const MediaObjectValidationSchema = Yup.object().shape({
+  object: Yup.string()
+    .required('This field is required')
+    .max(68, 'Text is too long. Maximum length is 68 chars.')
+});
+
+export type MediaObjectType = {
+  object: string
+};
+
+export type MediaObjectPropId =
+  'object'
+  ;
+
+export type MediaObjectGenericProp = {
+  id: MediaObjectPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type MediaObjectClassType = {
+  [id in keyof MediaObjectType]: MediaObjectGenericProp
+};
+
+export const MediaObjectClass: MediaObjectClassType = {
+  object: {
+    "id": "object",
+    "name": "Object",
+    "description": "ContentId of object in the data directory",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 68
+  }
+};

+ 44 - 0
packages/joy-media/src/schemas/general/PublicationStatus.ts

@@ -0,0 +1,44 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const PublicationStatusValidationSchema = Yup.object().shape({
+  status: Yup.string()
+    .required('This field is required')
+    .max(50, 'Text is too long. Maximum length is 50 chars.')
+});
+
+export type PublicationStatusType = {
+  status: string
+};
+
+export type PublicationStatusPropId =
+  'status'
+  ;
+
+export type PublicationStatusGenericProp = {
+  id: PublicationStatusPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type PublicationStatusClassType = {
+  [id in keyof PublicationStatusType]: PublicationStatusGenericProp
+};
+
+export const PublicationStatusClass: PublicationStatusClassType = {
+  status: {
+    "id": "status",
+    "name": "Status",
+    "description": "The publication status of the content in the content directory.",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 50
+  }
+};

+ 234 - 0
packages/joy-media/src/schemas/music/MusicAlbum.ts

@@ -0,0 +1,234 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const MusicAlbumValidationSchema = Yup.object().shape({
+  albumTitle: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  albumArtist: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  albumCover: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  aboutTheAlbum: Yup.string()
+    .required('This field is required')
+    .max(4000, 'Text is too long. Maximum length is 4000 chars.'),
+  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 MusicAlbumType = {
+  albumTitle: string
+  albumArtist: string
+  albumCover: string
+  aboutTheAlbum: string
+  firstReleased: number
+  genre?: any[]
+  mood?: any[]
+  theme?: any[]
+  tracks?: any[]
+  language?: any[]
+  link?: string[]
+  lyrics?: string
+  composerOrSongwriter?: string
+  reviews?: string[]
+  publicationStatus: any
+  curationStatus?: any
+  explicit: boolean
+  license: any
+  attribution?: string
+};
+
+export type MusicAlbumPropId =
+  'albumTitle' |
+  'albumArtist' |
+  'albumCover' |
+  'aboutTheAlbum' |
+  'firstReleased' |
+  'genre' |
+  'mood' |
+  'theme' |
+  'tracks' |
+  'language' |
+  'link' |
+  'lyrics' |
+  'composerOrSongwriter' |
+  'reviews' |
+  'publicationStatus' |
+  'curationStatus' |
+  'explicit' |
+  'license' |
+  'attribution'
+  ;
+
+export type MusicAlbumGenericProp = {
+  id: MusicAlbumPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type MusicAlbumClassType = {
+  [id in keyof MusicAlbumType]: MusicAlbumGenericProp
+};
+
+export const MusicAlbumClass: MusicAlbumClassType = {
+  albumTitle: {
+    "id": "albumTitle",
+    "name": "Album Title",
+    "description": "The title of the album",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 255
+  },
+  albumArtist: {
+    "id": "albumArtist",
+    "name": "Album Artist",
+    "description": "The artist, composer, band or group that published the album.",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 255
+  },
+  albumCover: {
+    "id": "albumCover",
+    "name": "Album Cover",
+    "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
+  },
+  aboutTheAlbum: {
+    "id": "aboutTheAlbum",
+    "name": "About the Album",
+    "description": "Information about the album and artist.",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 4000
+  },
+  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(s) of the album.",
+    "type": "InternalVec",
+    "maxItems": 3,
+    "classId": "Music Genre"
+  },
+  mood: {
+    "id": "mood",
+    "name": "Mood",
+    "description": "The mood(s) of the album.",
+    "type": "InternalVec",
+    "maxItems": 3,
+    "classId": "Music Mood"
+  },
+  theme: {
+    "id": "theme",
+    "name": "Theme",
+    "description": "The theme(s) of the album.",
+    "type": "InternalVec",
+    "maxItems": 3,
+    "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": "InternalVec",
+    "maxItems": 5,
+    "classId": "Language"
+  },
+  link: {
+    "id": "link",
+    "name": "Link",
+    "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 track 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
+  }
+};

+ 44 - 0
packages/joy-media/src/schemas/music/MusicGenre.ts

@@ -0,0 +1,44 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const MusicGenreValidationSchema = Yup.object().shape({
+  genre: Yup.string()
+    .required('This field is required')
+    .max(100, 'Text is too long. Maximum length is 100 chars.')
+});
+
+export type MusicGenreType = {
+  genre: string
+};
+
+export type MusicGenrePropId =
+  'genre'
+  ;
+
+export type MusicGenreGenericProp = {
+  id: MusicGenrePropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type MusicGenreClassType = {
+  [id in keyof MusicGenreType]: MusicGenreGenericProp
+};
+
+export const MusicGenreClass: MusicGenreClassType = {
+  genre: {
+    "id": "genre",
+    "name": "Genre",
+    "description": "Genres for music.",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 100
+  }
+};

+ 44 - 0
packages/joy-media/src/schemas/music/MusicMood.ts

@@ -0,0 +1,44 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const MusicMoodValidationSchema = Yup.object().shape({
+  mood: Yup.string()
+    .required('This field is required')
+    .max(100, 'Text is too long. Maximum length is 100 chars.')
+});
+
+export type MusicMoodType = {
+  mood: string
+};
+
+export type MusicMoodPropId =
+  'mood'
+  ;
+
+export type MusicMoodGenericProp = {
+  id: MusicMoodPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type MusicMoodClassType = {
+  [id in keyof MusicMoodType]: MusicMoodGenericProp
+};
+
+export const MusicMoodClass: MusicMoodClassType = {
+  mood: {
+    "id": "mood",
+    "name": "Mood",
+    "description": "Moods for music.",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 100
+  }
+};

+ 44 - 0
packages/joy-media/src/schemas/music/MusicTheme.ts

@@ -0,0 +1,44 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const MusicThemeValidationSchema = Yup.object().shape({
+  theme: Yup.string()
+    .required('This field is required')
+    .max(100, 'Text is too long. Maximum length is 100 chars.')
+});
+
+export type MusicThemeType = {
+  theme: string
+};
+
+export type MusicThemePropId =
+  'theme'
+  ;
+
+export type MusicThemeGenericProp = {
+  id: MusicThemePropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type MusicThemeClassType = {
+  [id in keyof MusicThemeType]: MusicThemeGenericProp
+};
+
+export const MusicThemeClass: MusicThemeClassType = {
+  theme: {
+    "id": "theme",
+    "name": "Theme",
+    "description": "Themes for music.",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 100
+  }
+};

+ 207 - 0
packages/joy-media/src/schemas/music/MusicTrack.ts

@@ -0,0 +1,207 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const MusicTrackValidationSchema = Yup.object().shape({
+  trackTitle: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  trackArtist: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  trackThumbnail: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  aboutTheTrack: 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.'),
+  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 MusicTrackType = {
+  trackTitle: string
+  trackArtist: string
+  trackThumbnail: string
+  aboutTheTrack?: string
+  language?: any
+  firstReleased: number
+  genre?: any
+  mood?: any
+  theme?: any
+  link?: string[]
+  composerOrSongwriter?: string
+  lyrics?: string
+  object?: any
+  publicationStatus: any
+  curationStatus?: any
+  license: any
+  attribution?: string
+};
+
+export type MusicTrackPropId =
+  'trackTitle' |
+  'trackArtist' |
+  'trackThumbnail' |
+  'aboutTheTrack' |
+  'language' |
+  'firstReleased' |
+  'genre' |
+  'mood' |
+  'theme' |
+  'link' |
+  'composerOrSongwriter' |
+  'lyrics' |
+  'object' |
+  'publicationStatus' |
+  'curationStatus' |
+  'license' |
+  'attribution'
+  ;
+
+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 = {
+  trackTitle: {
+    "id": "trackTitle",
+    "name": "Track Title",
+    "description": "The title of the track",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 255
+  },
+  trackArtist: {
+    "id": "trackArtist",
+    "name": "Track Artist",
+    "description": "The artist, composer, band or group that published the track.",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 255
+  },
+  trackThumbnail: {
+    "id": "trackThumbnail",
+    "name": "Track 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
+  },
+  aboutTheTrack: {
+    "id": "aboutTheTrack",
+    "name": "About the Track",
+    "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"
+  },
+  link: {
+    "id": "link",
+    "name": "Link",
+    "description": "A link to the artist page.",
+    "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 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"
+  },
+  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
+  }
+};

+ 177 - 0
packages/joy-media/src/schemas/video/Video.ts

@@ -0,0 +1,177 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+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.'),
+  videoThumbnail: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  aboutTheVideo: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.'),
+  description: Yup.string()
+    .max(4000, 'Text is too long. Maximum length is 4000 chars.'),
+  attribution: Yup.string()
+    .max(255, 'Text is too long. Maximum length is 255 chars.')
+});
+
+export type VideoType = {
+  title: string
+  videoThumbnail: string
+  aboutTheVideo: string
+  language: any
+  description?: string
+  firstReleased: number
+  category?: any
+  link?: string[]
+  object?: any
+  publicationStatus: any
+  curationStatus?: any
+  explicit: boolean
+  license: any
+  attribution?: string
+};
+
+export type VideoPropId =
+  'title' |
+  'videoThumbnail' |
+  'aboutTheVideo' |
+  'language' |
+  'description' |
+  'firstReleased' |
+  'category' |
+  'link' |
+  'object' |
+  'publicationStatus' |
+  'curationStatus' |
+  'explicit' |
+  'license' |
+  'attribution'
+  ;
+
+export type VideoGenericProp = {
+  id: VideoPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type VideoClassType = {
+  [id in keyof VideoType]: VideoGenericProp
+};
+
+export const VideoClass: VideoClassType = {
+  title: {
+    "id": "title",
+    "name": "Title",
+    "description": "The title of the video",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 255
+  },
+  videoThumbnail: {
+    "id": "videoThumbnail",
+    "name": "Video 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
+  },
+  aboutTheVideo: {
+    "id": "aboutTheVideo",
+    "name": "About the Video",
+    "description": "A short description of the video",
+    "required": true,
+    "type": "Text",
+    "maxTextLength": 255
+  },
+  language: {
+    "id": "language",
+    "name": "Language",
+    "description": "The main language used in the video.",
+    "required": true,
+    "type": "Internal",
+    "classId": "Language"
+  },
+  description: {
+    "id": "description",
+    "name": "Description",
+    "description": "Full description of the video",
+    "type": "Text",
+    "maxTextLength": 4000
+  },
+  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"
+  },
+  link: {
+    "id": "link",
+    "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
+  }
+};

+ 44 - 0
packages/joy-media/src/schemas/video/VideoCategory.ts

@@ -0,0 +1,44 @@
+
+/** This file is generated based on JSON schema. Do not modify. */
+
+import * as Yup from 'yup';
+
+export const VideoCategoryValidationSchema = Yup.object().shape({
+  category: Yup.string()
+    .required('This field is required')
+    .max(255, 'Text is too long. Maximum length is 255 chars.')
+});
+
+export type VideoCategoryType = {
+  category: string
+};
+
+export type VideoCategoryPropId =
+  'category'
+  ;
+
+export type VideoCategoryGenericProp = {
+  id: VideoCategoryPropId,
+  type: string,
+  name: string,
+  description?: string,
+  required?: boolean,
+  maxItems?: number,
+  maxTextLength?: number,
+  classId?: any
+};
+
+type VideoCategoryClassType = {
+  [id in keyof VideoCategoryType]: VideoCategoryGenericProp
+};
+
+export const VideoCategoryClass: VideoCategoryClassType = {
+  category: {
+    "id": "category",
+    "name": "Category",
+    "description": "Categories for videos.",
+    "type": "Text",
+    "required": true,
+    "maxTextLength": 255
+  }
+};

+ 33 - 0
packages/joy-media/src/stories/ExploreContent.stories.tsx

@@ -0,0 +1,33 @@
+import React from 'react';
+import '../common/index.css';
+
+import { withKnobs } from '@storybook/addon-knobs';
+import { ExploreContent } from '../explore/ExploreContent';
+import { MusicAlbumSamples } from './data/MusicAlbumSamples';
+import { PlayContent } from '../explore/PlayContent';
+import { Album1TrackSamples } from './data/MusicTrackSamples';
+import { ChannelDataSample } from './data/ChannelSamples';
+
+export default { 
+    title: 'Media | Explore',
+    decorators: [withKnobs],
+};
+
+const FeaturedAlbums = MusicAlbumSamples.slice(0, 3);
+
+export const DefaultState = () =>
+	<ExploreContent />
+
+export const FeaturedAndLatestAlbums = () =>
+	<ExploreContent 
+		featuredAlbums={FeaturedAlbums}
+		latestAlbums={MusicAlbumSamples.reverse()}
+	/>
+
+export const PlayAlbum = () =>
+	<PlayContent 
+		channel={ChannelDataSample}
+		featuredAlbums={FeaturedAlbums}
+		tracks={Album1TrackSamples}
+		currentTrackIndex={3}
+	/>

+ 40 - 0
packages/joy-media/src/stories/MusicAlbumTracks.stories.tsx

@@ -0,0 +1,40 @@
+import React from 'react';
+import '../common/index.css';
+
+import { withKnobs } from '@storybook/addon-knobs';
+import { EditMusicAlbum } from '../music/EditMusicAlbum';
+import { MyMusicTracks } from '../music/MyMusicTracks';
+import { MusicAlbumSamples } from './data/MusicAlbumSamples';
+import { MusicAlbumExample, albumTracks, AllMusicTrackSamples } from './data/MusicTrackSamples';
+
+export default { 
+    title: 'Media | My music tracks',
+    decorators: [withKnobs],
+};
+
+export const EditAlbumStory = () =>
+	<EditMusicAlbum
+		isStorybook={true} 
+		entity={MusicAlbumExample}
+		tracks={albumTracks}
+	/>
+
+export const MyMusicTracksStory = () =>
+	<MyMusicTracks
+		albums={MusicAlbumSamples}
+		tracks={AllMusicTrackSamples}
+	/>
+
+// export const DefaultState = () => {
+// 	return <TracksOfMyMusicAlbum album={MusicAlbumSample} />;
+// }
+
+// export const AlbumWithTracks = () => {
+// 	return <TracksOfMyMusicAlbum {...AlbumWithTracksProps} />
+// }
+
+// export const ReorderTracks = () =>
+// 	<ReorderableTracks {...AlbumWithTracksProps} />
+
+// export const EditAlbumModalStory = () =>
+// 	<EditAlbumModal {...AlbumWithTracksProps} />

+ 29 - 0
packages/joy-media/src/stories/MusicChannel.stories.tsx

@@ -0,0 +1,29 @@
+import React from 'react';
+import '../common/index.css';
+
+import { withKnobs } from '@storybook/addon-knobs';
+import { ChannelDataSample } from './data/ChannelSamples';
+import { ViewMusicChannel } from '../channels/ViewMusicChannel';
+import { MusicAlbumSamples } from './data/MusicAlbumSamples';
+import { AllMusicTrackSamples } from './data/MusicTrackSamples';
+
+export default { 
+    title: 'Media | Music channel',
+    decorators: [withKnobs],
+};
+
+export const EmptyMusicChannel = () =>
+	<ViewMusicChannel channel={ChannelDataSample} />
+
+export const MusicChannelWithAlbumsOnly = () =>
+	<ViewMusicChannel channel={ChannelDataSample} albums={MusicAlbumSamples} />
+
+export const MusicChannelWithAlbumAndTracks = () =>
+<>
+<div>tracks:{ AllMusicTrackSamples.length}</div>
+	<ViewMusicChannel
+		channel={ChannelDataSample}
+		albums={MusicAlbumSamples}
+		tracks={AllMusicTrackSamples}
+	/>
+	</>

+ 20 - 0
packages/joy-media/src/stories/MyChannels.stories.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import '../common/index.css';
+
+import { withKnobs } from '@storybook/addon-knobs';
+import { MyChannels } from '../channels/MyChannels';
+import { ChannelsDataSamples } from './data/ChannelSamples';
+
+export default { 
+    title: 'Media | My channels',
+    decorators: [withKnobs],
+};
+
+export const DefaultState = () =>
+	<MyChannels />
+
+export const ChannelCreationSuspended = () =>
+	<MyChannels suspended={true} />
+
+export const YouHaveChannels = () =>
+	<MyChannels channels={ChannelsDataSamples} />

+ 19 - 0
packages/joy-media/src/stories/MyMusicAlbums.stories.tsx

@@ -0,0 +1,19 @@
+import React from 'react';
+import '../common/index.css';
+
+import { withKnobs } from '@storybook/addon-knobs';
+import { MyMusicAlbums } from '../music/MyMusicAlbums';
+import { MusicAlbumSamples } from './data/MusicAlbumSamples';
+
+export default { 
+    title: 'Media | My music albums',
+    decorators: [withKnobs],
+};
+
+export const DefaultState = () => {
+	return <MyMusicAlbums />;
+}
+
+export const WithState = () => {
+	return <MyMusicAlbums albums={MusicAlbumSamples} />;
+}

+ 47 - 0
packages/joy-media/src/stories/UploadAudio.stories.tsx

@@ -0,0 +1,47 @@
+import React from 'react';
+import { EditForm } from '../upload/UploadAudio'
+import '../index.css';
+
+import { withKnobs } from '@storybook/addon-knobs';
+import { ContentId } from '@joystream/types/media';
+import { MusicTrackEntity } from '../entities/MusicTrackEntity';
+
+export default { 
+    title: 'Media | Upload audio',
+    decorators: [withKnobs],
+};
+
+export const DefaultState = () => {
+	return <EditForm
+		isStorybook={true}
+		contentId={ContentId.generate()} 
+	/>;
+}
+
+export const RequiemByMozart = () => {
+	return <EditForm
+		isStorybook={true}
+		contentId={ContentId.generate()}
+		entity={newAudioTrack()}
+	/>;
+}
+
+function newAudioTrack(): MusicTrackEntity {
+	return {
+		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',
+
+		// 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',
+	};
+}

+ 46 - 0
packages/joy-media/src/stories/UploadVideo.stories.tsx

@@ -0,0 +1,46 @@
+import React from 'react';
+import { EditForm } from '../upload/UploadVideo'
+import '../index.css';
+
+import { withKnobs } from '@storybook/addon-knobs';
+import { Option } from '@polkadot/types/codec';
+import { ContentId, ContentMetadata } from '@joystream/types/media';
+import { newContentMetadata } from '../upload/ContentMetadataHelper';
+
+export default { 
+	title: 'Media | Upload video',
+	decorators: [withKnobs],
+};
+
+export const DefaultState = () => {
+	return <EditForm
+		isStorybook={true}
+		contentId={ContentId.generate()} 
+	/>;
+}
+
+export const CharlieChaplinFilm = () => {
+	return <EditForm
+		isStorybook={true}
+		contentId={ContentId.generate()}
+		metadataOpt={newCharlieChaplinFilm()}
+	/>;
+}
+
+function newCharlieChaplinFilm(): Option<ContentMetadata> {
+	const owner =  '5HZ6GtaeyxagLynPryM7ZnmLzoWFePKuDrkb4AT8rT4pU1fp'
+	const name = 'Modern Times (film) by Charlie Chaplin'
+	const description = 'Modern Times is a 1936 American comedy film written and directed by Charlie Chaplin in which his iconic Little Tramp character struggles to survive in the modern, industrialized world. The film is a comment on the desperate employment and financial conditions many people faced during the Great Depression — conditions created, in Chaplins view, by the efficiencies of modern industrialization. The movie stars Chaplin, Paulette Goddard, Henry Bergman, Tiny Sandford and Chester Conklin.'
+	const thumbnail = 'https://upload.wikimedia.org/wikipedia/commons/3/36/Modern_Times_poster.jpg'
+	const keywords = 'comedy, movie, black and white'
+
+	return new Option(ContentMetadata, newContentMetadata({
+		owner,
+		block: 123,
+		time: 1572946770,
+		name,
+		description,
+		thumbnail,
+		keywords
+	}));
+}

+ 5 - 0
packages/joy-media/src/stories/data/AccountIdSamples.ts

@@ -0,0 +1,5 @@
+export const AccountIdSamples = {
+	Alice: '5GNJqTPyNqANBkUVMN1LPPrxXnFouWXoe2wNSmmEoLctxiZY',
+	Bob: '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty',
+	Charlie: '5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y'
+};

+ 37 - 0
packages/joy-media/src/stories/data/ChannelSamples.ts

@@ -0,0 +1,37 @@
+import { ChannelEntity } from "@polkadot/joy-media/entities/MusicChannelEntity";
+
+import { AccountIdSamples } from "./AccountIdSamples";
+import BN from 'bn.js';
+
+export const ChannelDataSample: ChannelEntity = {
+	revenueAccountId: AccountIdSamples.Alice,
+	rewardEarned: new BN('4587'),
+	contentItemsCount: 57,
+	visibility: 'Public',
+	blocked: false,
+
+	contentType: 'music',
+	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.',
+
+	avatarUrl: 'https://images.unsplash.com/photo-1485561222814-e6c50477491b?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60',
+	coverUrl: 'https://images.unsplash.com/photo-1514119412350-e174d90d280e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=900&q=80'
+};
+
+export const ChannelsDataSamples: ChannelEntity[] = [
+	ChannelDataSample,
+	{
+		revenueAccountId: AccountIdSamples.Bob,
+		rewardEarned: new BN('1820021'),
+		contentItemsCount: 1529,
+		visibility: 'Unlisted',
+		blocked: true,
+
+		contentType: 'video',
+		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.',
+
+		avatarUrl: 'https://images.unsplash.com/photo-1485965120184-e220f721d03e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=200&q=60',
+		coverUrl: 'https://images.unsplash.com/photo-1494488802316-82250d81cfcc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=900&q=60'
+	}
+];

+ 58 - 0
packages/joy-media/src/stories/data/MusicAlbumSamples.ts

@@ -0,0 +1,58 @@
+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
+	}
+];

+ 73 - 0
packages/joy-media/src/stories/data/MusicTrackSamples.ts

@@ -0,0 +1,73 @@
+import { MusicAlbumSample } from "./MusicAlbumSamples";
+import { TracksOfMyMusicAlbumProps } from "@polkadot/joy-media/music/MusicAlbumTracks";
+import { MusicAlbumEntity } from "@polkadot/joy-media/entities/MusicAlbumEntity";
+
+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: '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'
+}));
+
+export const Album1TrackSamples = trackNames
+	.slice(0, trackNames.length / 2)
+	.map((title, i) => ({
+		id: `${100 + i}`,
+		title,
+		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'
+	}))
+
+export const Album2TrackSamples = trackNames
+	.slice(trackNames.length / 2)
+	.map((title, i) => ({
+		id: `${200 + i}`,
+		title,
+		artist: 'Liquid Stone',
+		cover: 'https://images.unsplash.com/photo-1484352491158-830ef5692bb3?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=60',
+	}))
+
+export const AllMusicTrackSamples = 
+	Album1TrackSamples
+	.concat(Album2TrackSamples)
+
+export const AlbumWithTracksProps: TracksOfMyMusicAlbumProps = {
+	album: MusicAlbumSample,
+	tracks: albumTracks
+}
+
+export const MusicAlbumExample: MusicAlbumEntity = {
+	title: 'Requiem (Mozart)',
+	about: '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.',
+	cover: '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',
+};

+ 45 - 0
packages/joy-media/src/upload/ContentMetadataHelper.tsx

@@ -0,0 +1,45 @@
+import { u32, u64, Text, GenericAccountId } from '@polkadot/types';
+import { BlockAndTime, ContentMetadata, SchemaId, ContentVisibility } from '@joystream/types/media';
+
+type NewContentMetadataProps = {
+	owner: string,
+	block: number,
+	time: number,
+  visibility?: 'Public',
+  schema?: number,
+	name: string,
+	description: string,
+	thumbnail: string,
+	keywords: string
+}
+
+export function newContentMetadata (props: NewContentMetadataProps) {
+	const {
+		owner,
+		block,
+		time,
+    visibility = 'Public',
+    schema = 0,
+		name,
+		description,
+		thumbnail,
+		keywords
+	} = props;
+
+	return new ContentMetadata({
+		owner: new GenericAccountId(owner),
+		added_at: new BlockAndTime({
+			block: new u32(block),
+			time: new u64(time)
+		}),
+		children_ids: [],
+		visibility: new ContentVisibility(visibility),
+		schema: new SchemaId(schema),
+		json: new Text(JSON.stringify({
+			name,
+			description,
+			thumbnail,
+			keywords
+		}))
+	})
+}

+ 199 - 0
packages/joy-media/src/upload/UploadAudio.tsx

@@ -0,0 +1,199 @@
+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 { SubmittableResult } from '@polkadot/api';
+
+import { ContentId } from '@joystream/types/media';
+import { onImageError, DEFAULT_THUMBNAIL_URL } from '../utils';
+import { MusicTrackValidationSchema, MusicTrackType, MusicTrackClass as Fields } from '../schemas/music/MusicTrack';
+import * as Opts from '../common/DropdownOptions';
+import { withMediaForm, MediaFormProps } from '../common/MediaForms';
+
+type OuterProps = {
+  isStorybook?: boolean,
+  history?: History,
+  contentId: ContentId,
+  fileName?: string,
+  entity?: MusicTrackType
+};
+
+type FormValues = MusicTrackType;
+
+const InnerForm = (props: MediaFormProps<OuterProps, FormValues>) => {
+  const {
+    
+    // React components for form fields:
+    // LabelledText,
+    LabelledField,
+    MediaText,
+    MediaField,
+    MediaDropdown,
+
+    isStorybook = false,
+    history,
+    contentId,
+    entity,
+
+    // Formik stuff:
+    values,
+    dirty,
+    isValid,
+    isSubmitting,
+    setSubmitting,
+    resetForm
+  } = props;
+
+  const { trackThumbnail } = values;
+
+  const onSubmit = (sendTx: () => void) => {
+    if (isValid) sendTx();
+  };
+
+  const onTxCancelled = () => {
+    // Nothing yet.
+  };
+
+  const onTxFailed = (txResult: SubmittableResult) => {
+    setSubmitting(false);
+    if (txResult == null) {
+      return onTxCancelled();
+    }
+  };
+
+  const onTxSuccess = (_txResult: SubmittableResult) => {
+    setSubmitting(false);
+    goToPlayerPage();
+  };
+
+  const goToPlayerPage = () => {
+    if (history) {
+      history.push('/media/play/' + contentId.encode());
+    }
+  };
+
+  const isNew = !entity;
+
+  const buildTxParams = () => {
+    if (!isValid) return [];
+
+    return [ /* TODO save entity to versioned store */ ];
+  };
+
+  const basicInfoTab = () => <Tab.Pane as='div'>
+    <MediaText field={Fields.trackTitle} {...props} />
+    <MediaText field={Fields.trackThumbnail} {...props} />
+    <MediaField field={Fields.aboutTheTrack} component='textarea' rows={3} disabled={isSubmitting} {...props} />
+    <MediaDropdown field={Fields.publicationStatus} options={Opts.visibilityOptions} {...props} />
+  </Tab.Pane>
+
+  const additionalTab = () => <Tab.Pane as='div'>
+    <MediaText field={Fields.trackArtist} {...props} />
+    <MediaText field={Fields.composerOrSongwriter} {...props} />
+    <MediaDropdown field={Fields.genre} options={Opts.genreOptions} {...props} />
+    <MediaDropdown field={Fields.mood} options={Opts.moodOptions} {...props} />
+    <MediaDropdown field={Fields.theme} options={Opts.themeOptions} {...props} />
+    <MediaDropdown field={Fields.license} options={Opts.licenseOptions} {...props} />
+  </Tab.Pane>
+
+  const tabs = () => <Tab
+    menu={{ secondary: true, pointing: true, color: 'blue' }}
+    panes={[
+      { menuItem: 'Basic info', render: basicInfoTab },
+      { menuItem: 'Additional', render: additionalTab },
+    ]}
+  />;
+
+  const MainButton = () => {
+    const isDisabled = !dirty || isSubmitting;
+
+    const label = isNew
+      ? 'Publish'
+      : 'Update';
+
+    if (isStorybook) return (
+      <Button
+        primary
+        type='button'
+        size='large'
+        disabled={isDisabled}
+        content={label}
+      />
+    );
+
+    return <TxButton
+      type='submit'
+      size='large'
+      isDisabled={isDisabled}
+      label={label}
+      params={buildTxParams()}
+      tx={isNew
+        ? 'dataDirectory.addMetadata'
+        : 'dataDirectory.updateMetadata'
+      }
+      onClick={onSubmit}
+      txFailedCb={onTxFailed}
+      txSuccessCb={onTxSuccess}
+    />
+  }
+
+  return <div className='EditMetaBox'>
+    <div className='EditMetaThumb'>
+      {trackThumbnail && <img src={trackThumbnail} onError={onImageError} />}
+    </div>
+
+    <Form className='ui form JoyForm EditMetaForm'>
+      
+      {tabs()}
+
+      {/* TODO add metadata status dropdown: Draft, Published */}
+
+      <LabelledField style={{ marginTop: '1rem' }} {...props}>
+        <MainButton />
+        <Button
+          type='button'
+          size='large'
+          disabled={!dirty || isSubmitting}
+          onClick={() => resetForm()}
+          content='Reset form'
+        />
+      </LabelledField>
+    </Form>
+  </div>;
+};
+
+export const EditForm = withFormik<OuterProps, FormValues>({
+
+  // Transform outer props into form values
+  mapPropsToValues: props => {
+    const { entity, fileName } = props;
+
+    return {
+      // Basic:
+      trackTitle: entity && entity.trackTitle || fileName || '',
+      aboutTheTrack: entity && entity.aboutTheTrack || '',
+      trackThumbnail: entity && entity.trackThumbnail || DEFAULT_THUMBNAIL_URL,
+      publicationStatus: entity && entity.publicationStatus || Opts.visibilityOptions[0].value,
+      album: entity && entity.album || '',
+
+      // Additional:
+      trackArtist: entity && entity.trackArtist || '',
+      composerOrSongwriter: entity && entity.composerOrSongwriter || '',
+      genre: entity && entity.genre || Opts.genreOptions[0].value,
+      mood: entity && entity.mood || Opts.moodOptions[0].value,
+      theme: entity && entity.theme || Opts.themeOptions[0].value,
+      explicit: entity && entity.explicit || false, // TODO explicitOptions[0].value,
+      license: entity && entity.license || Opts.licenseOptions[0].value,
+    };
+  },
+
+  validationSchema: () => MusicTrackValidationSchema,
+
+  handleSubmit: () => {
+    // do submitting things
+  }
+})(withMediaForm(InnerForm));
+
+export default EditForm;

+ 284 - 0
packages/joy-media/src/upload/UploadVideo.tsx

@@ -0,0 +1,284 @@
+import React from 'react';
+import { Button, Tab, Dropdown } from 'semantic-ui-react';
+import { Form, Field, withFormik, FormikProps } from 'formik';
+import { History } from 'history';
+
+import TxButton from '@polkadot/joy-utils/TxButton';
+import { SubmittableResult } from '@polkadot/api';
+
+import * as JoyForms from '@polkadot/joy-utils/forms';
+import { Option } from '@polkadot/types/codec';
+import { ContentId, ContentMetadata } from '@joystream/types/media';
+import { onImageError, DEFAULT_THUMBNAIL_URL } from '../utils';
+import { VideoValidationSchema, VideoType, VideoClass, /* VideoPropNames, VideoPropDescriptions */ } from '../schemas/video/Video';
+
+export type VideoPropId = keyof VideoType;
+
+type PropIdToStringMapping = {
+  [_ in VideoPropId]: string;
+}
+
+export const VideoPropIds = {} as PropIdToStringMapping;
+export const VideoPropNames = {} as PropIdToStringMapping;
+export const VideoPropDescriptions = {} as PropIdToStringMapping;
+
+Object.keys(VideoClass).map(x => {
+  const id = x as VideoPropId
+  const prop = VideoClass[id];
+  VideoPropIds[id] = id;
+  VideoPropNames[id] = prop.name;
+  VideoPropDescriptions[id] = prop.description;
+});
+
+// TODO get from verstore
+const visibilityOptions = [
+  'Public',
+  'Unlisted'
+].map(x => ({
+  key: x, text: x, value: x,
+}));
+
+// TODO get from verstore
+const languageOptions = [
+  'English',
+  'Chinese (Mandarin)',
+  'Hindi',
+  'Spanish',
+  'Portuguese',
+  'German',
+  'Russian',
+  'Japanese',
+  'Norwegian'
+].map(x => ({
+  key: x, text: x, value: x,
+}));
+
+// TODO get from verstore
+const categoryOptions = [
+  'Film & Animation',
+  'Autos & Vehicles',
+  'Music',
+  'Pets & Animals',
+  'Sports',
+  'Travel & Events',
+  'Gaming',
+  'People & Blogs',
+  'Comedy',
+  'News & Politics'
+].map(x => ({
+  key: x, text: x, value: x,
+}));
+
+// TODO get from verstore
+const licenseOptions = [
+  'Public Domain',
+  'Share Alike',
+  'No Derivatives',
+  'No Commercial'
+].map(x => ({
+  key: x, text: x, value: x,
+}));
+
+type OuterProps = {
+  isStorybook?: boolean,
+  history?: History,
+  contentId: ContentId,
+  fileName?: string,
+  metadataOpt?: Option<ContentMetadata>
+};
+
+const FormLabels = VideoPropNames;
+
+const FormTooltips = VideoPropDescriptions;
+
+type FormValues = VideoType;
+
+type FormProps = OuterProps & FormikProps<FormValues>;
+
+const LabelledField = JoyForms.LabelledField<FormValues>();
+
+const LabelledText = JoyForms.LabelledText<FormValues>();
+
+const InnerForm = (props: FormProps) => {
+  const {
+    isStorybook = false,
+    history,
+    contentId,
+    metadataOpt,
+    values,
+    dirty,
+    isValid,
+    isSubmitting,
+    setSubmitting,
+    resetForm
+  } = props;
+
+  const { videoThumbnail } = values;
+
+  const onSubmit = (sendTx: () => void) => {
+    if (isValid) sendTx();
+  };
+
+  const onTxCancelled = () => {
+
+  };
+
+  const onTxFailed = (txResult: SubmittableResult) => {
+    setSubmitting(false);
+    if (txResult == null) {
+      return onTxCancelled();
+    }
+  };
+
+  const onTxSuccess = (_txResult: SubmittableResult) => {
+    setSubmitting(false);
+    goToPlayerPage();
+  };
+
+  const goToPlayerPage = () => {
+    if (history) {
+      history.push('/media/play/' + contentId.encode());
+    }
+  };
+
+  const isNew = !metadataOpt || metadataOpt.isNone;
+
+  const buildTxParams = () => {
+    if (!isValid) return [];
+
+    return [ /* TODO save entity to versioned store */ ];
+  };
+
+  const basicInfoTab = () => <Tab.Pane as='div'>
+    <LabelledText name='title' label={fieldName('title')} tooltip={tooltip('title')} {...props} />
+    
+    <LabelledText name='videoThumbnail' label={fieldName('videoThumbnail')} tooltip={tooltip('videoThumbnail')} {...props} />
+    
+    <LabelledField name='description' label={fieldName('description')} tooltip={tooltip('description')} {...props}>
+      <Field component='textarea' id='description' name='description' disabled={isSubmitting} rows={3} />
+    </LabelledField>
+
+    {/* <LabelledText name='keywords' label={fieldName(`Keywords`)} tooltip={tooltip('keywords')} placeholder={`Comma-separated keywords`} {...props} /> */}
+
+    <LabelledField name='publicationStatus' label={fieldName('publicationStatus')} tooltip={tooltip('publicationStatus')} {...props}>
+      <Field component={Dropdown} id='publicationStatus' name='publicationStatus' disabled={isSubmitting} selection options={visibilityOptions} />
+    </LabelledField>
+    
+  </Tab.Pane>
+
+  const additionalTab = () => <Tab.Pane as='div'>
+    <LabelledField name='aboutTheVideo' label={fieldName('aboutTheVideo')} tooltip={tooltip('aboutTheVideo')} {...props}>
+      <Field component='textarea' id='aboutTheVideo' name='aboutTheVideo' disabled={isSubmitting} rows={3} />
+    </LabelledField>
+
+    <LabelledField name='category' label={fieldName('category')} tooltip={tooltip('category')} {...props}>
+      <Field component={Dropdown} id='category' name='category' disabled={isSubmitting} search selection options={categoryOptions} />
+    </LabelledField>
+
+    <LabelledField name='language' label={fieldName('language')} tooltip={tooltip('language')} {...props}>
+      <Field component={Dropdown} id='language' name='language' disabled={isSubmitting} search selection options={languageOptions} />
+    </LabelledField>
+
+    <LabelledField name='license' label={fieldName('license')} tooltip={tooltip('license')} {...props}>
+      <Field component={Dropdown} id='license' name='license' disabled={isSubmitting} search selection options={licenseOptions} />
+    </LabelledField>
+  </Tab.Pane>
+
+  const tabs = () => <Tab
+    menu={{ secondary: true, pointing: true, color: 'blue' }}
+    panes={[
+      { menuItem: 'Basic info', render: basicInfoTab },
+      { menuItem: 'Additional', render: additionalTab },
+    ]}
+  />;
+
+  const MainButton = () => {
+    const isDisabled = !dirty || isSubmitting;
+
+    const label = isNew
+      ? 'Publish'
+      : 'Update';
+
+    if (isStorybook) return (
+      <Button
+        primary
+        type='button'
+        size='large'
+        disabled={isDisabled}
+        content={label}
+      />
+    );
+
+    return <TxButton
+      type='submit'
+      size='large'
+      isDisabled={isDisabled}
+      label={label}
+      params={buildTxParams()}
+      tx={isNew
+        ? 'dataDirectory.addMetadata'
+        : 'dataDirectory.updateMetadata'
+      }
+      onClick={onSubmit}
+      txFailedCb={onTxFailed}
+      txSuccessCb={onTxSuccess}
+    />
+  }
+
+  return <div className='EditMetaBox'>
+    <div className='EditMetaThumb'>
+      {videoThumbnail && <img src={videoThumbnail} onError={onImageError} />}
+    </div>
+
+    <Form className='ui form JoyForm EditMetaForm'>
+      
+      {tabs()}
+
+      {/* TODO add metadata status dropdown: Draft, Published */}
+
+      <LabelledField style={{ marginTop: '1rem' }} {...props}>
+        <MainButton />
+        <Button
+          type='button'
+          size='large'
+          disabled={!dirty || isSubmitting}
+          onClick={() => resetForm()}
+          content='Reset form'
+        />
+      </LabelledField>
+    </Form>
+  </div>;
+};
+
+export const EditForm = withFormik<OuterProps, FormValues>({
+
+  // Transform outer props into form values
+  mapPropsToValues: props => {
+    const { entity: json, fileName } = props;
+
+    return {
+      // Basic:
+      title: json && json.title || fileName || '',
+      description: json && json.description || '',
+      videoThumbnail: json && json.videoThumbnail || DEFAULT_THUMBNAIL_URL,
+      keywords: json && json.keywords || '',
+      publicationStatus: visibilityOptions[0].value,
+      playlist: '',
+
+      // Additional:
+      aboutTheVideo: '',
+      category: categoryOptions[0].value,
+      language: languageOptions[0].value,
+      explicit: '',// TODO explicitOptions[0].value,
+      license: licenseOptions[0].value,
+    };
+  },
+
+  validationSchema: () => VideoValidationSchema,
+
+  handleSubmit: () => {
+    // do submitting things
+  }
+})(InnerForm);
+
+export default EditForm;

+ 30 - 0
packages/joy-utils/src/Pluralize.tsx

@@ -0,0 +1,30 @@
+import React from 'react';
+import BN from 'bn.js';
+
+type PluralizeProps = {
+  count: number | BN,
+  singularText: string,
+  pluralText?: string,
+}
+
+export function Pluralize (props: PluralizeProps) {
+  let { count, singularText, pluralText } = props;
+
+  if (!count) {
+    count = 0
+  } else {
+    count = typeof count !== 'number'
+      ? count.toNumber()
+      : count;
+  }
+  
+  const plural = () => !pluralText 
+    ? singularText + 's' 
+    : pluralText;
+
+  const text = count === 1
+    ? singularText
+    : plural();
+
+  return <>{count} {text}</>;
+}

+ 28 - 12
packages/joy-utils/src/forms.tsx

@@ -3,44 +3,48 @@ import { Field, ErrorMessage, FormikErrors, FormikTouched } from 'formik';
 
 import { BareProps } from '@polkadot/react-components/types';
 import { nonEmptyStr } from '@polkadot/joy-utils/index';
+import { Popup, Icon } from 'semantic-ui-react';
 
-type FormValuesType = {
-  [s: string]: string
-};
-
-type LabelledProps<FormValues = FormValuesType> = BareProps & {
+export type LabelledProps<FormValues> = BareProps & {
   name?: keyof FormValues,
   label?: React.ReactNode,
   invisibleLabel?: boolean,
   placeholder?: string,
-  children?: JSX.Element | JSX.Element[],
+  tooltip?: React.ReactNode,
+  style?: React.CSSProperties,
+  children?: React.ReactNode,
   errors: FormikErrors<FormValues>,
   touched: FormikTouched<FormValues>,
   isSubmitting: boolean
 };
 
-export function LabelledField<FormValues = FormValuesType> () {
+export function LabelledField<FormValues> () {
   return (props: LabelledProps<FormValues>) => {
-    const { name, label, invisibleLabel = false, touched, errors, children } = props;
+    const { name, label, invisibleLabel = false, tooltip, touched, errors, children, style } = props;
     const hasError = name && touched[name] && errors[name];
+
     const fieldWithError = <>
       <div>{children}</div>
       {name && <ErrorMessage name={name as string} component='div' className='ui pointing red label' />}
     </>;
+    
     return (label || invisibleLabel)
-      ? <div className={`ui--Labelled field ${hasError ? 'error' : ''}`}>
-          <label htmlFor={name as string}>{nonEmptyStr(label) && label + ':'}</label>
+      ? <div style={style} className={`ui--Labelled field ${hasError ? 'error' : ''}`}>
+          <label htmlFor={name as string}>
+            {nonEmptyStr(label) && label}
+            {tooltip && <FieldTooltip>{tooltip}</FieldTooltip> }
+          </label>
           <div className='ui--Labelled-content'>
             {fieldWithError}
           </div>
         </div>
-      : <div className={`field ${hasError ? 'error' : ''}`}>
+      : <div style={style} className={`field ${hasError ? 'error' : ''}`}>
           {fieldWithError}
         </div>;
   };
 }
 
-export function LabelledText<FormValues = FormValuesType> () {
+export function LabelledText<FormValues> () {
   const LF = LabelledField<FormValues>();
   return (props: LabelledProps<FormValues>) => {
     const { name, placeholder, className, style, ...otherProps } = props;
@@ -50,3 +54,15 @@ export function LabelledText<FormValues = FormValuesType> () {
     </LF>;
   };
 }
+
+type FieldTooltipProps = {
+  children: React.ReactNode
+}
+
+export const FieldTooltip = (props: FieldTooltipProps) => {
+  return <Popup
+    trigger={<Icon name='question' circular size='small' style={{ marginLeft: '.25rem' }} />}
+    content={props.children}
+    position='right center'
+  />;
+}

+ 72 - 2
yarn.lock

@@ -1057,6 +1057,14 @@
     core-js "^2.6.5"
     regenerator-runtime "^0.13.2"
 
+"@babel/runtime-corejs2@^7.6.3":
+  version "7.7.2"
+  resolved "https://registry.yarnpkg.com/@babel/runtime-corejs2/-/runtime-corejs2-7.7.2.tgz#5a8c4e2f8688ce58adc9eb1d8320b6e7341f96ce"
+  integrity sha512-GfVnHchOBvIMsweQ13l4jd9lT4brkevnavnVOej5g2y7PpTRY+R4pcQlCjWMZoUla5rMLFzaS/Ll2s59cB1TqQ==
+  dependencies:
+    core-js "^2.6.5"
+    regenerator-runtime "^0.13.2"
+
 "@babel/runtime@7.0.0":
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.0.0.tgz#adeb78fedfc855aa05bc041640f3f6f98e85424c"
@@ -3456,6 +3464,13 @@
     "@types/history" "*"
     "@types/react" "*"
 
+"@types/react-beautiful-dnd@^11.0.3":
+  version "11.0.3"
+  resolved "https://registry.yarnpkg.com/@types/react-beautiful-dnd/-/react-beautiful-dnd-11.0.3.tgz#51d9f37942dd18cc4aa10da98a5c883664e7ee46"
+  integrity sha512-7ZbT/7mNJu+uRrUGdTQ1hAINtqg909L4NHrXyspV42fvVgBgda6ysiBzoDUMENmQ/RlRJdpyrcp8Dtd/77bp9Q==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react-color@^3.0.1":
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/@types/react-color/-/react-color-3.0.1.tgz#5433e2f503ea0e0831cbc6fd0c20f8157d93add0"
@@ -6789,6 +6804,13 @@ css-blank-pseudo@^0.1.4:
   dependencies:
     postcss "^7.0.5"
 
+css-box-model@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/css-box-model/-/css-box-model-1.2.0.tgz#3a26377b4162b3200d2ede4b064ec5b6a75186d0"
+  integrity sha512-lri0br+jSNV0kkkiGEp9y9y3Njq2PmpqbeGWRFQJuZteZzY9iC9GZhQ8Y4WpPwM/2YocjHePxy14igJY7YKzkA==
+  dependencies:
+    tiny-invariant "^1.0.6"
+
 css-color-keywords@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/css-color-keywords/-/css-color-keywords-1.0.0.tgz#fea2616dc676b2962686b3af8dbdbe180b244e05"
@@ -12967,7 +12989,7 @@ mem@^4.0.0:
     mimic-fn "^2.0.0"
     p-is-promise "^2.0.0"
 
-memoize-one@^5.0.0:
+memoize-one@^5.0.0, memoize-one@^5.1.1:
   version "5.1.1"
   resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.1.1.tgz#047b6e3199b508eaec03504de71229b8eb1d75c0"
   integrity sha512-HKeeBpWvqiVJD57ZUAsJNm71eHTykffzcLZVYWiVfQeI1rJtuEaS7hQiEpWfVVk18donPwJEcFKIkCmPJNOhHA==
@@ -16125,6 +16147,11 @@ rabin@^1.6.0:
     prebuild-install "^2.1.0"
     readable-stream "^2.0.4"
 
+raf-schd@^4.0.2:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/raf-schd/-/raf-schd-4.0.2.tgz#bd44c708188f2e84c810bf55fcea9231bcaed8a0"
+  integrity sha512-VhlMZmGy6A6hrkJWHLNTGl5gtgMUm+xfGza6wbwnE914yeQ5Ybm18vgM734RZhMgfw4tacUrWseGZlpUrrakEQ==
+
 raf@^3.4.0:
   version "3.4.1"
   resolved "https://registry.yarnpkg.com/raf/-/raf-3.4.1.tgz#0742e99a4a6552f445d73e3ee0328af0ff1ede39"
@@ -16221,6 +16248,19 @@ react-aplayer@^1.0.0:
     prop-types "^15.6.1"
     react "^16.2.0"
 
+react-beautiful-dnd@^12.0.0:
+  version "12.0.0"
+  resolved "https://registry.yarnpkg.com/react-beautiful-dnd/-/react-beautiful-dnd-12.0.0.tgz#38b76446455c332870bf0d05f14a97af2a07dfca"
+  integrity sha512-NdttVdqLL1y8vKNx7VlcBCsC4c/8R/H4NIYo5rqPGU1HF48oXHrTpSDWw1HPMTBifVty4OKOkSIwo1NcvE3bDw==
+  dependencies:
+    "@babel/runtime-corejs2" "^7.6.3"
+    css-box-model "^1.2.0"
+    memoize-one "^5.1.1"
+    raf-schd "^4.0.2"
+    react-redux "^7.1.1"
+    redux "^4.0.4"
+    use-memo-one "^1.1.1"
+
 react-chartjs-2@^2.8.0:
   version "2.8.0"
   resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-2.8.0.tgz#1c24de91fb3755f8c4302675de7d66fdda339759"
@@ -16487,6 +16527,18 @@ react-qr-reader@^2.2.1:
     prop-types "^15.7.2"
     webrtc-adapter "^7.2.1"
 
+react-redux@^7.1.1:
+  version "7.1.3"
+  resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.3.tgz#717a3d7bbe3a1b2d535c94885ce04cdc5a33fc79"
+  integrity sha512-uI1wca+ECG9RoVkWQFF4jDMqmaw0/qnvaSvOoL/GA4dNxf6LoV8sUAcNDvE5NWKs4hFpn0t6wswNQnY3f7HT3w==
+  dependencies:
+    "@babel/runtime" "^7.5.5"
+    hoist-non-react-statics "^3.3.0"
+    invariant "^2.2.4"
+    loose-envify "^1.4.0"
+    prop-types "^15.7.2"
+    react-is "^16.9.0"
+
 react-router-dom@^5.1.2:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.1.2.tgz#06701b834352f44d37fbb6311f870f84c76b9c18"
@@ -16850,6 +16902,14 @@ reduce@^1.0.1:
   dependencies:
     object-keys "^1.1.0"
 
+redux@^4.0.4:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.4.tgz#4ee1aeb164b63d6a1bcc57ae4aa0b6e6fa7a3796"
+  integrity sha512-vKv4WdiJxOWKxK0yRoaK3Y4pxxB0ilzVx6dszU2W8wLxlb2yikRph4iV/ymtdJ6ZxpBLFbyrxklnT5yBbQSl3Q==
+  dependencies:
+    loose-envify "^1.4.0"
+    symbol-observable "^1.2.0"
+
 reflect.ownkeys@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460"
@@ -18657,6 +18717,11 @@ svgo@^1.0.0, svgo@^1.2.2:
     unquote "~1.1.1"
     util.promisify "~1.0.0"
 
+symbol-observable@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
+  integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
+
 symbol-tree@^3.2.2:
   version "3.2.4"
   resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
@@ -18946,7 +19011,7 @@ tiny-emitter@^2.0.0:
   resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423"
   integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==
 
-tiny-invariant@^1.0.2:
+tiny-invariant@^1.0.2, tiny-invariant@^1.0.6:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.0.6.tgz#b3f9b38835e36a41c843a3b0907a5a7b3755de73"
   integrity sha512-FOyLWWVjG+aC0UqG76V53yAWdXfH8bO6FNmyZOuUrzDzK8DI3/JRY25UD7+g49JWM1LXwymsKERB+DzI0dTEQA==
@@ -19633,6 +19698,11 @@ usb@^1.6.0:
     nan "2.13.2"
     prebuild-install "^5.3.3"
 
+use-memo-one@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.1.tgz#39e6f08fe27e422a7d7b234b5f9056af313bd22c"
+  integrity sha512-oFfsyun+bP7RX8X2AskHNTxu+R3QdE/RC5IefMbqptmACAA/gfol1KDD5KRzPsGMa62sWxGZw+Ui43u6x4ddoQ==
+
 use@^3.1.0:
   version "3.1.1"
   resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"