Jump To …

drops.coffee

Drop

Represents an item uploaded to CloudApp.

Drop = Backbone.Model.extend

A Drop is unselected by default.

  defaults:
    selected: false

Construct the URL for the Drop's representation on the server. A Drop that was restored from the trash needs to pass along deleted=true in the querystring to indicate the trash is being modified.

  url: ->
    base = '/items'

    if @isNew()
      base
    else
      params = if not @get('deleted_at') and @previous('deleted_at')
                 '?deleted=true'
               else
                 ''

      "#{ base }/#{ @id }#{ params }"

Create a presenter for a Drop to be fed into a template and rendered. It probably makes the most sense to move this into DropView as the Drop needn't be concerned with how to render itself.

  toTemplateData: ->
    _.extend
      is_image:         -> @item_type == 'image'
      name_or_url:      -> @name || @redirect_url || @url
      created_duration: -> $.timeago @created_at
      @attributes

Return a copy of the model's attributes object. Must override this method because the API requires attributes be wrapped in an item object. This should be fixed in the API.

  toJSON: -> item: _.clone @attributes

Returns true if the Drop is trashed.

  isTrashed: -> @get('deleted_at')?

Drop Collection

A whole bunch of Drop models.

DropCollection = Backbone.Collection.extend
  model: Drop

Setup a new DropsCollection. Accept a socket to be watched for new, updated and deleted Drops.

  initialize: (options) ->
    _.bindAll this, 'created', 'updated', 'deleted'

    @socket = options.socket
    @socket
      .bind('create', @created)
      .bind('update', @updated)
      .bind('delete', @deleted)

Construct the URL to fetch a list of Drops taking into account the current page and filter.

  url: ->
    params = _([ @_filter, @_page ]).compact().join('/') or 'items'

    "/#{ params }?api_version=1.1.0"

The server response includes pagination data. Save it and return the array of Drop attributes.

  parse: (response) ->
    @pagination = new Pager this, response
    response.items

Accept a new filter and page number and fetch the new collection.

  setFilter: (filter, page) ->
    @_filter = filter
    @_page   = page

    @fetch()

Default sort is by ID in reverse order. If the current filter is "popular", sort by number of views highest to lowest.

  comparator: (drop) ->
    sort_column = if drop.collection._filter == 'popular'
                    'view_counter'
                  else
                    'id'

    drop.get(sort_column) * -1

Add the given Drop to the collection. Ignore it if the collection is filtered and the Drop is of a different type.

  addMatching: (drop) ->
    if @_filter and @_filter isnt 'popular'
      singularizedFilter = @_filter.match(/^(\w+?)s?$/)[1]

      return unless singularizedFilter == drop.get('item_type')

    @add drop

Listen for "create" on the socket and add new Drops to the collection.

  created: (drop) -> @addMatching new Drop drop

Listen for "update" on the drops socket. There are many scenarios this handler must take into account.

Updated Drop exists in the collection:

  1. Remove if trashed.
  2. Remove if recovered from trash and viewing the trash.
  3. Otherwise update attributes.

Updated Drop doesn't exist in the collection:

  1. Add if trashed when viewing the trash.
  2. Add if recovered from trash and not viewing the trash.
  3. Otherwise ignore.

One approach to clean this up is to convert filter into a delegate that is called upon to determine the relevance of the Drop instead of the collection being tasked with making that decision.

  updated: (drop) ->
    drop    = new Drop drop
    current = @get drop.id

    if current
      if (@_filter isnt 'trash' and     drop.isTrashed()) or
         (@_filter is   'trash' and not drop.isTrashed())
        @remove current
      else
        current.set drop

    else
      if @_filter is 'trash' and drop.isTrashed()
        @add drop
      else if @_filter isnt 'trash' and not drop.isTrashed()
        @addMatching drop

Listen for "delete" on the drops socket. If this drop exists in the collection, remove it; otherwise ignore it.

  deleted: (drop) -> @remove @get(drop.id) if @get(drop.id)

Pager

Store and present pagination data for a collection. Accepts a collection and its pagination data which will be used to render the UI.

class Pager
  constructor: (collection, attributes) ->
    @collection   = collection
    @currentPage  = attributes.current_page
    @totalPages   = attributes.total_pages
    @perPage      = attributes.per_page
    @totalEntries = attributes.total_entries
    @pages        = @visiblePageNumbers().map (page) =>
                      page:       page
                      path:       @buildPath page
                      createLink: page isnt @currentPage and page isnt '...'

Taking the current page and the total number of pages, compute and return an array containing the visible page numbers. At least 3 numbers on either side of the current page as well as the first and last pages will be included. For example:

[1] 2 3 4 5 6 7 ... 42 

1 2 3 4 5 [6] 7 8 9 ... 42

