utterance / utterances

:crystal_ball: A lightweight comments widget built on GitHub issues
https://utteranc.es
MIT License
8.72k stars 569 forks source link

Vue / Vuepress integration #231

Open psi-4ward opened 4 years ago

psi-4ward commented 4 years ago

Hi mates, I looked at client.js but I think to re-create a <script>-Tag in an SPA env is not the preferred way.

So I came up and drafted an Utterances Vue Component which actually (should) do the same as the client.js but in the Vue-way. More precisely, it generates and updates the <iframe>.

Pls have a look at https://github.com/AskSinPP/asksinpp.de/blob/master/.vuepress/theme/components/UtterancesComments.vue

If you find any frictions, please let me know. Perhaps we could transfer this Component into your Repo or create an official one in your org. Or we develop an entire Vuepress-Plugin for utterances ;)

PS: the project name is not very google-friendly :D

jdanyow commented 4 years ago

Hi @psi-4ward, I think I'd rather refactor client.js such that it could be installed as a framework agnostic npm package and bundled in any type of app:

yarn install utterances

then something like:

import { styles, createElement } from 'utterances';

// vanilla usage:
document.head.insertAdjacentHTML('afterbegin', `<style>${styles}</style>`);
const utterances = createElement(... options...);
document.body.appendChild(utterances);

Would this make it easier to integrate with Vue?

lights0123 commented 4 years ago

@jdanyow yeah, that would help a lot. Although Vue usage would probably prefer to use bundler CSS imports (i.e. import utterances/theme.css).

@psi-4ward in the meantime, I threw this together for use with Gridsome (but you could easily use anything else easily):

<template>
    <div class="comments" :style="{ height: `${height}px` }">
        <iframe ref="comments" scrolling="no" :src="url" />
    </div>
</template>

<script lang="ts">
import { Component, Prop, Ref, Vue, Watch } from 'vue-property-decorator';

interface ResizeMessage {
    type: 'resize';
    height: number;
}

@Component
export default class GithubComments extends Vue {
    @Prop({ type: String, required: true }) title!: string;
    @Ref() readonly comments!: HTMLIFrameElement;
    height = 0;
    url = '';
    loaded = false;

    get theme() {
        return this.$theme.dark ? 'dark-blue' : 'github-light';
    }

    created() {
        const parts: Record<string, string> = {
            'issue-term': 'title',
            url: this.$route.path,
            title: this.title,
            repo: 'lights0123/lights0123.github.io',
            theme: this.theme,
            // @ts-ignore
            origin: process.isClient ? window.origin : this.$static.metadata.siteUrl,
        };
        this.url = 'https://utteranc.es/utterances.html?' + Object.keys(parts).map(name => `${encodeURIComponent(name)}=${encodeURIComponent(parts[name])}`).join('&');
    }

    @Watch('loaded')
    @Watch('theme')
    sendTheme() {
        this.comments.contentWindow?.postMessage({
            type: 'set-theme',
            theme: this.theme,
        }, 'https://utteranc.es');
    }

    message(event: MessageEvent) {
        if (event.origin !== 'https://utteranc.es') return;
        if (!this.loaded) this.loaded = true;
        const data = event.data as Partial<ResizeMessage>;
        if (data && data.type === 'resize' && data.height) {
            this.height = data.height;
        }
    }

    mounted() {
        window.addEventListener('message', this.message);
    }

    beforeDestroy() {
        window.removeEventListener('message', this.message);
    }
}
</script>
<style lang="scss">
.comments {
    position: relative;
    box-sizing: border-box;
    width: 100%;
    margin-left: auto;
    margin-right: auto;

    iframe {
        position: absolute;
        left: 0;
        right: 0;
        width: 1px;
        min-width: 100%;
        max-width: 100%;
        height: 100%;
        border: 0;
    }
}
</style>

<static-query>
query {
    metadata {
        siteUrl
    }
}
</static-query>
Misaka-0x447f commented 2 years ago

@jdanyow yeah, that would help a lot. Although Vue usage would probably prefer to use bundler CSS imports (i.e. import utterances/theme.css).

@psi-4ward in the meantime, I threw this together for use with Gridsome (but you could easily use anything else easily):


<template>
  <div class="comments" :style="{ height: `${height}px` }">
...

This one won't work since it does not come with session support. Consider following version.

The file '../utils/deparam' comes from here: https://github.com/utterance/utterances/blob/master/src/deparam.ts

Also refer to the official one: https://github.com/utterance/utterances/blob/master/src/client.ts

<template>
  <iframe ref="comments" scrolling="no" :src="data.url" :height="data.height" />
</template>

<script lang="ts">
import { defineComponent, onMounted, reactive, watch } from '@vue/composition-api'
import { useBeforeMount } from '../utils/hooks'
import { param, deparam } from '../utils/deparam'

interface ResizeMessage {
  type: 'resize';
  height: number;
}

export default defineComponent({
  name: 'UtterancesVue',
  props: {
    title: {
      type: String,
      required: true,
    },
    redirectUrl: {
      type: String,
      required: true,
    },
    repo: {
      type: String,
      required: true
    },
    theme: {
      type: String,
      required: true
    },
    label: String,
    issueNumber: Number
  },
  setup (props, { root, refs }) {
    const data = reactive({
      height: 0,
      url: '',
      loaded: false
    })
    onMounted(() => {
      // get session
      const session = deparam(location.search.substr(1))
      if (session?.utterances) {
        localStorage.setItem('utterances-session', session.utterances)
        delete session.utterances
        let search = param(session)
        if (search.length) {
          search = '?' + search
        }
        history.replaceState(undefined, document.title, location.pathname + search + location.hash)
      }
      // setup
      const params = {
        'issue-term': 'title',
        // @ts-ignore
        url: props.redirectUrl,
        title: props.title,
        label: props.label,
        repo: props.repo,
        theme: props.theme,
        pathname: location.pathname,
        // @ts-ignore
        origin: process.isClient ? window.origin : root.$static.metadata.siteUrl,
        session: session?.utterances || localStorage.getItem('utterances-session') || '',
      }
      data.url = 'https://utteranc.es/utterances.html?' + Object.keys(params)
      // @ts-ignore
        .map(name => `${encodeURIComponent(name)}=${encodeURIComponent(params[name])}`).join('&')
    })
    // @ts-ignore
    watch(() => props.theme, () => refs.contentWindow?.postMessage({
      type: 'set-theme',
      theme: props.theme,
    }, 'https://utteranc.es'))
    const messageHandler = (event: MessageEvent) => {
      if (event.origin !== 'https://utteranc.es') return
      if (!data.loaded) data.loaded = true
      const res = event.data as Partial<ResizeMessage>
      if (res && res.type === 'resize' && res.height) {
        data.height = res.height
      }
    }
    useBeforeMount(() => {
      window.addEventListener('message', messageHandler)
      return () => window.removeEventListener('message', messageHandler)
    })
    return {
      data
    }
  }
})
</script>
<style scoped>
iframe {
  width: 100%;
  min-width: 100%;
  max-width: 100%;
  border: 0;
  outline: none;
}
</style>

<static-query>
query {
metadata {
siteUrl
}
}
</static-query>