浏览代码

Create the video type for admin and sites

Markus Ochel 12 年之前
父节点
当前提交
2c25e97bd0

+ 4 - 0
admin/controllers/dashboard.coffee

@@ -3,6 +3,7 @@ Spine       = require('spine/core')
 templates   = require('duality/templates')
 
 Essay       = require('models/essay')
+Video       = require('models/video')
 Scene       = require('models/scene')
 
 
@@ -16,12 +17,14 @@ class DashboardOne extends Spine.Controller
     super
     # @active @render
     Essay.bind 'change refresh', @render
+    Video.bind 'change refresh', @render
     Scene.bind 'change refresh', @render
     Spine.bind 'filterbox:change', @filter
 
   render: =>
     context = 
       essays: Essay.select(@selectFilter)
+      videos: Video.select(@selectFilter)
       scenes: Scene.select(@selectFilter)
     @html templates.render('dashboard.html', {}, context)
 
@@ -45,6 +48,7 @@ class DashboardOne extends Spine.Controller
 
   reload: ->
     Essay.fetch()
+    Video.fetch()
     Scene.fetch()
 
 

+ 3 - 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')
+Video       = require('models/video')
 Scene       = require('models/scene')
 Block       = require('models/block')
 Contact     = require('models/contact')
@@ -85,6 +86,7 @@ class App extends Spine.Controller
     Author.fetch()
     Collection.fetch()
     Essay.fetch()
+    Video.fetch()
     Scene.fetch()
     Block.fetch()
     Contact.fetch()
@@ -97,6 +99,7 @@ class App extends Spine.Controller
     Author.deleteAll()
     Collection.deleteAll()
     Essay.deleteAll()
+    Video.deleteAll()
     Scene.deleteAll()
     Block.deleteAll()
     Contact.deleteAll()

+ 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')
+Videos      = require('controllers/videos')
 Scenes      = require('controllers/scenes')
 Blocks      = require('controllers/blocks')
 Contacts    = require('controllers/contacts')
@@ -22,6 +23,7 @@ class MainStack extends Spine.Stack
     authors:     Authors
     collections: Collections
     essays:      Essays
+    videos:      Videos
     scenes:      Scenes
     blocks:      Blocks
     contacts:    Contacts
@@ -35,6 +37,7 @@ class MainStack extends Spine.Stack
     '/authors':     'authors'
     '/collections': 'collections'
     '/essays':      'essays'
+    '/videos':      'videos'
     '/scenes':      'scenes'
     '/blocks':      'blocks'
     '/contacts':    'contacts'

+ 298 - 0
admin/controllers/videos.coffee

@@ -0,0 +1,298 @@
+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')
+PreviewUI     = require('controllers/ui/preview')
+
+Video       = require('models/video')
+Author      = require('models/author')
+Collection  = require('models/collection')
+Sponsor     = require('models/sponsor')
+Site        = require('models/site')
+
+
+class VideoForm extends Spine.Controller
+  className: 'video 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=intro]':    'formIntro'
+    'textarea[name=body]':     'formBody'
+    '.collections-list':       'collectionsList'
+    '.upload-ui':              'fileUploadContainer'
+    '.save-button':            'saveButton'
+    '.cancel-button':          'cancelButton'
+    'button.fullscreen-button': 'fullscreenButton'
+
+  events:
+    'submit form':              'preventSubmit'
+    'change *[name]':           'markAsDirty'
+    'keyup *[name]':            'markAsDirty'
+    '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) ->
+    @dirtyForm = false
+    @editing = params.id?
+    if @editing
+      @copying = params.id.split('-')[0] is 'copy'
+      if @copying
+        @title = 'Copy Video'
+        @item = Video.find(params.id.split('-')[1]).dup()
+        # Important to indicate that we are creating a new record
+        @editing = false
+      else
+        @item = Video.find(params.id)
+        @title = @item.name
+        
+      # Fetch missing data if need be
+      if not @item.body?
+        @item.ajax().reload {},
+          success: =>
+            @formBody.val(@item.body)
+            @formIntro.val(@item.intro)
+    else
+      @title = 'New Video'
+      @item = {}
+
+    @item.collections ?= []
+    @item._attachments ?= {}
+    
+    @item.sites = Site.all().sort(Site.nameSort)
+    @item.sponsors = Sponsor.all().sort(Sponsor.nameSort)
+    @html templates.render('video-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
+      changeCallback: @markAsDirty
+    @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']
+      changeCallback: @markAsDirty
+    @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
+      @previewUI?.close()
+    else
+      @form.addClass('fullscreen')
+      @fullscreenButton.html "Exit #{@fullscreenButtonText}"
+      @previewUI = new PreviewUI field: @formBody
+
+  import: (e) =>
+    # For importing old HTML to Markdown directly from old location
+    e?.preventDefault()
+    url = $.trim prompt("Paste a URL from #{@formSite.val()}", @item.old_url or '')
+    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)
+
+          if not @item.old_url
+            @formTitle.val($title.text()) if $title
+            $slug = @form.find('input[name=slug]')
+            unless slug.val()
+              $slug.val($title.attr('href').replace('www.', '').replace("http://#{@formSite.val().replace('www.', '')}", '')) if $title
+            @formAuthorId.val($author.text()) if $author
+            @form.find('input[name=published_at]').val($date.text()) if $date
+
+  save: (e) ->
+    e.preventDefault()
+    if not navigator.onLine
+      alert "Can not save. You are OFFLINE."
+      return
+
+    if @editing
+      @item.fromForm(@form)
+    else
+      @item = new Video().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)
+  
+  destroy: (e) ->
+    e.preventDefault()
+    if @item and confirm "Are you sure you want to delete this item?"
+      @item.destroy()
+      @back()
+
+  markAsDirty: =>
+    @dirtyForm = true
+    @saveButton.addClass('glow')
+
+  cancel: (e) ->
+    e.preventDefault()
+    if @dirtyForm
+      if confirm "You may have some unsaved changes.\nAre you sure you want to proceed?"
+        @back()
+    else
+      @back()
+
+  back: ->
+    @navigate('/videos/list')
+
+  preventSubmit: (e) ->
+    e.preventDefault()
+    return false
+    
+  deactivate: ->
+    @el.scrollTop(0)
+    super
+
+
+class VideoList extends Spine.Controller
+  className: 'video list panel'
+
+  events:
+    'click h1 .count':    'reload'
+
+  constructor: ->
+    super
+    # @active @render
+    Video.bind 'change refresh', @render
+    Spine.bind 'filterbox:change', @filter
+
+  render: =>
+    context = 
+      videos: Video.filter(@filterObj).sort(Video.titleSort)
+    @html templates.render('videos.html', {}, context)
+
+  filter: (@filterObj) =>
+    @render()
+    @el.scrollTop(0)
+
+  reload: ->
+    Video.fetch()
+
+
+class Videos extends Spine.Stack
+  className: 'videos panel'
+
+  controllers:
+    list: VideoList
+    form: VideoForm
+
+  default: 'list'
+
+  routes:
+    '/videos/list': 'list'
+    '/video/new':   'form'
+    '/video/:id':   'form'
+
+  constructor: ->
+    super
+    for k, v of @controllers
+      @[k].active => @active()
+
+
+module.exports = Videos

