Эх сурвалжийг харах

Starting the `scene` content type

Markus Ochel 13 жил өмнө
parent
commit
bd5518103d

+ 35 - 0
README.md

@@ -49,3 +49,38 @@ Donation URL
 Temp Stuff
 ----------
 
+  select ID, post_date_gmt, post_content, post_title, post_modified_gmt, post_name, post_author, guid from wp_posts where post_type = 'post' and post_status = 'publish'
+
+
+  select "www.evolvingwellness.com" as site, "essay" as type, post_date_gmt as published_at, post_content as body, post_title as title, post_modified_gmt as updated_at, post_name as slug, case post_author when 2 then "2d9a5976ee3b7d7130154b708e03a517" else post_author end as author_id, ID as old_id, post_name as old_slug, guid as old_url from wp_posts where post_type = 'post' and post_status = 'publish' limit 10
+
+  kanso transform csv --indent=2 query_result.csv query_result.json
+
+  kanso transform add-ids -u http://localhost:80 query_result.json query_result_ids.json
+
+  kanso transform map --module=map.js query_result_ids.json query_result_final.json
+
+  kanso upload query_result_final.json
+
+
+{
+   "_id": "92655f6a1695d5c822d3c794e50074a5",
+   "site": "www.evolvingwellness.com",
+   "slug": "",
+   "title": "",
+   "intro": "",
+   "body": "",
+   "photo": "",
+   "published": true,
+   "published_at": "2012-08-20T18:30:00.000Z",
+   "updated_at": "2012-09-27T23:15:41.134Z",
+   "author_id": "56a80424639f9e4f5353bd01e20637c9",
+   "collections": [],
+   "type": "essay",
+   "_attachments": {}
+}
+
+Evolving Wellness = 569 docs
+Evolving Beings = 497 docs
+Evolving Scenes = 580 docs
+Healthytarian = 22 docs

+ 2 - 0
admin/controllers/index.coffee

@@ -14,6 +14,7 @@ Site        = require('models/site')
 Author      = require('models/author')
 Collection  = require('models/collection')
 Essay       = require('models/essay')
+Scene       = require('models/scene')
 Block       = require('models/block')
 Contact     = require('models/contact')
 Sponsor     = require('models/sponsor')
@@ -47,6 +48,7 @@ class App extends Spine.Controller
     Author.fetch()
     Collection.fetch()
     Essay.fetch()
+    Scene.fetch()
     Block.fetch()
     Contact.fetch()
     Sponsor.fetch()

+ 3 - 0
admin/controllers/main-stack.coffee

@@ -5,6 +5,7 @@ Sites       = require('controllers/sites')
 Authors     = require('controllers/authors')
 Collections = require('controllers/collections')
 Essays      = require('controllers/essays')
+Scenes      = require('controllers/scenes')
 Blocks      = require('controllers/blocks')
 Contacts    = require('controllers/contacts')
 Sponsors    = require('controllers/sponsors')
@@ -21,6 +22,7 @@ class MainStack extends Spine.Stack
     authors:     Authors
     collections: Collections
     essays:      Essays
+    scenes:      Scenes
     blocks:      Blocks
     contacts:    Contacts
     sponsors:    Sponsors
@@ -33,6 +35,7 @@ class MainStack extends Spine.Stack
     '/authors':     'authors'
     '/collections': 'collections'
     '/essays':      'essays'
+    '/scenes':      'scenes'
     '/blocks':      'blocks'
     '/contacts':    'contacts'
     '/sponsors':    'sponsors'

+ 261 - 0
admin/controllers/scenes.coffee

