PageRenderTime 58ms CodeModel.GetById 26ms RepoModel.GetById 1ms app.codeStats 0ms

/app/javascript/profile-package/badges.js

https://gitlab.com/gregtyka/server
JavaScript | 671 lines | 420 code | 85 blank | 166 comment | 43 complexity | 33b9ecb7c27f17720fb6b67b8185b12e MD5 | raw file
  1. /**
  2. * Code to handle badge-related UI components.
  3. */
  4. // TODO: stop clobering the stuff in pageutil.js
  5. var Badges = window.Badges || {};
  6. /**
  7. * @enum {number}
  8. */
  9. Badges.ContextType = {
  10. NONE: 0,
  11. EXERCISE: 1,
  12. PLAYLIST: 2
  13. };
  14. /**
  15. * @enum {number}
  16. */
  17. Badges.Category = {
  18. BRONZE: 0, // Meteorite, "Common"
  19. SILVER: 1, // Moon, "Uncommon"
  20. GOLD: 2, // Earth, "Rare"
  21. PLATINUM: 3, // Sun, "Epic"
  22. DIAMOND: 4, // Black Hole, "Legendary"
  23. MASTER: 5 // Summative/Academic Achievement
  24. };
  25. /**
  26. * A single badge that a user can earn.
  27. * Parallel to the JSON serialized formats of badges.Badge
  28. */
  29. Badges.Badge = Backbone.Model.extend({
  30. defaults: {
  31. "badgeCategory": Badges.Category.BRONZE,
  32. "name": "__empty__",
  33. "description": "",
  34. "icons": {},
  35. "isOwned": false,
  36. "points": 0,
  37. "safeExtendedDescription": ""
  38. },
  39. isEmpty: function() {
  40. // Specially reserved name for empty badge slots.
  41. // Used in display case - must be synced with what the server
  42. // understands in util_badges.py
  43. return this.get("name") === "__empty__";
  44. },
  45. toJSON: function() {
  46. var json = Badges.Badge.__super__.toJSON.call(this);
  47. json["isEmpty"] = this.isEmpty();
  48. return json;
  49. }
  50. });
  51. /**
  52. * A re-usable instance of an empty badge.
  53. */
  54. Badges.Badge.EMPTY_BADGE = new Badges.Badge({});
  55. /**
  56. * Badge information about a badge, or a set of badges that a user has earned
  57. * grouped by their badge type.
  58. * Parallel to the JSON serialized formats of badges.GroupedUserBadge
  59. */
  60. Badges.UserBadge = Backbone.Model.extend({
  61. defaults: {
  62. "badge": null,
  63. "count": 1,
  64. "lastEarnedDate": "2011-11-22T00:00:00Z",
  65. "targetContextNames": [],
  66. "isOwned": true
  67. },
  68. initialize: function(attributes, options) {
  69. if (!this.get("badge")) {
  70. throw "A UserBadge object needs a reference badge object";
  71. }
  72. // Wrap the underlying badge info in a Model object and forward
  73. // change events.
  74. var badgeModel = new Badges.Badge(this.get("badge"));
  75. this.set({ "badge": badgeModel }, { "silent": true });
  76. badgeModel.bind(
  77. "change",
  78. function(ev) { this.trigger("change:badge"); },
  79. this);
  80. }
  81. });
  82. /**
  83. * A list of badges that can be listened to.
  84. * This list can be edited by adding or removing from the collection,
  85. * and saved up to a server.
  86. */
  87. Badges.BadgeList = Backbone.Collection.extend({
  88. model: Badges.Badge,
  89. saveUrl: null,
  90. /**
  91. * Whether or not this badge list has been modified since the last
  92. * save to the server.
  93. */
  94. dirty_: false,
  95. setSaveUrl: function(url) {
  96. this.saveUrl = url;
  97. },
  98. toJSON: function() {
  99. return this.map(function(badge) {
  100. return badge.get("name");
  101. });
  102. },
  103. add: function(models, options) {
  104. Badges.BadgeList.__super__.add.apply(this, arguments);
  105. this.dirty_ = true;
  106. },
  107. remove: function(models, options) {
  108. Badges.BadgeList.__super__.remove.apply(this, arguments);
  109. this.dirty_ = true;
  110. },
  111. /**
  112. * Saves the collection to the server via Backbone.sync.
  113. * This does *not* save any individual edits to Badges within this list;
  114. * it simply posts the information about what belongs in the set.
  115. * @param {Object} options Options similar to what Backbone.sync accepts.
  116. */
  117. save: function(options) {
  118. if (!this.dirty_) {
  119. return;
  120. }
  121. options = options || {};
  122. options["url"] = this.saveUrl;
  123. options["contentType"] = "application/json";
  124. options["data"] = JSON.stringify(this.map(function(badge) {
  125. return badge.get("name");
  126. }));
  127. Backbone.sync.call(this, "update", this, options);
  128. this.dirty_ = false;
  129. },
  130. // TODO: figure out how to do this in a more systematic way!
  131. // Override base Backbone.parse since badge modifications can result in
  132. // api_action_results to be sent back.
  133. parse: function(resp, xhr) {
  134. if ("apiActionResults" in resp && "payload" in resp) {
  135. resp = resp["payload"];
  136. }
  137. Backbone.Model.prototype.parse.call(this, resp, xhr);
  138. }
  139. });
  140. /**
  141. * A list of user badges that can be listened to.
  142. */
  143. Badges.UserBadgeList = Backbone.Collection.extend({
  144. model: Badges.UserBadge
  145. });
  146. /**
  147. * A UI component that displays a list of badges to show off.
  148. * Typically used in a public profile page, but can be re-used
  149. * in the context of a hovercard, or any other context.
  150. *
  151. * Expects a Badges.BadgeList model to back it.
  152. */
  153. Badges.DisplayCase = Backbone.View.extend({
  154. className: "badge-display-case",
  155. /**
  156. * Whether or not this is currently in edit mode.
  157. */
  158. editing: false,
  159. /**
  160. * The full user badge list available to pick from when in edit mode.
  161. * @type {Badges.UserBadgeList}
  162. */
  163. fullBadgeList: null,
  164. /**
  165. * The number of slots available in the display case.
  166. */
  167. maxVisible: 5,
  168. /**
  169. * The slot number being edited. Any selection from the badge picker
  170. * will replace the badge in this slot number.
  171. * -1 if not currently editing.
  172. */
  173. selectedIndex: -1,
  174. mainCaseEl: null,
  175. badgePickerEl: null,
  176. editControlEl: null,
  177. /**
  178. * Ephemeral element used in animating a selection.
  179. */
  180. animatingBadgeEl: null,
  181. initialize: function() {
  182. this.model.bind("add", this.render, this);
  183. this.model.bind("remove", this.render, this);
  184. this.model.bind("change", this.render, this);
  185. this.template = Templates.get("profile.badge-display-case");
  186. // TODO: register in some central intializing point?
  187. Handlebars.registerPartial(
  188. "badge-compact",
  189. Templates.get("profile.badge-compact")
  190. );
  191. Handlebars.registerHelper("toBadgeDescriptionWithBreaks", function(description) {
  192. var lines = [];
  193. var line = "";
  194. _.each(description.split(" "), function(word) {
  195. if (line.length > 0) {
  196. // Split description into up to two lines
  197. if (line.length + word.length > 12 && lines.length == 0) {
  198. // Insert newline, break it up
  199. lines[lines.length] = line;
  200. line = "";
  201. }
  202. else {
  203. line += " ";
  204. }
  205. }
  206. line += word;
  207. });
  208. if (line) {
  209. lines[lines.length] = line;
  210. }
  211. // Guarantee 2 lines for consistent height
  212. while (lines.length < 2) {
  213. lines[lines.length] = "&nbsp;";
  214. }
  215. return lines.join("\n");
  216. });
  217. },
  218. events: {
  219. "click .main-case .achievement-badge .delete-icon": "onDeleteBadgeClicked_",
  220. "click .main-case .achievement-badge": "onBadgeClicked_",
  221. "click .badge-picker .achievement-badge": "onBadgeInPickerClicked_",
  222. "click .display-case-cover": "onCoverClicked_"
  223. },
  224. /**
  225. * @return {boolean} Whether or not this display case can go into "edit" mode
  226. * to allow a user to select which badges go inside.
  227. */
  228. isEditable: function() {
  229. return !!this.fullBadgeList;
  230. },
  231. /**
  232. * Sets the full badge list for the display case so it can go into edit
  233. * mode and pick badges from this badge list.
  234. * @param {Badges.UserBadgeList} The full list of badges that can be added
  235. * to this display case.
  236. * @return {Badges.DisplayCase} This same instance so calls can be chained.
  237. */
  238. setFullBadgeList: function(fullBadgeList) {
  239. // TODO: do we want to listen to events on the full badge list?
  240. this.fullBadgeList = fullBadgeList;
  241. $(this.editControlEl).toggleClass("editable", this.isEditable());
  242. },
  243. /**
  244. * Enters "edit mode" where badges can be added/removed, if possible.
  245. * @param {number=} index Optional index of the slot in the display-case
  246. * to be edited. Defaults to the first available slot, or if none
  247. * are available, the last used slot.
  248. * @return {Badges.DisplayCase} This same instance so calls can be chained.
  249. */
  250. edit: function(index) {
  251. if (!this.isEditable() || this.editing) {
  252. return this;
  253. }
  254. this.setEditing_(true);
  255. this.updateEditSelection_(index);
  256. this.showBadgePicker_();
  257. this.editControlEl.slideUp(350);
  258. this.mainCaseEl.addClass("enable-scrolling");
  259. $(document).bind("mousedown", this.getBoundStopEditFn_());
  260. return this;
  261. },
  262. /**
  263. * Updates the editor so that the badge at the specified index is
  264. * being edited. If no index is specified, the last possible spot
  265. * is selected by default.
  266. * @param {number=} index Optional index of the slot in the display-case
  267. * to be edited. -1 to indicate that none should be selected (i.e.
  268. * we're exiting edit mode.
  269. */
  270. updateEditSelection_: function(index) {
  271. // By default, select the first empty slot, or the last non-empty
  272. // slot if completely full.
  273. if (index === undefined) {
  274. for (var i = 0, len = this.model.length; i < len; i++) {
  275. if (this.model.at(i).isEmpty()) {
  276. index = i;
  277. break;
  278. }
  279. }
  280. }
  281. index = (index === undefined) ? this.model.length : index;
  282. this.selectedIndex = Math.min(index, this.maxVisible - 1);
  283. this.updateSelectionHighlight();
  284. },
  285. /**
  286. * Shows the badge picker for edit mode, if not already visible.
  287. * This view must have already have been rendered once.
  288. */
  289. showBadgePicker_: function() {
  290. this.renderBadgePicker();
  291. var jel = $(this.el);
  292. var jelPicker = $(this.badgePickerEl);
  293. jelPicker.slideDown("fast", function() { jelPicker.show(); })
  294. .css("margin-left", "300px")
  295. .animate({ "margin-left": "0" }, {
  296. duration: "fast",
  297. step: $.easing.easeInOutCubic,
  298. complete: function() {
  299. jel.addClass("editing");
  300. }
  301. });
  302. return this;
  303. },
  304. /**
  305. * Handles a click to a badge in the main display case.
  306. */
  307. onBadgeClicked_: function(e) {
  308. if (!this.editing) {
  309. // Noop when not editing.
  310. return;
  311. }
  312. var index = $(this.mainCaseEl)
  313. .find(".achievement-badge")
  314. .index(e.currentTarget);
  315. this.updateEditSelection_(index);
  316. e.stopPropagation();
  317. },
  318. /**
  319. * Handles a click to a delete button for a badge in the main display case.
  320. */
  321. onDeleteBadgeClicked_: function(e) {
  322. // Prevent the badge click from being processed, since
  323. // the X is a child of the badge.
  324. e.stopPropagation();
  325. if (!this.editing) {
  326. // Noop when not editing.
  327. return;
  328. }
  329. var badgeNode = e.currentTarget;
  330. while (badgeNode && !$(badgeNode).hasClass("achievement-badge")) {
  331. badgeNode = badgeNode.parentNode;
  332. }
  333. var index = $(this.mainCaseEl)
  334. .find(".achievement-badge")
  335. .index(badgeNode);
  336. // Store position before it's removed.
  337. var fromOffset = $(badgeNode).offset();
  338. var isLast = index == (this.model.length - 1);
  339. var removedBadge = this.model.at(index);
  340. this.model.remove(removedBadge);
  341. if (!isLast) {
  342. // Insert an empty badge, since we don't want things shifting
  343. this.model.add(Badges.Badge.EMPTY_BADGE.clone(), { at: index });
  344. }
  345. this.updateEditSelection_(index);
  346. // Animate-out the deleted badge
  347. this.ensureAnimatingBadgeEl();
  348. var badgeTemplate = Templates.get("profile.badge-compact");
  349. this.animatingBadgeEl.html(badgeTemplate(removedBadge.toJSON()));
  350. this.animatingBadgeEl.css({
  351. left: fromOffset.left,
  352. top: fromOffset.top,
  353. opacity: 1.0
  354. });
  355. this.animatingBadgeEl.show();
  356. this.animatingBadgeEl.animate({
  357. left: fromOffset.left + 5,
  358. top: fromOffset.top + 10,
  359. opacity: 0
  360. }, {
  361. duration: 250,
  362. step: $.easing.easeInOutCubic,
  363. complete: _.bind(function() {
  364. this.animatingBadgeEl.hide();
  365. this.animatingBadgeEl.css({ opacity: 1.0 });
  366. }, this)
  367. });
  368. },
  369. /**
  370. * Handles a click to a badge in the badge picker in edit mode.
  371. */
  372. onBadgeInPickerClicked_: function(e) {
  373. e.stopPropagation();
  374. if ($(e.currentTarget).hasClass("used")) {
  375. // Ignore badges already in the main case.
  376. return;
  377. }
  378. var name = e.currentTarget.id;
  379. var matchedBadge = _.find(
  380. this.fullBadgeList.models,
  381. function(userBadge) {
  382. return userBadge.get("badge").get("name") == name;
  383. });
  384. if (!matchedBadge) {
  385. // Shouldn't happen!
  386. return;
  387. }
  388. var badgeToAdd = matchedBadge.get("badge").clone();
  389. this.beginSelectionAnimation_(
  390. badgeToAdd, $(e.currentTarget), this.selectedIndex);
  391. },
  392. ensureAnimatingBadgeEl: function() {
  393. if (!this.animatingBadgeEl) {
  394. this.animatingBadgeEl = $("<div id='animating-badge'></div>")
  395. .appendTo("body");
  396. }
  397. },
  398. /**
  399. * Begin an animation to select a badge from the picker so that
  400. * it may be added to the main display case.
  401. *
  402. * @param {Badges.Badge} badgeSelected The badge to add
  403. * @param {jQuery} jelBadgeSelected The jQuery element of the badge
  404. * element that was selected in the picker.
  405. * @param {number} index The slot in the display case to add to.
  406. */
  407. beginSelectionAnimation_: function(
  408. badgeSelected, jelBadgeSelected, index) {
  409. this.ensureAnimatingBadgeEl();
  410. var jelTargetSlot = $(this.mainCaseEl)
  411. .find(".achievement-badge").eq(index);
  412. var badgeTemplate = Templates.get("profile.badge-compact");
  413. this.animatingBadgeEl.html(badgeTemplate(badgeSelected.toJSON()));
  414. var fromOffset = jelBadgeSelected.offset();
  415. this.animatingBadgeEl.css({
  416. left: fromOffset.left,
  417. top: fromOffset.top
  418. });
  419. this.animatingBadgeEl.show();
  420. var toOffset = jelTargetSlot.offset();
  421. this.animatingBadgeEl.animate({
  422. left: toOffset.left,
  423. top: toOffset.top
  424. }, {
  425. duration: 250,
  426. step: $.easing.easeInOutCubic,
  427. complete: _.bind(function() {
  428. this.finishSelection_(badgeSelected, index);
  429. }, this)
  430. });
  431. },
  432. finishSelection_: function(badgeToAdd, index) {
  433. if (!this.animatingBadgeEl) {
  434. return;
  435. }
  436. this.animatingBadgeEl.hide();
  437. this.animatingBadgeEl.html("");
  438. // Do the actual selection!
  439. // Backbone.Collection doesn't have a .replace method - do it ourselves
  440. var existing = this.model.at(index);
  441. if (existing) {
  442. this.model.remove(existing);
  443. }
  444. for (var i = this.model.length; i < index; i++) {
  445. // Ensure we pad the list with empty badges if the user is
  446. // inserting after some holes.
  447. this.model.add(Badges.Badge.EMPTY_BADGE.clone());
  448. }
  449. this.model.add(badgeToAdd, { at: index });
  450. // Pick the next empty slot.
  451. this.updateEditSelection_();
  452. },
  453. /**
  454. * Exits edit mode.
  455. */
  456. stopEdit: function() {
  457. if (this.editing) {
  458. this.setEditing_(false);
  459. this.updateEditSelection_(-1);
  460. var jelRootEl = $(this.el);
  461. var jelPicker = $(this.badgePickerEl);
  462. jelPicker.slideUp("fast", function() {
  463. jelRootEl.removeClass("editing");
  464. });
  465. jelPicker.undelegate();
  466. this.editControlEl.slideDown(250);
  467. this.mainCaseEl.removeClass("enable-scrolling");
  468. $(document).unbind("click", this.getBoundStopEditFn_());
  469. // TODO: avoid saving if not dirty.
  470. this.save();
  471. }
  472. return this;
  473. },
  474. getBoundStopEditFn_: function() {
  475. if (this.boundStopEditFn_) {
  476. return this.boundStopEditFn_;
  477. }
  478. var self = this;
  479. return this.boundStopEditFn_ = function(e) {
  480. for (var node = e.target; node; node = node.parentNode) {
  481. if (node === self.el) {
  482. // Click inside the display-case somewhere - ignore.
  483. return;
  484. }
  485. }
  486. self.stopEdit();
  487. };
  488. },
  489. save: function() {
  490. this.model.save();
  491. },
  492. setEditing_: function(editing) {
  493. this.editing = editing;
  494. },
  495. /**
  496. * Builds a context object to render a single badge.
  497. */
  498. getUserBadgeJsonContext_: function(badge) {
  499. var json = badge.get("badge").toJSON();
  500. json["count"] = badge.get("count");
  501. return json;
  502. },
  503. /**
  504. * Renders the contents of the main case.
  505. */
  506. renderMainCaseContents_: function() {
  507. var i,
  508. template = Templates.get("profile.badge-compact"),
  509. html = [],
  510. numRendered = Math.min(this.maxVisible, this.model.length);
  511. // While creating the JSON context, also update the badge overlays in
  512. // the display case cover.
  513. var overlays = this.editControlEl.find(".achievement-badge");
  514. for (i = 0; i < numRendered; i++) {
  515. var badge = this.model.at(i);
  516. html.push(template(badge.toJSON()));
  517. overlays[i].setAttribute(
  518. "title",
  519. badge.get("safeExtendedDescription"));
  520. }
  521. for (; i < this.maxVisible; i++) {
  522. html.push(template(Badges.Badge.EMPTY_BADGE.toJSON()));
  523. overlays[i].setAttribute("title", "");
  524. }
  525. this.mainCaseEl.html(html.join(""));
  526. },
  527. /**
  528. * Updates the appropriate badge being highlighted for edit mode.
  529. * See {@link #selectedIndex} for more details.
  530. */
  531. updateSelectionHighlight: function() {
  532. var badgeSlots = $(".achievement-badge", this.mainCaseEl);
  533. badgeSlots.removeClass("selected");
  534. if (this.selectedIndex > -1) {
  535. $(badgeSlots[this.selectedIndex]).addClass("selected");
  536. }
  537. },
  538. onCoverClicked_: function(e) {
  539. if (this.isEditable()) {
  540. this.edit();
  541. }
  542. e.stopPropagation();
  543. },
  544. /**
  545. * Renders the contents of the badge picker.
  546. * Idempotent - simply blows away and repopulates the contents if called
  547. * multiple times.
  548. */
  549. renderBadgePicker: function() {
  550. if (this.fullBadgeList.isEmpty()) {
  551. $(this.badgePickerEl).html(
  552. Templates.get("profile.empty-badge-picker")());
  553. return;
  554. }
  555. var html = [],
  556. badgeTemplate = Templates.get("profile.badge-compact");
  557. this.fullBadgeList.each(function(userBadge) {
  558. var alreadyInCase = this.model.find(function(b) {
  559. return b.get("name") === userBadge.get("badge").get("name");
  560. });
  561. // Mark badges that are already used in the display case
  562. var jsonContext = this.getUserBadgeJsonContext_(userBadge);
  563. if (alreadyInCase) {
  564. jsonContext["used"] = true;
  565. }
  566. html.push(badgeTemplate(jsonContext));
  567. }, this);
  568. $(this.badgePickerEl).html(html.join(""));
  569. },
  570. render: function() {
  571. if (!this.mainCaseEl) {
  572. // First render - build the chrome.
  573. $(this.el).html(Templates.get("profile.badge-display-case")());
  574. this.mainCaseEl = this.$(".main-case");
  575. this.badgePickerEl = this.$(".badge-picker");
  576. this.editControlEl = this.$(".display-case-cover");
  577. $(this.editControlEl).toggleClass("editable", this.isEditable());
  578. }
  579. this.renderMainCaseContents_();
  580. if (this.fullBadgeList) {
  581. this.renderBadgePicker();
  582. }
  583. this.updateSelectionHighlight();
  584. return this;
  585. }
  586. });