+ 68 - 0
admin/models/video.coffee

@@ -0,0 +1,68 @@
+Spine = require('spine/core')
+require('lib/spine-couch-ajax')
+
+utils = require('lib/utils')
+moment = require('lib/moment')
+
+BaseModel = require('models/base')
+
+class Video extends BaseModel
+  @configure "Video", "site", "slug", "title", "intro", "body", "video", "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 = Video.select (video) =>
+      matched = video.site is @site and video.slug is @slug
+      if @isNew()
+        matched
+      else
+        video.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) or moment()
+    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
+    @intro = utils.cleanContent @intro
+    @body = utils.cleanContent @body
+
+    return false
+
+
+module.exports = Video

+ 5 - 1
admin/static/css/theme.styl

@@ -482,6 +482,9 @@ span.label
         input[name='code']
           font-size: 1.5em
 
+        textarea[name='video']
+          height: 60px
+
         textarea[name='body'],
         textarea[name='content']:not(.default)
           height: 600px
@@ -575,7 +578,8 @@ span.label
           z-index: 100
           transition(0.3s, all)
 
-        textarea[name='intro']
+        textarea[name='intro'],
+        textarea[name='video']
           display: none
 
         .ui-preview

+ 20 - 0
admin/templates/dashboard.html

@@ -21,6 +21,26 @@
   <div class="no-data">No items to show in this view.</div>
   {{/if}}
 
