v2ex-reaciton

给v2ex增加emoji reaction功能

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

// ==UserScript==
// @name         v2ex-reaciton
// @namespace    npm/vite-plugin-monkey
// @version      0.1.1
// @author       yuyinws
// @description  给v2ex增加emoji reaction功能
// @license      MIT
// @icon         https://vitejs.dev/logo.svg
// @iconURL      https://www.v2ex.com/static/favicon.ico
// @match        *://*.v2ex.com/t/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.global.prod.js
// ==/UserScript==

(o=>{const e=document.createElement("style");e.dataset.source="vite-plugin-monkey",e.textContent=o,document.head.append(e)})(' :root{--emojir-text-primary: #24292f}@media (prefers-color-scheme: dark){:root{--emojir-text-primary: #f5f5f5}}.emoji-reaction{display:flex;flex-direction:column;align-items:center;gap:1rem;margin:1rem 0;flex-wrap:wrap}.emoji-title{font-size:14px;font-weight:600;cursor:pointer;color:var(--emojir-text-primary)}.emoji-face-icon:before,.emoji-face-icon::-webkit-details-marker{display:none}.emoji-face-icon::marker{content:""}.emoji-face-icon{height:100%;width:100%;display:flex;justify-content:center;align-items:center;cursor:pointer}.emoji-list{display:flex;gap:5px}.emoji-menu{position:relative;background:#f6f8fa;border:1px solid #d0d7de;border-radius:50%;width:24px;height:24px}.emoji-menu:hover{background:#eaeef2}.emoji-panel-list{display:flex;flex-wrap:wrap;gap:.3rem}.emoji-panel{padding:.5rem;position:absolute;z-index:10;box-shadow:0 0 10px #0000001a;border-radius:.5rem;background-color:#fff;display:flex;flex-wrap:wrap;width:9.5rem}.emoji-panel-login{font-size:12px;color:#2563eb!important;font-style:italic}.emoji-item{padding:.5rem;width:1rem;height:1rem;cursor:pointer;border-radius:3px;display:flex;justify-content:center;align-items:center}.emoji-item:hover{background:#f3f4f6;font-size:20px;transition:font-size .2s ease-in-out}.emoji-item-reacted{background:#ddf4ff}.emoji-counter{padding:0 4px;font-size:12px;border-radius:100px;background:red;height:24px;width:34px;line-height:24px;background:#fff;border:1px solid #d1d5db;cursor:pointer;color:#222}.emoji-counter:hover{background:#eaeef2}.emoji-counter-reacted{background:#ddf4ff;border:1px solid #0969da}.emoji-counter-reacted:hover{background:#b6e3ff}.emoji-item-disabled{cursor:not-allowed;opacity:.5} ');

