├── src ├── coffee │ ├── EventEmitter.coffee │ ├── Util.coffee │ ├── main.coffee │ ├── Cache.coffee │ ├── CommandQueue.coffee │ ├── ListForm.coffee │ ├── Keymap.coffee │ ├── Bootstrap.coffee │ ├── CommandAggregator.coffee │ ├── UserStream.coffee │ ├── Command.coffee │ ├── Selection.coffee │ ├── List.coffee │ ├── TwitterService.coffee │ ├── Toolbar.coffee │ └── ListCollection.coffee ├── layout │ ├── partials │ │ ├── modal.tpl.html │ │ ├── list-remove.tpl.html │ │ ├── over-limits.tpl.html │ │ ├── footer.tpl.html │ │ ├── user.tpl.html │ │ ├── navbar-right.tpl.html │ │ ├── save-progress.tpl.html │ │ ├── user-detail.tpl.html │ │ ├── list.tpl.html │ │ ├── list-form.tpl.html │ │ └── collection.tpl.html │ └── index.tpl.html └── sass │ ├── _common.scss │ ├── autocomplete.scss │ └── main.scss ├── reorder bar idea.gif ├── .gitignore ├── bower.json ├── package.json ├── README.md └── Gruntfile.coffee /src/coffee/EventEmitter.coffee: -------------------------------------------------------------------------------- 1 | class EventEmitter 2 | Eventify.enable @:: 3 | -------------------------------------------------------------------------------- /reorder bar idea.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/donmccurdy/powerline/master/reorder bar idea.gif -------------------------------------------------------------------------------- /src/coffee/Util.coffee: -------------------------------------------------------------------------------- 1 | RegExp.quote = (string) -> 2 | string.replace /[-\\^$*+?.()|[\]{}]/g, '\\$&' 3 | -------------------------------------------------------------------------------- /src/coffee/main.coffee: -------------------------------------------------------------------------------- 1 | # Main 2 | bootstrap = new Bootstrap() 3 | bootstrap.start() 4 | 5 | # Exports 6 | window.bootstrap = bootstrap 7 | window.collection = bootstrap.collection 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Deploy folder 2 | build 3 | 4 | # Dependencies 5 | node_modules 6 | bower_components 7 | 8 | # Side Effects 9 | .DS_Store 10 | .sass-cache 11 | 12 | # Environment 13 | .env 14 | -------------------------------------------------------------------------------- /src/layout/partials/modal.tpl.html: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/layout/partials/list-remove.tpl.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 12 | -------------------------------------------------------------------------------- /src/layout/partials/over-limits.tpl.html: -------------------------------------------------------------------------------- 1 | 4 | 10 | -------------------------------------------------------------------------------- /src/layout/partials/footer.tpl.html: -------------------------------------------------------------------------------- 1 |
2 | 6 | 14 |
-------------------------------------------------------------------------------- /src/layout/partials/user.tpl.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {{- user.name }} profile picture 4 |
5 |
6 | {{- user.name }} 7 | {{ if (user.protected) { }} 8 | 9 | {{ } }} 10 |
11 |
@{{- user.screen_name }}
12 |
13 | 14 |
15 |
16 | -------------------------------------------------------------------------------- /src/layout/partials/navbar-right.tpl.html: -------------------------------------------------------------------------------- 1 | {{ if (!user) { }} 2 |
  • 3 | 4 | 5 | Sign In 6 | 7 |
  • 8 | {{ } else { }} 9 |
  • 10 | 11 | {{- user.name }} 12 | 13 | 22 |
  • 23 | profile image 26 | {{ } }} -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "powerline", 3 | "version": "0.1.4", 4 | "authors": [ 5 | "Don McCurdy " 6 | ], 7 | "description": "Create and update Twitter lists through a clean, fast, and simple interface.", 8 | "moduleType": [ 9 | "amd" 10 | ], 11 | "keywords": [ 12 | "twitter", 13 | "client", 14 | "list", 15 | "management" 16 | ], 17 | "license": "MIT", 18 | "homepage": "https://powerline.io", 19 | "private": true, 20 | "ignore": [ 21 | "**/.*", 22 | "node_modules", 23 | "bower_components", 24 | ".sass-cache" 25 | ], 26 | "dependencies": { 27 | "jquery": "~2.1.3", 28 | "asg.js": "~0.5.7", 29 | "mousetrap": "~1.4.6", 30 | "hello": "~1.4.1", 31 | "jquery-sortable": "~0.9.12" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/layout/partials/save-progress.tpl.html: -------------------------------------------------------------------------------- 1 | 4 | 17 | -------------------------------------------------------------------------------- /src/sass/_common.scss: -------------------------------------------------------------------------------- 1 | /* Variables 2 | *********************************/ 3 | 4 | $bodyWhite: #ffffff; 5 | $bodyBlack: #444444; 6 | $lightGrey: #9d9d9d; 7 | $borderLight: #f0f0f0; 8 | $borderDark: #ebebeb; 9 | $accent: #63BFB6; 10 | $accentNew: #e0f0e0; 11 | 12 | /* Vendor Prefixing 13 | *********************************/ 14 | 15 | $VENDORS: webkit, moz, ms, o; 16 | 17 | @mixin vendor-prefix($property, $values...) { 18 | @each $vendor in $VENDORS { 19 | -#{$vendor}-#{$property}: $values; 20 | } 21 | #{$property}: $values; 22 | } 23 | 24 | @mixin border-radius($radius) { 25 | @include vendor-prefix(border-radius, $radius); 26 | } 27 | 28 | @mixin box-shadow($args...) { 29 | @include vendor-prefix(box-shadow, $args); 30 | } 31 | 32 | @mixin box-sizing($args...) { 33 | @include vendor-prefix(box-sizing, $args); 34 | } 35 | 36 | @mixin user-select($args...) { 37 | @include vendor-prefix(user-select, $args); 38 | } -------------------------------------------------------------------------------- /src/coffee/Cache.coffee: -------------------------------------------------------------------------------- 1 | # Cache Service 2 | # 3 | # Basic implementation of an in-memory + LocalStorage cache. 4 | # 5 | class Cache 6 | 7 | @instance: null 8 | 9 | @create: (userID) -> 10 | unless @instance 11 | @instance = new Cache(userID) 12 | @instance 13 | 14 | constructor: (@userID) -> 15 | @cache = JSON.parse(localStorage.getItem @getBin()) 16 | date = new Date().toLocaleDateString() 17 | unless @cache?.date is date 18 | @cache = {} 19 | @cache.date = date 20 | 21 | get: (key) -> 22 | @cache[key] 23 | 24 | set: (key, value) -> 25 | @cache[key] = value 26 | @persist() 27 | @ 28 | 29 | persist: () -> 30 | localStorage.setItem @getBin(), JSON.stringify @cache 31 | @ 32 | 33 | clear: (key) -> 34 | delete @cache[key] 35 | @persist() 36 | @ 37 | 38 | getBin: (key) -> 39 | "tmp-cache-#{@userID}" 40 | 41 | bind: (key, fetch) -> 42 | if data = @get(key) 43 | deferred = $.Deferred().resolve data 44 | else 45 | fetch().done (data) => 46 | @set(key, data) 47 | -------------------------------------------------------------------------------- /src/coffee/CommandQueue.coffee: -------------------------------------------------------------------------------- 1 | # 2 | # CommandQueue 3 | # 4 | # Implements (or at least leaves room for 5 | # eventual implementation of) undo/redo 6 | # functionality. 7 | # 8 | class CommandQueue 9 | constructor: (@collection) -> 10 | @finalized = [] 11 | @undoQueue = [] 12 | @redoQueue = [] 13 | 14 | push: (cmd) -> 15 | cmd.execute @collection 16 | @undoQueue.push cmd 17 | @redoQueue.length = 0 18 | 19 | pop: (cmd) -> 20 | cmd = @undoQueue.pop() 21 | cmd.undo @collection 22 | @redoQueue.push cmd 23 | 24 | isModified: () -> 25 | !!@undoQueue.length 26 | 27 | save: () -> 28 | changes = new CommandAggregator(@collection.twitter, @undoQueue) 29 | changes.apply() 30 | .done => 31 | @finalized = @undoQueue 32 | @undoQueue = [] 33 | @redoQueue = [] 34 | @reload(changes.getListIDs()) 35 | .fail -> 36 | console.log "Could not save changes" 37 | console.log arguments 38 | 39 | reload: (listIDs) -> 40 | for listID in listIDs 41 | list = @collection.getList listID 42 | list.reload() -------------------------------------------------------------------------------- /src/coffee/ListForm.coffee: -------------------------------------------------------------------------------- 1 | class ListForm extends EventEmitter 2 | 3 | DEFAULT_METADATA: 4 | id: 0 5 | name: '' 6 | description: '' 7 | mode: 'private' 8 | 9 | constructor: (@twitter, @metadata = {}) -> 10 | @metadata = _.merge @metadata, @DEFAULT_METADATA 11 | @el = $ JST['modal'](content: JST['list-form'](@metadata)) 12 | @el.modal 13 | backdrop: true 14 | keyboard: true 15 | show: true 16 | @el.on 'click', '.btn-primary', => @save() 17 | @el.on 'hidden.bs.modal', => @close() 18 | 19 | close: () -> 20 | @el.modal 'hide' 21 | _.delay ( => 22 | @el.remove() 23 | @trigger 'destroy' 24 | ), 25 | 500 26 | 27 | save: () -> 28 | @metadata.name = @el.find('.name').val() 29 | @metadata.description = @el.find('.description').val() 30 | @metadata.mode = @el.find('input[name=mode]:checked').val() 31 | if @validate() 32 | @twitter.upsertList @metadata 33 | .done (list) => 34 | @trigger 'save', list 35 | @close() 36 | .fail => @el.addClass 'exception' 37 | else 38 | $.Deferred().reject() 39 | 40 | validate: () -> 41 | unless @metadata.name 42 | @el.find('.name-group').addClass 'has-error' 43 | false 44 | else 45 | true 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "powerline", 3 | "title": "Powerline", 4 | "version": "0.1.4", 5 | "description": "Create and update Twitter lists through a clean, fast, and simple interface.", 6 | "homepage": "https://powerline.io", 7 | "twitter": "@don_mccurdy", 8 | "github": "https://github.com/donmccurdy/powerline", 9 | "bugs": "https://github.com/donmccurdy/powerline/issues", 10 | "repository": { 11 | "type": "git", 12 | "url": "git@github.com:donmccurdy/powerline.git" 13 | }, 14 | "scripts": { 15 | "compile": "grunt", 16 | "test": "grunt test", 17 | "start": "grunt connect" 18 | }, 19 | "keywords": [ 20 | "twitter", 21 | "list management", 22 | "productivity", 23 | "client" 24 | ], 25 | "author": "Don McCurdy", 26 | "license": "MIT", 27 | "devDependencies": { 28 | "coffeelint": "^1.8.1", 29 | "grunt": "^0.4.5", 30 | "grunt-bump": "0.0.16", 31 | "grunt-contrib-clean": "^0.6.0", 32 | "grunt-contrib-coffee": "^0.12.0", 33 | "grunt-contrib-connect": "^0.9.0", 34 | "grunt-contrib-copy": "^0.7.0", 35 | "grunt-contrib-jst": "^0.6.0", 36 | "grunt-contrib-sass": "^0.8.1", 37 | "grunt-contrib-uglify": "^0.7.0", 38 | "grunt-contrib-watch": "^0.6.1", 39 | "grunt-env": "^0.4.2" 40 | }, 41 | "dependencies": { 42 | "eventify": "^1.1.0", 43 | "lodash": "^2.4.1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/coffee/Keymap.coffee: -------------------------------------------------------------------------------- 1 | class Keymap 2 | 3 | ############################## 4 | # NAVIGATION 5 | 6 | @UP: 7 | action: 'Select Above' 8 | label: '↑' 9 | key: ['up', 'shift+up'] 10 | 11 | @DOWN: 12 | action: 'Select Below' 13 | label: '↓' 14 | key: ['down', 'shift+down'] 15 | 16 | @LEFT: 17 | action: 'Select Left' 18 | label: '←' 19 | key: ['left', 'shift+left'] 20 | 21 | @RIGHT: 22 | action: 'Select Right' 23 | label: '→' 24 | key: ['right', 'shift+right'] 25 | 26 | ############################## 27 | # LISTS 28 | 29 | @LIST_ADD: 30 | action: 'Add to list' 31 | label: 'L' 32 | key: 'l' 33 | 34 | @LIST_MOVE: 35 | action: 'Move to list' 36 | label: 'Shift + L' 37 | key: 'shift+l' 38 | 39 | @LIST_REMOVE: 40 | action: 'Remove from list' 41 | label: 'E' 42 | key: 'e' 43 | 44 | @LIST_OPEN: 45 | action: 'Goto list' 46 | label: 'G' 47 | key: 'g' 48 | 49 | ############################## 50 | # UTILITY 51 | 52 | @SEARCH: 53 | action: 'Search' 54 | label: '/' 55 | key: '/' 56 | 57 | @SELECT_ALL: 58 | action: 'Select All' 59 | label: 'Control/Command + A' 60 | key: ['ctrl+a', 'meta+a'] 61 | 62 | @SHOW_ACTIONS: 63 | action: 'More Actions' 64 | label: '.' 65 | key: '.' 66 | 67 | @SHOW_DETAILS: 68 | action: 'Show Details' 69 | label: 'Spacebar' 70 | key: 'space' 71 | 72 | @UNDO: 73 | action: 'Undo' 74 | label: 'Control/Command + Z' 75 | key: ['ctrl+z', 'meta+z'] 76 | 77 | @REDO: 78 | action: 'Redo' 79 | label: 'Control/Command + Shift + Z' 80 | key: ['ctrl+shift+z', 'cmd+shift+z'] 81 | -------------------------------------------------------------------------------- /src/coffee/Bootstrap.coffee: -------------------------------------------------------------------------------- 1 | # Bootstrap 2 | # 3 | # Kicks off main processes, and renders a static 4 | # view for logged-out users. 5 | # 6 | class Bootstrap 7 | 8 | MAX_USERS: 2000 9 | 10 | twitter: null 11 | user: null 12 | 13 | constructor: () -> 14 | @header = $('.pline-header') 15 | @friends = null 16 | @collection = null 17 | 18 | login: () -> 19 | @twitter = new TwitterService(Config.CLIENT_ID, Config.OAUTH_PROXY) 20 | if @twitter.isReady() 21 | @twitter.getCurrentUser().done (user) => @init user 22 | @render() 23 | 24 | init: (user) -> 25 | @user = user 26 | @collection = new ListCollection(@user, @twitter) 27 | @render() 28 | 29 | logout: () -> 30 | @twitter.logout() 31 | @user = null 32 | @collection = null 33 | @friends = null 34 | window.location.reload(false) 35 | 36 | start: () -> 37 | @header 38 | .on 'click', '.btn-login', => 39 | @twitter.connectTwitter().then => @login() 40 | .on 'click', '.btn-logout', => @logout() 41 | 42 | window.onbeforeunload = => @onBeforeUnload() 43 | 44 | @login() # Log in automatically, if possible 45 | 46 | onBeforeUnload: () -> 47 | if @collection?.isModified() 48 | 'Unsaved changes will be lost. Are you sure you want to continue?' 49 | 50 | render: () -> 51 | $('.navbar-right').html JST['navbar-right'](user: @user) 52 | $('.footer').html JST['footer'](user: @user) 53 | if @user?.friends_count > @MAX_USERS and not @popup 54 | @popup = $ JST['modal'](content: JST['over-limits']()) 55 | @popup.modal 56 | backdrop: true 57 | keyboard: true 58 | show: true 59 | -------------------------------------------------------------------------------- /src/layout/partials/user-detail.tpl.html: -------------------------------------------------------------------------------- 1 | 2 | 18 | 47 | -------------------------------------------------------------------------------- /src/sass/autocomplete.scss: -------------------------------------------------------------------------------- 1 | @import "common"; 2 | 3 | .asg-suggestions, 4 | .asg-static-suggestions { 5 | max-height: 300px; 6 | overflow-y: auto; 7 | background-color: $bodyWhite; 8 | cursor: pointer; 9 | 10 | .asg-ul, 11 | .asg-static-ul { 12 | display: table; 13 | list-style: none; 14 | padding: 0px; 15 | margin: 0px; 16 | min-width: 100%; 17 | } 18 | 19 | .asg-li, 20 | .asg-static-li { 21 | padding: 0px; 22 | margin: 0px; 23 | display: table-row; 24 | 25 | @include box-sizing(border-box); 26 | 27 | &:hover, 28 | &.sel { 29 | background-color: $accent; 30 | 31 | .asg-label { 32 | color: $bodyWhite; 33 | } 34 | } 35 | } 36 | 37 | .asg-label, 38 | .asg-static-label { 39 | display: table-cell; 40 | padding: 5px 10px; 41 | color: $bodyBlack; 42 | vertical-align: middle; 43 | &:focus, 44 | &:active { 45 | outline: none; 46 | } 47 | } 48 | 49 | .asg-img-wrap, 50 | .asg-static-img-wrap { 51 | display: table-cell; 52 | text-align: center; 53 | vertical-align: middle; 54 | padding: 3px; 55 | 56 | &.asg-no-img { 57 | padding: 0; 58 | } 59 | } 60 | 61 | .asg-img, 62 | .asg-static-img { 63 | max-width: 40px; 64 | max-height: 30px; 65 | } 66 | &.left:after { 67 | left: auto; 68 | right: 20%; 69 | } 70 | &.asg-fixed, 71 | &.asg-static-fixed { 72 | position: fixed; 73 | } 74 | } 75 | 76 | .asg-suggestions { 77 | position: absolute; 78 | z-index: 500000; 79 | width: auto; 80 | min-width: 200px; 81 | border: 0px solid $borderLight; 82 | border-bottom: 0px; 83 | @include border-radius(5px); 84 | 85 | @include box-shadow(1px 1px 9px rgba(0,0,0,0.2)); 86 | } -------------------------------------------------------------------------------- /src/layout/partials/list.tpl.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | {{ if (isSortable) { }}{{ } }} 4 | {{- pivot === 'all' ? name : 'Unlisted' }} 5 | ({{= count() }}) 6 | 46 |
    47 |
    48 |
    -------------------------------------------------------------------------------- /src/layout/partials/list-form.tpl.html: -------------------------------------------------------------------------------- 1 | 2 | 5 | 43 | -------------------------------------------------------------------------------- /src/coffee/CommandAggregator.coffee: -------------------------------------------------------------------------------- 1 | class CommandAggregator extends EventEmitter 2 | 3 | constructor: (@twitter, @queue) -> 4 | @additions = {} 5 | @removals = {} 6 | @pending = [] 7 | @init() 8 | 9 | init: () -> 10 | # Aggregate commands 11 | for cmd in @queue 12 | if cmd.is_adder 13 | @add cmd.destListID, cmd.userIDs 14 | if cmd.is_remover 15 | @remove cmd.srcListID, cmd.userIDs 16 | 17 | # Show modal 18 | @el = $ JST['modal'](content: JST['save-progress']( 19 | progress: 0 20 | error: false 21 | )) 22 | @el.modal 23 | backdrop: true 24 | keyboard: true 25 | show: true 26 | 27 | add: (listID, userIDs) -> 28 | unless @additions[listID] 29 | @additions[listID] = [] 30 | @additions[listID] = _.union(@additions[listID], userIDs) 31 | 32 | remove: (listID, userIDs) -> 33 | unless @removals[listID] 34 | @removals[listID] = [] 35 | @removals[listID] = _.union(@removals[listID], userIDs) 36 | 37 | getListIDs: () -> 38 | _.union _.keys(@additions), _.keys(@removals) 39 | 40 | apply: (commands) -> 41 | add_results = _.map @additions, (userIDs, listID) => @twitter.addListMembers(listID, userIDs) 42 | del_results = _.map @removals, (userIDs, listID) => @twitter.removeListMembers(listID, userIDs) 43 | @pending = add_results.concat add_results, del_results 44 | render = _.debounce (() => @render()), 100 45 | result.always(render) for result in @pending 46 | $.when.apply $, @pending 47 | 48 | render: () -> 49 | status = _.groupBy @pending, (cmd) -> cmd.state() 50 | @el.find('.modal-content').html(JST['save-progress']( 51 | progress: 100 * _.size(status.resolved) / _.size @pending 52 | error: !!_.size status.rejected 53 | )) 54 | @ 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Powerline for Twitter 2 | 3 | *** 4 | 5 | ## Quick Start 6 | 7 | ### Installation 8 | 9 | This project depends on NPM, Grunt, and Bower. 10 | 11 | ```bash 12 | npm install -g grunt-cli bower 13 | ``` 14 | 15 | Once you've downloaded the repository, run: 16 | 17 | ```bash 18 | npm install 19 | bower install 20 | ``` 21 | 22 | ### Development 23 | 24 | ```bash 25 | # Compile resources for development and serve app to http://localhost:8000 26 | grunt dev 27 | 28 | # Compile resources for production deployment 29 | grunt prod 30 | 31 | # Clear compilation output 32 | grunt clean 33 | ``` 34 | 35 | ## Contributing 36 | 37 | ## License 38 | 39 | The MIT License (MIT) 40 | 41 | Copyright (c) 2015 Don McCurdy 42 | 43 | Permission is hereby granted, free of charge, to any person obtaining a copy 44 | of this software and associated documentation files (the "Software"), to deal 45 | in the Software without restriction, including without limitation the rights 46 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 47 | copies of the Software, and to permit persons to whom the Software is 48 | furnished to do so, subject to the following conditions: 49 | 50 | The above copyright notice and this permission notice shall be included in 51 | all copies or substantial portions of the Software. 52 | 53 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 54 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 55 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 56 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 57 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 58 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 59 | THE SOFTWARE. 60 | -------------------------------------------------------------------------------- /src/coffee/UserStream.coffee: -------------------------------------------------------------------------------- 1 | class UserStream extends EventEmitter 2 | 3 | constructor: (@id, @metadata, @twitter) -> 4 | @localCursor = -1 5 | @remoteCursor = -1 6 | @users = [] 7 | @name = @metadata.name 8 | @mode = @metadata.mode 9 | @description = @metadata.description 10 | @isReady = $.Deferred() 11 | @reload @isReady, false 12 | 13 | 14 | reload: (deferred, noCache = true) -> 15 | @remoteCursor = -1 16 | if @id is 0 17 | resource = @twitter.getFriends @remoteCursor, noCache 18 | else 19 | resource = @twitter.getListMembers @id, @remoteCursor, noCache 20 | 21 | resource 22 | .done (data) => 23 | @remoteCursor = data.next_cursor 24 | @users = data.users 25 | deferred?.resolve @users 26 | @preload noCache 27 | .fail => deferred?.reject 'could not connect stream' 28 | 29 | preload: (noCache) -> 30 | if not @remoteCursor 31 | return @ 32 | else if @id is 0 33 | resource = @twitter.getFriends @remoteCursor, noCache 34 | else 35 | resource = @twitter.getListMembers @id, @remoteCursor, noCache 36 | 37 | resource 38 | .done (data) => 39 | @remoteCursor = data.next_cursor 40 | @users = @users.concat data.users 41 | @preload noCache 42 | @trigger 'load' 43 | @ 44 | 45 | ready: () -> 46 | @isReady 47 | 48 | current: () -> 49 | @users 50 | 51 | next: () -> 52 | if @localCursor >= 0 53 | throw "can't access page #{@localCursor}" 54 | else 55 | @isReady 56 | 57 | count: () -> 58 | @users.length 59 | 60 | remove: () -> 61 | deferred = $.Deferred() 62 | el = $(JST['modal'](content: JST['list-remove'](@))).modal 63 | backdrop: true 64 | keyboard: true 65 | show: true 66 | el.on 'hidden.bs.modal', -> deferred.reject() 67 | el.on 'click', '.btn-remove', => 68 | @twitter.removeList @id 69 | deferred.resolve() 70 | deferred -------------------------------------------------------------------------------- /src/coffee/Command.coffee: -------------------------------------------------------------------------------- 1 | # 2 | # Abstract Command 3 | # 4 | class Command extends EventEmitter 5 | constructor: (selection) -> 6 | @id = _.uniqueId() 7 | @userIDs = selection.get() 8 | @executed = false 9 | 10 | execute: (collection) -> 11 | @executed = true 12 | @ 13 | 14 | undo: (collection) -> 15 | @executed = false 16 | @ 17 | 18 | add: (collection, list) -> 19 | for userID in @userIDs.reverse() 20 | user = collection.getUser userID 21 | list.add user 22 | 23 | remove: (collection, list) -> 24 | for userID in @userIDs 25 | user = collection.getUser userID 26 | list.remove user 27 | 28 | # 29 | # Add user(s) to a list. 30 | # 31 | class AddCommand extends Command 32 | is_adder: true 33 | 34 | constructor: (selection, dest) -> 35 | super(selection) 36 | @destListID = dest.id 37 | 38 | execute: (collection) -> 39 | @add collection, collection.getList @destListID 40 | super() 41 | 42 | undo: (collection) -> 43 | @remove collection, collection.getList @destListID 44 | super() 45 | 46 | # 47 | # Remove user(s) from a list. 48 | # 49 | class RemoveCommand extends Command 50 | is_remover: true 51 | 52 | constructor: (selection) -> 53 | super(selection) 54 | @srcListID = selection.list.id 55 | 56 | execute: (collection) -> 57 | @remove collection, collection.getList @srcListID 58 | super() 59 | 60 | undo: (collection) -> 61 | @add collection, collection.getList @srcListID 62 | super() 63 | 64 | # 65 | # Move user(s) from one list to another. 66 | # 67 | class MoveCommand extends Command 68 | is_remover: true 69 | is_adder: true 70 | 71 | constructor: (selection, dest) -> 72 | super(selection) 73 | @srcListID = selection.list.id 74 | @destListID = dest.id 75 | 76 | execute: (collection) -> 77 | @add collection, collection.getList @destListID 78 | @remove collection, collection.getList @srcListID 79 | super() 80 | 81 | undo: (collection) -> 82 | @remove collection, collection.getList @destListID 83 | @add collection, collection.getList @srcListID 84 | super() 85 | -------------------------------------------------------------------------------- /src/layout/partials/collection.tpl.html: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 | 10 | 16 | 22 | 28 | 33 |
    34 |
    35 | 36 | 41 | 42 | 43 | 44 |
    45 |
    46 |
    47 |
    48 | -------------------------------------------------------------------------------- /src/coffee/Selection.coffee: -------------------------------------------------------------------------------- 1 | class Selection extends EventEmitter 2 | 3 | constructor: (@list) -> 4 | @listID = @list.id 5 | @userIDs = [] 6 | @anchorID = -1 7 | @cursorID = -1 8 | @list.setSelection @ 9 | @on 'change', => @render() 10 | 11 | # Select/deselect given user. 12 | set: (userID, reset = false) -> 13 | if @contains userID 14 | if reset and @userIDs.length > 1 15 | @userIDs = [userID] 16 | else 17 | @userIDs = _.without @userIDs, userID 18 | else 19 | if reset 20 | @userIDs = [userID] 21 | else 22 | @userIDs.push userID 23 | if @contains userID 24 | @anchorID = @cursorID = userID 25 | @trigger 'change' 26 | 27 | # Select all from anchor to given user. 28 | setRange: (userID) -> 29 | if @anchorID > 0 30 | @userIDs = @getRange @anchorID, userID 31 | @cursorID = userID 32 | @trigger 'change' 33 | 34 | # Move cursor to next user. 35 | incr: (direction = 1) -> 36 | users = @list.getUsers() 37 | index = direction + _.findIndex users, id: @cursorID 38 | @anchorID = @cursorID = users[index]?.id 39 | @userIDs = [@anchorID] 40 | @trigger 'change' 41 | 42 | # Extend selection to next cursor position. 43 | incrRange: (direction = 1) -> 44 | users = @list.getUsers() 45 | index = direction + _.findIndex users, id: @cursorID 46 | if users[index] 47 | @cursorID = users[index].id 48 | @userIDs = @getRange @anchorID, @cursorID 49 | @trigger 'change' 50 | 51 | decr: () -> 52 | @incr -1 53 | 54 | decrRange: () -> 55 | @incrRange -1 56 | 57 | # Get User IDs. 58 | # 59 | # Outside code shouldn't see @userIDs because 60 | # it's (1) unsorted, and (2) not checked 61 | # for uniqueness. 62 | get: () -> 63 | hash = _.invert @userIDs 64 | size = _.size @userIDs 65 | ids = [] 66 | for user in @list.getUsers() 67 | if hash[user.id] 68 | ids.push user.id 69 | if size is ids.length 70 | break 71 | ids 72 | 73 | # Get IDs between a given pair. 74 | getRange: (id1, id2) -> 75 | users = [] 76 | inRange = false 77 | for user in @list.getUsers() 78 | if user.id is id1 or user.id is id2 79 | if inRange or id1 is id2 80 | users.push user 81 | break 82 | inRange = true 83 | if inRange 84 | users.push user 85 | _.pluck users, 'id' 86 | 87 | # Return index of anchor user. Useful 88 | # when moving cursor left/right 89 | # between lists. 90 | getAnchorIndex: () -> 91 | _.findIndex @list.getUsers(), id: @anchorID 92 | 93 | # Select the user at the specified index, 94 | # and remove all other selections. 95 | setAnchorIndex: (index) -> 96 | users = @list.getUsers() 97 | if index < users.length then @set users[index].id 98 | else @set _.last(users).id 99 | 100 | count: () -> 101 | _.size @userIDs 102 | 103 | contains: (userID) -> 104 | _.contains @userIDs, userID 105 | 106 | render: () -> 107 | @list.el.find('.selected').removeClass 'selected' 108 | for id in @userIDs 109 | @list.el.find(".user[data-id=#{id}]").addClass 'selected' 110 | 111 | destroy: () -> 112 | @list.el.find('.selected').removeClass 'selected' 113 | @trigger 'destroy' 114 | -------------------------------------------------------------------------------- /src/coffee/List.coffee: -------------------------------------------------------------------------------- 1 | # List 2 | # 3 | # Represents a single list of Twitter users. 4 | # 5 | # Delegates to: 6 | # - Stream: Hides some of the details of 7 | # pagination, and the differences between 8 | # 'real' lists and the Following pseudo-list. 9 | # 10 | class List extends EventEmitter 11 | 12 | constructor: (@stream, @cache) -> 13 | @id = +@stream.id 14 | @name = @stream.name 15 | @mode = @stream.mode 16 | @description = @stream.description 17 | @users = @stream.current() 18 | @usersAdded = [] 19 | @usersRemoved = [] 20 | @selection = null 21 | @isMutable = !!@id 22 | @isSortable = !!@id 23 | 24 | @pivot = @cache.get("list-filter-#{@id}") or 'all' 25 | 26 | @el = $(JST.list(@)) 27 | @bindEvents() 28 | @filter @pivot if @pivot isnt 'all' 29 | 30 | @stream.on 'load', => 31 | @users = @stream.current() 32 | @render() 33 | 34 | render: (options = {}) -> 35 | unsavedIDs = _.invert _.pluck(@usersAdded, 'id') 36 | rows = _.map @getUsers(), (user) => 37 | JST.user 38 | user: user 39 | selected: @selection?.contains user.id 40 | unsaved: !!unsavedIDs[user.id] 41 | 42 | scrollTop = 0 43 | if not options.unscroll 44 | scrollTop = @el.find('.list').scrollTop() 45 | @el.html $(JST.list(@)).children() 46 | .find('.list') 47 | .html rows.join('') 48 | .scrollTop scrollTop 49 | @el 50 | 51 | setSelection: (selection) -> 52 | @selection = selection 53 | @selection.on 'destroy', => @selection = null 54 | @ 55 | 56 | # only provided for id=0 case 57 | setCollection: (collection) -> 58 | @collection = collection 59 | @collection.on 'change', => @render() 60 | 61 | bindEvents: () -> 62 | @el.on 'click', '.list-edit', => 63 | form = new ListForm(@stream.twitter, @) 64 | form.on 'save', (metadata) => @update metadata 65 | @el.on 'click', '.list-hide', => 66 | @trigger 'hide' 67 | @el.detach() 68 | @el.on 'click', '.list-remove', => 69 | @stream.remove() 70 | .done => @destroy() 71 | .fail => console.log "could not delete list #{@id}" 72 | @el.on 'click', '.list-filter', (e) => 73 | @filter $(e.target).data('pivot') 74 | @ 75 | 76 | count: () -> 77 | _.size @getUsers() 78 | 79 | getUsers: () -> 80 | users = @usersAdded.concat(@users) 81 | if _.size @usersRemoved 82 | skip = _ @usersRemoved 83 | .pluck 'id' 84 | .invert() 85 | .value() 86 | users = _.filter users, (u) -> !skip[u.id] 87 | if @pivot is 'unlisted' and @collection 88 | map = @collection.getMembershipMap() 89 | users = _.filter users, (u) -> !map[u.id] 90 | users 91 | 92 | add: (user) -> 93 | if @isMutable and not @contains user 94 | unless _.any(@users, id: user.id) 95 | @usersAdded.unshift user 96 | else 97 | _.remove @usersRemoved, id: user.id 98 | @render() 99 | @ 100 | 101 | remove: (user) -> 102 | if @isMutable and @contains user 103 | if _.any(@users, id: user.id) 104 | @usersRemoved.push user 105 | else 106 | _.remove @usersAdded, id: user.id 107 | @render() 108 | @ 109 | 110 | reload: () -> 111 | deferred = $.Deferred().done => 112 | @users = @stream.current() 113 | @usersAdded = [] 114 | @usersRemoved = [] 115 | @render() 116 | @stream.reload(deferred) 117 | deferred 118 | 119 | filter: (pivot) -> 120 | @pivot = pivot 121 | @cache.set "list-filter-#{@id}", @pivot 122 | @render unscroll: true 123 | 124 | update: (metadata) -> 125 | @name = metadata.name 126 | @mode = metadata.mode 127 | @description = metadata.description 128 | @render() 129 | @ 130 | 131 | destroy: () -> 132 | @el.remove() 133 | @trigger 'destroy' 134 | 135 | debug: () -> 136 | console.group 'Users' 137 | console.log _.pluck(@users, 'name') 138 | console.groupEnd() 139 | console.group 'Added' 140 | console.log _.pluck(@usersAdded, 'name') 141 | console.groupEnd() 142 | console.group 'Removed' 143 | console.log _.pluck(@usersRemoved, 'name') 144 | console.groupEnd() 145 | 146 | contains: (user) -> 147 | if _.any(@usersRemoved, id: user.id) then return false 148 | if _.any(@users, id: user.id) then return true 149 | if _.any(@usersAdded, id: user.id) then return true 150 | false 151 | -------------------------------------------------------------------------------- /src/coffee/TwitterService.coffee: -------------------------------------------------------------------------------- 1 | # Twitter Service 2 | # 3 | # Handles authorization (through OAuth.io), fetching 4 | # details for the current user, and fetching a list 5 | # of friends. 6 | # 7 | # We'll need to support more complicated Twitter API 8 | # interactions, so most of that will probably 9 | # need to be located elsewhere. 10 | # 11 | class TwitterService extends EventEmitter 12 | 13 | cache: null 14 | users: {} 15 | 16 | # Login / Initialization 17 | ####################################### 18 | 19 | constructor: (clientID, oauthProxy) -> 20 | hello.init {twitter: clientID}, {oauth_proxy: oauthProxy} 21 | @twitter = hello 'twitter' 22 | if @isReady() then @init() 23 | 24 | init: -> 25 | uid = @twitter.getAuthResponse().user_id 26 | @cache = Cache.create uid 27 | @trigger 'ready' 28 | 29 | isReady: -> 30 | !!@twitter.getAuthResponse() 31 | 32 | connectTwitter: -> 33 | deferred = $.Deferred() 34 | @twitter.login().then( 35 | (r) => 36 | @init() 37 | deferred.resolve() 38 | @trigger 'ready' 39 | (r) => 40 | deferred.reject r 41 | ) 42 | deferred 43 | 44 | logout: -> 45 | @twitter.logout() 46 | @twitter = false 47 | 48 | # AJAX Requests 49 | ####################################### 50 | 51 | request: (path, data, method) -> 52 | deferred = $.Deferred() 53 | params = '' 54 | if data 55 | # doing this for *both* GET+POST requests because 56 | # POST bodies aren't going through as expected. 57 | params = '?' + $.param data 58 | if method is 'get' 59 | data = {} 60 | @twitter.api path + params, method, data 61 | .then( 62 | (data) -> 63 | if data?.errors 64 | deferred.reject data 65 | else 66 | deferred.resolve data 67 | (data) -> 68 | deferred.reject data 69 | ) 70 | deferred 71 | 72 | get: (path, data) -> 73 | @request path, data, 'get' 74 | 75 | post: (path, data) -> 76 | @request path, data, 'post' 77 | 78 | # API Definition 79 | ####################################### 80 | 81 | getCurrentUser: -> 82 | @cache.bind 'current-user', () => 83 | @get 'me' 84 | .done (data) => @cacheUsers [data] 85 | 86 | getUser: (userID) -> 87 | if @users[userID] 88 | @users[userID] 89 | else 90 | throw 'remote user fetching not implemented' 91 | 92 | cacheUsers: (users) -> 93 | _.defer => 94 | for user in users 95 | unless @users[user.id] 96 | user.muting = user.muting or false 97 | user.thumbnail = user.profile_image_url_https or user.profile_image_url 98 | @users[user.id] = user 99 | 100 | getFriends: (cursor) -> 101 | @cache.bind "friends-#{cursor}", () => 102 | @get '/friends/list.json', 103 | count: 200 # max = 200, 15 / 15 min 104 | cursor: cursor 105 | skip_status: true 106 | include_user_entities: false 107 | .done (data) => @cacheUsers data.users 108 | 109 | getLists: (clear = false) -> 110 | if clear then @cache.clear 'lists' 111 | return @cache.bind 'lists', () => 112 | @get '/lists/ownerships.json', count: 100 113 | 114 | getListMembers: (listID, cursor, clear = false) -> 115 | if clear then @cache.clear "list-#{listID}-#{cursor}" 116 | @cache.bind "list-#{listID}-#{cursor}", () => 117 | @get '/lists/members.json', 118 | count: 200 # max = 5000, 180 / 15 min 119 | list_id: listID 120 | cursor: cursor 121 | skip_status: true 122 | include_entities: false 123 | .done (data) => @cacheUsers data.users 124 | 125 | addListMembers: (listID, userIDs) -> 126 | @post '/lists/members/create_all.json', 127 | list_id: listID 128 | user_id: userIDs.join(',') 129 | 130 | removeListMembers: (listID, userIDs) -> 131 | @post '/lists/members/destroy_all.json', 132 | list_id: listID 133 | user_id: userIDs.join(',') 134 | 135 | upsertList: (metadata) -> 136 | @cache.clear 'lists' 137 | if metadata.id 138 | @post '/lists/update.json', 139 | list_id: metadata.id 140 | name: metadata.name 141 | mode: metadata.mode 142 | description: metadata.description 143 | else 144 | @post '/lists/create.json', 145 | name: metadata.name 146 | mode: metadata.mode 147 | description: metadata.description 148 | 149 | removeList: (listID) -> 150 | @cache.clear 'lists' 151 | @post '/lists/destroy.json', list_id: listID 152 | -------------------------------------------------------------------------------- /src/coffee/Toolbar.coffee: -------------------------------------------------------------------------------- 1 | class Toolbar extends EventEmitter 2 | 3 | constructor: (@el, @footer, @collection) -> 4 | @bindEvents() 5 | @bindKeys() 6 | 7 | setLists: (lists) -> 8 | @lists = lists 9 | 10 | focusDropdown: (action) -> 11 | $btn = @el.find ".btn[data-action=#{action}]" 12 | unless $btn.prop 'disabled' 13 | $menu = @el.find ".dropdown-menu[data-action=#{action}]" 14 | .addClass 'focus' 15 | $menu.find '.dropdown-input' 16 | .focus() 17 | .one 'blur', -> $menu.removeClass 'focus' 18 | 19 | bindEvents: () -> 20 | self = @ 21 | asg_options = 22 | delay: 20 23 | minChars: 0 24 | numToSuggest: 100 25 | source: @findList 26 | clickEvent: 'mousedown' 27 | 28 | # list search 29 | $asgOpen = @el.find('.input-list-open').asg(_.merge(asg_options, 30 | offsetTop: 14 31 | callback: => 32 | @collection.openList asgOpen.get().key 33 | asgOpen.clear() 34 | $asgOpen.blur() 35 | )) 36 | asgOpen = $asgOpen.data('asg') 37 | @$asgOpen = $asgOpen 38 | 39 | # add user to list 40 | $asgAdd = @el.find('.dropdown-input[data-action=add]').asg(_.merge(asg_options, 41 | staticPos: true 42 | namespace: 'asg-static' 43 | container: @el.find('.dropdown-input-results[data-action=add]') 44 | callback: => 45 | @collection.addToList asgAdd.get().key 46 | asgAdd.clear() 47 | $asgAdd.blur() 48 | )) 49 | asgAdd = $asgAdd.data('asg') 50 | 51 | # move user to list 52 | $asgMove = @el.find('.dropdown-input[data-action=move]').asg(_.merge(asg_options, 53 | staticPos: true 54 | namespace: 'asg-static' 55 | container: @el.find('.dropdown-input-results[data-action=move]') 56 | callback: => 57 | @collection.moveToList asgMove.get().key 58 | asgMove.clear() 59 | $asgMove.blur() 60 | )) 61 | asgMove = $asgMove.data('asg') 62 | 63 | # when an certain buttons are clicked, focus an input 64 | @el.on 'click', '.btn-input-start', -> self.focusDropdown $(this).data 'action' 65 | 66 | # save button in footer 67 | @footer.on 'click', '.toolbar-save', => @collection.save() 68 | 69 | # when selection changes, update add/move/remove buttons 70 | @collection.on 'select', => 71 | $('.toolbar-add').prop 'disabled', !@collection.selection?.count() 72 | $('.toolbar-move, .toolbar-remove').prop 'disabled', 73 | !@collection.selection?.list.isMutable or !@collection.selection?.count() 74 | 75 | # create new list 76 | @el.on 'click', '.toolbar-create', => 77 | listForm = new ListForm(@collection.twitter) 78 | listForm.on 'save', (list) => @collection.update list 79 | 80 | # bind tooltips 81 | @el.find('[data-toggle="tooltip"]').tooltip(delay: show: 1000, hide: 0) 82 | 83 | findList: (id, label, callback) => 84 | lists = _.filter @collection.lists, (l) -> !!l.id 85 | if id 86 | lists = _.where lists, id: id 87 | else if label 88 | re1 = new RegExp('^' + RegExp.quote(label), 'i') 89 | re2 = new RegExp(RegExp.quote(label), 'i') 90 | primary = _.filter lists, (l) -> l.name.match re1 91 | secondary = _.filter lists, (l) -> l.name.match re2 92 | lists = _.unique primary.concat(secondary) 93 | callback(_.map lists, (l) -> key: l.id, value: l.name) 94 | 95 | bindKey: (key, callback) -> 96 | Mousetrap.bind key, (e) -> 97 | callback(e) 98 | e.preventDefault() 99 | 100 | bindKeys: () -> 101 | # list actions 102 | @bindKey Keymap.LIST_ADD.key, => @focusDropdown 'add' 103 | @bindKey Keymap.LIST_MOVE.key, => @focusDropdown 'move' 104 | @bindKey Keymap.LIST_REMOVE.key, => @collection.removeFromList() 105 | @bindKey Keymap.LIST_OPEN.key, => @$asgOpen.focus() 106 | 107 | # keyboard selection 108 | @bindKey Keymap.UP.key, (e) => 109 | if e.shiftKey then @collection.selection?.decrRange() 110 | else @collection.selection?.decr() 111 | @bindKey Keymap.DOWN.key, (e) => 112 | if e.shiftKey then @collection.selection?.incrRange() 113 | else @collection.selection?.incr() 114 | @bindKey Keymap.LEFT.key, (e) => 115 | if e.shiftKey then @collection.moveLeft() 116 | else @collection.selectLeft() 117 | @bindKey Keymap.RIGHT.key, (e) => 118 | if e.shiftKey then @collection.moveRight() 119 | else @collection.selectRight() 120 | 121 | # user details 122 | @bindKey Keymap.SHOW_DETAILS.key, (e) => @collection.showDetails() 123 | 124 | # blur inputs on escape 125 | $('input').keydown (e) -> $(this).blur() if e.keyCode is 27 126 | -------------------------------------------------------------------------------- /src/layout/index.tpl.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Home | {{= pkg.title }} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | {{ if (process.env.NODE_ENV === 'DEVELOPMENT') { }} 24 | 25 | 26 | {{ } else { }} 27 | 28 | 29 | {{ } }} 30 | 31 | 32 | 33 | 34 | 35 |
    36 | 99 |
    100 | 101 |
    102 | 103 |
    104 |
    105 | 106 | 107 | 110 | 111 | 112 | {{ _.forEach(vendor_scripts, function (script) { }} 113 | 114 | {{ }) }} 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | {{ if (process.env.NODE_ENV === 'DEVELOPMENT') { }} 127 | {{ _.forEach(dev_scripts, function (script) { }} 128 | 129 | {{ }) }} 130 | {{ } else { }} 131 | 132 | {{ } }} 133 | 134 | -------------------------------------------------------------------------------- /Gruntfile.coffee: -------------------------------------------------------------------------------- 1 | _ = require 'lodash' 2 | path = require 'path' 3 | 4 | module.exports = (grunt) -> 5 | 6 | grunt.template.addDelimiters 'handlebars', '{{', '}}' 7 | 8 | vendor_scripts = [ 9 | 'node_modules/lodash/dist/lodash.min.js' 10 | 'node_modules/eventify/dist/eventify.min.js' 11 | 'bower_components/jquery/dist/jquery.min.js' 12 | 'bower_components/hello/dist/hello.all.min.js' 13 | 'bower_components/mousetrap/mousetrap.min.js' 14 | 'bower_components/jquery-sortable/source/js/jquery-sortable-min.js' 15 | 'bower_components/asg.js/dist/asg.min.js' 16 | ] 17 | 18 | grunt.initConfig( 19 | 20 | pkg: grunt.file.readJSON 'package.json' 21 | build_dir: 'build' 22 | js_dir: "<%= build_dir %>/assets/js" 23 | css_dir: "<%= build_dir %>/assets/css" 24 | 25 | # 26 | # SCRIPTS (there's got to be a way around this...) 27 | 28 | vendor_scripts: _.map vendor_scripts, path.basename 29 | 30 | dev_scripts: [ 31 | 'EventEmitter' 32 | 33 | 'Bootstrap' 34 | 'Cache' 35 | 'Command' 36 | 'CommandAggregator' 37 | 'CommandQueue' 38 | 'Keymap' 39 | 'List' 40 | 'ListCollection' 41 | 'ListForm' 42 | 'Selection' 43 | 'Toolbar' 44 | 'TwitterService' 45 | 'UserStream' 46 | 'Util' 47 | 48 | 'main' 49 | ] 50 | 51 | # 52 | # ENVIRONMENT 53 | 54 | env: 55 | dev: 56 | src: ['.env'] 57 | NODE_ENV: 'DEVELOPMENT' 58 | prod: 59 | NODE_ENV: 'PRODUCTION' 60 | 61 | # 62 | # COFFEESCRIPT COMPILATION 63 | 64 | coffee: 65 | dist: 66 | options: 67 | join: true 68 | sourceMap: true 69 | files: 70 | "<%= js_dir %>/<%= pkg.name %>.js": ['src/coffee/*.coffee'] 71 | dev: 72 | expand: true 73 | flatten: true 74 | options: 75 | bare: true 76 | sourceMap: true 77 | src: "<%= js_dir %>/.tmp/*.coffee" 78 | dest: "<%= js_dir %>/.tmp/" 79 | ext: '.js' 80 | 81 | # 82 | # SASS COMPILATION 83 | 84 | sass: 85 | options: 86 | style: 'compressed' 87 | main: 88 | files: 89 | "<%= css_dir %>/<%= pkg.name %>.css": 'src/sass/main.scss' 90 | "<%= css_dir %>/autocomplete.css": 'src/sass/autocomplete.scss' 91 | 92 | # 93 | # JS MINIFICATION 94 | 95 | uglify: 96 | main: 97 | options: 98 | wrap: "<%= pkg.name %>" 99 | banner: '/*! <%= pkg.name %> <%= grunt.template.today("yyyy-mm-dd") %> */\n' 100 | files: 101 | "<%= js_dir %>/<%= pkg.name %>.min.js": "<%= js_dir %>/<%= pkg.name %>.js" 102 | templates: 103 | options: 104 | exposeAll: true 105 | files: 106 | "<%= js_dir %>/templates.min.js": "<%= js_dir %>/templates.js" 107 | 108 | # 109 | # SEMVER HELPER 110 | 111 | bump: 112 | options: 113 | pushTo: 'origin' 114 | files: ['package.json', 'bower.json'] 115 | commitFiles: ['package.json', 'bower.json'] 116 | 117 | # 118 | # HTML + DEPENDENCIES 119 | 120 | copy: 121 | main: 122 | src: 'src/layout/index.tpl.html' 123 | dest: "<%= build_dir %>/index.html" 124 | options: 125 | process: (content) -> 126 | grunt.template.process content, delimiters: 'handlebars' 127 | dev: 128 | expand: true 129 | flatten: true 130 | src: 'src/coffee/*.coffee' 131 | dest: "<%= js_dir %>/.tmp/" 132 | vendor: 133 | expand: true 134 | flatten: true 135 | src: vendor_scripts.concat [ 136 | 'bower_components/jquery/dist/jquery.min.map' 137 | 'bower_components/jquery/dist/jquery.js' 138 | 'bower_components/asg.js/dist/asg.min.css' 139 | ] 140 | dest: "<%= build_dir %>/lib/" 141 | 142 | # 143 | # TEMPLATES 144 | 145 | jst: 146 | main: 147 | options: 148 | templateSettings: 149 | evaluate: /\{\{(.+?)\}\}/g 150 | interpolate: /\{\{=(.+?)\}\}/g 151 | escape: /\{\{-(.+?)\}\}/g 152 | processName: (filename) -> 153 | filename 154 | .slice(filename.indexOf('partials') + 9, filename.length) 155 | .replace('.tpl.html', '') 156 | 157 | files: 158 | "<%= js_dir %>/templates.js": ['src/layout/partials/**/*.tpl.html'] 159 | 160 | # 161 | # LOCAL SERVER 162 | 163 | connect: 164 | main: 165 | options: 166 | hostname: 'localhost' 167 | port: 8000 168 | base: 169 | path: "<%= build_dir %>" 170 | options: 171 | index: 'index.html' 172 | 173 | # 174 | # WATCH 175 | 176 | watch: 177 | js: 178 | files: 'src/coffee/**/*.coffee' 179 | tasks: ['copy:dev', 'coffee:dev'] 180 | css: 181 | files: 'src/sass/**/*.scss' 182 | tasks: ['sass'] 183 | templates: 184 | files: 'src/layout/**/*.html' 185 | tasks: ['copy:main', 'jst'] 186 | 187 | # 188 | # CLEANUP 189 | 190 | clean: ["<%= build_dir %>"] 191 | 192 | ) 193 | 194 | grunt.loadNpmTasks 'grunt-contrib-connect' 195 | grunt.loadNpmTasks 'grunt-contrib-coffee' 196 | grunt.loadNpmTasks 'grunt-contrib-uglify' 197 | grunt.loadNpmTasks 'grunt-contrib-watch' 198 | grunt.loadNpmTasks 'grunt-contrib-clean' 199 | grunt.loadNpmTasks 'grunt-contrib-copy' 200 | grunt.loadNpmTasks 'grunt-contrib-sass' 201 | grunt.loadNpmTasks 'grunt-contrib-jst' 202 | grunt.loadNpmTasks 'grunt-bump' 203 | grunt.loadNpmTasks 'grunt-env' 204 | 205 | grunt.registerTask 'common', ['clean', 'copy:main', 'copy:vendor', 'jst', 'sass'] 206 | grunt.registerTask 'dev', ['env:dev', 'common', 'copy:dev', 'coffee:dev', 'connect', 'watch'] 207 | grunt.registerTask 'prod', ['env:prod', 'common', 'coffee:dist', 'uglify'] 208 | grunt.registerTask 'default', ['prod'] 209 | 210 | null 211 | -------------------------------------------------------------------------------- /src/sass/main.scss: -------------------------------------------------------------------------------- 1 | 2 | @import "common"; 3 | 4 | /* Type 5 | *********************************/ 6 | 7 | body { 8 | font-family: "Helvetica Neue"; 9 | color: $bodyBlack; 10 | height: 100vh; 11 | overflow: hidden; 12 | } 13 | 14 | .fa { 15 | color: $bodyBlack; 16 | } 17 | 18 | .font-header { 19 | font-size: 20px; 20 | font-weight: 300; 21 | } 22 | 23 | /* Page 24 | *********************************/ 25 | 26 | .navbar { 27 | .phase { 28 | text-transform: uppercase; 29 | font-weight: 300; 30 | font-size: 0.7em; 31 | vertical-align: top; 32 | line-height: 1em; 33 | position: relative; 34 | top: 4px; 35 | } 36 | } 37 | 38 | .footer { 39 | height: 50px; 40 | line-height: 50px; 41 | 42 | .footer-inner-right { 43 | text-align: right; 44 | } 45 | } 46 | 47 | /* Collection 48 | *********************************/ 49 | 50 | $pageHeaderHeight: 123px; 51 | $pageFooterHeight: 50px; 52 | $toolbarHeight: 37px; 53 | 54 | .collection { 55 | white-space: nowrap; 56 | width: 100%; 57 | height: calc(100vh - #{$pageHeaderHeight} - #{$pageFooterHeight} - #{$toolbarHeight}); 58 | overflow-x: auto; 59 | } 60 | 61 | .collection-wrap { 62 | height: calc(100vh - #{$pageHeaderHeight}); 63 | } 64 | 65 | .toolbar .input-group { 66 | width: 234px; 67 | float: right; 68 | & .input-group-btn:first-child > .btn { 69 | margin-right: -2px; 70 | } 71 | .input-list-open:focus + .glyphicon { 72 | display: none; 73 | } 74 | } 75 | 76 | .btn-input-start + .dropdown-menu { 77 | 78 | display: none; 79 | &.focus { display: block; } 80 | 81 | .dropdown-input, 82 | .dropdown-input:focus { 83 | border: none; 84 | @include box-shadow(none); 85 | } 86 | 87 | .dropdown-input-results { 88 | margin: 0; 89 | padding: 0; 90 | } 91 | } 92 | 93 | .toolbar { 94 | .btn ~ .btn { 95 | margin-left: -1px; 96 | } 97 | .dropdown-menu ~ .dropdown-menu { 98 | left: 70px; 99 | } 100 | } 101 | 102 | /* Lists 103 | *********************************/ 104 | 105 | $listHeaderHeight: 40px; 106 | 107 | .list-wrap { 108 | white-space: normal; 109 | display: inline-block; 110 | box-sizing: border-box; 111 | vertical-align: top; 112 | height: 100%; 113 | width: 300px; 114 | border: 1px solid $borderLight; 115 | border-radius: 5px; 116 | 117 | & + .list-wrap { 118 | margin-left: 5px; 119 | } 120 | 121 | .nav { 122 | display: inline-block; 123 | position: absolute; 124 | right: 0; 125 | } 126 | .dropdown-toggle { 127 | padding-top: 0; 128 | padding-bottom: 0; 129 | .glyphicon { 130 | color: $bodyBlack; 131 | top: 4px; 132 | } 133 | } 134 | .dropdown-menu { 135 | min-width: 0; 136 | } 137 | &.dragged { 138 | position: absolute; 139 | opacity: 0.5; 140 | z-index: 2000; 141 | background: #fff; 142 | margin: -21px 0 0 -15px; 143 | } 144 | &.placeholder { 145 | border: 5px dashed $borderLight; 146 | } 147 | } 148 | 149 | .list-title { 150 | @extend .font-header; 151 | height: $listHeaderHeight; 152 | line-height: 44px; 153 | padding-left: 10px; 154 | position: relative; 155 | } 156 | 157 | .list-title-inline { 158 | font-weight: bold; 159 | } 160 | 161 | .list { 162 | overflow-y: auto; 163 | height: calc(100% - #{$listHeaderHeight}); 164 | margin: 0; 165 | } 166 | 167 | /* List Sorting 168 | *********************************/ 169 | 170 | body.dragging, 171 | body.dragging * { 172 | cursor: ew-resize !important; 173 | } 174 | 175 | .list-wrap .handle { 176 | display: inline-block; 177 | width: 15px; 178 | height: 15px; 179 | margin-right: -5px; 180 | position: relative; 181 | cursor: ew-resize; 182 | 183 | &:before, 184 | &:after { 185 | content: ""; 186 | position: absolute; 187 | height: 100%; 188 | border-left: 1px solid $borderDark; 189 | border-right: 1px solid $borderDark; 190 | } 191 | &:after { 192 | left: 0px; 193 | width: 10px; 194 | } 195 | &:before { 196 | left: 3px; 197 | width: 4px; 198 | } 199 | } 200 | 201 | /* List Form 202 | *********************************/ 203 | 204 | input[type=radio]:not(:checked) ~ .glyphicon { 205 | display: none; 206 | } 207 | 208 | .save-error { 209 | display: none; 210 | .exception & { display: block; } 211 | } 212 | 213 | /* Users 214 | *********************************/ 215 | 216 | .user { 217 | clear: both; 218 | box-sizing: border-box; 219 | cursor: default; 220 | padding: 5px; 221 | 222 | @include user-select(none); 223 | 224 | & + .user { 225 | border-top: 1px solid $borderLight; 226 | } 227 | &.selected { 228 | background: $borderLight; 229 | border-left: 2px solid $accent; 230 | padding-left: 3px; 231 | } 232 | &.unsaved { 233 | background: $accentNew; 234 | &.selected { 235 | background: desaturate(darken($accentNew, 5%), 20%); 236 | } 237 | } 238 | .glyphicon { 239 | font-size: .8em; 240 | top: 0; 241 | } 242 | } 243 | .user-image-wrap { 244 | float: left; 245 | margin-right: 10px; 246 | } 247 | .user-image { 248 | height: 40px; 249 | } 250 | .user-name { 251 | font-weight: 600; 252 | } 253 | .user-screen-name { 254 | color: $lightGrey; 255 | font-style: italic; 256 | } 257 | 258 | /* User Detail Modal 259 | *********************************/ 260 | 261 | .user-detail-header.row { 262 | margin: 0; 263 | width: 100%; 264 | 265 | .user-name { 266 | margin-bottom: 0; 267 | } 268 | } 269 | 270 | .user-detail-header-cell { 271 | display: inline-block; 272 | vertical-align: top; 273 | max-width: 49%; 274 | } 275 | 276 | .user-detail-img-wrap { 277 | float: left; 278 | width: 100%; 279 | margin-right: 10px; 280 | } 281 | 282 | .user-detail-body { 283 | clear: both; 284 | 285 | .list-group { 286 | margin-bottom: 0; 287 | } 288 | } 289 | 290 | .user-description { 291 | margin-top: 10px; 292 | } 293 | 294 | /* Generic Modal 295 | *********************************/ 296 | 297 | // Nice Gaussian effect, but too slow on current Chrome: 298 | // body.modal-open > .container { 299 | // -webkit-filter: blur(2px); 300 | // filter: blur(2px); 301 | // } 302 | 303 | .modal-footer, 304 | .modal-header { 305 | border: none; 306 | } 307 | 308 | .modal-body { 309 | padding-bottom: 0; 310 | .alert { margin-bottom: 0; } 311 | } -------------------------------------------------------------------------------- /src/coffee/ListCollection.coffee: -------------------------------------------------------------------------------- 1 | # List Collection 2 | # 3 | # Represents the main workspace, comprised of 4 | # one or more lists that can exchange users. 5 | # 6 | # Delegates to: 7 | # 8 | # - Toolbar: UI+hotkey bindings 9 | # - Twitter: Interacts with Twitter API 10 | # - Selection: Manages selection state 11 | # - List: Represents a single list 12 | # - CommandQueue: Tracks changes for save/undo/redo 13 | # 14 | class ListCollection extends EventEmitter 15 | 16 | constructor: (user, twitter) -> 17 | @el = $('.collection-wrap') 18 | @footer = $('.footer') 19 | @collection = null 20 | @user = user 21 | @twitter = twitter 22 | @cache = Cache.create @user.id 23 | @toolbar = null 24 | @selection = null 25 | @lists = [] 26 | @openLists = [] 27 | @commandQueue = new CommandQueue(@) 28 | _.defer => @init() 29 | 30 | init: () -> 31 | hasLists = $.Deferred() 32 | hasFriends = $.Deferred() 33 | 34 | # Lists 35 | @twitter.getLists().done (data) => 36 | waiting = [] 37 | for metadata in data.lists 38 | waiting.push @addList(metadata) 39 | $.when.apply($, waiting).done -> 40 | hasLists.resolve() 41 | 42 | # Following 43 | metadata = 44 | name: 'Following' 45 | member_count: @user.friends_count 46 | stream = new UserStream(0, metadata, @twitter) 47 | stream.ready().done => 48 | list = new List(stream, @cache) 49 | list.setCollection @ 50 | @lists.unshift list 51 | @openLists.unshift list 52 | hasFriends.resolve() 53 | 54 | # Render 55 | $.when(hasLists, hasFriends).done => 56 | @lists = _.sortBy @lists, (l) -> l.name.toUpperCase() 57 | openListIDs = @cache.get('open-lists') or [] 58 | @render() 59 | @openList(id) for id in openListIDs 60 | 61 | # Render 62 | ####################################### 63 | 64 | render: () -> 65 | # collection ui 66 | @el.html JST.collection(@) 67 | @collection = @el.find('.collection') 68 | @toolbar = new Toolbar(@el.find('.toolbar'), @footer, @) 69 | 70 | # lists 71 | elements = _.map @openLists, (list) -> list.render() 72 | @collection.append elements 73 | 74 | @bindEvents() 75 | 76 | bindEvents: () -> 77 | self = @ 78 | 79 | @collection.on 'click', '.user', (e) -> 80 | $this = $(this) 81 | userID = + $this.data 'id' 82 | listID = + $this.closest('.list').data 'id' 83 | self.select userID, listID, !(e.ctrlKey or e.metaKey), e.shiftKey 84 | 85 | @el.on 'click', '.toolbar-remove', => @removeFromList() 86 | 87 | @collection.sortable 88 | vertical: false 89 | handle: '.handle' 90 | itemSelector: 'section[sortable]' 91 | placeholder: '
    ' 92 | onDrop: ($item, container, _super) => 93 | _super($item, container) 94 | @updateListOrder() 95 | 96 | # Getters / Setters 97 | ####################################### 98 | 99 | getList: (listID) -> 100 | _(@lists).where(id: +listID).first() 101 | 102 | addList: (metadata) -> 103 | stream = new UserStream(metadata.id, metadata, @twitter) 104 | stream.ready().done => 105 | list = new List(stream, @cache) 106 | @lists.push list 107 | list.on 'destroy', => @removeList list.id 108 | list.on 'hide', => @closeList list.id 109 | 110 | removeList: (listID) -> 111 | list = @getList listID 112 | if list 113 | @closeList listID 114 | _.remove @lists, list 115 | @ 116 | 117 | openList: (listID) -> 118 | unless _.findWhere(@openLists, id: listID) 119 | list = @getList listID 120 | if list 121 | @openLists.push list 122 | @cacheState() 123 | @collection.append list.render() 124 | else 125 | console.error "Unable to find list #{listID}" 126 | @ 127 | 128 | closeList: (listID) -> 129 | list = @getList listID 130 | if list and _.findWhere(@openLists, id: listID) 131 | _.remove @openLists, list 132 | @cacheState() 133 | @ 134 | 135 | getUser: (userID) -> 136 | @twitter.getUser userID 137 | 138 | # Selection Management 139 | ####################################### 140 | 141 | select: (userID, listID, reset, range) -> 142 | list = @getList listID 143 | 144 | if range and listID is @selection?.listID 145 | # Range selection 146 | @selection.setRange userID 147 | else 148 | # Individual selection 149 | if @selection?.list.id is listID 150 | @selection.set userID, reset 151 | else 152 | @selection?.destroy() 153 | @setSelection(new Selection(list)) 154 | @selection.set userID 155 | @ 156 | 157 | setSelection: (selection) -> 158 | @selection = selection 159 | @selection.on 'change', => @trigger 'select' 160 | @selection.on 'destroy', => 161 | @selection = null 162 | @trigger 'select' 163 | 164 | selectRight: (offset = 1) -> 165 | unless @selection then return 166 | targetIndex = offset + _.findIndex @openLists, id: @selection.listID 167 | list = @openLists[targetIndex] 168 | if list 169 | index = @selection.getAnchorIndex() 170 | @selection.destroy() 171 | @setSelection(new Selection(list)) 172 | @selection.setAnchorIndex index 173 | 174 | selectLeft: () -> 175 | @selectRight -1 176 | 177 | moveRight: (offset = 1) -> 178 | unless @selection then return 179 | targetIndex = offset + _.findIndex @openLists, id: @selection.listID 180 | list = @openLists[targetIndex] 181 | if list 182 | userIDs = @selection.get() 183 | @moveToList list.id 184 | @setSelection(new Selection(list)) 185 | @selection.set userID for userID in userIDs 186 | 187 | moveLeft: () -> 188 | @moveRight -1 189 | 190 | moveToList: (listID) -> 191 | if @selection 192 | cmd = new MoveCommand(@selection, @getList(listID)) 193 | @commandQueue.push cmd 194 | @selection.destroy() 195 | @trigger 'change' 196 | 197 | addToList: (listID) -> 198 | if @selection 199 | cmd = new AddCommand(@selection, @getList(listID)) 200 | @commandQueue.push cmd 201 | @trigger 'change' 202 | 203 | removeFromList: () -> 204 | if @selection 205 | cmd = new RemoveCommand(@selection) 206 | @commandQueue.push cmd 207 | @selection.destroy() 208 | @trigger 'change' 209 | 210 | showDetails: () -> 211 | if @userModal 212 | @userModal.modal 'hide' 213 | @userModal = null 214 | else if @selection.count() is 1 215 | user = @getUser @selection.get()[0] 216 | user.thumbnail = user.thumbnail.replace('_normal.', '.'); 217 | @userModal = $(JST['modal'](content: JST['user-detail'](user))).modal 218 | backdrop: true 219 | keyboard: true 220 | show: true 221 | 222 | # Save Changes 223 | ####################################### 224 | 225 | save: () -> 226 | if @isModified() 227 | @commandQueue.save() 228 | 229 | 230 | isModified: () -> 231 | @commandQueue.isModified() 232 | 233 | # List Management 234 | ####################################### 235 | 236 | update: (listChanges) -> 237 | list = @getList listChanges.id 238 | if list 239 | list.update listChanges 240 | else 241 | @addList listChanges 242 | .done => @openList listChanges.id 243 | @ 244 | 245 | updateListOrder: () -> 246 | ids = [] 247 | @collection.children().each -> 248 | ids.push $(this).find('.list').data('id') 249 | @openLists.length = 0 250 | @openLists = _.map ids, (id) => @getList id 251 | @cacheState() 252 | @ 253 | 254 | getMembershipMap: () -> 255 | map = {} 256 | for list in @lists 257 | if list.id is 0 then continue 258 | for user in list.getUsers() 259 | map[user.id] = true 260 | map 261 | 262 | cacheState: () -> 263 | @cache.set 'open-lists', _.pluck(@openLists, 'id') 264 | 265 | # Debugging 266 | ####################################### 267 | 268 | debug: () -> 269 | for list in @openLists 270 | unless list.id then continue 271 | console.group list.name 272 | list.debug() 273 | console.groupEnd() 274 | --------------------------------------------------------------------------------