mirror of
https://github.com/discourse/discourse.git
synced 2025-02-25 18:55:32 -06:00
Initial release of Discourse
This commit is contained in:
1
app/assets/javascripts/admin.js
Normal file
1
app/assets/javascripts/admin.js
Normal file
@@ -0,0 +1 @@
|
||||
//= require_tree ./admin
|
||||
@@ -0,0 +1,18 @@
|
||||
window.Discourse.AdminCustomizeController = Ember.Controller.extend
|
||||
newCustomization: ->
|
||||
item = Discourse.SiteCustomization.create(name: 'New Style')
|
||||
@get('content').pushObject(item)
|
||||
@set('content.selectedItem', item)
|
||||
|
||||
selectStyle: (style)-> @set('content.selectedItem', style)
|
||||
|
||||
save: -> @get('content.selectedItem').save()
|
||||
|
||||
delete: ->
|
||||
bootbox.confirm Em.String.i18n("admin.customize.delete_confirm"), Em.String.i18n("no_value"), Em.String.i18n("yes_value"), (result) =>
|
||||
if result
|
||||
selected = @get('content.selectedItem')
|
||||
selected.delete()
|
||||
@set('content.selectedItem', null)
|
||||
@get('content').removeObject(selected)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
window.Discourse.AdminEmailLogsController = Ember.ArrayController.extend Discourse.Presence,
|
||||
|
||||
sendTestEmailDisabled: (->
|
||||
@blank('testEmailAddress')
|
||||
).property('testEmailAddress')
|
||||
|
||||
sendTestEmail: ->
|
||||
@set('sentTestEmail', false)
|
||||
$.ajax
|
||||
url: '/admin/email_logs/test',
|
||||
type: 'POST'
|
||||
data:
|
||||
email_address: @get('testEmailAddress')
|
||||
success: =>
|
||||
@set('sentTestEmail', true)
|
||||
false
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
window.Discourse.AdminFlagsController = Ember.Controller.extend
|
||||
|
||||
clearFlags: (item) ->
|
||||
item.clearFlags().then (=>
|
||||
@content.removeObject(item)
|
||||
), (->
|
||||
bootbox.alert("something went wrong")
|
||||
)
|
||||
|
||||
adminOldFlagsView: (->
|
||||
@query == 'old'
|
||||
).property('query')
|
||||
|
||||
adminActiveFlagsView: (->
|
||||
@query == 'active'
|
||||
).property('query')
|
||||
@@ -0,0 +1,30 @@
|
||||
window.Discourse.AdminSiteSettingsController = Ember.ArrayController.extend Discourse.Presence,
|
||||
|
||||
filter: null
|
||||
onlyOverridden: false
|
||||
|
||||
filteredContent: (->
|
||||
return null unless @present('content')
|
||||
filter = @get('filter').toLowerCase() if @get('filter')
|
||||
|
||||
@get('content').filter (item, index, enumerable) =>
|
||||
|
||||
return false if @get('onlyOverridden') and !item.get('overridden')
|
||||
|
||||
if filter
|
||||
return true if item.get('setting').toLowerCase().indexOf(filter) > -1
|
||||
return true if item.get('description').toLowerCase().indexOf(filter) > -1
|
||||
return true if item.get('value').toLowerCase().indexOf(filter) > -1
|
||||
return false
|
||||
else
|
||||
true
|
||||
).property('filter', 'content.@each', 'onlyOverridden')
|
||||
|
||||
|
||||
resetDefault: (setting) ->
|
||||
setting.set('value', setting.get('default'))
|
||||
setting.save()
|
||||
|
||||
save: (setting) -> setting.save()
|
||||
|
||||
cancel: (setting) -> setting.resetValue()
|
||||
@@ -0,0 +1,45 @@
|
||||
window.Discourse.AdminUsersListController = Ember.ArrayController.extend Discourse.Presence,
|
||||
|
||||
username: null
|
||||
query: null
|
||||
selectAll: false
|
||||
content: null
|
||||
|
||||
selectAllChanged: (->
|
||||
@get('content').each (user) => user.set('selected', @get('selectAll'))
|
||||
).observes('selectAll')
|
||||
|
||||
filterUsers: Discourse.debounce(->
|
||||
@refreshUsers()
|
||||
,250).observes('username')
|
||||
|
||||
orderChanged: (->
|
||||
@refreshUsers()
|
||||
).observes('query')
|
||||
|
||||
showApproval: (->
|
||||
return false unless Discourse.SiteSettings.must_approve_users
|
||||
return true if @get('query') is 'new'
|
||||
return true if @get('query') is 'pending'
|
||||
).property('query')
|
||||
|
||||
selectedCount: (->
|
||||
return 0 if @blank('content')
|
||||
@get('content').filterProperty('selected').length
|
||||
).property('content.@each.selected')
|
||||
|
||||
hasSelection: (->
|
||||
@get('selectedCount') > 0
|
||||
).property('selectedCount')
|
||||
|
||||
refreshUsers: ->
|
||||
@set 'content', Discourse.AdminUser.findAll(@get('query'), @get('username'))
|
||||
|
||||
show: (term) ->
|
||||
if @get('query') == term
|
||||
@refreshUsers()
|
||||
else
|
||||
@set('query', term)
|
||||
|
||||
approveUsers: ->
|
||||
Discourse.AdminUser.bulkApprove(@get('content').filterProperty('selected'))
|
||||
122
app/assets/javascripts/admin/models/admin_user.js.coffee
Normal file
122
app/assets/javascripts/admin/models/admin_user.js.coffee
Normal file
@@ -0,0 +1,122 @@
|
||||
window.Discourse.AdminUser = Discourse.Model.extend
|
||||
|
||||
# Revoke the user's admin access
|
||||
revokeAdmin: ->
|
||||
@set('admin',false)
|
||||
@set('can_grant_admin',true)
|
||||
@set('can_revoke_admin',false)
|
||||
$.ajax "/admin/users/#{@get('id')}/revoke_admin", type: 'PUT'
|
||||
|
||||
grantAdmin: ->
|
||||
@set('admin',true)
|
||||
@set('can_grant_admin',false)
|
||||
@set('can_revoke_admin',true)
|
||||
$.ajax "/admin/users/#{@get('id')}/grant_admin", type: 'PUT'
|
||||
|
||||
refreshBrowsers: ->
|
||||
$.ajax "/admin/users/#{@get('id')}/refresh_browsers",
|
||||
type: 'POST'
|
||||
bootbox.alert("Message sent to all clients!")
|
||||
|
||||
|
||||
|
||||
approve: ->
|
||||
@set('can_approve', false)
|
||||
@set('approved', true)
|
||||
@set('approved_by', Discourse.get('currentUser'))
|
||||
$.ajax "/admin/users/#{@get('id')}/approve", type: 'PUT'
|
||||
|
||||
username_lower:(->
|
||||
@get('username').toLowerCase()
|
||||
).property('username')
|
||||
|
||||
trustLevel: (->
|
||||
Discourse.get('site.trust_levels').findProperty('id', @get('trust_level'))
|
||||
).property('trust_level')
|
||||
|
||||
|
||||
canBan: ( ->
|
||||
!@admin && !@moderator
|
||||
).property('admin','moderator')
|
||||
|
||||
banDuration: (->
|
||||
banned_at = Date.create(@banned_at)
|
||||
banned_till = Date.create(@banned_till)
|
||||
|
||||
"#{banned_at.short()} - #{banned_till.short()}"
|
||||
|
||||
).property('banned_till', 'banned_at')
|
||||
|
||||
ban: ->
|
||||
debugger
|
||||
if duration = parseInt(window.prompt(Em.String.i18n('admin.user.ban_duration')))
|
||||
if duration > 0
|
||||
$.ajax "/admin/users/#{@id}/ban",
|
||||
type: 'PUT'
|
||||
data:
|
||||
duration: duration
|
||||
success: ->
|
||||
window.location.reload()
|
||||
return
|
||||
error: (e) =>
|
||||
error = Em.String.i18n('admin.user.ban_failed', error: "http: #{e.status} - #{e.body}")
|
||||
bootbox.alert error
|
||||
return
|
||||
|
||||
unban: ->
|
||||
$.ajax "/admin/users/#{@id}/unban",
|
||||
type: 'PUT'
|
||||
success: ->
|
||||
window.location.reload()
|
||||
return
|
||||
error: (e) =>
|
||||
error = Em.String.i18n('admin.user.unban_failed', error: "http: #{e.status} - #{e.body}")
|
||||
bootbox.alert error
|
||||
return
|
||||
|
||||
impersonate: ->
|
||||
$.ajax "/admin/impersonate"
|
||||
type: 'POST'
|
||||
data:
|
||||
username_or_email: @get('username')
|
||||
success: ->
|
||||
document.location = "/"
|
||||
error: (e) =>
|
||||
@set('loading', false)
|
||||
if e.status == 404
|
||||
bootbox.alert Em.String.i18n('admin.impersonate.not_found')
|
||||
else
|
||||
bootbox.alert Em.String.i18n('admin.impersonate.invalid')
|
||||
|
||||
window.Discourse.AdminUser.reopenClass
|
||||
|
||||
create: (result) ->
|
||||
result = @_super(result)
|
||||
result
|
||||
|
||||
bulkApprove: (users) ->
|
||||
users.each (user) ->
|
||||
user.set('approved', true)
|
||||
user.set('can_approve', false)
|
||||
user.set('selected', false)
|
||||
|
||||
$.ajax "/admin/users/approve-bulk",
|
||||
type: 'PUT'
|
||||
data: {users: users.map (u) -> u.id}
|
||||
|
||||
find: (username)->
|
||||
promise = new RSVP.Promise()
|
||||
$.ajax
|
||||
url: "/admin/users/#{username}"
|
||||
success: (result) -> promise.resolve(Discourse.AdminUser.create(result))
|
||||
promise
|
||||
|
||||
findAll: (query, filter)->
|
||||
result = Em.A()
|
||||
$.ajax
|
||||
url: "/admin/users/list/#{query}.json"
|
||||
data: {filter: filter}
|
||||
success: (users) ->
|
||||
users.each (u) -> result.pushObject(Discourse.AdminUser.create(u))
|
||||
result
|
||||
|
||||
17
app/assets/javascripts/admin/models/email_log.js.coffee
Normal file
17
app/assets/javascripts/admin/models/email_log.js.coffee
Normal file
@@ -0,0 +1,17 @@
|
||||
window.Discourse.EmailLog = Discourse.Model.extend({})
|
||||
|
||||
window.Discourse.EmailLog.reopenClass
|
||||
|
||||
create: (attrs) ->
|
||||
attrs.user = Discourse.AdminUser.create(attrs.user) if attrs.user
|
||||
@_super(attrs)
|
||||
|
||||
findAll: (filter)->
|
||||
result = Em.A()
|
||||
$.ajax
|
||||
url: "/admin/email_logs.json"
|
||||
data: {filter: filter}
|
||||
success: (logs) ->
|
||||
logs.each (log) -> result.pushObject(Discourse.EmailLog.create(log))
|
||||
result
|
||||
|
||||
62
app/assets/javascripts/admin/models/flagged_post.js.coffee
Normal file
62
app/assets/javascripts/admin/models/flagged_post.js.coffee
Normal file
@@ -0,0 +1,62 @@
|
||||
window.Discourse.FlaggedPost = Discourse.Post.extend
|
||||
flaggers: (->
|
||||
r = []
|
||||
@post_actions.each (a)=>
|
||||
r.push(@userLookup[a.user_id])
|
||||
r
|
||||
).property()
|
||||
|
||||
messages: (->
|
||||
r = []
|
||||
@post_actions.each (a)=>
|
||||
if a.message
|
||||
r.push
|
||||
user: @userLookup[a.user_id]
|
||||
message: a.message
|
||||
r
|
||||
).property()
|
||||
|
||||
lastFlagged: (->
|
||||
@post_actions[0].created_at
|
||||
).property()
|
||||
|
||||
user: (->
|
||||
@userLookup[@user_id]
|
||||
).property()
|
||||
|
||||
topicHidden: (->
|
||||
@get('topic_visible') == 'f'
|
||||
).property('topic_hidden')
|
||||
|
||||
clearFlags: ->
|
||||
promise = new RSVP.Promise()
|
||||
$.ajax "/admin/flags/clear/#{@id}",
|
||||
type: 'POST'
|
||||
cache: false
|
||||
success: ->
|
||||
promise.resolve()
|
||||
error: (e)->
|
||||
promise.reject()
|
||||
|
||||
promise
|
||||
|
||||
hiddenClass: (->
|
||||
"hidden-post" if @get('hidden') == "t"
|
||||
).property()
|
||||
|
||||
|
||||
window.Discourse.FlaggedPost.reopenClass
|
||||
|
||||
findAll: (filter) ->
|
||||
result = Em.A()
|
||||
$.ajax
|
||||
url: "/admin/flags/#{filter}.json"
|
||||
success: (data) ->
|
||||
userLookup = {}
|
||||
data.users.each (u) -> userLookup[u.id] = Discourse.User.create(u)
|
||||
data.posts.each (p) ->
|
||||
f = Discourse.FlaggedPost.create(p)
|
||||
f.userLookup = userLookup
|
||||
result.pushObject(f)
|
||||
result
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
window.Discourse.SiteCustomization = Discourse.Model.extend
|
||||
|
||||
init: ->
|
||||
@_super()
|
||||
@startTrackingChanges()
|
||||
|
||||
trackedProperties: ['enabled','name', 'stylesheet', 'header', 'override_default_style']
|
||||
|
||||
description: (->
|
||||
"#{@.name}#{if @.enabled then ' (*)' else ''}"
|
||||
).property('selected', 'name')
|
||||
|
||||
changed: (->
|
||||
return false unless @.originals
|
||||
@trackedProperties.any (p)=>
|
||||
@.originals[p] != @get(p)
|
||||
).property('override_default_style','enabled','name', 'stylesheet', 'header', 'originals') # TODO figure out how to call with apply
|
||||
|
||||
startTrackingChanges: ->
|
||||
@set('originals',{})
|
||||
|
||||
@trackedProperties.each (p)=>
|
||||
@.originals[p] = @get(p)
|
||||
true
|
||||
|
||||
previewUrl: (->
|
||||
"/?preview-style=#{@get('key')}"
|
||||
).property('key')
|
||||
|
||||
disableSave:(->
|
||||
!@get('changed')
|
||||
).property('changed')
|
||||
|
||||
save: ->
|
||||
@startTrackingChanges()
|
||||
data =
|
||||
name: @name
|
||||
enabled: @enabled
|
||||
stylesheet: @stylesheet
|
||||
header: @header
|
||||
override_default_style: @override_default_style
|
||||
|
||||
$.ajax
|
||||
url: "/admin/site_customizations#{if @id then '/' + @id else ''}"
|
||||
data:
|
||||
site_customization: data
|
||||
type: if @id then 'PUT' else 'POST'
|
||||
|
||||
delete: ->
|
||||
return unless @id
|
||||
$.ajax
|
||||
url: "/admin/site_customizations/#{ @id }"
|
||||
type: 'DELETE'
|
||||
|
||||
SiteCustomizations = Ember.ArrayProxy.extend
|
||||
selectedItemChanged: (->
|
||||
selected = @get('selectedItem')
|
||||
@get('content').each (i)->
|
||||
i.set('selected', selected == i)
|
||||
).observes('selectedItem')
|
||||
|
||||
|
||||
Discourse.SiteCustomization.reopenClass
|
||||
findAll: ->
|
||||
content = SiteCustomizations.create
|
||||
content: []
|
||||
loading: true
|
||||
|
||||
$.ajax
|
||||
url: "/admin/site_customizations"
|
||||
dataType: "json"
|
||||
success: (data)=>
|
||||
data?.site_customizations.each (c)->
|
||||
item = Discourse.SiteCustomization.create(c)
|
||||
content.pushObject(item)
|
||||
content.set('loading',false)
|
||||
|
||||
content
|
||||
42
app/assets/javascripts/admin/models/site_setting.js.coffee
Normal file
42
app/assets/javascripts/admin/models/site_setting.js.coffee
Normal file
@@ -0,0 +1,42 @@
|
||||
window.Discourse.SiteSetting = Discourse.Model.extend Discourse.Presence,
|
||||
|
||||
# Whether a property is short.
|
||||
short: (->
|
||||
return true if @blank('value')
|
||||
return @get('value').toString().length < 80
|
||||
).property('value')
|
||||
|
||||
# Whether the site setting has changed
|
||||
dirty: (->
|
||||
@get('originalValue') != @get('value')
|
||||
).property('originalValue', 'value')
|
||||
|
||||
overridden: (->
|
||||
val = @get('value')
|
||||
defaultVal = @get('default')
|
||||
return val.toString() != defaultVal.toString() if (val and defaultVal)
|
||||
return val != defaultVal
|
||||
).property('value')
|
||||
|
||||
resetValue: ->
|
||||
@set('value', @get('originalValue'))
|
||||
|
||||
save: ->
|
||||
|
||||
# Update the setting
|
||||
$.ajax "/admin/site_settings/#{@get('setting')}",
|
||||
data:
|
||||
value: @get('value')
|
||||
type: 'PUT'
|
||||
success: => @set('originalValue', @get('value'))
|
||||
|
||||
|
||||
window.Discourse.SiteSetting.reopenClass
|
||||
findAll: ->
|
||||
result = Em.A()
|
||||
$.get "/admin/site_settings", (settings) ->
|
||||
settings.each (s) ->
|
||||
s.originalValue = s.value
|
||||
result.pushObject(Discourse.SiteSetting.create(s))
|
||||
result
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Discourse.AdminCustomizeRoute = Discourse.Route.extend
|
||||
model: -> Discourse.SiteCustomization.findAll()
|
||||
@@ -0,0 +1,2 @@
|
||||
Discourse.AdminEmailLogsRoute = Discourse.Route.extend
|
||||
model: -> Discourse.EmailLog.findAll()
|
||||
@@ -0,0 +1,6 @@
|
||||
Discourse.AdminFlagsActiveRoute = Discourse.Route.extend
|
||||
model: -> Discourse.FlaggedPost.findAll('active')
|
||||
setupController: (controller, model) ->
|
||||
c = @controllerFor('adminFlags')
|
||||
c.set('content', model)
|
||||
c.set('query', 'active')
|
||||
@@ -0,0 +1,6 @@
|
||||
Discourse.AdminFlagsOldRoute = Discourse.Route.extend
|
||||
model: -> Discourse.FlaggedPost.findAll('old')
|
||||
setupController: (controller, model) ->
|
||||
c = @controllerFor('adminFlags')
|
||||
c.set('content', model)
|
||||
c.set('query', 'old')
|
||||
17
app/assets/javascripts/admin/routes/admin_routes.js.coffee
Normal file
17
app/assets/javascripts/admin/routes/admin_routes.js.coffee
Normal file
@@ -0,0 +1,17 @@
|
||||
Discourse.buildRoutes ->
|
||||
@resource 'admin', path: '/admin', ->
|
||||
@route 'dashboard', path: '/'
|
||||
@route 'site_settings', path: '/site_settings'
|
||||
@route 'email_logs', path: '/email_logs'
|
||||
@route 'customize', path: '/customize'
|
||||
|
||||
@resource 'adminFlags', path: '/flags', ->
|
||||
@route 'active', path: '/active'
|
||||
@route 'old', path: '/old'
|
||||
|
||||
@resource 'adminUsers', path: '/users', ->
|
||||
@resource 'adminUser', path: '/:username'
|
||||
@resource 'adminUsersList', path: '/list', ->
|
||||
@route 'active', path: '/active'
|
||||
@route 'new', path: '/new'
|
||||
@route 'pending', path: '/pending'
|
||||
@@ -0,0 +1,2 @@
|
||||
Discourse.AdminSiteSettingsRoute = Discourse.Route.extend
|
||||
model: -> Discourse.SiteSetting.findAll()
|
||||
@@ -0,0 +1,2 @@
|
||||
Discourse.AdminUserRoute = Discourse.Route.extend
|
||||
model: (params) -> Discourse.AdminUser.find(params.username)
|
||||
@@ -0,0 +1,2 @@
|
||||
Discourse.AdminUsersListActiveRoute = Discourse.Route.extend
|
||||
setupController: (c) -> @controllerFor('adminUsersList').show('active')
|
||||
@@ -0,0 +1,2 @@
|
||||
Discourse.AdminUsersListNewRoute = Discourse.Route.extend
|
||||
setupController: (c) -> @controllerFor('adminUsersList').show('new')
|
||||
@@ -0,0 +1,2 @@
|
||||
Discourse.AdminUsersListNewRoute = Discourse.Route.extend
|
||||
setupController: (c) -> @controllerFor('adminUsersList').show('pending')
|
||||
23
app/assets/javascripts/admin/templates/admin.js.handlebars
Normal file
23
app/assets/javascripts/admin/templates/admin.js.handlebars
Normal file
@@ -0,0 +1,23 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="full-width">
|
||||
|
||||
<ul class="nav nav-pills">
|
||||
<li>{{#linkTo 'admin.dashboard'}}{{i18n admin.dashboard}}{{/linkTo}}</li>
|
||||
<li>{{#linkTo 'admin.site_settings'}}{{i18n admin.site_settings.title}}{{/linkTo}}</li>
|
||||
<li>{{#linkTo 'adminUsersList.active'}}{{i18n admin.users.title}}{{/linkTo}}</li>
|
||||
<li>{{#linkTo 'admin.email_logs'}}{{i18n admin.email_logs.title}}{{/linkTo}}</li>
|
||||
<li>{{#linkTo 'adminFlags.active'}}{{i18n admin.flags.title}}{{/linkTo}}</li>
|
||||
<li>{{#linkTo 'admin.customize'}}{{i18n admin.customize.title}}{{/linkTo}}</li>
|
||||
</ul>
|
||||
|
||||
<div class='boxed white admin-content'>
|
||||
<div class='admin-contents'>
|
||||
{{outlet}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
|
||||
<div class='list'>
|
||||
<div class='well'>
|
||||
<ul class='nav nav-list'>
|
||||
{{#each view.content}}
|
||||
<li {{bindAttr class="this.selected:active"}}><a {{action selectStyle this target="controller"}}>{{this.description}}</a></li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<button {{action newCustomization target="controller"}} class='btn btn-primary'>New</button>
|
||||
</div>
|
||||
|
||||
{{#if content.selectedItem}}
|
||||
<div class='current-style'>
|
||||
<div class='admin-controls'>
|
||||
<ul class="nav nav-pills">
|
||||
<li {{bindAttr class="view.stylesheetActive:active"}}>
|
||||
<a {{action selectStylesheet href="true" target="view"}}>{{i18n admin.customize.css}}</a>
|
||||
</li>
|
||||
<li {{bindAttr class="view.headerActive:active"}}>
|
||||
<a {{action selectHeader href="true" target="view"}}>{{i18n admin.customize.header}}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{{#with content.selectedItem}}
|
||||
{{view Ember.TextField class="style-name" valueBinding="name"}}
|
||||
{{#if view.headerActive}}
|
||||
{{view Discourse.AceEditorView contentBinding="header" mode="html"}}
|
||||
{{/if}}
|
||||
{{#if view.stylesheetActive}}
|
||||
{{view Discourse.AceEditorView contentBinding="stylesheet" mode="css"}}
|
||||
{{/if}}
|
||||
{{/with}}
|
||||
<br>
|
||||
<div class='status-actions'>
|
||||
<span>{{i18n admin.customize.override_default}} {{view Ember.Checkbox checkedBinding="content.selectedItem.override_default_style"}}</span>
|
||||
<span>{{i18n admin.customize.enabled}} {{view Ember.Checkbox checkedBinding="content.selectedItem.enabled"}}</span>
|
||||
{{#unless content.selectedItem.changed}}
|
||||
<a class='preview-link' {{bindAttr href="content.selectedItem.previewUrl"}} target='_blank'>{{i18n admin.customize.preview}}</a>
|
||||
|
|
||||
<a href="/?preview-style=" target='_blank'>{{i18n admin.customize.undo_preview}}</a><br>
|
||||
{{/unless}}
|
||||
</div>
|
||||
|
||||
<div class='buttons'>
|
||||
<button {{action save target="controller"}} {{bindAttr disabled="content.selectedItem.disableSave"}} class='btn btn-primary'>{{i18n admin.customize.save}}</button>
|
||||
<a {{action delete target="controller"}} class='delete-link'>{{i18n admin.customize.delete}}</a>
|
||||
<span class='saving'>{{content.savingStatus}}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{{/if}}
|
||||
<div class='clearfix'></div>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<h3>Welcome to the admin section.</h3>
|
||||
|
||||
<p>Not much to see here right now. Why not try the Site Settings?</p>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<div class='admin-controls'>
|
||||
<div class='span5 controls'>
|
||||
{{view Discourse.TextField valueBinding="controller.testEmailAddress" placeholderKey="admin.email_logs.test_email_address"}}
|
||||
</div>
|
||||
<div class='span10 controls'>
|
||||
<button class='btn' {{action sendTestEmail target="controller"}} {{bindAttr disabled="sendTestEmailDisabled"}}>{{i18n admin.email_logs.send_test}}</button>
|
||||
{{#if controller.sentTestEmail}}<span class='result-message'>{{i18n admin.email_logs.sent_test}}</span>{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class='table'>
|
||||
<tr>
|
||||
<th>{{i18n admin.email_logs.sent_at}}</th>
|
||||
<th>{{i18n user.title}}</th>
|
||||
<th>{{i18n admin.email_logs.to_address}}</th>
|
||||
<th>{{i18n admin.email_logs.email_type}}</th>
|
||||
</tr>
|
||||
|
||||
{{#if controller.content.length}}
|
||||
{{#group}}
|
||||
{{#collection contentBinding="controller.content" tagName="tbody" itemTagName="tr"}}
|
||||
<td>{{date view.content.created_at}}</td>
|
||||
<td>
|
||||
{{#if view.content.user}}
|
||||
<a href="/admin/users/{{unbound view.content.user.username_lower}}">{{avatar view.content.user imageSize="tiny"}}</a>
|
||||
<a href="/admin/users/{{unbound view.content.user.username_lower}}">{{view.content.user.username}}</a>
|
||||
{{else}}
|
||||
—
|
||||
{{/if}}
|
||||
</td>
|
||||
<td><a href='mailto:{{unbound view.content.to_address}}'>{{view.content.to_address}}</a></td>
|
||||
<td>{{view.content.email_type}}</td>
|
||||
{{/collection}}
|
||||
{{/group}}
|
||||
{{/if}}
|
||||
|
||||
</table>
|
||||
49
app/assets/javascripts/admin/templates/flags.js.handlebars
Normal file
49
app/assets/javascripts/admin/templates/flags.js.handlebars
Normal file
@@ -0,0 +1,49 @@
|
||||
<div class='admin-controls'>
|
||||
<div class='span15'>
|
||||
<ul class="nav nav-pills">
|
||||
<li>{{#linkTo adminFlags.active}}{{i18n admin.flags.active}}{{/linkTo}}</li>
|
||||
<li>{{#linkTo adminFlags.old}}{{i18n admin.flags.old}}{{/linkTo}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<table class='admin-flags'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class='user'></th>
|
||||
<th class='excerpt'></th>
|
||||
<th class='flaggers'>Flag by</th>
|
||||
<th class='last-flagged'></th>
|
||||
<th class='action'></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each content}}
|
||||
<tr {{bindAttr class="hiddenClass"}}>
|
||||
<td class='user'>{{avatar user imageSize="small"}}</td>
|
||||
<td class='excerpt'>{{#if topicHidden}}<i title='this topic is invisible' class='icon icon-eye-close'></i> {{/if}}<h3><a href='{{unbound url}}'>{{title}}</a></h3><br>{{{excerpt}}}
|
||||
</td>
|
||||
<td class='flaggers'>{{#each flaggers}}{{avatar this imageSize="small"}}{{/each}}</td>
|
||||
<td class='last-flagged'>{{date lastFlagged}}</td>
|
||||
<td class='action'>
|
||||
{{#if controller.adminActiveFlagsView}}
|
||||
<button title='dismiss all flags on this post (will unhide hidden posts)' class='btn' {{action clearFlags this}}>Clear Flags</button>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{{#each messages}}
|
||||
<tr>
|
||||
<td></td>
|
||||
<td class='message'>
|
||||
<div>{{avatar user imageSize="small"}} {{message}}</div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class='admin-controls'>
|
||||
<div class='span15 search controls'>
|
||||
<label>
|
||||
{{view Ember.Checkbox checkedBinding="controller.onlyOverridden"}}
|
||||
{{i18n admin.site_settings.show_overriden}}
|
||||
</label>
|
||||
</div>
|
||||
<div class='span5 controls'>
|
||||
{{view Discourse.TextField valueBinding="controller.filter" placeholderKey="type_to_filter"}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{{#collection contentBinding="filteredContent" classNames="form-horizontal settings" itemClass="row setting"}}
|
||||
{{#with view.content}}
|
||||
<div class='span4 offset1'>
|
||||
{{unbound setting}}
|
||||
</div>
|
||||
<div {{bindAttr class=":span11 overridden:overridden"}}>
|
||||
{{view Ember.TextField valueBinding="value" classNames="input-xxlarge"}}
|
||||
<div class='desc'>{{unbound description}}</div>
|
||||
</div>
|
||||
{{#if dirty}}
|
||||
<div class='span3'>
|
||||
<button class='btn ok' {{action save this target="controller"}}><i class='icon-ok'></i></button>
|
||||
<button class='btn cancel' {{action cancel this target="controller"}}><i class='icon-remove'></i></button
|
||||
</div>
|
||||
{{else}}
|
||||
{{#if overridden}}
|
||||
<button class='btn' href='#' {{action resetDefault this target="controller"}}>{{i18n admin.site_settings.reset}}</button>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/with}}
|
||||
{{/collection}}
|
||||
168
app/assets/javascripts/admin/templates/user.js.handlebars
Normal file
168
app/assets/javascripts/admin/templates/user.js.handlebars
Normal file
@@ -0,0 +1,168 @@
|
||||
<section class='details'>
|
||||
<h1>{{i18n admin.user.basics}}</h1>
|
||||
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n user.username.title}}</div>
|
||||
<div class='value'>{{content.username}}</div>
|
||||
<div class='controls'>
|
||||
<a href="/users/{{unbound content.username_lower}}" class='btn'>
|
||||
<i class='icon icon-user'></i>
|
||||
{{i18n admin.user.show_public_profile}}
|
||||
</a>
|
||||
{{#if content.can_impersonate}}
|
||||
<button class='btn' {{action impersonate target="content"}}>
|
||||
<i class='icon icon-screenshot'></i>
|
||||
{{i18n admin.user.impersonate}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n user.email.title}}</div>
|
||||
<div class='value'><a href="mailto:{{unbound content.email}}">{{content.email}}</a></div>
|
||||
</div>
|
||||
<div class='display-row' style='height: 50px'>
|
||||
<div class='field'>{{i18n user.avatar.title}}</div>
|
||||
<div class='value'>{{avatar content imageSize="large"}}</div>
|
||||
</div>
|
||||
<div class='display-row' style='height: 50px'>
|
||||
<div class='field'>{{i18n user.ip_address.title}}</div>
|
||||
<div class='value'>{{content.ip_address}}</div>
|
||||
<div class='controls'>
|
||||
<button class='btn' {{action refreshBrowsers target="content"}}>
|
||||
{{i18n admin.user.refresh_browsers}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
<section class='details'>
|
||||
<h1>{{i18n admin.user.permissions}}</h1>
|
||||
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n admin.users.approved}}</div>
|
||||
<div class='value'>
|
||||
{{#if content.approved}}
|
||||
{{i18n admin.user.approved_by}}
|
||||
<a href="/admin/users/{{unbound content.approved_by.username_lower}}">{{avatar approved_by imageSize="small"}}</a>
|
||||
<a href="/admin/users/{{unbound username_lower}}">{{content.approved_by.username}}</a>
|
||||
{{else}}
|
||||
{{i18n no_value}}
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
<div class='controls'>
|
||||
{{#if content.can_approve}}
|
||||
<button class='btn' {{action approve target="content"}}>
|
||||
<i class='icon icon-ok'></i>
|
||||
{{i18n admin.user.approve}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n admin.user.admin}}</div>
|
||||
<div class='value'>{{content.admin}}</div>
|
||||
<div class='controls'>
|
||||
{{#if content.can_revoke_admin}}
|
||||
<button class='btn' {{action revokeAdmin target="content"}}>
|
||||
<i class='icon icon-trophy'></i>
|
||||
{{i18n admin.user.revoke_admin}}
|
||||
</button>
|
||||
{{/if}}
|
||||
{{#if content.can_grant_admin}}
|
||||
<button class='btn' {{action grantAdmin target="content"}}>
|
||||
<i class='icon icon-trophy'></i>
|
||||
{{i18n admin.user.grant_admin}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n admin.user.moderator}}</div>
|
||||
<div class='value'>{{content.moderator}}</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n trust_level}}</div>
|
||||
<div class='value'>{{content.trustLevel.name}}</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n admin.user.banned}}</div>
|
||||
<div class='value'>{{content.is_banned}}</div>
|
||||
<div class='controls'>
|
||||
{{#if content.is_banned}}
|
||||
{{#if content.canBan}}
|
||||
<button class='btn' {{action unban target="content"}}>
|
||||
<i class='icon icon-screenshot'></i>
|
||||
{{i18n admin.user.unban}}
|
||||
</button>
|
||||
{{content.banDuration}}
|
||||
{{/if}}
|
||||
{{else}}
|
||||
{{#if content.canBan}}
|
||||
<button class='btn' {{action ban target="content"}}>
|
||||
<i class='icon icon-screenshot'></i>
|
||||
{{i18n admin.user.ban}}
|
||||
</button>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class='details'>
|
||||
<h1>{{i18n admin.user.activity}}</h1>
|
||||
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n created}}</div>
|
||||
<div class='value'>{{{content.created_at_age}}}</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n admin.users.last_emailed}}</div>
|
||||
<div class='value'>{{{content.last_emailed_age}}}</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n last_seen}}</div>
|
||||
<div class='value'>{{{content.last_seen_age}}}</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n admin.user.like_count}}</div>
|
||||
<div class='value'>{{content.like_count}}</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n admin.user.topics_entered}}</div>
|
||||
<div class='value'>{{content.topics_entered}}</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n admin.user.post_count}}</div>
|
||||
<div class='value'>{{content.post_count}}</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n admin.user.posts_read_count}}</div>
|
||||
<div class='value'>{{content.posts_read_count}}</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n admin.user.flags_given_count}}</div>
|
||||
<div class='value'>{{content.flags_given_count}}</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n admin.user.flags_received_count}}</div>
|
||||
<div class='value'>{{content.flags_received_count}}</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n admin.user.private_topics_count}}</div>
|
||||
<div class='value'>{{content.private_topics_count}}</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n admin.user.time_read}}</div>
|
||||
<div class='value'>{{{content.time_read}}}</div>
|
||||
</div>
|
||||
<div class='display-row'>
|
||||
<div class='field'>{{i18n user.invited.days_visited}}</div>
|
||||
<div class='value'>{{{content.days_visited}}}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<div class='admin-controls'>
|
||||
<div class='span15'>
|
||||
<ul class="nav nav-pills">
|
||||
<li>{{#linkTo adminUsersList.active}}{{i18n admin.users.active}}{{/linkTo}}</li>
|
||||
<li>{{#linkTo adminUsersList.new}}{{i18n admin.users.new}}{{/linkTo}}</li>
|
||||
{{#if Discourse.SiteSettings.must_approve_users}}
|
||||
<li>{{#linkTo adminUsersList.pending}}{{i18n admin.users.pending}}{{/linkTo}}</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</div>
|
||||
<div class='span5 username controls'>
|
||||
{{view Discourse.TextField valueBinding="controller.username" placeholderKey="username"}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{#if hasSelection}}
|
||||
<div id='selected-controls'>
|
||||
<button {{action approveUsers target="controller"}} class='btn'>{{countI18n admin.users.approved_selected countBinding="selectedCount"}}</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if content.length}}
|
||||
<table class='table'>
|
||||
<tr>
|
||||
{{#if showApproval}}
|
||||
<th>{{view Ember.Checkbox checkedBinding="selectAll"}}</th>
|
||||
{{/if}}
|
||||
<th> </th>
|
||||
<th>{{i18n username}}</th>
|
||||
<th>{{i18n email}}</th>
|
||||
<th>{{i18n admin.users.last_emailed}}</th>
|
||||
<th>{{i18n last_seen}}</th>
|
||||
<th>{{i18n admin.user.topics_entered}}</th>
|
||||
<th>{{i18n admin.user.posts_read_count}}</th>
|
||||
<th>{{i18n admin.user.time_read}}</th>
|
||||
<th>{{i18n created}}</th>
|
||||
{{#if showApproval}}
|
||||
<th>{{i18n admin.users.approved}}</th>
|
||||
{{/if}}
|
||||
<th> </th>
|
||||
|
||||
</tr>
|
||||
|
||||
{{#each content}}
|
||||
<tr {{bindAttr class="selected"}}>
|
||||
{{#if controller.showApproval}}
|
||||
<td>
|
||||
{{#if can_approve}}
|
||||
{{view Ember.Checkbox checkedBinding="selected"}}
|
||||
{{/if}}
|
||||
</td>
|
||||
{{/if}}
|
||||
<td>
|
||||
<a href="/admin/users/{{unbound username_lower}}">{{avatar this imageSize="small"}}</a>
|
||||
</td>
|
||||
<td><a href="/admin/users/{{unbound username_lower}}">{{unbound username}}</a></td>
|
||||
<td>{{unbound email}}</td>
|
||||
<td>{{{unbound last_emailed_age}}}</td>
|
||||
<td>{{{unbound last_seen_age}}}</td>
|
||||
<td>{{{unbound topics_entered}}}</td>
|
||||
<td>{{{unbound posts_read_count}}}</td>
|
||||
<td>{{{unbound time_read}}}</td>
|
||||
|
||||
<td>{{{unbound created_at_age}}}</td>
|
||||
|
||||
{{#if controller.showApproval}}
|
||||
<td>
|
||||
{{#if approved}}
|
||||
{{i18n yes_value}}
|
||||
{{else}}
|
||||
{{i18n no_value}}
|
||||
{{/if}}
|
||||
</td>
|
||||
{{/if}}
|
||||
<td>{{#if admin}}<i class="icon-trophy"></i>{{/if}}<td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
|
||||
</table>
|
||||
{{else}}
|
||||
<div class='admin-loading'>{{i18n loading}}</div>
|
||||
{{/if}}
|
||||
7
app/assets/javascripts/admin/translations.js.erb
Normal file
7
app/assets/javascripts/admin/translations.js.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
//= depend_on 'en.yml'
|
||||
|
||||
<% SimplesIdeias::I18n.assert_usable_configuration! %>
|
||||
<% admin = SimplesIdeias::I18n.translation_segments['app/assets/javascripts/i18n/admin.en.js']
|
||||
admin[:en][:js] = admin[:en].delete(:admin_js)
|
||||
%>
|
||||
$.extend(true, I18n.translations, <%= admin.to_json %>);
|
||||
42
app/assets/javascripts/admin/views/ace_editor_view.js.coffee
Normal file
42
app/assets/javascripts/admin/views/ace_editor_view.js.coffee
Normal file
@@ -0,0 +1,42 @@
|
||||
Discourse.AceEditorView = window.Discourse.View.extend
|
||||
mode: 'css'
|
||||
classNames: ['ace-wrapper']
|
||||
|
||||
contentChanged:(->
|
||||
if @editor && !@skipContentChangeEvent
|
||||
@editor.getSession().setValue(@get('content'))
|
||||
).observes('content')
|
||||
|
||||
render: (buffer) ->
|
||||
buffer.push("<div class='ace'>")
|
||||
buffer.push(Handlebars.Utils.escapeExpression(@get('content'))) if @get('content')
|
||||
buffer.push("</div>")
|
||||
|
||||
willDestroyElement: ->
|
||||
if @editor
|
||||
@editor.destroy()
|
||||
@editor = null
|
||||
|
||||
didInsertElement: ->
|
||||
initAce = =>
|
||||
@editor = ace.edit(@$('.ace')[0])
|
||||
@editor.setTheme("ace/theme/chrome")
|
||||
@editor.setShowPrintMargin(false)
|
||||
@editor.getSession().setMode("ace/mode/#{@get('mode')}")
|
||||
@editor.on "change", (e)=>
|
||||
# amending stuff as you type seems a bit out of scope for now - can revisit after launch
|
||||
# changes = @get('changes')
|
||||
# unless changes
|
||||
# changes = []
|
||||
# @set('changes', changes)
|
||||
# changes.push e.data
|
||||
|
||||
@skipContentChangeEvent = true
|
||||
@set('content', @editor.getSession().getValue())
|
||||
@skipContentChangeEvent = false
|
||||
if window.ace
|
||||
initAce()
|
||||
else
|
||||
$LAB.script('http://d1n0x3qji82z53.cloudfront.net/src-min-noconflict/ace.js').wait initAce
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
Discourse.AdminCustomizeView = window.Discourse.View.extend
|
||||
templateName: 'admin/templates/customize'
|
||||
classNames: ['customize']
|
||||
contentBinding: 'controller.content'
|
||||
|
||||
init: ->
|
||||
@_super()
|
||||
@set('selected', 'stylesheet')
|
||||
|
||||
headerActive: (->
|
||||
@get('selected') == 'header'
|
||||
).property('selected')
|
||||
|
||||
stylesheetActive: (->
|
||||
@get('selected') == 'stylesheet'
|
||||
).property('selected')
|
||||
|
||||
selectHeader: ->
|
||||
@set('selected', 'header')
|
||||
|
||||
selectStylesheet: ->
|
||||
@set('selected', 'stylesheet')
|
||||
|
||||
|
||||
didInsertElement: ->
|
||||
Mousetrap.bindGlobal ['meta+s', 'ctrl+s'], =>
|
||||
@get('controller').save()
|
||||
return false
|
||||
|
||||
willDestroyElement: ->
|
||||
Mousetrap.unbindGlobal('meta+s','ctrl+s')
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Discourse.AdminDashboardView = window.Discourse.View.extend
|
||||
templateName: 'admin/templates/dashboard'
|
||||
@@ -0,0 +1,2 @@
|
||||
Discourse.AdminEmailLogsView = window.Discourse.View.extend
|
||||
templateName: 'admin/templates/email_logs'
|
||||
@@ -0,0 +1,3 @@
|
||||
Discourse.AdminFlagsView = window.Discourse.View.extend
|
||||
templateName: 'admin/templates/flags'
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
Discourse.AdminSiteSettingsView = window.Discourse.View.extend
|
||||
templateName: 'admin/templates/site_settings'
|
||||
@@ -0,0 +1,2 @@
|
||||
Discourse.AdminUserView = window.Discourse.View.extend
|
||||
templateName: 'admin/templates/user'
|
||||
@@ -0,0 +1,2 @@
|
||||
Discourse.AdminUsersListView = window.Discourse.View.extend
|
||||
templateName: 'admin/templates/users_list'
|
||||
2
app/assets/javascripts/admin/views/admin_view.js.coffee
Normal file
2
app/assets/javascripts/admin/views/admin_view.js.coffee
Normal file
@@ -0,0 +1,2 @@
|
||||
Discourse.AdminView = window.Discourse.View.extend
|
||||
templateName: 'admin/templates/admin'
|
||||
51
app/assets/javascripts/application.js.erb
Normal file
51
app/assets/javascripts/application.js.erb
Normal file
@@ -0,0 +1,51 @@
|
||||
// This is a manifest file that'll be compiled into including all the files listed below.
|
||||
// Add new JavaScript/Coffee code in separate files in this directory and they'll automatically
|
||||
// be included in the compiled file accessible from http://example.com/assets/application.js
|
||||
// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
|
||||
// the compiled file.
|
||||
//
|
||||
//= require ./env
|
||||
|
||||
// probe framework first
|
||||
//= require ./discourse/components/probes.js
|
||||
|
||||
// Externals we need to load first
|
||||
//= require ./external/jquery-1.8.2.js
|
||||
//= require ./external/jquery.ui.widget.js
|
||||
//= require ./external/handlebars-1.0.rc.2.js
|
||||
//= require ./external/ember.js
|
||||
|
||||
// Pagedown customizations
|
||||
//= require ./pagedown_custom.js
|
||||
|
||||
// The rest of the externals
|
||||
//= require_tree ./external
|
||||
//= require i18n
|
||||
//= require discourse/translations
|
||||
|
||||
//= require ./discourse/helpers/i18n_helpers
|
||||
//= require ./discourse
|
||||
|
||||
// Stuff we need to load first
|
||||
//= require_tree ./discourse/mixins
|
||||
//= require ./discourse/components/debounce
|
||||
//= require ./discourse/views/view
|
||||
//= require ./discourse/controllers/controller
|
||||
//= require ./discourse/views/modal/modal_body_view
|
||||
//= require ./discourse/models/model
|
||||
//= require ./discourse/routes/discourse_route
|
||||
|
||||
//= require_tree ./discourse/controllers
|
||||
//= require_tree ./discourse/components
|
||||
//= require_tree ./discourse/models
|
||||
//= require_tree ./discourse/views
|
||||
//= require_tree ./discourse/helpers
|
||||
//= require_tree ./discourse/templates
|
||||
//= require_tree ./discourse/routes
|
||||
|
||||
<%
|
||||
# Include javascripts
|
||||
DiscoursePluginRegistry.javascripts.each do |js|
|
||||
require_asset(js)
|
||||
end
|
||||
%>
|
||||
272
app/assets/javascripts/discourse.js.coffee
Normal file
272
app/assets/javascripts/discourse.js.coffee
Normal file
@@ -0,0 +1,272 @@
|
||||
window.Discourse = Ember.Application.createWithMixins
|
||||
rootElement: '#main'
|
||||
|
||||
# Data we want to remember for a short period
|
||||
transient: Em.Object.create()
|
||||
|
||||
hasFocus: true
|
||||
scrolling: false
|
||||
|
||||
# The highest seen post number by topic
|
||||
highestSeenByTopic: {}
|
||||
|
||||
logoSmall: (->
|
||||
logo = Discourse.SiteSettings.logo_small_url
|
||||
if logo && logo.length > 1
|
||||
"<img src='#{logo}' width='33' height='33'>"
|
||||
else
|
||||
"<i class='icon-home'></i>"
|
||||
).property()
|
||||
|
||||
titleChanged: (->
|
||||
title = ""
|
||||
title += "#{@get('title')} - " if @get('title')
|
||||
title += Discourse.SiteSettings.title
|
||||
$('title').text(title)
|
||||
|
||||
title = ("(*) " + title) if !@get('hasFocus') && @get('notify')
|
||||
|
||||
# chrome bug workaround see: http://stackoverflow.com/questions/2952384/changing-the-window-title-when-focussing-the-window-doesnt-work-in-chrome
|
||||
window.setTimeout (->
|
||||
document.title = "."
|
||||
document.title = title
|
||||
return), 200
|
||||
return
|
||||
).observes('title', 'hasFocus', 'notify')
|
||||
|
||||
currentUserChanged: (->
|
||||
|
||||
bus = Discourse.MessageBus
|
||||
|
||||
# We don't want to receive any previous user notidications
|
||||
bus.unsubscribe "/notification"
|
||||
|
||||
bus.callbackInterval = Discourse.SiteSettings.anon_polling_interval
|
||||
bus.enableLongPolling = false
|
||||
|
||||
user = @get('currentUser')
|
||||
if user
|
||||
bus.callbackInterval = Discourse.SiteSettings.polling_interval
|
||||
bus.enableLongPolling = true
|
||||
|
||||
if user.admin
|
||||
bus.subscribe "/flagged_counts", (data) ->
|
||||
user.set('site_flagged_posts_count', data.total)
|
||||
bus.subscribe "/notification", ((data) ->
|
||||
user.set('unread_notifications', data.unread_notifications)
|
||||
user.set('unread_private_messages', data.unread_private_messages)), user.notification_channel_position
|
||||
|
||||
).observes('currentUser')
|
||||
|
||||
notifyTitle: ->
|
||||
@set('notify', true)
|
||||
|
||||
# Browser aware replaceState
|
||||
replaceState: (path) ->
|
||||
if window.history && window.history.pushState && window.history.replaceState && !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]|WebApps\/.+CFNetwork)/)
|
||||
history.replaceState({path: path}, null, path) unless window.location.pathname is path
|
||||
|
||||
openComposer: (opts) ->
|
||||
# TODO, remove container link
|
||||
Discourse.__container__.lookup('controller:composer')?.open(opts)
|
||||
|
||||
# Like router.route, but allow full urls rather than relative ones
|
||||
# HERE BE HACKS - uses the ember container for now until we can do this nicer.
|
||||
routeTo: (path) ->
|
||||
path = path.replace(/https?\:\/\/[^\/]+/, '')
|
||||
|
||||
# If we're in the same topic, don't push the state
|
||||
topicRegexp = /\/t\/([^\/]+)\/(\d+)\/?(\d+)?/
|
||||
newMatches = topicRegexp.exec(path);
|
||||
if newTopicId = newMatches?[2]
|
||||
oldMatches = topicRegexp.exec(window.location.pathname);
|
||||
if (oldTopicId = oldMatches?[2]) && (oldTopicId is newTopicId)
|
||||
Discourse.replaceState(path)
|
||||
topicController = Discourse.__container__.lookup('controller:topic')
|
||||
opts = {trackVisit: false}
|
||||
opts.nearPost = newMatches[3] if newMatches[3]
|
||||
topicController.get('content').loadPosts(opts)
|
||||
return
|
||||
|
||||
|
||||
# Be wary of looking up the router. In this case, we have links in our
|
||||
# HTML, say form compiled markdown posts, that need to be routed.
|
||||
router = Discourse.__container__.lookup('router:main')
|
||||
router.router.updateURL(path)
|
||||
router.handleURL(path)
|
||||
|
||||
# Scroll to the top if we're not replacing state
|
||||
|
||||
|
||||
# The classes of buttons to show on a post
|
||||
postButtons: (->
|
||||
Discourse.SiteSettings.post_menu.split("|").map (i) -> "#{i.replace(/\+/, '').capitalize()}"
|
||||
).property('Discourse.SiteSettings.post_menu')
|
||||
|
||||
bindDOMEvents: ->
|
||||
|
||||
$html = $('html')
|
||||
# Add the discourse touch event
|
||||
hasTouch = false
|
||||
hasTouch = true if $html.hasClass('touch')
|
||||
hasTouch = true if (Modernizr.prefixed("MaxTouchPoints", navigator) > 1)
|
||||
|
||||
if hasTouch
|
||||
$html.addClass('discourse-touch')
|
||||
@touch = true
|
||||
@hasTouch = true
|
||||
else
|
||||
$html.addClass('discourse-no-touch')
|
||||
@touch = false
|
||||
|
||||
$('#main').on 'click.discourse', '[data-not-implemented=true]', (e) =>
|
||||
e.preventDefault()
|
||||
alert Em.String.i18n('not_implemented')
|
||||
false
|
||||
|
||||
$('#main').on 'click.discourse', 'a', (e) =>
|
||||
|
||||
return if (e.isDefaultPrevented() || e.metaKey || e.ctrlKey)
|
||||
$currentTarget = $(e.currentTarget)
|
||||
|
||||
href = $currentTarget.attr('href')
|
||||
return if href is undefined
|
||||
return if href is '#'
|
||||
return if $currentTarget.attr('target')
|
||||
return if $currentTarget.data('auto-route')
|
||||
return if href.indexOf("mailto:") is 0
|
||||
|
||||
if href.match(/^http[s]?:\/\//i) && !href.match new RegExp("^http:\\/\\/" + window.location.hostname,"i")
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
@routeTo(href)
|
||||
|
||||
false
|
||||
|
||||
$(window).focus( =>
|
||||
@set('hasFocus',true)
|
||||
@set('notify',false)
|
||||
).blur( =>
|
||||
@set('hasFocus',false)
|
||||
)
|
||||
|
||||
logout: ->
|
||||
username = @get('currentUser.username')
|
||||
Discourse.KeyValueStore.abandonLocal()
|
||||
$.ajax "/session/#{username}",
|
||||
type: 'DELETE'
|
||||
success: (result) =>
|
||||
# To keep lots of our variables unbound, we can handle a redirect on logging out.
|
||||
window.location.reload()
|
||||
|
||||
# fancy probes in ember
|
||||
insertProbes: ->
|
||||
|
||||
return unless console?
|
||||
|
||||
topLevel = (fn,name) ->
|
||||
window.probes.measure fn,
|
||||
name: name
|
||||
before: (data,owner, args) ->
|
||||
if owner
|
||||
window.probes.clear()
|
||||
|
||||
after: (data, owner, args) ->
|
||||
if owner && data.time > 10
|
||||
f = (name,data) ->
|
||||
"#{name} - #{data.count} calls #{(data.time + 0.0).toFixed(2)}ms" if data && data.count
|
||||
|
||||
if console && console.group
|
||||
console.group(f(name, data))
|
||||
else
|
||||
console.log("")
|
||||
console.log(f(name,data))
|
||||
|
||||
ary = []
|
||||
for n,v of window.probes
|
||||
continue if n == name || v.time < 1
|
||||
ary.push(k: n, v: v)
|
||||
|
||||
ary.sortBy((item) -> if item.v && item.v.time then -item.v.time else 0).each (item)->
|
||||
console.log output if output = f("#{item.k}", item.v)
|
||||
console?.groupEnd?()
|
||||
|
||||
window.probes.clear()
|
||||
|
||||
|
||||
Ember.View.prototype.renderToBuffer = window.probes.measure Ember.View.prototype.renderToBuffer, "renderToBuffer"
|
||||
|
||||
Discourse.routeTo = topLevel(Discourse.routeTo, "Discourse.routeTo")
|
||||
Ember.run.end = topLevel(Ember.run.end, "Ember.run.end")
|
||||
|
||||
return
|
||||
|
||||
authenticationComplete: (options)->
|
||||
# TODO, how to dispatch this to the view without the container?
|
||||
loginView = Discourse.__container__.lookup('controller:modal').get('currentView')
|
||||
loginView.authenticationComplete(options)
|
||||
|
||||
buildRoutes: (builder) ->
|
||||
oldBuilder = Discourse.routeBuilder
|
||||
Discourse.routeBuilder = ->
|
||||
oldBuilder.call(@) if oldBuilder
|
||||
builder.call(@)
|
||||
|
||||
start: ->
|
||||
@bindDOMEvents()
|
||||
Discourse.SiteSettings = PreloadStore.getStatic('siteSettings')
|
||||
Discourse.MessageBus.start()
|
||||
Discourse.KeyValueStore.init("discourse_", Discourse.MessageBus)
|
||||
Discourse.insertProbes()
|
||||
|
||||
|
||||
# subscribe to any site customizations that are loaded
|
||||
$('link.custom-css').each ->
|
||||
split = @href.split("/")
|
||||
id = split[split.length-1].split(".css")[0]
|
||||
stylesheet = @
|
||||
Discourse.MessageBus.subscribe "/file-change/#{id}", (data)=>
|
||||
$(stylesheet).data('orig', stylesheet.href) unless $(stylesheet).data('orig')
|
||||
orig = $(stylesheet).data('orig')
|
||||
sp = orig.split(".css?")
|
||||
stylesheet.href = sp[0] + ".css?" + data
|
||||
|
||||
$('header.custom').each ->
|
||||
header = $(this)
|
||||
Discourse.MessageBus.subscribe "/header-change/#{$(@).data('key')}", (data)->
|
||||
header.html(data)
|
||||
|
||||
# possibly move this to dev only
|
||||
Discourse.MessageBus.subscribe "/file-change", (data)->
|
||||
Ember.TEMPLATES["empty"] = Handlebars.compile("")
|
||||
data.each (me)->
|
||||
if me == "refresh"
|
||||
document.location.reload(true)
|
||||
else if me.name.substr(-10) == "handlebars"
|
||||
js = me.name.replace(".handlebars","").replace("app/assets/javascripts","/assets")
|
||||
$LAB.script(js + "?hash=" + me.hash).wait ->
|
||||
templateName = js.replace(".js","").replace("/assets/","")
|
||||
$.each Ember.View.views, ->
|
||||
if(@get('templateName')==templateName)
|
||||
@set('templateName','empty')
|
||||
@rerender()
|
||||
Em.run.next =>
|
||||
@set('templateName', templateName)
|
||||
@rerender()
|
||||
else
|
||||
$('link').each ->
|
||||
if @.href.match(me.name) and me.hash
|
||||
$(@).data('orig', @.href) unless $(@).data('orig')
|
||||
@.href = $(@).data('orig') + "&hash=" + me.hash
|
||||
|
||||
window.Discourse.Router = Discourse.Router.reopen(location: 'discourse_location')
|
||||
|
||||
# since we have no jquery-rails these days, hook up csrf token
|
||||
csrf_token = $('meta[name=csrf-token]').attr('content')
|
||||
|
||||
$.ajaxPrefilter (options,originalOptions,xhr) ->
|
||||
unless options.crossDomain
|
||||
xhr.setRequestHeader('X-CSRF-Token', csrf_token)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
( ($) ->
|
||||
|
||||
template = null
|
||||
|
||||
$.fn.autocomplete = (options)->
|
||||
|
||||
return if @.length == 0
|
||||
|
||||
if options && options.cancel && @.data("closeAutocomplete")
|
||||
@.data("closeAutocomplete")()
|
||||
return this
|
||||
|
||||
alert "only supporting one matcher at the moment" unless @.length == 1
|
||||
|
||||
autocompleteOptions = null
|
||||
selectedOption = null
|
||||
completeStart = null
|
||||
completeEnd = null
|
||||
me = @
|
||||
div = null
|
||||
|
||||
# input is handled differently
|
||||
isInput = @[0].tagName == "INPUT"
|
||||
|
||||
inputSelectedItems = []
|
||||
addInputSelectedItem = (item) ->
|
||||
|
||||
transformed = options.transformComplete(item) if options.transformComplete
|
||||
d = $("<div class='item'><span>#{transformed || item}<a href='#'><i class='icon-remove'></i></a></span></div>")
|
||||
prev = me.parent().find('.item:last')
|
||||
if prev.length == 0
|
||||
me.parent().prepend(d)
|
||||
else
|
||||
prev.after(d)
|
||||
|
||||
inputSelectedItems.push(item)
|
||||
|
||||
if options.onChangeItems
|
||||
options.onChangeItems(inputSelectedItems)
|
||||
|
||||
d.find('a').click ->
|
||||
closeAutocomplete()
|
||||
inputSelectedItems.splice($.inArray(item),1)
|
||||
$(this).parent().parent().remove()
|
||||
if options.onChangeItems
|
||||
options.onChangeItems(inputSelectedItems)
|
||||
|
||||
if isInput
|
||||
|
||||
width = @.width()
|
||||
height = @.height()
|
||||
|
||||
wrap = @.wrap("<div class='ac-wrap clearfix'/>").parent()
|
||||
|
||||
wrap.width(width)
|
||||
|
||||
@.width(80)
|
||||
@.attr('name', @.attr('name') + "-renamed")
|
||||
|
||||
vals = @.val().split(",")
|
||||
|
||||
vals.each (x)->
|
||||
unless x == ""
|
||||
x = options.reverseTransform(x) if options.reverseTransform
|
||||
addInputSelectedItem(x)
|
||||
|
||||
@.val("")
|
||||
completeStart = 0
|
||||
wrap.click =>
|
||||
@.focus()
|
||||
true
|
||||
|
||||
|
||||
markSelected = ->
|
||||
links = div.find('li a')
|
||||
links.removeClass('selected')
|
||||
$(links[selectedOption]).addClass('selected')
|
||||
|
||||
renderAutocomplete = ->
|
||||
div.hide().remove() if div
|
||||
return if autocompleteOptions.length == 0
|
||||
div = $(options.template(options: autocompleteOptions))
|
||||
|
||||
ul = div.find('ul')
|
||||
selectedOption = 0
|
||||
markSelected()
|
||||
ul.find('li').click ->
|
||||
selectedOption = ul.find('li').index(this)
|
||||
completeTerm(autocompleteOptions[selectedOption])
|
||||
|
||||
pos = null
|
||||
if isInput
|
||||
pos =
|
||||
left: 0
|
||||
top: 0
|
||||
else
|
||||
pos = me.caretPosition(pos: completeStart, key: options.key)
|
||||
|
||||
div.css(left: "-1000px")
|
||||
me.parent().append(div)
|
||||
|
||||
mePos = me.position()
|
||||
|
||||
borderTop = parseInt(me.css('border-top-width')) || 0
|
||||
div.css
|
||||
position: 'absolute',
|
||||
top: (mePos.top + pos.top - div.height() + borderTop) + 'px',
|
||||
left: (mePos.left + pos.left + 27) + 'px'
|
||||
|
||||
|
||||
updateAutoComplete = (r)->
|
||||
return if completeStart == null
|
||||
autocompleteOptions = r
|
||||
if !r || r.length == 0
|
||||
closeAutocomplete()
|
||||
else
|
||||
renderAutocomplete()
|
||||
|
||||
closeAutocomplete = ->
|
||||
div.hide().remove() if div
|
||||
div = null
|
||||
completeStart = null
|
||||
autocompleteOptions = null
|
||||
|
||||
# chain to allow multiples
|
||||
oldClose = me.data("closeAutocomplete")
|
||||
me.data "closeAutocomplete", ->
|
||||
oldClose() if oldClose
|
||||
closeAutocomplete()
|
||||
|
||||
completeTerm = (term) ->
|
||||
if term
|
||||
if isInput
|
||||
me.val("")
|
||||
addInputSelectedItem(term)
|
||||
else
|
||||
term = options.transformComplete(term) if options.transformComplete
|
||||
text = me.val()
|
||||
text = text.substring(0, completeStart) + (options.key || "") + term + ' ' + text.substring(completeEnd+1, text.length)
|
||||
me.val(text)
|
||||
Discourse.Utilities.setCaretPosition(me[0], completeStart + 1 + term.length)
|
||||
closeAutocomplete()
|
||||
|
||||
$(@).keypress (e) ->
|
||||
|
||||
|
||||
if !options.key
|
||||
return
|
||||
|
||||
# keep hunting backwards till you hit a
|
||||
|
||||
if e.which == options.key.charCodeAt(0)
|
||||
caretPosition = Discourse.Utilities.caretPosition(me[0])
|
||||
prevChar = me.val().charAt(caretPosition-1)
|
||||
if !prevChar || /\s/.test(prevChar)
|
||||
completeStart = completeEnd = caretPosition
|
||||
term = ""
|
||||
options.dataSource term, updateAutoComplete
|
||||
return
|
||||
|
||||
$(@).keydown (e) ->
|
||||
|
||||
completeStart = 0 if !options.key
|
||||
|
||||
return if e.which == 16
|
||||
|
||||
if completeStart == null && e.which == 8 && options.key #backspace
|
||||
|
||||
c = Discourse.Utilities.caretPosition(me[0])
|
||||
next = me[0].value[c]
|
||||
nextIsGood = next == undefined || /\s/.test(next)
|
||||
|
||||
c-=1
|
||||
initial = c
|
||||
|
||||
prevIsGood = true
|
||||
while prevIsGood && c >= 0
|
||||
c -=1
|
||||
prev = me[0].value[c]
|
||||
stopFound = prev == options.key
|
||||
if stopFound
|
||||
prev = me[0].value[c-1]
|
||||
if !prev || /\s/.test(prev)
|
||||
completeStart = c
|
||||
caretPosition = completeEnd = initial
|
||||
term = me[0].value.substring(c+1, initial)
|
||||
options.dataSource term, updateAutoComplete
|
||||
return true
|
||||
|
||||
prevIsGood = /[a-zA-Z\.]/.test(prev)
|
||||
|
||||
|
||||
if e.which == 27 # esc key
|
||||
if completeStart != null
|
||||
closeAutocomplete()
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
if (completeStart != null)
|
||||
|
||||
caretPosition = Discourse.Utilities.caretPosition(me[0])
|
||||
# If we've backspaced past the beginning, cancel unless no key
|
||||
if caretPosition <= completeStart && options.key
|
||||
closeAutocomplete()
|
||||
return false
|
||||
|
||||
# Keyboard codes! So 80's.
|
||||
switch e.which
|
||||
when 13, 39, 9 # enter, tab or right arrow completes
|
||||
return true unless autocompleteOptions
|
||||
if selectedOption >= 0 and userToComplete = autocompleteOptions[selectedOption]
|
||||
completeTerm(userToComplete)
|
||||
else
|
||||
# We're cancelling it, really.
|
||||
return true
|
||||
|
||||
closeAutocomplete()
|
||||
return false
|
||||
when 38 # up arrow
|
||||
selectedOption = selectedOption - 1
|
||||
selectedOption = 0 if selectedOption < 0
|
||||
markSelected()
|
||||
return false
|
||||
when 40 # down arrow
|
||||
total = autocompleteOptions.length
|
||||
selectedOption = selectedOption + 1
|
||||
selectedOption = total - 1 if selectedOption >= total
|
||||
selectedOption = 0 if selectedOption < 0
|
||||
markSelected()
|
||||
return false
|
||||
else
|
||||
|
||||
# otherwise they're typing - let's search for it!
|
||||
completeEnd = caretPosition
|
||||
caretPosition-- if (e.which == 8)
|
||||
|
||||
if caretPosition < 0
|
||||
closeAutocomplete()
|
||||
if isInput
|
||||
i = wrap.find('a:last')
|
||||
i.click() if i
|
||||
|
||||
return false
|
||||
|
||||
term = me.val().substring(completeStart+(if options.key then 1 else 0), caretPosition)
|
||||
if (e.which > 48 && e.which < 90)
|
||||
term += String.fromCharCode(e.which)
|
||||
else
|
||||
term += "," unless e.which == 8 # backspace
|
||||
options.dataSource term, updateAutoComplete
|
||||
return true
|
||||
|
||||
|
||||
)(jQuery)
|
||||
130
app/assets/javascripts/discourse/components/bbcode.js.coffee
Normal file
130
app/assets/javascripts/discourse/components/bbcode.js.coffee
Normal file
@@ -0,0 +1,130 @@
|
||||
Discourse.BBCode =
|
||||
|
||||
QUOTE_REGEXP: /\[quote=([^\]]*)\]([\s\S]*?)\[\/quote\]/im
|
||||
|
||||
# Define our replacers
|
||||
replacers:
|
||||
|
||||
base:
|
||||
withoutArgs:
|
||||
"ol": (_, content) -> "<ol>#{content}</ol>"
|
||||
"li": (_, content) -> "<li>#{content}</li>"
|
||||
"ul": (_, content) -> "<ul>#{content}</ul>"
|
||||
"code": (_, content) -> "<pre>#{content}</pre>"
|
||||
"url": (_, url) -> "<a href=\"#{url}\">#{url}</a>"
|
||||
"email": (_, address) -> "<a href=\"mailto:#{address}\">#{address}</a>"
|
||||
"img": (_, src) -> "<img src=\"#{src}\">"
|
||||
withArgs:
|
||||
"url": (_, href, title) -> "<a href=\"#{href}\">#{title}</a>"
|
||||
"email": (_, address, title) -> "<a href=\"mailto:#{address}\">#{title}</a>"
|
||||
"color": (_, color, content) ->
|
||||
return content unless /^(\#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?)|(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)$/.test(color)
|
||||
"<span style=\"color: #{color}\">#{content}</span>"
|
||||
|
||||
# For HTML emails
|
||||
email:
|
||||
withoutArgs:
|
||||
"b": (_, content) -> "<b>#{content}</b>"
|
||||
"i": (_, content) -> "<i>#{content}</i>"
|
||||
"u": (_, content) -> "<u>#{content}</u>"
|
||||
"s": (_, content) -> "<s>#{content}</s>"
|
||||
"spoiler": (_, content) -> "<span style='background-color: #000'>#{content}</span>"
|
||||
|
||||
withArgs:
|
||||
"size": (_, size, content) -> "<span style=\"font-size: #{size}px\">#{content}</span>"
|
||||
|
||||
# For sane environments that support CSS
|
||||
default:
|
||||
withoutArgs:
|
||||
"b": (_, content) -> "<span class='bbcode-b'>#{content}</span>"
|
||||
"i": (_, content) -> "<span class='bbcode-i'>#{content}</span>"
|
||||
"u": (_, content) -> "<span class='bbcode-u'>#{content}</span>"
|
||||
"s": (_, content) -> "<span class='bbcode-s'>#{content}</span>"
|
||||
"spoiler": (_, content) -> "<span class=\"spoiler\">#{content}</span>"
|
||||
|
||||
withArgs:
|
||||
"size": (_, size, content) -> "<span class=\"bbcode-size-#{size}\">#{content}</span>"
|
||||
|
||||
# Apply a particular set of replacers
|
||||
apply: (text, environment) ->
|
||||
replacer = Discourse.BBCode.parsedReplacers()[environment]
|
||||
replacer.forEach (r) -> text = text.replace r.regexp, r.fn
|
||||
text
|
||||
|
||||
parsedReplacers: ->
|
||||
return @parsed if @parsed
|
||||
result = {}
|
||||
|
||||
Object.keys Discourse.BBCode.replacers, (name, rules) ->
|
||||
parsed = result[name] = []
|
||||
|
||||
Object.keys Object.merge(Discourse.BBCode.replacers.base.withoutArgs, rules.withoutArgs), (tag, val) ->
|
||||
parsed.push(regexp: RegExp("\\[#{tag}\\]([\\s\\S]*?)\\[\\/#{tag}\\]", "igm"), fn: val)
|
||||
|
||||
Object.keys Object.merge(Discourse.BBCode.replacers.base.withArgs, rules.withArgs), (tag, val) ->
|
||||
parsed.push(regexp: RegExp("\\[#{tag}=?(.+?)\\\]([\\s\\S]*?)\\[\\/#{tag}\\]", "igm"), fn: val)
|
||||
|
||||
@parsed = result
|
||||
@parsed
|
||||
|
||||
buildQuoteBBCode: (post, contents="") ->
|
||||
sansQuotes = contents.replace(@QUOTE_REGEXP, '').trim()
|
||||
return "" if sansQuotes.length == 0
|
||||
|
||||
# Strip the HTML from cooked
|
||||
tmp = document.createElement('div')
|
||||
tmp.innerHTML = post.get('cooked')
|
||||
stripped = tmp.textContent||tmp.innerText
|
||||
|
||||
# Let's remove any non alphanumeric characters as a kind of hash. Yes it's
|
||||
# not accurate but it should work almost every time we need it to. It would be unlikely
|
||||
# that the user would quote another post that matches in exactly this way.
|
||||
stripped_hashed = stripped.replace(/[^a-zA-Z0-9]/g, '')
|
||||
contents_hashed = contents.replace(/[^a-zA-Z0-9]/g, '')
|
||||
|
||||
result = "[quote=\"#{post.get('username')}, post:#{post.get('post_number')}, topic:#{post.get('topic_id')}"
|
||||
|
||||
# If the quote is the full message, attribute it as such
|
||||
if stripped_hashed == contents_hashed
|
||||
result += ", full:true"
|
||||
|
||||
result += "\"]#{sansQuotes}[/quote]\n\n"
|
||||
|
||||
formatQuote: (text, opts) ->
|
||||
|
||||
# Replace quotes with appropriate markup
|
||||
while matches = @QUOTE_REGEXP.exec(text)
|
||||
paramsString = matches[1]
|
||||
paramsString = paramsString.replace(/\"/g, '')
|
||||
paramsSplit = paramsString.split(/\, */)
|
||||
|
||||
params=[]
|
||||
paramsSplit.each (p, i) ->
|
||||
if i > 0
|
||||
assignment = p.split(':')
|
||||
if assignment[0] and assignment[1]
|
||||
params.push(key: assignment[0], value: assignment[1].trim())
|
||||
|
||||
username = paramsSplit[0]
|
||||
|
||||
# Arguments for formatting
|
||||
args =
|
||||
username: username
|
||||
params: params
|
||||
quote: matches[2].trim()
|
||||
avatarImg: opts.lookupAvatar(username) if opts.lookupAvatar
|
||||
|
||||
templateName = 'quote'
|
||||
templateName = "quote_#{opts.environment}" if opts?.environment
|
||||
|
||||
text = text.replace(matches[0], "</p>" + HANDLEBARS_TEMPLATES[templateName](args) + "<p>")
|
||||
|
||||
text
|
||||
|
||||
format: (text, opts) ->
|
||||
text = Discourse.BBCode.apply(text, opts?.environment || 'default')
|
||||
|
||||
# Add quotes
|
||||
text = Discourse.BBCode.formatQuote(text, opts)
|
||||
|
||||
text
|
||||
@@ -0,0 +1,101 @@
|
||||
# caret position in textarea ... very hacky ... sorry
|
||||
(($) ->
|
||||
|
||||
# http://stackoverflow.com/questions/263743/how-to-get-caret-position-in-textarea
|
||||
getCaret = (el) ->
|
||||
if el.selectionStart
|
||||
return el.selectionStart
|
||||
else if document.selection
|
||||
el.focus()
|
||||
r = document.selection.createRange()
|
||||
return 0 if r is null
|
||||
re = el.createTextRange()
|
||||
rc = re.duplicate()
|
||||
re.moveToBookmark r.getBookmark()
|
||||
rc.setEndPoint "EndToStart", re
|
||||
return rc.text.length
|
||||
0
|
||||
|
||||
clone = null
|
||||
$.fn.caretPosition = (options) ->
|
||||
|
||||
clone.remove() if clone
|
||||
span = $("#pos span")
|
||||
textarea = $(this)
|
||||
getStyles = (el, prop) ->
|
||||
if el.currentStyle
|
||||
el.currentStyle
|
||||
else
|
||||
document.defaultView.getComputedStyle el, ""
|
||||
|
||||
styles = getStyles(textarea[0])
|
||||
clone = $("<div><p></p></div>").appendTo("body")
|
||||
p = clone.find("p")
|
||||
clone.width textarea.width()
|
||||
clone.height textarea.height()
|
||||
|
||||
important = (prop) ->
|
||||
styles.getPropertyValue(prop)
|
||||
|
||||
clone.css
|
||||
border: "1px solid black"
|
||||
padding: important("padding")
|
||||
resize: important("resize")
|
||||
"max-height": textarea.height() + "px"
|
||||
"overflow-y": "auto"
|
||||
"word-wrap": "break-word"
|
||||
position: "absolute"
|
||||
left: "-7000px"
|
||||
|
||||
p.css
|
||||
margin: 0
|
||||
padding: 0
|
||||
"word-wrap": "break-word"
|
||||
"letter-spacing": important("letter-spacing")
|
||||
"font-family": important("font-family")
|
||||
"font-size": important("font-size")
|
||||
"line-height": important("line-height")
|
||||
|
||||
before = undefined
|
||||
after = undefined
|
||||
pos = if options && options.pos then options.pos else getCaret(textarea[0])
|
||||
val = textarea.val().replace("\r", "")
|
||||
if (options && options.key)
|
||||
val = val.substring(0,pos) + options.key + val.substring(pos)
|
||||
|
||||
before = pos - 1
|
||||
after = pos
|
||||
insertSpaceAfterBefore = false
|
||||
|
||||
# if before and after are \n insert a space
|
||||
insertSpaceAfterBefore = true if val[before] is "\n" and val[after] is "\n"
|
||||
guard = (v) ->
|
||||
buf = v.replace(/</g,"<")
|
||||
buf = buf.replace(/>/g,">")
|
||||
buf = buf.replace(/[ ]/g, "​ ​")
|
||||
buf.replace(/\n/g,"<br />")
|
||||
|
||||
|
||||
makeCursor = (pos, klass, color) ->
|
||||
l = val.substring(pos, pos + 1)
|
||||
return "<br>" if l is "\n"
|
||||
"<span class='" + klass + "' style='background-color:" + color + "; margin:0; padding: 0'>" + guard(l) + "</span>"
|
||||
|
||||
html = ""
|
||||
if before >= 0
|
||||
html += guard(val.substring(0, pos - 1)) + makeCursor(before, "before", "#d0ffff")
|
||||
html += makeCursor(0, "post-before", "#d0ffff") if insertSpaceAfterBefore
|
||||
if after >= 0
|
||||
html += makeCursor(after, "after", "#ffd0ff")
|
||||
html += guard(val.substring(after + 1)) if after - 1 < val.length
|
||||
p.html html
|
||||
clone.scrollTop textarea.scrollTop()
|
||||
letter = p.find("span:first")
|
||||
pos = letter.offset()
|
||||
pos.left = pos.left + letter.width() if letter.hasClass("before")
|
||||
pPos = p.offset()
|
||||
#clone.hide().remove()
|
||||
|
||||
left: pos.left - pPos.left
|
||||
top: (pos.top - pPos.top) - clone.scrollTop()
|
||||
) jQuery
|
||||
@@ -0,0 +1,64 @@
|
||||
# We use this object to keep track of click counts.
|
||||
window.Discourse.ClickTrack =
|
||||
|
||||
# Pass the event of the click here and we'll do the magic!
|
||||
trackClick: (e) ->
|
||||
|
||||
$a = $(e.currentTarget)
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
# We don't track clicks on quote back buttons
|
||||
return true if $a.hasClass('back') or $a.hasClass('quote-other-topic')
|
||||
|
||||
# Remove the href, put it as a data attribute
|
||||
unless $a.data('href')
|
||||
$a.addClass('no-href')
|
||||
$a.data('href', $a.attr('href'))
|
||||
$a.attr('href', null)
|
||||
|
||||
# Don't route to this URL
|
||||
$a.data('auto-route', true)
|
||||
|
||||
href = $a.data('href')
|
||||
$article = $a.closest('article')
|
||||
postId = $article.data('post-id')
|
||||
topicId = $('#topic').data('topic-id')
|
||||
userId = $a.data('user-id')
|
||||
userId = $article.data('user-id') unless userId
|
||||
|
||||
ownLink = userId and (userId is Discourse.get('currentUser.id'))
|
||||
|
||||
# Build a Redirect URL
|
||||
trackingUrl = "/clicks/track?url=" + encodeURIComponent(href)
|
||||
trackingUrl += "&post_id=" + encodeURI(postId) if postId and (not $a.data('ignore-post-id'))
|
||||
trackingUrl += "&topic_id=" + encodeURI(topicId) if topicId
|
||||
|
||||
# Update badge clicks unless it's our own
|
||||
unless ownLink
|
||||
$badge = $('span.badge', $a)
|
||||
if $badge.length == 1
|
||||
count = parseInt($badge.html())
|
||||
$badge.html(count + 1)
|
||||
|
||||
# If they right clicked, change the destination href
|
||||
if e.which is 3
|
||||
destination = if Discourse.SiteSettings.track_external_right_clicks then trackingUrl else href
|
||||
$a.attr('href', destination)
|
||||
return true
|
||||
|
||||
# if they want to open in a new tab, do an AJAX request
|
||||
if (e.metaKey || e.ctrlKey || e.which is 2)
|
||||
$.get "/clicks/track", url: href, post_id: postId, topic_id: topicId, redirect: false
|
||||
window.open(href, '_blank')
|
||||
return false
|
||||
|
||||
# If we're on the same site, use the router and track via AJAX
|
||||
if href.indexOf(window.location.origin) == 0
|
||||
$.get "/clicks/track", url: href, post_id: postId, topic_id: topicId, redirect: false
|
||||
Discourse.routeTo(href)
|
||||
return false
|
||||
|
||||
# Otherwise, use a custom URL with a redirect
|
||||
window.location = trackingUrl
|
||||
false
|
||||
@@ -0,0 +1,20 @@
|
||||
window.Discourse.debounce = (func, wait, trickle) ->
|
||||
timeout = null
|
||||
return ->
|
||||
context = @
|
||||
args = arguments
|
||||
later = ->
|
||||
timeout = null
|
||||
func.apply(context, args)
|
||||
|
||||
if timeout != null && trickle
|
||||
# already queued, let it through
|
||||
return
|
||||
|
||||
if typeof wait == "function"
|
||||
currentWait = wait()
|
||||
else
|
||||
currentWait = wait
|
||||
|
||||
clearTimeout(timeout) if timeout
|
||||
timeout = setTimeout(later, currentWait)
|
||||
@@ -0,0 +1,7 @@
|
||||
Discourse.TextField = Ember.TextField.extend
|
||||
|
||||
attributeBindings: ['autocorrect', 'autocapitalize']
|
||||
|
||||
placeholder: (->
|
||||
Em.String.i18n(@get('placeholderKey'))
|
||||
).property('placeholderKey')
|
||||
@@ -0,0 +1,61 @@
|
||||
#based off text area resizer by Ryan O'Dell : http://plugins.jquery.com/misc/textarea.js
|
||||
(($) ->
|
||||
|
||||
div = undefined
|
||||
originalPos = undefined
|
||||
originalDivHeight = undefined
|
||||
lastMousePos = 0
|
||||
min = 230
|
||||
grip = undefined
|
||||
wrappedEndDrag = undefined
|
||||
wrappedPerformDrag = undefined
|
||||
|
||||
startDrag = (e,opts) ->
|
||||
div = $(e.data.el)
|
||||
div.addClass('clear-transitions')
|
||||
div.blur()
|
||||
lastMousePos = mousePosition(e).y
|
||||
originalPos = lastMousePos
|
||||
originalDivHeight = div.height()
|
||||
wrappedPerformDrag = ( ->
|
||||
(e) -> performDrag(e,opts)
|
||||
)()
|
||||
wrappedEndDrag = ( ->
|
||||
(e) -> endDrag(e,opts)
|
||||
)()
|
||||
$(document).mousemove(wrappedPerformDrag).mouseup wrappedEndDrag
|
||||
false
|
||||
performDrag = (e,opts) ->
|
||||
thisMousePos = mousePosition(e).y
|
||||
size = originalDivHeight + (originalPos - thisMousePos)
|
||||
lastMousePos = thisMousePos
|
||||
size = Math.max(min, size)
|
||||
div.height size + "px"
|
||||
endDrag e,opts if size < min
|
||||
false
|
||||
endDrag = (e,opts) ->
|
||||
$(document).unbind("mousemove", wrappedPerformDrag).unbind "mouseup", wrappedEndDrag
|
||||
div.removeClass('clear-transitions')
|
||||
div.focus()
|
||||
opts.resize() if opts.resize
|
||||
div = null
|
||||
mousePosition = (e) ->
|
||||
x: e.clientX + document.documentElement.scrollLeft
|
||||
y: e.clientY + document.documentElement.scrollTop
|
||||
|
||||
$.fn.DivResizer = (opts) ->
|
||||
@each ->
|
||||
div = $(this)
|
||||
return if (div.hasClass("processed"))
|
||||
|
||||
div.addClass("processed")
|
||||
staticOffset = null
|
||||
|
||||
start = ->
|
||||
(e) -> startDrag(e,opts)
|
||||
|
||||
grippie = div.prepend("<div class='grippie'></div>").find('.grippie').bind("mousedown",
|
||||
el: this
|
||||
, start())
|
||||
) jQuery
|
||||
|
||||
64
app/assets/javascripts/discourse/components/eyeline.coffee
Normal file
64
app/assets/javascripts/discourse/components/eyeline.coffee
Normal file
@@ -0,0 +1,64 @@
|
||||
#
|
||||
# Track visible elements on the screen
|
||||
#
|
||||
# You can register for triggers on:
|
||||
# focusChanged: -> the top element we're focusing on
|
||||
# seenElement: -> if we've seen the element
|
||||
#
|
||||
class Discourse.Eyeline
|
||||
|
||||
constructor: (@selector) ->
|
||||
|
||||
# Call this whenever we want to consider what is currently being seen by the browser
|
||||
update: ->
|
||||
docViewTop = $(window).scrollTop()
|
||||
windowHeight = $(window).height()
|
||||
docViewBottom = docViewTop + windowHeight
|
||||
documentHeight = $(document).height()
|
||||
|
||||
$elements = $(@selector)
|
||||
|
||||
atBottom = false
|
||||
if bottomOffset = $elements.last().offset()
|
||||
atBottom = (bottomOffset.top <= docViewBottom) and (bottomOffset.top >= docViewTop)
|
||||
|
||||
# Whether we've seen any elements in this search
|
||||
foundElement = false
|
||||
|
||||
$results = $(@selector)
|
||||
$results.each (i, elem) =>
|
||||
$elem = $(elem)
|
||||
|
||||
elemTop = $elem.offset().top
|
||||
elemBottom = elemTop + $elem.height()
|
||||
|
||||
markSeen = false
|
||||
|
||||
# It's seen if...
|
||||
# ...the element is vertically within the top and botom
|
||||
markSeen = true if ((elemTop <= docViewBottom) and (elemTop >= docViewTop))
|
||||
# ...the element top is above the top and the bottom is below the bottom (large elements)
|
||||
markSeen = true if ((elemTop <= docViewTop) and (elemBottom >= docViewBottom))
|
||||
# ...we're at the bottom and the bottom of the element is visible (large bottom elements)
|
||||
markSeen = true if atBottom and (elemBottom >= docViewTop)
|
||||
|
||||
return true unless markSeen
|
||||
|
||||
# If you hit the bottom we mark all the elements as seen. Otherwise, just the first one
|
||||
unless atBottom
|
||||
@trigger('saw', detail: $elem)
|
||||
@trigger('sawTop', detail: $elem) if i == 0
|
||||
return false
|
||||
|
||||
@trigger('sawTop', detail: $elem) if i == 0
|
||||
@trigger('sawBottom', detail: $elem) if i == ($results.length - 1)
|
||||
|
||||
# Call this when we know aren't loading any more elements. Mark the rest
|
||||
# as seen
|
||||
flushRest: ->
|
||||
$(@selector).each (i, elem) =>
|
||||
$elem = $(elem)
|
||||
@trigger('saw', detail: $elem)
|
||||
|
||||
|
||||
RSVP.EventTarget.mixin(Discourse.Eyeline.prototype)
|
||||
@@ -0,0 +1,33 @@
|
||||
# key value store
|
||||
#
|
||||
|
||||
window.Discourse.KeyValueStore = (->
|
||||
initialized = false
|
||||
context = ""
|
||||
|
||||
init: (ctx,messageBus) ->
|
||||
initialized = true
|
||||
context = ctx
|
||||
|
||||
abandonLocal: ->
|
||||
return unless localStorage && initialized
|
||||
i=localStorage.length-1
|
||||
while i >= 0
|
||||
k = localStorage.key(i)
|
||||
localStorage.removeItem(k) if k.substring(0, context.length) == context
|
||||
i--
|
||||
return true
|
||||
|
||||
remove: (key)->
|
||||
localStorage.removeItem(context + key)
|
||||
|
||||
set: (opts)->
|
||||
return false unless localStorage && initialized
|
||||
localStorage[context + opts["key"]] = opts["value"]
|
||||
|
||||
|
||||
get: (key)->
|
||||
return null unless localStorage
|
||||
localStorage[context + key]
|
||||
)()
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
window.Discourse.MessageBus = ( ->
|
||||
|
||||
# http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript
|
||||
uniqueId = -> 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace /[xy]/g, (c)->
|
||||
r = Math.random()*16 | 0
|
||||
v = if c == 'x' then r else (r&0x3|0x8)
|
||||
v.toString(16)
|
||||
|
||||
clientId = uniqueId()
|
||||
|
||||
responseCallbacks = {}
|
||||
callbacks = []
|
||||
queue = []
|
||||
interval = null
|
||||
|
||||
failCount = 0
|
||||
|
||||
isHidden = ->
|
||||
if document.hidden != undefined
|
||||
document.hidden
|
||||
else if document.webkitHidden != undefined
|
||||
document.webkitHidden
|
||||
else if document.msHidden != undefined
|
||||
document.msHidden
|
||||
else if document.mozHidden != undefined
|
||||
document.mozHidden
|
||||
else
|
||||
# fallback to problamatic window.focus
|
||||
!Discourse.get('hasFocus')
|
||||
|
||||
enableLongPolling: true
|
||||
callbackInterval: 60000
|
||||
maxPollInterval: (3 * 60 * 1000)
|
||||
callbacks: callbacks
|
||||
clientId: clientId
|
||||
|
||||
#TODO
|
||||
stop:
|
||||
false
|
||||
|
||||
# Start polling
|
||||
start: (opts={})->
|
||||
|
||||
poll = =>
|
||||
if callbacks.length == 0
|
||||
setTimeout poll, 500
|
||||
return
|
||||
|
||||
data = {}
|
||||
callbacks.each (c)->
|
||||
data[c.channel] = if c.last_id == undefined then -1 else c.last_id
|
||||
|
||||
gotData = false
|
||||
|
||||
@longPoll = $.ajax "/message-bus/#{clientId}/poll?#{if isHidden() || !@enableLongPolling then "dlp=t" else ""}",
|
||||
data: data
|
||||
cache: false
|
||||
dataType: 'json'
|
||||
type: 'POST'
|
||||
headers:
|
||||
'X-SILENCE-LOGGER': 'true'
|
||||
success: (messages) =>
|
||||
failCount = 0
|
||||
messages.each (message) =>
|
||||
gotData = true
|
||||
callbacks.each (callback) ->
|
||||
if callback.channel == message.channel
|
||||
callback.last_id = message.message_id
|
||||
callback.func(message.data)
|
||||
if message["channel"] == "/__status"
|
||||
callback.last_id = message.data[callback.channel] if message.data[callback.channel] != undefined
|
||||
return
|
||||
error:
|
||||
failCount += 1
|
||||
complete: =>
|
||||
if gotData
|
||||
setTimeout poll, 100
|
||||
else
|
||||
interval = @callbackInterval
|
||||
if failCount > 2
|
||||
interval = interval * failCount
|
||||
else if isHidden()
|
||||
# slowning down stuff a lot when hidden
|
||||
# we will need to add a lot of fine tuning here
|
||||
interval = interval * 4
|
||||
|
||||
if interval > @maxPollInterval
|
||||
interval = @maxPollInterval
|
||||
|
||||
setTimeout poll, interval
|
||||
@longPoll = null
|
||||
return
|
||||
|
||||
poll()
|
||||
return
|
||||
|
||||
# Subscribe to a channel
|
||||
subscribe: (channel,func,lastId)->
|
||||
callbacks.push {channel:channel, func:func, last_id: lastId}
|
||||
@longPoll.abort() if @longPoll
|
||||
|
||||
# Unsubscribe from a channel
|
||||
unsubscribe: (channel) ->
|
||||
# TODO proper globbing
|
||||
if channel.endsWith("*")
|
||||
channel = channel.substr(0, channel.length-1)
|
||||
glob = true
|
||||
callbacks = callbacks.filter (callback) ->
|
||||
if glob
|
||||
callback.channel.substr(0, channel.length) != channel
|
||||
else
|
||||
callback.channel != channel
|
||||
@longPoll.abort() if @longPoll
|
||||
)()
|
||||
@@ -0,0 +1,24 @@
|
||||
window.Discourse.PagedownEditor = Ember.ContainerView.extend
|
||||
elementId: 'pagedown-editor'
|
||||
|
||||
init: ->
|
||||
|
||||
@_super()
|
||||
|
||||
# Add a button bar
|
||||
@pushObject Em.View.create(elementId: 'wmd-button-bar')
|
||||
@pushObject Em.TextArea.create(valueBinding: 'parentView.value', elementId: 'wmd-input')
|
||||
@pushObject Em.View.createWithMixins Discourse.Presence,
|
||||
elementId: 'wmd-preview',
|
||||
classNameBindings: [':preview', 'hidden']
|
||||
|
||||
hidden: (->
|
||||
@blank('parentView.value')
|
||||
).property('parentView.value')
|
||||
|
||||
|
||||
didInsertElement: ->
|
||||
$wmdInput = $('#wmd-input')
|
||||
$wmdInput.data('init', true)
|
||||
@editor = new Markdown.Editor(Discourse.Utilities.markdownConverter())
|
||||
@editor.run()
|
||||
122
app/assets/javascripts/discourse/components/probes.js
Normal file
122
app/assets/javascripts/discourse/components/probes.js
Normal file
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* JavaScript probing framework by Sam Saffron
|
||||
* MIT license
|
||||
*
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
|
||||
someFunction = window.probes.measure(someFunction, {
|
||||
name: "somename" // or function(args) { return "name"; },
|
||||
before: function(data, owner, args) {
|
||||
// if owner is true, we are not in a recursive function call.
|
||||
//
|
||||
// data contains the bucker of data already measuer
|
||||
// data.count >= 0
|
||||
// data.time is the total time measured till now
|
||||
//
|
||||
// arguments contains the original arguments sent to the function
|
||||
},
|
||||
after: function(data, owner, args) {
|
||||
// same format as before
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// minimal
|
||||
someFunction = window.probes.measure(someFunction, "someFunction");
|
||||
|
||||
*
|
||||
*
|
||||
* */
|
||||
(function(){
|
||||
var measure, clear;
|
||||
|
||||
clear = function() {
|
||||
window.probes = {
|
||||
clear: clear,
|
||||
measure: measure
|
||||
};
|
||||
};
|
||||
|
||||
measure = function(fn,options) {
|
||||
// start is outside so we measure time around recursive calls properly
|
||||
var start = null, nameParam, before, after;
|
||||
if (!options) {
|
||||
options = {};
|
||||
}
|
||||
|
||||
if (typeof options === "string") {
|
||||
nameParam = options;
|
||||
}
|
||||
else
|
||||
{
|
||||
nameParam = options["name"];
|
||||
|
||||
if (nameParam === "measure" || nameParam == "clear") {
|
||||
throw Error("can not be called measure or clear");
|
||||
}
|
||||
|
||||
if (!nameParam)
|
||||
{
|
||||
throw Error("you must specify the name option measure(fn, {name: 'some name'})");
|
||||
}
|
||||
|
||||
before = options["before"];
|
||||
after = options["after"];
|
||||
}
|
||||
|
||||
var now = (function(){
|
||||
var perf = window.performance || {};
|
||||
var time = perf.now || perf.mozNow || perf.webkitNow || perf.msNow || perf.oNow;
|
||||
return time ? time.bind(perf) : function() { return new Date().getTime(); };
|
||||
})();
|
||||
|
||||
return function() {
|
||||
var name = nameParam;
|
||||
if (typeof name == "function"){
|
||||
name = nameParam(arguments);
|
||||
}
|
||||
var p = window.probes[name];
|
||||
var owner = start === null;
|
||||
|
||||
if (before) {
|
||||
// would like to avoid try catch so its optimised properly by chrome
|
||||
before(p, owner, arguments);
|
||||
}
|
||||
|
||||
if (p === undefined) {
|
||||
window.probes[name] = {count: 0, time: 0, currentTime: 0};
|
||||
p = window.probes[name];
|
||||
}
|
||||
|
||||
var callStart;
|
||||
if (owner) {
|
||||
start = now();
|
||||
callStart = start;
|
||||
}
|
||||
else if(after)
|
||||
{
|
||||
callStart = now();
|
||||
}
|
||||
|
||||
var r = fn.apply(this, arguments);
|
||||
if (owner && start) {
|
||||
p.time += now() - start;
|
||||
start = null;
|
||||
}
|
||||
p.count += 1;
|
||||
|
||||
if (after) {
|
||||
p.currentTime = now() - callStart;
|
||||
// would like to avoid try catch so its optimised properly by chrome
|
||||
after(p, owner, arguments);
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
}
|
||||
|
||||
clear();
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,128 @@
|
||||
# We use this class to track how long posts in a topic are on the screen.
|
||||
# This could be a potentially awesome metric to keep track of.
|
||||
window.Discourse.ScreenTrack = Ember.Object.extend
|
||||
|
||||
# Don't send events if we haven't scrolled in a long time
|
||||
PAUSE_UNLESS_SCROLLED: 1000*60*3
|
||||
|
||||
# After 6 minutes stop tracking read position on post
|
||||
MAX_TRACKING_TIME: 1000*60*6
|
||||
|
||||
totalTimings: {}
|
||||
|
||||
# Elements to track
|
||||
timings: {}
|
||||
topicTime: 0
|
||||
|
||||
cancelled: false
|
||||
|
||||
track: (elementId, postNumber) ->
|
||||
@timings["##{elementId}"] =
|
||||
time: 0
|
||||
postNumber: postNumber
|
||||
|
||||
guessedSeen: (postNumber) ->
|
||||
@highestSeen = postNumber if postNumber > (@highestSeen || 0)
|
||||
|
||||
# Reset our timers
|
||||
reset: ->
|
||||
@lastTick = new Date().getTime()
|
||||
@lastFlush = 0
|
||||
@cancelled = false
|
||||
|
||||
# Start tracking
|
||||
start: ->
|
||||
@reset()
|
||||
@lastScrolled = new Date().getTime()
|
||||
@interval = setInterval =>
|
||||
@tick()
|
||||
, 1000
|
||||
|
||||
# Cancel and eject any tracking we have buffered
|
||||
cancel: ->
|
||||
@cancelled = true
|
||||
@timings = {}
|
||||
@topicTime = 0
|
||||
clearInterval(@interval)
|
||||
@interval = null
|
||||
|
||||
# Stop tracking and flush buffered read records
|
||||
stop: ->
|
||||
clearInterval(@interval)
|
||||
@interval = null
|
||||
@flush()
|
||||
|
||||
scrolled: ->
|
||||
@lastScrolled = new Date().getTime()
|
||||
|
||||
flush: ->
|
||||
|
||||
return if @cancelled
|
||||
|
||||
# We don't log anything unless we're logged in
|
||||
return unless Discourse.get('currentUser')
|
||||
|
||||
newTimings = {}
|
||||
Object.values @timings, (timing) =>
|
||||
@totalTimings[timing.postNumber] ||= 0
|
||||
if timing.time > 0 and @totalTimings[timing.postNumber] < @MAX_TRACKING_TIME
|
||||
@totalTimings[timing.postNumber] += timing.time
|
||||
newTimings[timing.postNumber] = timing.time
|
||||
timing.time = 0
|
||||
|
||||
topicId = @get('topic_id')
|
||||
|
||||
highestSeenByTopic = Discourse.get('highestSeenByTopic')
|
||||
if (highestSeenByTopic[topicId] || 0) < @highestSeen
|
||||
highestSeenByTopic[topicId] = @highestSeen
|
||||
|
||||
|
||||
unless Object.isEmpty(newTimings)
|
||||
$.ajax '/topics/timings'
|
||||
data:
|
||||
timings: newTimings
|
||||
topic_time: @topicTime
|
||||
highest_seen: @highestSeen
|
||||
topic_id: topicId
|
||||
cache: false
|
||||
type: 'POST'
|
||||
headers:
|
||||
'X-SILENCE-LOGGER': 'true'
|
||||
@topicTime = 0
|
||||
|
||||
@lastFlush = 0
|
||||
|
||||
tick: ->
|
||||
|
||||
# If the user hasn't scrolled the browser in a long time, stop tracking time read
|
||||
sinceScrolled = new Date().getTime() - @lastScrolled
|
||||
if sinceScrolled > @PAUSE_UNLESS_SCROLLED
|
||||
@reset()
|
||||
return
|
||||
|
||||
diff = new Date().getTime() - @lastTick
|
||||
@lastFlush += diff
|
||||
@lastTick = new Date().getTime()
|
||||
|
||||
@flush() if @lastFlush > (Discourse.SiteSettings.flush_timings_secs * 1000)
|
||||
|
||||
# Don't track timings if we're not in focus
|
||||
return unless Discourse.get("hasFocus")
|
||||
|
||||
@topicTime += diff
|
||||
|
||||
docViewTop = $(window).scrollTop() + $('header').height()
|
||||
docViewBottom = docViewTop + $(window).height()
|
||||
|
||||
Object.keys @timings, (id) =>
|
||||
$element = $(id)
|
||||
|
||||
if ($element.length == 1)
|
||||
elemTop = $element.offset().top
|
||||
elemBottom = elemTop + $element.height()
|
||||
|
||||
# If part of the element is on the screen, increase the counter
|
||||
if (docViewTop <= elemTop <= docViewBottom) or (docViewTop <= elemBottom <= docViewBottom)
|
||||
timing = @timings[id]
|
||||
timing.time = timing.time + diff
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Helper object for syntax highlighting. Uses highlight.js which is loaded
|
||||
# on demand.
|
||||
window.Discourse.SyntaxHighlighting =
|
||||
|
||||
apply: ($elem) ->
|
||||
$('pre code[class]', $elem).each (i, e) =>
|
||||
$LAB.script("/javascripts/highlight-handlebars.pack.js").wait ->
|
||||
hljs.highlightBlock(e)
|
||||
@@ -0,0 +1,25 @@
|
||||
# CSS transitions are a PITA, often we need to queue some js after a transition, this helper ensures
|
||||
# it happens after the transition
|
||||
#
|
||||
|
||||
# SO: http://stackoverflow.com/questions/9943435/css3-animation-end-techniques
|
||||
dummy = document.createElement("div")
|
||||
eventNameHash =
|
||||
webkit: "webkitTransitionEnd"
|
||||
Moz: "transitionend"
|
||||
O: "oTransitionEnd"
|
||||
ms: "MSTransitionEnd"
|
||||
|
||||
transitionEnd = (_getTransitionEndEventName = ->
|
||||
retValue = "transitionend"
|
||||
Object.keys(eventNameHash).some (vendor) ->
|
||||
if vendor + "TransitionProperty" of dummy.style
|
||||
retValue = eventNameHash[vendor]
|
||||
true
|
||||
|
||||
retValue
|
||||
)()
|
||||
|
||||
window.Discourse.TransitionHelper =
|
||||
after: (element, callback) ->
|
||||
$(element).on(transitionEnd, callback)
|
||||
@@ -0,0 +1,51 @@
|
||||
cache = {}
|
||||
cacheTopicId = null
|
||||
cacheTime = null
|
||||
|
||||
doSearch = (term,topicId,success)->
|
||||
$.ajax
|
||||
url: '/users/search/users'
|
||||
dataType: 'JSON'
|
||||
data: {term: term, topic_id: topicId}
|
||||
success: (r)->
|
||||
cache[term] = r
|
||||
cacheTime = new Date()
|
||||
success(r)
|
||||
|
||||
debouncedSearch = Discourse.debounce(doSearch, 200)
|
||||
|
||||
window.Discourse.UserSearch =
|
||||
search: (options) ->
|
||||
|
||||
term = options.term || ""
|
||||
callback = options.callback
|
||||
exclude = options.exclude || []
|
||||
topicId = options.topicId
|
||||
limit = options.limit || 5
|
||||
|
||||
throw "missing callback" unless callback
|
||||
|
||||
#TODO site setting for allowed regex in username ?
|
||||
if term.match(/[^a-zA-Z0-9\_\.]/)
|
||||
callback([])
|
||||
return true
|
||||
|
||||
cache = {} if (new Date() - cacheTime) > 30000
|
||||
cache = {} if cacheTopicId != topicId
|
||||
cacheTopicId = topicId
|
||||
|
||||
success = (r)->
|
||||
result = []
|
||||
r.users.each (u)->
|
||||
result.push(u) if exclude.indexOf(u.username) == -1
|
||||
return false if result.length > limit
|
||||
true
|
||||
callback(result)
|
||||
|
||||
if cache[term]
|
||||
success(cache[term])
|
||||
else
|
||||
debouncedSearch(term, topicId, success)
|
||||
true
|
||||
|
||||
|
||||
165
app/assets/javascripts/discourse/components/utilities.coffee
Normal file
165
app/assets/javascripts/discourse/components/utilities.coffee
Normal file
@@ -0,0 +1,165 @@
|
||||
baseUrl = null
|
||||
site = null
|
||||
|
||||
Discourse.Utilities =
|
||||
|
||||
translateSize: (size)->
|
||||
switch size
|
||||
when 'tiny' then size=20
|
||||
when 'small' then size=25
|
||||
when 'medium' then size=32
|
||||
when 'large' then size=45
|
||||
return size
|
||||
|
||||
# Create a badge like category link
|
||||
categoryLink: (category) ->
|
||||
return "" unless category
|
||||
|
||||
slug = Em.get(category, 'slug')
|
||||
color = Em.get(category, 'color')
|
||||
name = Em.get(category, 'name')
|
||||
|
||||
"<a href=\"/category/#{slug}\" class=\"badge-category excerptable\" data-excerpt-size=\"medium\" style=\"background-color: ##{color}\">#{name}</a>"
|
||||
|
||||
avatarUrl: (username, size, template)->
|
||||
return "" unless username
|
||||
size = Discourse.Utilities.translateSize(size)
|
||||
rawSize = (size * (window.devicePixelRatio || 1)).toFixed()
|
||||
|
||||
return template.replace(/\{size\}/g, rawSize) if template
|
||||
|
||||
"/users/#{username.toLowerCase()}/avatar/#{rawSize}?__ws=#{encodeURIComponent(Discourse.BaseUrl || "")}"
|
||||
|
||||
avatarImg: (options)->
|
||||
size = Discourse.Utilities.translateSize(options.size)
|
||||
title = options.title || ""
|
||||
extraClasses = options.extraClasses || ""
|
||||
url = Discourse.Utilities.avatarUrl(options.username, options.size, options.avatarTemplate)
|
||||
"<img width='#{size}' height='#{size}' src='#{url}' class='avatar #{extraClasses || ""}' title='#{Handlebars.Utils.escapeExpression(title || "")}'>"
|
||||
|
||||
postUrl: (slug, topicId, postNumber)->
|
||||
url = "/t/"
|
||||
url += slug + "/" if slug
|
||||
url += topicId
|
||||
url += "/#{postNumber}" if postNumber > 1
|
||||
url
|
||||
|
||||
emailValid: (email)->
|
||||
# see: http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
|
||||
re = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
|
||||
re.test(email)
|
||||
|
||||
selectedText: ->
|
||||
t = ''
|
||||
if window.getSelection
|
||||
t = window.getSelection().toString()
|
||||
else if document.getSelection
|
||||
t = document.getSelection().toString()
|
||||
else if document.selection
|
||||
t = document.selection.createRange().text
|
||||
String(t).trim()
|
||||
|
||||
# Determine the position of the caret in an element
|
||||
caretPosition: (el) ->
|
||||
|
||||
return el.selectionStart if el.selectionStart
|
||||
|
||||
if document.selection
|
||||
el.focus()
|
||||
r = document.selection.createRange()
|
||||
return 0 if r == null
|
||||
|
||||
re = el.createTextRange()
|
||||
rc = re.duplicate()
|
||||
re.moveToBookmark(r.getBookmark())
|
||||
rc.setEndPoint('EndToStart', re)
|
||||
return rc.text.length
|
||||
return 0
|
||||
|
||||
# Set the caret's position
|
||||
setCaretPosition: (ctrl, pos) ->
|
||||
if(ctrl.setSelectionRange)
|
||||
ctrl.focus()
|
||||
ctrl.setSelectionRange(pos,pos)
|
||||
return
|
||||
|
||||
if (ctrl.createTextRange)
|
||||
range = ctrl.createTextRange()
|
||||
range.collapse(true)
|
||||
range.moveEnd('character', pos)
|
||||
range.moveStart('character', pos)
|
||||
range.select()
|
||||
|
||||
markdownConverter: (opts)->
|
||||
converter = new Markdown.Converter()
|
||||
|
||||
mentionLookup = opts.mentionLookup if opts
|
||||
mentionLookup = mentionLookup || Discourse.Mention.lookupCache
|
||||
|
||||
# Before cooking callbacks
|
||||
converter.hooks.chain "preConversion", (text) =>
|
||||
@trigger 'beforeCook', detail: text, opts: opts
|
||||
@textResult || text
|
||||
|
||||
# Support autolinking of www.something.com
|
||||
converter.hooks.chain "preConversion", (text) ->
|
||||
text.replace /(^|[\s\n])(www\.[a-z\.\-\_\(\)\/\?\=\%0-9]+)/gim, (full, _, rest) ->
|
||||
" <a href=\"http://#{rest}\">#{rest}</a>"
|
||||
|
||||
# newline prediction in trivial cases
|
||||
unless Discourse.SiteSettings.traditional_markdown_linebreaks
|
||||
converter.hooks.chain "preConversion", (text) ->
|
||||
result = text.replace /(^[\w\<][^\n]*\n+)/gim, (t) ->
|
||||
return t if t.match /\n{2}/gim
|
||||
t = t.replace "\n"," \n"
|
||||
|
||||
# github style fenced code
|
||||
converter.hooks.chain "preConversion", (text) ->
|
||||
result = text.replace /^`{3}(?:(.*$)\n)?([\s\S]*?)^`{3}/gm, (wholeMatch,m1,m2) ->
|
||||
escaped = Handlebars.Utils.escapeExpression(m2)
|
||||
"<pre><code class='#{m1 || 'lang-auto'}'>#{escaped}</code></pre>"
|
||||
|
||||
converter.hooks.chain "postConversion", (text) ->
|
||||
return "" unless text
|
||||
# don't to mention voodoo in pres
|
||||
text = text.replace /<pre>([\s\S]*@[\s\S]*)<\/pre>/gi, (wholeMatch, inner) ->
|
||||
"<pre>#{inner.replace(/@/g, '@')}</pre>"
|
||||
|
||||
# Add @mentions of names
|
||||
text = text.replace(/([\s\t>,:'|";\]])(@[A-Za-z0-9_-|\.]*[A-Za-z0-9_-|]+)(?=[\s\t<\!:|;',"\?\.])/g, (x,pre,name) ->
|
||||
if mentionLookup(name.substr(1))
|
||||
"#{pre}<a href='/users/#{name.substr(1).toLowerCase()}' class='mention'>#{name}</a>"
|
||||
else
|
||||
"#{pre}<span class='mention'>#{name}</span>")
|
||||
|
||||
# a primitive attempt at oneboxing, this regex gives me much eye sores
|
||||
text = text.replace /(<li>)?((<p>|<br>)[\s\n\r]*)(<a href=["]([^"]+)[^>]*)>([^<]+<\/a>[\s\n\r]*(?=<\/p>|<br>))/gi, ->
|
||||
|
||||
# We don't onebox items in a list
|
||||
return arguments[0] if arguments[1]
|
||||
|
||||
url = arguments[5]
|
||||
onebox = Discourse.Onebox.lookupCache(url) if Discourse && Discourse.Onebox
|
||||
if onebox and !onebox.isBlank()
|
||||
return arguments[2] + onebox
|
||||
else
|
||||
return arguments[2] + arguments[4] + " class=\"onebox\" target=\"_blank\">" + arguments[6]
|
||||
|
||||
converter.hooks.chain "postConversion", (text) =>
|
||||
Discourse.BBCode.format(text, opts)
|
||||
|
||||
converter
|
||||
|
||||
|
||||
# Takes raw input and cooks it to display nicely (mostly markdown)
|
||||
cook: (raw, opts) ->
|
||||
|
||||
# Make sure we've got a string
|
||||
return "" unless raw
|
||||
return "" unless raw.length > 0
|
||||
|
||||
@converter = @markdownConverter(opts)
|
||||
@converter.makeHtml(raw)
|
||||
|
||||
|
||||
RSVP.EventTarget.mixin(Discourse.Utilities)
|
||||
@@ -0,0 +1,6 @@
|
||||
window.Discourse.ApplicationController = Ember.Controller.extend
|
||||
|
||||
needs: ['modal']
|
||||
|
||||
showLogin: ->
|
||||
@get('controllers.modal')?.show(Discourse.LoginView.create())
|
||||
@@ -0,0 +1,173 @@
|
||||
window.Discourse.ComposerController = Ember.Controller.extend Discourse.Presence,
|
||||
|
||||
needs: ['modal', 'topic']
|
||||
|
||||
togglePreview: ->
|
||||
@get('content').togglePreview()
|
||||
|
||||
# Import a quote from the post
|
||||
importQuote: ->
|
||||
@get('content').importQuote()
|
||||
|
||||
appendText: (text) ->
|
||||
c = @get('content')
|
||||
c.appendText(text) if c
|
||||
|
||||
save: ->
|
||||
composer = @get('content')
|
||||
composer.set('disableDrafts', true)
|
||||
composer.save(imageSizes: @get('view').imageSizes())
|
||||
.then (opts) =>
|
||||
opts = opts || {}
|
||||
@close()
|
||||
Discourse.routeTo(opts.post.get('url'))
|
||||
, (error) =>
|
||||
composer.set('disableDrafts', false)
|
||||
bootbox.alert error
|
||||
|
||||
saveDraft: ->
|
||||
model = @get('content')
|
||||
model.saveDraft() if model
|
||||
|
||||
# Open the reply view
|
||||
#
|
||||
# opts:
|
||||
# action - The action we're performing: edit, reply or createTopic
|
||||
# post - The post we're replying to, if present
|
||||
# topic - The topic we're replying to, if present
|
||||
# quote - If we're opening a reply from a quote, the quote we're making
|
||||
#
|
||||
open: (opts={}) ->
|
||||
opts.promise = promise = opts.promise || new RSVP.Promise
|
||||
|
||||
unless opts.draftKey
|
||||
alert("composer was opened without a draft key")
|
||||
throw "composer opened without a proper draft key"
|
||||
|
||||
# ensure we have a view now, without it transitions are going to be messed
|
||||
view = @get('view')
|
||||
unless view
|
||||
view = Discourse.ComposerView.create
|
||||
controller: @
|
||||
view.appendTo($('#main'))
|
||||
@set('view', view)
|
||||
# the next runloop is too soon, need to get the control rendered and then
|
||||
# we need to change stuff, otherwise css animations don't kick in
|
||||
Em.run.next =>
|
||||
Em.run.next =>
|
||||
@open(opts)
|
||||
return promise
|
||||
|
||||
composer = @get('content')
|
||||
|
||||
if composer && opts.draftKey != composer.draftKey && composer.composeState == Discourse.Composer.DRAFT
|
||||
@close()
|
||||
composer = null
|
||||
|
||||
if composer && !opts.tested && composer.wouldLoseChanges()
|
||||
if composer.composeState == Discourse.Composer.DRAFT && composer.draftKey == opts.draftKey && composer.action == opts.action
|
||||
composer.set('composeState', Discourse.Composer.OPEN)
|
||||
promise.resolve()
|
||||
return promise
|
||||
else
|
||||
opts.tested = true
|
||||
@cancel(( => @open(opts) ),( => promise.reject())) unless opts.ignoreIfChanged
|
||||
return promise
|
||||
|
||||
|
||||
# we need a draft sequence, without it drafts are bust
|
||||
if opts.draftSequence == undefined
|
||||
Discourse.Draft.get(opts.draftKey).then (data)=>
|
||||
opts.draftSequence = data.draft_sequence
|
||||
opts.draft = data.draft
|
||||
@open(opts)
|
||||
return promise
|
||||
|
||||
|
||||
if opts.draft
|
||||
composer = Discourse.Composer.loadDraft(opts.draftKey, opts.draftSequence, opts.draft)
|
||||
composer?.set('topic', opts.topic)
|
||||
|
||||
composer = composer || Discourse.Composer.open(opts)
|
||||
|
||||
@set('content', composer)
|
||||
@set('view.content', composer)
|
||||
promise.resolve()
|
||||
return promise
|
||||
|
||||
wouldLoseChanges: ->
|
||||
composer = @get('content')
|
||||
composer && composer.wouldLoseChanges()
|
||||
|
||||
# View a new reply we've made
|
||||
viewNewReply: ->
|
||||
Discourse.routeTo(@get('createdPost.url'))
|
||||
@close()
|
||||
false
|
||||
|
||||
destroyDraft: ->
|
||||
key = @get('content.draftKey')
|
||||
Discourse.Draft.clear(key, @get('content.draftSequence')) if key
|
||||
|
||||
cancel: (success, fail) ->
|
||||
if @get('content.hasMetaData') || ((@get('content.reply') || "") != (@get('content.originalText') || ""))
|
||||
bootbox.confirm Em.String.i18n("post.abandon"), Em.String.i18n("no_value"), Em.String.i18n("yes_value"), (result) =>
|
||||
if result
|
||||
@destroyDraft()
|
||||
@close()
|
||||
success() if typeof success == "function"
|
||||
else
|
||||
fail() if typeof fail == "function"
|
||||
else
|
||||
# it is possible there is some sort of crazy draft with no body ... just give up on it
|
||||
@destroyDraft()
|
||||
@close()
|
||||
success() if typeof success == "function"
|
||||
|
||||
return
|
||||
|
||||
click: ->
|
||||
if @get('content.composeState') == Discourse.Composer.DRAFT
|
||||
@set('content.composeState', Discourse.Composer.OPEN)
|
||||
false
|
||||
|
||||
shrink: ->
|
||||
if @get('content.reply') == @get('content.originalText') then @close() else @collapse()
|
||||
|
||||
collapse: ->
|
||||
@saveDraft()
|
||||
@set('content.composeState', Discourse.Composer.DRAFT)
|
||||
|
||||
close: ->
|
||||
@set('content', null)
|
||||
@set('view.content', null)
|
||||
|
||||
closeIfCollapsed: ->
|
||||
if @get('content.composeState') == Discourse.Composer.DRAFT
|
||||
@close()
|
||||
|
||||
closeAutocomplete: ->
|
||||
$('#wmd-input').autocomplete(cancel: true)
|
||||
|
||||
# Toggle the reply view
|
||||
toggle: ->
|
||||
@closeAutocomplete()
|
||||
|
||||
switch @get('content.composeState')
|
||||
when Discourse.Composer.OPEN
|
||||
if @blank('content.reply') and @blank('content.title') then @close() else @shrink()
|
||||
when Discourse.Composer.DRAFT
|
||||
@set('content.composeState', Discourse.Composer.OPEN)
|
||||
when Discourse.Composer.SAVING
|
||||
@close()
|
||||
|
||||
false
|
||||
|
||||
# ESC key hit
|
||||
hitEsc: ->
|
||||
@shrink() if @get('content.composeState') == @OPEN
|
||||
|
||||
|
||||
showOptions: ->
|
||||
@get('controllers.modal')?.show(Discourse.ArchetypeOptionsModalView.create(archetype: @get('content.archetype'), metaData: @get('content.metaData')))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Discourse.Controller = Ember.Controller.extend(Discourse.Presence)
|
||||
@@ -0,0 +1,7 @@
|
||||
Discourse.HeaderController = Ember.Controller.extend Discourse.Presence,
|
||||
topic: null
|
||||
showExtraInfo: false
|
||||
|
||||
toggleStar: ->
|
||||
@get('topic')?.toggleStar()
|
||||
false
|
||||
@@ -0,0 +1,21 @@
|
||||
Discourse.ListCategoriesController = Ember.ObjectController.extend Discourse.Presence,
|
||||
needs: ['modal']
|
||||
|
||||
categoriesEven: (->
|
||||
return Em.A() if @blank('categories')
|
||||
@get('categories').filter (item, index) -> (index % 2) == 0
|
||||
).property('categories.@each')
|
||||
|
||||
categoriesOdd: (->
|
||||
return Em.A() if @blank('categories')
|
||||
@get('categories').filter (item, index) -> (index % 2) == 1
|
||||
).property('categories.@each')
|
||||
|
||||
editCategory: (category) ->
|
||||
@get('controllers.modal').show(Discourse.EditCategoryView.create(category: category))
|
||||
false
|
||||
|
||||
canEdit: (->
|
||||
u = Discourse.get('currentUser')
|
||||
u && u.admin
|
||||
).property()
|
||||
@@ -0,0 +1,73 @@
|
||||
Discourse.ListController = Ember.Controller.extend Discourse.Presence,
|
||||
currentUserBinding: 'Discourse.currentUser'
|
||||
categoriesBinding: 'Discourse.site.categories'
|
||||
categoryBinding: 'topicList.category'
|
||||
|
||||
canCreateCategory: false
|
||||
canCreateTopic: false
|
||||
|
||||
needs: ['composer', 'modal', 'listTopics']
|
||||
|
||||
availableNavItems: (->
|
||||
summary = @get('filterSummary')
|
||||
loggedOn = !!Discourse.get('currentUser')
|
||||
hasCategories = !!@get('categories')
|
||||
|
||||
Discourse.SiteSettings.top_menu.split("|").map((i)->
|
||||
Discourse.NavItem.fromText i,
|
||||
loggedOn: loggedOn
|
||||
hasCategories: hasCategories
|
||||
countSummary: summary
|
||||
).filter((i)-> i != null)
|
||||
|
||||
).property('filterSummary')
|
||||
|
||||
load: (filterMode) ->
|
||||
@set('loading', true)
|
||||
if filterMode == 'categories'
|
||||
return Ember.Deferred.promise (deferred) =>
|
||||
Discourse.CategoryList.list(filterMode).then (items) =>
|
||||
@set('loading', false)
|
||||
@set('filterMode', filterMode)
|
||||
@set('categoryMode', true)
|
||||
deferred.resolve(items)
|
||||
else
|
||||
current = (@get('availableNavItems').filter (f)=> f.name == filterMode)[0]
|
||||
current = Discourse.NavItem.create(name: filterMode) unless current
|
||||
|
||||
return Ember.Deferred.promise (deferred) =>
|
||||
Discourse.TopicList.list(current).then (items) =>
|
||||
@set('filterSummary', items.filter_summary)
|
||||
@set('filterMode', filterMode)
|
||||
@set('loading', false)
|
||||
deferred.resolve(items)
|
||||
|
||||
|
||||
# Put in the appropriate page title based on our view
|
||||
updateTitle: (->
|
||||
if @get('filterMode') == 'categories'
|
||||
Discourse.set('title', Em.String.i18n('categories_list'))
|
||||
else
|
||||
if @present('category')
|
||||
Discourse.set('title', "#{@get('category.name').capitalize()} #{Em.String.i18n('topic.list')}")
|
||||
else
|
||||
Discourse.set('title', Em.String.i18n('topic.list'))
|
||||
|
||||
).observes('filterMode', 'category')
|
||||
|
||||
# Create topic button
|
||||
createTopic: ->
|
||||
topicList = @get('controllers.listTopics.content')
|
||||
return unless topicList
|
||||
|
||||
@get('controllers.composer').open
|
||||
categoryName: @get('category.name')
|
||||
action: Discourse.Composer.CREATE_TOPIC
|
||||
draftKey: topicList.get('draft_key')
|
||||
draftSequence: topicList.get('draft_sequence')
|
||||
|
||||
createCategory: ->
|
||||
@get('controllers.modal')?.show(Discourse.EditCategoryView.create())
|
||||
|
||||
|
||||
Discourse.ListController.reopenClass(filters: ['popular','favorited','read','unread','new','posted'])
|
||||
@@ -0,0 +1,53 @@
|
||||
Discourse.ListTopicsController = Ember.ObjectController.extend
|
||||
needs: ['list','composer']
|
||||
|
||||
# If we're changing our channel
|
||||
previousChannel: null
|
||||
|
||||
filterModeChanged: (->
|
||||
# Unsubscribe from a previous channel if necessary
|
||||
if previousChannel = @get('previousChannel')
|
||||
Discourse.MessageBus.unsubscribe "/#{previousChannel}"
|
||||
@set('previousChannel', null)
|
||||
|
||||
filterMode = @get('controllers.list.filterMode')
|
||||
return unless filterMode
|
||||
|
||||
channel = filterMode
|
||||
Discourse.MessageBus.subscribe "/#{channel}", (data) =>
|
||||
@get('content').insert(data)
|
||||
@set('previousChannel', channel)
|
||||
|
||||
).observes('controllers.list.filterMode')
|
||||
|
||||
draftLoaded: (->
|
||||
draft = @get('content.draft')
|
||||
if(draft)
|
||||
@get('controllers.composer').open
|
||||
draft: draft
|
||||
draftKey: @get('content.draft_key'),
|
||||
draftSequence: @get('content.draft_sequence')
|
||||
ignoreIfChanged: true
|
||||
|
||||
).observes('content.draft')
|
||||
|
||||
# Star a topic
|
||||
toggleStar: (topic) ->
|
||||
topic.toggleStar()
|
||||
false
|
||||
|
||||
observer: (->
|
||||
@set('filterMode', @get('controllser.list.filterMode'))
|
||||
).observes('controller.list.filterMode')
|
||||
|
||||
|
||||
# Show newly inserted topics
|
||||
showInserted: (e) ->
|
||||
|
||||
# Move inserted into topics
|
||||
@get('content.topics').unshiftObjects @get('content.inserted')
|
||||
|
||||
# Clear inserted
|
||||
@set('content.inserted', Em.A())
|
||||
|
||||
false
|
||||
@@ -0,0 +1,3 @@
|
||||
Discourse.ModalController = Ember.Controller.extend Discourse.Presence,
|
||||
|
||||
show: (view) -> @set('currentView', view)
|
||||
@@ -0,0 +1,54 @@
|
||||
Discourse.PreferencesController = Ember.ObjectController.extend Discourse.Presence,
|
||||
|
||||
# By default we haven't saved anything
|
||||
saved: false
|
||||
|
||||
saveDisabled: (->
|
||||
return true if @get('saving')
|
||||
return true if @blank('content.name')
|
||||
return true if @blank('content.email')
|
||||
false
|
||||
).property('saving', 'content.name', 'content.email')
|
||||
|
||||
digestFrequencies: (->
|
||||
freqs = Em.A()
|
||||
freqs.addObject(name: Em.String.i18n('user.email_digests.daily'), value: 1)
|
||||
freqs.addObject(name: Em.String.i18n('user.email_digests.weekly'), value: 7)
|
||||
freqs.addObject(name: Em.String.i18n('user.email_digests.bi_weekly'), value: 14)
|
||||
freqs
|
||||
).property()
|
||||
|
||||
autoTrackDurations: (->
|
||||
freqs = Em.A()
|
||||
freqs.addObject(name: Em.String.i18n('user.auto_track_options.never'), value: -1)
|
||||
freqs.addObject(name: Em.String.i18n('user.auto_track_options.always'), value: 0)
|
||||
freqs.addObject(name: Em.String.i18n('user.auto_track_options.after_n_seconds', count: 30), value: 30000)
|
||||
freqs.addObject(name: Em.String.i18n('user.auto_track_options.after_n_minutes', count: 1), value: 60000)
|
||||
freqs.addObject(name: Em.String.i18n('user.auto_track_options.after_n_minutes', count: 2), value: 120000)
|
||||
freqs.addObject(name: Em.String.i18n('user.auto_track_options.after_n_minutes', count: 5), value: 300000)
|
||||
freqs
|
||||
).property()
|
||||
|
||||
save: ->
|
||||
@set('saving', true)
|
||||
|
||||
# Cook the bio for preview
|
||||
@get('content').save (result) =>
|
||||
@set('saving', false)
|
||||
if result
|
||||
@set('content.bio_cooked', Discourse.Utilities.cook(@get('content.bio_raw')))
|
||||
@set('saved', true)
|
||||
else
|
||||
alert 'failed'
|
||||
|
||||
saveButtonText: (->
|
||||
return Em.String.i18n('saving') if @get('saving')
|
||||
return Em.String.i18n('save')
|
||||
).property('saving')
|
||||
|
||||
changePassword: ->
|
||||
unless @get('passwordProgress')
|
||||
@set('passwordProgress','(generating email)')
|
||||
@get('content').changePassword (message)=>
|
||||
@set('changePasswordProgress', false)
|
||||
@set('passwordProgress', "(#{message})")
|
||||
@@ -0,0 +1,35 @@
|
||||
Discourse.PreferencesEmailController = Ember.ObjectController.extend Discourse.Presence,
|
||||
|
||||
taken: false
|
||||
saving: false
|
||||
error: false
|
||||
success: false
|
||||
|
||||
saveDisabled: (->
|
||||
return true if @get('saving')
|
||||
return true if @blank('newEmail')
|
||||
return true if @get('taken')
|
||||
return true if @get('unchanged')
|
||||
).property('newEmail', 'taken', 'unchanged', 'saving')
|
||||
|
||||
unchanged: (->
|
||||
@get('newEmail') == @get('content.email')
|
||||
).property('newEmail', 'content.email')
|
||||
|
||||
initializeEmail: (->
|
||||
@set('newEmail', @get('content.email'))
|
||||
).observes('content.email')
|
||||
|
||||
saveButtonText: (->
|
||||
return Em.String.i18n("saving") if @get('saving')
|
||||
Em.String.i18n("user.change_email.action")
|
||||
).property('saving')
|
||||
|
||||
changeEmail: ->
|
||||
@set('saving', true)
|
||||
@get('content').changeEmail(@get('newEmail')).then =>
|
||||
@set('success', true)
|
||||
, =>
|
||||
# Error
|
||||
@set('error', true)
|
||||
@set('saving', false)
|
||||
@@ -0,0 +1,40 @@
|
||||
Discourse.PreferencesUsernameController = Ember.ObjectController.extend Discourse.Presence,
|
||||
|
||||
taken: false
|
||||
saving: false
|
||||
error: false
|
||||
|
||||
saveDisabled: (->
|
||||
return true if @get('saving')
|
||||
return true if @blank('newUsername')
|
||||
return true if @get('taken')
|
||||
return true if @get('unchanged')
|
||||
).property('newUsername', 'taken', 'unchanged', 'saving')
|
||||
|
||||
unchanged: (->
|
||||
@get('newUsername') == @get('content.username')
|
||||
).property('newUsername', 'content.username')
|
||||
|
||||
checkTaken: (->
|
||||
@set('taken', false)
|
||||
return if @blank('newUsername')
|
||||
return if @get('unchanged')
|
||||
Discourse.User.checkUsername(@get('newUsername')).then (result) =>
|
||||
@set('taken', true) unless result.available
|
||||
).observes('newUsername')
|
||||
|
||||
saveButtonText: (->
|
||||
return Em.String.i18n("saving") if @get('saving')
|
||||
Em.String.i18n("user.change_username.action")
|
||||
).property('saving')
|
||||
|
||||
changeUsername: ->
|
||||
bootbox.confirm Em.String.i18n("user.change_username.confirm"), Em.String.i18n("no_value"), Em.String.i18n("yes_value"), (result) =>
|
||||
if result
|
||||
@set('saving', true)
|
||||
@get('content').changeUsername(@get('newUsername')).then =>
|
||||
window.location = "/users/#{@get('newUsername').toLowerCase()}/preferences"
|
||||
, =>
|
||||
# Error
|
||||
@set('error', true)
|
||||
@set('saving', false)
|
||||
@@ -0,0 +1,70 @@
|
||||
Discourse.QuoteButtonController = Discourse.Controller.extend
|
||||
|
||||
needs: ['topic', 'composer']
|
||||
|
||||
started: null
|
||||
|
||||
# If the buffer is cleared, clear out other state (post)
|
||||
bufferChanged: (->
|
||||
@set('post', null) if @blank('buffer')
|
||||
).observes('buffer')
|
||||
|
||||
|
||||
mouseDown: (e) ->
|
||||
@started = [e.pageX, e.pageY]
|
||||
|
||||
mouseUp: (e) ->
|
||||
if @started[1] > e.pageY
|
||||
@started = [e.pageX, e.pageY]
|
||||
|
||||
selectText: (e) ->
|
||||
return unless Discourse.get('currentUser')
|
||||
return unless @get('controllers.topic.content.can_create_post')
|
||||
|
||||
selectedText = Discourse.Utilities.selectedText()
|
||||
return if @get('buffer') == selectedText
|
||||
return if @get('lastSelected') == selectedText
|
||||
|
||||
@set('post', e.context)
|
||||
@set('buffer', selectedText)
|
||||
|
||||
top = e.pageY + 5
|
||||
left = e.pageX + 5
|
||||
$quoteButton = $('.quote-button')
|
||||
if @started
|
||||
top = @started[1] - 50
|
||||
left = ((left - @started[0]) / 2) + @started[0] - ($quoteButton.width() / 2)
|
||||
|
||||
$quoteButton.css(top: top, left: left)
|
||||
@started = null
|
||||
|
||||
false
|
||||
|
||||
quoteText: (e) ->
|
||||
|
||||
e.stopPropagation()
|
||||
post = @get('post')
|
||||
|
||||
composerController = @get('controllers.composer')
|
||||
|
||||
composerOpts =
|
||||
post: post
|
||||
action: Discourse.Composer.REPLY
|
||||
draftKey: @get('post.topic.draft_key')
|
||||
|
||||
# If the composer is associated with a different post, we don't change it.
|
||||
if composerPost = composerController.get('content.post')
|
||||
composerOpts.post = composerPost if (composerPost.get('id') != @get('post.id'))
|
||||
|
||||
buffer = @get('buffer')
|
||||
quotedText = Discourse.BBCode.buildQuoteBBCode(post, buffer)
|
||||
|
||||
if composerController.wouldLoseChanges()
|
||||
composerController.appendText(quotedText)
|
||||
else
|
||||
composerController.open(composerOpts).then =>
|
||||
composerController.appendText(quotedText)
|
||||
|
||||
@set('buffer', '')
|
||||
|
||||
false
|
||||
@@ -0,0 +1,14 @@
|
||||
Discourse.ShareController = Ember.Controller.extend
|
||||
|
||||
# When the user clicks the post number, we pop up a share box
|
||||
shareLink: (e, url) ->
|
||||
x = e.pageX - 150
|
||||
x = 25 if x < 25
|
||||
$('#share-link').css(left: "#{x}px", top: "#{e.pageY - 100}px")
|
||||
@set('link', url)
|
||||
false
|
||||
|
||||
# Close the share controller
|
||||
close: ->
|
||||
@set('link', '')
|
||||
false
|
||||
@@ -0,0 +1,21 @@
|
||||
Discourse.StaticController = Ember.Controller.extend
|
||||
|
||||
content: null
|
||||
|
||||
loadPath: (path) ->
|
||||
@set('content', null)
|
||||
|
||||
# Load from <noscript> if we have it.
|
||||
$preloaded = $("noscript[data-path=\"#{path}\"]")
|
||||
if $preloaded.length
|
||||
text = $preloaded.text()# + ""
|
||||
text = text.replace(/\<header[\s\S]*\<\/header\>/, '')
|
||||
@set('content', text)
|
||||
else
|
||||
jQuery.ajax
|
||||
url: "#{path}.json"
|
||||
success: (result) =>
|
||||
@set('content', result)
|
||||
|
||||
|
||||
Discourse.StaticController.reopenClass(pages: ['faq', 'tos', 'privacy'])
|
||||
@@ -0,0 +1,6 @@
|
||||
Discourse.TopicAdminMenuController = Ember.ObjectController.extend
|
||||
|
||||
visible: false
|
||||
|
||||
show: -> @set('visible', true)
|
||||
hide: -> @set('visible', false)
|
||||
@@ -0,0 +1,309 @@
|
||||
Discourse.TopicController = Ember.ObjectController.extend Discourse.Presence,
|
||||
|
||||
# A list of usernames we want to filter by
|
||||
userFilters: new Em.Set()
|
||||
multiSelect: false
|
||||
bestOf: false
|
||||
showExtraHeaderInfo: false
|
||||
|
||||
needs: ['header', 'modal', 'composer', 'quoteButton']
|
||||
|
||||
filter: (->
|
||||
return 'best_of' if @get('bestOf') == true
|
||||
return 'user' if @get('userFilters').length > 0
|
||||
return null
|
||||
).property('userFilters.[]', 'bestOf')
|
||||
|
||||
filterDesc: (->
|
||||
return null unless filter = @get('filter')
|
||||
Em.String.i18n("topic.filters.#{filter}")
|
||||
).property('filter')
|
||||
|
||||
selectedPosts: (->
|
||||
return null unless posts = @get('content.posts')
|
||||
posts.filterProperty('selected')
|
||||
).property('content.posts.@each.selected')
|
||||
|
||||
selectedCount: (->
|
||||
return 0 unless @get('selectedPosts')
|
||||
@get('selectedPosts').length
|
||||
).property('selectedPosts')
|
||||
|
||||
canMoveSelected: (->
|
||||
return false unless @get('content.can_move_posts')
|
||||
|
||||
# For now, we can move it if we can delete it since the posts
|
||||
# need to be deleted.
|
||||
@get('canDeleteSelected')
|
||||
).property('canDeleteSelected')
|
||||
|
||||
showExtraHeaderInfoChanged: (->
|
||||
@set('controllers.header.showExtraInfo', @get('showExtraHeaderInfo'))
|
||||
).observes('showExtraHeaderInfo')
|
||||
|
||||
canDeleteSelected: (->
|
||||
selectedPosts = @get('selectedPosts')
|
||||
return false unless selectedPosts and selectedPosts.length > 0
|
||||
canDelete = true
|
||||
selectedPosts.each (p) ->
|
||||
unless p.get('can_delete')
|
||||
canDelete = false
|
||||
return false
|
||||
|
||||
canDelete
|
||||
).property('selectedPosts')
|
||||
|
||||
multiSelectChanged: (->
|
||||
# Deselect all posts when multi select is turned off
|
||||
unless @get('multiSelect')
|
||||
if posts = @get('content.posts')
|
||||
posts.forEach (p) -> p.set('selected', false)
|
||||
|
||||
).observes('multiSelect')
|
||||
|
||||
hideProgress: (->
|
||||
return true unless @get('content.loaded')
|
||||
return true unless @get('currentPost')
|
||||
return true unless @get('content.highest_post_number') > 1
|
||||
@present('filter')
|
||||
).property('filter', 'content.loaded', 'currentPost')
|
||||
|
||||
selectPost: (post) ->
|
||||
post.toggleProperty('selected')
|
||||
|
||||
toggleMultiSelect: ->
|
||||
@toggleProperty('multiSelect')
|
||||
|
||||
moveSelected: ->
|
||||
@get('controllers.modal')?.show(Discourse.MoveSelectedView.create(topic: @get('content'), selectedPosts: @get('selectedPosts')))
|
||||
|
||||
deleteSelected: ->
|
||||
bootbox.confirm Em.String.i18n("post.delete.confirm", count: @get('selectedCount')), (result) =>
|
||||
if (result)
|
||||
Discourse.Post.deleteMany(@get('selectedPosts'))
|
||||
@get('content.posts').removeObjects(@get('selectedPosts'))
|
||||
|
||||
jumpTop: ->
|
||||
Discourse.routeTo(@get('content.url'))
|
||||
|
||||
jumpBottom: ->
|
||||
Discourse.routeTo(@get('content.lastPostUrl'))
|
||||
|
||||
cancelFilter: ->
|
||||
@set('bestOf', false)
|
||||
@get('userFilters').clear()
|
||||
|
||||
replyAsNewTopic: (post) ->
|
||||
composerController = @get('controllers.composer')
|
||||
#TODO shut down topic draft cleanly if it exists ...
|
||||
promise = composerController.open
|
||||
action: Discourse.Composer.CREATE_TOPIC
|
||||
draftKey: Discourse.Composer.REPLY_AS_NEW_TOPIC_KEY
|
||||
|
||||
postUrl = "#{location.protocol}//#{location.host}#{post.get('url')}"
|
||||
postLink = "[#{@get('title')}](#{postUrl})"
|
||||
promise.then ->
|
||||
Discourse.Post.loadQuote(post.get('id')).then (q) ->
|
||||
composerController.appendText("#{Em.String.i18n("post.continue_discussion", postLink: postLink)}\n\n#{q}")
|
||||
|
||||
# Topic related
|
||||
reply: ->
|
||||
composerController = @get('controllers.composer')
|
||||
composerController.open
|
||||
topic: @get('content')
|
||||
action: Discourse.Composer.REPLY
|
||||
draftKey: @get('content.draft_key')
|
||||
draftSequence: @get('content.draft_sequence')
|
||||
|
||||
toggleParticipant: (user) ->
|
||||
@set('bestOf', false)
|
||||
username = Em.get(user, 'username')
|
||||
userFilters = @get('userFilters')
|
||||
if userFilters.contains(username)
|
||||
userFilters.remove(username)
|
||||
else
|
||||
userFilters.add(username)
|
||||
false
|
||||
|
||||
enableBestOf: (e) ->
|
||||
@set('bestOf', true)
|
||||
@get('userFilters').clear()
|
||||
false
|
||||
|
||||
showBestOf: (->
|
||||
return false if @get('bestOf') == true
|
||||
@get('content.has_best_of') == true
|
||||
).property('bestOf', 'content.has_best_of')
|
||||
|
||||
postFilters: (->
|
||||
return {bestOf: true} if @get('bestOf') == true
|
||||
return {userFilters: @get('userFilters')}
|
||||
).property('userFilters.[]', 'bestOf')
|
||||
|
||||
reloadTopics: (->
|
||||
topic = @get('content')
|
||||
return unless topic
|
||||
posts = topic.get('posts')
|
||||
return unless posts
|
||||
posts.clear()
|
||||
|
||||
@set('content.loaded', false)
|
||||
Discourse.Topic.find(@get('content.id'), @get('postFilters')).then (result) =>
|
||||
first = result.posts.first()
|
||||
@set('currentPost', first.post_number) if first
|
||||
$('#topic-progress .solid').data('progress', false)
|
||||
result.posts.each (p) =>
|
||||
posts.pushObject(Discourse.Post.create(p, topic))
|
||||
@set('content.loaded', true)
|
||||
|
||||
).observes('postFilters')
|
||||
|
||||
deleteTopic: (e) ->
|
||||
@unsubscribe()
|
||||
|
||||
@get('content').delete =>
|
||||
@set('message', "The topic has been deleted")
|
||||
@set('loaded', false)
|
||||
|
||||
toggleVisibility: ->
|
||||
@get('content').toggleStatus('visible')
|
||||
|
||||
toggleClosed: ->
|
||||
@get('content').toggleStatus('closed')
|
||||
|
||||
togglePinned: ->
|
||||
@get('content').toggleStatus('pinned')
|
||||
|
||||
toggleArchived: ->
|
||||
@get('content').toggleStatus('archived')
|
||||
|
||||
convertToRegular: ->
|
||||
@get('content').convertArchetype('regular')
|
||||
|
||||
startTracking: ->
|
||||
screenTrack = Discourse.ScreenTrack.create(topic_id: @get('content.id'))
|
||||
screenTrack.start()
|
||||
@set('content.screenTrack', screenTrack)
|
||||
|
||||
stopTracking: ->
|
||||
@get('content.screenTrack')?.stop()
|
||||
@set('content.screenTrack', null)
|
||||
|
||||
# Toggle the star on the topic
|
||||
toggleStar: (e) ->
|
||||
@get('content').toggleStar()
|
||||
|
||||
# Receive notifications for this topic
|
||||
subscribe: ->
|
||||
|
||||
bus = Discourse.MessageBus
|
||||
# there is a condition where the view never calls unsubscribe, navigate to a topic from a topic
|
||||
bus.unsubscribe('/topic/*')
|
||||
bus.subscribe "/topic/#{@get('content.id')}", (data) =>
|
||||
topic = @get('content')
|
||||
if data.notification_level_change
|
||||
topic.set('notification_level', data.notification_level_change)
|
||||
topic.set('notifications_reason_id', data.notifications_reason_id)
|
||||
return
|
||||
|
||||
posts = topic.get('posts')
|
||||
return if posts.some (p) -> p.get('post_number') == data.post_number
|
||||
topic.set 'posts_count', topic.get('posts_count') + 1
|
||||
topic.set 'highest_post_number', data.post_number
|
||||
topic.set 'last_poster', data.user
|
||||
topic.set 'last_posted_at', data.created_at
|
||||
Discourse.notifyTitle()
|
||||
|
||||
unsubscribe: ->
|
||||
topicId = @get('content.id')
|
||||
return unless topicId
|
||||
bus = Discourse.MessageBus
|
||||
bus.unsubscribe("/topic/#{topicId}")
|
||||
|
||||
# Post related methods
|
||||
replyToPost: (post) ->
|
||||
composerController = @get('controllers.composer')
|
||||
quoteController = @get('controllers.quoteButton')
|
||||
quotedText = Discourse.BBCode.buildQuoteBBCode(quoteController.get('post'), quoteController.get('buffer'))
|
||||
quoteController.set('buffer', '')
|
||||
|
||||
if (composerController.get('content.topic.id') == post.get('topic.id') and composerController.get('content.action') == Discourse.Composer.REPLY)
|
||||
composerController.set('content.post', post)
|
||||
composerController.set('content.composeState', Discourse.Composer.OPEN)
|
||||
composerController.appendText(quotedText)
|
||||
else
|
||||
promise = composerController.open
|
||||
post: post
|
||||
action: Discourse.Composer.REPLY
|
||||
draftKey: post.get('topic.draft_key')
|
||||
draftSequence: post.get('topic.draft_sequence')
|
||||
|
||||
promise.then =>
|
||||
composerController.appendText(quotedText)
|
||||
|
||||
false
|
||||
|
||||
# Edits a post
|
||||
editPost: (post) ->
|
||||
@get('controllers.composer').open
|
||||
post: post
|
||||
action: Discourse.Composer.EDIT
|
||||
draftKey: post.get('topic.draft_key')
|
||||
draftSequence: post.get('topic.draft_sequence')
|
||||
|
||||
toggleBookmark: (post) ->
|
||||
unless Discourse.get('currentUser')
|
||||
alert Em.String.i18n("bookmarks.not_bookmarked")
|
||||
return
|
||||
|
||||
post.toggleProperty('bookmarked')
|
||||
false
|
||||
|
||||
# Who acted on a particular post / action type
|
||||
whoActed: (actionType) ->
|
||||
actionType.loadUsers()
|
||||
false
|
||||
|
||||
like:(e) ->
|
||||
like_action = Discourse.get('site.post_action_types').findProperty('name_key', 'like')
|
||||
e.context.act(like_action.get('id'))
|
||||
|
||||
# log a post action towards this post
|
||||
act: (action) ->
|
||||
action.act()
|
||||
false
|
||||
|
||||
undoAction: (action) ->
|
||||
action.undo()
|
||||
false
|
||||
|
||||
showPrivateInviteModal: ->
|
||||
modal = Discourse.InvitePrivateModalView.create(topic: @get('content'))
|
||||
@get('controllers.modal')?.show(modal)
|
||||
false
|
||||
|
||||
showInviteModal: ->
|
||||
@get('controllers.modal')?.show(Discourse.InviteModalView.create(topic: @get('content')))
|
||||
false
|
||||
|
||||
# Clicked the flag button
|
||||
showFlags: (post) ->
|
||||
flagView = Discourse.FlagView.create(post: post, controller: @)
|
||||
@get('controllers.modal')?.show(flagView)
|
||||
|
||||
showHistory: (post) ->
|
||||
view = Discourse.HistoryView.create(originalPost: post)
|
||||
@get('controllers.modal')?.show(view)
|
||||
false
|
||||
|
||||
deletePost: (post) ->
|
||||
|
||||
deleted = !!post.get('deleted_at')
|
||||
|
||||
if deleted
|
||||
post.set('deleted_at', null)
|
||||
else
|
||||
post.set('deleted_at', new Date())
|
||||
|
||||
post.delete =>
|
||||
# nada
|
||||
@@ -0,0 +1,15 @@
|
||||
Discourse.UserActivityController = Ember.ObjectController.extend
|
||||
|
||||
needs: ['composer']
|
||||
|
||||
kickOffPrivateMessage: ( ->
|
||||
if @get('content.openPrivateMessage')
|
||||
@composePrivateMessage()
|
||||
).observes('content.openPrivateMessage')
|
||||
|
||||
composePrivateMessage: ->
|
||||
@get('controllers.composer').open
|
||||
action: Discourse.Composer.PRIVATE_MESSAGE
|
||||
usernames: @get('content').username
|
||||
archetypeId: 'private_message'
|
||||
draftKey: 'new_private_message'
|
||||
@@ -0,0 +1,9 @@
|
||||
Discourse.UserController = Ember.ObjectController.extend
|
||||
|
||||
viewingSelf: (->
|
||||
@get('content.username') == Discourse.get('currentUser.username')
|
||||
).property('content.username', 'Discourse.currentUser.username')
|
||||
|
||||
canSeePrivateMessages: (->
|
||||
@get('viewingSelf') || Discourse.get('currentUser.admin')
|
||||
).property('viewingSelf', 'Discourse.currentUser')
|
||||
@@ -0,0 +1,5 @@
|
||||
Discourse.UserInvitedController = Ember.ObjectController.extend
|
||||
|
||||
rescind: (invite) ->
|
||||
invite.rescind()
|
||||
false
|
||||
@@ -0,0 +1,11 @@
|
||||
Discourse.UserPrivateMessagesController = Ember.ObjectController.extend
|
||||
|
||||
editPreferences: ->
|
||||
Discourse.routeTo("/users/#{@get('content.username_lower')}/preferences")
|
||||
|
||||
composePrivateMessage: ->
|
||||
composerController = Discourse.get('router.composerController')
|
||||
composerController.open
|
||||
action: Discourse.Composer.PRIVATE_MESSAGE
|
||||
archetypeId: 'private_message'
|
||||
draftKey: 'new_private_message'
|
||||
@@ -0,0 +1,128 @@
|
||||
Handlebars.registerHelper 'breakUp', (property, options) ->
|
||||
prop = Ember.Handlebars.get(this, property, options)
|
||||
return "" unless prop
|
||||
|
||||
tokens = prop.match(RegExp(".{1,14}",'g'))
|
||||
return prop if tokens.length == 1
|
||||
|
||||
result = ""
|
||||
tokens.each (token, index) ->
|
||||
result += token
|
||||
|
||||
if token.indexOf(' ') == -1 and (index < tokens.length - 1)
|
||||
result += "- "
|
||||
|
||||
result
|
||||
|
||||
Handlebars.registerHelper 'shorten', (property, options) ->
|
||||
str = Ember.Handlebars.get(this, property, options)
|
||||
str.truncate(35)
|
||||
|
||||
Handlebars.registerHelper 'topicLink', (property, options) ->
|
||||
topic = Ember.Handlebars.get(this, property, options)
|
||||
"<a href='#{topic.get('lastReadUrl')}' class='title excerptable'>#{Handlebars.Utils.escapeExpression(topic.get('title'))}</a>"
|
||||
|
||||
Handlebars.registerHelper 'categoryLink', (property, options) ->
|
||||
category = Ember.Handlebars.get(this, property, options)
|
||||
new Handlebars.SafeString(Discourse.Utilities.categoryLink(category))
|
||||
|
||||
Handlebars.registerHelper 'titledLinkTo', (name, object) ->
|
||||
options = [].slice.call(arguments, -1)[0]
|
||||
|
||||
if options.hash.titleKey
|
||||
options.hash.title = Em.String.i18n(options.hash.titleKey)
|
||||
|
||||
if arguments.length is 3
|
||||
Ember.Handlebars.helpers.linkTo.call(this, name, object, options)
|
||||
else
|
||||
Ember.Handlebars.helpers.linkTo.call(this, name, options)
|
||||
|
||||
|
||||
Handlebars.registerHelper 'shortenUrl', (property, options) ->
|
||||
url = Ember.Handlebars.get(this, property, options)
|
||||
|
||||
# Remove trailing slash if it's a top level URL
|
||||
url = url.replace(/\/$/, '') if url.match(/\//g).length == 3
|
||||
|
||||
url = url.replace(/^https?:\/\//, '')
|
||||
url = url.replace(/^www\./, '')
|
||||
url.truncate(80)
|
||||
|
||||
Handlebars.registerHelper 'lower', (property, options) ->
|
||||
o = Ember.Handlebars.get(this, property, options)
|
||||
if o && typeof o == 'string'
|
||||
o.toLowerCase()
|
||||
else
|
||||
""
|
||||
|
||||
Handlebars.registerHelper 'avatar', (user, options) ->
|
||||
|
||||
user = Ember.Handlebars.get(this, user, options) if typeof user is 'string'
|
||||
username = Em.get(user, 'username')
|
||||
username ||= Em.get(user, options.hash.usernamePath)
|
||||
|
||||
new Handlebars.SafeString Discourse.Utilities.avatarImg(
|
||||
size: options.hash.imageSize
|
||||
extraClasses: Em.get(user, 'extras') || options.hash.extraClasses
|
||||
username: username
|
||||
title: Em.get(user, 'title') || Em.get(user, 'description')
|
||||
avatarTemplate: Ember.get(user, 'avatar_template') || options.hash.avatarTemplate
|
||||
)
|
||||
|
||||
Handlebars.registerHelper 'unboundDate', (property, options) ->
|
||||
dt = new Date(Ember.Handlebars.get(this, property, options))
|
||||
month = Date.SugarMethods.getLocale.method().months[12 + dt.getMonth()]
|
||||
"#{dt.getDate()} #{month}, #{dt.getFullYear()} #{dt.getHours()}:#{dt.getMinutes()}"
|
||||
|
||||
Handlebars.registerHelper 'editDate', (property, options) ->
|
||||
dt = Date.create(Ember.Handlebars.get(this, property, options))
|
||||
yesterday = new Date() - (60 * 60 * 24 * 1000)
|
||||
if yesterday > dt.getTime()
|
||||
dt.format("{d} {Mon}, {yyyy} {hh}:{mm}")
|
||||
else
|
||||
humaneDate(dt)
|
||||
|
||||
Handlebars.registerHelper 'number', (property, options) ->
|
||||
orig = parseInt(Ember.Handlebars.get(this, property, options))
|
||||
|
||||
orig = 0 if isNaN(orig)
|
||||
|
||||
title = orig
|
||||
if options.hash.numberKey
|
||||
title = Em.String.i18n(options.hash.numberKey, number: orig)
|
||||
|
||||
# Round off the thousands to one decimal place
|
||||
n = orig
|
||||
n = (orig / 1000).toFixed(1) + "K" if orig > 999
|
||||
new Handlebars.SafeString("<span class='number' title='#{title}'>#{n}</span>")
|
||||
|
||||
Handlebars.registerHelper 'date', (property, options) ->
|
||||
|
||||
if property.hash
|
||||
leaveAgo = property.hash.leaveAgo == "true" if property.hash.leaveAgo
|
||||
property = property.hash.path if property.hash.path
|
||||
|
||||
val = Ember.Handlebars.get(this, property, options)
|
||||
return new Handlebars.SafeString("—") unless val
|
||||
|
||||
dt = new Date(val)
|
||||
|
||||
fullReadable = dt.format("{d} {Mon}, {yyyy} {hh}:{mm}")
|
||||
displayDate = ""
|
||||
|
||||
fiveDaysAgo = ((new Date()) - 432000000) # 5 * 1000 * 60 * 60 * 24 - optimised 5 days ago
|
||||
|
||||
if fiveDaysAgo > (dt.getTime())
|
||||
if (new Date()).getFullYear() != dt.getFullYear()
|
||||
displayDate = dt.format("{d} {Mon} '{yy}")
|
||||
else
|
||||
displayDate = dt.format("{d} {Mon}")
|
||||
else
|
||||
humanized = humaneDate(dt)
|
||||
return "" unless humanized
|
||||
displayDate = humanized
|
||||
displayDate = displayDate.replace(' ago', '') unless leaveAgo
|
||||
|
||||
new Handlebars.SafeString("<span class='date' title='#{fullReadable}'>#{displayDate}</span>")
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
Ember.Handlebars.registerHelper 'i18n', (property, options) ->
|
||||
|
||||
# Resolve any properties
|
||||
params = options.hash
|
||||
Object.keys params, (key, value) =>
|
||||
params[key] = Em.Handlebars.get(this, value, options)
|
||||
|
||||
Ember.String.i18n(property, params)
|
||||
|
||||
# We always prefix with .js to select exactly what we want passed through to the front end.
|
||||
Ember.String.i18n = (scope, options) ->
|
||||
I18n.translate("js.#{scope}", options)
|
||||
|
||||
# Bind an i18n count
|
||||
Ember.Handlebars.registerHelper 'countI18n', (key, options) ->
|
||||
view = Em.View.extend
|
||||
tagName: 'span'
|
||||
render: (buffer) -> buffer.push(Ember.String.i18n(key, count: @get('count')))
|
||||
countChanged: (-> @rerender() ).observes('count')
|
||||
|
||||
Ember.Handlebars.helpers.view.call(this, view, options)
|
||||
|
||||
if Ember.EXTEND_PROTOTYPES
|
||||
String.prototype.i18n = (options) ->
|
||||
return Ember.String.i18n(String(this), options)
|
||||
15
app/assets/javascripts/discourse/mixins/presence.js.coffee
Normal file
15
app/assets/javascripts/discourse/mixins/presence.js.coffee
Normal file
@@ -0,0 +1,15 @@
|
||||
window.Discourse.Presence = Em.Mixin.create
|
||||
|
||||
# Is a property blank?
|
||||
blank: (name) ->
|
||||
prop = @get(name)
|
||||
return true unless prop
|
||||
|
||||
switch typeof(prop)
|
||||
when "string"
|
||||
return prop.trim().isBlank()
|
||||
when "object"
|
||||
return Object.isEmpty(prop)
|
||||
false
|
||||
|
||||
present: (name) -> not @blank(name)
|
||||
15
app/assets/javascripts/discourse/mixins/scrolling.js.coffee
Normal file
15
app/assets/javascripts/discourse/mixins/scrolling.js.coffee
Normal file
@@ -0,0 +1,15 @@
|
||||
# Use this mixin if you want to be notified every time the user scrolls the window
|
||||
window.Discourse.Scrolling = Em.Mixin.create
|
||||
|
||||
bindScrolling: ->
|
||||
|
||||
onScroll = Discourse.debounce(=>
|
||||
@scrolled()
|
||||
, 100)
|
||||
|
||||
$(document).bind 'touchmove.discourse', onScroll
|
||||
$(window).bind 'scroll.discourse', onScroll
|
||||
unbindScrolling: ->
|
||||
$(window).unbind 'scroll.discourse'
|
||||
$(document).unbind 'touchmove.discourse'
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
window.Discourse.ActionSummary = Em.Object.extend Discourse.Presence,
|
||||
|
||||
# Description for the action
|
||||
description: (->
|
||||
if @get('acted')
|
||||
Em.String.i18n('post.actions.by_you_and_others', count: @get('count') - 1, long_form: @get('actionType.long_form'))
|
||||
else
|
||||
Em.String.i18n('post.actions.by_others', count: @get('count'), long_form: @get('actionType.long_form'))
|
||||
).property('count', 'acted', 'actionType')
|
||||
|
||||
canAlsoAction: (->
|
||||
return false if @get('hidden')
|
||||
return @get('can_act')
|
||||
).property('can_act', 'hidden')
|
||||
|
||||
# Remove it
|
||||
removeAction: ->
|
||||
@set('acted', false)
|
||||
@set('count', @get('count') - 1)
|
||||
@set('can_act', true)
|
||||
@set('can_undo', false)
|
||||
|
||||
# Perform this action
|
||||
act: (opts) ->
|
||||
# Mark it as acted
|
||||
@set('acted', true)
|
||||
@set('count', @get('count') + 1)
|
||||
@set('can_act', false)
|
||||
@set('can_undo', true)
|
||||
|
||||
#TODO: mark all other flag types as acted
|
||||
|
||||
# Add ourselves to the users who liked it if present
|
||||
@users.pushObject(Discourse.get('currentUser')) if @present('users')
|
||||
|
||||
# Create our post action
|
||||
jQuery.ajax
|
||||
url: "/post_actions",
|
||||
type: 'POST'
|
||||
data:
|
||||
id: @get('post.id')
|
||||
post_action_type_id: @get('id')
|
||||
message: opts?.message || ""
|
||||
error: (error) =>
|
||||
@removeAction()
|
||||
errors = jQuery.parseJSON(error.responseText).errors
|
||||
bootbox.alert(errors[0])
|
||||
|
||||
|
||||
# Undo this action
|
||||
undo: ->
|
||||
@removeAction()
|
||||
|
||||
# Remove our post action
|
||||
jQuery.ajax
|
||||
url: "/post_actions/#{@get('post.id')}"
|
||||
type: 'DELETE'
|
||||
data:
|
||||
post_action_type_id: @get('id')
|
||||
|
||||
loadUsers: ->
|
||||
$.getJSON "/post_actions/users",
|
||||
id: @get('post.id'),
|
||||
post_action_type_id: @get('id')
|
||||
(result) =>
|
||||
@set('users', Em.A())
|
||||
result.each (u) => @get('users').pushObject(Discourse.User.create(u))
|
||||
11
app/assets/javascripts/discourse/models/archetype.js.coffee
Normal file
11
app/assets/javascripts/discourse/models/archetype.js.coffee
Normal file
@@ -0,0 +1,11 @@
|
||||
window.Discourse.Archetype = Discourse.Model.extend
|
||||
|
||||
hasOptions: (->
|
||||
return false unless @get('options')
|
||||
@get('options').length > 0
|
||||
).property('options.@each')
|
||||
|
||||
isDefault: (->
|
||||
@get('id') == Discourse.get('site.default_archetype')
|
||||
).property('id')
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
window.Discourse.Category = Discourse.Model.extend
|
||||
|
||||
url: (->
|
||||
"/category/#{@get('slug')}"
|
||||
).property('name')
|
||||
|
||||
style: (->
|
||||
"background-color: ##{@get('color')}"
|
||||
).property('color')
|
||||
|
||||
moreTopics: (->
|
||||
return @get('topic_count') > <%= SiteSetting.category_featured_topics %>
|
||||
).property('topic_count')
|
||||
|
||||
save: (args) ->
|
||||
|
||||
url = "/categories"
|
||||
url = "/categories/#{@get('id')}" if @get('id')
|
||||
|
||||
@ajax url,
|
||||
data:
|
||||
name: @get('name')
|
||||
color: @get('color')
|
||||
type: if @get('id') then 'PUT' else 'POST'
|
||||
success: (result) => args.success(result)
|
||||
error: (errors) => args.error(errors)
|
||||
|
||||
delete: (callback) ->
|
||||
$.ajax "/categories/#{@get('slug')}",
|
||||
type: 'DELETE'
|
||||
success: => callback()
|
||||
@@ -0,0 +1,29 @@
|
||||
window.Discourse.CategoryList = Discourse.Model.extend({})
|
||||
|
||||
|
||||
window.Discourse.CategoryList.reopenClass
|
||||
|
||||
categoriesFrom: (result) ->
|
||||
categories = Em.A()
|
||||
|
||||
users = @extractByKey(result.featured_users, Discourse.User)
|
||||
|
||||
result.category_list.categories.each (c) ->
|
||||
if c.featured_user_ids
|
||||
c.featured_users = c.featured_user_ids.map (u) -> users[u]
|
||||
if c.topics
|
||||
c.topics = c.topics.map (t) -> Discourse.Topic.create(t)
|
||||
|
||||
categories.pushObject(Discourse.Category.create(c))
|
||||
|
||||
categories
|
||||
|
||||
list: (filter) ->
|
||||
promise = new RSVP.Promise()
|
||||
jQuery.getJSON("/#{filter}.json").then (result) =>
|
||||
categoryList = Discourse.TopicList.create()
|
||||
categoryList.set('can_create_category', result.category_list.can_create_category)
|
||||
categoryList.set('categories', @categoriesFrom(result))
|
||||
categoryList.set('loaded', true)
|
||||
promise.resolve(categoryList)
|
||||
promise
|
||||
422
app/assets/javascripts/discourse/models/composer.js.coffee
Normal file
422
app/assets/javascripts/discourse/models/composer.js.coffee
Normal file
@@ -0,0 +1,422 @@
|
||||
# The status the compose view can have
|
||||
CLOSED = 'closed'
|
||||
SAVING = 'saving'
|
||||
OPEN = 'open'
|
||||
DRAFT = 'draft'
|
||||
|
||||
# The actions the composer can take
|
||||
CREATE_TOPIC = 'createTopic'
|
||||
PRIVATE_MESSAGE = 'privateMessage'
|
||||
REPLY = 'reply'
|
||||
EDIT = 'edit'
|
||||
|
||||
REPLY_AS_NEW_TOPIC_KEY = "reply_as_new_topic"
|
||||
|
||||
window.Discourse.Composer = Discourse.Model.extend
|
||||
init: ->
|
||||
@_super()
|
||||
val = (Discourse.KeyValueStore.get('composer.showPreview') or 'true')
|
||||
@set('showPreview', val is 'true')
|
||||
@set 'archetypeId', Discourse.get('site.default_archetype')
|
||||
|
||||
archetypesBinding: 'Discourse.site.archetypes'
|
||||
|
||||
creatingTopic: (-> @get('action') == CREATE_TOPIC ).property('action')
|
||||
creatingPrivateMessage: (-> @get('action') == PRIVATE_MESSAGE ).property('action')
|
||||
editingPost: (-> @get('action') == EDIT).property('action')
|
||||
viewOpen: (-> @get('composeState') == OPEN ).property('composeState')
|
||||
|
||||
archetype: (->
|
||||
@get('archetypes').findProperty('id', @get('archetypeId'))
|
||||
).property('archetypeId')
|
||||
|
||||
archetypeChanged: (->
|
||||
@set('metaData', Em.Object.create())
|
||||
).observes('archetype')
|
||||
|
||||
editTitle: (->
|
||||
return true if @get('creatingTopic') || @get('creatingPrivateMessage')
|
||||
return true if @get('editingPost') and @get('post.post_number') == 1
|
||||
false
|
||||
).property('editingPost', 'creatingTopic', 'post.post_number')
|
||||
|
||||
|
||||
togglePreview: ->
|
||||
@toggleProperty('showPreview')
|
||||
Discourse.KeyValueStore.set(key: 'showPreview', value: @get('showPreview'))
|
||||
|
||||
# Import a quote from the post
|
||||
importQuote: ->
|
||||
post = @get('post')
|
||||
unless post
|
||||
posts = @get('topic.posts')
|
||||
post = posts[0] if posts && posts.length > 0
|
||||
|
||||
if post
|
||||
@set('loading', true)
|
||||
Discourse.Post.load post.get('id'), (result) =>
|
||||
quotedText = Discourse.BBCode.buildQuoteBBCode(post, result.get('raw'))
|
||||
@appendText(quotedText)
|
||||
@set('loading', false)
|
||||
|
||||
appendText: (text)->
|
||||
@set 'reply', (@get('reply') || '') + text
|
||||
|
||||
# Determine the appropriate title for this action
|
||||
actionTitle: (->
|
||||
topic = @get('topic')
|
||||
postNumber = @get('post.post_number')
|
||||
|
||||
if topic
|
||||
postLink = "<a href='#{topic.get('url')}/#{postNumber}'>post #{postNumber}</a>"
|
||||
|
||||
switch @get('action')
|
||||
when PRIVATE_MESSAGE
|
||||
Em.String.i18n('topic.private_message')
|
||||
when CREATE_TOPIC
|
||||
Em.String.i18n('topic.create_long')
|
||||
when REPLY
|
||||
if @get('post')
|
||||
replyAvatar = Discourse.Utilities.avatarImg(
|
||||
username: @get('post.username'),
|
||||
size: 'tiny'
|
||||
)
|
||||
Em.String.i18n('post.reply', link: postLink, replyAvatar: replyAvatar, username: @get('post.username'))
|
||||
else if topic
|
||||
topicLink = "<a href='#{topic.get('url')}'> #{Handlebars.Utils.escapeExpression(topic.get('title'))}</a>"
|
||||
Em.String.i18n('post.reply_topic', link: topicLink)
|
||||
when EDIT
|
||||
Em.String.i18n('post.edit', link: postLink)
|
||||
).property('action', 'post', 'topic', 'topic.title')
|
||||
|
||||
toggleText: (->
|
||||
return Em.String.i18n('composer.hide_preview') if @get('showPreview')
|
||||
Em.String.i18n('composer.show_preview')
|
||||
).property('showPreview')
|
||||
|
||||
hidePreview: (-> not @get('showPreview') ).property('showPreview')
|
||||
|
||||
# Whether to disable the post button
|
||||
cantSubmitPost: (->
|
||||
|
||||
# Can't submit while loading
|
||||
return true if @get('loading')
|
||||
|
||||
# Title is required on new posts
|
||||
if @get('creatingTopic')
|
||||
return true if @blank('title')
|
||||
return true if @get('title').trim().length < Discourse.SiteSettings.min_topic_title_length
|
||||
|
||||
# Otherwise just reply is required
|
||||
return true if @blank('reply')
|
||||
return true if @get('reply').trim().length < Discourse.SiteSettings.min_post_length
|
||||
|
||||
false
|
||||
).property('reply', 'title', 'creatingTopic', 'loading')
|
||||
|
||||
# The text for the save button
|
||||
saveText: (->
|
||||
switch @get('action')
|
||||
when EDIT then Em.String.i18n('composer.save_edit')
|
||||
when REPLY then Em.String.i18n('composer.reply')
|
||||
when CREATE_TOPIC then Em.String.i18n('composer.create_topic')
|
||||
when PRIVATE_MESSAGE then Em.String.i18n('composer.create_pm')
|
||||
).property('action')
|
||||
|
||||
hasMetaData: (->
|
||||
metaData = @get('metaData')
|
||||
return false unless @get('metaData')
|
||||
return Em.empty(Em.keys(@get('metaData')))
|
||||
).property('metaData')
|
||||
|
||||
|
||||
wouldLoseChanges: ()->
|
||||
@get('reply') != @get('originalText') # TODO title check as well
|
||||
|
||||
# Open a composer
|
||||
#
|
||||
# opts:
|
||||
# action - The action we're performing: edit, reply or createTopic
|
||||
# post - The post we're replying to, if present
|
||||
# topic - The topic we're replying to, if present
|
||||
# quote - If we're opening a reply from a quote, the quote we're making
|
||||
#
|
||||
open: (opts={}) ->
|
||||
|
||||
@set('loading', false)
|
||||
|
||||
topicId = opts.topic.get('id') if opts.topic
|
||||
replyBlank = (@get("reply") || "") == ""
|
||||
if !replyBlank && (opts.action != @get('action') || ((opts.reply || opts.action == @EDIT) && @get('reply') != @get('originalText'))) && !opts.tested
|
||||
opts.tested = true
|
||||
@cancel(=> @open(opts))
|
||||
return
|
||||
|
||||
@set 'draftKey', opts.draftKey
|
||||
@set 'draftSequence', opts.draftSequence
|
||||
throw 'draft key is required' unless opts.draftKey
|
||||
throw 'draft sequence is required' if opts.draftSequence == null
|
||||
|
||||
@set 'composeState', opts.composerState || OPEN
|
||||
@set 'action', opts.action
|
||||
@set 'topic', opts.topic
|
||||
|
||||
@set 'targetUsernames', opts.usernames
|
||||
|
||||
if opts.post
|
||||
@set 'post', opts.post
|
||||
@set 'topic', opts.post.get('topic') unless @get('topic')
|
||||
|
||||
@set('categoryName', opts.categoryName || @get('topic.category.name'))
|
||||
@set('archetypeId', opts.archetypeId || Discourse.get('site.default_archetype'))
|
||||
@set('metaData', if opts.metaData then Em.Object.create(opts.metaData) else null)
|
||||
@set('reply', opts.reply || @get("reply") || "")
|
||||
|
||||
if opts.postId
|
||||
@set('loading', true)
|
||||
Discourse.Post.load opts.postId, (result) =>
|
||||
@set('post', result)
|
||||
@set('loading', false)
|
||||
|
||||
# If we are editing a post, load it.
|
||||
if opts.action == EDIT and opts.post
|
||||
@set 'title', @get('topic.title')
|
||||
@set('loading', true)
|
||||
Discourse.Post.load opts.post.get('id'), (result) =>
|
||||
@set 'reply', result.get('raw')
|
||||
@set('originalText', @get('reply'))
|
||||
@set('loading', false)
|
||||
|
||||
if opts.title
|
||||
@set('title', opts.title)
|
||||
if opts.draft
|
||||
@set('originalText', '')
|
||||
else if opts.reply
|
||||
@set('originalText', @get('reply'))
|
||||
|
||||
false
|
||||
|
||||
|
||||
save: (opts)->
|
||||
if @get('editingPost')
|
||||
@editPost(opts)
|
||||
else
|
||||
@createPost(opts)
|
||||
|
||||
# When you edit a post
|
||||
editPost: (opts)->
|
||||
promise = new RSVP.Promise
|
||||
|
||||
post = @get('post')
|
||||
|
||||
oldCooked = post.get('cooked')
|
||||
|
||||
# Update the title if we've changed it
|
||||
if @get('title') and post.get('post_number') == 1
|
||||
topic = @get('topic')
|
||||
topic.set('title', @get('title'))
|
||||
topic.set('categoryName', @get('categoryName'))
|
||||
topic.save()
|
||||
|
||||
post.set('raw', @get('reply'))
|
||||
post.set('imageSizes', opts.imageSizes)
|
||||
post.set('cooked', $('#wmd-preview').html())
|
||||
@set('composeState', CLOSED)
|
||||
|
||||
post.save (savedPost) =>
|
||||
posts = @get('topic.posts')
|
||||
# perhaps our post came from elsewhere eg. draft
|
||||
idx = -1
|
||||
postNumber = post.get('post_number')
|
||||
posts.each (p,i)->
|
||||
idx = i if p.get('post_number') == postNumber
|
||||
|
||||
if idx > -1
|
||||
savedPost.set('topic', @get('topic'))
|
||||
posts.replace(idx, 1, [savedPost])
|
||||
promise.resolve(post: post)
|
||||
@set('topic.draft_sequence', savedPost.draft_sequence)
|
||||
|
||||
, (error) =>
|
||||
errors = jQuery.parseJSON(error.responseText).errors
|
||||
promise.reject(errors[0])
|
||||
post.set('cooked', oldCooked)
|
||||
@set('composeState', OPEN)
|
||||
|
||||
promise
|
||||
|
||||
|
||||
# Create a new Post
|
||||
createPost: (opts)->
|
||||
promise = new RSVP.Promise
|
||||
post = @get('post')
|
||||
topic = @get('topic')
|
||||
|
||||
createdPost = Discourse.Post.create
|
||||
raw: @get('reply')
|
||||
title: @get('title')
|
||||
category: @get('categoryName')
|
||||
topic_id: @get('topic.id')
|
||||
reply_to_post_number: if post then post.get('post_number') else null
|
||||
imageSizes: opts.imageSizes
|
||||
post_number: @get('topic.highest_post_number') + 1
|
||||
cooked: $('#wmd-preview').html()
|
||||
reply_count: 0
|
||||
display_username: Discourse.get('currentUser.name')
|
||||
username: Discourse.get('currentUser.username')
|
||||
metaData: @get('metaData')
|
||||
archetype: @get('archetypeId')
|
||||
post_type: Discourse.Post.REGULAR_TYPE
|
||||
target_usernames: @get('targetUsernames')
|
||||
actions_summary: Em.A()
|
||||
yours: true
|
||||
newPost: true
|
||||
|
||||
addedToStream = false
|
||||
|
||||
# If we're in a topic, we can append the post instantly.
|
||||
if topic
|
||||
# Increase the reply count
|
||||
if post
|
||||
post.set('reply_count', (post.get('reply_count') || 0) + 1)
|
||||
|
||||
# Supress replies
|
||||
if (post.get('reply_count') == 1 && createdPost.get('cooked').length < Discourse.SiteSettings.max_length_show_reply)
|
||||
post.set('replyFollowing', true)
|
||||
|
||||
post.set('reply_below_post_number', createdPost.get('post_number'))
|
||||
|
||||
topic.set('posts_count', topic.get('posts_count') + 1)
|
||||
|
||||
# Update last post
|
||||
topic.set('last_posted_at', new Date())
|
||||
topic.set('highest_post_number', createdPost.get('post_number'))
|
||||
topic.set('last_poster', Discourse.get('currentUser'))
|
||||
|
||||
# Set the topic view for the new post
|
||||
createdPost.set('topic', topic)
|
||||
createdPost.set('created_at', new Date())
|
||||
|
||||
# If we're near the end of the topic, load new posts
|
||||
lastPost = topic.posts.last()
|
||||
|
||||
if lastPost
|
||||
diff = topic.get('highest_post_number') - lastPost.get('post_number')
|
||||
|
||||
# If the new post is within a threshold of the end of the topic,
|
||||
# add it and scroll there instead of adding the link.
|
||||
if diff < 5
|
||||
createdPost.set('scrollToAfterInsert', createdPost.get('post_number'))
|
||||
topic.pushPosts([createdPost])
|
||||
addedToStream = true
|
||||
|
||||
# Save callback
|
||||
createdPost.save (result) =>
|
||||
addedPost = false
|
||||
saving = true
|
||||
createdPost.updateFromSave(result)
|
||||
if topic
|
||||
# It's no longer a new post
|
||||
createdPost.set('newPost', false)
|
||||
topic.set('draft_sequence', result.draft_sequence)
|
||||
else
|
||||
# We created a new topic, let's show it.
|
||||
@set('composeState', CLOSED)
|
||||
saving = false
|
||||
|
||||
@set('reply', '')
|
||||
@set('createdPost', createdPost)
|
||||
|
||||
if addedToStream
|
||||
@set('composeState', CLOSED)
|
||||
else if saving
|
||||
@set('composeState', SAVING)
|
||||
|
||||
promise.resolve(post: result)
|
||||
|
||||
, (error) =>
|
||||
topic.posts.removeObject(createdPost) if topic
|
||||
errors = jQuery.parseJSON(error.responseText).errors
|
||||
promise.reject(errors[0])
|
||||
@set('composeState', OPEN)
|
||||
promise
|
||||
|
||||
saveDraft: ->
|
||||
|
||||
return if @disableDrafts
|
||||
|
||||
data =
|
||||
reply: @get("reply"),
|
||||
action: @get("action"),
|
||||
title: @get("title"),
|
||||
categoryName: @get("categoryName"),
|
||||
postId: @get("post.id"),
|
||||
archetypeId: @get('archetypeId')
|
||||
metaData: @get('metaData')
|
||||
usernames: @get('targetUsernames')
|
||||
|
||||
@set('draftStatus', Em.String.i18n('composer.saving_draft_tip'))
|
||||
Discourse.Draft.save(@get('draftKey'), @get('draftSequence'), data)
|
||||
.then(
|
||||
(=> @set('draftStatus', Em.String.i18n('composer.saved_draft_tip'))),
|
||||
(=> @set('draftStatus', 'drafts offline'))
|
||||
# (=> @set('draftStatus', Em.String.i18n('composer.saved_local_draft_tip')))
|
||||
)
|
||||
|
||||
resetDraftStatus: (->
|
||||
@set('draftStatus', null)
|
||||
).observes('reply','title')
|
||||
|
||||
|
||||
blank: (prop)->
|
||||
p = @get(prop)
|
||||
!(p && p.length > 0)
|
||||
|
||||
|
||||
Discourse.Composer.reopenClass
|
||||
|
||||
open: (opts) ->
|
||||
composer = Discourse.Composer.create()
|
||||
composer.open(opts)
|
||||
composer
|
||||
|
||||
loadDraft: (draftKey, draftSequence, draft, topic) ->
|
||||
|
||||
try
|
||||
draft = JSON.parse(draft) if draft && typeof draft == 'string'
|
||||
catch error
|
||||
draft = null
|
||||
Discourse.Draft.clear(draftKey, draftSequence)
|
||||
if draft && ((draft.title && draft.title != '') || (draft.reply && draft.reply != ''))
|
||||
composer = @open
|
||||
draftKey: draftKey
|
||||
draftSequence: draftSequence
|
||||
topic: topic
|
||||
action: draft.action
|
||||
title: draft.title
|
||||
categoryName: draft.categoryName
|
||||
postId: draft.postId
|
||||
archetypeId: draft.archetypeId
|
||||
reply: draft.reply
|
||||
metaData: draft.metaData
|
||||
usernames: draft.usernames
|
||||
draft: true
|
||||
composerState: DRAFT
|
||||
composer
|
||||
|
||||
# The status the compose view can have
|
||||
CLOSED: CLOSED
|
||||
SAVING: SAVING
|
||||
OPEN: OPEN
|
||||
DRAFT: DRAFT
|
||||
|
||||
# The actions the composer can take
|
||||
CREATE_TOPIC: CREATE_TOPIC
|
||||
PRIVATE_MESSAGE: PRIVATE_MESSAGE
|
||||
REPLY: REPLY
|
||||
EDIT: EDIT
|
||||
|
||||
# Draft key
|
||||
REPLY_AS_NEW_TOPIC_KEY: REPLY_AS_NEW_TOPIC_KEY
|
||||
|
||||
|
||||
51
app/assets/javascripts/discourse/models/draft.js.coffee
Normal file
51
app/assets/javascripts/discourse/models/draft.js.coffee
Normal file
@@ -0,0 +1,51 @@
|
||||
window.Discourse.Draft = Discourse.Model.extend({})
|
||||
|
||||
Discourse.Draft.reopenClass
|
||||
|
||||
clear: (key, sequence)->
|
||||
$.ajax
|
||||
type: 'DELETE'
|
||||
url: "/draft",
|
||||
data: {draft_key: key, sequence: sequence}
|
||||
# Discourse.KeyValueStore.remove("draft_#{key}")
|
||||
|
||||
get: (key) ->
|
||||
promise = new RSVP.Promise
|
||||
$.ajax
|
||||
url: '/draft'
|
||||
data: {draft_key: key}
|
||||
dataType: 'json'
|
||||
success: (data) =>
|
||||
promise.resolve(data)
|
||||
promise
|
||||
|
||||
getLocal: (key, current) ->
|
||||
return current
|
||||
|
||||
# disabling for now to see if it helps with siracusa issue.
|
||||
|
||||
local = Discourse.KeyValueStore.get("draft_#{key}")
|
||||
if !current || (local && local.length > current.length)
|
||||
local
|
||||
else
|
||||
current
|
||||
|
||||
save: (key, sequence, data) ->
|
||||
promise = new RSVP.Promise()
|
||||
data = if typeof data == "string" then data else JSON.stringify(data)
|
||||
$.ajax
|
||||
type: 'POST'
|
||||
url: "/draft",
|
||||
data: {draft_key: key, data: data, sequence: sequence}
|
||||
success: ->
|
||||
# don't keep local
|
||||
# Discourse.KeyValueStore.remove("draft_#{key}")
|
||||
promise.resolve()
|
||||
error: ->
|
||||
# save local
|
||||
# Discourse.KeyValueStore.set(key: "draft_#{key}", value: data)
|
||||
promise.reject()
|
||||
promise
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
window.Discourse.InputValidation = Discourse.Model.extend({})
|
||||
17
app/assets/javascripts/discourse/models/invite.js.coffee
Normal file
17
app/assets/javascripts/discourse/models/invite.js.coffee
Normal file
@@ -0,0 +1,17 @@
|
||||
window.Discourse.Invite = Discourse.Model.extend
|
||||
|
||||
rescind: ->
|
||||
$.ajax '/invites'
|
||||
type: 'DELETE'
|
||||
data: {email: @get('email')}
|
||||
|
||||
@set('rescinded', true)
|
||||
|
||||
|
||||
window.Discourse.Invite.reopenClass
|
||||
|
||||
create: (invite) ->
|
||||
result = @_super(invite)
|
||||
result.user = Discourse.User.create(result.user) if result.user
|
||||
result
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
window.Discourse.InviteList = Discourse.Model.extend Discourse.Presence,
|
||||
|
||||
empty: (->
|
||||
return @blank('pending') and @blank('redeemed')
|
||||
).property('pending.@each', 'redeemed.@each')
|
||||
|
||||
window.Discourse.InviteList.reopenClass
|
||||
|
||||
findInvitedBy: (user) ->
|
||||
promise = new RSVP.Promise()
|
||||
$.ajax
|
||||
url: "/users/#{user.get('username_lower')}/invited.json"
|
||||
success: (result) ->
|
||||
invitedList = result.invited_list
|
||||
invitedList.pending = (invitedList.pending.map (i) -> Discourse.Invite.create(i)) if invitedList.pending
|
||||
invitedList.redeemed = (invitedList.redeemed.map (i) -> Discourse.Invite.create(i)) if invitedList.redeemed
|
||||
invitedList.user = user
|
||||
promise.resolve(Discourse.InviteList.create(invitedList))
|
||||
promise
|
||||
41
app/assets/javascripts/discourse/models/mention.js.coffee
Normal file
41
app/assets/javascripts/discourse/models/mention.js.coffee
Normal file
@@ -0,0 +1,41 @@
|
||||
Discourse.Mention = (->
|
||||
|
||||
localCache = {}
|
||||
|
||||
cache = (name, valid) ->
|
||||
localCache[name] = valid
|
||||
return
|
||||
|
||||
lookupCache = (name) ->
|
||||
localCache[name]
|
||||
|
||||
lookup = (name, callback) ->
|
||||
cached = lookupCache(name)
|
||||
if cached == true || cached == false
|
||||
callback(cached)
|
||||
return false
|
||||
else
|
||||
$.get "/users/is_local_username", username: name, (r) ->
|
||||
cache(name,r.valid)
|
||||
callback(r.valid)
|
||||
return true
|
||||
|
||||
load = (e) ->
|
||||
$elem = $(e)
|
||||
return if $elem.data('mention-tested')
|
||||
|
||||
username = $elem.text()
|
||||
username = username.substr(1)
|
||||
loading = lookup username, (valid) ->
|
||||
if valid
|
||||
$elem.replaceWith("<a href='/users/#{username.toLowerCase()}' class='mention'>@#{username}</a>")
|
||||
else
|
||||
$elem.removeClass('mention-loading').addClass('mention-tested')
|
||||
|
||||
$elem.addClass('mention-loading') if loading
|
||||
|
||||
load: load
|
||||
lookup: lookup
|
||||
lookupCache: lookupCache
|
||||
)()
|
||||
|
||||
36
app/assets/javascripts/discourse/models/model.js.coffee
Normal file
36
app/assets/javascripts/discourse/models/model.js.coffee
Normal file
@@ -0,0 +1,36 @@
|
||||
window.Discourse.Model = Ember.Object.extend
|
||||
|
||||
# Our own AJAX handler that handles erronous responses
|
||||
ajax: (url, args) ->
|
||||
|
||||
# Error handler
|
||||
oldError = args.error
|
||||
args.error = (xhr) =>
|
||||
oldError($.parseJSON(xhr.responseText).errors)
|
||||
|
||||
$.ajax(url, args)
|
||||
|
||||
# Update our object from another object
|
||||
mergeAttributes: (attrs, builders) ->
|
||||
Object.keys attrs, (k, v) =>
|
||||
|
||||
# If they're in a builder we use that
|
||||
if typeof(v) == 'object' and builders and builder = builders[k]
|
||||
@set(k, Em.A()) unless @get(k)
|
||||
col = @get(k)
|
||||
v.each (obj) -> col.pushObject(builder.create(obj))
|
||||
else
|
||||
@set(k, v)
|
||||
|
||||
|
||||
window.Discourse.Model.reopenClass
|
||||
|
||||
# Given an array of values, return them in a hash
|
||||
extractByKey: (collection, klass) ->
|
||||
retval = {}
|
||||
return retval unless collection
|
||||
|
||||
collection.each (c) ->
|
||||
obj = klass.create(c)
|
||||
retval[c.id] = obj
|
||||
retval
|
||||
49
app/assets/javascripts/discourse/models/nav_item.js.coffee
Normal file
49
app/assets/javascripts/discourse/models/nav_item.js.coffee
Normal file
@@ -0,0 +1,49 @@
|
||||
# closure wrapping means this does not leak into global context
|
||||
validNavNames = ['read','popular','categories', 'favorited', 'category', 'unread', 'new', 'posted']
|
||||
validAnon = ['popular', 'category', 'categories']
|
||||
|
||||
window.Discourse.NavItem = Em.Object.extend
|
||||
|
||||
categoryName: (->
|
||||
split = @get('name').split('/')
|
||||
if (split[0] == 'category')
|
||||
split[1]
|
||||
else
|
||||
null
|
||||
).property()
|
||||
|
||||
href: (->
|
||||
# href from this item
|
||||
name = @get('name')
|
||||
if name == 'category'
|
||||
"/#{name}/#{@get('categoryName')}"
|
||||
else
|
||||
"/#{name}"
|
||||
).property()
|
||||
|
||||
Discourse.NavItem.reopenClass
|
||||
# create a nav item from the text, will return null if there is not valid nav item for this particular text
|
||||
fromText: (text, opts) ->
|
||||
countSummary = opts["countSummary"]
|
||||
loggedOn = opts["loggedOn"]
|
||||
hasCategories = opts["hasCategories"]
|
||||
|
||||
split = text.split(",")
|
||||
name = split[0]
|
||||
|
||||
testName = name.split("/")[0] # to handle category ...
|
||||
|
||||
return null if !loggedOn && !validAnon.contains(testName)
|
||||
return null if !hasCategories && testName == "categories"
|
||||
return null unless validNavNames.contains(testName)
|
||||
|
||||
opts =
|
||||
name: name
|
||||
hasIcon: name == "unread" || name == "favorited"
|
||||
filters: split.splice(1)
|
||||
|
||||
if countSummary
|
||||
opts["count"] = countSummary[name] if countSummary && countSummary[name]
|
||||
|
||||
Discourse.NavItem.create opts
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
window.Discourse.Notification = Discourse.Model.extend Discourse.Presence,
|
||||
|
||||
readClass: (->
|
||||
if @read then 'read' else ''
|
||||
).property('read')
|
||||
|
||||
url: (->
|
||||
return "" if @blank('data.topic_title')
|
||||
slug = @get('slug')
|
||||
"/t/#{slug}/#{@get('topic_id')}/#{@get('post_number')}"
|
||||
).property()
|
||||
|
||||
|
||||
rendered: (->
|
||||
notificationName = Discourse.get('site.notificationLookup')[@notification_type]
|
||||
Em.String.i18n "notifications.#{notificationName}",
|
||||
username: @data.display_username
|
||||
link: "<a href='#{@get('url')}'>#{@data.topic_title}</a>"
|
||||
).property()
|
||||
|
||||
|
||||
window.Discourse.Notification.reopenClass
|
||||
|
||||
create: (obj) ->
|
||||
result = @_super(obj)
|
||||
result.set('data', Em.Object.create(obj.data)) if obj.data
|
||||
result
|
||||
48
app/assets/javascripts/discourse/models/onebox.js.coffee
Normal file
48
app/assets/javascripts/discourse/models/onebox.js.coffee
Normal file
@@ -0,0 +1,48 @@
|
||||
Discourse.Onebox = (->
|
||||
# for now it only stores in a var, in future we can change it so it uses localStorage,
|
||||
# trouble with localStorage is that expire semantics need some thinking
|
||||
|
||||
#cacheKey = "__onebox__"
|
||||
localCache = {}
|
||||
|
||||
cache = (url, contents) ->
|
||||
localCache[url] = contents
|
||||
|
||||
#if localStorage && localStorage.setItem
|
||||
# localStorage.setItme
|
||||
null
|
||||
|
||||
lookupCache = (url) ->
|
||||
localCache[url]
|
||||
|
||||
lookup = (url, refresh, callback) ->
|
||||
cached = lookupCache(url) unless refresh
|
||||
if cached
|
||||
callback(cached)
|
||||
return false
|
||||
else
|
||||
$.get "/onebox", url: url, refresh: refresh, (html) ->
|
||||
cache(url,html)
|
||||
callback(html)
|
||||
return true
|
||||
|
||||
load = (e, refresh=false) ->
|
||||
|
||||
url = e.href
|
||||
$elem = $(e)
|
||||
return if $elem.data('onebox-loaded')
|
||||
|
||||
loading = lookup url, refresh, (html) ->
|
||||
$elem.removeClass('loading-onebox')
|
||||
$elem.data('onebox-loaded')
|
||||
return unless html
|
||||
return unless html.trim().length > 0
|
||||
$elem.replaceWith(html)
|
||||
|
||||
$elem.addClass('loading-onebox') if loading
|
||||
|
||||
load: load
|
||||
lookup: lookup
|
||||
lookupCache: lookupCache
|
||||
)()
|
||||
|
||||
242
app/assets/javascripts/discourse/models/post.js.coffee.erb
Normal file
242
app/assets/javascripts/discourse/models/post.js.coffee.erb
Normal file
@@ -0,0 +1,242 @@
|
||||
window.Discourse.Post = Ember.Object.extend Discourse.Presence,
|
||||
|
||||
# Url to this post
|
||||
url: (->
|
||||
Discourse.Utilities.postUrl(@get('topic.slug') || @get('topic_slug'), @get('topic_id'), @get('post_number'))
|
||||
).property('post_number', 'topic_id', 'topic.slug')
|
||||
|
||||
originalPostUrl: (->
|
||||
"/t/#{@get('topic_id')}/#{@get('reply_to_post_number')}"
|
||||
).property('reply_to_post_number')
|
||||
|
||||
showUserReplyTab: (->
|
||||
@get('reply_to_user') and (@get('reply_to_post_number') < (@get('post_number') - 1))
|
||||
).property('reply_to_user', 'reply_to_post_number', 'post_number')
|
||||
|
||||
firstPost: (->
|
||||
return true if @get('bestOfFirst') == true
|
||||
@get('post_number') == 1
|
||||
).property('post_number')
|
||||
|
||||
hasHistory: (-> @get('version') > 1 ).property('version')
|
||||
postElementId: (-> "post_#{@get('post_number')}").property()
|
||||
|
||||
# We only want to link to replies below if there's exactly one reply and it's below
|
||||
replyBelowUrlComputed: (->
|
||||
|
||||
return null unless @get('reply_below_post_number')
|
||||
return null if @get('reply_count') > 1
|
||||
|
||||
topic = @get('topic')
|
||||
return unless topic
|
||||
|
||||
p = @get('reply_below_post_number')
|
||||
diff = @get('reply_below_post_number') - @get('post_number')
|
||||
return topic.urlForPostNumber(p) if (diff < 3)
|
||||
null
|
||||
).property('topic', 'reply_below_post_number')
|
||||
|
||||
# We do this because right now you can't subclass a computed property and we want to add
|
||||
# plugin support. Later we should consider just subclassing it properly.
|
||||
replyBelowUrl: (->
|
||||
@get('replyBelowUrlComputed')
|
||||
).property('replyBelowUrlComputed')
|
||||
|
||||
# The class for the read icon of the post. It starts with read-icon then adds 'seen' or
|
||||
# 'last-read' if the post has been seen or is the highest post number seen so far respectively.
|
||||
bookmarkClass: (->
|
||||
result = 'read-icon'
|
||||
|
||||
return result + ' bookmarked' if @get('bookmarked')
|
||||
|
||||
topic = @get('topic')
|
||||
if topic and topic.get('last_read_post_number') == @get('post_number')
|
||||
result += ' last-read'
|
||||
else
|
||||
result += ' seen' if @get('read')
|
||||
|
||||
result
|
||||
).property('read', 'topic.last_read_post_number', 'bookmarked')
|
||||
|
||||
# Custom tooltips for the bookmark icons
|
||||
bookmarkTooltip: (->
|
||||
return Em.String.i18n('bookmarks.created') if @get('bookmarked')
|
||||
return "" unless @get('read')
|
||||
topic = @get('topic')
|
||||
if topic and topic.get('last_read_post_number') == @get('post_number')
|
||||
return Em.String.i18n('bookmarks.last_read')
|
||||
return Em.String.i18n('bookmarks.not_bookmarked')
|
||||
).property('read', 'topic.last_read_post_number', 'bookmarked')
|
||||
|
||||
bookmarkedChanged: (->
|
||||
jQuery.ajax
|
||||
url: "/posts/#{@get('id')}/bookmark",
|
||||
type: 'PUT'
|
||||
data:
|
||||
bookmarked: if @get('bookmarked') then true else false
|
||||
error: (error) =>
|
||||
errors = jQuery.parseJSON(error.responseText).errors
|
||||
bootbox.alert(errors[0])
|
||||
@toggleProperty('bookmarked')
|
||||
|
||||
).observes('bookmarked')
|
||||
|
||||
internalLinks: (->
|
||||
return null if @blank('link_counts')
|
||||
@get('link_counts').filterProperty('internal').filterProperty('title')
|
||||
).property('link_counts.@each.internal')
|
||||
|
||||
# Edits are the version - 1, so version 2 = 1 edit
|
||||
editCount: (->
|
||||
@get('version') - 1
|
||||
).property('version')
|
||||
|
||||
historyHeat: (->
|
||||
return unless updatedAt = @get('updated_at')
|
||||
|
||||
rightNow = new Date().getTime()
|
||||
|
||||
# Show heat on age
|
||||
updatedAtDate = Date.create(updatedAt).getTime()
|
||||
return 'heatmap-high' if updatedAtDate > (rightNow - 60 * 60 * 1000 * 12)
|
||||
return 'heatmap-med' if updatedAtDate > (rightNow - 60 * 60 * 1000 * 24)
|
||||
return 'heatmap-low' if updatedAtDate > (rightNow - 60 * 60 * 1000 * 48)
|
||||
|
||||
).property('updated_at')
|
||||
|
||||
flagsAvailable: (->
|
||||
Discourse.get('site.flagTypes').filter (item) =>
|
||||
@get("actionByName.#{item.get('name_key')}.can_act")
|
||||
).property('Discourse.site.flagTypes', 'actions_summary.@each.can_act')
|
||||
|
||||
actionsHistory: (->
|
||||
return null unless @present('actions_summary')
|
||||
@get('actions_summary').filter (i) ->
|
||||
return false if i.get('count') == 0
|
||||
return true if i.get('users') and i.get('users').length > 0
|
||||
return not i.get('hidden')
|
||||
).property('actions_summary.@each.users', 'actions_summary.@each.count')
|
||||
|
||||
# Save a post and call the callback when done.
|
||||
save: (complete, error) ->
|
||||
unless @get('newPost')
|
||||
# We're updating a post
|
||||
$.ajax
|
||||
url: "/posts/#{@get('id')}",
|
||||
type: 'PUT'
|
||||
data:
|
||||
post: {raw: @get('raw')}
|
||||
image_sizes: @get('imageSizes')
|
||||
success: (result) ->
|
||||
complete?(Discourse.Post.create(result))
|
||||
error: (result) -> error?(result)
|
||||
|
||||
else
|
||||
# We're saving a post
|
||||
data =
|
||||
post: @getProperties('raw', 'topic_id', 'reply_to_post_number', 'category')
|
||||
archetype: @get('archetype')
|
||||
title: @get('title')
|
||||
image_sizes: @get('imageSizes')
|
||||
target_usernames: @get('target_usernames')
|
||||
|
||||
# Put the metaData into the request
|
||||
if metaData = @get('metaData')
|
||||
data.meta_data = {}
|
||||
Ember.keys(metaData).forEach (key) -> data.meta_data[key] = metaData.get(key)
|
||||
|
||||
$.ajax
|
||||
type: 'POST'
|
||||
url: "/posts",
|
||||
data: data
|
||||
success: (result) -> complete?(Discourse.Post.create(result))
|
||||
error: (result) -> error?(result)
|
||||
|
||||
|
||||
delete: (complete) ->
|
||||
$.ajax "/posts/#{@get('id')}", type: 'DELETE', success: (result) -> complete()
|
||||
|
||||
# Update the properties of this post from an obj, ignoring cooked as we should already
|
||||
# have that rendered.
|
||||
updateFromSave: (obj) ->
|
||||
return unless obj
|
||||
Object.each obj, (key, val) =>
|
||||
return false if key == 'actions_summary'
|
||||
@set(key, val) if val
|
||||
|
||||
# Rebuild actions summary
|
||||
@set('actions_summary', Em.A())
|
||||
if obj.actions_summary
|
||||
lookup = Em.Object.create()
|
||||
obj.actions_summary.each (a) =>
|
||||
a.post = @
|
||||
a.actionType = Discourse.get("site").postActionTypeById(a.id)
|
||||
actionSummary = Discourse.ActionSummary.create(a)
|
||||
@get('actions_summary').pushObject(actionSummary)
|
||||
lookup.set(a.actionType.get('name_key'), actionSummary)
|
||||
@set('actionByName', lookup)
|
||||
|
||||
# Load replies to this post
|
||||
loadReplies: ->
|
||||
promise = new RSVP.Promise()
|
||||
@set('loadingReplies', true)
|
||||
@set('replies', [])
|
||||
jQuery.getJSON "/posts/#{@get('id')}/replies", (loaded) =>
|
||||
loaded.each (reply) =>
|
||||
@get('replies').pushObject Discourse.Post.create(reply)
|
||||
@set('loadingReplies', false)
|
||||
promise.resolve()
|
||||
promise
|
||||
|
||||
loadVersions: (callback) ->
|
||||
$.get "/posts/#{@get('id')}/versions.json", (result) -> callback(result)
|
||||
|
||||
window.Discourse.Post.reopenClass
|
||||
|
||||
REGULAR_TYPE: <%= Post::REGULAR %>
|
||||
MODERATOR_ACTION_TYPE: <%= Post::MODERATOR_ACTION %>
|
||||
|
||||
|
||||
createActionSummary: (result) ->
|
||||
if (result.actions_summary)
|
||||
lookup = Em.Object.create()
|
||||
result.actions_summary = result.actions_summary.map (a) ->
|
||||
a.post = result
|
||||
a.actionType = Discourse.get("site").postActionTypeById(a.id)
|
||||
actionSummary = Discourse.ActionSummary.create(a)
|
||||
lookup.set(a.actionType.get('name_key'), actionSummary)
|
||||
|
||||
actionSummary
|
||||
result.set('actionByName', lookup)
|
||||
|
||||
create: (obj, topic) ->
|
||||
result = @_super(obj)
|
||||
|
||||
@createActionSummary(result)
|
||||
result.set('reply_to_user', Discourse.User.create(obj.reply_to_user)) if obj.reply_to_user
|
||||
result.set('topic', topic)
|
||||
result
|
||||
|
||||
deleteMany: (posts) ->
|
||||
$.ajax "/posts/destroy_many",
|
||||
type: 'DELETE',
|
||||
data:
|
||||
post_ids: posts.map (p) -> p.get('id')
|
||||
|
||||
loadVersion: (postId, version, callback) ->
|
||||
jQuery.getJSON "/posts/#{postId}.json?version=#{version}", (result) =>
|
||||
callback(Discourse.Post.create(result))
|
||||
|
||||
loadByPostNumber: (topicId, postId, callback) ->
|
||||
jQuery.getJSON "/posts/by_number/#{topicId}/#{postId}.json", (result) => callback(Discourse.Post.create(result))
|
||||
|
||||
loadQuote: (postId) ->
|
||||
promise = new RSVP.Promise
|
||||
jQuery.getJSON "/posts/#{postId}.json", (result) =>
|
||||
post = Discourse.Post.create(result)
|
||||
promise.resolve(Discourse.BBCode.buildQuoteBBCode(post, post.get('raw')))
|
||||
promise
|
||||
|
||||
load: (postId, callback) ->
|
||||
jQuery.getJSON "/posts/#{postId}.json", (result) => callback(Discourse.Post.create(result))
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
window.Discourse.PostActionType = Em.Object.extend
|
||||
|
||||
alsoName: (->
|
||||
return Em.String.i18n('post.actions.flag') if @get('is_flag')
|
||||
@get('name')
|
||||
).property('is_flag', 'name')
|
||||
|
||||
alsoNameLower: (->
|
||||
@get('alsoName')?.toLowerCase()
|
||||
).property('alsoName')
|
||||
|
||||
36
app/assets/javascripts/discourse/models/site.js.coffee.erb
Normal file
36
app/assets/javascripts/discourse/models/site.js.coffee.erb
Normal file
@@ -0,0 +1,36 @@
|
||||
window.Discourse.Site = Ember.Object.extend
|
||||
|
||||
notificationLookup: (->
|
||||
result = Array()
|
||||
Object.keys @get('notification_types'), (k, v) -> result[v] = k
|
||||
result
|
||||
).property('notification_types')
|
||||
|
||||
flagTypes: (->
|
||||
postActionTypes = @get('post_action_types')
|
||||
return [] unless postActionTypes
|
||||
postActionTypes.filterProperty('is_flag', true)
|
||||
).property('post_action_types.@each')
|
||||
|
||||
postActionTypeById: (id) -> @get("postActionByIdLookup.action#{id}")
|
||||
|
||||
|
||||
window.Discourse.Site.reopenClass
|
||||
|
||||
create: (obj) ->
|
||||
Object.tap @_super(obj), (result) =>
|
||||
|
||||
if result.categories
|
||||
result.categories = result.categories.map (c) => Discourse.Category.create(c)
|
||||
|
||||
if result.post_action_types
|
||||
result.postActionByIdLookup = Em.Object.create()
|
||||
result.post_action_types = result.post_action_types.map (p) =>
|
||||
actionType = Discourse.PostActionType.create(p)
|
||||
result.postActionByIdLookup.set("action#{p.id}", actionType)
|
||||
actionType
|
||||
|
||||
|
||||
if result.archetypes
|
||||
result.archetypes = result.archetypes.map (a) => Discourse.Archetype.create(a)
|
||||
|
||||
307
app/assets/javascripts/discourse/models/topic.js.coffee
Normal file
307
app/assets/javascripts/discourse/models/topic.js.coffee
Normal file
@@ -0,0 +1,307 @@
|
||||
Discourse.Topic = Discourse.Model.extend Discourse.Presence,
|
||||
categoriesBinding: 'Discourse.site.categories'
|
||||
|
||||
fewParticipants: (->
|
||||
return null unless @present('participants')
|
||||
return @get('participants').slice(0, 3)
|
||||
).property('participants')
|
||||
|
||||
canConvertToRegular: (->
|
||||
a = @get('archetype')
|
||||
a != 'regular' && a != 'private_message'
|
||||
).property('archetype')
|
||||
|
||||
convertArchetype: (archetype) ->
|
||||
a = @get('archetype')
|
||||
if a != 'regular' && a != 'private_message'
|
||||
@set('archetype','regular')
|
||||
jQuery.post @get('url'),
|
||||
_method: 'put'
|
||||
archetype: 'regular'
|
||||
|
||||
category: (->
|
||||
if @get('categories')
|
||||
@get('categories').findProperty('name', @get('categoryName'))
|
||||
).property('categoryName', 'categories')
|
||||
|
||||
url: (->
|
||||
"/t/#{@get('slug')}/#{@get('id')}"
|
||||
).property('id', 'slug')
|
||||
|
||||
# Helper to build a Url with a post number
|
||||
urlForPostNumber: (postNumber) ->
|
||||
url = @get('url')
|
||||
url += "/#{postNumber}" if postNumber and (postNumber > 1)
|
||||
url
|
||||
|
||||
lastReadUrl: (-> @urlForPostNumber(@get('last_read_post_number')) ).property('url', 'last_read_post_number')
|
||||
lastPostUrl: (-> @urlForPostNumber(@get('highest_post_number')) ).property('url', 'highest_post_number')
|
||||
|
||||
# The last post in the topic
|
||||
lastPost: -> @get('posts').last()
|
||||
|
||||
postsChanged: ( ->
|
||||
posts = @get('posts')
|
||||
last = posts.last()
|
||||
return unless last && last.set && !last.lastPost
|
||||
|
||||
posts.each (p)->
|
||||
p.set('lastPost', false) if p.lastPost
|
||||
last.set('lastPost',true)
|
||||
return true
|
||||
).observes('posts.@each','posts')
|
||||
|
||||
# The amount of new posts to display. It might be different than what the server
|
||||
# tells us if we are still asynchronously flushing our "recently read" data.
|
||||
# So take what the browser has seen into consideration.
|
||||
displayNewPosts: (->
|
||||
|
||||
if highestSeen = Discourse.get('highestSeenByTopic')[@get('id')]
|
||||
delta = highestSeen - @get('last_read_post_number')
|
||||
if delta > 0
|
||||
result = (@get('new_posts') - delta)
|
||||
result = 0 if result < 0
|
||||
return result
|
||||
|
||||
@get('new_posts')
|
||||
|
||||
).property('new_posts', 'id')
|
||||
|
||||
displayTitle: (->
|
||||
return null unless @get('title')
|
||||
return @get('title') unless @get('category')
|
||||
matches = @get('title').match(/^([a-zA-Z0-9]+\: )?(.*)/)
|
||||
return matches[2]
|
||||
).property('title')
|
||||
|
||||
# The coldmap class for the age of the topic
|
||||
ageCold: (->
|
||||
return unless lastPost = @get('last_posted_at')
|
||||
return unless createdAt = @get('created_at')
|
||||
|
||||
daysSinceEpoch = (dt) ->
|
||||
# 1000 * 60 * 60 * 24 = days since epoch
|
||||
dt.getTime() / 86400000
|
||||
|
||||
# Show heat on age
|
||||
nowDays = daysSinceEpoch(new Date())
|
||||
createdAtDays = daysSinceEpoch(new Date(createdAt))
|
||||
if daysSinceEpoch(new Date(lastPost)) > nowDays - 90
|
||||
return 'coldmap-high' if createdAtDays < nowDays - 60
|
||||
return 'coldmap-med' if createdAtDays < nowDays - 30
|
||||
return 'coldmap-low' if createdAtDays < nowDays - 14
|
||||
|
||||
null
|
||||
).property('age', 'created_at')
|
||||
|
||||
archetypeObject: (->
|
||||
Discourse.get('site.archetypes').findProperty('id', @get('archetype'))
|
||||
).property('archetype')
|
||||
|
||||
isPrivateMessage: (->
|
||||
@get('archetype') == 'private_message'
|
||||
).property('archetype')
|
||||
|
||||
# Does this topic only have a single post?
|
||||
singlePost: (->
|
||||
@get('posts_count') == 1
|
||||
).property('posts_count')
|
||||
|
||||
toggleStatus: (property) ->
|
||||
@toggleProperty(property)
|
||||
jQuery.post "#{@get('url')}/status", _method: 'put', status: property, enabled: if @get(property) then 'true' else 'false'
|
||||
|
||||
toggleStar: ->
|
||||
@toggleProperty('starred')
|
||||
jQuery.ajax
|
||||
url: "#{@get('url')}/star"
|
||||
type: 'PUT'
|
||||
data:
|
||||
starred: if @get('starred') then true else false
|
||||
error: (error) =>
|
||||
@toggleProperty('starred')
|
||||
errors = jQuery.parseJSON(error.responseText).errors
|
||||
bootbox.alert(errors[0])
|
||||
|
||||
# Save any changes we've made to the model
|
||||
save: ->
|
||||
# Don't save unless we can
|
||||
return unless @get('can_edit')
|
||||
|
||||
jQuery.post @get('url'),
|
||||
_method: 'put'
|
||||
title: @get('title')
|
||||
category: @get('category.name')
|
||||
|
||||
# Reset our read data for this topic
|
||||
resetRead: (callback) ->
|
||||
$.ajax "/t/#{@get('id')}/timings",
|
||||
type: 'DELETE'
|
||||
success: -> callback?()
|
||||
|
||||
# Invite a user to this topic
|
||||
inviteUser: (user) ->
|
||||
$.ajax
|
||||
type: 'POST'
|
||||
url: "/t/#{@get('id')}/invite",
|
||||
data: {user: user}
|
||||
|
||||
# Delete this topic
|
||||
delete: (callback) ->
|
||||
$.ajax "/t/#{@get('id')}",
|
||||
type: 'DELETE'
|
||||
success: -> callback?()
|
||||
|
||||
# Load the posts for this topic
|
||||
loadPosts: (opts) ->
|
||||
|
||||
opts = {} unless opts
|
||||
|
||||
# Load the first post by default
|
||||
opts.nearPost ||= 1 unless opts.bestOf
|
||||
|
||||
|
||||
# If we already have that post in the DOM, jump to it
|
||||
return if Discourse.TopicView.scrollTo @get('id'), opts.nearPost
|
||||
|
||||
Discourse.Topic.find @get('id'),
|
||||
nearPost: opts.nearPost
|
||||
bestOf: opts.bestOf
|
||||
trackVisit: opts.trackVisit
|
||||
.then (result) =>
|
||||
|
||||
# If loading the topic succeeded...
|
||||
# Update the slug if different
|
||||
@set('slug', result.slug) if result.slug
|
||||
|
||||
# If we want to scroll to a post that doesn't exist, just pop them to the closest
|
||||
# one instead. This is likely happening due to a deleted post.
|
||||
opts.nearPost = parseInt(opts.nearPost)
|
||||
closestPostNumber = 0
|
||||
postDiff = Number.MAX_VALUE
|
||||
result.posts.each (p) ->
|
||||
diff = Math.abs(p.post_number - opts.nearPost)
|
||||
if diff < postDiff
|
||||
postDiff = diff
|
||||
closestPostNumber = p.post_number
|
||||
return false if diff == 0
|
||||
opts.nearPost = closestPostNumber
|
||||
|
||||
|
||||
@get('participants').clear() if @get('participants')
|
||||
|
||||
@set('suggested_topics', Em.A()) if result.suggested_topics
|
||||
@mergeAttributes result, suggested_topics: Discourse.Topic
|
||||
@set('posts', Em.A())
|
||||
|
||||
if opts.trackVisit and result.draft and result.draft.length > 0
|
||||
Discourse.openComposer
|
||||
draft: Discourse.Draft.getLocal(result.draft_key, result.draft)
|
||||
draftKey: result.draft_key
|
||||
draftSequence: result.draft_sequence
|
||||
topic: @
|
||||
ignoreIfChanged: true
|
||||
|
||||
# Okay this is weird, but let's store the length of the next post
|
||||
# when there
|
||||
lastPost = null
|
||||
result.posts.each (p) =>
|
||||
|
||||
# Determine whether there is a short reply below
|
||||
if (lastPost && lastPost.get('reply_count') == 1) && (p.reply_to_post_number == lastPost.get('post_number')) && (p.cooked.length < Discourse.SiteSettings.max_length_show_reply)
|
||||
lastPost.set('replyFollowing', true)
|
||||
|
||||
p.scrollToAfterInsert = opts.nearPost
|
||||
post = Discourse.Post.create(p)
|
||||
post.set('topic', @)
|
||||
@get('posts').pushObject(post)
|
||||
|
||||
lastPost = post
|
||||
|
||||
@set('loaded', true)
|
||||
, (result) =>
|
||||
@set('missing', true)
|
||||
@set('message', Em.String.i18n('topic.not_found.description'))
|
||||
|
||||
notificationReasonText: (->
|
||||
locale_string = "topic.notifications.reasons.#{@notification_level}"
|
||||
if typeof @notifications_reason_id == 'number'
|
||||
locale_string += "_#{@notifications_reason_id}"
|
||||
Em.String.i18n(locale_string, username: Discourse.currentUser.username.toLowerCase())
|
||||
).property('notifications_reason_id')
|
||||
|
||||
updateNotifications: (v)->
|
||||
@set('notification_level', v)
|
||||
@set('notifications_reason_id', null)
|
||||
$.ajax
|
||||
url: "/t/#{@get('id')}/notifications"
|
||||
type: 'POST'
|
||||
data: {notification_level: v}
|
||||
|
||||
# use to add post to topics protecting from dupes
|
||||
pushPosts: (newPosts)->
|
||||
map = {}
|
||||
posts = @get('posts')
|
||||
posts.each (p)->
|
||||
map["#{p.post_number}"] = true
|
||||
|
||||
newPosts.each (p)->
|
||||
posts.pushObject(p) unless map[p.get('post_number')]
|
||||
|
||||
window.Discourse.Topic.reopenClass
|
||||
|
||||
NotificationLevel:
|
||||
WATCHING: 3
|
||||
TRACKING: 2
|
||||
REGULAR: 1
|
||||
MUTE: 0
|
||||
|
||||
# Load a topic, but accepts a set of filters
|
||||
#
|
||||
# options:
|
||||
# onLoad - the callback after the topic is loaded
|
||||
find: (topicId, opts) ->
|
||||
url = "/t/#{topicId}"
|
||||
url += "/#{opts.nearPost}" if opts.nearPost
|
||||
|
||||
data = {}
|
||||
data.posts_after = opts.postsAfter if opts.postsAfter
|
||||
data.posts_before = opts.postsBefore if opts.postsBefore
|
||||
data.track_visit = true if opts.trackVisit
|
||||
|
||||
# Add username filters if we have them
|
||||
if opts.userFilters and opts.userFilters.length > 0
|
||||
data.username_filters = []
|
||||
opts.userFilters.forEach (username) => data.username_filters.push(username)
|
||||
|
||||
# Add the best of filter if we have it
|
||||
data.best_of = true if opts.bestOf == true
|
||||
|
||||
# Check the preload store. If not, load it via JSON
|
||||
promise = new RSVP.Promise()
|
||||
PreloadStore.get("topic_#{topicId}", -> jQuery.getJSON url + ".json", data).then (result) ->
|
||||
first = result.posts.first()
|
||||
first.bestOfFirst = true if first and opts and opts.bestOf
|
||||
promise.resolve(result)
|
||||
, (result) -> promise.reject(result)
|
||||
|
||||
promise
|
||||
|
||||
# Create a topic from posts
|
||||
movePosts: (topicId, title, postIds) ->
|
||||
$.ajax "/t/#{topicId}/move-posts",
|
||||
type: 'POST'
|
||||
data:
|
||||
title: title
|
||||
post_ids: postIds
|
||||
|
||||
create: (obj, topicView) ->
|
||||
Object.tap @_super(obj), (result) =>
|
||||
if result.participants
|
||||
result.participants = result.participants.map (u) => Discourse.User.create(u)
|
||||
|
||||
result.fewParticipants = Em.A()
|
||||
result.participants.each (p) =>
|
||||
return false if result.fewParticipants.length >= 8
|
||||
result.fewParticipants.pushObject(p)
|
||||
true
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user