(function (vue) {
  'use strict';

  function getSearchParam(key) {
    const params = new URLSearchParams(window.location.search);
    return params.get(key);
  }
  const emojiMap = {
    THUMBS_UP: "👍",
    THUMBS_DOWN: "👎",
    LAUGH: "😄",
    HOORAY: "🎉",
    CONFUSED: "😕",
    HEART: "❤️",
    ROCKET: "🚀",
    EYES: "👀"
  };
  const serverDomin = "https://v2ex-reaction.vercel.app";
  const token = vue.ref("");
  const authURL = vue.ref("");
  const isAuth = vue.ref(false);
  function useAuth() {
    async function genAuthURL() {
      const href = window.location.href;
      const response = await fetch(`${serverDomin}/authorize?app_return_url=${href}`);
      const data = await response.text();
      authURL.value = data;
    }
    function setToken() {
      const emoji_token = getSearchParam("emoji-reaction-token") || localStorage.getItem("emoji-reaction-token");
      if (emoji_token) {
        localStorage.setItem("emoji-reaction-token", emoji_token);
        token.value = emoji_token;
        isAuth.value = true;
      }
    }
    setToken();
    return {
      genAuthURL,
      authURL,
      token,
      isAuth
    };
  }
  const reactions = vue.ref([]);
  const subjectId = vue.ref("");
  const filteredReactions = vue.computed(() => {
    return reactions.value.filter((reaction) => reaction.totalCount > 0);
  });
  const totalCount = vue.computed(() => {
    return filteredReactions.value.reduce((total, reaction) => {
      return total + reaction.totalCount;
    }, 0);
  });
  function useReaction() {
    const discussionUrl = vue.ref("");
    const loading = vue.ref(false);
    async function getReaction() {
      try {
        loading.value = true;
        const pathname = window.location.pathname;
        if (pathname.includes("review"))
          return;
        const token2 = localStorage.getItem("emoji-reaction-token");
        const url = new URL(`${serverDomin}/getDiscussion`);
        if (token2)
          url.searchParams.append("token", token2);
        if (pathname)
          url.searchParams.append("pathname", pathname);
        const response = await fetch(url.toString());
        const { data, state } = await response.json();
        if (state === "fail")
          throw new Error(data);
        const reactionNodes = data.search.nodes;
        if (!reactionNodes.length) {
          const createUrl = new URL(`${serverDomin}/createDiscussion`);
          if (pathname)
            createUrl.searchParams.append("pathname", pathname);
          const res = await fetch(createUrl);
          const createData = await res.json();
          if (createData.state === "ok") {
            setTimeout(() => {
              getReaction();
            }, 2e3);
          }
        } else {
          const reactionGroups = reactionNodes[0].reactionGroups;
          const discussionId = reactionNodes[0].id;
          const _discussionUrl = reactionNodes[0].url;
          subjectId.value = discussionId;
          discussionUrl.value = _discussionUrl;
          reactions.value = reactionGroups.map((reaction) => {
            return {
              content: reaction.content,
              totalCount: reaction.users.totalCount,
              viewerHasReacted: reaction.viewerHasReacted,
              emoji: emojiMap[reaction.content]
            };
          });
        }
      } catch (error) {
        console.log(error);
      } finally {
        loading.value = false;
      }
    }
    const TOGGLE_REACTION_QUERY = (mode) => `
  mutation($content: ReactionContent!, $subjectId: ID!) {
    toggleReaction: ${mode}Reaction(input: {content: $content, subjectId: $subjectId}) {
      reaction {
        content
        id
      }
    }
  }`;
    async function clickReaction(isAuth2, content, token2, viewerHasReacted, cb) {
      try {
        if (!isAuth2)
          return;
        await fetch("https://api.github.com/graphql", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "Authorization": `Bearer ${token2}`
          },
          body: JSON.stringify({
            query: TOGGLE_REACTION_QUERY(viewerHasReacted ? "remove" : "add"),
            variables: {
              subjectId: subjectId.value,
              content
            }
          })
        });
        await getReaction();
      } catch (error) {
        console.log(error);
      } finally {
        cb();
      }
    }
    return {
      reactions,
      getReaction,
      filteredReactions,
      totalCount,
      clickReaction,
      discussionUrl,
      loading
    };
  }
  const _hoisted_1$1 = { class: "emoji-list" };
  const _hoisted_2$1 = { class: "emoji-face-icon" };
  const _hoisted_3$1 = ["fill"];
  const _hoisted_4$1 = /* @__PURE__ */ vue.createElementVNode("path", { d: "M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm3.82 1.636a.75.75 0 0 1 1.038.175l.007.009c.103.118.22.222.35.31.264.178.683.37 1.285.37.602 0 1.02-.192 1.285-.371.13-.088.247-.192.35-.31l.007-.008a.75.75 0 0 1 1.222.87l-.022-.015c.02.013.021.015.021.015v.001l-.001.002-.002.003-.005.007-.014.019a2.066 2.066 0 0 1-.184.213c-.16.166-.338.316-.53.445-.63.418-1.37.638-2.127.629-.946 0-1.652-.308-2.126-.63a3.331 3.331 0 0 1-.715-.657l-.014-.02-.005-.006-.002-.003v-.002h-.001l.613-.432-.614.43a.75.75 0 0 1 .183-1.044ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM5 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm5.25 2.25.592.416a97.71 97.71 0 0 0-.592-.416Z" }, null, -1);
  const _hoisted_5$1 = [
    _hoisted_4$1
  ];
  const _hoisted_6$1 = { class: "emoji-panel" };
  const _hoisted_7$1 = ["href"];
  const _hoisted_8$1 = /* @__PURE__ */ vue.createElementVNode("span", { style: { "font-size": "12px", "font-style": "italic", "color": "#94a3b8" } }, "以添加反应", -1);
  const _hoisted_9 = { class: "emoji-panel-list" };
  const _hoisted_10 = ["onClick"];
  const _sfc_main$1 = /* @__PURE__ */ vue.defineComponent({
    __name: "Menu",
    props: {
      reactions: {
        type: Array,
        required: true
      },
      color: {
        type: String,
        default: "#000"
      }
    },
    setup(__props) {
      const { clickReaction } = useReaction();
      const { token: token2, isAuth: isAuth2, authURL: authURL2 } = useAuth();
      const emojiPanelRef = vue.ref(null);
      const vClickOutside = {
        beforeMount(el, binding) {
          el.clickOutsideEvent = function(event) {
            if (!(el === event.target || el.contains(event.target)))
              binding.value(event);
          };
          document.addEventListener("mousedown", el.clickOutsideEvent);
        },
        beforeUnmount(el) {
          document.removeEventListener("mousedown", el.clickOutsideEvent);
        }
      };
      function handleClickOutside() {
        emojiPanelRef.value.open = false;
      }
      return (_ctx, _cache) => {
        return vue.openBlock(), vue.createElementBlock("div", _hoisted_1$1, [
          vue.withDirectives((vue.openBlock(), vue.createElementBlock("details", {
            ref_key: "emojiPanelRef",
            ref: emojiPanelRef,
            class: "emoji-menu"
          }, [
            vue.createElementVNode("summary", _hoisted_2$1, [
              (vue.openBlock(), vue.createElementBlock("svg", {
                "aria-hidden": "true",
                focusable: "false",
                role: "img",
                viewBox: "0 0 16 16",
                width: "16",
                height: "16",
                fill: __props.color,
                style: { "display": "inline-block", "user-select": "none", "vertical-align": "text-bottom", "overflow": "visible" }
              }, _hoisted_5$1, 8, _hoisted_3$1))
            ]),
            vue.createElementVNode("div", _hoisted_6$1, [
              !vue.unref(isAuth2) ? (vue.openBlock(), vue.createElementBlock(vue.Fragment, { key: 0 }, [
                vue.createElementVNode("a", {
                  href: vue.unref(authURL2),
                  class: "emoji-panel-login"
                }, "登录", 8, _hoisted_7$1),
                _hoisted_8$1
              ], 64)) : vue.createCommentVNode("", true),
              vue.createElementVNode("div", _hoisted_9, [
                (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(__props.reactions, (item, index) => {
                  return vue.openBlock(), vue.createElementBlock("div", {
                    key: index,
                    class: vue.normalizeClass([[
                      item.viewerHasReacted ? "emoji-item-reacted" : "",
                      vue.unref(isAuth2) ? "" : "emoji-item-disabled"
                    ], "emoji-item"]),
                    onClick: ($event) => vue.unref(clickReaction)(vue.unref(isAuth2), item.content, vue.unref(token2), item.viewerHasReacted, handleClickOutside)
                  }, vue.toDisplayString(item.emoji), 11, _hoisted_10);
                }), 128))
              ])
            ])
          ])), [
            [vClickOutside, handleClickOutside]
          ])
        ]);
      };
    }
  });
  const _hoisted_1 = { key: 0 };
  const _hoisted_2 = /* @__PURE__ */ vue.createElementVNode("img", {
    width: "50",
    style: { "margin-top": "1rem" },
    height: "50",
    src: "https://raw.githubusercontent.com/yuyinws/v2ex-reaction/main/source/loading.gif",
    alt: "loading",
    srcset: ""
  }, null, -1);
  const _hoisted_3 = [
    _hoisted_2
  ];
  const _hoisted_4 = { key: 1 };
  const _hoisted_5 = { class: "emoji-reaction" };
  const _hoisted_6 = ["href"];
  const _hoisted_7 = { class: "emoji-list" };
  const _hoisted_8 = ["onClick"];
  const _sfc_main = /* @__PURE__ */ vue.defineComponent({
    __name: "App",
    setup(__props) {
      const {
        reactions: reactions2,
        getReaction,
        filteredReactions: filteredReactions2,
        totalCount: totalCount2,
        clickReaction,
        discussionUrl,
        loading
      } = useReaction();
      const { genAuthURL, isAuth: isAuth2, token: token2 } = useAuth();
      function init() {
        if (!isAuth2.value)
          genAuthURL();
        getReaction();
      }
      vue.onMounted(() => {
        init();
      });
      return (_ctx, _cache) => {
        return vue.unref(loading) ? (vue.openBlock(), vue.createElementBlock("div", _hoisted_1, _hoisted_3)) : (vue.openBlock(), vue.createElementBlock("div", _hoisted_4, [
          vue.createElementVNode("div", _hoisted_5, [
            vue.createElementVNode("a", {
              class: "emoji-title",
              href: vue.unref(discussionUrl),
              target: "_blank"
            }, vue.toDisplayString(vue.unref(totalCount2)) + "个反应 ", 9, _hoisted_6),
            vue.createElementVNode("div", _hoisted_7, [
              vue.createVNode(_sfc_main$1, {
                reactions: vue.unref(reactions2),
                color: "#444"
              }, null, 8, ["reactions"]),
              (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList(vue.unref(filteredReactions2), (item, index) => {
                return vue.openBlock(), vue.createElementBlock("div", {
                  key: index,
                  class: vue.normalizeClass([[
                    item.viewerHasReacted ? "emoji-counter-reacted" : "",
                    vue.unref(isAuth2) ? "" : "emoji-item-disabled"
                  ], "emoji-counter"]),
                  onClick: ($event) => vue.unref(clickReaction)(vue.unref(isAuth2), item.content, vue.unref(token2), item.viewerHasReacted)
                }, vue.toDisplayString(item.emoji) + " " + vue.toDisplayString(item.totalCount), 11, _hoisted_8);
              }), 128))
            ])
          ])
        ]));
      };
    }
  });
  vue.createApp(_sfc_main).mount(
    (() => {
      const emojiApp = document.createElement("div");
      emojiApp.id = "emoji-reaction";
      const parentEL = document.querySelector("#Main > .box");
      const topicBtnEl = document.querySelector(".topic_buttons");
      parentEL.insertBefore(emojiApp, topicBtnEl);
      return emojiApp;
    })()
  );

})(Vue);