1 ... 4 5 6 [7] 8 9 ... 42

1 ... 36 37 38 39 [40] 41 42

This entire method is ported from the great will_paginate.

  visiblePageNumbers: ->
    innerWindow = 3
    outerWindow = 0
    windowFrom  = @currentPage - innerWindow
    windowTo    = @currentPage + innerWindow

    if windowTo > @totalPages
      windowFrom -= windowTo - @totalPages
      windowTo   = @totalPages

    if windowFrom < 1
      windowTo   += 1 - windowFrom
      windowFrom = 1
      windowTo   = @totalPages if windowTo > @totalPages

    visible  = [1..@totalPages]
    leftGap  = [Math.min(2 + outerWindow, windowFrom)...windowFrom]
    rightGap = [Math.min(windowTo + 1, @totalPages - outerWindow)...(@totalPages - outerWindow)]

    if leftGap.length > 1
      visible = _(visible).reject (page) -> _(leftGap).include(page)
      visible.splice(outerWindow + 1, 0, '...')

    if rightGap.length > 1
      visible = _(visible).reject (page) -> _(rightGap).include(page)
      visible.splice(visible.length - outerWindow - 1, 0, '...')

    visible

Feed this data to the pager template and return the resulting HTML. If there are less than 2 pages, pagination is unnecessary so an empty string is returned instead.

  render: -> if @totalPages > 1 then JST.pager(this) else ''

Construct the path for a given page in the format #/filter/page. Filter is omitted if collection isn't filtered.

  buildPath: (page) ->
    "#/#{ _([ @collection._filter, page ]).compact().join('/') }"

Pretty self-explanatory view helper methods for building an almost sentient pager UI.

  isFirstPage: -> @currentPage == 1
  isLastPage:  -> @currentPage == @totalPages
  prevPath:    -> @buildPath @currentPage - 1
  nextPath:    -> @buildPath @currentPage + 1

EmptyMessage

Present a friendly message when a collection is empty. An EmptyMessage takes the name of a filter which is used to customize the message.

class EmptyMessage
  constructor: (filter) -> @filter = filter

Cram this EmptyMessage into the empty message template and return the resulting HTML.

  render: -> JST.empty this

Return a friendly message based on filter.

  message: ->
    if !@filter or @filter is 'popular'
      return "You haven't created anything yet!"

    description = switch @filter
                    when 'archives'  then "uploaded any archives"
                    when 'audio'     then 'uploaded any audio'
                    when 'bookmarks' then 'bookmarked any links'
                    when 'images'    then 'uploaded any images'
                    when 'other'     then "uploaded any random or unknown file types"
                    when 'trash'     then null
                    when 'video'     then 'uploaded any videos'
                    else "uploaded any #{ @filter } files"

    if description is null then '' else "You haven't #{ description }!"

View helper method to detect if the trash is being viewed.

  isTrash: -> @filter is 'trash'

DropView

A UI representation of a Drop as an li element.

DropView = Backbone.View.extend
  tagName: 'li'

Setup a new DropView. Watch for changes in the Drop and refresh the UI.

  initialize: ->
    _.bindAll this, 'render', 'remove'

    @model
      .bind('change', @render)
      .bind('remove', @remove)

Cram the model into the drop template and dump the resulting HTML into the view.

  render: ->
    $(@el).html JST.drop(@model.toTemplateData())
    this

Remove this Drop from the view.

  remove: -> $(@el).remove()

Show the edit Drop form, focus the name field, and bind a few events to facilitate saving.

  active: ->
    $(@el)
      .addClass('active')
      .find("input[name='item[name]']").focus()

    @bindSaveHelpers()

Hide the edit Drop form and unbind edit-related events.

  inactive: ->
    $(@el).removeClass('active')

    @unbindSaveHelpers()

Listen for "click" outside of el or the Esc key pressed and save the Drop. Namespace the events unique to the Drop for ease of unbinding.

  bindSaveHelpers: ->
    $(@el).bind("click.drop#{ @model.id }", (e) -> e.stopPropagation())

Defer binding events to the document. If called within a "click" event, it will bubble up and fire immediately.

    _.defer =>
      $(document)
        .bind("click.drop#{ @model.id }", => @save())
        .bind("keyup.drop#{ @model.id }", (e) => @save() if e.keyCode == 27)

Remove the events bound in bindSaveHelpers.

  unbindSaveHelpers: ->
    $(@el).unbind(".drop#{ @model.id }")
    $(document).unbind(".drop#{ @model.id }")

UI Event Handlers

Delegate DOM events within the view to handlers here in DropView.

  events:
    'click  .title a'                     : 'edit'
    'submit form'                         : 'save'
    "change div.select input"             : 'selectedChanged'
    "change input[name='item[name]']"     : 'nameChanged'
    "change input[name='item[private]']"  : 'privacyChanged'
    "change input[name='item[tag_list]']" : 'tagListChanged'

