Merge pull request #371 from lbryio/282-thumbnail-selector

282 thumbnail selector
This commit is contained in:
Bill Bittner 2018-03-05 23:04:18 -08:00 committed by GitHub
commit be420b362a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 679 additions and 561 deletions

View file

@ -28,6 +28,10 @@ module.exports = {
host : 'https://spee.ch',
description: 'Open-source, decentralized image and video sharing.'
},
publish: {
thumbnailChannel : '@channelName', // create a channel to use for thumbnail images
thumbnailChannelId: 'xyz123...', // the channel_id (claim id) for the channel above
}
claim: {
defaultTitle : 'Spee.ch',
defaultThumbnail : 'https://spee.ch/assets/img/video_thumb_default.png',

View file

@ -39,7 +39,6 @@ module.exports = (req, res) => {
.run(saga)
.done
.then(() => {
console.log('preload sagas are done');
// render component to a string
const html = renderToString(
<Provider store={store}>
@ -56,10 +55,7 @@ module.exports = (req, res) => {
// check for a redirect
if (context.url) {
console.log('REDIRECTING:', context.url);
return res.redirect(301, context.url);
} else {
console.log(`we're good, send the response`);
}
// get the initial state from our Redux store

View file

@ -71,7 +71,6 @@ module.exports = {
},
resolveUri (uri) {
logger.debug(`lbryApi >> Resolving URI for "${uri}"`);
// console.log('resolving uri', uri);
return new Promise((resolve, reject) => {
axios
.post(lbryApiUri, {

View file

@ -1,6 +1,6 @@
const logger = require('winston');
const fs = require('fs');
const { site, wallet } = require('../config/speechConfig.js');
const { site, wallet, publish } = require('../config/speechConfig.js');
module.exports = {
parsePublishApiRequestBody ({name, nsfw, license, title, description, thumbnail}) {
@ -28,8 +28,7 @@ module.exports = {
thumbnail,
};
},
parsePublishApiRequestFiles ({file}) {
logger.debug('file', file);
parsePublishApiRequestFiles ({file, thumbnail}) {
// make sure a file was provided
if (!file) {
throw new Error('no file with key of [file] found in request');
@ -45,16 +44,18 @@ module.exports = {
}
// validate the file name
if (/'/.test(file.name)) {
logger.debug('publish > file validation > file name had apostrophe in it');
throw new Error('apostrophes are not allowed in the file name');
}
// validate the file
module.exports.validateFileTypeAndSize(file);
// return results
return {
fileName: file.name,
filePath: file.path,
fileType: file.type,
fileName : file.name,
filePath : file.path,
fileType : file.type,
thumbnailFileName: (thumbnail ? thumbnail.name : null),
thumbnailFilePath: (thumbnail ? thumbnail.path : null),
thumbnailFileType: (thumbnail ? thumbnail.type : null),
};
},
validateFileTypeAndSize (file) {
@ -116,11 +117,34 @@ module.exports = {
claim_address: wallet.lbryClaimAddress,
};
// add thumbnail to channel if video
if (thumbnail !== null) {
if (thumbnail) {
publishParams['metadata']['thumbnail'] = thumbnail;
}
return publishParams;
},
createThumbnailPublishParams (thumbnailFilePath, claimName, license, nsfw) {
if (!thumbnailFilePath) {
return;
}
logger.debug(`Creating Thumbnail Publish Parameters`);
// create the publish params
return {
name : `${claimName}-thumb`,
file_path: thumbnailFilePath,
bid : 0.01,
metadata : {
title : `${claimName} thumbnail`,
description: `a thumbnail for ${claimName}`,
author : site.title,
language : 'en',
license,
nsfw,
},
claim_address: wallet.lbryClaimAddress,
channel_name : publish.thumbnailChannel,
channel_id : publish.thumbnailChannelId,
};
},
deleteTemporaryFile (filePath) {
fs.unlink(filePath, err => {
if (err) {

View file

@ -283,11 +283,12 @@ a, a:visited {
/* ERROR MESSAGES */
.info-message--success, .info-message--failure {
.info-message, .info-message--success, .info-message--failure {
font-size: medium;
margin: 0px;
padding: 0.3em;
color: #9b9b9b;
}
.info-message--success {
@ -542,7 +543,7 @@ table {
/* show */
#video {
.video {
cursor: pointer;
background-color: #ffffff;
width: calc(100% - 12px - 12px - 2px);
@ -603,33 +604,6 @@ table {
text-align: center;
}
/* ---- grid items ---- */
.grid-item {
width: calc(33% - 2rem);
padding: 0px;
margin: 1rem;
float: left;
border: 0.5px solid white;
}
.grid-item-image {
width: 100%;
}
.grid-item-details {
position: absolute;
top: 0px;
left: 0px;
height: 100%;
width: 100%;
cursor: pointer;
}
.grid-item-details-text {
font-size: medium;
margin: 0px;
text-align: center;
padding: 1em 0px 1em 0px;
width: 100%;
.slider {
width: 100%
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -71,3 +71,17 @@ export function toggleMetadataInputs (showMetadataInputs) {
data: showMetadataInputs,
};
};
export function onNewThumbnail (file) {
return {
type: actions.THUMBNAIL_NEW,
data: file,
};
};
export function startPublish (history) {
return {
type: actions.PUBLISH_START,
data: { history },
};
}

View file

@ -2,7 +2,6 @@ import Request from 'utils/request';
const { site: { host } } = require('../../config/speechConfig.js');
export function getLongClaimId (name, modifier) {
// console.log('getting long claim id for asset:', name, modifier);
let body = {};
// create request params
if (modifier) {
@ -26,13 +25,11 @@ export function getLongClaimId (name, modifier) {
};
export function getShortId (name, claimId) {
// console.log('getting short id for asset:', name, claimId);
const url = `${host}/api/claim/short-id/${claimId}/${name}`;
return Request(url);
};
export function getClaimData (name, claimId) {
// console.log('getting claim data for asset:', name, claimId);
const url = `${host}/api/claim/data/${name}/${claimId}`;
return Request(url);
};

View file

@ -2,14 +2,12 @@ import Request from 'utils/request';
const { site: { host } } = require('../../config/speechConfig.js');
export function getChannelData (name, id) {
console.log('getting channel data for channel:', name, id);
if (!id) id = 'none';
const url = `${host}/api/channel/data/${name}/${id}`;
return Request(url);
};
export function getChannelClaims (name, longId, page) {
console.log('getting channel claims for channel:', name, longId);
if (!page) page = 1;
const url = `${host}/api/channel/claims/${name}/${longId}/${page}`;
return Request(url);

0
react/api/publishApi.js Normal file
View file

48
react/channels/publish.js Normal file
View file

@ -0,0 +1,48 @@
import {buffers, END, eventChannel} from 'redux-saga';
export const makePublishRequestChannel = (fd) => {
return eventChannel(emitter => {
const uri = '/api/claim/publish';
const xhr = new XMLHttpRequest();
// add event listeners
const onLoadStart = () => {
emitter({loadStart: true});
};
const onProgress = (event) => {
if (event.lengthComputable) {
const percentage = Math.round((event.loaded * 100) / event.total);
emitter({progress: percentage});
}
};
const onLoad = () => {
emitter({load: true});
};
xhr.upload.addEventListener('loadstart', onLoadStart);
xhr.upload.addEventListener('progress', onProgress);
xhr.upload.addEventListener('load', onLoad);
// set state change handler
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
const response = JSON.parse(xhr.response);
if ((xhr.status === 200) && response.success) {
emitter({success: response});
emitter(END);
} else {
emitter({error: new Error(response.message)});
emitter(END);
}
}
};
// open and send
xhr.open('POST', uri, true);
xhr.send(fd);
// clean up
return () => {
xhr.upload.removeEventListener('loadstart', onLoadStart);
xhr.upload.removeEventListener('progress', onProgress);
xhr.upload.removeEventListener('load', onLoad);
xhr.onreadystatechange = null;
xhr.abort();
};
}, buffers.sliding(2));
};

View file

@ -51,7 +51,7 @@ class AssetDisplay extends React.Component {
);
case 'video/mp4':
return (
<video id='video' className='asset' controls poster={thumbnail}>
<video className='asset video' controls poster={thumbnail}>
<source
src={`/${claimId}/${name}.${fileExt}`}
/>

View file

@ -24,7 +24,7 @@ const AssetPreview = ({ claimData: { name, claimId, fileExt, contentType, thumbn
case 'video/mp4':
return (
<img
className={'asset-preview'}
className={'asset-preview video'}
src={thumbnail || defaultThumbnail}
alt={name}
/>

View file

@ -10,34 +10,44 @@ class Preview extends React.Component {
};
}
componentDidMount () {
this.previewFile(this.props.file);
this.setPreviewImageSource(this.props.file);
}
componentWillReceiveProps (newProps) {
if (newProps.file !== this.props.file) {
this.previewFile(newProps.file);
this.setPreviewImageSource(newProps.file);
}
if (newProps.thumbnail !== this.props.thumbnail) {
this.setState({imgSource: (newProps.thumbnail || this.state.defaultThumbnail)});
if (newProps.thumbnail) {
this.setPreviewImageSourceFromFile(newProps.thumbnail);
} else {
this.setState({imgSource: this.state.defaultThumbnail});
}
}
}
previewFile (file) {
setPreviewImageSourceFromFile (file) {
const previewReader = new FileReader();
previewReader.readAsDataURL(file);
previewReader.onloadend = () => {
this.setState({imgSource: previewReader.result});
};
}
setPreviewImageSource (file) {
if (file.type !== 'video/mp4') {
const previewReader = new FileReader();
previewReader.readAsDataURL(file);
previewReader.onloadend = () => {
this.setState({imgSource: previewReader.result});
};
this.setPreviewImageSourceFromFile(file);
} else {
this.setState({imgSource: (this.props.thumbnail || this.state.defaultThumbnail)});
if (this.props.thumbnail) {
this.setPreviewImageSourceFromFile(this.props.thumbnail);
}
this.setState({imgSource: this.state.defaultThumbnail});
}
}
render () {
return (
<img
id="dropzone-preview"
id='dropzone-preview'
src={this.state.imgSource}
className={this.props.dimPreview ? 'dim' : ''}
alt="publish preview"
alt='publish preview'
/>
);
}
@ -46,7 +56,7 @@ class Preview extends React.Component {
Preview.propTypes = {
dimPreview: PropTypes.bool.isRequired,
file : PropTypes.object.isRequired,
thumbnail : PropTypes.string,
thumbnail : PropTypes.object,
};
export default Preview;

View file

@ -1,52 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import ProgressBar from 'components/ProgressBar';
import * as publishStates from 'constants/publish_claim_states';
function PublishStatus ({ status, message }) {
return (
<div className='row row--tall flex-container--column flex-container--center-center'>
{(status === publishStates.LOAD_START) &&
<div className='row align-content-center'>
<p>File is loading to server</p>
<p className='blue'>{message}</p>
</div>
}
{(status === publishStates.LOADING) &&
<div>
<div className='row align-content-center'>
<p>File is loading to server</p>
<p className='blue'>{message}</p>
</div>
</div>
}
{(status === publishStates.PUBLISHING) &&
<div className='row align-content-center'>
<p>Upload complete. Your file is now being published on the blockchain...</p>
<ProgressBar size={12} />
<p>Curious what magic is happening here? <a className='link--primary' target='blank' href='https://lbry.io/faq/what-is-lbry'>Learn more.</a></p>
</div>
}
{(status === publishStates.SUCCESS) &&
<div className='row align-content-center'>
<p>Your publish is complete! You are being redirected to it now.</p>
<p>If you are not automatically redirected, <a className='link--primary' target='_blank' href={message}>click here.</a></p>
</div>
}
{(status === publishStates.FAILED) &&
<div className='row align-content-center'>
<p>Something went wrong...</p>
<p><strong>{message}</strong></p>
<p>For help, post the above error text in the #speech channel on the <a className='link--primary' href='https://discord.gg/YjYbwhS' target='_blank'>lbry discord</a></p>
</div>
}
</div>
);
};
PublishStatus.propTypes = {
status : PropTypes.string.isRequired,
message: PropTypes.string,
};
export default PublishStatus;

View file

@ -7,3 +7,5 @@ export const PUBLISH_STATUS_UPDATE = 'PUBLISH_STATUS_UPDATE';
export const ERROR_UPDATE = 'ERROR_UPDATE';
export const SELECTED_CHANNEL_UPDATE = 'SELECTED_CHANNEL_UPDATE';
export const TOGGLE_METADATA_INPUTS = 'TOGGLE_METADATA_INPUTS';
export const THUMBNAIL_NEW = 'THUMBNAIL_NEW';
export const PUBLISH_START = 'PUBLISH_START';

View file

@ -1,7 +1,7 @@
import { connect } from 'react-redux';
import { updateLoggedInChannel } from 'actions/channel';
import View from './view';
import {updateSelectedChannel} from '../../actions/publish';
import {updateSelectedChannel} from 'actions/publish';
const mapDispatchToProps = dispatch => {
return {

View file

@ -54,7 +54,6 @@ class ChannelCreateForm extends React.Component {
return new Promise((resolve, reject) => {
request(`/api/channel/availability/${channelWithAtSymbol}`)
.then(isAvailable => {
console.log('checkIsChannelAvailable result:', isAvailable);
if (!isAvailable) {
return reject(new Error('That channel has already been claimed'));
}
@ -69,10 +68,8 @@ class ChannelCreateForm extends React.Component {
const password = this.state.password;
return new Promise((resolve, reject) => {
if (!password || password.length < 1) {
console.log('password not provided');
return reject(new Error('Please provide a password'));
}
console.log('password provided');
resolve();
});
}
@ -88,12 +85,10 @@ class ChannelCreateForm extends React.Component {
return new Promise((resolve, reject) => {
request('/signup', params)
.then(result => {
console.log('makePublishChannelRequest result:', result);
return resolve(result);
})
.catch(error => {
console.log('create channel request failed:', error);
reject(new Error('Unfortunately, we encountered an error while creating your channel. Please let us know in Discord!'));
reject(new Error(`Unfortunately, we encountered an error while creating your channel. Please let us know in Discord! ${error.message}`));
});
});
}
@ -119,38 +114,41 @@ class ChannelCreateForm extends React.Component {
return (
<div>
{ !this.state.status ? (
<form id="publish-channel-form">
<p id="input-error-channel-name" className="info-message-placeholder info-message--failure">{this.state.error}</p>
<div className="row row--wide row--short">
<div className="column column--3 column--sml-10">
<label className="label" htmlFor="new-channel-name">Name:</label>
</div><div className="column column--6 column--sml-10">
<div className="input-text--primary flex-container--row flex-container--left-bottom span--relative">
<form id='publish-channel-form'>
<div className='row row--wide row--short'>
<div className='column column--3 column--sml-10'>
<label className='label' htmlFor='new-channel-name'>Name:</label>
</div><div className='column column--6 column--sml-10'>
<div className='input-text--primary flex-container--row flex-container--left-bottom span--relative'>
<span>@</span>
<input type="text" name="channel" id="new-channel-name" className="input-text" placeholder="exampleChannelName" value={this.state.channel} onChange={this.handleChannelInput} />
{ (this.state.channel && !this.state.error) && <span id="input-success-channel-name" className="info-message--success span--absolute">{'\u2713'}</span> }
{ this.state.error && <span id="input-success-channel-name" className="info-message--failure span--absolute">{'\u2716'}</span> }
<input type='text' name='channel' id='new-channel-name' className='input-text' placeholder='exampleChannelName' value={this.state.channel} onChange={this.handleChannelInput} />
{ (this.state.channel && !this.state.error) && <span id='input-success-channel-name' className='info-message--success span--absolute'>{'\u2713'}</span> }
{ this.state.error && <span id='input-success-channel-name' className='info-message--failure span--absolute'>{'\u2716'}</span> }
</div>
</div>
</div>
<div className="row row--wide row--short">
<div className="column column--3 column--sml-10">
<label className="label" htmlFor="new-channel-password">Password:</label>
</div><div className="column column--6 column--sml-10">
<div className="input-text--primary">
<input type="password" name="password" id="new-channel-password" className="input-text" placeholder="" value={this.state.password} onChange={this.handleInput} />
<div className='row row--wide row--short'>
<div className='column column--3 column--sml-10'>
<label className='label' htmlFor='new-channel-password'>Password:</label>
</div><div className='column column--6 column--sml-10'>
<div className='input-text--primary'>
<input type='password' name='password' id='new-channel-password' className='input-text' placeholder='' value={this.state.password} onChange={this.handleInput} />
</div>
</div>
</div>
<div className="row row--wide">
<button className="button--primary" onClick={this.createChannel}>Create Channel</button>
{this.state.error ? (
<p className='info-message--failure'>{this.state.error}</p>
) : (
<p className='info-message'>Choose a name and password for your channel</p>
)}
<div className='row row--wide'>
<button className='button--primary' onClick={this.createChannel}>Create Channel</button>
</div>
</form>
) : (
<div>
<p className="fine-print">{this.state.status}</p>
<ProgressBar size={12}/>
<p className='fine-print'>{this.state.status}</p>
<ProgressBar size={12} />
</div>
)}
</div>

View file

@ -26,10 +26,9 @@ class ChannelLoginForm extends React.Component {
'Content-Type': 'application/json',
}),
credentials: 'include',
}
};
request('login', params)
.then(({success, channelName, shortChannelId, channelClaimId, message}) => {
console.log('loginToChannel success:', success);
if (success) {
this.props.onChannelLogin(channelName, shortChannelId, channelClaimId);
} else {
@ -37,7 +36,6 @@ class ChannelLoginForm extends React.Component {
};
})
.catch(error => {
console.log('login error', error);
if (error.message) {
this.setState({'error': error.message});
} else {
@ -47,30 +45,33 @@ class ChannelLoginForm extends React.Component {
}
render () {
return (
<form id="channel-login-form">
<p id="login-error-display-element" className="info-message-placeholder info-message--failure">{this.state.error}</p>
<div className="row row--wide row--short">
<div className="column column--3 column--sml-10">
<label className="label" htmlFor="channel-login-name-input">Name:</label>
</div><div className="column column--6 column--sml-10">
<div className="input-text--primary flex-container--row flex-container--left-bottom">
<span>@</span>
<input type="text" id="channel-login-name-input" className="input-text" name="name" placeholder="Your Channel Name" value={this.state.channelName} onChange={this.handleInput}/>
<form id='channel-login-form'>
<div className='row row--wide row--short'>
<div className='column column--3 column--sml-10'>
<label className='label' htmlFor='channel-login-name-input'>Name:</label>
</div><div className='column column--6 column--sml-10'>
<div className='input-text--primary flex-container--row flex-container--left-bottom'>
<span>@</span>
<input type='text' id='channel-login-name-input' className='input-text' name='name' placeholder='Your Channel Name' value={this.state.channelName} onChange={this.handleInput} />
</div>
</div>
</div>
</div>
<div className="row row--wide row--short">
<div className="column column--3 column--sml-10">
<label className="label" htmlFor="channel-login-password-input" >Password:</label>
</div><div className="column column--6 column--sml-10">
<div className="input-text--primary">
<input type="password" id="channel-login-password-input" name="password" className="input-text" placeholder="" value={this.state.channelPassword} onChange={this.handleInput}/>
<div className='row row--wide row--short'>
<div className='column column--3 column--sml-10'>
<label className='label' htmlFor='channel-login-password-input' >Password:</label>
</div><div className='column column--6 column--sml-10'>
<div className='input-text--primary'>
<input type='password' id='channel-login-password-input' name='password' className='input-text' placeholder='' value={this.state.channelPassword} onChange={this.handleInput} />
</div>
</div>
</div>
</div>
<div className="row row--wide">
<button className="button--primary" onClick={this.loginToChannel}>Authenticate</button>
{ this.state.error ? (
<p className='info-message--failure'>{this.state.error}</p>
) : (
<p className='info-message'>Enter the name and password for your channel</p>
)}
<div className='row row--wide'>
<button className='button--primary' onClick={this.loginToChannel}>Authenticate</button>
</div>
</form>
);

View file

@ -24,28 +24,32 @@ class ChannelSelect extends React.Component {
render () {
return (
<div>
<p id="input-error-channel-select" className="info-message-placeholder info-message--failure">{this.props.channelError}</p>
<form>
<div className="column column--3 column--med-10">
<input type="radio" name="anonymous-or-channel" id="anonymous-radio" className="input-radio" value="anonymous" checked={!this.props.publishInChannel} onChange={this.toggleAnonymousPublish}/>
<label className="label label--pointer" htmlFor="anonymous-radio">Anonymous</label>
<div className='column column--3 column--med-10'>
<input type='radio' name='anonymous-or-channel' id='anonymous-radio' className='input-radio' value='anonymous' checked={!this.props.publishInChannel} onChange={this.toggleAnonymousPublish} />
<label className='label label--pointer' htmlFor='anonymous-radio'>Anonymous</label>
</div>
<div className="column column--7 column--med-10">
<input type="radio" name="anonymous-or-channel" id="channel-radio" className="input-radio" value="in a channel" checked={this.props.publishInChannel} onChange={this.toggleAnonymousPublish}/>
<label className="label label--pointer" htmlFor="channel-radio">In a channel</label>
<div className='column column--7 column--med-10'>
<input type='radio' name='anonymous-or-channel' id='channel-radio' className='input-radio' value='in a channel' checked={this.props.publishInChannel} onChange={this.toggleAnonymousPublish} />
<label className='label label--pointer' htmlFor='channel-radio'>In a channel</label>
</div>
{ this.props.channelError ? (
<p className='info-message--failure'>{this.props.channelError}</p>
) : (
<p className='info-message'>Publish anonymously or in a channel</p>
)}
</form>
{ this.props.publishInChannel && (
<div>
<div className="column column--3">
<label className="label" htmlFor="channel-name-select">Channel:</label>
</div><div className="column column--7">
<select type="text" id="channel-name-select" className="select select--arrow" value={this.props.selectedChannel} onChange={this.handleSelection}>
{ this.props.loggedInChannelName && <option value={this.props.loggedInChannelName} id="publish-channel-select-channel-option">{this.props.loggedInChannelName}</option> }
<option value={states.LOGIN}>Existing</option>
<option value={states.CREATE}>New</option>
</select>
</div>
<div className='column column--3'>
<label className='label' htmlFor='channel-name-select'>Channel:</label>
</div><div className='column column--7'>
<select type='text' id='channel-name-select' className='select select--arrow' value={this.props.selectedChannel} onChange={this.handleSelection}>
{ this.props.loggedInChannelName && <option value={this.props.loggedInChannelName} id='publish-channel-select-channel-option'>{this.props.loggedInChannelName}</option> }
<option value={states.LOGIN}>Existing</option>
<option value={states.CREATE}>New</option>
</select>
</div>
{ (this.props.selectedChannel === states.LOGIN) && <ChannelLoginForm /> }
{ (this.props.selectedChannel === states.CREATE) && <ChannelCreateForm /> }
</div>

View file

@ -5,7 +5,7 @@ import View from './view';
const mapStateToProps = ({ publish }) => {
return {
file : publish.file,
thumbnail: publish.metadata.thumbnail,
thumbnail: publish.thumbnail,
fileError: publish.error.file,
};
};
@ -14,15 +14,11 @@ const mapDispatchToProps = dispatch => {
return {
selectFile: (file) => {
dispatch(selectFile(file));
dispatch(updateError('publishSubmit', null));
},
setFileError: (value) => {
dispatch(clearFile());
dispatch(updateError('file', value));
},
clearFileError: () => {
dispatch(updateError('file', null));
},
};
};

View file

@ -19,18 +19,17 @@ class Dropzone extends React.Component {
this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.handleClick = this.handleClick.bind(this);
this.handleFileInput = this.handleFileInput.bind(this);
this.selectFile = this.selectFile.bind(this);
this.chooseFile = this.chooseFile.bind(this);
}
handleDrop (event) {
event.preventDefault();
this.setState({dragOver: false});
// if dropped items aren't files, reject them
const dt = event.dataTransfer;
console.log('dt', dt);
if (dt.items) {
if (dt.items[0].kind == 'file') {
if (dt.items[0].kind === 'file') {
const droppedFile = dt.items[0].getAsFile();
this.selectFile(droppedFile);
this.chooseFile(droppedFile);
}
}
}
@ -61,15 +60,14 @@ class Dropzone extends React.Component {
}
handleClick (event) {
event.preventDefault();
// trigger file input
document.getElementById('file_input').click();
}
handleFileInput (event) {
event.preventDefault();
const fileList = event.target.files;
this.selectFile(fileList[0]);
this.chooseFile(fileList[0]);
}
selectFile (file) {
chooseFile (file) {
if (file) {
try {
validateFile(file); // validate the file's name, type, and size
@ -77,7 +75,6 @@ class Dropzone extends React.Component {
return this.props.setFileError(error.message);
}
// stage it so it will be ready when the publish button is clicked
this.props.clearFileError(null);
this.props.selectFile(file);
}
}

View file

@ -9,7 +9,6 @@ class LoginPage extends React.Component {
componentWillReceiveProps (newProps) {
// re-route the user to the homepage if the user is logged in
if (newProps.loggedInChannelName !== this.props.loggedInChannelName) {
console.log('user logged into new channel:', newProps.loggedInChannelName);
this.props.history.push(`/`);
}
}

View file

@ -39,9 +39,7 @@ class NavBar extends React.Component {
});
}
handleSelection (event) {
console.log('handling selection', event);
const value = event.target.selectedOptions[0].value;
console.log('value', value);
switch (value) {
case LOGOUT:
this.logoutUser();

View file

@ -0,0 +1,16 @@
import {connect} from 'react-redux';
import {clearFile, startPublish} from 'actions/publish';
import View from './view';
const mapStateToProps = ({ channel, publish }) => {
return {
file: publish.file,
};
};
const mapDispatchToProps = {
clearFile,
startPublish,
};
export default connect(mapStateToProps, mapDispatchToProps)(View);

View file

@ -0,0 +1,63 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import Dropzone from 'containers/Dropzone';
import PublishTitleInput from 'containers/PublishTitleInput';
import PublishUrlInput from 'containers/PublishUrlInput';
import PublishThumbnailInput from 'containers/PublishThumbnailInput';
import PublishMetadataInputs from 'containers/PublishMetadataInputs';
import ChannelSelect from 'containers/ChannelSelect';
class PublishDetails extends React.Component {
constructor (props) {
super(props)
this.onPublishSubmit = this.onPublishSubmit.bind(this);
}
onPublishSubmit () {
this.props.startPublish(this.props.history);
}
render () {
return (
<div className='row row--no-bottom'>
<div className='column column--10'>
<PublishTitleInput />
</div>
{/* left column */}
<div className='column column--5 column--sml-10' >
<div className='row row--padded'>
<Dropzone />
</div>
</div>
{/* right column */}
<div className='column column--5 column--sml-10 align-content-top'>
<div id='publish-active-area' className='row row--padded'>
<div className='row row--padded row--no-top row--wide'>
<PublishUrlInput />
</div>
<div className='row row--padded row--no-top row--wide'>
<ChannelSelect />
</div>
{ (this.props.file.type === 'video/mp4') && (
<div className='row row--padded row--no-top row--wide '>
<PublishThumbnailInput />
</div>
)}
<div className='row row--padded row--no-top row--no-bottom row--wide'>
<PublishMetadataInputs />
</div>
<div className='row row--wide align-content-center'>
<button id='publish-submit' className='button--primary button--large' onClick={this.onPublishSubmit}>Publish</button>
</div>
<div className='row row--padded row--no-bottom align-content-center'>
<button className='button--cancel' onClick={this.props.clearFile}>Cancel</button>
</div>
<div className='row row--short align-content-center'>
<p className='fine-print'>By clicking 'Publish', you affirm that you have the rights to publish this content to the LBRY network, and that you understand the properties of publishing it to a decentralized, user-controlled network. <a className='link--primary' target='_blank' href='https://lbry.io/learn'>Read more.</a></p>
</div>
</div>
</div>
</div>
);
}
};
export default withRouter(PublishDetails);

View file

@ -1,40 +0,0 @@
import {connect} from 'react-redux';
import {clearFile, updateError, updatePublishStatus} from 'actions/publish';
import View from './view';
const mapStateToProps = ({ channel, publish }) => {
return {
loggedInChannel : channel.loggedInChannel,
file : publish.file,
claim : publish.claim,
title : publish.metadata.title,
thumbnail : publish.metadata.thumbnail,
description : publish.metadata.description,
license : publish.metadata.license,
nsfw : publish.metadata.nsfw,
publishInChannel : publish.publishInChannel,
selectedChannel : publish.selectedChannel,
fileError : publish.error.file,
urlError : publish.error.url,
publishSubmitError: publish.error.publishSubmit,
};
};
const mapDispatchToProps = dispatch => {
return {
onFileClear: () => {
dispatch(clearFile());
},
onPublishStatusChange: (status, message) => {
dispatch(updatePublishStatus(status, message));
},
onChannelSelectionError: (value) => {
dispatch(updateError('channel', value));
},
onPublishSubmitError: (value) => {
dispatch(updateError('publishSubmit', value));
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(View);

View file

@ -1,169 +0,0 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import Dropzone from 'containers/Dropzone';
import PublishTitleInput from 'containers/PublishTitleInput';
import PublishUrlInput from 'containers/PublishUrlInput';
import PublishThumbnailInput from 'containers/PublishThumbnailInput';
import PublishMetadataInputs from 'containers/PublishMetadataInputs';
import ChannelSelect from 'containers/ChannelSelect';
import * as publishStates from 'constants/publish_claim_states';
class PublishForm extends React.Component {
constructor (props) {
super(props);
// this.makePublishRequest = this.makePublishRequest.bind(this);
this.publish = this.publish.bind(this);
}
validateChannelSelection () {
console.log('validating channel selection');
// make sure all required data is provided
return new Promise((resolve, reject) => {
// if publishInChannel is true, is a channel selected & logged in?
if (this.props.publishInChannel && (this.props.selectedChannel !== this.props.loggedInChannel.name)) {
// update state with error
this.props.onChannelSelectionError('Log in to a channel or select Anonymous"');
// reject this promise
return reject(new Error('Fix the channel'));
}
resolve();
});
}
validatePublishParams () {
console.log('validating publish params');
// make sure all required data is provided
return new Promise((resolve, reject) => {
// is there a file?
if (!this.props.file) {
return reject(new Error('Please choose a file'));
}
// is there a claim chosen?
if (!this.props.claim) {
return reject(new Error('Please enter a URL'));
}
if (this.props.urlError) {
return reject(new Error('Fix the url'));
}
resolve();
});
}
makePublishRequest (file, metadata) {
console.log('making publish request');
const uri = '/api/claim/publish';
const xhr = new XMLHttpRequest();
const fd = this.appendDataToFormData(file, metadata);
xhr.upload.addEventListener('loadstart', () => {
this.props.onPublishStatusChange(publishStates.LOAD_START, 'upload started');
});
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentage = Math.round((e.loaded * 100) / e.total);
console.log('progress:', percentage);
this.props.onPublishStatusChange(publishStates.LOADING, `${percentage}%`);
}
}, false);
xhr.upload.addEventListener('load', () => {
console.log('loaded 100%');
this.props.onPublishStatusChange(publishStates.PUBLISHING, null);
}, false);
xhr.open('POST', uri, true);
xhr.onreadystatechange = () => {
if (xhr.readyState === 4) {
const response = JSON.parse(xhr.response);
console.log('publish response:', response);
if ((xhr.status === 200) && response.success) {
this.props.history.push(`/${response.data.claimId}/${response.data.name}`);
this.props.onFileClear();
} else {
this.props.onPublishStatusChange(publishStates.FAILED, response.message);
}
}
};
// Initiate a multipart/form-data upload
xhr.send(fd);
}
createMetadata () {
console.log('creating metadata');
let metadata = {
name : this.props.claim,
title : this.props.title,
description: this.props.description,
license : this.props.license,
nsfw : this.props.nsfw,
type : this.props.file.type,
thumbnail : this.props.thumbnail,
};
if (this.props.publishInChannel) {
metadata['channelName'] = this.props.selectedChannel;
}
return metadata;
}
appendDataToFormData (file, metadata) {
var fd = new FormData();
fd.append('file', file);
for (var key in metadata) {
if (metadata.hasOwnProperty(key)) {
fd.append(key, metadata[key]);
}
}
return fd;
}
publish () {
console.log('publishing file');
// publish the asset
this.validateChannelSelection()
.then(() => {
return this.validatePublishParams();
})
.then(() => {
const metadata = this.createMetadata();
// publish the claim
return this.makePublishRequest(this.props.file, metadata);
})
.catch((error) => {
this.props.onPublishSubmitError(error.message);
});
}
render () {
return (
<div className='row row--no-bottom'>
<div className='column column--10'>
<PublishTitleInput />
</div>
<div className='column column--5 column--sml-10' >
<div className='row row--padded'>
<Dropzone />
</div>
</div>
<div className='column column--5 column--sml-10 align-content-top'>
<div id='publish-active-area' className='row row--padded'>
<div className='row row--padded row--no-top row--wide'>
<PublishUrlInput />
</div>
<div className='row row--padded row--no-top row--wide'>
<ChannelSelect />
</div>
{ (this.props.file.type === 'video/mp4') && (
<div className='row row--padded row--no-top row--wide '>
<PublishThumbnailInput />
</div>
)}
<div className='row row--padded row--no-top row--no-bottom row--wide'>
<PublishMetadataInputs />
</div>
<div className='row row--wide align-content-center'>
<button id='publish-submit' className='button--primary button--large' onClick={this.publish}>Publish</button>
</div>
<div className='row row--padded row--no-bottom align-content-center'>
<button className='button--cancel' onClick={this.props.onFileClear}>Cancel</button>
</div>
<div className='row row--short align-content-center'>
<p className='fine-print'>By clicking 'Publish', you affirm that you have the rights to publish this content to the LBRY network, and that you understand the properties of publishing it to a decentralized, user-controlled network. <a className='link--primary' target='_blank' href='https://lbry.io/learn'>Read more.</a></p>
</div>
</div>
</div>
</div>
);
}
};
export default withRouter(PublishForm);

View file

@ -0,0 +1,16 @@
import {connect} from 'react-redux';
import {clearFile} from 'actions/publish';
import View from './view';
const mapStateToProps = ({ publish }) => {
return {
status : publish.status.status,
message: publish.status.message,
};
};
const mapDispatchToProps = {
clearFile,
};
export default connect(mapStateToProps, mapDispatchToProps)(View);

View file

@ -0,0 +1,50 @@
import React from 'react';
import ProgressBar from 'components/ProgressBar';
import * as publishStates from 'constants/publish_claim_states';
class PublishStatus extends React.Component {
render () {
const { status, message, clearFile } = this.props;
return (
<div className='row row--tall flex-container--column flex-container--center-center'>
{status === publishStates.LOAD_START &&
<div className='row align-content-center'>
<p>File is loading to server</p>
<p className='blue'>0%</p>
</div>
}
{status === publishStates.LOADING &&
<div>
<div className='row align-content-center'>
<p>File is loading to server</p>
<p className='blue'>{message}</p>
</div>
</div>
}
{status === publishStates.PUBLISHING &&
<div className='row align-content-center'>
<p>Upload complete. Your file is now being published on the blockchain...</p>
<ProgressBar size={12} />
<p>Curious what magic is happening here? <a className='link--primary' target='blank' href='https://lbry.io/faq/what-is-lbry'>Learn more.</a></p>
</div>
}
{status === publishStates.SUCCESS &&
<div className='row align-content-center'>
<p>Your publish is complete! You are being redirected to it now.</p>
<p>If you are not automatically redirected, <a className='link--primary' target='_blank' href={message}>click here.</a></p>
</div>
}
{status === publishStates.FAILED &&
<div className='row align-content-center'>
<p>Something went wrong...</p>
<p><strong>{message}</strong></p>
<p>For help, post the above error text in the #speech channel on the <a className='link--primary' href='https://discord.gg/YjYbwhS' target='_blank'>lbry discord</a></p>
<button className='button--secondary' onClick={clearFile}>Reset</button>
</div>
}
</div>
);
}
};
export default PublishStatus;

View file

@ -1,19 +1,15 @@
import {connect} from 'react-redux';
import {updateMetadata} from 'actions/publish';
import { connect } from 'react-redux';
import { onNewThumbnail } from 'actions/publish';
import View from './view';
const mapStateToProps = ({ publish }) => {
const mapStateToProps = ({ publish: { file } }) => {
return {
thumbnail: publish.metadata.thumbnail,
file,
};
};
const mapDispatchToProps = dispatch => {
return {
onThumbnailChange: (name, value) => {
dispatch(updateMetadata(name, value));
},
};
const mapDispatchToProps = {
onNewThumbnail,
};
export default connect(mapStateToProps, mapDispatchToProps)(View);

View file

@ -1,77 +1,137 @@
import React from 'react';
function dataURItoBlob(dataURI) {
// convert base64/URLEncoded data component to raw binary data held in a string
let byteString = atob(dataURI.split(',')[1]);
// separate out the mime component
let mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];
// write the bytes of the string to a typed array
let ia = new Uint8Array(byteString.length);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ia], {type: mimeString});
}
class PublishThumbnailInput extends React.Component {
constructor (props) {
super(props);
this.state = {
videoPreviewSrc: null,
thumbnailError : null,
thumbnailInput : '',
}
this.handleInput = this.handleInput.bind(this);
this.updateVideoThumb = this.updateVideoThumb.bind(this);
videoSource : null,
error : null,
sliderMinRange: 1,
sliderMaxRange: null,
sliderValue : null,
};
this.handleVideoLoadedData = this.handleVideoLoadedData.bind(this);
this.handleSliderChange = this.handleSliderChange.bind(this);
this.createThumbnail = this.createThumbnail.bind(this);
}
handleInput (event) {
const value = event.target.value;
this.setState({thumbnailInput: value});
componentDidMount () {
const { file } = this.props;
this.setVideoSource(file);
}
urlIsAnImage (url) {
return (url.match(/\.(jpeg|jpg|gif|png)$/) != null);
componentWillReceiveProps (nextProps) {
// if file changes
if (nextProps.file && nextProps.file !== this.props.file) {
const { file } = nextProps;
this.setVideoSource(file);
};
}
testImage (url) {
return new Promise((resolve, reject) => {
const xhttp = new XMLHttpRequest();
xhttp.open('HEAD', url, true);
xhttp.onreadystatechange = () => {
if (xhttp.readyState === 4) {
if (xhttp.status === 200) {
resolve();
} else {
reject();
}
}
};
xhttp.send();
setVideoSource (file) {
const previewReader = new FileReader();
previewReader.readAsDataURL(file);
previewReader.onloadend = () => {
const dataUri = previewReader.result;
const blob = dataURItoBlob(dataUri);
const videoSource = URL.createObjectURL(blob);
this.setState({ videoSource });
};
}
handleVideoLoadedData (event) {
const duration = event.target.duration;
const totalMinutes = Math.floor(duration / 60);
const totalSeconds = Math.floor(duration % 60);
// set the slider
this.setState({
sliderMaxRange: duration * 100,
sliderValue : duration * 100 / 2,
totalMinutes,
totalSeconds,
});
// update the current time of the video
let video = document.getElementById('video-thumb-player');
video.currentTime = duration / 2;
}
updateVideoThumb (event) {
const imageUrl = event.target.value;
if (this.urlIsAnImage(imageUrl)) {
this.testImage(imageUrl, 3000)
.then(() => {
console.log('thumbnail is a valid image');
this.props.onThumbnailChange('thumbnail', imageUrl);
this.setState({thumbnailError: null});
})
.catch(error => {
console.log('encountered an error loading thumbnail image url:', error);
this.props.onThumbnailChange('thumbnail', null);
this.setState({thumbnailError: 'That is an invalid image url'});
});
} else {
this.props.onThumbnailChange('thumbnail', null);
this.setState({thumbnailError: null});
handleSliderChange (event) {
const value = parseInt(event.target.value);
// update the slider value
this.setState({
sliderValue: value,
});
// update the current time of the video
let video = document.getElementById('video-thumb-player');
video.currentTime = value / 100;
}
createThumbnail () {
// take a snapshot
let video = document.getElementById('video-thumb-player');
let canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL();
const blob = dataURItoBlob(dataUrl);
const snapshot = new File([blob], `thumbnail.png`, {
type: 'image/png',
});
// set the thumbnail in redux store
if (snapshot) {
this.props.onNewThumbnail(snapshot);
}
}
render () {
const { error, videoSource, sliderMinRange, sliderMaxRange, sliderValue, totalMinutes, totalSeconds } = this.state;
return (
<div>
<div className="column column--3 column--sml-10">
<label className="label">Thumbnail:</label>
</div><div className="column column--6 column--sml-10">
<div className="input-text--primary">
<p className="info-message-placeholder info-message--failure">{this.state.thumbnailError}</p>
<input
type="text" id="claim-thumbnail-input"
className="input-text input-text--full-width"
placeholder="https://spee.ch/xyz/example.jpg"
value={this.state.thumbnailInput}
onChange={ (event) => {
this.handleInput(event);
this.updateVideoThumb(event);
}} />
</div>
</div>
<label className='label'>Thumbnail:</label>
<video
id='video-thumb-player'
preload='metadata'
muted
style={{display: 'none'}}
playsInline
onLoadedData={this.handleVideoLoadedData}
src={videoSource}
onSeeked={this.createThumbnail}
/>
{
sliderValue ? (
<div>
<div className='flex-container--row flex-container--space-between-center' style={{width: '100%'}}>
<span className='info-message'>0'00"</span>
<span className='info-message'>{totalMinutes}'{totalSeconds}"</span>
</div>
<div>
<input
type='range'
min={sliderMinRange}
max={sliderMaxRange}
value={sliderValue}
className='slider'
onChange={this.handleSliderChange}
/>
</div>
</div>
) : (
<p className='info-message' >loading... </p>
)
}
{ error ? (
<p className='info-message--failure'>{error}</p>
) : (
<p className='info-message'>Use slider to set thumbnail</p>
)}
</div>
);
}

View file

@ -3,9 +3,8 @@ import View from './view';
const mapStateToProps = ({ publish }) => {
return {
file : publish.file,
status : publish.status.status,
message: publish.status.message,
file : publish.file,
status: publish.status.status,
};
};

View file

@ -1,20 +1,17 @@
import React from 'react';
import Dropzone from 'containers/Dropzone';
import PublishForm from 'containers/PublishForm';
import PublishStatus from 'components/PublishStatus';
import PublishDetails from 'containers/PublishDetails';
import PublishStatus from 'containers/PublishStatus';
class PublishTool extends React.Component {
render () {
if (this.props.file) {
if (this.props.status) {
return (
<PublishStatus
status={this.props.status}
message={this.props.message}
/>
<PublishStatus />
);
} else {
return <PublishForm />;
return <PublishDetails />;
}
} else {
return <Dropzone />;

View file

@ -8,15 +8,19 @@ class PublishUrlInput extends React.Component {
this.handleInput = this.handleInput.bind(this);
}
componentDidMount () {
if (!this.props.claim || this.props.claim === '') {
this.setClaimNameFromFileName();
const { claim, fileName } = this.props;
if (!claim) {
this.setClaimName(fileName);
}
}
componentWillReceiveProps ({claim: newClaim}) {
if (newClaim) {
this.checkClaimIsAvailable(newClaim);
} else {
this.props.onUrlError('Please enter a URL');
componentWillReceiveProps ({ claim, fileName }) {
// if a new file was chosen, update the claim name
if (fileName !== this.props.fileName) {
return this.setClaimName(fileName);
}
// if the claim has updated, check its availability
if (claim !== this.props.claim) {
this.validateClaim(claim);
}
}
handleInput (event) {
@ -30,16 +34,17 @@ class PublishUrlInput extends React.Component {
input = input.replace(/[^A-Za-z0-9-]/g, ''); // remove all characters that are not A-Z, a-z, 0-9, or '-'
return input;
}
setClaimNameFromFileName () {
const fileName = this.props.fileName;
setClaimName (fileName) {
const fileNameWithoutEnding = fileName.substring(0, fileName.lastIndexOf('.'));
const cleanClaimName = this.cleanseInput(fileNameWithoutEnding);
this.props.onClaimChange(cleanClaimName);
}
checkClaimIsAvailable (claim) {
validateClaim (claim) {
if (!claim) {
return this.props.onUrlError('Enter a url above');
}
request(`/api/claim/availability/${claim}`)
.then(validatedClaimName => {
console.log('api/claim/availability response:', validatedClaimName);
.then(() => {
this.props.onUrlError(null);
})
.catch((error) => {
@ -47,25 +52,27 @@ class PublishUrlInput extends React.Component {
});
}
render () {
const { claim, loggedInChannelName, loggedInChannelShortId, publishInChannel, selectedChannel, urlError } = this.props;
return (
<div>
<p id="input-error-claim-name" className="info-message-placeholder info-message--failure">{this.props.urlError}</p>
<div className="column column--3 column--sml-10">
<label className="label">URL:</label>
</div><div className="column column--7 column--sml-10 input-text--primary span--relative">
<span className="url-text--secondary">spee.ch / </span>
<div className='column column--10 column--sml-10'>
<div className='input-text--primary span--relative'>
<span className='url-text--secondary'>spee.ch / </span>
<UrlMiddle
publishInChannel={this.props.publishInChannel}
selectedChannel={this.props.selectedChannel}
loggedInChannelName={this.props.loggedInChannelName}
loggedInChannelShortId={this.props.loggedInChannelShortId}
publishInChannel={publishInChannel}
selectedChannel={selectedChannel}
loggedInChannelName={loggedInChannelName}
loggedInChannelShortId={loggedInChannelShortId}
/>
<input type="text" id="claim-name-input" className="input-text" name='claim' placeholder="your-url-here" onChange={this.handleInput} value={this.props.claim}/>
{ (this.props.claim && !this.props.urlError) && <span id="input-success-claim-name" className="info-message--success span--absolute">{'\u2713'}</span> }
{ this.props.urlError && <span id="input-success-channel-name" className="info-message--failure span--absolute">{'\u2716'}</span> }
<input type='text' id='claim-name-input' className='input-text' name='claim' placeholder='your-url-here' onChange={this.handleInput} value={claim} />
{ (claim && !urlError) && <span id='input-success-claim-name' className='info-message--success span--absolute'>{'\u2713'}</span> }
{ urlError && <span id='input-success-channel-name' className='info-message--failure span--absolute'>{'\u2716'}</span> }
</div>
<div>
{ urlError ? (
<p id='input-error-claim-name' className='info-message--failure'>{urlError}</p>
) : (
<p className='info-message'>Choose a custom url</p>
)}
</div>
</div>
);

View file

@ -2,9 +2,11 @@ import { combineReducers } from 'redux';
import PublishReducer from 'reducers/publish';
import ChannelReducer from 'reducers/channel';
import ShowReducer from 'reducers/show';
import SiteReducer from 'reducers/site';
export default combineReducers({
channel: ChannelReducer,
publish: PublishReducer,
show : ShowReducer,
site : SiteReducer,
});

View file

@ -1,5 +1,6 @@
import * as actions from 'constants/publish_action_types';
import { LOGIN } from 'constants/publish_channel_select_states';
const { publish } = require('../../config/speechConfig.js');
const initialState = {
publishInChannel : false,
@ -19,17 +20,19 @@ const initialState = {
claim : '',
metadata: {
title : '',
thumbnail : '',
description: '',
license : '',
nsfw : false,
},
thumbnailChannel : publish.thumbnailChannel,
thumbnailChannelId: publish.thumbnailChannelId,
thumbnail : null,
};
export default function (state = initialState, action) {
switch (action.type) {
case actions.FILE_SELECTED:
return Object.assign({}, state, {
return Object.assign({}, initialState, { // note: clears to initial state
file: action.data,
});
case actions.FILE_CLEAR:
@ -66,6 +69,10 @@ export default function (state = initialState, action) {
return Object.assign({}, state, {
showMetadataInputs: action.data,
});
case actions.THUMBNAIL_NEW:
return Object.assign({}, state, {
thumbnail: action.data,
});
default:
return state;
}

12
react/reducers/site.js Normal file
View file

@ -0,0 +1,12 @@
const { site } = require('../../config/speechConfig.js');
const initialState = {
host: site.host,
};
export default function (state = initialState, action) {
switch (action.type) {
default:
return state;
}
}

View file

@ -3,6 +3,7 @@ import { watchHandleShowPageUri } from './show_uri';
import { watchNewAssetRequest } from './show_asset';
import { watchNewChannelRequest, watchUpdateChannelClaims } from './show_channel';
import { watchFileIsRequested } from './file';
import { watchPublishStart } from './publish';
export default function * rootSaga () {
yield all([
@ -11,5 +12,6 @@ export default function * rootSaga () {
watchNewChannelRequest(),
watchUpdateChannelClaims(),
watchFileIsRequested(),
watchPublishStart(),
]);
}

62
react/sagas/publish.js Normal file
View file

@ -0,0 +1,62 @@
import { call, put, select, take, takeLatest } from 'redux-saga/effects';
import * as actions from 'constants/publish_action_types';
import * as publishStates from 'constants/publish_claim_states';
import { updateError, updatePublishStatus, clearFile } from 'actions/publish';
import { selectPublishState } from 'selectors/publish';
import { selectChannelState } from 'selectors/channel';
import { selectSiteState } from 'selectors/site';
import { validateChannelSelection, validatePublishParams } from 'utils/validate';
import { createPublishMetadata, createPublishFormData, createThumbnailUrl } from 'utils/publish';
import { makePublishRequestChannel } from 'channels/publish';
function * publishFile (action) {
const { history } = action.data;
const { publishInChannel, selectedChannel, file, claim, metadata, thumbnailChannel, thumbnailChannelId, thumbnail, error: { url: urlError } } = yield select(selectPublishState);
const { loggedInChannel } = yield select(selectChannelState);
const { host } = yield select(selectSiteState);
// validate the channel selection
try {
validateChannelSelection(publishInChannel, selectedChannel, loggedInChannel);
} catch (error) {
return yield put(updateError('channel', error.message));
};
// validate publish parameters
try {
validatePublishParams(file, claim, urlError);
} catch (error) {
return yield put(updateError('publishSubmit', error.message));
}
// create metadata
let publishMetadata = createPublishMetadata(claim, file, metadata, publishInChannel, selectedChannel);
if (thumbnail) {
// add thumbnail to publish metadata
publishMetadata['thumbnail'] = createThumbnailUrl(thumbnailChannel, thumbnailChannelId, claim, host);
}
// create form data for main publish
const publishFormData = createPublishFormData(file, thumbnail, publishMetadata);
// make the publish request
const publishChannel = yield call(makePublishRequestChannel, publishFormData);
while (true) {
const {loadStart, progress, load, success, error} = yield take(publishChannel);
if (error) {
return yield put(updatePublishStatus(publishStates.FAILED, error.message));
}
if (success) {
yield put(clearFile());
return history.push(`/${success.data.claimId}/${success.data.name}`);
}
if (loadStart) {
yield put(updatePublishStatus(publishStates.LOAD_START, null));
}
if (progress) {
yield put(updatePublishStatus(publishStates.LOADING, `${progress}%`));
}
if (load) {
yield put(updatePublishStatus(publishStates.PUBLISHING, null));
}
}
};
export function * watchPublishStart () {
yield takeLatest(actions.PUBLISH_START, publishFile);
};

View file

@ -12,16 +12,13 @@ export function * newAssetRequest (action) {
// If this uri is in the request list, it's already been fetched
const state = yield select(selectShowState);
if (state.requestList[requestId]) {
console.log('that request already exists in the request list!');
return null;
}
// get long id && add request to request list
console.log(`getting asset long id ${name}`);
let longId;
try {
({data: longId} = yield call(getLongClaimId, name, modifier));
} catch (error) {
console.log('error:', error);
return yield put(onRequestError(error.message));
}
const assetKey = `a#${name}#${longId}`;
@ -29,11 +26,9 @@ export function * newAssetRequest (action) {
// is this an existing asset?
// If this asset is in the asset list, it's already been fetched
if (state.assetList[assetKey]) {
console.log('that asset already exists in the asset list!');
return null;
}
// get short Id
console.log(`getting asset short id ${name} ${longId}`);
let shortId;
try {
({data: shortId} = yield call(getShortId, name, longId));
@ -41,7 +36,6 @@ export function * newAssetRequest (action) {
return yield put(onRequestError(error.message));
}
// get asset claim data
console.log(`getting asset claim data ${name} ${longId}`);
let claimData;
try {
({data: claimData} = yield call(getClaimData, name, longId));

View file

@ -12,11 +12,9 @@ export function * newChannelRequest (action) {
// If this uri is in the request list, it's already been fetched
const state = yield select(selectShowState);
if (state.requestList[requestId]) {
console.log('that request already exists in the request list!');
return null;
}
// get channel long id
console.log('getting channel long id and short id');
let longId, shortId;
try {
({ data: {longChannelClaimId: longId, shortChannelClaimId: shortId} } = yield call(getChannelData, channelName, channelId));
@ -29,11 +27,9 @@ export function * newChannelRequest (action) {
// is this an existing channel?
// If this channel is in the channel list, it's already been fetched
if (state.channelList[channelKey]) {
console.log('that channel already exists in the channel list!');
return null;
}
// get channel claims data
console.log('getting channel claims data');
let claimsData;
try {
({ data: claimsData } = yield call(getChannelClaims, channelName, longId, 1));

View file

@ -6,7 +6,6 @@ import { newChannelRequest } from 'sagas/show_channel';
import lbryUri from 'utils/lbryUri';
function * parseAndUpdateIdentifierAndClaim (modifier, claim) {
console.log('parseAndUpdateIdentifierAndClaim');
// this is a request for an asset
// claim will be an asset claim
// the identifier could be a channel or a claim id
@ -24,7 +23,6 @@ function * parseAndUpdateIdentifierAndClaim (modifier, claim) {
yield call(newAssetRequest, onNewAssetRequest(claimName, claimId, null, null, extension));
}
function * parseAndUpdateClaimOnly (claim) {
console.log('parseAndUpdateIdentifierAndClaim');
// this could be a request for an asset or a channel page
// claim could be an asset claim or a channel claim
let isChannel, channelName, channelClaimId;
@ -49,7 +47,6 @@ function * parseAndUpdateClaimOnly (claim) {
}
export function * handleShowPageUri (action) {
console.log('handleShowPageUri');
const { identifier, claim } = action.data;
if (identifier) {
return yield call(parseAndUpdateIdentifierAndClaim, identifier, claim);

View file

@ -0,0 +1,3 @@
export const selectChannelState = (state) => {
return state.channel;
};

View file

@ -0,0 +1,3 @@
export const selectPublishState = (state) => {
return state.publish;
};

3
react/selectors/site.js Normal file
View file

@ -0,0 +1,3 @@
export const selectSiteState = (state) => {
return state.site;
};

View file

@ -1,11 +1,9 @@
module.exports = {
validateFile (file) {
if (!file) {
console.log('no file found');
throw new Error('no file provided');
}
if (/'/.test(file.name)) {
console.log('file name had apostrophe in it');
throw new Error('apostrophes are not allowed in the file name');
}
// validate size and type
@ -14,24 +12,20 @@ module.exports = {
case 'image/jpg':
case 'image/png':
if (file.size > 10000000) {
console.log('file was too big');
throw new Error('Sorry, images are limited to 10 megabytes.');
}
break;
case 'image/gif':
if (file.size > 50000000) {
console.log('file was too big');
throw new Error('Sorry, GIFs are limited to 50 megabytes.');
if (file.size > 30000000) {
throw new Error('Sorry, GIFs are limited to 30 megabytes.');
}
break;
case 'video/mp4':
if (file.size > 50000000) {
console.log('file was too big');
throw new Error('Sorry, videos are limited to 50 megabytes.');
if (file.size > 20000000) {
throw new Error('Sorry, videos are limited to 20 megabytes.');
}
break;
default:
console.log('file type is not supported');
throw new Error(file.type + ' is not a supported file type. Only, .jpeg, .png, .gif, and .mp4 files are currently supported.');
}
},

35
react/utils/publish.js Normal file
View file

@ -0,0 +1,35 @@
export const createPublishMetadata = (claim, { type }, { title, description, license, nsfw }, publishInChannel, selectedChannel) => {
let metadata = {
name: claim,
title,
description,
license,
nsfw,
type,
};
if (publishInChannel) {
metadata['channelName'] = selectedChannel;
}
return metadata;
};
export const createPublishFormData = (file, thumbnail, metadata) => {
let fd = new FormData();
// append file
fd.append('file', file);
// append thumbnail
if (thumbnail) {
fd.append('thumbnail', thumbnail);
}
// append metadata
for (let key in metadata) {
if (metadata.hasOwnProperty(key)) {
fd.append(key, metadata[key]);
}
}
return fd;
};
export const createThumbnailUrl = (channel, channelId, claim, host) => {
return `${host}/${channel}:${channelId}/${claim}-thumb.png`;
};

17
react/utils/validate.js Normal file
View file

@ -0,0 +1,17 @@
export const validateChannelSelection = (publishInChannel, selectedChannel, loggedInChannel) => {
if (publishInChannel && (selectedChannel !== loggedInChannel.name)) {
throw new Error('Log in to a channel or select Anonymous');
}
};
export const validatePublishParams = (file, claim, urlError) => {
if (!file) {
throw new Error('Please choose a file');
}
if (!claim) {
throw new Error('Please enter a URL');
}
if (urlError) {
throw new Error('Fix the url');
}
};

View file

@ -5,7 +5,7 @@ const multipartMiddleware = multipart({uploadDir: files.uploadDirectory});
const db = require('../models');
const { claimNameIsAvailable, checkChannelAvailability, publish } = require('../controllers/publishController.js');
const { getClaimList, resolveUri, getClaim } = require('../helpers/lbryApi.js');
const { createBasicPublishParams, parsePublishApiRequestBody, parsePublishApiRequestFiles, addGetResultsToFileData, createFileData } = require('../helpers/publishHelpers.js');
const { addGetResultsToFileData, createBasicPublishParams, createThumbnailPublishParams, parsePublishApiRequestBody, parsePublishApiRequestFiles, createFileData } = require('../helpers/publishHelpers.js');
const errorHandlers = require('../helpers/errorHandlers.js');
const { sendGAAnonymousPublishTiming, sendGAChannelPublishTiming } = require('../helpers/googleAnalytics.js');
const { authenticateUser } = require('../auth/authentication.js');
@ -128,17 +128,17 @@ module.exports = (app) => {
});
// route to run a publish request on the daemon
app.post('/api/claim/publish', multipartMiddleware, ({ body, files, headers, ip, originalUrl, user }, res) => {
logger.debug('api/claim-publish body:', body);
logger.debug('api/claim-publish files:', files);
logger.debug('api/claim/publish req.body:', body);
logger.debug('api/claim/publish req.files:', files);
// define variables
let name, fileName, filePath, fileType, nsfw, license, title, description, thumbnail, channelName, channelId, channelPassword;
let name, fileName, filePath, fileType, thumbnailFileName, thumbnailFilePath, thumbnailFileType, nsfw, license, title, description, thumbnail, channelName, channelId, channelPassword;
// record the start time of the request
const publishStartTime = Date.now();
// validate the body and files of the request
try {
// validateApiPublishRequest(body, files);
({name, nsfw, license, title, description, thumbnail} = parsePublishApiRequestBody(body));
({fileName, filePath, fileType} = parsePublishApiRequestFiles(files));
({fileName, filePath, fileType, thumbnailFileName, thumbnailFilePath, thumbnailFileType} = parsePublishApiRequestFiles(files));
({channelName, channelId, channelPassword} = body);
} catch (error) {
return res.status(400).json({success: false, message: error.message});
@ -148,13 +148,18 @@ module.exports = (app) => {
authenticateUser(channelName, channelId, channelPassword, user),
claimNameIsAvailable(name),
createBasicPublishParams(filePath, name, title, description, license, nsfw, thumbnail),
createThumbnailPublishParams(thumbnailFilePath, name, license, nsfw),
])
.then(([{channelName, channelClaimId}, validatedClaimName, publishParams]) => {
.then(([{channelName, channelClaimId}, validatedClaimName, publishParams, thumbnailPublishParams]) => {
// add channel details to the publish params
if (channelName && channelClaimId) {
publishParams['channel_name'] = channelName;
publishParams['channel_id'] = channelClaimId;
}
// publish the thumbnail
if (thumbnailPublishParams) {
publish(thumbnailPublishParams, thumbnailFileName, thumbnailFileType);
}
// publish the asset
return publish(publishParams, fileName, fileType);
})