drops.coffee | |
|---|---|
DropRepresents 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 | toJSON: -> item: _.clone @attributes |
| Returns | isTrashed: -> @get('deleted_at')? |
Drop CollectionA 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 | 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
| 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 | created: (drop) -> @addMatching new Drop drop |
| Listen for Updated Drop exists in the collection:
Updated Drop doesn't exist in the collection:
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 | deleted: (drop) -> @remove @get(drop.id) if @get(drop.id) |
PagerStore 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:
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 | 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 |
EmptyMessagePresent 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 | 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' |
DropViewA UI representation of a Drop as an | 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 | bindSaveHelpers: ->
$(@el).bind("click.drop#{ @model.id }", (e) -> e.stopPropagation()) |
| Defer binding events to the document. If called within a | _.defer =>
$(document)
.bind("click.drop#{ @model.id }", => @save())
.bind("keyup.drop#{ @model.id }", (e) => @save() if e.keyCode == 27) |
| Remove the events bound in | unbindSaveHelpers: ->
$(@el).unbind(".drop#{ @model.id }")
$(document).unbind(".drop#{ @model.id }") |
UI Event HandlersDelegate 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 | edit: (e) ->
e.preventDefault()
@active() |
| Handle | save: (e) ->
e?.preventDefault()
@inactive()
@model.save() |
| Handle | selectedChanged: (e) ->
@model
.set({ selected: $(e.target).is(':checked') }, { silent: true })
.collection.trigger('selected') |
| Handle | nameChanged: (e) ->
@model.set { name: $(e.target).val() }, { silent: true } |
| Handle | privacyChanged: (e) ->
@model.set { 'private': $(e.target).val() == 'true' }, { silent: true } |
| Handle | tagListChanged: (e) ->
@model.set { tag_list: $(e.target).val() }, { silent: true } |
DropListViewThe UI representation of a DropCollection. Pass it an existing | 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 | 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 | addAll: ->
@list.children().remove()
@refreshPagination()
@refreshEmptyMessage()
@refreshTrashMessage()
@collection.each (item) => @addOne item, @collection |
| Handle | 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 | refreshEmptyMessage: ->
if @collection.isEmpty()
@el.addClass 'empty'
@empty or= $('#empty')
@empty.html new EmptyMessage(@collection._filter).render()
else
@el.removeClass 'empty' |
| Add the | refreshTrashMessage: ->
if @collection._filter is 'trash'
@el.addClass 'trash'
else
@el.removeClass 'trash' |
| Set the layout type for the list. Add the | 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 HandlersDelegate 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 | |
| Handle | changeView: (e) ->
target = $(e.target)
return if target.is '.active'
@setListLayout target.text().toLowerCase() |
| Destroy all the selected Drops in | deleteSelectedDrops: (e) ->
e.preventDefault()
@collection.each (item) -> item.destroy() if item.get 'selected' |
| Restore the selected trashed Drops in | restoreSelectedDrops: (e) ->
e.preventDefault()
@collection.each (item) ->
if item.get('selected')
item
.set({ deleted_at: null }, { silent: true })
.save() |
DropsControllerHandle routes for filtering and paging through | 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 | routes:
'/:filter' : 'filter'
'/:filter/:page' : 'filter' |
| Filter | 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
| $('#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 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)
|