+ */
+@-webkit-keyframes passing-through {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateY(40px);
+ -moz-transform: translateY(40px);
+ -ms-transform: translateY(40px);
+ -o-transform: translateY(40px);
+ transform: translateY(40px); }
+ 30%, 70% {
+ opacity: 1;
+ -webkit-transform: translateY(0px);
+ -moz-transform: translateY(0px);
+ -ms-transform: translateY(0px);
+ -o-transform: translateY(0px);
+ transform: translateY(0px); }
+ 100% {
+ opacity: 0;
+ -webkit-transform: translateY(-40px);
+ -moz-transform: translateY(-40px);
+ -ms-transform: translateY(-40px);
+ -o-transform: translateY(-40px);
+ transform: translateY(-40px); } }
+@-moz-keyframes passing-through {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateY(40px);
+ -moz-transform: translateY(40px);
+ -ms-transform: translateY(40px);
+ -o-transform: translateY(40px);
+ transform: translateY(40px); }
+ 30%, 70% {
+ opacity: 1;
+ -webkit-transform: translateY(0px);
+ -moz-transform: translateY(0px);
+ -ms-transform: translateY(0px);
+ -o-transform: translateY(0px);
+ transform: translateY(0px); }
+ 100% {
+ opacity: 0;
+ -webkit-transform: translateY(-40px);
+ -moz-transform: translateY(-40px);
+ -ms-transform: translateY(-40px);
+ -o-transform: translateY(-40px);
+ transform: translateY(-40px); } }
+@keyframes passing-through {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateY(40px);
+ -moz-transform: translateY(40px);
+ -ms-transform: translateY(40px);
+ -o-transform: translateY(40px);
+ transform: translateY(40px); }
+ 30%, 70% {
+ opacity: 1;
+ -webkit-transform: translateY(0px);
+ -moz-transform: translateY(0px);
+ -ms-transform: translateY(0px);
+ -o-transform: translateY(0px);
+ transform: translateY(0px); }
+ 100% {
+ opacity: 0;
+ -webkit-transform: translateY(-40px);
+ -moz-transform: translateY(-40px);
+ -ms-transform: translateY(-40px);
+ -o-transform: translateY(-40px);
+ transform: translateY(-40px); } }
+@-webkit-keyframes slide-in {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateY(40px);
+ -moz-transform: translateY(40px);
+ -ms-transform: translateY(40px);
+ -o-transform: translateY(40px);
+ transform: translateY(40px); }
+ 30% {
+ opacity: 1;
+ -webkit-transform: translateY(0px);
+ -moz-transform: translateY(0px);
+ -ms-transform: translateY(0px);
+ -o-transform: translateY(0px);
+ transform: translateY(0px); } }
+@-moz-keyframes slide-in {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateY(40px);
+ -moz-transform: translateY(40px);
+ -ms-transform: translateY(40px);
+ -o-transform: translateY(40px);
+ transform: translateY(40px); }
+ 30% {
+ opacity: 1;
+ -webkit-transform: translateY(0px);
+ -moz-transform: translateY(0px);
+ -ms-transform: translateY(0px);
+ -o-transform: translateY(0px);
+ transform: translateY(0px); } }
+@keyframes slide-in {
+ 0% {
+ opacity: 0;
+ -webkit-transform: translateY(40px);
+ -moz-transform: translateY(40px);
+ -ms-transform: translateY(40px);
+ -o-transform: translateY(40px);
+ transform: translateY(40px); }
+ 30% {
+ opacity: 1;
+ -webkit-transform: translateY(0px);
+ -moz-transform: translateY(0px);
+ -ms-transform: translateY(0px);
+ -o-transform: translateY(0px);
+ transform: translateY(0px); } }
+@-webkit-keyframes pulse {
+ 0% {
+ -webkit-transform: scale(1);
+ -moz-transform: scale(1);
+ -ms-transform: scale(1);
+ -o-transform: scale(1);
+ transform: scale(1); }
+ 10% {
+ -webkit-transform: scale(1.1);
+ -moz-transform: scale(1.1);
+ -ms-transform: scale(1.1);
+ -o-transform: scale(1.1);
+ transform: scale(1.1); }
+ 20% {
+ -webkit-transform: scale(1);
+ -moz-transform: scale(1);
+ -ms-transform: scale(1);
+ -o-transform: scale(1);
+ transform: scale(1); } }
+@-moz-keyframes pulse {
+ 0% {
+ -webkit-transform: scale(1);
+ -moz-transform: scale(1);
+ -ms-transform: scale(1);
+ -o-transform: scale(1);
+ transform: scale(1); }
+ 10% {
+ -webkit-transform: scale(1.1);
+ -moz-transform: scale(1.1);
+ -ms-transform: scale(1.1);
+ -o-transform: scale(1.1);
+ transform: scale(1.1); }
+ 20% {
+ -webkit-transform: scale(1);
+ -moz-transform: scale(1);
+ -ms-transform: scale(1);
+ -o-transform: scale(1);
+ transform: scale(1); } }
+@keyframes pulse {
+ 0% {
+ -webkit-transform: scale(1);
+ -moz-transform: scale(1);
+ -ms-transform: scale(1);
+ -o-transform: scale(1);
+ transform: scale(1); }
+ 10% {
+ -webkit-transform: scale(1.1);
+ -moz-transform: scale(1.1);
+ -ms-transform: scale(1.1);
+ -o-transform: scale(1.1);
+ transform: scale(1.1); }
+ 20% {
+ -webkit-transform: scale(1);
+ -moz-transform: scale(1);
+ -ms-transform: scale(1);
+ -o-transform: scale(1);
+ transform: scale(1); } }
+.dropzone, .dropzone * {
+ box-sizing: border-box; }
+
+.dropzone {
+ min-height: 150px;
+ border: 2px solid @adminPrimary;
+ padding: 20px 20px;
+ .roundedCorners(3px);
+}
+ .dropzone.dz-clickable {
+ cursor: pointer; }
+ .dropzone.dz-clickable * {
+ cursor: default; }
+ .dropzone.dz-clickable .dz-message, .dropzone.dz-clickable .dz-message * {
+ cursor: pointer; }
+ .dropzone.dz-started .dz-message {
+ display: none; }
+ .dropzone.dz-drag-hover {
+ border-style: solid; }
+ .dropzone.dz-drag-hover .dz-message {
+ opacity: 0.5; }
+ .dropzone .dz-message {
+ text-align: center;
+ margin: 41px 0;
+ font-size: 14px;
+ text-transform: uppercase;
+ color: #666666;
+ }
+ .dropzone .dz-preview {
+ position: relative;
+ display: inline-block;
+ vertical-align: top;
+ margin: 16px;
+ min-height: 100px; }
+ .dropzone .dz-preview:hover {
+ z-index: 1000; }
+ .dropzone .dz-preview:hover .dz-details {
+ opacity: 1; }
+ .dropzone .dz-preview.dz-file-preview .dz-image {
+ border-radius: 20px;
+ background: #999;
+ background: linear-gradient(to bottom, #eee, #ddd);
+ }
+ .dropzone .dz-preview.dz-file-preview .dz-details {
+ opacity: 1; }
+ .dropzone .dz-preview.dz-image-preview {
+ background: white;
+
+ .dz-image img{
+ min-width: 100%;
+ }
+ }
+ .dropzone .dz-preview.dz-image-preview .dz-details {
+ -webkit-transition: opacity 0.2s linear;
+ -moz-transition: opacity 0.2s linear;
+ -ms-transition: opacity 0.2s linear;
+ -o-transition: opacity 0.2s linear;
+ transition: opacity 0.2s linear; }
+ .dropzone .dz-preview .dz-remove {
+ font-size: 14px;
+ text-align: center;
+ display: block;
+ cursor: pointer;
+ border: none; }
+ .dropzone .dz-preview .dz-remove:hover {
+ text-decoration: underline; }
+ .dropzone .dz-preview:hover .dz-details {
+ opacity: 1; }
+ .dropzone .dz-preview .dz-details {
+ z-index: 20;
+ position: absolute;
+ top: 0;
+ left: 0;
+ opacity: 0;
+ font-size: 13px;
+ min-width: 100%;
+ max-width: 100%;
+ padding: 2em 1em;
+ text-align: center;
+ color: rgba(0, 0, 0, 0.9);
+ line-height: 150%; }
+ .dropzone .dz-preview .dz-details .dz-size {
+ margin-bottom: 1em;
+ font-size: 16px; }
+ .dropzone .dz-preview .dz-details .dz-filename {
+ white-space: nowrap; }
+ .dropzone .dz-preview .dz-details .dz-filename:hover span {
+ border: 1px solid rgba(200, 200, 200, 0.8);
+ background-color: rgba(255, 255, 255, 0.8); }
+ .dropzone .dz-preview .dz-details .dz-filename:not(:hover) {
+ overflow: hidden;
+ text-overflow: ellipsis; }
+ .dropzone .dz-preview .dz-details .dz-filename:not(:hover) span {
+ border: 1px solid transparent; }
+ .dropzone .dz-preview .dz-details .dz-filename span, .dropzone .dz-preview .dz-details .dz-size span {
+ background-color: rgba(255, 255, 255, 0.4);
+ padding: 0 0.4em;
+ border-radius: 3px; }
+ .dropzone .dz-preview:hover .dz-image img {
+ -webkit-transform: scale(1.05, 1.05);
+ -moz-transform: scale(1.05, 1.05);
+ -ms-transform: scale(1.05, 1.05);
+ -o-transform: scale(1.05, 1.05);
+ transform: scale(1.05, 1.05);
+ -webkit-filter: blur(8px);
+ filter: blur(8px); }
+ .dropzone .dz-preview .dz-image {
+ border-radius: 20px;
+ overflow: hidden;
+ width: 120px;
+ height: 120px;
+ position: relative;
+ display: block;
+ z-index: 10; }
+ .dropzone .dz-preview .dz-image img {
+ display: block; }
+ .dropzone .dz-preview.dz-success .dz-success-mark {
+ -webkit-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1);
+ -moz-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1);
+ -ms-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1);
+ -o-animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1);
+ animation: passing-through 3s cubic-bezier(0.77, 0, 0.175, 1); }
+ .dropzone .dz-preview.dz-error .dz-error-mark {
+ opacity: 1;
+ -webkit-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1);
+ -moz-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1);
+ -ms-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1);
+ -o-animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1);
+ animation: slide-in 3s cubic-bezier(0.77, 0, 0.175, 1); }
+ .dropzone .dz-preview .dz-success-mark, .dropzone .dz-preview .dz-error-mark {
+ pointer-events: none;
+ opacity: 0;
+ z-index: 500;
+ position: absolute;
+ display: block;
+ top: 50%;
+ left: 50%;
+ margin-left: -27px;
+ margin-top: -27px; }
+ .dropzone .dz-preview .dz-success-mark svg, .dropzone .dz-preview .dz-error-mark svg {
+ display: block;
+ width: 54px;
+ height: 54px; }
+ .dropzone .dz-preview.dz-processing .dz-progress {
+ opacity: 1;
+ -webkit-transition: all 0.2s linear;
+ -moz-transition: all 0.2s linear;
+ -ms-transition: all 0.2s linear;
+ -o-transition: all 0.2s linear;
+ transition: all 0.2s linear; }
+ .dropzone .dz-preview.dz-complete .dz-progress {
+ opacity: 0;
+ -webkit-transition: opacity 0.4s ease-in;
+ -moz-transition: opacity 0.4s ease-in;
+ -ms-transition: opacity 0.4s ease-in;
+ -o-transition: opacity 0.4s ease-in;
+ transition: opacity 0.4s ease-in; }
+ .dropzone .dz-preview:not(.dz-processing) .dz-progress {
+ -webkit-animation: pulse 6s ease infinite;
+ -moz-animation: pulse 6s ease infinite;
+ -ms-animation: pulse 6s ease infinite;
+ -o-animation: pulse 6s ease infinite;
+ animation: pulse 6s ease infinite; }
+ .dropzone .dz-preview .dz-progress {
+ opacity: 1;
+ z-index: 1000;
+ pointer-events: none;
+ position: absolute;
+ height: 16px;
+ left: 50%;
+ top: 50%;
+ margin-top: -8px;
+ width: 80px;
+ margin-left: -40px;
+ background: rgba(255, 255, 255, 0.9);
+ -webkit-transform: scale(1);
+ border-radius: 8px;
+ overflow: hidden; }
+ .dropzone .dz-preview .dz-progress .dz-upload {
+ background: #333;
+ background: linear-gradient(to bottom, #666, #444);
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 0;
+ -webkit-transition: width 300ms ease-in-out;
+ -moz-transition: width 300ms ease-in-out;
+ -ms-transition: width 300ms ease-in-out;
+ -o-transition: width 300ms ease-in-out;
+ transition: width 300ms ease-in-out; }
+ .dropzone .dz-preview.dz-error .dz-error-message {
+ display: block; }
+ .dropzone .dz-preview.dz-error:hover .dz-error-message {
+ opacity: 1;
+ pointer-events: auto; }
+ .dropzone .dz-preview .dz-error-message {
+ pointer-events: none;
+ z-index: 1000;
+ position: absolute;
+ display: block;
+ display: none;
+ opacity: 0;
+ -webkit-transition: opacity 0.3s ease;
+ -moz-transition: opacity 0.3s ease;
+ -ms-transition: opacity 0.3s ease;
+ -o-transition: opacity 0.3s ease;
+ transition: opacity 0.3s ease;
+ border-radius: 8px;
+ font-size: 13px;
+ top: 130px;
+ left: -10px;
+ width: 140px;
+ background: #be2626;
+ background: linear-gradient(to bottom, #be2626, #a92222);
+ padding: 0.5em 1.2em;
+ color: white; }
+ .dropzone .dz-preview .dz-error-message:after {
+ content: '';
+ position: absolute;
+ top: -6px;
+ left: 64px;
+ width: 0;
+ height: 0;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-bottom: 6px solid #be2626; }
diff --git a/assets/less/functions.less b/assets/less/functions.less
new file mode 100644
index 000000000..d80133795
--- /dev/null
+++ b/assets/less/functions.less
@@ -0,0 +1,122 @@
+
+// Maxes a div size to stick 0 pixels from the borders around
+.maxedSize(){
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+}
+
+// Alpha color background
+.alphaColor(@r, @g, @b, @a){
+ background: rgb(@r, @g, @b);
+ background: rgba(@r, @g, @b, @a);
+}
+
+.alphaColorToRgb(@color, @alpha) when (@alpha > 1) {
+ .alphaColor(red(@color), green(@color), blue(@color), @alpha/100);
+}
+.alphaColorToRgb(@color, @alpha) when (@alpha =< 1) {
+ .alphaColor(red(@color), green(@color), blue(@color), @alpha);
+}
+
+/*Gradient*/
+.gradient(@color1, @color2, @perc1: 50%, @perc2: 50%){
+ background-color: @color2;
+
+ background-image: linear-gradient(bottom, @color1 @perc1, @color2 @perc2);
+ background-image: -o-linear-gradient(bottom, @color1 @perc1, @color2 @perc2);
+ background-image: -moz-linear-gradient(bottom, @color1 @perc1, @color2 @perc2);
+ background-image: -webkit-linear-gradient(bottom, @color1 @perc1, @color2 @perc2);
+ background-image: -ms-linear-gradient(bottom, @color1 @perc1, @color2 @perc2);
+
+ background-image: -webkit-gradient(linear, left bottom, left top, color-stop(0.5, @color1), color-stop(0.5, @color2));
+}
+
+/*Drop Shadow*/
+.drop-shadow-box(@x-axis: 0, @y-axis: 1px, @blur: 2px, @color, @alpha){
+ box-shadow: @x-axis @y-axis @blur rgba(red(@color), green(@color), blue(@color), @alpha/100);
+}
+.drop-shadow-box-spread(@x-axis: 0, @y-axis: 1px, @blur: 2px, @spread: 2px, @color, @alpha){
+ box-shadow: @x-axis @y-axis @blur @spread rgba(red(@color), green(@color), blue(@color), @alpha/100);
+}
+.drop-shadow(@x-axis: 0, @y-axis: 1px, @blur: 2px, @r: 0, @g: 0, @b: 0, @alpha: 0.1) {
+ -webkit-text-shadow: @x-axis @y-axis @blur rgba(@r, @g, @b, @alpha);
+ -moz-text-shadow: @x-axis @y-axis @blur rgba(@r, @g, @b, @alpha);
+ text-shadow: @x-axis @y-axis @blur rgba(@r, @g, @b, @alpha);
+}
+
+/*Rounded Corners*/
+.roundedCorners(@rad: 2px, @other: false) when (@other = false) {
+ -webkit-border-radius : @rad;
+ -moz-border-radius : @rad;
+ -o-border-radius : @rad;
+ border-radius : @rad;
+}
+.roundedCorners(@tl_rad: 0, @tr_rad: false, @br_rad: 0, @bl_rad: 0) when not (@tr_rad = false){
+ -webkit-border-radius : @tl_rad @tr_rad @br_rad @bl_rad;
+ -moz-border-radius : @tl_rad @tr_rad @br_rad @bl_rad;
+ -o-border-radius : @tl_rad @tr_rad @br_rad @bl_rad;
+ border-radius : @tl_rad @tr_rad @br_rad @bl_rad;
+}
+
+// Border Colors
+.border(@size, @style, @color, @opc){
+ border: @size @style rgba(red(@color), green(@color), blue(@color), @opc/100);
+ -webkit-background-clip: padding-box;
+ background-clip: padding-box;
+}
+
+/*Transition*/
+.transition (@time: 0.2s, @to: all, @ease: cubic-bezier(0.190, 1.000, 0.220, 1.000)) {
+ transition : @to @time @ease;
+ -webkit-transition : @to @time @ease;
+ -moz-transition : @to @time @ease;
+ -o-transition : @to @time @ease;
+}
+
+/*Transition for transform only*/
+.transitionTransform (@time: 0.2s, @ease: cubic-bezier(0.190, 1.000, 0.220, 1.000)){
+ transition : transform @time @ease;
+ -ms-transition: -ms-transform @time @ease;
+ -webkit-transition : -webkit-transform @time @ease;
+ -moz-transition : -moz-transform @time @ease;
+ -o-transition : -o-transform @time @ease;
+}
+
+/*Transition Delay*/
+.transitionDelay (@time: 0.2s) {
+ transition-delay : @time;
+ -webkit-transition-delay : @time;
+ -moz-transition-delay : @time;
+ -o-transition-delay : @time;
+}
+
+/*Opacity*/
+.opacity(@opacity: 0.5) {
+ @percentage: @opacity*100;
+ ms-filter: ~"progid:DXImageTransform.Microsoft.Alpha(Opacity=@{percentage})";
+ filter: ~"alpha(opacity=@{percentage})";
+ -moz-opacity: @opacity;
+ -khtml-opacity: @opacity;
+ -webkit-opacity: @opacity;
+ opacity: @opacity;
+}
+
+
+.transformOrigin(@x, @y){
+ transform-origin: @x @y;
+ -ms-transform-origin: @x @y; /* IE 9 */
+ -webkit-transform-origin: @x @y; /* Safari and Chrome */
+ -moz-transform-origin: @x @y; /* Firefox */
+ -o-transform-origin: @x @y; /* Opera */
+}
+
+.transform(@values){
+ transform: @values;
+ -ms-transform: @values; /* IE 9 */
+ -moz-transform: @values; /* Firefox */
+ -webkit-transform: @values; /* Safari and Chrome */
+ -o-transform: @values; /* Opera */
+}
diff --git a/assets/less/main.less b/assets/less/main.less
new file mode 100644
index 000000000..1c788591a
--- /dev/null
+++ b/assets/less/main.less
@@ -0,0 +1,19 @@
+body {
+ font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
+}
+
+img {
+ max-width: 100%;
+}
+
+@import './variables.less';
+
+// Less functions
+@import './functions.less';
+
+// boilerplate
+@import './boilerplate/normalize.less';
+@import './boilerplate/main.less';
+
+// components
+@import './components/index.less';
diff --git a/assets/less/variables.less b/assets/less/variables.less
new file mode 100644
index 000000000..1b2f8f58f
--- /dev/null
+++ b/assets/less/variables.less
@@ -0,0 +1,39 @@
+@adminPrimary: #3eb0f2;
+@adminPrimaryLight: lighten(@adminPrimary, 20%);
+@adminPrimaryDarker: darken(@adminPrimary, 20%);
+
+@alert: #D22525;
+@alertDarker: #8A1212;
+
+@success: #17B923;
+
+@pageBuilderChrome: #1d1c1c;
+@pageBuilderChromeText: #f7f7f7;
+@pageBuilderChromeTextActive: #ffffff;
+
+@pageBuilderMenu: @pageBuilderChrome;
+@pageBuilderMenuText: @pageBuilderChromeText;
+@pageBuilderMenuTextActive: @pageBuilderChromeTextActive;
+
+@pageBuilderTopMenu: #1d1c1c;
+@pageBuilderTopMenuText: @pageBuilderChromeText;
+@pageBuilderTopMenuTextActive: @pageBuilderChromeTextActive;
+@pageBuilderTopMenuHeight: 60px;
+
+@pageBuilderContextMenu: @pageBuilderChrome;
+@pageBuilderContextMenuText: @pageBuilderChromeText;
+@pageBuilderContextMenuTextActive: @pageBuilderChromeTextActive;
+
+@pageBuilderAdvancedMenu: fade(#131618, 95%);
+@pageBuilderAdvancedMenuText: @pageBuilderChromeText;
+@pageBuilderAdvancedMenuTextActive: @pageBuilderChromeTextActive;
+@pageBuilderAdvancedMenuWidth: 258px;
+
+@pageBuilderElementDropHighlight: #1D991D;
+@pageBuilderElementHighlight: #3399FF;
+@pageBuilderElementSelected: #33CC33;
+@pageBuilderElementHighlightSub: #cccccc;
+@pageBuilderElementSelectedSub: #999999;
+
+@dashboardMenuWidth: 200px;
+@dashboardMenuBackground: #2b303b;
diff --git a/config.js b/config.js
new file mode 100644
index 000000000..8f4d1b2d2
--- /dev/null
+++ b/config.js
@@ -0,0 +1,5 @@
+import rc from 'rc';
+
+export default rc('relax', {
+ port: 8080
+});
diff --git a/index.js b/index.js
new file mode 100644
index 000000000..7770eb2fd
--- /dev/null
+++ b/index.js
@@ -0,0 +1,2 @@
+require('babel/register');
+require('./app');
diff --git a/lib/client/actions/colors.js b/lib/client/actions/colors.js
new file mode 100644
index 000000000..fef8bf812
--- /dev/null
+++ b/lib/client/actions/colors.js
@@ -0,0 +1,17 @@
+import {Actions} from 'relax-framework';
+
+class ColorActions extends Actions {
+ init () {
+
+ }
+
+ getActions () {
+ return [
+ 'add',
+ 'remove',
+ 'update'
+ ];
+ }
+}
+
+export default new ColorActions();
diff --git a/lib/client/actions/elements.js b/lib/client/actions/elements.js
new file mode 100644
index 000000000..fc9d04a38
--- /dev/null
+++ b/lib/client/actions/elements.js
@@ -0,0 +1,15 @@
+import {Actions} from 'relax-framework';
+
+class ElementsActions extends Actions {
+ init () {
+
+ }
+
+ getActions () {
+ return [
+ 'getElements'
+ ];
+ }
+}
+
+export default new ElementsActions();
diff --git a/lib/client/actions/fonts.js b/lib/client/actions/fonts.js
new file mode 100644
index 000000000..fd58e6c54
--- /dev/null
+++ b/lib/client/actions/fonts.js
@@ -0,0 +1,16 @@
+import {Actions} from 'relax-framework';
+
+class FontsActions extends Actions {
+ init () {
+
+ }
+
+ getActions () {
+ return [
+ 'submit',
+ 'remove'
+ ];
+ }
+}
+
+export default new FontsActions();
diff --git a/lib/client/actions/media.js b/lib/client/actions/media.js
new file mode 100644
index 000000000..4c1838a98
--- /dev/null
+++ b/lib/client/actions/media.js
@@ -0,0 +1,19 @@
+import {Actions} from 'relax-framework';
+
+class MediaActions extends Actions {
+ init () {
+
+ }
+
+ getActions () {
+ return [
+ 'add',
+ 'remove',
+ 'removeBulk',
+ 'find',
+ 'resize'
+ ];
+ }
+}
+
+export default new MediaActions();
diff --git a/lib/client/actions/page.js b/lib/client/actions/page.js
new file mode 100644
index 000000000..c50a50cea
--- /dev/null
+++ b/lib/client/actions/page.js
@@ -0,0 +1,18 @@
+import {Actions} from 'relax-framework';
+
+class PageActions extends Actions {
+ init () {
+
+ }
+
+ getActions () {
+ return [
+ 'add',
+ 'remove',
+ 'validateSlug',
+ 'update'
+ ];
+ }
+}
+
+export default new PageActions();
diff --git a/lib/client/actions/schema-entries.js b/lib/client/actions/schema-entries.js
new file mode 100644
index 000000000..5cdccbbbf
--- /dev/null
+++ b/lib/client/actions/schema-entries.js
@@ -0,0 +1,27 @@
+import {Actions} from 'relax-framework';
+
+class SchemaEntriesActions extends Actions {
+ init () {
+
+ }
+
+ getActions () {
+ return [
+ 'add',
+ 'remove',
+ 'validateSlug',
+ 'update'
+ ];
+ }
+}
+
+var schemaEntriesActionsArr = {};
+export default (slug) => {
+ if(schemaEntriesActionsArr[slug]){
+ return schemaEntriesActionsArr[slug];
+ } else {
+ var actions = new SchemaEntriesActions();
+ schemaEntriesActionsArr[slug] = actions;
+ return actions;
+ }
+};
diff --git a/lib/client/actions/schema.js b/lib/client/actions/schema.js
new file mode 100644
index 000000000..c5aa9416b
--- /dev/null
+++ b/lib/client/actions/schema.js
@@ -0,0 +1,18 @@
+import {Actions} from 'relax-framework';
+
+class SchemaActions extends Actions {
+ init () {
+
+ }
+
+ getActions () {
+ return [
+ 'add',
+ 'remove',
+ 'validateSlug',
+ 'update'
+ ];
+ }
+}
+
+export default new SchemaActions();
diff --git a/lib/client/actions/settings.js b/lib/client/actions/settings.js
new file mode 100644
index 000000000..023cb75b4
--- /dev/null
+++ b/lib/client/actions/settings.js
@@ -0,0 +1,15 @@
+import {Actions} from 'relax-framework';
+
+class SettingsActions extends Actions {
+ init () {
+
+ }
+
+ getActions () {
+ return [
+ 'saveSettings'
+ ];
+ }
+}
+
+export default new SettingsActions();
diff --git a/lib/client/actions/style.js b/lib/client/actions/style.js
new file mode 100644
index 000000000..396dd993c
--- /dev/null
+++ b/lib/client/actions/style.js
@@ -0,0 +1,17 @@
+import {Actions} from 'relax-framework';
+
+class StyleActions extends Actions {
+ init () {
+
+ }
+
+ getActions () {
+ return [
+ 'add',
+ 'remove',
+ 'update'
+ ];
+ }
+}
+
+export default new StyleActions();
diff --git a/lib/client/actions/tab.js b/lib/client/actions/tab.js
new file mode 100644
index 000000000..d7871a141
--- /dev/null
+++ b/lib/client/actions/tab.js
@@ -0,0 +1,14 @@
+import {Actions} from 'relax-framework';
+
+class TabActions extends Actions {
+ init () {}
+
+ getActions () {
+ return [
+ 'add',
+ 'remove'
+ ];
+ }
+}
+
+export default new TabActions();
diff --git a/lib/client/actions/user.js b/lib/client/actions/user.js
new file mode 100644
index 000000000..0ca5030a1
--- /dev/null
+++ b/lib/client/actions/user.js
@@ -0,0 +1,17 @@
+import {Actions} from 'relax-framework';
+
+class UserActions extends Actions {
+ init () {
+
+ }
+
+ getActions () {
+ return [
+ 'add',
+ 'remove',
+ 'update'
+ ];
+ }
+}
+
+export default new UserActions();
diff --git a/lib/client/admin.js b/lib/client/admin.js
new file mode 100644
index 000000000..92eb11b61
--- /dev/null
+++ b/lib/client/admin.js
@@ -0,0 +1,5 @@
+import {Router} from 'relax-framework';
+import adminRoutes from './routers/admin';
+import init from './init';
+
+init(Router.extend(adminRoutes));
diff --git a/lib/client/auth.js b/lib/client/auth.js
new file mode 100644
index 000000000..c87766e07
--- /dev/null
+++ b/lib/client/auth.js
@@ -0,0 +1,5 @@
+import {Router} from 'relax-framework';
+import authRoutes from './routers/auth';
+import init from './init';
+
+init(Router.extend(authRoutes));
diff --git a/lib/client/collections/colors.js b/lib/client/collections/colors.js
new file mode 100644
index 000000000..c14fd73b3
--- /dev/null
+++ b/lib/client/collections/colors.js
@@ -0,0 +1,7 @@
+import {Collection} from 'relax-framework';
+import ColorModel from '../models/color';
+
+export default Collection.extend({
+ model: ColorModel,
+ url: '/api/color'
+});
diff --git a/lib/client/collections/media.js b/lib/client/collections/media.js
new file mode 100644
index 000000000..6c6a27591
--- /dev/null
+++ b/lib/client/collections/media.js
@@ -0,0 +1,16 @@
+import {Collection} from 'relax-framework';
+import MediaModel from '../models/media';
+import Utils from '../../utils';
+
+export default Collection.extend({
+ model: MediaModel,
+ url: function () {
+ var url = '/api/media';
+
+ if (this.options) {
+ url = Utils.parseQueryUrl(url, this.options);
+ }
+
+ return url;
+ }
+});
diff --git a/lib/client/collections/pages.js b/lib/client/collections/pages.js
new file mode 100644
index 000000000..f30075e97
--- /dev/null
+++ b/lib/client/collections/pages.js
@@ -0,0 +1,16 @@
+import {Collection} from 'relax-framework';
+import PageModel from '../models/page';
+import Utils from '../../utils';
+
+export default Collection.extend({
+ model: PageModel,
+ url: function () {
+ var url = '/api/page';
+
+ if (this.options) {
+ url = Utils.parseQueryUrl(url, this.options);
+ }
+
+ return url;
+ }
+});
diff --git a/lib/client/collections/schema-entries.js b/lib/client/collections/schema-entries.js
new file mode 100644
index 000000000..d06b9d6bf
--- /dev/null
+++ b/lib/client/collections/schema-entries.js
@@ -0,0 +1,20 @@
+import {Collection} from 'relax-framework';
+import SchemaEntryModel from '../models/schema-entry';
+import Utils from '../../utils';
+
+export default Collection.extend({
+ model: SchemaEntryModel,
+ initialize: function(models, options) {
+ this.slug = options.slug;
+ delete options.slug;
+ },
+ url: function () {
+ var url = '/api/schema-entry/' + this.slug;
+
+ if (this.options) {
+ url = Utils.parseQueryUrl(url, this.options);
+ }
+
+ return url;
+ }
+});
diff --git a/lib/client/collections/schemas.js b/lib/client/collections/schemas.js
new file mode 100644
index 000000000..79d8d4d53
--- /dev/null
+++ b/lib/client/collections/schemas.js
@@ -0,0 +1,16 @@
+import {Collection} from 'relax-framework';
+import SchemaModel from '../models/schema';
+import Utils from '../../utils';
+
+export default Collection.extend({
+ model: SchemaModel,
+ url: function () {
+ var url = '/api/schema';
+
+ if (this.options) {
+ url = Utils.parseQueryUrl(url, this.options);
+ }
+
+ return url;
+ }
+});
diff --git a/lib/client/collections/settings.js b/lib/client/collections/settings.js
new file mode 100644
index 000000000..03048d248
--- /dev/null
+++ b/lib/client/collections/settings.js
@@ -0,0 +1,16 @@
+import {Collection} from 'relax-framework';
+import SettingModel from '../models/setting';
+
+export default Collection.extend({
+ model: SettingModel,
+ url: function () {
+ const ids = this.options.ids;
+ var url = '/api/setting';
+
+ if (ids) {
+ url += '?ids=' + JSON.stringify(ids);
+ }
+
+ return url;
+ }
+});
diff --git a/lib/client/collections/styles.js b/lib/client/collections/styles.js
new file mode 100644
index 000000000..bc2c681a2
--- /dev/null
+++ b/lib/client/collections/styles.js
@@ -0,0 +1,16 @@
+import {Collection} from 'relax-framework';
+import StyleModel from '../models/style';
+import Utils from '../../utils';
+
+export default Collection.extend({
+ model: StyleModel,
+ url: function () {
+ var url = '/api/style';
+
+ if (this.options) {
+ url = Utils.parseQueryUrl(url, this.options);
+ }
+
+ return url;
+ }
+});
diff --git a/lib/client/collections/tabs.js b/lib/client/collections/tabs.js
new file mode 100644
index 000000000..344810dad
--- /dev/null
+++ b/lib/client/collections/tabs.js
@@ -0,0 +1,15 @@
+import {Collection} from 'relax-framework';
+import TabModel from '../models/tab';
+
+export default Collection.extend({
+ model: TabModel,
+ url: function () {
+ var url = '/api/tab';
+
+ if (typeof this.options !== 'undefined' && this.options.user) {
+ url += '/'+this.options.user;
+ }
+
+ return url;
+ }
+});
diff --git a/lib/client/collections/users.js b/lib/client/collections/users.js
new file mode 100644
index 000000000..1d887e477
--- /dev/null
+++ b/lib/client/collections/users.js
@@ -0,0 +1,7 @@
+import {Collection} from 'relax-framework';
+import UserModel from '../models/user';
+
+export default Collection.extend({
+ model: UserModel,
+ url: '/api/user'
+});
diff --git a/lib/client/init.js b/lib/client/init.js
new file mode 100644
index 000000000..0c76c5986
--- /dev/null
+++ b/lib/client/init.js
@@ -0,0 +1,21 @@
+import $ from 'jquery';
+import Backbone from 'backbone';
+Backbone.$ = $;
+
+import initStores from './stores';
+
+export default function init (Router) {
+ window.initialProps = $('script[type="application/json"]').html();
+
+ if (window.initialProps) {
+ window.initialProps = JSON.parse(window.initialProps);
+ }
+
+ initStores(window.initialProps);
+
+ /* jshint ignore:start */
+ new Router();
+ /* jshint ignore:end */
+
+ Backbone.history.start({pushState: true});
+}
diff --git a/lib/client/models/color.js b/lib/client/models/color.js
new file mode 100644
index 000000000..565f24561
--- /dev/null
+++ b/lib/client/models/color.js
@@ -0,0 +1,6 @@
+import {Model} from 'relax-framework';
+
+export default Model.extend({
+ idAttribute: '_id',
+ urlRoot: '/api/color'
+});
diff --git a/lib/client/models/media.js b/lib/client/models/media.js
new file mode 100644
index 000000000..9b0d39c04
--- /dev/null
+++ b/lib/client/models/media.js
@@ -0,0 +1,8 @@
+import {Model} from 'relax-framework';
+
+export default Model.extend({
+ idAttribute: "_id",
+ url: function () {
+ return '/api/media/'+this.id;
+ }
+});
diff --git a/lib/client/models/page.js b/lib/client/models/page.js
new file mode 100644
index 000000000..192cc58ee
--- /dev/null
+++ b/lib/client/models/page.js
@@ -0,0 +1,5 @@
+import {Model} from 'relax-framework';
+
+export default Model.extend({
+ idAttribute: "_id"
+});
diff --git a/lib/client/models/schema-entry.js b/lib/client/models/schema-entry.js
new file mode 100644
index 000000000..192cc58ee
--- /dev/null
+++ b/lib/client/models/schema-entry.js
@@ -0,0 +1,5 @@
+import {Model} from 'relax-framework';
+
+export default Model.extend({
+ idAttribute: "_id"
+});
diff --git a/lib/client/models/schema.js b/lib/client/models/schema.js
new file mode 100644
index 000000000..2aa7d8e9a
--- /dev/null
+++ b/lib/client/models/schema.js
@@ -0,0 +1,12 @@
+import {Model} from 'relax-framework';
+
+export default Model.extend({
+ idAttribute: "_id",
+ url: function () {
+ if (this.id) {
+ return '/api/schema/'+this.id;
+ } else {
+ return '/api/schema';
+ }
+ }
+});
diff --git a/lib/client/models/setting.js b/lib/client/models/setting.js
new file mode 100644
index 000000000..42f2fd61e
--- /dev/null
+++ b/lib/client/models/setting.js
@@ -0,0 +1,12 @@
+import {Model} from 'relax-framework';
+
+export default Model.extend({
+ idAttribute: "_id",
+ url: function () {
+ if (this.id) {
+ return '/api/setting/'+this.id;
+ } else {
+ return '/api/setting';
+ }
+ }
+});
diff --git a/lib/client/models/style.js b/lib/client/models/style.js
new file mode 100644
index 000000000..be84b8a34
--- /dev/null
+++ b/lib/client/models/style.js
@@ -0,0 +1,12 @@
+import {Model} from 'relax-framework';
+
+export default Model.extend({
+ idAttribute: "_id",
+ url: function () {
+ if (this.id) {
+ return '/api/style/'+this.id;
+ } else {
+ return '/api/style';
+ }
+ }
+});
diff --git a/lib/client/models/tab.js b/lib/client/models/tab.js
new file mode 100644
index 000000000..192cc58ee
--- /dev/null
+++ b/lib/client/models/tab.js
@@ -0,0 +1,5 @@
+import {Model} from 'relax-framework';
+
+export default Model.extend({
+ idAttribute: "_id"
+});
diff --git a/lib/client/models/user.js b/lib/client/models/user.js
new file mode 100644
index 000000000..192cc58ee
--- /dev/null
+++ b/lib/client/models/user.js
@@ -0,0 +1,5 @@
+import {Model} from 'relax-framework';
+
+export default Model.extend({
+ idAttribute: "_id"
+});
diff --git a/lib/client/public.js b/lib/client/public.js
new file mode 100644
index 000000000..93487ed91
--- /dev/null
+++ b/lib/client/public.js
@@ -0,0 +1,5 @@
+import {Router} from 'relax-framework';
+import publicRoutes from './routers/public';
+import init from './init';
+
+init(Router.extend(publicRoutes));
diff --git a/lib/client/routers/admin.js b/lib/client/routers/admin.js
new file mode 100644
index 000000000..480fc4627
--- /dev/null
+++ b/lib/client/routers/admin.js
@@ -0,0 +1,282 @@
+import Cortex from 'backbone-cortex';
+import Admin from '../../components/admin';
+import Q from 'q';
+import merge from 'lodash.merge';
+import forEach from 'lodash.foreach';
+import {Router} from 'relax-framework';
+
+import sessionStore from '../stores/session';
+import usersStore from '../stores/users';
+import pagesStore from '../stores/pages';
+import elementsStore from '../stores/elements';
+import mediaStore from '../stores/media';
+import settingsStore from '../stores/settings';
+import colorsStore from '../stores/colors';
+import schemasStore from '../stores/schemas';
+import schemaEntriesStoreFactory from '../stores/schema-entries';
+import tabsStore from '../stores/tabs';
+
+import generalSettingsIds from '../../settings/general';
+import fontSettingsIds from '../../settings/fonts';
+
+var cortex = new Cortex();
+
+function renderComponent (Component, route, params) {
+ Router.prototype.renderComponent(Component, merge(route.data, params));
+}
+
+cortex.use((route, next) => {
+ Q()
+ .then(() => sessionStore.getSession())
+ .then((user) => {
+ route.data.user = user;
+ route.data.tabs = [];
+
+ tabsStore
+ .findAll({
+ user: user._id
+ })
+ .then((tabs) => {
+ route.data.tabs = tabs;
+ })
+ .fin(next);
+ })
+ .catch((err) => {
+ window.location.href = '/admin/login';
+ });
+});
+
+cortex.route('admin', (route, next) => {
+ settingsStore
+ .findByIds(generalSettingsIds)
+ .then((settings) => {
+ renderComponent(Admin, route, {
+ activePanelType: 'settings',
+ settings
+ });
+ })
+ .done();
+});
+
+cortex.route('admin/pages', (route, next) => {
+ var query = route.query || {};
+
+ pagesStore
+ .findAll(query)
+ .then((pages) => {
+ renderComponent(Admin, route, {
+ activePanelType: 'pages',
+ pages,
+ query
+ });
+ })
+ .done();
+});
+
+cortex.route('admin/media', (route, next) => {
+ var query = route.query || {};
+
+ mediaStore
+ .findAll(query)
+ .then((media) => {
+ renderComponent(Admin, route, {
+ activePanelType: 'media',
+ media,
+ query
+ });
+ })
+ .done();
+});
+
+cortex.route('admin/fonts', (route, next) => {
+ settingsStore
+ .findByIds(fontSettingsIds)
+ .then((settings) => {
+ renderComponent(Admin, route, {
+ activePanelType: 'fonts',
+ settings
+ });
+ })
+ .done();
+});
+
+cortex.route('admin/colors', (route, next) => {
+ colorsStore
+ .findAll()
+ .then((colors) => {
+ renderComponent(Admin, route, {
+ activePanelType: 'colors',
+ colors
+ });
+ })
+ .done();
+});
+
+cortex.route('admin/schemas/new', (route, next) => {
+ renderComponent(Admin, route, {
+ activePanelType: 'schemasNew',
+ breadcrumbs: [
+ {
+ label: 'Schemas',
+ type: 'schemas',
+ link: '/admin/schemas'
+ },
+ {
+ label: 'New'
+ }
+ ]
+ });
+});
+
+cortex.route('admin/schemas/:slug/:entrySlug', (route, next) => {
+ var schemaEntriesStore = schemaEntriesStoreFactory(route.params.slug);
+
+ Q
+ .all([
+ schemaEntriesStore.findBySlug(route.params.entrySlug),
+ schemasStore.findBySlug(route.params.slug)
+ ])
+ .spread((schemaEntry, schema) => {
+ renderComponent(Admin, route, {
+ activePanelType: 'schemaEntry',
+ schemaEntry,
+ schema,
+ breadcrumbs: [
+ {
+ label: 'Schemas',
+ type: 'schemas',
+ link: '/admin/schemas'
+ },
+ {
+ label: schema.title,
+ link: '/admin/schemas/'+schema.slug
+ },
+ {
+ label: schemaEntry.title
+ }
+ ]
+ });
+ })
+ .done();
+});
+
+cortex.route('admin/schemas/:slug', (route, next) => {
+ var schemaEntriesStore = schemaEntriesStoreFactory(route.params.slug);
+ var query = route.query || {};
+
+ Q
+ .all([
+ schemaEntriesStore.findAll(query),
+ schemasStore.findBySlug(route.params.slug)
+ ])
+ .spread((schemaEntries, schema) => {
+ renderComponent(Admin, route, {
+ activePanelType: 'schema',
+ schemaEntries,
+ schema,
+ query,
+ breadcrumbs: [
+ {
+ label: 'Schemas',
+ type: 'schemas',
+ link: '/admin/schemas'
+ },
+ {
+ label: schema.title
+ }
+ ]
+ });
+ })
+ .done();
+});
+
+cortex.route('admin/schemas', (route, next) => {
+ var query = route.query || {};
+ schemasStore
+ .findAll(query)
+ .then((schemas) => {
+ renderComponent(Admin, route, {
+ activePanelType: 'schemas',
+ schemas,
+ query
+ });
+ })
+ .done();
+});
+
+cortex.route('admin/users', (route, next) => {
+ var query = route.query || {};
+
+ usersStore
+ .findAll(query)
+ .then((users) => {
+ renderComponent(Admin, route, {
+ activePanelType: 'users',
+ users,
+ query
+ });
+ })
+ .done();
+});
+
+cortex.route('admin/users/:username', (route, next) => {
+ Q()
+ .then(() => usersStore.findOne({username: route.params.username}))
+ .then((editUser) => {
+ renderComponent(Admin, route, {
+ activePanelType: 'userEdit',
+ editUser,
+ breadcrumbs: [
+ {
+ label: 'Users',
+ type: 'users',
+ link: '/admin/users'
+ },
+ {
+ label: editUser.name
+ }
+ ]
+ });
+ })
+ .catch((error) => {
+
+ });
+});
+
+cortex.route('admin/page/:slug', (route, next) => {
+ Q()
+ .then(() => pagesStore.findBySlug(route.params.slug))
+ .then((page) => Q.all([page, elementsStore.findAll()]))
+ .spread((page, elements) => Q.all([page, elements, colorsStore.findAll()]))
+ .spread((page, elements, colors) => {
+
+ var inTabs = false;
+ forEach(route.data.tabs, (tab) => {
+ if (tab.pageId._id === page._id) {
+ inTabs = true;
+ return false;
+ }
+ });
+
+ if (!inTabs) {
+ tabsStore.add({
+ pageId: page._id,
+ userId: route.data.user._id
+ });
+ }
+
+ renderComponent(Admin, route, {
+ activePanelType: 'page',
+ elements,
+ page,
+ colors
+ });
+ })
+ .catch((error) => {
+
+ });
+});
+
+export default {
+ routes: cortex.getRoutes()
+};
diff --git a/lib/client/routers/auth.js b/lib/client/routers/auth.js
new file mode 100644
index 000000000..543d66e22
--- /dev/null
+++ b/lib/client/routers/auth.js
@@ -0,0 +1,20 @@
+import Cortex from 'backbone-cortex';
+import {Router} from 'relax-framework';
+
+import Init from '../../components/admin/init';
+import Login from '../../components/admin/login';
+
+var cortex = new Cortex();
+var renderComponent = Router.prototype.renderComponent.bind(Router.prototype);
+
+cortex.route('admin/init', (route, next) => {
+ renderComponent(Init, {});
+});
+
+cortex.route('admin/login', (route, next) => {
+ renderComponent(Login, {});
+});
+
+export default {
+ routes: cortex.getRoutes()
+};
diff --git a/lib/client/routers/public.js b/lib/client/routers/public.js
new file mode 100644
index 000000000..fb109e77b
--- /dev/null
+++ b/lib/client/routers/public.js
@@ -0,0 +1,21 @@
+import Page from '../../components/page';
+import elementsStore from '../stores/elements';
+import pagesStore from '../stores/pages';
+import Q from 'q';
+
+export default {
+ routes: {
+ ':slug': 'page'
+ },
+ page: function (slug) {
+ Q()
+ .then(() => pagesStore.findBySlug(slug))
+ .then((page) => Q.all([page, elementsStore.findAll()]))
+ .spread((page, elements) => {
+ this.renderComponent(Page, {elements, page});
+ })
+ .catch((error) => {
+ console.log('Error loading page: '+error);
+ });
+ }
+};
diff --git a/lib/client/stores/colors.js b/lib/client/stores/colors.js
new file mode 100644
index 000000000..3a51fc05c
--- /dev/null
+++ b/lib/client/stores/colors.js
@@ -0,0 +1,24 @@
+import {ClientStore} from 'relax-framework';
+import ColorsCollection from '../collections/colors';
+import colorActions from '../actions/colors';
+
+class ColorsStore extends ClientStore {
+ constructor () {
+ super();
+ }
+
+ init () {
+ if (this.isClient()) {
+ this.listenTo(colorActions, 'add', this.add);
+ this.listenTo(colorActions, 'remove', this.remove);
+ this.listenTo(colorActions, 'update', this.update);
+ }
+ }
+
+ createCollection () {
+ return new ColorsCollection();
+ }
+
+}
+
+export default new ColorsStore();
diff --git a/lib/client/stores/elements.js b/lib/client/stores/elements.js
new file mode 100644
index 000000000..aa968d3f1
--- /dev/null
+++ b/lib/client/stores/elements.js
@@ -0,0 +1,16 @@
+import {ClientStore} from 'relax-framework';
+import Q from 'q';
+import elements from '../../components/elements';
+
+class ElementsStore extends ClientStore {
+
+ findAll () {
+ return Q()
+ .then(() => {
+ return elements;
+ });
+ }
+
+}
+
+export default new ElementsStore();
diff --git a/lib/client/stores/fonts.js b/lib/client/stores/fonts.js
new file mode 100644
index 000000000..37942508c
--- /dev/null
+++ b/lib/client/stores/fonts.js
@@ -0,0 +1,73 @@
+import {ClientStore} from 'relax-framework';
+import Q from 'q';
+import fontsActions from '../actions/fonts';
+import $ from 'jquery';
+
+class FontsStore extends ClientStore {
+
+ constructor () {
+ super();
+ }
+
+ init () {
+ if (this.isClient()) {
+ this.listenTo(fontsActions, 'submit', this.onSubmit);
+ this.listenTo(fontsActions, 'remove', this.onRemove);
+ }
+ }
+
+ onSubmit (files, deferred) {
+ this.runPromises(deferred, [
+ this.submit(files)
+ ]);
+ }
+
+ onRemove (id, deferred) {
+ this.runPromises(deferred, [
+ this.remove(id)
+ ]);
+ }
+
+ submit (files) {
+ return () => {
+ return Q()
+ .then(() => {
+ var deferred = Q.defer();
+
+ $
+ .post('/api/fonts/submit', {data: JSON.stringify(files)})
+ .done((response) => {
+ deferred.resolve(response);
+ })
+ .fail((error) => {
+ deferred.error(error);
+ });
+
+ return deferred.promise;
+ });
+ };
+ }
+
+ remove (id) {
+ return () => {
+ return Q()
+ .then(() => {
+ var deferred = Q.defer();
+
+ $
+ .post('/api/fonts/remove', {id})
+ .done((response) => {
+ deferred.resolve(response);
+ })
+ .fail((error) => {
+ deferred.error(error);
+ });
+
+ return deferred.promise;
+ });
+ };
+ }
+
+}
+
+export default new FontsStore();
diff --git a/lib/client/stores/index.js b/lib/client/stores/index.js
new file mode 100644
index 000000000..0d1b09c25
--- /dev/null
+++ b/lib/client/stores/index.js
@@ -0,0 +1,49 @@
+import forEach from 'lodash.foreach';
+
+import colors from './colors';
+import schemaEntries from './schema-entries';
+import elements from './elements';
+import fonts from './fonts';
+import media from './media';
+import pages from './pages';
+import schemas from './schemas';
+import settings from './settings';
+import users from './users';
+import session from './session';
+import tabs from './tabs';
+
+var stores = {
+ colors,
+ schemaEntries,
+ elements,
+ fonts,
+ media,
+ pages,
+ schemas,
+ settings,
+ users,
+ session,
+ tabs
+};
+
+export {
+ colors,
+ schemaEntries,
+ elements,
+ fonts,
+ media,
+ pages,
+ schemas,
+ settings,
+ users,
+ session,
+ tabs
+};
+
+export default function initStore (data) {
+ forEach(stores, (store, key) => {
+ if (data[key]) {
+ store.data = data[key];
+ }
+ });
+}
diff --git a/lib/client/stores/media.js b/lib/client/stores/media.js
new file mode 100644
index 000000000..0616c0535
--- /dev/null
+++ b/lib/client/stores/media.js
@@ -0,0 +1,57 @@
+import {ClientStore} from 'relax-framework';
+import MediaCollection from '../collections/media';
+import mediaActions from '../actions/media';
+import forEach from 'lodash.foreach';
+import Q from 'q';
+import $ from 'jquery';
+
+class MediaStore extends ClientStore {
+ constructor () {
+ super();
+ }
+
+ init () {
+ if (this.isClient()) {
+ this.listenTo(mediaActions, 'add', this.add);
+ this.listenTo(mediaActions, 'remove', this.remove);
+ this.listenTo(mediaActions, 'removeBulk', this.removeBulk);
+ this.listenTo(mediaActions, 'find', this.findById);
+ this.listenTo(mediaActions, 'resize', this.resize);
+ }
+ }
+
+ createCollection () {
+ return new MediaCollection();
+ }
+
+ removeBulk (ids) {
+ var promises = [];
+
+ forEach(ids, (id) => {
+ promises.push(this.remove(id));
+ });
+
+ return Q.all(promises);
+ }
+
+ resize (data) {
+ return Q()
+ .then(() => {
+ var deferred = Q.defer();
+
+ $
+ .get('/api/media/resize', data)
+ .done((response) => {
+ this.getModel(data.id).set(response);
+ this.trigger('update', this.collection.toJSON());
+ })
+ .fail((error) => {
+ deferred.reject(error);
+ });
+
+ return deferred.promise;
+ });
+ }
+}
+
+export default new MediaStore();
diff --git a/lib/client/stores/pages.js b/lib/client/stores/pages.js
new file mode 100644
index 000000000..3266c7330
--- /dev/null
+++ b/lib/client/stores/pages.js
@@ -0,0 +1,57 @@
+import {ClientStore} from 'relax-framework';
+import PagesCollection from '../collections/pages';
+import pageActions from '../actions/page';
+import $ from 'jquery';
+import Q from 'q';
+import tabsStore from './tabs';
+
+class PagesStore extends ClientStore {
+ constructor () {
+ super();
+ }
+
+ init () {
+ if (this.isClient()) {
+ this.listenTo(pageActions, 'add', this.add);
+ this.listenTo(pageActions, 'remove', this.remove);
+ this.listenTo(pageActions, 'validateSlug', this.validateSlug);
+ this.listenTo(pageActions, 'update', this.update);
+ }
+ }
+
+ createCollection () {
+ var collection = new PagesCollection();
+
+ collection.on('remove', this.onRemove.bind(this));
+
+ return collection;
+ }
+
+ onRemove () {
+ tabsStore.fetchCollection();
+ }
+
+ validateSlug(slug, deferred) {
+ return Q()
+ .then(() => {
+ var deferred = Q.defer();
+
+ $
+ .get('/api/page/slug/'+slug)
+ .done((response) => {
+ deferred.resolve(response.count > 0);
+ })
+ .fail((error) => {
+ deferred.reject(error);
+ });
+
+ return deferred.promise;
+ });
+ }
+
+ findBySlug (slug) {
+ return this.findOne({slug});
+ }
+}
+
+export default new PagesStore();
diff --git a/lib/client/stores/schema-entries.js b/lib/client/stores/schema-entries.js
new file mode 100644
index 000000000..2c758b649
--- /dev/null
+++ b/lib/client/stores/schema-entries.js
@@ -0,0 +1,61 @@
+import {ClientStore} from 'relax-framework';
+import SchemaEntriesCollection from '../collections/schema-entries';
+import schemaEntriesActionsFactory from '../actions/schema-entries';
+import $ from 'jquery';
+import Q from 'q';
+
+class SchemaEntriesStore extends ClientStore {
+ constructor (slug) {
+ this.slug = slug;
+ this.schemaEntriesActions = schemaEntriesActionsFactory(slug);
+ super();
+ }
+
+ init () {
+ if (this.isClient()) {
+ this.listenTo(this.schemaEntriesActions, 'add', this.add);
+ this.listenTo(this.schemaEntriesActions, 'remove', this.remove);
+ this.listenTo(this.schemaEntriesActions, 'validateSlug', this.validateSlug);
+ this.listenTo(this.schemaEntriesActions, 'update', this.update);
+ }
+ }
+
+ createCollection () {
+ return new SchemaEntriesCollection([], {
+ slug: this.slug
+ });
+ }
+
+ validateSlug(slug) {
+ return Q()
+ .then(() => {
+ var deferred = Q.defer();
+
+ $
+ .get('/api/schema-entry/'+this.slug+'/slug/'+slug)
+ .done((response) => {
+ deferred.resolve(response.count > 0);
+ })
+ .fail((error) => {
+ deferred.error(error);
+ });
+
+ return deferred.promise;
+ });
+ }
+
+ findBySlug (slug) {
+ return this.findOne({slug});
+ }
+}
+
+var schemaEntriesStores = {};
+export default (slug) => {
+ if(schemaEntriesStores[slug]){
+ return schemaEntriesStores[slug];
+ } else {
+ var store = new SchemaEntriesStore(slug);
+ schemaEntriesStores[slug] = store;
+ return store;
+ }
+};
diff --git a/lib/client/stores/schemas.js b/lib/client/stores/schemas.js
new file mode 100644
index 000000000..bb618c933
--- /dev/null
+++ b/lib/client/stores/schemas.js
@@ -0,0 +1,48 @@
+import {ClientStore} from 'relax-framework';
+import SchemasCollection from '../collections/schemas';
+import schemaActions from '../actions/schema';
+import $ from 'jquery';
+import Q from 'q';
+
+class SchemasStore extends ClientStore {
+ constructor () {
+ super();
+ }
+
+ init () {
+ if (this.isClient()) {
+ this.listenTo(schemaActions, 'add', this.add);
+ this.listenTo(schemaActions, 'remove', this.remove);
+ this.listenTo(schemaActions, 'validateSlug', this.validateSlug);
+ this.listenTo(schemaActions, 'update', this.update);
+ }
+ }
+
+ createCollection () {
+ return new SchemasCollection();
+ }
+
+ validateSlug(slug) {
+ return Q()
+ .then(() => {
+ var deferred = Q.defer();
+
+ $
+ .get('/api/schema/slug/'+slug)
+ .done((response) => {
+ deferred.resolve(response.count > 0);
+ })
+ .fail((error) => {
+ deferred.error(error);
+ });
+
+ return deferred.promise;
+ });
+ }
+
+ findBySlug (slug) {
+ return this.findOne({slug});
+ }
+}
+
+export default new SchemasStore();
diff --git a/lib/client/stores/session.js b/lib/client/stores/session.js
new file mode 100644
index 000000000..ace421f0a
--- /dev/null
+++ b/lib/client/stores/session.js
@@ -0,0 +1,28 @@
+import $ from 'jquery';
+import Q from 'q';
+
+class SessionStore {
+ getSession () {
+ return Q()
+ .then(() => {
+ var deferred = Q.defer();
+
+ $
+ .get('/api/session')
+ .done((response) => {
+ if (response) {
+ deferred.resolve(response);
+ } else {
+ deferred.reject();
+ }
+ })
+ .fail((error) => {
+ deferred.reject(error);
+ });
+
+ return deferred.promise;
+ });
+ }
+}
+
+export default new SessionStore();
diff --git a/lib/client/stores/settings.js b/lib/client/stores/settings.js
new file mode 100644
index 000000000..4b5707ec1
--- /dev/null
+++ b/lib/client/stores/settings.js
@@ -0,0 +1,78 @@
+import every from 'lodash.every';
+import forEach from 'lodash.foreach';
+import {ClientStore} from 'relax-framework';
+import SettingsCollection from '../collections/settings';
+import settingActions from '../actions/settings';
+import Q from 'q';
+
+class SettingsStore extends ClientStore {
+ constructor () {
+ super();
+ }
+
+ init () {
+ if (this.isClient()) {
+ this.listenTo(settingActions, 'saveSettings', this.saveSettings);
+ }
+ }
+
+ createCollection () {
+ return new SettingsCollection([], {
+ ids: []
+ });
+ }
+
+ saveSettings(settings) {
+ var collection = this.getCollection();
+
+ var promises = [], optionsIds = false;
+
+ if (collection.options.ids) {
+ optionsIds = collection.options.ids;
+ delete collection.options.ids;
+ }
+
+ forEach(settings, (value, key) => {
+ promises.push(collection.createOrUpdate({_id: key, value}));
+ });
+
+ return Q()
+ .all(promises)
+ .then(() => {
+ var json = this.collection.toJSON();
+ this.trigger('update', json);
+ return json;
+ })
+ .catch((error) => {
+ this.trigger('error', error);
+ })
+ .fin(() => {
+ if (optionsIds) {
+ collection.options.ids = optionsIds;
+ }
+ });
+ }
+
+ findByIds (settingsIds) {
+ return Q()
+ .then(() => this.getCollectionAsync({ids: settingsIds}))
+ .then((collection) => {
+ var result;
+
+ if (!every(settingsIds, (settingId) => collection.get(settingId))) {
+ collection.options.ids = settingsIds;
+ result = Q()
+ .then(() => this.fetchCollection())
+ .then(() => {
+ return collection.toJSON();
+ });
+ } else {
+ result = Q().then(() => collection.toJSON());
+ }
+
+ return result;
+ });
+ }
+}
+
+export default new SettingsStore();
diff --git a/lib/client/stores/styles.js b/lib/client/stores/styles.js
new file mode 100644
index 000000000..e7aad4921
--- /dev/null
+++ b/lib/client/stores/styles.js
@@ -0,0 +1,24 @@
+import {ClientStore} from 'relax-framework';
+import StylesCollection from '../collections/styles';
+import styleActions from '../actions/style';
+
+class StylesStore extends ClientStore {
+ constructor () {
+ super();
+ }
+
+ init () {
+ if (this.isClient()) {
+ this.listenTo(styleActions, 'add', this.add);
+ this.listenTo(styleActions, 'remove', this.remove);
+ this.listenTo(styleActions, 'update', this.update);
+ }
+ }
+
+ createCollection () {
+ return new StylesCollection();
+ }
+
+}
+
+export default new StylesStore();
diff --git a/lib/client/stores/tabs.js b/lib/client/stores/tabs.js
new file mode 100644
index 000000000..59fa6faf3
--- /dev/null
+++ b/lib/client/stores/tabs.js
@@ -0,0 +1,22 @@
+import {ClientStore} from 'relax-framework';
+import TabsCollection from '../collections/tabs';
+import tabActions from '../actions/tab';
+
+class TabsStore extends ClientStore {
+ constructor () {
+ super();
+ }
+
+ init () {
+ if (this.isClient()) {
+ this.listenTo(tabActions, 'add', this.add);
+ this.listenTo(tabActions, 'remove', this.remove);
+ }
+ }
+
+ createCollection () {
+ return new TabsCollection();
+ }
+}
+
+export default new TabsStore();
diff --git a/lib/client/stores/users.js b/lib/client/stores/users.js
new file mode 100644
index 000000000..50b6dc51b
--- /dev/null
+++ b/lib/client/stores/users.js
@@ -0,0 +1,23 @@
+import {ClientStore} from 'relax-framework';
+import UsersCollection from '../collections/users';
+import userActions from '../actions/user';
+
+class UsersStore extends ClientStore {
+ constructor () {
+ super();
+ }
+
+ init () {
+ if (this.isClient()) {
+ this.listenTo(userActions, 'add', this.add);
+ this.listenTo(userActions, 'remove', this.remove);
+ this.listenTo(userActions, 'update', this.update);
+ }
+ }
+
+ createCollection () {
+ return new UsersCollection();
+ }
+}
+
+export default new UsersStore();
diff --git a/lib/colors/index.js b/lib/colors/index.js
new file mode 100644
index 000000000..f1e23932f
--- /dev/null
+++ b/lib/colors/index.js
@@ -0,0 +1,71 @@
+import merge from 'lodash.merge';
+import {Events} from 'backbone';
+import Colr from 'colr';
+
+import colorsStore from '../client/stores/colors';
+
+class ColorsManager {
+ constructor () {
+ if (this.isClient()) {
+ var collection = colorsStore.getCollection();
+ this.listenTo(collection, 'update change', this.onUpdate.bind(this));
+ }
+ }
+
+ isClient () {
+ return typeof document !== 'undefined';
+ }
+
+ onUpdate () {
+ this.trigger('update');
+ }
+
+ getColor (colorObj) {
+ if (typeof colorObj === 'object') {
+ var hex = '#000000';
+ var opacity = colorObj.opacity;
+ var label = 'Custom';
+
+ if (colorObj.type === 'palette') {
+ const collection = colorsStore.getCollection({fetch: false});
+ var model = collection.get(colorObj.value);
+
+ if (model) {
+ var json = model.toJSON();
+ hex = json.value;
+ label = json.label;
+ }
+ } else {
+ var isOk = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(colorObj.value);
+ hex = isOk ? colorObj.value : '#000000';
+ }
+
+ return {
+ colr: Colr().fromHex(hex),
+ opacity: opacity,
+ label
+ };
+ }
+ return {
+ colr: Colr().fromHex('#000000'),
+ opacity: 100,
+ label: 'Custom'
+ };
+ }
+
+ getColorString (colorObj) {
+ var color = (colorObj && colorObj.colr) ? colorObj : this.getColor(colorObj);
+ if (color) {
+ if (color.opacity === 100) {
+ return color.colr.toHex();
+ } else {
+ var rgb = color.colr.toRgbObject();
+ return 'rgba('+rgb.r+', '+rgb.g+', '+rgb.b+', '+(color.opacity/100)+')';
+ }
+ }
+ return '#000000';
+ }
+}
+merge(ColorsManager.prototype, Events);
+
+export default new ColorsManager();
diff --git a/lib/components/a.jsx b/lib/components/a.jsx
new file mode 100644
index 000000000..93db983d9
--- /dev/null
+++ b/lib/components/a.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import {Component, Router} from 'relax-framework';
+
+export default class A extends Component {
+ onClick (event) {
+ var url = this.props.href;
+ if (url && url.charAt(0) === '/') {
+ event.preventDefault();
+ Router.prototype.navigate(url, {trigger: true});
+ }
+
+ if (this.props.afterClick) {
+ this.props.afterClick();
+ }
+ }
+
+ render () {
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
diff --git a/lib/components/active-panel.jsx b/lib/components/active-panel.jsx
new file mode 100644
index 000000000..e1339aa3d
--- /dev/null
+++ b/lib/components/active-panel.jsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+
+export default class ActivePanel extends Component {
+ render () {
+ return (
+
+ {this.context.activePanel}
+
+ );
+ }
+}
+
+ActivePanel.contextTypes = {
+ activePanel: React.PropTypes.any
+};
diff --git a/lib/components/admin/index.jsx b/lib/components/admin/index.jsx
new file mode 100644
index 000000000..40c7e2e81
--- /dev/null
+++ b/lib/components/admin/index.jsx
@@ -0,0 +1,166 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import TopMenu from './top-menu';
+import MenuBar from './menu-bar';
+import Backbone from 'backbone';
+
+import panels from './panels';
+import Overlay from '../overlay';
+
+export default class Admin extends Component {
+
+ getInitialState () {
+ this.changeDisplayBind = this.changeDisplay.bind(this);
+ this.previewToggleBind = this.previewToggle.bind(this);
+ this.addOverlayBind = this.addOverlay.bind(this);
+ this.closeOverlayBind = this.closeOverlay.bind(this);
+
+ return {
+ display: 'desktop',
+ editing: true,
+ lastDashboard: '/admin',
+ overlay: false
+ };
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (!nextProps.page) {
+ this.updateLastDashboardPage();
+ }
+ }
+
+ componentDidUpdate (prevProps, prevState) {
+ if (prevState.display !== this.state.display) {
+ /* jshint ignore:start */
+ window.dispatchEvent(new Event('resize'));
+ /* jshint ignore:end */
+ }
+ }
+
+ getChildContext () {
+ return {
+ breadcrumbs: this.props.breadcrumbs,
+ pages: this.props.pages,
+ page: this.props.page,
+ elements: this.props.elements,
+ media: this.props.media,
+ settings: this.props.settings,
+ colors: this.props.colors,
+ schemas: this.props.schemas,
+ schema: this.props.schema,
+ schemaEntries: this.props.schemaEntries,
+ schemaEntry: this.props.schemaEntry,
+ query: this.props.query,
+ activePanelType: this.props.activePanelType,
+ display: this.state.display,
+ changeDisplay: this.changeDisplayBind,
+ editing: this.state.editing,
+ previewToggle: this.previewToggleBind,
+ user: this.props.user,
+ users: this.props.users,
+ editUser: this.props.editUser,
+ tabs: this.props.tabs,
+ lastDashboard: this.state.lastDashboard,
+ addOverlay: this.addOverlayBind,
+ closeOverlay: this.closeOverlayBind
+ };
+ }
+
+ updateLastDashboardPage () {
+ this.setState({
+ lastDashboard: '/' + Backbone.history.getFragment()
+ });
+ }
+
+ changeDisplay (display) {
+ this.setState({
+ display
+ });
+ }
+
+ previewToggle () {
+ this.setState({
+ editing: !this.state.editing
+ });
+ }
+
+ addOverlay (overlay) {
+ this.setState({
+ overlay
+ });
+ }
+
+ closeOverlay () {
+ this.setState({
+ overlay: false
+ });
+ }
+
+ renderActivePanel () {
+ if (this.props.activePanelType && panels[this.props.activePanelType]) {
+ let Panel = panels[this.props.activePanelType];
+ return (
+
+ );
+ }
+ }
+
+ renderOverlay () {
+ if (this.state.overlay !== false) {
+ return (
+
+ {React.cloneElement(this.state.overlay, {onClose: this.closeOverlayBind})}
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+
+ {!this.props.page &&
}
+
+ {this.renderActivePanel()}
+
+
+ {this.renderOverlay()}
+
+ );
+ }
+}
+
+Admin.propTypes = {
+ activePanelType: React.PropTypes.string,
+ breadcrumbs: React.PropTypes.array,
+ user: React.PropTypes.object,
+ users: React.PropTypes.array
+};
+
+Admin.childContextTypes = {
+ breadcrumbs: React.PropTypes.array,
+ pages: React.PropTypes.array,
+ page: React.PropTypes.object,
+ elements: React.PropTypes.object,
+ media: React.PropTypes.array,
+ settings: React.PropTypes.array,
+ colors: React.PropTypes.array,
+ schemas: React.PropTypes.array,
+ schema: React.PropTypes.object,
+ schemaEntries: React.PropTypes.array,
+ schemaEntry: React.PropTypes.object,
+ query: React.PropTypes.object,
+ activePanelType: React.PropTypes.string,
+ display: React.PropTypes.string.isRequired,
+ changeDisplay: React.PropTypes.func.isRequired,
+ editing: React.PropTypes.bool.isRequired,
+ previewToggle: React.PropTypes.func.isRequired,
+ user: React.PropTypes.object.isRequired,
+ users: React.PropTypes.array,
+ editUser: React.PropTypes.object,
+ tabs: React.PropTypes.array,
+ lastDashboard: React.PropTypes.string,
+ addOverlay: React.PropTypes.func,
+ closeOverlay: React.PropTypes.func
+};
diff --git a/lib/components/admin/init.jsx b/lib/components/admin/init.jsx
new file mode 100644
index 000000000..51a205c5f
--- /dev/null
+++ b/lib/components/admin/init.jsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import {Component, Router} from 'relax-framework';
+import OptionsList from '../options-list';
+import {Types} from '../../types';
+
+import usersActions from '../../client/actions/user';
+
+export default class Init extends Component {
+ getInitialState () {
+ return {
+ user: {
+ username: '',
+ name: '',
+ password: '',
+ email: ''
+ }
+ };
+ }
+
+ onChange (id, value) {
+ this.state.user[id] = value;
+ this.setState({
+ user: this.state.user
+ });
+ }
+
+ onSubmit (event) {
+ event.preventDefault();
+
+ usersActions
+ .add(this.state.user)
+ .then(() => {
+ Router.prototype.navigate('/admin/login', {trigger: true});
+ });
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+Init.options = [
+ {
+ label: 'Username',
+ type: Types.String,
+ id: 'username',
+ default: ''
+ },
+ {
+ label: 'Password',
+ type: Types.String,
+ id: 'password',
+ default: ''
+ },
+ {
+ label: 'Name',
+ type: Types.String,
+ id: 'name',
+ default: ''
+ },
+ {
+ label: 'Email',
+ type: Types.String,
+ id: 'email',
+ default: ''
+ }
+];
diff --git a/lib/components/admin/login.jsx b/lib/components/admin/login.jsx
new file mode 100644
index 000000000..d37e0cd21
--- /dev/null
+++ b/lib/components/admin/login.jsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import {Types} from '../../types';
+
+export default class Login extends Component {
+
+ onSubmit (event) {
+ event.preventDefault();
+ React.findDOMNode(this.refs.form).submit();
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+Login.options = [
+ {
+ label: 'Username',
+ type: Types.String,
+ id: 'username',
+ default: ''
+ },
+ {
+ label: 'Password',
+ type: Types.String,
+ id: 'password',
+ default: ''
+ }
+];
diff --git a/lib/components/admin/menu-bar.jsx b/lib/components/admin/menu-bar.jsx
new file mode 100644
index 000000000..ced16ea65
--- /dev/null
+++ b/lib/components/admin/menu-bar.jsx
@@ -0,0 +1,129 @@
+import A from '../a';
+import React from 'react';
+import {Component} from 'relax-framework';
+import cx from 'classnames';
+import Utils from '../../utils';
+
+export default class MenuBar extends Component {
+ getInitialState () {
+ return {
+ userOpened: false
+ };
+ }
+
+ toggleUser () {
+ this.setState({
+ userOpened: !this.state.userOpened
+ });
+ }
+
+ renderLink (link) {
+ let active = this.context.activePanelType === link.type || (this.context.breadcrumbs && this.context.breadcrumbs.length > 0 && this.context.breadcrumbs[0].type === link.type);
+
+ return (
+
+ {link.label}
+
+ );
+ }
+
+ renderOpenedUser () {
+ if (this.state.userOpened) {
+ const editLink = '/admin/users/' + this.context.user.username;
+ return (
+
+ );
+ }
+ }
+
+ renderUser () {
+ if (this.context.user) {
+ var url = Utils.getGravatarImage(this.context.user.email, 25);
+ return (
+
+
+

+
+
{this.context.user.name}
+
+ {this.state.userOpened ? 'arrow_drop_down' : 'arrow_drop_up'}
+ {this.renderOpenedUser()}
+
+
+ );
+ }
+ }
+
+ render () {
+ var links = [
+ {
+ type: 'settings',
+ link: '/admin',
+ label: 'General Settings'
+ },
+ {
+ type: 'pages',
+ link: '/admin/pages',
+ label: 'Pages'
+ },
+ {
+ type: 'media',
+ link: '/admin/media',
+ label: 'Media'
+ },
+ {
+ type: 'fonts',
+ link: '/admin/fonts',
+ label: 'Fonts'
+ },
+ {
+ type: 'colors',
+ link: '/admin/colors',
+ label: 'Colors'
+ },
+ {
+ type: 'schemas',
+ link: '/admin/schemas',
+ label: 'Schemas'
+ },
+ {
+ type: 'users',
+ link: '/admin/users',
+ label: 'Users'
+ }
+ ];
+
+ return (
+
+ );
+ }
+}
+
+MenuBar.propTypes = {
+};
+
+MenuBar.contextTypes = {
+ activePanelType: React.PropTypes.string,
+ breadcrumbs: React.PropTypes.any,
+ user: React.PropTypes.object
+};
diff --git a/lib/components/admin/panels/colors/color.jsx b/lib/components/admin/panels/colors/color.jsx
new file mode 100644
index 000000000..7573ef1a7
--- /dev/null
+++ b/lib/components/admin/panels/colors/color.jsx
@@ -0,0 +1,102 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import cloneDeep from 'lodash.clonedeep';
+import Lightbox from '../../../lightbox';
+
+import colorActions from '../../../../client/actions/colors';
+
+export default class Color extends Component {
+ getInitialState () {
+ return {
+ removing: false
+ };
+ }
+
+ onEdit (event) {
+ event.preventDefault();
+ this.props.onEdit(cloneDeep(this.props.color));
+ }
+
+ onRemove (event) {
+ event.preventDefault();
+ this.setState({
+ removing: true
+ });
+ }
+
+ cancelRemove (event) {
+ event.preventDefault();
+ this.setState({
+ removing: false
+ });
+ }
+
+ confirmRemove (event) {
+ event.preventDefault();
+ colorActions.remove(this.props.color._id);
+ this.setState({
+ removing: false
+ });
+ }
+
+ onDuplicate (event) {
+ event.preventDefault();
+ var cloneColor = cloneDeep(this.props.color);
+ delete cloneColor._id;
+ colorActions.add(cloneColor);
+ }
+
+ renderRemoving () {
+ if (this.state.removing) {
+ const label = 'Are you sure you want to remove the color '+this.props.color.label+' from your colors palette?';
+ const label1 = 'Every component in your pages using this color will loose the link to it! Notice you can change its value and label to customize and not lose the link.';
+ return (
+
+ {label}
+ {label1}
+
+
+ );
+ }
+ }
+
+ render () {
+ var colorStyle = {
+ backgroundColor: this.props.color.value
+ };
+
+ return (
+
+
+
+
{this.props.color.label}
+
{this.props.color.value}
+
+
+ {this.renderRemoving()}
+
+ );
+ }
+
+}
+
+Color.propTypes = {
+ color: React.PropTypes.object.isRequired,
+ onEdit: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/admin/panels/colors/edit.jsx b/lib/components/admin/panels/colors/edit.jsx
new file mode 100644
index 000000000..06ea5b728
--- /dev/null
+++ b/lib/components/admin/panels/colors/edit.jsx
@@ -0,0 +1,65 @@
+import {Component} from 'relax-framework';
+import ColorPicker from 'react-colorpicker';
+import Input from '../../../input';
+import React from 'react';
+import Lightbox from '../../../lightbox';
+
+import colorsActions from '../../../../client/actions/colors';
+
+export default class EditColor extends Component {
+ getInitialState () {
+ return {
+ value: this.props.value || {
+ label: '',
+ value: '#ffffff'
+ }
+ };
+ }
+
+ closeEdit () {
+ this.props.onClose();
+ }
+
+ onEditColorChange (color) {
+ this.state.value.value = color.toHex();
+ this.setState({
+ value: this.state.value
+ });
+ }
+
+ onTitleChange (value) {
+ this.state.value.label = value;
+ this.setState({
+ value: this.state.value
+ });
+ }
+
+ submit () {
+ if (this.state.value._id) {
+ colorsActions.update(this.state.value).then(() => this.closeEdit());
+ } else {
+ colorsActions.add(this.state.value).then(() => this.closeEdit());
+ }
+ }
+
+ render () {
+ var isNew = this.props.value ? false : true;
+ var title = isNew ? 'Adding new color to palette' : 'Editing '+this.state.value.label;
+ var btn = isNew ? 'Add color to palette' : 'Change color';
+
+ return (
+
+
+
+
+
+ {btn}
+
+ );
+ }
+}
+
+EditColor.propTypes = {
+ value: React.PropTypes.any,
+ onClose: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/admin/panels/colors/index.jsx b/lib/components/admin/panels/colors/index.jsx
new file mode 100644
index 000000000..beb7ff8ed
--- /dev/null
+++ b/lib/components/admin/panels/colors/index.jsx
@@ -0,0 +1,94 @@
+import {Component} from 'relax-framework';
+import Color from './color';
+import Edit from './edit';
+import React from 'react';
+
+import colorsStore from '../../../../client/stores/colors';
+
+export default class Colors extends Component {
+ getInitialState () {
+ return {
+ edit: false,
+ colors: this.context.colors
+ };
+ }
+
+ getInitialCollections () {
+ return {
+ colors: colorsStore.getCollection()
+ };
+ }
+
+ onAddNew (event) {
+ event.preventDefault();
+ this.setState({
+ edit: true,
+ editingColor: false
+ });
+ }
+
+ onEdit (color) {
+ this.setState({
+ edit: true,
+ editingColor: color
+ });
+ }
+
+ closeEdit () {
+ this.setState({
+ edit: false,
+ editingColor: false
+ });
+ }
+
+ renderColor (color) {
+ return (
+
+ );
+ }
+
+ renderColors () {
+ if(this.state.colors && this.state.colors.length > 0){
+ return (
+
+ {this.state.colors.map(this.renderColor, this)}
+
+ );
+ }
+ else {
+ return (
+ You don't have any color in your palette yet, you can add new colors on the right panel
+ );
+ }
+ }
+
+ renderEdit () {
+ if (this.state.edit) {
+ return (
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+
+ {this.renderColors()}
+
+ {this.renderEdit()}
+
+ );
+ }
+}
+
+Colors.contextTypes = {
+ colors: React.PropTypes.array.isRequired
+};
diff --git a/lib/components/admin/panels/colors/new.jsx b/lib/components/admin/panels/colors/new.jsx
new file mode 100644
index 000000000..325ef06ac
--- /dev/null
+++ b/lib/components/admin/panels/colors/new.jsx
@@ -0,0 +1,96 @@
+import {Component} from 'relax-framework';
+import ColorPicker from 'react-colorpicker';
+import ColorActions from '../../../../client/actions/colors';
+import Input from '../../../input';
+import React from 'react';
+
+export default class NewColor extends Component {
+
+ getInitialState () {
+ return {
+ title: 'Add new color',
+ button: 'Add new color',
+ titleInput: '',
+ colorInput: '#000000'
+ };
+ }
+
+ onTitleChange (value) {
+ this.setState({
+ titleInput: value
+ });
+ }
+
+ onColorChange (color) {
+ this.setState({
+ colorInput: color.toHex()
+ });
+ }
+
+ addNew (event) {
+ event.preventDefault();
+
+ if(this.props.selected){
+ ColorActions.update({
+ _id: this.props.selected._id,
+ value: this.state.colorInput,
+ label: this.state.titleInput
+ });
+ }
+ else{
+ ColorActions
+ .add({
+ label: this.state.titleInput,
+ value: this.state.colorInput
+ })
+ .then(() => {
+ this.setState(this.getInitialState);
+ });
+ }
+ }
+
+ remove (event) {
+ event.preventDefault();
+
+ ColorActions.remove(this.props.selected._id);
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if(nextProps.selected) {
+ this.setState({
+ title: 'Editing '+nextProps.selected.label+' color',
+ titleInput: nextProps.selected.label,
+ colorInput: nextProps.selected.value,
+ button: 'Change'
+ });
+ }
+ else if(this.props.selected){
+ this.setState(this.getInitialState());
+ }
+ }
+
+ renderRemoveButton () {
+ if(this.props.selected){
+ return (
+ Remove
+ );
+ }
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+NewColor.propTypes = {
+ selected: React.PropTypes.any
+};
diff --git a/lib/components/admin/panels/fonts/custom-fonts.jsx b/lib/components/admin/panels/fonts/custom-fonts.jsx
new file mode 100644
index 000000000..cbb925086
--- /dev/null
+++ b/lib/components/admin/panels/fonts/custom-fonts.jsx
@@ -0,0 +1,218 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import forEach from 'lodash.foreach';
+import Font from './font';
+import Utils from '../../../../utils';
+import Upload from '../../../upload';
+
+export default class CustomFonts extends Component {
+ getInitialState () {
+ return {
+ customLoading: false,
+ customError: false,
+ titleInput: '',
+ files: []
+ };
+ }
+
+ removeCustomFont (family, event) {
+ event.preventDefault();
+ this.props.removeCustomFont(family);
+ }
+
+ submitCustomFont (event) {
+ event.preventDefault();
+
+ // Validation of parameters
+ if(this.state.titleInput === ""){
+ this.setState({
+ customError: "Fill in your custom font family title"
+ });
+ return;
+ }
+
+ if(this.state.files.length === 0){
+ this.setState({
+ customError: "You haven't upload any font file"
+ });
+ return;
+ }
+
+ // Get each font file type
+ var types = [];
+ var filesInfo = [];
+ var files = this.state.files;
+ var re = /(?:\.([^.]+))?$/;
+ for(var i = 0; i < files.length; i++){
+ const file = files[i];
+ if(file.name && file.xhr && file.xhr.response){
+ const type = re.exec(file.name)[1];
+
+ if(type !== undefined){
+ types.push(type);
+ }
+
+ // server response
+ filesInfo.push({
+ name: file.name,
+ info: JSON.parse(file.xhr.response)
+ });
+ }
+ }
+
+ // At least woff is needed
+ var woff = types.indexOf("woff");
+ if(woff === -1){
+ this.setState({
+ customError: "You need to upload the .woff font file type"
+ });
+ return;
+ }
+
+ // .eot needed for ie9
+ var eot = types.indexOf("eot");
+ if(eot === -1){
+ this.setState({
+ customError: "You need to upload the .eot font file as well to support IE9"
+ });
+ return;
+ }
+
+ // .ttf
+ var ttf = types.indexOf("ttf");
+ if(ttf === -1){
+ this.setState({
+ customError: "Upload the ttf format to support Safari, Android and iOS"
+ });
+ return;
+ }
+
+ this.props.submitCustomFont( this.state.titleInput, filesInfo, types)
+ .then(() => {
+ forEach(this.state.files, (file) => {
+ file.previewElement.parentNode.removeChild(file.previewElement);
+ });
+
+ this.setState({
+ titleInput: '',
+ files: []
+ });
+ })
+ .catch((error) => {
+ this.setState({
+ customError: "Error uploading fonts: "+error
+ });
+ });
+ }
+
+ onTitleChange (event) {
+ this.setState({
+ titleInput: event.target.value
+ });
+ }
+
+ customFontFileSuccess (file) {
+ this.state.files.push(file);
+ }
+
+ customFontFileRemove (file) {
+ var files = this.state.files;
+ var index = -1;
+ for(var i = 0; i < files.length; i++){
+ if(files[i].name === file.name){
+ index = i;
+ break;
+ }
+ }
+
+ if(index !== -1){
+ this.state.files.splice(index, 1);
+ this.refs.uploadedFonts.querySelector(file.previewElement).remove();
+ }
+ }
+
+ renderList () {
+ if(this.props.customFonts && this.props.customFonts.length > 0){
+ var customFonts = [];
+
+ forEach(this.props.customFonts, (customFont) => {
+ let family = customFont.family;
+ customFonts.push(
+
+
+
+
{Utils.filterFontFamily(family)}
+
Custom font
+
+
+
+ );
+ });
+
+ return customFonts;
+ }
+ }
+
+ renderCover () {
+ if(this.state.customLoading){
+ return (
+
+ );
+ }
+ }
+
+ renderError () {
+ if(this.state.customError !== false){
+ return (
+ {this.state.customError}
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+ {this.renderList()}
+
+
+
+ Add new custom font
+
+
+ {this.renderCover()}
+
+ );
+ }
+}
+
+CustomFonts.propTypes = {
+ removeCustomFont: React.PropTypes.func.isRequired,
+ submitCustomFont: React.PropTypes.func.isRequired,
+ customFonts: React.PropTypes.array.isRequired,
+ previewText: React.PropTypes.string.isRequired
+};
diff --git a/lib/components/admin/panels/fonts/font.jsx b/lib/components/admin/panels/fonts/font.jsx
new file mode 100644
index 000000000..5bb2beafe
--- /dev/null
+++ b/lib/components/admin/panels/fonts/font.jsx
@@ -0,0 +1,24 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import Utils from '../../../../utils';
+
+export default class Font extends Component {
+
+ render () {
+
+ var style = this.props.style || {};
+ style.fontFamily = this.props.family;
+ Utils.processFVD(style, this.props.fvd);
+
+ var content = '';
+
+ if(this.props.input) {
+ content = ;
+ }
+ else {
+ content = {this.props.text}
;
+ }
+
+ return content;
+ }
+}
diff --git a/lib/components/admin/panels/fonts/fonts-list.jsx b/lib/components/admin/panels/fonts/fonts-list.jsx
new file mode 100644
index 000000000..7693400ad
--- /dev/null
+++ b/lib/components/admin/panels/fonts/fonts-list.jsx
@@ -0,0 +1,78 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import forEach from 'lodash.foreach';
+import Font from './font';
+import Utils from '../../../../utils';
+
+export default class Fonts extends Component {
+ changePreviewText (event) {
+ this.props.onPreviewTextChange(event.target.value);
+ }
+ changePreviewLayout (to, event) {
+ event.preventDefault();
+ this.props.onPreviewLayoutChange(to);
+ }
+
+ renderList () {
+ var list = [];
+ forEach(this.props.data.fonts, (variants, family) => {
+ variants.map((variant, ind) => {
+ var key = (family+variant).replace(/ /g, '_');
+ var font = (
+
+
+
+
{Utils.filterFontFamily(family)}
+
{Utils.filterFVD(variant)}
+
+
+ );
+
+ list.push(font);
+ }, this);
+ });
+
+ if(list.length === 0){
+ return (
+
+
+ error_outline
+
+
+
No fonts added yet!
+
Click on 'add fonts' button above to add new
+
+
+ );
+ }
+
+ return list;
+ }
+
+ renderCover () {
+ if(this.props.loading){
+ return (
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+ {this.renderList()}
+ {this.renderCover()}
+
+
+ );
+ }
+}
+
+Fonts.propTypes = {
+ data: React.PropTypes.object.isRequired,
+ onPreviewTextChange: React.PropTypes.func.isRequired,
+ loading: React.PropTypes.bool.isRequired
+};
diff --git a/lib/components/admin/panels/fonts/index.jsx b/lib/components/admin/panels/fonts/index.jsx
new file mode 100644
index 000000000..553461fa1
--- /dev/null
+++ b/lib/components/admin/panels/fonts/index.jsx
@@ -0,0 +1,487 @@
+import {Component} from 'relax-framework';
+import CustomFonts from './custom-fonts';
+import FontsList from './fonts-list';
+import merge from 'lodash.merge';
+import React from 'react';
+import InputValidation from '../../../input-validation';
+import Lightbox from '../../../lightbox';
+import forEach from 'lodash.foreach';
+import Q from 'q';
+import cx from 'classnames';
+
+import settingsActions from '../../../../client/actions/settings';
+import fontsActions from '../../../../client/actions/fonts';
+
+export default class Fonts extends Component {
+ getInitialState () {
+ var settings = this.parseSettings(this.context.settings);
+
+ return {
+ data: merge(Fonts.defaults, settings.fonts || {}),
+ tab: 0,
+ loading: false,
+ manager: false
+ };
+ }
+
+ changeTab (tab, event) {
+ event.preventDefault();
+
+ this.setState({
+ tab: tab
+ });
+ }
+
+ save () {
+ settingsActions
+ .saveSettings({fonts: this.state.data});
+ }
+
+ loadingFontsFinished () {
+ this.state.data.fonts = merge({}, this.newFonts);
+ this.newFonts = null;
+ delete this.newFonts;
+
+ this.save();
+
+ this.setState({
+ loading: false,
+ data: this.state.data
+ });
+ }
+
+ fontActive (familyName, fvd) {
+ if(!this.newFonts[familyName]){
+ this.newFonts[familyName] = [];
+ }
+
+ this.newFonts[familyName].push(fvd);
+ }
+
+ loadFonts () {
+ var events = {
+ active: this.loadingFontsFinished.bind(this),
+ fontactive: this.fontActive.bind(this)
+ };
+
+ this.newFonts = {};
+ var params = merge({}, events, this.state.data.webfontloader);
+
+ WebFont.load(params);
+
+ this.setState({
+ loading: true
+ });
+ }
+
+ changedInput (value) {
+ var inputData = this.state.data.input;
+ var previousValid;
+
+ this.state.data.webfontloader = this.state.data.webfontloader || {};
+ var webfontloader = this.state.data.webfontloader;
+
+ // Google fonts validation
+ if(this.state.tab === 0){
+ previousValid = inputData.google.valid;
+ inputData.google.input = value;
+ inputData.google.valid = false;
+
+ var paramsStr = value.split("?");
+
+ // Not valid
+ if(paramsStr.length !== 2){
+ return;
+ }
+
+ var params = {}, re = /[?&]?([^=]+)=([^&]*)/g;
+ var tokens = re.exec(paramsStr[1]);
+
+ while(tokens) {
+ params[decodeURIComponent(tokens[1])] = decodeURIComponent(tokens[2]);
+ tokens = re.exec(paramsStr[1]);
+ }
+
+ // Exists
+ if(params.family){
+ inputData.google.valid = true;
+
+ if(!webfontloader.google){
+ webfontloader.google = {
+ families: []
+ };
+ }
+ else{
+ webfontloader.google.families = [];
+ }
+
+ // Object {family: "Lobster|Open+Sans:400,700", subset: "latin,cyrillic-ext,cyrillic"}
+ var families = params.family.split("|");
+ for(var i = 0; i < families.length; i++){
+ var googleFont = families[i];
+
+ // Might not have multiple weights
+ if(families[i].indexOf(':') === -1){
+ googleFont += ":";
+ }
+
+ if(params.subset){
+ googleFont += ":"+params.subset;
+ }
+ else{
+ googleFont += ":latin";
+ }
+
+ webfontloader.google.families.push(googleFont);
+ }
+
+ this.loadFonts();
+ }
+
+ if(!inputData.google.valid && webfontloader.google){
+ delete webfontloader.google;
+ }
+
+ if(previousValid && !inputData.google.valid){
+ this.loadFonts();
+ }
+ }
+
+ // Typekit validation
+ else if(this.state.tab === 1){
+ previousValid = inputData.typekit.valid;
+ inputData.typekit.input = value;
+ inputData.typekit.valid = false;
+
+ if(value.length === 7){
+ inputData.typekit.valid = true;
+
+ webfontloader.typekit = webfontloader.typekit || {};
+
+ webfontloader.typekit.id = value;
+ this.loadFonts();
+ }
+
+ if(!inputData.typekit.valid && webfontloader.typekit){
+ delete webfontloader.typekit;
+ }
+
+ if(previousValid && !inputData.typekit.valid){
+ this.loadFonts();
+ }
+ }
+
+ // Fonts.com (monotype) validation
+ else if(this.state.tab === 2){
+ previousValid = inputData.monotype.valid;
+ inputData.monotype.input = value;
+ inputData.monotype.valid = false;
+
+ if(value.length === 36){
+ var regex = new RegExp( /[0-9|a-z]{8}-[0-9|a-z]{4}-[0-9|a-z]{4}-[0-9|a-z]{4}-[0-9|a-z]{12}/g );
+ inputData.monotype.valid = regex.test(value);
+ }
+
+ // valid
+ if(inputData.monotype.valid){
+ webfontloader.monotype = webfontloader.monotype || {};
+
+ webfontloader.monotype.projectId = value;
+ this.loadFonts();
+ }
+ else if(webfontloader.monotype){
+ delete webfontloader.monotype;
+ }
+
+ if(previousValid && !inputData.monotype.valid){
+ this.loadFonts();
+ }
+ }
+
+ // Font Deck validation
+ else if(this.state.tab === 3){
+ previousValid = inputData.fontdeck.valid;
+ inputData.fontdeck.input = value;
+ inputData.fontdeck.valid = false;
+
+ if(value.length === 5){
+ inputData.fontdeck.valid = true;
+ webfontloader.fontdeck = webfontloader.fontdeck || {};
+
+ webfontloader.fontdeck.id = value;
+ this.loadFonts();
+ }
+
+ if(!inputData.fontdeck.valid && webfontloader.fontdeck){
+ delete webfontloader.fontdeck;
+ }
+
+ if(previousValid && !inputData.fontdeck.valid){
+ this.loadFonts();
+ }
+ }
+ }
+
+ onPreviewTextChange (event) {
+ this.state.data.previewText = event.target.value;
+ this.setState({
+ data: this.state.data
+ });
+ }
+
+ onPreviewLayoutChange (layout, event) {
+ event.preventDefault();
+ this.state.data.previewLayout = layout;
+ this.setState({
+ data: this.state.data
+ });
+ }
+
+
+ removeCustomFont (family) {
+ forEach(this.state.data.customFonts, (obj, index) => {
+ if(obj.family === family){
+
+ fontsActions.remove(obj.id);
+
+ this.state.data.customFonts.splice(index, 1);
+
+ var ind = this.state.data.webfontloader.custom.families.indexOf(family);
+ if(ind !== -1){
+ this.state.data.webfontloader.custom.families.splice(ind, 1);
+ }
+
+ this.loadFonts();
+
+ return false;
+ }
+ });
+ }
+
+ onCustomSubmit (title, files, types) {
+ return Q()
+ .then(() => fontsActions.submit(files))
+ .then((id) => {
+ // All good -> process
+ var webfontloader = this.state.data.webfontloader;
+ this.state.customError = false;
+
+ // map types to file
+ var map = {};
+ for(var a = 0; a < files.length; a++){
+ map[types[a]] = files[a].name;
+ }
+
+ var rule = "font-family: '"+title+"';";
+ rule += "src: url('/fonts/"+id+"/"+map.eot+"'); ";
+ rule += "src: ";
+
+ // try woff2
+ var woff2 = types.indexOf("woff2");
+ if(woff2 !== -1){
+ rule += "url('/fonts/"+id+"/"+map.woff2+"'), ";
+ }
+
+ rule += "url('/fonts/"+id+"/"+map.woff+"'), ";
+ rule += "url('/fonts/"+id+"/"+map.ttf+"'); ";
+
+ var s = document.createElement('style');
+ s.type = "text/css";
+ document.getElementsByTagName('head')[0].appendChild(s);
+
+ var css = "@font-face {" + rule + "}";
+ if (s.styleSheet){
+ s.styleSheet.cssText = css;
+ } else {
+ s.appendChild(document.createTextNode(css));
+ }
+
+ webfontloader.custom = webfontloader.custom || { families: [] };
+ webfontloader.custom.families.push(title);
+
+ this.state.data.customFonts = this.state.data.customFonts || [];
+ this.state.data.customFonts.push({
+ family: title,
+ files: map,
+ id: id
+ });
+
+ this.loadFonts();
+
+ return true;
+ })
+ .catch((error) => {
+ console.log(error);
+ });
+ }
+
+ openManager (event) {
+ event.preventDefault();
+ this.setState({
+ manager: true
+ });
+ }
+
+ closeManager () {
+ this.setState({
+ manager: false
+ });
+ }
+
+ renderTabButton (tabButton, a) {
+ var is_valid = this.state.data.input[tabButton.lib].valid;
+
+ if(tabButton.lib === 'custom'){
+ is_valid = this.state.data.customFonts && this.state.data.customFonts.length > 0;
+ }
+
+ var classes = 'fp-tab '+(this.state.tab === a ? 'active ' : '')+(is_valid ? 'validated ' : '');
+ return (
+
+
+ {tabButton.title}
+
+ );
+ }
+
+ renderTabContent () {
+ var currentTab = Fonts.tabs[this.state.tab];
+
+ // Font library
+ if(this.state.tab !== Fonts.tabs.length - 1){
+ const lib = currentTab.lib;
+ const input = this.state.data.input[lib];
+
+ return (
+
+
{currentTab.label}
+
+
+ );
+ }
+ // Custom fonts
+ else{
+ return (
+
+ );
+ }
+ }
+
+ renderManager () {
+ if (this.state.manager) {
+ return (
+
+
+
+ {Fonts.tabs.map(this.renderTabButton.bind(this))}
+
+
+ {this.renderTabContent()}
+
+
+
+ );
+ }
+ }
+
+ render () {
+
+ return (
+
+
+
+
+
+ {this.renderManager()}
+
+ );
+ }
+}
+
+Fonts.contextTypes = {
+ settings: React.PropTypes.array.isRequired
+};
+
+Fonts.tabs = [
+{
+ icon: 'fp-tab-ic fp-google',
+ title: 'Google Fonts',
+ lib: 'google',
+ label: 'Google fonts fonts link'
+},
+{
+ icon: 'fp-tab-ic fp-typekit',
+ title: 'Typekit',
+ lib: 'typekit',
+ label: 'Typekit kit id'
+},
+{
+ icon: 'fp-tab-ic fp-fontscom',
+ title: 'Fonts.com',
+ lib: 'monotype',
+ label: 'Fonts.com project id'
+},
+{
+ icon: 'fp-tab-ic fp-fontdeck',
+ title: 'Font Deck',
+ lib: 'fontdeck',
+ label: 'Fontdeck project id'
+},
+{
+ icon: 'fp-tab-icon fa fa-font',
+ title: 'Custom Fonts',
+ lib: 'custom'
+}
+];
+
+Fonts.defaults = {
+ previewText: 'Abc',
+ previewLayout: 'grid', // grid || list
+ input: {
+ google: {
+ input: '',
+ valid: false
+ },
+ typekit: {
+ input: '',
+ valid: false
+ },
+ fontdeck: {
+ input: '',
+ valid: false
+ },
+ monotype: {
+ input: '',
+ valid: false
+ },
+ custom: {
+ input: '',
+ valid: false
+ }
+ },
+ customFonts: [],
+ fonts: {},
+ webfontloader: {}
+};
diff --git a/lib/components/admin/panels/index.js b/lib/components/admin/panels/index.js
new file mode 100644
index 000000000..55a1d624b
--- /dev/null
+++ b/lib/components/admin/panels/index.js
@@ -0,0 +1,27 @@
+import Settings from './settings';
+import Pages from './pages';
+import Page from './page';
+import Fonts from './fonts';
+import Media from './media';
+import Colors from './colors';
+import Schemas from './schemas';
+import SchemasNew from './schemas-new';
+import Schema from './schema';
+import SchemaEntry from './schema-entry';
+import Users from './users';
+import UserEdit from './user-edit';
+
+export default {
+ settings: Settings,
+ pages: Pages,
+ page: Page,
+ fonts: Fonts,
+ media: Media,
+ colors: Colors,
+ schemas: Schemas,
+ schemasNew: SchemasNew,
+ schema: Schema,
+ schemaEntry: SchemaEntry,
+ users: Users,
+ userEdit: UserEdit
+};
diff --git a/lib/components/admin/panels/media/grid/index.jsx b/lib/components/admin/panels/media/grid/index.jsx
new file mode 100644
index 000000000..d27b980b8
--- /dev/null
+++ b/lib/components/admin/panels/media/grid/index.jsx
@@ -0,0 +1,26 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import Item from './item';
+
+export default class MediaGrid extends Component {
+ renderItem (data) {
+ var selected = this.props.selected.indexOf(data._id) !== -1;
+ return (
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.props.media.map(this.renderItem, this)}
+
+ );
+ }
+}
+
+MediaGrid.propTypes = {
+ media: React.PropTypes.array.isRequired,
+ selected: React.PropTypes.array.isRequired,
+ onSelect: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/admin/panels/media/grid/item.jsx b/lib/components/admin/panels/media/grid/item.jsx
new file mode 100644
index 000000000..546cc5b14
--- /dev/null
+++ b/lib/components/admin/panels/media/grid/item.jsx
@@ -0,0 +1,57 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import Utils from '../../../../../utils';
+
+import mediaActions from '../../../../../client/actions/media';
+
+export default class MediaGridItem extends Component {
+ getInitialState () {
+ return {
+ requested: false
+ };
+ }
+
+ onSelect (id, event) {
+ event.preventDefault();
+ this.props.onSelect(id);
+ }
+
+ render () {
+ var className = '';
+ if (this.props.selected){
+ className += 'active';
+ }
+
+ const width = 350;
+ const height = 190;
+
+ const variation = Utils.getBestImageVariation(this.props.data.variations, width, height);
+
+ var style = {};
+
+ if (variation !== false) {
+ style.backgroundImage = 'url('+variation.url+')';
+ } else {
+ style.backgroundColor = '#cccccc';
+
+ if (!this.state.requested) {
+ this.state.requested = true;
+ mediaActions.resize({
+ id: this.props.data._id,
+ width,
+ height
+ });
+ }
+ }
+
+ return (
+
+ );
+ }
+}
+
+MediaGridItem.propTypes = {
+ data: React.PropTypes.object.isRequired,
+ selected: React.PropTypes.bool.isRequired,
+ onSelect: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/admin/panels/media/index.jsx b/lib/components/admin/panels/media/index.jsx
new file mode 100644
index 000000000..96397d429
--- /dev/null
+++ b/lib/components/admin/panels/media/index.jsx
@@ -0,0 +1,183 @@
+import {Component} from 'relax-framework';
+import List from './list';
+import Grid from './grid';
+import React from 'react';
+import Upload from '../../../upload';
+import Filter from '../../../filter';
+import AlertBox from '../../../alert-box';
+import Lightbox from '../../../lightbox';
+import cx from 'classnames';
+
+import mediaStore from '../../../../client/stores/media';
+import mediaActions from '../../../../client/actions/media';
+
+export default class MediaManager extends Component {
+ getInitialState () {
+ return {
+ display: 'list',
+ media: this.context.media,
+ upload: false,
+ selected: [],
+ removing: false
+ };
+ }
+
+ getInitialCollections () {
+ return {
+ media: mediaStore.getCollection()
+ };
+ }
+
+ onSuccess (file, mediaItem, progressFinal) {
+ mediaActions.add(mediaItem);
+ }
+
+ listClick (event) {
+ event.preventDefault();
+ this.setState({
+ display: 'list'
+ });
+ }
+
+ gridClick (event) {
+ event.preventDefault();
+ this.setState({
+ display: 'grid'
+ });
+ }
+
+ onSelect (id) {
+ var index = this.state.selected.indexOf(id);
+
+ if(index === -1){
+ this.state.selected.push(id);
+ } else {
+ this.state.selected.splice(index, 1);
+ }
+ this.setState({
+ selected: this.state.selected
+ });
+ }
+
+ removeSelected (event) {
+ event.preventDefault();
+ this.setState({
+ removing: true
+ });
+ }
+
+ cancelRemove (event) {
+ event.preventDefault();
+ this.setState({
+ removing: false
+ });
+ }
+
+ confirmRemove (event) {
+ event.preventDefault();
+ mediaActions.removeBulk(this.state.selected);
+ this.setState({
+ removing: false,
+ selected: []
+ });
+ }
+
+ openUpload (event) {
+ event.preventDefault();
+ this.setState({
+ upload: true
+ });
+ }
+
+ closeUpload () {
+ this.setState({
+ upload: false
+ });
+ }
+
+ renderItems () {
+ if(this.state.display === 'list'){
+ return (
+
+ );
+ } else if (this.state.display === 'grid') {
+ return (
+
+ );
+ }
+ }
+
+ renderSelectedMenu () {
+ if(this.state.selected.length > 0) {
+ let str = this.state.selected.length+' items selected ';
+ return (
+
+ {str}
+ Remove them
+
+ );
+ }
+ }
+
+ renderLightbox () {
+ if (this.state.upload) {
+ return (
+
+
+
+ );
+ }
+ }
+
+ renderRemoving () {
+ if (this.state.removing) {
+ const label = 'Are you sure you want to remove the selected media elements?';
+ return (
+
+ {label}
+
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+
+ {this.renderSelectedMenu()}
+ {this.renderItems()}
+
+ {this.renderLightbox()}
+ {this.renderRemoving()}
+
+ );
+ }
+}
+
+MediaManager.contextTypes = {
+ media: React.PropTypes.array.isRequired
+};
diff --git a/lib/components/admin/panels/media/list.jsx b/lib/components/admin/panels/media/list.jsx
new file mode 100644
index 000000000..088d10a15
--- /dev/null
+++ b/lib/components/admin/panels/media/list.jsx
@@ -0,0 +1,47 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import moment from 'moment';
+
+export default class MediaList extends Component {
+
+ onSelect (id) {
+ this.props.onSelect(id);
+ }
+
+ renderItem (data) {
+ var date = moment(data.date).format("Do MMMM YYYY");
+ var className = 'entry';
+
+ if(this.props.selected.indexOf(data._id) !== -1){
+ className += ' active';
+ }
+
+ return (
+
+
+

+
+
+
{data.name}
+
{data.dimension.width+'x'+data.dimension.height}
+
{data.size}
+
{date}
+
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.props.media.map(this.renderItem, this)}
+
+ );
+ }
+}
+
+MediaList.propTypes = {
+ media: React.PropTypes.array.isRequired,
+ selected: React.PropTypes.array.isRequired,
+ onSelect: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/admin/panels/page.jsx b/lib/components/admin/panels/page.jsx
new file mode 100644
index 000000000..d2c8de32f
--- /dev/null
+++ b/lib/components/admin/panels/page.jsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import PageBuilder from '../../page-builder';
+
+export default class Page extends Component {
+ render () {
+ return (
+
+ );
+ }
+}
+
+Page.contextTypes = {
+ page: React.PropTypes.object.isRequired
+};
diff --git a/lib/components/admin/panels/pages/entry.jsx b/lib/components/admin/panels/pages/entry.jsx
new file mode 100644
index 000000000..81c56bfd3
--- /dev/null
+++ b/lib/components/admin/panels/pages/entry.jsx
@@ -0,0 +1,136 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import moment from 'moment';
+import cx from 'classnames';
+import A from '../../../a';
+import cloneDeep from 'lodash.clonedeep';
+import Q from 'q';
+import Lightbox from '../../../lightbox';
+
+import pageActions from '../../../../client/actions/page';
+
+export default class Entry extends Component {
+ getInitialState () {
+ return {
+ removing: false
+ };
+ }
+
+ removePage (event) {
+ event.preventDefault();
+ this.setState({
+ removing: true
+ });
+ }
+
+ cancelRemove (event) {
+ event.preventDefault();
+ this.setState({
+ removing: false
+ });
+ }
+
+ confirmRemove (event) {
+ event.preventDefault();
+ pageActions.remove(this.props.page._id);
+ this.setState({
+ removing: false
+ });
+ }
+
+ resolveSlug (slug, it) {
+ var resultSlug = slug + (it > 0 ? '-'+it : '');
+
+ return Q()
+ .then(() => pageActions.validateSlug(resultSlug))
+ .then((response) => {
+ var slugValid = !response;
+
+ if (slugValid) {
+ return resultSlug;
+ } else {
+ return this.resolveSlug(slug, it+1);
+ }
+ });
+ }
+
+ duplicatePage (event) {
+ event.preventDefault();
+ var clonePage = cloneDeep(this.props.page);
+ delete clonePage._id;
+ delete clonePage.date;
+ delete clonePage.actions;
+ clonePage.title += ' (copy)';
+ clonePage.slug += '-copy';
+ clonePage.state = 'draft';
+
+ this.resolveSlug(clonePage.slug, 0).then((slug) => {
+ clonePage.slug = slug;
+ pageActions.add(clonePage);
+ });
+ }
+
+ renderRemoving () {
+ if (this.state.removing) {
+ const label = 'Are you sure you want to remove '+this.props.page.title+' page?';
+ const label1 = 'You\'ll loose this page\'s data forever!';
+ return (
+
+ {label}
+ {label1}
+
+
+ );
+ }
+ }
+
+ render () {
+ const page = this.props.page;
+
+ let editLink = '/admin/page/'+page.slug;
+ let viewLink = '/'+page.slug;
+ const published = page.state === 'published';
+ let date = 'Created - ' + moment(page.date).format('MMMM Do YYYY');
+
+ return (
+
+
+ {published ? 'cloud_queue' : 'cloud_off'}
+
+
+
+ {page.title}
+
+
{date}
+
{page.state}
+
+
+ {this.renderRemoving()}
+
+ );
+ }
+}
+
+Entry.propTypes = {
+ page: React.PropTypes.object.isRequired
+};
diff --git a/lib/components/admin/panels/pages/index.jsx b/lib/components/admin/panels/pages/index.jsx
new file mode 100644
index 000000000..542542837
--- /dev/null
+++ b/lib/components/admin/panels/pages/index.jsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import List from './list';
+import Filter from '../../../filter';
+import Lightbox from '../../../lightbox';
+import Manage from './manage';
+
+import pagesStore from '../../../../client/stores/pages';
+import pageActions from '../../../../client/actions/page';
+
+export default class Pages extends Component {
+ getInitialState () {
+ return {
+ pages: this.context.pages,
+ search: (this.context.query && this.context.query.s) || '',
+ lightbox: false
+ };
+ }
+
+ getInitialCollections () {
+ return {
+ pages: pagesStore.getCollection()
+ };
+ }
+
+ onAddNew (values) {
+ pageActions
+ .add({
+ title: values.title,
+ slug: values.slug
+ })
+ .then(() => {
+ this.setState({
+ lightbox: false
+ });
+ });
+ }
+
+ addNewClick (event) {
+ event.preventDefault();
+ this.setState({
+ lightbox: true
+ });
+ }
+
+ closeLightbox () {
+ this.setState({
+ lightbox: false
+ });
+ }
+
+ renderLightbox () {
+ if (this.state.lightbox) {
+ return (
+
+
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+
+
+
+ {this.renderLightbox()}
+
+ );
+ }
+}
+
+Pages.contextTypes = {
+ pages: React.PropTypes.array.isRequired,
+ query: React.PropTypes.object
+};
diff --git a/lib/components/admin/panels/pages/list.jsx b/lib/components/admin/panels/pages/list.jsx
new file mode 100644
index 000000000..a7983b637
--- /dev/null
+++ b/lib/components/admin/panels/pages/list.jsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Entry from './entry';
+
+export default class List extends Component {
+ renderEntry (page) {
+ return (
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.props.data.map(this.renderEntry, this)}
+
+ );
+ }
+}
+
+List.propTypes = {
+ data: React.PropTypes.array.isRequired
+};
diff --git a/lib/components/admin/panels/pages/manage.jsx b/lib/components/admin/panels/pages/manage.jsx
new file mode 100644
index 000000000..db2d3f75e
--- /dev/null
+++ b/lib/components/admin/panels/pages/manage.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import TitleSlug from '../../../title-slug';
+
+import pageActions from '../../../../client/actions/page';
+
+export default class Manage extends Component {
+ getInitialState () {
+ return {
+ title: '',
+ slug: ''
+ };
+ }
+
+ onChange (values) {
+ this.setState(values);
+ }
+
+ onSubmit (event) {
+ event.preventDefault();
+ this.props.onSubmit(this.state);
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+Manage.propTypes = {
+ onSubmit: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/admin/panels/schema-entry/index.jsx b/lib/components/admin/panels/schema-entry/index.jsx
new file mode 100644
index 000000000..5108427c6
--- /dev/null
+++ b/lib/components/admin/panels/schema-entry/index.jsx
@@ -0,0 +1,71 @@
+import {Component} from 'relax-framework';
+import merge from 'lodash.merge';
+import OptionsList from '../../../options-list';
+import React from 'react';
+import Breadcrumbs from '../../../breadcrumbs';
+import TitleSlug from '../../../title-slug';
+
+import schemaEntriesActionsFactory from '../../../../client/actions/schema-entries';
+
+export default class SchemaEntry extends Component {
+ getInitialState () {
+ this.schemaEntriesActions = schemaEntriesActionsFactory(this.context.schema.slug);
+ return {
+ opened: false,
+ schema: this.context.schema,
+ schemaEntry: this.context.schemaEntry
+ };
+ }
+
+ onSubmit (event) {
+ event.preventDefault();
+
+ this.schemaEntriesActions
+ .update(this.state.schemaEntry)
+ .then(() => {
+
+ });
+ }
+
+ onFieldChange (id, value) {
+ this.state.schemaEntry[id] = value;
+ this.setState({
+ schemaEntry: this.state.schemaEntry
+ });
+ }
+
+ onChange (values) {
+ merge(this.state.schemaEntry, values);
+ this.setState({
+ schemaEntry: this.state.schemaEntry
+ });
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+SchemaEntry.contextTypes = {
+ schema: React.PropTypes.object.isRequired,
+ schemaEntry: React.PropTypes.object.isRequired,
+ breadcrumbs: React.PropTypes.array.isRequired
+};
diff --git a/lib/components/admin/panels/schema/entry.jsx b/lib/components/admin/panels/schema/entry.jsx
new file mode 100644
index 000000000..bd845415a
--- /dev/null
+++ b/lib/components/admin/panels/schema/entry.jsx
@@ -0,0 +1,107 @@
+import A from '../../../a';
+import Lightbox from '../../../lightbox';
+import React from 'react';
+import {Component} from 'relax-framework';
+import moment from 'moment';
+import cx from 'classnames';
+
+import schemaEntriesActionsFactory from '../../../../client/actions/schema-entries';
+
+export default class Entry extends Component {
+ getInitialState () {
+ return {
+ removing: false
+ };
+ }
+
+ onRemove (event) {
+ event.preventDefault();
+ this.setState({
+ removing: true
+ });
+ }
+
+ cancelRemove (event) {
+ event.preventDefault();
+ this.setState({
+ removing: false
+ });
+ }
+
+ confirmRemove (event) {
+ event.preventDefault();
+ schemaEntriesActionsFactory(this.props.schema.slug).remove(this.props.schemaItem._id);
+ this.setState({
+ removing: false
+ });
+ }
+
+ renderRemoving () {
+ if (this.state.removing) {
+ const label = 'Are you sure you want to remove the post '+this.props.schemaItem.title+'?';
+ const label1 = 'This action cannot be reverted';
+ return (
+
+ {label}
+ {label1}
+
+
+ );
+ }
+ }
+
+ render () {
+ const schemaItem = this.props.schemaItem;
+ let editLink = '/admin/schemas/'+this.props.schema.slug+'/'+schemaItem.slug;
+ let buildLink = '/admin/schema/'+this.props.schema.slug+'/'+schemaItem.slug;
+ let viewLink = '/'+this.props.schema.slug+'/'+schemaItem.slug;
+ const published = schemaItem.state === 'published';
+ let date = 'Created - ' + moment(schemaItem.date).format('MMMM Do YYYY');
+
+ return (
+
+
+ {published ? 'cloud_queue' : 'cloud_off'}
+
+
+
+
{date}
+
{schemaItem.state}
+
+
+ {this.renderRemoving()}
+
+ );
+ }
+}
+
+Entry.propTypes = {
+ schema: React.PropTypes.object.isRequired,
+ schemaItem: React.PropTypes.object.isRequired
+};
diff --git a/lib/components/admin/panels/schema/index.jsx b/lib/components/admin/panels/schema/index.jsx
new file mode 100644
index 000000000..ea44e4b93
--- /dev/null
+++ b/lib/components/admin/panels/schema/index.jsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import List from './list';
+import Lightbox from '../../../lightbox';
+import Filter from '../../../filter';
+import Manage from './manage';
+import Breadcrumbs from '../../../breadcrumbs';
+
+import schemaEntriesStoreFactory from '../../../../client/stores/schema-entries';
+
+export default class Schema extends Component {
+ getInitialState () {
+ return {
+ opened: false,
+ schema: this.context.schema,
+ schemaEntries: this.context.schemaEntries
+ };
+ }
+
+ getInitialCollections () {
+ return {
+ schemaEntries: schemaEntriesStoreFactory(this.context.schema.slug).getCollection()
+ };
+ }
+
+ addNewClick (event) {
+ event.preventDefault();
+
+ this.setState({
+ opened: true
+ });
+ }
+
+ onClose () {
+ this.setState({
+ opened: false
+ });
+ }
+
+ renderLightbox () {
+ if(this.state.opened){
+ var title = 'Add new entry to ' + this.state.schema.title;
+ return (
+
+
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+
+
+
+ {this.renderLightbox()}
+
+ );
+ }
+}
+
+Schema.contextTypes = {
+ schemaEntries: React.PropTypes.array.isRequired,
+ schema: React.PropTypes.object.isRequired,
+ breadcrumbs: React.PropTypes.array.isRequired
+};
diff --git a/lib/components/admin/panels/schema/list.jsx b/lib/components/admin/panels/schema/list.jsx
new file mode 100644
index 000000000..9869616be
--- /dev/null
+++ b/lib/components/admin/panels/schema/list.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Entry from './entry';
+
+export default class List extends Component {
+ renderEntry (schemaItem) {
+ return (
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.props.schemaEntries.map(this.renderEntry, this)}
+
+ );
+ }
+}
+
+List.propTypes = {
+ schemaEntries: React.PropTypes.array,
+ schema: React.PropTypes.object
+};
diff --git a/lib/components/admin/panels/schema/manage.jsx b/lib/components/admin/panels/schema/manage.jsx
new file mode 100644
index 000000000..2102c0ebe
--- /dev/null
+++ b/lib/components/admin/panels/schema/manage.jsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import merge from 'lodash.merge';
+import OptionsList from '../../../options-list';
+import TitleSlug from '../../../title-slug';
+
+import schemaEntriesActionsFactory from '../../../../client/actions/schema-entries';
+
+export default class Manage extends Component {
+ getInitialState () {
+ return {
+ title: '',
+ slug: '',
+ values: {}
+ };
+ }
+
+ onSubmit (event) {
+ event.preventDefault();
+
+ schemaEntriesActionsFactory(this.props.schema.slug)
+ .add(merge(
+ {
+ title: this.state.title,
+ slug: this.state.slug
+ },
+ this.state.values
+ ))
+ .then(() => {
+ this.setState({
+ title: '',
+ slug: '',
+ values: {}
+ });
+ });
+ }
+
+ onChange (values) {
+ this.setState(values);
+ }
+
+ onFieldChange (id, value) {
+ this.state.values[id] = value;
+ this.setState({
+ values: this.state.values
+ });
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+Manage.propTypes = {
+ schema: React.PropTypes.object.isRequired
+};
diff --git a/lib/components/admin/panels/schemas-new/builder.jsx b/lib/components/admin/panels/schemas-new/builder.jsx
new file mode 100644
index 000000000..466bb8546
--- /dev/null
+++ b/lib/components/admin/panels/schemas-new/builder.jsx
@@ -0,0 +1,166 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Combobox from '../../../combobox';
+import Checkbox from '../../../checkbox';
+import Input from '../../../input';
+import merge from 'lodash.merge';
+import {Types} from '../../../../types';
+import Prop from './prop';
+import forEach from 'lodash.foreach';
+import clone from 'lodash.clone';
+
+var defaults = {
+ id: 'New prop',
+ type: 'String',
+ required: false
+};
+
+export default class SchemasBuilder extends Component {
+ getInitialState () {
+ return {
+ fields: [],
+ values: merge({}, defaults),
+ selected: false
+ };
+ }
+
+ onAddSchemaField (event) {
+ event.preventDefault();
+
+ const copy = clone(defaults);
+ this.state.fields.push(copy);
+
+ this.setState({
+ fields: this.state.fields,
+ selected: this.state.fields.length - 1
+ });
+
+ this.props.onChange(this.state.fields);
+ }
+
+ onChange (id, value) {
+ this.state.values[id] = value;
+ this.setState({
+ values: this.state.values
+ });
+ }
+
+ onRemoveProp (id) {
+ forEach(this.state.fields, (field, key) => {
+ if (field.id === id) {
+ this.state.fields.splice(key, 1);
+ return false;
+ }
+ });
+ this.setState({
+ fields: this.state.fields
+ });
+ this.props.onChange(this.state.fields);
+ }
+
+ renderFieldEntry (field, index) {
+ let selected = this.state.selected === index;
+ return (
+
+ );
+ }
+
+ renderFields () {
+ return (
+
+ {this.renderFieldEntry({
+ id: 'Title',
+ type: 'String',
+ required: true,
+ locked: true
+ })}
+ {this.renderFieldEntry({
+ id: 'Slug',
+ type: 'String',
+ required: true,
+ locked: true
+ })}
+ {this.renderFieldEntry({
+ id: 'Date',
+ type: 'Date',
+ required: true,
+ locked: true
+ })}
+ {this.state.fields.map(this.renderFieldEntry, this)}
+
+ );
+ }
+
+ renderOptions () {
+ if (this.state.selected !== false) {
+ let types = Object.keys(Types).sort();
+ let values = this.state.fields[this.state.selected];
+ return (
+
+ );
+ } else {
+ return (
+
+
+
+ error_outline
+
+
+
Select a property on the left to edit
+
Or click the add new to add a new property
+
+
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
Schema properties
+
+
+
Properties
+ {this.renderFields()}
+
+ add_circle_outline
+ Add new property
+
+
+
+
Selected property options
+
+ {this.renderOptions()}
+
+
+
+
+ );
+ }
+}
+
+SchemasBuilder.propTypes = {
+ onChange: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/admin/panels/schemas-new/index.jsx b/lib/components/admin/panels/schemas-new/index.jsx
new file mode 100644
index 000000000..01dbcf9b3
--- /dev/null
+++ b/lib/components/admin/panels/schemas-new/index.jsx
@@ -0,0 +1,66 @@
+import Builder from './builder';
+import {Component} from 'relax-framework';
+import React from 'react';
+import TitleSlug from '../../../title-slug';
+import Breadcrumbs from '../../../breadcrumbs';
+
+import schemaActions from '../../../../client/actions/schema';
+
+export default class SchemasNew extends Component {
+ getInitialState () {
+ return {
+ schema: [],
+ title: '',
+ slug: ''
+ };
+ }
+
+ onAddNew (event) {
+ event.preventDefault();
+
+ schemaActions
+ .add({
+ title: this.state.title,
+ slug: this.state.slug,
+ fields: this.state.schema
+ })
+ .then(() => {
+ this.setState({
+ title: '',
+ slug: '',
+ hasTypedSlug: false
+ });
+ });
+ }
+
+ onSchemaChange (schema) {
+ this.setState({
+ schema
+ });
+ }
+
+ onChange (values) {
+ this.setState(values);
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+SchemasNew.contextTypes = {
+ breadcrumbs: React.PropTypes.array.isRequired
+};
diff --git a/lib/components/admin/panels/schemas-new/prop.jsx b/lib/components/admin/panels/schemas-new/prop.jsx
new file mode 100644
index 000000000..01ef3b55f
--- /dev/null
+++ b/lib/components/admin/panels/schemas-new/prop.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import cx from 'classnames';
+
+export default class Prop extends Component {
+ onRemove (event) {
+ event.preventDefault();
+ this.props.onRemove(this.props.prop.id);
+ }
+
+ renderRemove () {
+ if (!this.props.prop.locked) {
+ return (
+
+ delete
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+
{this.props.prop.id}
+
{this.props.prop.type}
+
+ {this.renderRemove()}
+
+ );
+ }
+}
+
+Prop.propTypes = {
+ prop: React.PropTypes.object.isRequired,
+ onRemove: React.PropTypes.func.isRequired,
+ selected: React.PropTypes.bool.isRequired
+};
diff --git a/lib/components/admin/panels/schemas/entry.jsx b/lib/components/admin/panels/schemas/entry.jsx
new file mode 100644
index 000000000..4a33a4a19
--- /dev/null
+++ b/lib/components/admin/panels/schemas/entry.jsx
@@ -0,0 +1,91 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import A from '../../../a';
+import Lightbox from '../../../lightbox';
+
+import schemaActions from '../../../../client/actions/schema';
+
+export default class Entry extends Component {
+ getInitialState () {
+ return {
+ removing: false
+ };
+ }
+
+ onRemove (event) {
+ event.preventDefault();
+ this.setState({
+ removing: true
+ });
+ }
+
+ cancelRemove (event) {
+ event.preventDefault();
+ this.setState({
+ removing: false
+ });
+ }
+
+ confirmRemove (event) {
+ event.preventDefault();
+ schemaActions.remove(this.props.schema._id);
+ this.setState({
+ removing: false
+ });
+ }
+
+ renderRemoving () {
+ if (this.state.removing) {
+ const label = 'Are you sure you want to remove the schema '+this.props.schema.title+'?';
+ const label1 = 'You will loose the schema and all entries in it';
+ return (
+
+ {label}
+ {label1}
+
+
+ );
+ }
+ }
+
+ render () {
+ const schema = this.props.schema;
+ let viewLink = '/admin/schemas/'+schema.slug;
+
+ return (
+
+
+ archive
+
+
+ {this.renderRemoving()}
+
+ );
+ }
+}
+
+Entry.propTypes = {
+ schema: React.PropTypes.object.isRequired
+};
diff --git a/lib/components/admin/panels/schemas/index.jsx b/lib/components/admin/panels/schemas/index.jsx
new file mode 100644
index 000000000..1cf390f03
--- /dev/null
+++ b/lib/components/admin/panels/schemas/index.jsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import List from './list';
+import Filter from '../../../filter';
+import A from '../../../a';
+
+import schemasStore from '../../../../client/stores/schemas';
+
+export default class Schemas extends Component {
+ getInitialState () {
+ return {
+ opened: false,
+ schemas: this.context.schemas
+ };
+ }
+
+ getInitialCollections () {
+ return {
+ schemas: schemasStore.getCollection()
+ };
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+Schemas.contextTypes = {
+ schemas: React.PropTypes.array.isRequired
+};
diff --git a/lib/components/admin/panels/schemas/list.jsx b/lib/components/admin/panels/schemas/list.jsx
new file mode 100644
index 000000000..b6a85418f
--- /dev/null
+++ b/lib/components/admin/panels/schemas/list.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Entry from './entry';
+
+export default class Schemas extends Component {
+ renderEntry (schema) {
+ return (
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.props.data.map(this.renderEntry, this)}
+
+ );
+ }
+}
diff --git a/lib/components/admin/panels/settings.jsx b/lib/components/admin/panels/settings.jsx
new file mode 100644
index 000000000..039517126
--- /dev/null
+++ b/lib/components/admin/panels/settings.jsx
@@ -0,0 +1,112 @@
+import Animate from '../../animate';
+import React from 'react';
+import {Component} from 'relax-framework';
+import OptionsList from '../../options-list';
+import Spinner from '../../spinner';
+import cx from 'classnames';
+
+import {Types} from '../../../types';
+import settingsActions from '../../../client/actions/settings';
+
+export default class Settings extends Component {
+ getInitialState () {
+ return {
+ settings: this.parseSettings(this.context.settings),
+ saving: false
+ };
+ }
+
+ onChange (id, value) {
+ this.state.settings[id] = value;
+
+ this.setState({
+ settings: this.state.settings
+ });
+ }
+
+ outSuccess () {
+ this.setState({
+ success: false
+ });
+ }
+
+ submit (event) {
+ event.preventDefault();
+
+ clearTimeout(this.successTimeout);
+ this.setState({
+ saving: true
+ });
+
+ settingsActions
+ .saveSettings(this.state.settings)
+ .then(() => {
+ this.setState({
+ saving: false,
+ success: true
+ });
+ this.successTimeout = setTimeout(this.outSuccess.bind(this), 3000);
+ });
+ }
+
+ renderSaving () {
+ if (this.state.saving) {
+ return (
+
+
+
+ Saving changes
+
+
+ );
+ } else if (this.state.success) {
+ return (
+
+
+ check
+ Saved successfuly
+
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+ General Settings
+
+
+
+ );
+ }
+}
+
+Settings.options = [
+ {
+ label: 'Site Title',
+ type: Types.String,
+ id: 'title',
+ default: ''
+ },
+ {
+ label: 'Favicon',
+ type: Types.Image,
+ id: 'favicon',
+ props: {
+ width: 50,
+ height: 50
+ }
+ }
+];
+
+Settings.contextTypes = {
+ settings: React.PropTypes.array.isRequired
+};
diff --git a/lib/components/admin/panels/user-edit/index.jsx b/lib/components/admin/panels/user-edit/index.jsx
new file mode 100644
index 000000000..60bbb36bc
--- /dev/null
+++ b/lib/components/admin/panels/user-edit/index.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Breadcrumbs from '../../../breadcrumbs';
+
+export default class UserEdit extends Component {
+ render () {
+ //const user = this.context.editUser;
+
+ return (
+
+ );
+ }
+}
+
+UserEdit.contextTypes = {
+ editUser: React.PropTypes.object.isRequired,
+ breadcrumbs: React.PropTypes.array.isRequired
+};
diff --git a/lib/components/admin/panels/users/entry.jsx b/lib/components/admin/panels/users/entry.jsx
new file mode 100644
index 000000000..b86443074
--- /dev/null
+++ b/lib/components/admin/panels/users/entry.jsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import moment from 'moment';
+import A from '../../../a';
+import Lightbox from '../../../lightbox';
+import Utils from '../../../../utils';
+
+import userActions from '../../../../client/actions/user';
+
+export default class Entry extends Component {
+ getInitialState () {
+ return {
+ removing: false
+ };
+ }
+
+ onRemove (event) {
+ event.preventDefault();
+ this.setState({
+ removing: true
+ });
+ }
+
+ cancelRemove (event) {
+ event.preventDefault();
+ this.setState({
+ removing: false
+ });
+ }
+
+ confirmRemove (event) {
+ event.preventDefault();
+ userActions.remove(this.props.user._id);
+ this.setState({
+ removing: false
+ });
+ }
+
+ renderRemoving () {
+ if (this.state.removing) {
+ const label = 'Are you sure you want to remove the user '+this.props.user.name+'?';
+ const label1 = 'This action cannot be reverted';
+ return (
+
+ {label}
+ {label1}
+
+
+ );
+ }
+ }
+
+ render () {
+ const user = this.props.user;
+ let profileLink = '/admin/users/'+user.username;
+ let date = 'Registered - ' + moment(user.date).format('MMMM Do YYYY');
+ let url = Utils.getGravatarImage(user.email, 70);
+
+ return (
+
+
+
+

+
+
+
+
+ {user.name}
+
+
{date}
+
{'Username - '+user.username}
+
{'Email - '+user.email}
+
+
+ {this.renderRemoving()}
+
+ );
+ }
+}
+
+Entry.propTypes = {
+ user: React.PropTypes.object.isRequired
+};
diff --git a/lib/components/admin/panels/users/index.jsx b/lib/components/admin/panels/users/index.jsx
new file mode 100644
index 000000000..10a27a0e9
--- /dev/null
+++ b/lib/components/admin/panels/users/index.jsx
@@ -0,0 +1,86 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import List from './list';
+import Filter from '../../../filter';
+import Lightbox from '../../../lightbox';
+import New from './new';
+
+import userActions from '../../../../client/actions/user';
+import usersStore from '../../../../client/stores/users';
+
+export default class Users extends Component {
+ getInitialState () {
+ return {
+ users: this.context.users,
+ search: (this.context.query && this.context.query.s) || '',
+ lightbox: false
+ };
+ }
+
+ getInitialCollections () {
+ return {
+ users: usersStore.getCollection()
+ };
+ }
+
+ onAddNew (newUser) {
+ userActions
+ .add(newUser)
+ .then(() => {
+ this.setState({
+ lightbox: false
+ });
+ });
+ }
+
+ addNewClick (event) {
+ event.preventDefault();
+ this.setState({
+ lightbox: true
+ });
+ }
+
+ closeLightbox () {
+ this.setState({
+ lightbox: false
+ });
+ }
+
+ renderLightbox () {
+ if (this.state.lightbox) {
+ return (
+
+
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+
+
+
+ {this.renderLightbox()}
+
+ );
+ }
+}
+
+Users.contextTypes = {
+ users: React.PropTypes.array.isRequired,
+ query: React.PropTypes.object
+};
diff --git a/lib/components/admin/panels/users/list.jsx b/lib/components/admin/panels/users/list.jsx
new file mode 100644
index 000000000..81b74b0cd
--- /dev/null
+++ b/lib/components/admin/panels/users/list.jsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Entry from './entry';
+
+export default class List extends Component {
+ renderEntry (user) {
+ return (
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.props.data.map(this.renderEntry, this)}
+
+ );
+ }
+}
diff --git a/lib/components/admin/panels/users/new.jsx b/lib/components/admin/panels/users/new.jsx
new file mode 100644
index 000000000..554583ffd
--- /dev/null
+++ b/lib/components/admin/panels/users/new.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import OptionsList from '../../../options-list';
+import {Types} from '../../../../types';
+
+export default class New extends Component {
+ getInitialState () {
+ return {
+ username: '',
+ password: '',
+ name: '',
+ email: ''
+ };
+ }
+
+ onChange (id, value) {
+ this.setState({
+ [id]: value
+ });
+ }
+
+ onSubmit (event) {
+ event.preventDefault();
+ this.props.onSubmit(this.state);
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+New.options = [
+ {
+ label: 'Username',
+ type: Types.String,
+ id: 'username',
+ default: ''
+ },
+ {
+ label: 'Password',
+ type: Types.String,
+ id: 'password',
+ default: '',
+ props: {
+ password: true
+ }
+ },
+ {
+ label: 'Name',
+ type: Types.String,
+ id: 'name',
+ default: ''
+ },
+ {
+ label: 'Email',
+ type: Types.String,
+ id: 'email',
+ default: ''
+ }
+];
+
+New.propTypes = {
+ onSubmit: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/admin/top-menu/add-overlay/entry.jsx b/lib/components/admin/top-menu/add-overlay/entry.jsx
new file mode 100644
index 000000000..33874988a
--- /dev/null
+++ b/lib/components/admin/top-menu/add-overlay/entry.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import {Component} from 'relax';
+import moment from 'moment';
+import cx from 'classnames';
+import A from '../../../a';
+
+export default class Entry extends Component {
+ render () {
+ const page = this.props.page;
+ const editLink = '/admin/page/'+page.slug;
+ const published = page.state === 'published';
+ const date = 'Created ' + moment(page.date).format('MMMM Do YYYY');
+
+ return (
+
+
+ {published ? 'cloud_queue' : 'cloud_off'}
+
+
+
{page.title}
+
{date}
+
+
+ );
+ }
+}
+
+Entry.contextTypes = {
+ onClose: React.PropTypes.func.isRequired
+};
+
+Entry.propTypes = {
+ page: React.PropTypes.object.isRequired
+};
diff --git a/lib/components/admin/top-menu/add-overlay/index.jsx b/lib/components/admin/top-menu/add-overlay/index.jsx
new file mode 100644
index 000000000..5f5848c6e
--- /dev/null
+++ b/lib/components/admin/top-menu/add-overlay/index.jsx
@@ -0,0 +1,58 @@
+import React from 'react';
+import {Component} from 'relax';
+import cx from 'classnames';
+
+import NewPage from '../../panels/pages/manage';
+import List from './list';
+
+export default class AddOverlay extends Component {
+ getInitialState () {
+ return {
+ content: 'new'
+ };
+ }
+
+ getChildContext () {
+ return {
+ onClose: this.props.onClose
+ };
+ }
+
+ addNewPage (pageData) {
+
+ }
+
+ changeContent (content, event) {
+ event.preventDefault();
+ this.setState({
+ content
+ });
+ }
+
+ renderContent () {
+ if (this.state.content === 'new') {
+ return ;
+ } else {
+ return
;
+ }
+ }
+
+ render () {
+ return (
+
+
+ Add new page
+ /
+ Open existing page
+
+
+ {this.renderContent()}
+
+
+ );
+ }
+}
+
+AddOverlay.childContextTypes = {
+ onClose: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/admin/top-menu/add-overlay/list.jsx b/lib/components/admin/top-menu/add-overlay/list.jsx
new file mode 100644
index 000000000..dd1be3363
--- /dev/null
+++ b/lib/components/admin/top-menu/add-overlay/list.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import {Component} from 'relax';
+import cx from 'classnames';
+
+import Entry from './entry';
+
+import pagesStore from '../../../../client/stores/pages';
+
+export default class List extends Component {
+ getInitialState () {
+ return {
+ pages: []
+ };
+ }
+
+ getInitialCollections () {
+ return {
+ pages: pagesStore.getCollection()
+ };
+ }
+
+ renderEntry (page) {
+ return ;
+ }
+
+ render () {
+ return (
+
+ {this.state.pages.map(this.renderEntry, this)}
+
+ );
+ }
+}
diff --git a/lib/components/admin/top-menu/index.jsx b/lib/components/admin/top-menu/index.jsx
new file mode 100644
index 000000000..3a1cf47f8
--- /dev/null
+++ b/lib/components/admin/top-menu/index.jsx
@@ -0,0 +1,168 @@
+import React from 'react';
+import {Component, Router} from 'relax-framework';
+import cx from 'classnames';
+import forEach from 'lodash.foreach';
+
+import A from '../../a';
+import AddOverlay from './add-overlay';
+
+import pageActions from '../../../client/actions/page';
+import tabsStore from '../../../client/stores/tabs';
+import tabActions from '../../../client/actions/tab';
+
+export default class TopMenu extends Component {
+ getInitialState () {
+ return {
+ tabs: this.context.tabs
+ };
+ }
+
+ getInitialCollections () {
+ return {
+ tabs: tabsStore.getCollection({
+ user: this.context.user._id
+ })
+ };
+ }
+
+ publishPage (event) {
+ event.preventDefault();
+
+ this.context.page.state = 'published';
+ pageActions.update(this.context.page);
+ }
+
+ previewToggle (event) {
+ event.preventDefault();
+ this.context.previewToggle();
+ }
+
+ changeDisplay (display, event) {
+ event.preventDefault();
+ this.context.changeDisplay(display);
+ }
+
+ onCloseTab (id, active, event) {
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (active) {
+ var to = '/admin/pages';
+ forEach(this.state.tabs, (tab, ind) => {
+ if (tab._id === id) {
+ if (ind < this.state.tabs.length - 1) {
+ to = '/admin/page/'+this.state.tabs[ind+1].pageId.slug;
+ } else if (ind !== 0) {
+ to = '/admin/page/'+this.state.tabs[ind-1].pageId.slug;
+ }
+ return false;
+ }
+ });
+ Router.prototype.navigate(to, {trigger: true});
+ }
+
+ tabActions.remove(id);
+ }
+
+ onAddTabClick (event) {
+ event.preventDefault();
+ this.context.addOverlay(
+
+ );
+ }
+
+ renderTab (tab) {
+ const slug = tab.pageId.slug;
+ const title = tab.pageId.title;
+ const active = this.context.page && this.context.page.slug === slug;
+ const link = '/admin/page/'+slug;
+
+ return (
+
+ {title}
+ close
+
+ );
+ }
+
+ renderTabs () {
+ return (
+
+ {this.state.tabs && this.state.tabs.map(this.renderTab, this)}
+
+ add
+
+
+ );
+ }
+
+ renderDisplayMenu () {
+ var positions = {
+ desktop: 0,
+ tablet: -35,
+ mobile: -70
+ };
+ var centerMenuStyle = {
+ left: positions[this.context.display]
+ };
+
+ return (
+
+ );
+ }
+
+ renderPageActions () {
+ // TODO check if editing a page (might be on the options page)
+ return (
+
+ {this.renderDisplayMenu()}
+
dashboard
+
+
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.renderPageActions()}
+ {this.renderTabs()}
+
+ );
+ }
+}
+
+TopMenu.propTypes = {
+
+};
+
+TopMenu.contextTypes = {
+ tabs: React.PropTypes.array.isRequired,
+ user: React.PropTypes.object.isRequired,
+ page: React.PropTypes.object,
+ display: React.PropTypes.string.isRequired,
+ changeDisplay: React.PropTypes.func.isRequired,
+ previewToggle: React.PropTypes.func.isRequired,
+ editing: React.PropTypes.bool.isRequired,
+ lastDashboard: React.PropTypes.string.isRequired,
+ addOverlay: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/alert-box.jsx b/lib/components/alert-box.jsx
new file mode 100644
index 000000000..034cc0cf1
--- /dev/null
+++ b/lib/components/alert-box.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import cx from 'classnames';
+
+export default class AlertBox extends Component {
+ render () {
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
diff --git a/lib/components/animate.jsx b/lib/components/animate.jsx
new file mode 100644
index 000000000..495b55b95
--- /dev/null
+++ b/lib/components/animate.jsx
@@ -0,0 +1,28 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import Velocity from 'velocity-animate';
+
+export default class Animate extends Component {
+ componentDidMount () {
+ var dom = React.findDOMNode(this);
+ const transition = 'transition.'+this.props.transition;
+ Velocity(dom, transition, {
+ duration: this.props.duration,
+ display: null
+ });
+ }
+
+ render () {
+ return this.props.children;
+ }
+}
+
+Animate.propTypes = {
+ transition: React.PropTypes.string,
+ duration: React.PropTypes.number
+};
+
+Animate.defaultProps = {
+ transition: 'slideUpIn',
+ duration: 400
+};
diff --git a/lib/components/animateProps.jsx b/lib/components/animateProps.jsx
new file mode 100644
index 000000000..1be42e3ca
--- /dev/null
+++ b/lib/components/animateProps.jsx
@@ -0,0 +1,24 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import Velocity from 'velocity-animate';
+
+export default class AnimateProps extends Component {
+ componentDidMount () {
+ var dom = React.findDOMNode(this);
+ Velocity(dom, this.props.props, this.props.options);
+ }
+
+ render () {
+ return this.props.children;
+ }
+}
+
+AnimateProps.propTypes = {
+ props: React.PropTypes.object,
+ options: React.PropTypes.object
+};
+
+AnimateProps.defaultProps = {
+ props: {},
+ options: {}
+};
diff --git a/lib/components/autocomplete.jsx b/lib/components/autocomplete.jsx
new file mode 100644
index 000000000..1a82e1ae3
--- /dev/null
+++ b/lib/components/autocomplete.jsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+
+export default class Autocomplete extends Component {
+ componentDidMount () {
+ const autoFocus = this.props.autoFocus;
+
+ if (autoFocus) {
+ this.focus();
+ }
+ }
+
+ onInput (e) {
+ var str = React.findDOMNode(this.refs.editable).innerText;
+
+ if (this.props.onChange) {
+ this.props.onChange(str);
+ }
+ }
+
+ focus () {
+ var el = React.findDOMNode(this.refs.editable);
+ el.focus();
+ if (typeof window.getSelection !== "undefined" && typeof document.createRange !== "undefined") {
+ var range = document.createRange();
+ range.selectNodeContents(el);
+ range.collapse(false);
+ var sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+ } else if (typeof document.body.createTextRange !== "undefined") {
+ var textRange = document.body.createTextRange();
+ textRange.moveToElementText(el);
+ textRange.collapse(false);
+ textRange.select();
+ }
+ }
+
+ render () {
+ var suggestion = '';
+
+ if (this.props.suggestion !== '') {
+ suggestion = this.props.suggestion.slice(this.props.value.length);
+ }
+
+ return (
+
+ {this.props.value}
+ {suggestion}
+
+ );
+ }
+}
+
+Autocomplete.propTypes = {
+ autoFocus: React.PropTypes.bool,
+ onChange: React.PropTypes.func,
+ suggestion: React.PropTypes.string,
+ value: React.PropTypes.string.isRequired
+};
+
+Autocomplete.defaultProps = {
+ autoFocus: true
+};
diff --git a/lib/components/border-picker.jsx b/lib/components/border-picker.jsx
new file mode 100644
index 000000000..5d6c0e941
--- /dev/null
+++ b/lib/components/border-picker.jsx
@@ -0,0 +1,160 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import NumberInput from './number-input';
+import BorderStyle from './border-style';
+import ColorPicker from './color-palette-picker';
+import cloneDeep from 'lodash.clonedeep';
+
+export default class BorderPicker extends Component {
+ getInitialState () {
+ return {
+ selected: 'center',
+ values: this.parseValue(this.props.value)
+ };
+ }
+
+ componentWillReceiveProps (nextProps) {
+ this.setState({
+ values: this.parseValue(nextProps.value)
+ });
+ }
+
+ onInputChange (id, value) {
+ if (this.state.selected === 'center') {
+ this.state.values.top[id] = value;
+ this.state.values.right = cloneDeep(this.state.values.top);
+ this.state.values.bottom = cloneDeep(this.state.values.top);
+ this.state.values.left = cloneDeep(this.state.values.top);
+ this.state.values.equal = true;
+ } else {
+ this.state.values[this.state.selected][id] = value;
+ }
+ this.props.onChange(cloneDeep(this.state.values));
+ }
+
+ parseValue (value) {
+ var result = {
+ top: {
+ style: 'solid',
+ width: 1,
+ color: {
+ value: '#000000',
+ opacity: 100
+ }
+ },
+ left: {
+ style: 'solid',
+ width: 1,
+ color: {
+ value: '#000000',
+ opacity: 100
+ }
+ },
+ right: {
+ style: 'solid',
+ width: 1,
+ color: {
+ value: '#000000',
+ opacity: 100
+ }
+ },
+ bottom: {
+ style: 'solid',
+ width: 1,
+ color: {
+ value: '#000000',
+ opacity: 100
+ }
+ },
+ equal: false
+ };
+
+ if (value) {
+ result.top = value.top || result.top;
+ result.left = value.left || result.left;
+ result.right = value.right || result.right;
+ result.bottom = value.bottom || result.bottom;
+ }
+
+ if (this.equal(result.top, result.right) && this.equal(result.top, result.bottom) && this.equal(result.top, result.left)) {
+ result.equal = true;
+ } else {
+ result.equal = false;
+ }
+
+ return result;
+ }
+
+ equal (comp1, comp2) {
+ return (
+ comp1.style === comp2.style &&
+ comp1.width === comp2.width &&
+ comp1.color.value === comp2.color.value &&
+ comp1.color.opacity === comp2.color.opacity
+ );
+ }
+
+ changeSelected (selected, event) {
+ event.preventDefault();
+ this.setState({
+ selected
+ });
+ }
+
+ renderToggleButton (pos, icon, active) {
+ var className = 'toggle ' + pos;
+
+ if (this.state.selected === pos) {
+ className += ' selected';
+ }
+
+ if (active) {
+ className += ' active';
+ }
+
+ return (
+
+ {icon}
+
+ );
+ }
+
+ render () {
+ var values = this.state.values;
+ var value = 0;
+ var inactive = false;
+
+ if (this.state.selected !== 'center') {
+ value = values[this.state.selected];
+ } else {
+ inactive = !values.equal;
+ value = values.top;
+
+ if (inactive) {
+ value.style = 'none';
+ }
+ }
+
+ return (
+
+
+ {this.renderToggleButton('top', 'border_top', !values.equal)}
+ {this.renderToggleButton('left', 'border_left', !values.equal)}
+ {this.renderToggleButton('center', 'border_outer', values.equal)}
+ {this.renderToggleButton('right', 'border_right', !values.equal)}
+ {this.renderToggleButton('bottom', 'border_bottom', !values.equal)}
+
+
+
+
+
+
+
+ );
+ }
+}
+
+BorderPicker.propTypes = {
+ value: React.PropTypes.object.isRequired,
+ onChange: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/border-style.jsx b/lib/components/border-style.jsx
new file mode 100644
index 000000000..498ee1447
--- /dev/null
+++ b/lib/components/border-style.jsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import cx from 'classnames';
+
+export default class BorderStyle extends Component {
+ onClick (type, event) {
+ event.preventDefault();
+ this.props.onChange(type);
+ }
+
+ renderOption (type) {
+ return (
+
+ {type === 'none' && close}
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.renderOption('none')}
+ {this.renderOption('solid')}
+ {this.renderOption('dashed')}
+ {this.renderOption('dotted')}
+
+ );
+ }
+}
+
+BorderStyle.propTypes = {
+ value: React.PropTypes.string.isRequired,
+ onChange: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/breadcrumbs.jsx b/lib/components/breadcrumbs.jsx
new file mode 100644
index 000000000..2a42bcaf3
--- /dev/null
+++ b/lib/components/breadcrumbs.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import A from './a';
+
+export default class Breadcrumbs extends Component {
+ renderBreadcrumb (item, index) {
+ var props = {
+ className: 'breadcrumb'
+ };
+ var result;
+
+ if(item.link){
+ props.href = item.link;
+
+ result = (
+
+ {item.label}
+
+
+ );
+ }
+ else {
+ result = (
+ {item.label}
+ );
+ }
+
+ return result;
+ }
+
+ render () {
+ return (
+
+ {this.props.data.map(this.renderBreadcrumb, this)}
+
+ );
+ }
+}
+
+Breadcrumbs.propTypes = {
+ data: React.PropTypes.array.isRequired
+};
diff --git a/lib/components/button.jsx b/lib/components/button.jsx
new file mode 100644
index 000000000..512270bd7
--- /dev/null
+++ b/lib/components/button.jsx
@@ -0,0 +1,30 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+
+export default class Button extends Component {
+ onClick (event) {
+ event.preventDefault();
+
+ if (this.props.action === 'addElement') {
+ this.context.addElementAtSelected(this.props.actionProps);
+ }
+ }
+
+ render () {
+ return (
+
+ {this.props.label}
+
+ );
+ }
+}
+
+Button.contextTypes = {
+ addElementAtSelected: React.PropTypes.func
+};
+
+Button.propTypes = {
+ label: React.PropTypes.string.isRequired,
+ action: React.PropTypes.string.isRequired,
+ actionProps: React.PropTypes.object.isRequired
+};
diff --git a/lib/components/checkbox.jsx b/lib/components/checkbox.jsx
new file mode 100644
index 000000000..caad5853e
--- /dev/null
+++ b/lib/components/checkbox.jsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+
+export default class Checkbox extends Component {
+ toggle (event) {
+ event.preventDefault();
+
+ if(this.props.onChange){
+ this.props.onChange(!this.props.value);
+ }
+ }
+
+ render () {
+ var className = 'checkbox'+(this.props.value ? ' active' : '');
+
+ return (
+
+
+
+
+ );
+ }
+}
+
+Checkbox.propTypes = {
+ value: React.PropTypes.bool.isRequired,
+ onChange: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/color-palette-picker/edit.jsx b/lib/components/color-palette-picker/edit.jsx
new file mode 100644
index 000000000..a27f4db6f
--- /dev/null
+++ b/lib/components/color-palette-picker/edit.jsx
@@ -0,0 +1,61 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import ColorPicker from 'react-colorpicker';
+import Lightbox from '../lightbox';
+import Input from '../input';
+
+import colorsActions from '../../client/actions/colors';
+
+export default class Edit extends Component {
+ getInitialState () {
+ return {
+ value: this.props.value || {
+ label: '',
+ value: '#ffffff'
+ }
+ };
+ }
+
+ closeEdit () {
+ this.props.onClose();
+ }
+
+ onEditColorChange (color) {
+ this.state.value.value = color.toHex();
+ this.setState({
+ value: this.state.value
+ });
+ }
+
+ onTitleChange (value) {
+ this.state.value.label = value;
+ this.setState({
+ value: this.state.value
+ });
+ }
+
+ submit () {
+ if (this.state.value._id) {
+ colorsActions.update(this.state.value).then(() => this.closeEdit());
+ } else {
+ colorsActions.add(this.state.value).then(() => this.closeEdit());
+ }
+ }
+
+ render () {
+ var isNew = this.state.value._id ? false : true;
+ var title = isNew ? 'Adding new color to palette' : 'Editing '+this.state.value.label;
+ var btn = isNew ? 'Add color to palette' : 'Change color';
+
+ return (
+
+
+
+
+
+ {btn}
+
+ );
+ }
+
+}
diff --git a/lib/components/color-palette-picker/entry.jsx b/lib/components/color-palette-picker/entry.jsx
new file mode 100644
index 000000000..ac72c64b7
--- /dev/null
+++ b/lib/components/color-palette-picker/entry.jsx
@@ -0,0 +1,93 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import OptionsMenu from '../options-menu';
+import cloneDeep from 'lodash.clonedeep';
+import cx from 'classnames';
+
+import colorsActions from '../../client/actions/colors';
+
+export default class Entry extends Component {
+ getInitialState () {
+ return {
+ options: false
+ };
+ }
+
+ edit () {
+ this.props.onEdit(this.props.color);
+ this.setState({
+ options: false
+ });
+ }
+
+ duplicate () {
+ var duplicate = cloneDeep(this.props.color);
+ delete duplicate._id;
+
+ colorsActions.add(duplicate);
+
+ this.setState({
+ options: false
+ });
+ }
+
+ remove () {
+ colorsActions.remove(this.props.color._id);
+ }
+
+ openOptions (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.setState({
+ options: true
+ });
+ }
+
+ onMouseLeave () {
+ if (this.state.options) {
+ this.setState({
+ options: false
+ });
+ }
+ }
+
+ onClick (event) {
+ event.preventDefault();
+ this.props.onClick(this.props.color._id);
+ }
+
+ renderOptionsMenu () {
+ if (this.state.options) {
+ return (
+
+ );
+ }
+ }
+
+ render () {
+ var style = {
+ backgroundColor: this.props.color.value
+ };
+
+ return (
+
+
+
{this.props.color.label}
+
+ more_horiz
+ {this.renderOptionsMenu()}
+
+
+ );
+ }
+}
+
+Entry.propTypes = {
+ color: React.PropTypes.object.isRequired,
+ onEdit: React.PropTypes.func.isRequired,
+ selected: React.PropTypes.bool.isRequired
+};
diff --git a/lib/components/color-palette-picker/hex-edit.jsx b/lib/components/color-palette-picker/hex-edit.jsx
new file mode 100644
index 000000000..7c737d1f2
--- /dev/null
+++ b/lib/components/color-palette-picker/hex-edit.jsx
@@ -0,0 +1,40 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import ColorPicker from 'react-colorpicker';
+import Lightbox from '../lightbox';
+
+export default class HexEdit extends Component {
+ getInitialState () {
+ return {
+ value: this.props.value
+ };
+ }
+
+ onEditColorChange (color) {
+ this.setState({
+ value: color.toHex()
+ });
+ }
+
+ onSubmit () {
+ this.props.onSubmit(this.state.value);
+ }
+
+ render () {
+ return (
+
+
+
+
+ Change it
+
+ );
+ }
+
+}
+
+HexEdit.propTypes = {
+ onClose: React.PropTypes.func.isRequired,
+ onSubmit: React.PropTypes.func.isRequired,
+ value: React.PropTypes.string.isRequired
+};
diff --git a/lib/components/color-palette-picker/index.jsx b/lib/components/color-palette-picker/index.jsx
new file mode 100644
index 000000000..d089c54d9
--- /dev/null
+++ b/lib/components/color-palette-picker/index.jsx
@@ -0,0 +1,128 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import NumberInput from '../number-input';
+import List from './list';
+import HexEdit from './hex-edit';
+import Colors from '../../colors';
+import clone from 'lodash.clone';
+import cx from 'classnames';
+
+export default class ColorPicker extends Component {
+ getInitialState () {
+ return {
+ opened: false,
+ hexLightbox: false
+ };
+ }
+
+ onOpacityChange (opacity) {
+ var value = clone(this.props.value);
+ value.opacity = opacity;
+ this.props.onChange(value);
+ }
+
+ onHexChange (event) {
+ var value = clone(this.props.value);
+ value.type = 'custom';
+ value.value = event.target.value;
+ this.props.onChange(value);
+ }
+
+ onEntryClick (id) {
+ var value = clone(this.props.value);
+ value.type = 'palette';
+ value.value = id;
+ this.props.onChange(value);
+ this.setState({
+ opened: false
+ });
+ }
+
+ toggleOpen (event) {
+ event.preventDefault();
+ this.setState({
+ opened: !this.state.opened
+ });
+ }
+
+ openEditColor (event) {
+ event.preventDefault();
+ this.setState({
+ hexLightbox: true
+ });
+ }
+ closeEditColor () {
+ this.setState({
+ hexLightbox: false
+ });
+ }
+ submitEditColor (hex) {
+ var value = clone(this.props.value);
+ value.type = 'custom';
+ value.value = hex;
+ this.props.onChange(value);
+ this.closeEditColor();
+ }
+
+ renderHexLightbox (hex) {
+ if (this.state.hexLightbox) {
+ return ;
+ }
+ }
+
+ renderContent (color) {
+ if (this.state.opened) {
+ return
;
+ } else {
+ const hex = color.colr.toHex();
+ const hexString = this.props.value.type === 'palette' ? hex : this.props.value.value;
+
+ return (
+
+
+
+
+
+ {this.renderHexLightbox(hex)}
+
+ );
+ }
+ }
+
+ render () {
+ const color = Colors.getColor(this.props.value);
+ const colorString = Colors.getColorString(color);
+ const hsl = color.colr.toHslObject();
+ var colorStyle = {
+ backgroundColor: colorString,
+ borderColor: colorString
+ };
+
+ return (
+ 60 && color.opacity > 50 && 'dark', this.props.className)}>
+
+
{color.label}
+
{this.props.value.type === 'palette' ? 'invert_colors' : 'invert_colors_off'}
+
+ {this.renderContent(color)}
+
+ );
+ }
+
+}
+
+ColorPicker.propTypes = {
+ value: React.PropTypes.object,
+ onChange: React.PropTypes.func
+};
+
+ColorPicker.defaultProps = {
+ value: {
+ type: 'custom',
+ value: '#000000',
+ opacity: 100
+ }
+};
diff --git a/lib/components/color-palette-picker/list.jsx b/lib/components/color-palette-picker/list.jsx
new file mode 100644
index 000000000..05883afcd
--- /dev/null
+++ b/lib/components/color-palette-picker/list.jsx
@@ -0,0 +1,94 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import Entry from './entry';
+import Edit from './edit';
+import cloneDeep from 'lodash.clonedeep';
+
+import colorsStore from '../../client/stores/colors';
+
+export default class List extends Component {
+ getInitialState () {
+ return {
+ colors: [],
+ editing: false
+ };
+ }
+
+ getInitialCollections () {
+ return {
+ colors: colorsStore.getCollection()
+ };
+ }
+
+ addNewOpen (event) {
+ event.preventDefault();
+ this.setState({
+ editing: true,
+ editingValue: this.props.selected.charAt(0) === '#' ? {label: '', value: this.props.selected} : false
+ });
+ }
+
+ editOpen (color) {
+ this.setState({
+ editing: true,
+ editingValue: cloneDeep(color)
+ });
+ }
+
+ closeEdit () {
+ this.setState({
+ editing: false
+ });
+ }
+
+ renderColorEntry (color) {
+ return (
+
+ );
+ }
+
+ renderList () {
+ if (this.state.colors.length > 0) {
+ return (
+
+ {this.state.colors.map(this.renderColorEntry, this)}
+
+ );
+ } else {
+ return (
+
+
invert_colors_off
+
You still don't have any colors in your palette!
+
+ );
+ }
+ }
+
+ renderEditing () {
+ if (this.state.editing) {
+ return (
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+ {this.renderList()}
+
+
+ add_circle_outline
+ Add new color
+
+ {this.renderEditing()}
+
+ );
+ }
+}
+
+List.propTypes = {
+ onEntryClick: React.PropTypes.func.isRequired,
+ selected: React.PropTypes.string.isRequired
+};
diff --git a/lib/components/columns-manager.jsx b/lib/components/columns-manager.jsx
new file mode 100644
index 000000000..47f5b9a2c
--- /dev/null
+++ b/lib/components/columns-manager.jsx
@@ -0,0 +1,175 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import cx from 'classnames';
+import {Types} from '../types';
+import Utils from '../utils';
+
+export default class ColumnsManager extends Component {
+ getInitialState () {
+ return {
+ selected: false
+ };
+ }
+
+ parseValue (value, idChanged = -1) {
+ return Utils.parseColumnsDisplay (value, this.context.selected.children.length, this.props.multiRows, idChanged);
+ }
+
+ onClick (key, event) {
+ event.preventDefault();
+
+ if (this.state.selected === key) {
+ this.setState({
+ selected: false
+ });
+ } else {
+ this.setState({
+ selected: key
+ });
+ }
+ }
+
+ onChange (id, value) {
+ var valueParsed = this.parseValue(this.props.value);
+ valueParsed[this.state.selected][id] = value;
+ const result = this.parseValue(valueParsed, this.state.selected);
+ this.props.onChange(result);
+ }
+
+ renderColumn (id, value) {
+ var style = {};
+
+ if (value.width === 'custom') {
+ style.width = value.widthPerc+'%';
+ }
+
+ return (
+
+ );
+ }
+
+ renderChildren () {
+ var value = this.parseValue(this.props.value);
+ var children = [], i, numChildren = this.context.selected.children.length;
+
+ for (i = 0; i < numChildren; i++) {
+ if (value[i].width === 'block') {
+ children.push(this.renderColumn(i, value[i]));
+ } else {
+ var columns = [];
+ for (i; i < numChildren; i++) {
+ if (value[i].width !== 'block' && !(columns.length > 0 && value[i].break)) {
+ columns.push(this.renderColumn(i, value[i]));
+ } else {
+ i--;
+ break;
+ }
+ }
+ children.push(
+
+ {columns}
+
+ );
+ }
+ }
+
+ return children;
+ }
+
+ renderOptions () {
+ if (this.state.selected !== false) {
+ var value = this.parseValue(this.props.value);
+ var values = value[this.state.selected];
+ var breakable = (
+ this.props.multiRows &&
+ values.width !== 'block' &&
+ this.state.selected >= 2 &&
+ this.state.selected < value.length - 1 &&
+ value[this.state.selected - 1].width !== 'block' &&
+ value[this.state.selected - 2].width !== 'block'
+ );
+
+ return (
+
+
+ {breakable && }
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+ {this.renderChildren()}
+ {this.renderOptions()}
+
+ );
+ }
+}
+
+ColumnsManager.columnOptions = [
+ {
+ label: 'Width',
+ type: Types.Select,
+ id: 'width',
+ props: {
+ labels: ['Block', 'Column auto', 'Column custom'],
+ values: ['block', 'auto', 'custom']
+ },
+ unlocks: {
+ custom: [
+ {
+ label: 'Width (%)',
+ type: Types.Percentage,
+ id: 'widthPerc'
+ }
+ ]
+ }
+ }
+];
+
+ColumnsManager.columnOptionsSingleRow = [
+ {
+ label: 'Width',
+ type: Types.Select,
+ id: 'width',
+ props: {
+ labels: ['Column auto', 'Column custom'],
+ values: ['auto', 'custom']
+ },
+ unlocks: {
+ custom: [
+ {
+ label: 'Width (%)',
+ type: Types.Percentage,
+ id: 'widthPerc'
+ }
+ ]
+ }
+ }
+];
+
+ColumnsManager.breakOptions = [
+ {
+ label: 'To next line',
+ type: Types.Boolean,
+ id: 'break',
+ default: false
+ }
+];
+
+ColumnsManager.contextTypes = {
+ selected: React.PropTypes.any.isRequired
+};
+
+ColumnsManager.propTypes = {
+ value: React.PropTypes.array.isRequired,
+ onChange: React.PropTypes.func.isRequired,
+ OptionsList: React.PropTypes.any.isRequired,
+ multiRows: React.PropTypes.bool
+};
+
+ColumnsManager.defaultProps = {
+ multiRows: true
+};
diff --git a/lib/components/combobox.jsx b/lib/components/combobox.jsx
new file mode 100644
index 000000000..53c2c0504
--- /dev/null
+++ b/lib/components/combobox.jsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import forEach from 'lodash.foreach';
+
+export default class Combobox extends Component {
+ getInitialState () {
+ return {
+ opened: false
+ };
+ }
+
+ toggle () {
+ this.setState({
+ opened: !this.state.opened
+ });
+ }
+
+ optionClicked ( value, event ) {
+ event.preventDefault();
+
+ if (this.props.onChange) {
+ this.props.onChange(value);
+ }
+
+ this.setState({
+ opened: false
+ });
+ }
+
+ renderOption (option, i) {
+ return (
+
+ {option}
+
+ );
+ }
+
+ render () {
+ var className = 'combobox-holder' + (this.state.opened ? ' opened' : '');
+
+ var label = '';
+ forEach(this.props.values, (value, key) => {
+ if (this.props.value === value) {
+ label = this.props.labels[key];
+ }
+ });
+
+ return (
+
+
+
+
+ {this.props.labels.map(this.renderOption, this)}
+
+
+
+ );
+ }
+}
+
+Combobox.propTypes = {
+ labels: React.PropTypes.array.isRequired,
+ values: React.PropTypes.array.isRequired,
+ value: React.PropTypes.string.isRequired,
+ onChange: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/complement-menu.jsx b/lib/components/complement-menu.jsx
new file mode 100644
index 000000000..803bcb3c1
--- /dev/null
+++ b/lib/components/complement-menu.jsx
@@ -0,0 +1,17 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import cx from 'classnames';
+
+export default class ComplementMenu extends Component {
+ render () {
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
+
+ComplementMenu.propTypes = {
+ className: React.PropTypes.string
+};
diff --git a/lib/components/component.jsx b/lib/components/component.jsx
new file mode 100644
index 000000000..564970393
--- /dev/null
+++ b/lib/components/component.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import {Droppable} from './drag';
+
+import stylesStore from '../client/stores/styles';
+
+export default class ElementComponent extends Component {
+
+ getInitialModels () {
+ var models = {};
+
+ if (this.constructor.settings.styles && this.props.style && typeof this.props.style === 'string' && this.props.style !== '') {
+ models.style = stylesStore.getModel(this.props.style);
+ }
+
+ return models;
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.constructor.settings.styles) {
+ if (typeof nextProps.style === 'object') {
+ this.unsetModels(['style']);
+ this.setState({
+ style: nextProps.style
+ });
+ } else {
+ if (nextProps.style && nextProps.style !== this.props.style && nextProps.style !== '') {
+ this.setModels({
+ style: stylesStore.getModel(nextProps.style)
+ });
+ } else if (!nextProps.style || nextProps.style === '' || nextProps.style === null) {
+ this.unsetModels(['style']);
+ this.setState({
+ style: false
+ });
+ }
+ }
+ }
+ }
+
+ renderContent (customProps) {
+ if (this.context.editing) {
+ var dropInfo = {
+ id: this.props.element.id
+ };
+
+ return (
+
+ {this.props.children}
+
+ );
+ } else {
+ return this.props.children;
+ }
+ }
+}
+
+ElementComponent.contextTypes = {
+ editing: React.PropTypes.bool.isRequired
+};
diff --git a/lib/components/corners-picker.jsx b/lib/components/corners-picker.jsx
new file mode 100644
index 000000000..584163f67
--- /dev/null
+++ b/lib/components/corners-picker.jsx
@@ -0,0 +1,132 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import NumberInput from './number-input';
+
+export default class CornersPicker extends Component {
+ getInitialState () {
+ return {
+ selected: 'center',
+ values: this.parseValue(this.props.value)
+ };
+ }
+
+ componentWillReceiveProps (nextProps) {
+ this.setState({
+ values: this.parseValue(nextProps.value)
+ });
+ }
+
+ onInputChange (value) {
+ if (this.state.selected === 'center') {
+ this.state.values.tl = value;
+ this.state.values.tr = value;
+ this.state.values.br = value;
+ this.state.values.bl = value;
+ } else {
+ this.state.values[this.state.selected] = value;
+ }
+ this.props.onChange(this.getValuesString(this.state.values));
+ }
+
+ getValuesString (values) {
+ return values.tl+'px '+values.tr+'px '+values.br+'px '+values.bl+'px';
+ }
+
+ parseValue (value) {
+ var values = value.split(' ');
+ var result = {
+ tl: 0,
+ bl: 0,
+ tr: 0,
+ br: 0,
+ equal: false
+ };
+
+ if (values.length === 1) {
+ var parsedValue = parseInt(values[0], 10);
+ result.tl = parsedValue;
+ result.br = parsedValue;
+ result.bl = parsedValue;
+ result.tr = parsedValue;
+ } else if (values.length === 2) {
+ result.tl = parseInt(values[0], 10);
+ result.tr = parseInt(values[1], 10);
+ result.br = parseInt(values[0], 10);
+ result.bl = parseInt(values[1], 10);
+ } else if (values.length === 4) {
+ result.tl = parseInt(values[0], 10);
+ result.tr = parseInt(values[1], 10);
+ result.br = parseInt(values[2], 10);
+ result.bl = parseInt(values[3], 10);
+ }
+
+ if (result.tl === result.tr && result.tl === result.br && result.tl === result.bl) {
+ result.equal = true;
+ } else {
+ result.equal = false;
+ }
+
+ return result;
+ }
+
+ changeSelected (selected, event) {
+ event.preventDefault();
+ this.setState({
+ selected
+ });
+ }
+
+ renderToggleButton (pos, active) {
+ var className = 'toggle ' + pos;
+
+ if (this.state.selected === pos) {
+ className += ' selected';
+ }
+
+ if (active) {
+ className += ' active';
+ }
+
+ return (
+
+ {pos === 'center' ? link : }
+
+ );
+ }
+
+ render () {
+ var className = 'corners-picker type-' + this.props.type;
+ var values = this.state.values;
+ var value = 0;
+ var inactive = false;
+
+ if (this.state.selected !== 'center') {
+ value = values[this.state.selected];
+ } else {
+ inactive = !values.equal;
+ value = values.equal ? values.tl : Math.round((values.tl+values.tr+values.br+values.bl)/4);
+ }
+
+ return (
+
+
+ {this.renderToggleButton('tl', !values.equal)}
+ {this.renderToggleButton('bl', !values.equal)}
+ {this.renderToggleButton('center', values.equal)}
+ {this.renderToggleButton('tr', !values.equal)}
+ {this.renderToggleButton('br', !values.equal)}
+
+
+
+ );
+ }
+}
+
+CornersPicker.propTypes = {
+ value: React.PropTypes.string.isRequired,
+ onChange: React.PropTypes.func.isRequired,
+ type: React.PropTypes.string.isRequired
+};
diff --git a/lib/components/drag.jsx b/lib/components/drag.jsx
new file mode 100644
index 000000000..c11aea85a
--- /dev/null
+++ b/lib/components/drag.jsx
@@ -0,0 +1,637 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import forEach from 'lodash.foreach';
+import merge from 'lodash.merge';
+import Utils from '../utils';
+import AnimateProps from './animateProps';
+import Velocity from 'velocity-animate';
+import cx from 'classnames';
+
+var LEFT_BUTTON = 0;
+var draggingData = {};
+var dragReport = {
+ dropInfo: false
+};
+var droppableOrientation = 'vertical';
+
+export class Draggable extends Component {
+ getInitialState () {
+ this.onMouseUpListener = this.onMouseUp.bind(this);
+ this.onMouseMoveListener = this.onMouseMove.bind(this);
+
+ return {
+ dragging: false
+ };
+ }
+
+ onMouseUp () {
+ document.removeEventListener('mouseup', this.onMouseUpListener);
+ document.removeEventListener('mousemove', this.onMouseMoveListener);
+ }
+
+ onMouseMove (event) {
+ event.preventDefault();
+
+ this.onMouseUp();
+
+ var element = React.findDOMNode(this);
+
+ var elementOffset = Utils.getOffsetRect(element);
+ var width = elementOffset.width;
+
+ // Change state
+ this.setState({
+ dragging: true
+ });
+
+ // Dragging data
+ draggingData = {
+ children: this.props.children,
+ elementOffset: elementOffset,
+ elementWidth: width,
+ mouseX: event.pageX,
+ mouseY: event.pageY,
+ type: this.props.type
+ };
+
+ dragReport.dragInfo = this.props.dragInfo;
+
+ if(this.props.droppableOn){
+ draggingData.droppableOn = this.props.droppableOn;
+ }
+
+ // Parent events
+ if (this.props && this.props.onStartDrag) {
+ this.props.onStartDrag();
+ } else if (this.context && this.context.onStartDrag) {
+ this.context.onStartDrag();
+ } else {
+ console.log("onStartDrag callback was no set on draggable object");
+ }
+
+ }
+
+ onMouseDown (event) {
+ if (event.button === LEFT_BUTTON) {
+ let draggable = !(this.props.dragSelected === false && this.context.selected.id === this.props.dragInfo.id);
+ event.stopPropagation();
+
+ if (draggable) {
+ document.addEventListener('mouseup', this.onMouseUpListener);
+ document.addEventListener('mousemove', this.onMouseMoveListener);
+ }
+ }
+ }
+
+ render () {
+ var props = {
+ className: (this.props.children.props.className || '')+' draggable',
+ draggable: 'false',
+ onMouseDown: this.onMouseDown.bind(this)
+ };
+
+ if (this.props.onClick) {
+ props.onClick = this.props.onClick;
+ }
+
+ return React.cloneElement(this.props.children, props);
+ }
+}
+
+Draggable.contextTypes = {
+ selected: React.PropTypes.any,
+ onStartDrag: React.PropTypes.func
+};
+
+Draggable.propTypes = {
+ type: React.PropTypes.string.isRequired,
+ dragInfo: React.PropTypes.object.isRequired,
+ droppableOn: React.PropTypes.string,
+ onStartDrag: React.PropTypes.func,
+ onClick: React.PropTypes.func
+};
+
+class Marker extends Component {
+ animateIn () {
+ var animateObj = {};
+
+ if (droppableOrientation === 'vertical') {
+ animateObj.height = '7px';
+ } else {
+ animateObj.width = '7px';
+ }
+
+ Velocity(React.findDOMNode(this), animateObj, { duration: 400, easing: "easeOutExpo" });
+ }
+
+ componentDidMount () {
+ this.animateInterval = setTimeout(this.animateIn.bind(this), 50);
+ }
+
+ componentWillUnmount () {
+ clearTimeout(this.animateInterval);
+ }
+
+ render () {
+ var style = {
+ height: droppableOrientation === 'vertical' ? 0 : 'auto',
+ width: droppableOrientation === 'vertical' ? '100%' : 0,
+ backgroundColor: '#33CC33',
+ opacity: '0.8'
+ };
+
+ if (droppableOrientation === 'horizontal') {
+ style.display = 'table-cell';
+ }
+
+ return (
+
+ );
+ }
+}
+
+
+export class Dragger extends Component {
+ getInitialState () {
+ return {
+ top: draggingData.elementOffset.top + (this.props.offset && this.props.offset.top ? this.props.offset.top : 0),
+ left: draggingData.elementOffset.left + (this.props.offset && this.props.offset.left ? this.props.offset.left : 0)
+ };
+ }
+
+ componentDidMount () {
+ super.componentDidMount();
+
+ this.onMouseUpListener = this.onMouseUp.bind(this);
+ this.onMouseMoveListener = this.onMouseMove.bind(this);
+
+ let node = React.findDOMNode(this);
+
+ let relativeX = draggingData.mouseX - draggingData.elementOffset.left;
+ let relativeY = draggingData.mouseY - draggingData.elementOffset.top;
+ node.style.transformOrigin = relativeX+'px '+relativeY+'px';
+
+ Velocity(React.findDOMNode(this), {
+ scaleX: '0.5',
+ scaleY: '0.5',
+ opacity: '0.7'
+ }, { duration: 500, easing: "easeOutExpo" });
+
+ document.addEventListener('mouseup', this.onMouseUpListener);
+ document.addEventListener('mousemove', this.onMouseMoveListener);
+ }
+
+ onMouseUp (event) {
+ document.removeEventListener('mouseup', this.onMouseUpListener);
+ document.removeEventListener('mousemove', this.onMouseMoveListener);
+
+ // Parent events
+ if (this.props.onStopDrag) {
+ this.props.onStopDrag();
+ }
+ else {
+ console.log("onStopDrag callback was no set on dragger object");
+ }
+ }
+
+ onMouseMove (event) {
+ event.preventDefault();
+
+ var deltaX = event.pageX - draggingData.mouseX + draggingData.elementOffset.left;
+ var deltaY = event.pageY - draggingData.mouseY + draggingData.elementOffset.top;
+
+ this.setState({
+ top: deltaY + (this.props.offset && this.props.offset.top ? this.props.offset.top : 0),
+ left: deltaX + (this.props.offset && this.props.offset.left ? this.props.offset.left : 0)
+ });
+ }
+
+ render () {
+ var style = {
+ position: 'absolute',
+ width: draggingData.elementWidth+'px',
+ top: this.state.top+'px',
+ left: this.state.left+'px',
+ pointerEvents: 'none',
+ boxShadow: '0px 0px 20px 0px rgba(0, 0, 0, 0.5)',
+ zIndex: 20
+ };
+
+ return (
+
+ {draggingData.children}
+
+ );
+ }
+}
+
+
+export class Droppable extends Component {
+
+ getInitialState () {
+ this.onMouseMoveListener = this.onMouseMove.bind(this);
+ this.onMouseUpListener = this.onMouseUp.bind(this);
+
+ return {
+ overed: false,
+ closeToMargin: false
+ };
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (!nextProps.dragging) {
+ this.removeOrderingEvents();
+
+ this.setState({
+ entered: false,
+ overed: false
+ });
+ }
+ }
+
+ componentDidMount () {
+ const containerRect = React.findDOMNode(this).getBoundingClientRect();
+
+ if (containerRect.left < 40) {
+ if (!this.state.closeToMargin) {
+ this.setState({
+ closeToMargin: true
+ });
+ }
+ } else if (this.state.closeToMargin) {
+ this.setState({
+ closeToMargin: false
+ });
+ }
+ }
+
+ getChildContext () {
+ var childContext = {
+ dropHighlight: 'none'
+ };
+
+ if (this.context.dragging) {
+ if (this.droppableHere()) {
+ if (this.props.orientation && this.props.orientation === 'horizontal') {
+ childContext.dropHighlight = 'horizontal';
+ } else {
+ childContext.dropHighlight = 'vertical';
+ }
+ } else if (this.draggingSelf()) {
+ childContext.dropBlock = true;
+ }
+ }
+
+ return childContext;
+ }
+
+ onMouseEnter (event) {
+ if (this.state.entered) {
+ return;
+ }
+
+ // Ordering
+ var order = false;
+ if (this.props.children && (this.props.children instanceof Array && this.props.children.length > 0)) {
+ var elements = React.findDOMNode(this).children;
+
+ if (elements.length > 0) {
+
+ // store children positions
+ this.childrenData = [];
+ var domPosition = 0;
+ forEach(elements, (child) => {
+ if (this.props.selectionChildren && child.className.indexOf(this.props.selectionChildren) === -1) {
+ domPosition ++;
+ return true;
+ }
+ order = true;
+
+ let position = Utils.getOffsetRect(child);
+ let width = position.width;
+ let height = position.height;
+
+ this.childrenData.push({position, width, height, domPosition});
+
+ domPosition ++;
+ });
+ }
+ }
+
+ this.setState({
+ entered: true,
+ order: order,
+ overed: !order
+ });
+
+ if (order) {
+ this.addOrderingEvents();
+ this.onMouseMove(event);
+ } else {
+ dragReport.dropInfo = this.props.dropInfo;
+ }
+ }
+
+ onMouseLeave (event) {
+ this.removeOrderingEvents();
+
+ if (dragReport.dropInfo && dragReport.dropInfo.id === this.props.dropInfo.id) {
+ dragReport.dropInfo = false;
+ }
+
+ this.setState({
+ entered: false,
+ overed: false
+ });
+ }
+
+ onMouseMove (event) {
+ var position = -1;
+ const hitSpace = this.props.hitSpace;
+
+ var mousePosition = this.props.orientation === 'horizontal' ? event.pageX : event.pageY;
+
+ forEach(this.childrenData, (child, index) => {
+
+ if (index === 0) {
+ let firstPosition = this.props.orientation === 'horizontal' ? child.position.left : child.position.top;
+ let secondPosition = this.props.orientation === 'horizontal' ? child.position.left+hitSpace : child.position.top+hitSpace;
+
+ if (mousePosition > firstPosition && mousePosition < secondPosition) {
+ position = index;
+ this.draggerPosition = child.domPosition;
+ return false;
+ }
+ }
+
+ var firstPosition, secondPosition;
+ if (index === this.childrenData.length - 1) {
+ firstPosition = this.props.orientation === 'horizontal' ? child.position.left+child.width-hitSpace : child.position.top+child.height-hitSpace;
+ secondPosition = this.props.orientation === 'horizontal' ? child.position.left+child.width : child.position.top+child.height;
+ } else {
+ firstPosition = this.props.orientation === 'horizontal' ? child.position.left+child.width-hitSpace/2 : child.position.top+child.height-hitSpace/2;
+ secondPosition = this.props.orientation === 'horizontal' ? child.position.left+child.width+hitSpace/2 : child.position.top+child.height+hitSpace/2;
+ }
+
+ if (mousePosition > firstPosition && mousePosition < secondPosition) {
+ position = index+1;
+ this.draggerPosition = child.domPosition + 1;
+ return false;
+ }
+ });
+
+ if (position !== -1 && !this.state.overed) {
+ if (dragReport.dropInfo === false) {
+ this.setState({
+ overed: true,
+ position
+ });
+ this.props.dropInfo.position = position;
+ dragReport.dropInfo = this.props.dropInfo;
+ droppableOrientation = this.props.orientation;
+
+ event.stopPropagation();
+ }
+ }
+ if (position === -1 && this.state.overed) {
+ this.setState({
+ overed: false,
+ position: false
+ });
+
+ if (dragReport.dropInfo && dragReport.dropInfo.id === this.props.dropInfo.id) {
+ dragReport.dropInfo = false;
+ }
+ }
+ }
+
+ onMouseUp (event) {
+
+ }
+
+ addOrderingEvents () {
+ document.addEventListener('mousemove', this.onMouseMoveListener);
+ document.addEventListener('mouseup', this.onMouseUpListener);
+ }
+
+ removeOrderingEvents () {
+ document.removeEventListener('mousemove', this.onMouseMoveListener);
+ document.removeEventListener('mouseup', this.onMouseUpListener);
+ }
+
+ draggingSelf () {
+ return this.props.dropInfo.id && dragReport.dragInfo.id && this.props.dropInfo.id === dragReport.dragInfo.id;
+ }
+
+ droppableHere () {
+ var is = true;
+
+ var dropBlock = this._reactInternalInstance._context && this._reactInternalInstance._context.dropBlock; // # TODO modify when react passes context from owner-based to parent-based (0.14?)
+ if (this.draggingSelf() || dropBlock) {
+ return false;
+ }
+
+ // Droppable restrictions
+ if (this.props.accepts) {
+ if (this.props.accepts !== 'any' && this.props.accepts !== draggingData.type) {
+ is = false;
+ }
+ } else if (this.props.rejects) {
+ if (this.props.rejects === 'any' || this.props.rejects === draggingData.type) {
+ is = false;
+ }
+ }
+
+ // Dragging restrictions
+ if (is && draggingData.droppableOn) {
+ if (draggingData.droppableOn !== 'any' && this.props.type !== draggingData.droppableOn) {
+ is = false;
+ }
+ }
+
+ return is;
+ }
+
+ getEvents () {
+ if (this.context.dragging && this.droppableHere()) {
+ return {
+ onMouseOver: this.onMouseEnter.bind(this),
+ onMouseLeave: this.onMouseLeave.bind(this)
+ };
+ }
+ }
+
+ addSpotClick (position, event) {
+ event.preventDefault();
+ this.context.openElementsMenu({
+ targetId: this.props.dropInfo.id || 'body',
+ targetType: this.props.type,
+ targetPosition: position,
+ container: React.findDOMNode(this.refs['spot'+position]),
+ accepts: this.props.accepts,
+ rejects: this.props.rejects
+ });
+ }
+
+ renderPlaceholderContent () {
+ if (this.state.overed) {
+ let props = {
+ scaleX: '150%',
+ scaleY: '150%'
+ };
+ let options = {
+ duration: 600,
+ loop: true
+ };
+ return (
+
+ add_circle
+
+ );
+ } else {
+ return (
+
+ Drop elements here or
+
+ click to add
+
+
+ );
+ }
+ }
+
+ renderPlaceholder () {
+ if (this.props.placeholder) {
+ return (
+
+ {this.renderPlaceholderContent()}
+
+ );
+ }
+ }
+
+ renderMark (position) {
+ let vertical = this.props.orientation && this.props.orientation === 'horizontal';
+ let active = this.context.elementsMenuSpot === position;
+
+ return (
+
+
+
+
+ add
+
+
+
+ );
+ }
+
+ render () {
+ var children = this.props.children;
+ var hasChildren = children && (children instanceof Array && children.length > 0);
+
+ var style = {
+ backgroundColor: this.state.overed && !this.state.order && !this.props.placeholder ? '#33CC33' : 'transparent',
+ minHeight: hasChildren ? 0 : this.props.minHeight,
+ minWidth: hasChildren ? 0 : this.props.minWidth,
+ position: 'relative'
+ };
+ if (this.props.style) {
+ merge(style, this.props.style);
+ }
+
+ if (this.state.overed && this.state.order && this.state.position !== false) {
+ children = hasChildren ? children.slice(0) : [this.props.children];
+
+ let marker = (
+
+ );
+
+ children.splice(this.draggerPosition, 0, marker);
+ } else if (hasChildren && this.context.selected.id === this.props.dropInfo.id && !this.context.dragging) {
+ var tempChildren = [
+ this.renderMark(0)
+ ];
+
+ forEach(children, (child, index) => {
+ tempChildren.push(child);
+ tempChildren.push(this.renderMark(index+1));
+ });
+
+ children = tempChildren;
+ }
+
+ return (
+
+ {hasChildren ? children : this.renderPlaceholder()}
+
+ );
+ }
+}
+
+Droppable.propTypes = {
+ orientation: React.PropTypes.string,
+ dropInfo: React.PropTypes.object.isRequired,
+ hitSpace: React.PropTypes.number.isRequired,
+ style: React.PropTypes.object,
+ minHeight: React.PropTypes.number,
+ accepts: React.PropTypes.any,
+ rejects: React.PropTypes.any,
+ type: React.PropTypes.string,
+ placeholder: React.PropTypes.bool
+};
+
+Droppable.defaultProps = {
+ orientation: 'vertical',
+ minHeight: 150,
+ minWidth: 50,
+ hitSpace: 18,
+ placeholder: false
+};
+
+Droppable.contextTypes = {
+ dragging: React.PropTypes.bool.isRequired,
+ dropBlock: React.PropTypes.bool,
+ selected: React.PropTypes.any,
+ openElementsMenu: React.PropTypes.func.isRequired,
+ elementsMenuSpot: React.PropTypes.number
+};
+
+Droppable.childContextTypes = {
+ dropHighlight: React.PropTypes.string.isRequired,
+ dropBlock: React.PropTypes.bool
+};
+
+
+export class DragRoot extends Component {
+ constructor (props, children) {
+ super(props, children);
+ }
+
+ onStartDrag () {
+ this.setState({
+ dragging: true
+ });
+ }
+
+ onStopDragEvent () {
+ this.setState({
+ dragging: false
+ });
+
+ if (this.draggedComponent) {
+ this.draggedComponent(dragReport);
+ dragReport.dropInfo = false;
+ } else {
+ console.log('draggedComponent not implemented on drag root extended component');
+ }
+ }
+
+ renderDragger (offset = {}) {
+ if (this.state && this.state.dragging) {
+ return (
+
+ );
+ }
+ }
+}
diff --git a/lib/components/element.jsx b/lib/components/element.jsx
new file mode 100644
index 000000000..81b6caffe
--- /dev/null
+++ b/lib/components/element.jsx
@@ -0,0 +1,253 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import forEach from 'lodash.foreach';
+import merge from 'lodash.merge';
+import {Droppable, Draggable} from './drag';
+import Utils from '../utils';
+import Velocity from 'velocity-animate';
+import cx from 'classnames';
+
+export default class Element extends Component {
+ getInitialState () {
+ if (this.context.editing && this.isClient()) {
+ this.animationEditingBind = this.animationEditing.bind(this);
+ window.addEventListener('animateElements', this.animationEditingBind);
+ }
+
+ return {
+ offset: {top: 0},
+ animation: this.props.element.animation && this.props.element.animation.use,
+ animated: false,
+ animatedEditing: false
+ };
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.context.editing && this.state.animation !== (this.props.element.animation && this.props.element.animation.use)) {
+ this.setState({
+ animation: this.props.element.animation && this.props.element.animation.use
+ });
+ }
+ }
+
+ componentDidMount () {
+ this.state.offset = this.getOffset();
+
+ if ((!this.context.editing && this.state.animation) || this.props.onEnterScreen) {
+ this.onScrollBind = this.onScroll.bind(this);
+ window.addEventListener('scroll', this.onScrollBind);
+ this.onScroll();
+ }
+ }
+
+ componentWillUnmount () {
+ if (this.onScrollBind) {
+ window.removeEventListener('scroll', this.onScrollBind);
+ }
+ if (this.animationEditingBind) {
+ window.removeEventListener('animateElements', this.animationEditingBind);
+ }
+ if (this.animationTimeout) {
+ clearTimeout(this.animationTimeout);
+ }
+ }
+
+ animate () {
+ var dom = React.findDOMNode(this);
+ var animation = this.props.element.animation;
+ this.state.animated = true;
+ this.state.animatedEditing = false;
+ Velocity(dom, animation.effect, {
+ duration: animation.duration,
+ display: null
+ });
+ }
+
+ animationInit () {
+ if (this.state.animation) {
+ var animation = this.props.element.animation;
+ this.animationTimeout = setTimeout(this.animate.bind(this), animation.delay);
+ }
+ }
+
+ animationEditing () {
+ if (this.state.animation) {
+ this.setState({
+ animated: false,
+ animatedEditing: true
+ });
+ this.animationInit();
+ }
+ }
+
+ onScroll () {
+ var dom = React.findDOMNode(this);
+ var rect = dom.getBoundingClientRect();
+
+ if ( (rect.top <= 0 && rect.bottom >= 0) || (rect.top > 0 && rect.top < window.outerHeight) ) {
+ if (this.state.animation) {
+ this.animationInit();
+ }
+ if (this.props.onEnterScreen) {
+ this.props.onEnterScreen();
+ }
+ window.removeEventListener('scroll', this.onScrollBind);
+ }
+ }
+
+ onElementClick (event) {
+ event.stopPropagation();
+ this.context.selectElement(this.props.element.id);
+ }
+
+ getOffset () {
+ var dom = React.findDOMNode(this);
+ return Utils.getOffsetRect(dom);
+ }
+
+ onMouseOver (event) {
+ if (!this.context.dragging) {
+ event.stopPropagation();
+ clearTimeout(this.outTimeout);
+ if (this.props.element.id !== this.context.overedElement.id && this.props.element.id !== this.context.selected.id) {
+ var offset = this.getOffset();
+ this.context.overElement(this.props.element.id);
+ this.state.offset = offset;
+ }
+ }
+ }
+
+ onMouseOut () {
+ if (!this.context.dragging && this.props.element.id === this.context.overedElement.id) {
+ this.outTimeout = setTimeout(this.selectOut.bind(this), 50);
+ }
+ }
+
+ selectOut () {
+ this.context.outElement(this.props.element.id);
+ }
+
+ renderContent () {
+ if (this.props.settings.drop && !this.props.settings.drop.customDropArea && this.context.editing) {
+ var dropInfo = {
+ id: this.props.element.id
+ };
+
+ return (
+
+ {this.props.children}
+
+ );
+ } else {
+ return this.props.children;
+ }
+ }
+
+ renderHighlight () {
+ if (typeof this.props.element.id !== 'string') {
+ var className;
+ var dropHighlight = this._reactInternalInstance._context.dropHighlight; // # TODO modify when react passes context from owner-based to parent-based (0.14?)
+
+ if (!this.context.dragging && (this.props.element.id === this.context.overedElement.id || this.props.element.id === this.context.selected.id)) {
+ var elementType = this.props.element.tag;
+ var element = this.context.elements[elementType];
+
+ let selected = this.props.element.id === this.context.selected.id;
+ let inside = this.state.offset.top <= 65 || (this.props.style && this.props.style.overflow === 'hidden');
+ let subComponent = this.props.element.subComponent;
+
+ return (
+
+
+ {element.settings.icon.content}
+ {this.props.element.label || elementType}
+
+
+ );
+ } else if (dropHighlight !== 'none') {
+ className = 'element-drop-highlight '+dropHighlight;
+ return (
+
+ );
+ }
+ }
+ }
+
+ render () {
+ var props = {};
+ forEach(this.props, (prop, key) => {
+ if (key !== 'tag' && key !== 'children' && key !== 'settings' && key !== 'element' && key !== 'onEnterScreen') {
+ props[key] = prop;
+ }
+ });
+
+ if (this.context.editing && this.props.settings.drag) {
+ if ((!this.context.dragging && (this.props.element.id === this.context.overedElement.id || this.props.element.id === this.context.selected.id)) || this.context.dragging) {
+ props.style = props.style || {};
+ props.style.position = props.style.position || 'relative';
+ }
+
+ if (this.state.animatedEditing && this.state.animation && !this.state.animated) {
+ props.style = props.style || {};
+ props.style.opacity = 0;
+ }
+
+ if (this.props.element.subComponent) {
+ return (
+
+ {this.renderContent()}
+ {this.renderHighlight()}
+
+ );
+ } else {
+ var draggableProps = merge({
+ dragInfo: {
+ type: 'move',
+ id: this.props.element.id
+ },
+ onClick: this.onElementClick.bind(this),
+ type: this.props.element.tag
+ }, this.props.settings.drag);
+
+ return (
+
+
+ {this.renderContent()}
+ {this.renderHighlight()}
+
+
+ );
+ }
+ } else {
+ if (this.state.animation && !this.state.animated) {
+ props.style = props.style || {};
+ props.style.opacity = 0;
+ }
+
+ return (
+
+ {this.renderContent()}
+
+ );
+ }
+ }
+}
+
+Element.propTypes = {
+ tag: React.PropTypes.string.isRequired,
+ element: React.PropTypes.object.isRequired,
+ settings: React.PropTypes.object.isRequired,
+ onEnterScreen: React.PropTypes.func
+};
+
+Element.contextTypes = {
+ selected: React.PropTypes.any,
+ selectElement: React.PropTypes.func,
+ dragging: React.PropTypes.bool,
+ elements: React.PropTypes.object,
+ overElement: React.PropTypes.func,
+ outElement: React.PropTypes.func,
+ overedElement: React.PropTypes.any,
+ editing: React.PropTypes.bool.isRequired,
+ dropHighlight: React.PropTypes.string
+};
diff --git a/lib/components/elements/button/classes.js b/lib/components/elements/button/classes.js
new file mode 100644
index 000000000..9475e7e39
--- /dev/null
+++ b/lib/components/elements/button/classes.js
@@ -0,0 +1,26 @@
+import jss from '../../../react-jss';
+
+export default jss.createRules({
+ holder: {
+ textAlign: 'center'
+ },
+ button: {
+ color: '#ffffff',
+ backgroundColor: '#282828',
+ borderRadius: '3px 3px 3px 3px',
+ padding: '11px 20px 11px 20px',
+ maxWidth: '250px',
+ display: 'inline-block',
+ ':hover': {
+ color: '#282828',
+ backgroundColor: '#ffffff'
+ }
+ },
+ sided: {
+ display: 'block',
+ '>div': {
+ display: 'table-cell',
+ verticalAlign: 'middle'
+ }
+ }
+});
diff --git a/lib/components/elements/button/index.jsx b/lib/components/elements/button/index.jsx
new file mode 100644
index 000000000..4696f5195
--- /dev/null
+++ b/lib/components/elements/button/index.jsx
@@ -0,0 +1,136 @@
+import React from 'react';
+import Component from '../../component';
+import Element from '../../element';
+import styles from '../../../styles';
+import cx from 'classnames';
+import forEach from 'lodash.foreach';
+
+import settings from './settings';
+import style from './style';
+import classes from './classes';
+import propsSchema from './props-schema';
+
+export default class Button extends Component {
+
+ componentWillReceiveProps (nextProps) {
+ if (this.context.editing && this.context.selected && this.context.selected.id === this.props.element.id) {
+ // Check if layout changed
+ if (nextProps.layout !== this.props.layout) {
+ // 'text', 'icontext', 'texticon', 'icon'
+
+ let newChildren = [];
+
+ let textChild = false;
+ let iconChild = false;
+
+ if (nextProps.layout === 'text' || nextProps.layout === 'texticon' || nextProps.layout === 'icontext') {
+ forEach(this.props.element.children, (child) => {
+ if (child.tag === 'TextBox') {
+ textChild = child;
+ }
+ });
+
+ if (!textChild) {
+ textChild = {
+ tag: 'TextBox',
+ children: 'Button text',
+ subComponent: true
+ };
+ }
+ }
+
+ if (nextProps.layout === 'icon' || nextProps.layout === 'texticon' || nextProps.layout === 'icontext') {
+ forEach(this.props.element.children, (child) => {
+ if (child.tag === 'Icon') {
+ iconChild = child;
+ }
+ });
+
+ if (!iconChild) {
+ iconChild = {
+ tag: 'Icon',
+ subComponent: true
+ };
+ }
+ }
+
+ if (iconChild && textChild) {
+ if (nextProps.layout === 'icon' || nextProps.layout === 'icontext') {
+ newChildren.push(iconChild);
+ if (nextProps.layout === 'icontext') {
+ newChildren.push(textChild);
+ }
+ } else if (nextProps.layout === 'text' || nextProps.layout === 'texticon') {
+ newChildren.push(textChild);
+ if (nextProps.layout === 'texticon') {
+ newChildren.push(iconChild);
+ }
+ }
+ } else {
+ newChildren.push(iconChild || textChild);
+ }
+
+ // Change it
+ this.context.elementContentChange(newChildren);
+ }
+ }
+ }
+
+ renderChildren () {
+ if (this.props.arrange === 'blocks' || this.props.layout === 'text' || this.props.layout === 'icon') {
+ return this.props.children;
+ } else {
+ return (
+
+ {this.props.children[0]}
+ {this.props.children[1]}
+
+ );
+ }
+ }
+
+ render () {
+ let classMap = (this.props.style && styles.getClassesMap(this.props.style)) || {};
+
+ let props = {
+ tag: 'div',
+ element: this.props.element,
+ settings: this.constructor.settings,
+ className: cx(classes.holder, classMap.holder)
+ };
+
+ return (
+
+
+ {this.renderChildren()}
+
+
+ );
+ }
+}
+
+Button.propTypes = {
+};
+
+Button.contextTypes = {
+ editing: React.PropTypes.bool.isRequired,
+ elementContentChange: React.PropTypes.func.isRequired,
+ selected: React.PropTypes.any
+};
+
+Button.defaultProps = {
+ layout: 'text',
+ arrange: 'side'
+};
+
+Button.defaultChildren = [
+ {
+ tag: 'TextBox',
+ children: 'Button text',
+ subComponent: true
+ }
+];
+
+styles.registerStyle(style);
+Button.propsSchema = propsSchema;
+Button.settings = settings;
diff --git a/lib/components/elements/button/props-schema.js b/lib/components/elements/button/props-schema.js
new file mode 100644
index 000000000..c54a4c8a5
--- /dev/null
+++ b/lib/components/elements/button/props-schema.js
@@ -0,0 +1,35 @@
+export default [
+ {
+ label: 'Layout',
+ type: 'Select',
+ id: 'layout',
+ props: {
+ labels: ['Text', 'Icon - Text', 'Text - Icon', 'Icon'],
+ values: ['text', 'icontext', 'texticon', 'icon']
+ },
+ unlocks: {
+ 'icontext': [
+ {
+ label: 'Arrange',
+ type: 'Select',
+ id: 'arrange',
+ props: {
+ labels: ['Side by side', 'Blocks'],
+ values: ['side', 'blocks']
+ }
+ }
+ ],
+ 'texticon': [
+ {
+ label: 'Arrange',
+ type: 'Select',
+ id: 'arrange',
+ props: {
+ labels: ['Side by side', 'Blocks'],
+ values: ['side', 'blocks']
+ }
+ }
+ ]
+ }
+ }
+];
diff --git a/lib/components/elements/button/settings.js b/lib/components/elements/button/settings.js
new file mode 100644
index 000000000..0b1f81f67
--- /dev/null
+++ b/lib/components/elements/button/settings.js
@@ -0,0 +1,10 @@
+export default {
+ icon: {
+ class: 'material-icons',
+ content: 'link'
+ },
+ style: 'button',
+ category: 'content',
+ drop: false,
+ drag: {}
+};
diff --git a/lib/components/elements/button/style.js b/lib/components/elements/button/style.js
new file mode 100644
index 000000000..56211fbf2
--- /dev/null
+++ b/lib/components/elements/button/style.js
@@ -0,0 +1,241 @@
+import {Types} from '../../../types';
+import Colors from '../../../colors';
+import Utils from '../../../utils';
+
+export default {
+ type: 'button',
+ options: [
+ {
+ label: 'Size',
+ type: Types.Select,
+ id: 'size',
+ props: {
+ labels: ['Full Width', 'Fit Content', 'Max Width (px)', 'Strict (px)'],
+ values: ['full', 'fit', 'max', 'strict']
+ },
+ unlocks: {
+ fit : [
+ {
+ label: 'Horizontal alignment',
+ type: Types.Select,
+ id: 'alignment',
+ props: {
+ labels: ['Left', 'Center', 'Right'],
+ values: ['left', 'center', 'right']
+ }
+ }
+ ],
+ max: [
+ {
+ label: 'Max Width',
+ type: Types.Pixels,
+ id: 'maxWidth'
+ },
+ {
+ label: 'Horizontal alignment',
+ type: Types.Select,
+ id: 'alignment',
+ props: {
+ labels: ['Left', 'Center', 'Right'],
+ values: ['left', 'center', 'right']
+ }
+ }
+ ],
+ strict: [
+ {
+ label: 'Width',
+ type: Types.Pixels,
+ id: 'width'
+ },
+ {
+ label: 'Height',
+ type: Types.Pixels,
+ id: 'height'
+ },
+ {
+ label: 'Horizontal alignment',
+ type: Types.Select,
+ id: 'alignment',
+ props: {
+ labels: ['Left', 'Center', 'Right'],
+ values: ['left', 'center', 'right']
+ }
+ }
+ ]
+ }
+ },
+ {
+ label: 'Color (overlaps texts color)',
+ type: 'Optional',
+ id: 'useColor',
+ unlocks: [
+ {
+ type: Types.Color,
+ id: 'color'
+ }
+ ]
+ },
+ {
+ label: 'Color On Over',
+ type: 'Optional',
+ id: 'useColorHover',
+ unlocks: [
+ {
+ type: Types.Color,
+ id: 'colorHover'
+ }
+ ]
+ },
+ {
+ label: 'Use Background',
+ type: 'Optional',
+ id: 'useBackground',
+ unlocks: [
+ {
+ label: 'Background Color',
+ type: Types.Color,
+ id: 'backgroundColor'
+ },
+ {
+ label: 'Background Color on over',
+ type: Types.Color,
+ id: 'backgroundColorOver'
+ }
+ ]
+ },
+ {
+ label: 'Border',
+ type: 'Optional',
+ id: 'useBorder',
+ unlocks: [
+ {
+ type: Types.Border,
+ id: 'border'
+ },
+ {
+ label: 'Border Color on over',
+ type: Types.Color,
+ id: 'borderColorOver'
+ }
+ ]
+ },
+ {
+ label: 'Rounded Corners',
+ type: 'Optional',
+ id: 'useCorners',
+ unlocks: [
+ {
+ type: Types.Corners,
+ id: 'corners'
+ }
+ ]
+ },
+ {
+ label: 'Padding',
+ type: 'Optional',
+ id: 'usePadding',
+ unlocks: [
+ {
+ type: Types.Padding,
+ id: 'padding'
+ }
+ ]
+ },
+ {
+ label: 'Animation on hover',
+ type: 'Optional',
+ id: 'useAnimation',
+ unlocks: [
+ {
+ type: Types.Number,
+ id: 'animationDuration',
+ props: {
+ label: 'ms'
+ }
+ }
+ ]
+ }
+ ],
+ defaults: {
+ size: 'fit',
+ maxWidth: 200,
+ width: 70,
+ height: 70,
+ alignment: 'center',
+ useColor: false,
+ color: {
+ value: '#ffffff',
+ opacity: 100
+ },
+ useColorHover: false,
+ colorHover: {
+ value: '#ffffff',
+ opacity: 100
+ },
+ useBackground: false,
+ backgroundColor: {
+ value: '#ffffff',
+ opacity: 100
+ },
+ backgroundColorHover: {
+ value: '#ffffff',
+ opacity: 100
+ },
+ useBorder: false,
+ border: false,
+ borderColorOver: {
+ value: '#ffffff',
+ opacity: 100
+ },
+ useCorners: false,
+ corners: '0px',
+ usePadding: false,
+ padding: '0px',
+ useAnimation: false,
+ animationDuration: 500
+ },
+ rules: (props) => {
+ let rules = {
+ button: {
+ backgroundColor: props.useBackground && Colors.getColorString(props.backgroundColor),
+ borderRadius: props.useCorners && props.corners,
+ padding: props.usePadding && props.padding,
+ '*': {
+ color: props.useColor && Colors.getColorString(props.color)
+ },
+ ':hover': {
+ '*': {
+ color: props.useColorHover && Colors.getColorString(props.colorHover)
+ },
+ backgroundColor: props.useBackground && Colors.getColorString(props.backgroundColorHover),
+ }
+ },
+ holder: {}
+ };
+
+ if (props.size === 'strict') {
+ rules.button.width = props.width;
+ rules.button.height = props.height;
+ } else if (props.size === 'fit') {
+ rules.button.display = 'inline-block';
+ } else if (props.size === 'max') {
+ rules.button.maxWidth = props.maxWidth;
+ }
+
+ if (props.size !== 'full') {
+ rules.holder.textAlign = props.alignment;
+ rules.button.display = 'inline-block';
+ }
+
+ if (props.useAnimation) {
+ rules.button.transition = 'all '+props.animationDuration+'ms cubic-bezier(0.190, 1.000, 0.220, 1.000)';
+ }
+
+ if (props.useBorder) {
+ Utils.applyBorders(rules.button, props.border);
+ rules.button[':hover'].borderColor = Colors.getColorString(props.borderColorOver);
+ }
+
+ return rules;
+ }
+};
diff --git a/lib/components/elements/column.jsx b/lib/components/elements/column.jsx
new file mode 100644
index 000000000..112ccec65
--- /dev/null
+++ b/lib/components/elements/column.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import Component from '../component';
+import Element from '../element';
+import {Types} from '../../types';
+
+export default class Column extends Component {
+ render () {
+ const layout = this.props.layout || {
+ width: 'auto'
+ };
+
+ var style = {
+ display: layout.width === 'block' ? 'block' : 'table-cell',
+ verticalAlign: this.props.vertical
+ };
+
+ if (this.props.left || this.props.right) {
+ style.padding = '0px '+this.props.right+'px 0px '+this.props.left+'px';
+ }
+ if (this.props.bottom) {
+ style.marginBottom = this.props.bottom;
+ }
+
+ var contentStyle = {
+ padding: this.props.padding
+ };
+
+ if (layout.width !== 'block') {
+ style.width = layout.widthPerc+'%';
+ }
+
+ return (
+
+
+ {this.renderContent()}
+
+
+ );
+ }
+}
+
+Column.settings = {
+ icon: {
+ class: 'material-icons',
+ content: 'view_carousel'
+ },
+ category: 'structure',
+ drop: {
+ rejects: 'Section',
+ customDropArea: true
+ },
+ drag: {
+ droppableOn: 'Columns'
+ }
+};
+
+Column.propTypes = {
+ padding: React.PropTypes.string.isRequired,
+ vertical: React.PropTypes.string.isRequired,
+ columnsDisplay: React.PropTypes.string.isRequired
+};
+
+Column.defaultProps = {
+ padding: '15px',
+ vertical: 'top'
+};
+
+Column.propsSchema = [
+ {
+ label: 'Padding',
+ type: Types.String,
+ id: 'padding'
+ },
+ {
+ label: 'Content vertical align',
+ type: Types.Select,
+ id: 'vertical',
+ props: {
+ labels: ['Top', 'Center', 'Bottom'],
+ values: ['top', 'middle', 'bottom']
+ }
+ }
+];
diff --git a/lib/components/elements/columns/index.jsx b/lib/components/elements/columns/index.jsx
new file mode 100644
index 000000000..9e1cb59b4
--- /dev/null
+++ b/lib/components/elements/columns/index.jsx
@@ -0,0 +1,132 @@
+import React from 'react';
+import Component from '../../component';
+import Element from '../../element';
+import Utils from '../../../utils';
+import {Droppable} from '../../drag';
+
+import jss from '../../../react-jss';
+import settings from './settings';
+import propsSchema from './props-schema';
+
+export default class Columns extends Component {
+
+ renderBlock (child, layout, bottom) {
+ return React.cloneElement(child, {
+ layout,
+ bottom
+ });
+ }
+
+ renderColumn (child, layout, left, right) {
+ return React.cloneElement(child, {
+ layout,
+ left,
+ right
+ });
+ }
+
+ renderChildren () {
+ var children = [], i, numChildren = this.props.children.length;
+ const layout = Utils.parseColumnsDisplay(this.props[this.context.display], numChildren, this.context.display !== 'desktop');
+
+ const spaceThird = Math.round(this.props.spacing / 3 * 100) / 100;
+ const spaceSides = spaceThird * 2;
+
+ var dropInfo = {
+ id: this.props.element.id
+ };
+
+ if (numChildren > 0) {
+ for (i = 0; i < numChildren; i++) {
+ if (layout[i].width === 'block') {
+ children.push(this.renderBlock(this.props.children[i], layout[i], i !== numChildren - 1 ? this.props.spacing : 0));
+ } else {
+ var columns = [];
+ for (i; i < numChildren; i++) {
+ if (layout[i].width !== 'block' && !(columns.length > 0 && layout[i].break)) {
+ let isLastColumn = (columns.length !== 0 && (i === numChildren - 1 || (layout[i + 1].width === 'block' || layout[i + 1].break)));
+ let left = columns.length === 0 ? 0 : (isLastColumn ? spaceSides : spaceThird);
+ let right = columns.length === 0 ? spaceSides : (isLastColumn ? 0 : spaceThird);
+
+ columns.push(this.renderColumn(this.props.children[i], layout[i], left, right));
+ } else {
+ i--;
+ break;
+ }
+ }
+
+ if (this.context.editing && this.context.display === 'desktop') {
+ return (
+
+ {columns}
+
+ );
+ } else {
+ let style = {};
+
+ if (i < numChildren - 1) {
+ style.paddingBottom = this.props.spacingRows;
+ }
+
+ children.push(
+
+ {columns}
+
+ );
+ }
+ }
+ }
+ } else if (this.context.editing) {
+ return (
+
+ );
+ }
+
+
+ return children;
+ }
+
+ render () {
+ return (
+
+ {this.renderChildren()}
+
+ );
+ }
+}
+
+var classes = jss.createRules({
+ row: {
+ display: 'table',
+ tableLayout: 'fixed',
+ width: '100%'
+ }
+});
+
+Columns.contextTypes = {
+ editing: React.PropTypes.bool.isRequired,
+ display: React.PropTypes.string.isRequired
+};
+
+Columns.propTypes = {
+ spacing: React.PropTypes.number.isRequired,
+ spacingRows: React.PropTypes.number.isRequired,
+ desktop: React.PropTypes.array.isRequired,
+ tablet: React.PropTypes.array.isRequired,
+ mobile: React.PropTypes.array.isRequired
+};
+
+Columns.defaultProps = {
+ spacing: 10,
+ spacingRows: 10,
+ desktop: [],
+ tablet: [],
+ mobile: []
+};
+
+Columns.defaultChildren = [
+ {tag: 'Column'}, {tag: 'Column'}
+];
+
+Columns.settings = settings;
+Columns.propsSchema = propsSchema;
diff --git a/lib/components/elements/columns/props-schema.js b/lib/components/elements/columns/props-schema.js
new file mode 100644
index 000000000..7cd5d7e98
--- /dev/null
+++ b/lib/components/elements/columns/props-schema.js
@@ -0,0 +1,42 @@
+import {Types} from '../../../types';
+
+export default [
+ {
+ id: 'add-button',
+ label: false,
+ type: Types.Button,
+ props: {
+ label: 'Add column',
+ action: 'addElement',
+ actionProps: 'Column'
+ }
+ },
+ {
+ label: 'Space between columns',
+ type: Types.Pixels,
+ id: 'spacing'
+ },
+ {
+ label: 'Space between rows (not used on desktop)',
+ type: Types.Pixels,
+ id: 'spacingRows'
+ },
+ {
+ label: 'Desktop display',
+ type: 'Columns',
+ id: 'desktop',
+ props: {
+ multiRows: false
+ }
+ },
+ {
+ label: 'Tablet display',
+ type: 'Columns',
+ id: 'tablet'
+ },
+ {
+ label: 'Mobile display',
+ type: 'Columns',
+ id: 'mobile'
+ }
+];
diff --git a/lib/components/elements/columns/settings.js b/lib/components/elements/columns/settings.js
new file mode 100644
index 000000000..231669db6
--- /dev/null
+++ b/lib/components/elements/columns/settings.js
@@ -0,0 +1,14 @@
+export default {
+ icon: {
+ class: 'material-icons',
+ content: 'view_column'
+ },
+ category: 'structure',
+ drop: {
+ orientation: 'horizontal',
+ selectionChildren: 'column',
+ accepts: 'Column',
+ customDropArea: true
+ },
+ drag: {}
+};
diff --git a/lib/components/elements/container/index.jsx b/lib/components/elements/container/index.jsx
new file mode 100644
index 000000000..b084c42f4
--- /dev/null
+++ b/lib/components/elements/container/index.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import Component from '../../component';
+import Element from '../../element';
+import styles from '../../../styles';
+
+import settings from './settings';
+import style from './style';
+import propsSchema from './props-schema';
+
+export default class Container extends Component {
+ render () {
+ let classMap = this.props.style && styles.getClassesMap(this.props.style);
+ let className = classMap && classMap.container || '';
+ let classNameHolder = classMap && classMap.holder || '';
+
+ let props = {
+ tag: 'div',
+ style: {
+ position: 'relative'
+ },
+ className,
+ settings: this.constructor.settings,
+ element: this.props.element
+ };
+
+ return (
+
+
+ {this.props.children}
+
+
+ );
+ }
+}
+
+Container.propTypes = {};
+Container.defaultProps = {};
+
+styles.registerStyle(style);
+Container.propsSchema = propsSchema;
+Container.settings = settings;
diff --git a/lib/components/elements/container/props-schema.js b/lib/components/elements/container/props-schema.js
new file mode 100644
index 000000000..d6d1738de
--- /dev/null
+++ b/lib/components/elements/container/props-schema.js
@@ -0,0 +1 @@
+export default [];
diff --git a/lib/components/elements/container/settings.js b/lib/components/elements/container/settings.js
new file mode 100644
index 000000000..524759fb1
--- /dev/null
+++ b/lib/components/elements/container/settings.js
@@ -0,0 +1,12 @@
+export default {
+ icon: {
+ class: 'material-icons',
+ content: 'view_array'
+ },
+ category: 'structure',
+ style: 'container',
+ drop: {
+ rejects: 'Section'
+ },
+ drag: {}
+};
diff --git a/lib/components/elements/container/style.js b/lib/components/elements/container/style.js
new file mode 100644
index 000000000..86ddf4101
--- /dev/null
+++ b/lib/components/elements/container/style.js
@@ -0,0 +1,129 @@
+import {Types} from '../../../types';
+import Colors from '../../../colors';
+import Utils from '../../../utils';
+
+export default {
+ type: 'container',
+ options: [
+ {
+ label: 'Background Color',
+ type: Types.Select,
+ id: 'backgroundColor',
+ props: {
+ labels: ['Transparent', 'Color'],
+ values: ['transparent', 'color']
+ },
+ unlocks: {
+ color: [
+ {
+ label: 'Background color',
+ type: Types.Color,
+ id: 'color'
+ }
+ ]
+ }
+ },
+ {
+ label: 'Width',
+ type: Types.Select,
+ id: 'width',
+ props: {
+ labels: ['Full Width', 'Max Width'],
+ values: ['full', 'max']
+ },
+ unlocks: {
+ max: [
+ {
+ label: 'Maximum Width',
+ type: Types.Pixels,
+ id: 'widthPx',
+ props: {
+ min: 0,
+ max: false
+ }
+ },
+ {
+ label: 'Content horizontal alignment',
+ type: Types.Select,
+ id: 'contentHorizontal',
+ props: {
+ labels: ['Left', 'Center', 'Right'],
+ values: ['left', 'center', 'right']
+ }
+ }
+ ]
+ }
+ },
+ {
+ label: 'Padding',
+ type: Types.Padding,
+ id: 'padding'
+ },
+ {
+ label: 'Rounded Corners',
+ type: Types.Corners,
+ id: 'corners'
+ },
+ {
+ label: 'Border',
+ type: Types.Border,
+ id: 'border'
+ }
+ ],
+ defaults: {
+ backgroundColor: 'transparent',
+ color: {
+ value: '#ffffff',
+ opacity: 100
+ },
+ width: 'max',
+ widthPx: 100,
+ contentHorizontal: 'center',
+ padding: '20px',
+ corners: '0px'
+ },
+ rules: (props) => {
+ var rule = {};
+ var holderRule = {};
+
+ if (props.backgroundColor === 'color') {
+ rule.backgroundColor = Colors.getColorString(props.color);
+ }
+
+ if (props.width === 'max') {
+ rule.maxWidth = props.widthPx;
+ rule.width = '100%';
+ rule.display = 'inline-block';
+
+ holderRule.textAlign = props.contentHorizontal;
+ }
+
+ rule.padding = props.padding;
+ rule.borderRadius = props.corners;
+ Utils.applyBorders(rule, props.border);
+
+ return {
+ container: rule,
+ holder: holderRule
+ };
+ },
+ getIdentifierLabel: (props) => {
+ var str = '';
+
+ if (props.width === 'max') {
+ str += props.widthPx+'px';
+ } else {
+ str += 'Full';
+ }
+
+ str += ' | ';
+
+ if (props.backgroundColor === 'color') {
+ str += Colors.getColorString(props.color);
+ } else {
+ str += 'transparent';
+ }
+
+ return str;
+ }
+};
diff --git a/lib/components/elements/counter/index.jsx b/lib/components/elements/counter/index.jsx
new file mode 100644
index 000000000..5605c6d1c
--- /dev/null
+++ b/lib/components/elements/counter/index.jsx
@@ -0,0 +1,70 @@
+import React from 'react';
+import Component from '../../component';
+import Element from '../../element';
+import ReactCounter from 'react-counter';
+import styles from '../../../styles';
+import cx from 'classnames';
+
+import settings from './settings';
+import propsSchema from './props-schema';
+
+export default class Counter extends Component {
+
+ getInitialState () {
+ return {
+ animate: false
+ };
+ }
+
+ onEnterScreen () {
+ this.setState({
+ animate: true
+ });
+ }
+
+ renderCounter () {
+ if (this.state.animate) {
+ return (
+
+ );
+ } else {
+ return {this.props.begin};
+ }
+ }
+
+ render () {
+ var classMap = (this.props.style && styles.getClassesMap(this.props.style)) || {};
+
+ var props = {
+ tag: 'div',
+ element: this.props.element,
+ settings: this.constructor.settings,
+ onEnterScreen: this.onEnterScreen.bind(this),
+ className: cx(classMap.text),
+ style: {
+ textAlign: this.props.align
+ }
+ };
+
+ return (
+
+ {this.renderCounter()}
+
+ );
+ }
+}
+
+Counter.propTypes = {
+ icon: React.PropTypes.string.isRequired,
+ style: React.PropTypes.any.isRequired
+};
+
+Counter.defaultProps = {
+ begin: 0,
+ end: 100,
+ duration: 2000,
+ align: 'center'
+};
+
+Counter.propsSchema = propsSchema;
+Counter.settings = settings;
diff --git a/lib/components/elements/counter/props-schema.js b/lib/components/elements/counter/props-schema.js
new file mode 100644
index 000000000..75732cb53
--- /dev/null
+++ b/lib/components/elements/counter/props-schema.js
@@ -0,0 +1,28 @@
+import {Types} from '../../../types';
+
+export default [
+ {
+ label: 'Begin',
+ type: Types.Number,
+ id: 'begin'
+ },
+ {
+ label: 'End',
+ type: Types.Number,
+ id: 'end'
+ },
+ {
+ label: 'Duration',
+ type: Types.Number,
+ id: 'duration'
+ },
+ {
+ label: 'Alignment',
+ type: Types.Select,
+ id: 'align',
+ props: {
+ labels: ['Left', 'Center', 'Right'],
+ values: ['left', 'center', 'right']
+ }
+ }
+];
diff --git a/lib/components/elements/counter/settings.js b/lib/components/elements/counter/settings.js
new file mode 100644
index 000000000..36e2f743d
--- /dev/null
+++ b/lib/components/elements/counter/settings.js
@@ -0,0 +1,10 @@
+export default {
+ icon: {
+ class: 'fa fa-sort-numeric-asc',
+ content: ''
+ },
+ category: 'content',
+ style: 'text',
+ drop: false,
+ drag: {}
+};
diff --git a/lib/components/elements/gap/index.jsx b/lib/components/elements/gap/index.jsx
new file mode 100644
index 000000000..9db2e0c61
--- /dev/null
+++ b/lib/components/elements/gap/index.jsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import Component from '../../component';
+import Element from '../../element';
+
+import settings from './settings';
+import propsSchema from './props-schema';
+
+export default class Gap extends Component {
+ render () {
+ var style = {
+ height: this.props.amount
+ };
+
+ return (
+
+ );
+ }
+}
+
+Gap.propTypes = {
+ amount: React.PropTypes.number.isRequired
+};
+
+Gap.defaultProps = {
+ amount: 30
+};
+
+Gap.propsSchema = propsSchema;
+Gap.settings = settings;
diff --git a/lib/components/elements/gap/props-schema.js b/lib/components/elements/gap/props-schema.js
new file mode 100644
index 000000000..96fc8ba15
--- /dev/null
+++ b/lib/components/elements/gap/props-schema.js
@@ -0,0 +1,9 @@
+import {Types} from '../../../types';
+
+export default [
+ {
+ label: 'Size',
+ type: Types.Pixels,
+ id: 'amount'
+ }
+];
diff --git a/lib/components/elements/gap/settings.js b/lib/components/elements/gap/settings.js
new file mode 100644
index 000000000..f99f08b5c
--- /dev/null
+++ b/lib/components/elements/gap/settings.js
@@ -0,0 +1,9 @@
+export default {
+ icon: {
+ class: 'material-icons',
+ content: 'vertical_align_bottom'
+ },
+ category: 'structure',
+ drop: false,
+ drag: {}
+};
diff --git a/lib/components/elements/google-maps/index.jsx b/lib/components/elements/google-maps/index.jsx
new file mode 100644
index 000000000..24d625b20
--- /dev/null
+++ b/lib/components/elements/google-maps/index.jsx
@@ -0,0 +1,155 @@
+import React from 'react';
+import Component from '../../component';
+import Element from '../../element';
+import {GoogleMaps, Marker} from 'react-google-maps';
+import Utils from '../../../utils';
+
+import settings from './settings';
+import propsSchema from './props-schema';
+
+export default class GoogleMapsElem extends Component {
+
+ getInitialState () {
+ return {
+ ready: this.loadAPI()
+ };
+ }
+
+ shouldComponentUpdate (nextProps, nextState) {
+ return (
+ this.context.editing && this.props.selected ||
+ nextState.ready !== this.state.ready
+ );
+ }
+
+ componentDidUpdate (prevProps) {
+ if (this.context.editing && this.state.ready && prevProps.height !== this.props.height) {
+ if (this.refs.map && this.refs.map.state && this.refs.map.state.instance) {
+ window.google.maps.event.trigger(this.refs.map.state.instance, 'resize');
+ }
+ }
+ }
+
+ loadAPI () {
+ if (typeof document !== 'undefined') {
+ if (!Utils.hasClass(document.body, 'googleMapsInitiated') && !Utils.hasClass(document.body, 'googleMapsLoading')) {
+ Utils.addClass(document.body, 'googleMapsLoading');
+
+ window.googleMapsInitiated = function () {
+ Utils.removeClass(document.body, 'googleMapsLoading');
+ Utils.addClass(document.body, 'googleMapsInitiated');
+ /* jshint ignore:start */
+ window.dispatchEvent(new Event('googleMapsInitiated'));
+ /* jshint ignore:end */
+ };
+
+ var script = document.createElement('script');
+ script.type = 'text/javascript';
+ script.src = 'https://maps.googleapis.com/maps/api/js?v=3.exp&sensor=false&callback=googleMapsInitiated';
+ document.body.appendChild(script);
+
+ window.addEventListener('googleMapsInitiated', this.onReady.bind(this));
+
+ return false;
+ } else if (!Utils.hasClass(document.body, 'googleMapsInitiated')) {
+ window.addEventListener('googleMapsInitiated', this.onReady.bind(this));
+ return false;
+ } else {
+ return true;
+ }
+ }
+ }
+
+ onReady () {
+ this.setState({
+ ready: true
+ });
+ }
+
+ renderMarker () {
+ if (this.props.useMarker) {
+ var position = {
+ lat: parseFloat(this.props.lat, 10),
+ lng: parseFloat(this.props.lng, 10)
+ };
+ return (
+
+ );
+ }
+ }
+
+ renderMap () {
+ if (this.state.ready) {
+ var gmap = (
+ {this.renderMarker()}
+ );
+
+ if (this.context.editing) {
+ return (
+
+ );
+ } else {
+ return gmap;
+ }
+ }
+ }
+
+ render () {
+ return (
+
+ {this.renderMap()}
+
+ );
+ }
+}
+
+GoogleMapsElem.contextTypes = {
+ editing: React.PropTypes.bool.isRequired
+};
+
+GoogleMapsElem.propTypes = {
+ zoom: React.PropTypes.number.isRequired,
+ lat: React.PropTypes.string.isRequired,
+ lng: React.PropTypes.string.isRequired,
+ height: React.PropTypes.number.isRequired,
+ scrollwheel: React.PropTypes.bool.isRequired,
+ panControl: React.PropTypes.bool.isRequired,
+ zoomControls: React.PropTypes.bool.isRequired,
+ mapTypeControl: React.PropTypes.bool.isRequired,
+ streetViewControl: React.PropTypes.bool.isRequired,
+ useMarker: React.PropTypes.bool.isRequired
+};
+
+GoogleMapsElem.defaultProps = {
+ zoom: 15,
+ lat: '41.1761671',
+ lng: '-8.601692',
+ height: 250,
+ scrollwheel: false,
+ panControl: false,
+ zoomControls: true,
+ mapTypeControl: false,
+ streetViewControl: true,
+ useMarker: true
+};
+
+GoogleMapsElem.propsSchema = propsSchema;
+GoogleMapsElem.settings = settings;
diff --git a/lib/components/elements/google-maps/props-schema.js b/lib/components/elements/google-maps/props-schema.js
new file mode 100644
index 000000000..a298003b0
--- /dev/null
+++ b/lib/components/elements/google-maps/props-schema.js
@@ -0,0 +1,60 @@
+import {Types} from '../../../types';
+import React from 'react';
+
+export default [
+ {
+ label: 'Zoom',
+ type: Types.Number,
+ id: 'zoom',
+ props: {
+ min: 0,
+ max: 21,
+ label:
+ }
+ },
+ {
+ label: 'Latitude',
+ type: Types.String,
+ id: 'lat'
+ },
+ {
+ label: 'Longitude',
+ type: Types.String,
+ id: 'lng'
+ },
+ {
+ label: 'Height',
+ type: Types.Pixels,
+ id: 'height'
+ },
+ {
+ label: 'Use scrollwheel',
+ type: Types.Boolean,
+ id: 'scrollwheel'
+ },
+ {
+ label: 'Pan Control',
+ type: Types.Boolean,
+ id: 'panControl'
+ },
+ {
+ label: 'Zoom controls',
+ type: Types.Boolean,
+ id: 'zoomControls'
+ },
+ {
+ label: 'Map Type Controls',
+ type: Types.Boolean,
+ id: 'mapTypeControl'
+ },
+ {
+ label: 'Streetview Control',
+ type: Types.Boolean,
+ id: 'streetViewControl'
+ },
+ {
+ label: 'Use marker',
+ type: Types.Boolean,
+ id: 'useMarker'
+ }
+];
diff --git a/lib/components/elements/google-maps/settings.js b/lib/components/elements/google-maps/settings.js
new file mode 100644
index 000000000..3be50f73d
--- /dev/null
+++ b/lib/components/elements/google-maps/settings.js
@@ -0,0 +1,9 @@
+export default {
+ icon: {
+ class: 'fa fa-google',
+ content: ''
+ },
+ category: 'media',
+ drop: false,
+ drag: {}
+};
diff --git a/lib/components/elements/icon/classes.js b/lib/components/elements/icon/classes.js
new file mode 100644
index 000000000..74ce45458
--- /dev/null
+++ b/lib/components/elements/icon/classes.js
@@ -0,0 +1,8 @@
+import jss from '../../../react-jss';
+
+export default jss.createRules({
+ holder: {
+ display: 'inline-block',
+ textAlign: 'center'
+ }
+});
diff --git a/lib/components/elements/icon/index.jsx b/lib/components/elements/icon/index.jsx
new file mode 100644
index 000000000..57e5597f7
--- /dev/null
+++ b/lib/components/elements/icon/index.jsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import Component from '../../component';
+import Element from '../../element';
+import styles from '../../../styles';
+import cx from 'classnames';
+
+import settings from './settings';
+import style from './style';
+import propsSchema from './props-schema';
+import classes from './classes';
+
+export default class Icon extends Component {
+ render () {
+ let classMap = (this.props.style && styles.getClassesMap(this.props.style)) || {};
+ var props = {
+ tag: 'div',
+ element: this.props.element,
+ settings: this.constructor.settings,
+ style: {
+ textAlign: this.props.align
+ }
+ };
+
+ return (
+
+
+
+
+
+ );
+ }
+}
+
+Icon.propTypes = {
+ icon: React.PropTypes.string.isRequired,
+ style: React.PropTypes.any.isRequired
+};
+
+Icon.defaultProps = {
+ icon: 'fa fa-beer',
+ align: 'center'
+};
+
+styles.registerStyle(style);
+Icon.propsSchema = propsSchema;
+Icon.settings = settings;
diff --git a/lib/components/elements/icon/props-schema.js b/lib/components/elements/icon/props-schema.js
new file mode 100644
index 000000000..d1599044a
--- /dev/null
+++ b/lib/components/elements/icon/props-schema.js
@@ -0,0 +1,18 @@
+import {Types} from '../../../types';
+
+export default [
+ {
+ label: 'Icon',
+ type: Types.Icon,
+ id: 'icon'
+ },
+ {
+ label: 'Alignment',
+ type: Types.Select,
+ id: 'align',
+ props: {
+ labels: ['Left', 'Center', 'Right'],
+ values: ['left', 'center', 'right']
+ }
+ }
+];
diff --git a/lib/components/elements/icon/settings.js b/lib/components/elements/icon/settings.js
new file mode 100644
index 000000000..f90d11b20
--- /dev/null
+++ b/lib/components/elements/icon/settings.js
@@ -0,0 +1,10 @@
+export default {
+ icon: {
+ class: 'fa fa-flag',
+ content: ''
+ },
+ style: 'icon',
+ category: 'content',
+ drop: false,
+ drag: {}
+};
diff --git a/lib/components/elements/icon/style.js b/lib/components/elements/icon/style.js
new file mode 100644
index 000000000..02d81048e
--- /dev/null
+++ b/lib/components/elements/icon/style.js
@@ -0,0 +1,92 @@
+import {Types} from '../../../types';
+import Colors from '../../../colors';
+
+export default {
+ type: 'icon',
+ options: [
+ {
+ label: 'Color',
+ type: Types.Color,
+ id: 'color'
+ },
+ {
+ label: 'Font size',
+ type: Types.Pixels,
+ id: 'size'
+ },
+ {
+ label: 'Use Background',
+ type: Types.Boolean,
+ id: 'background',
+ unlocks: {
+ true: [
+ {
+ label: 'Width',
+ type: Types.Pixels,
+ id: 'width'
+ },
+ {
+ label: 'Height',
+ type: Types.Pixels,
+ id: 'height'
+ },
+ {
+ label: 'Background Color',
+ type: Types.Color,
+ id: 'backgroundColor'
+ },
+ {
+ label: 'Rounded Corners',
+ type: Types.Corners,
+ id: 'corners'
+ }
+ ]
+ }
+ }
+ ],
+ defaults: {
+ color: {
+ value: '#ffffff',
+ opacity: 100
+ },
+ size: 16,
+ background: false,
+ width: 70,
+ height: 70,
+ backgroundColor: {
+ value: '#000000',
+ opacity: 100
+ },
+ corners: '0px'
+ },
+ rules: (props) => {
+ let rules = {
+ icon: {}
+ };
+
+ rules.icon.fontSize = props.size;
+ rules.icon.color = Colors.getColorString(props.color);
+
+ if (props.background) {
+ rules.holder = {};
+
+ rules.holder.width = props.width;
+ rules.holder.height = props.height;
+ rules.holder.backgroundColor = Colors.getColorString(props.backgroundColor);
+ rules.holder.borderRadius = props.corners;
+
+ rules.icon.lineHeight = props.height + 'px';
+ }
+
+ return rules;
+ },
+ getIdentifierLabel: (props) => {
+ var str = '';
+
+ str += props.size+'px';
+ str += ' | ';
+ str += Colors.getColorString(props.color);
+
+ return str;
+ }
+};
diff --git a/lib/components/elements/image/index.jsx b/lib/components/elements/image/index.jsx
new file mode 100644
index 000000000..5df980829
--- /dev/null
+++ b/lib/components/elements/image/index.jsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import Component from '../../component';
+import Element from '../../element';
+import MediaImage from '../../image';
+import Utils from '../../../utils';
+import Colors from '../../../colors';
+
+import settings from './settings';
+import propsSchema from './props-schema';
+
+export default class Image extends Component {
+ getInitialState () {
+ return {
+ mounted: false
+ };
+ }
+
+ componentDidMount () {
+ var dom = React.findDOMNode(this);
+ var rect = dom.getBoundingClientRect();
+
+ var width = Math.round(rect.right-rect.left);
+
+ this.setState({
+ mounted: true,
+ width
+ });
+ }
+
+ render () {
+ var style = {
+ backgroundColor: Colors.getColorString(this.props.color)
+ };
+ var imageStyle = {};
+
+ if (this.props.height === 'strict') {
+ style.height = this.props.height_px;
+ style.overflow = 'hidden';
+
+ Utils.translate(imageStyle, 0, (-this.props.vertical)+'%');
+ imageStyle.top = this.props.height_px * (this.props.vertical / 100);
+ imageStyle.position = 'relative';
+ }
+
+ if (this.props.width === 'max') {
+ imageStyle.maxWidth = this.props.width_px;
+ style.textAlign = this.props.horizontal;
+ } else {
+ imageStyle.minWidth = '100%';
+ }
+
+ return (
+
+ {this.state.mounted ? : null}
+
+ );
+ }
+}
+
+Image.propTypes = {
+ color: React.PropTypes.string.isRequired,
+ image: React.PropTypes.string.isRequired,
+ height: React.PropTypes.string.isRequired,
+ height_px: React.PropTypes.number,
+ vertical: React.PropTypes.number
+};
+
+Image.defaultProps = {
+ color: {
+ value: '#ffffff',
+ opacity: 0
+ },
+ height: 'auto',
+ height_px: 200,
+ vertical: 50,
+ width: 'full',
+ width_px: 300,
+ horizontal: 'center'
+};
+
+Image.propsSchema = propsSchema;
+Image.settings = settings;
diff --git a/lib/components/elements/image/props-schema.js b/lib/components/elements/image/props-schema.js
new file mode 100644
index 000000000..de1e5563b
--- /dev/null
+++ b/lib/components/elements/image/props-schema.js
@@ -0,0 +1,64 @@
+import {Types} from '../../../types';
+
+export default [
+ {
+ label: 'Background color',
+ type: Types.Color,
+ id: 'color'
+ },
+ {
+ label: 'Background Image',
+ type: Types.Image,
+ id: 'image'
+ },
+ {
+ label: 'Height',
+ type: Types.Select,
+ id: 'height',
+ props: {
+ labels: ['Auto', 'Strict'],
+ values: ['auto', 'strict']
+ },
+ unlocks: {
+ strict: [
+ {
+ label: 'Pixels',
+ type: Types.Pixels,
+ id: 'height_px'
+ },
+ {
+ label: 'Vertical position',
+ type: Types.Percentage,
+ id: 'vertical'
+ }
+ ]
+ }
+ },
+ {
+ label: 'Width',
+ type: Types.Select,
+ id: 'width',
+ props: {
+ labels: ['Full width', 'Max. width'],
+ values: ['full', 'max']
+ },
+ unlocks: {
+ max: [
+ {
+ label: 'Pixels',
+ type: Types.Pixels,
+ id: 'width_px'
+ },
+ {
+ label: 'Horizontal alignment',
+ type: Types.Select,
+ id: 'horizontal',
+ props: {
+ labels: ['Left', 'Center', 'Right'],
+ values: ['left', 'center', 'right']
+ }
+ }
+ ]
+ }
+ }
+];
diff --git a/lib/components/elements/image/settings.js b/lib/components/elements/image/settings.js
new file mode 100644
index 000000000..21fe41787
--- /dev/null
+++ b/lib/components/elements/image/settings.js
@@ -0,0 +1,9 @@
+export default {
+ icon: {
+ class: 'fa fa-image',
+ content: ''
+ },
+ category: 'media',
+ drop: false,
+ drag: {}
+};
diff --git a/lib/components/elements/index.js b/lib/components/elements/index.js
new file mode 100644
index 000000000..c1d6e2d80
--- /dev/null
+++ b/lib/components/elements/index.js
@@ -0,0 +1,33 @@
+import Button from './button';
+import Section from './section';
+import Container from './container';
+import Columns from './columns';
+import Column from './column';
+import TextBox from './text-box';
+import Image from './image';
+import Video from './video';
+import Gap from './gap';
+import LineDivider from './line-divider';
+import GoogleMaps from './google-maps';
+import Icon from './icon';
+import Counter from './counter';
+import Schema from './schema';
+import MusicPlayer from './music-player';
+
+export default {
+ Button,
+ Section,
+ Container,
+ Columns,
+ Column,
+ TextBox,
+ Image,
+ Video,
+ Gap,
+ LineDivider,
+ GoogleMaps,
+ Icon,
+ Counter,
+ Schema,
+ MusicPlayer
+};
diff --git a/lib/components/elements/line-divider/classes.js b/lib/components/elements/line-divider/classes.js
new file mode 100644
index 000000000..6a44bfd37
--- /dev/null
+++ b/lib/components/elements/line-divider/classes.js
@@ -0,0 +1,10 @@
+import jss from '../../../react-jss';
+
+export default jss.createRules({
+ holder: {
+ height: '1px'
+ },
+ line: {
+ borderBottom: '1px solid #000000'
+ }
+});
diff --git a/lib/components/elements/line-divider/index.jsx b/lib/components/elements/line-divider/index.jsx
new file mode 100644
index 000000000..7cce51109
--- /dev/null
+++ b/lib/components/elements/line-divider/index.jsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import Component from '../../component';
+import Element from '../../element';
+import styles from '../../../styles';
+import cx from 'classnames';
+
+import settings from './settings';
+import style from './style';
+import classes from './classes';
+import propsSchema from './props-schema';
+
+export default class LineDivider extends Component {
+ render () {
+ let classMap = (this.props.style && styles.getClassesMap(this.props.style)) || {};
+ let style = {
+ padding: this.props.padding
+ };
+
+ return (
+
+
+
+ );
+ }
+}
+
+LineDivider.propTypes = {
+ padding: React.PropTypes.string.isRequired
+};
+LineDivider.defaultProps = {
+ padding: '0px'
+};
+
+styles.registerStyle(style);
+LineDivider.propsSchema = propsSchema;
+LineDivider.settings = settings;
diff --git a/lib/components/elements/line-divider/props-schema.js b/lib/components/elements/line-divider/props-schema.js
new file mode 100644
index 000000000..60fc62175
--- /dev/null
+++ b/lib/components/elements/line-divider/props-schema.js
@@ -0,0 +1,9 @@
+import {Types} from '../../../types';
+
+export default [
+ {
+ label: 'Padding',
+ type: Types.Padding,
+ id: 'padding'
+ }
+];
diff --git a/lib/components/elements/line-divider/settings.js b/lib/components/elements/line-divider/settings.js
new file mode 100644
index 000000000..ea9877b30
--- /dev/null
+++ b/lib/components/elements/line-divider/settings.js
@@ -0,0 +1,10 @@
+export default {
+ icon: {
+ class: 'fa fa-minus',
+ content: ''
+ },
+ category: 'structure',
+ style: 'lineDivider',
+ drop: false,
+ drag: {}
+};
diff --git a/lib/components/elements/line-divider/style.js b/lib/components/elements/line-divider/style.js
new file mode 100644
index 000000000..356f0a5ee
--- /dev/null
+++ b/lib/components/elements/line-divider/style.js
@@ -0,0 +1,109 @@
+import React from 'react';
+import {Types} from '../../../types';
+import Colors from '../../../colors';
+
+export default {
+ type: 'lineDivider',
+ options: [
+ {
+ label: 'Line Height',
+ type: Types.Pixels,
+ id: 'size'
+ },
+ {
+ label: 'Style',
+ type: Types.String,
+ id: 'style'
+ },
+ {
+ label: 'Color',
+ type: Types.Color,
+ id: 'color'
+ },
+ {
+ label: 'Max Width',
+ type: Types.Select,
+ id: 'width',
+ props: {
+ labels: ['Full width', 'Strict'],
+ values: ['full', 'strict']
+ },
+ unlocks: {
+ strict: [
+ {
+ label: 'Max Width',
+ type: Types.Pixels,
+ id: 'maxWidth'
+ },
+ {
+ label: 'Align',
+ type: Types.Select,
+ id: 'align',
+ props: {
+ labels: ['Left', 'Center', 'Right'],
+ values: ['left', 'center', 'right']
+ }
+ }
+ ]
+ }
+ }
+ ],
+ defaults: {
+ size: 1,
+ style: 'solid',
+ color: {
+ value: '#000000',
+ opacity: 100
+ },
+ width: 'full',
+ maxWidth: 100,
+ align: 'center'
+ },
+ rules: (props) => {
+ let rules = {
+ line: {},
+ holder: {}
+ };
+
+ rules.line.borderBottom = props.size+'px '+props.style+' '+Colors.getColorString(props.color);
+ rules.holder.height = props.size;
+
+ if (props.width === 'strict') {
+ rules.line.display = 'inline-block';
+ rules.line.width = props.maxWidth;
+ rules.line.maxWidth = '100%';
+ rules.line.verticalAlign = 'top';
+ rules.holder.textAlign = props.align;
+ }
+
+ return rules;
+ },
+ getIdentifierLabel: (props) => {
+ var str = '';
+
+ str += props.size+'px';
+ str += ' | ';
+ str += props.width;
+
+ return str;
+ },
+ preview: (classesMap) => {
+ var holderStyle = {
+ height: 50,
+ position: 'relative'
+ };
+ var style = {
+ position: 'relative',
+ top: '50%',
+ transform: 'translateY(-50%)'
+ };
+
+ return (
+
+ );
+ }
+};
diff --git a/lib/components/elements/music-player.jsx b/lib/components/elements/music-player.jsx
new file mode 100644
index 000000000..8126a3566
--- /dev/null
+++ b/lib/components/elements/music-player.jsx
@@ -0,0 +1,303 @@
+import React from 'react';
+import Component from '../component';
+import Element from '../element';
+
+import {Types} from '../../types';
+import $ from 'jquery';
+import {soundManager} from 'soundmanager2';
+
+import classNames from 'classnames';
+import jss from '../../react-jss';
+
+const consumer_key = '6c786345f5161898f1e1380802ce9226';
+
+export default class MusicPlayer extends Component {
+
+ getInitialState () {
+ if (!this.context.editing && this.isClient()) {
+ if (this.props.type === 'soundcloud') {
+ this.loadSoundcloud();
+ } else {
+
+ }
+ }
+
+ return {
+ playing: false,
+ loadedPercentage: 0,
+ loadedLabel: '00:00',
+ playedPercentage: 0,
+ playedLabel: '00:00'
+ };
+ }
+
+ componentWillUnmount () {
+ if (this.sound) {
+ this.sound.destruct();
+ }
+ }
+
+ loadSoundcloud () {
+ $.getJSON("http://api.soundcloud.com/resolve?url=" + this.props.soundcloud + "&format=json&consumer_key=" + consumer_key + "&callback=?", this.soundcloudLoaded.bind(this));
+ }
+
+ soundcloudLoaded (soundcloudInfo) {
+ this.url = soundcloudInfo.stream_url;
+
+ if (this.url.indexOf('secret_token') === -1) {
+ this.url += '?';
+ } else {
+ this.url += "&";
+ }
+
+ this.url += 'consumer_key=' + consumer_key;
+ soundManager.onready(this.createSound.bind(this));
+ }
+
+ createSound () {
+ this.sound = soundManager.createSound({
+ id: 'sound_'+this.props.element.id,
+ url: this.url,
+ autoLoad: false,
+ autoPlay: false,
+ onfinish: this.playingStatusChanged.bind(this),
+ whileloading: this.whileLoading.bind(this),
+ whileplaying: this.whilePlaying.bind(this),
+ volume: this.props.defaultVolume
+ });
+
+ this.playingStatusChanged();
+ //this.volumeChanged();
+ }
+
+ whileLoading () {
+ var loadedPercentage = this.sound.bytesLoaded / this.sound.bytesTotal;
+
+ var secondsPassed = Math.round(this.sound.duration/1000);
+ var minutesPassed = 0;
+ if(secondsPassed >= 60){
+ minutesPassed = Math.floor(secondsPassed/60);
+ secondsPassed = secondsPassed - minutesPassed*60;
+ }
+ var loadedLabel = (minutesPassed < 10 ? "0"+minutesPassed : minutesPassed) + ":" + (secondsPassed < 10 ? "0"+secondsPassed : secondsPassed);
+
+ this.setState({
+ loadedPercentage,
+ loadedLabel
+ });
+ }
+
+ whilePlaying () {
+ var playedPercentage;
+ if (this.sound.loaded) {
+ playedPercentage = this.sound.position / this.sound.duration;
+ } else {
+ playedPercentage = this.sound.position / this.sound.durationEstimate;
+ }
+
+ var secondsPassed = Math.round(this.sound.position/1000);
+ var minutesPassed = 0;
+ if (secondsPassed >= 60) {
+ minutesPassed = Math.floor(secondsPassed/60);
+ secondsPassed = secondsPassed - minutesPassed*60;
+ }
+ var playedLabel = (minutesPassed < 10 ? "0"+minutesPassed : minutesPassed) + ":" + (secondsPassed < 10 ? "0"+secondsPassed : secondsPassed);
+
+ this.setState({
+ playedPercentage,
+ playedLabel
+ });
+ }
+
+ playingStatusChanged () {
+ if (this.sound.paused || this.sound.playState === 0) {
+ this.setState({
+ playing: false
+ });
+ } else {
+ this.setState({
+ playing: true
+ });
+ }
+ }
+
+ togglePlay (event) {
+ event.preventDefault();
+
+ this.sound.togglePause();
+ this.playingStatusChanged();
+ }
+
+ renderControls () {
+ var icon = this.state.playing ? 'fa fa-pause' : 'fa fa-play';
+ return (
+
+ );
+ }
+
+ renderProgressBar () {
+ return (
+
+ );
+ }
+
+ renderPlayback () {
+ return (
+
+
{this.props.artist}
+
{this.props.title}
+
+
{this.state.playedLabel}
+
+ {this.renderProgressBar()}
+
+
{this.state.loadedLabel}
+
+
+ );
+ }
+
+ renderVolume () {
+ return (
+
+
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.renderControls()}
+ {this.renderPlayback()}
+ {this.renderVolume()}
+
+ );
+ }
+}
+
+var classes = jss.createRules({
+ musicPlayer: {
+ position: 'relative',
+ backgroundColor: '#333333'
+ },
+ part: {
+ display: 'table-cell',
+ verticalAlign: 'middle'
+ },
+ fit: {
+ width: '1%',
+ whiteSpace: 'nowrap'
+ },
+ bar: {
+ position: 'relative',
+ height: 7,
+ backgroundColor: '#cccccc'
+ },
+ streamBars: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: 0
+ }
+});
+
+MusicPlayer.propTypes = {
+ artist: React.PropTypes.string.isRequired,
+ title: React.PropTypes.string.isRequired,
+ type: React.PropTypes.oneOf(['local', 'soundcloud']).isRequired
+};
+
+MusicPlayer.defaultProps = {
+ artist: 'Artist/Band name',
+ title: 'Song title',
+ type: 'local',
+ defaultVolume: 50
+};
+
+MusicPlayer.propsSchema = [
+ {
+ label: 'Music',
+ type: Types.Select,
+ id: 'type',
+ props: {
+ labels: ['Your Sounds', 'Soundcloud'],
+ values: ['local', 'soundcloud']
+ },
+ unlocks: {
+ local: [
+
+ ],
+ soundcloud: [
+ {
+ label: 'Soundcloud music url',
+ type: Types.String,
+ id: 'soundcloud'
+ }
+ ]
+ }
+ }/*,
+ {
+ label: 'Style',
+ type: Types.Style,
+ id: 'style',
+ props: {
+ type: 'musicPlayer',
+ options: [
+ {
+ label: 'Toggle Play Button',
+ id: 'togglePlayButton',
+ type: Types.Group,
+ props: {
+ options: [
+
+ ]
+ }
+ }
+ {
+ label: 'Font Family',
+ id: 'font',
+ type: Types.Font
+ },
+ {
+ label: 'Font Size',
+ id: 'fontSize',
+ type: Types.Pixels
+ },
+ {
+ label: 'Line Height',
+ id: 'lineHeight',
+ type: Types.Pixels
+ },
+ {
+ label: 'Color',
+ id: 'color',
+ type: Types.Color
+ }
+ ],
+ defaults: {
+ font: {},
+ fontSize: 16,
+ lineHeight: 16,
+ color: '#ffffff'
+ }
+ }
+ }*/
+];
+
+MusicPlayer.settings = {
+ icon: {
+ class: 'fa fa-music',
+ content: ''
+ },
+ category: 'media',
+ drop: false,
+ drag: {}
+};
diff --git a/lib/components/elements/schema.jsx b/lib/components/elements/schema.jsx
new file mode 100644
index 000000000..07f639b7a
--- /dev/null
+++ b/lib/components/elements/schema.jsx
@@ -0,0 +1,164 @@
+import React from 'react';
+import Component from '../component';
+import {Types} from '../../types';
+import Element from '../element';
+import cloneDeep from 'lodash.clonedeep';
+import forEach from 'lodash.foreach';
+
+import schemasStore from '../../client/stores/schemas';
+import schemaEntriesStoreFactory from '../../client/stores/schema-entries';
+
+export default class Schema extends Component {
+
+ getInitialModels () {
+ var models = {};
+
+ if (this.props.schema && this.props.schema.schema) {
+ models.schema = schemasStore.getModel(this.props.schema.schema);
+ }
+
+ return models;
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.schema && nextProps.schema.schema !== this.props.schema.schema) {
+ this.setModels({
+ schema: schemasStore.getModel(nextProps.schema.schema)
+ });
+ }
+ }
+
+ componentDidUpdate (prevProps, prevState) {
+ if (
+ this.state.schema && this.state.schema.slug && (
+ (!prevState.schema) ||
+ ( prevState.schema && this.state.schema.slug !== prevState.schema.slug)
+ )) {
+ this.setCollections({
+ entries: schemaEntriesStoreFactory(this.state.schema.slug).getCollection()
+ });
+ }
+ }
+
+ alterProps (children, entry) {
+ forEach(children, (element) => {
+ element.props = element.props || {};
+
+ forEach(this.props.schema.fields, (field, key) => {
+ if (field.link === element.id) {
+ if (field.prop === 'children') {
+ element.children = entry[key];
+ } else {
+ element.props[field.prop] = entry[key];
+ }
+ }
+ });
+
+ element.id = this.props.element.id + '.' + element.id;
+
+ if (element.children instanceof Array && element.children.length > 0) {
+ this.alterProps(element.children, entry);
+ }
+ });
+ }
+
+ renderCover () {
+ if (this.context.editing) {
+ return (
+
+ );
+ }
+ }
+
+ renderEntry (entry) {
+ var schemaModuleClone = cloneDeep(this.props.element.children);
+
+ if (this.props.schema && this.props.schema.fields) {
+ this.alterProps(schemaModuleClone, entry);
+ }
+
+ return (
+
+ {schemaModuleClone.map(this.context.renderElement)}
+ {this.renderCover()}
+
+ );
+ }
+
+ renderEntries () {
+ if (
+ this.state.entries &&
+ this.state.entries.length &&
+ this.state.entries.length > 0 &&
+ this.props.element.children &&
+ this.props.element.children.length > 0
+ ) {
+ return (
+
+ {this.state.entries.map(this.renderEntry, this)}
+
+ );
+ }
+ }
+
+ renderTemplateModule () {
+ if (this.context.editing) {
+ var style = {
+ position: 'relative'
+ };
+ return (
+
+ {this.renderContent()}
+
+
+ );
+ }
+ }
+
+ render () {
+ var props = {
+ tag: 'div',
+ element: this.props.element,
+ settings: this.constructor.settings
+ };
+
+ return (
+
+ {this.renderTemplateModule()}
+ {this.renderEntries()}
+
+ );
+ }
+}
+
+Schema.contextTypes = {
+ renderElement: React.PropTypes.func.isRequired,
+ editing: React.PropTypes.bool.isRequired
+};
+
+Schema.propTypes = {
+
+};
+
+Schema.defaultProps = {
+
+};
+
+Schema.propsSchema = [
+ {
+ label: 'Schema',
+ type: Types.SchemaLink,
+ id: 'schema'
+ }
+];
+
+Schema.settings = {
+ icon: {
+ class: 'fa fa-sitemap',
+ content: ''
+ },
+ drop: {
+ customDropArea: true
+ },
+ drag: {}
+};
diff --git a/lib/components/elements/section/index.jsx b/lib/components/elements/section/index.jsx
new file mode 100644
index 000000000..1f44a1b30
--- /dev/null
+++ b/lib/components/elements/section/index.jsx
@@ -0,0 +1,114 @@
+import React from 'react';
+import Component from '../../component';
+import Element from '../../element';
+import MediaImage from '../../image';
+import Utils from '../../../utils';
+import styles from '../../../styles';
+
+import settings from './settings';
+import style from './style';
+import propsSchema from './props-schema';
+
+export default class Section extends Component {
+ getInitialState () {
+ return {
+ mounted: false
+ };
+ }
+
+ componentDidMount () {
+ this.resize();
+ }
+
+ componentDidUpdate () {
+ this.resize();
+ }
+
+ resize () {
+ var dom = React.findDOMNode(this);
+ var rect = dom.getBoundingClientRect();
+
+ var width = Math.round(rect.right-rect.left);
+ var height = Math.round(rect.bottom-rect.top);
+
+ if (this.state.width !== width || this.state.height !== height) {
+ this.setState({
+ mounted: true,
+ width,
+ height
+ });
+ }
+ }
+
+ renderBackground () {
+ if (this.state.mounted && this.props.useBackgroundImage && this.props.backgroundImage && this.props.backgroundImage !== '') {
+ var style = {
+ position: 'absolute',
+ top: 0, left: 0, right: 0, bottom: 0,
+ overflow: 'hidden'
+ };
+ var imageStyle= {
+ position: 'relative',
+ minWidth: '100%',
+ minHeight: '100%',
+ maxWidth: 'none'
+ };
+ imageStyle.top = this.state.height * (this.props.vertical / 100);
+ imageStyle.left = this.state.width * (this.props.horizontal / 100);
+ Utils.translate(imageStyle, (-this.props.horizontal)+'%', (-this.props.vertical)+'%');
+
+ return (
+
+
+
+ );
+ }
+ }
+
+ render () {
+ let classMap = this.props.style && styles.getClassesMap(this.props.style);
+ let className = classMap && classMap.section || '';
+ let classNameContent = classMap && classMap.content || '';
+
+ let props = {
+ tag: 'div',
+ style: {
+ position: 'relative'
+ },
+ className,
+ settings: this.constructor.settings,
+ element: this.props.element
+ };
+
+ if (this.props.navigation && this.props.navigation !== '') {
+ props.id = this.props.navigation;
+ }
+
+ return (
+
+ {this.renderBackground()}
+
+ {this.renderContent()}
+
+
+ );
+ }
+}
+
+Section.propTypes = {
+ backgroundImage: React.PropTypes.string.isRequired,
+ vertical: React.PropTypes.number,
+ horizontal: React.PropTypes.number,
+ navigation: React.PropTypes.string
+};
+
+Section.defaultProps = {
+ backgroundImage: '',
+ vertical: 50,
+ horizontal: 50,
+ navigation: ''
+};
+
+styles.registerStyle(style);
+Section.propsSchema = propsSchema;
+Section.settings = settings;
diff --git a/lib/components/elements/section/props-schema.js b/lib/components/elements/section/props-schema.js
new file mode 100644
index 000000000..79677ee5d
--- /dev/null
+++ b/lib/components/elements/section/props-schema.js
@@ -0,0 +1,32 @@
+import {Types} from '../../../types';
+
+export default [
+ {
+ label: 'Navigation ID',
+ type: Types.String,
+ id: 'navigation'
+ },
+ {
+ label: 'Background Image',
+ type: 'Optional',
+ id: 'useBackgroundImage',
+ unlocks: [
+ {
+ type: Types.Image,
+ id: 'backgroundImage',
+ unlocks: [
+ {
+ label: 'Vertical position',
+ type: Types.Percentage,
+ id: 'vertical'
+ },
+ {
+ label: 'Horizontal position',
+ type: Types.Percentage,
+ id: 'horizontal'
+ }
+ ]
+ }
+ ]
+ }
+];
diff --git a/lib/components/elements/section/settings.js b/lib/components/elements/section/settings.js
new file mode 100644
index 000000000..f84972073
--- /dev/null
+++ b/lib/components/elements/section/settings.js
@@ -0,0 +1,13 @@
+export default {
+ icon: {
+ class: 'material-icons',
+ content: 'view_day'
+ },
+ category: 'structure',
+ style: 'section',
+ drop: {
+ rejects: 'Section',
+ customDropArea: true
+ },
+ drag: {}
+};
diff --git a/lib/components/elements/section/style.js b/lib/components/elements/section/style.js
new file mode 100644
index 000000000..7247e5950
--- /dev/null
+++ b/lib/components/elements/section/style.js
@@ -0,0 +1,110 @@
+import {Types} from '../../../types';
+import Colors from '../../../colors';
+
+export default {
+ type: 'section',
+ options: [
+ {
+ label: 'Background Color',
+ type: Types.Select,
+ id: 'backgroundColor',
+ props: {
+ labels: ['Transparent', 'Color'],
+ values: ['transparent', 'color']
+ },
+ unlocks: {
+ color: [
+ {
+ label: 'Background color',
+ type: Types.Color,
+ id: 'color'
+ }
+ ]
+ }
+ },
+ {
+ label: 'Height',
+ type: Types.Select,
+ id: 'height',
+ props: {
+ labels: ['Auto', 'Strict Height'],
+ values: ['auto', 'strict']
+ },
+ unlocks: {
+ strict: [
+ {
+ label: 'Percentage from viewport',
+ type: Types.Percentage,
+ id: 'heightPerc',
+ props: {
+ min: 0,
+ max: 200
+ }
+ },
+ {
+ label: 'Content vertical alignment',
+ type: Types.Percentage,
+ id: 'contentVertical'
+ }
+ ]
+ }
+ },
+ {
+ label: 'Padding',
+ type: Types.Padding,
+ id: 'padding'
+ }
+ ],
+ defaults: {
+ backgroundColor: 'transparent',
+ color: {
+ value: '#ffffff',
+ opacity: 100
+ },
+ height: 'auto',
+ heightPerc: 100,
+ contentVertical: 50,
+ padding: '20px'
+ },
+ rules: (props) => {
+ var rule = {};
+ var contentRule = {};
+
+ if (props.backgroundColor === 'color') {
+ rule.backgroundColor = Colors.getColorString(props.color);
+ }
+
+ if (props.height === 'strict') {
+ rule.height = props.heightPerc+'vh';
+ contentRule.position = 'relative';
+ contentRule.top = props.contentVertical+'%';
+ contentRule.transform = 'translateY(-'+props.contentVertical+'%)';
+ }
+
+ rule.padding = props.padding;
+
+ return {
+ section: rule,
+ content: contentRule
+ };
+ },
+ getIdentifierLabel: (props) => {
+ var str = '';
+
+ if (props.height === 'strict') {
+ str += props.heightPerc+'%';
+ } else {
+ str += 'Auto';
+ }
+
+ str += ' | ';
+
+ if (props.backgroundColor === 'color') {
+ str += Colors.getColorString(props.color);
+ } else {
+ str += 'transparent';
+ }
+
+ return str;
+ }
+};
diff --git a/lib/components/elements/text-box/classes.js b/lib/components/elements/text-box/classes.js
new file mode 100644
index 000000000..a1b2f53ad
--- /dev/null
+++ b/lib/components/elements/text-box/classes.js
@@ -0,0 +1,27 @@
+import jss from '../../../react-jss';
+
+const common = {
+ fontSize: 'inherit',
+ lineHeight: 'inherit',
+ letterSpacing: 'inherit',
+ fontFamily: 'inherit',
+ fontStyle: 'inherit',
+ fontWeight: 'inherit',
+ margin: 0,
+ padding: 0,
+ color: 'inherit'
+};
+
+export default jss.createRules({
+ text: {
+ outline: 0,
+ border: 0,
+ p: common,
+ h1: common,
+ h2: common,
+ h3: common,
+ h4: common,
+ h5: common,
+ h6: common
+ }
+});
diff --git a/lib/components/elements/text-box/index.jsx b/lib/components/elements/text-box/index.jsx
new file mode 100644
index 000000000..a82ed144f
--- /dev/null
+++ b/lib/components/elements/text-box/index.jsx
@@ -0,0 +1,98 @@
+import React from 'react';
+import Component from '../../component';
+import Element from '../../element';
+import styles from '../../../styles';
+import cx from 'classnames';
+import Editor from '../../medium-editor';
+
+import settings from './settings';
+import style from './style';
+import classes from './classes';
+import propsSchema from './props-schema';
+
+export default class TextBox extends Component {
+
+ getStyle () {
+ let style = {};
+
+ if (this.props.usePadding) {
+ style.padding = this.props.padding;
+ }
+ if (this.props.useAlign) {
+ style.textAlign = this.props.textAlign;
+ }
+
+ return style;
+ }
+
+ renderContent () {
+ var classMap = (this.props.style && styles.getClassesMap(this.props.style)) || {};
+
+ let html = '';
+ if ((!this.props.children || this.props.children === '') && this.context.editing && !this.props.selected) {
+ html = 'Double click to edit text';
+ } else {
+ html = this.props.children;
+ }
+
+ if (this.context.editing && this.props.selected) {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+
+ render () {
+ var props = {
+ tag: 'div',
+ element: this.props.element,
+ settings: this.constructor.settings,
+ style: this.getStyle()
+ };
+
+ return (
+
+ {this.renderContent()}
+
+ );
+ }
+}
+
+TextBox.contextTypes = {
+ editing: React.PropTypes.bool.isRequired,
+ elementContentChange: React.PropTypes.func.isRequired
+};
+
+TextBox.propTypes = {
+ selected: React.PropTypes.bool.isRequired,
+ padding: React.PropTypes.string.isRequired,
+ textAlign: React.PropTypes.string.isRequired
+};
+
+TextBox.defaultProps = {
+ padding: '0px',
+ textAlign: 'left'
+};
+
+TextBox.defaultChildren = 'Click to edit text';
+
+styles.registerStyle(style);
+TextBox.propsSchema = propsSchema;
+TextBox.settings = settings;
diff --git a/lib/components/elements/text-box/props-schema.js b/lib/components/elements/text-box/props-schema.js
new file mode 100644
index 000000000..34e3b2987
--- /dev/null
+++ b/lib/components/elements/text-box/props-schema.js
@@ -0,0 +1,30 @@
+import {Types} from '../../../types';
+
+export default [
+ {
+ label: 'Padding',
+ type: 'Optional',
+ id: 'usePadding',
+ unlocks: [
+ {
+ type: Types.Padding,
+ id: 'padding'
+ }
+ ]
+ },
+ {
+ label: 'Alignment',
+ type: 'Optional',
+ id: 'useAlign',
+ unlocks: [
+ {
+ type: Types.Select,
+ id: 'textAlign',
+ props: {
+ labels: ['Left', 'Center', 'Right'],
+ values: ['left', 'center', 'right']
+ }
+ }
+ ]
+ }
+];
diff --git a/lib/components/elements/text-box/settings.js b/lib/components/elements/text-box/settings.js
new file mode 100644
index 000000000..de1bb414d
--- /dev/null
+++ b/lib/components/elements/text-box/settings.js
@@ -0,0 +1,11 @@
+export default {
+ icon: {
+ class: 'fa fa-font'
+ },
+ category: 'content',
+ style: 'text',
+ drop: false,
+ drag: {
+ dragSelected: false
+ }
+};
diff --git a/lib/components/elements/text-box/style.js b/lib/components/elements/text-box/style.js
new file mode 100644
index 000000000..1ae053b93
--- /dev/null
+++ b/lib/components/elements/text-box/style.js
@@ -0,0 +1,114 @@
+import React from 'react';
+import Utils from '../../../utils';
+import Colors from '../../../colors';
+import {Types} from '../../../types';
+
+export default {
+ type: 'text',
+ options: [
+ {
+ label: 'Font Family',
+ id: 'font',
+ type: Types.Font
+ },
+ {
+ label: 'Font Size',
+ id: 'fontSize',
+ type: Types.Pixels
+ },
+ {
+ label: 'Line Height',
+ id: 'lineHeight',
+ type: Types.Pixels
+ },
+ {
+ label: 'Letter Spacing',
+ id: 'letterSpacing',
+ type: Types.Pixels
+ },
+ {
+ label: 'Color',
+ id: 'color',
+ type: Types.Color
+ },
+ {
+ label: 'Links underline',
+ id: 'linkUnderline',
+ type: Types.Boolean
+ },
+ {
+ label: 'Links color',
+ id: 'linkColor',
+ type: Types.Color
+ },
+ {
+ label: 'Links color hover',
+ id: 'linkColorOver',
+ type: Types.Color
+ }
+ ],
+ defaults: {
+ font: {},
+ fontSize: 16,
+ lineHeight: 16,
+ letterSpacing: 0,
+ color: {
+ value: '#ffffff',
+ opacity: 100
+ },
+ linkUnderline: true,
+ linkColor: {
+ value: '#ffffff',
+ opacity: 100
+ },
+ linkColorOver: {
+ value: '#ffffff',
+ opacity: 100
+ }
+ },
+ rules: (props) => {
+ var rule = {};
+ rule.fontSize = props.fontSize+'px';
+ rule.lineHeight = props.lineHeight+'px';
+ rule.color = Colors.getColorString(props.color);
+ rule.letterSpacing = props.letterSpacing+'px';
+
+ if (props.font && props.font.family && props.font.fvd) {
+ rule.fontFamily = props.font.family;
+ Utils.processFVD(rule, props.font.fvd);
+ }
+
+ // links
+ rule.a = {
+ textDecoration: props.linkUnderline ? 'underline' : 'none',
+ color: Colors.getColorString(props.linkColor),
+ ':hover': {
+ color: Colors.getColorString(props.linkColorOver)
+ }
+ };
+
+ return {
+ text: rule
+ };
+ },
+ getIdentifierLabel: (props) => {
+ var variation = props.font && props.font.fvd && ' ' + props.font.fvd.charAt(1)+'00' || '';
+ return (props.font && props.font.family || '') + variation;
+ },
+ preview: (classesMap) => {
+ var holderStyle = {
+ height: 55,
+ lineHeight: '55px'
+ };
+ var style = {
+ display: 'inline-block',
+ verticalAlign: 'bottom'
+ };
+
+ return (
+
+ );
+ }
+};
diff --git a/lib/components/elements/video/index.jsx b/lib/components/elements/video/index.jsx
new file mode 100644
index 000000000..238aab345
--- /dev/null
+++ b/lib/components/elements/video/index.jsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import Component from '../../component';
+import Element from '../../element';
+import Utils from '../../../utils';
+
+import settings from './settings';
+import propsSchema from './props-schema';
+
+export default class Video extends Component {
+ getInitialState () {
+ this.onResizeBind = this.onResize.bind(this);
+ return {
+ mounted: false
+ };
+ }
+
+ componentDidMount () {
+ window.addEventListener('resize', this.onResizeBind);
+ this.onResize();
+ }
+
+ onResize () {
+ var dom = React.findDOMNode(this);
+ var rect = dom.getBoundingClientRect();
+
+ var width = Math.round(rect.right-rect.left);
+
+ this.setState({
+ mounted: true,
+ width
+ });
+ }
+
+ componentWillUnmount () {
+ window.removeEventListener('resize', this.onResizeBind);
+ }
+
+ renderIframe () {
+ var height = 300;
+ if (this.state.width) {
+ height = Math.round(this.state.width * (this.props.videoHeight / 100));
+ }
+
+ if (this.props.videoId && this.props.videoId !== '') {
+ var src = '';
+
+ if (this.props.type === 'youtube') {
+ let parsedID = Utils.parseYoutubeURL(this.props.videoId);
+ src = 'http://www.youtube.com/embed/' + (parsedID || this.props.videoId);
+ } else if (this.props.type === 'vimeo') {
+ let parsedID = Utils.parseVimeoURL(this.props.videoId);
+ src = 'http://player.vimeo.com/video/' + (parsedID || this.props.videoId);
+ } else if (this.props.type === 'dailymotion') {
+ let parsedID = Utils.parseDailymotionURL(this.props.videoId);
+ src = 'http://www.dailymotion.com/embed/video/' + (parsedID || this.props.videoId);
+ }
+
+ var iframe = ;
+
+ if (this.context.editing) {
+ return (
+
+ );
+ } else {
+ return iframe;
+ }
+ } else {
+ let style = {
+ height
+ };
+ return (
+
+
+
+ );
+ }
+ }
+
+ render () {
+ var style = {};
+
+ return (
+
+ {this.renderIframe()}
+
+ );
+ }
+}
+
+Video.contextTypes = {
+ editing: React.PropTypes.bool.isRequired
+};
+
+Video.propTypes = {
+ type: React.PropTypes.string.isRequired,
+ videoId: React.PropTypes.string.isRequired,
+ videoHeight: React.PropTypes.number.isRequired
+};
+
+Video.defaultProps = {
+ type: 'youtube',
+ videoId: '',
+ videoHeight: 56
+};
+
+Video.propsSchema = propsSchema;
+Video.settings = settings;
diff --git a/lib/components/elements/video/props-schema.js b/lib/components/elements/video/props-schema.js
new file mode 100644
index 000000000..7e56d53ab
--- /dev/null
+++ b/lib/components/elements/video/props-schema.js
@@ -0,0 +1,26 @@
+import {Types} from '../../../types';
+
+export default [
+ {
+ label: 'Video Host',
+ type: Types.Select,
+ id: 'type',
+ props: {
+ labels: ['Youtube', 'Vimeo', 'Dailymotion'],
+ values: ['youtube', 'vimeo', 'dailymotion']
+ }
+ },
+ {
+ label: 'Video Id/Url',
+ type: Types.String,
+ id: 'videoId'
+ },
+ {
+ label: 'Video Height',
+ type: Types.Percentage,
+ id: 'videoHeight',
+ props: {
+ max: 200
+ }
+ }
+];
diff --git a/lib/components/elements/video/settings.js b/lib/components/elements/video/settings.js
new file mode 100644
index 000000000..1d48079c8
--- /dev/null
+++ b/lib/components/elements/video/settings.js
@@ -0,0 +1,9 @@
+export default {
+ icon: {
+ class: 'material-icons',
+ content: 'videocam'
+ },
+ category: 'media',
+ drop: false,
+ drag: {}
+};
diff --git a/lib/components/filter.jsx b/lib/components/filter.jsx
new file mode 100644
index 000000000..7db3d72b2
--- /dev/null
+++ b/lib/components/filter.jsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import {Router} from 'relax-framework';
+import A from './a';
+import Utils from '../utils';
+import merge from 'lodash.merge';
+
+export default class Filter extends Component {
+ getInitialState () {
+ return {
+ search: (this.context.query && this.context.query.s) || '',
+ };
+ }
+
+ searchChange (event) {
+ this.setState({
+ search: event.target.value
+ });
+ }
+
+ searchSubmit (event) {
+ event.preventDefault();
+
+ var query = merge({}, this.context.query || {});
+
+ if(this.state.search !== ''){
+ merge(query, {search: this.props.search, s: this.state.search});
+ } else {
+ delete query.search;
+ delete query.s;
+ }
+
+ var url = Utils.parseQueryUrl(this.props.url, query);
+
+ Router.prototype.navigate(url, {trigger: true});
+ }
+
+ renderSortButton (button, key) {
+ var props = {
+ className: 'button-filter'
+ };
+
+ var query = {
+ sort: button.property,
+ order: 'asc'
+ };
+ if(this.context.query && this.context.query.sort && this.context.query.sort === button.property) {
+ props.className += ' active';
+
+ if(!this.context.query.order || this.context.query.order === 'asc'){
+ query.order = 'desc';
+ }
+ }
+
+ props.href = Utils.parseQueryUrl(this.props.url, query);
+
+ return (
+ {button.label}
+ );
+ }
+
+ render () {
+ return (
+
+ Sort by:
+ {this.props.sorts.map(this.renderSortButton, this)}
+
+
+ );
+ }
+}
+
+Filter.propTypes = {
+ sorts: React.PropTypes.array.isRequired,
+ url: React.PropTypes.string.isRequired,
+ search: React.PropTypes.string.isRequired
+};
+
+Filter.contextTypes = {
+ query: React.PropTypes.object
+};
diff --git a/lib/components/font-picker/dropdown.jsx b/lib/components/font-picker/dropdown.jsx
new file mode 100644
index 000000000..eb5375267
--- /dev/null
+++ b/lib/components/font-picker/dropdown.jsx
@@ -0,0 +1,72 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+
+export default class Dropdown extends Component {
+
+ getInitialState () {
+ return {
+ opened: false
+ };
+ }
+
+ onEntryClick (value, event) {
+ event.preventDefault();
+ this.props.onChange(value);
+ }
+
+ onEntryEnter (value) {
+ this.props.tempChange(value);
+ }
+
+ onEntryLeave () {
+ this.props.tempRevert();
+ }
+
+ toggle () {
+ this.setState({
+ opened: !this.state.opened
+ });
+ }
+
+ renderEntry (entry) {
+ return (
+
+ {entry.label}
+
+ );
+ }
+
+ renderCollapsable () {
+ if (this.state.opened) {
+ return (
+
+ {this.props.entries.map(this.renderEntry, this)}
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+ {this.renderCollapsable()}
+
+ {this.props.label}
+
+
+
+ );
+ }
+}
+
+Dropdown.propTypes = {
+ value: React.PropTypes.string.isRequired,
+ label: React.PropTypes.string.isRequired,
+ entries: React.PropTypes.array.isRequired,
+ onChange: React.PropTypes.func.isRequired,
+ tempChange: React.PropTypes.func.isRequired,
+ tempRevert: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/font-picker/index.jsx b/lib/components/font-picker/index.jsx
new file mode 100644
index 000000000..a32b7c012
--- /dev/null
+++ b/lib/components/font-picker/index.jsx
@@ -0,0 +1,132 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import Font from '../font';
+import Utils from '../../utils';
+import Dropdown from './dropdown';
+import clone from 'lodash.clone';
+import forEach from 'lodash.foreach';
+
+import settingsStore from '../../client/stores/settings';
+
+export default class FontPicker extends Component {
+
+ getInitialState () {
+ return {
+ data: {}
+ };
+ }
+
+ getInitialModels () {
+ return {
+ data: settingsStore.getModel('fonts')
+ };
+ }
+
+ getChangedValue (key, value) {
+ var newValue = clone(this.props.value || {});
+
+ newValue[key] = value;
+
+ if (key === 'family') {
+ // check if current fvd exists
+ var family = value;
+ var fvds = this.state.data.value.fonts[family];
+
+ if (fvds.indexOf(newValue.fvd) === -1) {
+ newValue.fvd = fvds[0];
+ }
+ }
+
+ return newValue;
+ }
+
+ onChange (key, value) {
+ var newValue = this.getChangedValue(key, value);
+ this.props.onChange(newValue);
+ this.setState({
+ selected: newValue
+ });
+ }
+
+ tempChange (key, value) {
+ var newValue = this.getChangedValue(key, value);
+ this.props.onChange(newValue);
+ }
+
+ tempRevert () {
+ this.props.onChange(this.state.selected);
+ }
+
+ renderFont () {
+ if (typeof this.props.value === 'object' && this.props.value.family && this.props.value.fvd) {
+ return (
+
+ );
+ } else {
+ return (
+ No font selected yet
+ );
+ }
+ }
+
+ renderOptions () {
+ var families = [], fvds = [];
+ var value = this.props.value || {};
+
+ if (this.state.data.value && typeof this.state.data.value.fonts === 'object') {
+ forEach(this.state.data.value.fonts, (fvdsArray, family) => {
+ families.push({
+ label: family,
+ value: family
+ });
+
+ if (value.family && value.family === family) {
+ forEach(fvdsArray, (fvd) => {
+ fvds.push({
+ label: fvd,
+ value: fvd
+ });
+ });
+ }
+ });
+ }
+
+ return (
+
+
+
+
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.renderFont()}
+ {this.renderOptions()}
+
+ );
+ }
+}
+
+FontPicker.propTypes = {
+ value: React.PropTypes.string.isRequired,
+ onChange: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/font.jsx b/lib/components/font.jsx
new file mode 100644
index 000000000..3c83b7fee
--- /dev/null
+++ b/lib/components/font.jsx
@@ -0,0 +1,28 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import Utils from '../utils';
+
+export default class Font extends Component {
+
+ render () {
+
+ var style = this.props.style || {};
+ style.fontFamily = this.props.family;
+ Utils.processFVD(style, this.props.fvd);
+
+ var content = '';
+
+ if(this.props.input) {
+ content = ;
+ }
+ else {
+ content = {this.props.text}
;
+ }
+
+ return content;
+ }
+}
+
+Font.propTypes = {
+ input: React.PropTypes.bool
+};
diff --git a/lib/components/html.jsx b/lib/components/html.jsx
new file mode 100644
index 000000000..6f2a8d666
--- /dev/null
+++ b/lib/components/html.jsx
@@ -0,0 +1,41 @@
+import React from 'react';
+
+export default class Html extends React.Component {
+ renderTag (tag) {
+ tag.props = tag.props || {};
+ if(tag.content){
+ tag.props.dangerouslySetInnerHTML = {__html: tag.content};
+ }
+ return (
+
+ );
+ }
+
+ renderHeader () {
+ if(this.props.props && this.props.props._locals && this.props.props._locals.header){
+ return this.props.props._locals.header.map(this.renderTag, this);
+ }
+ }
+
+ renderFooter () {
+ if(this.props.props && this.props.props._locals && this.props.props._locals.footer){
+ return this.props.props._locals.footer.map(this.renderTag, this);
+ }
+ }
+
+ render () {
+ return (
+
+
+ {this.renderHeader()}
+
+
+
+
+
+ {this.renderFooter()}
+
+
+ );
+ }
+}
diff --git a/lib/components/icon-manager.jsx b/lib/components/icon-manager.jsx
new file mode 100644
index 000000000..92567c5ce
--- /dev/null
+++ b/lib/components/icon-manager.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Lightbox from './lightbox';
+
+import icons from '../icons';
+
+export default class IconManager extends Component {
+ getInitialState () {
+ return {
+ tab: 0,
+ search: ''
+ };
+ }
+
+ changeTab (tab, event) {
+ event.preventDefault();
+ this.setState({
+ tab
+ });
+ }
+
+ searchChange (event) {
+ this.setState({
+ search: event.target.value
+ });
+ }
+
+ toggleIcon (icon, event) {
+ event.preventDefault();
+ this.props.toggleIcon(icon);
+ }
+
+ renderTab (info, key) {
+ var className = 'tab-button';
+
+ if (this.state.tab === key) {
+ className += ' active';
+ }
+
+ return (
+
+ {info.family}
+ ({info.icons.length})
+
+ );
+ }
+
+ renderIcon (icon, key) {
+ var valid = true;
+
+ if (this.state.search !== '') {
+ valid = icon.indexOf(this.state.search) !== -1;
+ }
+
+ if (valid) {
+ var className = 'icon';
+
+ if (this.props.icons.indexOf(icon) !== -1) {
+ className += ' active';
+ }
+
+ return (
+
+ );
+ }
+ }
+
+ renderContent () {
+ if (icons[this.state.tab] && icons[this.state.tab].icons) {
+ return icons[this.state.tab].icons.map(this.renderIcon, this);
+ }
+ }
+
+ render () {
+ return (
+
+
+
+
ICONS FONTS
+
+ {icons.map(this.renderTab, this)}
+
+
+
+
+ AVAILABLE ICONS
+
+ SEARCH
+
+
+ {this.renderContent()}
+
+
+
+
+ );
+ }
+}
+
+IconManager.propTypes = {
+ onClose: React.PropTypes.func.isRequired,
+ icons: React.PropTypes.array.isRequired,
+ toggleIcon: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/icon-picker.jsx b/lib/components/icon-picker.jsx
new file mode 100644
index 000000000..3602274fd
--- /dev/null
+++ b/lib/components/icon-picker.jsx
@@ -0,0 +1,154 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import IconManager from './icon-manager';
+
+import settingsStore from '../client/stores/settings';
+import settingsActions from '../client/actions/settings';
+
+export default class IconPicker extends Component {
+ getInitialState () {
+ return {
+ opened: false,
+ manager: false,
+ icons: []
+ };
+ }
+
+ getInitialModels () {
+ return {
+ icons: settingsStore.getModel('icons')
+ };
+ }
+
+ open (event) {
+ event.preventDefault();
+ this.setState({
+ opened: true
+ });
+ }
+
+ close () {
+ this.setState({
+ opened: false
+ });
+ }
+
+ onManagerOpen (event) {
+ event.preventDefault();
+ this.setState({
+ manager: true
+ });
+ }
+
+ onManagerClose () {
+ this.setState({
+ manager: false
+ });
+ }
+
+ closeDelay () {
+ this.closeTimeout = setTimeout(this.close.bind(this), 600);
+ }
+
+ enter () {
+ clearTimeout(this.closeTimeout);
+ }
+
+ onIconClick (icon) {
+ this.props.onChange(icon);
+ this.close();
+ }
+
+ toggleIcon (icon) {
+ this.state.icons = this.state.icons || {};
+
+ var icons = this.state.icons.value || [];
+ var ind = icons.indexOf(icon);
+
+ if (ind === -1) {
+ icons.push(icon);
+ } else {
+ icons.splice(ind, 1);
+ }
+
+ // Confident set state before save completed
+ this.setState({
+ icons: {
+ value: icons
+ }
+ });
+
+ settingsActions.saveSettings({
+ icons
+ });
+ }
+
+ renderSelected () {
+ if (this.props.value) {
+ return (
+
+
+
+ );
+ } else {
+ return No icon selected
;
+ }
+ }
+
+ renderIcon (icon) {
+ var className = 'icon';
+
+ if (this.props.value === icon) {
+ className += ' active';
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ renderIcons () {
+ if (this.state.icons.value && this.state.icons.value.length > 0) {
+ return this.state.icons.value.map(this.renderIcon, this);
+ } else {
+ return No icons selected yet, you can add icons by opening the icon manager below
;
+ }
+ }
+
+ renderPicker () {
+ if (this.state.opened) {
+ return (
+
+ );
+ }
+ }
+
+ renderManager () {
+ if (this.state.manager) {
+ var icons = this.state.icons.value || [];
+ return ;
+ }
+ }
+
+ render () {
+ return (
+
+ {this.renderSelected()}
+ {this.renderPicker()}
+ {this.renderManager()}
+
+ );
+ }
+}
+
+IconPicker.propTypes = {
+ value: React.PropTypes.number.isRequired,
+ onChange: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/image-picker.jsx b/lib/components/image-picker.jsx
new file mode 100644
index 000000000..42153ebce
--- /dev/null
+++ b/lib/components/image-picker.jsx
@@ -0,0 +1,101 @@
+import {Component} from 'relax-framework';
+import Lightbox from './lightbox';
+import MediaSelector from './media-selector';
+import React from 'react';
+import Image from './image';
+
+export default class ImagePicker extends Component {
+ getInitialState () {
+ return {
+ style: {
+ width: this.props.width,
+ height: this.props.height
+ },
+ opened: false,
+ mounted: false
+ };
+ }
+
+ componentDidMount () {
+ var dom = React.findDOMNode(this.refs.imageHolder);
+ var rect = dom.getBoundingClientRect();
+
+ var width = Math.round(rect.right-rect.left);
+
+ this.setState({
+ mounted: true,
+ width
+ });
+ }
+
+ onClick (event) {
+ event.preventDefault();
+
+ this.setState({
+ opened: true
+ });
+ }
+
+ onClose () {
+ this.setState({
+ opened: false
+ });
+ }
+
+ changedSelected (id) {
+ this.props.onChange(id);
+ }
+
+ shouldComponentUpdate (nextProps, nextState) {
+ return (
+ this.props.value !== nextProps.value ||
+ this.state.opened !== nextState.opened ||
+ this.state.mounted !== nextState.mounted
+ );
+ }
+
+ renderLightbox () {
+ if(this.state.opened){
+ return (
+
+
+
+ );
+ }
+ }
+
+ renderSelected () {
+ if (this.state.mounted) {
+ return (
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+
+ {this.renderSelected()}
+
Choose Image
+
+
+ {this.props.value !== '' &&
Unselect Image
}
+ {this.renderLightbox()}
+
+ );
+ }
+}
+
+ImagePicker.propTypes = {
+ width: React.PropTypes.any,
+ height: React.PropTypes.number,
+ onChange: React.PropTypes.func.isRequired,
+ value: React.PropTypes.string.isRequired
+};
+
+ImagePicker.defaultProps = {
+ width: '100%',
+ height: 135
+};
diff --git a/lib/components/image.jsx b/lib/components/image.jsx
new file mode 100644
index 000000000..0da74836e
--- /dev/null
+++ b/lib/components/image.jsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Utils from '../utils';
+import forEach from 'lodash.foreach';
+
+import mediaStore from '../client/stores/media';
+
+export default class Image extends Component {
+ getInitialState () {
+ return {
+ requested: false
+ };
+ }
+
+ getInitialModels () {
+ var models = {};
+
+ if (this.props.id && this.props.id !== '') {
+ models.image = mediaStore.getModel(this.props.id);
+ }
+
+ return models;
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.props.id !== nextProps.id) {
+ if (nextProps.id && nextProps.id !== '') {
+ this.setModels({
+ image: mediaStore.getModel(nextProps.id)
+ });
+ } else {
+ this.unsetModels(['image']);
+ this.setState({
+ image: null
+ });
+ }
+ }
+ }
+
+ render () {
+ if (this.state.image && this.state.image._id === this.props.id) {
+ const url = Utils.getBestImageUrl(this.state.image._id, this.props.width);
+ var extraProps = {};
+
+ forEach(this.props, (value, key) => {
+ if (key !== 'id' && key !== 'width' && key !== 'height') {
+ extraProps[key] = value;
+ }
+ });
+
+ return (
+
+ );
+ } else if (this.context.editing) {
+ return (
+
+
+
+ );
+ }
+
+ return null;
+ }
+}
+
+Image.contextTypes = {
+ editing: React.PropTypes.bool
+};
+
+Image.propTypes = {
+ id: React.PropTypes.string.isRequired,
+ width: React.PropTypes.number.isRequired
+};
diff --git a/lib/components/input-validation.jsx b/lib/components/input-validation.jsx
new file mode 100644
index 000000000..69925376e
--- /dev/null
+++ b/lib/components/input-validation.jsx
@@ -0,0 +1,27 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+
+export default class InputValidation extends Component {
+
+ onChange (event) {
+ this.props.onChange(event.target.value);
+ }
+
+ render () {
+ var validClass = 'input-valid '+(this.props.valid ? 'valid' : 'invalid');
+ var icon = this.props.valid ? 'fa fa-check' : 'fa fa-remove';
+
+ return (
+
+
+
+
+ );
+ }
+}
+
+InputValidation.propTypes = {
+ valid: React.PropTypes.bool.isRequired,
+ value: React.PropTypes.string.isRequired,
+ onChange: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/input.jsx b/lib/components/input.jsx
new file mode 100644
index 000000000..4c8c0e9b8
--- /dev/null
+++ b/lib/components/input.jsx
@@ -0,0 +1,26 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import classNames from 'classnames';
+
+export default class Input extends Component {
+
+ onChange (event) {
+ this.props.onChange(event.target.value);
+ }
+
+ render () {
+ return (
+
+
+
+ );
+ }
+}
+
+Input.propTypes = {
+ value: React.PropTypes.string.isRequired,
+ onChange: React.PropTypes.func.isRequired,
+ className: React.PropTypes.string,
+ placeholder: React.PropTypes.string,
+ password: React.PropTypes.bool
+};
diff --git a/lib/components/lightbox.jsx b/lib/components/lightbox.jsx
new file mode 100644
index 000000000..ebc1a6a81
--- /dev/null
+++ b/lib/components/lightbox.jsx
@@ -0,0 +1,57 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import classNames from 'classnames';
+import Animate from './animate';
+
+export default class Lightbox extends Component {
+
+ close (event) {
+ event.preventDefault();
+
+ if(this.props.onClose){
+ this.props.onClose();
+ }
+ }
+
+ renderHeader () {
+ if (this.props.header) {
+ return (
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+
+
+
+
+
+ {this.renderHeader()}
+
+ {this.props.children}
+
+
+
+
+
+ );
+ }
+}
+
+Lightbox.propTypes = {
+ onClose: React.PropTypes.func,
+ title: React.PropTypes.string,
+ header: React.PropTypes.bool
+};
+
+Lightbox.defaultProps = {
+ title: '',
+ header: true
+};
diff --git a/lib/components/media-selector.jsx b/lib/components/media-selector.jsx
new file mode 100644
index 000000000..dd6cfc361
--- /dev/null
+++ b/lib/components/media-selector.jsx
@@ -0,0 +1,70 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import mediaStore from '../client/stores/media';
+import mediaActions from '../client/actions/media';
+import Upload from './upload';
+
+export default class MediaSelector extends Component {
+ getInitialState () {
+ return {
+ selected: this.props.selected,
+ media: []
+ };
+ }
+
+ getInitialCollections () {
+ return {
+ media: mediaStore.getCollection()
+ };
+ }
+
+ imageClicked (id, event) {
+ event.preventDefault();
+
+ this.setState({
+ selected: id
+ });
+
+ if(this.props.onChange){
+ this.props.onChange(id);
+ }
+ }
+
+ onSuccess (file, mediaItem, progressFinal) {
+ mediaActions.add(mediaItem);
+ file.previewElement.style.display = "none";
+ }
+
+ renderMediaItem (item) {
+ var className = 'ms-item';
+
+ if(item._id === this.state.selected){
+ className += ' selected';
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ render () {
+ return (
+
+
+ {this.state.media.map(this.renderMediaItem, this)}
+
+
+ );
+ }
+}
+
+MediaSelector.propTypes = {
+ onChange: React.PropTypes.func,
+ selected: React.PropTypes.string
+};
+
+MediaSelector.defaultProps = {
+ selected: ''
+};
diff --git a/lib/components/medium-editor.jsx b/lib/components/medium-editor.jsx
new file mode 100644
index 000000000..f82f658c9
--- /dev/null
+++ b/lib/components/medium-editor.jsx
@@ -0,0 +1,58 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import MediumEditor from 'medium-editor';
+
+export default class MediumEditorElement extends Component {
+ getInitialState () {
+ this.currentValue = this.props.value;
+ return {
+ value: this.props.value
+ };
+ }
+
+ componentDidMount () {
+ this.medium = new MediumEditor(React.findDOMNode(this), this.props.options);
+ this.medium.subscribe('editableInput', this.onChange.bind(this));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.currentValue !== nextProps.value) {
+ this.currentValue = nextProps.value;
+ this.setState({
+ value: nextProps.value
+ });
+ }
+ }
+
+ componentWillUnmount () {
+ this.medium.destroy();
+ }
+
+ onChange () {
+ let value = React.findDOMNode(this).innerHTML;
+ this.currentValue = value;
+ this.props.onChange(value);
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+MediumEditorElement.propTypes = {
+ tag: React.PropTypes.string,
+ className: React.PropTypes.string,
+ value: React.PropTypes.string.isRequired,
+ onChange: React.PropTypes.func.isRequired,
+ options: React.PropTypes.obj
+};
+
+MediumEditorElement.defaultProps = {
+ tag: 'div'
+};
diff --git a/lib/components/number-input.jsx b/lib/components/number-input.jsx
new file mode 100644
index 000000000..3326281f6
--- /dev/null
+++ b/lib/components/number-input.jsx
@@ -0,0 +1,116 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+
+export default class NumberInput extends Component {
+ limitValue (value) {
+ if (value < this.props.min){
+ value = this.props.min;
+ }
+
+ if (this.props.max) {
+ if (value > this.props.max) {
+ value = this.props.max;
+ }
+ }
+
+ return value;
+ }
+
+ onInput (event) {
+ if(!isNaN(event.target.value)){
+ var val = this.limitValue(parseFloat(event.target.value, 10));
+ this.props.onChange(isNaN(val) ? '' : val);
+ }
+ }
+
+ up (event) {
+ event.preventDefault();
+ this.props.onChange(this.limitValue(this.props.value+1));
+ }
+
+ down (event) {
+ event.preventDefault();
+ this.props.onChange(this.limitValue(this.props.value-1));
+ }
+
+ onFocus () {
+ this.setState({
+ focused: true
+ });
+ }
+
+ onBlur () {
+ this.setState({
+ focused: false
+ });
+ }
+
+ onMouseDown (event) {
+ event.preventDefault();
+
+ this.startValue = this.props.value;
+ this.startY = event.pageY;
+
+ this.onMouseUpListener = this.onMouseUp.bind(this);
+ this.onMouseMoveListener = this.onMouseMove.bind(this);
+ document.addEventListener('mouseup', this.onMouseUpListener);
+ document.addEventListener('mousemove', this.onMouseMoveListener);
+ }
+
+ onMouseMove (event) {
+ event.preventDefault();
+
+ const cof = 2;
+ var amount = this.startY - event.pageY;
+
+ this.props.onChange(this.limitValue(Math.round(this.startValue + amount/cof)));
+ }
+
+ onMouseUp (event) {
+ document.removeEventListener('mouseup', this.onMouseUpListener);
+ document.removeEventListener('mousemove', this.onMouseMoveListener);
+ }
+
+ render () {
+ var className = 'number-input';
+ var value = this.props.value;
+
+ if (this.state.focused) {
+ className += ' focused';
+ }
+
+ if (this.props.inactive) {
+ value = '--';
+ }
+
+ return (
+
+
+
{this.props.label}
+
+
+ );
+ }
+}
+
+NumberInput.propTypes = {
+ value: React.PropTypes.number.isRequired,
+ onChange: React.PropTypes.func.isRequired,
+ label: React.PropTypes.any,
+ min: React.PropTypes.number,
+ max: React.PropTypes.number,
+ inactive: React.PropTypes.bool
+};
+
+NumberInput.defaultProps = {
+ min: 0,
+ label: 'px',
+ inactive: false
+};
diff --git a/lib/components/numeric-input.jsx b/lib/components/numeric-input.jsx
new file mode 100644
index 000000000..5107b9e81
--- /dev/null
+++ b/lib/components/numeric-input.jsx
@@ -0,0 +1,110 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+
+export default class Numeric extends Component {
+ limitValue (value) {
+ if(value < this.props.min){
+ value = this.props.min;
+ }
+ if(value > this.props.max){
+ value = this.props.max;
+ }
+
+ return value;
+ }
+
+ onInputChanged (event) {
+ if(!isNaN(event.target.value)){
+ this.props.onChange(this.limitValue(parseFloat(event.target.value, 10)));
+ }
+ }
+
+ onBlur () {
+ if(isNaN(this.props.value)){
+ this.props.onChange(this.limitValue(0));
+ }
+ }
+
+ onBackgroundClick (event) {
+ let element = React.findDOMNode(this.refs.background);
+
+ let elementOffset = element.getBoundingClientRect();
+ let width = elementOffset.right - elementOffset.left;
+
+ let percentage = (event.pageX - elementOffset.left) / width;
+ let value = Math.round(percentage * (this.props.max - this.props.min) + this.props.min, 10);
+
+ this.props.onChange(this.limitValue(value));
+ }
+
+ onMouseDown (event) {
+ event.preventDefault();
+
+ let element = React.findDOMNode(this.refs.background);
+
+ this.backgroundOffset = element.getBoundingClientRect();
+ this.backgroundWidth = this.backgroundOffset.right - this.backgroundOffset.left;
+
+ this.onMouseUpListener = this.onMouseUp.bind(this);
+ this.onMouseMoveListener = this.onMouseMove.bind(this);
+
+ document.addEventListener('mouseup', this.onMouseUpListener);
+ document.addEventListener('mousemove', this.onMouseMoveListener);
+
+ this.onMouseMove(event);
+ }
+
+ onMouseMove (event) {
+ event.preventDefault();
+
+ let percentage = (event.pageX - this.backgroundOffset.left) / this.backgroundWidth;
+ let value = Math.round(percentage * (this.props.max - this.props.min) + this.props.min, 10);
+
+ this.props.onChange(this.limitValue(value));
+ }
+
+ onMouseUp (event) {
+ document.removeEventListener('mouseup', this.onMouseUpListener);
+ document.removeEventListener('mousemove', this.onMouseMoveListener);
+ }
+
+ render () {
+ var percentage = ((this.props.value + (-this.props.min)) / (this.props.max + (-this.props.min))) * 100;
+ var circleStyle = {
+ transform: 'translateX('+(-percentage)+'%)',
+ left: percentage+'%'
+ };
+ var activeStyle = {
+ width: percentage + '%'
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+}
+
+Numeric.propTypes = {
+ value: React.PropTypes.number.isRequired,
+ onChange: React.PropTypes.func.isRequired,
+ min: React.PropTypes.number,
+ max: React.PropTypes.number,
+ label: React.PropTypes.string
+};
+
+Numeric.defaultProps = {
+ min: 0,
+ max: 100,
+ label: '%'
+};
diff --git a/lib/components/optional.jsx b/lib/components/optional.jsx
new file mode 100644
index 000000000..7271e372e
--- /dev/null
+++ b/lib/components/optional.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+
+export default class Optional extends Component {
+ toggle (event) {
+ event.preventDefault();
+ this.props.onChange(!this.props.value);
+ }
+
+ render () {
+ return (
+
+ {this.props.label}
+
+ {this.props.value && check}
+
+
+ );
+ }
+}
+
+Optional.propTypes = {
+ value: React.PropTypes.bool.isRequired,
+ onChange: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/options-list.jsx b/lib/components/options-list.jsx
new file mode 100644
index 000000000..71cb67d56
--- /dev/null
+++ b/lib/components/options-list.jsx
@@ -0,0 +1,131 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import merge from 'lodash.merge';
+
+// options components
+import BorderPicker from './border-picker';
+import Checkbox from './checkbox';
+import Input from './input';
+import ImagePicker from './image-picker';
+import Combobox from './combobox';
+import NumberInput from './number-input';
+import FontPicker from './font-picker';
+import SchemaLink from './schema-link';
+import Button from './button';
+import IconPicker from './icon-picker';
+import SpacingPicker from './spacing-picker';
+import CornersPicker from './corners-picker';
+import ColorPalettePicker from './color-palette-picker';
+import ColumnsManager from './columns-manager';
+import Optional from './optional';
+
+export default class OptionsList extends Component {
+
+ onChange (id, value) {
+ this.props.onChange(id, value);
+ }
+
+ renderLabel (label) {
+ if (label) {
+ return (
+ {label}
+ );
+ }
+ }
+
+ renderOption (option) {
+ if (OptionsList.optionsMap[option.type]) {
+ var Option = OptionsList.optionsMap[option.type];
+ var value = this.props.values[option.id];
+
+ var extraProps = merge({}, OptionsList.optionsDefaultProps[option.type] || {});
+ merge(extraProps, option.props || {});
+
+ var unlockedContent = null;
+ if (option.unlocks && value !== '') {
+ if (option.type === 'Optional') {
+ if (value && option.unlocks.length > 1) {
+ unlockedContent = this.renderOptions(option.unlocks);
+ } else if (value && option.unlocks.length === 1) {
+ unlockedContent = (
+ {this.renderOptions(option.unlocks)}
+ );
+ }
+ extraProps.label = option.label;
+ } else if (option.unlocks.constructor === Array) {
+ unlockedContent = this.renderOptions(option.unlocks);
+ } else if (option.unlocks[value]) {
+ unlockedContent = this.renderOptions(option.unlocks[value]);
+ }
+ }
+
+ return (
+
+ {this.renderLabel(option.type !== 'Optional' && option.label)}
+
+ {unlockedContent}
+
+ );
+ }
+ else {
+ console.log('Element option type not valid');
+ }
+ }
+
+ renderOptions (options) {
+ return (
+
+ {options.map(this.renderOption, this)}
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.renderOptions(this.props.options)}
+
+ );
+ }
+}
+
+OptionsList.propTypes = {
+ options: React.PropTypes.array.isRequired,
+ values: React.PropTypes.object.isRequired,
+ onChange: React.PropTypes.func.isRequired
+};
+
+OptionsList.optionsMap = {
+ Color: ColorPalettePicker,
+ String: Input,
+ Image: ImagePicker,
+ Select: Combobox,
+ Number: NumberInput,
+ Pixels: NumberInput,
+ Percentage: NumberInput,
+ Padding: SpacingPicker,
+ Margin: SpacingPicker,
+ Boolean: Checkbox,
+ Font: FontPicker,
+ SchemaLink,
+ Button,
+ Icon: IconPicker,
+ Corners: CornersPicker,
+ Columns: ColumnsManager,
+ Border: BorderPicker,
+ Optional
+};
+
+OptionsList.optionsDefaultProps = {
+ Percentage: {
+ min: 0,
+ max: 100,
+ label: '%'
+ },
+ Padding: {
+ type: 'padding'
+ },
+ Margin: {
+ type: 'margin'
+ }
+};
diff --git a/lib/components/options-menu.jsx b/lib/components/options-menu.jsx
new file mode 100644
index 000000000..b9a8bc850
--- /dev/null
+++ b/lib/components/options-menu.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+
+export default class OptionsMenu extends Component {
+ renderOption (option, key) {
+ return (
+
+ {option.icon ? : null}
+ {option.label}
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.props.options.map(this.renderOption, this)}
+
+ );
+ }
+}
+
+OptionsMenu.propTypes = {
+ options: React.PropTypes.array.isRequired
+};
diff --git a/lib/components/overlay.jsx b/lib/components/overlay.jsx
new file mode 100644
index 000000000..a1fa19f74
--- /dev/null
+++ b/lib/components/overlay.jsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import {Component} from 'relax';
+import Animate from './animate';
+
+export default class Overlay extends Component {
+ render () {
+ return (
+
+
+
+
+
+
+ {this.props.children}
+
+
+
+ close
+
+
+ );
+ }
+}
+
+Overlay.contextTypes = {
+ closeOverlay: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/page-builder/canvas.jsx b/lib/components/page-builder/canvas.jsx
new file mode 100644
index 000000000..b249de6e9
--- /dev/null
+++ b/lib/components/page-builder/canvas.jsx
@@ -0,0 +1,149 @@
+import React from 'react';
+import {Droppable} from '../drag';
+import {Component} from 'relax-framework';
+import ContextMenu from './context-menu';
+import key from 'keymaster';
+import displays from '../../displays';
+
+export default class Canvas extends Component {
+
+ getInitialState () {
+ this.keyDownBind = this.onContextMenu.bind(this);
+
+ return {
+ contextMenu: false
+ };
+ }
+
+ componentDidMount () {
+ this.openContextBind();
+
+ React.findDOMNode(this.refs.canvas).addEventListener('scroll', this.onScroll.bind(this));
+ }
+
+ getChildContext () {
+ return {
+ renderElement: this.renderElement.bind(this),
+ dropHighlight: this.context.dragging ? 'vertical' : 'none'
+ };
+ }
+
+ onScroll () {
+ /* jshint ignore:start */
+ window.dispatchEvent(new Event('scroll'));
+ /* jshint ignore:end */
+ }
+
+ onElementClick (id, event) {
+ event.preventDefault();
+ this.context.selectElement(id);
+ }
+
+ onContextMenu (event) {
+ return; // XXX temp for debug
+ if (this.context.editing) {
+ if (!event.keyCode) {
+ event.preventDefault();
+ } else if (key.command || key.ctrl || key.control || key.alt) {
+ return;
+ }
+
+ document.removeEventListener('keydown', this.keyDownBind);
+
+ this.setState({
+ contextMenu: true,
+ contextMenuSearch: event.keyCode ? true : false,
+ contextMenuX: event.clientX,
+ contextMenuY: event.clientY
+ });
+ }
+ }
+
+ openContextBind () {
+ //document.addEventListener('keydown', this.keyDownBind);
+ }
+
+ contextMenuClose () {
+ this.openContextBind();
+ this.setState({
+ contextMenu: false
+ });
+ }
+
+ renderContextMenu () {
+ if (this.state.contextMenu) {
+ return (
+
+ );
+ }
+ }
+
+ renderElement (element) {
+ if (!element.hide || !element.hide[this.context.display]) {
+ var FactoredElement = this.context.elements[element.tag];
+ var selected = this.context.selected && this.context.selected.id === element.id;
+
+ return (
+
+ {this.renderChildren(element.children || '')}
+
+ );
+ }
+ }
+
+ renderChildren (children) {
+ // group of elements (array)
+ if( children instanceof Array ){
+ return children.map(this.renderElement.bind(this));
+ }
+ // String or other static content
+ else {
+ return children;
+ }
+ }
+
+ render () {
+ var dropInfo = {
+ type: 'body'
+ };
+
+ var bodyStyle = {
+ margin: '0 auto',
+ maxWidth: displays[this.context.display]
+ };
+
+ return (
+
+
+
+
+ {this.renderChildren(this.context.page.data)}
+
+
+
+ {this.renderContextMenu()}
+
+ );
+ }
+}
+
+Canvas.childContextTypes = {
+ renderElement: React.PropTypes.func.isRequired,
+ dropHighlight: React.PropTypes.string.isRequired
+};
+
+Canvas.contextTypes = {
+ dragging: React.PropTypes.bool,
+ selected: React.PropTypes.any.isRequired,
+ elements: React.PropTypes.object.isRequired,
+ selectElement: React.PropTypes.func.isRequired,
+ addElementAtSelected: React.PropTypes.func.isRequired,
+ page: React.PropTypes.object.isRequired,
+ display: React.PropTypes.string.isRequired,
+ editing: React.PropTypes.bool.isRequired
+};
diff --git a/lib/components/page-builder/chrome/index.jsx b/lib/components/page-builder/chrome/index.jsx
new file mode 100644
index 000000000..9d1d7e8c9
--- /dev/null
+++ b/lib/components/page-builder/chrome/index.jsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+
+import PropsMenu from './props-menu';
+
+export default class Chrome extends Component {
+ getInitialState () {
+ return {
+ panel: false
+ };
+ }
+
+ renderPropsMenu () {
+ if (this.context.editing) {
+ return (
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+ {this.renderPropsMenu()}
+
+ );
+ }
+}
+
+Chrome.contextTypes = {
+ editing: React.PropTypes.bool.isRequired
+};
diff --git a/lib/components/page-builder/chrome/props-menu/breadcrumbs.jsx b/lib/components/page-builder/chrome/props-menu/breadcrumbs.jsx
new file mode 100644
index 000000000..aa2f58da8
--- /dev/null
+++ b/lib/components/page-builder/chrome/props-menu/breadcrumbs.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+
+export default class Breadcrumbs extends Component {
+ entryClicked (id, event) {
+ event.preventDefault();
+ this.context.selectElement(id);
+ }
+
+ renderPathEntry (entry) {
+ return (
+
+ {entry.label || entry.tag}
+ >
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.context.selectedPath.map(this.renderPathEntry, this)}
+ {this.context.selected.label || this.context.selected.tag || 'body'}
+
+ );
+ }
+}
+
+Breadcrumbs.contextTypes = {
+ selected: React.PropTypes.any.isRequired,
+ selectedPath: React.PropTypes.array.isRequired,
+ selectElement: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/page-builder/chrome/props-menu/index.jsx b/lib/components/page-builder/chrome/props-menu/index.jsx
new file mode 100644
index 000000000..3d099e5b9
--- /dev/null
+++ b/lib/components/page-builder/chrome/props-menu/index.jsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Tabs from './tabs';
+import Breadcrumbs from './breadcrumbs';
+import cx from 'classnames';
+
+export default class PropsMenu extends Component {
+
+ getInitialState () {
+ return {
+ tab: 'style',
+ switchSide: false,
+ opened: null
+ };
+ }
+
+ changeTab (tab, event) {
+ event.preventDefault();
+ this.setState({tab});
+ }
+
+ toggleOpen (event) {
+ event.preventDefault();
+ this.setState({opened: !this.isOpened()});
+ }
+
+ toggleSide (event) {
+ event.preventDefault();
+ this.setState({switchSide: !this.state.switchSide});
+ }
+
+ isOpened () {
+ return this.state.opened === null ? this.context.selected !== false && this.context.selected !== 'body' : this.state.opened;
+ }
+
+ renderTabButton (tab) {
+ var className = 'tab';
+ if (this.state.tab === tab) {
+ className += ' selected';
+ }
+ return (
+ {tab}
+ );
+ }
+
+ renderTabs () {
+ return (
+
+ {this.renderTabButton('style')}
+ {this.renderTabButton('settings')}
+ {this.renderTabButton('layers')}
+
+ );
+ }
+
+ renderBreadcrumbs () {
+ return (
+
+ );
+ }
+
+ renderTab () {
+ if (Tabs[this.state.tab]) {
+ var Tab = Tabs[this.state.tab];
+ return ;
+ }
+ }
+
+ renderButtons () {
+ return (
+
+
+ {this.isOpened() ? 'close' : 'menu'}
+
+
+ {this.state.switchSide ? 'keyboard_arrow_right' : 'keyboard_arrow_left'}
+
+
+ );
+ }
+
+ render () {
+ return (
+
+ {this.renderTabs()}
+ {this.renderBreadcrumbs()}
+ {this.renderTab()}
+ {this.renderButtons()}
+
+ );
+ }
+}
+
+PropsMenu.propTypes = {
+ panel: React.PropTypes.any.isRequired
+};
+
+PropsMenu.contextTypes = {
+ selected: React.PropTypes.any.isRequired
+};
diff --git a/lib/components/page-builder/chrome/props-menu/tabs/index.js b/lib/components/page-builder/chrome/props-menu/tabs/index.js
new file mode 100644
index 000000000..dec07b282
--- /dev/null
+++ b/lib/components/page-builder/chrome/props-menu/tabs/index.js
@@ -0,0 +1,9 @@
+import style from './style';
+import settings from './settings';
+import layers from './layers';
+
+export default {
+ style,
+ settings,
+ layers
+};
diff --git a/lib/components/page-builder/chrome/props-menu/tabs/layers/entry.jsx b/lib/components/page-builder/chrome/props-menu/tabs/layers/entry.jsx
new file mode 100644
index 000000000..d0236c3b6
--- /dev/null
+++ b/lib/components/page-builder/chrome/props-menu/tabs/layers/entry.jsx
@@ -0,0 +1,161 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import {Draggable} from '../../../../../drag';
+import OptionsMenu from '../../../../../options-menu';
+import cx from 'classnames';
+
+export default class Entry extends Component {
+ getInitialState () {
+ return {
+ options: false
+ };
+ }
+
+ onClick () {
+ this.context.selectElement(this.props.id);
+ }
+
+ onMouseOver (event) {
+ if (!this.context.dragging) {
+ this.context.overElement(this.props.id);
+ } else if (this.props.hasChildren && !this.props.isExpanded) {
+ this.openInterval = setTimeout(this.context.toggleExpandElement.bind(this, this.props.id), 500);
+ }
+ }
+
+ onMouseOut () {
+ if (!this.context.dragging) {
+ this.context.outElement(this.props.id);
+
+ if (this.state.options) {
+ this.setState({
+ options: false
+ });
+ }
+ } else if (this.openInterval) {
+ clearTimeout(this.openInterval);
+ }
+ }
+
+ openOptions (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.setState({
+ options: true
+ });
+ }
+
+ duplicate (event) {
+ event.preventDefault();
+ this.context.duplicateElement(this.props.id);
+ this.setState({
+ options: false
+ });
+ }
+
+ remove (event) {
+ event.preventDefault();
+ this.context.removeElement(this.props.id);
+ this.setState({
+ options: false
+ });
+ }
+
+ toggleExpand (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.context.toggleExpandElement(this.props.id);
+ }
+
+ renderOptionsMenu () {
+ if (this.state.options) {
+ return (
+
+ );
+ }
+ }
+
+ renderOptions () {
+ if (!this.props.elementInfo.subComponent) {
+ return (
+
+
+ {this.renderOptionsMenu()}
+
+ );
+ }
+ }
+
+ renderContent () {
+ var element = this.context.elements[this.props.elementInfo.tag];
+
+ let selected = this.context.selected && this.context.selected.id === this.props.id;
+ let overed = this.context.overedElement && this.context.overedElement.id === this.props.id;
+ let hasChildren = this.props.hasChildren;
+ let subComponent = this.props.elementInfo.subComponent;
+
+ return (
+
+
{element.settings.icon.content}
+
{this.props.display === 'tag' ? this.props.elementInfo.tag : (this.props.elementInfo.label || this.props.elementInfo.tag)}
+ {this.props.hasChildren ?
: null}
+ {this.renderOptions()}
+
+ );
+ }
+
+ render () {
+ var element = this.context.elements[this.props.elementInfo.tag];
+
+ if (this.props.elementInfo.subComponent) {
+ return (
+
+ {this.renderContent()}
+
+ );
+ } else {
+ var dragInfo = {
+ type: 'move',
+ id: this.props.id
+ };
+
+ return (
+
+
+ {this.renderContent()}
+
+
+ );
+ }
+ }
+}
+
+Entry.propTypes = {
+ id: React.PropTypes.number.isRequired,
+ elementInfo: React.PropTypes.object.isRequired,
+ toggleExpand: React.PropTypes.func.isRequired,
+ isExpanded: React.PropTypes.bool.isRequired,
+ hasChildren: React.PropTypes.bool.isRequired,
+ display: React.PropTypes.string.isRequired
+};
+
+Entry.contextTypes = {
+ dragging: React.PropTypes.bool.isRequired,
+ elements: React.PropTypes.object.isRequired,
+ selected: React.PropTypes.any.isRequired,
+ selectElement: React.PropTypes.func.isRequired,
+ overElement: React.PropTypes.func.isRequired,
+ outElement: React.PropTypes.func.isRequired,
+ overedElement: React.PropTypes.any.isRequired,
+ duplicateElement: React.PropTypes.func.isRequired,
+ removeElement: React.PropTypes.func.isRequired,
+ toggleExpandElement: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/page-builder/chrome/props-menu/tabs/layers/index.jsx b/lib/components/page-builder/chrome/props-menu/tabs/layers/index.jsx
new file mode 100644
index 000000000..4da9cb923
--- /dev/null
+++ b/lib/components/page-builder/chrome/props-menu/tabs/layers/index.jsx
@@ -0,0 +1,132 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Entry from './entry';
+import {Droppable} from '../../../../../drag';
+import merge from 'lodash.merge';
+import forEach from 'lodash.foreach';
+import GeminiScrollbar from 'react-gemini-scrollbar';
+
+export default class Layers extends Component {
+ getInitialState () {
+ return {
+ display: 'label'
+ };
+ }
+
+ changeDisplay (type, event) {
+ event.preventDefault();
+ this.setState({
+ display: type
+ });
+ }
+
+ expandAll (event) {
+ event.preventDefault();
+ this.context.expandAll();
+ }
+
+ collapseAll (event) {
+ event.preventDefault();
+ this.context.collapseAll();
+ }
+
+ inPath (id) {
+ var is = false;
+ forEach(this.context.selectedPath, (pathEntry) => {
+ if (pathEntry.id === id) {
+ is = true;
+ return false;
+ }
+ });
+ return is;
+ }
+
+ renderListEntry (elementInfo) {
+ var hasChildren = elementInfo.children instanceof Array && elementInfo.children.length > 0;
+ var isExpanded = hasChildren && elementInfo.expanded;
+ var element = this.context.elements[elementInfo.tag];
+ var dropInfo = {id: elementInfo.id};
+ var dropSettings = element.settings.drop;
+
+ if (dropSettings !== false) {
+ dropSettings = merge({}, element.settings.drop);
+ dropSettings.orientation = 'vertical';
+ delete dropSettings.customDropArea;
+ delete dropSettings.selectionChildren;
+ }
+
+ if (this.inPath(elementInfo.id)) {
+ isExpanded = true;
+ }
+
+ return (
+
+
+ {
+ isExpanded ?
+ this.renderList(elementInfo.children, dropInfo, dropSettings, elementInfo, dropSettings) :
+ (this.context.dragging && !hasChildren && dropSettings !== false ? : null)
+ }
+
+ );
+ }
+
+ renderList (children, dropInfo, dropSettings, parent, droppable = true) {
+ if (!droppable) {
+ return (
+
+ {children.map(this.renderListEntry, this)}
+
+ );
+ } else {
+ return (
+
+
+ {children.map(this.renderListEntry, this)}
+
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+
+
+
+ {this.renderList(this.context.page.data, {type: 'body'}, {accepts: 'Section'}, {tag: 'body'})}
+
+
+
+ );
+ }
+}
+
+Layers.contextTypes = {
+ page: React.PropTypes.object.isRequired,
+ elements: React.PropTypes.object.isRequired,
+ dragging: React.PropTypes.bool.isRequired,
+ expandAll: React.PropTypes.func.isRequired,
+ collapseAll: React.PropTypes.func.isRequired,
+ selectedPath: React.PropTypes.array.isRequired
+};
diff --git a/lib/components/page-builder/chrome/props-menu/tabs/settings/animation.jsx b/lib/components/page-builder/chrome/props-menu/tabs/settings/animation.jsx
new file mode 100644
index 000000000..0193f61eb
--- /dev/null
+++ b/lib/components/page-builder/chrome/props-menu/tabs/settings/animation.jsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import OptionsList from '../../../../../options-list';
+import {Types} from '../../../../../../types';
+import cloneDeep from 'lodash.clonedeep';
+
+export default class AnimationTab extends Component {
+
+ onChange (id, value) {
+ var values = cloneDeep(this.context.selected.animation || AnimationTab.defaults);
+ values[id] = value;
+
+ this.context.setElementAnimation(values);
+ }
+
+ playAnimations (event) {
+ event.preventDefault();
+ /* jshint ignore:start */
+ window.dispatchEvent(new Event('animateElements'));
+ /* jshint ignore:end */
+ }
+
+ renderContent () {
+ if (this.context.selected) {
+ var values = this.context.selected.animation || AnimationTab.defaults;
+
+ return (
+
+ );
+ } else {
+ return (
+ You need select an element first
+ );
+ }
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+AnimationTab.contextTypes = {
+ selected: React.PropTypes.any.isRequired,
+ setElementAnimation: React.PropTypes.func.isRequired
+};
+
+AnimationTab.defaults = {
+ use: false,
+ effect: 'transition.fadeIn',
+ duration: 400,
+ delay: 300
+};
+
+AnimationTab.options = [
+ {
+ label: 'Animation',
+ type: 'Optional',
+ id: 'use',
+ unlocks: [
+ {
+ label: 'Effect',
+ type: Types.Select,
+ id: 'effect',
+ props: {
+ labels: [
+ 'Fade',
+ 'Flip X',
+ 'Flip Y',
+ 'Whirl',
+ 'Shrink',
+ 'Expand',
+ 'Slide up',
+ 'Slide down',
+ 'Slide left',
+ 'Slide right',
+ 'Slide big up',
+ 'Slide big down',
+ 'Slide big left',
+ 'Slide big right'
+ ],
+ values: [
+ 'transition.fadeIn',
+ 'transition.flipXIn',
+ 'transition.flipYIn',
+ 'transition.whirlIn',
+ 'transition.shrinkIn',
+ 'transition.expandIn',
+ 'transition.slideUpIn',
+ 'transition.slideDownIn',
+ 'transition.slideLeftIn',
+ 'transition.slideRightIn',
+ 'transition.slideUpBigIn',
+ 'transition.slideDownBigIn',
+ 'transition.slideLeftBigIn',
+ 'transition.slideRightBigIn'
+ ]
+ },
+ },
+ {
+ label: 'Duration',
+ type: Types.Number,
+ id: 'duration',
+ props: {
+ min: 0,
+ max: 20000,
+ label: 'ms'
+ }
+ },
+ {
+ label: 'Delay',
+ type: Types.Number,
+ id: 'delay',
+ props: {
+ min: 0,
+ max: 20000,
+ label: 'ms'
+ }
+ }
+ ]
+ }
+];
diff --git a/lib/components/page-builder/chrome/props-menu/tabs/settings/index.jsx b/lib/components/page-builder/chrome/props-menu/tabs/settings/index.jsx
new file mode 100644
index 000000000..2a21ebc1d
--- /dev/null
+++ b/lib/components/page-builder/chrome/props-menu/tabs/settings/index.jsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Props from './props';
+import GeminiScrollbar from 'react-gemini-scrollbar';
+
+export default class EditTab extends Component {
+ duplicate (event) {
+ event.preventDefault();
+ this.context.duplicateElement(this.context.selected.id);
+ }
+
+ remove (event) {
+ event.preventDefault();
+ this.context.removeElement(this.context.selected.id);
+ }
+
+ renderTab () {
+ return ;
+ }
+
+ renderNonSelected () {
+ return (
+ You don't have any element selected
+ );
+ }
+
+ renderActionButtons () {
+ if (this.context.selected && this.context.selected !== 'body') {
+ if (this.context.selected.subComponent) {
+ return (
+
+ This is a sub element
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+ }
+
+ render () {
+ return (
+
+
+
+
+ {this.context.selected && this.context.selected !== 'body' ? this.renderTab() : this.renderNonSelected()}
+
+
+
+ {this.renderActionButtons()}
+
+ );
+ }
+}
+
+EditTab.contextTypes = {
+ selected: React.PropTypes.any.isRequired,
+ duplicateElement: React.PropTypes.func.isRequired,
+ removeElement: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/page-builder/chrome/props-menu/tabs/settings/props.jsx b/lib/components/page-builder/chrome/props-menu/tabs/settings/props.jsx
new file mode 100644
index 000000000..8f20d91e2
--- /dev/null
+++ b/lib/components/page-builder/chrome/props-menu/tabs/settings/props.jsx
@@ -0,0 +1,84 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Animation from './animation';
+import OptionsList from '../../../../../options-list';
+import merge from 'lodash.merge';
+import Input from '../../../../../input';
+
+export default class EditProps extends Component {
+
+ displayToggleElement (id, display, event) {
+ event.preventDefault();
+ this.context.displayToggleElement(id, display);
+ }
+
+ renderOptions (options, values) {
+ return (
+
+ );
+ }
+
+ render () {
+ var element = this.context.elements[this.context.selected.tag];
+ var options = null;
+
+ if (!element) {
+ return No element selected
;
+ }
+
+ if (element.propsSchema) {
+ var values = {};
+ merge(values, element.defaultProps);
+ merge(values, this.context.selected.props);
+ options = this.renderOptions(element.propsSchema, values);
+ }
+
+ return (
+
+
+
+ {element.settings.icon.content}
+ {this.context.selected.tag + ' element label'}
+
+
+
+
+ {options}
+
+
+ );
+ }
+}
+
+EditProps.contextTypes = {
+ elements: React.PropTypes.object.isRequired,
+ selected: React.PropTypes.any.isRequired,
+ onPropChange: React.PropTypes.func.isRequired,
+ displayToggleElement: React.PropTypes.func.isRequired,
+ onLabelChange: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/page-builder/chrome/props-menu/tabs/style/index.jsx b/lib/components/page-builder/chrome/props-menu/tabs/style/index.jsx
new file mode 100644
index 000000000..b4d880181
--- /dev/null
+++ b/lib/components/page-builder/chrome/props-menu/tabs/style/index.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import StylePicker from '../../../../../style-picker';
+
+export default class Style extends Component {
+ onChange (id) {
+ this.context.onPropChange('style', id);
+ }
+
+ renderStyles () {
+ if (this.context.selected && this.context.selected.tag !== 'body') {
+ var element = this.context.selected;
+ var Element = this.context.elements[element.tag];
+
+ if (Element && Element.settings && Element.settings.style) {
+ return (
+
+ );
+ } else {
+ return (
+
+
gps_off
+
Current selected element has no style options, head to settings tab to edit its properties!
+
+ );
+ }
+ }
+ }
+
+ render () {
+ return (
+
+ {this.renderStyles()}
+
+ );
+ }
+}
+
+Style.contextTypes = {
+ elements: React.PropTypes.object.isRequired,
+ selected: React.PropTypes.any.isRequired,
+ onPropChange: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/page-builder/context-menu.jsx b/lib/components/page-builder/context-menu.jsx
new file mode 100644
index 000000000..eaba16777
--- /dev/null
+++ b/lib/components/page-builder/context-menu.jsx
@@ -0,0 +1,295 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Autocomplete from '../autocomplete';
+import foreach from 'lodash.foreach';
+import key from 'keymaster';
+import merge from 'lodash.merge';
+import {Draggable} from '../drag';
+
+export default class ContextMenu extends Component {
+ getInitialState () {
+ return {
+ searchOpened: this.props.search,
+ search: '',
+ suggestions: [],
+ suggestion: ''
+ };
+ }
+
+ componentDidMount () {
+ this.keyDownBind = this.focusSearch.bind(this);
+ document.addEventListener('keydown', this.keyDownBind);
+
+ if (this.state.searchOpened) {
+ this.bindSearchEvents();
+ }
+
+ key('escape', 'context-menu', this.close.bind(this));
+ key.setScope('context-menu');
+ }
+
+ focusSearch (event) {
+ document.removeEventListener('keydown', this.keyDownBind);
+ this.setState({
+ searchOpened: true
+ });
+ }
+
+ toggleSearch (event) {
+ event.preventDefault();
+ this.setState({
+ searchOpened: !this.state.searchOpened
+ });
+ }
+
+ close (event) {
+ if (typeof event !== 'undefined') {
+ event.preventDefault();
+ }
+ if (this.props.onClose) {
+ this.props.onClose();
+ }
+ }
+
+ onChange (search) {
+ var suggestion = '';
+ var suggestions = [];
+
+ foreach(this.context.elements, (element, name) => {
+ if (name.toLowerCase().indexOf(search.toLowerCase()) === 0) {
+ if (suggestion === '' || name === this.state.suggestion) {
+ suggestion = name;
+ }
+ suggestions.push(name);
+ }
+ });
+
+ this.setState({
+ suggestions,
+ suggestion,
+ search
+ });
+
+ return suggestion;
+ }
+
+ nextSuggestion (event) {
+ event.preventDefault();
+
+ var next = false;
+
+ foreach(this.state.suggestions, (suggestion, key) => {
+ if (suggestion === this.state.suggestion) {
+ if (key+1 < this.state.suggestions.length) {
+ next = this.state.suggestions[key+1];
+ }
+ return false;
+ }
+ });
+
+ if (next !== false) {
+ this.setState({
+ suggestion: next
+ });
+ }
+ }
+
+ previousSuggestion (event) {
+ event.preventDefault();
+
+ var previous = false;
+
+ foreach(this.state.suggestions, (suggestion) => {
+ if (suggestion === this.state.suggestion) {
+ return false;
+ }
+ previous = suggestion;
+ });
+
+ if (previous !== false) {
+ this.setState({
+ suggestion: previous
+ });
+ }
+ }
+
+ submitSelected (event) {
+ event.preventDefault();
+
+ if (this.state.suggestion) {
+ this.context.addElementAtSelected(this.state.suggestion);
+ this.close();
+ }
+ }
+
+ acceptSuggestion (event) {
+ event.preventDefault();
+ this.setState({
+ search: this.state.suggestion
+ });
+ }
+
+ bindSearchEvents () {
+ key('down', 'context-menu', this.nextSuggestion.bind(this));
+ key('up', 'context-menu', this.previousSuggestion.bind(this));
+ key('enter', 'context-menu', this.submitSelected.bind(this));
+ key('tab', 'context-menu', this.acceptSuggestion.bind(this));
+ }
+
+ unbindSearchEvents () {
+ key.unbind('down', 'context-menu');
+ key.unbind('up', 'context-menu');
+ key.unbind('enter', 'context-menu');
+ key.unbind('tab', 'context-menu');
+ }
+
+ componentDidUpdate (prevProps, prevState) {
+ if (this.state.searchOpened && !prevState.searchOpened) {
+ this.bindSearchEvents();
+ } else if (!this.state.searchOpened && prevState.searchOpened) {
+ this.unbindSearchEvents();
+ }
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('keydown', this.keyDownBind);
+ key.unbind('escape', 'context-menu');
+ this.unbindSearchEvents();
+ key.setScope('all');
+ }
+
+ onStartDrag () {
+ this.context.onStartDrag();
+ this.close();
+ }
+
+ renderCommon (name) {
+ var element = this.context.elements[name];
+ var settings = element.settings;
+
+ var props = {
+ key: name,
+ onStartDrag: this.onStartDrag.bind(this),
+ dragInfo: {
+ type: 'new',
+ element: name
+ },
+ type: name
+ };
+
+ if(settings && settings.drag){
+ merge(props, settings.drag);
+ }
+
+ return (
+
+
+ {element.settings.icon.content}
+
+
+ );
+ }
+
+ renderContent () {
+ if (!this.state.searchOpened) {
+ return (
+
+ {this.props.common.map(this.renderCommon, this)}
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+
+ renderSuggestion (elementName) {
+ var element = this.context.elements[elementName];
+ var settings = element.settings;
+ var className = '';
+
+ if (elementName === this.state.suggestion) {
+ className += 'active';
+ }
+
+ var activeStr = elementName.slice(0, this.state.search.length);
+ var inactiveStr = elementName.slice(this.state.search.length);
+
+ var props = {
+ key: elementName,
+ onStartDrag: this.onStartDrag.bind(this),
+ dragInfo: {
+ type: 'new',
+ element: elementName
+ },
+ type: elementName
+ };
+
+ if(settings && settings.drag){
+ merge(props, settings.drag);
+ }
+
+ return (
+
+
+
+ {activeStr}
+ {inactiveStr}
+
+
+ );
+ }
+
+ renderDropdown () {
+ if (this.state.searchOpened) {
+ return (
+
+ {this.state.suggestions.length > 0 ? this.state.suggestions.map(this.renderSuggestion, this) :
No results found
}
+
+ );
+ }
+ }
+
+ render () {
+ var style = {
+ top: this.props.y,
+ left: this.props.x
+ };
+
+ var className = 'context-menu';
+ if (this.state.searchOpened) {
+ className += ' search';
+ }
+
+ return (
+
+
+
+ {this.renderContent()}
+ {this.renderDropdown()}
+
+ );
+ }
+}
+
+ContextMenu.contextTypes = {
+ elements: React.PropTypes.object.isRequired,
+ addElementAtSelected: React.PropTypes.func.isRequired,
+ onStartDrag: React.PropTypes.func.isRequired
+};
+
+ContextMenu.propTypes = {
+ x: React.PropTypes.number.isRequired,
+ y: React.PropTypes.number.isRequired,
+ onClose: React.PropTypes.func,
+ search: React.PropTypes.bool,
+ common: React.PropTypes.array
+};
+
+ContextMenu.defaultProps = {
+ x: 200,
+ y: 200,
+ search: false,
+ common: ['Section', 'Container', 'Columns', 'TextBox', 'Image']
+};
diff --git a/lib/components/page-builder/elements-menu.jsx b/lib/components/page-builder/elements-menu.jsx
new file mode 100644
index 000000000..a8e931972
--- /dev/null
+++ b/lib/components/page-builder/elements-menu.jsx
@@ -0,0 +1,236 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import forEach from 'lodash.foreach';
+import GeminiScrollbar from 'react-gemini-scrollbar';
+import Animate from '../animate';
+import {Events} from 'backbone';
+import merge from 'lodash.merge';
+import cx from 'classnames';
+
+var collapsed = false;
+
+export default class ElementsMenu extends Component {
+ getInitialState () {
+ var categories = [
+ 'structure',
+ 'content',
+ 'media'
+ ];
+
+ forEach(this.context.elements, (element) => {
+ if (element.settings && element.settings.category) {
+ if (categories.indexOf(element.settings.category) === -1) {
+ categories.push(element.settings.category);
+ }
+ }
+ });
+
+ categories.push('other');
+
+ if (collapsed === false) {
+ collapsed = {};
+ forEach(categories, (category) => collapsed[category] = false);
+ }
+
+ return {
+ categories,
+ top: 0,
+ left: 0,
+ contentTop: 0,
+ side: 'right'
+ };
+ }
+
+ toggleCategory (category, event) {
+ event.preventDefault();
+ collapsed[category] = !collapsed[category];
+ this.forceUpdate();
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.props.targetId !== nextProps.targetId || this.props.targetPosition !== nextProps.targetPosition) {
+ this.updatePosition(nextProps);
+ }
+ }
+
+ componentDidMount () {
+ this.onCloseBind = this.onClose.bind(this);
+ this.updatePositionBind = this.updatePosition.bind(this);
+ this.stopPropagationBind = this.stopPropagation.bind(this);
+
+ document.addEventListener('click', this.onCloseBind);
+ React.findDOMNode(this).addEventListener('click', this.stopPropagationBind);
+ window.addEventListener('scroll', this.updatePositionBind);
+ window.addEventListener('resize', this.updatePositionBind);
+ this.updatePosition();
+ }
+
+ componentWillUnmount () {
+ document.removeEventListener('click', this.onCloseBind);
+ React.findDOMNode(this).removeEventListener('click', this.stopPropagationBind);
+ window.removeEventListener('scroll', this.updatePositionBind);
+ window.removeEventListener('resize', this.updatePositionBind);
+ }
+
+ stopPropagation (event) {
+ this.clickedInside = true;
+ }
+
+ updatePosition (props) {
+ props = (props && props.container) ? props : this.props;
+
+ const containerRect = props.container.getBoundingClientRect();
+
+ let top = containerRect.top + containerRect.height / 2 - 26;
+ let left = containerRect.right + 10;
+ let side = 'right';
+
+ // Constraints
+ let contentTop = 0;
+ let menuWidth = 190; // XXX hard coded
+ let menuHeight = 240; // XXX hard coded
+ let windowHeight = window.innerHeight;
+ let windowWidth = window.innerWidth;
+
+ if (top + menuHeight > windowHeight) {
+ contentTop = windowHeight - (top + menuHeight);
+
+ if (contentTop < -200) {
+ contentTop = -200;
+ }
+ }
+
+ if (left + menuWidth > windowWidth) {
+ side = 'left';
+ left = containerRect.right - 10 - menuWidth - containerRect.width;
+ }
+
+ this.setState({top, left, contentTop, side});
+ }
+
+ onClose () {
+ if (!this.clickedInside) {
+ this.props.onClose();
+ }
+ this.clickedInside = false;
+ }
+
+ addElement (tag) {
+ this.props.onClose();
+ this.context.addElementAtId(tag, this.props.targetId, this.props.targetPosition);
+ }
+
+ elementAcceptable (elementTag, element) {
+ var is = true;
+
+ if (this.props.accepts) {
+ if (this.props.accepts !== 'any' && this.props.accepts !== elementTag) {
+ is = false;
+ }
+ } else if (this.props.rejects) {
+ if (this.props.rejects === 'any' || this.props.rejects === elementTag) {
+ is = false;
+ }
+ }
+
+ const droppableOn = element.settings.drag && element.settings.drag.droppableOn;
+ if (droppableOn) {
+ if (droppableOn !== 'any' && this.props.targetType !== droppableOn) {
+ is = false;
+ }
+ }
+
+ return is;
+ }
+
+ renderElement (elementObj) {
+ const element = elementObj.element;
+ const icon = element.settings.icon;
+ const label = elementObj.label;
+
+ return (
+
+ {icon.content}
+ {label}
+
+ );
+ }
+
+ renderCategory (category) {
+ var elements = [];
+
+ forEach(this.context.elements, (element, index) => {
+ if (element.settings && element.settings.category) {
+ if (element.settings.category === category && this.elementAcceptable(index, element)) {
+ elements.push({
+ label: index,
+ element
+ });
+ }
+ } else if (category === 'other' && this.elementAcceptable(index, element)){
+ elements.push({
+ label: index,
+ element
+ });
+ }
+ });
+
+ if (elements.length > 0) {
+ let collapsedCategory = collapsed[category];
+
+ return (
+
+
+ {category}
+ {collapsedCategory ? 'expand_more' : 'expand_less'}
+
+
+ {!collapsedCategory && elements.map(this.renderElement, this)}
+
+
+ );
+ }
+ }
+
+ render () {
+ let style = {
+ top: this.state.top,
+ left: this.state.left
+ };
+
+ let ballonStyle = {
+ top: this.state.contentTop
+ };
+
+ return (
+
+
+
+
+
+
+ {this.state.categories.map(this.renderCategory, this)}
+
+
+
+
+
+ );
+ }
+}
+merge(ElementsMenu.prototype, Events);
+
+ElementsMenu.contextTypes = {
+ elements: React.PropTypes.object.isRequired,
+ addElementAtId: React.PropTypes.func.isRequired
+};
+
+ElementsMenu.propTypes = {
+ container: React.PropTypes.any.isRequired,
+ targetId: React.PropTypes.number.isRequired,
+ onClose: React.PropTypes.func.isRequired
+};
+
+ElementsMenu.defaultProps = {
+
+};
diff --git a/lib/components/page-builder/general-elements-menu.jsx b/lib/components/page-builder/general-elements-menu.jsx
new file mode 100644
index 000000000..70a4aa65c
--- /dev/null
+++ b/lib/components/page-builder/general-elements-menu.jsx
@@ -0,0 +1,198 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import forEach from 'lodash.foreach';
+import GeminiScrollbar from 'react-gemini-scrollbar';
+import cx from 'classnames';
+import merge from 'lodash.merge';
+import {Draggable} from '../drag';
+
+export default class GeneralElementsMenu extends Component {
+ getInitialState () {
+ var categories = [
+ 'structure',
+ 'content',
+ 'media'
+ ];
+
+ forEach(this.context.elements, (element) => {
+ if (element.settings && element.settings.category) {
+ if (categories.indexOf(element.settings.category) === -1) {
+ categories.push(element.settings.category);
+ }
+ }
+ });
+
+ categories.push('other');
+
+ var collapsed = {};
+ forEach(categories, (category) => collapsed[category] = false);
+
+ return {
+ categories,
+ search: '',
+ collapsed
+ };
+ }
+
+ toggleCategory (category, event) {
+ event.preventDefault();
+ this.state.collapsed[category] = !this.state.collapsed[category];
+ this.setState({
+ collapsed: this.state.collapsed
+ });
+ }
+
+ onToggle (event) {
+ event.preventDefault();
+ this.setState({
+ opened: !this.state.opened
+ });
+ }
+
+ close () {
+ this.setState({
+ opened: false
+ });
+ }
+
+ elementAcceptable (tag, element) {
+ if (this.state.search === '') {
+ return true;
+ }
+
+ return tag.toLowerCase().indexOf(this.state.search.toLowerCase()) === 0;
+ }
+
+ onStartDrag () {
+ this.context.onStartDrag();
+ this.close();
+ }
+
+ searchChange (event) {
+ this.setState({
+ search: event.target.value
+ });
+ }
+
+ renderElement (elementObj) {
+ const element = elementObj.element;
+ const icon = element.settings.icon;
+ const label = elementObj.label;
+
+ var props = {
+ key: label,
+ onStartDrag: this.onStartDrag.bind(this),
+ dragInfo: {
+ type: 'new',
+ element: label
+ },
+ type: label
+ };
+
+ if(element.settings && element.settings.drag){
+ merge(props, element.settings.drag);
+ }
+
+ return (
+
+
+
{icon.content}
+
{label}
+
+ more_vert
+ more_vert
+
+
+
+ );
+ }
+
+ renderCategory (category) {
+ var elements = [];
+
+ forEach(this.context.elements, (element, index) => {
+ if (element.settings && element.settings.category) {
+ if (element.settings.category === category && this.elementAcceptable(index, element)) {
+ elements.push({
+ label: index,
+ element
+ });
+ }
+ } else if (category === 'other' && this.elementAcceptable(index, element)){
+ elements.push({
+ label: index,
+ element
+ });
+ }
+ });
+
+ if (elements.length > 0) {
+ let collapsed = this.state.collapsed[category];
+
+ return (
+
+
+ {category}
+ {collapsed ? 'expand_more' : 'expand_less'}
+
+
+ {!collapsed && elements.map(this.renderElement, this)}
+
+
+ );
+ }
+ }
+
+ renderCategories () {
+ return (
+
+
+ {this.state.categories.map(this.renderCategory, this)}
+
+
+ );
+ }
+
+ renderOpened () {
+ if (this.state.opened) {
+ return (
+
+
+ {this.renderCategories()}
+
+
+ search
+
+
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+ {this.renderOpened()}
+
+ add
+
+
+ );
+ }
+}
+
+GeneralElementsMenu.contextTypes = {
+ elements: React.PropTypes.object.isRequired,
+ addElementAtId: React.PropTypes.func.isRequired,
+ onStartDrag: React.PropTypes.func.isRequired
+};
+
+GeneralElementsMenu.propTypes = {
+ container: React.PropTypes.any.isRequired,
+ targetId: React.PropTypes.number.isRequired,
+ onClose: React.PropTypes.func.isRequired
+};
+
+GeneralElementsMenu.defaultProps = {
+
+};
diff --git a/lib/components/page-builder/index.jsx b/lib/components/page-builder/index.jsx
new file mode 100644
index 000000000..6de030dae
--- /dev/null
+++ b/lib/components/page-builder/index.jsx
@@ -0,0 +1,731 @@
+import React from 'react';
+
+import Canvas from './canvas';
+import Chrome from './chrome';
+import ElementsMenu from './elements-menu';
+import GeneralElementsMenu from './general-elements-menu';
+
+import {DragRoot} from '../drag';
+import JSSReact from '../../react-jss/jss-react';
+import Colors from '../../colors';
+import Styles from '../../styles';
+
+import forEach from 'lodash.foreach';
+import key from 'keymaster';
+import cloneDeep from 'lodash.clonedeep';
+
+var idCounter = 0;
+
+export default class PageBuilder extends DragRoot {
+ getInitialState () {
+ this.onStartDragBind = this.onStartDrag.bind(this);
+ this.onPropChangeBind = this.onPropChange.bind(this);
+ this.onAddElementAtSelected = this.addElementAtSelected.bind(this);
+ this.selectElementBind = this.selectElement.bind(this);
+ this.overElementBind = this.overElement.bind(this);
+ this.outElementBind = this.outElement.bind(this);
+ this.duplicateElementBind = this.duplicateElement.bind(this);
+ this.removeElementBind = this.removeElement.bind(this);
+ this.onLabelChangeBind = this.onLabelChange.bind(this);
+ this.toggleExpandElementBind = this.toggleExpandElement.bind(this);
+ this.expandAllBind = this.expandAll.bind(this);
+ this.collapseAllBind = this.collapseAll.bind(this);
+ this.displayToggleElementBind = this.displayToggleElement.bind(this);
+ this.onResizeBind = this.onResize.bind(this);
+ this.setElementAnimationBind = this.setElementAnimation.bind(this);
+ this.elementContentChangeBind = this.elementContentChange.bind(this);
+ this.findPageElementByIdBind = this.findPageElementById.bind(this);
+ this.addElementAtIdBind = this.addElementAtId.bind(this);
+ this.openElementsMenuBind = this.openElementsMenu.bind(this);
+
+ var page = this.props.page;
+ page.data = page.data || [];
+ page.actions = page.actions || [];
+ idCounter = this.checkLatestId(page.data) + 1;
+
+ this.listenTo(Colors, 'update', this.colorUpdated.bind(this));
+
+ return {
+ dragging: false,
+ selected: false,
+ selectedPath: [],
+ overedElement: false,
+ page: page,
+ redos: []
+ };
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.page._id !== this.state.page._id) {
+ nextProps.page.data = nextProps.page.data || [];
+ nextProps.page.actions = nextProps.page.actions || [];
+ idCounter = this.checkLatestId(nextProps.page.data) + 1;
+
+ this.setState({
+ page: nextProps.page
+ });
+ }
+ }
+
+ colorUpdated () {
+ Styles.onStylesUpdate();
+ this.forceUpdate();
+ }
+
+ getChildContext () {
+ return {
+ elements: this.context.elements,
+ page: this.state.page,
+ selectElement: this.selectElementBind,
+ selected: this.state.selected,
+ selectedPath: this.state.selectedPath,
+ overElement: this.overElementBind,
+ outElement: this.outElementBind,
+ overedElement: this.state.overedElement,
+ onStartDrag: this.onStartDragBind,
+ onPropChange: this.onPropChangeBind,
+ dragging: this.state.dragging,
+ redos: this.state.redos,
+ addElementAtSelected: this.onAddElementAtSelected,
+ addElementAtId: this.addElementAtIdBind,
+ duplicateElement: this.duplicateElementBind,
+ removeElement: this.removeElementBind,
+ onLabelChange: this.onLabelChangeBind,
+ toggleExpandElement: this.toggleExpandElementBind,
+ expandAll: this.expandAllBind,
+ collapseAll: this.collapseAllBind,
+ displayToggleElement: this.displayToggleElementBind,
+ setElementAnimation: this.setElementAnimationBind,
+ elementContentChange: this.elementContentChangeBind,
+ findPageElementById: this.findPageElementByIdBind,
+ openElementsMenu: this.openElementsMenuBind,
+ elementsMenuSpot: this.state.elementsMenu ? this.state.elementsMenuProps.targetPosition : -1
+ };
+ }
+
+ componentDidMount () {
+ if (window !== undefined) {
+ key('⌘+z, ctrl+z', this.undoAction.bind(this));
+ key('⌘+y, ctrl+y', this.redoAction.bind(this));
+ key('delete, del', this.removeSelectedElement.bind(this));
+
+ window.addEventListener('resize', this.onResizeBind);
+ this.onResize();
+ }
+ }
+
+ onResize () {
+ this.setState({
+ mounted: true
+ });
+ }
+
+ updatePage () {
+ this.setState({
+ page: this.state.page
+ });
+ }
+
+ forEachElement (elements, callback) {
+ forEach(elements, (element, index) => {
+ callback(element);
+
+ if (element.children && element.children.length) {
+ this.forEachElement(element.children, callback);
+ }
+ });
+ }
+
+ checkLatestId (data) {
+ var max = 0;
+
+ this.forEachElement(data, (element) => {
+ if (element.id > max) {
+ max = element.id;
+ }
+ });
+
+ return max;
+ }
+
+ registerAction (action) {
+ this.state.page.actions.push(action);
+ }
+
+ undoAction (event) {
+ if (event && event.preventDefault) {
+ event.preventDefault();
+ }
+
+ this.revertAction();
+ }
+
+ redoAction (event) {
+ if (event && event.preventDefault) {
+ event.preventDefault();
+ }
+
+ if (this.state.redos.length > 0) {
+ var action = this.state.redos.pop();
+ this.state.page.actions.push(action);
+ this.doAction(action);
+ }
+ }
+
+ selectElement (id) {
+ if (id === 'body') {
+ this.setState({
+ selected: 'body',
+ selectedPath: []
+ });
+ } else {
+ var info = this.findElementById(this.state.page.data, id);
+ var element = info.element;
+
+ if (element !== false) {
+ this.setState({
+ selected: element,
+ selectedPath: info.path
+ });
+ }
+ }
+ }
+
+ overElement (id) {
+ if (id !== 'body' && (!this.overedElement || this.overedElement.id !== id)) {
+ var info = this.findElementById(this.state.page.data, id);
+ var element = info.element;
+
+ if (element !== false) {
+ this.setState({
+ overedElement: info.element
+ });
+ }
+ }
+ }
+
+ outElement (id) {
+ if (this.state.overedElement && this.state.overedElement.id === id) {
+ this.setState({
+ overedElement: false
+ });
+ }
+ }
+
+ doAction (action) {
+ if(action.type === 'new' || action.type === 'move' || action.type === 'add'){
+ // Source
+ var element;
+ if (action.type === 'new') {
+ element = action.element;
+ element.id = element.id || idCounter++;
+
+ if (!element.children && this.context.elements[element.tag].defaultChildren) {
+ const defaultChildren = this.context.elements[element.tag].defaultChildren;
+
+ if (defaultChildren.constructor === Array) {
+ let defaultChildrenClone = cloneDeep(defaultChildren);
+ forEach(defaultChildrenClone, (childElement) => {
+ childElement.id = idCounter++;
+ });
+ element.children = defaultChildrenClone;
+ } else {
+ element.children = defaultChildren;
+ }
+ }
+ } else if (action.type === 'add') {
+ element = action.element;
+ } else {
+ element = this.findElementById(this.state.page.data, action.source.id, true).element;
+ }
+
+ // Destination
+ let destination;
+ if (action.destination.id === 'body') {
+ destination = this.state.page.data;
+ } else {
+ destination = this.findElementById(this.state.page.data, action.destination.id).element;
+ destination.children = destination.children || [];
+ destination = destination.children;
+ }
+
+ // Position
+ var position = action.destination.position;
+ if (action.type === 'move') {
+ if(action.source.parent === action.destination.id && action.source.position < action.destination.position){
+ position -= 1;
+ }
+ }
+
+ destination.splice(position, 0, element);
+
+ if (action.type === 'new') {
+ this.selectElement(element.id);
+ }
+ } else if (action.type === 'remove') {
+ if (this.state.selected.id === action.id) {
+ this.selectElement('body');
+ } else {
+ // check if child is selected
+ let info = this.findElementById(this.state.page.data, this.state.selected.id);
+ forEach(info.path, (element) => {
+ if (element.id === action.id) {
+ this.selectElement('body');
+ return false;
+ }
+ });
+ }
+ this.findElementById(this.state.page.data, action.id, true);
+ } else if (action.type === 'changeProp') {
+ let info = this.findElementById(this.state.page.data, action.id);
+ info.element.props[action.prop] = action.value;
+ } else if (action.type === 'changeContent') {
+ let info = this.findElementById(this.state.page.data, action.id);
+ info.element.children = action.value;
+ } else if (action.type === 'changeAnimation') {
+ let info = this.findElementById(this.state.page.data, action.id);
+ info.element.animation = action.value;
+ } else if (action.type === 'changeLabel') {
+ let info = this.findElementById(this.state.page.data, action.id);
+ info.element.label = action.value;
+ } else if (action.type === 'changeDisplay') {
+ let info = this.findElementById(this.state.page.data, action.id);
+ info.element.hide[action.display] = info.element.hide[action.display] ? false : true;
+ }
+
+ this.setState({
+ page: this.state.page
+ });
+ }
+
+ revertAction () {
+ if(this.state.page.actions.length > 0){
+ var revertedAction = {};
+ var action = this.state.page.actions.pop();
+
+ // New
+ if (action.type === 'new') {
+ revertedAction.type = 'remove';
+ revertedAction.id = action.element.id;
+ }
+
+ // Move
+ else if (action.type === 'move') {
+ revertedAction.type = 'move';
+
+ revertedAction.source = {
+ id: action.source.id,
+ parent: action.destination.id,
+ position: action.destination.position
+ };
+
+ revertedAction.destination = {
+ id: action.source.parent,
+ position: action.source.position
+ };
+ }
+
+ // Remove
+ else if (action.type === 'remove') {
+ revertedAction.type = 'add';
+ revertedAction.element = action.element;
+ revertedAction.destination = {
+ id: action.parent,
+ position: action.position
+ };
+ }
+
+ // Prop change
+ else if (action.type === 'changeProp') {
+ revertedAction = {
+ type: 'changeProp',
+ id: action.id,
+ prop: action.prop,
+ value: action.oldValue,
+ oldValue: action.value
+ };
+ }
+
+ // Content change
+ else if (action.type === 'changeContent') {
+ revertedAction = {
+ type: 'changeContent',
+ id: action.id,
+ value: action.oldValue,
+ oldValue: action.value
+ };
+ }
+
+ // Animation change
+ else if (action.type === 'changeAnimation') {
+ revertedAction = {
+ type: 'changeAnimation',
+ id: action.id,
+ value: action.oldValue,
+ oldValue: action.value
+ };
+ }
+
+ // Label change
+ else if (action.type === 'changeLabel') {
+ revertedAction = {
+ type: 'changeLabel',
+ id: action.id,
+ value: action.oldValue,
+ oldValue: action.value
+ };
+ }
+
+ // Display change
+ else if (action.type === 'changeDisplay') {
+ revertedAction = {
+ type: 'changeDisplay',
+ id: action.id,
+ display: action.display
+ };
+ }
+
+ this.state.redos.push(action);
+ this.doAction(revertedAction);
+ }
+ }
+
+ addElementAtSelected (element) {
+ var position = 0;
+
+ if (this.state.selected.children && this.state.selected.children.constructor === Array) {
+ position = this.state.selected.children.length;
+ }
+
+ var action = {
+ type: 'new',
+ element: {
+ tag: element,
+ id: idCounter++
+ },
+ destination: {
+ id: this.state.selected !== false ? this.state.selected.id : 'body',
+ position
+ }
+ };
+ this.factorAction(action);
+ }
+
+ addElementAtId (tag, toId, position = 0) {
+ var action = {
+ type: 'new',
+ element: {
+ tag,
+ id: idCounter++
+ },
+ destination: {
+ id: toId,
+ position
+ }
+ };
+ this.factorAction(action);
+ }
+
+ updateElementIds (element) {
+ element.id = idCounter++;
+
+ if (element.children && element.children.constructor === Array) {
+ forEach(element.children, (element) => {
+ this.updateElementIds(element);
+ });
+ }
+ }
+
+ duplicateElement (id) {
+ var info = this.findElementById(this.state.page.data, id, false);
+ var element = cloneDeep(info.element);
+ this.updateElementIds(element);
+ var action = {
+ type: 'new',
+ element,
+ destination: {
+ id: info.parent,
+ position: info.position+1
+ }
+ };
+ this.factorAction(action);
+ }
+
+ removeElement (id) {
+ let info = this.findElementById(this.state.page.data, id);
+ this.factorAction({
+ type: 'remove',
+ id,
+ parent: info.parent,
+ position: info.position,
+ element: info.element
+ });
+ }
+
+ removeSelectedElement () {
+ if (this.state.selected !== 'body') {
+ this.removeElement (this.state.selected.id);
+ }
+ }
+
+ draggedComponent (dragReport) {
+ const dragInfo = dragReport.dragInfo;
+ const dropInfo = dragReport.dropInfo;
+
+ // dropped no where
+ if(!dropInfo || !dragInfo){
+ return;
+ }
+
+ var action = {
+ type: dragInfo.type
+ };
+
+ // dragging element
+ if (dragInfo.type === 'new') {
+ action.element = {
+ tag: dragInfo.element,
+ id: idCounter++
+ };
+ } else if (dragInfo.type === 'move') {
+ let info = this.findElementById(this.state.page.data, dragInfo.id);
+ action.source = {
+ id: dragInfo.id,
+ parent: info.parent,
+ position: info.position
+ };
+ }
+
+ // destination
+ if (dropInfo.type === 'body') {
+ action.destination = {
+ id: 'body',
+ position: 0
+ };
+ } else {
+ action.destination = {
+ id: dropInfo.id,
+ position: 0
+ };
+ }
+
+ // position
+ if(dropInfo.position !== undefined) {
+ action.destination.position = dropInfo.position;
+ }
+
+ this.factorAction(action);
+ }
+
+ factorAction (action) {
+ this.state.redos = [];
+ this.state.page.actions.push(action);
+
+ this.doAction(action);
+ }
+
+ findPageElementById (id) {
+ return this.findElementById (this.state.page.data, id).element;
+ }
+
+ findElementById (elements, id, remove = false, parent = {tag: 'body', id: 'body'}, path = []) {
+ var result = false;
+
+ path.push(parent);
+
+ forEach(elements, (element, index) => {
+ if (element.id === id) {
+ if (remove) {
+ result = {
+ element: (elements.splice(index, 1))[0],
+ parent: parent.id,
+ position: index,
+ path
+ };
+ } else {
+ result = {
+ element,
+ parent: parent.id,
+ position: index,
+ path
+ };
+ }
+
+ return false;
+ }
+
+ if (element.children && element.children.length > 0) {
+ result = this.findElementById(element.children, id, remove, element, path);
+
+ if (result !== false) {
+ return false;
+ }
+ }
+ });
+
+ if (result === false) {
+ path.pop();
+ }
+
+ return result;
+ }
+
+ toggleExpandElement (elementId) {
+ var info = this.findElementById(this.state.page.data, elementId);
+ info.element.expanded = !info.element.expanded;
+ this.updatePage();
+ }
+
+ expandAll () {
+ this.forEachElement(this.state.page.data, (element) => {
+ element.expanded = true;
+ });
+ this.updatePage();
+ }
+
+ collapseAll () {
+ this.forEachElement(this.state.page.data, (element) => {
+ element.expanded = false;
+ });
+ this.updatePage();
+ }
+
+ onPropChange (id, value) {
+ this.state.selected.props = this.state.selected.props || {};
+ this.factorAction({
+ type: 'changeProp',
+ id: this.state.selected.id,
+ prop: id,
+ value,
+ oldValue: this.state.selected.props[id]
+ });
+ }
+
+ elementContentChange (value) {
+ if (value && value.constructor === Array) {
+ forEach(value, (childElement) => {
+ if (!childElement.id) {
+ childElement.id = idCounter++;
+ }
+ });
+ }
+
+ this.factorAction({
+ type: 'changeContent',
+ id: this.state.selected.id,
+ value,
+ oldValue: this.state.selected.children
+ });
+ }
+
+ onLabelChange (value) {
+ this.factorAction({
+ type: 'changeLabel',
+ id: this.state.selected.id,
+ value,
+ oldValue: this.state.selected.label
+ });
+ }
+
+ displayToggleElement (elementId, display) {
+ var info = this.findElementById(this.state.page.data, elementId);
+ info.element.hide = info.element.hide || {};
+
+ this.factorAction({
+ type: 'changeDisplay',
+ id: this.state.selected.id,
+ display
+ });
+ }
+
+ setElementAnimation (animation) {
+ this.factorAction({
+ type: 'changeAnimation',
+ id: this.state.selected.id,
+ value: animation,
+ oldValue: this.state.selected.animation
+ });
+ }
+
+ openElementsMenu (options) {
+ this.setState({
+ elementsMenu: true,
+ elementsMenuProps: options
+ });
+ }
+
+ closeElementsMenu () {
+ this.setState({
+ elementsMenu: false,
+ elementsMenuProps: null
+ });
+ }
+
+ renderElementsMenu () {
+ if (this.state.elementsMenu) {
+ return (
+
+ );
+ }
+ }
+
+ render () {
+ var className = 'page-builder';
+
+ if (!this.context.editing) {
+ className += ' preview';
+ }
+
+ return (
+
+
+
+
+ {this.renderDragger({top: -60})}
+ {this.renderElementsMenu()}
+
+
+ );
+ }
+}
+
+PageBuilder.propTypes = {
+ page: React.PropTypes.object.isRequired
+};
+
+PageBuilder.contextTypes = {
+ elements: React.PropTypes.object.isRequired,
+ editing: React.PropTypes.bool.isRequired
+};
+
+PageBuilder.childContextTypes = {
+ elements: React.PropTypes.object.isRequired,
+ page: React.PropTypes.object.isRequired,
+ selectElement: React.PropTypes.func.isRequired,
+ selected: React.PropTypes.any.isRequired,
+ selectedPath: React.PropTypes.array.isRequired,
+ overElement: React.PropTypes.func.isRequired,
+ outElement: React.PropTypes.func.isRequired,
+ overedElement: React.PropTypes.any.isRequired,
+ onStartDrag: React.PropTypes.func.isRequired,
+ onPropChange: React.PropTypes.func.isRequired,
+ dragging: React.PropTypes.bool.isRequired,
+ redos: React.PropTypes.array.isRequired,
+ addElementAtSelected: React.PropTypes.func.isRequired,
+ addElementAtId: React.PropTypes.func.isRequired,
+ duplicateElement: React.PropTypes.func.isRequired,
+ removeElement: React.PropTypes.func.isRequired,
+ onLabelChange: React.PropTypes.func.isRequired,
+ toggleExpandElement: React.PropTypes.func.isRequired,
+ expandAll: React.PropTypes.func.isRequired,
+ collapseAll: React.PropTypes.func.isRequired,
+ displayToggleElement: React.PropTypes.func.isRequired,
+ setElementAnimation: React.PropTypes.func.isRequired,
+ elementContentChange: React.PropTypes.func.isRequired,
+ findPageElementById: React.PropTypes.func.isRequired,
+ openElementsMenu: React.PropTypes.func.isRequired,
+ elementsMenuSpot: React.PropTypes.number.isRequired
+};
diff --git a/lib/components/page/index.jsx b/lib/components/page/index.jsx
new file mode 100644
index 000000000..b6d639897
--- /dev/null
+++ b/lib/components/page/index.jsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import displays from '../../displays';
+import forEach from 'lodash.foreach';
+
+export default class Page extends Component {
+ getInitialState () {
+ this.onResizeBind = this.onResize.bind(this);
+ return {
+ mounted: false,
+ display: 'desktop'
+ };
+ }
+
+ componentDidMount () {
+ window.addEventListener('resize', this.onResizeBind);
+ this.onResize();
+ }
+
+ onResize () {
+ var width = window.outerWidth;
+ //var height = window.outerHeight;
+
+ var display = 'desktop';
+ var amount = 99999;
+
+ forEach(displays, (value, key) => {
+ var dif = value - width;
+ if (width < value && dif < amount) {
+ amount = dif;
+ display = key;
+ }
+ });
+
+ this.setState({
+ mounted: true,
+ display
+ });
+ }
+
+ getChildContext () {
+ return {
+ editing: false
+ };
+ }
+
+ renderElement (element) {
+ if (element.hide && element.hide[this.state.display]) {
+ return null;
+ }
+ var FactoredElement = this.props.elements[element.tag];
+
+ return (
+
+ {this.renderChildren(element.children || '')}
+
+ );
+ }
+
+ renderChildren (children) {
+ // group of elements (array)
+ if( children instanceof Array ){
+ return children.map(this.renderElement.bind(this));
+ }
+ // String or other static content
+ else {
+ return children;
+ }
+ }
+
+ render () {
+ return (
+
+ {this.renderChildren(this.props.page.data)}
+
+ );
+ }
+}
+
+Page.propTypes = {
+ elements: React.PropTypes.object.isRequired,
+ page: React.PropTypes.object.isRequired
+};
+
+Page.childContextTypes = {
+ editing: React.PropTypes.bool.isRequired
+};
diff --git a/lib/components/schema-link/field.jsx b/lib/components/schema-link/field.jsx
new file mode 100644
index 000000000..ae01b4f5f
--- /dev/null
+++ b/lib/components/schema-link/field.jsx
@@ -0,0 +1,134 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import Combobox from '../combobox';
+import forEach from 'lodash.foreach';
+import Utils from '../../utils';
+
+export default class Field extends Component {
+ onClick (event) {
+ event.preventDefault();
+ this.props.onClick(this.props.id);
+ }
+
+ onElementLinkChange (value) {
+ this.props.onChange(this.props.id, {
+ link: value,
+ prop: this.props.value.prop || ''
+ });
+ }
+
+ onElementPropChange (value) {
+ this.props.onChange(this.props.id, {
+ link: this.props.value.link,
+ prop: value
+ });
+ }
+
+ getElementsArray (children, info) {
+ forEach(children, (element) => {
+ info.labels.push(element.label || element.tag);
+ info.values.push(element.id);
+
+ if (element.children instanceof Array && element.children.length > 0) {
+ this.getElementsArray(element.children, info);
+ }
+ });
+ }
+
+ renderLinkTo () {
+ var props = {
+ value: this.props.value.link || 'none',
+ values: ['none'],
+ labels: ['None'],
+ onChange: this.onElementLinkChange.bind(this)
+ };
+ if (this.context.selected.children && this.context.selected.children instanceof Array && this.context.selected.children.length > 0) {
+ this.getElementsArray(this.context.selected.children, props);
+ }
+
+ return (
+
+ );
+ }
+
+ renderPropSelect () {
+ if (this.props.value.link && this.props.value.link !== 'none') {
+ var props = {
+ values: ['children'],
+ labels: ['Content'],
+ value: this.props.value.prop || '',
+ onChange: this.onElementPropChange.bind(this)
+ };
+
+ var pageElement = this.context.findPageElementById(this.props.value.link);
+ if (pageElement.tag && this.context.elements[pageElement.tag]) {
+ var element = this.context.elements[pageElement.tag];
+
+ if (element.propsSchema) {
+ var propsList = Utils.getPropSchemaList(element.propsSchema);
+
+ forEach(propsList, (propSchema) => {
+ if (propSchema.id && propSchema.label) {
+ props.values.push(propSchema.id);
+ props.labels.push(propSchema.label);
+ }
+ });
+ }
+
+ return (
+
+ );
+ }
+
+ }
+ }
+
+ renderOptions () {
+ if (this.props.opened) {
+ return (
+
+ {this.renderLinkTo()}
+ {this.renderPropSelect()}
+
+ );
+ }
+ }
+
+ render () {
+ var className = 'schema-link-field';
+
+ if (this.props.opened) {
+ className += ' opened';
+ }
+
+ return (
+
+ );
+ }
+}
+
+Field.contextTypes = {
+ selected: React.PropTypes.any.isRequired,
+ elements: React.PropTypes.object.isRequired,
+ findPageElementById: React.PropTypes.func.isRequired
+};
+
+Field.propTypes = {
+ id: React.PropTypes.string.isRequired,
+ opened: React.PropTypes.bool.isRequired,
+ onClick: React.PropTypes.func.isRequired,
+ onChange: React.PropTypes.func.isRequired,
+ value: React.PropTypes.object.isRequired
+};
diff --git a/lib/components/schema-link/index.jsx b/lib/components/schema-link/index.jsx
new file mode 100644
index 000000000..ab37ab90b
--- /dev/null
+++ b/lib/components/schema-link/index.jsx
@@ -0,0 +1,130 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import Combobox from '../combobox';
+import forEach from 'lodash.foreach';
+import cloneDeep from 'lodash.clonedeep';
+import Field from './field';
+
+import schemasStore from '../../client/stores/schemas';
+
+export default class SchemaLink extends Component {
+
+ getInitialState () {
+ return {
+ opened: false
+ };
+ }
+
+ getInitialCollections () {
+ return {
+ schemas: schemasStore.getCollection()
+ };
+ }
+
+ getInitialModels () {
+ var models = {};
+
+ if (this.props.value.schema && this.props.value.schema !== '') {
+ models.schema = schemasStore.getModel(this.props.value.schema);
+ }
+
+ return models;
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.value.schema && nextProps.value.schema !== this.props.value.schema) {
+ this.setModels({
+ schema: schemasStore.getModel(nextProps.value.schema)
+ });
+ }
+ }
+
+ onSchemaChange (value) {
+ var cloned = cloneDeep(this.props.value);
+ cloned.schema = value;
+ this.props.onChange(cloned);
+ }
+
+ changeOpened (id) {
+ var value = this.state.opened === id ? false : id;
+ this.setState({
+ opened: value
+ });
+ }
+
+ onFieldChange (id, value) {
+ var cloned = cloneDeep(this.props.value);
+ cloned.fields = cloned.fields || {};
+ cloned.fields[id] = value;
+ this.props.onChange(cloned);
+ }
+
+ renderCombobox () {
+ if (this.state.schemas) {
+ var props = {
+ value: this.props.value.schema || '',
+ values: [],
+ labels: [],
+ onChange: this.onSchemaChange.bind(this)
+ };
+ forEach(this.state.schemas, (schema) => {
+ props.values.push(schema._id);
+ props.labels.push(schema.title);
+ });
+
+ return (
+
+ );
+ }
+ }
+
+ renderField (field) {
+ var value = this.props.value && this.props.value.fields && this.props.value.fields[field.id] ? this.props.value.fields[field.id] : {};
+ return (
+
+ );
+ }
+
+ renderFields () {
+ if (this.state.schema && this.state.schema.fields) {
+ return (
+
+ {this.renderField({
+ id: 'title',
+ type: 'String'
+ })}
+ {this.renderField({
+ id: 'date',
+ type: 'String'
+ })}
+ {this.state.schema.fields.map(this.renderField, this)}
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+ {this.renderCombobox()}
+ {this.renderFields()}
+
+ );
+ }
+}
+
+
+SchemaLink.defaultProps = {
+ value: {}
+};
+
+SchemaLink.propTypes = {
+ value: React.PropTypes.string.isRequired,
+ onChange: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/spacing-picker.jsx b/lib/components/spacing-picker.jsx
new file mode 100644
index 000000000..2b076c313
--- /dev/null
+++ b/lib/components/spacing-picker.jsx
@@ -0,0 +1,132 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import NumberInput from './number-input';
+
+export default class SpacingPicker extends Component {
+ getInitialState () {
+ return {
+ selected: 'center',
+ values: this.parseValue(this.props.value)
+ };
+ }
+
+ componentWillReceiveProps (nextProps) {
+ this.setState({
+ values: this.parseValue(nextProps.value)
+ });
+ }
+
+ onInputChange (value) {
+ if (this.state.selected === 'center') {
+ this.state.values.top = value;
+ this.state.values.right = value;
+ this.state.values.bottom = value;
+ this.state.values.left = value;
+ } else {
+ this.state.values[this.state.selected] = value;
+ }
+ this.props.onChange(this.getValuesString(this.state.values));
+ }
+
+ getValuesString (values) {
+ return values.top+'px '+values.right+'px '+values.bottom+'px '+values.left+'px';
+ }
+
+ parseValue (value) {
+ var values = value.split(' ');
+ var result = {
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ equal: false
+ };
+
+ if (values.length === 1) {
+ var parsedValue = parseInt(values[0], 10);
+ result.top = parsedValue;
+ result.bottom = parsedValue;
+ result.left = parsedValue;
+ result.right = parsedValue;
+ } else if (values.length === 2) {
+ result.top = parseInt(values[0], 10);
+ result.right = parseInt(values[1], 10);
+ result.bottom = parseInt(values[0], 10);
+ result.left = parseInt(values[1], 10);
+ } else if (values.length === 4) {
+ result.top = parseInt(values[0], 10);
+ result.right = parseInt(values[1], 10);
+ result.bottom = parseInt(values[2], 10);
+ result.left = parseInt(values[3], 10);
+ }
+
+ if (result.top === result.right && result.top === result.bottom && result.top === result.left) {
+ result.equal = true;
+ } else {
+ result.equal = false;
+ }
+
+ return result;
+ }
+
+ changeSelected (selected, event) {
+ event.preventDefault();
+ this.setState({
+ selected
+ });
+ }
+
+ renderToggleButton (pos, icon, active) {
+ var className = 'toggle ' + pos;
+
+ if (this.state.selected === pos) {
+ className += ' selected';
+ }
+
+ if (active) {
+ className += ' active';
+ }
+
+ return (
+
+ {icon}
+
+ );
+ }
+
+ render () {
+ var className = 'spacing-picker type-' + this.props.type;
+ var values = this.state.values;
+ var value = 0;
+ var inactive = false;
+
+ if (this.state.selected !== 'center') {
+ value = values[this.state.selected];
+ } else {
+ inactive = !values.equal;
+ value = values.equal ? values.top : Math.round((values.top+values.right+values.bottom+values.left)/4);
+ }
+
+ return (
+
+
+ {this.renderToggleButton('top', 'expand_less', !values.equal)}
+ {this.renderToggleButton('left', 'chevron_left', !values.equal)}
+ {this.renderToggleButton('center', 'link', values.equal)}
+ {this.renderToggleButton('right', 'chevron_right', !values.equal)}
+ {this.renderToggleButton('bottom', 'expand_more', !values.equal)}
+
+
+
+ );
+ }
+}
+
+SpacingPicker.propTypes = {
+ value: React.PropTypes.string.isRequired,
+ onChange: React.PropTypes.func.isRequired,
+ type: React.PropTypes.string.isRequired
+};
diff --git a/lib/components/spinner.jsx b/lib/components/spinner.jsx
new file mode 100644
index 000000000..79786461f
--- /dev/null
+++ b/lib/components/spinner.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+
+export default class Spinner extends Component {
+ render () {
+ return (
+
+ );
+ }
+}
diff --git a/lib/components/style-picker/edit.jsx b/lib/components/style-picker/edit.jsx
new file mode 100644
index 000000000..938ba7e86
--- /dev/null
+++ b/lib/components/style-picker/edit.jsx
@@ -0,0 +1,112 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import merge from 'lodash.merge';
+import ComplementMenu from '../complement-menu';
+import Styles from '../../styles';
+import OptionsList from '../options-list';
+import cloneDeep from 'lodash.clonedeep';
+import Input from '../input';
+import GeminiScrollbar from 'react-gemini-scrollbar';
+
+import styleActions from '../../client/actions/style';
+
+export default class Edit extends Component {
+ getInitialState () {
+ var model;
+ this.isNew = this.props.editing === false;
+
+ if (!this.isNew) {
+ model = Styles.get(this.props.editing);
+ this.initialOptions = model && model.get('options') || {};
+ } else {
+ model = Styles.createTemp(this.props.type);
+ }
+
+ return {
+ model
+ };
+ }
+
+ onTitleChange (value) {
+ this.state.model.set({
+ title: value
+ });
+ }
+
+ onChange (id, value) {
+ var options = cloneDeep(this.state.model.get('options'));
+ options[id] = value;
+ this.state.model.set({
+ options
+ });
+ }
+
+ onCancel () {
+ if (this.isNew) {
+ Styles.removeTemp();
+ } else {
+ this.state.model.set({
+ options: this.initialOptions
+ });
+ }
+
+ this.props.onClose();
+ }
+
+ onSubmit () {
+ var json = this.state.model.toJSON();
+ if (this.isNew) {
+ delete json._id;
+ Styles.removeTemp();
+ styleActions
+ .add(json)
+ .then(() => this.props.onClose());
+ } else {
+ styleActions
+ .update(json)
+ .then(() => this.props.onClose());
+ }
+ }
+
+ renderOptions () {
+ var registeredStyle = Styles.getStyleOptionsByType(this.props.type);
+ var values = cloneDeep(registeredStyle.defaults);
+ merge(values, this.state.model.get('options'));
+
+ return (
+
+ );
+ }
+
+ render () {
+ return (
+
+
+
+
+
+ {this.renderOptions()}
+
+
+
+
+
+ );
+ }
+}
+
+Edit.propTypes = {
+ type: React.PropTypes.string.isRequired,
+ editing: React.PropTypes.any.isRequired,
+ onClose: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/style-picker/entry.jsx b/lib/components/style-picker/entry.jsx
new file mode 100644
index 000000000..9a732b686
--- /dev/null
+++ b/lib/components/style-picker/entry.jsx
@@ -0,0 +1,122 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import styles from '../../styles';
+import OptionsMenu from '../options-menu';
+import cloneDeep from 'lodash.clonedeep';
+import cx from 'classnames';
+
+import stylesActions from '../../client/actions/style';
+
+export default class Entry extends Component {
+ getInitialState () {
+ return {
+ classesMap: styles.getClassesMap(this.props.entry._id),
+ options: false
+ };
+ }
+
+ onClick (event) {
+ event.preventDefault();
+ this.props.onClick(this.props.entry._id);
+ }
+
+ openOptions (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.setState({
+ options: true
+ });
+ }
+
+ onMouseLeave () {
+ if (this.state.options) {
+ this.setState({
+ options: false
+ });
+ }
+ }
+
+ edit () {
+ this.props.onEdit(this.props.entry._id);
+ this.setState({
+ options: false
+ });
+ }
+
+ duplicate () {
+ var duplicate = cloneDeep(this.props.entry);
+ delete duplicate._id;
+
+ stylesActions.add(duplicate);
+
+ this.setState({
+ options: false
+ });
+ }
+
+ remove () {
+ stylesActions.remove(this.props.entry._id);
+ }
+
+ renderInfo () {
+ if (this.props.styleOptions.getIdentifierLabel) {
+ return (
+ {this.props.styleOptions.getIdentifierLabel(this.props.entry.options)}
+ );
+ }
+ }
+
+ renderPreview () {
+ if (this.props.styleOptions.preview) {
+ return (
+
+ {this.props.styleOptions.preview(this.state.classesMap)}
+
+ );
+ }
+ }
+
+ renderOptionsMenu () {
+ if (this.state.options) {
+ return (
+
+ );
+ }
+ }
+
+ render () {
+ var className = cx(
+ 'entry',
+ this.props.selected && 'selected',
+ this.props.entry.options && this.props.entry.options.preview && 'contrast',
+ this.props.editing && 'editing'
+ );
+
+ return (
+
+
+ {this.props.entry.title}
+ {this.renderInfo()}
+
+ {this.renderPreview()}
+
+ more_horiz
+ {this.renderOptionsMenu()}
+
+
+ );
+ }
+}
+
+Entry.propTypes = {
+ entry: React.PropTypes.object.isRequired,
+ styleOptions: React.PropTypes.object.isRequired,
+ selected: React.PropTypes.bool.isRequired,
+ editing: React.PropTypes.bool.isRequired,
+ onClick: React.PropTypes.func.isRequired,
+ onEdit: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/style-picker/index.jsx b/lib/components/style-picker/index.jsx
new file mode 100644
index 000000000..d3ba33f50
--- /dev/null
+++ b/lib/components/style-picker/index.jsx
@@ -0,0 +1,126 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import styles from '../../styles';
+import Entry from './entry';
+import Edit from './edit';
+import {Events} from 'backbone';
+import merge from 'lodash.merge';
+import GeminiScrollbar from 'react-gemini-scrollbar';
+
+export default class StylePicker extends Component {
+ getInitialState () {
+ styles.fetch();
+ return {
+ styles: styles.getEntriesByType(this.props.type),
+ styleOptions: styles.getStyleOptionsByType(this.props.type)
+ };
+ }
+
+ componentWillReceiveProps (nextProps) {
+ if (this.props.type !== nextProps.type) {
+ this.setState({
+ styles: styles.getEntriesByType(nextProps.type),
+ styleOptions: styles.getStyleOptionsByType(nextProps.type)
+ });
+ }
+ }
+
+ componentDidMount () {
+ this.listenTo(styles, 'update', this.updatedStyles.bind(this));
+ }
+
+ comoponentWillUnmount () {
+ this.stopListening();
+ }
+
+ updatedStyles () {
+ this.setState({
+ styles: styles.getEntriesByType(this.props.type)
+ });
+ }
+
+ onClick (id) {
+ this.props.onChange(id);
+ }
+
+ onAddNewClick (event) {
+ event.preventDefault();
+ this.setState({
+ editing: true,
+ editingValue: false
+ });
+ }
+
+ onEditEntry (id) {
+ this.setState({
+ editing: true,
+ editingValue: id
+ });
+ }
+
+ onEditClose () {
+ this.setState({
+ editing: false,
+ editingValue: false
+ });
+ }
+
+ renderEdit () {
+ if (this.state.editing) {
+ return ;
+ }
+ }
+
+ renderEntry (entry) {
+ return (
+
+ );
+ }
+
+ renderEntries () {
+ if (this.state.styles.length > 0) {
+ return this.state.styles.map(this.renderEntry, this);
+ } else {
+ return (
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+
+
+ {this.renderEntries()}
+
+
+
+ add_circle_outline
+ Add new style
+
+ {this.renderEdit()}
+
+ );
+ }
+}
+
+merge(StylePicker.prototype, Events);
+
+StylePicker.propTypes = {
+ type: React.PropTypes.string.isRequired,
+ value: React.PropTypes.any.isRequired,
+ onChange: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/style/edit.jsx b/lib/components/style/edit.jsx
new file mode 100644
index 000000000..93e982d1f
--- /dev/null
+++ b/lib/components/style/edit.jsx
@@ -0,0 +1,71 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import Input from '../input';
+
+import stylesActions from '../../client/actions/style';
+
+export default class Edit extends Component {
+ getInitialState () {
+ return {
+ editing: this.props.editing
+ };
+ }
+
+ submit (event) {
+ event.preventDefault();
+
+ if (this.state.editing._id) {
+ stylesActions
+ .update(this.state.editing)
+ .then(() => this.props.onClose());
+ } else {
+ stylesActions
+ .add(this.state.editing)
+ .then(() => this.props.onClose());
+ }
+ }
+
+ cancel (event) {
+ event.preventDefault();
+ this.props.onClose();
+ }
+
+ onValueChange (id, value) {
+ this.state.editing.options[id] = value;
+ this.setState({
+ editing: this.state.editing
+ });
+ this.props.onChange(this.state.editing);
+ }
+
+ onTitleChange (value) {
+ this.state.editing.title = value;
+ this.setState({
+ editing: this.state.editing
+ });
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+Edit.propTypes = {
+ OptionsList: React.PropTypes.any.isRequired,
+ options: React.PropTypes.array.isRequired,
+ onClose: React.PropTypes.func.isRequired,
+ onChange: React.PropTypes.func.isRequired,
+ editing: React.PropTypes.object.isRequired
+};
diff --git a/lib/components/style/entry.jsx b/lib/components/style/entry.jsx
new file mode 100644
index 000000000..9216367fb
--- /dev/null
+++ b/lib/components/style/entry.jsx
@@ -0,0 +1,108 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import OptionsMenu from '../options-menu';
+import cloneDeep from 'lodash.clonedeep';
+
+import stylesActions from '../../client/actions/style';
+
+export default class StyleEntry extends Component {
+ getInitialState () {
+ return {
+ options: false
+ };
+ }
+
+ onMouseEnter () {
+ this.props.onMouseEnter(this.props.style);
+ }
+
+ onMouseLeave () {
+ if (!this.props.selected) {
+ this.props.onMouseLeave();
+ }
+ if (this.state.options) {
+ this.setState({
+ options: false
+ });
+ }
+ }
+
+ onClick (event) {
+ event.preventDefault();
+ this.props.onSelect(this.props.style);
+ }
+
+ duplicate (event) {
+ event.preventDefault();
+ var style = cloneDeep(this.props.style);
+ delete style._id;
+ stylesActions.add(style);
+ }
+
+ remove (event) {
+ event.preventDefault();
+ stylesActions.remove(this.props.style._id);
+ }
+
+ openOptions (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.setState({
+ options: true
+ });
+ }
+
+ editClick (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ this.props.onEdit(this.props.style);
+ }
+
+ renderOptionsMenu () {
+ if (this.state.options) {
+ return (
+
+ );
+ }
+ }
+
+ render (style) {
+ var props = {
+ className: 'style-entry',
+ href: '#',
+ onMouseLeave: this.onMouseLeave.bind(this)
+ };
+
+ if (this.props.selected) {
+ props.className += ' selected';
+ } else {
+ props.onMouseEnter = this.onMouseEnter.bind(this);
+ props.onClick = this.onClick.bind(this);
+ }
+
+ return (
+
+ {this.props.style.title}
+
+
+
+
+
+ {this.renderOptionsMenu()}
+
+
+ );
+ }
+}
+
+StyleEntry.propTypes = {
+ selected: React.PropTypes.bool.isRequired,
+ style: React.PropTypes.object.isRequired,
+ onMouseEnter: React.PropTypes.func.isRequired,
+ onMouseLeave: React.PropTypes.func.isRequired,
+ onSelect: React.PropTypes.func.isRequired,
+ onEdit: React.PropTypes.func.isRequired
+};
diff --git a/lib/components/style/index.jsx b/lib/components/style/index.jsx
new file mode 100644
index 000000000..04b60874a
--- /dev/null
+++ b/lib/components/style/index.jsx
@@ -0,0 +1,197 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import clone from 'lodash.clone';
+import cloneDeep from 'lodash.clonedeep';
+import Input from '../input';
+import Entry from './entry';
+import Edit from './edit';
+import styles from '../../styles';
+
+import stylesActions from '../../client/actions/style';
+
+export default class StylePicker extends Component {
+ getInitialState () {
+ this.onMouseEnterBind = this.onMouseEnter.bind(this);
+ this.onMouseLeaveBind = this.onMouseLeave.bind(this);
+ this.onSelectBind = this.onSelect.bind(this);
+ this.onEditBind = this.onEdit.bind(this);
+
+ return {
+ edit: false,
+ editing: {},
+ styles: styles.getByType(this.props.type),
+ selected: this.props.value,
+ element: this.context.selected
+ };
+ }
+
+ componentDidMount () {
+ this.updatedStylesBind = this.updatedStyles.bind(this);
+ styles.on('update', this.updatedStylesBind);
+ }
+
+ comoponentWillUnmount () {
+ styles.off('update', this.updatedStylesBind);
+ }
+
+ updatedStyles () {
+ this.setState({
+ styles: styles.getByType(this.props.type)
+ });
+ }
+
+ componentDidUpdate (prevProps, prevState) {
+ if (this.context.selected.id !== this.state.element.id) {
+ this.setState({
+ element: this.context.selected,
+ selected: this.props.value
+ });
+
+ if (prevProps.type !== this.props.type) {
+ this.setCollections(this.getInitialCollections());
+ }
+ }
+ }
+
+ addNewClick (event) {
+ event.preventDefault();
+ var editing = {
+ title: 'New style',
+ type: this.props.type,
+ options: clone(this.props.defaults)
+ };
+ this.setState({
+ edit: true,
+ editing
+ });
+ this.props.onChange(editing);
+ }
+
+ onValueChange (id, value) {
+ this.state.editing.options[id] = value;
+ this.setState({
+ editing: this.state.editing
+ });
+ this.props.onChange(this.state.editing);
+ }
+
+ onTitleChange (value) {
+ this.state.editing.title = value;
+ this.setState({
+ editing: this.state.editing
+ });
+ }
+
+ onEdit (style) {
+ this.setState({
+ edit: true,
+ editing: cloneDeep(style)
+ });
+ this.props.onChange(style);
+ }
+
+ submitStyle (event) {
+ event.preventDefault();
+
+ if (this.state.editing._id) {
+ stylesActions
+ .update(this.state.editing)
+ .then(() => {
+ this.setState({
+ edit: false,
+ editing: false
+ });
+ this.props.onChange(this.state.selected);
+ });
+ } else {
+ stylesActions
+ .add(this.state.editing)
+ .then(() => {
+ this.setState({
+ edit: false,
+ editing: false
+ });
+ this.props.onChange(this.state.selected);
+ });
+ }
+ }
+
+ cancel (event) {
+ event.preventDefault();
+ this.setState({
+ edit: false,
+ editing: false
+ });
+ this.props.onChange(this.state.selected);
+ }
+
+ onSelect (style) {
+ this.props.onChange(style._id);
+ this.setState({
+ selected: style._id
+ });
+ }
+
+ renderStyle (style) {
+ var props = {
+ selected: false,
+ style,
+ onMouseEnter: this.onMouseEnterBind,
+ onMouseLeave: this.onMouseLeaveBind,
+ onSelect: this.onSelectBind,
+ onEdit: this.onEditBind,
+ key: style._id
+ };
+
+ if (this.props.value === style._id || this.state.selected === style._id) {
+ props.selected = true;
+ }
+
+ return (
+
+ );
+ }
+
+ renderContent () {
+ if (this.state.edit) {
+ return (
+
+ );
+ } else {
+ return (
+
+
+ {this.state.styles.map(this.renderStyle, this)}
+
+
Add new style
+
+ );
+ }
+ }
+
+ render () {
+ return (
+
+ {this.renderContent()}
+
+ );
+ }
+}
+
+StylePicker.contextTypes = {
+ selected: React.PropTypes.any.isRequired
+};
+
+StylePicker.propTypes = {
+ OptionsList: React.PropTypes.any.isRequired,
+ onChange: React.PropTypes.func.isRequired,
+ value: React.PropTypes.string.isRequired,
+ type: React.PropTypes.string.isRequired,
+ options: React.PropTypes.array.isRequired
+};
diff --git a/lib/components/text.jsx b/lib/components/text.jsx
new file mode 100644
index 000000000..6a79ffefb
--- /dev/null
+++ b/lib/components/text.jsx
@@ -0,0 +1,37 @@
+import {Component} from 'relax-framework';
+import React from 'react';
+import Utils from '../utils';
+
+export default class Text extends Component {
+ getStyle () {
+ var style = {};
+
+ if (this.props.style && typeof this.props.style !== 'undefined' && this.props.style.options) {
+ var options = this.props.style.options;
+ style.fontSize = options.fontSize+'px';
+ style.lineHeight = options.lineHeight+'px';
+ style.color = options.color;
+
+ if (options.font && options.font.family && options.font.fvd) {
+ style.fontFamily = options.font.family;
+ Utils.processFVD(style, options.font.fvd);
+ }
+ }
+
+ return style;
+ }
+
+ render () {
+ var style = this.getStyle();
+
+ return (
+
+ {this.props.children}
+
+ );
+ }
+}
+
+Text.propTypes = {
+ style: React.PropTypes.object
+};
diff --git a/lib/components/title-slug.jsx b/lib/components/title-slug.jsx
new file mode 100644
index 000000000..e25bab791
--- /dev/null
+++ b/lib/components/title-slug.jsx
@@ -0,0 +1,82 @@
+import {Component} from 'relax-framework';
+import Input from './input';
+import React from 'react';
+import slugify from 'slug';
+
+export default class TitleSlug extends Component {
+ getInitialState () {
+ return {
+ slugValid: this.props.slug === '' ? false : true,
+ hasTypedSlug: false
+ };
+ }
+
+ componentWillUnmount () {
+ super.componentWillUnmount();
+ clearTimeout(this.slugTimeout);
+ }
+
+ onTitleChange (value) {
+ var title = value;
+ var slug = this.props.slug;
+
+ if (!this.state.hasTypedSlug) {
+ slug = slugify(title, {lower: true}).toLowerCase();
+ this.validateSlugTimeout();
+ }
+
+ this.props.onChange({title, slug});
+ this.setState({title, slug});
+ }
+
+ onSlugChange (value) {
+ var slug = value;
+
+ this.validateSlugTimeout();
+ this.setState({
+ slug,
+ hasTypedSlug: !slug ? false : true
+ });
+
+ this.props.onChange({title: this.props.title, slug});
+ }
+
+ validateSlugTimeout () {
+ clearTimeout(this.slugTimeout);
+ this.slugTimeout = setTimeout(this.validateSlug.bind(this), 500);
+ }
+
+ validateSlug () {
+ if (this.props.slug) {
+ this.props
+ .validateSlug(this.props.slug)
+ .then((response) => {
+ this.setState({
+ slugValid: !response
+ });
+ });
+ }
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+TitleSlug.propTypes = {
+ validateSlug: React.PropTypes.func.isRequired,
+ onChange: React.PropTypes.func.isRequired,
+ title: React.PropTypes.string.isRequired,
+ slug: React.PropTypes.string.isRequired
+};
diff --git a/lib/components/upload.jsx b/lib/components/upload.jsx
new file mode 100644
index 000000000..21cbcdac7
--- /dev/null
+++ b/lib/components/upload.jsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import {Component} from 'relax-framework';
+import Dropzone from 'dropzone';
+
+export default class Upload extends Component {
+ componentDidMount () {
+ if(typeof document !== 'undefined'){
+ var options = {};
+ for (var opt in Dropzone.prototype.defaultOptions) {
+ var prop = this.props[opt];
+ if (prop) {
+ options[opt] = prop;
+ continue;
+ }
+ options[opt] = Dropzone.prototype.defaultOptions[opt];
+ }
+
+ this.dropzone = new Dropzone(React.findDOMNode(this), options);
+ }
+ }
+
+ componentWillUnmount () {
+ this.dropzone.destroy();
+ this.dropzone = null;
+ }
+
+ render () {
+ return (
+
+ );
+ }
+}
+
+Upload.propTypes = {
+ action: React.PropTypes.string.isRequired,
+ acceptedFiles: React.PropTypes.string,
+ success: React.PropTypes.func
+};
diff --git a/lib/displays.js b/lib/displays.js
new file mode 100644
index 000000000..4230c1426
--- /dev/null
+++ b/lib/displays.js
@@ -0,0 +1,5 @@
+export default {
+ desktop: 99999,
+ tablet: 911,
+ mobile: 479
+};
diff --git a/lib/icons.js b/lib/icons.js
new file mode 100644
index 000000000..16cb65ff6
--- /dev/null
+++ b/lib/icons.js
@@ -0,0 +1,614 @@
+export default [
+ {
+ family: 'FontAwesome',
+ icons: [
+ // web application icons
+ 'fa fa-adjust',
+ 'fa fa-adn',
+ 'fa fa-align-center',
+ 'fa fa-align-justify',
+ 'fa fa-align-left',
+ 'fa fa-align-right',
+ 'fa fa-ambulance',
+ 'fa fa-anchor',
+ 'fa fa-android',
+ 'fa fa-angellist',
+ 'fa fa-angle-double-down',
+ 'fa fa-angle-double-left',
+ 'fa fa-angle-double-right',
+ 'fa fa-angle-double-up',
+ 'fa fa-apple',
+ 'fa fa-archive',
+ 'fa fa-area-chart',
+ 'fa fa-arrow-circle-down',
+ 'fa fa-arrow-circle-left',
+ 'fa fa-arrow-circle-o-down',
+ 'fa fa-arrow-circle-up',
+ 'fa fa-arrow-down',
+ 'fa fa-arrow-left',
+ 'fa fa-arrow-right',
+ 'fa fa-arrow-up',
+ 'fa fa-arrows',
+ 'fa fa-arrows-alt',
+ 'fa fa-arrows-h',
+ 'fa fa-arrows-v',
+ 'fa fa-asterisk',
+ 'fa fa-at',
+ 'fa fa-automobile',
+ 'fa fa-backward',
+ 'fa fa-ban',
+ 'fa fa-bank',
+ 'fa fa-bar-chart',
+ 'fa fa-barcode',
+ 'fa fa-bars',
+ 'fa fa-bed',
+ 'fa fa-beer',
+ 'fa fa-behance',
+ 'fa fa-behance-square',
+ 'fa fa-bell',
+ 'fa fa-bell-o',
+ 'fa fa-bell-slash',
+ 'fa fa-bell-slash-o',
+ 'fa fa-bicycle',
+ 'fa fa-binoculars',
+ 'fa fa-birthday-cake',
+ 'fa fa-bitbucket',
+ 'fa fa-bitbucket-square',
+ 'fa fa-bitcoin',
+ 'fa fa-bold',
+ 'fa fa-bolt',
+ 'fa fa-bomb',
+ 'fa fa-book',
+ 'fa fa-bookmark',
+ 'fa fa-bookmark-o',
+ 'fa fa-briefcase',
+ 'fa fa-bug',
+ 'fa fa-building',
+ 'fa fa-building-o',
+ 'fa fa-bullhorn',
+ 'fa fa-bullseye',
+ 'fa fa-bus',
+ 'fa fa-buysellads',
+ 'fa fa-cab',
+ 'fa fa-calculator',
+ 'fa fa-calendar',
+ 'fa fa-calendar-o',
+ 'fa fa-camera',
+ 'fa fa-camera-retro',
+ 'fa fa-car',
+ 'fa fa-caret-down',
+ 'fa fa-caret-left',
+ 'fa fa-caret-right',
+ 'fa fa-caret-square-o-down',
+ 'fa fa-caret-square-o-left',
+ 'fa fa-caret-square-o-right',
+ 'fa fa-caret-square-o-up',
+ 'fa fa-caret-up',
+ 'fa fa-cart-arrow-down',
+ 'fa fa-cart-plus',
+ 'fa fa-cc',
+ 'fa fa-cc-amex',
+ 'fa fa-cc-discover',
+ 'fa fa-cc-mastercard',
+ 'fa fa-cc-paypal',
+ 'fa fa-cc-stripe',
+ 'fa fa-cc-visa',
+ 'fa fa-certificate',
+ 'fa fa-chain',
+ 'fa fa-chain-broken',
+ 'fa fa-check',
+ 'fa fa-check-circle',
+ 'fa fa-check-circle-o',
+ 'fa fa-check-square',
+ 'fa fa-check-square-o',
+ 'fa fa-chevron-circle-down',
+ 'fa fa-chevron-circle-left',
+ 'fa fa-chevron-circle-right',
+ 'fa fa-chevron-circle-up',
+ 'fa fa-chevron-down',
+ 'fa fa-chevron-left',
+ 'fa fa-chevron-right',
+ 'fa fa-chevron-up',
+ 'fa fa-child',
+ 'fa fa-circle',
+ 'fa fa-circle-o',
+ 'fa fa-circle-o-notch',
+ 'fa fa-circle-thin',
+ 'fa fa-clipboard',
+ 'fa fa-clock-o',
+ 'fa fa-close',
+ 'fa fa-cloud',
+ 'fa fa-cloud-download',
+ 'fa fa-cloud-upload',
+ 'fa fa-cny',
+ 'fa fa-code',
+ 'fa fa-code-fork',
+ 'fa fa-codepen',
+ 'fa fa-coffee',
+ 'fa fa-cog',
+ 'fa fa-cogs',
+ 'fa fa-columns',
+ 'fa fa-comment',
+ 'fa fa-comment-o',
+ 'fa fa-comments',
+ 'fa fa-comments-o',
+ 'fa fa-compass',
+ 'fa fa-compress',
+ 'fa fa-connectdevelop',
+ 'fa fa-copy',
+ 'fa fa-copyright',
+ 'fa fa-credit-card',
+ 'fa fa-crop',
+ 'fa fa-crosshairs',
+ 'fa fa-css3',
+ 'fa fa-cube',
+ 'fa fa-cubes',
+ 'fa fa-cut',
+ 'fa fa-cutlery',
+ 'fa fa-dashboard',
+ 'fa fa-dashcube',
+ 'fa fa-database',
+ 'fa fa-delicious',
+ 'fa fa-desktop',
+ 'fa fa-deviantart',
+ 'fa fa-diamond',
+ 'fa fa-digg',
+ 'fa fa-dollar',
+ 'fa fa-dot-circle-o',
+ 'fa fa-download',
+ 'fa fa-dribbble',
+ 'fa fa-dropbox',
+ 'fa fa-drupal',
+ 'fa fa-edit',
+ 'fa fa-eject',
+ 'fa fa-ellipsis-h',
+ 'fa fa-ellipsis-v',
+ 'fa fa-empire',
+ 'fa fa-envelope',
+ 'fa fa-envelope-o',
+ 'fa fa-envelope-square',
+ 'fa fa-eraser',
+ 'fa fa-euro',
+ 'fa fa-exchange',
+ 'fa fa-exclamation',
+ 'fa fa-exclamation-circle',
+ 'fa fa-exclamation-triangle',
+ 'fa fa-expand',
+ 'fa fa-external-link',
+ 'fa fa-external-link-square',
+ 'fa fa-eye',
+ 'fa fa-eye-slash',
+ 'fa fa-eyedropper',
+ 'fa fa-facebook',
+ 'fa fa-facebook-official',
+ 'fa fa-facebook-square',
+ 'fa fa-fast-backward',
+ 'fa fa-fast-forward',
+ 'fa fa-fax',
+ 'fa fa-female',
+ 'fa fa-fighter-jet',
+ 'fa fa-file',
+ 'fa fa-file-archive-o',
+ 'fa fa-file-audio-o',
+ 'fa fa-file-code-o',
+ 'fa fa-file-excel-o',
+ 'fa fa-file-image-o',
+ 'fa fa-file-movie-o',
+ 'fa fa-file-pdf-o',
+ 'fa fa-file-photo-o',
+ 'fa fa-file-picture-o',
+ 'fa fa-file-powerpoint-o',
+ 'fa fa-file-sound-o',
+ 'fa fa-file-text',
+ 'fa fa-file-text-o',
+ 'fa fa-file-video-o',
+ 'fa fa-file-word-o',
+ 'fa fa-file-zip-o',
+ 'fa fa-files-o',
+ 'fa fa-film',
+ 'fa fa-filter',
+ 'fa fa-fire',
+ 'fa fa-fire-extinguisher',
+ 'fa fa-flag',
+ 'fa fa-flag-checkered',
+ 'fa fa-flag-o',
+ 'fa fa-flash',
+ 'fa fa-flask',
+ 'fa fa-flickr',
+ 'fa fa-floppy-o',
+ 'fa fa-folder',
+ 'fa fa-folder-o',
+ 'fa fa-folder-open',
+ 'fa fa-folder-open-o',
+ 'fa fa-font',
+ 'fa fa-forumbee',
+ 'fa fa-forward',
+ 'fa fa-foursquare',
+ 'fa fa-frown-o',
+ 'fa fa-futbol-o',
+ 'fa fa-gamepad',
+ 'fa fa-gavel',
+ 'fa fa-gbp',
+ 'fa fa-gear',
+ 'fa fa-gears',
+ 'fa fa-genderless',
+ 'fa fa-gift',
+ 'fa fa-git',
+ 'fa fa-git-square',
+ 'fa fa-github',
+ 'fa fa-github-alt',
+ 'fa fa-github-square',
+ 'fa fa-gittip',
+ 'fa fa-glass',
+ 'fa fa-globe',
+ 'fa fa-google',
+ 'fa fa-google-plus',
+ 'fa fa-google-plus-square',
+ 'fa fa-google-wallet',
+ 'fa fa-graduation-cap',
+ 'fa fa-gratipay',
+ 'fa fa-group',
+ 'fa fa-h-square',
+ 'fa fa-hacker-news',
+ 'fa fa-hand-o-down',
+ 'fa fa-hand-o-left',
+ 'fa fa-hand-o-right',
+ 'fa fa-hand-o-up',
+ 'fa fa-hdd-o',
+ 'fa fa-header',
+ 'fa fa-headphones',
+ 'fa fa-heart',
+ 'fa fa-heart-o',
+ 'fa fa-heartbeat',
+ 'fa fa-history',
+ 'fa fa-home',
+ 'fa fa-hospital-o',
+ 'fa fa-hotel',
+ 'fa fa-html5',
+ 'fa fa-ils',
+ 'fa fa-image',
+ 'fa fa-inbox',
+ 'fa fa-indent',
+ 'fa fa-info',
+ 'fa fa-info-circle',
+ 'fa fa-inr',
+ 'fa fa-instagram',
+ 'fa fa-institution',
+ 'fa fa-ioxhost',
+ 'fa fa-italic',
+ 'fa fa-joomla',
+ 'fa fa-jpy',
+ 'fa fa-jsfiddle',
+ 'fa fa-key',
+ 'fa fa-keyboard-o',
+ 'fa fa-krw',
+ 'fa fa-language',
+ 'fa fa-laptop',
+ 'fa fa-lastfm',
+ 'fa fa-lastfm-square',
+ 'fa fa-leaf',
+ 'fa fa-leanpub',
+ 'fa fa-legal',
+ 'fa fa-lemon-o',
+ 'fa fa-level-down',
+ 'fa fa-level-up',
+ 'fa fa-life-bouy',
+ 'fa fa-lightbulb-o',
+ 'fa fa-line-chart',
+ 'fa fa-link',
+ 'fa fa-linkedin',
+ 'fa fa-linkedin-square',
+ 'fa fa-linux',
+ 'fa fa-list',
+ 'fa fa-list-alt',
+ 'fa fa-list-ol',
+ 'fa fa-list-ul',
+ 'fa fa-location-arrow',
+ 'fa fa-lock',
+ 'fa fa-long-arrow-down',
+ 'fa fa-long-arrow-left',
+ 'fa fa-long-arrow-right',
+ 'fa fa-long-arrow-up',
+ 'fa fa-magic',
+ 'fa fa-magnet',
+ 'fa fa-mail-forward',
+ 'fa fa-mail-reply',
+ 'fa fa-mail-reply-all',
+ 'fa fa-male',
+ 'fa fa-map-marker',
+ 'fa fa-mars',
+ 'fa fa-mars-double',
+ 'fa fa-mars-stroke',
+ 'fa fa-mars-stroke-h',
+ 'fa fa-mars-stroke-v',
+ 'fa fa-maxcdn',
+ 'fa fa-meanpath',
+ 'fa fa-medium',
+ 'fa fa-medkit',
+ 'fa fa-meh-o',
+ 'fa fa-mercury',
+ 'fa fa-microphone',
+ 'fa fa-microphone-slash',
+ 'fa fa-minus',
+ 'fa fa-minus-circle',
+ 'fa fa-minus-square',
+ 'fa fa-minus-square-o',
+ 'fa fa-mobile',
+ 'fa fa-money',
+ 'fa fa-moon-o',
+ 'fa fa-motorcycle',
+ 'fa fa-music',
+ 'fa fa-neuter',
+ 'fa fa-newspaper-o',
+ 'fa fa-openid',
+ 'fa fa-outdent',
+ 'fa fa-pagelines',
+ 'fa fa-paint-brush',
+ 'fa fa-paper-plane',
+ 'fa fa-paper-plane-o',
+ 'fa fa-paperclip',
+ 'fa fa-paragraph',
+ 'fa fa-paste',
+ 'fa fa-pause',
+ 'fa fa-paw',
+ 'fa fa-paypal',
+ 'fa fa-pencil',
+ 'fa fa-pencil-square',
+ 'fa fa-pencil-square-o',
+ 'fa fa-phone',
+ 'fa fa-phone-square',
+ 'fa fa-picture-o',
+ 'fa fa-pie-chart',
+ 'fa fa-pied-piper',
+ 'fa fa-pied-piper-alt',
+ 'fa fa-pinterest',
+ 'fa fa-pinterest-p',
+ 'fa fa-pinterest-square',
+ 'fa fa-plane',
+ 'fa fa-play',
+ 'fa fa-play-circle',
+ 'fa fa-play-circle-o',
+ 'fa fa-plug',
+ 'fa fa-plus',
+ 'fa fa-plus-circle',
+ 'fa fa-plus-square',
+ 'fa fa-plus-square-o',
+ 'fa fa-power-off',
+ 'fa fa-print',
+ 'fa fa-puzzle-piece',
+ 'fa fa-qq',
+ 'fa fa-qrcode',
+ 'fa fa-question',
+ 'fa fa-question-circle',
+ 'fa fa-quote-left',
+ 'fa fa-quote-right',
+ 'fa fa-random',
+ 'fa fa-rebel',
+ 'fa fa-recycle',
+ 'fa fa-reddit',
+ 'fa fa-reddit-square',
+ 'fa fa-refresh',
+ 'fa fa-remove',
+ 'fa fa-renren',
+ 'fa fa-repeat',
+ 'fa fa-reply',
+ 'fa fa-reply-all',
+ 'fa fa-retweet',
+ 'fa fa-road',
+ 'fa fa-rocket',
+ 'fa fa-rouble',
+ 'fa fa-rss',
+ 'fa fa-rss-square',
+ 'fa fa-rub',
+ 'fa fa-ruble',
+ 'fa fa-rupee',
+ 'fa fa-save',
+ 'fa fa-scissors',
+ 'fa fa-search',
+ 'fa fa-search-minus',
+ 'fa fa-search-plus',
+ 'fa fa-sellsy',
+ 'fa fa-server',
+ 'fa fa-share',
+ 'fa fa-share-alt',
+ 'fa fa-share-alt-square',
+ 'fa fa-share-square',
+ 'fa fa-share-square-o',
+ 'fa fa-shekel',
+ 'fa fa-shield',
+ 'fa fa-ship',
+ 'fa fa-shirtsinbulk',
+ 'fa fa-shopping-cart',
+ 'fa fa-sign-in',
+ 'fa fa-sign-out',
+ 'fa fa-signal',
+ 'fa fa-simplybuilt',
+ 'fa fa-sitemap',
+ 'fa fa-skyatlas',
+ 'fa fa-skype',
+ 'fa fa-slack',
+ 'fa fa-sliders',
+ 'fa fa-slideshare',
+ 'fa fa-smile-o',
+ 'fa fa-sort',
+ 'fa fa-sort-alpha-asc',
+ 'fa fa-sort-alpha-desc',
+ 'fa fa-sort-amount-asc',
+ 'fa fa-sort-amount-desc',
+ 'fa fa-sort-asc',
+ 'fa fa-sort-desc',
+ 'fa fa-sort-numeric-asc',
+ 'fa fa-sort-numeric-desc',
+ 'fa fa-soundcloud',
+ 'fa fa-space-shuttle',
+ 'fa fa-spinner',
+ 'fa fa-spoon',
+ 'fa fa-spotify',
+ 'fa fa-square',
+ 'fa fa-square-o',
+ 'fa fa-stack-exchange',
+ 'fa fa-stack-overflow',
+ 'fa fa-star',
+ 'fa fa-star-half',
+ 'fa fa-star-half-o',
+ 'fa fa-star-o',
+ 'fa fa-steam',
+ 'fa fa-steam-square',
+ 'fa fa-step-backward',
+ 'fa fa-step-forward',
+ 'fa fa-stethoscope',
+ 'fa fa-stop',
+ 'fa fa-street-view',
+ 'fa fa-strikethrough',
+ 'fa fa-stumbleupon',
+ 'fa fa-stumbleupon-circle',
+ 'fa fa-subscript',
+ 'fa fa-subway',
+ 'fa fa-suitcase',
+ 'fa fa-sun-o',
+ 'fa fa-superscript',
+ 'fa fa-support',
+ 'fa fa-table',
+ 'fa fa-tablet',
+ 'fa fa-tachometer',
+ 'fa fa-tag',
+ 'fa fa-tags',
+ 'fa fa-tasks',
+ 'fa fa-taxi',
+ 'fa fa-tencent-weibo',
+ 'fa fa-terminal',
+ 'fa fa-text-height',
+ 'fa fa-text-width',
+ 'fa fa-th',
+ 'fa fa-th-large',
+ 'fa fa-th-list',
+ 'fa fa-thumb-tack',
+ 'fa fa-thumbs-down',
+ 'fa fa-thumbs-o-down',
+ 'fa fa-thumbs-o-up',
+ 'fa fa-thumbs-up',
+ 'fa fa-ticket',
+ 'fa fa-times',
+ 'fa fa-times-circle',
+ 'fa fa-times-circle-o',
+ 'fa fa-tint',
+ 'fa fa-toggle-off',
+ 'fa fa-toggle-on',
+ 'fa fa-train',
+ 'fa fa-transgender',
+ 'fa fa-transgender-alt',
+ 'fa fa-trash',
+ 'fa fa-trash-o',
+ 'fa fa-tree',
+ 'fa fa-trello',
+ 'fa fa-trophy',
+ 'fa fa-truck',
+ 'fa fa-try',
+ 'fa fa-tty',
+ 'fa fa-tumblr',
+ 'fa fa-tumblr-square',
+ 'fa fa-turkish-lira',
+ 'fa fa-twitch',
+ 'fa fa-twitter',
+ 'fa fa-twitter-square',
+ 'fa fa-umbrella',
+ 'fa fa-underline',
+ 'fa fa-undo',
+ 'fa fa-university',
+ 'fa fa-unlock',
+ 'fa fa-unlock-alt',
+ 'fa fa-unsorted',
+ 'fa fa-upload',
+ 'fa fa-usd',
+ 'fa fa-user',
+ 'fa fa-user-md',
+ 'fa fa-user-plus',
+ 'fa fa-user-secret',
+ 'fa fa-user-times',
+ 'fa fa-users',
+ 'fa fa-venus',
+ 'fa fa-venus-double',
+ 'fa fa-venus-mars',
+ 'fa fa-viacoin',
+ 'fa fa-video-camera',
+ 'fa fa-vimeo-square',
+ 'fa fa-vine',
+ 'fa fa-vk',
+ 'fa fa-volume-down',
+ 'fa fa-volume-off',
+ 'fa fa-volume-up',
+ 'fa fa-warning',
+ 'fa fa-wechat',
+ 'fa fa-weibo',
+ 'fa fa-weixin',
+ 'fa fa-whatsapp',
+ 'fa fa-wheelchair',
+ 'fa fa-wifi',
+ 'fa fa-windows',
+ 'fa fa-won',
+ 'fa fa-wordpress',
+ 'fa fa-wrench',
+ 'fa fa-xing',
+ 'fa fa-xing-square',
+ 'fa fa-yahoo',
+ 'fa fa-yelp',
+ 'fa fa-youtube',
+ 'fa fa-youtube-play',
+ 'fa fa-youtube-square'
+ ]
+ },
+ {
+ family: 'Linecons',
+ class: '',
+ icons: [
+ 'flaticon-banknote',
+ 'flaticon-big58',
+ 'flaticon-big59',
+ 'flaticon-big60',
+ 'flaticon-blockade',
+ 'flaticon-bubble1',
+ 'flaticon-camera6',
+ 'flaticon-camera7',
+ 'flaticon-cup4',
+ 'flaticon-data3',
+ 'flaticon-diamons',
+ 'flaticon-display14',
+ 'flaticon-fire',
+ 'flaticon-heart3',
+ 'flaticon-lab',
+ 'flaticon-leaf5',
+ 'flaticon-like',
+ 'flaticon-location4',
+ 'flaticon-news',
+ 'flaticon-noodle',
+ 'flaticon-note2',
+ 'flaticon-packet',
+ 'flaticon-paperclip2',
+ 'flaticon-paperplane',
+ 'flaticon-parameters',
+ 'flaticon-pen3',
+ 'flaticon-phone12',
+ 'flaticon-photo3',
+ 'flaticon-search3',
+ 'flaticon-see',
+ 'flaticon-settings3',
+ 'flaticon-shop1',
+ 'flaticon-small56',
+ 'flaticon-small57',
+ 'flaticon-small58',
+ 'flaticon-small59',
+ 'flaticon-small60',
+ 'flaticon-small61',
+ 'flaticon-sound',
+ 'flaticon-stack',
+ 'flaticon-study',
+ 'flaticon-t1',
+ 'flaticon-tag6',
+ 'flaticon-tv1',
+ 'flaticon-user12',
+ 'flaticon-vynil',
+ 'flaticon-wallet',
+ 'flaticon-world5'
+ ]
+ }
+];
diff --git a/lib/logger.js b/lib/logger.js
new file mode 100644
index 000000000..2c45769ef
--- /dev/null
+++ b/lib/logger.js
@@ -0,0 +1,13 @@
+import winston from 'winston';
+
+export default new winston.Logger({
+ transports: [
+ new winston.transports.Console({
+ level: 'debug',
+ handleExceptions: true,
+ json: false,
+ colorize: true
+ })
+ ],
+ exitOnError: false
+});
diff --git a/lib/react-jss/index.js b/lib/react-jss/index.js
new file mode 100644
index 000000000..008ce36e1
--- /dev/null
+++ b/lib/react-jss/index.js
@@ -0,0 +1,49 @@
+import forEach from 'lodash.foreach';
+import merge from 'lodash.merge';
+import RulesSet from './rules-set';
+import {Events} from 'backbone';
+
+class StyleSheet {
+ constructor () {
+ this._rulesSets = [];
+ }
+
+ createRules (rules) {
+ var rulesSet = this.createRulesGet(rules);
+ return rulesSet.getRulesMap();
+ }
+
+ createRulesGet (rules) {
+ var rulesSet = new RulesSet(rules);
+ this._rulesSets.push(rulesSet);
+
+ rulesSet.on('remove', this.onRemove.bind(this, rulesSet.id));
+
+ return rulesSet;
+ }
+
+ onRemove (id) {
+ forEach(this._rulesSets, (rulesSet, key) => {
+ if (rulesSet.id === id) {
+ this._rulesSets.splice(key, 1);
+ }
+ });
+ }
+
+ update () {
+ this.trigger('update');
+ }
+
+ toString () {
+ var css = '';
+
+ forEach(this._rulesSets, (ruleSet) => {
+ css += ruleSet.toString();
+ });
+
+ return css;
+ }
+}
+merge(StyleSheet.prototype, Events);
+
+export default new StyleSheet();
diff --git a/lib/react-jss/jss-react.jsx b/lib/react-jss/jss-react.jsx
new file mode 100644
index 000000000..0c4021423
--- /dev/null
+++ b/lib/react-jss/jss-react.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import jss from './index';
+
+export default class JSSReact extends React.Component {
+ componentDidMount () {
+ jss.on('update', this.onUpdate.bind(this));
+ this.onUpdate();
+ }
+
+ onUpdate () {
+ this.forceUpdate();
+ }
+
+ render () {
+ return ;
+ }
+}
diff --git a/lib/react-jss/rule.js b/lib/react-jss/rule.js
new file mode 100644
index 000000000..6772cbe30
--- /dev/null
+++ b/lib/react-jss/rule.js
@@ -0,0 +1,73 @@
+import forEach from 'lodash.foreach';
+
+var PREFIX = '_';
+var COUNTER = 0;
+var pxBlacklist = [
+ 'boxFlex',
+ 'boxFlexGroup',
+ 'columnCount',
+ 'fillOpacity',
+ 'flex',
+ 'flexGrow',
+ 'flexPositive',
+ 'flexShrink',
+ 'flexNegative',
+ 'fontWeight',
+ 'lineClamp',
+ 'lineHeight',
+ 'opacity',
+ 'order',
+ 'orphans',
+ 'strokeOpacity',
+ 'widows',
+ 'zIndex',
+ 'zoom'
+];
+
+export default class Rule {
+ constructor (ruleName, styles, id) {
+ this._name = ruleName;
+ this._id = PREFIX + id;
+ this._styles = styles;
+
+ return this._id;
+ }
+
+ stylesToString (styles, selector = '') {
+ var css = selector;
+ css += '{';
+
+ var followingCss = '';
+
+ forEach(styles, (value, key) => {
+ var type = typeof value;
+
+ if (type === 'object') {
+ var newSelector = selector;
+
+ if (key.charAt(0) !== ':') {
+ newSelector += ' ';
+ }
+ newSelector += key;
+
+ followingCss += this.stylesToString(value, newSelector);
+ } else if (value !== false && value !== null) {
+ var cssProperty = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
+
+ if (type === 'number' && pxBlacklist.indexOf(key) === -1) {
+ value = value+'px';
+ }
+
+ css += cssProperty+':'+value+';';
+ }
+ });
+
+ css += '}';
+
+ return css+followingCss;
+ }
+
+ toString () {
+ return this.stylesToString(this._styles, '.'+this._id);
+ }
+}
diff --git a/lib/react-jss/rules-set.js b/lib/react-jss/rules-set.js
new file mode 100644
index 000000000..3559487ce
--- /dev/null
+++ b/lib/react-jss/rules-set.js
@@ -0,0 +1,43 @@
+import forEach from 'lodash.foreach';
+import Rule from './rule';
+import merge from 'lodash.merge';
+import {Events} from 'backbone';
+
+var RULESET_ID = 0;
+
+export default class RulesSet {
+ constructor (rules) {
+ this.id = (RULESET_ID++).toString(16);
+ this.update(rules);
+ }
+
+ update (rules) {
+ this._rules = [];
+ this._map = {};
+ var count = 0;
+ forEach(rules, (styles, ruleName) => {
+ var rule = new Rule(ruleName, styles, (this.id + (count++).toString(16)));
+ this._rules.push(rule);
+ this._map[ruleName] = rule._id;
+ });
+ }
+
+ remove () {
+ this.trigger('remove');
+ }
+
+ getRulesMap () {
+ return this._map;
+ }
+
+ toString () {
+ var css = '';
+
+ forEach(this._rules, (rule) => {
+ css += rule.toString();
+ });
+
+ return css;
+ }
+}
+merge(RulesSet.prototype, Events);
diff --git a/lib/server/index.js b/lib/server/index.js
new file mode 100644
index 000000000..9b8d30034
--- /dev/null
+++ b/lib/server/index.js
@@ -0,0 +1,165 @@
+import bodyParser from 'body-parser';
+import express from 'express';
+import morgan from 'morgan';
+import mongoose from 'mongoose';
+import path from 'path';
+import passport from 'passport';
+import reactEngine from 'express-react-engine';
+import routers from './routers';
+import './stores';
+import settingsStore from './stores/settings';
+import forEach from 'lodash.foreach';
+import session from 'express-session';
+import connectMongo from 'connect-mongo';
+
+var app = express();
+
+app.use(morgan('short'));
+app.use(bodyParser.urlencoded({extended: false}));
+app.use(bodyParser.json({limit: 100000000}));
+
+// View engine
+app.set('views', path.join(__dirname, '../components'));
+app.engine('jsx', reactEngine({wrapper: 'html.jsx'}));
+
+// session
+var MongoStore = connectMongo(session);
+app.use(session({
+ secret: 'Is very secret',
+ store: new MongoStore({mongooseConnection: mongoose.connection})
+}));
+
+// Passport
+app.use(passport.initialize());
+app.use(passport.session());
+
+// Static files
+app.use('/', express.static(path.join(__dirname, '../../public')));
+
+app.use((req, res, next) => {
+ settingsStore
+ .findAll()
+ .then((settings) => {
+ settings = settingsStore.parseSettings(settings);
+
+ // header
+ res.locals.header = [
+ {
+ tag: 'title',
+ content: settings.title
+ },
+ {
+ tag: 'link',
+ props: {
+ rel: 'stylesheet',
+ type: 'text/css',
+ href: '/css/main.css'
+ }
+ },
+ {
+ tag: 'link',
+ props: {
+ rel: 'stylesheet',
+ type: 'text/css',
+ href: '//maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css'
+ }
+ },
+ {
+ tag: 'script',
+ props: {
+ src: '//ajax.googleapis.com/ajax/libs/webfont/1.5.10/webfont.js'
+ }
+ },
+ {
+ tag: 'link',
+ props: {
+ href: 'https://fonts.googleapis.com/icon?family=Material+Icons',
+ rel: 'stylesheet'
+ }
+ }
+ ];
+
+
+ // Custom fonts
+ if(settings.fonts && settings.fonts.customFonts){
+ const customFonts = settings.fonts.customFonts;
+
+ var css = '';
+ forEach(customFonts, (customFont) => {
+ const family = customFont.family;
+ const map = customFont.files;
+ const location = '/fonts/'+customFont.id+'/';
+
+ css += '@font-face {';
+ css += 'font-family: "'+family+'";';
+ css += 'src: url("'+location+map.eot+'"); ';
+ css += 'src: ';
+
+ // try woff2
+ if(map.woff2){
+ css += 'url("'+location+map.woff2+'"), ';
+ }
+
+ css += 'url("'+location+map.woff+'"), ';
+ css += 'url("'+location+map.ttf+'"); ';
+
+ css += '}';
+ });
+
+ res.locals.header.push({
+ tag: 'style',
+ props: {
+ type: 'text/css'
+ },
+ content: css
+ });
+
+ if(settings.fonts.webfontloader){
+ res.locals.header.push({
+ tag: 'script',
+ content: 'WebFont.load('+JSON.stringify(settings.fonts.webfontloader)+');'
+ });
+ }
+ }
+
+ // footer
+ res.locals.footer = [{
+ tag: 'script',
+ props: {
+ src: '/js/common.js'
+ }
+ }];
+
+ next();
+ })
+ .catch(() => {
+ res.status(404).end();
+ });
+});
+
+(function iterateRouters (routers) {
+ for (let key in routers) {
+ if (Object.getPrototypeOf(routers[key]).route) {
+ app.use('/', routers[key]);
+ } else {
+ iterateRouters(routers[key]);
+ }
+ }
+})(routers);
+
+app.use((req, res, next) => {
+ res.status(404).end();
+});
+
+app.use((error, req, res) => {
+ var statusCode = error.statusCode || 500;
+ var err = {
+ error: statusCode,
+ message: error.message
+ };
+ if (!res.headersSent) {
+ res.status(statusCode).send(err);
+ }
+});
+
+export default app;
diff --git a/lib/server/models/color.js b/lib/server/models/color.js
new file mode 100644
index 000000000..264517766
--- /dev/null
+++ b/lib/server/models/color.js
@@ -0,0 +1,14 @@
+import mongoose from 'mongoose';
+
+var colorSchema = new mongoose.Schema({
+ label: {
+ type: String,
+ required: true
+ },
+ value: {
+ type: mongoose.Schema.Types.Mixed,
+ required: true
+ }
+});
+
+export default mongoose.model('Color', colorSchema);
diff --git a/lib/server/models/media.js b/lib/server/models/media.js
new file mode 100644
index 000000000..0209c3f88
--- /dev/null
+++ b/lib/server/models/media.js
@@ -0,0 +1,55 @@
+import mongoose from 'mongoose';
+
+var mediaSchema = new mongoose.Schema({
+ name: {
+ type: String,
+ required: true
+ },
+ fileName: {
+ type: String,
+ required: true
+ },
+ type: {
+ type: String,
+ required: true
+ },
+ size: {
+ type: String,
+ required: true
+ },
+ filesize: {
+ type: Number,
+ required: true
+ },
+ dimension: {
+ width: {
+ type: Number,
+ required: true
+ },
+ height: {
+ type: Number,
+ required: true
+ }
+ },
+ url: {
+ type: String,
+ required: true,
+ unique: true
+ },
+ absoluteUrl: {
+ type: String,
+ required: true,
+ unique: true
+ },
+ date: {
+ type: Date,
+ default: Date.now
+ },
+ thumbnail: {
+ type: String,
+ required: true
+ },
+ variations: []
+});
+
+export default mongoose.model('Media', mediaSchema);
diff --git a/lib/server/models/page.js b/lib/server/models/page.js
new file mode 100644
index 000000000..7840ad6f4
--- /dev/null
+++ b/lib/server/models/page.js
@@ -0,0 +1,37 @@
+import mongoose from 'mongoose';
+import tabsStore from '../stores/tabs';
+
+var pageSchema = new mongoose.Schema({
+ title: {
+ type: String,
+ required: true
+ },
+ slug: {
+ type: String,
+ required: true,
+ unique: true
+ },
+ state: {
+ type: String,
+ default: 'draft'
+ },
+ date: {
+ type: Date,
+ default: Date.now
+ },
+ data: {
+ type: Array
+ },
+ actions: {
+ type: Array,
+ default: []
+ }
+});
+
+pageSchema.post('remove', (page) => {
+ tabsStore.removeMultiple({
+ pageId: page._id
+ });
+});
+
+export default mongoose.model('Page', pageSchema);
diff --git a/lib/server/models/schema-entry.js b/lib/server/models/schema-entry.js
new file mode 100644
index 000000000..891ce5d7f
--- /dev/null
+++ b/lib/server/models/schema-entry.js
@@ -0,0 +1,44 @@
+import mongoose from 'mongoose';
+import forEach from 'lodash.foreach';
+import {TypesNative} from '../../types';
+
+var models = {};
+
+export default (schema) => {
+ if(models[schema.slug]){
+ return models[schema.slug];
+ }
+ else {
+ var mongooseSchema = {
+ title: {
+ type: String,
+ required: true
+ },
+ slug: {
+ type: String,
+ required: true,
+ unique: true
+ },
+ date: {
+ type: Date,
+ default: Date.now
+ }
+ };
+
+ forEach(schema.fields, (field) => {
+ if(TypesNative[field.type]){
+ let native = TypesNative[field.type];
+
+ mongooseSchema[field.id] = {
+ type: native,
+ required: field.isRequired
+ };
+ }
+ });
+
+ var model = mongoose.model(schema.slug, new mongoose.Schema(mongooseSchema, { collection: schema.slug }));
+
+ models[schema.slug] = model;
+ return model;
+ }
+};
diff --git a/lib/server/models/schema.js b/lib/server/models/schema.js
new file mode 100644
index 000000000..f473df0bf
--- /dev/null
+++ b/lib/server/models/schema.js
@@ -0,0 +1,31 @@
+import mongoose from 'mongoose';
+
+var schema = new mongoose.Schema({
+ title: {
+ type: String,
+ required: true
+ },
+ slug: {
+ type: String,
+ required: true,
+ unique: true
+ },
+ fields: [
+ {
+ id: {
+ type: String,
+ required: true
+ },
+ type: {
+ type: String,
+ required: true
+ },
+ required: {
+ type: Boolean,
+ required: true
+ }
+ }
+ ]
+});
+
+export default mongoose.model('Schema', schema);
diff --git a/lib/server/models/setting.js b/lib/server/models/setting.js
new file mode 100644
index 000000000..09bbced13
--- /dev/null
+++ b/lib/server/models/setting.js
@@ -0,0 +1,13 @@
+import mongoose from 'mongoose';
+
+var settingSchema = new mongoose.Schema({
+ _id: {
+ type: String
+ },
+ value: {
+ type: mongoose.Schema.Types.Mixed,
+ required: true
+ }
+});
+
+export default mongoose.model('Setting', settingSchema);
diff --git a/lib/server/models/style.js b/lib/server/models/style.js
new file mode 100644
index 000000000..68f6771d8
--- /dev/null
+++ b/lib/server/models/style.js
@@ -0,0 +1,18 @@
+import mongoose from 'mongoose';
+
+var styleSchema = new mongoose.Schema({
+ type: {
+ type: String,
+ required: true
+ },
+ title: {
+ type: String,
+ required: true
+ },
+ options: {
+ type: mongoose.Schema.Types.Mixed,
+ required: true
+ }
+});
+
+export default mongoose.model('Style', styleSchema);
diff --git a/lib/server/models/tab.js b/lib/server/models/tab.js
new file mode 100644
index 000000000..d0569f5e5
--- /dev/null
+++ b/lib/server/models/tab.js
@@ -0,0 +1,14 @@
+import mongoose from 'mongoose';
+
+var tabSchema = new mongoose.Schema({
+ pageId: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'Page'
+ },
+ userId: {
+ type: mongoose.Schema.Types.ObjectId,
+ ref: 'User'
+ }
+});
+
+export default mongoose.model('Tab', tabSchema);
diff --git a/lib/server/models/user.js b/lib/server/models/user.js
new file mode 100644
index 000000000..b221238b7
--- /dev/null
+++ b/lib/server/models/user.js
@@ -0,0 +1,39 @@
+import mongoose from 'mongoose';
+import passport from 'passport';
+import passportLocalMongoose from 'passport-local-mongoose';
+import {Strategy} from 'passport-local';
+
+var userSchema = new mongoose.Schema({
+ username: {
+ type: String,
+ required: true,
+ unique: true
+ },
+ name: {
+ type: String,
+ required: true
+ },
+ password: {
+ type: String
+ },
+ email: {
+ type: String,
+ unique: true,
+ trim: true,
+ required: true
+ },
+ date: {
+ type: Date,
+ default: Date.now
+ }
+});
+
+userSchema.plugin(passportLocalMongoose);
+
+var UserModel = mongoose.model('User', userSchema);
+
+passport.use(new Strategy(UserModel.authenticate()));
+passport.serializeUser(UserModel.serializeUser());
+passport.deserializeUser(UserModel.deserializeUser());
+
+export default UserModel;
diff --git a/lib/server/routers/admin.js b/lib/server/routers/admin.js
new file mode 100644
index 000000000..7464e6957
--- /dev/null
+++ b/lib/server/routers/admin.js
@@ -0,0 +1,263 @@
+import {Router} from 'express';
+import Q from 'q';
+
+import usersStore from '../stores/users';
+import pagesStore from '../stores/pages';
+import mediaStore from '../stores/media';
+import settingsStore from '../stores/settings';
+import colorsStore from '../stores/colors';
+import schemasStore from '../stores/schemas';
+import elementsStore from '../stores/elements';
+import schemaEntriesStoreFactory from '../stores/schema-entries';
+import tabsStore from '../stores/tabs';
+
+import generalSettingsIds from '../../settings/general';
+import fontSettingsIds from '../../settings/fonts';
+
+var adminRouter = new Router();
+
+
+// Restrict from here onwards
+adminRouter.use('/admin*', (req, res, next) => {
+ if (req.isAuthenticated()){
+ res.locals.footer.push({
+ tag: 'script',
+ props: {
+ src: '/js/admin.js'
+ }
+ });
+ res.locals.user = req.user;
+ res.locals.tabs = [];
+
+ tabsStore
+ .findAll({
+ query: {
+ userId: req.user._id
+ },
+ populate: {
+ path: 'pageId',
+ select: 'title slug'
+ }
+ })
+ .then((tabs) => {
+ res.locals.tabs = tabs;
+ })
+ .fin(() => {
+ return next();
+ });
+ } else {
+ res.redirect('/admin/login');
+ }
+});
+
+// Logout
+adminRouter.get('/admin/logout', (req, res) => {
+ req.logout();
+ res.redirect('/admin/login');
+});
+
+adminRouter.get('/admin', (req, res, next) => {
+ settingsStore
+ .findByIds(generalSettingsIds)
+ .then((settings) => {
+ res.render('admin/index.jsx', {
+ activePanelType: 'settings',
+ settings
+ });
+ })
+ .catch(next);
+});
+
+adminRouter.get('/admin/pages', (req, res, next) => {
+ pagesStore
+ .findAll(req.query)
+ .then((pages) => {
+ res.render('admin/index.jsx', {
+ activePanelType: 'pages',
+ pages: pages,
+ query: req.query
+ });
+ })
+ .catch(next);
+});
+
+adminRouter.get('/admin/media', (req, res, next) => {
+ mediaStore
+ .findAll(req.query)
+ .then((media) => {
+ res.render('admin/index.jsx', {
+ activePanelType: 'media',
+ media: media,
+ query: req.query
+ });
+ })
+ .catch(next);
+});
+
+adminRouter.get('/admin/fonts', (req, res, next) => {
+ settingsStore
+ .findByIds(fontSettingsIds)
+ .then((settings) => {
+ res.render('admin/index.jsx', {
+ activePanelType: 'fonts',
+ settings
+ });
+ })
+ .catch(next);
+});
+
+adminRouter.get('/admin/colors', (req, res, next) => {
+ colorsStore
+ .findAll()
+ .then((colors) => {
+ res.render('admin/index.jsx', {
+ activePanelType: 'colors',
+ colors
+ });
+ })
+ .catch(next);
+});
+
+
+
+adminRouter.get('/admin/schemas/new', (req, res, next) => {
+ res.render('admin/index.jsx', {
+ activePanelType: 'schemasNew',
+ breadcrumbs: [
+ {
+ label: 'Schemas',
+ type: 'schemas',
+ link: '/admin/schemas'
+ },
+ {
+ label: 'New'
+ }
+ ]
+ });
+});
+
+adminRouter.get('/admin/schemas/:slug/:entrySlug', (req, res, next) => {
+ schemaEntriesStoreFactory(req.params.slug)
+ .then((schemaEntriesStore) => Q.all([
+ schemaEntriesStore.findBySlug(req.params.entrySlug),
+ schemasStore.findBySlug(req.params.slug)
+ ]))
+ .spread((schemaEntry, schema) => {
+ res.render('admin/index.jsx', {
+ activePanelType: 'schemaEntry',
+ schemaEntry,
+ schema,
+ breadcrumbs: [
+ {
+ label: 'Schemas',
+ type: 'schemas',
+ link: '/admin/schemas'
+ },
+ {
+ label: schema.title,
+ link: '/admin/schemas/'+schema.slug
+ },
+ {
+ label: schemaEntry.title
+ }
+ ]
+ });
+ })
+ .catch(next);
+});
+
+adminRouter.get('/admin/schemas/:slug', (req, res, next) => {
+ schemaEntriesStoreFactory(req.params.slug)
+ .then((schemaEntriesStore) => Q.all([
+ schemaEntriesStore.findAll(req.query),
+ schemasStore.findBySlug(req.params.slug)
+ ]))
+ .spread((schemaEntries, schema) => {
+ res.render('admin/index.jsx', {
+ activePanelType: 'schema',
+ schemaEntries,
+ schema,
+ query: req.query,
+ breadcrumbs: [
+ {
+ label: 'Schemas',
+ type: 'schemas',
+ link: '/admin/schemas'
+ },
+ {
+ label: schema.title
+ }
+ ]
+ });
+ })
+ .catch(next);
+});
+
+adminRouter.get('/admin/schemas', (req, res, next) => {
+ schemasStore
+ .findAll(req.query)
+ .then((schemas) => {
+ res.render('admin/index.jsx', {
+ activePanelType: 'schemas',
+ schemas,
+ query: req.query
+ });
+ })
+ .catch(next);
+});
+
+adminRouter.get('/admin/users', (req, res, next) => {
+ usersStore
+ .findAll(req.query)
+ .then((users) => {
+ res.render('admin/index.jsx', {
+ activePanelType: 'users',
+ users: users,
+ query: req.query
+ });
+ })
+ .catch(next);
+});
+
+adminRouter.get('/admin/users/:username', (req, res, next) => {
+ usersStore
+ .findOne({username: req.params.username})
+ .then((editUser) => {
+ res.render('admin/index.jsx', {
+ activePanelType: 'userEdit',
+ editUser,
+ breadcrumbs: [
+ {
+ label: 'Users',
+ type: 'users',
+ link: '/admin/users'
+ },
+ {
+ label: editUser.name
+ }
+ ]
+ });
+ })
+ .catch(next);
+});
+
+adminRouter.get('/admin/page/:slug', (req, res, next) => {
+ Q
+ .all([
+ pagesStore.findBySlug(req.params.slug),
+ elementsStore.findAll(),
+ colorsStore.findAll()
+ ])
+ .spread((page, elements, colors) => {
+ res.render('admin/index.jsx', {
+ activePanelType: 'page',
+ elements,
+ page,
+ colors
+ });
+ })
+ .catch(next);
+
+});
+
+export default adminRouter;
diff --git a/lib/server/routers/api/color.js b/lib/server/routers/api/color.js
new file mode 100644
index 000000000..017ae7c36
--- /dev/null
+++ b/lib/server/routers/api/color.js
@@ -0,0 +1,68 @@
+import {Router} from 'express';
+import colorsStore from '../../stores/colors';
+
+var colorApiRouter = new Router();
+
+colorApiRouter.get('/api/color', (req, res, next) => {
+ colorsStore
+ .findAll()
+ .then((colors) => {
+ res.status(200).send(colors);
+ })
+ .catch(next);
+});
+
+colorApiRouter.get('/api/color/:id', (req, res, next) => {
+ var colorId = req.params.id;
+
+ colorsStore
+ .findById(colorId)
+ .then((color) => {
+ res.status(200).send(color);
+ })
+ .catch(next);
+});
+
+colorApiRouter.get('/api/color/slug/:slug', (req, res, next) => {
+ var slug = req.params.slug;
+
+ colorsStore
+ .count({slug: slug})
+ .then((count) => {
+ res.status(200).send({count});
+ })
+ .catch(next);
+});
+
+colorApiRouter.post('/api/color', (req, res, next) => {
+ colorsStore
+ .add(req.body)
+ .then((color) => {
+ res.status(200).send(color);
+ })
+ .catch(next);
+});
+
+colorApiRouter.put('/api/color/:id', (req, res, next) => {
+ var colorId = req.params.id;
+
+ colorsStore
+ .update(colorId, req.body)
+ .then((color) => {
+ res.status(200).send(color);
+ })
+ .catch(next);
+});
+
+colorApiRouter.delete('/api/color/:id', (req, res, next) => {
+ var colorId = req.params.id;
+
+ colorsStore
+ .remove(colorId)
+ .then((color) => {
+ res.status(200).send(color);
+ })
+ .catch(next);
+});
+
+export default colorApiRouter;
diff --git a/lib/server/routers/api/element.js b/lib/server/routers/api/element.js
new file mode 100644
index 000000000..9f01bc678
--- /dev/null
+++ b/lib/server/routers/api/element.js
@@ -0,0 +1,15 @@
+import {Router} from 'express';
+import elementsStore from '../../stores/elements';
+
+var elementApiRouter = new Router();
+
+elementApiRouter.get('/api/element', (req, res, next) => {
+ elementsStore
+ .findAll()
+ .then((elements) => {
+ res.status(200).send(elements);
+ })
+ .catch(next);
+});
+
+export default elementApiRouter;
diff --git a/lib/server/routers/api/fonts.js b/lib/server/routers/api/fonts.js
new file mode 100644
index 000000000..6003b90e9
--- /dev/null
+++ b/lib/server/routers/api/fonts.js
@@ -0,0 +1,74 @@
+import {Router} from 'express';
+import multer from 'multer';
+import forEach from 'lodash.foreach';
+import mongoose from 'mongoose';
+import mkdirp from 'mkdirp';
+import path from 'path';
+import fs from 'fs';
+import Q from 'q';
+import logger from '../../../logger';
+import rmdir from 'rimraf';
+
+var fontsApiRouter = new Router();
+
+function fontUploaded(file, req, res){
+ res.status(200).json(file).end();
+}
+
+fontsApiRouter.post('/api/fonts/upload', [multer({
+ dest: './uploads/',
+ onFileUploadComplete: fontUploaded
+}), (req, res, next) => {}]);
+
+
+fontsApiRouter.post('/api/fonts/submit', (req, res, next) => {
+ var files = JSON.parse(req.body.data);
+ var promises = [];
+ var id = mongoose.Types.ObjectId().toString();
+
+ var root = path.join(__dirname, '../../../..');
+ var fontsFolder = path.join(root, 'public/fonts', id);
+
+ return Q
+ .nfcall(mkdirp, fontsFolder)
+ .then(() => {
+ forEach(files, (file) => {
+ let name = file.name;
+ let info = file.info;
+
+ promises.push(
+ Q.ninvoke(fs, 'rename', path.join(root, info.path), path.join(fontsFolder, name))
+ );
+ });
+
+ return Q.all(promises);
+ })
+ .then(() => {
+ res.status(200).send(id);
+ })
+ .catch((error) => {
+ logger.debug('Error submiting font: ', error);
+ res.status(500).end();
+ });
+});
+
+
+fontsApiRouter.post('/api/fonts/remove', (req, res, next) => {
+ var id = req.body.id;
+
+ var root = path.join(__dirname, '../../../..');
+ var fontsFolder = path.join(root, 'public/fonts', id);
+
+ return Q
+ .nfcall(rmdir, fontsFolder)
+ .then(() => {
+ res.status(200).end();
+ })
+ .catch((error) => {
+ logger.debug('Error removing font: ', error);
+ res.status(500).end();
+ });
+});
+
+
+export default fontsApiRouter;
diff --git a/lib/server/routers/api/media.js b/lib/server/routers/api/media.js
new file mode 100644
index 000000000..8d41a03bd
--- /dev/null
+++ b/lib/server/routers/api/media.js
@@ -0,0 +1,235 @@
+import {Router} from 'express';
+import mediaStore from '../../stores/media';
+import multer from 'multer';
+import filesize from 'file-size';
+import fs from 'fs';
+import sharp from 'sharp';
+import Q from 'q';
+import path from 'path';
+import mkdirp from 'mkdirp';
+import mongoose from 'mongoose';
+import logger from '../../../logger';
+import rmdir from 'rimraf';
+import forEach from 'lodash.foreach';
+
+var mediaApiRouter = new Router();
+
+function fileUploaded(file, req, res){
+ var id = mongoose.Types.ObjectId();
+ var idString = id.toString();
+
+ var folder = path.join(__dirname, '../../../../public/images', idString);
+ var relativePath = path.join('/images', idString, '/');
+
+ var absoluteUrl = path.join(folder, file.originalname);
+ var relativeUrl = relativePath+file.originalname;
+
+ Q
+ .nfcall(mkdirp, folder)
+ .then(() => {
+ return Q.ninvoke(fs, 'rename', file.path, absoluteUrl);
+ })
+ .then(() => {
+ return Q.fcall(sharp, absoluteUrl);
+ })
+ .then((image) => {
+ return Q.all([
+ image,
+ Q.ninvoke(image, 'metadata')
+ ]);
+ })
+ .spread((image, metadata) => {
+ return Q.all([
+ metadata,
+ Q.ninvoke(image.quality(100).resize(100, 100), 'toFile', folder+'/thumbnail.'+metadata.format)
+ ]);
+ })
+ .spread((metadata, thumbnailInfo) => {
+ let mediaItem = {
+ _id: id,
+ name: file.originalname,
+ fileName: file.originalname,
+ type: file.mimetype,
+ size: filesize(file.size).human(),
+ filesize: file.size,
+ dimension: {
+ width: metadata.width,
+ height: metadata.height
+ },
+ url: relativeUrl,
+ absoluteUrl: absoluteUrl,
+ thumbnail: relativePath+'thumbnail.'+metadata.format,
+ variations: []
+ };
+
+ return mediaStore.add(mediaItem);
+ })
+ .then((mediaItem) => {
+ res.status(200).send(mediaItem);
+ })
+ .catch((err) => {
+ logger.debug('Error uploading media: ', err);
+ res.status(500).send('An error occurred while uploading the image');
+ });
+}
+
+function getOrCreateVariation (options) {
+ const mediaId = options.id;
+ const width = options.width;
+ const height = options.height;
+
+ const folder = path.join(__dirname, '../../../../public/images', mediaId);
+ const relativePath = path.join('/images', mediaId);
+
+ return Q()
+ .then(() => mediaStore.findById(mediaId))
+ .then((media) => {
+ var originalRatio = media.dimension.width / media.dimension.height;
+ var resultWidth = Math.ceil(width/100) * 100;
+ var resultHeight = resultWidth / originalRatio;
+
+ if (resultHeight < height) {
+ resultHeight = Math.ceil(height/100) * 100;
+ resultWidth = resultHeight * originalRatio;
+ }
+
+ resultWidth = Math.round(resultWidth);
+ resultHeight = Math.round(resultHeight);
+
+ var filename = resultWidth+'x'+resultHeight+'-'+media.fileName;
+ var filePath = path.join(folder, filename);
+
+ // Check if variation already exists
+ var variation = false;
+ forEach(media.variations, (_variation) => {
+ if (_variation.dimension.width === resultWidth && _variation.dimension.height === resultHeight) {
+ variation = _variation;
+ return false;
+ }
+ });
+
+ if (variation !== false) {
+ return {
+ media,
+ variation
+ };
+ }
+
+ // Create
+ return Q()
+ .then(() => Q.fcall(sharp, media.absoluteUrl))
+ .then((image) => Q.ninvoke(image.quality(100).resize(resultWidth, resultHeight), 'toFile', filePath))
+ .then((info) => {
+ info = info[0];
+
+ variation = {
+ url: path.join(relativePath, filename),
+ absoluteUrl: path.join(folder, filename),
+ dimension: {
+ width: info.width,
+ height: info.height
+ }
+ };
+
+ media.variations.push(variation);
+
+ return mediaStore.update(mediaId, media);
+ })
+ .then((media) => {
+ return {
+ media,
+ variation
+ };
+ });
+ });
+}
+
+mediaApiRouter.post('/api/media/upload', [multer({
+ dest: './uploads/',
+ onFileUploadComplete: fileUploaded
+}), (req, res, next) => {}]);
+
+mediaApiRouter.get('/api/media', (req, res, next) => {
+ mediaStore
+ .findAll(req.query)
+ .then((media) => {
+ res.status(200).send(media);
+ })
+ .catch(next);
+});
+
+mediaApiRouter.get('/api/media/resized/:id', (req, res, next) => {
+ var id = req.params.id;
+ var width = parseInt(req.query.width, 10);
+ var height = parseInt(req.query.height, 10);
+
+ getOrCreateVariation({
+ id,
+ width,
+ height
+ })
+ .then((info) => {
+ res.sendFile(info.variation.absoluteUrl);
+ })
+ .catch(next);
+});
+
+mediaApiRouter.get('/api/media/resize', (req, res, next) => {
+ var id = req.query.id;
+ var width = parseInt(req.query.width, 10);
+ var height = parseInt(req.query.height, 10);
+
+ getOrCreateVariation({
+ id,
+ width,
+ height
+ })
+ .then((info) => {
+ res.status(200).send(info.media);
+ })
+ .catch(next);
+});
+
+mediaApiRouter.get('/api/media/:id', (req, res, next) => {
+ var mediaId = req.params.id;
+
+ mediaStore
+ .findById(mediaId)
+ .then((media) => {
+ res.status(200).send(media);
+ })
+ .catch(next);
+});
+
+mediaApiRouter.post('/api/media', (req, res, next) => {
+ mediaStore
+ .add(req.body)
+ .then((media) => {
+ res.status(200).send(media);
+ })
+ .catch(next);
+});
+
+mediaApiRouter.put('/api/media/:id', (req, res, next) => {
+ var mediaId = req.params.id;
+
+ mediaStore
+ .update(mediaId, req.body)
+ .then((media) => {
+ res.status(200).send(media);
+ })
+ .catch(next);
+});
+
+mediaApiRouter.delete('/api/media/:id', (req, res, next) => {
+ var mediaId = req.params.id;
+ var folder = path.join(__dirname, '../../../../public/images', mediaId);
+
+ Q
+ .nfcall(rmdir, folder)
+ .then(() => mediaStore.remove(mediaId))
+ .then((media) => res.status(200).send(media))
+ .catch(next);
+});
+
+export default mediaApiRouter;
diff --git a/lib/server/routers/api/page.js b/lib/server/routers/api/page.js
new file mode 100644
index 000000000..343ed11b8
--- /dev/null
+++ b/lib/server/routers/api/page.js
@@ -0,0 +1,68 @@
+import {Router} from 'express';
+import pagesStore from '../../stores/pages';
+
+var pageApiRouter = new Router();
+
+pageApiRouter.get('/api/page', (req, res, next) => {
+ pagesStore
+ .findAll(req.query)
+ .then((pages) => {
+ res.status(200).send(pages);
+ })
+ .catch(next);
+});
+
+pageApiRouter.get('/api/page/:id', (req, res, next) => {
+ var pageId = req.params.id;
+
+ pagesStore
+ .findById(pageId)
+ .then((page) => {
+ res.status(200).send(page);
+ })
+ .catch(next);
+});
+
+pageApiRouter.get('/api/page/slug/:slug', (req, res, next) => {
+ var slug = req.params.slug;
+
+ pagesStore
+ .count({slug: slug})
+ .then((count) => {
+ res.status(200).send({count});
+ })
+ .catch(next);
+});
+
+pageApiRouter.post('/api/page', (req, res, next) => {
+ pagesStore
+ .add(req.body)
+ .then((page) => {
+ res.status(200).send(page);
+ })
+ .catch(next);
+});
+
+pageApiRouter.put('/api/page/:id', (req, res, next) => {
+ var pageId = req.params.id;
+
+ pagesStore
+ .update(pageId, req.body)
+ .then((page) => {
+ res.status(200).send(page);
+ })
+ .catch(next);
+});
+
+pageApiRouter.delete('/api/page/:id', (req, res, next) => {
+ var pageId = req.params.id;
+
+ pagesStore
+ .remove(pageId)
+ .then((page) => {
+ res.status(200).send(page);
+ })
+ .catch(next);
+});
+
+export default pageApiRouter;
diff --git a/lib/server/routers/api/schema-entry.js b/lib/server/routers/api/schema-entry.js
new file mode 100644
index 000000000..c4f1c26bc
--- /dev/null
+++ b/lib/server/routers/api/schema-entry.js
@@ -0,0 +1,70 @@
+import {Router} from 'express';
+import schemaEntriesStoreFactory from '../../stores/schema-entries';
+
+var schemaEntryApiRouter = new Router();
+
+schemaEntryApiRouter.get('/api/schema-entry/:slug', (req, res, next) => {
+ schemaEntriesStoreFactory(req.params.slug)
+ .then((schemaEntryStore) => schemaEntryStore.findAll(req.query))
+ .then((schemaEntries) => {
+ res.status(200).send(schemaEntries);
+ })
+ .catch(next);
+});
+
+schemaEntryApiRouter.get('/api/schema-entry/:slug/:id', (req, res, next) => {
+ var schemaEntryId = req.params.id;
+ var slug = req.params.slug;
+
+ schemaEntriesStoreFactory(slug)
+ .then((schemaEntryStore) => schemaEntryStore.findById(schemaEntryId))
+ .then((schemaEntry) => {
+ res.status(200).send(schemaEntry);
+ })
+ .catch(next);
+});
+
+schemaEntryApiRouter.get('/api/schema-entry/:slug/slug/:entry', (req, res, next) => {
+ var entry = req.params.entry;
+ var slug = req.params.slug;
+
+ schemaEntriesStoreFactory(slug)
+ .then((schemaEntryStore) => schemaEntryStore.count({slug: entry}))
+ .then((count) => {
+ res.status(200).send({count});
+ })
+ .catch(next);
+});
+
+schemaEntryApiRouter.post('/api/schema-entry/:slug', (req, res, next) => {
+ schemaEntriesStoreFactory(req.params.slug)
+ .then((schemaEntryStore) => schemaEntryStore.add(req.body))
+ .then((schemaEntry) => {
+ res.status(200).send(schemaEntry);
+ })
+ .catch(next);
+});
+
+schemaEntryApiRouter.put('/api/schema-entry/:slug/:id', (req, res, next) => {
+ var schemaEntryId = req.params.id;
+
+ schemaEntriesStoreFactory(req.params.slug)
+ .then((schemaEntryStore) => schemaEntryStore.update(schemaEntryId, req.body))
+ .then((schemaEntry) => {
+ res.status(200).send(schemaEntry);
+ })
+ .catch(next);
+});
+
+schemaEntryApiRouter.delete('/api/schema-entry/:slug/:id', (req, res, next) => {
+ var schemaEntryId = req.params.id;
+
+ schemaEntriesStoreFactory(req.params.slug)
+ .then((schemaEntryStore) => schemaEntryStore.remove(schemaEntryId))
+ .then((schemaEntry) => {
+ res.status(200).send(schemaEntry);
+ })
+ .catch(next);
+});
+
+export default schemaEntryApiRouter;
diff --git a/lib/server/routers/api/schema.js b/lib/server/routers/api/schema.js
new file mode 100644
index 000000000..227fa0013
--- /dev/null
+++ b/lib/server/routers/api/schema.js
@@ -0,0 +1,68 @@
+import {Router} from 'express';
+import schemasStore from '../../stores/schemas';
+
+var schemaApiRouter = new Router();
+
+schemaApiRouter.get('/api/schema', (req, res, next) => {
+ schemasStore
+ .findAll(req.query)
+ .then((schemas) => {
+ res.status(200).send(schemas);
+ })
+ .catch(next);
+});
+
+schemaApiRouter.get('/api/schema/:id', (req, res, next) => {
+ var schemaId = req.params.id;
+
+ schemasStore
+ .findById(schemaId)
+ .then((schema) => {
+ res.status(200).send(schema);
+ })
+ .catch(next);
+});
+
+schemaApiRouter.get('/api/schema/slug/:slug', (req, res, next) => {
+ var slug = req.params.slug;
+
+ schemasStore
+ .count({slug: slug})
+ .then((count) => {
+ res.status(200).send({count});
+ })
+ .catch(next);
+});
+
+schemaApiRouter.post('/api/schema', (req, res, next) => {
+ schemasStore
+ .add(req.body)
+ .then((schema) => {
+ res.status(200).send(schema);
+ })
+ .catch(next);
+});
+
+schemaApiRouter.put('/api/schema/:id', (req, res, next) => {
+ var schemaId = req.params.id;
+
+ schemasStore
+ .update(schemaId, req.body)
+ .then((schema) => {
+ res.status(200).send(schema);
+ })
+ .catch(next);
+});
+
+schemaApiRouter.delete('/api/schema/:id', (req, res, next) => {
+ var schemaId = req.params.id;
+
+ schemasStore
+ .remove(schemaId)
+ .then((schema) => {
+ res.status(200).send(schema);
+ })
+ .catch(next);
+});
+
+export default schemaApiRouter;
diff --git a/lib/server/routers/api/session.js b/lib/server/routers/api/session.js
new file mode 100644
index 000000000..ff0da87fc
--- /dev/null
+++ b/lib/server/routers/api/session.js
@@ -0,0 +1,13 @@
+import {Router} from 'express';
+
+var sessionApiRouter = new Router();
+
+sessionApiRouter.get('/api/session', (req, res, next) => {
+ if (req.isAuthenticated()){
+ res.status(200).send(req.user);
+ } else {
+ res.status(400).end();
+ }
+});
+
+export default sessionApiRouter;
diff --git a/lib/server/routers/api/setting.js b/lib/server/routers/api/setting.js
new file mode 100644
index 000000000..e14039be6
--- /dev/null
+++ b/lib/server/routers/api/setting.js
@@ -0,0 +1,57 @@
+import {Router} from 'express';
+import settingsStore from '../../stores/settings';
+
+var settingApiRouter = new Router();
+
+settingApiRouter.get('/api/setting', (req, res, next) => {
+ settingsStore
+ .findByIds(JSON.parse(req.query.ids))
+ .then((settings) => {
+ res.status(200).send(settings);
+ })
+ .catch(next);
+});
+
+settingApiRouter.get('/api/setting/:id', (req, res, next) => {
+ var settingId = req.params.id;
+
+ settingsStore
+ .findById(settingId)
+ .then((setting) => {
+ res.status(200).send(setting);
+ })
+ .catch(next);
+});
+
+settingApiRouter.post('/api/setting', (req, res, next) => {
+ settingsStore
+ .add(req.body)
+ .then((setting) => {
+ res.status(200).send(setting);
+ })
+ .catch(next);
+});
+
+settingApiRouter.put('/api/setting/:id', (req, res, next) => {
+ var settingId = req.params.id;
+
+ settingsStore
+ .update(settingId, req.body)
+ .then((setting) => {
+ res.status(200).send(setting);
+ })
+ .catch(next);
+});
+
+settingApiRouter.delete('/api/setting/:name', (req, res, next) => {
+ var settingName = req.params.name;
+
+ settingsStore
+ .remove(settingName)
+ .then(() => {
+ res.status(200).end();
+ })
+ .catch(next);
+});
+
+export default settingApiRouter;
diff --git a/lib/server/routers/api/style.js b/lib/server/routers/api/style.js
new file mode 100644
index 000000000..6389eecf0
--- /dev/null
+++ b/lib/server/routers/api/style.js
@@ -0,0 +1,60 @@
+import {Router} from 'express';
+import stylesStore from '../../stores/styles';
+
+var styleApiRouter = new Router();
+
+styleApiRouter.get('/api/style', (req, res, next) => {
+ var options = {
+ query: req.query
+ };
+ stylesStore
+ .findAll(options)
+ .then((styles) => {
+ res.status(200).send(styles);
+ })
+ .catch(next);
+});
+
+styleApiRouter.get('/api/style/:id', (req, res, next) => {
+ var styleId = req.params.id;
+
+ stylesStore
+ .findById(styleId)
+ .then((style) => {
+ res.status(200).send(style);
+ })
+ .catch(next);
+});
+
+styleApiRouter.post('/api/style', (req, res, next) => {
+ stylesStore
+ .add(req.body)
+ .then((style) => {
+ res.status(200).send(style);
+ })
+ .catch(next);
+});
+
+styleApiRouter.put('/api/style/:id', (req, res, next) => {
+ var styleId = req.params.id;
+
+ stylesStore
+ .update(styleId, req.body)
+ .then((style) => {
+ res.status(200).send(style);
+ })
+ .catch(next);
+});
+
+styleApiRouter.delete('/api/style/:id', (req, res, next) => {
+ var styleId = req.params.id;
+
+ stylesStore
+ .remove(styleId)
+ .then((style) => {
+ res.status(200).send(style);
+ })
+ .catch(next);
+});
+
+export default styleApiRouter;
diff --git a/lib/server/routers/api/tab.js b/lib/server/routers/api/tab.js
new file mode 100644
index 000000000..701422f9d
--- /dev/null
+++ b/lib/server/routers/api/tab.js
@@ -0,0 +1,54 @@
+import {Router} from 'express';
+import tabsStore from '../../stores/tabs';
+
+var tabApiRouter = new Router();
+
+tabApiRouter.get('/api/tab/:userId', (req, res, next) => {
+ tabsStore
+ .findAll({
+ query: {
+ userId: req.params.userId
+ },
+ populate: {
+ path: 'pageId',
+ select: 'title slug'
+ }
+ })
+ .then((tabs) => {
+ res.status(200).send(tabs);
+ })
+ .catch(next);
+});
+
+tabApiRouter.post('/api/tab/:userId', (req, res, next) => {
+ tabsStore
+ .add(req.body)
+ .then((tab) => {
+ res.status(200).send(tab);
+ })
+ .catch(next);
+});
+
+tabApiRouter.put('/api/tab/:id', (req, res, next) => {
+ var tabId = req.params.id;
+
+ tabsStore
+ .update(tabId, req.body)
+ .then((tab) => {
+ res.status(200).send(tab);
+ })
+ .catch(next);
+});
+
+tabApiRouter.delete('/api/tab/:id', (req, res, next) => {
+ var tabId = req.params.id;
+
+ tabsStore
+ .remove(tabId)
+ .then((tab) => {
+ res.status(200).send(tab);
+ })
+ .catch(next);
+});
+
+export default tabApiRouter;
diff --git a/lib/server/routers/api/user.js b/lib/server/routers/api/user.js
new file mode 100644
index 000000000..b200b79ae
--- /dev/null
+++ b/lib/server/routers/api/user.js
@@ -0,0 +1,57 @@
+import {Router} from 'express';
+import usersStore from '../../stores/users';
+
+var userApiRouter = new Router();
+
+userApiRouter.get('/api/user', (req, res, next) => {
+ usersStore
+ .findAll(req.query)
+ .then((users) => {
+ res.status(200).send(users);
+ })
+ .catch(next);
+});
+
+userApiRouter.get('/api/user/:id', (req, res, next) => {
+ var userId = req.params.id;
+
+ usersStore
+ .findById(userId)
+ .then((user) => {
+ res.status(200).send(user);
+ })
+ .catch(next);
+});
+
+userApiRouter.post('/api/user', (req, res, next) => {
+ usersStore
+ .add(req.body)
+ .then((user) => {
+ res.status(200).send(user);
+ })
+ .catch(next);
+});
+
+userApiRouter.put('/api/user/:id', (req, res, next) => {
+ var userId = req.params.id;
+
+ usersStore
+ .update(userId, req.body)
+ .then((user) => {
+ res.status(200).send(user);
+ })
+ .catch(next);
+});
+
+userApiRouter.delete('/api/user/:id', (req, res, next) => {
+ var userId = req.params.id;
+
+ usersStore
+ .remove(userId)
+ .then((user) => {
+ res.status(200).send(user);
+ })
+ .catch(next);
+});
+
+export default userApiRouter;
diff --git a/lib/server/routers/auth.js b/lib/server/routers/auth.js
new file mode 100644
index 000000000..7f60d0c43
--- /dev/null
+++ b/lib/server/routers/auth.js
@@ -0,0 +1,50 @@
+import passport from 'passport';
+import {Router} from 'express';
+import Q from 'q';
+
+import usersStore from '../stores/users';
+
+var authRouter = new Router();
+
+function injectScript (req, res, next) {
+ res.locals.footer.push({
+ tag: 'script',
+ props: {
+ src: '/js/auth.js'
+ }
+ });
+ next();
+}
+
+// Register
+authRouter.get('/admin/init', injectScript, (req, res, next) => {
+ Q()
+ .then(() => usersStore.count())
+ .then((number) => {
+ if (number === 0) {
+ res.render('admin/init.jsx', {});
+ } else {
+ next();
+ }
+ })
+ .catch(next);
+});
+
+// Login
+authRouter.get('/admin/login', injectScript, (req, res) => {
+ if (req.isAuthenticated()){
+ res.redirect('/admin');
+ } else {
+ res.render('admin/login.jsx', {
+ user : req.user
+ });
+ }
+});
+
+authRouter.post('/admin/login', passport.authenticate('local'), (req, res) => {
+ res.redirect('/admin');
+});
+
+
+
+export default authRouter;
diff --git a/lib/server/routers/index.js b/lib/server/routers/index.js
new file mode 100644
index 000000000..4e5791b85
--- /dev/null
+++ b/lib/server/routers/index.js
@@ -0,0 +1,32 @@
+import auth from './auth';
+import admin from './admin';
+import publicRouter from './public';
+
+import apiPage from './api/page';
+import apiMedia from './api/media';
+import apiSetting from './api/setting';
+import apiFonts from './api/fonts';
+import apiColor from './api/color';
+import apiSchema from './api/schema';
+import apiSchemaEntry from './api/schema-entry';
+import apiStyle from './api/style';
+import apiUser from './api/user';
+import apiSession from './api/session';
+import apiTab from './api/tab';
+
+export default {
+ auth,
+ admin,
+ publicRouter,
+ apiPage,
+ apiMedia,
+ apiSetting,
+ apiFonts,
+ apiColor,
+ apiSchema,
+ apiSchemaEntry,
+ apiStyle,
+ apiUser,
+ apiSession,
+ apiTab
+};
diff --git a/lib/server/routers/public.js b/lib/server/routers/public.js
new file mode 100644
index 000000000..cb0cf786c
--- /dev/null
+++ b/lib/server/routers/public.js
@@ -0,0 +1,27 @@
+import {Router} from 'express';
+import {elements, pages} from '../stores';
+
+var frontendRouter = new Router();
+
+frontendRouter.get('/:slug', (req, res, next) => {
+ res.locals.footer.push({
+ tag: 'script',
+ props: {
+ src: '/js/public.js'
+ }
+ });
+
+ var page;
+ pages
+ .findBySlug(req.params.slug)
+ .then((_page) => {
+ page = _page;
+ return elements.findAll();
+ })
+ .then((elements) => {
+ res.render('page/index.jsx', {elements, page});
+ })
+ .catch(next);
+});
+
+export default frontendRouter;
diff --git a/lib/server/stores/colors.js b/lib/server/stores/colors.js
new file mode 100644
index 000000000..6fd59b073
--- /dev/null
+++ b/lib/server/stores/colors.js
@@ -0,0 +1,11 @@
+import {ServerStore} from 'relax-framework';
+import ColorModel from '../models/color';
+
+class ColorsStore extends ServerStore {
+ constructor () {
+ super();
+ this.Model = ColorModel;
+ }
+}
+
+export default new ColorsStore();
diff --git a/lib/server/stores/elements.js b/lib/server/stores/elements.js
new file mode 100644
index 000000000..23dd50c4e
--- /dev/null
+++ b/lib/server/stores/elements.js
@@ -0,0 +1,16 @@
+import {ServerStore} from 'relax-framework';
+import Q from 'q';
+import elements from '../../components/elements';
+
+class ElementsStore extends ServerStore {
+
+ findAll () {
+ return Q()
+ .then(() => {
+ return elements;
+ });
+ }
+
+}
+
+export default new ElementsStore();
diff --git a/lib/server/stores/index.js b/lib/server/stores/index.js
new file mode 100644
index 000000000..51d7ef1c6
--- /dev/null
+++ b/lib/server/stores/index.js
@@ -0,0 +1,19 @@
+import colors from './colors';
+import elements from './elements';
+import media from './media';
+import pages from './pages';
+import schemas from './schemas';
+import settings from './settings';
+import styles from './styles';
+import users from './users';
+
+export default {
+ colors,
+ elements,
+ media,
+ pages,
+ schemas,
+ settings,
+ styles,
+ users
+};
diff --git a/lib/server/stores/media.js b/lib/server/stores/media.js
new file mode 100644
index 000000000..eec1e770d
--- /dev/null
+++ b/lib/server/stores/media.js
@@ -0,0 +1,11 @@
+import {ServerStore} from 'relax-framework';
+import MediaModel from '../models/media';
+
+class MediaStore extends ServerStore {
+ constructor () {
+ super();
+ this.Model = MediaModel;
+ }
+}
+
+export default new MediaStore();
diff --git a/lib/server/stores/pages.js b/lib/server/stores/pages.js
new file mode 100644
index 000000000..e8a0baf9d
--- /dev/null
+++ b/lib/server/stores/pages.js
@@ -0,0 +1,15 @@
+import {ServerStore} from 'relax-framework';
+import PageModel from '../models/page';
+
+class PagesStore extends ServerStore {
+ constructor () {
+ super();
+ this.Model = PageModel;
+ }
+
+ findBySlug (slug) {
+ return this.findOne({slug});
+ }
+}
+
+export default new PagesStore();
diff --git a/lib/server/stores/schema-entries.js b/lib/server/stores/schema-entries.js
new file mode 100644
index 000000000..96884ad06
--- /dev/null
+++ b/lib/server/stores/schema-entries.js
@@ -0,0 +1,46 @@
+import {ServerStore} from 'relax-framework';
+import schemaEntriesModelFactory from '../models/schema-entry';
+import schemasStore from './schemas';
+import Q from 'q';
+import forEach from 'lodash.foreach';
+
+class SchemaEntriesStore extends ServerStore {
+ constructor (schema) {
+ super();
+ this.Model = schemaEntriesModelFactory(schema);
+ }
+
+ findBySlug (slug) {
+ return this.findOne({slug});
+ }
+}
+
+
+var schemaEntriesStores = {};
+var factory = function (slug) {
+ if(schemaEntriesStores[slug]){
+ return Q().then(() => schemaEntriesStores[slug]);
+ } else {
+ return Q()
+ .then(() => schemasStore.findBySlug(slug))
+ .then((schema) => {
+ var store = new SchemaEntriesStore(schema);
+ schemaEntriesStores[slug] = store;
+ return store;
+ })
+ .catch((error) => {
+ console.log(error);
+ });
+ }
+};
+
+schemasStore
+ .findAll()
+ .then((schemas) => {
+ forEach(schemas, (schema) => {
+ var store = new SchemaEntriesStore(schema);
+ schemaEntriesStores[schema.slug] = store;
+ });
+ });
+
+export default factory;
diff --git a/lib/server/stores/schemas.js b/lib/server/stores/schemas.js
new file mode 100644
index 000000000..7ff845c15
--- /dev/null
+++ b/lib/server/stores/schemas.js
@@ -0,0 +1,35 @@
+import {ServerStore} from 'relax-framework';
+import SchemaModel from '../models/schema';
+import Q from 'q';
+import mongoose from 'mongoose';
+
+class SchemasStore extends ServerStore {
+ constructor () {
+ super();
+ this.Model = SchemaModel;
+ }
+
+ findBySlug (slug) {
+ return this.findOne({slug});
+ }
+
+ remove (id) {
+ return Q()
+ .then(() => Q.ninvoke(SchemaModel, 'findByIdAndRemove', id))
+ .then((schema) => {
+ var schemaJson = schema.toJSON();
+ return Q.all([
+ schemaJson,
+ mongoose.connection.collections[schemaJson.slug] ? Q.ninvoke(mongoose.connection.collections[schemaJson.slug], 'drop') : false
+ ]);
+ })
+ .spread((schema, result) => {
+ return schema;
+ })
+ .catch((error) => {
+ throw new Error('Error removing schema ' + error);
+ });
+ }
+}
+
+export default new SchemasStore();
diff --git a/lib/server/stores/settings.js b/lib/server/stores/settings.js
new file mode 100644
index 000000000..5341995c5
--- /dev/null
+++ b/lib/server/stores/settings.js
@@ -0,0 +1,22 @@
+import {ServerStore} from 'relax-framework';
+import SettingModel from '../models/setting';
+import forEach from 'lodash.foreach';
+
+class SettingsStore extends ServerStore {
+ constructor () {
+ super();
+ this.Model = SettingModel;
+ }
+ parseSettings (_settings) {
+ var settings = {};
+
+ forEach(_settings, (setting) => {
+ settings[setting._id] = setting.value;
+ });
+
+ return settings;
+ }
+
+}
+
+export default new SettingsStore();
diff --git a/lib/server/stores/styles.js b/lib/server/stores/styles.js
new file mode 100644
index 000000000..6ab4508ce
--- /dev/null
+++ b/lib/server/stores/styles.js
@@ -0,0 +1,11 @@
+import {ServerStore} from 'relax-framework';
+import StyleModel from '../models/style';
+
+class StylesStore extends ServerStore {
+ constructor () {
+ super();
+ this.Model = StyleModel;
+ }
+}
+
+export default new StylesStore();
diff --git a/lib/server/stores/tabs.js b/lib/server/stores/tabs.js
new file mode 100644
index 000000000..42cc398f1
--- /dev/null
+++ b/lib/server/stores/tabs.js
@@ -0,0 +1,37 @@
+import {ServerStore} from 'relax-framework';
+import TabModel from '../models/tab';
+import Q from 'q';
+
+class TabsStore extends ServerStore {
+ constructor () {
+ super();
+ this.Model = TabModel;
+ }
+
+ add (data) {
+ var model = new this.Model(data);
+
+ return Q
+ .ninvoke(model, 'save')
+ .then(() => {
+ return Q.ninvoke(
+ this.Model,
+ 'populate',
+ model,
+ {
+ path: 'pageId',
+ select: 'title slug'
+ }
+ );
+ })
+ .then((entry) => {
+ return entry.toJSON();
+ });
+ }
+
+ removeMultiple (query) {
+ this.Model.find(query).remove().exec();
+ }
+}
+
+export default new TabsStore();
diff --git a/lib/server/stores/users.js b/lib/server/stores/users.js
new file mode 100644
index 000000000..a9d2e67cd
--- /dev/null
+++ b/lib/server/stores/users.js
@@ -0,0 +1,29 @@
+import {ServerStore} from 'relax-framework';
+import UserModel from '../models/user';
+import Q from 'q';
+
+class UsersStore extends ServerStore {
+ constructor () {
+ super();
+ this.Model = UserModel;
+ }
+
+ add (data) {
+ var user = new UserModel({
+ username: data.username,
+ name: data.name,
+ email: data.email
+ });
+
+ return Q()
+ .then(() => Q.ninvoke(UserModel, 'register', user, data.password))
+ .then(() => {
+ return user.toJSON();
+ })
+ .catch((err) => {
+ throw new Error(err);
+ });
+ }
+}
+
+export default new UsersStore();
diff --git a/lib/settings/fonts.js b/lib/settings/fonts.js
new file mode 100644
index 000000000..fdab15474
--- /dev/null
+++ b/lib/settings/fonts.js
@@ -0,0 +1,3 @@
+export default [
+ 'fonts'
+];
diff --git a/lib/settings/general.js b/lib/settings/general.js
new file mode 100644
index 000000000..c10f48ae8
--- /dev/null
+++ b/lib/settings/general.js
@@ -0,0 +1,6 @@
+export default [
+ 'title',
+ 'url',
+ 'favicon',
+ 'webclip'
+];
diff --git a/lib/styles/index.js b/lib/styles/index.js
new file mode 100644
index 000000000..b6d71e567
--- /dev/null
+++ b/lib/styles/index.js
@@ -0,0 +1,101 @@
+import cloneDeep from 'lodash.clonedeep';
+import merge from 'lodash.merge';
+import {isClient} from '../utils';
+import {Events} from 'backbone';
+import jss from '../react-jss';
+
+import stylesStore from '../client/stores/styles';
+
+class StylesManager {
+ constructor () {
+ this._registeredStyles = {};
+ this._rules = {};
+
+ if (isClient()) {
+ var collection = stylesStore.getCollection();
+ this.listenTo(collection, 'update change', this.onStylesUpdate.bind(this));
+ this.onStylesUpdate();
+ }
+ }
+
+ fetch () {
+ stylesStore.getCollection();
+ }
+
+ onStylesUpdate () {
+ this.makeCss();
+ this.trigger('update');
+ }
+
+ registerStyle (options) {
+ this._registeredStyles[options.type] = options;
+ }
+
+ getClassesMap (styleId) {
+ if (typeof styleId === 'string' && styleId !== '' && this._rules[styleId]) {
+ return this._rules[styleId].getRulesMap();
+ }
+ return null;
+ }
+
+ getEntriesByType (type) {
+ var collection = stylesStore.collection;
+ var entries = [];
+ collection.each((entry) => {
+ if (entry.get('type') === type) {
+ entries.push(entry.toJSON());
+ }
+ });
+ return entries;
+ }
+
+ getStyleOptionsByType (type) {
+ return this._registeredStyles[type];
+ }
+
+ get (id) {
+ return stylesStore.collection.get(id);
+ }
+
+ createTemp (type) {
+ const collection = stylesStore.collection;
+ const Model = collection.model;
+ const settings = this.getStyleOptionsByType(type);
+ const model = new Model({
+ _id: 'temp',
+ type,
+ label: '',
+ options: cloneDeep(settings.defaults)
+ });
+ collection.add(model);
+ return model;
+ }
+
+ removeTemp () {
+ const collection = stylesStore.collection;
+ collection.remove('temp');
+ }
+
+ makeCss () {
+ var collection = stylesStore.collection;
+ collection.each((style) => {
+ const id = style.get('_id');
+ const type = style.get('type');
+ const options = style.get('options');
+
+ if (this._registeredStyles[type]) {
+ var rules = this._registeredStyles[type].rules(options);
+
+ if (this._rules[id]) {
+ this._rules[id].update(rules);
+ } else {
+ this._rules[id] = jss.createRulesGet(rules);
+ }
+ }
+ });
+ jss.update();
+ }
+}
+merge(StylesManager.prototype, Events);
+
+export default new StylesManager();
diff --git a/lib/types.js b/lib/types.js
new file mode 100644
index 000000000..c216c7432
--- /dev/null
+++ b/lib/types.js
@@ -0,0 +1,50 @@
+import {Schema} from 'mongoose';
+
+var Types = {
+ String: 'String',
+ Date: 'Date',
+ Number: 'Number',
+ Boolean: 'Boolean',
+ Relation: 'Relation',
+ Color: 'Color',
+ Font: 'Font',
+ Html: 'Html',
+ Image: 'Image',
+ Select: 'Select',
+ Pixels: 'Pixels',
+ Percentage: 'Percentage',
+ Padding: 'Padding',
+ Margin: 'Margin',
+ Corners: 'Corners',
+ Border: 'Border',
+ Style: 'Style',
+ SchemaLink: 'SchemaLink',
+ Button: 'Button',
+ Icon: 'Icon',
+ TextStyle: 'TextStyle'
+};
+
+var TypesNative = {
+ String: String,
+ Date: Date,
+ Number: Number,
+ Boolean: Boolean,
+ Relation: Schema.Types.ObjectId,
+ Color: String,
+ Font: String,
+ Html: String,
+ Image: Schema.Types.ObjectId,
+ Select: String,
+ Pixels: Number,
+ Percentage: Number,
+ Padding: String,
+ Margin: String,
+ Corners: String,
+ Style: Schema.Types.ObjectId,
+ SchemaLink: Schema.Types.ObjectId
+};
+
+export default {
+ Types,
+ TypesNative
+};
diff --git a/lib/utils.js b/lib/utils.js
new file mode 100644
index 000000000..7ce533133
--- /dev/null
+++ b/lib/utils.js
@@ -0,0 +1,386 @@
+import Colr from 'colr';
+import forEach from 'lodash.foreach';
+import cloneDeep from 'lodash.clonedeep';
+import {md5} from 'blueimp-md5';
+import Colors from './colors';
+
+var utils = {
+ getGravatarImage (email, size = 100) {
+ var hash = email ? md5(email.toLowerCase()) : '0';
+ return 'http://www.gravatar.com/avatar/'+hash+'?d=mm&s='+size;
+ },
+
+ makeBorder (style, property, border) {
+ if (border.style !== 'none' && border.width !== 0) {
+ style[property] = border.width+'px '+border.style+' '+Colors.getColorString(border.color);
+ }
+ },
+
+ applyBorders (style, borderObj) {
+ if (borderObj && borderObj.top && borderObj.left && borderObj.right && borderObj.bottom) {
+ if (borderObj.equal) {
+ this.makeBorder(style, 'border', borderObj.top);
+ } else {
+ this.makeBorder(style, 'borderTop', borderObj.top);
+ this.makeBorder(style, 'borderRight', borderObj.right);
+ this.makeBorder(style, 'borderBottom', borderObj.bottom);
+ this.makeBorder(style, 'borderLeft', borderObj.left);
+ }
+ }
+ },
+
+ parseYoutubeURL (url) {
+ var regExp = /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*/;
+ var match = url.match(regExp);
+ if (match && match[7].length === 11) {
+ return match[7];
+ } else {
+ return false;
+ }
+ },
+
+ parseVimeoURL (url) {
+ var regExp = /^.*(vimeo\.com\/)((channels\/[A-z]+\/)|(groups\/[A-z]+\/videos\/))?([0-9]+)/;
+ var match = regExp.exec(url);
+ if (match && match[5].length === 9) {
+ return match[5];
+ } else {
+ return false;
+ }
+ },
+
+ parseDailymotionURL(url) {
+ var regExp = /^.+dailymotion.com\/((video|hub)\/([^_]+))?[^#]*(#video=([^_&]+))?/;
+ var match = url.match(regExp);
+
+ if (match && match.length > 2) {
+ return match[5] || match[3];
+ } else {
+ return false;
+ }
+ },
+
+ parseColumnsDisplay (value, numChildren, multiRows, idChanged = -1) {
+ var parsedValue;
+
+ if (value && value.constructor === Array) {
+ parsedValue = value;
+ } else {
+ parsedValue = [];
+ }
+
+ // Check missing
+ if (parsedValue.length < numChildren) {
+ let difference = numChildren - parsedValue.length;
+
+ for (difference; difference > 0; difference--) {
+ parsedValue.push({
+ width: multiRows ? 'block' : 'auto',
+ break: false,
+ widthPerc: 50
+ });
+ }
+ }
+
+ // Check if too much
+ if (parsedValue.length > numChildren) {
+ let difference = parsedValue.length - numChildren;
+ parsedValue.splice(-difference, difference);
+ }
+
+ // Go through all to check rules
+ if (multiRows && numChildren > 1) {
+ var previous = 'block', i;
+ for (i = 0; i < parsedValue.length; i++) {
+ if (previous === 'block' && parsedValue[i].width !== 'block') {
+ if (i === numChildren - 1) {
+ if (idChanged === i) {
+ parsedValue[i-1].width = 'auto';
+ } else {
+ parsedValue[i].width = 'block';
+ }
+ } else if (parsedValue[i + 1].width === 'block') {
+ if (idChanged === i) {
+ parsedValue[i + 1].width = 'auto';
+ } else {
+ parsedValue[i].width = 'block';
+ }
+ }
+ }
+
+ if (parsedValue[i].width === 'block' ||
+ previous === 'block' ||
+ (i >= 2 && parsedValue[i-2].width === 'block') ||
+ i === parsedValue.length - 1) {
+ parsedValue[i].break = false;
+ }
+
+ if (parsedValue[i].break && i < parsedValue.length - 1) {
+ if (parsedValue[i + 1].width === 'block'){
+ if(idChanged === i + 1) {
+ parsedValue[i].break = false;
+ } else {
+ parsedValue[i + 1].width = 'auto';
+ }
+ }
+ }
+
+ if (!parsedValue[i].widthPerc) {
+ parsedValue[i].widthPerc = 50;
+ }
+
+ previous = parsedValue[i].width;
+ }
+ }
+
+ // Calculate auto widths
+ var it, it1;
+ for (it = 0; it < parsedValue.length; it++) {
+ if (parsedValue[it].width !== 'block') {
+
+ let countAutoColumns = 0;
+ let availableAutoSpace = 100;
+
+ for (it1 = it; it1 < parsedValue.length; it1++) {
+ if ((parsedValue[it1].break && it1 !== it) || parsedValue[it1].width === 'block'){
+ break;
+ }
+
+ if (parsedValue[it1].width === 'custom') {
+ availableAutoSpace -= parsedValue[it1].widthPerc;
+ } else if (parsedValue[it1].width === 'auto') {
+ countAutoColumns ++;
+ }
+ }
+
+ // calc and apply width
+ if (countAutoColumns > 0) {
+ let widthCalc = Math.round(availableAutoSpace / countAutoColumns * 100) / 100;
+
+ for (it; it < it1; it++) {
+ if (parsedValue[it].width === 'auto') {
+ parsedValue[it].widthPerc = widthCalc;
+ }
+ }
+
+ it--;
+ } else {
+ it = it1 - 1;
+ }
+ }
+ }
+
+ return parsedValue;
+ },
+
+ getOffsetRect (elem) {
+ var box = elem.getBoundingClientRect();
+
+ var body = document.body;
+ var docElem = document.documentElement;
+
+ var scrollTop = window.pageYOffset || docElem.scrollTop || body.scrollTop;
+ var scrollLeft = window.pageXOffset || docElem.scrollLeft || body.scrollLeft;
+
+ var clientTop = docElem.clientTop || body.clientTop || 0;
+ var clientLeft = docElem.clientLeft || body.clientLeft || 0;
+
+ var top = box.top + scrollTop - clientTop;
+ var left = box.left + scrollLeft - clientLeft;
+
+ return {
+ top: Math.round(top),
+ left: Math.round(left),
+ width: Math.round(box.right-box.left),
+ height: Math.round(box.bottom-box.top)
+ };
+ },
+
+ hasClass (dom, className) {
+ return dom.className.indexOf(className) !== -1;
+ },
+
+ addClass (dom, className) {
+ if (!this.hasClass(dom, className)) {
+ dom.className = dom.className + ' ' + className;
+ }
+ },
+
+ removeClass (dom, className) {
+ var str = dom.className;
+ str = str.replace(' '+className, '');
+ str = str.replace(className+' ', '');
+ dom.className = str;
+ },
+
+ getBestImageVariation (variations, width, height = 0) {
+ var variationReturn = false;
+
+ forEach (variations, (variation) => {
+ if (variation.dimension.width >= width && variation.dimension.height >= height && (variation.dimension.width - width < 100 || variation.dimension.heigh - height < 100)) {
+ variationReturn = variation;
+ }
+ });
+
+ return variationReturn;
+ },
+
+ getBestImageUrl (imageId, width, height = 0) {
+ return this.parseQueryUrl('/api/media/resized/'+imageId, {
+ width,
+ height
+ });
+ },
+
+ parseQueryUrl ( url , query ) {
+ var count = 0;
+ forEach(query, (value, key) => {
+ url += count === 0 ? '?' : '&';
+ url += key + '=' + value;
+ count ++;
+ });
+
+ return url;
+ },
+
+ parseQueryString ( queryString ) {
+ var params = {}, queries, temp, i, l;
+
+ // Split into key/value pairs
+ queries = queryString.split("&");
+
+ // Convert the array of strings into an object
+ for ( i = 0, l = queries.length; i < l; i++ ) {
+ temp = queries[i].split('=');
+ params[temp[0]] = temp[1];
+ }
+
+ return params;
+ },
+
+ _getPropSchemaListIt (propsSchema, list) {
+ forEach(propsSchema, (propSchema) => {
+ list.push(propSchema);
+ if (propSchema.unlocks) {
+ if (propSchema.unlocks.constructor === Array) {
+ list = this._getPropSchemaListIt(propSchema.unlocks, list);
+ } else {
+ forEach(propsSchema.unlocks, (propSchemaUnlocks) => {
+ list = this._getPropSchemaListIt(propSchemaUnlocks, list);
+ });
+ }
+ }
+ });
+
+ return list;
+ },
+
+ getPropSchemaList (propsSchema) {
+ return this._getPropSchemaListIt(cloneDeep(propsSchema), []);
+ },
+
+
+ // Filers font family into more readable state ex: source-sans-pro into source sans pro
+ filterFontFamily (str) {
+ return str.replace(/-/g, " ");
+ },
+
+ // Makes a fvd more readable
+ filterFVD (fvd) {
+ var str = "";
+
+ // font weight
+ var weightChar = fvd.charAt(1);
+ str += weightChar+"00 ";
+
+ // font style
+ // n: normal (default)
+ // i: italic
+ // o: oblique
+ var styleChar = fvd.charAt(0);
+
+ if(styleChar === "n") {
+ str += "normal";
+ }
+ else if(styleChar === "i") {
+ str += "italic";
+ }
+ else if(styleChar === "o") {
+ str += "oblique";
+ }
+
+ return str;
+ },
+
+ getRGBA (hex, opacity) {
+ const colr = Colr.fromHex(hex);
+ const rgb = colr.toRgbObject();
+
+ return 'rgba('+rgb.r+', '+rgb.g+', '+rgb.b+', '+opacity+')';
+ },
+
+ border (arr, size, style, color, opacity) {
+ arr.border = size + " " + style + " " + utils.getRGBA(color, parseInt(opacity, 10)/100.0);
+ arr.WebkitBackgroundClip = "padding-box";
+ arr.backgroundClip = "padding-box";
+ },
+
+ isClient () {
+ return typeof document !== 'undefined';
+ },
+
+ rounded (arr, corners) {
+ arr.WebkitBorderRadius = corners;
+ arr.MozBorderRadius = corners;
+ arr.OBorderRadius = corners;
+ arr.borderRadius = corners;
+ },
+
+ backgroundRGBA (arr, color, opacity) {
+ arr.backgroundColor = utils.getRGBA(color, parseInt(opacity, 10)/100.0);
+ },
+
+ transition (arr, to, time, ease) {
+ arr.transition = to + " " + time + " " + ease;
+ arr.WebkitTransition = to + " " + time + " " + ease;
+ arr.MozTransition = to + " " + time + " " + ease;
+ arr.OTransition = to + " " + time + " " + ease;
+ },
+
+ translate (arr, x, y) {
+ arr.transform = "translate("+x+", "+y+")";
+ arr.msTransform = "translate("+x+", "+y+")";
+ arr.MozTransform = "translate("+x+", "+y+")";
+ arr.WebkitTransform = "translate("+x+", "+y+")";
+ arr.OTransform = "translate("+x+", "+y+")";
+ },
+
+ padding (arr, top, right, bottom, left) {
+ arr.padding = top+" "+right+" "+bottom+" "+left;
+ },
+
+ // Return json from a font format fvd (ex. input: i4, n4, n8 ...) https://github.com/typekit/fvd
+ processFVD (style, fvd) {
+ style.fontStyle = "normal";
+ style.fontWeight = 400;
+
+ // font style
+ // n: normal (default)
+ // i: italic
+ // o: oblique
+ var styleChar = fvd.charAt(0);
+
+ if(styleChar === "i") {
+ style.fontStyle = "italic";
+ }
+ else if(styleChar === "o") {
+ style.fontStyle = "oblique";
+ }
+
+ // font weight
+ var weightChar = fvd.charAt(1);
+ style.fontWeight = parseInt(weightChar+"00", 10);
+ }
+};
+
+export default utils;
diff --git a/package.json b/package.json
new file mode 100644
index 000000000..a1a721de8
--- /dev/null
+++ b/package.json
@@ -0,0 +1,102 @@
+{
+ "name": "relax",
+ "version": "1.0.0",
+ "description": "New generation CMS on top of React and Node.js",
+ "license": "GPLv3",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/relax/relax.git"
+ },
+ "scripts": {
+ "prepublish": "NODE_ENV=production grunt build:production",
+ "postinstall": "NODE_ENV=production grunt build:production",
+ "dev": "grunt dev",
+ "test": "grunt test",
+ "start": "node index",
+ "watch": "grunt watch"
+ },
+ "dependencies": {
+ "babel": "^4.0.0",
+ "backbone": "^1.2.0",
+ "backbone-cortex": "git://github.com/bruno12mota/backbone-cortex.git",
+ "blueimp-md5": "^1.1.1",
+ "body-parser": "^1.12.2",
+ "classnames": "^2.1.3",
+ "colr": "^1.2.1",
+ "connect-mongo": "^0.8.2",
+ "dropzone": "git://github.com/bruno12mota/dropzone.git",
+ "express": "^4.12.3",
+ "express-react-engine": "^0.4.4",
+ "express-session": "^1.11.3",
+ "file-size": "^1.0.0",
+ "jquery": "^2.1.3",
+ "keymaster": "git://github.com/bruno12mota/keymaster.git",
+ "lodash.clone": "^3.0.2",
+ "lodash.clonedeep": "^3.0.1",
+ "lodash.every": "^3.2.0",
+ "lodash.foreach": "^3.0.2",
+ "lodash.map": "^3.1.0",
+ "lodash.merge": "^3.1.0",
+ "mkdirp": "^0.5.0",
+ "medium-editor": "git://github.com/bruno12mota/medium-editor.git",
+ "moment": "^2.10.2",
+ "mongoose": "^4.0.1",
+ "morgan": "^1.5.2",
+ "multer": "^0.1.8",
+ "passport": "^0.2.2",
+ "passport-local": "^1.0.0",
+ "passport-local-mongoose": "^1.0.1",
+ "q": "^1.2.0",
+ "rc": "^1.0.1",
+ "react": "^0.13.2",
+ "react-colorpicker": "git://github.com/bruno12mota/react-colorpicker.git",
+ "react-counter": "git://github.com/bruno12mota/react-counter.git",
+ "react-draggable": "^0.4.0",
+ "react-frame-component": "^0.3.2",
+ "react-gemini-scrollbar": "^1.1.1",
+ "react-google-maps": "^1.7.0",
+ "relax-framework": "^1.0.0-alpha2",
+ "rimraf": "^2.3.2",
+ "sharp": "^0.9.3",
+ "slug": "^0.8.0",
+ "soundmanager2": "git://github.com/bruno12mota/SoundManager2.git",
+ "velocity-animate": "git://github.com/bruno12mota/velocity.git",
+ "winston": "^0.9.0"
+ },
+ "devDependencies": {
+ "babelify": "^5.0.0",
+ "grunt": "^0.4.5",
+ "grunt-browserify": "^4.0.0",
+ "grunt-concurrent": "^1.0.0",
+ "grunt-contrib-copy": "^0.8.0",
+ "grunt-contrib-less": "^1.0.0",
+ "grunt-contrib-uglify": "^0.9.1",
+ "grunt-contrib-watch": "^0.6.1",
+ "grunt-nodemon": "^0.4.0",
+ "less-plugin-autoprefix": "^1.4.2",
+ "less-plugin-clean-css": "^1.5.0",
+ "nodemon": "^1.3.7"
+ },
+ "apps": [
+ {
+ "name": "relax",
+ "script": "index.js",
+ "instances": 1,
+ "exec_mode": "cluster",
+ "error_file": "../shared/logs/stderr.log",
+ "out_file": "../shared/logs/stdout.log",
+ "pid_file": "../shared/pids/pid",
+ "merge_logs": true
+ }
+ ],
+ "deploy": {
+ "test": {
+ "user": "node",
+ "host": "178.62.64.146",
+ "path": "/home/node/apps/cms",
+ "ref": "origin/master",
+ "repo": "git@178.62.67.88:cms/cms.git",
+ "post-deploy": ". /home/node/.nvm/nvm.sh && npm install && pm2 startOrGracefulReload package.json --env test"
+ }
+ }
+}
diff --git a/uploads/.gitignore b/uploads/.gitignore
new file mode 100644
index 000000000..5e7d2734c
--- /dev/null
+++ b/uploads/.gitignore
@@ -0,0 +1,4 @@
+# Ignore everything in this directory
+*
+# Except this file
+!.gitignore