Handle "click" on the edit button. Show the edit Drop form.

  edit: (e) ->
    e.preventDefault()

    @active()

Handle "submit" on the form. Save the Drop and take it out of edit mode. This function may be called without being triggered by a "submit" event.

  save: (e) ->
    e?.preventDefault()

    @inactive()
    @model.save()

Handle "change" on the select checkbox. Pass the new selected state to the Drop and triger "selected" on the model's collection.

  selectedChanged: (e) ->
    @model
      .set({ selected: $(e.target).is(':checked') }, { silent: true })
      .collection.trigger('selected')

Handle "change" on the name input. Pass the new name to the Drop.

  nameChanged: (e) ->
    @model.set { name: $(e.target).val() }, { silent: true }

Handle "change" on the privacy radio buttons. Pass the new privacy to the Drop.

  privacyChanged: (e) ->
    @model.set { 'private': $(e.target).val() == 'true' }, { silent: true }

Handle "change" on the tag list input. Pass the new tag list to the Drop.

  tagListChanged: (e) ->
    @model.set { tag_list: $(e.target).val() }, { silent: true }

DropListView

The UI representation of a DropCollection. Pass it an existing ol to be filled by individual DropViews.

DropListView = Backbone.View.extend

Setup a new DropListView. Watch for changes in the collection and cascade updates down to the individual DropViews. Look for the listType cookie to set the display type of the list.

  initialize: ->
    _.bindAll this, 'addOne', 'addAll', 'remove', 'refreshSelected', 'setView'

    @collection
      .bind('add',      @addOne)
      .bind('refresh',  @addAll)
      .bind('remove',   @remove)
      .bind('selected', @refreshSelected)
      .bind('remove',   @refreshSelected)

    @list = @el.find '#listing'

    listType = $.cookie('listType')
    @setListLayout listType if listType

Render the newly added Drop and add it to the list view based on its sorted position in collection.

  addOne: (drop, collection) ->
    @refreshEmptyMessage()

    index    = collection.indexOf drop
    node     = new DropView(model: drop).render().el
    children = @list.children()

    if index == children.length
      @list.append node
    else
      children.eq(index).before node

Refresh the pagination control and empty collection message.

  remove: ->
    @refreshPagination()
    @refreshEmptyMessage()

Clear the view and render each individual Drop in collection. Add the class "empty" to the list element if the collection is empty and "trash" when viewing the trash.

  addAll: ->
    @list.children().remove()

    @refreshPagination()
    @refreshEmptyMessage()
    @refreshTrashMessage()

    @collection.each (item) => @addOne item, @collection

Handle "selected' and "remove" on collection. Enable the delete/restore button if collection contains any selected Drops or disable otherwise.

  refreshSelected: ->
    @submits or= $('#delete-items :submit, #restore-items :submit')

    if @collection.any((drop) -> drop.get('selected'))
      @submits.removeAttr 'disabled'
    else
      @submits.attr 'disabled': 'disabled'

Grab the updated pager content and dump it into the pagination control.

  refreshPagination: ->
    @pager or= $(@el).find('#pagination')
    @pager.html @collection.pagination.render()

Inspect the collection and apply or remove the "empty" class on the collection element as appropriate.

  refreshEmptyMessage: ->
    if @collection.isEmpty()
      @el.addClass 'empty'

      @empty or= $('#empty')
      @empty.html new EmptyMessage(@collection._filter).render()
    else
      @el.removeClass 'empty'

Add the "trash" class if the collection is showing trashed Drops.

  refreshTrashMessage: ->
    if @collection._filter is 'trash'
      @el.addClass 'trash'
    else
      @el.removeClass 'trash'

