5 |
6 |
8 | {{= progress }}% Complete
9 |
10 |
11 | {{ if (progress === 100.0) { }}
12 |
Changes have been saved.
13 | {{ } else if (error) { }}
14 |
Sorry, something went wrong!
15 | {{ } }}
16 |
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 |
6 |
41 |
Sorry, something went wrong!
42 |
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 |