+  <h3>Draft Videos</h3>
+  {{#if videos}}
+  <ul class="drafts videos list">
+    {{#each videos}}
+    <li>
+      <div class="actions">
+        {{#unless collections}}<i class="icon-collection" title="Not in a collection"></i>{{/unless}}
+        {{#unless published}}<i class="icon-pencil" title="In draft mode"></i>{{/unless}}
+      </div>
+      <a class="title" href="#/{{type}}/{{id}}"><i class="icon icon-{{type}}"></i>{{title}}</a>
+      <div class="meta">
+        <div><a href="http://{{site}}/{{type}}/{{slug}}" target="_blank"><strong>{{type}}</strong> on {{site}}</a></div>
+      </div>
+    </li>
+    {{/each}}
+  </ul>
+  {{else}}
+  <div class="no-data">No items to show in this view.</div>
+  {{/if}}
+
   <h3>Draft Scenes</h3>
   {{#if scenes}}
   <ul class="drafts scenes list">

+ 117 - 0
admin/templates/video-form.html

@@ -0,0 +1,117 @@
+<form class="video">
+
+  <div class="content">
+    <h1>
+      Video <i class="icon icon-video"></i>
+      <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">
+        <label>Author</label>
+        <select name="author_id"></select>
+      </div>
+    </div>
+    <div class="field">
+      <label>Video Embed <small>640 × 360</small></label>
+      <textarea name="video" placeholder="Paste video embed IFRAME code">{{video}}</textarea>
+    </div>
+    <div class="field">
+      <label>Intro as <a class="markdown-help">Markdown/HTML</a></label>
+      <textarea name="intro" placeholder="Provide an video introduction text as Markdown/HTML">{{intro}}</textarea>
+    </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 video content as Markdown/HTML">{{body}}</textarea>
+    </div>
+  </div>
+
+  <div class="sidebar">
+    <div class="buttons">
+      <button class="save-button" tabindex="0">Save</button>
+      <button class="cancel-button plain" tabindex="0">Cancel</button>
+      {{#if _id}}<div class="delete-button"><i class="icon-trash"></i></div>{{/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="note">Main photo must be 900px by 180px strictly.</div>
+      <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>
+
+    {{#if old_url}}
+    <h3 class="heading">Old Data</h3>
+    <div class="field">
+      <label>Old URL</label>
+      <div><a href="{{old_url}}" target="_blank" title="Open in New Tab">{{old_url}}</a></div>
+    </div>
+    <div class="field">
+      <label>Old Photos</label>
+      {{#each old_photos}}
+      <div><a href="{{.}}" target="_blank" title="Open in New Tab">{{.}}</a></div>
+      {{/each}}
+    </div>
+    {{/if}}
+  </div>
+
+</form>

+ 25 - 0
admin/templates/videos.html

@@ -0,0 +1,25 @@
+<div class="content">
+  <h1>
+    Videos
+    <a class="new" href="#/video/new">+</a>
+    <span class="count">{{videos.length}}</span>
+  </h1>
+  <ul class="videos list">
+    {{#each videos}}
+    <li>
+      <div class="actions">
+        {{#unless collections}}<i class="icon-collection" title="Not in a collection"></i>{{/unless}}
+        {{#unless published}}<i class="icon-pencil" title="In draft mode"></i>{{/unless}}
+      </div>
+      <a class="title" href="#/video/{{id}}"><i class="icon icon-video"></i>{{title}}</a>
+      <div class="meta">
+        <div><a href="http://{{site}}/video/{{slug}}" target="_blank">view on {{site}}</a></div>
+      </div>
+    </li>
+    {{/each}}
+  </ul>
+</div>
+
+<div class="sidebar">
+  
+</div>

+ 0 - 2
site/lib/app.coffee

@@ -4,8 +4,6 @@ moment  = require('lib/moment')
 require('lib/fastclick')
 
 exports.initialize = (config) ->
-  touch = Modernizr.touch
-
   # Use the fastclick module for touch devices.
   # Add a class of `needsclick` of the original click
   # is needed.

+ 3 - 1
site/static/css/responsive.styl

@@ -28,6 +28,7 @@
   article
 
     > .photo
+    > .video
     > .intro > .photo
     > .home-block > .photo
       left: -14px
@@ -110,7 +111,7 @@
   //-- END NAVIGATION
  
   article
-    
+   
     > .body
 
       img
@@ -171,6 +172,7 @@
   article
 
     > .photo
+    > .video
     > .intro > .photo
     > .home-block > .photo
       left: -20px

+ 30 - 14
site/static/css/theme.styl

@@ -319,6 +319,35 @@ article
     &.large
       max-height: initial
 
+  > .video
+    position: relative
+    left: -40px
+    width: 105%
+    max-height: 360px
+    margin: 0 0 2em 0
+    background: #000
+    overflow: hidden
+
+    .wrapper
+      display: block
+      position: relative
+      padding-bottom: 56.25% /* 16:9 */
+      padding-top: 25px
+      height: 0
+      max-width: 640px
+      margin: 0 auto
+
+      iframe
+      object
+      embed
+        display: block
+        position: absolute
+        top: 0
+        left: 0
+        width: 100% !important
+        height: 100% !important
+        max-height: 360px
+
   > .intro
     margin-bottom: 2em
     font-size: 1.125em
@@ -402,6 +431,7 @@ article
       overflow: hidden
       iframe
         display: block
+        max-width: 100%
 
   > ul.list
     list-style-type: none
@@ -542,20 +572,6 @@ article.scene
     max-height: initial
     margin-bottom: 1em
 
-    .scene-prev
-    .scene-next
-      position: absolute
-      bottom: -20px
-      width: 40px
-      height: 40px
-      border: 4px solid #fff
-
-    .scene-prev
-      left: -4px
-
-    .scene-next
-      right: -4px
-
   > .body
     font-size: 1.3em
     line-height: 1.5em

+ 1 - 1
site/templates/doc.html

@@ -12,7 +12,7 @@
 
   {{#if doc.video}}
   <section class="video">
-    {{doc.video}}
+    <div class="wrapper">{{{doc.video}}}</div>
   </section>
   {{/if}}