Set the layout type for the list. Add the "active" class to the button of the current view and either "grid" or "list" to `list. Save the view as a cookie in order to use as the user's default layout.

  setListLayout: (listType) ->
    $.cookie 'listType', listType, expires: 365

    @listTypeButtons or= $('#toolbar .view button')
    label = listType.charAt(0).toUpperCase() + listType.slice(1)

    @listTypeButtons
      .removeClass('active')
      .filter(":contains('#{ label }')")
        .addClass('active')

    @list
      .removeClass('grid list')
      .addClass(listType)

UI Event Handlers

Delegate DOM events within the view to handlers here in DropListView.

  events:
    'click  #toolbar .view button' : 'changeView'
    'submit #delete-items'         : 'deleteSelectedDrops'
    'submit #restore-items'        : 'restoreSelectedDrops'

Toggle between grid and list layout by adding the "active" class to the button of the current view and either "grid" or "list" to list.

Handle click on either of the list layout buttons to toggle the layout of the list. Ignore clicks on an active button.

  changeView: (e) ->
    target = $(e.target)
    return if target.is '.active'

    @setListLayout target.text().toLowerCase()

Destroy all the selected Drops in collection.

  deleteSelectedDrops: (e) ->
    e.preventDefault()

    @collection.each (item) -> item.destroy() if item.get 'selected'

Restore the selected trashed Drops in collection.

  restoreSelectedDrops: (e) ->
    e.preventDefault()

    @collection.each (item) ->
      if item.get('selected')
        item
          .set({ deleted_at: null }, { silent: true })
          .save()

DropsController

Handle routes for filtering and paging through Drops.

DropsController = Backbone.Controller.extend

Accept a collection which will be passed the filters as the routes are fired.

  initialize: (options) ->
    @collection = options.collection

As Jeremy points out, /#!/ routes are a temporary hack until Backbone supports real routes with HTML5 pushState() and replaceState().

  routes:
    '/:filter'       : 'filter'
    '/:filter/:page' : 'filter'

Filter Drops to a specific file type. Indicate the active filter by toggling the "active" class on items in the library menu.

  filter: (filter, page) ->
    [ page, filter ] = [ filter, undefined ] if /^\d+$/.test(filter)

    @filters or= $('#library aside a')
    href       = "/#{ filter or '' }"

    @filters
      .filter("a[href='#{ href }']")
        .closest('li')
          .addClass('active')
          .end()
        .end()
      .not("a[href='#{ href }']")
        .closest('li')
          .removeClass('active')

Scroll to the top of the collection view before applying the filter when paging. Try to come up with some sort of scalable scrolling timing so both short and long scrolling distances take approximately the same time per pixel. This calculation only works for linear easing, but it's still better than nothing.

    deferred = $.Deferred().then => @collection.setFilter filter, page
    position = $('body').scrollTop()
    @listTop or= window.DropsView.el.offset().top

    if page isnt @collection._page and position > @listTop
      time = (position - @listTop) * 0.5
      $('body').animate { scrollTop: @listTop }, time, -> deferred.resolve()
    else
      deferred.resolve()


$ ->

Wire up the CloudApp drops page. This is where the magic happens. Let's kick things off by creating the global DropCollection and passing it to the DropListView to render.

  window.Drops     = new DropCollection socket: window.dropsSocket
  window.DropsView = new DropListView
                           el:         $('#recent-drops')
                           collection: Drops

Boot up the router.

  new DropsController collection: Drops
  Backbone.history.start()

Don't load the preloaded drops if a filter or page is present. This happens when the page is refreshed and a route is present. Without this check, the preloaded drops will show for a second only to be replaced by the filtered drops once the ajax request is complete. This will be obviated once HTML5 history is implemented.

  if not Drops._filter? and not Drops._page?
    Drops._filter = window.preloadedDropsFilter
    Drops.refresh Drops.parse(window.preloadedDrops)

Links to filter the library are "real" links. Hijack clicks and interpret them as hash links ensuring middle, control, and command clicks act normally. Thanks, Kneath.

  $('#library aside a').click (e) ->
    return true if e.which == 2 or e.metaKey or e.ctrlKey

    e.preventDefault()
    window.location.hash = $(e.target).attr('href')

Intercept submitting the new bookmark form and create it via the DropCollection. Clear the new form field in preparation for bookmarking future URLs.

  $('#add_bookmark').submit (e) ->
    e.preventDefault()

    input = $(this).find("[name='item[redirect_url]']")
    url   = input.val()

    input.val null

The more correct way would be to call Drops.create but this causes the Drop to be inserted twice--once here and once via push notification. Need to handle the push notification and update the item instead of simply tossing it into the collection.

For now we're just creating the new Drop and relying on push notifications to get it into the collection.

    new Drop(redirect_url: url).save()

Handle showing and hiding the global loading indicator. Keep a counter of the number of running processes and only hide the spinner after the last has completed.

  loader =
    running: 0
    link:    _.memoize -> $('#header h1 a')
    message: _.memoize -> loader.link().siblings('.loading')

    begin: ->
      ++loader.running

      loader.link().removeClass('active')
      loader.message().addClass('active')

    complete: ->
      loader.running = Math.max loader.running - 1, 0

      if loader.running is 0
        loader.link().addClass('active')
        loader.message().removeClass('active')

  $('body')

Bind events used to start or complete a loading process and pass it along to the loader object above. .bind('begin.loader', loader.begin) .bind('complete.loader', loader.complete)

    .bind('begin.loader',    loader.begin)
    .bind('complete.loader', loader.complete)

Listen for the global jQuery ajaxStart and ajaxStop events to toggle the visibility of the loading indicator.

    .ajaxStart(loader.begin)
    .ajaxStop(loader.complete)