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 += ` +
    + + + + ${result["user_name"]} + ${result["posted_at_friendly"]} +
    + `; + } + $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 += '
    '; - html += ''; - html += - ''; - html += - '' + - result["user_name"] + - ""; - html += - '' + - result["posted_at_friendly"] + - ""; - 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 %}