PageRenderTime 28ms CodeModel.GetById 21ms RepoModel.GetById 1ms app.codeStats 0ms

/public/assets/frontpage.js

https://bitbucket.org/paulsnar/river
JavaScript | 765 lines | 626 code | 126 blank | 13 comment | 66 complexity | cba007588428330900e4e9c9fd82f0de MD5 | raw file
  1. (function(window) {
  2. var River = { }
  3. if ('River' in window) {
  4. River = window.River
  5. } else {
  6. window.River = River
  7. }
  8. var Frontpage = River.Frontpage = { }
  9. var Common = River.Common
  10. var Feed = Backbone.Model.extend({
  11. idAttribute: '_id',
  12. })
  13. var Feeds = Backbone.Collection.extend({
  14. model: Feed,
  15. url: River.Config.urlbase + '1/front/feeds',
  16. })
  17. /*
  18. A news entry.
  19. Note that #.collection reflects the timeline it is a part of, however a
  20. #.feed value should be provided if the timeline is not equal to the feed.
  21. */
  22. var Entry = Backbone.Model.extend({
  23. idAttribute: '_id',
  24. initialize: function(attrs, opts) {
  25. this.feed = opts.feed
  26. this.feed = _.result(this, 'feed')
  27. },
  28. })
  29. var Timeline = Backbone.Collection.extend({
  30. model: Entry,
  31. url: River.Config.urlbase + '1/front/entries',
  32. comparator: function(a, b) {
  33. return b.get('published_at') - a.get('published_at')
  34. },
  35. })
  36. Frontpage.attach = function($root, kickstart) {
  37. var feeds = new Feeds(kickstart.feeds)
  38. var filter
  39. var entries = new Timeline(kickstart.entries, {
  40. model: function(attrs, opts) {
  41. if ( ! ('feed' in opts)) {
  42. opts.feed = feeds.get(attrs.of_feed)
  43. }
  44. return new Entry(attrs, opts)
  45. },
  46. })
  47. var lastSeenTimestamp = window.localStorage.getItem(
  48. 'river://data/last-seen')
  49. if (lastSeenTimestamp !== null) {
  50. lastSeenTimestamp = parseInt(lastSeenTimestamp, 10)
  51. }
  52. var _setLastSeen = (function() {
  53. var _t = null, _pending = 0
  54. return function(newTs) {
  55. if (_t !== null) {
  56. if (newTs < _pending) {
  57. return
  58. }
  59. _pending = newTs
  60. clearTimeout(_t)
  61. }
  62. _t = setTimeout(function() {
  63. window.localStorage.setItem('river://data/last-seen', newTs)
  64. clearTimeout(_t)
  65. _t = null
  66. }, 1000)
  67. }
  68. })(),
  69. _handleNewEntryForLastSeen = function(_entry) {
  70. var ts = _entry.get('published_at')
  71. if (ts <= lastSeenTimestamp) {
  72. _entry.set('_seen', true)
  73. } else {
  74. _entry.set('_seen', false)
  75. _setLastSeen(ts)
  76. }
  77. }
  78. entries.on('add', _handleNewEntryForLastSeen)
  79. entries.each(_handleNewEntryForLastSeen)
  80. var timelineView = new TimelineView({
  81. el: $root,
  82. entries: entries,
  83. feeds: feeds,
  84. }).render()
  85. filter = timelineView.filterView
  86. // entries.add(kickstart.entries)
  87. $('[data-timestamp]').each(function() {
  88. var $this = $(this),
  89. ts = $this.data('timestamp')
  90. $this.text(Common.humanizeTimestamp(ts))
  91. })
  92. var _entryStaging = new Timeline(),
  93. _fetchNewEntryPage = function(since, done) {
  94. $.ajax({
  95. method: 'GET',
  96. url: River.Config.urlbase + '1/front/entries',
  97. data: {
  98. since: since,
  99. },
  100. dataType: 'json',
  101. success: function(newEntries) {
  102. done(null, newEntries)
  103. },
  104. error: function(xhr) {
  105. console.error('[bg] API status %d', xhr.status)
  106. done(xhr)
  107. },
  108. })
  109. }
  110. setInterval(function() {
  111. var since =
  112. (_entryStaging.length ?
  113. _entryStaging : entries).reduce(function(acc, _entry) {
  114. var createdAt = _entry.get('created_at')
  115. return (createdAt > acc) ? createdAt : acc
  116. }, 0)
  117. _fetchNewEntryPage(since, function __process(err, newEntries) {
  118. if (err) { return }
  119. _entryStaging.add(newEntries)
  120. if (newEntries.length === River.Config.page_size) {
  121. return _fetchNewEntryPage(_entryStaging.reduce(function(acc, _entry) {
  122. var createdAt = _entry.get('created_at')
  123. return (createdAt > acc) ? createdAt : acc
  124. }, 0), __process)
  125. }
  126. var unseenFeeds = _entryStaging.map(function(e) {
  127. return e.get('of_feed')
  128. }).reduce(function(acc, f) {
  129. if (acc.indexOf(f) === -1) {
  130. return acc.concat([ f ])
  131. }
  132. return acc
  133. }, [ ]).map(function(f) {
  134. if ( ! feeds.get(f)) {
  135. return f
  136. }
  137. return null
  138. }).reduce(function(acc, f) {
  139. if (f !== null) {
  140. return acc.concat([ f ])
  141. }
  142. return acc
  143. }, [ ])
  144. if (unseenFeeds.length !== 0) {
  145. feeds.fetch({
  146. success: timelineView.trigger.bind(timelineView,
  147. 'new_entries', _entryStaging.length),
  148. error: function(xhr) {
  149. console.error('[bg] API status %d', xhr.status)
  150. },
  151. })
  152. } else {
  153. timelineView.trigger('new_entries', _entryStaging.length)
  154. }
  155. })
  156. }, 30000)
  157. timelineView.on('merge_new_entries', function() {
  158. for (var i = _entryStaging.length - 1; i >= 0; i -= 1) {
  159. var e = _entryStaging.at(i)
  160. e.feed = feeds.get(e.get('of_feed'))
  161. entries.add(e) // filtering occurs by event propagation
  162. }
  163. _entryStaging.reset()
  164. timelineView.trigger('new_entries', 0)
  165. })
  166. }
  167. var LOAD_MORE = 'Ielādēt vairāk',
  168. LOADING = 'Notiek ielāde…'
  169. var TimelineView = Backbone.View.extend({
  170. initialize: function(opts) {
  171. this.feeds = opts.feeds
  172. this.entries = opts.entries
  173. this.filterView = new FilterSelectionView({
  174. feeds: this.feeds,
  175. entries: this.entries,
  176. })
  177. this.feedView = new FeedView({
  178. el: this.$('.feed-container'),
  179. entries: opts.entries,
  180. })
  181. this.$loadMoreButton =
  182. $('<button>')
  183. .attr('id', 'js-tl-load-more')
  184. .addClass('feed-entry button')
  185. .text(LOAD_MORE)
  186. this._loadMoreButtonActive = false
  187. this.$newEntryIndicator =
  188. $('<button>')
  189. .attr('id', 'js-tl-unread-notification')
  190. .addClass('feed-unread-notification')
  191. .hide()
  192. this.listenTo(this, 'new_entries', this._displayNewEntryIndicator)
  193. },
  194. events: {
  195. 'click button#js-tl-unread-notification': '_mergeNewEntries',
  196. 'click button#js-tl-load-more': '_loadMoreEntries',
  197. },
  198. render: function() {
  199. var $feedView = this.feedView.$el
  200. var $filterView = this.filterView.render().$el
  201. $feedView.before($filterView)
  202. $feedView.after(this.$loadMoreButton)
  203. $filterView.before(this.$newEntryIndicator)
  204. return this
  205. },
  206. _displayNewEntryIndicator: function(n) {
  207. if (n < 1) {
  208. this.$newEntryIndicator.hide()
  209. } else {
  210. var pluralize = ! ((n % 10) === 1 && (n % 100) !== 11)
  211. this.$newEntryIndicator
  212. .show()
  213. .text(n + (pluralize ? ' jaunas vēstis' : ' jauna vēsts'))
  214. }
  215. },
  216. _mergeNewEntries: function() {
  217. this.trigger('merge_new_entries')
  218. },
  219. _loadMoreEntries: function(e) {
  220. var self = this
  221. if ('preventDefault' in e) { e.preventDefault() }
  222. if (self._loadMoreButtonActive) { return }
  223. self._loadMoreButtonActive = true
  224. self.$loadMoreButton
  225. .addClass('disabled')
  226. .text(LOADING)
  227. self.entries.fetch({
  228. remove: false,
  229. merge: false,
  230. feed: function() {
  231. return self.feeds.get(this.get('of_feed'))
  232. },
  233. data: {
  234. before: self.entries.at(-1).get('published_at'),
  235. },
  236. complete: function() {
  237. self.$loadMoreButton
  238. .removeClass('disabled')
  239. .text(LOAD_MORE)
  240. self._loadMoreButtonActive = false
  241. },
  242. error: function(xhr) {
  243. alert('Sorry, something is horribly wrong.')
  244. console.error('API status %d', xhr.status)
  245. },
  246. })
  247. },
  248. })
  249. var CB_OFF = 0,
  250. CB_ON = 1,
  251. CB_PARTIAL = 2
  252. var FilterSelectionView = Backbone.View.extend({
  253. className: 'fp-filter',
  254. initialize: function(opts) {
  255. this._feeds = opts.feeds
  256. this._entries = opts.entries
  257. this.listenTo(this._entries, 'add', this._mergeFilteredStatus)
  258. this.listenTo(this._feeds, 'add', this._handleAdd)
  259. this.listenTo(this._feeds, 'remove', this._handleRemove)
  260. this.listenTo(this._feeds, 'reset', this._handleReset)
  261. this.listenTo(this._feeds, 'sort', this._handleReset)
  262. this.feeds =
  263. opts.feeds
  264. .map(function(feed) { return feed.id })
  265. this.categories =
  266. opts.feeds
  267. .map(function(f) { return f.get('category') })
  268. .reduce(function(acc, cat) {
  269. if (cat === null) { return acc }
  270. if (acc.indexOf(cat) === -1) {
  271. return acc.concat([ cat ])
  272. }
  273. return acc
  274. }, [ ])
  275. this.$feeds = { }
  276. for (var i = 0; i < this.feeds.length; i += 1) {
  277. var feed = this.feeds[i],
  278. _feed = this._feeds.get(feed),
  279. view = new FilterUnitView({
  280. type: 'feed',
  281. _id: feed,
  282. name: _feed.get('name'),
  283. })
  284. this.listenTo(view, 'changed', this._handleUnitChanged)
  285. this.$feeds[feed] = view
  286. }
  287. this.$categories = { }
  288. for (var i = 0; i < this.categories.length; i += 1) {
  289. var cat = this.categories[i],
  290. view = new FilterUnitView({
  291. type: 'category',
  292. _id: cat
  293. })
  294. this.listenTo(view, 'changed', this._handleUnitChanged)
  295. this.$categories[cat] = view
  296. }
  297. this._entries.each(this._mergeFilteredStatus, this)
  298. },
  299. _handleAdd: function(_feed) {
  300. var feed = _feed.id,
  301. $feed = new FilterUnitView({
  302. type: 'feed',
  303. _id: feed,
  304. name: _feed.get('name'),
  305. })
  306. var lastFeed = this.feeds[this.feeds.length - 1],
  307. $lastFeed = this.$feeds[lastFeed]
  308. $lastFeed.$el.after($feed.render().el)
  309. this.feeds.push(feed)
  310. this.$feeds[feed] = $feed
  311. this.listenTo($feed, 'changed', this._handleUnitChanged)
  312. this._recheckCategories()
  313. },
  314. _handleRemove: function(_feed) {
  315. var feed = _feed.id,
  316. $feed = this.$feeds[feed]
  317. $feed.remove()
  318. delete this.$feeds[feed]
  319. this.feeds.splice(this.feeds.indexOf(feed), 1)
  320. this._recheckCategories()
  321. },
  322. _handleReset: function(_feeds) {
  323. var self = this
  324. var keep = { }, add = [ ]
  325. _feeds.each(function(_feed) {
  326. var feed = _feed.id
  327. if (feed in self.$feeds) {
  328. keep[feed] = true
  329. } else {
  330. add.push(_feed)
  331. }
  332. })
  333. for (var i = self.feeds.length - 1; i >= 0; i -= 1) {
  334. var feed = self.feeds[i]
  335. if ( ! (feed in keep)) {
  336. self.feeds.splice(i, 1)
  337. self.$feeds[feed].remove()
  338. delete self.$feeds[feed]
  339. }
  340. }
  341. for (var i = 0; i < add.length; i += 1) {
  342. self._handleAdd(add[i])
  343. }
  344. self._recheckCategories()
  345. self._handleSort(_feeds)
  346. },
  347. _handleSort: function(_feeds) {
  348. var $lastEl = this.$feeds[this.feeds[0]].$el
  349. this.feeds = _feeds.map(function(_feed) { return _feed.id })
  350. $lastEl.before(this.$feeds[this.feeds[0]].$el)
  351. var $lastEl = this.$feeds[this.feeds[0]].$el
  352. for (var i = 1; i < this.feeds.length; i += 1) {
  353. var $el = this.$feeds[this.feeds[i]].$el
  354. $lastEl.after($el)
  355. $lastEl = $el
  356. }
  357. },
  358. _recheckCategories: function() {
  359. var self = this
  360. var keep = { }, add = [ ]
  361. self._feeds.each(function(_feed) {
  362. var cat = _feed.get('category')
  363. if (typeof cat !== 'undefined') {
  364. if (cat in self.$categories) {
  365. keep[cat] = true
  366. } else {
  367. add.push(cat)
  368. }
  369. }
  370. })
  371. for (var i = self.categories.length - 1; i >= 0; i -= 1) {
  372. var cat = self.categories[i]
  373. if ( ! (cat in keep)) {
  374. self.categories.splice(i, 1)
  375. self.$categories[cat].remove()
  376. delete self.$categories[cat]
  377. }
  378. }
  379. for (var i = 0; i < add.length; i += 1) {
  380. var cat = add[i],
  381. $cat = new FilterUnitView({
  382. type: 'category',
  383. _id: cat,
  384. })
  385. var lastCat = this.categories[this.categories.length - 1],
  386. $lastCat = this.$categories[lastCat]
  387. $lastCat.$el.after($cat.render().el)
  388. this.categories.push(cat)
  389. this.$categories[cat] = $cat
  390. this.listenTo($cat, 'changed', this._handleUnitChanged)
  391. }
  392. },
  393. render: function() {
  394. this.$el.append(
  395. $('<span>')
  396. .addClass('fp-filter-label')
  397. .text('Filtrēt: ')
  398. )
  399. for (var i = 0; i < this.feeds.length; i += 1) {
  400. var feed = this.feeds[i],
  401. view = this.$feeds[feed]
  402. this.$el.append(view.render().el)
  403. }
  404. for (var i = 0; i < this.categories.length; i += 1) {
  405. var cat = this.categories[i],
  406. view = this.$categories[cat]
  407. this.$el.append(view.render().el)
  408. }
  409. return this
  410. },
  411. _handleUnitChanged: function(type, name, state) {
  412. var self = this
  413. if (type === 'feed') {
  414. var feed = self._feeds.get(name),
  415. cat = feed.get('category')
  416. if (typeof cat !== 'undefined') {
  417. var otherFeedStates = self._feeds.where({ category: cat })
  418. .map(function(feed) {
  419. return self.$feeds[feed.id].state
  420. })
  421. var summaryState = otherFeedStates.reduce(function(acc, st) {
  422. if (acc === null) {
  423. return st
  424. } else if (st === acc) {
  425. return acc
  426. } else {
  427. return CB_PARTIAL
  428. }
  429. }, null)
  430. this.$categories[cat].set(summaryState, true)
  431. }
  432. this._propagateFeedFiltered(feed.id, ! state)
  433. } else if (type === 'category') {
  434. var affectedFeeds = self._feeds.where({ category: name })
  435. for (var i = 0; i < affectedFeeds.length; i += 1) {
  436. var feed = affectedFeeds[i]
  437. self.$feeds[feed.id].set(state, true)
  438. this._propagateFeedFiltered(feed.id, ! state)
  439. }
  440. }
  441. },
  442. _propagateFeedFiltered: function(feed, filtered) {
  443. this._entries.each(function(entry) {
  444. if (entry.feed.id === feed) {
  445. entry.set('_filtered', filtered)
  446. }
  447. })
  448. },
  449. _mergeFilteredStatus: function(entry) {
  450. var filtered = ! this.$feeds[entry.feed.id].state
  451. entry.set('_filtered', filtered)
  452. },
  453. })
  454. var FilterUnitView = Backbone.View.extend({
  455. tagName: 'label',
  456. className: 'fp-filter-crit',
  457. initialize: function(opts) {
  458. this.type = opts.type
  459. this._id = opts._id
  460. this.name = opts.name || opts._id
  461. this.$checkbox = $('<input>')
  462. .prop('type', 'checkbox')
  463. .prop('checked', true)
  464. .prop('indeterminate', false)
  465. .on('input change', this._handleToggle.bind(this))
  466. .hide()
  467. this.$label = $('<span>')
  468. .addClass('fp-filter-crit-name')
  469. .text(this.name)
  470. this.set(CB_ON)
  471. },
  472. remove: function() {
  473. this.$checkbox.off()
  474. this.$label.off()
  475. Backbone.View.prototype.remove.call(this)
  476. },
  477. render: function() {
  478. this.$el.addClass(this.type)
  479. this.$el.append(this.$checkbox)
  480. this.$el.append(this.$label)
  481. return this
  482. },
  483. set: function(state, silent) {
  484. if ([ CB_OFF, CB_ON, CB_PARTIAL ].indexOf(state) === -1) {
  485. debugger
  486. }
  487. //
  488. // | CB_OFF | CB_ON | CB_PARTIAL |
  489. // --------------+--------+-------+------------+
  490. // checked | false | true | true |
  491. // indeterminate | false | false | true |
  492. //
  493. this.$checkbox.prop('checked', state !== CB_OFF)
  494. this.$checkbox.prop('indeterminate', state === CB_PARTIAL)
  495. this.$el.removeClass('is-on is-off is-partial')
  496. this.state = state
  497. switch (state) {
  498. case CB_OFF: this.$el.addClass('is-off'); break
  499. case CB_ON: this.$el.addClass('is-on'); break
  500. case CB_PARTIAL: this.$el.addClass('is-partial'); break
  501. }
  502. if ( ! silent) {
  503. this.trigger('changed', this.type, this._id, state)
  504. }
  505. return this
  506. },
  507. _handleToggle: function(e) {
  508. this.set(e.target.checked ? CB_ON : CB_OFF)
  509. },
  510. })
  511. var FeedView = Backbone.View.extend({
  512. initialize: function(opts) {
  513. var self = this
  514. self._entries = opts.entries
  515. self.listenTo(self._entries, 'add', self._handleAdd)
  516. self.listenTo(self._entries, 'remove', self._handleRemove)
  517. self.listenTo(self._entries, 'reset', self._handleReset)
  518. self.listenTo(self._entries, 'sort', self._handleSort)
  519. self.$entries = { }
  520. self.entries = self._entries.map(function(_entry) {
  521. var entry = _entry.id,
  522. $el = self.$('a.feed-entry[data-id="' + entry + '"]'),
  523. $entry = new EntryView({ model: _entry, el: $el })
  524. self.$entries[entry] = $entry
  525. return entry
  526. })
  527. },
  528. _handleAdd: function(_entry, _entries) {
  529. var entry = _entry.id,
  530. pos = _entries.indexOf(_entry),
  531. $entry = new EntryView({ model: _entry })
  532. this.$entries[entry] = $entry
  533. $entry.render()
  534. if (pos === 0) {
  535. this.entries.unshift(entry)
  536. this.$el.prepend($entry.el)
  537. } else if (pos >= this.entries.length) {
  538. if (pos !== this.entries.length) {
  539. console.warn('Inserting entry larger than total (%d/%d)',
  540. pos, this.entries.length)
  541. }
  542. this.entries.push(entry)
  543. this.$el.append($entry.el)
  544. } else {
  545. var prevEntry = this._entries.at(pos - 1).id,
  546. $prevEntry = this.$entries[prevEntry]
  547. $prevEntry.$el.append($entry.el)
  548. this.entries.splice(pos, 0, entry)
  549. }
  550. },
  551. _handleRemove: function(_entry, _entries) {
  552. var entry = _entry.id,
  553. pos = this.entries.indexOf(entry),
  554. $entry = this.$entries[entry]
  555. $entry.remove()
  556. delete this.$entries[entry]
  557. this.entries.splice(pos, 1)
  558. },
  559. _handleReset: function(_entries) {
  560. var self = this
  561. var keep = { }, add = [ ]
  562. _entries.each(function(_entry) {
  563. var entry = _entry.id
  564. if (entry in self.$entries) {
  565. keep[entry] = true
  566. } else {
  567. add.push(_entry)
  568. }
  569. })
  570. for (var i = self.entries.length - 1; i >= 0; i -= 1) {
  571. var entry = self.entries[i]
  572. if ( ! (entry in keep)) {
  573. self.entries.splice(i, 1)
  574. self.$entries[entry].remove()
  575. delete self.$entries[entry]
  576. }
  577. }
  578. for (var i = 0; i < add.length; i += 1) {
  579. self._handleAdd(add[i])
  580. }
  581. },
  582. _handleSort: function(_entries) {
  583. this.entries = _entries.map(function(_entry) { return _entry.id })
  584. var $lastEl = this.$entries[this.entries[0]].$el
  585. this.$el.prepend($lastEl)
  586. for (var i = 1; i < this.entries.length; i += 1) {
  587. var $el = this.$entries[this.entries[i]].$el
  588. $lastEl.after($el)
  589. $lastEl = $el
  590. }
  591. },
  592. })
  593. var EntryView = Backbone.View.extend({
  594. tagName: 'a',
  595. className: 'feed-entry',
  596. template: _.template([
  597. '<span class="feed-entry-publisher"><%- feed_name %></span>',
  598. '<span class="feed-entry-timestamp" data-timestamp="<%- timestamp %>">',
  599. '<%- timestamp_human %>',
  600. '</span>',
  601. '<span class="feed-entry-title"><%- title %></span>',
  602. '<span class="feed-entry-summary"><%- content %></span>',
  603. ].join('\n')),
  604. initialize: function(opts) {
  605. this.model = opts.model
  606. this.listenTo(this.model, 'change:_filtered', this._handleFiltered)
  607. this.listenTo(this.model, 'change:_seen', this._handleSeen)
  608. this._handleFiltered(this.model, this.model.get('_filtered'))
  609. this._handleSeen(this.model, this.model.get('_seen'))
  610. },
  611. render: function() {
  612. var tsHuman = Common.humanizeTimestamp(this.model.get('published_at'))
  613. var title = this.model.get('title')
  614. var tpl = this.template({
  615. feed_name: this.model.feed.get('name'),
  616. timestamp: this.model.get('published_at'),
  617. timestamp_human: tsHuman,
  618. title: title,
  619. content: Common.truncate(this.model.get('content'),
  620. 170 - title.length),
  621. })
  622. this.$el.html(tpl)
  623. this.$el.data('id', this.model.id)
  624. this.$el.prop('href', this.model.get('link'))
  625. return this
  626. },
  627. _handleFiltered: function(model, filtered) {
  628. if (filtered) {
  629. this.$el.hide()
  630. } else {
  631. this.$el.show()
  632. }
  633. },
  634. _handleSeen: function(model, seen) {
  635. this.$el[seen ? 'addClass' : 'removeClass']('is-seen')
  636. },
  637. })
  638. })(this)