diff --git a/.gitignore b/.gitignore index 580f3863..31b4a8c5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ node_modules npm-debug.log build/app.js + +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md index 21c85c0c..c4391d52 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,7 @@ Install some nice extensions for Chrom(e|ium): git clone git@bitbucket.org:ascribe/onion.git cd onion npm install -npm run watch - -python -mSimpleHTTPServer +gulp serve ``` diff --git a/css/ascribe-fonts/style.css b/css/ascribe-fonts/style.css index c4ecbdc8..eaab4bca 100644 --- a/css/ascribe-fonts/style.css +++ b/css/ascribe-fonts/style.css @@ -1,10 +1,10 @@ @font-face { font-family: 'ascribe'; - src:url('fonts/ascribe.eot?-6bb2dq'); - src:url('fonts/ascribe.eot?#iefix-6bb2dq') format('embedded-opentype'), - url('fonts/ascribe.woff?-6bb2dq') format('woff'), - url('fonts/ascribe.ttf?-6bb2dq') format('truetype'), - url('fonts/ascribe.svg?-6bb2dq#ascribe') format('svg'); + src:url('ascribe.eot?-6bb2dq'); + src:url('ascribe.eot?#iefix-6bb2dq') format('embedded-opentype'), + url('ascribe.woff?-6bb2dq') format('woff'), + url('ascribe.ttf?-6bb2dq') format('truetype'), + url('ascribe.svg?-6bb2dq#ascribe') format('svg'); font-weight: normal; font-style: normal; } @@ -187,3 +187,7 @@ content: "\eae9"; } +.btn-glyph-ascribe{ + font-size: 18px; + padding: 4px 12px 0 10px +} \ No newline at end of file diff --git a/css/main.css b/css/main.css index cac29beb..cbd7f7ec 100644 --- a/css/main.css +++ b/css/main.css @@ -12,15 +12,17 @@ .ascribe-table-header-row { border-bottom: 2px solid rgba(2, 182, 163, 0.5); border-top: 2px solid rgba(2, 182, 163, 0.5); + padding: 0; } - + .ascribe-table-header-column { display: table; - height:4em; + height:3em; + padding: 0; } .ascribe-table-header-column > span { - display:table-cell; + display: table-cell; vertical-align: middle; font-family: 'Source Sans Pro'; font-weight: 600; @@ -31,10 +33,10 @@ .ascribe-table-header-column > span > .glyphicon { font-size: .5em; } - +/* .ascribe-table-item:nth-child(even) { background-color: #F5F5F5; -} +}*/ /*.ascribe-table-item:hover { background-color: #EEEEEE; @@ -44,15 +46,168 @@ display: table; font-family: 'Source Sans Pro'; font-size: 1.2em; - height:4em; + height:3em; } .ascribe-table-item-column > * { - display:table-cell; + display: table-cell; vertical-align: middle; } -.btn-ascribe, .btn-ascribe:hover, .btn-ascribe:active, .btn-ascribe:focus { +.ascribe-table-item-selected { background-color: rgba(2, 182, 163, 0.5); - border-color: rgba(2, 182, 163, 0.5); +} + +.ascribe-table-item-selectable { + cursor: default; +} + +.piece-list-toolbar { + height:3em; +} + +.no-margin { + margin-right: 0; + margin-left: 0; +} + +.btn-ascribe, .btn-ascribe-inv { + border: 1px solid #444; + line-height: 2em; + margin-right: 1px; + margin-left: 0 !important; + font-family: sans-serif !important; + border-radius: 0 !important; + +} + +.btn-ascribe, .btn-ascribe-inv:active, .btn-ascribe-inv:hover { + color: #222 !important; + background-color: #FFF; +} + +.btn-ascribe:active, .btn-ascribe:hover, .btn-ascribe-inv { + color: #FFF !important; + background-color: #444; +} + +.btn-ascribe-inv:disabled, .btn-ascribe-inv:focus { + color: #444 !important; + background-color: #BBB !important; + border: 1px solid #444 !important; +} + +.btn-ascribe-sm { + font-size: 12px; + line-height: 1.3em; +} + +.btn-ascribe-green, .btn-ascribe-green-inv { + border: 1px solid #48DACB; + line-height: 2em; + margin-left: 0 !important; + font-family: sans-serif !important; + border-radius: 0 !important; + +} + +.btn-ascribe-green, .btn-ascribe-green-inv:active, .btn-ascribe-green-inv:hover { + background-color: #FFF; + border: 1px solid rgba(2, 182, 163, 0.5); + color: rgba(2, 182, 163, 0.5); +} + +.btn-ascribe-green:active, .btn-ascribe-green:hover, .btn-ascribe-green-inv { + border: 1px solid rgba(2, 182, 163, 0.5); + color: white; + background-color: rgba(2, 182, 163, 0.5); +} + +.ascribe-detail-header { + margin-top: 2em; +} + + +.ascribe-detail-title { + font-size: 2em; + margin-bottom: -0.2em; +} + +.ascribe-detail-property { + padding-bottom: 0.4em; +} +.ascribe-detail-property > .row-same-height > .col-xs-2 { + text-transform: uppercase; +} + +.input-text-ascribe { + border-bottom: 1px solid black; + border-top: 0; + border-left: 0; + border-right: 0; + background: transparent; + border-radius: 0 !important; + box-shadow: none; +} + +.textarea-ascribe-message { + height: 13em !important; +} + +/* columns of same height styles */ +/* http://www.minimit.com/articles/solutions-tutorials/bootstrap-3-responsive-columns-of-same-height */ +.row-full-height { + height: 100%; +} + +.col-full-height { + height: 100%; + vertical-align: middle; +} + +.row-same-height { + display: table; + width: 100%; + /* fix overflow */ + table-layout: fixed; +} + +.col-xs-height { + display: table-cell; + float: none !important; +} + +@media (min-width: 768px) { + .col-sm-height { + display: table-cell; + float: none !important; + } +} + +@media (min-width: 992px) { + .col-md-height { + display: table-cell; + float: none !important; + } +} + +@media (min-width: 1200px) { + .col-lg-height { + display: table-cell; + float: none !important; + } +} + +/* vertical alignment styles */ + +.col-top { + vertical-align: top; +} + +.col-middle { + vertical-align: middle; +} + +.col-bottom { + vertical-align: bottom; } \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 00000000..3b42e4de --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,67 @@ +var gulp = require('gulp'); +var gulpif = require('gulp-if'); +var sourcemaps = require('gulp-sourcemaps'); +var util = require('gulp-util'); +var source = require('vinyl-source-stream'); +var buffer = require('vinyl-buffer'); +var watchify = require('watchify'); +var browserify = require('browserify'); +var browserSync = require('browser-sync'); +var babelify = require('babelify'); +var notify = require('gulp-notify'); +var _ = require('lodash'); + +gulp.task('build', function() { + bundle(false); +}); + +gulp.task('serve', ['browser-sync'], function() { + bundle(true); +}); + +gulp.task('browser-sync', function() { + browserSync({ + server: { + baseDir: "." + }, + port: process.env.PORT || 3000 + }); +}); + +function bundle(watch) { + var bro; + + if (watch) { + bro = watchify(browserify('./js/app.js', + // Assigning debug to have sourcemaps + _.assign(watchify.args, { + debug: true + }))); + bro.on('update', function() { + rebundle(bro, true); + }); + } else { + bro = browserify('./js/app.js', { + debug: true + }); + } + + bro.transform(babelify.configure({ + compact: false + })); + + function rebundle(bundler, watch) { + return bundler.bundle() + .on('error', notify.onError('Error: <%= error.message %>')) + .pipe(source('app.js')) + .pipe(buffer()) + .pipe(sourcemaps.init({ + loadMaps: true + })) // loads map from browserify file + .pipe(sourcemaps.write()) // writes .map file + .pipe(gulp.dest('./build')) + .pipe(browserSync.stream()); + } + + return rebundle(bro); +} \ No newline at end of file diff --git a/index.html b/index.html index 824eeaee..21dac715 100644 --- a/index.html +++ b/index.html @@ -5,13 +5,15 @@ ascribe - - - + + + +
+ diff --git a/js/actions/edition_actions.js b/js/actions/edition_actions.js new file mode 100644 index 00000000..a39c330a --- /dev/null +++ b/js/actions/edition_actions.js @@ -0,0 +1,23 @@ +import alt from '../alt'; +import EditionFetcher from '../fetchers/edition_fetcher'; + + +class EditionActions { + constructor() { + this.generateActions( + 'updateEdition' + ); + } + + fetchOne(editionId) { + EditionFetcher.fetchOne(editionId) + .then((res) => { + this.actions.updateEdition(res.edition); + }) + .catch((err) => { + console.log(err); + }); + } +} + +export default alt.createActions(EditionActions); diff --git a/js/actions/edition_list_actions.js b/js/actions/edition_list_actions.js index 055ef2a5..48e02d2d 100644 --- a/js/actions/edition_list_actions.js +++ b/js/actions/edition_list_actions.js @@ -5,7 +5,8 @@ import EditionListFetcher from '../fetchers/edition_list_fetcher.js'; class EditionListActions { constructor() { this.generateActions( - 'updateEditionList' + 'updateEditionList', + 'selectEdition' ); } @@ -14,7 +15,7 @@ class EditionListActions { .fetch(pieceId) .then((res) => { this.actions.updateEditionList({ - 'editionList': res.editions, + 'editionListOfPiece': res.editions, pieceId }); }) @@ -22,6 +23,6 @@ class EditionListActions { console.log(err); }); } -}; +} export default alt.createActions(EditionListActions); \ No newline at end of file diff --git a/js/actions/piece_actions.js b/js/actions/piece_actions.js new file mode 100644 index 00000000..d657539b --- /dev/null +++ b/js/actions/piece_actions.js @@ -0,0 +1,23 @@ +import alt from '../alt'; +import PieceFetcher from '../fetchers/piece_fetcher'; + + +class PieceActions { + constructor() { + this.generateActions( + 'updatePiece' + ); + } + + fetchOne(pieceId) { + PieceFetcher.fetchOne(pieceId) + .then((res) => { + this.actions.updatePiece(res.piece); + }) + .catch((err) => { + console.log(err); + }); + } +} + +export default alt.createActions(PieceActions); diff --git a/js/components/acl_button.js b/js/components/acl_button.js new file mode 100644 index 00000000..9d52a09a --- /dev/null +++ b/js/components/acl_button.js @@ -0,0 +1,32 @@ +import React from 'react'; + +import AppConstants from '../constants/application_constants'; + +let AclButton = React.createClass({ + propTypes: { + action: React.PropTypes.oneOf(AppConstants.aclList).isRequired, + availableAcls: React.PropTypes.array.isRequired + }, + + render() { + let shouldDisplay = this.props.availableAcls.indexOf(this.props.action) > -1; + let styles = {}; + + if(shouldDisplay) { + styles.display = 'inline-block'; + } else { + styles.display = 'none'; + } + + return ( + + ); + } +}); + +export default AclButton; \ No newline at end of file diff --git a/js/components/ascribe_forms/alert.js b/js/components/ascribe_forms/alert.js new file mode 100644 index 00000000..72ced310 --- /dev/null +++ b/js/components/ascribe_forms/alert.js @@ -0,0 +1,31 @@ +import React from 'react'; +import Alert from 'react-bootstrap/lib/Alert'; + +let AlertDismissable = React.createClass({ + getInitialState() { + return { + alertVisible: true + }; + }, + show() { + this.setState({alertVisible: true}); + }, + hide() { + this.setState({alertVisible: false}); + }, + render() { + if (this.state.alertVisible) { + let key = this.props.error; + return ( + + {this.props.error} + + ); + } + return ( + + ); + } +}); + +export default AlertDismissable; \ No newline at end of file diff --git a/js/components/ascribe_forms/button_submit_close.js b/js/components/ascribe_forms/button_submit_close.js new file mode 100644 index 00000000..60091f2f --- /dev/null +++ b/js/components/ascribe_forms/button_submit_close.js @@ -0,0 +1,21 @@ +import React from 'react'; + +let ButtonSubmitOrClose = React.createClass({ + render() { + if (this.props.submitted){ + return ( +
+ Loading +
+ ) + } + return ( +
+ + +
+ ) + } +}); + +export default ButtonSubmitOrClose; diff --git a/js/components/ascribe_forms/form_share_email.js b/js/components/ascribe_forms/form_share_email.js new file mode 100644 index 00000000..dd61bc7b --- /dev/null +++ b/js/components/ascribe_forms/form_share_email.js @@ -0,0 +1,53 @@ +import fetch from 'isomorphic-fetch'; + +import React from 'react'; + +import ApiUrls from '../../constants/api_urls'; +import FormMixin from '../../mixins/form_mixin'; +import InputText from './input_text'; +import InputTextArea from './input_textarea'; +import ButtonSubmitOrClose from './button_submit_close'; + +let ShareForm = React.createClass({ + mixins: [FormMixin], + + url() { + return ApiUrls.ownership_shares_mail + }, + getFormData() { + return { + bitcoin_id: this.props.edition.bitcoin_id, + share_emails: this.refs.share_emails.state.value, + share_message: this.refs.share_message.state.value + } + }, + renderForm() { + let message = "Hi,\n" + + "\n" + + "I am sharing \"" + this.props.edition.title + "\" with you.\n" + + "\n" + + "Truly yours,\n" + + this.props.currentUser.username; + return ( +
+ + + + + ); + } +}); + +export default ShareForm; \ No newline at end of file diff --git a/js/components/ascribe_forms/form_transfer.js b/js/components/ascribe_forms/form_transfer.js new file mode 100644 index 00000000..28e86db3 --- /dev/null +++ b/js/components/ascribe_forms/form_transfer.js @@ -0,0 +1,68 @@ +import fetch from 'isomorphic-fetch'; + +import React from 'react'; + +import ApiUrls from '../../constants/api_urls'; +import FormMixin from '../../mixins/form_mixin'; +import InputText from './input_text'; +import InputTextArea from './input_textarea'; +import ButtonSubmitOrClose from './button_submit_close'; + + + +let TransferForm = React.createClass({ + mixins: [FormMixin], + + url() { + return ApiUrls.ownership_transfers + }, + getFormData() { + return { + bitcoin_id: this.props.edition.bitcoin_id, + transferee: this.refs.transferee.state.value, + transfer_message: this.refs.transfer_message.state.value, + password: this.refs.password.state.value + } + }, + renderForm() { + let message = "Hi,\n" + + "\n" + + "I transfer ownership of \"" + this.props.edition.title + "\" to you.\n" + + "\n" + + "Truly yours,\n" + + this.props.currentUser.username; + return ( +
+ + + + + +
+ Make sure that display instructions and technology details are correct. + They cannot be edited after the transfer. +
+ + + ); + } +}); + +export default TransferForm; \ No newline at end of file diff --git a/js/components/ascribe_forms/input_text.js b/js/components/ascribe_forms/input_text.js new file mode 100644 index 00000000..a65bcc8f --- /dev/null +++ b/js/components/ascribe_forms/input_text.js @@ -0,0 +1,35 @@ +import React from 'react'; + +import AlertMixin from '../../mixins/alert_mixin' + +let InputText = React.createClass({ + + mixins : [AlertMixin], + + getInitialState() { + return {value: null, + alerts: null, // needed in AlertMixin + retry: 0 // needed in AlertMixin for generating unique alerts + }; + }, + handleChange(event) { + this.setState({value: event.target.value}); + }, + render() { + let className = "form-control input-text-ascribe"; + let alerts = (this.props.submitted) ? null : this.state.alerts; + return ( +
+ {alerts} + +
+ ); + + } +}); + +export default InputText; \ No newline at end of file diff --git a/js/components/ascribe_forms/input_textarea.js b/js/components/ascribe_forms/input_textarea.js new file mode 100644 index 00000000..b5f64b66 --- /dev/null +++ b/js/components/ascribe_forms/input_textarea.js @@ -0,0 +1,35 @@ +import React from 'react'; + +import AlertMixin from '../../mixins/alert_mixin' + +let InputTextArea = React.createClass({ + + mixins : [AlertMixin], + + getInitialState() { + return {value: this.props.defaultValue, + alerts: null, // needed in AlertMixin + retry: 0 // needed in AlertMixin for generating unique alerts + }; + }, + handleChange(event) { + this.setState({value: event.target.value}); + }, + render() { + let className = "form-control input-text-ascribe textarea-ascribe-message"; + + let alerts = (this.props.submitted) ? null : this.state.alerts; + return ( +
+ {alerts} + +
+ ); + + } +}); + +export default InputTextArea; \ No newline at end of file diff --git a/js/components/ascribe_media/image_viewer.js b/js/components/ascribe_media/image_viewer.js new file mode 100644 index 00000000..fdbd0342 --- /dev/null +++ b/js/components/ascribe_media/image_viewer.js @@ -0,0 +1,25 @@ +import React from 'react'; + +/** + * This is the component that implements display-specific functionality + */ +let ImageViewer = React.createClass({ + propTypes: { + thumbnail: React.PropTypes.string.isRequired + }, + + render() { + let thumbnail = ; + let aligner = ; + return ( +
+
+ {aligner} + {thumbnail} +
+
+ ); + } +}); + +export default ImageViewer; \ No newline at end of file diff --git a/js/components/ascribe_modal/modal_share.js b/js/components/ascribe_modal/modal_share.js new file mode 100644 index 00000000..176541ee --- /dev/null +++ b/js/components/ascribe_modal/modal_share.js @@ -0,0 +1,46 @@ +import React from 'react'; +import Modal from 'react-bootstrap/lib/Modal'; +import OverlayTrigger from 'react-bootstrap/lib/OverlayTrigger'; +import ModalTrigger from 'react-bootstrap/lib/ModalTrigger'; +import Tooltip from 'react-bootstrap/lib/Tooltip'; + + + +import ShareForm from '../ascribe_forms/form_share_email' + +let ShareModalButton = React.createClass({ + render() { + return ( + Share the artwork}> + }> +
+ +
+
+
+ ) + } +}); + +let ShareModal = React.createClass({ + onRequestHide(e){ + if (e) + e.preventDefault(); + this.props.onRequestHide(); + }, + render() { + return ( + +
+ +
+
+ ) + } +}); + + +export default ShareModalButton; diff --git a/js/components/ascribe_modal/modal_transfer.js b/js/components/ascribe_modal/modal_transfer.js new file mode 100644 index 00000000..b57064ff --- /dev/null +++ b/js/components/ascribe_modal/modal_transfer.js @@ -0,0 +1,45 @@ +import React from 'react'; +import Modal from 'react-bootstrap/lib/Modal'; +import OverlayTrigger from 'react-bootstrap/lib/OverlayTrigger'; +import ModalTrigger from 'react-bootstrap/lib/ModalTrigger'; +import Tooltip from 'react-bootstrap/lib/Tooltip'; + +import TransferForm from '../ascribe_forms/form_transfer' + + +let TransferModalButton = React.createClass({ + render() { + return ( + Transfer the ownership of the artwork}> + }> +
+ TRANSFER +
+
+
+ ) + } +}); + +let TransferModal = React.createClass({ + onRequestHide(e){ + e.preventDefault(); + this.props.onRequestHide(); + }, + render() { + return ( + +
+ +
+
+ ) + } +}); + + +export default TransferModalButton; diff --git a/js/components/ascribe_piece_list_toolbar/piece_list_toolbar.js b/js/components/ascribe_piece_list_toolbar/piece_list_toolbar.js new file mode 100644 index 00000000..1ce4d744 --- /dev/null +++ b/js/components/ascribe_piece_list_toolbar/piece_list_toolbar.js @@ -0,0 +1,82 @@ +import React from 'react'; + +import EditionListStore from '../../stores/edition_list_store'; + +import AclButton from '../acl_button'; + +let PieceListToolbar = React.createClass({ + getInitialState() { + return EditionListStore.getState(); + }, + + onChange(state) { + this.setState(state); + }, + + componentDidMount() { + EditionListStore.listen(this.onChange) + }, + + componentDidUnmount() { + EditionListStore.unlisten(this.onChange) + }, + + filterForSelected(edition) { + return edition.selected; + }, + + fetchSelectedEditionList() { + let selectedEditionList = []; + + Object + .keys(this.state.editionList) + .forEach((key) => { + let filteredEditionsForPiece = this.state.editionList[key].filter(this.filterForSelected); + selectedEditionList = selectedEditionList.concat(filteredEditionsForPiece); + }); + + return selectedEditionList; + }, + + intersectAcls(a, b) { + return a.filter((val) => b.indexOf(val) > -1); + }, + + getAvailableAcls() { + let availableAcls = []; + let selectedEditionList = this.fetchSelectedEditionList(); + + // If no edition has been selected, availableActions is empty + // If only one edition has been selected, their actions are available + // If more than one editions have been selected, their acl properties are intersected + if(selectedEditionList.length >= 1) { + availableAcls = selectedEditionList[0].acl; + } + if(selectedEditionList.length >= 2) { + for(let i = 1; i < selectedEditionList.length; i++) { + availableAcls = this.intersectAcls(availableAcls, selectedEditionList[i].acl); + } + } + + return availableAcls; + }, + + render() { + let availableAcls = this.getAvailableAcls(); + + return ( +
+
+
+ + + + +
+
+
+ ); + } +}); + +export default PieceListToolbar; \ No newline at end of file diff --git a/js/components/ascribe_table/table.js b/js/components/ascribe_table/table.js index c51c4994..06af7b89 100644 --- a/js/components/ascribe_table/table.js +++ b/js/components/ascribe_table/table.js @@ -27,12 +27,13 @@ let Table = React.createClass({ if(this.props.itemList && this.props.itemList.length > 0) { return (
- + {this.renderChildren()}
); diff --git a/js/components/ascribe_table/table_header.js b/js/components/ascribe_table/table_header.js index 564f7725..8176ad31 100644 --- a/js/components/ascribe_table/table_header.js +++ b/js/components/ascribe_table/table_header.js @@ -12,9 +12,9 @@ let TableHeader = React.createClass({ propTypes: { columnList: React.PropTypes.arrayOf(React.PropTypes.instanceOf(TableColumnContentModel)), itemList: React.PropTypes.array.isRequired, - changeOrder: React.PropTypes.func.isRequired, - orderAsc: React.PropTypes.bool.isRequired, - orderBy: React.PropTypes.string.isRequired + changeOrder: React.PropTypes.func, + orderAsc: React.PropTypes.bool, + orderBy: React.PropTypes.string }, render() { @@ -23,7 +23,7 @@ let TableHeader = React.createClass({
{this.props.columnList.map((val, i) => { - let columnClasses = this.calcColumnClasses(this.props.columnList, i); + let columnClasses = this.calcColumnClasses(this.props.columnList, i, 12); let columnName = this.props.columnList[i].columnName; let canBeOrdered = this.props.columnList[i].canBeOrdered; diff --git a/js/components/ascribe_table/table_header_item.js b/js/components/ascribe_table/table_header_item.js index fef82f34..dce2261e 100644 --- a/js/components/ascribe_table/table_header_item.js +++ b/js/components/ascribe_table/table_header_item.js @@ -8,10 +8,10 @@ let TableHeaderItem = React.createClass({ columnClasses: React.PropTypes.string.isRequired, displayName: React.PropTypes.string.isRequired, columnName: React.PropTypes.string.isRequired, - canBeOrdered: React.PropTypes.bool.isRequired, - changeOrder: React.PropTypes.func.isRequired, - orderAsc: React.PropTypes.bool.isRequired, - orderBy: React.PropTypes.string.isRequired + canBeOrdered: React.PropTypes.bool, + changeOrder: React.PropTypes.func, + orderAsc: React.PropTypes.bool, + orderBy: React.PropTypes.string }, changeOrder() { @@ -19,7 +19,7 @@ let TableHeaderItem = React.createClass({ }, render() { - if(this.props.canBeOrdered) { + if(this.props.canBeOrdered && this.props.changeOrder && this.props.orderAsc != null && this.props.orderBy) { if(this.props.columnName === this.props.orderBy) { return (
{ - return this.props.columnList.map((column, i) => { - - let TypeElement = column.displayType; - let columnClass = this.calcColumnClasses(this.props.columnList, i); - - return ( -
- -
- ); - - }); - }; - return ( -
+
- {calcColumnElementContent()} + +
); diff --git a/js/components/ascribe_table/table_item_acl.js b/js/components/ascribe_table/table_item_acl.js new file mode 100644 index 00000000..87e14761 --- /dev/null +++ b/js/components/ascribe_table/table_item_acl.js @@ -0,0 +1,18 @@ +import React from 'react'; + + +let TableItemAcl = React.createClass({ + propTypes: { + content: React.PropTypes.array.isRequired + }, + + render() { + return ( + + {this.props.content.join('/')} + + ); + } +}); + +export default TableItemAcl; diff --git a/js/components/ascribe_table/table_item_selectable.js b/js/components/ascribe_table/table_item_selectable.js new file mode 100644 index 00000000..d62f8d04 --- /dev/null +++ b/js/components/ascribe_table/table_item_selectable.js @@ -0,0 +1,38 @@ +import React from 'react'; +import classNames from 'classnames'; + +import TableColumnContentModel from '../../models/table_column_content_model'; + +import TableItem from './table_item'; + +// This component is implemented as recommended here: http://stackoverflow.com/a/25723635/1263876 +let TableItemSelectable = React.createClass({ + + propTypes: { + columnList: React.PropTypes.arrayOf(React.PropTypes.instanceOf(TableColumnContentModel)), + columnContent: React.PropTypes.object, + parentId: React.PropTypes.number, + className: React.PropTypes.string + }, + + selectItem() { + this.props.selectItem(this.props.parentId, this.props.columnContent.edition_number); + }, + + render() { + let tableItemClasses = classNames({ + 'ascribe-table-item-selected': this.props.columnContent.selected + }); + + return ( + + + ); + } +}); + +export default TableItemSelectable; diff --git a/js/components/ascribe_table/table_item_subtable.js b/js/components/ascribe_table/table_item_subtable.js index e9bd05f2..0e055991 100644 --- a/js/components/ascribe_table/table_item_subtable.js +++ b/js/components/ascribe_table/table_item_subtable.js @@ -5,12 +5,12 @@ import TableColumnContentModel from '../../models/table_column_content_model'; import EditionListStore from '../../stores/edition_list_store'; import EditionListActions from '../../actions/edition_list_actions'; -// ToDo: Create Table-specific Utils to not lock it to projects utilities -import GeneralUtils from '../../utils/general_utils'; import Table from './table'; -import TableItem from './table_item'; +import TableItemWrapper from './table_item_wrapper'; import TableItemText from './table_item_text'; +import TableItemAcl from './table_item_acl'; +import TableItemSelectable from './table_item_selectable'; import TableItemSubtableButton from './table_item_subtable_button'; @@ -40,6 +40,7 @@ let TableItemSubtable = React.createClass({ 'open': false }); } else { + EditionListActions.fetchEditionList(this.props.columnContent.id); this.setState({ 'open': true, @@ -48,43 +49,21 @@ let TableItemSubtable = React.createClass({ } }, - calcColumnClasses(list, i) { - let bootstrapClasses = ['col-xs-', 'col-sm-', 'col-md-', 'col-lg-']; - - let listOfRowValues = list.map((column) => column.rowWidth ); - let numOfColumns = GeneralUtils.sumNumList(listOfRowValues); - - if(numOfColumns > 10) { - throw new Error('Bootstrap has only 12 columns to assign. You defined ' + numOfColumns + '. Change this in the columnMap you\'re passing to the table.') - } else { - return bootstrapClasses.join( listOfRowValues[i] + ' ') + listOfRowValues[i]; - } + selectItem(parentId, itemId) { + EditionListActions.selectEdition({ + 'pieceId': parentId, + 'editionId': itemId + }); }, render() { - let calcColumnElementContent = () => { - return this.props.columnList.map((column, i) => { - - let TypeElement = column.displayType; - let columnClass = this.calcColumnClasses(this.props.columnList, i); - - return ( -
- -
- ); - - }); - }; - - let renderEditionListTable = () => { let columnList = [ new TableColumnContentModel('edition_number', 'Edition Number', TableItemText, 2, false), new TableColumnContentModel('user_registered', 'User', TableItemText, 4, true), - new TableColumnContentModel('bitcoin_id', 'Bitcoin Address', TableItemText, 4, true) + new TableColumnContentModel('acl', 'Actions', TableItemAcl, 4, true) ]; if(this.state.open && this.state.editionList[this.props.columnContent.id] && this.state.editionList[this.props.columnContent.id].length) { @@ -94,9 +73,12 @@ let TableItemSubtable = React.createClass({ {this.state.editionList[this.props.columnContent.id].map((edition, i) => { return ( - - + ); })}
@@ -109,10 +91,14 @@ let TableItemSubtable = React.createClass({ return (
- {calcColumnElementContent()} -
- + + +
+ +
{renderEditionListTable()} diff --git a/js/components/ascribe_table/table_item_subtable_button.js b/js/components/ascribe_table/table_item_subtable_button.js index 7638dc13..8c5431d8 100644 --- a/js/components/ascribe_table/table_item_subtable_button.js +++ b/js/components/ascribe_table/table_item_subtable_button.js @@ -10,7 +10,7 @@ let TableItemSubtableButton = React.createClass({ render() { return ( - diff --git a/js/components/ascribe_table/table_item_wrapper.js b/js/components/ascribe_table/table_item_wrapper.js new file mode 100644 index 00000000..2f9f4c7e --- /dev/null +++ b/js/components/ascribe_table/table_item_wrapper.js @@ -0,0 +1,34 @@ +import React from 'react'; + +import TableColumnContentModel from '../../models/table_column_content_model'; +import TableColumnMixin from '../../mixins/table_column_mixin'; + +let TableItemWrapper = React.createClass({ + mixins: [TableColumnMixin], + propTypes: { + columnList: React.PropTypes.arrayOf(React.PropTypes.instanceOf(TableColumnContentModel)), + columnContent: React.PropTypes.object, + columnWidth: React.PropTypes.number.isRequired + }, + + render() { + return ( +
+ {this.props.columnList.map((column, i) => { + + let TypeElement = column.displayType; + let columnClass = this.calcColumnClasses(this.props.columnList, i, this.props.columnWidth); + + return ( +
+ +
+ ); + + })} +
+ ); + } +}); + +export default TableItemWrapper; \ No newline at end of file diff --git a/js/components/edition.js b/js/components/edition.js new file mode 100644 index 00000000..ffeb9b25 --- /dev/null +++ b/js/components/edition.js @@ -0,0 +1,78 @@ +import React from 'react'; + +import ImageViewer from './ascribe_media/image_viewer'; +import TransferModalButton from './ascribe_modal/modal_transfer'; +import ShareModalButton from './ascribe_modal/modal_share'; + +/** + * This is the component that implements display-specific functionality + */ +let Edition = React.createClass({ + render() { + return ( +
+
+ +
+
+ + +
+ +
+ ); + } +}); + +let EditionHeader = React.createClass({ + render() { + var title_html =
{this.props.edition.title}
; + return ( +
+ + + +
+
+ ); + } +}); + +let EditionDetails = React.createClass({ + render() { + return ( +
+ + + +
+ + +
+
+ ); + + } +}); + +let EditionDetailProperty = React.createClass({ + render() { + return ( +
+
+
+
{ this.props.label }:
+
+
+
{ this.props.value }
+
+
+
+ ); + } +}); + + +export default Edition; + diff --git a/js/components/edition_container.js b/js/components/edition_container.js new file mode 100644 index 00000000..77d28962 --- /dev/null +++ b/js/components/edition_container.js @@ -0,0 +1,52 @@ +import React from 'react'; + +import EditionActions from '../actions/edition_actions'; +import EditionStore from '../stores/edition_store'; +import UserActions from '../actions/user_actions'; +import UserStore from '../stores/user_store'; + +import Edition from './edition'; + +/** + * This is the component that implements resource/data specific functionality + */ +let EditionContainer = React.createClass({ + + getInitialState() { + return {'user': UserStore.getState(), + 'edition': EditionStore.getState()} + }, + + onChange(state) { + this.setState(state); + }, + + componentDidMount() { + EditionActions.fetchOne(this.props.params.editionId); + EditionStore.listen(this.onChange); + UserActions.fetchCurrentUser(); + UserStore.listen(this.onChange); + }, + + componentDidUnmount() { + EditionStore.unlisten(this.onChange); + UserStore.unlisten(this.onChange); + }, + + render() { + + if('title' in this.state.edition) { + return ( + + ); + } else { + return ( +

Loading

+ ); + } + + + } +}); + +export default EditionContainer; diff --git a/js/components/piece.js b/js/components/piece.js deleted file mode 100644 index bd268115..00000000 --- a/js/components/piece.js +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; - - -let Piece = React.createClass({ - render() { - return ( -

this.props.piece.title

- ); - } -}); - -export default Piece; diff --git a/js/components/piece_list.js b/js/components/piece_list.js index 191c0d9d..34a1baf0 100644 --- a/js/components/piece_list.js +++ b/js/components/piece_list.js @@ -13,7 +13,9 @@ import TableItemSubtableButton from './ascribe_table/table_item_subtable_button' import TableColumnContentModel from '../models/table_column_content_model'; -import Pagination from './ascribe_pagination/pagination' +import Pagination from './ascribe_pagination/pagination'; + +import PieceListToolbar from './ascribe_piece_list_toolbar/piece_list_toolbar'; let PieceList = React.createClass({ @@ -60,6 +62,7 @@ let PieceList = React.createClass({ if(this.state.pieceList && this.state.pieceList.length > 0) { return (
+ res.json() + ); + } +}; + +export default EditionFetcher; diff --git a/js/fetchers/piece_fetcher.js b/js/fetchers/piece_fetcher.js new file mode 100644 index 00000000..4aaecda5 --- /dev/null +++ b/js/fetchers/piece_fetcher.js @@ -0,0 +1,22 @@ +import fetch from 'isomorphic-fetch'; + +import AppConstants from '../constants/application_constants'; +import FetchApiUtils from '../utils/fetch_api_utils'; + + +let PieceFetcher = { + /** + * Fetch one user from the API. + * If no arg is supplied, load the current user + * + */ + fetchOne(pieceId) { + return fetch(AppConstants.baseUrl + 'pieces/' + pieceId + '/', { + headers: { + 'Authorization': 'Basic ' + AppConstants.debugCredentialBase64 + } + }).then((res) => res.json()); + } +}; + +export default PieceFetcher; diff --git a/js/mixins/alert_mixin.js b/js/mixins/alert_mixin.js new file mode 100644 index 00000000..efabed0e --- /dev/null +++ b/js/mixins/alert_mixin.js @@ -0,0 +1,16 @@ +import React from 'react'; +import AlertDismissable from '../components/ascribe_forms/alert'; + +let AlertMixin = { + setAlerts(errors){ + let alerts = errors.map( + function(error) { + let key = error + this.state.retry; + return ; + }.bind(this) + ); + this.setState({alerts: alerts, retry: this.state.retry + 1}); + } +}; + +export default AlertMixin; \ No newline at end of file diff --git a/js/mixins/form_mixin.js b/js/mixins/form_mixin.js new file mode 100644 index 00000000..6bbf5efa --- /dev/null +++ b/js/mixins/form_mixin.js @@ -0,0 +1,67 @@ +import React from 'react'; + +import AppConstants from '../constants/application_constants' +import AlertDismissable from '../components/ascribe_forms/alert' + +export const FormMixin = { + getInitialState() { + return { + submitted: false + , status: null + } + }, + submit(e) { + e.preventDefault(); + this.setState({submitted: true}); + fetch(this.url(), { + method: 'post', + headers: { + 'Authorization': 'Basic ' + AppConstants.debugCredentialBase64, + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(this.getFormData()) + }) + .then( + (response) => this.handleResponse(response) + ); + }, + handleResponse(response){ + if (response.status >= 200 && response.status < 300){ + this.props.onRequestHide(); + } + else if (response.status >= 400 && response.status < 500) { + this.handleError(response); + } + else { + this.setState({submitted: false, status: response.status}); + } + }, + handleError(response){ + response.json().then((response) => this.dispatchErrors(response.errors)); + + }, + dispatchErrors(errors){ + for (var input in errors){ + if (this.refs && this.refs[input] && this.refs[input].state){ + this.refs[input].setAlerts(errors[input]); + } + } + this.setState({submitted: false}); + }, + render(){ + let alert = null; + if (this.state.status >= 500){ + alert = ; + } + return ( +
+ {alert} + {this.renderForm()} +
+ ) + } +}; + +export default FormMixin; + diff --git a/js/mixins/table_column_mixin.js b/js/mixins/table_column_mixin.js index 5904389d..d74214e1 100644 --- a/js/mixins/table_column_mixin.js +++ b/js/mixins/table_column_mixin.js @@ -7,14 +7,14 @@ let TableColumnMixin = { * Generates the bootstrap grid column declarations automatically using * the columnMap. */ - calcColumnClasses(list, i) { + calcColumnClasses(list, i, numOfColumns) { let bootstrapClasses = ['col-xs-', 'col-sm-', 'col-md-', 'col-lg-']; let listOfRowValues = list.map((column) => column.rowWidth ); - let numOfColumns = GeneralUtils.sumNumList(listOfRowValues); + let numOfUsedColumns = GeneralUtils.sumNumList(listOfRowValues); - if(numOfColumns > 12) { - throw new Error('Bootstrap has only 12 columns to assign. You defined ' + numOfColumns + '. Change this in the columnMap you\'re passing to the table.') + if(numOfUsedColumns > numOfColumns) { + throw new Error('This table has only ' + numOfColumns + ' columns to assign. You defined ' + numOfUsedColumns + '. Change this in the columnMap you\'re passing to the table.') } else { return bootstrapClasses.join( listOfRowValues[i] + ' ') + listOfRowValues[i]; } diff --git a/js/routes.js b/js/routes.js index b9dd01c8..0fccc60a 100644 --- a/js/routes.js +++ b/js/routes.js @@ -3,7 +3,7 @@ import Router from 'react-router'; import AscribeApp from './components/ascribe_app'; import PieceList from './components/piece_list'; -import Piece from './components/piece'; +import EditionContainer from './components/edition_container'; let Route = Router.Route; @@ -11,7 +11,9 @@ let Route = Router.Route; let routes = ( - + + + ); diff --git a/js/stores/edition_list_store.js b/js/stores/edition_list_store.js index 4158e74c..feb0ba2a 100644 --- a/js/stores/edition_list_store.js +++ b/js/stores/edition_list_store.js @@ -1,3 +1,5 @@ +import React from 'react'; + import alt from '../alt'; import EditionsListActions from '../actions/edition_list_actions'; @@ -7,8 +9,29 @@ class EditionListStore { this.bindActions(EditionsListActions); } - onUpdateEditionList({pieceId, editionList}) { - this.editionList[pieceId] = editionList; + onUpdateEditionList({pieceId, editionListOfPiece}) { + if(this.editionList[pieceId]) { + this.editionList[pieceId].forEach((edition, i) => { + // This uses the index of the new editionList for determining the edition. + // If the list of editions can be sorted in the future, this needs to be changed! + editionListOfPiece[i] = React.addons.update(edition, {$merge: editionListOfPiece[i]}); + }) + } + this.editionList[pieceId] = editionListOfPiece; + } + + onSelectEdition({pieceId, editionId}) { + + this.editionList[pieceId].forEach((edition) => { + if(edition.edition_number === editionId) { + if(edition.selected) { + edition.selected = false; + } else { + edition.selected = true; + } + } + }); + } }; diff --git a/js/stores/edition_store.js b/js/stores/edition_store.js new file mode 100644 index 00000000..9859ec7c --- /dev/null +++ b/js/stores/edition_store.js @@ -0,0 +1,16 @@ +import alt from '../alt'; +import EditionAction from '../actions/edition_actions'; + + +class EditionStore { + constructor() { + this.edition = {}; + this.bindActions(EditionAction); + } + + onUpdateEdition(edition) { + this.edition = edition; + } +} + +export default alt.createStore(EditionStore); diff --git a/js/stores/piece_store.js b/js/stores/piece_store.js new file mode 100644 index 00000000..35e8c229 --- /dev/null +++ b/js/stores/piece_store.js @@ -0,0 +1,16 @@ +import alt from '../alt'; +import PieceAction from '../actions/piece_actions'; + + +class PieceStore { + constructor() { + this.piece = {}; + this.bindActions(PieceAction); + } + + onUpdatePiece(piece) { + this.piece = piece; + } +} + +export default alt.createStore(PieceStore); diff --git a/js/utils/fetch_api_utils.js b/js/utils/fetch_api_utils.js index e8395490..47b52e66 100644 --- a/js/utils/fetch_api_utils.js +++ b/js/utils/fetch_api_utils.js @@ -54,6 +54,13 @@ let FetchApiUtils = { } return interpolation + orderBy; + }, + + status(response) { + if (response.status >= 200 && response.status < 300) { + return response + } + throw new Error(response.json()) } }; diff --git a/js/utils/general_utils.js b/js/utils/general_utils.js index fe92611b..7ab180fa 100644 --- a/js/utils/general_utils.js +++ b/js/utils/general_utils.js @@ -35,6 +35,7 @@ let GeneralUtils = { l.forEach((num) => sum += parseFloat(num) || 0); return sum; } + }; export default GeneralUtils; diff --git a/package.json b/package.json index a7b45c6c..40277a40 100644 --- a/package.json +++ b/package.json @@ -3,26 +3,24 @@ "version": "0.0.1", "description": "Das neue web client for Ascribe", "main": "js/app.js", - "scripts": { - "watch": "watchify -o build/app.js -v -d js/app.js", - "build": "browserify . -t [envify --NODE_ENV production] | uglifyjs -cm > build/app.js", - "test": "jest" - }, "author": "Ascribe", "license": "Copyright", - "browserify": { - "transform": [ - "babelify", - "envify" - ] - }, "devDependencies": { "babel-jest": "^4.0.0", - "babelify": "^6.0.2", + "babelify": "^6.1.2", + "browser-sync": "^2.7.5", "browserify": "^9.0.8", "envify": "^3.4.0", + "gulp": "^3.8.11", + "gulp-if": "^1.2.5", + "gulp-notify": "^2.2.0", + "gulp-sourcemaps": "^1.5.2", + "gulp-util": "^3.0.4", "jest-cli": "^0.4.0", + "lodash": "^3.9.3", "reactify": "^1.1.0", + "vinyl-buffer": "^1.0.0", + "vinyl-source-stream": "^1.1.0", "watchify": "^3.1.2" }, "dependencies": { @@ -32,7 +30,8 @@ "object-assign": "^2.0.0", "react": "^0.13.2", "react-router": "^0.13.3", - "uglifyjs": "^2.4.10" + "uglifyjs": "^2.4.10", + "react-bootstrap": "~0.22.6" }, "jest": { "scriptPreprocessor": "node_modules/babel-jest",