diff --git a/app/assets/javascripts/app/index.js.coffee b/app/assets/javascripts/app/index.js.coffee index 2e4596b2c..494ca106e 100644 --- a/app/assets/javascripts/app/index.js.coffee +++ b/app/assets/javascripts/app/index.js.coffee @@ -1,40 +1,30 @@ #s#= require json2 -#= require ./lib/jquery-1.8.1.min.js -#= require ./lib/ui/jquery-ui-1.8.23.custom.min.js +#= require ./lib/core/jquery-1.8.1.min.js +#= require ./lib/core/jquery-ui-1.8.23.custom.min.js +#= require ./lib/core/underscore-1.3.3.js +#not_used= require_tree ./lib/spine #= require ./lib/spine/spine.js #= require ./lib/spine/ajax.js #= require ./lib/spine/route.js -#= require ./lib/bootstrap-dropdown.js -#= require ./lib/bootstrap-tooltip.js -#= require ./lib/bootstrap-popover.js -#= require ./lib/bootstrap-modal.js -#= require ./lib/bootstrap-tab.js -#= require ./lib/bootstrap-transition.js +#not_used= require_tree ./lib/bootstrap +#= require ./lib/bootstrap/bootstrap-dropdown.js +#= require ./lib/bootstrap/bootstrap-tooltip.js +#= require ./lib/bootstrap/bootstrap-popover.js +#= require ./lib/bootstrap/bootstrap-modal.js +#= require ./lib/bootstrap/bootstrap-tab.js +#= require ./lib/bootstrap/bootstrap-transition.js -#= require ./lib/underscore-1.3.3.js -#= require ./lib/ba-linkify.js -#= require ./lib/jquery.tagsinput.js -#= require ./lib/jquery.noty.js -#= require ./lib/waypoints.js -#= require ./lib/fileuploader.js -#= require ./lib/jquery.elastic.source.js +#= require_tree ./lib/base #not_used= require_tree ./lib #= require_self #= require_tree ./models #= require_tree ./controllers #= require_tree ./views -#= require ./lib/ajax.js.coffee -#= require ./lib/clipboard.js.coffee -#= require ./lib/websocket.js.coffee -#= require ./lib/auth.js.coffee -#= require ./lib/i18n.js.coffee -#= require ./lib/store.js.coffee -#= require ./lib/collection.js.coffee -#= require ./lib/event.js.coffee -#= require ./lib/interface_handle.js.coffee + +#= require_tree ./lib/app class App extends Spine.Controller @view: (name) -> diff --git a/app/assets/javascripts/app/lib/app/ajax.js.coffee b/app/assets/javascripts/app/lib/app/ajax.js.coffee new file mode 100644 index 000000000..531d2a371 --- /dev/null +++ b/app/assets/javascripts/app/lib/app/ajax.js.coffee @@ -0,0 +1,66 @@ +$ = jQuery.sub() + +class App.Com + _instance = undefined # Must be declared here to force the closure on the class + @ajax: (args) -> # Must be a static method + if _instance == undefined + _instance ?= new _Singleton + + _instance.ajax(args) + _instance + +# The actual Singleton class +class _Singleton + defaults: + contentType: 'application/json' + dataType: 'json' + processData: false + headers: {'X-Requested-With': 'XMLHttpRequest'} + cache: false + async: true + + queue_list: {} + count: 0 + + constructor: (@args) -> + + # bindings + $('body').bind( 'ajaxSend', => + @_show_spinner() + ).bind( 'ajaxComplete', => + @_hide_spinner() + ) + + # show error messages + $('body').bind( 'ajaxError', ( e, jqxhr, settings, exception ) -> + status = jqxhr.status + detail = jqxhr.responseText + if !status && !detail + detail = 'General communication error, maybe internet is not available!' + new App.ErrorModal( + message: 'StatusCode: ' + status + detail: detail + close: true + ) + ) + + ajax: (params, defaults) -> + data = $.extend({}, @defaults, defaults, params) + if params['id'] + if @queue_list[ params['id'] ] + @queue_list[ params['id'] ].abort() + @queue_list[ params['id'] ] = $.ajax( data ) + else + $.ajax( data ) + +# console.log('AJAX', params['url'] ) + + _show_spinner: => + @count++ + $('.spinner').show() + + _hide_spinner: => + @count-- + if @count == 0 + $('.spinner').hide() + diff --git a/app/assets/javascripts/app/lib/app/auth.js.coffee b/app/assets/javascripts/app/lib/app/auth.js.coffee new file mode 100644 index 000000000..5beec7d18 --- /dev/null +++ b/app/assets/javascripts/app/lib/app/auth.js.coffee @@ -0,0 +1,108 @@ +$ = jQuery.sub() + +class App.Auth + + @login: (params) -> + console.log 'login(...)', params + App.Com.ajax( + id: 'login', + type: 'POST', + url: '/signin', + data: JSON.stringify(params.data), + success: (data, status, xhr) => + + # clear store + App.Store.clear('all') + + # execute callback + params.success(data, status, xhr) + + error: (xhr, statusText, error) => + params.error(xhr, statusText, error) + ) + + @loginCheck: -> + console.log 'loginCheck(...)' + App.Com.ajax( + id: 'login_check', + async: false, + type: 'GET', + url: '/signshow', + success: (data, status, xhr) => + console.log 'logincheck:success', data + + # if session is not valid + if data.error + + # update config + for key, value of data.config + window.Config[key] = value + + # empty session + window.Session = {} + + # update websocked auth info + App.WebSocket.auth() + + # rebuild navbar with new navbar items + App.Event.trigger 'navrebuild' + + return false; + + # set avatar + if !data.session.image + data.session.image = 'http://placehold.it/48x48' + + # update config + for key, value of data.config + window.Config[key] = value + + # store user data + for key, value of data.session + window.Session[key] = value + + # update websocked auth info + App.WebSocket.auth() + + # refresh/load default collections + for key, value of data.default_collections + App.Collection.reset( type: key, data: value ) + + # rebuild navbar with new navbar items + App.Event.trigger 'navrebuild', data.session + + # rebuild navbar with updated ticket count of overviews + App.Event.trigger 'navupdate_remote' + + error: (xhr, statusText, error) => + console.log 'loginCheck:error'#, error, statusText, xhr.statusCode + + # empty session + window.Session = {} + + # clear store + App.Store.clear('all') + + # update websocked auth info + App.WebSocket.auth() + ) + + @logout: -> + console.log 'logout(...)' + App.Com.ajax( + id: 'logout', + type: 'DELETE', + url: '/signout', + success: => + + # update websocked auth info + App.WebSocket.auth() + + # clear store + App.Store.clear('all') + + error: (xhr, statusText, error) => + + # update websocked auth info + App.WebSocket.auth() + ) \ No newline at end of file diff --git a/app/assets/javascripts/app/lib/app/clipboard.js.coffee b/app/assets/javascripts/app/lib/app/clipboard.js.coffee new file mode 100644 index 000000000..1ad6ffeb2 --- /dev/null +++ b/app/assets/javascripts/app/lib/app/clipboard.js.coffee @@ -0,0 +1,181 @@ +class App.ClipBoard + _instance = undefined + + @bind: (el) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.bind(el) + + @getSelected: -> + if _instance == undefined + _instance ?= new _Singleton + _instance.getSelected() + + @getSelectedLast: -> + if _instance == undefined + _instance ?= new _Singleton + _instance.getSelectedLast() + + @keycode: (code) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.keycode(code) + +class _Singleton + constructor: -> + @selection = '' + @selectionLast = '' + + # bind to fill selected text into + bind: (el) -> + $(el).bind('mouseup', => + + # check selection on mouse up + @selection = @_getSelected() + if @selection + @selectionLast = @selection + ) + $(el).bind('keyup', (e) => + + # check selection on sonder key + if e.keyCode == 91 + @selection = @_getSelected() + if @selection + @selectionLast = @selection + + # check selection of arrow keys + if e.keyCode == 37 || e.keyCode == 38 || e.keyCode == 39 || e.keyCode == 40 + @selection = @_getSelected() + if @selection + @selectionLast = @selection + ) + + # get cross browser selected string + _getSelected: -> + text = ''; + if window.getSelection + text = window.getSelection() + else if document.getSelection + text = document.getSelection() + else if document.selection + text = document.selection.createRange().text + if text + text = text.toString().trim() + text + + # get current selection + getSelected: -> + @selection + + # get latest selection + getSelectedLast: -> + @selectionLast + + keycode: (code) -> + for key, value of @keycodesTable() + if value.toString() is code.toString() + return key + + keycodesTable: -> +   map = { +   'backspace' : 8, +   'tab' : 9, +   'enter' : 13, +   'shift' : 16, +   'ctrl' : 17, +   'alt' : 18, +   'space' : 32, +   'pause_break' : '19', +   'caps_lock' : '20', +   'escape' : '27', +   'page_up' : '33', +   'page down' : '34', +   'end' : '35', +   'home' : '36', +   'left_arrow' : '37', +   'up_arrow' : '38', +   'right_arrow' : '39', +   'down_arrow' : '40', +   'insert' : '45', +   'delete' : '46', +   '0' : '48', +   '1' : '49', +   '2' : '50', +   '3' : '51', +   '4' : '52', +   '5' : '53', +   '6' : '54', +   '7' : '55', +   '8' : '56', +   '9' : '57', +   'a' : '65', +   'b' : '66', +   'c' : '67', +   'd' : '68', +   'e' : '69', +   'f' : '70', +   'g' : '71', +   'h' : '72', +   'i' : '73', +   'j' : '74', +   'k' : '75', +   'l' : '76', +   'm' : '77', +   'n' : '78', +   'o' : '79', +   'p' : '80', +   'q' : '81', +   'r' : '82', +   's' : '83', +   't' : '84', +   'u' : '85', +   'v' : '86', +   'w' : '87', +   'x' : '88', +   'y' : '89', +   'z' : '90', +   'left_window key' : '91', +   'right_window key' : '92', +   'select_key' : '93', +   'numpad 0' : '96', +   'numpad 1' : '97', +   'numpad 2' : '98', +   'numpad 3' : '99', +   'numpad 4' : '100', +   'numpad 5' : '101', +   'numpad 6' : '102', +   'numpad 7' : '103', +   'numpad 8' : '104', +   'numpad 9' : '105', +   'multiply' : '106', +   'add' : '107', +   'subtract' : '109', +   'decimal point' : '110', +   'divide' : '111', +   'f1' : '112', +   'f2' : '113', +   'f3' : '114', +   'f4' : '115', +   'f5' : '116', +   'f6' : '117', +   'f7' : '118', +   'f8' : '119', +   'f9' : '120', +   'f10' : '121', +   'f11' : '122', +   'f12' : '123', +   'num_lock' : '144', +   'scroll_lock' : '145', +   'semi_colon' : '186', +   'equal_sign' : '187', +   'comma' : '188', +   'dash' : '189', +   'period' : '190', +   'forward_slash' : '191', +   'grave_accent' : '192', +   'open_bracket' : '219', +   'backslash' : '220', +   'closebracket' : '221', +   'single_quote' : '222' + } + map diff --git a/app/assets/javascripts/app/lib/app/collection.js.coffee b/app/assets/javascripts/app/lib/app/collection.js.coffee new file mode 100644 index 000000000..7d3b2e7e0 --- /dev/null +++ b/app/assets/javascripts/app/lib/app/collection.js.coffee @@ -0,0 +1,363 @@ +class App.Collection + _instance = undefined + + @init: -> + _instance = new _Singleton + + @load: ( args ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.load( args ) + + @reset: ( args ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.reset( args ) + + @find: ( type, id, callback, force ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.find( type, id, callback, force ) + + @get: ( args ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.get( args ) + + @all: ( type ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.all( type ) + + @deleteAll: ( type ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.deleteAll( type ) + + @findByAttribute: ( type, key, value ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.findByAttribute( type, key, value ) + + @count: ( type ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.count( type ) + + @fetch: ( type ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.fetch( type ) + + @observe: (args) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.observe(args) + + @observeUnbindLevel: (level) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.observeUnbindLevel(level) + + @_observeStats: -> + if _instance == undefined + _instance ?= new _Singleton + _instance._observeStats() + +class _Singleton + + constructor: (@args) -> + + # add trigger - bind new events + App.Event.bind 'loadCollection', (data) => + + # load collections + if data.collections + for type of data.collections + + console.log 'loadCollection:trigger', type, data.collections[type] + @load( localStorage: data.localStorage, type: type, data: data.collections[type] ) + + # add trigger - bind new events + App.Event.bind 'resetCollection', (data) => + + # load collections + if data.collections + for type of data.collections + + console.log 'resetCollection:trigger', type, data.collections[type] + @reset( localStorage: data.localStorage, type: type, data: data.collections[type] ) + + # find collections to load + @_loadCollectionAll() + + _loadCollectionAll: -> + list = App.Store.list() + for key in list + parts = key.split('::') + if parts[0] is 'collection' + data = App.Store.get( key ) + if data && data.localStorage + console.log('load INIT', data) + @load( data ) + + reset: (params) -> + console.log( 'reset', params ) + + # empty in-memory + App[ params.type ].refresh( [], { clear: true } ) + + # remove permanent storage + list = App.Store.list() + for key in list + parts = key.split('::') + if parts[0] is 'collection' && parts[1] is params.type + App.Store.delete(key) + + # load with new data + @load(params) + + load: (params) -> + console.log( 'load', params ) + + return if _.isEmpty( params.data ) + + localStorage = params.localStorage + + # load full array once + if _.isArray( params.data ) +# console.log( 'load ARRAY', params.data) + App[ params.type ].refresh( params.data ) + + # remember in store if not already requested from local storage + if !localStorage + for object in params.data + App.Store.write( 'collection::' + params.type + '::' + object.id, { type: params.type, localStorage: true, data: [ object ] } ) + return + + # load data from object +# if _.isObject( params.data ) + for key, object of params.data +# console.log( 'load OB', object) + App[ params.type ].refresh( object ) + + # remember in store if not already requested from local storage + if !localStorage + App.Store.write( 'collection::' + params.type + '::' + object.id, { type: params.type, localStorage: true, data: [ object ] } ) + + find: ( type, id, callback, force ) -> + +# console.log( 'find', type, id, force ) +# if App[type].exists( id ) && !callback + if !force && App[type].exists( id ) +# console.log( 'find exists', type, id ) + data = App[type].find( id ) + if callback + callback( data ) + else + if force + console.log( 'find forced to load!', type, id ) + else + console.log( 'find not loaded!', type, id ) + if callback + + # execute callback if record got loaded + App[type].one 'refresh', -> + console.log 'loaded..' + type + '..', id + + data = App.Collection.find( type, id ) + callback( data ) + + # fetch object + console.log 'loading..' + type + '..', id + App[type].fetch( id: id ) + return true + return false + + # users + if type == 'User' + + # set socal media links + if data['accounts'] + for account of data['accounts'] + if account == 'twitter' + data['accounts'][account]['link'] = 'http://twitter.com/' + data['accounts'][account]['username'] + if account == 'facebook' + data['accounts'][account]['link'] = 'https://www.facebook.com/profile.php?id=' + data['accounts'][account]['uid'] + + # set image url + if data && !data['image'] + data['image'] = 'http://placehold.it/48x48' + + return data + + # tickets + else if type == 'Ticket' + + # priority + data.ticket_priority = @find( 'TicketPriority', data.ticket_priority_id ) + + # state + data.ticket_state = @find( 'TicketState', data.ticket_state_id ) + + # group + data.group = @find( 'Group', data.group_id ) + + # customer + if data.customer_id + data.customer = @find( 'User', data.customer_id ) + + # owner + if data.owner_id + data.owner = @find( 'User', data.owner_id ) + + # add created & updated + if data.created_by_id + data.created_by = @find( 'User', data.created_by_id ) + if data.updated_by_id + data.updated_by = @find( 'User', data.updated_by_id ) + + return data + + # articles + else if type == 'TicketArticle' + + # add created & updated + data.created_by = @find( 'User', data.created_by_id ) + + # add possible actions + data.article_type = @find( 'TicketArticleType', data.ticket_article_type_id ) + data.article_sender = @find( 'TicketArticleSender', data.ticket_article_sender_id ) + + return data + + # history + else if type == 'History' + + # add user + data.created_by = @find( 'User', data.created_by_id ) + + # add possible actions + if data.history_attribute_id + data.attribute = @find( 'HistoryAttribute', data.history_attribute_id ) + if data.history_type_id + data.type = @find( 'HistoryType', data.history_type_id ) + if data.history_object_id + data.object = @find( 'HistoryObject', data.history_object_id ) + + return data + + else + return data + + get: (params) -> + console.log('get') + App[ params.type ].refresh( object, options: { clear: true } ) + + all: (params) -> + all = App[ params.type ].all() + all_complied = [] + for item in all + item_new = @find( params.type, item.id ) + all_complied.push item_new + + if params.filter + all_complied = @_filter( all_complied, params.filter ) + + if params.filterExtended + all_complied = @_filterExtended( all_complied, params.filterExtended ) + + if params.sortBy + all_complied = @_sortBy( all_complied, params.sortBy ) + + if params.order + all_complied = @_order( all_complied, params.order ) + + return all_complied + + deleteAll: (type) -> + App[type].deleteAll() + + findByAttribute: ( type, key, value ) -> + App[type].findByAttribute( key, value ) + + count: ( type ) -> + App[type].count() + + fetch: ( type ) -> + App[type].fetch() + + _sortBy: ( collection, attribute ) -> + _.sortBy( collection, (item) -> + return '' if item[ attribute ] is undefined || item[ attribute ] is null + return item[ attribute ].toLowerCase() + ) + + _order: ( collection, attribute ) -> + if attribute is 'DESC' + return collection.reverse() + return collection + + _filter: ( collection, filter ) -> + for key, value of filter + collection = _.filter( collection, (item) -> + if item[ key ] is value + return item + ) + return collection + + _filterExtended: ( collection, filters ) -> + collection = _.filter( collection, (item) -> + + # check all filters + for filter in filters + + # all conditions need match + matchInner = undefined + for key, value of filter + + if matchInner isnt false + reg = new RegExp( value, 'i' ) + if item[ key ] isnt undefined && item[ key ] isnt null && item[ key ].match( reg ) + matchInner = true + else + matchInner = false + + # if all matched, add item to new collection + if matchInner is true + return item + + return + ) + return collection + + observeUnbindLevel: (level) -> + return if !@observeCurrent + return if !@observeCurrent[level] + for observers in @observeCurrent[level] + @_observeUnbind( observers ) + @observeCurrent[level] = [] + + observe: (data) -> + if !@observeCurrent + @observeCurrent = {} + + if !@observeCurrent[ data.level ] + @observeCurrent[ data.level ] = [] + + @observeCurrent[ data.level ].push data.collections + for observe in data.collections + events = observe.event.split(' ') + for event in events + if App[ observe.collection ] + App[ observe.collection ].bind( event, observe.callback ) + + _observeUnbind: (observers) -> + for observe in observers + events = observe.event.split(' ') + for event in events + if App[ observe.collection ] + App[ observe.collection ].unbind( event, observe.callback ) + + _observeStats: -> + @observeCurrent \ No newline at end of file diff --git a/app/assets/javascripts/app/lib/app/event.js.coffee b/app/assets/javascripts/app/lib/app/event.js.coffee new file mode 100644 index 000000000..99c8238cf --- /dev/null +++ b/app/assets/javascripts/app/lib/app/event.js.coffee @@ -0,0 +1,90 @@ +class App.Event + _instance = undefined + + @init: -> + _instance = new _Singleton + + @bind: ( events, callback, level ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.bind( events, callback, level ) + + @unbind: ( events, callback, level ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.unbind( events, callback, level ) + + @trigger: ( events, data ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.trigger( events, data ) + + @unbindLevel: (level) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.unbindLevel(level) + + @_allBindings: -> + if _instance == undefined + _instance ?= new _Singleton + _instance._allBindings() + +class _Singleton + + constructor: -> + @eventCurrent = {} + + unbindLevel: (level) -> + return if !@eventCurrent[level] + for item in @eventCurrent[level] + @unbind( item.event, item.callback, level ) + @eventCurrent[level] = [] + + bind: ( events, callback, level ) -> + + if !level + level = '_all' + + if !@eventCurrent[level] + @eventCurrent[level] = [] + + # level boundary events + eventList = events.split(' ') + for event in eventList + + # remember all events + @eventCurrent[ level ].push { + event: event, + callback: callback, + } + + # bind + Spine.bind( event, callback ) + + unbind: ( events, callback, level ) -> + + if !level + level = '_all' + + if !@eventCurrent[level] + @eventCurrent[level] = [] + + eventList = events.split(' ') + for event in eventList + + # remove from + @eventCurrent[level] = _.filter( @eventCurrent[level], (item) -> + if callback + return item if item.event isnt event && item.callback isnt callback + else + return item if item.event isnt event + ) + Spine.unbind( event, callback ) + + trigger: ( events, data ) -> + eventList = events.split(' ') + for event in eventList + Spine.trigger event, data + + _allBindings: -> + @eventCurrent \ No newline at end of file diff --git a/app/assets/javascripts/app/lib/app/i18n.js.coffee b/app/assets/javascripts/app/lib/app/i18n.js.coffee new file mode 100644 index 000000000..413c91b98 --- /dev/null +++ b/app/assets/javascripts/app/lib/app/i18n.js.coffee @@ -0,0 +1,169 @@ +$ = jQuery.sub() + +class App.i18n + _instance = undefined + + @init: -> + _instance ?= new _Singleton + + @translateContent: ( string, args... ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.translate_content( string, args ) + + @translateInline: ( string, args... ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.translate_inline( string, args ) + + @translateTimestamp: ( args ) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.timestamp( args ) + +class _Singleton + + constructor: -> + @locale = 'de' + @timestampFormat = 'yyyy-mm-dd HH:MM' + @set( @locale ) + +# $('.translation [contenteditable]') + $('body') + .delegate '.translation', 'focus', (e) => + $this = $(e.target) + $this.data 'before', $this.html() +# console.log('11111current', $this.html()) + return $this +# .delegate '.translation', 'blur keyup paste', (e) => + .delegate '.translation', 'blur', (e) => + $this = $(e.target) + source = $this.attr('data-text') + + # get new translation + translation_new = $this.html() + translation_new = ('' + translation_new) + .replace(/<.+?>/g, '') + + # set new translation + $this.html(translation_new) + + # update translation + return if $this.data('before') is translation_new + console.log 'Translation Update', translation_new, $this.data 'before' + $this.data 'before', translation_new + + # update runtime translation map + @map[ source ] = translation_new + + # replace rest in page + $(".translation[data-text='#{source}']").html( translation_new ) + + # update permanent translation map + translation = App.Collection.findByAttribute( 'Translation', 'source', source ) + if translation + translation.updateAttribute( 'target', translation_new ) + else + translation = new App.Translation + translation.load( + locale: @locale, + source: source, + target: translation_new, + ) + translation.save() + + return $this + + set: ( locale ) -> + @map = {} + App.Com.ajax( + id: 'i18n-set-' + locale, + type: 'GET', + url: '/translations/lang/' + locale, + async: false, + success: (data, status, xhr) => + + # set timestamp format + if data.timestampFormat + @timestampFormat = data.timestampFormat + + # load translation collection + for object in data.list + + # set runtime lookup table + @map[ object[1] ] = object[2] + + # load in collection if needed + App.Translation.refresh( { id: object[0], source: object[1], target: object[2], locale: @locale } ) + + error: (xhr, statusText, error) => + console.log 'error', error, statusText, xhr.statusCode + ) + + translate_inline: ( string, args... ) => + @translate( string, args... ) + + translate_content: ( string, args... ) => + translated = @translate( string, args... ) +# replace = '' + translated + '' + if window.Config['Translation'] + replace = '' + translated + '' + # if !@_translated + # replace += 'XX' + replace += '' + else + translated + + translate: ( string, args... ) => + + # return '' on undefined + return '' if string is undefined + + # return translation + if @map[string] isnt undefined + @_translated = true + translated = @map[string] + else + @_translated = false + translated = string + + # search %s + for arg in args + translated = translated.replace(/%s/, arg) + + # escape + translated = @escape(translated) + + # return translated string + return translated + + escape: ( string ) -> + string = ( '' + string ) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\x22/g, '"') + + timestamp: ( time ) => + s = ( num, digits ) -> + while num.toString().length < digits + num = "0" + num + return num + + timeObject = new Date(time) + d = timeObject.getDate() + m = timeObject.getMonth() + 1 + y = timeObject.getFullYear() + S = timeObject.getSeconds() + M = timeObject.getMinutes() + H = timeObject.getHours() + format = @timestampFormat + format = format.replace /dd/, s( d, 2 ) + format = format.replace /d/, d + format = format.replace /mm/, s( m, 2 ) + format = format.replace /m/, m + format = format.replace /yyyy/, y + format = format.replace /SS/, s( S, 2 ) + format = format.replace /MM/, s( M, 2 ) + format = format.replace /HH/, s( H, 2 ) + return format diff --git a/app/assets/javascripts/app/lib/app/interface_handle.js.coffee b/app/assets/javascripts/app/lib/app/interface_handle.js.coffee new file mode 100644 index 000000000..341325939 --- /dev/null +++ b/app/assets/javascripts/app/lib/app/interface_handle.js.coffee @@ -0,0 +1,76 @@ + +class App.Run extends App.Controller + constructor: -> + super + @log 'RUN app' + @el = $('#app') + + # init collections + App.Collection.init() + + # create web socket connection + App.WebSocket.connect() + + # init of i18n + App.i18n.init() + + # start navigation controller + new App.Navigation( el: @el.find('#navigation') ) + + # check if session already exists/try to get session data from server + App.Auth.loginCheck() + + # start notify controller + new App.Notify( el: @el.find('#notify') ) + + # start content + new App.Content( el: @el.find('#content') ) + + # bind to fill selected text into + App.ClipBoard.bind( @el ) + +class App.Content extends Spine.Controller + className: 'container' + + constructor: -> + super + @log 'RUN content' + + for route, callback of Config.Routes + do (route, callback) => + @route(route, (params) -> + + # remove observers for page + App.Collection.observeUnbindLevel('page') + + # remove events for page + App.Event.unbindLevel('page') + + # unbind in controller area + @el.unbind() + @el.undelegate() + + # send current controller + params_only = {} + for i of params + if typeof params[i] isnt 'object' + params_only[i] = params[i] + + # tell server what we are calling right now + App.WebSocket.send( + action: 'active_controller', + controller: route, + params: params_only, + ) + + # remove waypoints + $('footer').waypoint('remove') + + params.el = @el + new callback( params ) + + # scroll to top +# window.scrollTo(0,0) + ) + + Spine.Route.setup() \ No newline at end of file diff --git a/app/assets/javascripts/app/lib/app/store.js.coffee b/app/assets/javascripts/app/lib/app/store.js.coffee new file mode 100644 index 000000000..3507d5ead --- /dev/null +++ b/app/assets/javascripts/app/lib/app/store.js.coffee @@ -0,0 +1,61 @@ +class App.Store + _instance = undefined # Must be declared here to force the closure on the class + @renew: -> + _instance = new _Singleton + + @write: (key, value) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.write(key, value) + + @get: (args) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.get(args) + + @delete: (args) -> + if _instance == undefined + _instance ?= new _Singleton + _instance.delete(args) + + @clear: -> + if _instance == undefined + _instance ?= new _Singleton + _instance.clear() + + @list: () -> + if _instance == undefined + _instance ?= new _Singleton + _instance.list() + +# The actual Singleton class +class _Singleton + + # write to local storage + write: (key, value) -> + localStorage.setItem( key, JSON.stringify( value ) ) + + # get item + get: (key) -> + value = localStorage.getItem( key ) + return if !value + object = JSON.parse( value ) + return object + + # delete item + delete: (key) -> + localStorage.removeItem( key ) + + # clear local storage + clear: -> + localStorage.clear() + + # return list of all keys + list: -> + list = [] + logLength = localStorage.length-1; + for count in [0..logLength] + key = localStorage.key( count ) + if key + list.push key + list \ No newline at end of file diff --git a/app/assets/javascripts/app/lib/app/websocket.js.coffee b/app/assets/javascripts/app/lib/app/websocket.js.coffee new file mode 100644 index 000000000..052195eec --- /dev/null +++ b/app/assets/javascripts/app/lib/app/websocket.js.coffee @@ -0,0 +1,163 @@ +$ = jQuery.sub() + +class App.WebSocket + _instance = undefined # Must be declared here to force the closure on the class + @connect: (args) -> # Must be a static method + if _instance == undefined + _instance ?= new _Singleton + _instance + + @close: (args) -> # Must be a static method + if _instance isnt undefined + _instance.close() + + @send: (args) -> # Must be a static method + @connect() + _instance.send(args) + + @auth: (args) -> # Must be a static method + @connect() + _instance.auth(args) + +# The actual Singleton class +class _Singleton extends App.Controller + queue: [] + supported: true + + constructor: (@args) -> + @connect() + + send: (data) => + return if !@supported +# console.log 'ws:send trying', data, @ws, @ws.readyState + + # A value of 0 indicates that the connection has not yet been established. + # A value of 1 indicates that the connection is established and communication is possible. + # A value of 2 indicates that the connection is going through the closing handshake. + # A value of 3 indicates that the connection has been closed or could not be opened. + if @ws.readyState is 0 + @queue.push data + else +# console.log( 'ws:send', data ) + string = JSON.stringify( data ) + @ws.send(string) + + auth: (data) => + return if !@supported + + # logon websocket + data = { + action: 'login', + session: window.Session + } + @send(data) + + close: => + return if !@supported + + @ws.close() + + ping: => + return if !@supported + +# console.log 'send websockend ping' + @send( { action: 'ping' } ) + + # check if ping is back within 2 min + @clearDelay('websocket-ping-check') + check = => + console.log 'no websockend ping response, reconnect...' + @close() + @delay check, 120000, 'websocket-ping-check' + + pong: -> + return if !@supported +# console.log 'received websockend ping' + + # test again after 1 min + @delay @ping, 60000 + + connect: => +# console.log '------------ws connect....--------------' + + if !window.WebSocket + @error = new App.ErrorModal( + message: 'Sorry, no websocket support!' + ) + @supported = false + return + + protocol = 'ws://' + if window.location.protocol is 'https:' + protocol = 'wss://' + + @ws = new window.WebSocket( protocol + window.location.hostname + ":6042/" ) + + # Set event handlers. + @ws.onopen = => + console.log( 'onopen' ) + + # close error message show up (because try so connect again) if exists + @clearDelay('websocket-no-connection-try-reconnect') + if @error + @error.modalHide() + @error = undefined + + @auth() + + # empty queue + for item in @queue +# console.log( 'ws:send queue', item ) + @send(item) + @queue = [] + + # send ping to check connection + @delay @ping, 60000 + + @ws.onmessage = (e) => + pipe = JSON.parse( e.data ) + console.log( 'ws:onmessage', pipe ) + + # go through all blocks + for item in pipe + + # reset reconnect loop + if item['action'] is 'pong' + @pong() + + # fill collection + if item['collection'] + console.log( "ws:onmessage collection:" + item['collection'] ) + App.Store.write( item['collection'], item['data'] ) + + # fire event + if item['event'] + if typeof item['event'] is 'object' + for event in item['event'] + console.log( "ws:onmessage event:" + event ) + App.Event.trigger( event, item['data'] ) + else + console.log( "ws:onmessage event:" + item['event'] ) + App.Event.trigger( item['event'], item['data'] ) + + # bind to send messages + App.Event.bind 'ws:send', (data) => + @send(data) + + @ws.onclose = (e) => + console.log( 'onclose', e ) + + # show error message, first try to reconnect + if !@error + message = => + @error = new App.ErrorModal( + message: 'No connection to websocket, trying to reconnect...' + ) + @delay message, 7000, 'websocket-no-connection-try-reconnect' + + # try reconnect after 4.5 sec. + @delay @connect, 4500 + + @ws.onerror = -> + console.log( 'onerror' ) + diff --git a/app/assets/javascripts/app/lib/base/ba-linkify.js b/app/assets/javascripts/app/lib/base/ba-linkify.js new file mode 100644 index 000000000..293dd67af --- /dev/null +++ b/app/assets/javascripts/app/lib/base/ba-linkify.js @@ -0,0 +1,179 @@ +/*! + * linkify - v0.3 - 6/27/2009 + * http://benalman.com/code/test/js-linkify/ + * + * Copyright (c) 2009 "Cowboy" Ben Alman + * Licensed under the MIT license + * http://benalman.com/about/license/ + * + * Some regexps adapted from http://userscripts.org/scripts/review/7122 + */ + +// Turn text into linkified html. +// +// var html = linkify( text, options ); +// +// options: +// +// callback (Function) - default: undefined - if defined, this will be called +// for each link- or non-link-chunk with two arguments, text and href. If the +// chunk is non-link, href will be omitted. +// +// punct_regexp (RegExp | Boolean) - a RegExp that can be used to trim trailing +// punctuation from links, instead of the default. +// +// This is a work in progress, please let me know if (and how) it fails! + +window.linkify = (function(){ + var + SCHEME = "[a-z\\d.-]+://", + IPV4 = "(?:(?:[0-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])\\.){3}(?:[0-9]|[1-9]\\d|1\\d{2}|2[0-4]\\d|25[0-5])", + HOSTNAME = "(?:(?:[^\\s!@#$%^&*()_=+[\\]{}\\\\|;:'\",.<>/?]+)\\.)+", + TLD = "(?:ac|ad|aero|ae|af|ag|ai|al|am|an|ao|aq|arpa|ar|asia|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|biz|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|cat|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|coop|com|co|cr|cu|cv|cx|cy|cz|de|dj|dk|dm|do|dz|ec|edu|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gov|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|info|int|in|io|iq|ir|is|it|je|jm|jobs|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mil|mk|ml|mm|mn|mobi|mo|mp|mq|mr|ms|mt|museum|mu|mv|mw|mx|my|mz|name|na|nc|net|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|org|pa|pe|pf|pg|ph|pk|pl|pm|pn|pro|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sy|sz|tc|td|tel|tf|tg|th|tj|tk|tl|tm|tn|to|tp|travel|tr|tt|tv|tw|tz|ua|ug|uk|um|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|xn--0zwm56d|xn--11b5bs3a9aj6g|xn--80akhbyknj4f|xn--9t4b11yi5a|xn--deba0ad|xn--g6w251d|xn--hgbk6aj7f53bba|xn--hlcj6aya9esc7a|xn--jxalpdlp|xn--kgbechtv|xn--zckzah|ye|yt|yu|za|zm|zw)", + HOST_OR_IP = "(?:" + HOSTNAME + TLD + "|" + IPV4 + ")", + PATH = "(?:[;/][^#?<>\\s]*)?", + QUERY_FRAG = "(?:\\?[^#<>\\s]*)?(?:#[^<>\\s]*)?", + URI1 = "\\b" + SCHEME + "[^<>\\s]+", + URI2 = "\\b" + HOST_OR_IP + PATH + QUERY_FRAG + "(?!\\w)", + + MAILTO = "mailto:", + EMAIL = "(?:" + MAILTO + ")?[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@" + HOST_OR_IP + QUERY_FRAG + "(?!\\w)", + + URI_RE = new RegExp( "(?:" + URI1 + "|" + URI2 + "|" + EMAIL + ")", "ig" ), + SCHEME_RE = new RegExp( "^" + SCHEME, "i" ), + + quotes = { + "'": "`", + '>': '<', + ')': '(', + ']': '[', + '}': '{', + '»': '«', + '›': '‹' + }, + + default_options = { + callback: function( text, href ) { +// return href ? '' + text + '<\/a>' : text; + return href ? '' + text + '<\/a>' : text; + }, + punct_regexp: /(?:[!?.,:;'"]|(?:&|&)(?:lt|gt|quot|apos|raquo|laquo|rsaquo|lsaquo);)$/ + }; + + return function( txt, options ) { + options = options || {}; + + // me + txt = txt + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, ''') + .replace(//g, '>'); + // me + // Temp variables. + var arr, + i, + link, + href, + + // Output HTML. + html = '', + + // Store text / link parts, in order, for re-combination. + parts = [], + + // Used for keeping track of indices in the text. + idx_prev, + idx_last, + idx, + link_last, + + // Used for trimming trailing punctuation and quotes from links. + matches_begin, + matches_end, + quote_begin, + quote_end; + + // Initialize options. + for ( i in default_options ) { + if ( options[ i ] === undefined ) { + options[ i ] = default_options[ i ]; + } + } + + // Find links. + while ( arr = URI_RE.exec( txt ) ) { + + link = arr[0]; + idx_last = URI_RE.lastIndex; + idx = idx_last - link.length; + + // Not a link if preceded by certain characters. + if ( /[\/:]/.test( txt.charAt( idx - 1 ) ) ) { + continue; + } + + // Trim trailing punctuation. + do { + // If no changes are made, we don't want to loop forever! + link_last = link; + + quote_end = link.substr( -1 ) + quote_begin = quotes[ quote_end ]; + + // Ending quote character? + if ( quote_begin ) { + matches_begin = link.match( new RegExp( '\\' + quote_begin + '(?!$)', 'g' ) ); + matches_end = link.match( new RegExp( '\\' + quote_end, 'g' ) ); + + // If quotes are unbalanced, remove trailing quote character. + if ( ( matches_begin ? matches_begin.length : 0 ) < ( matches_end ? matches_end.length : 0 ) ) { + link = link.substr( 0, link.length - 1 ); + idx_last--; + } + } + + // Ending non-quote punctuation character? + if ( options.punct_regexp ) { + link = link.replace( options.punct_regexp, function(a){ + idx_last -= a.length; + return ''; + }); + } + } while ( link.length && link !== link_last ); + + href = link; + + // Add appropriate protocol to naked links. + if ( !SCHEME_RE.test( href ) ) { + href = ( href.indexOf( '@' ) !== -1 ? ( !href.indexOf( MAILTO ) ? '' : MAILTO ) + : !href.indexOf( 'irc.' ) ? 'irc://' + : !href.indexOf( 'ftp.' ) ? 'ftp://' + : 'http://' ) + + href; + } + + // Push preceding non-link text onto the array. + if ( idx_prev != idx ) { + parts.push([ txt.slice( idx_prev, idx ) ]); + idx_prev = idx_last; + } + + // Push massaged link onto the array + parts.push([ link, href ]); + }; + + // Push remaining non-link text onto the array. + parts.push([ txt.substr( idx_prev ) ]); + + // Process the array items. + for ( i = 0; i < parts.length; i++ ) { + html += options.callback.apply( window, parts[i] ); + } + + // In case of catastrophic failure, return the original text; + return html || txt; + }; + +})(); \ No newline at end of file diff --git a/app/assets/javascripts/app/lib/base/fileuploader.js b/app/assets/javascripts/app/lib/base/fileuploader.js new file mode 100644 index 000000000..6da5d90b3 --- /dev/null +++ b/app/assets/javascripts/app/lib/base/fileuploader.js @@ -0,0 +1,1251 @@ +/** + * http://github.com/valums/file-uploader + * + * Multiple file upload component with progress-bar, drag-and-drop. + * © 2010 Andrew Valums ( andrew(at)valums.com ) + * + * Licensed under GNU GPL 2 or later and GNU LGPL 2 or later, see license.txt. + */ + +// +// Helper functions +// + +var qq = qq || {}; + +/** + * Adds all missing properties from second obj to first obj + */ +qq.extend = function(first, second){ + for (var prop in second){ + first[prop] = second[prop]; + } +}; + +/** + * Searches for a given element in the array, returns -1 if it is not present. + * @param {Number} [from] The index at which to begin the search + */ +qq.indexOf = function(arr, elt, from){ + if (arr.indexOf) return arr.indexOf(elt, from); + + from = from || 0; + var len = arr.length; + + if (from < 0) from += len; + + for (; from < len; from++){ + if (from in arr && arr[from] === elt){ + return from; + } + } + return -1; +}; + +qq.getUniqueId = (function(){ + var id = 0; + return function(){ return id++; }; +})(); + +// +// Events + +qq.attach = function(element, type, fn){ + if (element.addEventListener){ + element.addEventListener(type, fn, false); + } else if (element.attachEvent){ + element.attachEvent('on' + type, fn); + } +}; +qq.detach = function(element, type, fn){ + if (element.removeEventListener){ + element.removeEventListener(type, fn, false); + } else if (element.attachEvent){ + element.detachEvent('on' + type, fn); + } +}; + +qq.preventDefault = function(e){ + if (e.preventDefault){ + e.preventDefault(); + } else{ + e.returnValue = false; + } +}; + +// +// Node manipulations + +/** + * Insert node a before node b. + */ +qq.insertBefore = function(a, b){ + b.parentNode.insertBefore(a, b); +}; +qq.remove = function(element){ + element.parentNode.removeChild(element); +}; + +qq.contains = function(parent, descendant){ + // compareposition returns false in this case + if (parent == descendant) return true; + + if (parent.contains){ + return parent.contains(descendant); + } else { + return !!(descendant.compareDocumentPosition(parent) & 8); + } +}; + +/** + * Creates and returns element from html string + * Uses innerHTML to create an element + */ +qq.toElement = (function(){ + var div = document.createElement('div'); + return function(html){ + div.innerHTML = html; + var element = div.firstChild; + div.removeChild(element); + return element; + }; +})(); + +// +// Node properties and attributes + +/** + * Sets styles for an element. + * Fixes opacity in IE6-8. + */ +qq.css = function(element, styles){ + if (styles.opacity != null){ + if (typeof element.style.opacity != 'string' && typeof(element.filters) != 'undefined'){ + styles.filter = 'alpha(opacity=' + Math.round(100 * styles.opacity) + ')'; + } + } + qq.extend(element.style, styles); +}; +qq.hasClass = function(element, name){ + var re = new RegExp('(^| )' + name + '( |$)'); + return re.test(element.className); +}; +qq.addClass = function(element, name){ + if (!qq.hasClass(element, name)){ + element.className += ' ' + name; + } +}; +qq.removeClass = function(element, name){ + var re = new RegExp('(^| )' + name + '( |$)'); + element.className = element.className.replace(re, ' ').replace(/^\s+|\s+$/g, ""); +}; +qq.setText = function(element, text){ + element.innerText = text; + element.textContent = text; +}; + +// +// Selecting elements + +qq.children = function(element){ + var children = [], + child = element.firstChild; + + while (child){ + if (child.nodeType == 1){ + children.push(child); + } + child = child.nextSibling; + } + + return children; +}; + +qq.getByClass = function(element, className){ + if (element.querySelectorAll){ + return element.querySelectorAll('.' + className); + } + + var result = []; + var candidates = element.getElementsByTagName("*"); + var len = candidates.length; + + for (var i = 0; i < len; i++){ + if (qq.hasClass(candidates[i], className)){ + result.push(candidates[i]); + } + } + return result; +}; + +/** + * obj2url() takes a json-object as argument and generates + * a querystring. pretty much like jQuery.param() + * + * how to use: + * + * `qq.obj2url({a:'b',c:'d'},'http://any.url/upload?otherParam=value');` + * + * will result in: + * + * `http://any.url/upload?otherParam=value&a=b&c=d` + * + * @param Object JSON-Object + * @param String current querystring-part + * @return String encoded querystring + */ +qq.obj2url = function(obj, temp, prefixDone){ + var uristrings = [], + prefix = '&', + add = function(nextObj, i){ + var nextTemp = temp + ? (/\[\]$/.test(temp)) // prevent double-encoding + ? temp + : temp+'['+i+']' + : i; + if ((nextTemp != 'undefined') && (i != 'undefined')) { + uristrings.push( + (typeof nextObj === 'object') + ? qq.obj2url(nextObj, nextTemp, true) + : (Object.prototype.toString.call(nextObj) === '[object Function]') + ? encodeURIComponent(nextTemp) + '=' + encodeURIComponent(nextObj()) + : encodeURIComponent(nextTemp) + '=' + encodeURIComponent(nextObj) + ); + } + }; + + if (!prefixDone && temp) { + prefix = (/\?/.test(temp)) ? (/\?$/.test(temp)) ? '' : '&' : '?'; + uristrings.push(temp); + uristrings.push(qq.obj2url(obj)); + } else if ((Object.prototype.toString.call(obj) === '[object Array]') && (typeof obj != 'undefined') ) { + // we wont use a for-in-loop on an array (performance) + for (var i = 0, len = obj.length; i < len; ++i){ + add(obj[i], i); + } + } else if ((typeof obj != 'undefined') && (obj !== null) && (typeof obj === "object")){ + // for anything else but a scalar, we will use for-in-loop + for (var i in obj){ + add(obj[i], i); + } + } else { + uristrings.push(encodeURIComponent(temp) + '=' + encodeURIComponent(obj)); + } + + return uristrings.join(prefix) + .replace(/^&/, '') + .replace(/%20/g, '+'); +}; + +// +// +// Uploader Classes +// +// + +var qq = qq || {}; + +/** + * Creates upload button, validates upload, but doesn't create file list or dd. + */ +qq.FileUploaderBasic = function(o){ + this._options = { + // set to true to see the server response + debug: false, + action: '/server/upload', + params: {}, + button: null, + multiple: true, + maxConnections: 3, + // validation + allowedExtensions: [], + sizeLimit: 0, + minSizeLimit: 0, + // events + // return false to cancel submit + onSubmit: function(id, fileName){}, + onProgress: function(id, fileName, loaded, total){}, + onComplete: function(id, fileName, responseJSON){}, + onCancel: function(id, fileName){}, + // messages + messages: { + typeError: "{file} has invalid extension. Only {extensions} are allowed.", + sizeError: "{file} is too large, maximum file size is {sizeLimit}.", + minSizeError: "{file} is too small, minimum file size is {minSizeLimit}.", + emptyError: "{file} is empty, please select files again without it.", + onLeave: "The files are being uploaded, if you leave now the upload will be cancelled." + }, + showMessage: function(message){ + alert(message); + } + }; + qq.extend(this._options, o); + + // number of files being uploaded + this._filesInProgress = 0; + this._handler = this._createUploadHandler(); + + if (this._options.button){ + this._button = this._createUploadButton(this._options.button); + } + + this._preventLeaveInProgress(); +}; + +qq.FileUploaderBasic.prototype = { + setParams: function(params){ + this._options.params = params; + }, + getInProgress: function(){ + return this._filesInProgress; + }, + _createUploadButton: function(element){ + var self = this; + + return new qq.UploadButton({ + element: element, + multiple: this._options.multiple && qq.UploadHandlerXhr.isSupported(), + onChange: function(input){ + self._onInputChange(input); + } + }); + }, + _createUploadHandler: function(){ + var self = this, + handlerClass; + + if(qq.UploadHandlerXhr.isSupported()){ + handlerClass = 'UploadHandlerXhr'; + } else { + handlerClass = 'UploadHandlerForm'; + } + + var handler = new qq[handlerClass]({ + debug: this._options.debug, + action: this._options.action, + maxConnections: this._options.maxConnections, + onProgress: function(id, fileName, loaded, total){ + self._onProgress(id, fileName, loaded, total); + self._options.onProgress(id, fileName, loaded, total); + }, + onComplete: function(id, fileName, result){ + self._onComplete(id, fileName, result); + self._options.onComplete(id, fileName, result); + }, + onCancel: function(id, fileName){ + self._onCancel(id, fileName); + self._options.onCancel(id, fileName); + } + }); + + return handler; + }, + _preventLeaveInProgress: function(){ + var self = this; + + qq.attach(window, 'beforeunload', function(e){ + if (!self._filesInProgress){return;} + + var e = e || window.event; + // for ie, ff + e.returnValue = self._options.messages.onLeave; + // for webkit + return self._options.messages.onLeave; + }); + }, + _onSubmit: function(id, fileName){ + this._filesInProgress++; + }, + _onProgress: function(id, fileName, loaded, total){ + }, + _onComplete: function(id, fileName, result){ + this._filesInProgress--; + if (result.error){ + this._options.showMessage(result.error); + } + }, + _onCancel: function(id, fileName){ + this._filesInProgress--; + }, + _onInputChange: function(input){ + if (this._handler instanceof qq.UploadHandlerXhr){ + this._uploadFileList(input.files); + } else { + if (this._validateFile(input)){ + this._uploadFile(input); + } + } + this._button.reset(); + }, + _uploadFileList: function(files){ + for (var i=0; i this._options.sizeLimit){ + this._error('sizeError', name); + return false; + + } else if (size && size < this._options.minSizeLimit){ + this._error('minSizeError', name); + return false; + } + + return true; + }, + _error: function(code, fileName){ + var message = this._options.messages[code]; + function r(name, replacement){ message = message.replace(name, replacement); } + + r('{file}', this._formatFileName(fileName)); + r('{extensions}', this._options.allowedExtensions.join(', ')); + r('{sizeLimit}', this._formatSize(this._options.sizeLimit)); + r('{minSizeLimit}', this._formatSize(this._options.minSizeLimit)); + + this._options.showMessage(message); + }, + _formatFileName: function(name){ + if (name.length > 33){ + name = name.slice(0, 19) + '...' + name.slice(-13); + } + return name; + }, + _isAllowedExtension: function(fileName){ + var ext = (-1 !== fileName.indexOf('.')) ? fileName.replace(/.*[.]/, '').toLowerCase() : ''; + var allowed = this._options.allowedExtensions; + + if (!allowed.length){return true;} + + for (var i=0; i 99); + + return Math.max(bytes, 0.1).toFixed(1) + ['kB', 'MB', 'GB', 'TB', 'PB', 'EB'][i]; + } +}; + + +/** + * Class that creates upload widget with drag-and-drop and file list + * @inherits qq.FileUploaderBasic + */ +qq.FileUploader = function(o){ + // call parent constructor + qq.FileUploaderBasic.apply(this, arguments); + + // additional options + qq.extend(this._options, { + element: null, + // if set, will be used instead of qq-upload-list in template + listElement: null, + + template: '
' + + '
Drop to attach files
' + + '
Attach files
' + + '
    ' + + '
    ', + + // template for one item in file list + fileTemplate: '
  • ' + + '' + + '' + + '' + + 'Cancel' + + 'Failed' + + '
  • ', + + classes: { + // used to get elements from templates + button: 'qq-upload-button', + drop: 'qq-upload-drop-area', + dropActive: 'qq-upload-drop-area-active', + list: 'qq-upload-list', + + file: 'qq-upload-file', + spinner: 'qq-upload-spinner', + size: 'qq-upload-size', + cancel: 'qq-upload-cancel', + + // added to list item when upload completes + // used in css to hide progress spinner + success: 'qq-upload-success', + fail: 'qq-upload-fail' + } + }); + // overwrite options with user supplied + qq.extend(this._options, o); + + this._element = this._options.element; + this._element.innerHTML = this._options.template; + this._listElement = this._options.listElement || this._find(this._element, 'list'); + + this._classes = this._options.classes; + + this._button = this._createUploadButton(this._find(this._element, 'button')); + + this._bindCancelEvent(); + this._setupDragDrop(); +}; + +// inherit from Basic Uploader +qq.extend(qq.FileUploader.prototype, qq.FileUploaderBasic.prototype); + +qq.extend(qq.FileUploader.prototype, { + /** + * Gets one of the elements listed in this._options.classes + **/ + _find: function(parent, type){ + var element = qq.getByClass(parent, this._options.classes[type])[0]; + if (!element){ + throw new Error('element not found ' + type); + } + + return element; + }, + _setupDragDrop: function(){ + var self = this, + dropArea = this._find(this._element, 'drop'), + button = this._find(this._element, 'button'); + + var dz = new qq.UploadDropZone({ + element: dropArea, + onEnter: function(e){ + qq.addClass(dropArea, self._classes.dropActive); + e.stopPropagation(); + }, + onLeave: function(e){ + e.stopPropagation(); + }, + onLeaveNotDescendants: function(e){ + qq.removeClass(dropArea, self._classes.dropActive); + }, + onDrop: function(e){ + dropArea.style.display = 'none'; + button.style.display = 'inline-block'; + qq.removeClass(dropArea, self._classes.dropActive); + self._uploadFileList(e.dataTransfer.files); + } + }); + + dropArea.style.display = 'none'; + + qq.attach(document, 'dragenter', function(e){ + if (!dz._isValidFileDrag(e)) return; + + dropArea.style.display = 'block'; + button.style.display = 'none'; + }); + qq.attach(document, 'dragleave', function(e){ + if (!dz._isValidFileDrag(e)) return; + + var relatedTarget = document.elementFromPoint(e.clientX, e.clientY); + // only fire when leaving document out + if ( ! relatedTarget || relatedTarget.nodeName == "HTML"){ + dropArea.style.display = 'none'; + button.style.display = 'inline-block'; + } + }); + }, + _onSubmit: function(id, fileName){ + qq.FileUploaderBasic.prototype._onSubmit.apply(this, arguments); + this._addToList(id, fileName); + }, + _onProgress: function(id, fileName, loaded, total){ + qq.FileUploaderBasic.prototype._onProgress.apply(this, arguments); + + var item = this._getItemByFileId(id); + var size = this._find(item, 'size'); + size.style.display = 'inline'; + + var text; + if (loaded != total){ + text = Math.round(loaded / total * 100) + '% from ' + this._formatSize(total); + } else { + text = this._formatSize(total); + } + + qq.setText(size, text); + }, + _onComplete: function(id, fileName, result){ + qq.FileUploaderBasic.prototype._onComplete.apply(this, arguments); + + // mark completed + var item = this._getItemByFileId(id); + qq.remove(this._find(item, 'cancel')); + qq.remove(this._find(item, 'spinner')); + + if (result.success){ + qq.addClass(item, this._classes.success); + } else { + qq.addClass(item, this._classes.fail); + } + }, + _addToList: function(id, fileName){ + var item = qq.toElement(this._options.fileTemplate); + item.qqFileId = id; + + var fileElement = this._find(item, 'file'); + qq.setText(fileElement, this._formatFileName(fileName)); + this._find(item, 'size').style.display = 'none'; + + this._listElement.appendChild(item); + }, + _getItemByFileId: function(id){ + var item = this._listElement.firstChild; + + // there can't be txt nodes in dynamically created list + // and we can use nextSibling + while (item){ + if (item.qqFileId == id) return item; + item = item.nextSibling; + } + }, + /** + * delegate click event for cancel link + **/ + _bindCancelEvent: function(){ + var self = this, + list = this._listElement; + + qq.attach(list, 'click', function(e){ + e = e || window.event; + var target = e.target || e.srcElement; + + if (qq.hasClass(target, self._classes.cancel)){ + qq.preventDefault(e); + + var item = target.parentNode; + self._handler.cancel(item.qqFileId); + qq.remove(item); + } + }); + } +}); + +qq.UploadDropZone = function(o){ + this._options = { + element: null, + onEnter: function(e){}, + onLeave: function(e){}, + // is not fired when leaving element by hovering descendants + onLeaveNotDescendants: function(e){}, + onDrop: function(e){} + }; + qq.extend(this._options, o); + + this._element = this._options.element; + + this._disableDropOutside(); + this._attachEvents(); +}; + +qq.UploadDropZone.prototype = { + _disableDropOutside: function(e){ + // run only once for all instances + if (!qq.UploadDropZone.dropOutsideDisabled ){ + + qq.attach(document, 'dragover', function(e){ + if (e.dataTransfer){ + e.dataTransfer.dropEffect = 'none'; + e.preventDefault(); + } + }); + + qq.UploadDropZone.dropOutsideDisabled = true; + } + }, + _attachEvents: function(){ + var self = this; + + qq.attach(self._element, 'dragover', function(e){ + if (!self._isValidFileDrag(e)) return; + + var effect = e.dataTransfer.effectAllowed; + if (effect == 'move' || effect == 'linkMove'){ + e.dataTransfer.dropEffect = 'move'; // for FF (only move allowed) + } else { + e.dataTransfer.dropEffect = 'copy'; // for Chrome + } + + e.stopPropagation(); + e.preventDefault(); + }); + + qq.attach(self._element, 'dragenter', function(e){ + if (!self._isValidFileDrag(e)) return; + + self._options.onEnter(e); + }); + + qq.attach(self._element, 'dragleave', function(e){ + if (!self._isValidFileDrag(e)) return; + + self._options.onLeave(e); + + var relatedTarget = document.elementFromPoint(e.clientX, e.clientY); + // do not fire when moving a mouse over a descendant + if (qq.contains(this, relatedTarget)) return; + + self._options.onLeaveNotDescendants(e); + }); + + qq.attach(self._element, 'drop', function(e){ + if (!self._isValidFileDrag(e)) return; + + e.preventDefault(); + self._options.onDrop(e); + }); + }, + _isValidFileDrag: function(e){ + var dt = e.dataTransfer, + // do not check dt.types.contains in webkit, because it crashes safari 4 + isWebkit = navigator.userAgent.indexOf("AppleWebKit") > -1; + + // dt.effectAllowed is none in Safari 5 + // dt.types.contains check is for firefox + return dt && dt.effectAllowed != 'none' && + (dt.files || (!isWebkit && dt.types.contains && dt.types.contains('Files'))); + + } +}; + +qq.UploadButton = function(o){ + this._options = { + element: null, + // if set to true adds multiple attribute to file input + multiple: false, + // name attribute of file input + name: 'file', + onChange: function(input){}, + hoverClass: 'qq-upload-button-hover', + focusClass: 'qq-upload-button-focus' + }; + + qq.extend(this._options, o); + + this._element = this._options.element; + + // make button suitable container for input + qq.css(this._element, { + position: 'relative', + overflow: 'hidden', + // Make sure browse button is in the right side + // in Internet Explorer + direction: 'ltr' + }); + + this._input = this._createInput(); +}; + +qq.UploadButton.prototype = { + /* returns file input element */ + getInput: function(){ + return this._input; + }, + /* cleans/recreates the file input */ + reset: function(){ + if (this._input.parentNode){ + qq.remove(this._input); + } + + qq.removeClass(this._element, this._options.focusClass); + this._input = this._createInput(); + }, + _createInput: function(){ + var input = document.createElement("input"); + + if (this._options.multiple){ + input.setAttribute("multiple", "multiple"); + } + + input.setAttribute("type", "file"); + input.setAttribute("name", this._options.name); + + qq.css(input, { + position: 'absolute', + // in Opera only 'browse' button + // is clickable and it is located at + // the right side of the input + right: 0, + top: 0, + fontFamily: 'Arial', + // 4 persons reported this, the max values that worked for them were 243, 236, 236, 118 + fontSize: '118px', + margin: 0, + padding: 0, + cursor: 'pointer', + opacity: 0 + }); + + this._element.appendChild(input); + + var self = this; + qq.attach(input, 'change', function(){ + self._options.onChange(input); + }); + + qq.attach(input, 'mouseover', function(){ + qq.addClass(self._element, self._options.hoverClass); + }); + qq.attach(input, 'mouseout', function(){ + qq.removeClass(self._element, self._options.hoverClass); + }); + qq.attach(input, 'focus', function(){ + qq.addClass(self._element, self._options.focusClass); + }); + qq.attach(input, 'blur', function(){ + qq.removeClass(self._element, self._options.focusClass); + }); + + // IE and Opera, unfortunately have 2 tab stops on file input + // which is unacceptable in our case, disable keyboard access + if (window.attachEvent){ + // it is IE or Opera + input.setAttribute('tabIndex', "-1"); + } + + return input; + } +}; + +/** + * Class for uploading files, uploading itself is handled by child classes + */ +qq.UploadHandlerAbstract = function(o){ + this._options = { + debug: false, + action: '/upload.php', + // maximum number of concurrent uploads + maxConnections: 999, + onProgress: function(id, fileName, loaded, total){}, + onComplete: function(id, fileName, response){}, + onCancel: function(id, fileName){} + }; + qq.extend(this._options, o); + + this._queue = []; + // params for files in queue + this._params = []; +}; +qq.UploadHandlerAbstract.prototype = { + log: function(str){ + if (this._options.debug && window.console) console.log('[uploader] ' + str); + }, + /** + * Adds file or file input to the queue + * @returns id + **/ + add: function(file){}, + /** + * Sends the file identified by id and additional query params to the server + */ + upload: function(id, params){ + var len = this._queue.push(id); + + var copy = {}; + qq.extend(copy, params); + this._params[id] = copy; + + // if too many active uploads, wait... + if (len <= this._options.maxConnections){ + this._upload(id, this._params[id]); + } + }, + /** + * Cancels file upload by id + */ + cancel: function(id){ + this._cancel(id); + this._dequeue(id); + }, + /** + * Cancells all uploads + */ + cancelAll: function(){ + for (var i=0; i= max && i < max){ + var nextId = this._queue[max-1]; + this._upload(nextId, this._params[nextId]); + } + } +}; + +/** + * Class for uploading files using form and iframe + * @inherits qq.UploadHandlerAbstract + */ +qq.UploadHandlerForm = function(o){ + qq.UploadHandlerAbstract.apply(this, arguments); + + this._inputs = {}; +}; +// @inherits qq.UploadHandlerAbstract +qq.extend(qq.UploadHandlerForm.prototype, qq.UploadHandlerAbstract.prototype); + +qq.extend(qq.UploadHandlerForm.prototype, { + add: function(fileInput){ + fileInput.setAttribute('name', 'qqfile'); + var id = 'qq-upload-handler-iframe' + qq.getUniqueId(); + + this._inputs[id] = fileInput; + + // remove file input from DOM + if (fileInput.parentNode){ + qq.remove(fileInput); + } + + return id; + }, + getName: function(id){ + // get input value and remove path to normalize + return this._inputs[id].value.replace(/.*(\/|\\)/, ""); + }, + _cancel: function(id){ + this._options.onCancel(id, this.getName(id)); + + delete this._inputs[id]; + + var iframe = document.getElementById(id); + if (iframe){ + // to cancel request set src to something else + // we use src="javascript:false;" because it doesn't + // trigger ie6 prompt on https + iframe.setAttribute('src', 'javascript:false;'); + + qq.remove(iframe); + } + }, + _upload: function(id, params){ + var input = this._inputs[id]; + + if (!input){ + throw new Error('file with passed id was not added, or already uploaded or cancelled'); + } + + var fileName = this.getName(id); + + var iframe = this._createIframe(id); + var form = this._createForm(iframe, params); + form.appendChild(input); + + var self = this; + this._attachLoadEvent(iframe, function(){ + self.log('iframe loaded'); + + var response = self._getIframeContentJSON(iframe); + + self._options.onComplete(id, fileName, response); + self._dequeue(id); + + delete self._inputs[id]; + // timeout added to fix busy state in FF3.6 + setTimeout(function(){ + qq.remove(iframe); + }, 1); + }); + + form.submit(); + qq.remove(form); + + return id; + }, + _attachLoadEvent: function(iframe, callback){ + qq.attach(iframe, 'load', function(){ + // when we remove iframe from dom + // the request stops, but in IE load + // event fires + if (!iframe.parentNode){ + return; + } + + // fixing Opera 10.53 + if (iframe.contentDocument && + iframe.contentDocument.body && + iframe.contentDocument.body.innerHTML == "false"){ + // In Opera event is fired second time + // when body.innerHTML changed from false + // to server response approx. after 1 sec + // when we upload file with iframe + return; + } + + callback(); + }); + }, + /** + * Returns json object received by iframe from server. + */ + _getIframeContentJSON: function(iframe){ + // iframe.contentWindow.document - for IE<7 + var doc = iframe.contentDocument ? iframe.contentDocument: iframe.contentWindow.document, + response; + + this.log("converting iframe's innerHTML to JSON"); + this.log("innerHTML = " + doc.body.innerHTML); + + try { + response = eval("(" + doc.body.innerHTML + ")"); + } catch(err){ + response = {}; + } + + return response; + }, + /** + * Creates iframe with unique name + */ + _createIframe: function(id){ + // We can't use following code as the name attribute + // won't be properly registered in IE6, and new window + // on form submit will open + // var iframe = document.createElement('iframe'); + // iframe.setAttribute('name', id); + + var iframe = qq.toElement('