@@ -0,0 +1,261 @@
+Spine       = require('spine/core')
+$           = Spine.$
+templates   = require('duality/templates')
+utils       = require('lib/utils')
+
+# For importing HTML from old sites
+require('lib/reMarked')
+require('lib/jquery-xdomainajax')
+
+MultiSelectUI = require('controllers/ui/multi-select')
+FileUploadUI  = require('controllers/ui/file-upload')
+
+Scene       = require('models/scene')
+Author      = require('models/author')
+Collection  = require('models/collection')
+Sponsor     = require('models/sponsor')
+Site        = require('models/site')
+
+
+class SceneForm extends Spine.Controller
+  className: 'scene form panel'
+
+  elements:
+    '.item-title':             'itemTitle'
+    '.error-message':          'errorMessage'
+    'form':                    'form'
+    'select[name=site]':       'formSite'
+    'select[name=author_id]':  'formAuthorId'
+    'select[name=sponsor_id]': 'formSponsorId'
+    'input[name=title]':       'formTitle'
+    'input[name=published]':   'formPublished'
+    'textarea[name=body]':     'formBody'
+    '.collections-list':       'collectionsList'
+    '.upload-ui':              'fileUploadContainer'
+    '.save-button':            'saveButton'
+    '.cancel-button':          'cancelButton'
+    'button.fullscreen-button': 'fullscreenButton'
+
+  events:
+    'submit form':              'preventSubmit'
+    'click .save-button':       'save'
+    'click .cancel-button':     'cancel'
+    'click .delete-button':     'destroy'
+    'change select[name=site]': 'siteChange'
+    'blur input[name=slug]':    'updateSlug'
+    'click .fullscreen-button': 'fullscreen'
+    'click .import-button':     'import'
+
+  constructor: ->
+    super
+    @active @render
+
+  render: (params) ->
+    @editing = params.id?
+    if @editing
+      @copying = params.id.split('-')[0] is 'copy'
+      if @copying
+        @title = 'Copy Scene'
+        @item = Scene.find(params.id.split('-')[1]).dup()
+      else
+        @item = Scene.find(params.id)
+        @title = @item.name
+    else
+      @title = 'New Scene'
+      @item = {}
+
+    @item.collections ?= []
+    @item._attachments ?= {}
+    
+    @item.sites = Site.all().sort(Site.nameSort)
+    @item.sponsors = Sponsor.all().sort(Sponsor.nameSort)
+    @html templates.render('scene-form.html', {}, @item)
+
+    @itemTitle.html @title
+    
+    # Set few initial form values
+    if @editing
+      @formSite.val(@item.site)
+      @formSponsorId.val(@item.sponsor_id)
+      @formPublished.prop('checked', @item.published)
+    else
+      @formSite.val(@stack.stack.filterBox.siteId)
+      # @formPublished.prop('checked', true)
+    @siteChange()
+
+    # Files upload area
+    @fileUploadUI = new FileUploadUI
+      docId: @item.id
+      selectedFile: @item.photo
+      attachments: @item._attachments
+    @fileUploadContainer.html @fileUploadUI.el
+
+  siteChange: ->
+    $siteSelected = @formSite.parents('.field').find('.site-selected')
+    site = Site.exists(@formSite.val())
+    if site
+      $siteSelected.html "<div class=\"site-name theme-#{site.theme}\">#{site.name_html}</div>"
+      @makeAuthorsList(site)
+      @makeCollectionsList(site)
+    else
+      $siteSelected.html ""
+
+  makeAuthorsList: (site) ->
+    authors = Author.findAllByAttribute('site', site.id).sort(Author.nameSort)
+    @formAuthorId.empty()
+      .append "<option value=\"\">Select an author...</option>"
+    for author in authors
+      @formAuthorId.append "<option value=\"#{author.id}\">#{author.name}</option>"
+    @formAuthorId.val(@item.author_id)
+  
+  makeCollectionsList: (site) ->
+    collections = Collection.findAllByAttribute('site', site.id).sort(Collection.nameSort)
+    @collectionSelectUI = new MultiSelectUI
+      items: collections
+      selectedItems: (c.id for c in @item.collections)
+      valueFields: ['id','slug']
+    @collectionsList.html @collectionSelectUI.el
+
+  updateSlug: (e) =>
+    slug = $(e.currentTarget)
+    unless slug.val()
+      slug.val utils.cleanSlug(@formTitle.val())
+
+  fullscreen: (e) =>
+    e?.preventDefault()
+    @fullscreenButtonText ?= @fullscreenButton.html()
+    if @form.hasClass('fullscreen')
+      @form.removeClass('fullscreen')
+      @fullscreenButton.html @fullscreenButtonText
+    else
+      @form.addClass('fullscreen')
+      @fullscreenButton.html "&lt; Exit #{@fullscreenButtonText}"
+
+  import: (e) =>
+    # For importing old HTML to Markdown directly from old location
+    e?.preventDefault()
+    url = $.trim prompt("Paste a URL from #{@formSite.val()}")
+    if url
+      $.ajax
+        type: 'GET'
+        url: url
+        success: (res) =>
+          $html = $(res.responseText)
+          $title = $html.find('.post > h2:first > a')
+          $author = $html.find('.post .entry-author > a:first')
+          $date = $html.find('.post .entry-date > .published')
+          $content = $html.find('.post .entry:first')
+          $image = $content.find('img:first')
+          if $content
+            $content.find('.addthis_toolbox, .author-bio').remove()
+            options =
+                link_list:  false    # render links as references, create link list as appendix
+                h1_setext:  true     # underline h1 headers
+                h2_setext:  true     # underline h2 headers
+                h_atx_suf:  true     # header suffixes (###)
+                gfm_code:   false    # render code blocks as via ``` delims
+                li_bullet:  "*"      # list item bullet style
+                hr_char:    "-"      # hr style
+                indnt_str:  "    "   # indentation string
+                bold_char:  "*"      # char used for strong
+                emph_char:  "_"      # char used for em
+                gfm_tbls:   false    # markdown-extra tables
+                tbl_edges:  false    # show side edges on tables
+                hash_lnks:  false    # anchors w/hash hrefs as links
+            reMarker = new reMarked(options)
+            markdown = reMarker.render($content.html())
+            @formBody.val(markdown)
+
+          @formTitle.val($title.text()) if $title
+          @form.find('input[name=slug]').val($title.attr('href').replace("http://#{@formSite.val()}", '')) if $title
+          @formAuthorId.val($author.text()) if $author
+          @form.find('input[name=published_at]').val($date.text()) if $date
+
+  save: (e) ->
+    e.preventDefault()
+    if @editing
+      @item.fromForm(@form)
+    else
+      @item = new Scene().fromForm(@form)
+
+    @item.collections = @collectionSelectUI.selected()
+    @item._attachments = @fileUploadUI.attachments
+
+    # Take care of some boolean checkboxes
+    @item.published = @formPublished.is(':checked')
+    
+    # Save the item and make sure it validates
+    if @item.save()
+      @back()
+    else
+      msg = @item.validate()
+      @showError msg
+
+  showError: (msg) ->
+    @errorMessage.html(msg).show()
+    @el.scrollTop(0, 0)
+  
+  destroy: (e) ->
+    e.preventDefault()
+    if @item and confirm "Are you sure you want to delete this #{@item.constructor.name}?"
+      @item.destroy()
+      @back()
+
+  cancel: (e) ->
+    e.preventDefault()
+    if @dirtyForm
+      if confirm "You may have some unsaved changes.\nAre you sure you want to cancel?"
+        @back()
+    else
+      @back()
+
+  back: ->
+    @navigate('/scenes/list')
+
+  preventSubmit: (e) ->
+    e.preventDefault()
+    
+  deactivate: ->
+    @el.scrollTop(0, 0)
+    super
+
+
+class SceneList extends Spine.Controller
+  className: 'scene list panel'
+
+  constructor: ->
+    super
+    # @active @render
+    Scene.bind 'change refresh', @render
+    Spine.bind 'filterbox:change', @filter
+
+  render: =>
+    context = 
+      scenes: Scene.filter(@filterObj).sort(Scene.titleSort)
+    @html templates.render('scenes.html', {}, context)
+
+  filter: (@filterObj) =>
+    @render()
+
+
+class Scenes extends Spine.Stack
+  className: 'scenes panel'
+
+  controllers:
+    list: SceneList
+    form: SceneForm
+
+  default: 'list'
+
+  routes:
+    '/scenes/list': 'list'
+    '/scene/new':   'form'
+    '/scene/:id':   'form'
+
+  constructor: ->
+    super
+    for k, v of @controllers
+      @[k].active => @active()
+
+
+module.exports = Scenes

