diff --git a/static/js/InviteMember.js b/static/js/InviteMember.js
new file mode 100644
index 0000000..b7f0cf5
--- /dev/null
+++ b/static/js/InviteMember.js
@@ -0,0 +1,128 @@
+/**
+ * Functionality for "Invite A New Member" form shown in the sidebar of a shake
+ * the current user is an editor of. Lets the user type in a few characters, see
+ * an autocompleted list of matching usernames to pick from, and finally sends
+ * an invitation via the backend.
+ */
+
+// Invite Member widget for the Shake administrator.
+const $mainModule = $("#shake-invite-member");
+const $inputField = $mainModule.find(".input-text");
+const $inviteButton = $mainModule.find(".invite-button");
+const $shakeResults = $mainModule.find(".shake-results");
+const $form = $mainModule.find("form");
+const $title = $mainModule.find("h3");
+let searchResults = [];
+let lastSearch = "";
+
+const InviteMember = {
+ attachEvents: function () {
+ $inputField.keyup((ev) => this.searchNames(ev));
+ $form.submit((ev) => this.submitForm());
+ $shakeResults.click((ev) => this.selectUser($(ev.target).text()));
+ $inviteButton.click(() => {
+ this.sendInvite();
+ return false;
+ });
+ },
+
+ searchNames: async function () {
+ if ($inputField.val() == "") {
+ this.clearResults();
+ this.clearInput();
+ return false;
+ }
+
+ // don't search again if field hasn't changed.
+ if ($inputField.val() == lastSearch) {
+ return false;
+ }
+ lastSearch = $inputField.val();
+
+ const data = $form.serialize();
+ const resp = await fetch("/account/quick_name_search", {
+ method: "POST",
+ body: new URLSearchParams(data),
+ });
+ const json = await resp.json();
+
+ if ("users" in json) {
+ this.updateResults(json["users"]);
+ }
+ },
+
+ updateResults: function (users) {
+ searchResults = users;
+ if (searchResults.length == 0) {
+ this.clearResults();
+ } else {
+ this.renderResults();
+ }
+ },
+
+ renderResults: function () {
+ $shakeResults.html("").show();
+ for (let i = 0; i < searchResults.length; i++) {
+ $shakeResults.append(
+ `
+
+ ${searchResults[i].name}
+ `,
+ );
+ }
+ },
+
+ selectUser: function (userName) {
+ this.clearResults();
+ $inputField.val(userName);
+ $inviteButton.removeAttr("disabled");
+ },
+
+ submitForm: function (ev) {
+ if (
+ searchResults.length == 1 &&
+ searchResults[0].name == $inputField.val()
+ ) {
+ this.selectUser(searchResults[0].name);
+ this.sendInvite();
+ this.clearResults();
+ }
+ return false;
+ },
+
+ clearResults: function () {
+ lastSearch = "";
+ $shakeResults.hide().html("");
+ },
+
+ clearInput: function () {
+ $inputField.val("");
+ $inviteButton.attr("disabled", "disabled");
+ },
+
+ sendInvite: async function () {
+ if ($inviteButton.disabled) {
+ return false;
+ } else {
+ const url = $form.attr("action");
+ const data = $form.serialize();
+
+ await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
+ });
+
+ this.dataSent();
+ return false;
+ }
+ },
+
+ dataSent: function () {
+ $title.html("Your invitation has been sent");
+ this.clearInput();
+ this.clearResults();
+ },
+};
+
+export { InviteMember };
diff --git a/static/js/NSFWCover.js b/static/js/NSFWCover.js
new file mode 100644
index 0000000..bc693d1
--- /dev/null
+++ b/static/js/NSFWCover.js
@@ -0,0 +1,65 @@
+import { applyHoverForVideo } from "./common.js";
+
+/**
+ * Functionality associated with NSFW covers. Called on to attach event
+ * handlers to any posts that the server has generated a NSFW cover for.
+ */
+
+const NSFWCover = {
+ attachEvents($root) {
+ // Per invocation functions that will close over the context dependent
+ // variables defined above.
+ function clickShowImage(ev) {
+ var location = document.location,
+ basePath = location.protocol + "//" + location.host,
+ filePath = $(ev.target).attr("href");
+ // Going to leave this as a jquery get rather than migrate to fetch
+ // right away. The two implementations behave differently and only
+ // the existing method seems to work with /services/oembed
+ $.get(
+ basePath +
+ "/services/oembed?include_embed=1&url=" +
+ escape(basePath + filePath),
+ (resp) => loadImage(resp),
+ "json",
+ );
+ return false;
+ }
+
+ function loadImage(response) {
+ var parent = $root.parent(),
+ parentHeight = parent.height();
+
+ if (response["type"] === "photo") {
+ parent
+ .css("min-height", parentHeight + "px")
+ .html(
+ '
',
+ );
+ } else if (response["embed_html"]) {
+ parent
+ .css("min-height", parentHeight + "px")
+ .html(
+ '' +
+ response["embed_html"] +
+ "
",
+ );
+ } else if (response["type"] === "video") {
+ var content = parent
+ .css("min-height", parentHeight + "px")
+ .html(
+ response["html"].replace(
+ / clickShowImage(ev));
+ },
+};
+
+export { NSFWCover };
diff --git a/static/js/NewPostPanel.js b/static/js/NewPostPanel.js
new file mode 100644
index 0000000..77bc5d9
--- /dev/null
+++ b/static/js/NewPostPanel.js
@@ -0,0 +1,200 @@
+/**
+ * Functionality associated with the new post panel that pops up on any page
+ * allowing the user to post a new image or video to one of their shakes.
+ *
+ * Takes care of adding event listeners to the new post button, as well as to
+ * the shake dropdowns on both the image and video sides of the dialog. The new
+ * post dropdown slides in from the top of the screen.
+ */
+let $newPostPanel;
+let $newPostPanelInner;
+let $newPostButton;
+let $saveVideoForm;
+let $saveVideoFormButton;
+let $postVideoForm;
+let $postVideoFormButton;
+let $uploadImageInput;
+let $linkToVideo;
+let $videoShakeId;
+let $shakeSelector;
+
+function removeEvents() {
+ $saveVideoFormButton.unbind();
+ $postVideoFormButton.unbind();
+ $shakeSelector.unbind();
+ $linkToVideo.unbind();
+}
+
+function initDom() {
+ $newPostPanel = $("#new-post-panel");
+ $newPostPanelInner = $("#new-post-panel .new-post-panel--inner");
+ $newPostButton = $("#new-post-button");
+ // upload image
+ $uploadImageInput = $("#upload-image-input");
+ // link to video
+ $linkToVideo = $("#link-to-video");
+ $videoShakeId = $("#video-shake-id");
+ // video preview screen
+ $saveVideoForm = $("#new-post-panel .save-video-form");
+ $saveVideoFormButton = $("#new-post-panel .save-video-form .btn");
+ $postVideoForm = $("#new-post-panel .post-video-form");
+ $postVideoFormButton = $("#new-post-panel .post-video-form .btn");
+ // shake selector
+ $shakeSelector = $(".shake-selector");
+}
+
+function initEvents() {
+ // The events that are inside the panel that we want to initialize
+ // when the panel loads. These are the events that are subject
+ // to change depending on content that is loaded.
+
+ // Video upload step 1.
+ // Called when user clicks "video" link and takes them to a new form where
+ // they can enter the video url.
+ $linkToVideo.click(function () {
+ NewPostPanel.loadPostVideo();
+ return false;
+ });
+
+ // Video upload step 2.
+ // Called when the user clicks "Go get it!" button which takes the user to a
+ // new form containing a preview of the video.
+ $saveVideoFormButton.click(function (e) {
+ NewPostPanel.submitSaveVideo();
+ return false;
+ });
+
+ // Video upload step 3.
+ // Called when the user clicks "Yes! Post it please!" which uploads the
+ // video via submitPostVideo().
+ $postVideoFormButton.click(function (e) {
+ NewPostPanel.submitPostVideo();
+ return false;
+ });
+
+ // Uploads the image file upon selection by the file upload dialog.
+ $uploadImageInput.change(function () {
+ $(this).closest("form").submit();
+ });
+
+ $shakeSelector.click(NewPostPanel.toggleShakeSelector);
+ $shakeSelector.find("ul a").click(NewPostPanel.chooseShake);
+}
+
+const NewPostPanel = {
+ attachEvents: function () {
+ initDom();
+
+ $newPostButton.click(function () {
+ NewPostPanel.loadNewPost();
+ return false;
+ });
+
+ // We don't want click event on panel to bubble up to body
+ // since a click to body closes the panel.
+ $newPostPanel.click(function (ev) {
+ ev.stopPropagation();
+ });
+ },
+
+ toggleShakeSelector: function (ev) {
+ $(this).toggleClass("is-active").find("ul").toggle();
+ ev.stopPropagation();
+ ev.preventDefault();
+ },
+
+ // Sets the text of the shake to the chosen one and
+ // sets a hidden input field with the proper shake id.
+ chooseShake: function () {
+ const $shakeSelector = $(this).parents(".shake-selector");
+ const $selectedShake = $shakeSelector.find(".green");
+ const $selectedShakeInput = $shakeSelector.find("input");
+ const name = $(this).html();
+ const id = $(this)
+ .attr("id")
+ .replace(/[^0-9]+/, "");
+ $selectedShake.html(name);
+ $selectedShakeInput.val(id);
+ },
+
+ // Renders step 1 of image / video upload process - shake choice and file
+ // type.
+ loadNewPost: async function () {
+ var url = "/tools/new-post";
+ const resp = await fetch(url);
+
+ this.refreshPanel(await resp.text());
+ this.expandPanel();
+ return false;
+ },
+
+ // Renders step 2 of the video upload process - entering the url.
+ loadPostVideo: async function () {
+ let shakeSuffix = "";
+ if ($videoShakeId.length > 0) {
+ shakeSuffix = "?shake_id=" + $videoShakeId.val();
+ }
+ const url = `/tools/save-video${shakeSuffix}`;
+
+ const resp = await fetch(url);
+ this.refreshPanel(await resp.text());
+ this.expandPanel();
+ },
+
+ expandPanel: function () {
+ $newPostPanel.slideDown();
+ var that = this;
+ $("body").one("click", $.proxy(this.close_panel, this));
+ // we want to hide anything with a video since we can't
+ // overlap things like youtube embeds, which is an iframe
+ // that has an absolutely positioned flash element inside.
+ $(".the-image iframe").each(function () {
+ $(this).parent().css("height", $(this).height());
+ $(this).parent().css("width", $(this).width());
+ $(this).hide();
+ });
+ },
+
+ close_panel: function () {
+ $newPostPanel.hide();
+ removeEvents();
+ // show the videos again.
+ $(".the-image iframe").show();
+ },
+
+ // Renders step 3 of the video upload process - previewing the video.
+ submitSaveVideo: async function () {
+ const url = $saveVideoForm.attr("action");
+ const data = $saveVideoForm.serialize();
+
+ const resp = await fetch(`${url}?${new URLSearchParams(data)}`);
+ this.refreshPanel(await resp.text());
+ },
+
+ // Final step of video upload - submitting the post details to the server.
+ submitPostVideo: async function () {
+ const url = $postVideoForm.attr("action");
+ const data = $postVideoForm.serialize();
+ $postVideoFormButton.unbind("click").find("span").html("Posting...");
+
+ const resp = await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
+ });
+ const json = await resp.json();
+
+ // Redirect to the new post permalink page.
+ document.location =
+ document.location.protocol +
+ `//${document.location.host}${json["path"]}`;
+ },
+
+ refreshPanel: function (html) {
+ $newPostPanelInner.html(html);
+ removeEvents();
+ initDom();
+ initEvents();
+ },
+};
+
+export { NewPostPanel };
diff --git a/static/js/NotificationInvitationContainer.js b/static/js/NotificationInvitationContainer.js
new file mode 100644
index 0000000..1c5b8d1
--- /dev/null
+++ b/static/js/NotificationInvitationContainer.js
@@ -0,0 +1,31 @@
+import { NotificationInvitationRequest } from "./NotificationInvitationRequest.js";
+
+let onShakePage;
+let $header;
+let $body;
+
+function update_count(count) {
+ if (!onShakePage) {
+ const requestText = count === 1 ? "request" : "requests";
+ const html = `${count} ${requestText} to join a shake`;
+ $header.html(html);
+ } else {
+ $header.html("Got it!");
+ }
+}
+
+const NotificationInvitationContainer = {
+ populate: function ($root) {
+ $header = $root.find(".notification-block-hd");
+ $body = $root.find(".notification-block-bd");
+ onShakePage = $header.hasClass("on-shake-page");
+ $root.find(".notification").each(function () {
+ // Attach events to each invitation, passing the update_count
+ // function as a callback for approval / disapproval clicks to
+ // update the container header.
+ NotificationInvitationRequest.attachEvents($(this), update_count);
+ });
+ },
+};
+
+export { NotificationInvitationContainer };
diff --git a/static/js/NotificationInvitationRequest.js b/static/js/NotificationInvitationRequest.js
new file mode 100644
index 0000000..cd9c88c
--- /dev/null
+++ b/static/js/NotificationInvitationRequest.js
@@ -0,0 +1,44 @@
+const NotificationInvitationRequest = {
+ attachEvents: function ($root, updateCallbackFn) {
+ const $formApproveInvitation = $root.find(".approve-invitation");
+ const $formDeclineInvitation = $root.find(".decline-invitation");
+
+ function submitApproveInvitation(ev) {
+ ev.preventDefault();
+ submitForm($formApproveInvitation);
+ }
+
+ function submitDeclineInvitation(ev) {
+ ev.preventDefault();
+ submitForm($formDeclineInvitation);
+ }
+
+ async function submitForm($form) {
+ const url = $form.attr("action");
+ const data = $form.serialize();
+ const resp = await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
+ });
+ const json = await resp.json();
+ clearNotification(json);
+ }
+
+ function clearNotification(response) {
+ if (response["status"] == "ok") {
+ $root.remove();
+ updateCallbackFn(response["count"]);
+ }
+ }
+
+ $root.delegate(".approve-invitation", "submit", (ev) =>
+ submitApproveInvitation(ev),
+ );
+
+ $root.delegate(".decline-invitation", "submit", (ev) =>
+ submitDeclineInvitation(ev),
+ );
+ },
+};
+
+export { NotificationInvitationRequest };
diff --git a/static/js/PermalinkCommentsView.js b/static/js/PermalinkCommentsView.js
new file mode 100644
index 0000000..1e380ae
--- /dev/null
+++ b/static/js/PermalinkCommentsView.js
@@ -0,0 +1,49 @@
+import { setCaret } from "./common.js";
+
+/**
+ * Functionality associated with replying to or deleting a comment from a
+ * permalink page e.g. https://mltshp.com/p/1RNJS#post-comment
+ *
+ * Attaches event handlers to "reply" and "delete" (if owned by the current
+ * user) links on each existing comment. Only initialised if the
+ * #post-comment-body has some children.
+ */
+
+let $root;
+let $postCommentBody;
+
+const PermalinkCommentsView = {
+ addEvents: function ($imageCommentsPermalink) {
+ $root = $imageCommentsPermalink;
+ $postCommentBody = $("#post-comment-body");
+
+ $root.delegate(".reply-to", "click", (ev) =>
+ PermalinkCommentsView.clickReplyTo(ev),
+ );
+ $root.delegate(".delete", "click", (ev) =>
+ PermalinkCommentsView.clickDelete(ev),
+ );
+ },
+
+ clickReplyTo: function (ev) {
+ const $target = $(ev.target);
+ const $meta = $target.parent();
+ const username = $meta.find(".username").html();
+ const usernameClean = username.replace(/[^a-zA-Z0-9_\-]+/g, "");
+ const currentText = $postCommentBody.val();
+ $postCommentBody.val(currentText + "@" + usernameClean + " ");
+ setCaret($postCommentBody.get(0));
+ window.location.hash = "post-comment";
+ return false;
+ },
+
+ clickDelete: function (ev) {
+ const $deleteForm = $("#" + ev.target.id + "-form");
+ if (confirm("Are you sure you want to delete this?")) {
+ $deleteForm.submit();
+ }
+ return false;
+ },
+};
+
+export { PermalinkCommentsView };
diff --git a/static/js/RecommendedShakeCategory.js b/static/js/RecommendedShakeCategory.js
new file mode 100644
index 0000000..884bc31
--- /dev/null
+++ b/static/js/RecommendedShakeCategory.js
@@ -0,0 +1,48 @@
+/**
+ * Functionality associated with the shake categories accordian control on the
+ * find shakes page e.g. https://mltshp.com/tools/find-shakes
+ *
+ * Adds event listener to toggle category open and closed, and load shakes from
+ * the server upon first opening of a category.
+ */
+
+const RecommendedShakeCategory = {
+ attachEvents: function (root) {
+ const $root = $(root);
+ const $toggle = $root.find(".shake-category-toggle");
+ const $body = $root.find(".shake-category-body");
+
+ let fetched = false;
+
+ // Per invocation functions that will close over the context dependent
+ // variables defined above.
+ async function clickToggle() {
+ if (!fetched) {
+ const url =
+ "/tools/find-shakes/quick-fetch-category/" +
+ $toggle.attr("href").replace("#", "");
+
+ const resp = await fetch(url);
+ populateResults(await resp.text());
+ } else {
+ toggle();
+ }
+ return false;
+ }
+
+ function populateResults(results) {
+ fetched = true;
+ $body.html(results);
+ toggle();
+ }
+
+ function toggle(result) {
+ $root.toggleClass("shake-category-selected");
+ }
+
+ // Attach any event handlers.
+ $toggle.click(() => clickToggle());
+ },
+};
+
+export { RecommendedShakeCategory };
diff --git a/static/js/RequestInvitation.js b/static/js/RequestInvitation.js
new file mode 100644
index 0000000..b544d2e
--- /dev/null
+++ b/static/js/RequestInvitation.js
@@ -0,0 +1,41 @@
+/**
+ * Functionality related to a user requesting an invitation to join a shake.
+ * This module attaches an event handler to the "Join this shake" button, and
+ * persists a request for invitation to the server for the shaken owner to sed.
+ */
+
+const RequestInvitation = {
+ attachEvents: function ($root) {
+ const $form = $root.find("form");
+
+ // Per invocation functions that will close over the context dependent
+ // variables defined above.
+ async function submitRequest() {
+ var url = $form.attr("action");
+ var data = $form.serialize();
+ $.post(url, data, () => processResponse());
+
+ // Work in progress. Seems to be some difference in behaviour
+ // between the two techniques.
+ // const url = $form.attr("action");
+ // const data = $form.serialize();
+ // console.log(url, data);
+ // await fetch(url, {
+ // method: "POST",
+ // body: new URLSearchParams(data),
+ // });
+ // processResponse();
+
+ return false;
+ }
+
+ function processResponse() {
+ $root.html("Ok! Request sent.");
+ }
+
+ // Attach any event handlers.
+ $root.delegate("form", "submit", () => submitRequest());
+ },
+};
+
+export { RequestInvitation };
diff --git a/static/js/SaveThisView.js b/static/js/SaveThisView.js
new file mode 100644
index 0000000..d429165
--- /dev/null
+++ b/static/js/SaveThisView.js
@@ -0,0 +1,119 @@
+import { ShakesCache } from "./ShakesCache.js";
+import { SidebarStatsView } from "./SidebarStatsView.js";
+import { StreamStatsViewRegistry } from "./StreamStatsViewRegistry.js";
+import { toText } from "./common.js";
+
+/**
+ * Functionality related to the "save this" button on posts. Sets up event
+ * handlers for responding to buttons, dynamically generating and displaying
+ * the drop down box to select shakes from, and handles submitting the save.
+ *
+ * Although a global module the attachEvents function is called once per post,
+ * passing per post context as a parameter.
+ */
+
+const SaveThisView = {
+ attachEvents: function (container) {
+ const $saveThis = $(container);
+ const $saveThisLink = $saveThis.find(".save-this-link");
+ const $form = $saveThis.find("form");
+ const $shakeIdInput = $saveThis.find(".shake-id-input");
+ const $shakeSelector = $(
+ "",
+ );
+
+ // Per invocation functions that will close over the context dependent
+ // variables defined above.
+ function clickSaveThis(ev) {
+ ev.stopPropagation();
+ if ($saveThisLink.hasClass("save-this-link-multiple")) {
+ showShakeSelector();
+ } else {
+ submitImageSave();
+ }
+ return false;
+ }
+
+ function clickChooseShake(ev) {
+ ev.stopPropagation();
+ const shakeId = ev.target.id.replace(/[^\d]+/, "");
+ $shakeIdInput.val(shakeId);
+ submitImageSave();
+ return false;
+ }
+
+ function clickCloseSelector(ev) {
+ ev.stopPropagation();
+ $shakeSelector.remove();
+ }
+
+ async function showShakeSelector() {
+ $saveThis.append($shakeSelector);
+ $("body").one("click", (ev) => clickCloseSelector(ev));
+
+ // Only query once per page.
+ if (ShakesCache.fetch() !== false) {
+ fetchAvailableShakes(ShakesCache.fetch());
+ } else {
+ const resp = await fetch("/account/shakes");
+ const json = await resp.json();
+ fetchAvailableShakes(json);
+ }
+ }
+
+ function fetchAvailableShakes(response) {
+ ShakesCache.store(response);
+ let html = '";
+ $shakeSelector
+ .removeClass("save-this-shake-selector-loading")
+ .html(html);
+ }
+
+ async function submitImageSave(ev) {
+ const url = $form.attr("action");
+ const data = $form.serialize();
+ const resp = await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
+ });
+ const json = await resp.json();
+ processImageSaveResponse(json);
+ }
+
+ function processImageSaveResponse(response) {
+ if (response["share_key"]) {
+ const count = response["count"];
+ const shareKey = response["share_key"];
+ const newShareKey = response["new_share_key"];
+ const countString = toText(count, "Save");
+ $("#save-count-amount-" + shareKey).html(countString);
+ const output = `
+
+
+ `;
+ $shakeSelector.remove();
+ $saveThis.html(output);
+ SidebarStatsView.refreshSaves();
+ StreamStatsViewRegistry.refreshSaves(shareKey);
+ }
+ }
+
+ // Attach any event handlers.
+ $saveThisLink.click((ev) => clickSaveThis(ev));
+ $saveThis.delegate(".shake-link", "click", (ev) =>
+ clickChooseShake(ev),
+ );
+ $saveThis.delegate(".close", "click", (ev) => clickCloseSelector(ev));
+ },
+};
+
+export { SaveThisView };
diff --git a/static/js/ShakeMemberList.js b/static/js/ShakeMemberList.js
new file mode 100644
index 0000000..87cf418
--- /dev/null
+++ b/static/js/ShakeMemberList.js
@@ -0,0 +1,46 @@
+/**
+ * Functionality associated with the edit shake members list, found on each
+ * shake page e.g. https://mltshp.com/AMearworm
+ *
+ * Attaches an event handler to each person in the list, allowing them to be
+ * removed as a member by the shake owner.
+ */
+
+function process_remove(elem) {
+ // li element of this user in the list.
+ elem.remove();
+}
+
+const ShakeMemberList = {
+ attachEvents: function ($root) {
+ // Per invocation functions that will close over the context dependent
+ // variable defined above.
+ async function removeFromShake(ev) {
+ const $target = $(ev.target),
+ $li = $target.parents("li"),
+ $form = $target.next(),
+ url = $form.attr("action");
+ const data = $form.serialize();
+
+ if (
+ confirm(
+ "Are you sure you want to remove this user from a shake? If they have notifications on an email will be sent informing them of the change.",
+ )
+ ) {
+ await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
+ });
+ process_remove($li);
+ }
+ return false;
+ }
+
+ // Attach any event handlers.
+ $root.delegate(".remove-from-shake-button-link", "click", (ev) =>
+ removeFromShake(ev),
+ );
+ },
+};
+
+export { ShakeMemberList };
diff --git a/static/js/ShakesCache.js b/static/js/ShakesCache.js
new file mode 100644
index 0000000..732b68c
--- /dev/null
+++ b/static/js/ShakesCache.js
@@ -0,0 +1,15 @@
+const ShakesCache = {
+ fetch: function () {
+ if (this.result !== undefined) {
+ return this.result;
+ } else {
+ return false;
+ }
+ },
+
+ store: function (result) {
+ this.result = result;
+ },
+};
+
+export { ShakesCache };
diff --git a/static/js/SidebarStatsView.js b/static/js/SidebarStatsView.js
new file mode 100644
index 0000000..d55aaa9
--- /dev/null
+++ b/static/js/SidebarStatsView.js
@@ -0,0 +1,221 @@
+import { toText } from "./common.js";
+
+/**
+ * Funcionality associated with the stats shown in the sidebar of a permalink
+ * page e.g. https://mltshp.com/p/1RNNC
+ *
+ * Defines and attaches event handlers.
+ */
+const DEFAULT_IMAGE_STATS = { saveCount: 0, likeCount: 0 };
+
+let scope;
+
+const imageStats = { ...DEFAULT_IMAGE_STATS };
+
+// Initialise these once init() has been called with a valid scope. If never
+// initialised, never referred to.
+let $saveCount;
+let $likeCount;
+
+let $saveButton;
+let $likeButton;
+let $content;
+
+let savesExpanded;
+let likesExpanded;
+
+// if we aren't on a permalink page, just expose a dummy public API
+function noScope() {
+ return scope === undefined || $(scope).length === 0;
+}
+
+const SidebarStatsView = {
+ init: function (_scope) {
+ scope = _scope;
+
+ if (noScope()) {
+ return;
+ }
+
+ // Set all these now that we have been provided a meaningful scope.
+ $saveCount = $(".save-count", scope);
+ $likeCount = $(".like-count", scope);
+ imageStats.saveCount = parseInt($saveCount.html(), 10);
+ imageStats.likeCount = parseInt($likeCount.html(), 10);
+
+ $saveButton = $(".sidebar-stats-saves", scope);
+ $likeButton = $(".sidebar-stats-hearts", scope);
+ $content = $(".sidebar-stats-content", scope);
+
+ savesExpanded = false;
+ likesExpanded = false;
+
+ if (imageStats.saveCount > 0) {
+ this.bindSaves();
+ } else {
+ this.unbindSaves();
+ }
+ if (imageStats.likeCount > 0) {
+ this.bindLikes();
+ this.toggleLikes();
+ } else {
+ this.unbindLikes();
+ }
+ },
+
+ refreshLikes: function () {
+ if (noScope()) {
+ return;
+ }
+
+ $likeCount = $(".like-count", scope);
+ imageStats.likeCount = parseInt($likeCount.html(), 10);
+ if (likesExpanded) {
+ this.getLikes();
+ }
+ if (imageStats.likeCount > 0) {
+ this.bindLikes();
+ } else {
+ likesExpanded = false;
+ this.unbindLikes();
+ }
+ },
+
+ refreshSaves: async function () {
+ if (noScope()) {
+ return;
+ }
+
+ $saveCount = $(".save-count", scope);
+ imageStats.saveCount = parseInt($saveCount.html(), 10);
+ if (savesExpanded) {
+ await this.getSaves();
+ }
+ if (imageStats.saveCount > 0) {
+ this.bindSaves();
+ } else {
+ savesExpanded = false;
+ this.unbindSaves();
+ }
+ },
+
+ bindSaves: function () {
+ $saveButton.unbind("click");
+ $saveButton.addClass("enable-cursor");
+ $saveButton.click(function () {
+ SidebarStatsView.toggleSaves();
+ });
+ },
+
+ unbindSaves: function () {
+ $saveButton.removeClass("enable-cursor");
+ $saveButton.unbind("click");
+ this.collapse();
+ },
+
+ bindLikes: function () {
+ $likeButton.unbind("click");
+ $likeButton.addClass("enable-cursor");
+ $likeButton.click(function () {
+ SidebarStatsView.toggleLikes();
+ });
+ },
+
+ unbindLikes: function () {
+ $likeButton.removeClass("enable-cursor");
+ $likeButton.unbind("click");
+ this.collapse();
+ },
+
+ toggleSaves: function () {
+ likesExpanded = false;
+ savesExpanded = !savesExpanded;
+ if (savesExpanded) {
+ $likeButton.removeClass("selected");
+ $content.addClass("loading").show();
+ $saveButton.addClass("selected");
+ this.getSaves();
+ } else {
+ this.collapse();
+ }
+ },
+
+ getSaves: async function () {
+ const resp = await fetch(`${document.location.pathname}/saves`);
+ const json = await resp.json();
+ if (response["result"]) {
+ SidebarStatsView.processSave(json);
+ }
+ },
+
+ toggleLikes: function () {
+ savesExpanded = false;
+ likesExpanded = !likesExpanded;
+ if (likesExpanded) {
+ $saveButton.removeClass("selected");
+ $content.addClass("loading").show();
+ $likeButton.addClass("selected");
+ this.getLikes();
+ } else {
+ this.collapse();
+ }
+ },
+
+ getLikes: async function () {
+ const resp = await fetch(document.location.pathname + "/likes");
+ const json = await resp.json();
+ if (json["result"]) {
+ SidebarStatsView.processLike(json);
+ }
+ },
+
+ processSave: function (response) {
+ if (response["count"] == 0) {
+ this.disable_saves();
+ } else {
+ $saveCount.html(toText(response["count"], "Save"));
+ this.renderContent(response);
+ }
+ },
+
+ processLike: function (response) {
+ if (response["count"] == 0) {
+ this.unbindLikes();
+ } else {
+ $likeCount.html(toText(response["count"], "Like"));
+ this.renderContent(response);
+ }
+ },
+
+ collapse: function (repsponse) {
+ $likeButton.removeClass("selected");
+ $saveButton.removeClass("selected");
+ $content.hide();
+ },
+
+ renderContent: function (response) {
+ var html = "";
+ for (var i = 0, len = response["result"].length; i < len; i++) {
+ var result = response["result"][i];
+ var link;
+ if (result["action"] == "save") {
+ // for saves, we link to the saved post
+ link = result["post_url"];
+ } else {
+ link = `/user/${result["user_name"]}`;
+ }
+ html += `
+
+ `;
+ }
+ $content.removeClass("loading").html(html);
+ },
+};
+
+export { SidebarStatsView };
diff --git a/static/js/StreamStatsView.js b/static/js/StreamStatsView.js
new file mode 100644
index 0000000..ffe0125
--- /dev/null
+++ b/static/js/StreamStatsView.js
@@ -0,0 +1,258 @@
+import { setCaret } from "./common.js";
+
+/**
+ * Functionality associated with the stats and comments section of each post on
+ * a list page e.g. https://mltshp.com/incoming,
+ * https://mltshp.com/CurrentListening, or https://mltshp.com/user/LocalStain
+ *
+ * Attaches event handlers to "likes", "comments", and "saves" tabs, as well as
+ * event handlers and processing for replaying, deleting, and adding commetns.
+ * One instance created per post on the page, and managed by the global
+ * StreamStatsViewRegistry module.
+ */
+
+class StreamStatsView {
+ constructor($imageContentFooter) {
+ this.$imageContentFooter = $imageContentFooter;
+ this.shareKey = this.$imageContentFooter
+ .attr("id")
+ .replace("image-content-footer-", "");
+ this.canSubmitComments = true;
+ this.initDom();
+ this.initEvents();
+ }
+
+ initDom() {
+ this.$likesButton = this.$imageContentFooter.find(".likes");
+ this.$savesButton = this.$imageContentFooter.find(".saves");
+ this.$commentsButton = this.$imageContentFooter.find(".comments");
+ this.$inlineDetails = this.$imageContentFooter.find(".inline-details");
+ this.initCommentDom();
+ }
+
+ initCommentDom() {
+ this.$postCommentInline = this.$inlineDetails.find(
+ ".post-comment-inline",
+ );
+ this.$commentForm = this.$inlineDetails.find(".post-comment-form");
+ this.$commentTextarea = this.$inlineDetails.find("textarea");
+ this.$submitCommentButton = this.$inlineDetails.find(
+ ".submit-comment-button",
+ );
+ this.$showMoreComments = this.$inlineDetails.find(
+ ".show-more-comments",
+ );
+ this.$comment = this.$inlineDetails.find(".comment");
+ this.$replyTo = this.$inlineDetails.find(".reply-to");
+ this.$delete = this.$inlineDetails.find(".delete");
+ }
+
+ initEvents() {
+ this.$likesButton.click((ev) => this.clickLike(ev));
+ this.$savesButton.click((ev) => this.clickSaves(ev));
+ this.$commentsButton.click((ev) => this.clickComments(ev));
+ this.initCommentEvents();
+ }
+
+ initCommentEvents() {
+ this.$commentTextarea.click((ev) => this.clickCommentTextarea(ev));
+ this.$showMoreComments.click((ev) => this.clickMoreComments(ev));
+ this.$replyTo.click((ev) => this.clickReplyTo(ev));
+ this.$delete.click((ev) => this.clickDelete(ev));
+ // Fix for Webkit bug where textarea looses focus incorrectly on mouseup.
+ // http://code.google.com/p/chromium/issues/detail?id=4505
+ this.$commentTextarea.mouseup(function (ev) {
+ ev.preventDefault();
+ });
+ this.$commentForm.submit((ev) => this.submitComment(ev));
+ }
+
+ // Removes selected state from all tabs.
+ clearTabSelection() {
+ this.$likesButton.removeClass("selected");
+ this.$savesButton.removeClass("selected");
+ this.$commentsButton.removeClass("selected");
+ }
+
+ // Start the "loading" state transition.
+ startLoading() {
+ this.$inlineDetails.addClass("inline-details-loading").html("").show();
+ }
+
+ user_html(data) {
+ let html = "";
+ for (let i = 0; i < data.result.length; i++) {
+ const result = data.result[i];
+ const link =
+ result["action"] == "save"
+ ? result["post_url"]
+ : `/user/${result["user_name"]}`;
+
+ html += `
+
+
+ ${result["user_name"]}
+
+ `;
+ }
+ return html;
+ }
+
+ clickLike(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ if (this.$likesButton.hasClass("selected")) {
+ this.clearTabSelection();
+ this.$inlineDetails.hide();
+ }
+
+ this.clearTabSelection();
+ this.$likesButton.addClass("selected");
+ this.startLoading();
+ this.loadLikes();
+ }
+
+ async loadLikes() {
+ const url = `/p/${this.shareKey}/likes`;
+ const resp = await fetch(url);
+ const json = await resp.json();
+ this.processLikeResponse(json);
+ }
+
+ processLikeResponse(data) {
+ const html = `${this.user_html(data)}
`;
+ this.$inlineDetails.html(html);
+ }
+
+ clickSaves(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ if (this.$savesButton.hasClass("selected")) {
+ this.clearTabSelection();
+ this.$inlineDetails.hide();
+ }
+
+ this.clearTabSelection();
+ this.$savesButton.addClass("selected");
+ this.startLoading();
+ this.loadSaves();
+ }
+
+ async loadSaves() {
+ const url = `/p/${this.shareKey}/saves`;
+ const resp = await fetch(url);
+ const json = await resp.json();
+ this.processLikeResponse(json);
+ }
+
+ async clickComments(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ if (this.$commentsButton.hasClass("selected")) {
+ this.clearTabSelection();
+ this.$inlineDetails.hide();
+ }
+
+ this.clearTabSelection();
+ this.$commentsButton.addClass("selected");
+ this.startLoading();
+ const url = `/p/${this.shareKey}/quick-comments`;
+ const resp = await fetch(url);
+ const json = await resp.json();
+ this.processCommentsResponse(json);
+ }
+
+ clickMoreComments(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ this.$comment.show();
+ this.$showMoreComments.hide();
+ }
+
+ processCommentsResponse(data) {
+ this.canSubmitComments = true;
+ if (data["result"] == "ok") {
+ this.$commentsButton.find("a").html(data["count"]);
+ this.$inlineDetails.html(data["html"]);
+ this.initCommentDom();
+ this.initCommentEvents();
+ }
+ }
+
+ clickReplyTo(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ this.clickCommentTextarea(ev);
+ const username = $(ev.target)
+ .parents(".comment")
+ .find(".username")
+ .html();
+ const usernameClean = username.replace(/[^a-zA-Z0-9_\-]+/g, "");
+ const currentText = this.$commentTextarea.val();
+ this.$commentTextarea.val(currentText + "@" + usernameClean + " ");
+ setCaret(this.$commentTextarea.get(0));
+ }
+
+ async clickDelete(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ const $deleteForm = $(`#${ev.target.id}-form`),
+ url = $deleteForm.attr("action"),
+ data = $deleteForm.serialize();
+ if (confirm("Are you sure you want to delete this?")) {
+ const resp = await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
+ });
+ const json = await resp.json();
+ this.processCommentsResponse(json);
+ }
+ }
+
+ async submitComment(ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ if (this.canSubmitComments === false) {
+ return;
+ }
+ this.canSubmitComments = false;
+ const url = this.$commentForm.attr("action");
+ const data = this.$commentForm.serialize();
+ const resp = await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
+ });
+ const json = await resp.json();
+ this.processCommentsResponse(json);
+ }
+
+ clickCommentTextarea(ev) {
+ this.$postCommentInline.addClass("post-comment-inline-expanded");
+ if (this.$commentTextarea.val().indexOf("Write a comment") === 0) {
+ this.$commentTextarea.val("");
+ }
+ this.clickMoreComments(ev);
+ this.$commentTextarea.css("min-height", "60px");
+ }
+
+ refreshLikes() {
+ if (this.$likesButton.hasClass("selected")) {
+ this.loadLikes();
+ }
+ }
+
+ refreshSaves() {
+ if (this.$savesButton.hasClass("selected")) {
+ this.loadSaves();
+ }
+ }
+}
+
+export { StreamStatsView };
diff --git a/static/js/StreamStatsViewRegistry.js b/static/js/StreamStatsViewRegistry.js
new file mode 100644
index 0000000..70214c0
--- /dev/null
+++ b/static/js/StreamStatsViewRegistry.js
@@ -0,0 +1,33 @@
+/**
+ * A singleton registry of StreamStatsView objects. These objects are created
+ * one per post on a page, and contain logic and event handlers for stats and
+ * comments of each individual post.
+ */
+
+const filesOnPage = {};
+
+const getView = function (shareKey) {
+ return filesOnPage[shareKey];
+};
+
+const StreamStatsViewRegistry = {
+ register: function (view) {
+ filesOnPage[view.shareKey] = view;
+ },
+
+ refreshLikes: function (shareKey) {
+ const view = getView(shareKey);
+ if (view !== undefined) {
+ view.refreshLikes();
+ }
+ },
+
+ refreshSaves: function (shareKey) {
+ const view = getView(shareKey);
+ if (view !== undefined) {
+ view.refreshSaves();
+ }
+ },
+};
+
+export { StreamStatsViewRegistry };
diff --git a/static/js/UserCounts.js b/static/js/UserCounts.js
new file mode 100644
index 0000000..eb9e5ba
--- /dev/null
+++ b/static/js/UserCounts.js
@@ -0,0 +1,87 @@
+/**
+ * Functionality to update the statistics (views, saves, likes) in the sidebar
+ * of a user page e.g. https://mltshp.com/user/epski
+ *
+ * Populates latest values from a backend API call on page load. Unsure why not
+ * just server side generated.
+ */
+
+function format(str_num) {
+ return Number.parseInt(str_num).toLocaleString();
+}
+
+function formatBrief(strNum) {
+ // Format number in a way that won't need excessive space to
+ // display. Abbreviate with suffixes and limit to one
+ // decimal place.
+
+ const n = parseInt(strNum, 10);
+
+ // Handle garbage input (somewhat) gracefully.
+ if (Number.isNaN(n)) {
+ return "0";
+ }
+
+ // Anything up to and including 9,999 return verbatim as
+ // we've got four characters minimum to play with.
+ if (n < 10000) {
+ return n.toLocaleString();
+ }
+
+ const suffixes = [
+ { threshold: 1e12, suffix: "T" },
+ { threshold: 1e9, suffix: "B" },
+ { threshold: 1e6, suffix: "M" },
+ { threshold: 1e3, suffix: "K" },
+ ];
+
+ for (const { threshold, suffix } of suffixes) {
+ // Iterate until we find a suffix that can handle this
+ // value.
+ if (n >= threshold) {
+ // Truncate to 1 decimal place.
+ const truncated = Math.floor((n / threshold) * 10) / 10;
+
+ // If the decimal part is 0 trim it.
+ if (truncated % 1 === 0) {
+ return truncated.toFixed(0) + suffix;
+ } else {
+ return truncated.toFixed(1) + suffix;
+ }
+ }
+ }
+
+ return n.toLocaleString();
+}
+
+const UserCounts = {
+ populate: async function ($userCountsPanel) {
+ const name = $userCountsPanel.attr("name");
+
+ function displayResults(result) {
+ if ("views" in result) {
+ $userCountsPanel
+ .find(".views")
+ .attr("title", format(result["views"]) + " views")
+ .find(".num")
+ .html(formatBrief(result["views"]));
+ $userCountsPanel
+ .find(".saves")
+ .attr("title", format(result["saves"]) + " saves")
+ .find(".num")
+ .html(formatBrief(result["saves"]));
+ $userCountsPanel
+ .find(".likes")
+ .attr("title", format(result["likes"]) + " likes")
+ .find(".num")
+ .html(formatBrief(result["likes"]));
+ }
+ }
+
+ const resp = await fetch(`/user/${name}/counts`);
+ const json = await resp.json();
+ displayResults(json);
+ },
+};
+
+export { UserCounts };
diff --git a/static/js/common.js b/static/js/common.js
new file mode 100644
index 0000000..7611afe
--- /dev/null
+++ b/static/js/common.js
@@ -0,0 +1,37 @@
+/**
+ * Common utility functions.
+ */
+
+// http://stackoverflow.com/questions/1125292/how-to-move-cursor-to-end-of-contenteditable-entity
+function setCaret(el) {
+ const ctrl = el;
+ const pos = ctrl.value.length;
+ if (ctrl.setSelectionRange) {
+ ctrl.focus();
+ ctrl.setSelectionRange(pos, pos);
+ } else if (ctrl.createTextRange) {
+ var range = ctrl.createTextRange();
+ range.collapse(true);
+ range.moveEnd("character", pos);
+ range.moveStart("character", pos);
+ range.select();
+ }
+}
+
+function toText(num, base) {
+ return num == 1
+ ? num + " " + "" + base + ""
+ : num + " " + "" + base + "s" + "";
+}
+
+function applyHoverForVideo(sel) {
+ sel.hover(function () {
+ if (this.hasAttribute("controls")) {
+ this.removeAttribute("controls");
+ } else {
+ this.setAttribute("controls", "controls");
+ }
+ });
+}
+
+export { applyHoverForVideo, setCaret, toText };
diff --git a/static/js/main.js b/static/js/main.js
index 61ae35a..4130208 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -1,2222 +1,811 @@
/* For now the core JS behavior needed accross the site */
-$(document).ready(function () {
- var NewPostPanel = (function () {
- var panel_expanded = false;
-
- var $new_post_panel;
- var $new_post_panel_inner;
- var $new_post_button;
- var $save_video_form;
- var $save_video_form_button;
- var $post_video_form;
- var $post_video_form_button;
-
- var init_dom = function () {
- $new_post_panel = $("#new-post-panel");
- $new_post_panel_inner = $("#new-post-panel .new-post-panel--inner");
- $new_post_button = $("#new-post-button");
- // upload image
- $upload_image_input = $("#upload-image-input");
- // link to video
- $link_to_video = $("#link-to-video");
- $video_shake_id = $("#video-shake-id");
- // video preview screen
- $save_video_form = $("#new-post-panel .save-video-form");
- $save_video_form_button = $(
- "#new-post-panel .save-video-form .btn",
- );
- $post_video_form = $("#new-post-panel .post-video-form");
- $post_video_form_button = $(
- "#new-post-panel .post-video-form .btn",
- );
- // shake selector
- $shake_selector = $(".shake-selector");
- };
- init_dom();
-
- $new_post_button.click(function () {
- NewPostPanel.load_new_post();
- return false;
- });
-
- // We don't want click event on panel to bubble up to body
- // since a click to body closes the panel.
- $new_post_panel.click(function (ev) {
- ev.stopPropagation();
- });
-
- // The events that are inside the panel that we want to initialize
- // when the panel loads. These are the events that are subject
- // to change depending on content that is loaded.
- var init_events = function () {
- $link_to_video.click(function () {
- NewPostPanel.load_post_video();
- return false;
- });
-
- $save_video_form_button.click(function (e) {
- NewPostPanel.submit_save_video();
- return false;
- });
-
- $post_video_form_button.click(function (e) {
- NewPostPanel.submit_post_video();
- return false;
- });
-
- $upload_image_input.change(function () {
- $(this).closest("form").submit();
- });
-
- $shake_selector.click(NewPostPanel.toggle_shake_selector);
- $shake_selector.find("ul a").click(NewPostPanel.choose_shake);
- };
-
- var remove_events = function () {
- $save_video_form_button.unbind();
- $post_video_form_button.unbind();
- $shake_selector.unbind();
- $link_to_video.unbind();
- };
-
- return {
- toggle_shake_selector: function (ev) {
- $(this).toggleClass("is-active").find("ul").toggle();
- ev.stopPropagation();
- ev.preventDefault();
- },
- // Sets the text of the shake to the chosen one and
- // sets a hidden input field with the proper shake id.
- choose_shake: function () {
- var $shake_selector = $(this).parents(".shake-selector");
- var $selected_shake = $shake_selector.find(".green");
- var $selected_shake_input = $shake_selector.find("input");
- var name = $(this).html();
- var id = $(this)
- .attr("id")
- .replace(/[^0-9]+/, "");
- $selected_shake.html(name);
- $selected_shake_input.val(id);
- },
- load_new_post: function () {
- var url = "/tools/new-post";
- var that = this;
- $.get(url, function (response) {
- that.refresh_panel(response);
- that.expand_panel();
- });
- return false;
- },
- load_post_video: function () {
- if ($video_shake_id.length > 0) {
- var shake_suffix = "?shake_id=" + $video_shake_id.val();
- } else {
- var shake_suffix = "";
- }
- var url = "/tools/save-video" + shake_suffix;
- var that = this;
- $.get(url, function (response) {
- that.refresh_panel(response);
- that.expand_panel();
- });
- },
- expand_panel: function () {
- panel_expanded = true;
- $new_post_panel.slideDown();
- that = this;
- $("body").one("click", $.proxy(this.close_panel, this));
- // we want to hide anything with a video since we can't
- // overlap things like youtube embeds, which is an iframe
- // that has an absolutely positioned flash element inside.
- $(".the-image iframe").each(function () {
- $(this).parent().css("height", $(this).height());
- $(this).parent().css("width", $(this).width());
- $(this).hide();
- });
- },
- close_panel: function () {
- panel_expanded = false;
- $new_post_panel.hide();
- remove_events();
- // show the videos again.
- $(".the-image iframe").show();
- },
- submit_save_video: function () {
- var url = $save_video_form.attr("action");
- var data = $save_video_form.serialize();
- var that = this;
- $.get(url, data, function (response) {
- that.refresh_panel(response);
- });
- },
- submit_post_video: function () {
- var url = $post_video_form.attr("action");
- var data = $post_video_form.serialize();
- var that = this;
- $post_video_form_button
- .unbind("click")
- .find("span")
- .html("Posting...");
- $.post(
- url,
- data,
- function (response) {
- document.location =
- document.location.protocol +
- "//" +
- document.location.host +
- response["path"];
- },
- "json",
- );
- },
- refresh_panel: function (response) {
- $new_post_panel_inner.html(response);
- $new_post_panel_inner.html();
- remove_events();
- init_dom();
- init_events();
- },
- };
- })();
-
- var to_text = function (num, base) {
- return num == 1
- ? num + " " + "" + base + ""
- : num + " " + "" + base + "s" + "";
- };
-
- var ShakesCache = {
- fetch: function () {
- if (this.result !== undefined) {
- return this.result;
- } else {
- return false;
- }
- },
-
- store: function (result) {
- this.result = result;
- },
- };
-
- var SaveThisView = function (container) {
- this.$save_this = $(container);
- this.init();
- };
-
- $.extend(SaveThisView.prototype, {
- init: function () {
- this.init_dom();
- this.init_events();
- },
-
- init_dom: function () {
- this.$save_this_link = this.$save_this.find(".save-this-link");
- this.$form = this.$save_this.find("form");
- this.$shake_id_input = this.$save_this.find(".shake-id-input");
- this.$shake_selector = $(
- "",
- );
- },
-
- init_events: function () {
- this.$save_this_link.click($.proxy(this.click_save_this, this));
- this.$save_this.delegate(
- ".shake-link",
- "click",
- $.proxy(this.click_choose_shake, this),
- );
- this.$save_this.delegate(
- ".close",
- "click",
- $.proxy(this.click_close_selector, this),
- );
- },
-
- click_save_this: function (ev) {
- ev.stopPropagation();
- if (this.$save_this_link.hasClass("save-this-link-multiple")) {
- this.show_shake_selector();
- } else {
- this.submit_image_save();
- }
- return false;
- },
-
- click_choose_shake: function (ev) {
- ev.stopPropagation();
- var shake_id = ev.target.id.replace(/[^\d]+/, "");
- this.$shake_id_input.val(shake_id);
- this.submit_image_save();
- return false;
- },
-
- click_close_selector: function (ev) {
- ev.stopPropagation();
- this.$shake_selector.remove();
- },
-
- show_shake_selector: function () {
- this.$save_this.append(this.$shake_selector);
- $("body").one("click", $.proxy(this.click_close_selector, this));
-
- // Only query once per page.
- if (ShakesCache.fetch() !== false) {
- this.fetch_available_shakes(ShakesCache.fetch());
- } else {
- $.get(
- "/account/shakes",
- $.proxy(this.fetch_available_shakes, this),
- "json",
- );
- }
- },
-
- fetch_available_shakes: function (response) {
- ShakesCache.store(response);
- var html = '";
- this.$shake_selector
- .removeClass("save-this-shake-selector-loading")
- .html(html);
- },
-
- submit_image_save: function (ev) {
- var url = this.$form.attr("action");
- var data = this.$form.serialize();
- $.post(
- url,
- data,
- $.proxy(this.process_image_save_response, this),
- "json",
- );
- },
-
- process_image_save_response: function (response) {
- if (response["share_key"]) {
- var count = response["count"];
- var share_key = response["share_key"];
- var new_share_key = response["new_share_key"];
- var count_string = to_text(count, "Save");
- $("#save-count-amount-" + share_key).html(count_string);
- var output =
- '
';
- this.$shake_selector.remove();
- this.$save_this.html(output);
- SidebarStatsView.refresh_saves();
- StreamStatsViewRegistry.refresh_saves(share_key);
- } else {
- return false;
- }
- },
- });
-
- function screen_reader_focus(el) {
- el.setAttribute("tabindex", "0");
- el.blur();
- el.focus();
- }
-
- $(".save-this").each(function () {
- var save_this_view = new SaveThisView(this);
- });
-
- // when we hit enter on a form, we want to submit it
- // even though we don't have an type="submit" input
- // available, since we're using a styled button.
- $sign_in_form = $("#sign-in-form");
- $("input", $sign_in_form).keydown(function (e) {
- if (e.keyCode == 13) {
- $sign_in_form.submit();
- return false;
- }
- });
+import { InviteMember } from "./InviteMember.js";
+import { NewPostPanel } from "./NewPostPanel.js";
+import { NotificationInvitationContainer } from "./NotificationInvitationContainer.js";
+import { NSFWCover } from "./NSFWCover.js";
+import { PermalinkCommentsView } from "./PermalinkCommentsView.js";
+import { RecommendedShakeCategory } from "./RecommendedShakeCategory.js";
+import { RequestInvitation } from "./RequestInvitation.js";
+import { SaveThisView } from "./SaveThisView.js";
+import { ShakeMemberList } from "./ShakeMemberList.js";
+import { SidebarStatsView } from "./SidebarStatsView.js";
+import { StreamStatsView } from "./StreamStatsView.js";
+import { StreamStatsViewRegistry } from "./StreamStatsViewRegistry.js";
+import { UserCounts } from "./UserCounts.js";
+import { applyHoverForVideo, toText } from "./common.js";
+
+NewPostPanel.attachEvents();
+InviteMember.attachEvents();
+
+function screenReaderFocus(el) {
+ el.setAttribute("tabindex", "0");
+ el.blur();
+ el.focus();
+}
+
+$(".save-this").each(function () {
+ SaveThisView.attachEvents(this);
+});
- $(".btn", $sign_in_form).click(function () {
+// when we hit enter on a form, we want to submit it even though we don't have
+// an type="submit" input available, since we're using a styled button.
+const $sign_in_form = $("#sign-in-form");
+$("input", $sign_in_form).keydown(function (e) {
+ if (e.keyCode == 13) {
$sign_in_form.submit();
return false;
- });
-
- // Prompt user to confirm before flagging something as NSFW.
- $("#flag-image-permalink").click(function () {
- return confirm("Are you sure you want to flag this as NSFW?");
- });
-
- // Prompt user to confirm before quitting a shake.
- $("#quit-shake-page").click(function () {
- return confirm(
- "Are you sure you want to quit this shake?\n(If you are following this shake you will also have to unfollow with the button above.)",
- );
- });
-
- // Prompt user to confirm before deleting a sharedfile.
- $("#delete-post-text").click(function () {
- return confirm("Are you sure you want to delete this post?");
- });
-
- // Inline editing of the title.
- $(".image-edit-title-form .cancel").click(function () {
- $(this).closest(".image-title").find(".image-edit-title").show();
- $(this).closest(".image-edit-title-form").removeClass("is-active");
- return false;
- });
-
- $(".image-edit-title").hover(
- function () {
- $(this).addClass("image-edit-title-hover");
- },
- function () {
- $(this).removeClass("image-edit-title-hover");
- },
- );
-
- $(".image-edit-title").click(function () {
- var $title_container = $(this).closest(".image-title");
- var url = $title_container.find("form").attr("action");
- var that = this;
-
- $.get(
- url,
- function (result) {
- if ("title_raw" in result) {
- $(that).hide();
- $title_container
- .find(".title-input")
- .val(result["title_raw"]);
- var $input = $title_container.find(".title-input");
- $(that)
- .next(".image-edit-title-form")
- .addClass("is-active");
- screen_reader_focus($input[0]);
- }
- },
- "json",
- );
- });
+ }
+});
- $(".image-edit-title-form").submit(function () {
- var data = $(this).serialize();
- var url = $(this).attr("action");
- var that = this;
- $.post(
- url,
- data,
- function (result) {
- if ("title" in result && "title_raw" in result) {
- var $title_container = $(that).closest(".image-title");
- $title_container
- .find(".image-edit-title")
- .html(result["title"])
- .show();
- $title_container
- .find(".title-input")
- .val(result["title_raw"]);
- $title_container
- .find(".image-edit-title-form")
- .removeClass("is-active");
- if (result["title_raw"] === "") {
- $title_container
- .find(".the-title")
- .html("click here to edit title")
- .show();
- $title_container
- .find(".the-title")
- .addClass("the-title-blank");
- } else {
- $title_container
- .find(".the-title")
- .removeClass("the-title-blank");
- }
- }
- },
- "json",
- );
- return false;
- });
+$(".btn", $sign_in_form).click(function () {
+ $sign_in_form.submit();
+ return false;
+});
- // Inline editing of the description.
- $(".description-edit-form").submit(function () {
- var data = $(this).serialize();
- var url = $(this).attr("action");
- var that = this;
- $.post(
- url,
- data,
- function (result) {
- if ("description" in result && "description_raw" in result) {
- var $description_container =
- $(that).closest(".description-edit");
- $description_container
- .find("textarea")
- .val(result["description_raw"]);
- $description_container
- .find(".description-edit-form")
- .hide();
- if (result["description"]) {
- $description_container
- .find(".the-description")
- .html(result["description"])
- .show();
- $description_container
- .find(".the-description")
- .removeClass("the-description-blank");
- } else {
- $description_container
- .find(".the-description")
- .html("click here to edit description")
- .show();
- $description_container
- .find(".the-description")
- .addClass("the-description-blank");
- }
- }
- },
- "json",
- );
- return false;
- });
+// Prompt user to confirm before flagging something as NSFW.
+$("#flag-image-permalink").click(function () {
+ return confirm("Are you sure you want to flag this as NSFW?");
+});
- $(".description-edit .the-description").hover(
- function () {
- $(this).addClass("the-description-hover");
- },
- function () {
- $(this).removeClass("the-description-hover");
- },
+// Prompt user to confirm before quitting a shake.
+$("#quit-shake-page").click(function () {
+ return confirm(
+ "Are you sure you want to quit this shake?\n(If you are following this shake you will also have to unfollow with the button above.)",
);
+});
- $(".description-edit .the-description").click(function () {
- var $description_container = $(this).closest(".description-edit");
- var url = $description_container.find("form").attr("action");
- var that = this;
- $.get(
- url,
- function (result) {
- if ("description_raw" in result) {
- $(that).hide();
- let $textarea = $description_container.find(
- ".description-edit-textarea",
- );
- $textarea.val(result["description_raw"]);
- $(that).next(".description-edit-form").show();
- screen_reader_focus($textarea[0]);
- }
- },
- "json",
- );
- });
-
- $(".description-edit .cancel").click(function () {
- $(this).closest(".description-edit").find(".the-description").show();
- $(this).closest(".description-edit-form").hide();
- return false;
- });
-
- // Inline editing of the alt text.
- $(".alt-text-edit-form").submit(function () {
- var data = $(this).serialize();
- var url = $(this).attr("action");
- var that = this;
- $.post(
- url,
- data,
- function (result) {
- if ("alt_text" in result && "alt_text_raw" in result) {
- var $alt_text_container = $(that).closest(".alt-text-edit");
- if (result["alt_text"]) {
- $alt_text_container.removeClass("alt-text--blank");
- $alt_text_container
- .find(".the-alt-text")
- .html(result["alt_text"]);
- } else {
- $alt_text_container.addClass("alt-text--blank");
- $alt_text_container
- .find(".the-alt-text")
- .html("add some alt text");
- }
- $alt_text_container.removeClass("alt-text--hidden");
- $alt_text_container.removeClass("alt-text--editing");
- $alt_text_container
- .find("textarea")
- .val(result["alt_text_raw"]);
- screen_reader_focus(
- $alt_text_container.find(".the-alt-text")[0],
- );
- }
- },
- "json",
- );
- return false;
- });
-
- $(".alt-text-edit .the-alt-text").hover(
- function () {
- $(this).addClass("the-alt-text-hover");
- },
- function () {
- $(this).removeClass("the-alt-text-hover");
- },
- );
+// Prompt user to confirm before deleting a sharedfile.
+$("#delete-post-text").click(function () {
+ return confirm("Are you sure you want to delete this post?");
+});
- $(".alt-text-edit .the-alt-text").click(function () {
- var $alt_text_container = $(this).closest(".alt-text-edit");
- var url = $alt_text_container.find("form").attr("action");
- var that = this;
- $.get(
- url,
- function (result) {
- if ("alt_text_raw" in result) {
- $(that)
- .closest(".alt-text-edit")
- .addClass("alt-text--editing");
- let $textarea = $alt_text_container.find(
- ".alt-text-edit-textarea",
- );
- $textarea.val(result["alt_text_raw"]);
- screen_reader_focus($textarea[0]);
- }
- },
- "json",
- );
- });
+// Inline editing of the title.
+$(".image-edit-title-form .cancel").click(function () {
+ $(this).closest(".image-title").find(".image-edit-title").show();
+ $(this).closest(".image-edit-title-form").removeClass("is-active");
+ return false;
+});
- $(".alt-text-edit .cancel").click(function () {
- $(this).closest(".alt-text-edit").removeClass("alt-text--hidden");
- $(this).closest(".alt-text-edit").removeClass("alt-text--editing");
- return false;
- });
+// TODO this could probably be a CSS :hover
+$(".image-edit-title").hover(
+ function () {
+ $(this).addClass("image-edit-title-hover");
+ },
+ function () {
+ $(this).removeClass("image-edit-title-hover");
+ },
+);
+
+$(".image-edit-title").click(async (ev) => {
+ const $label = $(ev.currentTarget);
+ const $container = $label.closest(".image-title");
+ const url = $container.find("form").attr("action");
+
+ const resp = await fetch(url);
+ const json = await resp.json();
+
+ if ("title_raw" in json) {
+ $label.hide();
+ const $input = $container.find(".title-input");
+ $input.val(json["title_raw"]);
+ $label.next(".image-edit-title-form").addClass("is-active");
+ screenReaderFocus($input[0]);
+ }
+});
- $(".alt-text-toggle").click(function () {
- let $alt = $(this).closest(".alt-text");
- $alt.toggleClass("alt-text--hidden");
- if (!$alt.hasClass("alt-text--hidden")) {
- screen_reader_focus($alt.find(".the-alt-text")[0]);
+$(".image-edit-title-form").submit(async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ const $form = $(ev.currentTarget);
+ const data = $form.serialize();
+ const url = $form.attr("action");
+ const resp = await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
+ });
+ const json = await resp.json();
+ if ("title" in json && "title_raw" in json) {
+ const $container = $form.closest(".image-title");
+ $container.find(".image-edit-title").html(json["title"]).show();
+ $container.find(".title-input").val(json["title_raw"]);
+ $container.find(".image-edit-title-form").removeClass("is-active");
+ if (json["title_raw"] === "") {
+ $container
+ .find(".the-title")
+ .html("click here to edit title")
+ .show();
+ $container.find(".the-title").addClass("the-title-blank");
+ } else {
+ $container.find(".the-title").removeClass("the-title-blank");
}
- });
+ }
+});
- $(".delete-from-shakes-form").click(function () {
- return confirm("Are you sure you want to remove it?");
- });
+// Inline editing of the description.
+$(".description-edit-form").submit(async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ const $form = $(ev.currentTarget);
+ const data = $form.serialize();
+ const url = $form.attr("action");
+ const resp = await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
+ });
+ const json = await resp.json();
+
+ console.log(json);
+
+ if ("description" in json && "description_raw" in json) {
+ const $container = $form.closest(".description-edit");
+ $container.find("textarea").val(json["description_raw"]);
+ $container.find(".description-edit-form").hide();
+ if (json["description"]) {
+ $container
+ .find(".the-description")
+ .html(json["description"])
+ .show();
+ $container
+ .find(".the-description")
+ .removeClass("the-description-blank");
+ } else {
+ $container
+ .find(".the-description")
+ .html("click here to edit description")
+ .show();
+ $container
+ .find(".the-description")
+ .addClass("the-description-blank");
+ }
+ }
+});
- /* Like / Unlike button */
- $(".like-button, .unlike-button").click(function () {
- var to_text = function (num, base) {
- return num == 1
- ? num + " " + "" + base + ""
- : num + " " + "" + base + "s" + "";
- };
-
- var $form = $(this).parents("form");
- var $buttons = $form.children("button");
- var url = $form.attr("action");
- var data = $form.serialize() + "&json=1";
-
- $.post(
- url,
- data,
- function (response) {
- if (response["error"]) {
- return false;
- } else {
- var count = response["count"];
- var share_key = response["share_key"];
- var count_string = to_text(count, "Like");
- $("#like-count-amount-" + share_key).html(count_string);
- if (response["like"] === true) {
- $form.attr("action", "/p/" + share_key + "/unlike");
- } else {
- $form.attr("action", "/p/" + share_key + "/like");
- }
- $buttons.toggleClass("is-active");
- SidebarStatsView.refresh_likes();
- StreamStatsViewRegistry.refresh_likes(share_key);
- }
- },
- "json",
- );
- return false;
- });
+// TODO this could probably be a CSS :hover
+$(".description-edit .the-description").hover(
+ function () {
+ $(this).addClass("the-description-hover");
+ },
+ function () {
+ $(this).removeClass("the-description-hover");
+ },
+);
+
+$(".description-edit .the-description").click(async (ev) => {
+ const $label = $(ev.currentTarget);
+ const $container = $label.closest(".description-edit");
+ const url = $container.find("form").attr("action");
+ const resp = await fetch(url);
+ const json = await resp.json();
+
+ if ("description_raw" in json) {
+ $label.hide();
+ const $textarea = $container.find(".description-edit-textarea");
+ $textarea.val(json["description_raw"]);
+ $label.next(".description-edit-form").show();
+ screenReaderFocus($textarea[0]);
+ }
+});
- var ImageStats = function () {
- return {
- save_count: 0,
- like_count: 0,
- };
- };
+$(".description-edit .cancel").click(function () {
+ $(this).closest(".description-edit").find(".the-description").show();
+ $(this).closest(".description-edit-form").hide();
+ return false;
+});
- var SidebarStatsView = (function (scope) {
- // if we aren't on a permalink page, just expose a dummy public API
- if ($(scope).length == 0) {
- return {
- init: function () {},
- refresh_likes: function () {},
- refresh_saves: function () {},
- };
+// Inline editing of the alt text.
+$(".alt-text-edit-form").submit(async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ const $form = $(ev.currentTarget);
+ const data = $form.serialize();
+ const url = $form.attr("action");
+ const resp = await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
+ });
+ const json = await resp.json();
+ if ("alt_text" in json && "alt_text_raw" in json) {
+ var $container = $form.closest(".alt-text-edit");
+ if (json["alt_text"]) {
+ $container
+ .removeClass("alt-text--blank")
+ .find(".the-alt-text")
+ .html(json["alt_text"]);
+ } else {
+ $container
+ .addClass("alt-text--blank")
+ .find(".the-alt-text")
+ .html("add some alt text");
}
+ $container
+ .removeClass("alt-text--hidden")
+ .removeClass("alt-text--editing")
+ .find("textarea")
+ .val(json["alt_text_raw"]);
+ screenReaderFocus($container.find(".the-alt-text")[0]);
+ }
+});
- var image_stats = new ImageStats();
- $save_count = $(".save-count", scope);
- $like_count = $(".like-count", scope);
- image_stats.save_count = parseInt($save_count.html(), 10);
- image_stats.like_count = parseInt($like_count.html(), 10);
-
- var $save_button = $(".sidebar-stats-saves", scope);
- var $like_button = $(".sidebar-stats-hearts", scope);
- var $content = $(".sidebar-stats-content", scope);
-
- var saves_expanded = false;
- var likes_expanded = false;
-
- return {
- init: function () {
- if (image_stats.save_count > 0) {
- this.bind_saves();
- } else {
- this.unbind_saves();
- }
- if (image_stats.like_count > 0) {
- this.bind_likes();
- this.toggle_likes();
- } else {
- this.unbind_likes();
- }
- },
-
- refresh_likes: function () {
- $like_count = $(".like-count", scope);
- image_stats.like_count = parseInt($like_count.html(), 10);
- if (likes_expanded) {
- this.get_likes();
- }
- if (image_stats.like_count > 0) {
- this.bind_likes();
- } else {
- likes_expanded = false;
- this.unbind_likes();
- }
- },
-
- refresh_saves: function () {
- $save_count = $(".save-count", scope);
- image_stats.save_count = parseInt($save_count.html(), 10);
- if (saves_expanded) {
- this.get_saves();
- }
- if (image_stats.save_count > 0) {
- this.bind_saves();
- } else {
- saves_expanded = false;
- this.unbind_saves();
- }
- },
-
- bind_saves: function () {
- $save_button.unbind("click");
- $save_button.addClass("enable-cursor");
- $save_button.click(function () {
- SidebarStatsView.toggle_saves();
- });
- },
-
- unbind_saves: function () {
- $save_button.removeClass("enable-cursor");
- $save_button.unbind("click");
- this.collapse();
- },
-
- bind_likes: function () {
- $like_button.unbind("click");
- $like_button.addClass("enable-cursor");
- $like_button.click(function () {
- SidebarStatsView.toggle_likes();
- });
- },
-
- unbind_likes: function () {
- $like_button.removeClass("enable-cursor");
- $like_button.unbind("click");
- this.collapse();
- },
-
- toggle_saves: function () {
- likes_expanded = false;
- saves_expanded = !saves_expanded;
- if (saves_expanded) {
- $like_button.removeClass("selected");
- $content.addClass("loading").show();
- $save_button.addClass("selected");
- this.get_saves();
- } else {
- this.collapse();
- }
- },
-
- get_saves: function () {
- $.get(
- document.location.pathname + "/saves",
- function (response) {
- if (response["result"]) {
- SidebarStatsView.process_save(response);
- }
- },
- "json",
- );
- },
-
- toggle_likes: function () {
- saves_expanded = false;
- likes_expanded = !likes_expanded;
- if (likes_expanded) {
- $save_button.removeClass("selected");
- $content.addClass("loading").show();
- $like_button.addClass("selected");
- this.get_likes();
- } else {
- this.collapse();
- }
- },
-
- get_likes: function () {
- $.get(
- document.location.pathname + "/likes",
- function (response) {
- if (response["result"]) {
- SidebarStatsView.process_like(response);
- }
- },
- "json",
- );
- },
-
- process_save: function (response) {
- if (response["count"] == 0) {
- this.disable_saves();
- } else {
- $save_count.html(this.to_text(response["count"], "Save"));
- this.render_content(response);
- }
- },
-
- process_like: function (response) {
- if (response["count"] == 0) {
- this.unbind_likes();
- } else {
- $like_count.html(this.to_text(response["count"], "Like"));
- this.render_content(response);
- }
- },
-
- to_text: function (num, base) {
- return num == 1
- ? num + " " + "" + base + ""
- : num + " " + "" + base + "s" + "";
- },
-
- collapse: function (repsponse) {
- $like_button.removeClass("selected");
- $save_button.removeClass("selected");
- $content.hide();
- },
-
- render_content: function (response) {
- var html = "";
- for (var i = 0, len = response["result"].length; i < len; i++) {
- var result = response["result"][i];
- var link;
- if (result["action"] == "save") {
- // for saves, we link to the saved post
- link = result["post_url"];
- } else {
- link = "/user/" + result["user_name"];
- }
- html += '";
- }
- $content.removeClass("loading").html(html);
- },
- };
- })("#sidebar-stats");
- SidebarStatsView.init();
-
- var StreamStatsView = function ($image_content_footer) {
- this.$image_content_footer = $image_content_footer;
- this.share_key = this.$image_content_footer
- .attr("id")
- .replace("image-content-footer-", "");
- this.can_submit_comments = true;
- this.init_dom();
- this.init_events();
- };
-
- $.extend(StreamStatsView.prototype, {
- init_dom: function () {
- this.$likes_button = this.$image_content_footer.find(".likes");
- this.$saves_button = this.$image_content_footer.find(".saves");
- this.$comments_button =
- this.$image_content_footer.find(".comments");
- this.$inline_details =
- this.$image_content_footer.find(".inline-details");
- this.init_comment_dom();
- },
-
- init_comment_dom: function () {
- this.$post_comment_inline = this.$inline_details.find(
- ".post-comment-inline",
- );
- this.$comment_form =
- this.$inline_details.find(".post-comment-form");
- this.$comment_textarea = this.$inline_details.find("textarea");
- this.$submit_comment_button = this.$inline_details.find(
- ".submit-comment-button",
- );
- this.$show_more_comments = this.$inline_details.find(
- ".show-more-comments",
- );
- this.$comment = this.$inline_details.find(".comment");
- this.$reply_to = this.$inline_details.find(".reply-to");
- this.$delete = this.$inline_details.find(".delete");
- },
-
- init_events: function () {
- this.$likes_button.click($.proxy(this.click_like, this));
- this.$saves_button.click($.proxy(this.click_saves, this));
- this.$comments_button.click($.proxy(this.click_comments, this));
- this.init_comment_events();
- },
-
- init_comment_events: function () {
- this.$comment_textarea.click(
- $.proxy(this.click_comment_textarea, this),
- );
- this.$show_more_comments.click(
- $.proxy(this.click_more_comments, this),
- );
- this.$reply_to.click($.proxy(this.click_reply_to, this));
- this.$delete.click($.proxy(this.click_delete, this));
- // Fix for Webkit bug where textarea looses focus incorrectly on mouseup.
- // http://code.google.com/p/chromium/issues/detail?id=4505
- this.$comment_textarea.mouseup(function (e) {
- e.preventDefault();
- });
- this.$comment_form.submit($.proxy(this.submit_comment, this));
- },
-
- // Removes selected state from all tabs.
- clear_tab_selection: function () {
- this.$likes_button.removeClass("selected");
- this.$saves_button.removeClass("selected");
- this.$comments_button.removeClass("selected");
- },
-
- // Start the "loading" state transition.
- start_loading: function () {
- this.$inline_details
- .addClass("inline-details-loading")
- .html("")
- .show();
- },
-
- user_html: function (data) {
- var html = "";
- for (var i = 0; i < data.result.length; i++) {
- var result = data.result[i];
- var link;
- if (result["action"] == "save") {
- link = result["post_url"];
- } else {
- link = "/user/" + result["user_name"];
- }
- html +=
- '' +
- '
' +
- '' +
- result["user_name"] +
- "";
- }
- return html;
- },
-
- click_like: function () {
- if (this.$likes_button.hasClass("selected")) {
- this.clear_tab_selection();
- this.$inline_details.hide();
- return false;
- }
- this.clear_tab_selection();
- this.$likes_button.addClass("selected");
- this.start_loading();
- this.load_likes();
- return false;
- },
-
- load_likes: function () {
- var url = "/p/" + this.share_key + "/likes";
- $.get(url, $.proxy(this.process_like_response, this), "json");
- },
-
- process_like_response: function (data) {
- var html =
- '' +
- this.user_html(data) +
- "
";
- this.$inline_details.html(html);
- },
-
- click_saves: function () {
- if (this.$saves_button.hasClass("selected")) {
- this.clear_tab_selection();
- this.$inline_details.hide();
- return false;
- }
-
- this.clear_tab_selection();
- this.$saves_button.addClass("selected");
- this.start_loading();
- this.load_saves();
- return false;
- },
-
- load_saves: function () {
- var url = "/p/" + this.share_key + "/saves";
- $.get(url, $.proxy(this.process_like_response, this), "json");
- },
-
- click_comments: function () {
- if (this.$comments_button.hasClass("selected")) {
- this.clear_tab_selection();
- this.$inline_details.hide();
- return false;
- }
+// TODO this could probably be a CSS :hover
+$(".alt-text-edit .the-alt-text").hover(
+ function () {
+ $(this).addClass("the-alt-text-hover");
+ },
+ function () {
+ $(this).removeClass("the-alt-text-hover");
+ },
+);
+
+$(".alt-text-edit .the-alt-text").click(async (ev) => {
+ const $label = $(ev.currentTarget);
+ const $container = $label.closest(".alt-text-edit");
+ const url = $container.find("form").attr("action");
+ const resp = await fetch(url);
+ const json = await resp.json();
+
+ if ("alt_text_raw" in json) {
+ $label.closest(".alt-text-edit").addClass("alt-text--editing");
+ const $textarea = $container.find(".alt-text-edit-textarea");
+ $textarea.val(json["alt_text_raw"]);
+ screenReaderFocus($textarea[0]);
+ }
+});
- this.clear_tab_selection();
- this.$comments_button.addClass("selected");
- this.start_loading();
- var url = "/p/" + this.share_key + "/quick-comments";
- $.get(url, $.proxy(this.process_comments_response, this), "json");
- return false;
- },
-
- click_more_comments: function () {
- this.$comment.show();
- this.$show_more_comments.hide();
- return false;
- },
-
- process_comments_response: function (data) {
- this.can_submit_comments = true;
- if (data["result"] == "ok") {
- this.$comments_button.find("a").html(data["count"]);
- this.$inline_details.html(data["html"]);
- this.init_comment_dom();
- this.init_comment_events();
- }
- },
-
- click_reply_to: function (ev) {
- this.click_comment_textarea();
- var username = $(ev.target)
- .parents(".comment")
- .find(".username")
- .html();
- var username_clean = username.replace(/[^a-zA-Z0-9_\-]+/g, "");
- var current_text = this.$comment_textarea.val();
- this.$comment_textarea.val(
- current_text + "@" + username_clean + " ",
- );
- setCaret(this.$comment_textarea.get(0));
- return false;
- },
-
- click_delete: function (ev) {
- var $delete_form = $("#" + ev.target.id + "-form"),
- url = $delete_form.attr("action"),
- data = $delete_form.serialize();
- if (confirm("Are you sure you want to delete this?")) {
- $.post(
- url,
- data,
- $.proxy(this.process_comments_response, this),
- "json",
- );
- }
- return false;
- },
+$(".alt-text-edit .cancel").click(function () {
+ $(this)
+ .closest(".alt-text-edit")
+ .removeClass("alt-text--hidden")
+ .removeClass("alt-text--editing");
+ return false;
+});
- submit_comment: function () {
- if (this.can_submit_comments === false) {
- return false;
- }
- this.can_submit_comments = false;
- var url = this.$comment_form.attr("action");
- var data = this.$comment_form.serialize();
- $.post(
- url,
- data,
- $.proxy(this.process_comments_response, this),
- "json",
- );
- return false;
- },
+$(".alt-text-toggle").click(function () {
+ let $alt = $(this).closest(".alt-text");
+ $alt.toggleClass("alt-text--hidden");
+ if (!$alt.hasClass("alt-text--hidden")) {
+ screenReaderFocus($alt.find(".the-alt-text")[0]);
+ }
+});
- click_comment_textarea: function (e) {
- this.$post_comment_inline.addClass("post-comment-inline-expanded");
- if (this.$comment_textarea.val().indexOf("Write a comment") === 0) {
- this.$comment_textarea.val("");
- }
- this.click_more_comments();
- this.$comment_textarea.css("min-height", "60px");
- },
+$(".delete-from-shakes-form").click(function () {
+ return confirm("Are you sure you want to remove it?");
+});
- refresh_likes: function () {
- if (this.$likes_button.hasClass("selected")) {
- this.load_likes();
- }
- },
+/* Like / Unlike button */
+$(".like-button, .unlike-button").click(async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
- refresh_saves: function () {
- if (this.$saves_button.hasClass("selected")) {
- this.load_saves();
- }
- },
+ const $form = $(ev.currentTarget).parents("form");
+ const $buttons = $form.children("button");
+ const url = $form.attr("action");
+ const data = $form.serialize() + "&json=1"; // TODO json suffix required?
+ const resp = await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
});
+ const json = await resp.json();
- var StreamStatsViewRegistry = {
- files_on_page: {},
- register: function (view) {
- this.files_on_page[view.share_key] = view;
- },
- refresh_likes: function (share_key) {
- view = this.get_view(share_key);
- if (view !== undefined) {
- view.refresh_likes();
- }
- },
- refresh_saves: function (share_key) {
- view = this.get_view(share_key);
- if (view !== undefined) {
- view.refresh_saves();
- }
- },
- get_view: function (share_key) {
- return this.files_on_page[share_key];
- },
- };
+ if (json["error"]) {
+ return;
+ }
- function apply_hover_for_video(sel) {
- sel.hover(function () {
- if (this.hasAttribute("controls")) {
- this.removeAttribute("controls");
- } else {
- this.setAttribute("controls", "controls");
- }
- });
+ const count = json["count"];
+ const shareKey = json["share_key"];
+ const countString = toText(count, "Like");
+ $("#like-count-amount-" + shareKey).html(countString);
+ if (json["like"] === true) {
+ $form.attr("action", `/p/${shareKey}/unlike`);
+ } else {
+ $form.attr("action", `/p/${shareKey}/like`);
}
+ $buttons.toggleClass("is-active");
+ SidebarStatsView.refreshLikes();
+ StreamStatsViewRegistry.refreshLikes(shareKey);
+});
- var NSFWCover = function ($root) {
- this.$root = $root;
- this.init();
- };
+SidebarStatsView.init("#sidebar-stats");
- $.extend(NSFWCover.prototype, {
- init: function () {
- this.$root.delegate(
- "a",
- "click",
- $.proxy(this.click_show_image, this),
- );
- },
-
- click_show_image: function (ev) {
- var location = document.location,
- host = location.host,
- protocol = location.protocol,
- base_path = location.protocol + "//" + location.host,
- file_path = $(ev.target).attr("href");
- $.get(
- base_path +
- "/services/oembed?include_embed=1&url=" +
- escape(base_path + file_path),
- $.proxy(this.load_image, this),
- "json",
- );
- return false;
- },
-
- load_image: function (response) {
- var parent = this.$root.parent(),
- parent_height = parent.height();
-
- if (response["type"] === "photo") {
- parent
- .css("min-height", parent_height + "px")
- .html(
- '
',
- );
- } else if (response["embed_html"]) {
- parent
- .css("min-height", parent_height + "px")
- .html(
- '' +
- response["embed_html"] +
- "
",
- );
- } else if (response["type"] === "video") {
- var content = parent
- .css("min-height", parent_height + "px")
- .html(
- response["html"].replace(
- / 0) {
+ NSFWCover.attachEvents($nsfw_cover);
+ }
+});
- $(".image-content").each(function () {
- var $image_content = $(this),
- $image_footer = $image_content.find(".image-content-footer"),
- $nsfw_cover = $image_content.find(".nsfw-cover");
- var stream_stats_view = new StreamStatsView($image_footer);
- StreamStatsViewRegistry.register(stream_stats_view);
- var nsfw_cover = new NSFWCover($nsfw_cover);
- });
+applyHoverForVideo($(".image-content video.autoplay"));
- apply_hover_for_video($(".image-content video.autoplay"));
+/* Open / close notification boxes */
+$(document).on("click", ".notification-block-hd", function () {
+ $(this).next().toggle();
+});
- /* Open / close notification boxes */
- $(document).on("click", ".notification-block-hd", function () {
- $(this).next().toggle();
- });
+/* User follow module */
+$(document).on("click", ".user-follow .submit-form", async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
- /* User follow module */
- $(document).on("click", ".user-follow .submit-form", function () {
- var $container = $(this).parents(".user-follow");
- var $form = $container.find("form");
- var url = $form.attr("action");
- var data = $form.serialize() + "&json=1";
- var that = this;
- $.post(
- url,
- data,
- function (response) {
- if (response["error"]) {
- return false;
- } else {
- if (response["subscription_status"] == true) {
- $form.attr(
- "action",
- url.replace("subscribe", "unsubscribe"),
- );
- $(that)
- .text("- Unfollow")
- .addClass("btn-warning")
- .removeClass("btn-secondary");
- } else {
- $form.attr(
- "action",
- url.replace("unsubscribe", "subscribe"),
- );
- $(that)
- .text("+ Follow")
- .addClass("btn-secondary")
- .removeClass("btn-warning");
- }
- }
- },
- "json",
- );
- return false;
+ const $button = $(ev.currentTarget);
+ const $container = $(button).parents(".user-follow");
+ const $form = $container.find("form");
+ const url = $form.attr("action");
+ const data = $form.serialize() + "&json=1"; // TODO json suffix required?
+ const resp = await fetch(url, {
+ method: "POST",
+ body: URLSearchParams(data),
});
+ const json = await resp.json();
- $(document).on("click", ".notification-close", function () {
- $notification = $(this).parent(".notification");
- var $notification_block = $(this).parents(".notification-block");
- var $notification_block_hd = $notification_block.find(
- ".notification-block-hd",
- );
- var id = $(this)
- .attr("id")
- .replace(/[^\d]+/, "");
- $.post(
- "/account/clear-notification" + "?type=single&id=" + id,
- {},
- function (response) {
- $notification.remove();
- var html = $notification_block_hd.html();
- var count = html.replace(/[^\d]+/, "");
- var new_count = parseInt(count, 10) - 1;
- if (new_count == 0) {
- $notification_block_hd.html("You have 0 new followers");
- $notification_block.find(".clear-all").remove();
- } else {
- $notification_block_hd.html(
- html.replace(/[\d]+/, new_count),
- );
- }
- },
- "json",
- );
+ if (json["error"]) {
return false;
- });
-
- $(document).on("click", ".notification-block .clear-all a", function () {
- var url = $(this).attr("href");
- var $notification_block = $(this).parents(".notification-block");
- $.post(
- url,
- {},
- function (response) {
- if (response["error"]) {
- return false;
- } else {
- $notification_block
- .find(".notification-block-hd")
- .html(response["response"]);
- $notification_block
- .find(".notification-block-bd")
- .html("")
- .toggle();
- }
- },
- "json",
- );
-
- return false;
- });
-
- /* Notification block: invitations: */
- $(document).on(
- "submit",
- "#notifcation-block-invitations form",
- function () {
- var data = $(this).serialize();
- var url = $(this).attr("action");
-
- var that = this;
- $.post(
- url,
- data,
- function (response) {
- if (response["error"]) {
- $(that)
- .find(".main-message")
- .html("" + response["error"] + "
");
- } else {
- if (response["count"] == 0) {
- $(that).find("input").hide();
- $("#invitation-count-text").html(
- response["count"] + " invitations",
- );
- $(that)
- .find(".main-message")
- .html("Thanks!
");
- } else {
- var invitation_text =
- response["count"] == 1
- ? "invitation"
- : "invitations";
- $("#invitation-count-text").html(
- response["count"] + " " + invitation_text,
- );
- $(that)
- .find(".main-message")
- .html(response["message"]);
- $("#email_address").val("");
- }
- }
- },
- "json",
- );
-
- return false;
- },
- );
+ }
- /* Notification block: shake invitations: */
- $(document).on(
- "submit",
- "#notifcation-block-shakeinvitation form",
- function () {
- var data = $(this).serialize();
- var url = $(this).attr("action");
- var $block = $(this).parents(".notification");
- var $header = $(
- "#notifcation-block-shakeinvitation .notification-block-hd",
- );
+ if (json["subscription_status"] == true) {
+ $form.attr("action", url.replace("subscribe", "unsubscribe"));
+ $button
+ .text("- Unfollow")
+ .addClass("btn-warning")
+ .removeClass("btn-secondary");
+ } else {
+ $form.attr("action", url.replace("unsubscribe", "subscribe"));
+ $button
+ .text("+ Follow")
+ .addClass("btn-secondary")
+ .removeClass("btn-warning");
+ }
+});
- var that = this;
- $.post(
- url,
- data,
- function (response) {
- if (!response["error"]) {
- $block.remove();
- // we update the header differently when presenting only one
- // invitation on the shake page itself.
- if ($header.hasClass("invitation-single")) {
- $header.html("Got it.");
- } else {
- var invitation_text =
- response["count"] == 1
- ? "invitation"
- : "invitations";
- $header.html(
- response["count"] +
- " new shake " +
- invitation_text,
- );
- }
- }
- },
- "json",
- );
+$(document).on("click", ".notification-close", async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
- return false;
- },
+ const $button = $(ev.currentTarget);
+ const $notification = $button.parent(".notification");
+ const $notificationBlock = $button.parents(".notification-block");
+ const $notificationBlockHd = $notificationBlock.find(
+ ".notification-block-hd",
);
+ const id = $button.attr("id").replace(/[^\d]+/, "");
+ const url = `/account/clear-notification?type=single&id=${id}`;
+ // TODO data should be sent as body for POST?
+ const resp = await fetch(url, { method: "POST" });
+
+ $notification.remove();
+ const html = $notificationBlockHd.html();
+ const count = html.replace(/[^\d]+/, "");
+ var newCount = parseInt(count, 10) - 1;
+ if (newCount == 0) {
+ $notificationBlockHd.html("You have 0 new followers");
+ $notificationBlock.find(".clear-all").remove();
+ } else {
+ $notificationBlockHd.html(html.replace(/[\d]+/, newCount));
+ }
+});
- var NotificationInvitationContainer = function ($root) {
- this.$root = $root;
- this.init();
- };
-
- $.extend(NotificationInvitationContainer.prototype, {
- init: function () {
- this.$hd = this.$root.find(".notification-block-hd");
- this.$bd = this.$root.find(".notification-block-bd");
- this.on_shake_page = this.$hd.hasClass("on-shake-page");
- var that = this;
- this.$root.find(".notification").each(function () {
- var new_invitation_request = new NotificationInvitationRequest(
- $(this),
- that,
- );
- });
- },
-
- update_count: function (count) {
- if (!this.on_shake_page) {
- var request_text = count == 1 ? " request" : " requests";
- var html = count + request_text + " to join a shake";
- this.$hd.html(html);
- } else {
- this.$hd.html("Got it!");
- }
- },
- });
-
- var NotificationInvitationRequest = function ($root, container) {
- this.$root = $root;
- this.container = container;
- this.init_dom();
- this.init_events();
- };
-
- $.extend(NotificationInvitationRequest.prototype, {
- init_dom: function () {
- this.$form = this.$root.find("form");
- this.$form_approve_invitation = this.$root.find(
- ".approve-invitation",
- );
- this.$form_decline_invitation = this.$root.find(
- ".decline-invitation",
- );
- },
+$(document).on("click", ".notification-block .clear-all a", async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
- init_events: function () {
- this.$root.delegate(
- ".approve-invitation",
- "submit",
- $.proxy(this.submit_approve_invitation, this),
- );
- this.$root.delegate(
- ".decline-invitation",
- "submit",
- $.proxy(this.submit_decline_invitation, this),
- );
- },
-
- submit_approve_invitation: function (ev) {
- ev.preventDefault();
- this.submit_form(this.$form_approve_invitation);
- },
-
- submit_decline_invitation: function (ev) {
- ev.preventDefault();
- this.submit_form(this.$form_decline_invitation);
- },
-
- submit_form: function ($form) {
- var url = $form.attr("action");
- var data = $form.serialize();
- $.post(url, data, $.proxy(this.clear_notification, this), "json");
- },
-
- clear_notification: function (response) {
- if (response["status"] == "ok") {
- this.$root.remove();
- this.container.update_count(response["count"]);
- }
- },
- });
+ const $link = $(ev.currentTarget);
+ const url = $link.attr("href");
+ const $notificationBlock = $link.parents(".notification-block");
+ const resp = await fetch(url, { method: "POST" });
+ const json = await resp.json();
- var init_notification_invitation_request = function () {
- $notification_invitation_request = $(
- "#notification-block-invitation-request",
- );
- if ($notification_invitation_request.length > 0) {
- var invitation_requests = new NotificationInvitationContainer(
- $notification_invitation_request,
- );
- }
- };
- init_notification_invitation_request();
-
- // Expand all notifications.
- $("#notification-block-aggregate").click(function () {
- $(this).find(".notification-block-hd").html("Loading...");
- $.get("/account/quick-notifications", function (response) {
- $("#notification-block-aggregate").hide().after(response);
- init_notification_invitation_request();
- });
- });
-
- /* Action Button in a Fun Form, should submit the form (exception here
- for a button with a g-recaptcha class which has a separate event
- handler). */
- $(".field-submit .btn:not(.g-recaptcha)").click(function () {
- $(this).closest("form").submit();
+ if (json["error"]) {
return false;
- });
+ }
- /* Site Nav dropdown */
- $site_nav = $("#site-nav");
- var site_nav_expanded = false;
- $("#site-nav .site-nav--toggle").click(function (event) {
- event.stopPropagation();
- if (site_nav_expanded == false) {
- site_nav_expanded = true;
- $site_nav.addClass("is-expanded");
- $("body").one("click", function () {
- $site_nav.removeClass("is-expanded");
- site_nav_expanded = false;
- });
- } else {
- $site_nav.removeClass("is-expanded");
- $("body").unbind("click");
- site_nav_expanded = false;
- }
- });
+ $notificationBlock.find(".notification-block-hd").html(resp["response"]);
+ $notificationBlock.find(".notification-block-bd").html("").toggle();
- $("#site-nav .site-nav--list").click(function (event) {
- event.stopPropagation();
- });
+ return false;
+});
- /* Choose a shake dropdown */
- $choose_a_shake = $("#choose-a-shake");
- var shake_expanded = false;
- $("#choose-a-shake .choose-a-shake--toggle").click(function (event) {
- event.stopPropagation();
- if (shake_expanded == false) {
- shake_expanded = true;
- $choose_a_shake.addClass("is-expanded");
- $("body").one("click", function () {
- $choose_a_shake.removeClass("is-expanded");
- shake_expanded = false;
- });
+/* Notification block: invitations: */
+$(document).on("submit", "#notifcation-block-invitations form", async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ const $form = $(ev.currentTarget);
+ const data = $form.serialize();
+ const url = $form.attr("action");
+ const resp = await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
+ });
+ const json = await resp.json();
+
+ if (json["error"]) {
+ $form.find(".main-message").html(`${response["error"]}
`);
+ } else {
+ if (json["count"] == 0) {
+ $form.find("input").hide();
+ $("#invitation-count-text").html(`${json["count"]} invitations`);
+ $form.find(".main-message").html("Thanks!
");
} else {
- $choose_a_shake.removeClass("is-expanded");
- $("body").unbind("click");
- shake_expanded = false;
- }
- });
-
- $("#choose-a-shake .choose-a-shake--dropdown").click(function (event) {
- event.stopPropagation();
- });
-
- /* Conversations - mute this button */
- $(".mute-this-conversation").click(function () {
- var $form = $(this).next(".mute-this-conversation-form");
- var url = $form.attr("action");
- var data = $form.serialize();
- var $conversation = $(this).parents(".conversation");
- $.post(url, data, function () {
- $conversation.fadeOut("slow");
- });
- return false;
- });
-
- // http://stackoverflow.com/questions/1125292/how-to-move-cursor-to-end-of-contenteditable-entity
- function setCaret(el) {
- ctrl = el;
- pos = ctrl.value.length;
- if (ctrl.setSelectionRange) {
- ctrl.focus();
- ctrl.setSelectionRange(pos, pos);
- } else if (ctrl.createTextRange) {
- var range = ctrl.createTextRange();
- range.collapse(true);
- range.moveEnd("character", pos);
- range.moveStart("character", pos);
- range.select();
+ const invitation_text =
+ json["count"] == 1 ? "invitation" : "invitations";
+ $("#invitation-count-text").html(
+ `${json["count"]} ${invitation_text}`,
+ );
+ $form.find(".main-message").html(json["message"]);
+ $("#email_address").val("");
}
}
+});
- var PermalinkCommentsView = function ($root) {
- this.$root = $root;
- this.init();
- this.init_events();
- };
-
- $.extend(PermalinkCommentsView.prototype, {
- init: function () {
- this.$post_comment_body = $("#post-comment-body");
- },
-
- init_events: function () {
- this.$root.delegate(
- ".reply-to",
- "click",
- $.proxy(this.click_reply_to, this),
- );
- this.$root.delegate(
- ".delete",
- "click",
- $.proxy(this.click_delete, this),
- );
- },
-
- click_reply_to: function (ev) {
- var $target = $(ev.target);
- var $meta = $target.parent();
- var username = $meta.find(".username").html();
- var username_clean = username.replace(/[^a-zA-Z0-9_\-]+/g, "");
- var current_text = this.$post_comment_body.val();
- this.$post_comment_body.val(
- current_text + "@" + username_clean + " ",
- );
- setCaret(this.$post_comment_body.get(0));
- window.location.hash = "post-comment";
- return false;
- },
-
- click_delete: function (ev) {
- var $delete_form = $("#" + ev.target.id + "-form");
- if (confirm("Are you sure you want to delete this?")) {
- $delete_form.submit();
+/* Notification block: shake invitations: */
+$(document).on(
+ "submit",
+ "#notifcation-block-shakeinvitation form",
+ async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ const $form = $(ev.currentTarget);
+ const data = $form.serialize();
+ const url = $form.attr("action");
+ const $block = $form.parents(".notification");
+ const $header = $(
+ "#notifcation-block-shakeinvitation .notification-block-hd",
+ );
+ const resp = await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
+ });
+ const json = await resp.json();
+
+ if (!json["error"]) {
+ $block.remove();
+ // we update the header differently when presenting only one
+ // invitation on the shake page itself.
+ if ($header.hasClass("invitation-single")) {
+ $header.html("Got it.");
+ } else {
+ const invitationText =
+ json["count"] == 1 ? "invitation" : "invitations";
+ $header.html(`${json["count"]} new shake ${invitationText}`);
}
- return false;
- },
- });
+ }
+ },
+);
- var $image_comments_permalink = $("#image-comments-permalink");
- if ($image_comments_permalink.length > 0) {
- var new_comment = new PermalinkCommentsView($image_comments_permalink);
+const initNotificationInvitationRequest = function () {
+ const $notificationInvitationRequest = $(
+ "#notification-block-invitation-request",
+ );
+ if ($notificationInvitationRequest.length > 0) {
+ NotificationInvitationContainer.populate(
+ $notificationInvitationRequest,
+ );
}
+};
+initNotificationInvitationRequest();
- $("#nsfw-filter-button a").click(function () {
- $(this).parents("form").submit();
- return false;
- });
-
- $("#apps .disconnect").click(function () {
- if (confirm("Are you sure you want to disconnect this app?")) {
- var $form = $(this).parent().next("form");
- var url = $form.attr("action");
- var data = $form.serialize();
- var parent = $(this).parents("li");
- $.post(
- url,
- data,
- function (response) {
- parent.hide("slow");
- },
- "ajax",
- );
- return false;
- } else {
- return false;
- }
+// Expand all notifications.
+$("#notification-block-aggregate").click(function () {
+ $(this).find(".notification-block-hd").html("Loading...");
+ $.get("/account/quick-notifications", function (response) {
+ $("#notification-block-aggregate").hide().after(response);
+ initNotificationInvitationRequest();
});
+});
- // Tools: Recommended group shakes
- if ($("#shake-categories").length > 0) {
- var RecommendedShakeCategory = function (root) {
- this.root = root;
- this.$root = $(root);
- this.fetched = false;
- this.init_events();
- };
-
- $.extend(RecommendedShakeCategory.prototype, {
- init_events: function () {
- this.$toggle = this.$root.find(".shake-category-toggle");
- this.$body = this.$root.find(".shake-category-body");
- this.$toggle.click($.proxy(this.click_toggle, this));
- },
-
- click_toggle: function () {
- if (!this.fetched) {
- var url =
- "/tools/find-shakes/quick-fetch-category/" +
- this.$toggle.attr("href").replace("#", "");
- $.get(url, $.proxy(this.populate_results, this));
- } else {
- this.toggle();
- }
- return false;
- },
-
- populate_results: function (results) {
- this.fetched = true;
- this.$body.html(results);
- this.toggle();
- },
-
- toggle: function (result) {
- this.$root.toggleClass("shake-category-selected");
- },
- });
+/* Action Button in a Fun Form, should submit the form (exception here
+ for a button with a g-recaptcha class which has a separate event
+ handler). */
+$(".field-submit .btn:not(.g-recaptcha)").click(function () {
+ $(this).closest("form").submit();
+ return false;
+});
- $("#shake-categories .shake-category").each(function () {
- var new_category = new RecommendedShakeCategory(this);
+/* Site Nav dropdown */
+const $siteNav = $("#site-nav");
+let siteNavExpanded = false;
+$("#site-nav .site-nav--toggle").click(function (event) {
+ event.stopPropagation();
+ if (siteNavExpanded == false) {
+ siteNavExpanded = true;
+ $siteNav.addClass("is-expanded");
+ $("body").one("click", function () {
+ $siteNav.removeClass("is-expanded");
+ siteNavExpanded = false;
});
+ } else {
+ $siteNav.removeClass("is-expanded");
+ $("body").unbind("click");
+ siteNavExpanded = false;
}
+});
- // Shake Page - change image.
- $("#shake-image-edit").hover(
- function () {
- $(this).addClass("shake-image-hover");
- },
- function () {
- $(this).removeClass("shake-image-hover");
- },
- );
-
- // Shake Page: choosing file to upload.
- $("#shake-image-edit input").change(function () {
- $(this).closest("form").submit();
- });
-
- // Shake Page: inline editing title & description:
- $(".shake-edit-title-form .cancel").click(function () {
- $(this).parents(".shake-details").find(".shake-edit-title").show();
- $(this).closest(".shake-edit-title-form").hide();
- return false;
- });
-
- $(".shake-edit-title").hover(
- function () {
- $(this).addClass("shake-edit-title-hover");
- },
- function () {
- $(this).removeClass("shake-edit-title-hover");
- },
- );
-
- $(".shake-edit-title").click(function () {
- var $title_container = $(this).closest(".shake-details");
- var url = $title_container.find("form").attr("action");
- var that = this;
-
- $.get(
- url,
- function (result) {
- if ("title_raw" in result) {
- $(that).hide();
- $title_container
- .find(".shake-edit-title-input")
- .val(result["title_raw"]);
- $(that).next(".shake-edit-title-form").show();
- }
- },
- "json",
- );
- });
-
- $(".shake-edit-title-form").submit(function () {
- var data = $(this).serialize();
- var url = $(this).attr("action");
- var that = this;
- $.post(
- url,
- data,
- function (result) {
- if ("title" in result && "title_raw" in result) {
- var $title_container = $(that).closest(".shake-details");
- $title_container
- .find(".shake-edit-title")
- .html(result["title"])
- .show();
- $title_container
- .find(".shake-edit-title-input")
- .val(result["title_raw"]);
- $title_container.find(".shake-edit-title-form").hide();
- }
- },
- "json",
- );
- return false;
- });
+$("#site-nav .site-nav--list").click(function (event) {
+ event.stopPropagation();
+});
- // Shake Page: Edit Description
- $(".shake-edit-description-form .cancel").click(function () {
- $(this)
- .parents(".shake-details")
- .find(".shake-edit-description")
- .show();
- $(this).closest(".shake-edit-description-form").hide();
- return false;
- });
+/* Choose a shake dropdown */
+const $chooseAShake = $("#choose-a-shake");
+let shakeExpanded = false;
+$("#choose-a-shake .choose-a-shake--toggle").click(function (event) {
+ event.stopPropagation();
+ if (shakeExpanded == false) {
+ shakeExpanded = true;
+ $chooseAShake.addClass("is-expanded");
+ $("body").one("click", function () {
+ $chooseAShake.removeClass("is-expanded");
+ shakeExpanded = false;
+ });
+ } else {
+ $chooseAShake.removeClass("is-expanded");
+ $("body").unbind("click");
+ shakeExpanded = false;
+ }
+});
- $(".shake-edit-description").hover(
- function () {
- $(this).addClass("shake-edit-description-hover");
- },
- function () {
- $(this).removeClass("shake-edit-description-hover");
- },
- );
+$("#choose-a-shake .choose-a-shake--dropdown").click(function (event) {
+ event.stopPropagation();
+});
- $(".shake-edit-description").click(function () {
- var $title_container = $(this).closest(".shake-details");
- var url = $title_container.find("form").attr("action");
- var that = this;
-
- $.get(
- url,
- function (result) {
- if ("description_raw" in result) {
- $(that).hide();
- $title_container
- .find(".shake-edit-description-input")
- .val(result["description_raw"]);
- $(that).next(".shake-edit-description-form").show();
- }
- },
- "json",
- );
- });
+/* Conversations - mute this button */
+$(".mute-this-conversation").click(async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ const $button = $(ev.currentTarget);
+ const $form = $button.next(".mute-this-conversation-form");
+ const url = $form.attr("action");
+ const data = $form.serialize();
+ const $conversation = $button.parents(".conversation");
+ await fetch(url, { method: "POST", body: new URLSearchParams(data) });
+ $conversation.fadeOut("slow");
+});
- $(".shake-edit-description-form").submit(function () {
- var data = $(this).serialize();
- var url = $(this).attr("action");
- var that = this;
- $.post(
- url,
- data,
- function (result) {
- if ("description" in result && "description_raw" in result) {
- var $title_container = $(that).closest(".shake-details");
- $title_container
- .find(".shake-edit-description")
- .html(result["description"])
- .show();
- $title_container
- .find(".shake-edit-description-input")
- .val(result["description_raw"]);
- $title_container
- .find(".shake-edit-description-form")
- .hide();
- }
- },
- "json",
- );
- return false;
- });
+const $imageCommentsPermalink = $("#image-comments-permalink");
+if ($imageCommentsPermalink.length > 0) {
+ PermalinkCommentsView.addEvents($imageCommentsPermalink);
+}
- var $user_counts = $("#user-counts");
- if ($user_counts.length > 0) {
- var UserCounts = (function () {
- var $root = $user_counts,
- name = $root.attr("name");
- $.get(
- "/user/" + name + "/counts",
- function (result) {
- UserCounts.display_results(result);
- },
- "json",
- );
+$("#nsfw-filter-button a").click(function () {
+ $(this).parents("form").submit();
+ return false;
+});
- return {
- display_results: function (result) {
- if ("views" in result) {
- $root
- .find(".views")
- .attr("title", UserCounts.format(result["views"]) + " views")
- .find(".num")
- .html(UserCounts.formatBrief(result["views"]));
- $root
- .find(".saves")
- .attr("title", UserCounts.format(result["saves"]) + " saves")
- .find(".num")
- .html(UserCounts.formatBrief(result["saves"]));
- $root
- .find(".likes")
- .attr("title", UserCounts.format(result["likes"]) + " likes")
- .find(".num")
- .html(UserCounts.formatBrief(result["likes"]));
- }
- },
- format: function (str_num) {
- return Number.parseInt(str_num).toLocaleString();
- },
- formatBrief: function (str_num) {
- // Format number in a way that won't need excessive space to
- // display. Abbreviate with suffixes and limit to one
- // decimal place.
-
- const n = parseInt(str_num, 10);
-
- // Handle garbage input (somewhat) gracefully.
- if (Number.isNaN(n)) {
- return '0';
- }
-
- // Anything up to and including 9,999 return verbatim as
- // we've got four characters minimum to play with.
- if (n < 10000) {
- return n.toLocaleString();
- }
-
- const suffixes = [
- { threshold: 1e12, suffix: 'T' },
- { threshold: 1e9, suffix: 'B' },
- { threshold: 1e6, suffix: 'M' },
- { threshold: 1e3, suffix: 'K' }
- ];
-
- for (const { threshold, suffix } of suffixes) {
- // Iterate until we find a suffix that can handle this
- // value.
- if (n >= threshold) {
- // Truncate to 1 decimal place.
- const truncated = Math.floor((n / threshold) * 10) / 10;
-
- // If the decimal part is 0 trim it.
- if (truncated % 1 === 0) {
- return truncated.toFixed(0) + suffix;
- } else {
- return truncated.toFixed(1) + suffix;
- }
- }
- }
-
- return n.toLocaleString();
- },
- };
- })();
+$("#apps .disconnect").click(async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
+
+ if (confirm("Are you sure you want to disconnect this app?")) {
+ const $button = $(ev.currentTarget);
+ const $form = $button.parent().next("form");
+ const url = $form.attr("action");
+ const data = $form.serialize();
+ const parent = $button.parents("li");
+ await fetch(url, { method: "POST", body: new URLSearchParams(data) });
+ parent.hide("slow");
}
+});
- /* Shake Page: Request invitation to shake */
- var RequestInvitation = function ($root) {
- this.$root = $root;
- this.init_dom();
- this.init_events();
- };
-
- $.extend(RequestInvitation.prototype, {
- init_dom: function () {
- this.$form = this.$root.find("form");
- },
+// Tools: Recommended group shakes
+if ($("#shake-categories").length > 0) {
+ $("#shake-categories .shake-category").each(function () {
+ RecommendedShakeCategory.attachEvents(this);
+ });
+}
+
+// Shake Page - change image.
+// TODO this could probably be a CSS :hover
+$("#shake-image-edit").hover(
+ function () {
+ $(this).addClass("shake-image-hover");
+ },
+ function () {
+ $(this).removeClass("shake-image-hover");
+ },
+);
+
+// Shake Page: choosing file to upload.
+$("#shake-image-edit input").change(function () {
+ $(this).closest("form").submit();
+});
- init_events: function () {
- this.$root.delegate(
- "form",
- "submit",
- $.proxy(this.submit_request, this),
- );
- },
-
- submit_request: function () {
- var url = this.$form.attr("action");
- var data = this.$form.serialize();
- $.post(url, data, $.proxy(this.process_response, this));
- return false;
- },
-
- process_response: function () {
- this.$root.html("Ok! Request sent.");
- },
- });
+// Shake Page: inline editing title & description:
+$(".shake-edit-title-form .cancel").click(function () {
+ $(this).parents(".shake-details").find(".shake-edit-title").show();
+ $(this).closest(".shake-edit-title-form").hide();
+ return false;
+});
- var $request_invitation = $("#request-invitation");
- if ($request_invitation.length > 0) {
- request_invitation = new RequestInvitation($request_invitation);
+// TODO this could probably be a CSS :hover
+$(".shake-edit-title").hover(
+ function () {
+ $(this).addClass("shake-edit-title-hover");
+ },
+ function () {
+ $(this).removeClass("shake-edit-title-hover");
+ },
+);
+
+$(".shake-edit-title").click(async (ev) => {
+ const $label = $(ev.currentTarget);
+ const $container = $label.closest(".shake-details");
+ const url = $container.find("form").attr("action");
+ const resp = await fetch(url);
+ const json = await resp.json();
+
+ if ("title_raw" in json) {
+ $label.hide();
+ $container.find(".shake-edit-title-input").val(json["title_raw"]);
+ $label.next(".shake-edit-title-form").show();
}
+});
- // Button to remove from shake.
- $(".remove-from-shake").click(function () {
- $form = $(this).find("form").submit();
- return false;
- });
+$(".shake-edit-title-form").submit(async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
- //make incoming clickable (I know.)
- $(".incoming-header").click(function () {
- document.location =
- document.location.protocol +
- "//" +
- document.location.host +
- "/incoming";
+ const $form = $(ev.currentTarget);
+ const data = $form.serialize();
+ const url = $form.attr("action");
+ const resp = await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
});
+ const json = await resp.json();
- // Invite Member widget for the Shake administrator.
- var InviteMember = (function () {
- var $main_module = $("#shake-invite-member");
- var $input_field = $main_module.find(".input-text");
- var $invite_button = $main_module.find(".invite-button");
- var $shake_results = $main_module.find(".shake-results");
- var $form = $main_module.find("form");
- var $title = $main_module.find("h3");
- var search_results = [];
- var last_search = "";
-
- $input_field.keyup(function (ev) {
- InviteMember.search_names(ev);
- });
-
- $form.submit(function (ev) {
- return InviteMember.submit_form();
- });
+ if ("title" in json && "title_raw" in json) {
+ const $container = $form.closest(".shake-details");
+ $container.find(".shake-edit-title").html(json["title"]).show();
+ $container.find(".shake-edit-title-input").val(json["title_raw"]);
+ $container.find(".shake-edit-title-form").hide();
+ }
+});
- $shake_results.click(function (ev) {
- InviteMember.select_user($(ev.target).text());
- });
+// Shake Page: Edit Description
+$(".shake-edit-description-form .cancel").click((ev) => {
+ const $form = $(ev.currentTarget);
+ $form.parents(".shake-details").find(".shake-edit-description").show();
+ $form.closest(".shake-edit-description-form").hide();
+ return false;
+});
- $invite_button.click(function () {
- InviteMember.send_invite();
- return false;
- });
+// TODO this could probably be a CSS :hover
+$(".shake-edit-description").hover(
+ function () {
+ $(this).addClass("shake-edit-description-hover");
+ },
+ function () {
+ $(this).removeClass("shake-edit-description-hover");
+ },
+);
+
+$(".shake-edit-description").click(async (ev) => {
+ const $label = $(ev.currentTarget);
+ // Form is sibling of label, so find the enclosing container ...
+ const $container = $label.closest(".shake-details");
+
+ // ... then navigate down to the form
+ const url = $container.find("form").attr("action");
+ const resp = await fetch(url);
+ const json = await resp.json();
+
+ if ("description_raw" in json) {
+ $label.hide();
+ $container
+ .find(".shake-edit-description-input")
+ .val(json["description_raw"]);
+ $label.next(".shake-edit-description-form").show();
+ }
+});
- return {
- search_names: function () {
- if ($input_field.val() == "") {
- this.clear_results();
- this.clear_input();
- return false;
- }
-
- // don't search again if field hasn't changed.
- if ($input_field.val() == last_search) {
- return false;
- }
-
- last_search = $input_field.val();
- var data = $form.serialize();
- var that = this;
- $.post(
- "/account/quick_name_search",
- data,
- function (response) {
- if ("users" in response) {
- that.update_results(response["users"]);
- }
- },
- "json",
- );
- },
-
- update_results: function (users) {
- search_results = users;
- if (search_results.length == 0) {
- this.clear_results();
- } else {
- this.render_results();
- }
- },
-
- render_results: function () {
- $shake_results.html("").show();
- for (var i = 0; i < search_results.length; i++) {
- $shake_results.append(
- '
' +
- search_results[i].name +
- "",
- );
- }
- },
-
- select_user: function (user_name) {
- this.clear_results();
- $input_field.val(user_name);
- $invite_button.removeAttr("disabled");
- },
-
- submit_form: function (ev) {
- if (
- search_results.length == 1 &&
- search_results[0].name == $input_field.val()
- ) {
- this.select_user(search_results[0].name);
- this.send_invite();
- this.clear_results();
- }
- return false;
- },
-
- clear_results: function () {
- last_search = "";
- $shake_results.hide().html("");
- },
-
- clear_input: function () {
- $input_field.val("");
- $invite_button.attr("disabled", "disabled");
- },
-
- send_invite: function () {
- if ($invite_button.disabled) {
- return false;
- } else {
- var url = $form.attr("action");
- var data = $form.serialize();
- $.post(
- url,
- data,
- function () {
- InviteMember.data_sent();
- return false;
- },
- "json",
- );
- return false;
- }
- },
-
- data_sent: function () {
- $title.html("Your invitation has been sent");
- this.clear_input();
- this.clear_results();
- },
- };
- })();
-
- /* Shake Page: Remove Members From Shake */
- var ShakeMemberList = function ($root) {
- this.$root = $root;
- this.init_events();
- };
+$(".shake-edit-description-form").submit(async (ev) => {
+ ev.preventDefault();
+ ev.stopPropagation();
- $.extend(ShakeMemberList.prototype, {
- init_events: function () {
- this.$root.delegate(
- ".remove-from-shake-button-link",
- "click",
- $.proxy(this.remove_from_shake, this),
- );
- },
-
- remove_from_shake: function (ev) {
- var $target = $(ev.target),
- $li = $target.parents("li"),
- $form = $target.next(),
- url = $form.attr("action");
- data = $form.serialize();
-
- if (
- confirm(
- "Are you sure you want to remove this user from a shake? If they have notifications on an email will be sent informing them of the change.",
- )
- ) {
- $.post(url, data, $.proxy(this.process_remove, $li));
- }
- return false;
- },
+ const $form = $(ev.currentTarget);
+ const data = $form.serialize();
+ const url = $form.attr("action");
- process_remove: function (response) {
- this.remove();
- },
+ const resp = await fetch(url, {
+ method: "POST",
+ body: new URLSearchParams(data),
});
-
- var $shake_member_list = $("#shake-members-list");
- if ($shake_member_list.length > 0) {
- var shake_member_list = new ShakeMemberList($shake_member_list);
+ const json = await resp.json();
+ if ("description" in json && "description_raw" in json) {
+ const $container = $form.closest(".shake-details");
+ $container
+ .find(".shake-edit-description")
+ .html(json["description"])
+ .show();
+ $container
+ .find(".shake-edit-description-input")
+ .val(json["description_raw"]);
+ $container.find(".shake-edit-description-form").hide();
}
+});
- // support for dismissable "Vote" banner;
- // cookie naturally expires the day after the election
- var alertVote = $("#alert-vote");
- var alertVoteCookieVal = "dismiss-alert-vote=1";
- var alertVoteExpires = new Date("2024-11-06T00:00:00");
- if (
- document.cookie.indexOf(alertVoteCookieVal) === -1 &&
- new Date() < alertVoteExpires
- ) {
- alertVote.css({ display: "block" });
- alertVote.find("button").click(function () {
- document.cookie = [
- alertVoteCookieVal,
- "expires=" + alertVoteExpires.toGMTString(),
- "path=/",
- ].join("; ");
- alertVote.css({ display: "none" });
- });
- }
+const $userCountsPanel = $("#user-counts");
+if ($userCountsPanel.length > 0) {
+ UserCounts.populate($userCountsPanel);
+}
+
+/* Shake Page: Request invitation to join shake */
+const $requestInvitationPanel = $("#request-invitation");
+if ($requestInvitationPanel.length > 0) {
+ RequestInvitation.attachEvents($requestInvitationPanel);
+}
+
+// Button to remove user from shake membership.
+$(".remove-from-shake").click(function () {
+ $form = $(this).find("form").submit();
+ return false;
+});
- // Support for sticky site header
- const $siteHeader = $('.site-header');
- if ($siteHeader.length > 0) {
- let lastScrollY = window.scrollY;
- const scrollHandler = () => {
- if (!$siteHeader.hasClass('docked')) {
- if (window.scrollY > 120) {
- $siteHeader.addClass('docked');
- }
- } else {
- if (window.scrollY <= 120) {
- $siteHeader.removeClass('docked visible hidden');
- }
+//make incoming clickable (I know.)
+$(".incoming-header").click(function () {
+ document.location = `${document.location.protocol}//${document.location.host}/incoming`;
+});
+
+/* Shake Page: Remove Members From Shake */
+const $shakeMembersList = $("#shake-members-list");
+if ($shakeMembersList.length > 0) {
+ ShakeMemberList.attachEvents($shakeMembersList);
+}
+
+// support for dismissable "Vote" banner;
+// cookie naturally expires the day after the election
+var alertVote = $("#alert-vote");
+var alertVoteCookieVal = "dismiss-alert-vote=1";
+var alertVoteExpires = new Date("2024-11-06T00:00:00");
+if (
+ document.cookie.indexOf(alertVoteCookieVal) === -1 &&
+ new Date() < alertVoteExpires
+) {
+ alertVote.css({ display: "block" });
+ alertVote.find("button").click(function () {
+ document.cookie = [
+ alertVoteCookieVal,
+ "expires=" + alertVoteExpires.toGMTString(),
+ "path=/",
+ ].join("; ");
+ alertVote.css({ display: "none" });
+ });
+}
+
+// Support for sticky site header
+const $siteHeader = $(".site-header");
+if ($siteHeader.length > 0) {
+ let lastScrollY = window.scrollY;
+ const scrollHandler = () => {
+ if (!$siteHeader.hasClass("docked")) {
+ if (window.scrollY > 120) {
+ $siteHeader.addClass("docked");
}
- if ($siteHeader.hasClass('docked')) {
- const isVisible = $siteHeader.hasClass('visible');
- if (!isVisible && window.scrollY < lastScrollY) {
+ } else {
+ if (window.scrollY <= 120) {
+ $siteHeader.removeClass("docked visible hidden");
+ }
+ }
+ if ($siteHeader.hasClass("docked")) {
+ const isVisible = $siteHeader.hasClass("visible");
+ if (!isVisible && window.scrollY < lastScrollY) {
// triggering delta can be 1px
- $siteHeader.addClass('visible');
- $siteHeader.removeClass('hidden');
- } else if (isVisible && window.scrollY > lastScrollY + 20) {
+ $siteHeader.addClass("visible");
+ $siteHeader.removeClass("hidden");
+ } else if (isVisible && window.scrollY > lastScrollY + 20) {
// triggering delta must be ~20px
- $siteHeader.removeClass('visible');
- $siteHeader.addClass('hidden');
- }
+ $siteHeader.removeClass("visible");
+ $siteHeader.addClass("hidden");
}
- lastScrollY = window.scrollY;
- };
- $(window).on('scroll', scrollHandler);
- }
-});
+ }
+ lastScrollY = window.scrollY;
+ };
+ $(window).on("scroll", scrollHandler);
+}
diff --git a/templates/base.html b/templates/base.html
index 541203d..7fc0b38 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -205,7 +205,7 @@
-
+
{% block included_scripts %}
{% end %}