+ 563 - 0
admin/lib/html2markdown.js

@@ -0,0 +1,563 @@
+/**
+ * HTML2Markdown - An HTML to Markdown converter.
+ * 
+ * This implementation uses HTML DOM parsing for conversion. Parsing code was
+ * abstracted out in a parsing function which should be easy to remove in favor
+ * of other parsing libraries.
+ * 
+ * Converted MarkDown was tested with ShowDown library for HTML rendering. And
+ * it tries to create MarkDown that does not confuse ShowDown when certain
+ * combination of HTML tags come together.
+ * 
+ * @author Himanshu Gilani
+ * @author Kates Gasis (original author)
+ * 
+ */
+
+/**
+ * HTML2Markdown
+ * @param html - html string to convert
+ * @return converted markdown text
+ */
+function HTML2Markdown(html, opts) {
+	var logging = false;
+	var nodeList = [];
+	var listTagStack = [];
+	var linkAttrStack = [];
+	var blockquoteStack = [];
+	var preStack = [];
+	
+	var links = [];
+	
+	opts = opts || {};
+	var inlineStyle = opts['inlineStyle'] || false;
+
+	var markdownTags = {
+		"hr": "- - -\n\n",
+		"br": "  \n",
+		"title": "# ",
+		"h1": "# ",
+		"h2": "## ",
+		"h3": "### ",
+		"h4": "#### ",
+		"h5": "##### ",
+		"h6": "###### ",
+		"b": "**",
+		"strong": "**",
+		"i": "_",
+		"em": "_",
+		"dfn": "_",
+		"var": "_",	
+		"cite": "_",
+		"span": " ",
+		"ul": "* ",
+		"ol": "1. ",
+		"dl": "- ",
+		"blockquote": "> "
+	};
+
+	function getListMarkdownTag() {
+		var listItem = "";		
+		if(listTagStack) {
+			for ( var i = 0; i < listTagStack.length - 1; i++) {
+				listItem += "  ";
+			}			
+		}
+		listItem += peek(listTagStack);		
+		return listItem;
+	}
+	
+	function convertAttrs(attrs) {
+		var attributes = {};
+		for(var k in attrs) {
+			var attr = attrs[k];
+			attributes[attr.name] = attr;
+		}
+		return attributes;
+	}
+
+	function peek(list) {
+		if(list && list.length > 0) {
+			return list.slice(-1)[0];	
+		} 
+		return "";		
+	}
+
+	function peekTillNotEmpty(list) {
+		if(!list) {
+			return "";
+		}
+				
+		for(var i = list.length - 1; i>=0; i-- ){
+			if(list[i] != "") {
+				return list[i];
+			} 		
+		}		
+		return "";
+	}
+	
+	function removeIfEmptyTag(start) {
+		var cleaned = false;
+		if(start == peekTillNotEmpty(nodeList)) {
+			while(peek(nodeList) != start) {
+				nodeList.pop();
+			}
+			nodeList.pop();
+			cleaned = true;
+		} 
+		return cleaned;
+	}
+	
+	function sliceText(start) {
+		var text = [];
+		while(nodeList.length > 0 && peek(nodeList) != start) {
+			var t = nodeList.pop();
+			text.unshift(t);
+		}
+		return text.join("");
+	}
+	
+	function block(isEndBlock) {
+		var lastItem = nodeList.pop();
+		if (!lastItem) {
+			return;
+		}
+		
+		if(!isEndBlock) {
+			var block;
+			if(/\s*\n\n\s*$/.test(lastItem)) {
+				lastItem = lastItem.replace(/\s*\n\n\s*$/, "\n\n");
+				block = "";
+			} else if(/\s*\n\s*$/.test(lastItem)) {
+				lastItem = lastItem.replace(/\s*\n\s*$/, "\n");
+				block = "\n";
+			} else if(/\s+$/.test(lastItem)) {				
+				block = "\n\n";
+			} else {
+				block = "\n\n";
+			} 
+			
+			nodeList.push(lastItem);
+			nodeList.push(block);	
+		} else {
+			nodeList.push(lastItem);
+			if(!lastItem.endsWith("\n")) {
+				nodeList.push("\n\n");
+			}
+		}
+ 	}
+	
+	function listBlock() {
+		if(nodeList.length > 0) {
+			var li = peek(nodeList);
+
+			if(!li.endsWith("\n")) {
+				nodeList.push("\n");
+			} 
+		} else {
+			nodeList.push("\n");
+		}
+	}
+	
+	try {
+		var dom;
+		if(html) {
+		var e = document.createElement('div');
+			e.innerHTML = html;
+			dom = e;
+		} else {
+			dom = window.document.body;
+		}
+
+		HTMLParser(dom,{
+			start: function(tag, attrs, unary) {
+				tag = tag.toLowerCase();
+				if(logging) {
+					console.log("start: "+ tag);
+				}
+				
+				if(unary && (tag != "br" && tag != "hr" && tag != "img")) {
+					return;
+				}
+				
+				switch (tag) {
+				case "br":
+					nodeList.push(markdownTags[tag]);
+					break;
+				case "hr":
+					block();
+					nodeList.push(markdownTags[tag]);
+					break;
+				case "title":	
+				case "h1":
+				case "h2":
+				case "h3":
+				case "h4":
+				case "h5":
+				case "h6":
+					block();
+					nodeList.push(markdownTags[tag]);
+					break;
+				case "b":
+				case "strong":
+				case "i":
+				case "em":
+				case "dfn": 
+				case "var": 	
+				case "cite":
+					nodeList.push(markdownTags[tag]);
+					break;
+				case "span":
+					if(! /\s+$/.test(peek(nodeList))) {
+						nodeList.push(markdownTags[tag]);	
+					}
+					break;
+				case "p":
+				case "div":				
+				case "td":
+					block();
+					break;
+				case "ul":
+				case "ol":
+				case "dl":	
+					listTagStack.push(markdownTags[tag]);
+					// lists are block elements
+					if(listTagStack.length > 1) {
+						listBlock();
+					} else {						
+						block();
+					}										
+					break;
+				case "li":
+				case "dt":
+					var li = getListMarkdownTag();
+					nodeList.push(li);
+					break;
+				case "a":					
+					var attribs = convertAttrs(attrs);
+					linkAttrStack.push(attribs);
+					nodeList.push("[");
+					break;
+				case "img":
+					var attribs = convertAttrs(attrs);
+					var alt, title, url; 
+					
+					attribs["src"] ? url = getNormalizedUrl(attribs["src"].value) : url = "";
+					if(!url) {
+						break;
+					}
+					
+					attribs['alt'] ? alt = attribs['alt'].value.trim() : alt = "";
+					attribs['title'] ? title = attribs['title'].value.trim() : title = "";
+										
+					// if parent of image tag is nested in anchor tag use inline style
+					if(!inlineStyle && !peekTillNotEmpty(nodeList).startsWith("[")) {						
+						var l = links.indexOf(url);
+						if(l == -1) {
+							links.push(url);
+							l=links.length-1;							 
+						}		
+						
+						block();
+						nodeList.push("![");
+						if(alt!= "") {
+							nodeList.push(alt);
+						} else if (title != null) {
+							nodeList.push(title);
+						} 
+						
+						nodeList.push("][" + l + "]");
+						block();
+					} else {
+						//if image is not a link image then treat images as block elements
+						if(!peekTillNotEmpty(nodeList).startsWith("[")) {
+							block();	
+						}
+						
+						nodeList.push("![" + alt + "](" + url + (title ? " \"" + title + "\"" : "") + ")");
+						
+						if(!peekTillNotEmpty(nodeList).startsWith("[")) {
+							block(true);	
+						}
+					}
+					break;	
+				case "blockquote":
+					block();
+					blockquoteStack.push(markdownTags[tag]);
+					nodeList.push(blockquoteStack.join(""));
+					break;
+				case "pre":
+				case "code":
+					block();
+					preStack.push(true);
+					break;
+				}				
+			},
+			chars: function(text) {			
+				if(preStack.length > 0) {
+					text = "    " + text.replace(/\n/g,"\n    ");
+				} else if(text.trim() != "") {
+					text = text.replace(/\s+/g, " ");
+					
+					var prevText = peekTillNotEmpty(nodeList);
+					if(/\s+$/.test(prevText)) {
+						text = text.replace(/^\s+/g, "");
+					}	
+				} else {
+					nodeList.push("");
+					return;
+				}
+
+				if(logging) {
+					console.log("text: "+ text);
+				}
+				
+				nodeList.push(text);
+			},
+			end: function(tag) {
+				tag = tag.toLowerCase();
+				if(logging) {
+					console.log("end: "+ tag);
+				}
+				switch (tag) {				
+				case "title":	
+				case "h1":
+				case "h2":
+				case "h3":
+				case "h4":
+				case "h5":
+				case "h6":
+					if(!removeIfEmptyTag(markdownTags[tag])) {
+						block(true);
+					}
+					break;
+				case "p":
+				case "div":
+				case "td":
+					while(nodeList.length > 0 && peek(nodeList).trim() == "") {
+						nodeList.pop();
+					}
+					block(true);
+					break;
+				case "b":
+				case "strong":
+				case "i":
+				case "em":
+				case "dfn": 
+				case "var": 	
+				case "cite":
+					if(!removeIfEmptyTag(markdownTags[tag])) {						
+						nodeList.push(sliceText(markdownTags[tag]).trim());
+						nodeList.push(markdownTags[tag]);
+					}
+					break;
+				case "a":
+					var text = sliceText("[");
+					text = text.replace(/\s+/g, " ");					
+					text = text.trim();
+					
+					if(text == "") {
+						nodeList.pop();
+						break;
+					}
+
+					var attrs = linkAttrStack.pop();
+					var url;
+					attrs["href"] &&  attrs["href"].value != "" ? url = getNormalizedUrl(attrs["href"].value) : url = "";
+					
+					if(url == "") {
+						nodeList.pop();
+						nodeList.push(text);
+						break;
+					}
+					
+					nodeList.push(text);
+					
+					if(!inlineStyle && !peek(nodeList).startsWith("!")){
+						var l = links.indexOf(url);
+						if(l == -1) {
+							links.push(url);
+							l=links.length-1;
+						}							
+						nodeList.push("][" + l + "]");
+					} else {
+						if(peek(nodeList).startsWith("!")){
+							var text = nodeList.pop();
+							text = nodeList.pop() + text;
+							block();
+							nodeList.push(text);
+						}
+						
+						var title = attrs["title"];						
+						nodeList.push("](" + url + (title ? " \"" + title.value.trim().replace(/\s+/g, " ") + "\"" : "") + ")");
+						
+						if(peek(nodeList).startsWith("!")){
+							block(true);
+						}
+					}
+					break;					
+				case "ul":
+				case "ol":
+				case "dl":	
+					listBlock();
+					listTagStack.pop();
+					break;
+				case "li":
+				case "dt":
+					var li = getListMarkdownTag();
+					if(!removeIfEmptyTag(li)) {
+						var text = sliceText(li).trim();
+						
+						if(text.startsWith("[![")) {
+							nodeList.pop();							
+							block();
+							nodeList.push(text);
+							block(true);
+						} else {
+							nodeList.push(text);
+							listBlock();
+						}
+					}	
+					break;
+				case "blockquote":
+					blockquoteStack.pop();
+					break;
+				case "pre":
+				case "code":
+					block(true);
+					preStack.pop();	
+					break;
+				case "span":
+					if(peek(nodeList).trim() == "") {
+						nodeList.pop();
+						if(peek(nodeList) == " ") {
+							nodeList.pop();	
+						} else {
+							nodeList.push(markdownTags[tag]);
+						}						
+					} else {
+						var text = nodeList.pop();
+						nodeList.push(text.trim());
+						nodeList.push(markdownTags[tag]);													
+					}
+					break;					
+				case "br":
+				case "hr":
+				case "img":
+				case "table":	
+				case "tr":
+					break;
+				}
+				
+			}
+		}, {"nodesToIgnore": ["script", "noscript", "object", "iframe", "frame", "head", "style", "label"]});
+		
+		if(!inlineStyle) {							
+			for ( var i = 0; i < links.length; i++) {
+				if(i == 0) {
+					var lastItem = nodeList.pop();
+					nodeList.push(lastItem.replace(/\s+$/g, ""));
+					nodeList.push("\n\n[" + i + "]: " + links[i]);
+				} else {
+					nodeList.push("\n[" + i + "]: " + links[i]);	
+				}
+			}
+		}
+	} catch(e) {
+		console.log(e.stack);
+		console.trace();
+	}
+	
+	return nodeList.join("");
+	
+}
+
+function getNormalizedUrl(s) {
+	var urlBase = location.href;
+	var urlDir  = urlBase.replace(/\/[^\/]*$/, '/');
+	var urlPage = urlBase.replace(/#[^\/#]*$/, '');
+
+	var url;
+	if(/^[a-zA-Z]([a-zA-Z0-9 -.])*:/.test(s)) {
+		// already absolute url
+		url = s;
+	} else if(/^\x2f/.test(s)) {// %2f --> /
+		// url is relative to site
+		location.protocol != "" ? url = location.protocol + "//" : url ="";		
+		url+= location.hostname;		
+		if(location.port != "80") {
+			url+=":"+location.port;
+		}				
+		url += s;
+	} else if(/^#/.test(s)) {
+		// url is relative to page
+		url = urlPage + s;
+	} else {
+		url = urlDir + s;
+	}
+	return encodeURI(url);
+}
+
+if (typeof exports != "undefined") {
+	exports.HTML2Markdown = HTML2Markdown;
+}
+		
+if (typeof exports != "undefined") {
+	exports.HTML2MarkDown = HTML2MarkDown;
+}
+
+/* add the useful functions to String object*/
+if (typeof String.prototype.trim != 'function') {
+	String.prototype.trim = function() {
+		return replace(/^\s+|\s+$/g,"");
+	};	
+}
+
+if (typeof String.prototype.isNotEmpty != 'function') {
+	String.prototype.isNotEmpty = function() {
+		if (/\S/.test(this)) {
+		    return true;
+		} else {
+			return false;
+		}		
+	};	
+}
+
+if (typeof String.prototype.replaceAll != 'function') {
+	String.prototype.replaceAll = function(stringToFind,stringToReplace){
+	    var temp = this;
+	    var index = temp.indexOf(stringToFind);
+	        while(index != -1){
+	            temp = temp.replace(stringToFind,stringToReplace);
+	            index = temp.indexOf(stringToFind);
+	        }
+	        return temp;
+	};	
+}
+
+if (typeof String.prototype.startsWith != 'function') {
+	String.prototype.startsWith = function(str) {
+		return this.indexOf(str) == 0;
+	};
+}
+
+if (typeof String.prototype.endsWith != 'function') {
+	String.prototype.endsWith = function(suffix) {
+	    return this.match(suffix+"$") == suffix;
+	};	
+}
+
+if (typeof Array.prototype.indexOf != 'function') {
+	Array.prototype.indexOf = function(obj, fromIndex) {
+		if (fromIndex == null) {
+			fromIndex = 0;
+		} else if (fromIndex < 0) {
+			fromIndex = Math.max(0, this.length + fromIndex);
+		}
+		for ( var i = fromIndex, j = this.length; i < j; i++) {
+			if (this[i] === obj)
+				return i;
+		}
+		return -1;
+	};
+}

+ 2 - 2
admin/lib/utils.coffee

@@ -6,10 +6,10 @@ exports.msg = {
 }
 
 exports.cleanCode = (code) ->
-  code.toLowerCase().replace(/[\ \.\'\"\-]/g, '_')
+  code.toLowerCase().replace(/[\ \.\'\"\:\,\-]/g, '_')
 
 exports.cleanSlug = (slug) ->
-  slug.toLowerCase().replace(/[\ \.\'\"]/g, '-')
+  slug.toLowerCase().replace(/[\ \.\'\"\:\,]/g, '-')
 
 exports.cleanContent = (content) ->
   protocol = "http(s)?:\/\/"

+ 67 - 0
admin/models/scene.coffee

@@ -0,0 +1,67 @@
+Spine = require('spine/core')
+require('lib/spine-couch-ajax')
+
+utils = require('lib/utils')
+moment = require('lib/moment')
+
+BaseModel = require('models/base')
+
+class Scene extends BaseModel
+  @configure "Scene", "site", "slug", "title", "body", "photo", "published", "published_at", "updated_at", "author_id", "sponsor_id", "sponsor_start", "sponsor_end", "sponsors_history", "collections", "_attachments"
+  
+  @extend @CouchAjax
+  
+  @titleSort: (a, b) ->
+    if (a.title or a.published_at) > (b.title or b.published_at) then 1 else -1
+
+  @dateSort: (a, b) ->
+    if (a.published_at or a.title) > (b.published_at or b.title) then 1 else -1
+
+  @queryOn: ['title','slug']
+    
+  validate: ->
+    @slug = utils.cleanSlug @slug
+    
+    return 'Site is required' unless @site
+    return 'Slug is required' unless @slug
+    return 'Title is required' unless @title
+
+    # Validate the `slug` to be unique within site
+    found = Scene.select (scene) =>
+      matched = scene.site is @site and scene.slug is @slug
+      if @isNew()
+        matched
+      else
+        scene.id isnt @id and matched
+    return 'Slug has been already used for this site.' if found.length
+
+    # Take care of some dates
+    @updated_at = moment.utc().format()
+
+    published_at = moment(@published_at)
+    return "Published #{utils.msg.DATE_NOT_VALID}" unless published_at.isValid()
+    @published_at = published_at.utc().format()
+
+    # Convert some boolean properties
+    @published = Boolean(@published)
+
+    # Sponsor dates if setting a sponsor
+    if @sponsor_id
+      return 'Sponsor Start Date is required' unless @sponsor_start
+      return 'Sponsor End Date is required' unless @sponsor_end
+      sponsor_start = moment(@sponsor_start)
+      sponsor_end = moment(@sponsor_end)
+      return "Sponsor Start #{utils.msg.DATE_NOT_VALID}" unless sponsor_start.isValid()
+      return "Sponsor End #{utils.msg.DATE_NOT_VALID}" unless sponsor_end.isValid()
+      return 'Sponsor Start Date cannot be later than End Date' if sponsor_start >= sponsor_end
+      # Save in UTC format string
+      @sponsor_start = sponsor_start.utc().format()
+      @sponsor_end = sponsor_end.utc().format()
+
+    # Some content transformation
+    @body = utils.cleanContent @body
+
+    return false
+
+
+module.exports = Scene

+ 1 - 1
admin/server/validate.coffee

@@ -2,7 +2,7 @@ utils = require('lib/utils')
 
 exports.validate_doc_update = (newDoc, oldDoc, userCtx) ->
 
-  access = if 'admin' in userCtx.roles or 'manager' in userCtx.roles then true else false
+  access = if '_admin' in userCtx.roles or 'admin' in userCtx.roles or 'manager' in userCtx.roles then true else false
 
   if not access
     throw unauthorized: 'You must have the role admin or manager to make changes'

+ 4 - 0
admin/static/css/theme.styl

@@ -157,6 +157,10 @@ span.label
         &.active
           background: darken($primaryColor, 10%)
 
+      &.seperator
+        margin: 0.5em 0
+        border-bottom: 1px solid rgba(255,255,255,0.5)
+
 
 .main.stack
   position: absolute

+ 6 - 4
admin/templates/main-nav.html

@@ -1,11 +1,13 @@
 <div class="app-logo"><a href="/">kleks</a></div>
 <ul>
   <li class="dashboard"><a class="active" href="#/">Dashboard</a></li>
-  <li class="sites"><a href="#/sites">Sites</a></li>
-  <li class="authors"><a href="#/authors">Authors</a></li>
-  <li class="collections"><a href="#/collections">Collections</a></li>
   <li class="essays"><a href="#/essays">Essays</a></li>
+  <li class="scenes"><a href="#/scenes">Scenes</a></li>
+  <li class="collections"><a href="#/collections">Collections</a></li>
+  <li class="sponsors"><a href="#/sponsors">Sponsors</a></li>
+  <li class="seperator"></li>
+  <li class="sites"><a href="#/sites">Sites</a></li>
   <li class="blocks"><a href="#/blocks">Blocks</a></li>
+  <li class="authors"><a href="#/authors">Authors</a></li>
   <li class="contacts"><a href="#/contacts">Contacts</a></li>
-  <li class="sponsors"><a href="#/sponsors">Sponsors</a></li>
 </ul>

+ 94 - 0
admin/templates/scene-form.html

@@ -0,0 +1,94 @@
+<form class="scene">
+
+  <div class="content">
+    <h1>
+      Scene
+      <button class="fullscreen-button small">Fullscreen</button>
+      <span class="status">{{#if published}}Published{{else}}In Draft{{/if}}</span>
+    </h1>
+    <h3 class="item-title">{{title}}</h3>
+
+    <div class="error-message"></div>
+
+    <div class="field required">
+      <label>Site</label>
+      <select name="site">
+        <option value="" disabled>Choose a site...</option>
+        {{#each sites}}
+        <option value="{{id}}">{{name}} &mdash; {{id}}</option>
+        {{/each}}
+      </select>
+      <div class="site-selected"></div>
+    </div>
+    <div class="field required">
+      <label>Title</label>
+      <input type="text" name="title" value="{{title}}" placeholder="Write a smart title">
+    </div>
+    <div class="field">
+      <div class="field-left required">
+        <label>Slug</label>
+        <input type="text" name="slug" value="{{slug}}" placeholder="All lowercase and hyphens but NO spaces">
+      </div>
+      <div class="field-right required">
+        <label>Author</label>
+        <select name="author_id"></select>
+      </div>
+    </div>
+    <div class="field">
+      <label>Content as <a class="markdown-help">Markdown/HTML</a> | <a class="fullscreen-button">Fullscreen</a> | <a class="import-button">Import</a></label>
+      <textarea name="body" placeholder="Write your scene content as Markdown/HTML">{{body}}</textarea>
+    </div>
+  </div>
+
+  <div class="sidebar">
+    <div class="buttons">
+      <button class="save-button">Save</button>
+      <button class="cancel-button plain">Cancel</button>
+      {{#if _id}}<a class="delete-button" href="#">delete {{type}}</a>{{/if}}
+    </div>
+
+    <div class="top-spacer"></div>
+
+    <div class="field">
+      <div class="field-left">
+        <label>Published</label>
+        <input type="checkbox" name="published">
+      </div>
+      <div class="field-right">
+        <label>Published At</label>
+        <input type="text" name="published_at" value="{{published_at}}" placeholder="ex. Feb 20 2012 6:30 PM or leave blank">
+      </div>
+    </div>
+
+    <h3 class="heading">Collections *</h3>
+    <div class="field">
+      <div class="collections-list"></div>
+    </div>
+
+    <h3 class="heading">Files</h3>
+    <div class="field">
+      <div class="upload-ui"></div>
+    </div>
+
+    <h3 class="heading">Sponsorship</h3>
+    <div class="field">
+      <div class="field-left">
+        <label>Sponsor</label>
+        <select name="sponsor_id">
+          <option value="">Select a sponsor...</option>
+          {{#each sponsors}}
+          <option value="{{id}}">{{name}}</option>
+          {{/each}}
+        </select>
+      </div>
+      <div class="field-right">
+        <label>Start</label>
+        <input type="text" name="sponsor_start" value="{{sponsor_start}}" placeholder="Feb 20 2012 6:30 PM">
+        <br class="clearfix">
+        <label>End</label>
+        <input type="text" name="sponsor_end" value="{{sponsor_end}}" placeholder="Feb 20 2012 6:30 PM">
+      </div>
+    </div>
+  </div>
+
+</form>

+ 24 - 0
admin/templates/scenes.html

@@ -0,0 +1,24 @@
+<div class="content">
+  <h1>
+    Scenes
+    <a class="new" href="#/scene/new">+</a>
+    <span class="count">{{scenes.length}}</span>
+  </h1>
+  <ul class="scenes list">
+    {{#each scenes}}
+    <li>
+      <div class="actions">
+        {{#unless published}}<div><strong>In Draft</strong></div>{{/unless}}
+      </div>
+      <a href="#/scene/{{id}}">{{title}}</a>
+      <div class="meta">
+        <div><a href="http://{{site}}/scene/{{slug}}" target="_blank">view on {{site}}</a></div>
+      </div>
+    </li>
+    {{/each}}
+  </ul>
+</div>
+
+<div class="sidebar">
+  
+</div>

+ 1 - 1
site/server/rewrites.coffee

@@ -39,7 +39,7 @@ module.exports = [
     }
   }
 
-# Collection's JSON view - list of essays ONLY
+  # Collection's JSON view - list of essays ONLY
   # `:slug` is the collection's slug
   {
     from: '/render/:site/json/collection/:slug',

+ 2 - 0
site/static/css/setup-theme.styl

@@ -42,6 +42,8 @@ setupTheme($primaryColor = $blueColor, $secondaryColor = $lightGrey, $linkColor
         background: darken($primaryColor, 10%)
     > ul
       background: $primaryColor
+      &:before
+        background: $primaryColor
       > li
         a
           &:hover

+ 16 - 0
site/static/css/theme.styl

@@ -176,6 +176,9 @@
   // see above
   right: 140px
 
+  &.second
+    right: 70px
+
   $iconWidth = 144px
 
   .icon
@@ -392,6 +395,19 @@ article.essay
           display: inline-block
           margin-right: 2em
 
+article.scene
+
+  > .photo
+    position: relative
+    left: -40px
+    width: 105%
+    max-height: 360px
+    margin: 0 0 1em 0
+    overflow: hidden
+
+    img
+      width: 100%
+
 
 footer
   margin-top: 2em

+ 6 - 0
site/templates/essay.html

@@ -7,6 +7,12 @@
   <section class="photo">
     <img src="/file/{{essay._id}}/{{essay.photo}}">
   </section>
+  {{else}}
+    {{#if essay.old_photo}}
+    <section class="photo old">
+      <img src="{{essay.old_photo}}">
+    </section>
+    {{/if}}
   {{/if}}
 
   <section class="intro">

+ 99 - 0
site/templates/scene.html

@@ -0,0 +1,99 @@
+{{#if scene.css}}<style>{{{scene.css}}}</style>{{/if}}
+
+<article class="view scene">
+  <h2 class="title"><a href="{{baseURL}}/scene/{{scene.slug}}">{{scene.title}}</a></h2>
+
+  {{#if scene.photo}}
+  <section class="photo">
+    <img src="/file/{{scene._id}}/{{scene.photo}}">
+  </section>
+  {{else}}
+    {{#if scene.old_photo}}
+    <section class="photo old">
+      <img src="{{scene.old_photo}}">
+    </section>
+    {{/if}}
+  {{/if}}
+
+  <section class="intro">
+    {{{scene.intro_html}}}
+  </section>
+
+  {{#if sponsor}}
+  <section class="sponsor scene-sponsor">
+    <div class="label">
+      {{#if sponsor.label}}
+        {{{sponsor.label}}}
+      {{else}}
+        Scene is sponsored by
+      {{/if}}
+    </div>
+
+    {{#if sponsor.text_format}}
+    <div class="name">
+      {{#if sponsor.link}}
+        <a href="{{sponsor.link}}">{{{sponsor.name}}}</a>
+      {{else}}
+        {{{sponsor.name}}}
+      {{/if}}
+    </div>
+    <div class="content">{{{sponsor.content}}}</div>
+    {{/if}}
+
+    {{#if sponsor.image_format}}
+    <div class="image">
+      {{#if sponsor.link}}
+        <a href="{{sponsor.link}}" target="_blank"><img src="{{#if sponsor.image}}{{sponsor.image}}{{else}}{{sponsor.content}}{{/if}}" alt=""></a>
+      {{else}}
+        <img src="{{#if sponsor.image}}{{sponsor.image}}{{else}}{{sponsor.content}}{{/if}}" alt="">
+      {{/if}}
+    </div>
+    {{/if}}
+
+    {{#if sponsor.video_format}}
+    <div class="video">{{{sponsor.content}}}</div>
+    {{/if}}
+  </section>
+  {{/if}}
+
+  <section class="body">
+    {{{scene.body_html}}}
+  </section>
+
+  <section class="meta">
+    <div class="published"><span class="label">Published</span> {{{scene.published_at_html}}}</div>
+    <div class="updated"><span class="label">Updated</span> {{{scene.updated_at_html}}}</div>
+    {{#each collections}}
+    <div class="collection"><a href="{{baseURL}}/collection/{{slug}}">{{name}}</a></div>
+    {{/each}}
+  </section>
+
+  {{#if author}}
+  <section class="author">
+    {{#if author.photo}}
+    <div class="photo"><img src="{{author.photo}}" alt="{{author.name}}"></div>
+    {{/if}}
+    <div class="content">
+      <h5 class="name"><span class="label">Author</span> {{author.name}}</h5>
+      <div class="bio">{{{author.bio}}}</div>
+      <ul class="links">
+        {{#each author.links}}
+        <li><a href="{{url}}" target="_blank">{{label}}</a></li>
+        {{/each}}
+      </ul>
+    </div>
+  </section>
+  {{/if}}
+</article>
+
+<nav class="collection-nav second" data-id="{{collection._id}}" data-slug="{{collection.slug}}">
+  <div class="icon" data-track-click="Collection Nav Icon">
+    <div>Collection</div><i></i>
+  </div>
+  <ul>
+    <li class="heading">
+      <small>5 Scenes in Collection:</small><br>
+      <a href="{{baseURL}}/collection/{{collection.slug}}">{{collection.name}}</a>
+    </li>
+  </ul>
+</nav>

+ 16 - 0
site/templates/scenes.html

@@ -0,0 +1,16 @@
+<article class="scenes">
+  <h2 class="title">Scenes List</h2>
+
+  <ul class="list">
+    {{#each scenes}}
+    <li class="item">
+      <h3 class="title"><a href="{{baseURL}}/scene/{{slug}}">{{title}}</a></h3>
+      <div class="meta">
+        {{#each collection_docs}}
+        <a class="collection" href="{{baseURL}}/collection/{{slug}}">{{name}}</a>
+        {{/each}}
+      </div>
+    </li>
+    {{/each}}
+  </ul>
+</article>